fix(ui): make inline styles and code blocks follow the theme (#37651)

* fix(ui): make inline styles and code blocks follow the theme

Two families of colour that a stylesheet never gets to see, so dark mode could
not reach them.

The log details drawer paints most of its chrome through React inline style
objects holding raw hex: #f0f0f0 borders, #fafafa panels, #262626 body text,
the antd-era role accents on message cards, and a green/red guardrail summary
pill. Inline styles win over any class, so the drawer stayed light on a dark
page. Every one of those literals becomes the var(--color-*) it was already
imitating, which costs nothing in light mode and now tracks the theme. The
guardrail pill keeps its layout inline and moves its three colours onto the
success and destructive tokens the rest of the dashboard uses.

The eleven code blocks pass a prism stylesheet as a prop, so the theme has to be
picked in JavaScript. There is no dark-mode toggle in the app yet, only the
`dark` class the design system keys off, so useIsDarkMode subscribes to that
class through useSyncExternalStore and useSyntaxTheme swaps in oneDark when it
is set. Each call site keeps the light stylesheet it already had, including the
two that were relying on the prism default and now name it, so light mode is
unchanged everywhere.

Six of those call sites were casting the stylesheet to `any` or re-declaring its
type to get past the prop signature; the hook returns the right type, so the
casts are gone.

* fix(ui): let the markdown code renderer keep its own syntax theme

The three ReactMarkdown code renderers spread the remaining code element
props after style, so the incoming style attribute widened the prop type
and next build's type check rejected the hook's return value. The old
`coy as any` cast hid the same conflict. Spreading first lets the
explicit props win, which is what every one of these call sites meant.

* test(ui): cover the dark-mode hooks that pick a syntax stylesheet

useIsDarkMode carries the only real logic in this change: an external
store over the root element's class list. Cover the three things that can
regress, the class already being present at mount, the class being
toggled later, and the observer being disconnected on unmount, then cover
useSyntaxTheme handing back the caller's own stylesheet in light mode and
oneDark in dark. The assertions are on which stylesheet object comes
back, by identity, not on any colour it holds.

* refactor(ui): drop the last stylesheet cast in the chat code renderer

This was the one markdown code renderer still spreading the code element
props over its style, so an incoming style attribute would have won over
the theme, and the cast on the spread was what kept that compiling.
Spreading first lets the theme win and the cast go.
This commit is contained in:
yuneng-jiang 2026-08-20 10:59:23 -07:00 committed by GitHub
parent c794dcb91d
commit 5cd6347c2c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 216 additions and 57 deletions

View file

@ -6,6 +6,9 @@
import { Plus, Wallet } from "lucide-react";
import React, { useCallback, useState } from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { prism } from "react-syntax-highlighter/dist/esm/styles/prism";
import { useSyntaxTheme } from "@/hooks/useSyntaxTheme";
import { LegacyPageHeader } from "@/components/shared/LegacyPageHeader";
import { ToolbarSeparator } from "@/components/shared/ToolbarSeparator";
import { Button } from "@/components/ui/button";
@ -25,6 +28,7 @@ interface BudgetSettingsPageProps {
}
const BudgetPanel: React.FC<BudgetSettingsPageProps> = ({ accessToken }) => {
const syntaxTheme = useSyntaxTheme(prism);
const [isCreateModelVisible, setIsCreateModelVisible] = useState(false);
const [isEditModalVisible, setIsEditModalVisible] = useState(false);
const [selectedBudget, setSelectedBudget] = useState<budgetItem | null>(null);
@ -150,13 +154,19 @@ const BudgetPanel: React.FC<BudgetSettingsPageProps> = ({ accessToken }) => {
</TabsTrigger>
</TabsList>
<TabsContent value="assign-budget" keepMounted>
<SyntaxHighlighter language="bash">{CREATE_END_USER_CURL_COMMAND}</SyntaxHighlighter>
<SyntaxHighlighter language="bash" style={syntaxTheme}>
{CREATE_END_USER_CURL_COMMAND}
</SyntaxHighlighter>
</TabsContent>
<TabsContent value="curl" keepMounted>
<SyntaxHighlighter language="bash">{CHAT_COMPLETIONS_CURL_COMMAND}</SyntaxHighlighter>
<SyntaxHighlighter language="bash" style={syntaxTheme}>
{CHAT_COMPLETIONS_CURL_COMMAND}
</SyntaxHighlighter>
</TabsContent>
<TabsContent value="openai-sdk" keepMounted>
<SyntaxHighlighter language="python">{OPENAI_SDK_PYTHON_CODE}</SyntaxHighlighter>
<SyntaxHighlighter language="python" style={syntaxTheme}>
{OPENAI_SDK_PYTHON_CODE}
</SyntaxHighlighter>
</TabsContent>
</Tabs>
</div>

View file

@ -15,6 +15,9 @@ vi.mock("react-syntax-highlighter", () => ({
vi.mock("react-syntax-highlighter/dist/esm/styles/prism", () => ({
coy: {},
oneDark: {},
oneLight: {},
prism: {},
}));
vi.mock("@/components/chat_ui/ReasoningContent", () => ({

View file

@ -3,6 +3,8 @@ import React from "react";
import ReactMarkdown from "react-markdown";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { coy } from "react-syntax-highlighter/dist/esm/styles/prism";
import { useSyntaxTheme } from "@/hooks/useSyntaxTheme";
import { CodeInterpreterResult } from "@/components/llm_calls/code_interpreter_handler";
import A2AMetrics from "./A2AMetrics";
import AudioRenderer from "./AudioRenderer";
@ -38,6 +40,7 @@ function ChatMessageBubble({
codeInterpreterResult,
accessToken,
}: ChatMessageBubbleProps) {
const syntaxTheme = useSyntaxTheme(coy);
const isUser = message.role === "user";
return (
@ -143,13 +146,13 @@ function ChatMessageBubble({
const match = /language-(\w+)/.exec(className || "");
return !inline && match ? (
<SyntaxHighlighter
style={coy as any}
{...props}
style={syntaxTheme}
language={match[1]}
PreTag="div"
className="rounded-md my-2"
wrapLines={true}
wrapLongLines={true}
{...props}
>
{String(children).replace(/\n$/, "")}
</SyntaxHighlighter>

View file

@ -21,6 +21,8 @@ import {
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { coy } from "react-syntax-highlighter/dist/esm/styles/prism";
import { useSyntaxTheme } from "@/hooks/useSyntaxTheme";
import { v4 as uuidv4 } from "uuid";
import useCan from "@/app/(dashboard)/hooks/useCan";
import GuardrailSelector from "@/components/guardrails/GuardrailSelector";
@ -123,6 +125,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
simplified = false,
fixedModel,
}) => {
const syntaxTheme = useSyntaxTheme(coy);
const canViewPolicies = useCan("viewPolicies");
const [mcpServers, setMCPServers] = useState<MCPServer[]>([]);
const [mcpToolsets, setMCPToolsets] = useState<MCPToolset[]>([]);
@ -2126,7 +2129,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
</div>
<SyntaxHighlighter
language="python"
style={coy as Record<string, React.CSSProperties>}
style={syntaxTheme}
wrapLines={true}
wrapLongLines={true}
className="rounded-md"

View file

@ -2,6 +2,8 @@ import React, { useEffect, useState } from "react";
import { Code, Download, FileImage, FileText, Loader2 } from "lucide-react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { coy } from "react-syntax-highlighter/dist/esm/styles/prism";
import { useSyntaxTheme } from "@/hooks/useSyntaxTheme";
import { getProxyBaseUrl, getGlobalLitellmHeaderName } from "@/components/networking";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
@ -33,6 +35,7 @@ function isImageFilename(filename: string | undefined): boolean {
}
const CodeInterpreterOutput: React.FC<CodeInterpreterOutputProps> = ({ code, annotations = [], accessToken }) => {
const syntaxTheme = useSyntaxTheme(coy);
const [imageUrls, setImageUrls] = useState<Record<string, string>>({});
const [loadingImages, setLoadingImages] = useState<Record<string, boolean>>({});
const [codeOpen, setCodeOpen] = useState(false);
@ -147,7 +150,7 @@ const CodeInterpreterOutput: React.FC<CodeInterpreterOutputProps> = ({ code, ann
<div className="border-t border-border p-2">
<SyntaxHighlighter
language="python"
style={coy}
style={syntaxTheme}
customStyle={{
margin: 0,
borderRadius: "6px",

View file

@ -3,6 +3,8 @@ import React from "react";
import ReactMarkdown from "react-markdown";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { coy } from "react-syntax-highlighter/dist/esm/styles/prism";
import { useSyntaxTheme } from "@/hooks/useSyntaxTheme";
import ChatImageRenderer from "../../chat_ui/ChatImageRenderer";
import ReasoningContent from "@/components/chat_ui/ReasoningContent";
import ResponseMetrics from "@/components/chat_ui/ResponseMetrics";
@ -15,6 +17,7 @@ interface MessageDisplayProps {
}
export function MessageDisplay({ messages, isLoading }: MessageDisplayProps) {
const syntaxTheme = useSyntaxTheme(coy);
if (messages.length === 0) {
return <div className="h-full" />;
}
@ -73,13 +76,13 @@ export function MessageDisplay({ messages, isLoading }: MessageDisplayProps) {
const match = /language-(\w+)/.exec(className || "");
return !inline && match ? (
<SyntaxHighlighter
style={coy as any}
{...props}
style={syntaxTheme}
language={match[1]}
PreTag="div"
className="rounded-md my-2"
wrapLines={true}
wrapLongLines={true}
{...props}
>
{String(children).replace(/\n$/, "")}
</SyntaxHighlighter>

View file

@ -2,6 +2,8 @@ import React, { useState } from "react";
import { CodeIcon, CopyIcon } from "lucide-react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { coy } from "react-syntax-highlighter/dist/esm/styles/prism";
import { useSyntaxTheme } from "@/hooks/useSyntaxTheme";
import { toast } from "@/lib/toast";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
@ -34,6 +36,7 @@ const PromptCodeSnippets: React.FC<PromptCodeSnippetsProps> = ({
version = "1",
proxySettings,
}) => {
const syntaxTheme = useSyntaxTheme(coy);
const [isModalVisible, setIsModalVisible] = useState(false);
const [selectedLanguage, setSelectedLanguage] = useState<"curl" | "python" | "javascript">("curl");
const [selectedTab, setSelectedTab] = useState("basic");
@ -296,7 +299,7 @@ main();`;
<SyntaxHighlighter
language={selectedLanguage === "curl" ? "bash" : selectedLanguage === "python" ? "python" : "javascript"}
style={coy as any}
style={syntaxTheme}
wrapLines={true}
wrapLongLines={true}
className="rounded-md mt-0"

View file

@ -3,6 +3,8 @@ import { Bot, User } from "lucide-react";
import ReactMarkdown from "react-markdown";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { coy } from "react-syntax-highlighter/dist/esm/styles/prism";
import { useSyntaxTheme } from "@/hooks/useSyntaxTheme";
import ResponseMetrics from "@/components/chat_ui/ResponseMetrics";
import { Message } from "./types";
@ -11,6 +13,7 @@ interface MessageBubbleProps {
}
const MessageBubble: React.FC<MessageBubbleProps> = ({ message }) => {
const syntaxTheme = useSyntaxTheme(coy);
return (
<div className={`mb-4 flex ${message.role === "user" ? "justify-end" : "justify-start"}`}>
<div
@ -63,13 +66,13 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({ message }) => {
const match = /language-(\w+)/.exec(className || "");
return !inline && match ? (
<SyntaxHighlighter
style={coy as any}
{...props}
style={syntaxTheme}
language={match[1]}
PreTag="div"
className="rounded-md my-2"
wrapLines={true}
wrapLongLines={true}
{...props}
>
{String(children).replace(/\n$/, "")}
</SyntaxHighlighter>

View file

@ -35,7 +35,7 @@ vi.mock("react-syntax-highlighter", () => ({
Prism: ({ children }: { children: string }) => <pre>{children}</pre>,
}));
vi.mock("react-syntax-highlighter/dist/esm/styles/prism", () => ({ coy: {} }));
vi.mock("react-syntax-highlighter/dist/esm/styles/prism", () => ({ coy: {}, oneDark: {}, oneLight: {}, prism: {} }));
vi.mock("@/contexts/ChatShellContext", () => ({
useChatShell: () => {

View file

@ -33,6 +33,9 @@ import { Copy, Inbox } from "lucide-react";
import { useRouter } from "next/navigation";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { prism } from "react-syntax-highlighter/dist/esm/styles/prism";
import { useSyntaxTheme } from "@/hooks/useSyntaxTheme";
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
import { checkTokenValidity } from "@/utils/jwtUtils";
import { getCookie } from "@/utils/cookieUtils";
@ -58,6 +61,7 @@ function HubEmptyState({ title, body }: { title: string; body: string }) {
}
const ModelHubTable: React.FC<ModelHubTableProps> = ({ accessToken, publicPage, premiumUser, userRole }) => {
const syntaxTheme = useSyntaxTheme(prism);
// Admin Viewer follows the read-parity rule: see the AI Hub catalog, but
// cannot toggle public visibility (write).
const canModify = isProxyAdminRole(userRole || "");
@ -736,7 +740,7 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({ accessToken, publicPage,
{/* Usage Example */}
<div>
<p className="text-lg font-semibold mb-4">Usage Example</p>
<SyntaxHighlighter language="python" className="text-sm">
<SyntaxHighlighter language="python" className="text-sm" style={syntaxTheme}>
{`import openai
client = openai.OpenAI(
@ -1057,7 +1061,7 @@ print(response.choices[0].message.content)`}
{/* Usage Example */}
<div>
<p className="text-lg font-semibold mb-4">Usage Example</p>
<SyntaxHighlighter language="python" className="text-sm">
<SyntaxHighlighter language="python" className="text-sm" style={syntaxTheme}>
{`from fastmcp import Client
import asyncio

View file

@ -3,12 +3,15 @@ import { CheckIcon, ClipboardIcon } from "lucide-react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneLight } from "react-syntax-highlighter/dist/esm/styles/prism";
import { useSyntaxTheme } from "@/hooks/useSyntaxTheme";
interface CodeBlockProps {
code: string;
language: string;
}
const CodeBlock = ({ code, language }: CodeBlockProps) => {
const syntaxTheme = useSyntaxTheme(oneLight);
const [copied, setCopied] = useState(false);
const copyToClipboard = () => {
navigator.clipboard.writeText(code);
@ -27,7 +30,7 @@ const CodeBlock = ({ code, language }: CodeBlockProps) => {
</button>
<SyntaxHighlighter
language={language}
style={oneLight}
style={syntaxTheme}
customStyle={{
margin: 0,
padding: "1.5rem",

View file

@ -9,6 +9,8 @@ import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { coy } from "react-syntax-highlighter/dist/esm/styles/prism";
import { useSyntaxTheme } from "@/hooks/useSyntaxTheme";
import ReasoningContent from "@/components/chat_ui/ReasoningContent";
import MCPEventsDisplay from "@/components/chat_ui/MCPEventsDisplay";
import ResponseMetrics from "@/components/chat_ui/ResponseMetrics";
@ -49,15 +51,10 @@ function MarkdownCodeRenderer({
children,
...props
}: React.ComponentPropsWithoutRef<"code"> & { node?: unknown }) {
const syntaxTheme = useSyntaxTheme(coy);
const match = /language-(\w+)/.exec(className || "");
return match ? (
<SyntaxHighlighter
style={coy as Record<string, React.CSSProperties>}
language={match[1]}
PreTag="div"
className="rounded-md my-2"
{...(props as Record<string, unknown>)}
>
<SyntaxHighlighter {...props} style={syntaxTheme} language={match[1]} PreTag="div" className="rounded-md my-2">
{String(children).replace(/\n$/, "")}
</SyntaxHighlighter>
) : (

View file

@ -2,6 +2,8 @@ import React, { useState } from "react";
import ReactMarkdown from "react-markdown";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { coy } from "react-syntax-highlighter/dist/esm/styles/prism";
import { useSyntaxTheme } from "@/hooks/useSyntaxTheme";
import { ChevronDown, ChevronRight, Lightbulb } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
@ -11,6 +13,7 @@ interface ReasoningContentProps {
}
const ReasoningContent: React.FC<ReasoningContentProps> = ({ reasoningContent }) => {
const syntaxTheme = useSyntaxTheme(coy);
const [isExpanded, setIsExpanded] = useState(true);
if (!reasoningContent) return null;
@ -59,7 +62,7 @@ const ReasoningContent: React.FC<ReasoningContentProps> = ({ reasoningContent })
wrapLines={true}
wrapLongLines={true}
{...props}
style={coy as { [key: string]: React.CSSProperties }}
style={syntaxTheme}
>
{String(children).replace(/\n$/, "")}
</SyntaxHighlighter>

View file

@ -189,13 +189,13 @@ function NavigationSection({
onClose: () => void;
}) {
const keyboardShortcutStyle = {
border: "1px solid #d9d9d9",
border: "1px solid var(--color-border)",
borderRadius: 4,
padding: "0 4px",
fontSize: 12,
fontFamily: "monospace",
marginLeft: 4,
background: "#fafafa",
background: "var(--color-muted)",
};
const splitStyle = { width: 1, height: 20, background: COLOR_BORDER };

View file

@ -39,7 +39,7 @@ export function InputCard({ messages, promptTokens, inputCost }: InputCardProps)
return (
<div
style={{
border: "1px solid #f0f0f0",
border: "1px solid var(--color-border)",
borderRadius: 6,
marginBottom: 8,
overflow: "hidden",

View file

@ -182,7 +182,9 @@ export function LogDetailContent({ logEntry, isLoadingDetails = false, accessTok
{isLoadingDetails ? (
<div className="bg-card rounded-lg shadow-sm w-full max-w-full overflow-hidden mb-6 p-8 text-center">
<UiLoadingSpinner className="inline-block size-5" />
<div style={{ marginTop: 8, color: "#999" }}>Loading request &amp; response data...</div>
<div style={{ marginTop: 8, color: "var(--color-muted-foreground)" }}>
Loading request &amp; response data...
</div>
</div>
) : (
<RequestResponseSection
@ -600,7 +602,14 @@ function RequestResponseSection({
{hasResponse || hasError ? (
<JsonViewer data={getFormattedResponse()} mode="formatted" />
) : (
<div style={{ textAlign: "center", padding: 20, color: "#999", fontStyle: "italic" }}>
<div
style={{
textAlign: "center",
padding: 20,
color: "var(--color-muted-foreground)",
fontStyle: "italic",
}}
>
Response data not available
</div>
)}
@ -631,6 +640,11 @@ export function GuardrailJumpLink({ guardrailEntries }: { guardrailEntries: any[
<div style={{ textAlign: "left", marginBottom: 12 }}>
<div
onClick={handleClick}
className={
allPassed
? "border border-success/20 bg-success/10 text-success"
: "border border-destructive/20 bg-destructive/10 text-destructive"
}
style={{
display: "inline-flex",
alignItems: "center",
@ -640,9 +654,6 @@ export function GuardrailJumpLink({ guardrailEntries }: { guardrailEntries: any[
cursor: "pointer",
fontSize: 13,
fontWeight: 500,
backgroundColor: allPassed ? "#f0fdf4" : "#fef2f2",
color: allPassed ? "#15803d" : "#b91c1c",
border: `1px solid ${allPassed ? "#bbf7d0" : "#fecaca"}`,
}}
>
{allPassed ? "\u2713" : "\u2717"} {guardrailEntries.length} guardrail{guardrailEntries.length !== 1 ? "s" : ""}{" "}

View file

@ -106,10 +106,10 @@ export function RealtimePrettyView({ response, metrics }: RealtimePrettyViewProp
{!sessionEvent && responseEvents.length === 0 && (
<div
style={{
border: "1px solid #f0f0f0",
border: "1px solid var(--color-border)",
borderRadius: 6,
padding: "16px",
color: "#8c8c8c",
color: "var(--color-muted-foreground)",
fontStyle: "italic",
fontSize: 13,
}}
@ -127,7 +127,7 @@ function SessionCard({ session, turnCount }: { session: RealtimeSession; turnCou
return (
<div
style={{
border: "1px solid #f0f0f0",
border: "1px solid var(--color-border)",
borderRadius: 6,
marginBottom: 8,
overflow: "hidden",
@ -140,16 +140,16 @@ function SessionCard({ session, turnCount }: { session: RealtimeSession; turnCou
alignItems: "center",
justifyContent: "space-between",
padding: "10px 16px",
borderBottom: isCollapsed ? "none" : "1px solid #f0f0f0",
background: "#fafafa",
borderBottom: isCollapsed ? "none" : "1px solid var(--color-border)",
background: "var(--color-muted)",
cursor: "pointer",
transition: "background 0.15s ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "#f5f5f5";
e.currentTarget.style.background = "var(--color-accent)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "#fafafa";
e.currentTarget.style.background = "var(--color-muted)";
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
@ -236,11 +236,11 @@ function SessionCard({ session, turnCount }: { session: RealtimeSession; turnCou
style={{
fontSize: 12,
lineHeight: 1.6,
color: "#595959",
background: "#fafafa",
color: "var(--color-muted-foreground)",
background: "var(--color-muted)",
padding: "8px 12px",
borderRadius: 4,
border: "1px solid #f0f0f0",
border: "1px solid var(--color-border)",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
maxHeight: 120,
@ -282,7 +282,7 @@ function ConversationCard({
return (
<div
style={{
border: "1px solid #f0f0f0",
border: "1px solid var(--color-border)",
borderRadius: 6,
overflow: "hidden",
}}
@ -324,7 +324,7 @@ function ResponseTurn({ response, index }: { response: RealtimeResponse; index:
style={{
marginBottom: 12,
paddingBottom: 12,
borderBottom: "1px solid #f5f5f5",
borderBottom: "1px solid var(--color-border)",
}}
>
{/* Turn header */}
@ -425,7 +425,7 @@ function OutputMessage({ output }: { output: RealtimeOutputItem }) {
style={{
fontSize: 13,
lineHeight: 1.7,
color: "#262626",
color: "var(--color-foreground)",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
@ -484,7 +484,7 @@ function ConfigRow({ label, value }: { label: string; value: any }) {
<span className="text-muted-foreground" style={{ fontSize: 11 }}>
{label}
</span>
<div style={{ fontSize: 13, color: "#262626" }}>{String(value)}</div>
<div style={{ fontSize: 13, color: "var(--color-foreground)" }}>{String(value)}</div>
</div>
);
}

View file

@ -27,9 +27,9 @@ export const FONT_SIZE_MEDIUM = 13;
export const FONT_SIZE_HEADER = 16;
// Colors
export const COLOR_BORDER = "#f0f0f0";
export const COLOR_BACKGROUND = "#fff";
export const COLOR_BG_LIGHT = "#fafafa";
export const COLOR_BORDER = "var(--color-border)";
export const COLOR_BACKGROUND = "var(--color-background)";
export const COLOR_BG_LIGHT = "var(--color-muted)";
// Spacing
export const SPACING_SMALL = 4;

View file

@ -19,27 +19,27 @@ import {
export const ROLE_STYLES: Record<string, RoleStyle> = {
system: {
background: "transparent",
borderColor: "#8c8c8c",
borderColor: "var(--color-muted-foreground)",
label: "SYSTEM",
labelColor: "#8c8c8c",
labelColor: "var(--color-muted-foreground)",
},
user: {
background: "transparent",
borderColor: "#1677ff",
borderColor: "var(--color-info)",
label: "USER",
labelColor: "#1677ff",
labelColor: "var(--color-info)",
},
assistant: {
background: "transparent",
borderColor: "#52c41a",
borderColor: "var(--color-success)",
label: "ASSISTANT",
labelColor: "#52c41a",
labelColor: "var(--color-success)",
},
tool: {
background: "transparent",
borderColor: "#fa8c16",
borderColor: "var(--color-warning)",
label: "TOOL RESULT",
labelColor: "#fa8c16",
labelColor: "var(--color-warning)",
},
};

View file

@ -0,0 +1,42 @@
import { renderHook, waitFor } from "@testing-library/react";
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import { useIsDarkMode } from "./useIsDarkMode";
beforeEach(() => {
document.documentElement.classList.remove("dark");
});
afterAll(() => {
document.documentElement.classList.remove("dark");
});
describe("useIsDarkMode", () => {
it("reports the dark class already on the root element at mount", () => {
document.documentElement.classList.add("dark");
const { result } = renderHook(() => useIsDarkMode());
expect(result.current).toBe(true);
});
it("follows the root element's dark class as it is toggled", async () => {
const { result } = renderHook(() => useIsDarkMode());
expect(result.current).toBe(false);
document.documentElement.classList.add("dark");
await waitFor(() => expect(result.current).toBe(true));
document.documentElement.classList.remove("dark");
await waitFor(() => expect(result.current).toBe(false));
});
it("stops observing the root element once unmounted", () => {
const disconnect = vi.spyOn(MutationObserver.prototype, "disconnect");
const { unmount } = renderHook(() => useIsDarkMode());
unmount();
expect(disconnect).toHaveBeenCalled();
disconnect.mockRestore();
});
});

View file

@ -0,0 +1,13 @@
import { useSyncExternalStore } from "react";
const subscribe = (onStoreChange: () => void): (() => void) => {
const observer = new MutationObserver(onStoreChange);
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
return () => observer.disconnect();
};
const getSnapshot = (): boolean => document.documentElement.classList.contains("dark");
const getServerSnapshot = (): boolean => false;
export const useIsDarkMode = (): boolean => useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);

View file

@ -0,0 +1,47 @@
import { act, renderHook } from "@testing-library/react";
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
import { afterAll, beforeEach, describe, expect, it } from "vitest";
import { useSyntaxTheme, type SyntaxTheme } from "./useSyntaxTheme";
const callerLightTheme: SyntaxTheme = { 'code[class*="language-"]': { color: "rebeccapurple" } };
const setRootDark = async (enabled: boolean) => {
await act(async () => {
document.documentElement.classList.toggle("dark", enabled);
await Promise.resolve();
});
};
beforeEach(() => {
document.documentElement.classList.remove("dark");
});
afterAll(() => {
document.documentElement.classList.remove("dark");
});
describe("useSyntaxTheme", () => {
it("keeps the caller's own stylesheet in light mode", () => {
const { result } = renderHook(() => useSyntaxTheme(callerLightTheme));
expect(result.current).toBe(callerLightTheme);
});
it("swaps to oneDark when the root element turns dark", async () => {
const { result } = renderHook(() => useSyntaxTheme(callerLightTheme));
await setRootDark(true);
expect(result.current).toBe(oneDark);
});
it("restores the caller's stylesheet when dark mode is turned back off", async () => {
document.documentElement.classList.add("dark");
const { result } = renderHook(() => useSyntaxTheme(callerLightTheme));
expect(result.current).toBe(oneDark);
await setRootDark(false);
expect(result.current).toBe(callerLightTheme);
});
});

View file

@ -0,0 +1,8 @@
import type { CSSProperties } from "react";
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
import { useIsDarkMode } from "./useIsDarkMode";
export type SyntaxTheme = Record<string, CSSProperties>;
export const useSyntaxTheme = (light: SyntaxTheme): SyntaxTheme => (useIsDarkMode() ? oneDark : light);