Skip to content
Merged
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
86 changes: 60 additions & 26 deletions runner/e2e/row-striping.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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*
Expand Down Expand Up @@ -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();
Expand All @@ -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<Reading> {
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,
};
});
}
Expand Down Expand Up @@ -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),
Expand All @@ -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
Expand Down
Loading