Fase 16.Z. Documents the component decisions, the five integration sites, the unit + e2e test surface (5 + 3 specs), the vitest browser- condition tweak, the Supabase storage-key derivation gotcha used by SP-E-03, and the cumulative MVP2 test count (497 → 505). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
6.9 KiB
Fase 16 — Loading spinner
MVP2 · 8/8. Reopened after Fase 15 to cover the missing visual feedback during async operations.
Status: ✅ Shipped 2026-05-18.
A small UX gap had survived through every previous fase: every async-loading site rendered the plain text Loading… and nothing else. Operations under ~100 ms feel instant; anything longer just sat there with stale UI. This fase adds a reusable spinner component and integrates it at the five existing loading sites listed in the plan, with no new loading semantics.
Plan: plan/fase-16-spinner.md. No DB migration, no new Paraglide string.
What changed
16.1 — Spinner.svelte
apps/web/src/lib/components/Spinner.svelte:
- SVG circle with
stroke-dasharray="42 14"driven by Tailwind'sanimate-spin.motion-reduce:animate-nonehonoursprefers-reduced-motion. - Three sizes (
sm/md/lg) map to 16 / 24 / 40 px. Thepxvalue is$derivedfromsizeto keep the Svelte 5 reactivity correct without warnings. classprop is forwarded to the wrapper so callers can colour-shift it (the SVG usescurrentColor, so it integrates with the Fase 9 theme tokens out of the box).role="status" aria-live="polite"wrapper around the SVG, plus ansr-onlym.loading()label. No new Paraglide strings — the existingloadingmessage is reused.data-testid="spinner"on the SVG itself so e2e and unit tests can address it without scraping classes.
16.2 — Integration at five existing loading sites
The plan listed exactly five sites that already had a visible loading indicator. Each one keeps the m.loading() text as a visual + a11y fallback and gains a spinner alongside; no new flag was introduced anywhere.
apps/web/src/routes/(app)/+layout.svelte— auth splash getssize="lg"centred above the loading label.apps/web/src/routes/(app)/search/+page.svelte— inlinesize="sm"next to the search-in-flight copy.apps/web/src/routes/(app)/notes/archive/+page.svelte— inlinesize="sm"next to the archive-load copy.apps/web/src/lib/components/CreateCollectiveModal.svelte— inlinesize="sm"inside the disabled Save button while thecreatingflag is true.apps/web/src/routes/(app)/collective/manage/+page.svelte— three sites: members-list load (loading), invitation-generate button (generating), and add-common-item Save button (addSaving). The Common Items list itself uses the existing$commonItemsstore which already short-circuits before the table is rendered, so it didn't need a dedicated spinner.
16.3 — Tests
Unit (apps/web/src/lib/components/Spinner.test.ts)
Five specs covering the public surface of the component, using Svelte 5's mount() + unmount() directly (no @testing-library/svelte dependency — it isn't in the repo).
- SP-U-01 default size renders a 24x24 SVG.
- SP-U-02
size="sm"→ 16x16,size="lg"→ 40x40. - SP-U-03 wrapper exposes
role="status"+aria-live="polite". - SP-U-04 sr-only label content equals
m.loading()(compared against the Paraglide message, not the literal string). - SP-U-05
classprop is forwarded onto the wrapper element.
apps/web/vitest.config.ts had to grow resolve.conditions: ['browser'] + an inline of svelte so mount() resolves to index-client.js. Without it, vitest picked Svelte's server entry and mount() threw lifecycle_function_unavailable. The other seven unit suites still pass under the new config — no behavioural drift.
E2E (apps/web/tests/e2e/spinner.test.ts)
Three Playwright specs, all using page.route to add deterministic latency so the spinner assertion window is wide enough for the auto-wait.
- SP-E-01
/search— throttles thesearch_in_collectiveRPC by 500 ms, fills the input, asserts the spinner is visible inside the loading paragraph and then disappears once the RPC resolves. - SP-E-02
/collective/manage(Ana, admin) — throttlesset_item_frequency_weight, opens the Add common item modal, types a unique name, clicks Save, and asserts the spinner is visible scoped inside the Save button. - SP-E-03 auth splash — the hardest one. The splash is gated on
$authLoading, which only flips to false onceonAuthStateChangefiresINITIAL_SESSION. On loopback that is microseconds, too fast for Playwright. We seed an expiredsb-${hostname-first-label}-auth-token(the storage key Supabase derives fromPUBLIC_SUPABASE_URL's hostname) so GoTrue's_recoverAndRefreshis forced to call/auth/v1/token?grant_type=refresh_tokenbefore emittingINITIAL_SESSION, then we hang that endpoint indefinitely with a Promise that's only resolved in the test'sfinallyblock.authLoadingstays true for the assertion window. Bothsb-192-auth-token(LAN-IP dev) andsb-localhost-auth-token(loopback fallback) are seeded so the test is resilient to aPUBLIC_SUPABASE_URLswap.
Test deltas (Fase 16 only)
| Suite | Before | After | Δ |
|---|---|---|---|
| Vitest unit (apps/web) | 47 | 52 | +5 |
| Playwright e2e | 93 | 96 | +3 |
| MVP2 cumulative (all categories) | 497 | 505 | +8 |
(pgTAP, Vitest integration, gated rate-limit unchanged.)
Gotchas / things found while wiring it
- Svelte 5
mount()requires the browser export condition. Vitest's default conditions resolvesveltetoindex-server.js, which doesn't exportmount. Addingresolve.conditions: ['browser']+ inliningsvelteinserver.depsfixes it without touching any other test file. state_referenced_locallywarning onconst px = size === .... The component's props are conceptually fixed for a given mount, but Svelte 5's strict mode still flags reading a prop in non-reactive position. Switching to$derivedremoves the warning and keeps the value tracked if the parent ever wiressizeto a store.- Supabase storage key derivation.
sb-${hostname.split('.')[0]}-auth-tokenwas the surprise — forhttp://192.168.1.167:8001the key issb-192-auth-token, not the project ref or the hostname itself. Knowing this is necessary to seed a session from a PlaywrightaddInitScriptand force a refresh path. Documented inline intests/e2e/spinner.test.tsso the next person doesn't have to re-derive it from the supabase-js source. - Throttle pattern in Playwright.
page.route+await new Promise((r) => setTimeout(r, 500)); await route.continue()is enough to keep Playwright's 30 ms auto-wait cadence from racing past a brief loading state. For SP-E-03 the variant is an indefinitely-hung route (Promise resolved only in the test'sfinally) because a fixed delay still letINITIAL_SESSIONfire too quickly under network jitter.
Out of scope (re-affirmed)
- Skeleton placeholders. Spinner only.
- Determinate progress bars.
- Toast or notification-style "loading" indicators.
- Full-screen overlays.
- Wiring up stores that don't already expose a loading flag (
listsLoading,tasksLoading, etc.).