Commit Graph

78 Commits

Author SHA1 Message Date
59a32f80f7 feat(fase-12): section visibility model — feature_flags + section_enabled()
Adds JSONB `feature_flags` columns on public.users and public.collectives
(default '{}') plus two SQL helpers:

  * public.known_sections() — IMMUTABLE, returns the canonical 4 sections
    (lists, tasks, notes, search). Adding a section is a fresh migration.
  * public.section_enabled(section, user, collective) — STABLE, returns
    boolean. Precedence is COLLECTIVE > USER > default TRUE, evaluated as
    a single COALESCE so Fase 13 can prepend a server layer as a one-line
    diff. Unknown section keys default TRUE (forward-compat).

No new RLS policy: writes to feature_flags piggyback on users_update_own
and collectives_update (migration 003). 16 new pgTAP assertions verify
column shape, function signatures, volatility, and precedence semantics.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 03:47:52 +02:00
c1d0dfaee9 docs(fase-11): history doc + final test counts
Fase 11 closed. 385 tests green (+26 vs Fase 10): pgTAP 96→104, Vitest
integration 151→157, Vitest unit 38→45, Playwright 74→79. 3 skipped
unchanged (2 upstream Realtime presence, 1 gated rate-limit).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 03:41:19 +02:00
2b04edc511 feat(fase-11): settings tag management subsection (11.3.6)
Adds a "Tags" section to /settings between Collectives and Account. Lists
every tag in the active collective with:
  * inline rename — tap the chip, edit, Enter / blur to save
  * recolor swatch row (8 preset dots, current colour outlined)
  * delete button per tag (cascades item attachments via FK)

Uses the tagsStore which is already subscribed to item_tags realtime, so
edits from another tab / device update this view live without a reload.
No new screens; everything is inline in the existing settings layout.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 03:27:36 +02:00
1a6aae2874 feat(fase-11): realtime re-sort on sort_order UPDATE + drag-reorder e2e (11.4)
The drag handle + dndzone + reorderItems batch UPDATE were already wired
on the list detail view (Fase 5.10). One observable contract was missing:
when another client reorders an item, the local realtime UPDATE feed
shallow-merges the new payload onto the existing row but does NOT
re-position the row in the array — so the new order is invisible until
a reload. Fix: sort the `items` array by sort_order after every realtime
UPDATE / INSERT merge. Cheap (n is small per list) and matches the cold-
load order.

E2E (reorder-items.test.ts):
  * RO-01: create 3 items, write a new sort_order via the same PATCH the
    in-page reorderItems() helper does, reload, assert the reorder
    persisted.
  * RO-02: dual-context, Ana reorders, Borja sees the new order via
    realtime within the suite's 10s window — without this commit's
    sort fix the test failed because Borja's DOM order stayed at the
    old order.

Drag emulation in headless Chromium is unreliable for svelte-dnd-action
(native HTML5 DnD). Both tests drive the same write path the page uses
(reorderItems = batch PATCH on sort_order) via fetch from inside the
browser context, using the session token the SDK has already stored.
The realtime + reload paths are unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 03:26:12 +02:00
f28577e165 feat(fase-11): tags on item row + edit modal + list filter bar (11.3.3-5)
Wires the tag model into the list detail view end-to-end.

`/lists/[id]/+page.svelte`:
  * switches the cold-load to loadListItems() so every row carries its
    tag array via the PostgREST embedding (one query, not N+1)
  * items state typed as ItemWithTags; the existing realtime shopping_items
    feed shallow-merges the row payload back onto the cached tag array,
    so attaches survive an UPDATE echo for the same row
  * new tagLinkChannel subscribes to shopping_item_tags `*` events and
    refreshes just the affected item's tags from the server — the delta
    payload doesn't carry the joined tag rows, refetching is simplest
  * item-row chips render below the name on a separate wrap-friendly
    line; each chip is its own filter button (TagChip onSelect)
  * filter bar between the title and the list shows the collective's
    tags (active ones first); selecting toggles a tag; multi-selection
    is intersection; the selection persists in `?tags=foo,bar` via
    history.replaceState so the URL is deep-linkable
  * edit overlay gains a "Tags" section powered by TagPicker — attach +
    detach are optimistic and write through to shopping_item_tags; the
    "Create" affordance creates the tag then attaches it in one click

Drag-reorder handlers were updated to merge `e.detail.items` (the dndzone
subset, which is the post-filter unchecked rows) with EVERY off-zone row,
not just the checked half. Without this, dragging while a tag filter is
active would drop hidden rows from `items`.

E2E (tags.test.ts): TG-01 verifies the full create → attach → filter
loop (3 items shown after activating the filter, seed items hidden); TG-02
deep-links via `?tags=a,b` and asserts intersection; TG-03 sanity-checks
the picker for a second user in the same collective (cross-collective
denial is fully covered by the Vitest IT-03 integration assertion).

All 3 e2e tests pass against the dev stack.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 03:16:03 +02:00
8fa629ad74 feat(fase-11): TagChip + TagPicker components + filter unit tests (11.3.1-2)
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>
2026-05-18 03:08:54 +02:00
c1152fac90 feat(fase-11): tags store + lateral-join item loader (11.2)
apps/web/src/lib/stores/tags.ts — collective-scoped store for item_tags
with realtime subscription per active collective (resub on switch), plus
mutation helpers: createTag, renameTag, recolorTag, deleteTag, and the
many-to-many writers attachTag / detachTag for shopping_item_tags.

apps/web/src/lib/stores/lists.ts — new loadListItems(listId) issues a
single PostgREST embedding (`*, shopping_item_tags(item_tags(*))`) and
flattens the rows to ItemWithTags. Cast goes through `unknown` because
the curated database.ts doesn't declare the shopping_items ↔
shopping_item_tags relationship — runtime resolves it from the FK.

Tests: packages/test-utils/tests/rls-item-tags.test.ts adds 6 integration
assertions covering the policies an authenticated role sees: member
create (IT-01), guest reject (IT-02), non-member empty read (IT-03),
cross-collective attach reject (IT-04), unique violation (IT-05), and
cascade on item delete (IT-06).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 03:04:25 +02:00
4c3552fb3c feat(fase-11): item_tags + shopping_item_tags schema (11.1)
Migration 022 adds two new tables for collective-scoped item tags plus the
many-to-many bridge to shopping_items. Tags carry a colour from an 8-preset
palette enforced by CHECK. RLS uses is_member for SELECT (guests can see)
and is_active_member for writes (guests are read-only). A new STABLE helper
item_collective_id(uuid) resolves the parent collective in a single function
call so the shopping_item_tags policies do not chain two STABLE lookups.

