From 0c0e761dd4e2d316268e46f809c56f0199f65e6b Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 4 Sep 2026 09:41:08 -0700 Subject: [PATCH] fix(ui): answer forced colours and reduced motion, and declare color-scheme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three platform settings the grid left to the consumer, only one of which a consumer could actually fix from outside. FORCED COLOURS. A range selection was invisible. The fill is a translucent background-image; forced colours drop the image and force the colour underneath to Canvas, so an eleven-cell selection measured identical to no selection — every cell rgb(255,255,255) on rgb(0,0,0), with only the single FOCUSED cell marked. The answer is the system pair Highlight/HighlightText, which is honoured inside the block rather than forced away. It needs `forced-color-adjust: none` to land, and that is the interesting part: without it Chromium paints a text BACKPLATE — an opaque Canvas rectangle behind text sitting over a background image, which a selected cell counts as — and every word inside the fill came out a solid white block. The computed styles were identical with and without it; only a screenshot told the two apart, and dropping the background-image explicitly does NOT lift the backplate (tried first). Opting out is safe only because the colours are the system's, so the pair travels together in one rule with the reason written above it. Descendants inherit the cell's colour so a red P&L cannot paint itself onto the fill, and disabled menu items take GrayText. Everything else in the grid survives forced colours unaided, which the audit established before any of this was written: the border cage carries the structure, the row-select glyph is drawn in currentColor, the focus ring is an outline the UA forces to CanvasText, and the frozen edge falls back to the pinned cell's own border when its gradient is dropped. REDUCED MOTION. Three animated declarations, no way out, and not something a consumer can patch without re-implementing rules they do not own. All three are decoration, so all three are switched off. The guard is structural rather than a spot check: every rule in the file that animates must be named in the reduce block, so the next transition added cannot ship without one. COLOR-SCHEME. Only pretable.css declared it, so an Excel- or Material-themed grid kept light scrollbars and a light ` popup inside a +dark app — the one part of the surface a theme cannot reach with a custom +property. Both now declare it, Material in each mode it ships. diff --git a/apps/bench/tests/cascade-override.spec.ts b/apps/bench/tests/cascade-override.spec.ts index 10f9dc4c9..0442a94f9 100644 --- a/apps/bench/tests/cascade-override.spec.ts +++ b/apps/bench/tests/cascade-override.spec.ts @@ -189,3 +189,43 @@ test("the selection fill composes over zebra instead of replacing it", async ({ // is the only declaration left in it. await expect(page.locator("#sel")).toHaveCSS("color", "rgb(9, 9, 9)"); }); + +test("under forced colours a selected cell is still visibly selected", async ({ + page, +}) => { + // The defect this locks: the range fill is a translucent background-IMAGE, + // and forced colours drop background-image and force the colour underneath + // to Canvas. An eleven-cell selection on the live grid came out identical to + // no selection at all — every cell rgb(255,255,255) on rgb(0,0,0), with only + // the single FOCUSED cell marked — so what the grid was about to copy could + // not be read off the screen. + // + // Only a browser can prove this: the substitution happens in the UA, not in + // any declaration a stylesheet test can read. + await page.emulateMedia({ forcedColors: "active" }); + await page.setContent( + "
" + + "
" + + 'x' + + 'y' + + "
", + ); + await page.addStyleTag({ path: GRID_CSS }); + + const read = (id: string) => + page.evaluate((sel) => { + const s = getComputedStyle(document.querySelector(sel)!); + return { background: s.backgroundColor, color: s.color }; + }, `#${id}`); + + const selected = await read("sel"); + const plain = await read("plain"); + + // The assertion that matters is the DIFFERENCE: whatever system palette the + // user runs, a selected cell must not paint as an unselected one. + expect(selected.background).not.toBe(plain.background); + // And the pair has to be the platform's own, or the text can land invisible + // on the fill in a palette nobody tested. + expect(selected.color).not.toBe(plain.color); +}); diff --git a/packages/ui/grid.css b/packages/ui/grid.css index 45219bf23..9b0148f64 100644 --- a/packages/ui/grid.css +++ b/packages/ui/grid.css @@ -2369,4 +2369,80 @@ transform: translateX(-50%); } } + + /* ---- Platform preferences -------------------------------------------- + Two settings the OS carries and a data grid has to answer for itself: a + consumer cannot patch either from outside without re-implementing rules + they do not own. */ + + /* Windows High Contrast and friends. Forced colours REPLACE every author + background, border and text colour with a system pair, and drop + background-image and box-shadow outright. Most of this grid survives that + unaided — the cage of borders carries the structure, the row-select + glyph is drawn in `currentColor`, and the focus ring is an outline the UA + forces to CanvasText. The frozen edge loses its gradient, and needs + nothing: the pinned cell's own border-right is forced to a system colour + and marks the boundary. + + SELECTION does not survive, and that is the one thing here that had to + be answered. It is a translucent background-image over the cell (see the + range-fill rule above), and forced colours drop the image and force the + colour underneath to Canvas — so an eleven-cell range came out identical + to no selection at all, with only the single FOCUSED cell marked. What + the grid is about to copy became unreadable. System colours, not author + ones: `Highlight`/`HighlightText` are the pair the platform guarantees + against each other, and inside this block they are honoured rather than + forced away. */ + @media (forced-colors: active) { + :where([role="gridcell"][aria-selected="true"]) { + /* `forced-color-adjust: none` is load-bearing, not an escape hatch, and + it is safe only because the two colours below are the SYSTEM's: the + cell still paints in the user's own palette, it is simply painting a + state the UA would otherwise have erased. + Without it Chromium draws a text BACKPLATE — an opaque Canvas + rectangle behind text that sits over a background image, which this + cell counts as (the range fill is one). The fill then arrived + correctly and every word inside it came out as a solid white block. + Computed styles looked right in both cases; only a screenshot told + them apart. Dropping the background-image here does NOT lift the + backplate — that was tried first. */ + forced-color-adjust: none; + background-color: Highlight; + color: HighlightText; + } + /* A cell opted out of forcing hands its descendants author colours again, + so a P&L in red or a badge in amber would paint itself onto the + Highlight fill at whatever contrast it happened to have. Inheriting the + cell's HighlightText is the only pairing guaranteed against it. */ + :where([role="gridcell"][aria-selected="true"] *) { + color: inherit; + } + /* On a Highlight ground the ring has to change with it: CanvasText is + only guaranteed against Canvas, and this cell is no longer Canvas. */ + :where( + [role="gridcell"][aria-selected="true"][data-pretable-focused="true"] + ) { + outline-color: HighlightText; + } + /* GrayText is the system's own disabled ink. Without it a disabled item + is forced to CanvasText — the enabled colour — and the menu goes back + to offering the one thing that cannot be chosen. */ + :where([data-pretable-menu-item]:disabled) { + color: GrayText; + } + } + + /* Reduced motion. Every animated declaration in this file is named here: + they are all decoration — a twisty swinging, two icons fading in — so + each is simply switched off rather than shortened. A consumer's own + motion is theirs to handle; this is the grid answering for its own. */ + @media (prefers-reduced-motion: reduce) { + :where( + [data-pretable-group-twisty], + [data-pretable-filter-funnel], + [data-pretable-column-menu-button] + ) { + transition: none; + } + } } diff --git a/packages/ui/src/__tests__/contract.test.ts b/packages/ui/src/__tests__/contract.test.ts index 6e5179270..beeda8d09 100644 --- a/packages/ui/src/__tests__/contract.test.ts +++ b/packages/ui/src/__tests__/contract.test.ts @@ -68,6 +68,11 @@ const HEIGHT_TOKENS = [ ]; const THEMES_DIR = path.resolve(__dirname, "../../themes"); +/** + * Every theme this package ships. One list, so a theme added tomorrow inherits + * every guard below rather than only the ones whose inline copy was updated. + */ +const THEME_FILES = ["excel.css", "material.css", "pretable.css"]; const GRID_CSS = path.resolve(__dirname, "../../grid.css"); /** @@ -124,7 +129,7 @@ afterEach(() => { }); describe("token contract", () => { - for (const themeFile of ["excel.css", "material.css", "pretable.css"]) { + for (const themeFile of THEME_FILES) { test(`${themeFile} defines every public token at :root`, () => { const cleanup = loadCSS(path.join(THEMES_DIR, themeFile)); const computed = getComputedStyle(document.documentElement); @@ -181,6 +186,31 @@ describe("token contract", () => { }); } + test("every theme declares color-scheme, in each mode it ships", () => { + // The browser paints scrollbars and native controls — the filter dialog's + // operator select and its popup list — from color-scheme, not from any + // token. A dark theme without it leaves light scrollbars inside a dark + // grid, and that is the one part of the surface a theme cannot reach with + // a custom property. + for (const themeFile of THEME_FILES) { + const css = fs + .readFileSync(path.join(THEMES_DIR, themeFile), "utf8") + .replace(/\/\*[\s\S]*?\*\//g, ""); + const root = css.match(/:root\s*\{([\s\S]*?)\n\}/)?.[1]; + expect(root, `${themeFile} has no :root block`).toBeDefined(); + expect(root, `${themeFile} :root declares no color-scheme`).toMatch( + /color-scheme:\s*(light|dark|light dark)/, + ); + const dark = css.match(/\[data-theme="dark"\]\s*\{([\s\S]*?)\n\}/)?.[1]; + if (dark !== undefined) { + expect( + dark, + `${themeFile} ships a dark block that never switches color-scheme`, + ).toMatch(/color-scheme:\s*dark/); + } + } + }); + test("material.css resolves dark mode (color override fires)", () => { const cleanup = loadCSS(path.join(THEMES_DIR, "material.css")); const lightBg = getComputedStyle(document.documentElement) @@ -260,7 +290,7 @@ describe("token contract", () => { return (hi + 0.05) / (lo + 0.05); } - for (const themeFile of ["excel.css", "material.css", "pretable.css"]) { + for (const themeFile of THEME_FILES) { for (const mode of ["light", "dark"] as const) { test(`${themeFile}: the checkbox mark is legible on its own fill (${mode})`, () => { // WCAG 1.4.11 puts a 3:1 floor on graphical objects, and the check mark @@ -321,7 +351,7 @@ describe("token contract", () => { expect(stale, `grid.css still references ${stale.join(", ")}`).toEqual([]); }); - for (const themeFile of ["excel.css", "material.css", "pretable.css"]) { + for (const themeFile of THEME_FILES) { test(`grid.css has no unresolved var(--pretable-*) refs under ${themeFile}`, () => { const themeCleanup = loadCSS(path.join(THEMES_DIR, themeFile)); // Comments stripped first, the same way every other prose-sensitive check diff --git a/packages/ui/src/__tests__/css-cascade.test.ts b/packages/ui/src/__tests__/css-cascade.test.ts index 07b02e735..d7fb15415 100644 --- a/packages/ui/src/__tests__/css-cascade.test.ts +++ b/packages/ui/src/__tests__/css-cascade.test.ts @@ -203,6 +203,82 @@ describe("grid.css cascade contract", () => { ).toBeGreaterThan(pinnedLeft); }); + test("forced colours keep selection readable, with system colours", () => { + // Forced colours replace every author background and drop background-image + // outright. The range fill is a translucent background-image, so an + // eleven-cell selection came out identical to no selection at all — only + // the focused cell was marked, and what the grid was about to copy was + // unreadable. Measured under `forced-colors: active` before this existed: + // selected and unselected cells both computed rgb(255,255,255) on + // rgb(0,0,0). + const css = strippedCss(); + const forced = css.slice(css.indexOf("@media (forced-colors: active)")); + expect(css, "no forced-colors block at all").toContain( + "@media (forced-colors: active)", + ); + + const selection = rulesSelecting(forced, (selector) => + selector.includes('aria-selected="true"'), + ); + expect(selection.length, "selection is unanswered").toBeGreaterThan(0); + const bodies = selection.map((m) => m[2]).join(""); + // System colours, not author ones: only these are guaranteed to contrast, + // and only these are honoured rather than forced away inside the block. + expect(bodies).toMatch(/background-color:\s*Highlight/); + expect(bodies).toMatch(/color:\s*HighlightText/); + expect(bodies).not.toMatch(/var\(--pretable-/); + // Chromium's text backplate paints an opaque rectangle behind text over a + // background image — which a selected cell has — so the fill arrived and + // every word in it came out a solid block. Opting out is only safe + // BECAUSE the pair above is the system's, so the two travel together. + expect(bodies).toMatch(/forced-color-adjust:\s*none/); + + // A disabled item forced to CanvasText is the enabled colour again. + const disabled = rulesSelecting( + forced, + (selector) => + selector.includes("data-pretable-menu-item") && + selector.includes(":disabled"), + ); + expect(disabled.length, "disabled items unanswered").toBeGreaterThan(0); + expect(disabled.map((m) => m[2]).join("")).toMatch(/color:\s*GrayText/); + }); + + test("every animated rule is switched off under reduced motion", () => { + // Structural, not a spot check: whatever this file animates has to appear + // in the reduce block, so the NEXT transition added cannot ship without + // one. A consumer cannot patch this from outside without re-implementing + // rules they do not own. + const css = strippedCss(); + const reduceAt = css.indexOf("@media (prefers-reduced-motion: reduce)"); + expect(reduceAt, "no reduced-motion block at all").toBeGreaterThan(-1); + const reduce = css.slice(reduceAt); + + const animated = rulesSelecting(css.slice(0, reduceAt), () => true).filter( + ([, , body]) => /(?:^|[;{\s])(?:transition|animation):/.test(body), + ); + expect( + animated.length, + "nothing in grid.css animates; this guard has lost its subject", + ).toBeGreaterThan(0); + + // Each animated rule is named by at least one of its own attributes. + for (const [, selector] of animated) { + const attrs = [...selector.matchAll(/\[(data-pretable-[a-z-]+)/g)].map( + (m) => m[1], + ); + expect( + attrs.length, + `cannot tell what "${selector.trim()}" animates`, + ).toBeGreaterThan(0); + expect( + attrs.some((attr) => reduce.includes(attr)), + `"${selector.trim()}" animates but the reduced-motion block never names it`, + ).toBe(true); + } + expect(reduce).toMatch(/transition:\s*none/); + }); + test("a focused cell draws its ring with `outline`, never `box-shadow`", () => { // Two reasons, both load-bearing: // diff --git a/packages/ui/themes/excel.css b/packages/ui/themes/excel.css index 01df5c28a..1d691703e 100644 --- a/packages/ui/themes/excel.css +++ b/packages/ui/themes/excel.css @@ -8,6 +8,14 @@ */ :root { + /* Declared for the same reason pretable.css does: the browser paints the + scroll viewport's scrollbars and every native control inside the grid — + the filter dialog's operator select, its popup list — from this, not from + the tokens. Without it a dark-mode host keeps this grid's scrollbars light and a light + select popup, which is the one part of the surface a theme cannot reach + with a custom property. */ + color-scheme: light; + /* Surfaces */ --pretable-bg-grid: #ffffff; --pretable-bg-grid-alt: #ffffff; /* No striping by default — Excel doesn't band */ diff --git a/packages/ui/themes/material.css b/packages/ui/themes/material.css index 37e174dcd..7be67a440 100644 --- a/packages/ui/themes/material.css +++ b/packages/ui/themes/material.css @@ -13,6 +13,14 @@ */ :root { + /* Declared for the same reason pretable.css does: the browser paints the + scroll viewport's scrollbars and every native control inside the grid — + the filter dialog's operator select, its popup list — from this, not from + the tokens. Without it a dark-mode grid keeps light scrollbars and a light + select popup, which is the one part of the surface a theme cannot reach + with a custom property. */ + color-scheme: light; + /* Surfaces — M3 neutral grayscale (untinted by primary seed) */ --pretable-bg-grid: #fcfcfc; /* N99 */ --pretable-bg-grid-alt: #fcfcfc; /* No striping by default — Material list pattern */ @@ -163,6 +171,8 @@ /* Dark mode — M3 baseline dark; color-only overrides; density inherits from light. */ [data-theme="dark"] { + color-scheme: dark; + --pretable-bg-grid: #1c1c1c; /* surface-container-low */ --pretable-bg-grid-alt: #1c1c1c; --pretable-bg-header: #212121; /* surface-container */