@marianmeres/stuic 3.113.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.
@@ -1,6 +1,7 @@
1
1
  import { mount, unmount } from "svelte";
2
2
  import { twMerge } from "../../utils/tw-merge.js";
3
3
  import { addAnchorName, removeAnchorName } from "../../utils/anchor-name.js";
4
+ import { clampIntoViewport } from "../../utils/anchor-position.js";
4
5
  import { iconX } from "../../icons/index.js";
5
6
  import { BodyScroll } from "../../utils/body-scroll-locker.js";
6
7
  import PopoverContent from "./PopoverContent.svelte";
@@ -434,8 +435,15 @@ export function popover(anchorEl, fn) {
434
435
  requestAnimationFrame(() => {
435
436
  if (!popoverEl)
436
437
  return;
438
+ // Clamp into the viewport first. The discrete @position-try
439
+ // fallbacks can leave a residual overflow (and don't cover
440
+ // sub-pixel/vertical cases); clamping keeps small edge-anchored
441
+ // popovers anchored instead of switching them to a modal.
442
+ clampIntoViewport(popoverEl);
437
443
  const rect = popoverEl.getBoundingClientRect();
438
444
  const viewportWidth = window.innerWidth;
445
+ // If it STILL overflows horizontally after clamping, the content
446
+ // is too wide to fit anchored — fall back to the centered modal.
439
447
  if (rect.left < 0 || rect.right > viewportWidth) {
440
448
  debug("overflow detected, switching to fallback mode");
441
449
  switchingToFallback = true;
@@ -26,11 +26,18 @@
26
26
 
27
27
  @supports (anchor-name: --anchor) {
28
28
  .stuic-spotlight-annotation {
29
- position-try-order: most-width;
29
+ /* The spotlight action overrides these inline per-instance, tailoring the
30
+ fallbacks to each annotation's position (see buildPositionTryFallbacks).
31
+ These are a sane default for the centered `bottom` placement. `normal`
32
+ order keeps the base position when it fits and only engages the span
33
+ fallbacks on overflow; the JS clamp is the final backstop. */
34
+ position-try-order: normal;
30
35
  position-try-fallbacks:
31
36
  flip-block,
32
37
  flip-inline,
33
- flip-block flip-inline;
38
+ flip-block flip-inline,
39
+ bottom span-left,
40
+ bottom span-right;
34
41
 
35
42
  &.spot-block {
36
43
  display: block;
@@ -1,6 +1,7 @@
1
1
  import { mount, unmount } from "svelte";
2
2
  import { twMerge } from "../../utils/tw-merge.js";
3
3
  import { addAnchorName, removeAnchorName } from "../../utils/anchor-name.js";
4
+ import { buildPositionTryFallbacks, clampIntoViewport, } from "../../utils/anchor-position.js";
4
5
  import { BodyScroll } from "../../utils/body-scroll-locker.js";
5
6
  import SpotlightContent from "./SpotlightContent.svelte";
6
7
  //
@@ -189,6 +190,9 @@ export function spotlight(targetEl, fn) {
189
190
  let resizeObserver = null;
190
191
  let rafId = null;
191
192
  let lastRect = null;
193
+ // True once the annotation is display:block and laid out — gates the
194
+ // viewport clamp so it never measures a `display:none` element (zeros).
195
+ let annotationShown = false;
192
196
  // Unique identifiers
193
197
  const rnd = Math.random().toString(36).slice(2);
194
198
  const anchorName = `--anchor-spotlight-${rnd}`;
@@ -230,9 +234,14 @@ export function spotlight(targetEl, fn) {
230
234
  anchorEl.style.width = `${rect.width + padding * 2}px`;
231
235
  anchorEl.style.height = `${rect.height + padding * 2}px`;
232
236
  }
233
- // Update fallback annotation position
234
- if (annotationEl && !isSupported) {
235
- positionAnnotationFallback(rect, padding);
237
+ // Reposition / re-clamp the annotation. The fallback path recomputes its
238
+ // base left/top here; the anchor path is re-placed by the browser. Either
239
+ // way we re-clamp so an edge-anchored annotation stays on-screen as the
240
+ // target moves (the anchor path has no built-in viewport clamping).
241
+ if (annotationEl) {
242
+ if (!isSupported)
243
+ positionAnnotationFallback(rect, padding);
244
+ clampAnnotationIntoViewport();
236
245
  }
237
246
  }
238
247
  /**
@@ -305,27 +314,21 @@ export function spotlight(targetEl, fn) {
305
314
  annotationEl.style.left = `${x + w + offset}px`;
306
315
  annotationEl.style.top = `${y}px`;
307
316
  }
308
- // Clamp into viewport after layout settles. Using transform keeps the
309
- // underlying left/top intent intact so the next call recomputes cleanly.
310
- requestAnimationFrame(() => {
311
- if (!annotationEl)
312
- return;
313
- const a = annotationEl.getBoundingClientRect();
314
- const m = 8;
315
- const vw = window.innerWidth;
316
- const vh = window.innerHeight;
317
- let dx = 0;
318
- let dy = 0;
319
- if (a.left < m)
320
- dx = m - a.left;
321
- else if (a.right > vw - m)
322
- dx = vw - m - a.right;
323
- if (a.top < m)
324
- dy = m - a.top;
325
- else if (a.bottom > vh - m)
326
- dy = vh - m - a.bottom;
327
- annotationEl.style.transform = dx || dy ? `translate(${dx}px, ${dy}px)` : "";
328
- });
317
+ // Viewport clamping is handled by clampAnnotationIntoViewport(), called by
318
+ // the caller once the annotation is laid out (and on every reposition).
319
+ }
320
+ /**
321
+ * Clamp the annotation into the viewport, on BOTH positioning paths. The CSS
322
+ * Anchor Positioning path has no built-in way to slide a centered annotation
323
+ * back on-screen when the target is near a viewport edge (visible on Android
324
+ * Chrome, which supports anchor positioning; iOS Safari ≤18 takes the JS
325
+ * fallback path). Gated on `annotationShown` so it never measures a
326
+ * `display:none` element. See {@link clampIntoViewport}.
327
+ */
328
+ function clampAnnotationIntoViewport() {
329
+ if (!annotationEl || !annotationShown)
330
+ return;
331
+ clampIntoViewport(annotationEl);
329
332
  }
330
333
  function renderContent() {
331
334
  if (!annotationEl || !currentOptions.content)
@@ -406,8 +409,8 @@ export function spotlight(targetEl, fn) {
406
409
  position: fixed;
407
410
  position-anchor: ${anchorName};
408
411
  position-area: ${POSITION_MAP[currentOptions.position || "bottom"] || "bottom"};
409
- position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
410
- position-try-order: most-width;
412
+ position-try-fallbacks: ${buildPositionTryFallbacks(currentOptions.position || "bottom")};
413
+ position-try-order: normal;
411
414
  max-width: calc(100vw - 1rem);
412
415
  max-height: calc(100vh - 1rem);
413
416
  transition-duration: ${TRANSITION}ms;
@@ -437,6 +440,10 @@ export function spotlight(targetEl, fn) {
437
440
  backdropEl?.classList.add("spot-visible");
438
441
  if (annotationEl) {
439
442
  annotationEl.classList.add("spot-block");
443
+ // Now display:block and laid out — clamp into the viewport before
444
+ // it fades in. Applies to both the anchor and fallback paths.
445
+ annotationShown = true;
446
+ clampAnnotationIntoViewport();
440
447
  requestAnimationFrame(() => {
441
448
  annotationEl?.classList.add("spot-visible");
442
449
  });
@@ -467,6 +474,7 @@ export function spotlight(targetEl, fn) {
467
474
  if (!isVisible)
468
475
  return;
469
476
  isVisible = false;
477
+ annotationShown = false;
470
478
  if (currentOptions.id) {
471
479
  spotlightOpenStates[currentOptions.id] = false;
472
480
  }
@@ -555,6 +563,7 @@ export function spotlight(targetEl, fn) {
555
563
  $effect(() => {
556
564
  return () => {
557
565
  // Cleanup on unmount
566
+ annotationShown = false;
558
567
  if (mountedComponent) {
559
568
  unmount(mountedComponent);
560
569
  mountedComponent = null;
@@ -1,5 +1,6 @@
1
1
  import { twMerge } from "../../utils/tw-merge.js";
2
2
  import { addAnchorName, removeAnchorName } from "../../utils/anchor-name.js";
3
+ import { clampIntoViewport } from "../../utils/anchor-position.js";
3
4
  const TIMEOUT = 200;
4
5
  const TRANSITION = 200;
5
6
  /**
@@ -224,6 +225,10 @@ export function tooltip(anchorEl, fn) {
224
225
  anchorEl.setAttribute("aria-expanded", "true");
225
226
  //
226
227
  tooltipEl.classList.add("tt-block");
228
+ // Backstop: the CSS @position-try fallbacks handle most edge cases,
229
+ // but can leave a residual overflow (and don't cover the "no fallback
230
+ // fits" case) — clamp fully on-screen now that it's laid out.
231
+ clampIntoViewport(tooltipEl);
227
232
  requestAnimationFrame(() => {
228
233
  tooltipEl.classList.add("tt-visible");
229
234
  on_show?.();
@@ -170,9 +170,20 @@ export function validate(el, fn) {
170
170
  // }">`,
171
171
  // { enabled, on, hasCustomValidator: typeof customValidator === "function" }
172
172
  // );
173
+ // Flipped to `true` in this $effect's cleanup. Guards the deferred blur
174
+ // validation (and any other stray late event) from running after the action
175
+ // is torn down — see the guard in `_doValidate` and the `onBlur` deferral.
176
+ let destroyed = false;
173
177
  const _doValidate = () => {
174
178
  if (!enabled)
175
179
  return;
180
+ // Bail if the action has already been torn down. Together with the
181
+ // deferral in `onBlur`, this makes the deferred validation a guaranteed
182
+ // no-op after unmount even in the rare case the node stays connected —
183
+ // e.g. a keyed `{#each}` move that destroys this effect while the DOM
184
+ // node persists, where the `isConnected` check below would still pass.
185
+ if (destroyed)
186
+ return;
176
187
  // A focused, dirty field torn down by a route change fires a final
177
188
  // synchronous `change`/`blur` while being removed from the DOM. That
178
189
  // removal runs inside Svelte's flush, so writing `validation` state here
@@ -241,13 +252,36 @@ export function validate(el, fn) {
241
252
  let _touchCount = 0;
242
253
  const onFocus = () => _touchCount++;
243
254
  el.addEventListener("focus", onFocus);
244
- // also validate on first blur
255
+ // also validate on first blur — but DEFERRED out of the current task.
256
+ //
257
+ // When a focused, touched field is unmounted (e.g. a successful submit
258
+ // navigates away and tears down the form), the browser fires a final
259
+ // synchronous `blur` *during* Svelte's destroy flush, while the node is
260
+ // still connected. Running `_doValidate` there reads any consumer `$derived`
261
+ // belonging to the now-destroyed effect (`derived_inert` warning) and writes
262
+ // the parent's `validation` `$state` (`state_unsafe_mutation`, uncaught) —
263
+ // the existing `isConnected` guard misses it because the node hasn't been
264
+ // detached yet at blur time.
265
+ //
266
+ // A microtask runs *after* the synchronous flush completes: by then a
267
+ // torn-down node is detached and this $effect's cleanup has set `destroyed`,
268
+ // so `_doValidate`'s guards bail (no derived read, no state write). On a real
269
+ // user blur the field is still connected and alive, so validation runs as
270
+ // before — just one microtask later (imperceptible).
271
+ //
272
+ // Only `blur` is deferred. The `change`/`input` listener and the imperative
273
+ // `setDoValidate` path must stay synchronous: `onSubmitValidityCheck`
274
+ // dispatches synthetic `input`/`change` and reads `el.validity` immediately,
275
+ // and `FieldInput.validate()` reads `validation` right after invoking the
276
+ // exposed validator — deferring either would break submit-time validation.
245
277
  const onBlur = () => {
246
- if (_touchCount === 1)
247
- _doValidate();
278
+ if (_touchCount !== 1)
279
+ return;
280
+ queueMicrotask(_doValidate);
248
281
  };
249
282
  el.addEventListener("blur", onBlur);
250
283
  return () => {
284
+ destroyed = true;
251
285
  el.removeEventListener(on, _doValidate);
252
286
  el.removeEventListener("focus", onFocus);
253
287
  el.removeEventListener("blur", onBlur);
@@ -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,41 @@
1
+ /**
2
+ * Shared helpers for CSS Anchor Positioning based actions (spotlight, popover,
3
+ * tooltip).
4
+ */
5
+ /**
6
+ * Builds the `position-try-fallbacks` value for an anchored element at a given
7
+ * position.
8
+ *
9
+ * For the centered positions (`top`/`bottom` are inline-centered; `left`/`right`
10
+ * are block-centered) a `flip-inline`/`flip-block` is a no-op on the centered
11
+ * axis, so the browser cannot slide the element back on-screen when the target
12
+ * sits near a viewport edge. We append `span-*` variants that give it an edge to
13
+ * align to. A JS clamp (see {@link clampIntoViewport}) should still be used as
14
+ * the ultimate backstop; these fallbacks just yield nicer native placement.
15
+ *
16
+ * Note: tooltip/popover declare their fallbacks via `@position-try` named rules
17
+ * in CSS instead and don't use this; it's primarily for the spotlight action,
18
+ * which sets `position-try-fallbacks` inline.
19
+ */
20
+ export declare function buildPositionTryFallbacks(position: string): string;
21
+ /**
22
+ * Pull an element fully into the viewport with a corrective `transform`.
23
+ *
24
+ * This is the backstop for CSS Anchor Positioning: `position-try` can only swap
25
+ * between discrete declared positions and cannot slide a centered annotation
26
+ * back on-screen when the target is near a viewport edge — so without this an
27
+ * anchored element can render off-screen on browsers that support anchor
28
+ * positioning (e.g. Android Chrome).
29
+ *
30
+ * Synchronous and flicker-free: it clears any prior transform, force-measures
31
+ * the natural rect (`getBoundingClientRect` triggers a synchronous layout), then
32
+ * applies a single translate — all within one JS turn, so the browser only
33
+ * paints the final, clamped position. The element MUST be laid out
34
+ * (`display: block`) when called, and `transform` MUST NOT be in its
35
+ * `transition-property` (callers use `transition-property: opacity`) so the
36
+ * correction applies instantly. The caller owns the element's `transform`.
37
+ *
38
+ * @param el - The (anchored, position:fixed) element to clamp
39
+ * @param margin - Minimum gap from each viewport edge, in px (default 8)
40
+ */
41
+ export declare function clampIntoViewport(el: HTMLElement, margin?: number): void;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Shared helpers for CSS Anchor Positioning based actions (spotlight, popover,
3
+ * tooltip).
4
+ */
5
+ /**
6
+ * Builds the `position-try-fallbacks` value for an anchored element at a given
7
+ * position.
8
+ *
9
+ * For the centered positions (`top`/`bottom` are inline-centered; `left`/`right`
10
+ * are block-centered) a `flip-inline`/`flip-block` is a no-op on the centered
11
+ * axis, so the browser cannot slide the element back on-screen when the target
12
+ * sits near a viewport edge. We append `span-*` variants that give it an edge to
13
+ * align to. A JS clamp (see {@link clampIntoViewport}) should still be used as
14
+ * the ultimate backstop; these fallbacks just yield nicer native placement.
15
+ *
16
+ * Note: tooltip/popover declare their fallbacks via `@position-try` named rules
17
+ * in CSS instead and don't use this; it's primarily for the spotlight action,
18
+ * which sets `position-try-fallbacks` inline.
19
+ */
20
+ export function buildPositionTryFallbacks(position) {
21
+ const flips = "flip-block, flip-inline, flip-block flip-inline";
22
+ if (position === "top" || position === "bottom") {
23
+ return `${flips}, ${position} span-left, ${position} span-right`;
24
+ }
25
+ if (position === "left" || position === "right") {
26
+ return `${flips}, ${position} span-top, ${position} span-bottom`;
27
+ }
28
+ return flips;
29
+ }
30
+ /**
31
+ * Pull an element fully into the viewport with a corrective `transform`.
32
+ *
33
+ * This is the backstop for CSS Anchor Positioning: `position-try` can only swap
34
+ * between discrete declared positions and cannot slide a centered annotation
35
+ * back on-screen when the target is near a viewport edge — so without this an
36
+ * anchored element can render off-screen on browsers that support anchor
37
+ * positioning (e.g. Android Chrome).
38
+ *
39
+ * Synchronous and flicker-free: it clears any prior transform, force-measures
40
+ * the natural rect (`getBoundingClientRect` triggers a synchronous layout), then
41
+ * applies a single translate — all within one JS turn, so the browser only
42
+ * paints the final, clamped position. The element MUST be laid out
43
+ * (`display: block`) when called, and `transform` MUST NOT be in its
44
+ * `transition-property` (callers use `transition-property: opacity`) so the
45
+ * correction applies instantly. The caller owns the element's `transform`.
46
+ *
47
+ * @param el - The (anchored, position:fixed) element to clamp
48
+ * @param margin - Minimum gap from each viewport edge, in px (default 8)
49
+ */
50
+ export function clampIntoViewport(el, margin = 8) {
51
+ // Remove any prior correction so we measure the natural (anchored or
52
+ // left/top) position, then recompute from scratch.
53
+ el.style.transform = "";
54
+ const a = el.getBoundingClientRect();
55
+ const vw = window.innerWidth;
56
+ const vh = window.innerHeight;
57
+ let dx = 0;
58
+ let dy = 0;
59
+ if (a.left < margin)
60
+ dx = margin - a.left;
61
+ else if (a.right > vw - margin)
62
+ dx = vw - margin - a.right;
63
+ if (a.top < margin)
64
+ dy = margin - a.top;
65
+ else if (a.bottom > vh - margin)
66
+ dy = vh - margin - a.bottom;
67
+ if (dx || dy)
68
+ el.style.transform = `translate(${dx}px, ${dy}px)`;
69
+ }
@@ -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).