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/.changeset/semantic-contrast-thresholds.md b/.changeset/semantic-contrast-thresholds.md new file mode 100644 index 000000000..e8422c442 --- /dev/null +++ b/.changeset/semantic-contrast-thresholds.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/app-shell": patch +--- + +Document the contrast thresholds for the semantic colour roles: 4.5:1 for text and 3:1 for non-text. The styling guide now states that the hue roles have the same values in every shipped palette, and lists the known exceptions: hover text, status content directly on the `bloom` light shell gradient, and the `warning` solid and indicator against a light card. The contrast test now checks the `cream` and `bloom` palettes as well as `default`. No colour values change. 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/semantic-color-roles.md b/decisions/semantic-color-roles.md index 1d93ae570..0c5c8063b 100644 --- a/decisions/semantic-color-roles.md +++ b/decisions/semantic-color-roles.md @@ -131,3 +131,32 @@ The default palette's surfaces and brand tokens read scale steps. For the defaul - New role `--backdrop`, `var(--primitive-black-a7)` (50%) in both modes, bridged as `bg-backdrop`. `Dialog`, `Sheet` and the CsvImporter drawer use it instead of `bg-black/50`; the value is the same. The name follows the CSS `::backdrop` pseudo-element and the Base UI `Backdrop` part. It is not `overlay`, because `--z-overlay` already names the modal layer, which holds both the backdrop and the popup. - `--accent`, the shadows and the chart colours stay literal. They have no matching step. `--destructive` reads `--danger-solid` (see Update 2026-10-06). - `cream`, `bloom` and `_template.css` keep their own literal values. The header comments now list `--backdrop` among the inherited tokens. + +## Update 2026-10-24: palettes, literal values and contrast thresholds + +Three open points from the remaining-work issue ([platform-planning#2208](https://github.com/tailor-inc/platform-planning/issues/2208)) and the contrast issue ([platform-planning#1892](https://github.com/tailor-inc/platform-planning/issues/1892)) are decided. No colour value changes. + +### Status colours are the same in every palette + +`cream` and `bloom` do not set the hue roles, and they will not. The info, success, warning and danger roles have the same values in every shipped palette. A palette changes brand and surface colours; a status colour keeps one meaning across products. Material Design 3 treats error the same way: it calls error a static colour that does not change even in dynamic colour schemes. The neutral roles still follow the palette, because they read the system tokens. A palette that needs other status colours overrides the roles (`--{intent}-{role}`), as `_template.css` says. + +### Literal values stay literal + +`--accent`, `--chart-1` to `--chart-5` and the `--semantic-shadow-*` tokens keep literal values. `DocumentProgressCard` keeps its Tailwind palette colours. + +- `--accent` (light `rgb(237, 237, 237)`, dark `rgb(48, 48, 48)`) sits between two gray steps. Moving it to a step changes the hover and selected colour, so it waits for the palette work with the Theme Generator. +- The chart colours and the `DocumentProgressCard` colours are categorical. They tell series apart and carry no status. The `DocumentProgressCard` `color` prop takes colour names (`indigo`, `pink`, `green` and so on), not intents. The semantic roles do not fit them, and the scales have no steps for most of their hues. A shared categorical palette, if one is needed, is a separate axis. +- A shadow is a composite value (offsets, blur and colour), not one colour, so it has no scale step to read. + +### Contrast thresholds + +AppShell's thresholds are 4.5:1 for text (WCAG 1.4.3) and 3:1 for non-text (WCAG 1.4.11). `tests/browser/semantic-contrast.test.ts` now runs the same checks for `default`, `cream` and `bloom`, in light and dark mode, on `--card` and `--background`. The pairs and the hover floor are unchanged, and all three palettes pass. The styling guide states the thresholds and the exceptions: + +- `text` on `surface-hover` stays at the 4.2:1 floor. Choosing another step is a visible change for a later PR. +- In `cream` and `bloom`, the main content area is transparent over the shell gradient. At the top of the bloom light gradient (55% of `--shell-gradient-base`), `text` on `surface` is 4.49:1 for success and 4.48:1 for danger. cream light is 4.68:1 or more. The figure under "Not guaranteed" (4.16:1 over `#efe8ff`) used `--shell-gradient-base` itself, which the gradient covers. +- `warning` `solid` and `indicator` are 2.15:1 against a light card. They are used with a label. +- The neutral intent is not checked. + +This supersedes "The `cream` and `bloom` palettes and their tinted shell backgrounds" under "Not guaranteed". + +**How the checks run.** The contrast checks run in Chromium with Vitest Browser Mode (#652). The browser resolves the built stylesheet, including aliases, palette overrides, inheritance and alpha, so the tests check the colours that consumers get, and the tests no longer need their own CSS parser. Structural checks (bridge completeness, alias chains and the typography role declarations) stay in the Node suite, because they are fast and do not need a browser. CI installs Chromium and runs `vitest.browser.config.ts` after the unit tests. `pnpm test` does not run the browser tests; run `pnpm --filter @tailor-platform/app-shell test:browser` locally. 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/docs-manifest.json b/docs-manifest.json index 032f95a3f..2743f0d6e 100644 --- a/docs-manifest.json +++ b/docs-manifest.json @@ -1381,9 +1381,9 @@ "symbols": [], "hashes": { "typeSurface": null, - "outline": "052cc2c234995a16", + "outline": "9f6a42c42dd3f54f", "snapshot": null, - "outputMd": "755e362908623d2c", + "outputMd": "7ee0130a20b88310", "examples": "4d19f4f688e0f0ac" } }, @@ -1770,7 +1770,7 @@ "packages/core/skills/app-shell-patterns/references/concepts/modules-and-resources.md": "bf15b5460f22d165", "packages/core/skills/app-shell-patterns/references/concepts/routing-navigation.md": "8034c7f885994b29", "packages/core/skills/app-shell-patterns/references/concepts/sidebar-navigation.md": "5a3c8bac248061f9", - "packages/core/skills/app-shell-patterns/references/concepts/styling-theming.md": "755e362908623d2c", + "packages/core/skills/app-shell-patterns/references/concepts/styling-theming.md": "7ee0130a20b88310", "packages/core/skills/app-shell-patterns/references/components/action-panel.md": "dc861082aa9610af", "packages/core/skills/app-shell-patterns/references/components/activity-card.md": "4f386e49380fb0b0", "packages/core/skills/app-shell-patterns/references/components/ai-chat.md": "824d1aa36bb7f194", diff --git a/docs-src/concepts/styling-theming.docs.outline.md b/docs-src/concepts/styling-theming.docs.outline.md index 80fed2bb8..f28f9d2bf 100644 --- a/docs-src/concepts/styling-theming.docs.outline.md +++ b/docs-src/concepts/styling-theming.docs.outline.md @@ -200,7 +200,14 @@ Each intent has eight roles. The intents are `info`, `success`, `warning`, `dang Paid ``` -In the default palette, `text` on `surface` and `contrast` on `solid` and `solid-hover` are at least 4.5:1 for the four hue intents, in light and dark mode, on `--card` and `--background`. A unit test checks this from the shipped CSS. `text` on `surface-hover` is held at 4.2:1 by the same test; it is below 4.5:1 for `danger` and `success` in light mode. The test does not cover the `cream` and `bloom` palettes or the `neutral` intent. +The hue roles have the same values in every shipped palette. `cream` and `bloom` change brand and surface colors, not status colors. The `neutral` roles follow the palette, because they read the system tokens. + +**Contrast.** AppShell's thresholds are 4.5:1 for text (WCAG 1.4.3) and 3:1 for non-text (WCAG 1.4.11). In every shipped palette (`default`, `cream`, `bloom`), in light and dark mode, on `--card` and `--background`, `text` on `surface` and `contrast` on `solid` and `solid-hover` are at least 4.5:1 for the four hue intents, and `danger` `solid` (the invalid-input border) is at least 3:1. A browser test checks this in Chromium against the built stylesheet. Known exceptions: + +- `text` on `surface-hover` is below 4.5:1 for `danger` and `success` in light mode. The test holds it at 4.2:1 or more. +- In `cream` and `bloom`, the main content area is transparent over the shell gradient. At the top of the `bloom` light gradient, `text` on `surface` is 4.49:1 for `success` and 4.48:1 for `danger`. Put status content on a card when it needs to pass. +- `warning` `solid` and `indicator` are 2.15:1 against a light card. Use them with a label, not as the only sign of a state. +- The `neutral` intent reads the system tokens and is not checked. Most `surface` and `border` values are translucent tints, so an opacity modifier compounds rather than replaces — `bg-info-surface/50` halves the tint's alpha instead of setting it to 50%. Some light-mode values are opaque instead of translucent: the `warning` surface, surface-hover and border, the `info` border and the `danger` surface-hover. They do not blend with a tinted parent. All dark-mode values are translucent. @@ -210,7 +217,7 @@ Most `surface` and `border` values are translucent tints, so an opacity modifier `--sidebar`, `--sidebar-foreground`, `--sidebar-border`, `--sidebar-primary(-foreground)`, `--sidebar-accent(-foreground)`, `--sidebar-ring` → `bg-sidebar`, `text-sidebar-foreground`, and so on. These let a palette tint the shell independently of page content. -`--chart-1` … `--chart-5` → `bg-chart-1`, `text-chart-1`, `fill-chart-1`. Use them in order for categorical series. +`--chart-1` … `--chart-5` → `bg-chart-1`, `text-chart-1`, `fill-chart-1`. Use them in order for categorical series. They tell series apart and carry no status, so they are not semantic roles and have literal values. #### Alerts diff --git a/docs/concepts/styling-theming.md b/docs/concepts/styling-theming.md index aeadbf237..d4a705948 100644 --- a/docs/concepts/styling-theming.md +++ b/docs/concepts/styling-theming.md @@ -217,7 +217,14 @@ Each intent has eight roles. The intents are `info`, `success`, `warning`, `dang Paid ``` -In the default palette, `text` on `surface` and `contrast` on `solid` and `solid-hover` are at least 4.5:1 for the four hue intents, in light and dark mode, on `--card` and `--background`. A unit test checks this from the shipped CSS. `text` on `surface-hover` is held at 4.2:1 by the same test; it is below 4.5:1 for `danger` and `success` in light mode. The test does not cover the `cream` and `bloom` palettes or the `neutral` intent. +The hue roles have the same values in every shipped palette. `cream` and `bloom` change brand and surface colors, not status colors. The `neutral` roles follow the palette, because they read the system tokens. + +**Contrast.** AppShell's thresholds are 4.5:1 for text (WCAG 1.4.3) and 3:1 for non-text (WCAG 1.4.11). In every shipped palette (`default`, `cream`, `bloom`), in light and dark mode, on `--card` and `--background`, `text` on `surface` and `contrast` on `solid` and `solid-hover` are at least 4.5:1 for the four hue intents, and `danger` `solid` (the invalid-input border) is at least 3:1. A browser test checks this in Chromium against the built stylesheet. Known exceptions: + +- `text` on `surface-hover` is below 4.5:1 for `danger` and `success` in light mode. The test holds it at 4.2:1 or more. +- In `cream` and `bloom`, the main content area is transparent over the shell gradient. At the top of the `bloom` light gradient, `text` on `surface` is 4.49:1 for `success` and 4.48:1 for `danger`. Put status content on a card when it needs to pass. +- `warning` `solid` and `indicator` are 2.15:1 against a light card. Use them with a label, not as the only sign of a state. +- The `neutral` intent reads the system tokens and is not checked. Most `surface` and `border` values are translucent tints, so an opacity modifier compounds rather than replaces — `bg-info-surface/50` halves the tint's alpha instead of setting it to 50%. Some light-mode values are opaque instead of translucent: the `warning` surface, surface-hover and border, the `info` border and the `danger` surface-hover. They do not blend with a tinted parent. All dark-mode values are translucent. @@ -227,7 +234,7 @@ Most `surface` and `border` values are translucent tints, so an opacity modifier `--sidebar`, `--sidebar-foreground`, `--sidebar-border`, `--sidebar-primary(-foreground)`, `--sidebar-accent(-foreground)`, `--sidebar-ring` → `bg-sidebar`, `text-sidebar-foreground`, and so on. These let a palette tint the shell independently of page content. -`--chart-1` … `--chart-5` → `bg-chart-1`, `text-chart-1`, `fill-chart-1`. Use them in order for categorical series. +`--chart-1` … `--chart-5` → `bg-chart-1`, `text-chart-1`, `fill-chart-1`. Use them in order for categorical series. They tell series apart and carry no status, so they are not semantic roles and have literal values. #### Alerts 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 d5a667978..000000000 --- a/packages/core/src/assets/semantic-contrast.test.ts +++ /dev/null @@ -1,227 +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 - * `themes/default.css` itself. Nothing is copied into the test, so a change to - * a hue step or an alias that breaks a pair fails here. - * - * Scope of the guarantee: the default state of each pair, on the default - * palette's `--card` and `--background`. 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; - * - branded palettes (cream, bloom) and their tinted shell backgrounds; - * - 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 css = readFileSync(join(here, "themes/default.css"), "utf8").replace(/\/\*[\s\S]*?\*\//g, ""); - -type Rgba = { r: number; g: number; b: number; a: number }; -type Mode = "light" | "dark"; - -/** Custom properties in effect on for a mode, applying rules in source order. */ -function variablesFor(mode: Mode): Map { - const vars = new Map(); - for (const rule of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) { - const selectors = rule[1].split(",").map((s) => s.trim()); - // matches both `:root` and `.dark`; light matches `:root` only. - const applies = selectors.some((s) => s === ":root" || (mode === "dark" && s === ".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 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(modes)("semantic colour contrast (%s)", (mode) => { - const vars = variablesFor(mode); - 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("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/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 new file mode 100644 index 000000000..caf126b1b --- /dev/null +++ b/packages/core/vitest.browser.config.ts @@ -0,0 +1,22 @@ +import react from "@vitejs/plugin-react"; +import { playwright } from "@vitest/browser-playwright"; +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}"], + 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 cf6b084b5..821219830 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -469,12 +469,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: 21.0.4 version: 21.0.4 + playwright: + specifier: 1.63.0 + version: 1.63.0 postcss: specifier: 8.5.31 version: 8.5.31 @@ -504,7 +510,7 @@ importers: version: 5.1.2(@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@21.0.4)(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@21.0.4)(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: @@ -532,7 +538,7 @@ importers: version: 5.9.3 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.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)) + 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.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)) packages/eslint-plugin: devDependencies: @@ -547,7 +553,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@25.9.6)(@vitest/coverage-v8@4.1.11)(happy-dom@20.9.0)(vite@8.2.2(@types/node@25.9.6)(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.6)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(happy-dom@20.9.0)(vite@8.2.2(@types/node@25.9.6)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) packages/sdk-plugin: devDependencies: @@ -593,7 +599,7 @@ importers: version: 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.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)) + 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.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)) packages: @@ -723,6 +729,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: @@ -2137,6 +2146,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==} @@ -2974,6 +2986,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: @@ -4370,6 +4393,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==} @@ -4546,6 +4573,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'} @@ -4823,6 +4854,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==} @@ -4965,6 +5000,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 @@ -5483,6 +5522,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 @@ -6678,6 +6719,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 @@ -7156,7 +7199,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@21.0.4)(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@21.0.4)(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' @@ -7466,7 +7509,69 @@ 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/coverage-v8@4.1.11(vitest@4.1.11)': + '@vitest/browser-playwright@4.1.11(playwright@1.63.0)(vite@8.2.2(@types/node@25.9.6)(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.6)(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.6)(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.6)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(happy-dom@20.9.0)(vite@8.2.2(@types/node@25.9.6)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + optional: true + + '@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@21.0.4)(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.6)(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.6)(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.6)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(happy-dom@20.9.0)(vite@8.2.2(@types/node@25.9.6)(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 + optional: true + + '@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@21.0.4)(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 @@ -7478,7 +7583,9 @@ snapshots: 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/coverage-v8@4.1.11)(happy-dom@21.0.4)(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@21.0.4)(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/expect@4.1.11': dependencies: @@ -8946,6 +9053,8 @@ snapshots: moo@0.5.3: {} + mrmime@2.0.1: {} + ms@2.1.3: {} mute-stream@3.0.0: {} @@ -9197,6 +9306,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 @@ -9588,6 +9699,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): @@ -9696,6 +9813,8 @@ snapshots: tinyrainbow@3.1.0: {} + totalist@3.0.1: {} + tree-kill@1.2.2: {} trim-lines@3.0.1: {} @@ -9969,7 +10088,7 @@ snapshots: transitivePeerDependencies: - msw - vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@25.9.6)(@vitest/coverage-v8@4.1.11)(happy-dom@20.9.0)(vite@8.2.2(@types/node@25.9.6)(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.6)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(happy-dom@20.9.0)(vite@8.2.2(@types/node@25.9.6)(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.6)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) @@ -9994,12 +10113,13 @@ snapshots: optionalDependencies: '@opentelemetry/api': 1.9.1 '@types/node': 25.9.6 - '@vitest/coverage-v8': 4.1.11(vitest@4.1.11) + '@vitest/browser-playwright': 4.1.11(playwright@1.63.0)(vite@8.2.2(@types/node@25.9.6)(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.9.0 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.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)): + 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.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: '@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)) @@ -10024,12 +10144,13 @@ snapshots: optionalDependencies: '@opentelemetry/api': 1.9.1 '@types/node': 25.9.8 - '@vitest/coverage-v8': 4.1.11(vitest@4.1.11) + '@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.9.0 transitivePeerDependencies: - msw - vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@25.9.8)(@vitest/coverage-v8@4.1.11)(happy-dom@21.0.4)(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@21.0.4)(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)) @@ -10054,7 +10175,8 @@ snapshots: optionalDependencies: '@opentelemetry/api': 1.9.1 '@types/node': 25.9.8 - '@vitest/coverage-v8': 4.1.11(vitest@4.1.11) + '@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: 21.0.4 transitivePeerDependencies: - msw