Fase 16.2 + 16.3 — replace the bare `m.loading()` text at every
pre-existing async-loading site with `<Spinner size=...> + <span>`.
Text is kept (visual fallback + tooltip / SR redundancy). No new
loading semantics — only existing flags get a visual indicator.
Sites:
- (app)/+layout.svelte: auth splash gets `size="lg"` centred under the
loading label.
- (app)/search/+page.svelte: inline `size="sm"` next to the search-in-flight
copy.
- (app)/notes/archive/+page.svelte: inline `size="sm"` next to the
archive-load copy.
- lib/components/CreateCollectiveModal.svelte: inline `size="sm"` inside
the disabled Save button while the create RPC is in flight.
- (app)/collective/manage/+page.svelte: three sites — members-list load,
invitation-generate button, and add-common-item Save button.
E2E (tests/e2e/spinner.test.ts):
- SP-E-01 throttles search_in_collective RPC by 500ms and asserts the
spinner is visible in the loading paragraph.
- SP-E-02 throttles set_item_frequency_weight RPC and asserts the spinner
is scoped inside the Save button.
- SP-E-03 seeds an expired `sb-192-auth-token` so GoTrue is forced to
call /auth/v1/token, then hangs that endpoint indefinitely and
asserts the splash spinner is visible. Storage key derivation matches
Supabase's `sb-${hostname.split('.')[0]}-auth-token` default; we
populate both the LAN-IP variant and a loopback fallback for
resilience.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Fase 16.1 — reusable inline loading indicator. SVG circle with
stroke-dasharray + Tailwind animate-spin (respects motion-reduce).
Sizes: sm=16, md=24, lg=40. Inherits currentColor so it plays nicely
with the Fase 9 theme tokens. Wrapper has role=status + aria-live=polite
and an sr-only m.loading() label (no new Paraglide strings).
vitest.config: resolve.conditions=['browser'] + inline svelte so the
Svelte 5 mount() API resolves to index-client.js instead of the server
entry. Without it the new Spinner.test throws lifecycle_function_unavailable.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
/lists/[id]/+page.svelte now passes the current item names through to
fetchSuggestions as excludeNames, both on initial cold-load and on every
debounced keystroke. The exclude list is a `$derived` of `items` so it
recomputes the moment an optimistic insert lands — the next keystroke
no longer surfaces the freshly-added item.
ItemSuggestions.svelte gets a stable data-testid handle on the strip
plus per-chip testids so the suite can assert presence/absence without
fragile text matches. The component still hides itself entirely when
the post-filter suggestion list is empty (the dropdown was already
gated on `suggestions.length > 0`).
Playwright CI-02 lives on a freshly-created list (the seed list is
shared with the entire suite and tends to accumulate hundreds of test
items, which both buries the assertion target and inflates the
excludeNames URL). Boosts two synthetic names heavily so they lead
the dropdown regardless of pollution in item_frequency, verifies
"added" → "next fetch excludes it", and tears its own rows down via
the admin purge RPC (direct table deletes are blocked by the deny-all
RLS from migration 006).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds an OfflineChip surfaced in the MobileTopBar (compact, icon-only)
and at the bottom of the DesktopSidebar (full pill with label). Hidden
while online. When `navigator.onLine` is false:
- The chip mounts with the existing amber palette
- A title tooltip explains the implication ("changes will sync when
you reconnect")
- If `pendingOpsCount > 0`, a small numeric badge is appended so the
user sees the actual backlog size at a glance
`pendingOpsCount` is the existing store (kept in sync by SyncQueue);
nothing new wires up — we only surface what was already tracked.
OF-01 e2e: drives `context.setOffline(true)`, asserts the chip
appears, queues an INSERT, asserts the numeric badge renders, and
verifies the chip disappears when back online. Added alongside the
existing O-01/O-02 (banner-based) so we keep coverage of both surfaces.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Migrate from `registerSW({ immediate: true })` to `useRegisterSW` from
`virtual:pwa-register/svelte`, mirror its `needRefresh` / `offlineReady`
stores into stable layout-local writables, and render a new
`UpdateToast` pill (bottom-center) that surfaces:
- "New version available" + a "Reload" button that calls
`updateServiceWorker(true)`
- "Ready to use offline" (auto-dismiss after 4s) on first install
Adds `[pwa] …` console.info telemetry on register / need-refresh /
apply. Stand-in for the unavailable `updateViaCache: 'none'` runtime
option (RegisterSWOptions doesn't expose it in vite-plugin-pwa 0.21.2):
poll `reg.update()` every 15min, which keeps a too-aggressively-cached
SW file from pinning users on the previous build for up to 24h.
`apps/web/src/global.d.ts` declares the virtual module's surface inline
so svelte-check picks it up without a relative path into node_modules;
the same file pre-declares the Fase-14.3 `__APP_VERSION__` triplet.
PU-01 stubs the registrar via `window.__pwaRegisterStub` (the layout
checks for it before calling the real `useRegisterSW`) so the toast
flow can be driven from a Playwright test without a second build.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
13.3 admin UI:
- (admin) route group with own layout + red banner + sidebar (Collectives,
Admins, Audit log, Server). +layout.ts is client-rendered (ssr=false),
same rationale as the (app) group.
- /admin/collectives — paginated list via admin_list_collectives, search
by name, soft-delete / restore / hard-delete modals (hard-delete needs
explicit checkbox; force toggle bypasses the 30-day wait server-side).
- /admin/collectives/[id] — members list with kick action; recent actions
for the collective filtered from admin_actions.
- /admin/admins — list via server_admins join to users (disambiguated by
FK name — there are TWO FKs to users so the embed needs the explicit
server_admins_user_id_fkey); promote modal does an email lookup; revoke
button is replaced by "you (cannot revoke yourself while sole admin)"
when applicable.
- /admin/audit — paginated feed (default 50), action filter.
- /admin/server — server-layer default-section toggles backed by
admin_set_default_section + the new admin_clear_default_section RPC
(added because patching the JSONB to `true` is NOT equivalent to "no
opinion" — `true` explicitly overrides a collective OFF).
13.4 sidebar/drawer entry:
- $isServerAdmin store (writable; +$isServerAdminLoaded for the gate
race) refreshed on every onAuthStateChange in root layout, cleared on
sign-out. Cached so the sidebar tile is synchronous.
- DesktopSidebar + MobileDrawer render the entry only when the store is
true. Distinct red icon so it never blends with normal nav.
Stores wiring:
- features.ts gains serverSectionDefaults writable + enabledSections
derived now reads server > collective > user > default. Loader
piggybacks on loadCurrentUserFeatures so the server layer is fetched
once per sign-in.
- serverAdmin.ts new module — refreshServerAdminFlag() + clearServerAdminFlag().
- Tracks isServerAdminLoaded so the (admin) layout doesn't redirect away
during the millisecond between SIGNED_IN and the RPC resolving (caught
empirically — every hard-load to /admin/* bounced to / without it).
13.5 tests:
- admin.test.ts (SA-01..SA-04) with new loginAsAdmin fixture. Ana is the
seed admin (server_admins seeded via supabase/seed.sql).
- SA-04 + SV-02 reset their server-layer JSONB via the new
admin_clear_default_section RPC so the suites don't leak state into
each other.
Migration 025 adds admin_clear_default_section(text) — same SECURITY DEFINER
+ audit pattern as the rest. pgTAP 017 updated (23 plans, AR-T13b + the
SECURITY DEFINER list now includes the clear RPC).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
UI side:
* DesktopSidebar / BottomTabBar filter their entries by $enabledSections,
tag each entry with data-section, and BottomTabBar exposes a
data-section-count attribute so the grid is observable from tests.
* "lists" is force-shown unconditionally — the §12.3.4 always-one-
landing rule covers the edge case where every layer says OFF.
* (app)/+layout.svelte adds a $effect that, when the URL matches a
disabled section (/tasks, /notes, /search), goto's /lists and shows
a transient `section_disabled_for_collective` toast.
* Root +layout.svelte exposes the supabase singleton on window.__sb
in dev so the new Playwright spec can patch rows without a parallel
client; dead-code-eliminated in production builds.
DB side:
* Migration 023 grows by ALTER PUBLICATION supabase_realtime ADD TABLE
public.users + public.collectives + REPLICA IDENTITY FULL on both.
Without this membership the features.ts subscriptions get zero
events and SV-02 (collective toggle → realtime → member's nav
updates) silently fails. Caught while running the spec.
* pgTAP 015 grows from 16 to 20 assertions to cover publication
membership + REPLICA IDENTITY for both new realtime tables.
Paraglide messages: section_disabled_for_collective, the visibility
section titles + blurbs, section_label_* per SectionKey. Both en + es.
Playwright tests/e2e/section-visibility.test.ts (SV-01..SV-03):
SV-01 user toggle → nav reflects → /tasks redirects to /lists
SV-02 admin collective toggle → member sees it disappear in realtime
SV-03 collective ON beats user OFF — per-collective resolution
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds the client mirror of section_enabled():
* SectionKey / FeatureFlags types + SECTION_KEYS constant in
@colectivo/types so the SQL and TS sides share the same vocabulary.
* apps/web/src/lib/stores/features.ts:
- currentUserFeatures writable, loaded by root layout
- enabledSections derived map { section → boolean }
- enabledSectionList ordered SectionKey[] visible to UI;
forces 'lists' if every layer says OFF
- setUserFeature per-user toggle (optimistic + rollback)
- setCollectiveFeature admin toggle (rolls back on RLS deny)
- realtime subscriptions one channel for the user's row, one
for the active collective; rebuilt
whenever those targets change
Wired into the root layout: loadCurrentUserFeatures runs from inside
the existing setTimeout-deferred onAuthStateChange callback (the
documented gotcha), and currentCollective.subscribe drives
subscribeCollectiveFeatures so collective switches re-target the
realtime filter without an extra auth event.
loadUserCollectives + every other place that fabricates a Collective
object now carries feature_flags (defaulting to {} for freshly created
collectives) so the Collective interface stays sound.
Integration spec (6 tests, SV-01..SV-06) covers default-ON, user-only,
collective-beats-user, RLS writes (user_update_own + collectives_update),
and per-collective isolation for a user in two collectives.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
TagChip — small pill rendering a tag's name with the colour from the 8-preset
palette. Three usage shapes (mutually exclusive):
* display-only (default)
* with onRemove → renders an inline X (used by picker-selected chips)
* with onSelect → renders the whole chip as a button (used by the lateral
filter bar and by item-row chips that filter the list on click)
Nested <button> was the SSR warning from the first cut — fixed by making
onSelect / onRemove mutually exclusive.
TagPicker — combobox-style picker driven by filterTagOptions (pure helper
in tag-picker-filter.ts so it can be unit-tested without rendering). Lists
matches by substring, surfaces a "Create `{query}`" affordance only when no
existing tag matches the trimmed query exactly. Enter key prefers toggling
the first match, falls back to creating. Re-usable from the item modal,
the list-filter bar, and the settings tag-management section.
tag-picker-filter.test.ts — 7 unit tests (TP-01..07) covering case-
insensitive substring match, empty query → full list, create affordance
gating on exact-match absence, exclusion of already-selected ids, and
createName preserving verbatim user input (no lowercasing).
tailwind.config.ts safelists the runtime-built bg-/text-/ring-/dot
combinations for all 8 preset colours so JIT doesn't strip them.
Messages: 9 new keys in en + es (tag picker, filter bar, settings section).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a new "+ New collective" entry at the bottom of the sidebar
collective-switcher dropdown (and the mobile drawer for parity).
Clicking it opens a CreateCollectiveModal that reuses the same
onboarding form — name + emoji grid — and calls the existing
create_collective() RPC (migration 012). On success, the new
collective is appended to $userCollectives, set active in
$currentCollective, persisted to localStorage, and the user is sent
to /lists.
Side effects:
- Switcher now opens even with a single collective (previously gated
on >1) so the entry is always reachable; the chevron is always
shown.
- DesktopSidebar gets a data-testid on the switcher button so the
Playwright suite can target it without text matching.
O-04 (rescued from Fase 7): Eva creates her first collective via
onboarding, then opens the switcher and creates a second one without
leaving /lists; both collectives end up in the switcher and the DB.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes 9.3 in full: migration 016 + pgTAP test 010 + queue.ts
conflict-detection path + /settings debug panel + integration test.
- Migration 016 adds public.sync_conflicts (append-only audit log of
last-write-wins conflicts surfaced by the offline mutation queue).
RLS allows owners to SELECT + INSERT their own rows only; UPDATE
and DELETE have no policy and are silently denied. collective_id
is intentionally nullable so user-level conflicts (theme, language
in future) also fit. Indexed on (user_id, created_at desc) for the
"show me my last 20 conflicts" query.
- queue.ts gains a `QueueContext` (currentUserId + collectiveId) and
an optional `preImage` field on UpdateOp. Before sending an UPDATE
the queue fetches the remote row (limited to the patched fields),
proceeds with the UPDATE (last-write-wins), then fires a best-effort
INSERT into sync_conflicts when the remote diverged. Failure to log
never blocks the UPDATE or pollutes the pending_ops queue. Three
unit specs (SC-01..SC-03) lock the contract in.
- sync/index.ts exposes setSyncContext(), called from (app)/+layout's
$effect whenever currentUser / currentCollective change.
- New SyncConflictsPanel.svelte renders the last 20 conflict rows
for the current user with an "Export JSON" button. Gated on
import.meta.env.DEV (or a forceShow prop for a future secret
unlock) — never ships in the production bundle. Slotted into
/settings just above the Account section.
- Integration tests (packages/test-utils/tests/sync-conflicts.test.ts,
4 SC-INT specs) exercise the real PostgREST + RLS contract end-to-end
with the existing seed users (Ana, Borja). Requires fake-indexeddb in
the test-utils dev deps (queue's IDB shim).
- pgTAP test 010 covers structure, RLS reads/writes, ownership rejection,
and the append-only invariant (UPDATE/DELETE return 0 rows).
- packages/types/src/database.ts adds the sync_conflicts table type.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wires the dark-mode plumbing:
- New $lib/theme module: themePreference + resolvedTheme stores plus
initTheme() / setThemePreference(). Owns localStorage persistence,
<html data-theme=...> mirroring, and the matchMedia listener that
re-resolves "system" when the OS preference flips. Storage-agnostic
(no Supabase coupling) so the caller persists to public.users.theme
separately.
- Inline anti-FOUC script in app.html reads localStorage + matchMedia
synchronously and writes data-theme before the first paint — refresh
in dark mode no longer flashes white.
- Tailwind switched to darkMode: ['selector', '[data-theme="dark"]']
so all existing `dark:` utilities resolve from the same attribute.
- :root and :root[data-theme=light] share the light token set; dark
tokens move into :root[data-theme=dark]. Tokens stay as RGB triplets
(rgb(var(--token) / <alpha>)) per the repo gotcha — no hsl().
- ThemeToggle.svelte: three-way radiogroup (Light / Dark / System) with
paraglide messages in EN + ES.
Tests (TDD): 8 new unit specs in src/lib/theme.test.ts cover defaults,
localStorage round-trip, data-theme reflection, system resolution
against matchMedia, invalid-value fallback, and OS preference change
propagation.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Fase 2b closes the critical-path product differentiator. Two sessions on the
same list see each other's changes in real time, mutations survive network
drops and sync when reconnected, and a dedicated full-screen shopping view
optimises the in-store experience.
2b.1 Realtime
- `$lib/stores/realtimeSync.ts` wraps `postgres_changes` with an `applyItemEvent`
reducer (INSERT/UPDATE/DELETE → next items[]) and a `subscribeToList` helper
that awaits the SUBSCRIBED handshake before returning
- `/lists/[id]` subscribes in onMount, unsubscribes in onDestroy. Uses a
`pendingTempIds` set to deduplicate own-mutation echoes against optimistic
rows (matches by name+created_by+sort_order)
- Client-generated UUIDs for new inserts make the path idempotent: if the
server response is lost and we retry, the second POST hits PK-conflict
which the queue treats as success
- CHECKED section div now carries role="listitem" so Playwright locators
follow the item across sections
2b.2 Offline queue
- `$lib/sync/queue.ts` — SyncQueue class backed by IndexedDB (idb), FIFO flush,
MAX_ATTEMPTS=5 retry budget, PK-conflict-as-success short-circuit
- `$lib/sync/index.ts` — app-wide singleton, window.online listener flushes
automatically, hydrateSyncState() at page load restores pendingOpsCount
- `$lib/stores/syncStatus.ts` — derived store (offline | syncing | synced)
tracking navigator.onLine + queue depth
- SyncBanner component renders the offline/syncing indicator in the detail
and session views
- handleAdd enqueues on failure instead of reverting, so an offline mutation
keeps its optimistic row and syncs on reconnect
2b.3 Modo Compra
- `/lists/[id]/session/+page.svelte` — full-screen overlay (fixed inset-0
z-50) that covers the sidebar while keeping the normal auth routing.
56×56 toggles, flip animation shuffling items between TO BUY / CHECKED,
Finish Shopping confirmation modal → completeList → goto('/lists')
- Link from `/lists/[id]` header (data-testid=start-session) with the
new `list_start_session` message
Testing infra
- apps/web gets its own Vitest config (jsdom + fake-indexeddb/auto) and a
new `pnpm test:unit` script. `just test-all` now chains pgTAP → integration
→ unit → e2e so a single command is the gate.
- packages/test-utils/tests/sync-queue.test.ts (placeholder scaffold) removed —
replaced by `apps/web/src/lib/sync/queue.test.ts` co-located with the module
Totals: 103 tests green
16 pgTAP
59 Vitest integration (in packages/test-utils)
6 Vitest unit (in apps/web — the SyncQueue)
22 Playwright E2E (5 auth + 4 lists + 6 items + 2 realtime + 2 offline + 3 session)
2 skipped (realtime-presence — upstream bug, unchanged)
Documented in plan/fase-2b and CLAUDE.md.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>