feat(frontend): add a warm dark theme and close the DESIGN.md drift
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:
co-authored by
Claude Opus 5
parent
d02fa8bbf3
commit
c2d705ef6e
@@ -0,0 +1,82 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||||
|
||||
/** Declarations of the block that starts at `opener`, up to its closing brace. */
|
||||
function blockTokens(opener: string): Map<string, string> {
|
||||
const start = globalStyles.indexOf(opener);
|
||||
assert.notEqual(start, -1, `missing block: ${opener}`);
|
||||
const end = globalStyles.indexOf("\n}", start);
|
||||
assert.notEqual(end, -1, `unterminated block: ${opener}`);
|
||||
const body = globalStyles.slice(start, end);
|
||||
return new Map([...body.matchAll(/--([\w-]+)\s*:\s*([^;]+);/g)]
|
||||
.map((hit) => [hit[1], hit[2].trim().replace(/\s+/g, " ")]));
|
||||
}
|
||||
|
||||
const light = blockTokens(":root {\n color-scheme: light;");
|
||||
const preferred = blockTokens(':root:not([data-theme="light"]) {');
|
||||
const pinned = blockTokens(':root[data-theme="dark"] {');
|
||||
|
||||
// A token whose value is the same in both themes on purpose. Everything else
|
||||
// must be redefined, or dark mode ships a light-theme value on a dark ground.
|
||||
const themeNeutral = new Set([
|
||||
"font-display", "font-body", "font-mono", "ease-out", "composer-reserve",
|
||||
]);
|
||||
|
||||
test("the OS-preference and pinned dark blocks never drift apart", () => {
|
||||
// Plain CSS cannot share a declaration list, so the dark palette is written
|
||||
// twice. This is the guard that keeps the copies honest.
|
||||
assert.deepEqual([...pinned.keys()].sort(), [...preferred.keys()].sort());
|
||||
for (const [token, value] of pinned) {
|
||||
assert.equal(preferred.get(token), value, `--${token} differs between the two dark blocks`);
|
||||
}
|
||||
});
|
||||
|
||||
test("every themeable token has a dark value", () => {
|
||||
const themeable = [...light.keys()].filter((token) => (
|
||||
!themeNeutral.has(token)
|
||||
&& (token.startsWith("color-") || token.startsWith("shadow-")
|
||||
|| token.startsWith("report-") || token === "ring-hairline" || token === "sheen")
|
||||
));
|
||||
const missing = themeable.filter((token) => !pinned.has(token)).sort();
|
||||
assert.deepEqual(missing, [], `no dark value for: ${missing.join(", ")}`);
|
||||
assert.ok(themeable.length >= 30, `only ${themeable.length} themeable tokens found`);
|
||||
});
|
||||
|
||||
test("both themes declare color-scheme so form controls and scrollbars follow", () => {
|
||||
assert.equal(light.get("color-scheme") ?? "light", "light");
|
||||
assert.match(globalStyles, /:root:not\(\[data-theme="light"\]\) \{\n\s*color-scheme: dark;/);
|
||||
assert.match(globalStyles, /:root\[data-theme="dark"\] \{\n\s*color-scheme: dark;/);
|
||||
});
|
||||
|
||||
test("dark ink reads lighter than dark canvas, and the action stays legible", () => {
|
||||
const luminance = (hex: string) => {
|
||||
const channel = (value: number) => {
|
||||
const c = value / 255;
|
||||
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
|
||||
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
|
||||
};
|
||||
const contrast = (a: string, b: string) => {
|
||||
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
|
||||
return (hi + 0.05) / (lo + 0.05);
|
||||
};
|
||||
const canvas = pinned.get("color-canvas")!;
|
||||
for (const token of ["color-ink", "color-action", "color-danger", "color-success", "color-warning"]) {
|
||||
const value = pinned.get(token)!;
|
||||
assert.match(value, /^#[0-9a-f]{6}$/i, `--${token} must be a hex so contrast is checkable`);
|
||||
assert.ok(
|
||||
contrast(value, canvas) >= 4.5,
|
||||
`--${token} (${value}) on --color-canvas (${canvas}) is ${contrast(value, canvas).toFixed(2)}:1, below AA`,
|
||||
);
|
||||
}
|
||||
// Elevation reads through lightness on dark: floor is darkest, raised steps up.
|
||||
const steps = ["color-canvas-soft", "color-canvas", "color-canvas-muted", "color-canvas-strong"]
|
||||
.map((token) => luminance(pinned.get(token)!));
|
||||
for (let i = 1; i < steps.length; i += 1) {
|
||||
assert.ok(steps[i] > steps[i - 1], "dark surfaces must get lighter as they rise");
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { cssDeclarations } from "./css-contract-test-support.ts";
|
||||
|
||||
const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||||
const markdownView = readFileSync(new URL("../src/components/chat-markdown-view.tsx", import.meta.url), "utf8");
|
||||
const codeBlock = readFileSync(new URL("../src/components/markdown-code-block.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("a fenced code block scrolls inside itself instead of spilling out of the message", () => {
|
||||
// There was no rule for fenced blocks at all. A bare <pre> keeps
|
||||
// `white-space: pre` with no overflow, so one long line pushed past the
|
||||
// message column; the fix has to keep both properties together.
|
||||
const body = cssDeclarations(".markdown-code pre > code", globalStyles);
|
||||
assert.match(body, /overflow-x:\s*auto/);
|
||||
assert.match(body, /white-space:\s*pre\b/);
|
||||
assert.match(cssDeclarations(".markdown-code", globalStyles), /overflow:\s*hidden/);
|
||||
});
|
||||
|
||||
test("the block resets the inline-code chip styling", () => {
|
||||
// `.message-markdown code` paints a small chip for inline code. Inside a
|
||||
// fence that chip has to go, or every block gets a nested background.
|
||||
assert.match(cssDeclarations(".markdown-code pre > code", globalStyles), /background:\s*none/);
|
||||
assert.match(cssDeclarations(".markdown-code pre", globalStyles), /background:\s*none/);
|
||||
});
|
||||
|
||||
test("react-markdown routes fences through the block, not a bare pre", () => {
|
||||
assert.match(markdownView, /pre: \(\{ children \}\) => <MarkdownCodeBlock>/);
|
||||
});
|
||||
|
||||
test("the copy control is labelled and the language is announced as text", () => {
|
||||
assert.match(codeBlock, /aria-label=\{copied \? "已复制代码" : "复制代码"\}/);
|
||||
assert.match(codeBlock, /language \|\| "代码"/);
|
||||
// The copy target is the raw fence source, not rendered markup.
|
||||
assert.match(codeBlock, /navigator\.clipboard\.writeText\(source\)/);
|
||||
// A rejected clipboard permission must not flip the button to "copied".
|
||||
assert.match(codeBlock, /catch \{\s*return;\s*\}/);
|
||||
});
|
||||
|
||||
test("the copy timer is cleared when the block unmounts", () => {
|
||||
// Streaming replaces these blocks constantly; a dangling timer would set
|
||||
// state on an unmounted node.
|
||||
assert.match(codeBlock, /useEffect\(\(\) => \(\) => \{[\s\S]*clearTimeout\(resetTimer\.current\)/);
|
||||
});
|
||||
@@ -36,6 +36,52 @@ test("sidebar nav items read left-aligned everywhere; centering is the desktop r
|
||||
);
|
||||
});
|
||||
|
||||
test("新建对话 carries the sidebar's only accent; 我的报告 stays neutral", () => {
|
||||
// The sidebar had no accent pixel at all in the empty state: its only two
|
||||
// action-colored surfaces are the 2px active-session bar and the terracotta
|
||||
// profile initial, and neither renders with no sessions and an uploaded
|
||||
// avatar. DESIGN.md keeps the action color scarce, so exactly one row gets
|
||||
// it — as tinted text, not a filled block, so the surface stays light.
|
||||
assert.match(topLevelRule(".new-chat"), /color:\s*var\(--color-action\)/);
|
||||
assert.doesNotMatch(topLevelRule(".new-chat"), /background:\s*var\(--color-action\)/);
|
||||
assert.match(topLevelRule(".report-nav-button"), /color:\s*var\(--sidebar-foreground\)/);
|
||||
assert.doesNotMatch(topLevelRule(".report-nav-button"), /--color-action/);
|
||||
// Hover deepens the same hue rather than reverting to ink.
|
||||
for (const body of [...globalStyles.matchAll(/\.new-chat(?::not\(:disabled\))?:hover\s*\{([^}]*)\}/g)]) {
|
||||
assert.match(body[1], /color:\s*var\(--color-action-hover\)/);
|
||||
}
|
||||
});
|
||||
|
||||
test("the mobile drawer is opaque; the glass surface is a desktop treatment", () => {
|
||||
// rgba(235,233,227,.86) assumes a light backdrop. On mobile the drawer sits
|
||||
// above the scrim, which showed through and pulled #EBE9E3 down to #E3E1DC.
|
||||
assert.match(
|
||||
globalStyles,
|
||||
/@media\s*\(max-width:\s*767px\)[\s\S]*\[data-sidebar="sidebar"\]\s*\{[^}]*background:\s*var\(--sidebar-solid\)[^}]*backdrop-filter:\s*none/,
|
||||
);
|
||||
// Desktop keeps the glass.
|
||||
assert.match(topLevelRule(".sidebar"), /background:\s*var\(--sidebar-background\)/);
|
||||
assert.match(topLevelRule(".sidebar"), /backdrop-filter:\s*saturate/);
|
||||
});
|
||||
|
||||
test("a section heading outranks its own body copy", () => {
|
||||
// Headings and their empty-state copy both sat on ink-tertiary, so the nav
|
||||
// read as one flat grey with no hierarchy to scan.
|
||||
const heading = cssDeclarations(".sidebar-label, .sidebar-section-summary", globalStyles);
|
||||
assert.match(heading, /color:\s*var\(--color-ink-secondary\)/);
|
||||
assert.doesNotMatch(topLevelRule(".sidebar-empty"), /--color-ink-secondary/);
|
||||
assert.match(topLevelRule(".sidebar-empty"), /color:\s*var\(--color-ink-tertiary\)/);
|
||||
});
|
||||
|
||||
test("empty sidebar sections name the next step instead of dead-ending", () => {
|
||||
const appSidebar = readProjectFile("src/components/app-sidebar.tsx");
|
||||
// Neutral on purpose: 新建对话 already carries the accent a few rows above,
|
||||
// and a second tinted call to action would compete with it.
|
||||
assert.match(appSidebar, /还没有收藏,可在对话的「更多操作」里收藏/);
|
||||
assert.match(appSidebar, /暂无对话,点上方「新建对话」开始/);
|
||||
assert.doesNotMatch(topLevelRule(".sidebar-empty"), /--color-action/);
|
||||
});
|
||||
|
||||
test("provides the generic composable sidebar primitive", () => {
|
||||
assert.equal(existsSync(projectFile("src/components/ui/sidebar.tsx")), true);
|
||||
});
|
||||
@@ -340,8 +386,12 @@ test("nests sidebar lists under one heading scale without an archive toggle", ()
|
||||
assert.match(cssBlock(".session-title"), /font-size:\s*var\(--type-caption\)/);
|
||||
assert.match(cssBlock(".session-title"), /font-weight:\s*500/);
|
||||
assert.match(cssBlock(".session-row"), /color:\s*var\(--sidebar-foreground\)/);
|
||||
assert.match(cssBlock(".sidebar-label"), /color:\s*var\(--color-ink-tertiary\)/);
|
||||
assert.match(cssBlock(".sidebar-section-summary"), /color:\s*var\(--color-ink-tertiary\)/);
|
||||
// Was ink-tertiary, identical to .sidebar-empty below it, which left the
|
||||
// headings and their own body copy indistinguishable. The shared scale is
|
||||
// what this test guards; the rank between the two levels is asserted in
|
||||
// "a section heading outranks its own body copy".
|
||||
assert.match(cssBlock(".sidebar-label"), /color:\s*var\(--color-ink-secondary\)/);
|
||||
assert.match(cssBlock(".sidebar-section-summary"), /color:\s*var\(--color-ink-secondary\)/);
|
||||
assert.match(cssBlock(".sidebar-label"), /font-size:\s*var\(--type-caption\)/);
|
||||
assert.match(cssBlock(".sidebar-section-summary"), /font-size:\s*var\(--type-caption\)/);
|
||||
assert.match(cssBlock(".new-chat"), /font-size:\s*var\(--type-body-sm\)/);
|
||||
|
||||
Reference in New Issue
Block a user