Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/platform-a11y-prefs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
"@pretable/ui": patch
---

The grid now answers two platform accessibility settings it was ignoring, and
every theme declares its `color-scheme`.

**Forced colours (Windows High Contrast).** A range selection was invisible.
The fill is a translucent `background-image`, and forced colours drop the image
and force the colour underneath to `Canvas` — so eleven selected cells came out
identical to no selection at all, with only the single focused cell marked, and
what the grid was about to copy could not be read off the screen. Selected cells
now take `Highlight`/`HighlightText`, the pair the platform guarantees against
each other, with `forced-color-adjust: none` so Chromium's text backplate does
not paint an opaque rectangle over every word (it did; the computed styles were
identical either way and only a screenshot told them apart). Descendants inherit
the cell's colour, so a red P&L inside a selection cannot paint itself onto the
fill at whatever contrast it happened to have. Disabled menu items take
`GrayText`. The rest of the grid needed nothing: the border cage carries the
structure, the row-select glyph is drawn in `currentColor`, and the frozen edge
falls back to the pinned cell's own border.

**Reduced motion.** `grid.css` animated three things and offered no way out; a
consumer cannot patch that without re-implementing rules they do not own. All
three are decoration and are now switched off under
`prefers-reduced-motion: reduce`, with a guard that fails if a future animation
is not named there too.

**`color-scheme`.** Only `pretable.css` declared it, so an Excel- or
Material-themed grid kept light scrollbars and a light `<select>` 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.
40 changes: 40 additions & 0 deletions apps/bench/tests/cascade-override.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
"<div data-pretable-scroll-viewport>" +
"<div data-pretable-row>" +
'<span data-pretable-cell data-pretable-selected="true" ' +
'role="gridcell" aria-selected="true" id="sel">x</span>' +
'<span data-pretable-cell role="gridcell" id="plain">y</span>' +
"</div></div>",
);
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);
});
76 changes: 76 additions & 0 deletions packages/ui/grid.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}
36 changes: 33 additions & 3 deletions packages/ui/src/__tests__/contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

/**
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
76 changes: 76 additions & 0 deletions packages/ui/src/__tests__/css-cascade.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
//
Expand Down
8 changes: 8 additions & 0 deletions packages/ui/themes/excel.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
10 changes: 10 additions & 0 deletions packages/ui/themes/material.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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 */
Expand Down