diff --git a/.agents/skills/quality-check/SKILL.md b/.agents/skills/quality-check/SKILL.md
index a3f788930..fdd3dbd9d 100644
--- a/.agents/skills/quality-check/SKILL.md
+++ b/.agents/skills/quality-check/SKILL.md
@@ -5,7 +5,7 @@ description: "Runs type-check, lint, test, check-dts, and formatting for the App
# Quality Check
-Run type-check, lint, test, `check-dts`, and formatting in sequence. Fix errors where possible and re-run until clean. Formatting runs last so that any code changes from fixing errors are also formatted.
+Run type-check, lint, test, `check-dts`, browser style tests, and formatting in sequence. Fix errors where possible and re-run until clean. Formatting runs last so that any code changes from fixing errors are also formatted.
These are the same tasks CI runs in `.github/actions/ci`. Keep this list in step with that file — a task that runs there but not here passes locally and fails on the PR.
@@ -53,7 +53,19 @@ Step 1 cannot catch what this catches: `type-check` sets `skipLibCheck: true` an
the emitted declarations, while `check-dts` packs a tarball and type-checks it with
`skipLibCheck: false`. A declaration that only breaks for consumers fails here and nowhere else.
-### Step 5: Format
+### Step 5: Browser style contracts
+
+Run the shipped CSS contracts in Chromium (the script builds the package first):
+
+```sh
+pnpm --filter @tailor-platform/app-shell test:browser
+```
+
+On first use, install Chromium with `pnpm --filter @tailor-platform/app-shell exec playwright install chromium`.
+Linux CI uses `playwright install --with-deps chromium` to install system libraries too.
+Keep these tests enabled in CI when replacing Node-based style checks; `pnpm test` alone does not run them.
+
+### Step 6: Format
Run formatting:
@@ -76,4 +88,5 @@ Provide a summary of:
2. Lint errors found and fixed (if any)
3. Test failures found and fixed (if any)
4. Declaration (`check-dts`) errors found and fixed (if any)
-5. Formatting changes (if any)
+5. Browser style test failures found and fixed (if any)
+6. Formatting changes (if any)
diff --git a/.github/actions/ci/action.yaml b/.github/actions/ci/action.yaml
index 2d9d6ad98..95178081c 100644
--- a/.github/actions/ci/action.yaml
+++ b/.github/actions/ci/action.yaml
@@ -22,6 +22,12 @@ runs:
# sets skipLibCheck: true and never looks at the emitted declarations.
- run: pnpm exec turbo run lint test type-check check-dts
shell: bash
+ - name: Install Chromium for style contract tests
+ run: pnpm --filter @tailor-platform/app-shell exec playwright install --with-deps chromium
+ shell: bash
+ - name: Check styles in Chromium
+ run: pnpm --filter @tailor-platform/app-shell exec vitest run --config vitest.browser.config.ts
+ shell: bash
# Deterministic docs + generated-skill drift gate (no LLM). Runs uncached
# so a docs/ change always re-validates the manifest and consumer skill.
- run: pnpm docs:check
diff --git a/decisions/typography-roles.md b/decisions/typography-roles.md
index c4911e5c2..c68cdc320 100644
--- a/decisions/typography-roles.md
+++ b/decisions/typography-roles.md
@@ -94,7 +94,7 @@ Limit: this does not let a role replace a size that core sets on its own element
- **API.** `Text` takes the role as `variant`. The prop is not called `role`, because `role` is an ARIA attribute. The element is set with Base UI's `render` prop, as in `Button`, the sidebar primitives, and the breadcrumb. AppShell has no `as` or `asChild` prop.
- **Default elements.** The three heading roles render `h2`. The body roles, including the relaxed variants, render `p`. `label-md`, `label-sm`, and `code-sm` render `span`. All headings share one default so that no heading level is implied by size. `h2` is the default because a page has one `h1` (`Layout.Header`), and `Dialog.Title` from Base UI also renders `h2`. A default is hard to change later, because every call site without `render` would get a different element.
- **Lint.** The `jsx-a11y/heading-has-content` rule reports `render={
}` as an empty heading, and the function form `render={(props) => }` too. Apps that enable the rule disable it on that line. A `level` prop would avoid this, but it would add a second way to choose the element next to `render`. The documentation shows the disable comment instead.
-- **No color or modifier props.** Color and the four modifiers stay in `className`, as for the utilities. A consumer `leading-none`, `font-semibold`, or `tracking-*` overrides the role even though AppShell's prefixed sheet loads later, because the role reads the shared `--tw-leading`, `--tw-font-weight`, and `--tw-tracking` variables first. `tests/typography-compile.test.ts` checks this.
+- **No color or modifier props.** Color and the four modifiers stay in `className`, as for the utilities. A consumer `leading-none`, `font-semibold`, or `tracking-*` overrides the role even though AppShell's prefixed sheet loads later, because the role reads the shared `--tw-leading`, `--tw-font-weight`, and `--tw-tracking` variables first. `tests/browser/theme.test.tsx` checks the computed styles in Chromium; `tests/typography-compile.test.ts` retains the full role-declaration checks.
- **No `textVariants` export.** tailor-inc/platform-planning#1580 asked for a variants export. It is descoped, not deferred. The `text-` utilities already apply the same role mappings, so a cva export would be a third way to do the same thing, and it would put `astw:`-prefixed classes into consumer code. Unlike `Button` and `Badge`, `Text` does not export its cva function.
- **`code-sm` includes `font-mono`.** The utility cannot set the font family, but the component can.
- **cream and bloom.** The `h1` to `h6` rule is unchanged. The default `h2` takes the palette's line height and letter spacing under these palettes, the same as existing headings in AppShell components today.
diff --git a/packages/core/package.json b/packages/core/package.json
index d3cdf859a..b4e560006 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -53,6 +53,7 @@
"vendor:fonts": "node scripts/sync-noto-fonts.mjs",
"lint": "oxlint -c .oxlintrc.jsonc",
"test": "vitest run",
+ "test:browser": "pnpm build && vitest run --config vitest.browser.config.ts",
"type-check": "tsc --incremental"
},
"dependencies": {
@@ -84,8 +85,10 @@
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@vitejs/plugin-react": "catalog:",
+ "@vitest/browser-playwright": "4.1.11",
"@vitest/coverage-v8": "4.1.11",
"happy-dom": "21.0.4",
+ "playwright": "1.63.0",
"postcss": "8.5.31",
"react": "catalog:",
"react-dom": "catalog:",
diff --git a/packages/core/src/assets/semantic-contrast.test.ts b/packages/core/src/assets/semantic-contrast.test.ts
deleted file mode 100644
index a46ed67fd..000000000
--- a/packages/core/src/assets/semantic-contrast.test.ts
+++ /dev/null
@@ -1,258 +0,0 @@
-import { readFileSync } from "node:fs";
-import { dirname, join } from "node:path";
-import { fileURLToPath } from "node:url";
-import { describe, expect, it } from "vitest";
-
-/**
- * WCAG 1.4.3 contrast checks for the semantic colour roles, computed from the
- * shipped theme files themselves. Nothing is copied into the test, so a change
- * to a hue step, an alias or a palette surface that breaks a pair fails here.
- *
- * Scope of the guarantee: the default state of each pair, on `--card` and
- * `--background`, in every shipped palette (default, cream, bloom). The
- * palettes do not set the roles, so the pairs differ only by the page colour.
- * Not covered on purpose:
- * - hover states (`surface-hover`) against 4.5:1: subtle-badge hover text is
- * below 4.5:1 for some roles (danger and success in light mode). They are held
- * at a documented floor (MIN_HOVER_TEXT) so they cannot get worse unnoticed,
- * see decisions/semantic-color-roles.md;
- * - the tinted shell background of cream and bloom (`--shell-gradient-base`);
- * - the neutral intent, whose values are the pre-existing system tokens;
- * - the warning `solid` fill against the card (a fill, not a text pair).
- */
-const here = dirname(fileURLToPath(import.meta.url));
-const readTheme = (name: string) =>
- readFileSync(join(here, `themes/${name}.css`), "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
-const defaultCss = readTheme("default");
-
-type Rgba = { r: number; g: number; b: number; a: number };
-type Mode = "light" | "dark";
-type Palette = "default" | "cream" | "bloom";
-
-/**
- * Custom properties in effect on for a mode, applying rules in source
- * order. A palette file is imported after `default.css` and overrides it.
- */
-function variablesFor(mode: Mode, palette: Palette = "default"): Map {
- const vars = new Map();
- const sheets = palette === "default" ? [defaultCss] : [defaultCss, readTheme(palette)];
- for (const css of sheets) {
- for (const rule of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
- const selectors = rule[1].split(",").map((s) => s.trim());
- // matches `:root`, `.dark` and `:root.dark`; light matches `:root` only.
- const applies = selectors.some(
- (s) => s === ":root" || (mode === "dark" && (s === ".dark" || s === ":root.dark")),
- );
- if (!applies) continue;
- for (const decl of rule[2].matchAll(/(--[a-z0-9-]+)\s*:\s*([^;]+);/g)) {
- vars.set(decl[1], decl[2].replace(/\s+/g, " ").trim());
- }
- }
- }
- return vars;
-}
-
-function parseColor(value: string, vars: Map): Rgba {
- const v = value.trim();
- const ref = /^var\((--[a-z0-9-]+)\)$/.exec(v);
- if (ref) {
- const target = vars.get(ref[1]);
- if (target === undefined) throw new Error(`Undefined variable ${ref[1]}`);
- return parseColor(target, vars);
- }
- const hex = /^#([0-9a-f]{3,8})$/i.exec(v);
- if (hex) {
- let h = hex[1];
- if (h.length === 3 || h.length === 4) h = [...h].map((c) => c + c).join("");
- if (h.length !== 6 && h.length !== 8) throw new Error(`Bad hex colour ${v}`);
- return {
- r: parseInt(h.slice(0, 2), 16),
- g: parseInt(h.slice(2, 4), 16),
- b: parseInt(h.slice(4, 6), 16),
- a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
- };
- }
- const fn = /^rgba?\(([^)]+)\)$/.exec(v);
- if (fn) {
- const [r, g, b, a = "1"] = fn[1].split(",").map((p) => p.trim());
- return { r: Number(r), g: Number(g), b: Number(b), a: Number(a) };
- }
- throw new Error(`Unsupported colour syntax: ${v}`);
-}
-
-function over(top: Rgba, base: Rgba): Rgba {
- const a = top.a + base.a * (1 - top.a);
- const mix = (t: number, b: number) => (t * top.a + b * base.a * (1 - top.a)) / a;
- return { r: mix(top.r, base.r), g: mix(top.g, base.g), b: mix(top.b, base.b), a };
-}
-
-function linearize(channel: number): number {
- const s = channel / 255;
- return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
-}
-
-function luminance({ r, g, b }: Rgba): number {
- return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b);
-}
-
-function contrast(a: Rgba, b: Rgba): number {
- const [hi, lo] = [luminance(a), luminance(b)].toSorted((x, y) => y - x);
- return (hi + 0.05) / (lo + 0.05);
-}
-
-const MIN_TEXT = 4.5;
-/** WCAG 1.4.11 non-text contrast, for the invalid-input border. */
-const MIN_NON_TEXT = 3;
-/** Known and accepted: subtle hover text is below AA for some roles. A floor, not a pass. */
-const MIN_HOVER_TEXT = 4.2;
-const modes: Mode[] = ["light", "dark"];
-const palettes: Palette[] = ["default", "cream", "bloom"];
-const intents = ["info", "success", "warning", "danger"] as const;
-const alertVariantFor = { info: "info", success: "success", warning: "warning", danger: "error" };
-const pages = ["--card", "--background"] as const;
-
-describe.each(palettes.flatMap((palette) => modes.map((mode) => [palette, mode] as const)))(
- "semantic colour contrast (%s, %s)",
- (palette, mode) => {
- const vars = variablesFor(mode, palette);
- const color = (name: string) => parseColor(`var(${name})`, vars);
- /** Foreground drawn on a translucent surface that sits on a page colour. */
- const pair = (fg: string, surface: string, page: string) => {
- const base = color(page);
- const bg = over(color(surface), base);
- return contrast(over(color(fg), bg), bg);
- };
-
- it("resolves the page colours to opaque values", () => {
- for (const page of pages) expect(color(page).a).toBe(1);
- });
-
- describe.each(intents)("%s", (intent) => {
- const role = (name: string) => `--${intent}-${name}`;
-
- it("the contrast role meets 4.5:1 on solid", () => {
- expect(contrast(color(role("contrast")), color(role("solid")))).toBeGreaterThanOrEqual(
- MIN_TEXT,
- );
- });
-
- it("the contrast role meets 4.5:1 on solid-hover", () => {
- expect(
- contrast(color(role("contrast")), color(role("solid-hover"))),
- ).toBeGreaterThanOrEqual(MIN_TEXT);
- });
-
- it.each(pages)("text meets 4.5:1 on surface over %s", (page) => {
- expect(pair(role("text"), role("surface"), page)).toBeGreaterThanOrEqual(MIN_TEXT);
- });
-
- it.each(pages)(
- "text stays above the documented hover floor on surface-hover over %s",
- (page) => {
- expect(pair(role("text"), role("surface-hover"), page)).toBeGreaterThanOrEqual(
- MIN_HOVER_TEXT,
- );
- },
- );
-
- const variant = alertVariantFor[intent];
-
- it.each(pages)("alert foreground meets 4.5:1 on alert background over %s", (page) => {
- expect(
- pair(`--alert-${variant}-foreground`, `--alert-${variant}-background`, page),
- ).toBeGreaterThanOrEqual(MIN_TEXT);
- });
-
- it.each(pages)("alert description meets 4.5:1 on alert background over %s", (page) => {
- expect(
- pair(`--alert-${variant}-foreground-muted`, `--alert-${variant}-background`, page),
- ).toBeGreaterThanOrEqual(MIN_TEXT);
- });
- });
-
- describe("destructive aliases", () => {
- it.each([
- ["--destructive", "--danger-solid"],
- ["--destructive-foreground", "--danger-contrast"],
- ])("%s resolves to %s", (alias, role) => {
- expect(vars.get(alias)).toBe(`var(${role})`);
- expect(color(alias)).toEqual(color(role));
- });
-
- it.each(pages)("danger solid meets 3:1 against %s (invalid-input border)", (page) => {
- expect(contrast(color("--danger-solid"), color(page))).toBeGreaterThanOrEqual(MIN_NON_TEXT);
- });
- });
- },
-);
-
-describe("palette files", () => {
- // Guards the parser: if a palette's selectors stopped matching, the checks
- // above would silently run against the default palette.
- it.each(["cream", "bloom"] as const)(
- "%s overrides --card and --muted in dark mode",
- (palette) => {
- const own = variablesFor("dark", palette);
- const base = variablesFor("dark");
- expect(own.get("--card")).not.toBe(base.get("--card"));
- expect(own.get("--muted")).not.toBe(base.get("--muted"));
- },
- );
-});
-
-describe("default palette neutral tokens", () => {
- // Pinned when the BRAND and SYSTEM tokens moved from literals onto the gray,
- // white and alpha scales. That move was meant to keep every value, so a step
- // change that alters one of these colours fails here.
- const expected: Record> = {
- light: {
- "--foreground": "#0a0a0a",
- "--muted-foreground": "#737373",
- "--primary": "#171717",
- "--primary-foreground": "#fafafa",
- "--secondary": "#f5f5f5",
- "--secondary-foreground": "#171717",
- "--accent-foreground": "#0a0a0a",
- "--background": "#fafafa",
- "--card": "#ffffff",
- "--card-foreground": "#0a0a0a",
- "--popover": "#ffffff",
- "--popover-foreground": "#0a0a0a",
- "--muted": "#f5f5f5",
- "--border": "#e5e5e5",
- "--input": "#e5e5e5",
- "--sidebar": "#fafafa",
- "--sidebar-foreground": "#0a0a0a",
- "--sidebar-border": "#e5e5e5",
- "--backdrop": "rgba(0, 0, 0, 0.5)",
- },
- dark: {
- "--foreground": "#fafafa",
- "--muted-foreground": "#a3a3a3",
- "--primary": "#e5e5e5",
- "--primary-foreground": "#171717",
- "--secondary": "#262626",
- "--secondary-foreground": "#fafafa",
- "--accent-foreground": "#fafafa",
- "--background": "#0a0a0a",
- "--card": "#171717",
- "--card-foreground": "#fafafa",
- "--popover": "#262626",
- "--popover-foreground": "#fafafa",
- "--muted": "#262626",
- "--border": "rgba(255, 255, 255, 0.1)",
- "--input": "rgba(255, 255, 255, 0.15)",
- "--sidebar": "#0a0a0a",
- "--sidebar-foreground": "#fafafa",
- "--sidebar-border": "rgba(255, 255, 255, 0.1)",
- "--backdrop": "rgba(0, 0, 0, 0.5)",
- },
- };
-
- describe.each(["light", "dark"] as const)("%s mode", (mode) => {
- const vars = variablesFor(mode);
- it.each(Object.entries(expected[mode]))("%s resolves to %s", (token, value) => {
- expect(parseColor(`var(${token})`, vars)).toEqual(parseColor(value, vars));
- });
- });
-});
diff --git a/packages/core/src/assets/theme-bridge.test.ts b/packages/core/src/assets/theme-bridge.test.ts
index 93f2273ff..e67908c43 100644
--- a/packages/core/src/assets/theme-bridge.test.ts
+++ b/packages/core/src/assets/theme-bridge.test.ts
@@ -69,6 +69,23 @@ describe("theme bridge", () => {
expect(bridgedTokens(SEMANTIC_PREFIX).size).toBe(40);
});
+ it.each([
+ ["--destructive", "--danger-solid"],
+ ["--destructive-foreground", "--danger-contrast"],
+ ])("keeps %s linked to %s for consumer overrides", (alias, target) => {
+ const sheets = [
+ defaultTheme,
+ ...["cream", "bloom"].map((name) => readFileSync(join(here, `themes/${name}.css`), "utf8")),
+ ];
+ const values = sheets.flatMap((css) =>
+ [...css.matchAll(new RegExp(`^\\s*${alias}:\\s*([^;]+);`, "gm"))].map((match) =>
+ match[1].trim(),
+ ),
+ );
+ expect(values.length).toBeGreaterThan(0);
+ expect(new Set(values)).toEqual(new Set([`var(${target})`]));
+ });
+
describe("typography roles", () => {
const slots = ["size", "line-height", "weight", "letter-spacing"] as const;
/** `--type--` variables in the default palette, grouped by role. */
diff --git a/packages/core/tests/browser/consumer.css b/packages/core/tests/browser/consumer.css
new file mode 100644
index 000000000..610ae7614
--- /dev/null
+++ b/packages/core/tests/browser/consumer.css
@@ -0,0 +1,3 @@
+@import "tailwindcss" source(none);
+@import "@tailor-platform/app-shell/styles";
+@source "./";
diff --git a/packages/core/tests/browser/semantic-contrast.test.ts b/packages/core/tests/browser/semantic-contrast.test.ts
new file mode 100644
index 000000000..05f98d39b
--- /dev/null
+++ b/packages/core/tests/browser/semantic-contrast.test.ts
@@ -0,0 +1,144 @@
+import { beforeEach, describe, expect, it } from "vitest";
+import { applyPalette, colour, contrastOn, sample } from "./theme-utils";
+
+/**
+ * Shipped CSS is resolved by Chromium, including aliases, palette overrides and alpha.
+ * Scope: card/background in all palettes and modes. Shell gradients, neutral intent
+ * contrast and warning solid/card contrast remain excluded. Subtle hover's 4.2 floor
+ * is a documented exception, not an AA pass; see decisions/semantic-color-roles.md.
+ */
+const palettes = ["default", "cream", "bloom"] as const;
+const modes = ["light", "dark"] as const;
+const pages = ["--card", "--background"] as const;
+const intents = ["info", "success", "warning", "danger"] as const;
+const alertVariantFor = { info: "info", success: "success", warning: "warning", danger: "error" };
+
+it("measures opaque, transparent and translucent contrast without a CSS parser", () => {
+ expect(contrastOn("black", "white", "white")).toBe(21);
+ expect(contrastOn("rgb(255 255 255 / 0)", "black", "white")).toBe(1);
+ expect(contrastOn("rgb(255 255 255 / 0.5)", "black", "white")).toBeCloseTo(5.317, 2);
+ expect(contrastOn("black", "rgb(255 255 255 / 0.5)", "black")).toBeCloseTo(5.317, 2);
+});
+
+it("rejects an undefined or invalid colour instead of inheriting the body colour", () => {
+ expect(() => colour("var(--missing-test-colour)")).toThrow();
+ expect(() => colour("12px")).toThrow();
+});
+
+describe.each(palettes.flatMap((palette) => modes.map((mode) => [palette, mode] as const)))(
+ "semantic colour contrast (%s, %s)",
+ (palette, mode) => {
+ beforeEach(() => applyPalette(palette, mode));
+ const token = (name: string) => colour(`var(${name})`);
+ const pair = (foreground: string, surface: string, page: string) =>
+ contrastOn(token(foreground), token(surface), token(page));
+
+ it.each(pages)("%s is an opaque base colour", (page) => {
+ expect(sample(token(page))[3]).toBe(255);
+ });
+
+ describe.each(intents)("%s", (intent) => {
+ const role = (name: string) => `--${intent}-${name}`;
+
+ it("keeps solid, solid-hover and contrast opaque", () => {
+ for (const name of ["solid", "solid-hover", "contrast"]) {
+ expect(sample(token(role(name)))[3], role(name)).toBe(255);
+ }
+ });
+
+ it.each(["solid", "solid-hover"])("contrast meets 4.5:1 on %s", (surface) => {
+ expect(pair(role("contrast"), role(surface), "--card")).toBeGreaterThanOrEqual(4.5);
+ });
+
+ it.each(pages)("text meets 4.5:1 on surface over %s", (page) => {
+ expect(pair(role("text"), role("surface"), page)).toBeGreaterThanOrEqual(4.5);
+ });
+
+ it.each(pages)("text meets the 4.2:1 hover floor over %s", (page) => {
+ expect(pair(role("text"), role("surface-hover"), page)).toBeGreaterThanOrEqual(4.2);
+ });
+
+ const variant = alertVariantFor[intent];
+ describe.each(["foreground", "foreground-muted"])("legacy Alert %s", (slot) => {
+ it.each(pages)("meets 4.5:1 on its background over %s", (page) => {
+ expect(
+ pair(`--alert-${variant}-${slot}`, `--alert-${variant}-background`, page),
+ ).toBeGreaterThanOrEqual(4.5);
+ });
+ });
+ });
+
+ it.each([
+ ["--destructive", "--danger-solid"],
+ ["--destructive-foreground", "--danger-contrast"],
+ ])("%s resolves to the same colour as %s", (alias, role) => {
+ expect(token(alias)).toBe(token(role));
+ });
+
+ it.each(pages)("danger solid meets 3:1 against %s for invalid-input borders", (page) => {
+ expect(pair("--danger-solid", page, page)).toBeGreaterThanOrEqual(3);
+ });
+ },
+);
+
+it.each(["cream", "bloom"] as const)("%s changes card and muted in dark mode", async (palette) => {
+ await applyPalette("default", "dark");
+ const base = [colour("var(--card)"), colour("var(--muted)")];
+ await applyPalette(palette, "dark");
+ expect(colour("var(--card)")).not.toBe(base[0]);
+ expect(colour("var(--muted)")).not.toBe(base[1]);
+});
+
+// Pins the neutral-scale refactor's original values, including translucent borders.
+const expected = {
+ light: {
+ "--foreground": "#0a0a0a",
+ "--muted-foreground": "#737373",
+ "--primary": "#171717",
+ "--primary-foreground": "#fafafa",
+ "--secondary": "#f5f5f5",
+ "--secondary-foreground": "#171717",
+ "--accent-foreground": "#0a0a0a",
+ "--background": "#fafafa",
+ "--card": "#ffffff",
+ "--card-foreground": "#0a0a0a",
+ "--popover": "#ffffff",
+ "--popover-foreground": "#0a0a0a",
+ "--muted": "#f5f5f5",
+ "--border": "#e5e5e5",
+ "--input": "#e5e5e5",
+ "--sidebar": "#fafafa",
+ "--sidebar-foreground": "#0a0a0a",
+ "--sidebar-border": "#e5e5e5",
+ "--backdrop": "rgba(0, 0, 0, 0.5)",
+ },
+ dark: {
+ "--foreground": "#fafafa",
+ "--muted-foreground": "#a3a3a3",
+ "--primary": "#e5e5e5",
+ "--primary-foreground": "#171717",
+ "--secondary": "#262626",
+ "--secondary-foreground": "#fafafa",
+ "--accent-foreground": "#fafafa",
+ "--background": "#0a0a0a",
+ "--card": "#171717",
+ "--card-foreground": "#fafafa",
+ "--popover": "#262626",
+ "--popover-foreground": "#fafafa",
+ "--muted": "#262626",
+ "--border": "rgba(255, 255, 255, 0.1)",
+ "--input": "rgba(255, 255, 255, 0.15)",
+ "--sidebar": "#0a0a0a",
+ "--sidebar-foreground": "#fafafa",
+ "--sidebar-border": "rgba(255, 255, 255, 0.1)",
+ "--backdrop": "rgba(0, 0, 0, 0.5)",
+ },
+};
+
+describe.each(modes)("default palette neutral tokens (%s)", (mode) => {
+ beforeEach(() => applyPalette("default", mode));
+
+ it.each(Object.entries(expected[mode]))("%s preserves %s", (token, value) => {
+ expect(colour(`var(${token})`)).toBe(colour(value));
+ });
+});
diff --git a/packages/core/tests/browser/setup.ts b/packages/core/tests/browser/setup.ts
new file mode 100644
index 000000000..e3548672a
--- /dev/null
+++ b/packages/core/tests/browser/setup.ts
@@ -0,0 +1,19 @@
+import { cleanup } from "@testing-library/react";
+import { afterEach, beforeEach } from "vitest";
+import "./consumer.css";
+
+const html = document.documentElement;
+let originalClass: string;
+let originalStyle: string;
+
+beforeEach(() => {
+ originalClass = html.className;
+ originalStyle = html.style.cssText;
+});
+
+afterEach(() => {
+ cleanup();
+ document.querySelectorAll("link[data-test-palette]").forEach((link) => link.remove());
+ html.className = originalClass;
+ html.style.cssText = originalStyle;
+});
diff --git a/packages/core/tests/browser/theme-utils.ts b/packages/core/tests/browser/theme-utils.ts
new file mode 100644
index 000000000..5a564a68f
--- /dev/null
+++ b/packages/core/tests/browser/theme-utils.ts
@@ -0,0 +1,64 @@
+///
+import creamUrl from "@tailor-platform/app-shell/themes/cream?url";
+import bloomUrl from "@tailor-platform/app-shell/themes/bloom?url";
+import { expect } from "vitest";
+
+export async function applyPalette(palette: "default" | "cream" | "bloom", mode: "light" | "dark") {
+ document.querySelectorAll("link[data-test-palette]").forEach((link) => link.remove());
+ document.documentElement.classList.toggle("dark", mode === "dark");
+ if (palette === "default") return;
+
+ const link = document.createElement("link");
+ link.rel = "stylesheet";
+ link.href = palette === "cream" ? creamUrl : bloomUrl;
+ link.dataset.testPalette = palette;
+ await new Promise((resolve, reject) => {
+ link.addEventListener("load", () => resolve(), { once: true });
+ link.addEventListener("error", () => reject(new Error(`Failed to load ${palette} palette`)), {
+ once: true,
+ });
+ document.head.append(link);
+ });
+}
+
+export function colour(value: string): string {
+ const probe = document.createElement("span");
+ probe.style.setProperty("--test-colour", value);
+ probe.style.color = "var(--test-colour)";
+ document.body.append(probe);
+ try {
+ const style = getComputedStyle(probe);
+ expect(CSS.supports("color", style.getPropertyValue("--test-colour")), value).toBe(true);
+ return style.color;
+ } finally {
+ probe.remove();
+ }
+}
+
+// Canvas delegates CSS colour parsing and alpha compositing to the browser.
+// shortcut: 8-bit flat backgrounds only; use rendered checks for gradients or group opacity.
+export function sample(...colours: string[]): Uint8ClampedArray {
+ const canvas = document.createElement("canvas");
+ canvas.width = canvas.height = 1;
+ const ctx = canvas.getContext("2d")!;
+ for (const value of colours) {
+ expect(CSS.supports("color", value), value).toBe(true);
+ ctx.fillStyle = value;
+ ctx.fillRect(0, 0, 1, 1);
+ }
+ return ctx.getImageData(0, 0, 1, 1).data;
+}
+
+function luminance(pixel: Uint8ClampedArray): number {
+ const channels = Array.from(pixel.subarray(0, 3), (channel) => {
+ const s = channel / 255;
+ return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
+ });
+ return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
+}
+
+export function contrastOn(foreground: string, surface: string, page: string): number {
+ const a = luminance(sample(page, surface, foreground));
+ const b = luminance(sample(page, surface));
+ return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
+}
diff --git a/packages/core/tests/browser/theme.test.tsx b/packages/core/tests/browser/theme.test.tsx
new file mode 100644
index 000000000..6b4788bb3
--- /dev/null
+++ b/packages/core/tests/browser/theme.test.tsx
@@ -0,0 +1,170 @@
+import { render } from "@testing-library/react";
+import { Alert, Button, Text } from "@tailor-platform/app-shell";
+import { beforeEach, describe, expect, it } from "vitest";
+import { applyPalette, contrastOn } from "./theme-utils";
+
+const html = document.documentElement;
+
+const palettes = [
+ {
+ name: "default",
+ light: { card: "rgb(255, 255, 255)", text: "rgb(10, 10, 10)", primary: "rgb(23, 23, 23)" },
+ dark: { card: "rgb(23, 23, 23)", text: "rgb(250, 250, 250)", primary: "rgb(229, 229, 229)" },
+ },
+ {
+ name: "cream",
+ light: { card: "rgb(255, 255, 255)", text: "rgb(16, 18, 43)", primary: "rgb(83, 90, 232)" },
+ dark: { card: "rgb(22, 19, 17)", text: "rgb(247, 244, 238)", primary: "rgb(131, 138, 247)" },
+ },
+ {
+ name: "bloom",
+ light: { card: "rgb(255, 255, 255)", text: "rgb(16, 18, 43)", primary: "rgb(83, 90, 232)" },
+ dark: { card: "rgb(26, 28, 42)", text: "rgb(248, 249, 252)", primary: "rgb(131, 138, 247)" },
+ },
+] as const;
+
+for (const palette of palettes) {
+ describe.each(["light", "dark"] as const)(`${palette.name} / %s`, (mode) => {
+ beforeEach(() => applyPalette(palette.name, mode));
+
+ it("applies the published CSS to internal components and consumer utilities", () => {
+ const view = render(
+ <>
+
+
+ Label
+
+