From 26869f455a414db22c834561f06dd4e158327f7b Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Tue, 27 Oct 2026 12:33:31 +0900 Subject: [PATCH 1/2] test(theme): move style contracts to Vitest Browser Mode --- .agents/skills/quality-check/SKILL.md | 19 +- .github/actions/ci/action.yaml | 6 + decisions/typography-roles.md | 2 +- packages/core/package.json | 3 + .../core/src/assets/semantic-contrast.test.ts | 258 ------------------ packages/core/src/assets/theme-bridge.test.ts | 17 ++ packages/core/tests/browser/consumer.css | 3 + .../tests/browser/semantic-contrast.test.ts | 144 ++++++++++ packages/core/tests/browser/setup.ts | 19 ++ packages/core/tests/browser/theme-utils.ts | 64 +++++ packages/core/tests/browser/theme.test.tsx | 170 ++++++++++++ .../core/tests/typography-compile.test.ts | 33 --- packages/core/vitest.browser.config.ts | 19 ++ packages/core/vitest.config.ts | 3 +- pnpm-lock.yaml | 140 +++++++++- 15 files changed, 601 insertions(+), 299 deletions(-) delete mode 100644 packages/core/src/assets/semantic-contrast.test.ts create mode 100644 packages/core/tests/browser/consumer.css create mode 100644 packages/core/tests/browser/semantic-contrast.test.ts create mode 100644 packages/core/tests/browser/setup.ts create mode 100644 packages/core/tests/browser/theme-utils.ts create mode 100644 packages/core/tests/browser/theme.test.tsx create mode 100644 packages/core/vitest.browser.config.ts 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 b52342cee..ff4204430 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": "20.14.5", + "playwright": "1.63.0", "postcss": "8.5.30", "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 + +
+ Consumer +
+ , + ); + const expected = palette[mode]; + const button = getComputedStyle(view.getByTestId("button")); + expect(button.backgroundColor).toBe(expected.primary); + expect(button.height).toBe("36px"); + const consumer = getComputedStyle(view.getByTestId("consumer")); + expect(consumer.backgroundColor).toBe(expected.card); + expect(consumer.color).toBe(expected.text); + for (const id of ["label", "consumer"]) { + const style = getComputedStyle(view.getByTestId(id)); + expect(style.fontSize).toBe("14px"); + expect(style.lineHeight).toBe("20px"); + expect(style.fontWeight).toBe("500"); + } + }); + + it("preserves the documented heading cascade and consumer modifiers", () => { + const view = render( + <> + + Heading + + } + data-testid="div" + > + Body with consumer modifiers + + , + ); + const heading = getComputedStyle(view.getByTestId("heading")); + expect(heading.fontSize).toBe("18px"); + expect(parseFloat(heading.lineHeight)).toBeCloseTo(palette.name === "default" ? 18 : 21.6); + expect(parseFloat(heading.letterSpacing) || 0).toBeCloseTo( + palette.name === "default" ? 0 : -0.54, + ); + const div = getComputedStyle(view.getByTestId("div")); + expect(div.lineHeight).toBe("14px"); + expect(div.fontWeight).toBe("600"); + expect(parseFloat(div.letterSpacing)).toBeCloseTo(0.35); + }); + + it.each(["info", "success", "warning", "error"] as const)( + "keeps %s Alert text readable on a card", + (variant) => { + const view = render( +
+ + Status + Details + +
, + ); + const card = getComputedStyle(view.getByTestId("card")); + const alert = getComputedStyle(view.getByTestId("alert")); + const description = getComputedStyle(view.getByTestId("description")); + expect( + contrastOn(alert.color, alert.backgroundColor, card.backgroundColor), + ).toBeGreaterThanOrEqual(4.5); + expect( + contrastOn(description.color, alert.backgroundColor, card.backgroundColor), + ).toBeGreaterThanOrEqual(4.5); + }, + ); + }); +} + +describe.each(["light", "dark"] as const)("consumer overrides / %s", (mode) => { + beforeEach(() => applyPalette("default", mode)); + + it("resolves root token overrides in both prefixed and unprefixed utilities", () => { + html.style.setProperty("--primary", "rgb(12 34 56)"); + html.style.setProperty("--type-label-md-size", "18px"); + html.style.setProperty("--type-label-md-line-height", "1.5"); + const view = render( + <> + + + Label + +
+ Consumer +
+ , + ); + for (const id of ["button", "consumer"]) { + expect(getComputedStyle(view.getByTestId(id)).backgroundColor).toBe("rgb(12, 34, 56)"); + } + for (const id of ["label", "consumer"]) { + const style = getComputedStyle(view.getByTestId(id)); + expect(style.fontSize).toBe("18px"); + expect(style.lineHeight).toBe("27px"); + } + }); +}); + +it("resolves deprecated typography overrides at element scope before the new name", () => { + html.style.setProperty("--type-body-md-size", "18px"); + const view = render( +
+ Body +
, + ); + const scope = view.getByTestId("scope"); + const text = view.getByTestId("text"); + expect(getComputedStyle(text).fontSize).toBe("18px"); + scope.style.setProperty("--app-shell-type-body-md-size", "22px"); + expect(getComputedStyle(text).fontSize).toBe("22px"); + scope.style.removeProperty("--app-shell-type-body-md-size"); + expect(getComputedStyle(text).fontSize).toBe("18px"); +}); + +it("re-resolves semantic roles in a nested dark wrapper without changing its light sibling", () => { + const view = render( + <> + + Light + +
+ + Dark + +
+ , + ); + expect(getComputedStyle(view.getByTestId("light")).color).toBe("rgb(206, 12, 23)"); + expect(getComputedStyle(view.getByTestId("dark")).color).toBe("rgb(255, 144, 131)"); +}); diff --git a/packages/core/tests/typography-compile.test.ts b/packages/core/tests/typography-compile.test.ts index 810c11ffa..1e1438c44 100644 --- a/packages/core/tests/typography-compile.test.ts +++ b/packages/core/tests/typography-compile.test.ts @@ -78,39 +78,6 @@ describe("typography role utilities", () => { }, ); - it("lets stock leading-* utilities override the role line-height", async () => { - // `leading-none` sets --tw-leading, which the role reads before its own variable. - const css = await compile('@import "tailwindcss" source(none);', [ - "text-label-md", - "leading-none", - ]); - expect(declarationsOf(css, ".leading-none").get("--tw-leading")).toBe("1"); - expect(declarationsOf(css, ".text-label-md").get("line-height")).toMatch(/^var\(--tw-leading,/); - }); - - it("lets a consumer modifier override a role from AppShell's prefixed build", async () => { - // `` puts AppShell's `astw:text-*` and the - // consumer's unprefixed modifier on one element. The prefixed sheet loads later and - // wins the cascade, so the modifier works only because both builds share the - // unprefixed `--tw-*` variables. - const library = await compile('@import "tailwindcss" prefix(astw) source(none);', [ - "astw:text-heading-md", - ]); - const consumer = await compile('@import "tailwindcss" source(none);', [ - "leading-none", - "font-semibold", - "tracking-wide", - ]); - - const role = declarationsOf(library, ".astw\\:text-heading-md"); - expect(role.get("line-height")).toMatch(/^var\(--tw-leading,/); - expect(role.get("font-weight")).toMatch(/^var\(--tw-font-weight,/); - expect(role.get("letter-spacing")).toMatch(/^var\(--tw-tracking,/); - expect(declarationsOf(consumer, ".leading-none").get("--tw-leading")).toBe("1"); - expect(declarationsOf(consumer, ".font-semibold").has("--tw-font-weight")).toBe(true); - expect(declarationsOf(consumer, ".tracking-wide").has("--tw-tracking")).toBe(true); - }); - it("emits every role defined in the default palette", async () => { const roles = [ ...new Set([...defaultTheme.matchAll(/^\s*--type-([a-z-]+)-size:/gm)].map((m) => m[1])), diff --git a/packages/core/vitest.browser.config.ts b/packages/core/vitest.browser.config.ts new file mode 100644 index 000000000..813f0173d --- /dev/null +++ b/packages/core/vitest.browser.config.ts @@ -0,0 +1,19 @@ +import react from "@vitejs/plugin-react"; +import { playwright } from "@vitest/browser-playwright"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [react()], + test: { + name: "styles", + include: ["tests/browser/**/*.test.{ts,tsx}"], + setupFiles: ["./tests/browser/setup.ts"], + browser: { + enabled: true, + provider: playwright(), + instances: [{ browser: "chromium" }], + headless: true, + screenshotFailures: false, + }, + }, +}); diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts index 1d6721344..9a8fb3a3c 100644 --- a/packages/core/vitest.config.ts +++ b/packages/core/vitest.config.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import { defineConfig } from "vitest/config"; +import { configDefaults, defineConfig } from "vitest/config"; const coverageEnabled = process.env.CI === "true"; const projectDir = import.meta.dirname; @@ -10,6 +10,7 @@ export default defineConfig({ }, test: { environment: "happy-dom", + exclude: [...configDefaults.exclude, "dist/**", "tests/browser/**"], typecheck: { enabled: true, }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d96ead7c6..5a077641a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -466,12 +466,18 @@ importers: '@vitejs/plugin-react': specifier: 'catalog:' version: 6.1.0(vite@8.2.2(@types/node@25.9.8)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + '@vitest/browser-playwright': + specifier: 4.1.11 + version: 4.1.11(playwright@1.63.0)(vite@8.2.2(@types/node@25.9.8)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(vitest@4.1.11) '@vitest/coverage-v8': specifier: 4.1.11 - version: 4.1.11(vitest@4.1.11) + version: 4.1.11(@vitest/browser@4.1.11)(vitest@4.1.11) happy-dom: specifier: 20.14.5 version: 20.14.5 + playwright: + specifier: 1.63.0 + version: 1.63.0 postcss: specifier: 8.5.30 version: 8.5.30 @@ -501,7 +507,7 @@ importers: version: 5.0.3(@microsoft/api-extractor@7.58.12(@types/node@25.9.8))(esbuild@0.28.1)(rolldown@1.2.6)(rollup@4.62.3)(supports-color@8.1.1)(typescript@5.9.3)(vite@8.2.2(@types/node@25.9.8)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) vitest: specifier: 'catalog:' - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@25.9.8)(@vitest/coverage-v8@4.1.11)(happy-dom@20.14.5)(vite@8.2.2(@types/node@25.9.8)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@25.9.8)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(happy-dom@20.14.5)(vite@8.2.2(@types/node@25.9.8)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) packages/docs-kit: dependencies: @@ -720,6 +726,9 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@blazediff/core@1.9.1': + resolution: {integrity: sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==} + '@bufbuild/cel-spec@0.4.0': resolution: {integrity: sha512-dUS6f2fNt6KEumsYGE7YFxERZE5ZuyME1hQmGjtO8tkZhR6ow6/ne3v4Gik9cfdb9lSLK3AJ+vDxCdGWmDbWvA==} peerDependencies: @@ -2132,6 +2141,9 @@ packages: '@inquirer/prompts': optional: true + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@quansync/fs@1.0.0': resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} @@ -2969,6 +2981,17 @@ packages: oxc-transform-react: optional: true + '@vitest/browser-playwright@4.1.11': + resolution: {integrity: sha512-riLBxPqwnJ0lWs2DN2WeUfYeKLoAjbP2Xx8cLQdSddzMi20sksIa6K2mPz79DyMZKKVKH2ksOC2yJvtNcZg8cg==} + peerDependencies: + playwright: '*' + vitest: 4.1.11 + + '@vitest/browser@4.1.11': + resolution: {integrity: sha512-bwMovvAeuTFOK5kIFevw4VEf+1gVEICv4SYK4k3knJOxl6b1zEWud8mYKD73e1B0odAn174h1MofURy2TPWf3w==} + peerDependencies: + vitest: 4.1.11 + '@vitest/coverage-v8@4.1.11': resolution: {integrity: sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==} peerDependencies: @@ -4362,6 +4385,10 @@ packages: moo@0.5.3: resolution: {integrity: sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==} + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -4538,6 +4565,10 @@ packages: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} + pngjs@7.0.0: + resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} + engines: {node: '>=14.19.0'} + politty@0.11.6: resolution: {integrity: sha512-yX2vsYOeeY1+Dygir/7EOnVYJ4emByfWaegN/aYj5+t2yB1Q3ctSwEhHUzu6VK+1csXSGTJRjpJtI3ec0Zd9oA==} engines: {node: '>=20.12.0 <21.0.0 || >=21.7.0'} @@ -4815,6 +4846,10 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} @@ -4957,6 +4992,10 @@ packages: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -5475,6 +5514,8 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@blazediff/core@1.9.1': {} + '@bufbuild/cel-spec@0.4.0(@bufbuild/protobuf@2.13.0)': dependencies: '@bufbuild/protobuf': 2.13.0 @@ -6670,6 +6711,8 @@ snapshots: '@clack/prompts': 1.7.0 '@inquirer/prompts': 8.6.0(@types/node@25.9.8) + '@polka/url@1.0.0-next.29': {} + '@quansync/fs@1.0.0': dependencies: quansync: 1.0.0 @@ -7148,7 +7191,7 @@ snapshots: zod: 4.4.3 optionalDependencies: vite: 8.2.2(@types/node@25.9.8)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) - vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@25.9.8)(@vitest/coverage-v8@4.1.11)(happy-dom@20.14.5)(vite@8.2.2(@types/node@25.9.8)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@25.9.8)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(happy-dom@20.14.5)(vite@8.2.2(@types/node@25.9.8)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) transitivePeerDependencies: - '@clack/prompts' - '@types/node' @@ -7458,6 +7501,52 @@ snapshots: '@rolldown/pluginutils': 1.0.1 vite: 8.2.2(@types/node@25.9.8)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) + '@vitest/browser-playwright@4.1.11(playwright@1.63.0)(vite@8.2.2(@types/node@25.9.8)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(vitest@4.1.11)': + dependencies: + '@vitest/browser': 4.1.11(vite@8.2.2(@types/node@25.9.8)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(vitest@4.1.11) + '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@25.9.8)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + playwright: 1.63.0 + tinyrainbow: 3.1.0 + vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@25.9.8)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(happy-dom@20.14.5)(vite@8.2.2(@types/node@25.9.8)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/browser@4.1.11(vite@8.2.2(@types/node@25.9.8)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(vitest@4.1.11)': + dependencies: + '@blazediff/core': 1.9.1 + '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@25.9.8)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + '@vitest/utils': 4.1.11 + magic-string: 0.30.21 + pngjs: 7.0.0 + sirv: 3.0.2 + tinyrainbow: 3.1.0 + vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@25.9.8)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(happy-dom@20.14.5)(vite@8.2.2(@types/node@25.9.8)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/coverage-v8@4.1.11(@vitest/browser@4.1.11)(vitest@4.1.11)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.11 + ast-v8-to-istanbul: 1.0.5 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.4 + obug: 2.1.4 + std-env: 4.2.0 + tinyrainbow: 3.1.0 + vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@25.9.8)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(happy-dom@20.14.5)(vite@8.2.2(@types/node@25.9.8)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + optionalDependencies: + '@vitest/browser': 4.1.11(vite@8.2.2(@types/node@25.9.8)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(vitest@4.1.11) + '@vitest/coverage-v8@4.1.11(vitest@4.1.11)': dependencies: '@bcoe/v8-coverage': 1.0.2 @@ -7471,6 +7560,7 @@ snapshots: std-env: 4.2.0 tinyrainbow: 3.1.0 vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@25.9.8)(@vitest/coverage-v8@4.1.11)(happy-dom@20.14.5)(vite@8.2.2(@types/node@25.9.8)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + optional: true '@vitest/expect@4.1.11': dependencies: @@ -8936,6 +9026,8 @@ snapshots: moo@0.5.3: {} + mrmime@2.0.1: {} + ms@2.1.3: {} mute-stream@3.0.0: {} @@ -9187,6 +9279,8 @@ snapshots: pluralize@8.0.0: {} + pngjs@7.0.0: {} + politty@0.11.6(@clack/prompts@1.7.0)(@inquirer/prompts@8.5.2(@types/node@25.9.5))(zod@4.4.3): dependencies: yaml: 2.9.1 @@ -9578,6 +9672,12 @@ snapshots: signal-exit@4.1.0: {} + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + sisteransi@1.0.5: {} sonner@2.0.8(@types/react@19.2.13)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): @@ -9686,6 +9786,8 @@ snapshots: tinyrainbow@3.1.0: {} + totalist@3.0.1: {} + tree-kill@1.2.2: {} trim-lines@3.0.1: {} @@ -9988,6 +10090,37 @@ snapshots: transitivePeerDependencies: - msw + vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@25.9.8)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(happy-dom@20.14.5)(vite@8.2.2(@types/node@25.9.8)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@25.9.8)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.7 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.2.2(@types/node@25.9.8)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 25.9.8 + '@vitest/browser-playwright': 4.1.11(playwright@1.63.0)(vite@8.2.2(@types/node@25.9.8)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(vitest@4.1.11) + '@vitest/coverage-v8': 4.1.11(@vitest/browser@4.1.11)(vitest@4.1.11) + happy-dom: 20.14.5 + transitivePeerDependencies: + - msw + vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@25.9.8)(@vitest/coverage-v8@4.1.11)(happy-dom@20.14.5)(vite@8.2.2(@types/node@25.9.8)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 @@ -10017,6 +10150,7 @@ snapshots: happy-dom: 20.14.5 transitivePeerDependencies: - msw + optional: true vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@25.9.8)(@vitest/coverage-v8@4.1.11)(happy-dom@20.9.0)(vite@8.2.2(@types/node@25.9.8)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)): dependencies: From 1937da4b09e40985d4ec5f1fee5fa42cfe2817ab Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Oct 2026 03:50:11 +0000 Subject: [PATCH 2/2] fix CI for browser style contracts Co-authored-by: IzumiSy <982850+IzumiSy@users.noreply.github.com> --- .github/workflows/dependency-changeset.yaml | 2 +- packages/core/tsconfig.json | 3 ++- packages/core/vitest.browser.config.ts | 3 +++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/dependency-changeset.yaml b/.github/workflows/dependency-changeset.yaml index 1ca1ce51b..9ade2e9f3 100644 --- a/.github/workflows/dependency-changeset.yaml +++ b/.github/workflows/dependency-changeset.yaml @@ -18,4 +18,4 @@ jobs: with: fetch-depth: 0 persist-credentials: false - - uses: IzumiSy/deps2changesets@1e89736c02d7e4ca62a0ae8e5324ebc62b22d6e2 # main + - uses: IzumiSy/deps2changesets@af90a6ab831c1e3560fd568b5d6615be68f29bc8 # main diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index f3eed0b6f..fabae81da 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -22,5 +22,6 @@ "noImplicitReturns": true, "noFallthroughCasesInSwitch": true }, - "include": ["."] + "include": ["."], + "exclude": ["tests/browser"] } diff --git a/packages/core/vitest.browser.config.ts b/packages/core/vitest.browser.config.ts index 813f0173d..caf126b1b 100644 --- a/packages/core/vitest.browser.config.ts +++ b/packages/core/vitest.browser.config.ts @@ -4,6 +4,9 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ plugins: [react()], + optimizeDeps: { + include: ["@tailor-platform/app-shell", "@testing-library/react"], + }, test: { name: "styles", include: ["tests/browser/**/*.test.{ts,tsx}"],