@marianmeres/stuic 3.114.0 → 3.115.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -63,7 +63,7 @@ Render a semantic `h2` but style it like an `h4`:
63
63
  | `--stuic-h-line-height` | `1.2` | Line height |
64
64
  | `--stuic-h-letter-spacing` | `normal` | Letter spacing |
65
65
  | `--stuic-h-color` | `inherit` | Text color |
66
- | `--stuic-h-margin` | `0` | Margin |
66
+ | `--stuic-h-margin` | `0 0 0.5em` | Margin (bottom-only) |
67
67
  | `--stuic-h1-font-size` | `clamp(1.875rem, 1.6rem + 1vw, 2.5rem)` | H1 font size (fluid) |
68
68
  | `--stuic-h2-font-size` | `clamp(1.5rem, 1.3rem + 0.9vw, 2.125rem)` | H2 font size (fluid) |
69
69
  | `--stuic-h3-font-size` | `clamp(1.25rem, 1.1rem + 0.65vw, 1.625rem)` | H3 font size (fluid) |
@@ -10,7 +10,10 @@
10
10
  --stuic-h-line-height: 1.2;
11
11
  --stuic-h-letter-spacing: normal;
12
12
  --stuic-h-color: inherit;
13
- --stuic-h-margin: 0;
13
+ /* Bottom-only by default: binds the heading to the content below it, while
14
+ space above comes from the previous element's collapsing bottom margin.
15
+ Avoids the first-child / flex-grid issues a baked-in margin-top would cause. */
16
+ --stuic-h-margin: 0 0 0.5em;
14
17
 
15
18
  /* Per-level font sizes — fluid scaling, original values at ~1024px viewport */
16
19
  --stuic-h1-font-size: clamp(1.875rem, 1.6rem + 1vw, 2.5rem);
