From 7891edf2e05f818f075b8b027513a1f6058080ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20M=C4=99dryga=C5=82?= Date: Tue, 18 Aug 2026 10:42:58 +0200 Subject: [PATCH] test(runner): flip the grid's colour scheme the way the theme reads it (DEV-2546) The dark half of `row-striping.spec.ts` failed 4/4 on its first ever live run, with an odd/even channel delta of exactly 0. The starters are fine: measured on production, an OS dark preference gives odd `(12, 12, 13)` against even `(5, 5, 6)`, and the Style panel's dark scheme gives the same. What was broken is the switch this suite used to get there. It swapped the wrapper class `ht-theme-main` -> `ht-theme-main-dark`. That worked only while the starters imported `handsontable/styles/ht-theme-main.min.css`, the one place the dark class was ever defined. DEV-2200 dropped that import in favour of the JS theme object, and `ThemeManager` now injects `:where(.ht-theme-main){...}` plus `.ht-theme-main { color-scheme: light dark }` keyed to the *resolved* class name. Rename the class and the whole block stops applying: every `--ht-*` token goes empty, the `color-mix()` stripe is invalid at computed-value time, and the theme rules that painted the even row are gone too. Both rows end up `transparent` -- an unthemed grid, which is not a dark one. Green Aug 11, invalidated Aug 12, first run Aug 17. `mainTheme` declares no `colorScheme`, so the grid resolves its `light-dark()` tokens against `color-scheme: light dark` -- the visitor's own preference. Emulate that instead, and assert the theme really is scheme-adaptive before doing so. The emulation is page-scoped and survives the reload, so it is reset before the Style panel half, which sets an explicit scheme of its own. Two reasons the suite's own anti-vacuity guard certified this: * `transparent` computes to `rgba(0, 0, 0, 0)` -- four numbers, none of them NaN -- so the parser that claimed to refuse it read pure black instead. Against a white light reading that is contrast 21 and luminance 0, so `expectSchemeFlipped` passed on a grid with no theme at all. Reject a zero alpha, and name the row that carried it. * Nothing checked that the tokens the stripe is mixed from still exist. Read `--ht-background-color` off the theme wrapper and fail there, so the message says the grid lost its theme rather than leaving a bare `Received: 0` to explain. Verified against production: 4/4 pass. Both failure modes were re-run as controls -- the old class swap now fails with "the grid lost its theme", and removing the flip entirely fails with "the colour scheme never changed", which now also reports what the preview saw. Spec-only. No starter source changed, so no catalog resync and no deploy -- unlike DEV-2197, which needed both. Co-Authored-By: Claude Opus 5 --- runner/e2e/row-striping.spec.ts | 86 +++++++++++++++++++++++---------- 1 file changed, 60 insertions(+), 26 deletions(-) diff --git a/runner/e2e/row-striping.spec.ts b/runner/e2e/row-striping.spec.ts index c4a9abca..527a0dfe 100644 --- a/runner/e2e/row-striping.spec.ts +++ b/runner/e2e/row-striping.spec.ts @@ -2,7 +2,7 @@ import { test, expect, type FrameLocator, type Page } from "@playwright/test"; // Does the starters' own row striping survive a dark colour scheme? (DEV-2197) // -// Five starters mark every other row with `odd` from their own `beforeRenderer` +// Six starters mark every other row with `odd` from their own `beforeRenderer` // and stripe it from their own stylesheet. That rule used to pin // `background: #fafbff`, which outranks the theme's `:where()`-wrapped tokens — // so a demo switched to dark kept near-white rows carrying the theme's *light* @@ -36,7 +36,7 @@ import { test, expect, type FrameLocator, type Page } from "@playwright/test"; const EXAMPLES = ["react", "javascript", "typescript", "vue"] as const; type Row = { bg: [number, number, number]; color: [number, number, number] }; -type Reading = { odd: Row; even: Row }; +type Reading = { odd: Row; even: Row; prefersDark: boolean }; const preview = (page: Page): FrameLocator => page.frameLocator("iframe").first(); const cell = (page: Page) => preview(page).locator(".handsontable td").first(); @@ -47,32 +47,59 @@ const cell = (page: Page) => preview(page).locator(".handsontable td").first(); * Read through `color()` as well as `rgb()`: a `color-mix()` result computes to * `color(srgb 0.965 …)`, so a naive `rgb(…)` parse silently yields nothing and * every comparison below would trivially pass. + * + * Two of the guards below exist because of DEV-2546, where a grid that had lost + * its theme entirely was read as a legitimately dark one and only the stripe + * delta noticed. Refusing transparency and requiring the theme's own tokens + * turns that into an accurate message instead of a puzzling `Received: 0`. */ async function readRows(page: Page): Promise { return preview(page) .locator(".handsontable") .first() - .evaluate(() => { - const parse = (value: string): [number, number, number] => { - // `color(srgb …)` carries 0-1 components; `rgb(…)` carries 0-255. + .evaluate((grid) => { + const parse = (value: string, what: string): [number, number, number] => { + // `color(srgb …)` carries 0-1 components; `rgb(…)` carries 0-255. Alpha + // is 0-1 in both notations and is never scaled. const scale = value.startsWith("color(") ? 255 : 1; - const nums = (value.match(/-?[\d.]+/g) ?? []).slice(0, 3).map((n) => Number(n) * scale); + const nums = (value.match(/-?[\d.]+/g) ?? []).map(Number); if (nums.length < 3 || nums.some((n) => Number.isNaN(n))) { - // A transparent or unparsed background is the failure this suite is - // looking for, so refuse to guess a value for it. - throw new Error(`cannot read a colour out of ${JSON.stringify(value)}`); + // An unparsed colour is the failure this suite is looking for, so + // refuse to guess a value for it. + throw new Error(`cannot read a colour out of ${JSON.stringify(value)} (${what})`); + } + // `transparent` computes to `rgba(0, 0, 0, 0)` — four numbers, none of + // them NaN — so it parses as pure black and sails past every check + // below. That is exactly how an unthemed grid passed for a dark one. + if (nums.length > 3 && nums[3] === 0) { + throw new Error(`${what} is transparent — the rule that paints it never applied`); } - return [nums[0]!, nums[1]!, nums[2]!]; + return [nums[0]! * scale, nums[1]! * scale, nums[2]! * scale]; }; const read = (selector: string) => { const el = document.querySelector(selector); if (!el) throw new Error(`no cell matched ${selector}`); const styles = getComputedStyle(el); - return { bg: parse(styles.backgroundColor), color: parse(styles.color) }; + return { + bg: parse(styles.backgroundColor, `${selector} background`), + color: parse(styles.color, `${selector} text`), + }; }; + // The two tokens the stripe is mixed from. Read off the same ancestor + // `themeClass` and `resolvedScheme` use. Empty here means the theme block + // `ThemeManager` injects is no longer reaching this grid, at which point + // every colour below is a fallback rather than a theme's answer. + const wrapper = grid.closest("[class*='ht-theme-']"); + if (!wrapper) throw new Error("no ht-theme-* wrapper around the grid"); + if (!getComputedStyle(wrapper).getPropertyValue("--ht-background-color").trim()) { + throw new Error("the grid lost its theme — `--ht-background-color` is unset"); + } return { odd: read("table.htCore tr.odd td"), even: read("table.htCore tr.ht__row_even:not(.odd) td"), + // Diagnostic only: proof that a media emulation reached this + // cross-origin document, so a scheme that did not flip says why. + prefersDark: window.matchMedia("(prefers-color-scheme: dark)").matches, }; }); } @@ -163,9 +190,10 @@ function expectLegibleStripe(reading: Reading, scheme: string) { * touches it, so its background is the theme's own answer. */ function expectSchemeFlipped(light: Reading, dark: Reading) { + const witness = `(the preview reports prefers-color-scheme: dark = ${dark.prefersDark})`; expect( contrast(light.even.bg, dark.even.bg), - "the colour scheme never changed — this run proves nothing about dark mode", + `the colour scheme never changed — this run proves nothing about dark mode ${witness}`, ).toBeGreaterThan(4); expect(luminance(dark.even.bg), "the 'dark' reading is not dark").toBeLessThan( luminance(light.even.bg), @@ -188,32 +216,38 @@ for (const example of EXAMPLES) { // 1 & 2. The theme the demo ships with, in both of its schemes. This is // what a visitor sees before touching anything, and what someone - // copying the starter into their own app gets. The shipped - // `ht-theme-main.min.css` delivers its light/dark pairs as a - // lightningcss `var()` switch keyed off the class name, so swapping the - // class is what flips it — there is no media query to emulate. + // copying the starter into their own app gets. let shippedLight: Reading | undefined; await expect(async () => { shippedLight = await readRows(page); expectLegibleStripe(shippedLight, "default theme, light"); }).toPass({ timeout: 60_000 }); - await preview(page) - .locator(".handsontable") - .first() - .evaluate(() => { - document.querySelectorAll(".ht-theme-main").forEach((el) => { - el.classList.replace("ht-theme-main", "ht-theme-main-dark"); - }); - }); - expect(await themeClass(page), "the dark theme class did not land").toEqual( - "ht-theme-main-dark", + // The starters pass `theme: mainTheme` (DEV-2200) and `mainTheme` declares + // no `colorScheme`, so `ThemeManager` writes `color-scheme: light dark`: + // the grid resolves its `light-dark()` tokens against the visitor's own + // preference, and emulating the media query is the only honest switch. + // + // This used to swap the wrapper class `ht-theme-main` -> `ht-theme-main-dark` + // instead. That worked only while the starters imported + // `handsontable/styles/ht-theme-main.min.css`, the one place the dark + // class was ever defined; DEV-2200 dropped that import. Renaming the class + // now detaches the theme block `ThemeManager` injected against the + // resolved name, so every `--ht-*` token goes empty and *both* rows fall + // back to `transparent` — an unthemed grid, which is not a dark one + // (DEV-2546). + expect(await resolvedScheme(page), "the shipped theme is not scheme-adaptive").toEqual( + "light dark", ); + await page.emulateMedia({ colorScheme: "dark" }); await expect(async () => { const shippedDark = await readRows(page); expectSchemeFlipped(shippedLight!, shippedDark); expectLegibleStripe(shippedDark, "default theme, dark"); }).toPass({ timeout: 60_000 }); + // Page-scoped, and it survives the reload below. The panel half sets its + // own explicit scheme and must not inherit this one. + await page.emulateMedia({ colorScheme: "light" }); // 3 & 4. A theme from the Style panel. Structurally different, and the // reason both halves are worth running: the panel's theme is injected