feat(frontend): add a warm dark theme and close the DESIGN.md drift
Independent Staging Quality Gate / validate (push) Has been cancelled
Independent Staging Quality Gate / publish (push) Has been cancelled

The palette is restated for dark rather than inverted: elevation reads
through lightness on dark and through darkness on light, so the floor is
the darkest surface here and the second-lightest there. The clay hue is
kept and lifted, because #85432f is 2.1:1 on a dark ground. All 37
themeable tokens are covered, in an OS-preference block and a data-theme
block that a contract test keeps identical, and ink, action, danger,
success and warning are asserted at 4.5:1 against the dark canvas.

Four raw colors that would have stayed light-theme values are tokenised
(the avatar hairline, the sheen sweep, a one-off shadow, a literal
warning hex). The QR keeps literal white in both themes, since scanners
need light modules to be light, and print keeps white paper.

The four root boundary pages cannot read a token, so they restate the
handful they need in both themes. forbidden.tsx also stops painting a
bespoke near-black page in four colours that appear nowhere in the
palette, which broke the rule that dark ink is never a page-scale
surface.

Also fixes what the audit found in DESIGN.md itself: two ink values that
had drifted from the code, a motion tier documented at 360ms that was
never implemented, a breakpoint section claiming three tiers where the
stylesheet has eleven, an undocumented report-paper palette, and an admin
section describing a bespoke panel that antd + Refine replaced. Five
zero-reference admin rules go with it.

The sidebar gets the accent, opaque drawer, heading rank and empty-state
guidance settled earlier, and fenced code blocks finally get a container.

BUG-439.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155nFCgCHtoA7jhSDGmZmMu
This commit is contained in:
Jesse_Chen
2026-08-29 12:01:50 +00:00
parent d02fa8bbf3
commit c2d705ef6e
13 changed files with 720 additions and 69 deletions
+2 -2
View File
@@ -230,7 +230,7 @@ export function AppSidebar({
<ChevronDown className="sidebar-section-chevron" size={16} strokeWidth={1.75} aria-hidden="true" />
</summary>
<SidebarGroupContent className="sidebar-nested">
{favoriteSessions.length === 0 ? <p className="sidebar-empty"></p> : (
{favoriteSessions.length === 0 ? <p className="sidebar-empty"></p> : (
<SidebarMenu className="session-list">
{favoriteSessions.map((session, index) => renderSession(session, index))}
</SidebarMenu>
@@ -247,7 +247,7 @@ export function AppSidebar({
<ChevronDown className="sidebar-section-chevron" size={16} strokeWidth={1.75} aria-hidden="true" />
</summary>
<SidebarGroupContent className="sidebar-nested">
{historySessions.length === 0 ? <p className="sidebar-empty"></p> : (
{historySessions.length === 0 ? <p className="sidebar-empty"></p> : (
<SidebarMenu className="session-list">
{historySessions.map((session, index) => renderSession(session, favoriteSessions.length + index))}
</SidebarMenu>
@@ -3,6 +3,7 @@
import ReactMarkdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import { MarkdownCodeBlock } from "@/components/markdown-code-block";
import { promoteDefinitionLists } from "@/lib/chat-definition-lists";
const markdownComponents: Components = {
@@ -16,6 +17,7 @@ const markdownComponents: Components = {
<table>{children}</table>
</div>
),
pre: ({ children }) => <MarkdownCodeBlock>{children}</MarkdownCodeBlock>,
ul: ({ children }) => <ul className="markdown-list">{children}</ul>,
ol: ({ children }) => <ol className="markdown-list">{children}</ol>,
};
@@ -0,0 +1,58 @@
"use client";
import { Check, Copy } from "lucide-react";
import { isValidElement, useEffect, useRef, useState, type ReactNode } from "react";
/** The fenced language, from the `language-xxx` class react-markdown puts on <code>. */
function fencedLanguage(node: ReactNode): string {
if (!isValidElement<{ className?: string }>(node)) return "";
return /language-([\w+-]+)/.exec(node.props.className ?? "")?.[1] ?? "";
}
/** The raw source inside the fence, for the copy button. */
function fencedSource(node: ReactNode): string {
if (typeof node === "string") return node;
if (Array.isArray(node)) return node.map(fencedSource).join("");
if (isValidElement<{ children?: ReactNode }>(node)) return fencedSource(node.props.children);
return "";
}
export function MarkdownCodeBlock({ children }: { readonly children?: ReactNode }) {
const [copied, setCopied] = useState(false);
const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const language = fencedLanguage(children);
const source = fencedSource(children);
useEffect(() => () => {
if (resetTimer.current) clearTimeout(resetTimer.current);
}, []);
async function copy() {
try {
await navigator.clipboard.writeText(source);
} catch {
return;
}
setCopied(true);
if (resetTimer.current) clearTimeout(resetTimer.current);
resetTimer.current = setTimeout(() => setCopied(false), 1600);
}
return (
<div className="markdown-code">
<div className="markdown-code-bar">
<span className="markdown-code-language">{language || "代码"}</span>
<button
aria-label={copied ? "已复制代码" : "复制代码"}
className="markdown-code-copy"
onClick={() => void copy()}
type="button"
>
{copied ? <Check aria-hidden="true" /> : <Copy aria-hidden="true" />}
<span>{copied ? "已复制" : "复制"}</span>
</button>
</div>
<pre>{children}</pre>
</div>
);
}