@@ -0,0 +1,119 @@
1
+ <!--
2
+ GENERATED ANALYSIS — @marianmeres/stuic real-browser component testing
3
+ Produced 2026-06-08 by multi-agent research → adversarial verify → synthesize.
4
+ Claims verified against the codebase at commit cc9958b and the live Vitest 4 /
5
+ vitest-browser-svelte docs. Planning artifact; no code was changed.
6
+ -->
7
+
8
+ # @marianmeres/stuic — Component Testing: Overview & Roadmap
9
+
10
+ > **Verdict:** the proposed stack — Vitest Browser Mode + `vitest-browser-svelte` + Playwright/Chromium
11
+ > — is the right default for *this* library, where the value being shipped is precisely the DOM/layout/
12
+ > focus/positioning behavior that the current node/server-build test setup **cannot exercise at all**.
13
+ > The claim is *mostly* correct rather than gospel: it's not an officially-mandated singular standard
14
+ > (svelte.dev still nominally leads with jsdom + @testing-library), and one term was dated — modern
15
+ > spelling is `vitest-browser-svelte` + **`@vitest/browser-playwright`** + `playwright` on **Vitest 4**.
16
+ >
17
+ > **The one thing that matters most:** a **vitest 3 → 4 major upgrade is a hard prerequisite**
18
+ > (`vitest-browser-svelte@^2` peer-requires `vitest ^4`; Browser Mode is only stable in v4). Do it as
19
+ > a discrete, reversible first commit and confirm the 9 existing suites stay green before anything else.
20
+ >
21
+ > **The second thing:** route tests by filename into a Vitest `projects` split — `*.test.ts` → fast
22
+ > **node** (the existing 9 suites, untouched), `*.svelte.test.ts` → real **browser**. Get that glob
23
+ > right and nothing regresses; get it wrong and either utils crawl in a browser or components fail in
24
+ > node with the very server-build error this effort exists to escape.
25
+ >
26
+ > Read order: this file → [`01-framework-setup`](./01-framework-setup.md) for the exact config →
27
+ > [`02-test-conventions`](./02-test-conventions.md) for how to write a test → then work
28
+ > [`PROGRESS.md`](./PROGRESS.md) top-down. [`03`](./03-component-coverage-roadmap.md) ranks all 74
29
+ > components; [`04`](./04-hard-cases-and-e2e.md) handles the hard 30; [`05`](./05-ci.md) is CI.
30
+
31
+ ## A note on `docs/testing.md` (this is a partial reversal — by design)
32
+
33
+ [`docs/testing.md`](../testing.md) records a deliberate decision **not** to test component rendering
34
+ ("50+ components × prop combos = slow suite, tiny yield; rendering is gated by svelte-check + publint +
35
+ build"). That reasoning holds for *"does it render"* and we keep it. What changes: browser mode lets
36
+ us test *"does it **behave**"* — events, two-way binding, aria/disabled/active state, focus traps,
37
+ viewport-clamped positioning (cf. the `9d8c974` annotation regression) — which the build does **not**
38
+ cover and which was **previously impossible**. Updating `docs/testing.md` to add this layer is an
39
+ explicit sprint task so the docs don't contradict each other.
40
+
41
+ ## Top recommendations across all dimensions (ranked)
42
+
43
+ | Rank | Recommendation | Dimension | Value | Effort | Risk | Why now |
44
+ |------|----------------|-----------|-------|--------|------|---------|
45
+ | 1 | Upgrade vitest 3→4, verify 9 suites green | [01](./01-framework-setup.md) | high | S | med | Gating prerequisite; nothing installs without it |
46
+ | 2 | Add `projects` split (node `server` + browser `client`) + Chromium | [01](./01-framework-setup.md) | high | S | med | The harness; routes by `*.svelte.test.ts` filename |
47
+ | 3 | Separator smoke test — prove client build + `$effect` actually run | [01](./01-framework-setup.md) | high | S | med | Disproves/confirms the documented server-build blocker |
48
+ | 4 | Reconcile `docs/testing.md` (behavior ✅, rendering still ❌) | [02](./02-test-conventions.md) | med | S | low | Keep docs internally consistent before scaling |
49
+ | 5 | Button — flagship; sets every assertion pattern | [03](./03-component-coverage-roadmap.md) | high | S | low | Most-used primitive; template for the rest |
50
+ | 6 | Pill, Switch — events + binding patterns | [03](./03-component-coverage-roadmap.md) | high | S | low | Cover dismiss/toggle/bind once, reuse everywhere |
51
+ | 7 | Spinner, Skeleton, DismissibleMessage, Avatar, Progress | [03](./03-component-coverage-roadmap.md) | high | S | low | Deterministic, high-traffic; quick wins |
52
+ | 8 | **One hard proof** — anchor-position viewport clamp (or focus trap) | [04](./04-hard-cases-and-e2e.md) | high | M | med | Guards a real recent regression; proves browser mode's worth |
53
+ | 9 | Minimal GitHub Actions workflow | [05](./05-ci.md) | high | S | low | Stops broken tests reaching npm; once a few tests pass |
54
+ | 10 | Tier-2 form fields (`FieldInput` first, then the family) | [03](./03-component-coverage-roadmap.md) | med | M | low | Largest component group; one pattern unlocks many |
55
+ | 11 | Portals/focus-traps in browser mode (Modal/Drawer/Backdrop) | [04](./04-hard-cases-and-e2e.md) | med | M | med | High-value a11y contracts; after patterns settle |
56
+ | 12 | Standalone Playwright E2E layer (drag, Milkdown, checkout flows) | [04](./04-hard-cases-and-e2e.md) | med | L | med | Separate later initiative; explicitly out of sprint 1 |
57
+
58
+ > **Deliberately deferred as low-yield:** visual-regression / `toMatchScreenshot`, multi-browser
59
+ > (Firefox/WebKit) matrix, and exhaustive prop-matrix coverage. Revisit only if motivated by a real bug.
60
+
61
+ ## Recommended first sprint (do these first)
62
+
63
+ Branch: `feat/component-testing`. One commit per task.
64
+
65
+ 1. **Vitest 4 upgrade (#1)** — `pnpm add -D vitest@^4`, run `pnpm test`, confirm 9 suites green. Why
66
+ first: everything else peer-depends on it; isolating it makes the one risky bump reversible.
67
+ 2. **Browser harness (#2, #3)** — add browser deps, the `projects` config, `playwright install
68
+ chromium`, fix the test scripts, and land the Separator smoke test. Unblocks all component tests
69
+ and proves the server-build blocker is gone. Detail in [01](./01-framework-setup.md).
70
+ 3. **Reconcile `docs/testing.md` (#4)** — small doc edit so the philosophy matches reality.
71
+ 4. **Button (#5)** — establishes the assertion vocabulary ([02](./02-test-conventions.md)) every later
72
+ test reuses. Highest-leverage single component.
73
+ 5. **Pill → Switch → Spinner → Skeleton → DismissibleMessage → Avatar → Progress (#6, #7)** — the easy
74
+ tier, one commit each; fast, deterministic, high-traffic coverage.
75
+ 6. **One hard proof (#8)** — anchor-position viewport clamp (recommended) or focus trap; the payoff
76
+ moment that justifies the whole setup. Detail in [04](./04-hard-cases-and-e2e.md).
77
+ 7. **CI (#9)** — the ~30-line workflow, now that there's a real suite to run. Detail in [05](./05-ci.md).
78
+
79
+ ## Cross-cutting themes
80
+
81
+ - **Filename routing is load-bearing.** The `*.svelte.test.ts` vs `*.test.ts` convention is what keeps
82
+ node fast and browser correct. It appears in every dimension.
83
+ - **Test behavior, not rendering.** The build already proves components render; tests exist for the
84
+ contracts it can't see (events, binding, aria, geometry). This both reconciles `docs/testing.md` and
85
+ picks the high-yield targets.
86
+ - **Extract-then-unit-test stays valid.** Pure logic in `_internal/*.ts` (drop math, cron parsing,
87
+ clamp math) should keep getting fast node tests; browser mode is additive, not a replacement.
88
+ - **One commit per component** keeps the effort resumable and reviewable, exactly matching the
89
+ PROGRESS.md convention.
90
+
91
+ ## Dependency / sequencing notes
92
+
93
+ ```mermaid
94
+ flowchart TD
95
+ A["1. vitest 3→4 upgrade"] --> B["2. projects config + Chromium + smoke test"]
96
+ B --> C["3. reconcile docs/testing.md"]
97
+ B --> D["4. Button (flagship patterns)"]
98
+ D --> E["5. easy tier: Pill, Switch, Spinner, Skeleton, ..."]
99
+ E --> F["8. one hard proof (anchor clamp / focus trap)"]
100
+ E --> G["9. CI workflow"]
101
+ F --> H["later: Tier 2 fields, portals, standalone Playwright E2E"]
102
+ G --> H
103
+ ```
104
+
105
+ ## Completeness check
106
+
107
+ - **Snippet-heavy components** (Button needs `children`): the `createRawSnippet` pattern is settled in
108
+ [02](./02-test-conventions.md); first real exercise is Button — watch for friction and codify a shared
109
+ helper home then.
110
+ - **SvelteKit-plugin-in-browser-mode** is the top unknown; the Separator smoke test (task 2) is the
111
+ designated early canary, with a documented fallback (plain `svelte()` plugin for the client project).
112
+ - **`packageManager` field is absent** — surfaces in CI ([05](./05-ci.md)); resolve when writing the
113
+ workflow.
114
+ - Not yet scoped: a dedicated `playwright.config.ts` for the standalone E2E layer — intentionally
115
+ deferred to its own future initiative ([04](./04-hard-cases-and-e2e.md)).
116
+
117
+ Source documents: [`01-framework-setup`](./01-framework-setup.md), [`02-test-conventions`](./02-test-conventions.md),
118
+ [`03-component-coverage-roadmap`](./03-component-coverage-roadmap.md), [`04-hard-cases-and-e2e`](./04-hard-cases-and-e2e.md),
119
+ [`05-ci`](./05-ci.md).
@@ -0,0 +1,164 @@
1
+ <!--
2
+ GENERATED ANALYSIS — @marianmeres/stuic real-browser component testing
3
+ Produced 2026-06-08 by multi-agent research → adversarial verify → synthesize.
4
+ Claims verified against the codebase at commit cc9958b and the live Vitest 4 docs.
5
+ Planning artifact; no code was changed.
6
+ -->
7
+
8
+ # Framework Setup
9
+
10
+ > This is the infrastructure dimension: the one-time work that turns "no DOM, no `$effect`"
11
+ > into a working browser test harness. The single most important takeaway: **a Vitest
12
+ > `projects` split routed by filename** (`*.test.ts` → fast node, `*.svelte.test.ts` → real
13
+ > browser) keeps the 9 existing suites untouched while adding component tests on top. The one
14
+ > hard prerequisite is a **vitest 3 → 4 major upgrade** — `vitest-browser-svelte@^2` peer-requires
15
+ > `vitest ^4`, and Browser Mode only became *stable* in Vitest 4.
16
+
17
+ ## Current state (verified at `cc9958b`)
18
+
19
+ - **Package manager:** pnpm. **Svelte:** 5.56.2. **SvelteKit:** 2.63.0 (adapter-auto). **Vitest:** 3.2.6.
20
+ - **Test config:** none dedicated. Vitest is configured only implicitly through
21
+ [`vite.config.ts`](../../vite.config.ts) (`plugins: [tailwindcss(), sveltekit()]`), and the
22
+ script is `"test": "vitest --dir src/"`.
23
+ - **What runs today:** 9 suites / ~59 tests, all **node environment, pure logic** — validation
24
+ helpers, stack classes (`NotificationsStack`, `AlertConfirmPromptStack`), `tr`, `replace-map`,
25
+ `storage-abstraction`, `checkout-utils`, `max-height`, `phone-validation`. Zero component tests.
26
+ - **Why components can't be tested today:** the `@sveltejs/kit/vite` plugin makes Vitest resolve
27
+ Svelte's **server (SSR) build** in node, so components render as strings and `$effect` / actions /
28
+ lifecycle never run (documented in project memory; an `$effect.root` + `flushSync` probe left the
29
+ effect body unrun even with `resolve.conditions: ['browser']`). Real-browser mode fixes this by
30
+ resolving the **client build** and running in an actual Chromium page.
31
+ - **No CI** (`.github/` absent). No browser-testing deps installed.
32
+
33
+ ## Target state
34
+
35
+ A two-project Vitest config. Tests are routed purely by filename:
36
+
37
+ | Project | Environment | Matches | Runs |
38
+ |---------|-------------|---------|------|
39
+ | `server` | `node` | `src/**/*.test.ts` (excl. `*.svelte.test.ts`) | the existing 9 pure-logic suites — unchanged, fast |
40
+ | `client` | real browser (Chromium via Playwright) | `src/**/*.svelte.test.ts` | new component tests with DOM, `$effect`, layout, focus |
41
+
42
+ ## Step 1 — Upgrade Vitest 3 → 4 (own commit, gating)
43
+
44
+ ```bash
45
+ pnpm add -D vitest@^4
46
+ pnpm test # confirm the 9 existing suites still pass before touching anything else
47
+ ```
48
+
49
+ Vitest 4 breaking changes that touch this repo (budget for them):
50
+
51
+ 1. **Browser providers are separate packages now** — there is no built-in `@vitest/browser` provider; you install `@vitest/browser-playwright` (Step 2).
52
+ 2. **Browser context import moved** — `@vitest/browser/context` → `vitest/browser` (only matters if/when we import `page`, `userEvent`, etc. directly; not needed for the basics).
53
+ 3. `test.workspace` is deprecated in favour of `test.projects` (we use `projects` from the start).
54
+
55
+ > **Risk:** an incidental break in the 9 node suites during the bump. Mitigation: this is its own
56
+ > commit; if `pnpm test` regresses, fix or revert before any browser work. Verify against
57
+ > `@sveltejs/vite-plugin-svelte@6.2.4` and `@tailwindcss/vite` — both are Vite-7-era and compatible
58
+ > with Vitest 4, but confirm the dev server (`pnpm dev`) and `pnpm build` still work after the bump.
59
+
60
+ ## Step 2 — Add browser deps + Chromium
61
+
62
+ ```bash
63
+ pnpm add -D @vitest/browser-playwright vitest-browser-svelte playwright
64
+ pnpm exec playwright install chromium # without this, browser tests fail: "No browsers found"
65
+ ```
66
+
67
+ We do **not** install `@vitest/browser` directly (pulled in transitively by the provider), and we
68
+ do **not** need `@testing-library/svelte` or `@testing-library/jest-dom` — `vitest-browser-svelte`'s
69
+ `render()` + `expect.element` replace both.
70
+
71
+ ## Step 3 — The `projects` config
72
+
73
+ Add a `test` block to [`vite.config.ts`](../../vite.config.ts) (keep the file; just extend it).
74
+ Verified against the live Vitest 4 docs (`provider: playwright()` is an imported **function**, not
75
+ the old `'playwright'` string; `instances` is required):
76
+
77
+ ```ts
78
+ /// <reference types="node" />
79
+ import { sveltekit } from "@sveltejs/kit/vite";
80
+ import { defineConfig } from "vitest/config";
81
+ import tailwindcss from "@tailwindcss/vite";
82
+ import { playwright } from "@vitest/browser-playwright";
83
+ import dotenv from "dotenv";
84
+
85
+ dotenv.config();
86
+
87
+ export default defineConfig({
88
+ plugins: [tailwindcss(), sveltekit()],
89
+ server: {
90
+ port: parseInt(process.env.DEV_PORT || "8886"),
91
+ host: true,
92
+ },
93
+ test: {
94
+ projects: [
95
+ {
96
+ extends: true, // inherit root plugins (tailwind + sveltekit)
97
+ test: {
98
+ name: "server",
99
+ environment: "node",
100
+ include: ["src/**/*.test.ts"],
101
+ exclude: ["src/**/*.svelte.test.ts"],
102
+ },
103
+ },
104
+ {
105
+ extends: true,
106
+ test: {
107
+ name: "client",
108
+ include: ["src/**/*.svelte.test.ts"],
109
+ setupFiles: ["vitest-browser-svelte"],
110
+ testTimeout: 2000,
111
+ browser: {
112
+ enabled: true,
113
+ headless: true,
114
+ provider: playwright(),
115
+ instances: [{ browser: "chromium" }],
116
+ },
117
+ },
118
+ },
119
+ ],
120
+ },
121
+ });
122
+ ```
123
+
124
+ Why this shape:
125
+
126
+ - **`extends: true`** makes each project inherit the root `plugins` array, so both the `tailwindcss()`
127
+ (component CSS / variant classes resolve — several STUIC components assert on variant/size class
128
+ output) and `sveltekit()` plugins apply. In the `client` project Vitest builds for the browser, so
129
+ the **client** Svelte build is used and `$effect`/actions run — the exact thing the node setup can't do.
130
+ - **The filename glob is the routing mechanism** and the single most important config decision. Note
131
+ `src/**/*.test.ts` *also* matches `foo.svelte.test.ts` (it ends in `.test.ts`), so the server
132
+ project's `exclude` is **required**, not optional.
133
+ - **`setupFiles: ['vitest-browser-svelte']`** is the entire setup — it registers auto-cleanup and
134
+ helpers. Browser mode eliminates the 60–250-line jsdom API-mock setup files older guides used.
135
+ - **`headless: true`** for CI; drop it (or `--browser.headless=false`) locally to watch tests run.
136
+
137
+ ## Step 4 — Update the test scripts
138
+
139
+ `--dir src/` is now redundant (the `include` globs scope each project). Suggested:
140
+
141
+ ```jsonc
142
+ "test": "vitest run", // one-shot, used by CI
143
+ "test:watch": "vitest", // watch mode for local dev
144
+ "test:ui": "vitest --ui" // optional: the Vitest UI, handy for browser tests
145
+ ```
146
+
147
+ `pnpm test` then runs **both** projects (node + browser) in one command.
148
+
149
+ ## Step 5 — Smoke test (prove the harness before writing real tests)
150
+
151
+ Create one trivial browser test — e.g. `src/lib/components/Separator/Separator.svelte.test.ts`
152
+ that renders `<Separator />` and asserts it's in the document. If it goes green, the client build +
153
+ Chromium + tailwind + sveltekit-plugin chain all work. **This is where the project-memory
154
+ "server-build" risk is actually disproven or confirmed** — treat a passing smoke test as the gate to
155
+ the rest of the plan.
156
+
157
+ ## Open questions / decisions needed
158
+
159
+ - **Config location** — keep it in `vite.config.ts` (recommended, minimal change; chosen here) vs a
160
+ dedicated `vitest.config.ts`. Purely organizational; nothing functional depends on it.
161
+ - **`@sveltejs/kit/vite` vs plain `@sveltejs/vite-plugin-svelte` in the `client` project** — the
162
+ SvelteKit plugin should be fine in browser mode (the `sv` CLI's own vitest add-on uses it), but if
163
+ the smoke test surfaces SSR/`$lib` alias issues, the fallback is to give the `client` project a
164
+ plain `svelte()` plugin instead of inheriting `sveltekit()`. Decide only if the smoke test fails.
@@ -0,0 +1,148 @@
1
+ <!--
2
+ GENERATED ANALYSIS — @marianmeres/stuic real-browser component testing
3
+ Produced 2026-06-08 by multi-agent research → adversarial verify → synthesize.
4
+ Claims verified against the codebase at commit cc9958b and the live
5
+ vitest-browser-svelte docs. Planning artifact; no code was changed.
6
+ -->
7
+
8
+ # Test Conventions
9
+
10
+ > How to write a STUIC browser component test, and — just as important — **what is worth
11
+ > asserting**. The headline: test *behavior the build can't see* (events fire, bindings update,
12
+ > aria/disabled/active states, computed layout), not "does it render" (already gated by
13
+ > `svelte-check` + `publint` + the build). Use `render()` → locators → `expect.element`. In Svelte 5
14
+ > events are props, so you assert them with spies; snippet children come from `createRawSnippet`.
15
+
16
+ ## Reconciling with `docs/testing.md`
17
+
18
+ [`docs/testing.md`](../testing.md) currently states the library **deliberately does not** test full
19
+ component rendering ("50+ components × prop combinations = slow suite with tiny yield... Rendering is
20
+ already gated by svelte-check + publint + the build") and treats interactive/visual behavior as
21
+ out of scope.
22
+
23
+ That reasoning was **correct for what it described and is not actually reversed here** — it just
24
+ predates a capability we didn't have:
25
+
26
+ - "Does it render / compile / export" → still low-yield, still covered by `svelte-check` + `publint` +
27
+ build. We will **not** write tests for that.
28
+ - "Does it *behave*" — click handlers, two-way `bind:`, `aria-*`/`disabled`/`active` state, focus
29
+ traps, viewport-clamped anchor positioning (cf. the recent `9d8c974` annotation-clamp fix) — was
30
+ **previously impossible** (node/server build, no DOM, no `$effect`). Browser mode makes it possible,
31
+ and *this* is the high-yield target.
32
+
33
+ **Task in the roadmap:** update `docs/testing.md` to add this browser-test layer so the docs aren't
34
+ self-contradictory — promote "interactive behavior" from ❌ to ✅-when-it's-a-real-contract, and point
35
+ to this directory. (See [`PROGRESS.md`](./PROGRESS.md), sprint task.)
36
+
37
+ ## File naming & location
38
+
39
+ - One test per component, **co-located** next to the `.svelte` file (matches the existing co-located
40
+ style, e.g. `Input/phone-validation.test.ts`).
41
+ - Name it `ComponentName.svelte.test.ts` — the `.svelte.test.ts` suffix is what routes it into the
42
+ browser `client` project (see [01](./01-framework-setup.md)). A plain `*.test.ts` next to a
43
+ component stays in the fast node project (correct for extracted pure logic like `_internal/*.ts`).
44
+
45
+ ## The canonical test
46
+
47
+ ```ts
48
+ // src/lib/components/Pill/Pill.svelte.test.ts
49
+ import { render } from "vitest-browser-svelte";
50
+ import { expect, test, vi } from "vitest";
51
+ import Pill from "./Pill.svelte";
52
+
53
+ test("renders label and applies intent", async () => {
54
+ const screen = render(Pill, { label: "Beta", intent: "primary" });
55
+ const pill = screen.getByText("Beta");
56
+ await expect.element(pill).toBeVisible();
57
+ await expect.element(pill).toHaveAttribute("data-intent", "primary");
58
+ });
59
+
60
+ test("dismiss button fires ondismiss", async () => {
61
+ const ondismiss = vi.fn();
62
+ const screen = render(Pill, { label: "Beta", ondismiss });
63
+ await screen.getByRole("button", { name: /dismiss/i }).click();
64
+ expect(ondismiss).toHaveBeenCalledOnce();
65
+ });
66
+ ```
67
+
68
+ Key facts (verified against the `vitest-browser-svelte` README):
69
+
70
+ - `render(Component, props)` returns a **screen** object with Playwright-style locator methods
71
+ (`getByRole`, `getByText`, `getByLabelText`, …). `render` is imported from `"vitest-browser-svelte"`.
72
+ - **`expect.element(locator)` auto-retries** — it polls the DOM until the assertion passes or times
73
+ out (`testTimeout`, set to 2000ms in our config). Prefer it over reading values synchronously; it
74
+ removes the need for `act`/manual `tick()` for in-component reactivity.
75
+ - `.click()`, `.fill()`, etc. are awaited interactions on a locator.
76
+ - **No `@testing-library`, no `act`.** `vitest-browser-svelte` intentionally omits `act`.
77
+
78
+ ## Events are props (Svelte 5)
79
+
80
+ There are no `component.$on` listeners in Svelte 5 — events are callback props (`onclick`,
81
+ `onchange`, and STUIC's own `ondismiss`, `onLogout`, etc.). Assert them by passing a spy:
82
+
83
+ ```ts
84
+ const onclick = vi.fn();
85
+ render(Button, { onclick, children: text("Save") });
86
+ await screen.getByRole("button").click();
87
+ expect(onclick).toHaveBeenCalledOnce();
88
+ ```
89
+
90
+ ## Snippet props (`children`, `header`, etc.)
91
+
92
+ Many STUIC components take snippet props (`children`, header/footer snippets). A `.svelte.test.ts`
93
+ file can't contain markup, so build snippets with `createRawSnippet`:
94
+
95
+ ```ts
96
+ import { createRawSnippet } from "svelte";
97
+
98
+ // tiny helper for static text children — keep it in a shared test util
99
+ const text = (s: string) => createRawSnippet(() => ({ render: () => `<span>${s}</span>` }));
100
+
101
+ render(Button, { children: text("Click me") });
102
+ ```
103
+
104
+ For complex snippet scenarios (a snippet that itself renders a STUIC component, takes args, etc.),
105
+ the escape hatch is a **fixture component**: a small `ComponentName.fixture.svelte` next to the test
106
+ that composes the real component with markup, then `render(Fixture, props)`. Prefer `createRawSnippet`
107
+ for the common case; reach for a fixture only when markup composition is the thing under test.
108
+
109
+ ## Two-way binding (`bind:value`, `bind:checked`)
110
+
111
+ To assert that user interaction updates bound state, render a fixture that binds the prop to local
112
+ state and exposes it, **or** assert the observable DOM proxy (e.g. the hidden `<input>`'s `checked`
113
+ for `Switch`, the `<input>`'s `value` for `FieldInput`). Default to asserting the DOM — it's what a
114
+ consumer observes — and use a fixture only when you must read the bound JS value directly.
115
+
116
+ ## What to assert (the high-yield checklist)
117
+
118
+ For each component, prefer these over snapshot dumps:
119
+
120
+ 1. **Prop → DOM contract** — `intent`/`variant`/`size` → `data-*` attribute or class; `href` → renders
121
+ `<a>` vs `<button>`; `disabled` → the `disabled` attribute / blocked clicks.
122
+ 2. **Events** — the right callback fires once, with the right argument, and `disabled` suppresses it.
123
+ 3. **Binding** — interaction updates the value/checked state.
124
+ 4. **A11y** — `role`, `aria-*`, label association, focus order where it's a contract.
125
+ 5. **Computed layout** (browser-only superpower) — width from a `progress` value, viewport clamping
126
+ of anchor-positioned elements, focus actually trapped. These are exactly what jsdom returns zeros
127
+ for, and what regressed in recent commits.
128
+
129
+ Avoid: asserting exact class strings (brittle — assert the `data-*` contract or a single meaningful
130
+ class), and snapshotting full HTML (self-closing-tag / class-order differences make them noisy).
131
+
132
+ ## Cleanup gotcha
133
+
134
+ `vitest-browser-svelte` cleans up **before** each test, not after — so the last rendered component
135
+ stays on screen for debugging. Import from `vitest-browser-svelte/pure` to opt out of auto-cleanup
136
+ when a test needs to manage it manually. Rarely needed.
137
+
138
+ ## Timing / flakiness
139
+
140
+ Rely on `expect.element` retries and awaited locator actions — **never fixed `sleep`s**. In-component
141
+ reactivity resolves through the retry loop; only assertions on *external* universal state living in a
142
+ `*.svelte.ts` module may need `flushSync()` from `svelte`.
143
+
144
+ ## Open questions / decisions needed
145
+
146
+ - **Shared test utilities** — agree on one home for the `text()` snippet helper and any future
147
+ fixtures (suggest `src/lib/test-utils/` or `src/test-helpers.ts`), so it's not redefined per file.
148
+ Decide when the second snippet-needing component lands.
@@ -0,0 +1,92 @@
1
+ <!--
2
+ GENERATED ANALYSIS — @marianmeres/stuic real-browser component testing
3
+ Produced 2026-06-08 by multi-agent research → adversarial verify → synthesize.
4
+ Component inventory verified against src/lib/components at commit cc9958b.
5
+ Planning artifact; no code was changed.
6
+ -->
7
+
8
+ # Component Coverage Roadmap
9
+
10
+ > The library has **74 components** (≈105 `.svelte` files incl. sub-components). Roughly **26 are
11
+ > "easy"** (deterministic prop→DOM, no portals/traps), **23 "medium"** (actions, focus jumps, layout
12
+ > reads), **30 "hard/E2E-only"** (portals, focus traps, anchor positioning, drag, Milkdown). The plan:
13
+ > **one commit per component**, starting with the easy primitives that are used everywhere, then a
14
+ > single "hard proof" to validate that browser mode earns its keep (see [04](./04-hard-cases-and-e2e.md)).
15
+
16
+ ## Summary of recommendations
17
+
18
+ | # | Recommendation | Value | Effort | Risk |
19
+ |---|----------------|-------|--------|------|
20
+ | 1 | Cover the **easy tier** primitives first (Button, Pill, Switch, …), one commit each | high | S | low |
21
+ | 2 | Establish reusable assertion patterns on Button (the flagship), reuse everywhere | high | S | low |
22
+ | 3 | Do **one hard proof** early (focus trap or anchor positioning) to de-risk the approach | high | M | med |
23
+ | 4 | Cover the **medium tier** (form fields with actions) once patterns are settled | med | M | low |
24
+ | 5 | Defer the **hard 30** to a later sprint / standalone Playwright E2E layer | med | L | med |
25
+
26
+ ## Tier 1 — Easy, highest-value (the warm-up; one commit each)
27
+
28
+ Deterministic prop→DOM/aria/event surface; no portals, traps, or external geometry. Paths under
29
+ `src/lib/components/`.
30
+
31
+ | Order | Component | Path | What to assert |
32
+ |-------|-----------|------|----------------|
33
+ | 1 | **Button** | `Button/Button.svelte` | `intent`/`variant`/`size` → `data-*`; `disabled` attr + click suppressed; `href` → `<a>`; spinner shows/hides; `roleSwitch` toggle; `onclick` fires once. The flagship — sets every pattern. |
34
+ | 2 | **Pill** | `Pill/Pill.svelte` | intent/variant → `data-*`; `active` state; dismissible click → `ondismiss` (+ `stopPropagation`); `href` → link; disabled blocks click. |
35
+ | 3 | **Switch** | `Switch/Switch.svelte` | `checked` reflected in hidden input; click toggles; `disabled`; intent/size classes; on/off snippets. First real "reactivity in a browser" proof. |
36
+ | 4 | **Spinner** | `Spinner/Spinner.svelte` | size → dims; `count` → number of bars; thickness/direction/duration → style/CSS var. Pure CSS, no motion lib. |
37
+ | 5 | **Skeleton** | `Skeleton/Skeleton.svelte` | variant (text/circle/rect) markup; `lines` count; rounded class; animation off under `prefers-reduced-motion`; width/height inline style. |
38
+ | 6 | **DismissibleMessage** | `DismissibleMessage/DismissibleMessage.svelte` | message renders; `intent` → `data-intent`; dismiss button hidden when `onDismiss=false`; `ondismiss` fires; new message resets dismissed state. |
39
+ | 7 | **Avatar** | `Avatar/Avatar.svelte` | `src` → `<img>` + alt; initials extracted from name; icon fallback; error→fallback switch; `autoColor` deterministic; size class. |
40
+ | 8 | **Progress** | `Progress/Progress.svelte` | `type=bar` vs `circle` markup; value → bar **width** / circle stroke (genuine layout read — jsdom can't). |
41
+ | 9 | **Separator** | `Separator/Separator.svelte` | orientation → classes/role. Near-zero risk — good harness smoke test (see [01](./01-framework-setup.md) Step 5). |
42
+ | 10 | **H** | `H/H.svelte` | `level` → correct `h1`–`h6` tag; size override class. |
43
+ | 11 | **KbdShortcut** | `KbdShortcut/KbdShortcut.svelte` | key labels rendered; modifier order; cross-platform output. |
44
+ | 12 | **ButtonGroupRadio** | `ButtonGroupRadio/ButtonGroupRadio.svelte` | single selection; click changes selection; value binding. |
45
+ | 13 | **ListItemButton** | `ListItemButton/ListItemButton.svelte` | active class; disabled blocks click; `onclick`; icon/avatar slot. |
46
+ | 14 | **Card** | `Card/Card.svelte` | variant → `data-*`; header/footer/children snippets render. |
47
+ | 15 | **TabbedMenu** | `TabbedMenu/TabbedMenu.svelte` | tab click updates bound `activeTab`; active content renders; indicator moves. |
48
+ | 16 | **IconSwap** | `IconSwap/IconSwap.svelte` | `active` swaps icon; animation class applied. |
49
+ | 17 | **Collapsible** | `Collapsible/Collapsible.svelte` | `needsCollapse` true when `scrollHeight > clientHeight` (browser-only); expand removes line-clamp; toggle visible only when needed. |
50
+
51
+ > The first sprint targets **#1–#9 plus one hard proof**; #10–#17 are ranked backlog (still Tier 1,
52
+ > just diminishing marginal value). Adjust freely as patterns settle.
53
+
54
+ ## Tier 2 — Medium value, moderate complexity (next sprint)
55
+
56
+ Use actions (`trim`/`typeahead`/`validate`/`autogrow`/`tooltip`) and/or real focus/geometry, but stay
57
+ deterministic. Form fields are the bulk and share one harness pattern once `FieldInput` is solved.
58
+
59
+ `FieldInput`, `FieldTextarea`, `FieldCheckbox`, `FieldSelect`, `FieldRadios`, `FieldSwitch`,
60
+ `FieldKeyValues`, `FieldObject`, `FieldPhoneNumber`, `FieldLikeButton` · `OtpInput` (focus jumps,
61
+ paste) · `Nav` (expand/collapse) · `ImageCycler` · `AppShell`/`AppShellSimple` · `PricingTable` ·
62
+ `ThemePreview` · `AssetsPreview` · `SlidingPanels` · `Notifications` (timers; portal — borderline) ·
63
+ `TypeaheadInput` · `ColorScheme` (localStorage).
64
+
65
+ Pattern note: for the `validate` action, inject a stub validator (`(k) => k` style, as existing util
66
+ tests do) rather than exercising live validation — keeps these fast and deterministic.
67
+
68
+ ## Tier 3 — Hard / E2E-only (later; see [04](./04-hard-cases-and-e2e.md))
69
+
70
+ Portals & focus traps (`Modal`, `ModalDialog`, `Backdrop`, `Drawer`, `AlertConfirmPrompt`), anchor
71
+ positioning (`DropdownMenu`, `CommandMenu`, `UserAvatarMenu`), drag (`Tree`, `FieldOptions`,
72
+ `FieldFile`, `FieldAssets`), scroll/observer (`Carousel`, `DataTable`, `Book`), heavy 3rd-party
73
+ (`MarkdownEditor`/Milkdown, `CronInput`), and the composite flows (`Checkout/*`, `LoginForm`,
74
+ `RegisterForm`, `EmailVerifyForm`, `LoginOrRegisterForm`, `FieldInputLocalized`).
75
+
76
+ Several of these *can* be done in browser mode (focus traps, anchor positioning, observers all work
77
+ in a real Chromium); only drag-heavy and Milkdown components genuinely want standalone Playwright
78
+ E2E. [04](./04-hard-cases-and-e2e.md) draws that line.
79
+
80
+ ## Cadence
81
+
82
+ - **One component per commit**, message like `test(Button): browser-mode coverage`.
83
+ - Each commit: write `ComponentName.svelte.test.ts`, run `pnpm test` (both projects green), tick the
84
+ row in [`PROGRESS.md`](./PROGRESS.md), commit.
85
+ - Don't chase coverage %. Stop at the behavior contracts in [02](./02-test-conventions.md)'s checklist.
86
+
87
+ ## Open questions / decisions needed
88
+
89
+ - **Depth per component** — assert the headline contracts (recommended) vs exhaustive prop-matrix.
90
+ Default: headline contracts; expand only where a real regression happened.
91
+ - **Where to stop Tier 1 before pausing for the hard proof** — plan assumes after #9; confirm or
92
+ extend.
@@ -0,0 +1,97 @@
1
+ <!--
2
+ GENERATED ANALYSIS — @marianmeres/stuic real-browser component testing
3
+ Produced 2026-06-08 by multi-agent research → adversarial verify → synthesize.
4
+ Claims verified against src/lib at commit cc9958b. Planning artifact; no code was changed.
5
+ -->
6
+
7
+ # Hard Cases & E2E Strategy
8
+
9
+ > ~30 of the 74 components are "hard". But "hard" splits two ways: **most are hard for *jsdom* yet
10
+ > perfectly testable in Vitest browser mode** (focus traps, anchor positioning, ResizeObserver — all
11
+ > work in a real Chromium); only **drag-heavy and Milkdown-class** components genuinely need a
12
+ > separate Playwright **E2E** layer against a running app. The single most valuable near-term action
13
+ > is **one "hard proof"** test now — pick the thing that recently regressed — to prove browser mode
14
+ > earns its setup cost before we invest in the easy tier en masse.
15
+
16
+ ## The two kinds of "hard"
17
+
18
+ | Kind | Examples | Where it's tested |
19
+ |------|----------|-------------------|
20
+ | **Hard for jsdom, fine in browser mode** | focus traps, anchor/viewport positioning, scroll/observer tracking, transitions, computed layout | **Vitest browser mode** (this plan) — a real Chromium page gives real `getBoundingClientRect`, focus, layout |
21
+ | **Hard even in browser mode** | drag-and-drop reorder, file drop, full WYSIWYG editors, multi-step composite flows | **Standalone Playwright E2E** against `pnpm dev`/`preview` (deferred to a later sprint) |
22
+
23
+ This matters because the naive read ("30 components are E2E-only") is wrong — it would defer exactly
24
+ the high-value behaviors (focus, positioning) that motivated this whole effort. Most of the 30 belong
25
+ in browser mode; only a handful are true E2E.
26
+
27
+ ## The "hard proof" — do this once, early
28
+
29
+ Per the agreed scope (easy warm-up → one hard proof), after the first easy components land, write
30
+ **one** test that is impossible in jsdom and protects a real, recent regression. Two strong
31
+ candidates, both verified to exist:
32
+
33
+ ### Candidate A (recommended) — anchor-position viewport clamping
34
+
35
+ - **Code:** [`src/lib/utils/anchor-position.ts`](../../src/lib/utils/anchor-position.ts), consumed by
36
+ `DropdownMenu/DropdownMenu.svelte` (and others).
37
+ - **Why:** this is precisely what regressed in `9d8c974` *"clamp anchor-positioned annotations to
38
+ viewport on all paths"* and `8c52afe`. A test here has immediate, proven value and prevents
39
+ recurrence. It's also the textbook "jsdom returns all-zeros from `getBoundingClientRect`" case — so
40
+ it can only exist in browser mode.
41
+ - **Shape:** render a host + an anchored element positioned near a viewport edge in a real page;
42
+ assert the element's rect stays clamped inside `window.innerWidth/innerHeight`.
43
+ - **Bonus:** the *pure* clamp math in `anchor-position.ts` can additionally get a fast **node** test
44
+ (`anchor-position.test.ts`) — cheap regression net independent of the browser.
45
+
46
+ ### Candidate B (alternative) — focus trap
47
+
48
+ - **Code:** [`src/lib/actions/focus-trap.ts`](../../src/lib/actions/focus-trap.ts), used by
49
+ `ModalDialog`, `Backdrop`, `Drawer`.
50
+ - **Why:** Tab/Shift-Tab cycling and `returnFocus`-on-teardown are core a11y contracts that node
51
+ cannot exercise at all.
52
+ - **Shape:** render a container with the action + 3 focusables; assert Tab from the last wraps to the
53
+ first, Shift-Tab from the first wraps to the last, and focus returns to the opener on destroy.
54
+
55
+ > **Open question (resolve at this task):** A or B as the single hard proof. Recommendation: **A** —
56
+ > it guards a regression that already happened twice. Doing both is fine but only one is required to
57
+ > de-risk the approach.
58
+
59
+ ## Portals & focus traps (browser-mode, later sprint)
60
+
61
+ `Modal`, `ModalDialog`, `Backdrop`, `Drawer`, `AlertConfirmPrompt` — all use
62
+ [`focus-trap.ts`](../../src/lib/actions/focus-trap.ts), scroll-lock, and an Escape-key stack. These
63
+ are testable in browser mode (open → focus trapped → Escape closes → backdrop click closes →
64
+ `returnFocus`). The stack/queue *logic* is already unit-tested (`AlertConfirmPromptStack`,
65
+ `NotificationsStack`); browser tests add the DOM-interaction layer. Higher effort, real value —
66
+ schedule after Tier 1/2.
67
+
68
+ ## Anchor positioning (browser-mode, later sprint)
69
+
70
+ `DropdownMenu`, `CommandMenu`, `UserAvatarMenu` — positioning + keyboard nav + click-outside. Test
71
+ positioning and keyboard nav in browser mode; the search/filter logic should be extracted to
72
+ `_internal/*.ts` and unit-tested in node where practical.
73
+
74
+ ## True E2E candidates (standalone Playwright, deferred)
75
+
76
+ These want a running app, not isolated component mounts:
77
+
78
+ - **Drag & file drop:** `Tree` (reorder + auto-expand + async `onMove`), `FieldOptions`, `FieldFile`,
79
+ `FieldAssets`. Extract and node-test the pure logic (`calcDropPosition`, `isDescendantOf` per
80
+ `docs/testing.md`); E2E the gesture.
81
+ - **Heavy editors:** `MarkdownEditor` (Milkdown + CodeMirror) — E2E only; mock/skip at component
82
+ level. `CronInput` — node-test the cron logic, E2E the builder UI.
83
+ - **Composite flows:** `Checkout/*`, `LoginForm`/`RegisterForm`/`EmailVerifyForm`/
84
+ `LoginOrRegisterForm`, `FieldInputLocalized` — multi-step, cross-component; E2E the flow, browser-
85
+ test the leaf fields individually.
86
+
87
+ A standalone Playwright E2E project (its own `playwright.config.ts` against `pnpm preview`) is a
88
+ **separate later initiative** — explicitly out of scope for the first sprint. Visual-regression
89
+ (Vitest 4 ships `toMatchScreenshot`) is likewise deferred.
90
+
91
+ ## Open questions / decisions needed
92
+
93
+ - **Hard proof A vs B** (above) — recommend A.
94
+ - **When to start the standalone Playwright E2E layer** — not in sprint 1; revisit after Tier 2.
95
+ - **Logic-extraction appetite** — several hard components get much cheaper if pure logic moves to
96
+ `_internal/*.ts` (already endorsed by `docs/testing.md`). Decide per-component whether to extract
97
+ before testing.
@@ -0,0 +1,88 @@
1
+ <!--
2
+ GENERATED ANALYSIS — @marianmeres/stuic real-browser component testing
3
+ Produced 2026-06-08 by multi-agent research → adversarial verify → synthesize.
4
+ Claims verified against the repo at commit cc9958b. Planning artifact; no code was changed.
5
+ -->
6
+
7
+ # CI — GitHub Actions
8
+
9
+ > The repo is on GitHub (`github.com/marianmeres/stuic`) and publishes to npm, so CI pays off: it
10
+ > runs the tests automatically on every push/PR in a clean machine, catching regressions **before**
11
+ > a broken release reaches npm. The plan: **one ~30-line workflow** that installs Chromium and runs
12
+ > `pnpm test`. It's fully decoupled from the tests themselves (those run identically via `pnpm test`
13
+ > locally) and is scheduled **late in the first sprint**, once a few component tests pass locally, so
14
+ > the first CI run verifies something real.
15
+
16
+ ## What CI is, briefly
17
+
18
+ A robot that runs your checks on every push. **GitHub Actions** is GitHub's built-in version: you
19
+ commit a YAML file at `.github/workflows/test.yml` describing *when* to run (push / pull_request) and
20
+ *what steps* to run on a fresh Linux VM. GitHub then shows a green ✓ / red ✗ on each commit and PR.
21
+ Free for this repo's usage.
22
+
23
+ ## The one wrinkle vs a normal Node test job
24
+
25
+ Browser tests need a real Chromium binary on the CI machine, so the workflow adds one step —
26
+ `pnpm exec playwright install --with-deps chromium` — before `pnpm test`. (`--with-deps` also
27
+ installs the OS libraries Chromium needs on the Ubuntu runner.) Everything else is a standard
28
+ pnpm + Node setup.
29
+
30
+ ## The workflow (to create as the sprint's CI task)
31
+
32
+ ```yaml
33
+ # .github/workflows/test.yml
34
+ name: test
35
+
36
+ on:
37
+ push:
38
+ branches: [master]
39
+ pull_request:
40
+
41
+ jobs:
42
+ test:
43
+ runs-on: ubuntu-latest
44
+ steps:
45
+ - uses: actions/checkout@v4
46
+
47
+ - uses: pnpm/action-setup@v4
48
+
49
+ - uses: actions/setup-node@v4
50
+ with:
51
+ node-version: 22
52
+ cache: pnpm
53
+
54
+ - run: pnpm install --frozen-lockfile
55
+
56
+ - name: Install Chromium for browser tests
57
+ run: pnpm exec playwright install --with-deps chromium
58
+
59
+ - name: Run tests (node + browser projects)
60
+ run: pnpm test # = "vitest run", runs both projects headless
61
+ ```
62
+
63
+ Notes:
64
+
65
+ - `pnpm test` must be the one-shot `vitest run` (see [01](./01-framework-setup.md) Step 4), not watch
66
+ mode, or CI hangs.
67
+ - `browser.headless: true` is already set in the config, so no display server is needed.
68
+ - Pin `node-version` to whatever you develop on (22 shown). `pnpm/action-setup@v4` reads the pnpm
69
+ version from `package.json`'s `packageManager` field if present — the repo currently has none, so
70
+ either add one (e.g. `"packageManager": "pnpm@9.x"`) or pin `version:` in the action. **Decide this
71
+ when creating the workflow.**
72
+
73
+ ## Alternative considered (not chosen)
74
+
75
+ Running the job inside the official Playwright Docker image
76
+ (`container: mcr.microsoft.com/playwright:v1.xx-jammy`) ships browsers pre-installed and is ~30%
77
+ faster per published benchmarks. For a library this size the absolute difference is seconds; the
78
+ plain workflow above is more readable, so it's the chosen default. The Docker route stays available
79
+ if CI time ever matters.
80
+
81
+ ## Open questions / decisions needed
82
+
83
+ - **`packageManager` field vs pinned pnpm version in the action** — pick one so `pnpm/action-setup`
84
+ is deterministic.
85
+ - **Node version** to pin (suggest 22).
86
+ - **Add `pnpm check` / `pnpm lint` to the same workflow?** — cheap to fold in (the repo already has
87
+ both scripts) and would make CI a full quality gate, but it widens scope beyond testing. Recommend
88
+ yes, as a second job, but only after the test job is green.
@@ -0,0 +1,63 @@
1
+ # Implementation Progress — Component Testing
2
+
3
+ Living tracker for acting on [`00-overview-and-roadmap.md`](./00-overview-and-roadmap.md).
4
+ A fresh conversation should read this file first, then the relevant `NN-*.md` section.
5
+
6
+ **Status legend:** ⬜ not started · 🚧 in progress · ⏸️ blocked/awaiting decision · ✅ done · ⏭️ deferred
7
+
8
+ > Convention: branch `feat/component-testing`; **one commit per task**. Each task resolves its source
9
+ > doc's "Open questions" first (record in the Decisions log), then implement → `pnpm test` green →
10
+ > tick here → commit.
11
+
12
+ ## First sprint (harness + easy tier + one hard proof + CI)
13
+
14
+ Branch: `feat/component-testing`
15
+
16
+ | # | Task | Source | Status | Commit |
17
+ |---|------|--------|--------|--------|
18
+ | 1 | Upgrade vitest 3→4; confirm 9 existing node suites still green | [01](./01-framework-setup.md) Step 1 | ✅ | `71e47e2` |
19
+ | 2 | Browser harness: add deps, `projects` config split, `playwright install chromium`, fix test scripts, Separator smoke test | [01](./01-framework-setup.md) Steps 2–5 | ✅ | `980b323` |
20
+ | 3 | Reconcile [`docs/testing.md`](../testing.md) — add the browser-behavior layer | [02](./02-test-conventions.md) | ✅ | _next_ |
21
+ | 4 | **Button** — flagship; establish assertion patterns | [03](./03-component-coverage-roadmap.md) #1 | ⬜ | — |
22
+ | 5 | **Pill** — intent/active/dismissible event | [03](./03-component-coverage-roadmap.md) #2 | ⬜ | — |
23
+ | 6 | **Switch** — checked binding, toggle, disabled | [03](./03-component-coverage-roadmap.md) #3 | ⬜ | — |
24
+ | 7 | **Spinner** — size/count/direction | [03](./03-component-coverage-roadmap.md) #4 | ⬜ | — |
25
+ | 8 | **Skeleton** — variants, reduced-motion | [03](./03-component-coverage-roadmap.md) #5 | ⬜ | — |
26
+ | 9 | **DismissibleMessage** — intent, dismiss, auto-reset | [03](./03-component-coverage-roadmap.md) #6 | ⬜ | — |
27
+ | 10 | **Avatar** — initials/img/icon fallback, autoColor | [03](./03-component-coverage-roadmap.md) #7 | ⬜ | — |
28
+ | 11 | **Progress** — value→width/stroke (real layout) | [03](./03-component-coverage-roadmap.md) #8 | ⬜ | — |
29
+ | 12 | **Hard proof** — anchor-position viewport clamp (rec.) *or* focus trap | [04](./04-hard-cases-and-e2e.md) | ⏸️ | — |
30
+ | 13 | CI — minimal GitHub Actions `test.yml` | [05](./05-ci.md) | ⬜ | — |
31
+
32
+ ## Backlog (ranked, post-sprint)
33
+
34
+ | Rank | Task | Source | Status |
35
+ |------|------|--------|--------|
36
+ | 14 | Rest of Tier 1 (Separator already smoke-tested · H, KbdShortcut, ButtonGroupRadio, ListItemButton, Card, TabbedMenu, IconSwap, Collapsible) | [03](./03-component-coverage-roadmap.md) #10–17 | ⬜ |
37
+ | 15 | Tier 2 — `FieldInput` first, then the Field* family + OtpInput, Nav, etc. | [03](./03-component-coverage-roadmap.md) | ⬜ |
38
+ | 16 | Portals/focus-traps in browser mode (Modal, ModalDialog, Backdrop, Drawer, AlertConfirmPrompt) | [04](./04-hard-cases-and-e2e.md) | ⬜ |
39
+ | 17 | Anchor-positioned menus (DropdownMenu, CommandMenu, UserAvatarMenu) + extract search logic to `_internal` | [04](./04-hard-cases-and-e2e.md) | ⬜ |
40
+ | 18 | Standalone Playwright E2E layer (drag: Tree/FieldOptions/FieldFile; Milkdown; Checkout/auth flows) | [04](./04-hard-cases-and-e2e.md) | ⏭️ |
41
+ | 19 | Add `pnpm check` + `pnpm lint` as a second CI job | [05](./05-ci.md) | ⬜ |
42
+ | 20 | (Maybe) visual-regression via `toMatchScreenshot`; multi-browser matrix | [00](./00-overview-and-roadmap.md) | ⏭️ |
43
+
44
+ ## Decisions log
45
+
46
+ - **2026-06-08** — Adopt **Vitest 4 Browser Mode + `vitest-browser-svelte` + `@vitest/browser-playwright` (Chromium)** — verified the right default for a component library whose value is DOM/layout/focus behavior the current node/server-build setup can't test.
47
+ - **2026-06-08** — **Take the vitest 3→4 major upgrade now** (gating prerequisite) — `vitest-browser-svelte@^2` peer-requires `vitest ^4`; the vitest-3-compatible `0.1.0` is a dead-end.
48
+ - **2026-06-08** — **Scope:** easy warm-up (Button/Pill/Switch/…) then **one** hard proof — not full coverage up front.
49
+ - **2026-06-08** — **Browsers: Chromium only** — leanest CI; add Firefox/WebKit only if engine-specific behavior demands it.
50
+ - **2026-06-08** — **CI: yes, minimal GitHub Actions**, added late in the sprint once a few component tests pass locally.
51
+ - **2026-06-08** — **Config lives in `vite.config.ts`** (extend with a `test` block) rather than a separate `vitest.config.ts` — minimal change.
52
+ - **2026-06-08** — Task 1 done: **vitest 4.1.8** installed (vite already `^7.3.5`, compatible); all 9 node suites / 59 tests pass unchanged. `--dir src/` still supported in v4.
53
+ - **2026-06-08** — Task 2 done: harness works. Deps: `@vitest/browser-playwright 4.1.8`, `playwright 1.60.0`, `vitest-browser-svelte 2.1.1`. `projects` split added to `vite.config.ts`; scripts now `test` = `vitest run` (+ `test:watch`, `test:ui`). Separator smoke test (3 assertions) passes in Chromium → **10 files / 62 tests green**, `pnpm check` clean (0 errors). **The documented SvelteKit-plugin/server-build blocker is resolved** — browser mode resolves the client build, `toHaveClass("stuic-separator")` confirms tailwind + the client runtime run. No `client`-project plugin fallback needed.
54
+ - **⏸️ Open (task 12):** which single hard proof — anchor-position clamp (recommended, guards the `9d8c974` regression) vs focus trap.
55
+ - **⏸️ Open (task 13):** `packageManager` field vs pinned pnpm version in the CI action; Node version to pin.
56
+
57
+ ## How to resume (for a fresh conversation)
58
+
59
+ 1. Read this file + [`00-overview-and-roadmap.md`](./00-overview-and-roadmap.md).
60
+ 2. Pick the next ⬜ task; open its source doc section for the verified detail.
61
+ 3. Resolve that task's "Open questions" with the owner; record in the Decisions log.
62
+ 4. On `feat/component-testing`: implement → `pnpm test` (both projects green) → update this file →
63
+ commit when the owner asks (one commit per task).
@@ -0,0 +1,38 @@
1
+ <!--
2
+ GENERATED ANALYSIS — @marianmeres/stuic real-browser component testing
3
+ Produced 2026-06-08 by multi-agent research → adversarial verify → synthesize.
4
+ Claims verified against the codebase at commit cc9958b and the live Vitest 4 /
5
+ vitest-browser-svelte docs. Planning artifact; no code was changed.
6
+ -->
7
+
8
+ # Component Testing — @marianmeres/stuic
9
+
10
+ This directory holds the plan for introducing **real-browser component tests** to STUIC
11
+ (Vitest 4 Browser Mode + `vitest-browser-svelte` + Playwright/Chromium). It was produced
12
+ 2026-06-08 from a research pass over the codebase and the current Svelte/Vitest ecosystem.
13
+ It is a **planning artifact — no code has been changed**; every claim is verified against the
14
+ repo at commit `cc9958b` or against the live docs cited in each section.
15
+
16
+ **Start here:** [`00-overview-and-roadmap.md`](./00-overview-and-roadmap.md). Then track and
17
+ resume execution from [`PROGRESS.md`](./PROGRESS.md).
18
+
19
+ ## Documents
20
+
21
+ | # | Doc | Scope | Headline |
22
+ |---|-----|-------|----------|
23
+ | 00 | [overview-and-roadmap](./00-overview-and-roadmap.md) | synthesis + roadmap | The stack is the right default; vitest 3→4 upgrade is the gating prerequisite. |
24
+ | 01 | [framework-setup](./01-framework-setup.md) | infra | Upgrade vitest 4, add a `projects` split (node `server` + browser `client`), route by filename. |
25
+ | 02 | [test-conventions](./02-test-conventions.md) | how-to | `render()` + locators + `expect.element`; events are props (spies); snippets via `createRawSnippet`. |
26
+ | 03 | [component-coverage-roadmap](./03-component-coverage-roadmap.md) | what to cover | 74 components tiered; warm up on Button/Pill/Switch, one commit per component. |
27
+ | 04 | [hard-cases-and-e2e](./04-hard-cases-and-e2e.md) | the hard 30 | Portals/focus-traps/anchor-positioning: one "hard proof" now; standalone Playwright E2E deferred. |
28
+ | 05 | [ci](./05-ci.md) | automation | One ~30-line GitHub Actions workflow; install Chromium, run `pnpm test`. |
29
+
30
+ ## How it was produced
31
+
32
+ Five parallel research agents (test-infra audit, full component inventory, multistep-format
33
+ extraction, two independent web-research angles on the stack) → synthesis → live-docs
34
+ verification of the exact Vitest 4 config syntax → this plan.
35
+
36
+ > Nothing here is decided beyond the four clarifying answers recorded in
37
+ > [`PROGRESS.md`](./PROGRESS.md) → Decisions log. Each doc's "Open questions / decisions needed"
38
+ > lists what still needs a call.
package/docs/testing.md CHANGED
@@ -11,7 +11,12 @@ This is a component library. Most of its correctness guarantees come from:
11
11
  3. **The build** — every component compiles, every export resolves.
12
12
  4. **Manual/visual review** — styling, animation, keyboard interaction, a11y cues.
13
13
 
14
- Unit tests are for what those tools can't see: **pure deterministic logic where a regression silently corrupts data**. We explicitly don't try to test everything.
14
+ Tests are for what those tools can't see. There are **two layers**, split by filename:
15
+
16
+ - **`*.test.ts` — node, fast.** Pure deterministic logic where a regression silently corrupts data.
17
+ - **`*.svelte.test.ts` — real browser (Chromium).** Component _behavior_ the build can't see: events firing, two-way `bind:`, `aria`/`disabled`/`active` state, focus traps, computed layout/positioning.
18
+
19
+ We still explicitly don't try to test everything. The browser layer targets **behavior contracts**, not "does it render" — see [`component-testing/`](./component-testing/) for the strategy, roadmap, and how-to.
15
20
 
16
21
  ## What we test
17
22
 
@@ -20,6 +25,7 @@ Unit tests are for what those tools can't see: **pure deterministic logic where
20
25
  - **Validation helpers** — `validateEmail`, `validateAddress`, `validateCustomerForm`, `validateLoginForm`, `validatePhoneNumber`, `addressesEqual`.
21
26
  - **State-machine classes** — `NotificationsStack`, `AlertConfirmPromptStack`, `SwitchState`, `InputHistory`. Tri-state transitions, dedupe, ordering, cleanup semantics.
22
27
  - **Pure utilities** — `replace-map`, `tr`, `storage-abstraction`, and anything else with non-trivial input/output logic.
28
+ - **Component behavior** (browser mode, `*.svelte.test.ts`) — prop→DOM/`aria` contracts, events firing, `bind:` updates, focus traps, viewport-clamped positioning. One component at a time, asserting contracts a consumer relies on — not every prop permutation.
23
29
 
24
30
  ### ⚠️ Maybe, if motivated by a regression
25
31
 
@@ -27,18 +33,23 @@ Unit tests are for what those tools can't see: **pure deterministic logic where
27
33
 
28
34
  ### ❌ We don't test
29
35
 
30
- - **Full component rendering** via `@testing-library/svelte`. 50+ components × prop combinations = slow suite with tiny yield. Rendering is already gated by `svelte-check` + `publint` + the build.
31
- - **Visual regression**. That's a separate project (Playwright + screenshot diffing) — not part of `vitest --dir src/`.
32
- - **Interactive behavior** (keyboard nav, drag-drop, scroll snap) unless the underlying math is extracted to a pure function.
36
+ - **Exhaustive prop-matrix / "does it render" tests.** 50+ components × every prop combination = slow suite with tiny yield. Rendering is already gated by `svelte-check` + `publint` + the build; the browser layer asserts _behavior contracts_, not coverage of every permutation.
37
+ - **Visual regression**. A separate concern (Vitest's `toMatchScreenshot` / screenshot diffing) — deferred, not part of `pnpm test` today.
38
+ - **Heavy gestures & 3rd-party editors** (drag-drop reorder, file drop, Milkdown/CodeMirror) deferred to a future standalone Playwright **E2E** layer, not the in-repo browser project. Extract and node-test their pure logic where practical.
33
39
  - **Coverage % targets**. They're the wrong goal for a component library.
34
40
 
35
41
  ## Running tests
36
42
 
37
43
  ```bash
38
- pnpm run test
44
+ pnpm test # both projects (node + browser), one-shot
45
+ pnpm test:watch # watch mode
46
+ pnpm test:ui # Vitest UI (handy for the browser project)
39
47
  ```
40
48
 
41
- Vitest is configured to run everything under `src/`. Tests live next to the code they test: `foo.ts` `foo.test.ts`.
49
+ Vitest runs **two projects**, routed by filename: a node project for `*.test.ts` and a real-browser
50
+ (Chromium, via Playwright) project for `*.svelte.test.ts`. Tests live next to the code they test:
51
+ `foo.ts` → `foo.test.ts`; `Foo.svelte` → `Foo.svelte.test.ts`. The browser binary is installed once
52
+ with `pnpm exec playwright install chromium`.
42
53
 
43
54
  ## Writing a test
44
55
 
@@ -65,8 +76,16 @@ import type { TranslateFn } from "$lib/types.js";
65
76
  const t: TranslateFn = (k) => k;
66
77
  ```
67
78
 
79
+ For **component** tests (`*.svelte.test.ts`), the patterns differ — `render()` from
80
+ `vitest-browser-svelte`, locators, and the retry-able `expect.element`; events are props (assert with
81
+ spies); snippet children come from `createRawSnippet`. See
82
+ [`component-testing/02-test-conventions.md`](./component-testing/02-test-conventions.md) for the full
83
+ how-to and the "what to assert" checklist.
84
+
68
85
  ## When in doubt
69
86
 
70
- - **Logic in a `.ts` file with clear input/output?** Write a test.
87
+ - **Logic in a `.ts` file with clear input/output?** Write a `*.test.ts`.
88
+ - **A component behavior that's a real contract** (event fires, value binds, focus traps, position
89
+ clamps)? Write a `*.svelte.test.ts`.
71
90
  - **A regression just bit you in production?** Write a test for that specific case before fixing.
72
- - **Anything else?** Probably don't bother.
91
+ - **"Does it render with these 12 props?"** Probably don't bother — the build already covers that.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/stuic",
3
- "version": "3.114.0",
3
+ "version": "3.115.0",
4
4
  "scripts": {
5
5
  "dev": "vite dev",
6
6
  "build": "vite build && pnpm run prepack",
@@ -13,7 +13,9 @@
13
13
  "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
14
14
  "format": "prettier --write .",
15
15
  "lint": "eslint . && prettier --check .",
16
- "test": "vitest --dir src/",
16
+ "test": "vitest run",
17
+ "test:watch": "vitest",
18
+ "test:ui": "vitest --ui",
17
19
  "svelte-check": "svelte-check",
18
20
  "svelte-package": "svelte-package",
19
21
  "rp": "pnpm run build && ./release.sh patch",
@@ -145,9 +147,11 @@
145
147
  "@tailwindcss/typography": "^0.5.19",
146
148
  "@tailwindcss/vite": "^4.3.0",
147
149
  "@types/node": "^25.9.2",
150
+ "@vitest/browser-playwright": "^4.1.8",
148
151
  "dotenv": "^16.6.1",
149
152
  "eslint": "^9.39.4",
150
153
  "globals": "^16.5.0",
154
+ "playwright": "^1.60.0",
151
155
  "prettier": "^3.8.3",
152
156
  "prettier-plugin-svelte": "^3.5.2",
153
157
  "publint": "^0.3.21",
@@ -158,7 +162,8 @@
158
162
  "typescript": "^5.9.3",
159
163
  "typescript-eslint": "^8.60.1",
160
164
  "vite": "^7.3.5",
161
- "vitest": "^3.2.6"
165
+ "vitest": "^4.1.8",
166
+ "vitest-browser-svelte": "^2.1.1"
162
167
  },
163
168
  "dependencies": {
164
169
  "@marianmeres/clog": "^3.21.0",