Both tables join the supabase_realtime publication with REPLICA IDENTITY
FULL, matching the shape of 007 for shopping_items / shopping_lists.

pgTAP 014 covers schema invariants (publication, replica identity, unique
+ CHECK constraints, cascade behaviour for both tag delete and collective
delete). Per-role RLS semantics will be covered by the Vitest integration
suite in the next commit — pgTAP runs as postgres which bypasses RLS.

Types: ItemTag, ItemWithTags + ItemTagColor in domain.ts; item_tags,
shopping_item_tags, item_collective_id added by hand to the curated
database.ts (the supabase CLI is not installed locally; just db-types is
gated and skips with a notice).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 03:01:52 +02:00
a868b5a314 docs(fase-10): history doc + CLAUDE.md status + plan closed
Records the closing state of Fase 10: spec coverage now 100% on the
H-series CUs, full FK on-delete audit table, 5 architectural decisions
(Keycloak not cleaned up on account delete, sole-member must dissolve,
type-the-name dissolve confirmation, client-side language detection,
trash via RPC). Bumps the MVP2 progress to 2/6 and the test totals
to 360 active + 3 skipped.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 02:43:29 +02:00
378fcbe5f4 feat(fase-10): auto language detection on first login (10.8)
New $lib/utils/accept-language.ts parses Accept-Language strings
(server-style "es;q=0.9,en;q=0.8") and navigator.languages arrays,
returns 'en' or 'es'. Rule (plan §10.8.1): the highest-q-scored 'es'
entry wins if q >= 0.5; otherwise fall back to 'en'. Unknown
languages (e.g. fr) → 'en'. Pure function, 12 unit tests covering
bare tags, regional variants, q-score ordering, malformed input,
navigator.languages arrays, and the borderline q=0.5 case.

The root +layout.svelte calls maybeBootstrapLanguage() after each
SIGNED_IN: it only fires for genuinely new users (public.users row
created within the last 60s) whose language is still the default
('en'); it reads navigator.languages, runs the parser, and
UPDATEs public.users.language if the result is 'es'. setLanguageTag()
flips the live Paraglide runtime so the user lands on the next
page already in Spanish — no reload needed.

Established users keep their explicit choice (plan §10.8.5 / Riesgo
5). Server-side Accept-Language sniffing was rejected: the OIDC
dance lives outside SvelteKit (Keycloak ↔ GoTrue), so a hooks.server.ts
would never see those requests. navigator.languages is the
client-side equivalent and survives Paraglide's compile-time
tree-shaking.

LANG-01/02 (vitest integration): UPDATE public.users RLS contract
the bootstrap relies on — own row OK, other user's row rejected.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 02:30:09 +02:00
2924b94329 feat(fase-10): reset list button on detail view (10.7)
A list shown at status=completed used to have its only Reset action
buried in the 3-dot menu, forcing the user back to /lists to start
over. Now the title row of /lists/[id] renders a prominent
"Reset list" pill button when status === 'completed' — reusing the
existing handleReset() handler, no new logic. The 3-dot menu entry
stays for the active and archived views.

