From a96df7547378c389f57b7e9c141a3eee7684cc74 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Sun, 30 Aug 2026 11:29:58 +0000 Subject: [PATCH] fix(frontend): stop the personal report rendering white on white in dark mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .personal-report-reader declared --report-paper, --report-rule and --report-accent on itself. A custom property resolves from the nearest element that declares it, so an element-local declaration beats :root regardless of media queries or source order — the dark override added with the theme was dead the moment it was written. The inks did follow the theme, because they live on :root. So dark mode put --color-ink (#f2f0ea) on paper that stayed #f8f5ee: 1.05:1 for headings and 1.22:1 for body. The report was effectively invisible. Printing had the same defect from the other side: the print block forces white backgrounds but never reset the inks, so printing from a dark screen produced a blank page. It now pins the palette back to light — paper is always paper. Every report ink now clears AA on report paper in both themes, and the new contract test forbids re-declaring the report palette anywhere but :root, which is the trap that caused this. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0155nFCgCHtoA7jhSDGmZmMu --- frontend/src/app/globals.css | 35 +++++++++- .../personal-report-theme-contract.test.ts | 70 +++++++++++++++++++ 2 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 frontend/tests/personal-report-theme-contract.test.ts diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index d523a550..7cca7c46 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -119,6 +119,11 @@ --space-24: 96px; --composer-reserve: 148px; --ease-out: cubic-bezier(.22, 1, .36, 1); + /* The personal report's paper surface. Declared here so the dark blocks can + override it; see the note on .personal-report-reader. */ + --report-accent: #85432f; + --report-paper: #f8f5ee; + --report-rule: #c9c2b7; /* Hairlines and sheens that are dark-on-light here and light-on-dark there. */ --ring-hairline: oklch(0 0 0 / .1); --sheen: rgba(255, 255, 255, .54); @@ -3169,9 +3174,10 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class scroll boundary because the chat shell intentionally locks html/body. ============================================================ */ .personal-report-reader { - --report-accent: #85432f; - --report-paper: #f8f5ee; - --report-rule: #c9c2b7; + /* The report palette lives on :root, not here. A custom property declared on + this element wins over :root no matter what a media query says, so keeping + it local made the theme override dead: dark mode kept the light paper while + --color-ink went light with the theme, leaving white text on white paper. */ width: 100%; height: 100%; min-height: 0; @@ -3309,6 +3315,29 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class } @media print { + /* Paper is always paper. Without this, printing while the screen is in dark + mode kept the light-forced backgrounds below but left every ink light, + which prints as a blank page. */ + :root { + color-scheme: light; + --color-ink: #1d1d1f; + --color-ink-strong: #32322f; + --color-ink-secondary: #5f5f59; + --color-ink-tertiary: #6a6963; + --color-canvas: #fbfaf7; + --color-canvas-soft: #f3f2ee; + --color-border: #d8d6cf; + --color-border-strong: #b8b5ad; + --color-action: #85432f; + --color-action-hover: #6f3627; + --color-focus: #85432f; + --color-danger: #9a2f2f; + --color-success: #28633e; + --color-warning: #b07b22; + --report-accent: #85432f; + --report-paper: #f8f5ee; + --report-rule: #c9c2b7; + } html, body { width: auto !important; height: auto !important; min-height: 0 !important; overflow: visible !important; background: #fff !important; } * { -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important; } .personal-report-screen-only { display: none !important; } diff --git a/frontend/tests/personal-report-theme-contract.test.ts b/frontend/tests/personal-report-theme-contract.test.ts new file mode 100644 index 00000000..f6a18bb2 --- /dev/null +++ b/frontend/tests/personal-report-theme-contract.test.ts @@ -0,0 +1,70 @@ +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"); + +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); +}; + +function blockTokens(opener: string): Map { + const start = globalStyles.indexOf(opener); + assert.notEqual(start, -1, `missing block: ${opener}`); + const end = globalStyles.indexOf("\n}", start); + const body = globalStyles.slice(start, end); + return new Map([...body.matchAll(/--([\w-]+)\s*:\s*([^;]+);/g)] + .map((hit) => [hit[1], hit[2].trim()])); +} + +test("the report palette is declared on :root, never on the reader element", () => { + // A custom property declared on .personal-report-reader wins over :root no + // matter what a media query says. Keeping it local made the dark override + // dead: the paper stayed light while --color-ink went light with the theme, + // which is white text on white paper. + const reader = globalStyles.slice(globalStyles.indexOf(".personal-report-reader {")); + const readerBody = reader.slice(0, reader.indexOf("\n}")); + assert.doesNotMatch(readerBody, /--report-(paper|rule|accent)\s*:/); + for (const token of ["report-paper", "report-rule", "report-accent"]) { + assert.ok(blockTokens(":root {\n color-scheme: light;").has(token), `--${token} must live on :root`); + } +}); + +test("report ink clears AA on report paper in both themes", () => { + const light = blockTokens(":root {\n color-scheme: light;"); + const dark = blockTokens(':root[data-theme="dark"] {'); + for (const [name, palette] of [["light", light], ["dark", dark]] as const) { + const paper = palette.get("report-paper")!; + assert.match(paper, /^#[0-9a-f]{6}$/i); + for (const ink of ["color-ink", "color-ink-strong", "color-ink-secondary", "color-ink-tertiary", "report-accent"]) { + const value = palette.get(ink)!; + const ratio = contrast(value, paper); + assert.ok( + ratio >= 4.5, + `${name}: --${ink} (${value}) on --report-paper (${paper}) is ${ratio.toFixed(2)}:1`, + ); + } + } +}); + +test("print pins the palette back to light so paper is always paper", () => { + // Printing while the screen is dark forced white backgrounds but left every + // ink light, which comes out of the printer blank. + const print = globalStyles.slice(globalStyles.indexOf("@media print {")); + assert.match(print, /:root \{\s*\n\s*color-scheme: light;/); + const printRoot = blockTokens("@media print {\n /* Paper is always paper"); + for (const token of ["color-ink", "color-ink-strong", "report-paper", "report-accent"]) { + assert.ok(printRoot.has(token), `@media print must reset --${token}`); + } + assert.equal(printRoot.get("color-ink"), "#1d1d1f"); + assert.equal(printRoot.get("report-paper"), "#f8f5ee"); +});