RST-01 seeds a completed list with a checked item, clicks the new
button, verifies status flips back to active + the item gets
unchecked.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 02:26:41 +02:00
10415bfca8 feat(fase-10): sidebar "+ New collective" entry + modal (10.6)
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>
2026-05-18 02:25:11 +02:00
92ad29696d feat(fase-10): account hard-delete — FK relax + RPC (10.5)
Migration 019 normalizes every \`created_by\` / \`updated_by\` FK from
\`NOT NULL REFERENCES users(id) ON DELETE RESTRICT\` to
\`NULL REFERENCES users(id) ON DELETE SET NULL\`. Affected tables:
collectives, collective_invitations, shopping_lists, shopping_items,
task_lists, tasks, notes (created_by + updated_by). Without this,
delete_account() would be blocked the first time the user creates any
content. Spec §6.3: content survives the user; the row is orphaned with
the FK set to NULL (UI shows "Deleted user" — to be polished later).

Migration 021 adds delete_account():
- two-step delete: public.users (fires the existing promote-oldest-
  admin trigger from migration 002, then CASCADEs collective_members
  and SET-NULLs content FKs), then auth.users (CASCADEs sync_conflicts
  + drops the GoTrue identity row + token rows)
- guard: errcode P0003 when the caller is the sole admin of a
  collective where every other member is a guest (nobody promotable);
  the user must promote someone manually first
- Keycloak is NOT touched (documented limitation in the UI body); the
  user can re-register with the same email and will receive a fresh
  UUID-distinct profile

Settings UI (already shipped with 10.1) wires the modal: explicit body
listing what is and isn't deleted; confirm button only enables when the
user types DELETE exactly. Post-success calls logout() so the Keycloak
SSO cookie is ended too.

pgTAP 013 (8 assertions): RPC exists, regular user delete + cascade +
content-orphan, sole-admin-with-promotable promotes, sole-admin-with-
only-guest blocked with P0003. Playwright DEL-01 covers the
type-the-word UI guard; full "delete + re-login fails" is left to
pgTAP because seeding ephemeral Keycloak accounts in E2E would
contaminate every downstream suite.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 02:21:25 +02:00
5b4ba9aaef feat(fase-10): trash drawer — restore + hard-delete RPCs (10.4)
Migration 020 (019 reserved per plan) introduces two SECURITY DEFINER
RPCs that back the trash drawer's per-row actions:

- restore_list(uuid): clears deleted_at; requires caller to be admin
  or member of the owning collective; rejects if the list is not
  currently in trash (defensive)
- hard_delete_list(uuid): permanently deletes a soft-deleted list;
  same role guard; rejects if the list is still active

Both close the existing UI gap where guest could in principle issue
the equivalent UPDATE (the FROM-UPDATE policy is is_active_member,
which excludes guest, but the surface area is broader than these two
intents — the RPCs make the contract explicit).

The lists drawer already exposed the Restore + Delete-for-ever
buttons; only the store calls swap from chained PostgREST writes to
.rpc() — the UI itself is unchanged.

pgTAP 012 (8 assertions) covers existence, restore happy path,
non-member rejection, hard-delete happy path, active-list guard.
Integration T-10..T-14 (5 vitest) verifies the wire format the store
relies on (rpc errors, row state, active-listing visibility).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 02:14:10 +02:00
83f613ec1e feat(fase-10): CU-H08 dissolve collective — RPC + danger-zone UI
Migration 018 adds public.dissolve_collective(uuid):
- admin-only; non-admin (member/guest/non-member) → errcode P0002
- single DELETE from public.collectives; all child tables cascade
  (collective_members, collective_invitations, shopping_lists →
  shopping_items, task_lists → tasks, notes, item_frequency,
  sync_conflicts — verified by audit, no schema change needed)

/collective/manage gains a "Danger zone" card (admin-only) with a
Dissolve button. The confirmation modal loads live counts of lists,
tasks and notes, displays them in the warning, and requires the user
to type the collective name exactly before the confirm button is
enabled. On success, locals stores drop the collective and the user
is sent to /lists (next collective) or /onboarding (none left).

pgTAP 011 (8 assertions): RPC exists, member/guest/non-member all
rejected with P0002, admin succeeds, collective row gone, child
list cascade-deleted, members cascade-deleted. Playwright D-01..D-03
cover the happy path, the type-the-name guard, and member visibility.

Auxiliary collective fixture (NOT the seed) per test — the seed
collective must survive every run for downstream suites.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 02:11:21 +02:00
5e2ba3a6d6 feat(fase-10): CU-H07 rename collective + change emoji
Admin gets an editable name input + emoji picker at the top of
/collective/manage. Enter / blur / Save button all flush the PostgREST
UPDATE; Esc reverts to the persisted value. The response payload feeds
both $currentCollective and $userCollectives so the sidebar updates
without reload. Members and guests see no editing affordances at all
(the section is admin-gated; UPDATE policies were already in place from
Fase 2a so no migration is needed).

MC-01a: Ana renames + picks a new emoji, reload preserves both, DB
reflects the change. MC-01b: Borja never sees the editable fields.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 02:07:16 +02:00
2f0847a5a3 feat(fase-10): CU-H06 leave collective — RPC + settings UI
Migration 017 adds public.leave_collective(uuid) with three branches:
- normal member: remove membership row
- sole admin with other members: promote oldest-joined remaining member
  to admin (inline; the existing user-delete trigger does not cover the
  membership-delete path), then remove the row
- sole member: reject with errcode P0001 so the UI can direct the user
  to the dissolve flow (CU-H08)

Settings page gains a "Your collectives" section listing the user's
memberships with a per-row Leave button; the confirmation modal calls
the RPC, drops the collective from local stores, and either switches
to the next collective or sends the user to /onboarding when none
remain. Also seeds the Danger zone scaffolding for Fase 10.5 and adds
all message keys consumed by 10.1, 10.3, 10.5 and 10.6.

pgTAP 010 (7 assertions): member-leave, sole-admin-leave + auto-promote,
sole-member rejected with P0001. Playwright L-01 walks Borja through
the UI flow + checks the seed collective survives (content stays).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 02:04:43 +02:00
6cb13146f5 docs(fase-9): history doc + CLAUDE.md status + plan closed
- docs/history/fase-9-polish-dark-mode.md captures the final palette
  (hex + contrast ratios), the four sub-fases (dark mode / reading
  width / sync_conflicts / WebKit specs), the test-count delta
  (259 → 300 + 3 skipped, +41 specs), the upstream-bug audit for
  Realtime presence (#1617 still open as of 2026-05-18, latest stable
  v2.86.3), and the prod-migration note (existing sessions do not
  need a re-login because users.theme is queried, not in the JWT).
- CLAUDE.md project-status line flips Fase 9 to complete, bumps the
  test totals to 300 green / 3 skipped, and adds a new "Testing"
  gotcha for the WebKit project gate (RUN_WEBKIT=1 + libicu74 deps).
- plan/fase-9 marks the phase  with a one-liner about new test counts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 01:53:02 +02:00
cda7321d37 test(fase-9): WebKit project + swipe-toggle gesture specs
Closes 9.4 (swipe-delete E2E in WebKit — repurposed to the current
swipe-to-check / swipe-to-uncheck gesture, since Fase 5.10 replaced
swipe-to-delete with a double-tap delete flow).

- playwright.config.ts gains a second project named "webkit"
  (devices['iPhone 13']) gated behind RUN_WEBKIT=1 so the suite stays
  green on hosts that do not have the GTK/WebKit system libs
  (libicu74, libxml2, libmanette-0.2-0, libwoff1) installed — those
  need `sudo pnpm exec playwright install-deps`. The chromium project
  ignores `*.webkit.test.ts` so the same file never runs in both.
- Justfile adds `just playwright-install` (chromium + webkit) and
  `just test-webkit` for the on-demand run.
- New tests/e2e/swipe-toggle.webkit.test.ts contains SW-01 + SW-02:
  swipe right on an unchecked row marks it checked; swipe left on a
  checked row marks it unchecked. The handlers in lists/[id]/+page.svelte
  use unified pointer events, so the helper dispatches
  PointerEvent('pointer{down,move,up}') with pointerType:'touch'
  directly — works regardless of the project's hasTouch setting.
- lists/[id]/+page.svelte exposes data-checked and data-name on each
  item-row to give the specs a robust locator without depending on
  text/heading hierarchy.

Note: cannot validate the WebKit runs end-to-end in this sandbox
(missing libicu74/libwoff1 require sudo apt install). The spec is
ready to run under any CI image that has Playwright's standard
linux deps installed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 01:37:42 +02:00
ed556ce245 feat(fase-9): wire sync_conflicts table + queue logging + debug panel
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>
2026-05-18 01:31:35 +02:00
a0e00b8044 feat(fase-9): cap reading column at max-w-2xl on desktop detail routes
Closes 9.2 (desktop reading width).

- (app)/+layout.svelte wraps detail routes (/lists/[id], /tasks/[id],
  /notes/[id]) in a max-w-2xl mx-auto container at the md breakpoint
  and up. Mobile (<md) keeps the existing full-width layout — the
  wrapper only kicks in via md:max-w-2xl. The shopping session
  (/lists/[id]/session) is explicitly excluded so the immersive mode
  still occupies the whole viewport.
- A new readingRoutePattern lives alongside the existing
  detailRoutePattern so the two concerns stay independently testable
  (detail = hide global top bar; reading = cap width).
- RW-01..RW-03 in tests/e2e/reading-width.test.ts measure the
  bounding box of [data-testid="reading-column"] under 1280px and
  390px viewports, and confirm the wrapper is absent from /session.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 01:20:20 +02:00
9592879ad7 feat(fase-9): wire ThemeToggle into /settings + cross-device theme sync
Closes 9.1.6 (settings UI), 9.1.7 (reactivity), 9.1.8 (Playwright E2E).

- /settings now has an "Appearance" section housing ThemeToggle. The
  onChange callback fires an UPDATE on public.users.theme so the choice
  follows the user across devices. The DB write is best-effort: a failed
  UPDATE does not roll back the immediate localStorage + <html data-theme>
  flip the toggle already performed.
- Root +layout.svelte calls initTheme() once on mount so the JS-side
  stores hydrate from localStorage (the inline anti-FOUC script in
  app.html has already painted the correct theme by this point — this
  just rebuilds the JS state on top so reactive consumers see it).
- (app)/+layout.svelte gains a one-shot effect that reads
  public.users.theme on first hydration and adopts the remote value when
  it differs from localStorage. Defers the PostgREST query through
  setTimeout(..., 0) per the Supabase auth-lock gotcha.
- tests/e2e/theme.test.ts adds T-01 (toggle persists across reload),
  T-02 (system mode follows emulated prefers-color-scheme), and T-03
  (refresh in dark mode never paints a light first frame, including on
  /logged-out which lives outside the (app) group and relies entirely
  on the inline app.html script).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 01:15:47 +02:00
5873c2fa9f feat(fase-9): ThemeToggle component + anti-FOUC bootstrap
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>
2026-05-18 01:09:19 +02:00
3efe0b9be0 feat(fase-9): migration 015 — users.theme column for dark mode
Adds `theme text not null default 'system' check (theme in
('light','dark','system'))` to public.users. New users get 'system' so
the UI follows the OS preference until they opt in; existing rows
backfill via the default.

Includes pgTAP test 009 (8 assertions covering column existence/type,
default value, valid transitions, and the check constraint rejecting
unknown values). Wires the theme field into the hand-curated
database.ts type so PostgREST queries stay typed.

Also hardens `just db-types` to bail out (instead of truncating
database.ts) when the supabase CLI is missing — the recipe was silently
wiping the file on this dev machine.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 01:06:18 +02:00
93153cd750 docs(plan): brand fases 9–14 as MVP2
Stamps the six planned post-MVP fases as the MVP2 cycle (matching the
`mvp2` branch name). Each plan file gets an "MVP2 · N/6" header. README
status + index tables group them under an MVP2 bracket. CLAUDE.md project
status reframes the planned line as MVP2 and clarifies that Fase 7+8
shipped as MVP cleanup, not MVP2.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 00:42:08 +02:00
6e0814169d docs(plan): fases 11–14 — tags, section visibility, server admin, PWA hardening
Adds four planned-phase docs covering the next post-MVP roadmap:

- Fase 11 — item tags (collective-scoped) + drag-reorder UI for the
  existing `shopping_items.sort_order` column. Migration 021.
- Fase 12 — section visibility via JSONB `feature_flags` on `users` and
  `collectives`, with a `section_enabled()` SQL helper that resolves
  precedence (collective > user > default ON). Migration 022.
- Fase 13 — `/admin` area with a global `server_admins` role, append-only
  `admin_actions` audit log, RPCs for collective CRUD + member removal,
  and server-level section-visibility defaults that sit above Fase 12.
  Migrations 023+024. Depends on Fase 12.
- Fase 14 — PWA hardening: `onNeedRefresh` update toast, explicit offline
  indicator + sync queue badge + conflict surface, `__APP_VERSION__` and
  `__APP_COMMIT__` baked at build with a chip in `/settings`. No migration.
  Depends on Fase 9.3 (`sync_conflicts` wiring).

Also bumps CLAUDE.md project status with the planned-fases pointer, and
brings README.md status + index tables in line with Fase 7, 8, 9, 10
(previously missing) plus the four new ones.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 00:38:23 +02:00
d4a781ddcf deploy(prod): swap NetBird-Traefik labels for off-stack proxy + internal Caddy
The stack no longer talks to any specific external proxy. Removed traefik.*
labels and the shared `traefik` external network from kong + app, and dropped
TRAEFIK_NETWORK / TRAEFIK_ENTRYPOINT / TRAEFIK_CERTRESOLVER from the .env
template and the deploy-script bootstrap.

Internal edge is a `caddy:2-alpine` container on the `colectivo` network,
publishing host port 3000. `infra/Caddyfile.erosi` does the path split:
/rest|/auth|/realtime|/storage|/graphql|/pg -> kong:8000, everything else
-> app:3000. Global block sets `auto_https off` + `admin off` so :3000 is
the only listener.

Whatever proxy fronts the host (NetBird's Traefik, nginx, anything) just
terminates TLS for erosi.limonia.net and forwards plain HTTP to
ambrosio:3000. Off-stack and out of this repo.

Docs (CLAUDE.md + docs/deployment.md) updated to describe the two-layer
edge. Includes the gotcha that bind-mounted file edits (Caddyfile) need
`docker compose restart caddy` since `up -d` won't recreate the container
on file-only changes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 22:26:06 +02:00
dee9ee8014 docs(deploy): runbook + fixes from the limonia.net cutover
Document what we learned doing the real NetBird+Traefik migration:

- Keycloak realm/client names are operator-chosen; the realm-export.json's
  literal "colectivo-web" / "colectivo" values are illustrative, not required.
- Legacy Keycloak /auth/ base path: PUBLIC_KEYCLOAK_URL must include the
  suffix when the deployment serves realms under /auth/realms/... (hit this
  on auth.fosil.eu). Verify with the discovery URL returning 200.
- NetBird's installer deploys Traefik with idleTimeout=0 (unlimited) by
  default — verify instead of prescribing 3600s.
- Runbook: --no-cache fixes the intermittent vite SSR "transforming..."
  hang that surfaces as a PostHog shutdown timeout.
- Runbook: any PUBLIC_* change needs an app rebuild (build args); secret
  changes only need `docker compose restart auth`.
- Runbook: TRUNCATE recipe for wiping all app + auth data while keeping
  schema + migration tracking intact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 01:58:00 +02:00
33b32cae4a deploy(prod): swap host Caddy for NetBird-Traefik labels + external Keycloak
Ambrosio's TLS is now handled by the Traefik that NetBird's self-hosted
installer deploys. kong + app attach to that Traefik network and expose
themselves via Docker-provider labels (erosi-kong priority 100 for
Supabase API paths, erosi-app priority 1 for everything else on
erosi.limonia.net). No container publishes host ports; host Caddy is
out of the deploy path permanently.

Keycloak is no longer bundled — PUBLIC_KEYCLOAK_URL points at an
external IdP. The deploy script writes .env with FILL_IN_* placeholders
for PUBLIC_KEYCLOAK_URL, KEYCLOAK_CLIENT_SECRET, and TRAEFIK_NETWORK,
and fails fast until they are filled.

New domain: erosi.limonia.net. realm-export.erosi.json is retained
as reference for the external Keycloak operator; it is no longer
imported by this stack.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:49:10 +02:00
0e4ada0083 docs(plan): fase-9 (polish + dark mode) and fase-10 (spec completion)
Fase 9 bundles dark mode as the flagship feature plus the four polish items
parked from Fase 5/6 (sync_conflicts wiring, presence avatars still blocked
on supabase/realtime #1617). Fase 10 closes the completion debt found in the
2026-04-22 audit: CU-H06/H07/H08 (leave/dissolve/transfer admin), trash
restore, reset-from-detail, sidebar create-collective, account deletion, and
automatic language detection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:54:26 +02:00
42509cc7c7 chore(graphify): ignore cache + record auth-flow query output
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:53:48 +02:00
b660ead40d chore(graphify): knowledge graph of the repo (1398 nodes, 1627 edges)
Generated via /graphify over the full tree (177 files, ~320k words).
Produces graph.html, graph.json, GRAPH_REPORT.md plus per-file
extraction cache so incremental --update runs reuse prior results.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 16:25:53 +02:00
6bc7092ce5 docs(auth): record fase-8 logout + migration 014 hardening
Some checks failed
CI / Lint & Type-check (push) Failing after 1m12s
CI / Build (push) Has been skipped
Deploy / Build & Push Docker image (push) Failing after 2m28s
Deploy / Deploy to production (push) Has been skipped
- CLAUDE.md status line + logout paragraph + migration 013 gotcha updated
  to cover the INSERT+UPDATE two-step GoTrue behaviour and the RP-initiated
  logout flow; new SvelteKit gotcha block spelling out the signOut→$effect
  race that produced "PKCE code verifier not found in storage" and how the
  isLoggingOut flag + /logged-out public route close it.
- docs/history/fase-8-auth-logout.md captures both prod bugs with symptom,
  root cause, fix, the Keycloak id_token_hint tradeoff (why the one-click
  confirmation page is accepted UX), and the test deltas.
- docs/development.md auth.ts one-liner updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 10:40:07 +02:00
ca12516005 fix(auth): RP-initiated Keycloak logout + PKCE verifier race
Clicking logout previously only called supabase.auth.signOut(): Keycloak's
SSO cookie was untouched, so the (app) layout's $effect immediately
re-fired login() and Keycloak silently re-authenticated — logout looked
broken. That same chain also surfaced "PKCE code verifier not found in
storage" on prod, since signOut()'s async storage clear could race
signInWithOAuth()'s verifier write, or the $effect could double-fire and
overwrite verifiers mid-flight.

- logout(): signOut() then redirect to Keycloak's end_session endpoint
  (client_id + post_logout_redirect_uri=/logged-out). Keycloak shows a
  one-click "Do you want to log out?" confirmation because GoTrue's proxy
  flow does not expose the id_token, so we can't pass id_token_hint.
- isLoggingOut flag + guard in (app) layout $effect: prevents auto-relogin
  during the logout navigation.
- New public /logged-out route, outside the (app) group.
- A-05 rewritten, new A-07 (fresh login must prompt for credentials after
  RP-initiated logout).
- pgTAP 008 extended with 4 assertions for migration 014's UPDATE trigger.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 09:35:17 +02:00
9067ab8f0c fix(auth): also default role/aud on auth.users UPDATE, not just INSERT
GoTrue v2.158.1 emits an UPDATE right after inserting an OIDC-provisioned
user (pop ORM resends the full struct incl. the zero-value role=''),
which bypassed migration 013's BEFORE INSERT trigger and left prod users
with role='' — breaking every PostgREST call with `role "" does not exist`.
Add a matching BEFORE UPDATE trigger reusing the same guard function, and
re-backfill the rows already clobbered.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 15:51:21 +02:00
768b3cda58 docs: split CLAUDE.md into docs/{development,deployment,history/fase-*}
CLAUDE.md shrinks to rules + status + index (175 lines, down from 476).
Dev setup, prod deploy, and per-phase build records move to docs/.
Gotchas regrouped by domain (auth, frontend, PWA, testing, backend, deploy,
platform) and unnumbered so they don't drift as new ones land.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 22:21:20 +02:00
3d85d0a50e fix(deploy): stop docker compose exec -T from draining the heredoc
Symptom observed twice (migrations 012 and 013): deploy-erosi.sh ran the
migration loop's first iteration (the `CREATE TABLE IF NOT EXISTS
_applied_migrations` step) then silently skipped every file in
supabase/migrations/*.sql without printing a single skip/apply line. Had
to `psql -f <new-migration>` by hand on ambrosio after every deploy.

Root cause: the deploy uses `ssh host bash -s << 'REMOTE' ... REMOTE` so
the outer bash on the remote reads its own script from stdin. Any
`docker compose exec -T db psql ...` inside that script — particularly
inside a command substitution like `already=$(docker compose exec -T ...)`
— inherits that stdin and consumes it, eating the rest of the heredoc.

Fix: pass `</dev/null` (or the migration file for the actual `apply`
step) to every `docker compose exec -T` call so docker gets an empty /
file-scoped stdin instead of the parent's heredoc.

Verified on ambrosio: deploy now prints all 13 `skip ... (applied)` lines
as expected, and will apply new migrations going forward without manual
intervention.

- CLAUDE.md: gotcha #20 documenting the pattern; applies to any
  heredoc-delivered remote script using `docker compose exec -T` or
  `kubectl exec`.
2026-04-15 00:31:00 +02:00
2294a31942 fix(auth): default empty role/aud on auth.users → 'authenticated'
Found on ambrosio after the first real self-registration: GoTrue v2.158.1
persists OIDC-provisioned users with empty-string role and aud. PostgREST
then dies with `role "" does not exist` on every REST call because its
per-request `SET LOCAL role = $claim` can't resolve ''. Dev never caught
this because seed.sql hardcodes both columns.

- supabase/migrations/013_auth_users_role_default.sql: backfill existing
  rows + BEFORE INSERT trigger on auth.users that fills empty/NULL role +
  aud with 'authenticated'. Idempotent, preserves explicit values.
- supabase/tests/008_auth_user_role_default.sql: pgTAP coverage — function
  + trigger exist, backfill left nothing empty, empty-string and NULL
  values get defaulted, explicit non-empty role is preserved.
- CLAUDE.md: status line updated to 255 tests (was 248), new gotcha #19
  documenting the GoTrue quirk + that users in existing sessions must
  re-login once for a fresh JWT after the fix lands on any given env.

Total: 41 pgTAP (was 34) + 140 integration + 15 unit + 58 Playwright + 1
gated rate-limit. Full `just test-all` green.
2026-04-15 00:23:00 +02:00
f5c8cb68be feat(fase-7): collective-flow E2E coverage + fix 3 latent bugs
12 new Playwright tests closing the UI coverage gap in the collective
lifecycle (onboarding → invitation → admin manage). Writing them surfaced
three product-code bugs that have silently been present since Fase 1;
fixed as part of this phase.

Tests:
- tests/e2e/onboarding.test.ts       O-01..O-03 (Eva auto-redirect, happy
                                     path create, empty-name submit-disabled)
- tests/e2e/invitation.test.ts       I-01..I-05 (token generation, accept
                                     logged-out + logged-in, expired, used)
- tests/e2e/manage-collective.test.ts MC-02..MC-05 (promote, demote, remove
                                     member, generate pending invite)
- tests/fixtures/db.ts               resetEva, seedExpiredInvitation,
                                     seedUsedInvitation, restoreSeedMembership,
                                     countMembership — all via raw SQL so they
                                     don't depend on SUPABASE_SERVICE_ROLE_KEY

Bugs found & fixed:
- supabase/migrations/012_create_collective_rpc.sql: atomic
  `create_collective(name, emoji)` SECURITY DEFINER RPC. Fase 1 onboarding
  used `.insert(...).select().single()` which triggers the SELECT policy on
  the fresh row — creator isn't a member yet, so the INSERT was rejected with
  "row-level security" even though the INSERT policy passed. onboarding now
  calls the RPC which inserts both the collective AND the creator-as-admin
  membership in one transaction, bypassing RLS. F-08 in rls-isolation.test.ts
  has been silently masking this bug (only the spoof branch was asserted).

- routes/+layout.svelte: post-login redirect now reads sessionStorage
  `pendingInvitationToken` and routes back to /invitation/<token> instead of
  defaulting to /onboarding or /lists. The invitation page already stashed
  the token before kicking off Keycloak, but nothing read it back.

- routes/(app)/collective/manage/+page.svelte: loadMembers now subscribes
  to currentCollective so it re-runs when the store resolves after cold
  navigation. onMount alone races with the auth listener's first emit and
  the member list stayed empty on direct URL hits.

- routes/invitation/[token]/+page.svelte: awaits authLoading before deciding
  whether to redirect to login. Previously raced with the auth listener and
  triggered a redundant Keycloak round-trip for freshly-authenticated users.

Stable selectors added (Playwright):
- Onboarding: onboarding-create-tab, onboarding-join-tab,
  collective-name-input, collective-submit
- Manage: member-row-<uid>, role-select-<uid>, remove-member-<uid>,
  generate-invite, invite-link
- Invitation: invitation-accept, invitation-error (+ data-error-key attr)

Scope dropped from the plan — UI does not exist, would be feature work:
- O-04: "+ new collective" affordance in the sidebar.
- MC-01: rename-collective input in /collective/manage.
Both flagged as follow-ups in plan/fase-7-collective-flow-tests.md.

Test totals: 34 pgTAP + 140 Vitest integration + 15 Vitest unit + 58
Playwright + 1 gated rate-limit. 3 skipped (2 Realtime presence, 1 gated).
2026-04-14 22:33:33 +02:00
17bc344986 docs(fase-7): plan — collective-flow E2E coverage + ambrosio deploy notes
- plan/fase-7-collective-flow-tests.md: 14 new Playwright tests (O-01..O-04
  onboarding, I-01..I-05 invitation acceptance, MC-01..MC-05 admin manage)
  plus shared fixtures/db.ts helpers (resetEva, seedExpiredInvitation,
  seedUsedInvitation, restoreSeedMembership). Risks + scope-out documented.
- CLAUDE.md: add "Fase 6+ production deploy to ambrosio" section with the
  files / config / discovered fixes for the prod stack; link to the new plan
  from the Project Status line; add gotchas #16 (injectManifest filename
  hardcoded in @vite-pwa/sveltekit 0.6.8) and #17 (supabase/postgres doesn't
  bootstrap its internal roles on a fresh volume).
2026-04-14 21:39:26 +02:00
74e919aa71 feat(deploy): ambrosio production stack (erosi.oier.ovh)
Self-contained compose for a single-VPS deploy behind the host's Caddy.
App + Supabase API share erosi.oier.ovh; Keycloak on auth.oier.ovh.

- infra/docker-compose.erosi.yml — full stack bound to 127.0.0.1 only
  (db, auth, rest, realtime, storage, imgproxy, kong, meta, keycloak, app)
- infra/caddy/erosi.Caddyfile — host-Caddy snippet with TLS + path routing
  (/auth /rest /storage /realtime /graphql → kong, rest → app)
- infra/scripts/deploy-erosi.sh — rsync + first-run secret generation +
  build + migrations + Caddy snippet install
- keycloak/realm-export.erosi.json — prod redirect URIs, registration on,
  client secret as __KEYCLOAK_CLIENT_SECRET__ placeholder (deploy sub'd)
- .env.erosi.example — env template with placeholders

Supporting fixes to make the deploy work on a clean Supabase-postgres volume:

- infra/db-init/00-role-passwords.sh — rewrote as idempotent bootstrap:
  CREATE the Supabase service roles if missing, set passwords on pre-existing
  ones, enable pg_cron/pgcrypto/uuid-ossp, create auth/storage/graphql_public/
  _realtime/realtime schemas, create empty supabase_realtime publication,
  set per-role search_path (auth→auth, storage→storage). Old version only
  ALTERed passwords and relied on the roles already existing — worked for a
  grandfathered dev volume, failed on a fresh prod init.

- infra/db-init/00-role-passwords.sql — removed (folded into .sh).

- apps/web/vite.config.ts — PWA strategy generateSW (was injectManifest).
  The plugin's injectManifest hardcodes the SW source filename to
  service-worker.js and ignores the filename: 'sw.ts' override, making the
  production build fail. generateSW's auto-generated precache-only SW is
  functionally equivalent to our 5-line src/sw.ts.

Tested end-to-end on ambrosio: all 9 containers up, 11 migrations applied,
https://erosi.oier.ovh returns 200, Keycloak OIDC discovery serves the
correct issuer, /auth/v1/settings lists Keycloak as the sole external
provider.
2026-04-14 19:41:33 +02:00
2db5aaac14 Fase 6: deploy readiness (PWA + invitation rate-limit + JWT rotation)
Closes the deferrals from Fase 4 that gated a public deploy of the MVP.

PWA install
- Custom SW at apps/web/src/sw.ts (named sw.ts, not service-worker.ts, so
  SvelteKit doesn't intercept it and block Workbox imports).
- vite.config.ts switched to injectManifest strategy with precache of the
  built shell; no runtime caching of Supabase (offline writes stay on the
  existing $lib/sync pending_ops queue).
- Root +layout.svelte onMount() calls registerSW({ immediate: true }) from
  virtual:pwa-register — the plugin does not auto-register.
- Web manifest with 192/512/512-maskable icons + iOS meta tags
  (apple-mobile-web-app-capable, apple-touch-icon, etc.) in app.html.
- Placeholder icons generated via ImageMagick; replace before public launch
  (noted in apps/web/static/icons/README.md).

Kong rate-limit
- /rest/v1/collective_invitations POST: 20/hour per Authorization header.
  Anti-spam on invitation creation is the only rate-limit that survived —
  auth rate-limits were dropped during execution (see plan §6.3): Keycloak
  already provides bruteForceProtected=true on the realm, and stacking a
  Kong limit on /auth/v1/token throttled legitimate PKCE code exchanges
  during E2E.
- Added 'rate-limiting' to KONG_PLUGINS in docker-compose.dev.yml.
- Discovered: strip_path=true on a full-table path sends "/" upstream and
  PostgREST rejects POST to root with PGRST117. Route carries a
  request-transformer plugin that rewrites the URI back.

JWT rotation + prod template
- infra/scripts/rotate-jwt.sh signs anon + service_role JWTs with a fresh
  48-byte secret via openssl. Prints three values for the operator to
  paste; does not touch files.
- Dev secret rotated as a dry-run. Updated both .env (local, gitignored)
  and apps/web/.env.development. Existing sessions are invalidated — all
  users must re-login once.
- .env.production.example committed with placeholders + rotation notes.

Lighthouse + RLS audit tooling
- `just lighthouse` boots the prod build + Supabase stack and runs
  lighthouse CLI against PWA/A11y/Best-Practices.
- `just rls-audit` runs infra/scripts/rls-audit.sh which signs an Eva JWT
  (non-member) and GETs 8 REST endpoints — all must return []. Complements
  the Vitest rls-audit.test.ts suite.

Tests
- 236 green: 34 pgTAP + 140 Vitest integration + 15 unit + 46 Playwright.
- 1 rate-limit test gated behind RUN_RATE_LIMIT_TESTS=1 (Kong counters are
  shared state); run via `just test-rate-limit` which force-recreates Kong
  first to reset counters.
- 3 skipped in `just test-all` (2 Realtime presence upstream bug, 1 gated
  rate-limit).

Deliberately out of scope: push notifications, CI ephemeral Docker, final
production icons, runtime caching of Supabase (SyncBanner + pending_ops
already covers offline).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 04:48:04 +02:00
1f5b34d55d docs(fase-6): propuesta — deploy readiness (PWA + rate-limit + JWT rotation)
New plan file closing out the Fase 4 deferrals with specifics confirmed
by user on 2026-04-14:
  - 6.1 PWA injectManifest, MINIMAL scope (precache shell only; Supabase
    GETs stay network-only since $lib/sync + SyncBanner already cover
    offline writes + the offline indicator)
  - 6.2 manifest.webmanifest + neutral placeholder icons (192/512/180 +
    maskable 512) + iOS meta tags
  - 6.3 Kong rate-limiting — 10/min + 60/hour on /auth/v1/token per IP,
    30/min on /auth/v1/authorize per IP, 20/hour on invitations POST
    per authenticated user
  - 6.4 JWT rotation — dev rotate now + .env.production.example template
    + infra/scripts/rotate-jwt.sh
  - 6.5 Lighthouse PWA ≥ 90 + scripted rls-audit.sh (curl edition of
    Vitest's rls-audit.test.ts)
  - 6.Z verification

Explicitly out of scope (recorded at bottom of the plan):
  - Push notifications (no current use case)
  - Ephemeral-Docker CI (deferred)
  - Runtime caching of Supabase in the SW (deferred)
  - Final production icon art (placeholders land now; real art before
    public launch)

README phase table + index updated; plan total now 11–16 weeks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 03:39:32 +02:00
5803806295 docs(fase-5.15): title chrome cleanup on detail views
Recorded in plan/fase-5-mobile-ux.md (new §5.15 right next to the
existing 5.13 / 5.14 sections) and CLAUDE.md "Fase 5 — what has been
built" with the list.status → visual signal table.

Code change landed in 73bc51e.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 03:25:28 +02:00
73bc51e0ab feat(detail): drop "ACTIVE LIST" / "TASK MANAGER" eyebrow; show status via pill + strikethrough
Detail views (/lists/[id], /tasks/[id]) no longer show the editorial
uppercase label above the editable title. The title moves up into that
space for a cleaner top.

Status signaling on /lists/[id] (replaces the hardcoded "ACTIVE LIST"):
  - list.status === 'completed'  → title renders text-text-secondary + line-through
  - list.status === 'archived'   → muted pill next to the title
    (data-testid="list-status-pill", slate-200 bg)
  - list.status === 'active'     → no extra chrome, just the title

/tasks/[id] has no status on task_lists — just the label drop.

Verified: just test-all → exit 0, 233 green, 2 skipped.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 03:21:27 +02:00
12b7b7443f docs(fase-5.14): document inline add-form UX change
Recorded in plan/fase-5-mobile-ux.md (new §5.14 with the six UX answers)
and CLAUDE.md "Fase 5 — what has been built" section. Also corrected the
"Fase 2a — what has been built" line that still claimed a sticky create
input for /lists, which was replaced by the masthead "New list" button
back in Fase 5.8.

Code change was committed in 3a11914.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 03:12:49 +02:00
3a11914fc5 feat(lists+tasks): inline add-form at end of list, suggestions below input
UX change per direct user request:

  - Add-item / add-task form no longer sticks to the bottom of the viewport.
    It's now the last element of the unchecked/pending section, sitting
    just above the Checked/Completed section (option `a` of the review).
  - On /lists/[id] the frequency suggestion chips moved from ABOVE the
    input to BELOW it, forming a two-line block (input row on top, chips
    on the second line). Chips keep their responsive behavior — horizontal
    scroll on mobile, wrap on desktop (ItemSuggestions already handles
    that).
  - Empty-state message still shows when the list has no items; the form
    renders below it so you can add the first item without scrolling.
  - The form disappears while selection mode is active on /lists/[id]
    (selection chrome takes over the bottom).

Implementation
  /lists/[id]/+page.svelte
    - Removed the `absolute bottom-0` sticky form block.
    - New `addItemForm` Svelte snippet rendered at the end of unchecked
      items AND in the empty-state branch, gated by `!selectionMode`.
    - ItemSuggestions moved inside the snippet, below the input.
    - pb-32 scroll padding reduced to pb-16/md:pb-6 (no more sticky
      overlapping the last row).
  /tasks/[id]/+page.svelte
    - Same treatment with an `addTaskForm` snippet (no suggestions).

Tests
  All existing D- / T-UI- / selection tests pass unchanged: the locators
  are placeholder-based and the form is still findable regardless of
  placement. `data-testid="add-item-form"` / `"add-task-form"` added as
  future-proof hooks.

Verification
  just test-all → exit 0, 233 green, 2 skipped.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 03:05:07 +02:00
53de447aa5 fix: generateId() fallback for insecure-context origins
Problem
  The phone-preview path (http://192.168.1.167:5173) is a non-secure
  origin. `window.crypto.randomUUID` is gated behind secure contexts
  (HTTPS / localhost / 127.0.0.1 / file://) so it's undefined there,
  and every optimistic-id call site crashed handleAdd with
  `TypeError: crypto.randomUUID is not a function`.

Fix
  apps/web/src/lib/utils/id.ts — generateId() uses crypto.randomUUID()
    when available, otherwise falls back to crypto.getRandomValues()
    + RFC 4122 §4.4 byte assembly. getRandomValues IS exposed on
    insecure contexts (unlike subtle), so the fallback is v4-compliant
    and cryptographically acceptable for client-side optimistic ids.
  Replaced crypto.randomUUID() at every call site:
    stores/lists.ts, stores/tasks.ts, sync/queue.ts, sync/undoQueue.ts
    routes/(app)/lists/[id]/+page.svelte
    routes/(app)/tasks/[id]/+page.svelte
  CLAUDE.md gotcha added: never call crypto.randomUUID() directly —
    always import generateId from $lib/utils/id.

Tests (written first, confirmed failing on main)
  src/lib/utils/id.test.ts (4 tests)
    U-01 returns UUID v4 when randomUUID is available
    U-02 falls back when randomUUID is undefined
    U-03 50 fallback-generated ids are all unique
    U-04 prefers randomUUID when present (doesn't drop into fallback)
  tests/e2e/insecure-context.test.ts (1 test)
    INS-01 stubs crypto.randomUUID = undefined via addInitScript,
      drives the add-item flow, confirms the row renders (if the
      old call site were still there, handleAdd would throw).

Verification
  just test-all → exit 0, 233 green, 2 skipped:
    34 pgTAP (unchanged)
    140 Vitest integration + 2 skipped presence (unchanged)
    15 Vitest unit (was 11, +4 generateId)
    44 Playwright (was 43, +1 INS-01)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 02:41:39 +02:00
ab8ac08226 fix(tasks): match /lists + /notes create pattern
Two concrete problems the user hit:
  1) /tasks overview had no visible "create" button — only an enter-to-submit
     input hidden behind a decorative Plus icon.
  2) /tasks/[id] add-task form had no visible submit button either.

Both now mirror the /lists + /notes pattern:

  /tasks — new "New list" button in the masthead (bg-slate-900 primary
    style matching "New list" on /lists and "New note" on /notes). Click
    creates an empty task_list and navigates to /tasks/[id] where the
    user names it inline via the new big editable title input
    (autosaves after NAME_SAVE_DEBOUNCE_MS, 500 ms — same hook as
    /lists/[id] and /notes/[id]).
  /tasks/[id] — sticky add-task footer gains a filled "+" submit button
    on the right; clicking it or pressing Enter both fire handleAdd.
    Input is now wrapped in the same rounded-lg card used on /lists/[id]
    so the two screens look identical.

i18n
  tasks_new_list = "New list" / "Nueva lista" (en / es).

Tests
  tasks.test.ts helper updated to drive the new button-then-title flow
  (same shape as lists.test.ts). T-UI-01..03 all pass.

just test-all → exit 0, 228 green, 2 skipped.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 02:25:14 +02:00
3dd4f64cb2 test: remove obsolete mobile-swipe-delete.test.ts
The file tested a Fase 2b.5 interaction — full-swipe commits a delete
with an undo toast — that Fase 5.10 replaced. Swipes on the list detail
view are now a symmetric check/uncheck toggle; delete lives in the
double-tap overlay and in selection mode. The two tests were
describe.skip'd since Fase 5.10 and the UX they describe doesn't exist
anymore.

The swipe-toggle gesture that replaced it is still untested at the E2E
level because Chromium touch emulation can't drive pointer/touch events
into Svelte's component handlers reliably. Adding `playwright install
webkit` to CI would unblock a fresh `mobile-swipe-toggle.test.ts` with
M-06 swipe-right-check, M-07 swipe-left-uncheck, and M-08 wrong-
direction snapback. Noted in plan/fase-5-mobile-ux.md §5.10.b.

Verification
  just test-all → 228 green, 2 skipped (both are the upstream
  Realtime presence bug in realtime-presence.test.ts; everything else
  passes).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 01:22:21 +02:00