Files
pad/web
xarmian 57820422a2 fix(web): four singleton stores kept the previous user's data across an identity change (BUG-3005) (#1325)
* feat(web): an ORDERED identity epoch, so a store can tell "still me" from "me again" (BUG-3005, unit 1)

Four singleton stores hold per-user state and none of them can currently ask
the question they need to ask when a response comes back: was this issued
under the identity that is signed in now.

The tree's existing answer is a captured user id compared at settle
(`workspace.svelte.ts`). That answers "is this a DIFFERENT user", which is a
weaker question and comes apart on sign out of A, in as B, back in as A: a
request issued during the first A session settles, compares equal, and commits
data from a session that has ended. An ordinal cannot be confused that way —
the epoch it captured is strictly lower than the one it settles into.

`authStore.identityEpoch` bumps on exactly the transitions `onIdentityChange`
already fires on, and `identityFence()` is the capture-and-compare helper, so
four stores do not hand-roll it four ways. That is not a hypothetical: this
store and `workspace.svelte.ts` hand-rolled the same three parts of a
single-flight loader and drifted three times in one afternoon, which is why
`singleFlight.ts` exists.

Two orderings are load-bearing and each has a test:

- NOT bumped on the baseline, matching `notifyIdentityChange`'s own early
  return. The first resolution of the session is not a change from anything,
  and a fence captured before an identity existed has nothing stale to refuse.
- Bumped BEFORE the listeners, not after. A listener's expected shape is "drop
  state, reload it", and that reload captures a fence while the listener is
  still running — under the old epoch it would refuse its own response.

Distinct from `generation`, which bumps only on `clear()` and fences this
module's own /auth/session fetches; a sign-in as a different user through
`load()` moves the identity without touching it, so it cannot serve as the
epoch.

Five tests. Mutants: fence on the user id instead of the epoch (2 fail,
including the A->B->A straggler this exists for), bump after the listeners
(1 fail, alone), bump on the baseline too (3 fail). Full web suite 2416 passed.

No consumer yet — the stores are unit 2. Nothing changes behaviour on its own.

* fix(web): starredStore drops the previous user's stars, and refuses a settle from a session that has ended (BUG-3005, unit 2a)

`starredStore` holds a PER-USER set and was only ever cleared inside `load()`,
which is keyed by workspace slug. A same-route account swap starts no new load,
so A's starred ids stayed rendered inside B's session with no request having
been made for them.

Two halves, because they fail independently:

- an `authStore.onIdentityChange` subscription clears the store when nothing is
  racing at all, which is the common case;
- an identity fence on every settle, for the case where something is. Both
  arms of `load` commit — the catch arm publishes an empty set and flips
  `loaded` — so fencing only the success path would leave the store telling B
  that B's stars are loaded.

The navigation fence stays and is not subsumed: a workspace switch under one
identity moves no epoch, and an account swap moves no workspace.

FIVE TESTS, AND ONE OF THEM TOOK THREE TRIES TO BE WORTH ANYTHING. The
toggle-revert mutant SURVIVED twice:

- first because the identity-change reset blanks `currentWs`, so the workspace
  fence was doing the work — fixed by having B sign back into the SAME
  workspace before the revert fires;
- then because the test reverted a failed STAR, which is a delete, and a
  delete is invisible in an empty set. The write that actually leaks is the
  revert of a failed UNSTAR, which is an ADD into the next user's set.

Both are recorded in the test's own comment, because "this assertion is
pointed the wrong way" is not visible from a green run. Mutants now: drop the
subscription (2 fail), drop the fence on load's success arm (1), on its catch
arm (1), on the toggle revert (1, and only against the corrected test).

All 291 store tests pass.

* fix(web): collectionStore drops the previous user's collections and items, and fences every settle (BUG-3005, unit 2b)

`collections`, `items`, `itemsWorkspace`, `collectionsWorkspace` and
`activeItem` all belong to a user and none of them is keyed by one. The loader
is keyed by workspace SLUG and the workspace layout drives it from a slug-keyed
effect, so a same-route sign-in as a different user starts no new load at all —
Roles, the item picker and the editor's link gate then render A's collection
names and item titles inside B's session.

The store's only fence was `isLatest`, which is a NAVIGATION fence and cannot
see this: a same-route account swap supersedes nothing, so A's load is still
the latest one for that workspace and commits.

Both halves, as with starredStore:

- an `onIdentityChange` subscription clearing the store, for the ordinary case
  where nothing is racing;
- an identity fence captured before the first await on `loadCollections`,
  `loadItems` (both arms — they assign through different branches and stamp
  `itemsWorkspace` differently) and `loadItem`.

`loadItem` returns NULL rather than the fetched item when the identity moved.
The return value matters as much as the store write: the caller renders what it
is handed, so handing back A's item puts it on B's screen with `activeItem`
untouched.

The FRESHNESS STAMPS clear with the arrays, and that is load-bearing rather
than tidiness. Emptying `collections` while leaving `collectionsWorkspace` set
makes `collectionsAreFreshFor(ws)` answer true for an empty list — the one
answer that stops TASK-2200's recovery from re-fetching, so the new user's
sidebar would stay permanently empty. It has its own test and its own mutant.

`loadItems` and `loadItem` have no production callers today (the item list is
maintained through addItem/updateItemInList/removeItem from SSE, and
`activeItem` through setActiveItem). They are fenced anyway: they are public
API and this item names exactly that state as retained.

Seven tests, six mutants, five of them dying to exactly one test: drop the
subscription (2 fail), drop the fence on loadCollections (1), on each items arm
(1 each), make loadItem return the item anyway (1), leave the stamps set in
clear() (1). Full web suite 2428 passed.

Claude-Session: https://claude.ai/code/session_0115vpkjH5mmFdjRdRmZRuZt

* fix(web): localIndex and localSearch drop every workspace when the signed-in user changes (BUG-3005, unit 2c)

`localIndex` reset only when `bootstrap()` noticed a userId mismatch, which
makes the invalidation depend on somebody calling bootstrap. A route that does
not bootstrap the index on an identity change kept the previous user's rows in
RAM — and `localSearch`, whose only reset callers live inside `localIndex`,
kept a full-text index built from their item titles and bodies. That fourth
store is not named on the item; the enumeration this unit re-ran found it,
which is what CONVE-18 asks for.

The per-workspace shape is the other half. `reset(ws)` names one slug, so the
sign-out path dropped the workspace the layout happened to be looking at and a
tab that had visited two kept the other.

`localIndex.resetAll()` sweeps every workspace it holds and then calls
`localSearch.resetAll()`. The second call is not redundant with the per-
workspace resets it just performed: the two maps are keyed independently and
nothing keeps them in step, so an index whose workspace state is already gone
survives a sweep driven from localIndex's keys alone. It has its own test and
its own mutant.

The bootstrap mismatch check STAYS. It covers a different case — a first
bootstrap for a workspace whose cached state predates this tab's identity
signal, where nothing changed within this tab so nothing fired.

Four tests, three mutants: drop the subscription (3 fail), sweep only the first
workspace (1, alone), drop the localSearch.resetAll belt (1, alone). Full web
suite 2432 passed; svelte-check 0 errors.

Claude-Session: https://claude.ai/code/session_0115vpkjH5mmFdjRdRmZRuZt

* fix(web): the store fix routed the starred PAGE around its own filter, and three more singletons held user state (BUG-3005, codex round 1)

Round 1's largest finding is that the four stores were not the population, and
the sharpest instance is one my own fix made worse.

THE STARRED PAGE. `items` is `starredStore.loaded ? filter(fetchedItems) :
fetchedItems` — an unfiltered fallback for the pre-load window. The store's
identity reset sets `loaded = false`, so the fix routed the page around its
only filter and handed B the full `fetchedItems` array it had fetched as A. Its
load effect is keyed on the workspace slug, so a same-route swap started no
reload to correct it. The page now drops its own copy on the identity signal,
invalidates any load in flight, and reloads; its settle carries an identity
fence beside the `seq` one, which is a navigation fence and does not move on an
account swap.

THREE MORE STORES, none of which looks like per-user state to a sweep for
per-user FIELDS:

- `adminStore.stats` — instance-wide DATA, but authorization-scoped: only an
  admin can read it, and it must not outlive the admin who did.
- `titleStore` — its parts are retired by a PATH stamp, and an account swap
  moves no path, so A's workspace name and item ref stayed in the browser tab.
  Cosmetic, and cheap enough that leaving one member of a class unfixed costs
  more than fixing it.
- `editorStore` — `dirty` and `saveStatus` are read by GUARDS, not displayed:
  A's dirty editor makes B's unsaved-draft prompt and SSE archive suppression
  act on a document B never opened. Needs a WIDER clear than `resetForDoc`,
  which keeps `saveStatus` on purpose because it handles a document change
  within one session; an identity change ends the session. Its own test.

AND ONE FALSE CLAIM OF MINE. Unit 2b's message said every post-await write in
collectionStore was fenced. `finally { loading = false }` was not, so A's load
settling under B's in-flight one cleared B's flag and could flash empty-state
mid-load. Fenced, with `clear()` now owning the flag so nothing is stuck true.

THREE THINGS DOCUMENTED RATHER THAN CHANGED, each with the reasoning in the
code:

- The baseline exemption's residual. A request issued before `/auth/session`
  resolves settles under a fence that still says current, so a cross-tab
  identity change inside that window commits. It cannot be closed by refusing
  pre-baseline settles: the root layout issues the workspace list CONCURRENTLY
  with the session — that concurrency is why `identityEstablished` exists — so
  refusing them empties the app on every cold start. Nothing inside the tab can
  tell the two cases apart.
- The contract bumping-before-listeners places on listeners: a fence captured
  inside a listener passes, so a listener must reload for the identity signed
  in NOW. Every listener here clears and either stops or reloads from the
  current route.
- The durable wipe `resetAll` inherits from `reset`. Not needed for isolation
  (the cache is namespaced per user+workspace) and it costs a returning user
  their warm cache, but it is what sign-out has done since TASK-1360 and
  leaving a signed-out user's item titles on a shared browser's disk is the
  wrong default to adopt silently. What this widens is the pre-existing race
  between a fast re-login's hydrate and a wipe still in flight; that is a
  persistence-layer fix, not a change to this sweep.

Six new tests. Five mutants, each dying to exactly one: unfence `loading`,
drop the admin subscription, drop the title subscription, use `resetForDoc`
instead of `clear` for the editor, drop the starred page's subscription.

The admin test loads REAL stats through a stubbed fetch first — asserting null
before and after would have passed against a store with no clear at all, which
is the failure this branch has already produced once.

Full web suite 2438 passed; svelte-check 0 errors.

Claude-Session: https://claude.ai/code/session_0115vpkjH5mmFdjRdRmZRuZt

* fix(web): a reset that dropped the data but not the in-flight slot, plus the layout that outlived the identity (BUG-3005, codex round 2)

Round 2 found four defects, one of which my round-1 reset CREATED, and the
lead's three folded rows land with them.

THE SINGLE-FLIGHT SLOT — the reset was actively harmful. `clear()` dropped the
collections but left the previous identity's promise published, so B's
`ensureCollections` JOINED A's request, A's identity fence then refused to
commit, and B was answered with no data and no request of its own. A
permanently empty sidebar, which is the exact failure the cleared freshness
stamp was there to prevent. `invalidate()` on the keyed single-flight abandons
the slot AND bumps the sequence; `workspaceStore` gets the same line, because
it is the same defect one file away and BUG-2991 did not have to be filed for
it to be true there too.

THE WORKSPACE LAYOUT outlived the identity. Its per-workspace effect is keyed
on the slug, so a same-route swap re-ran nothing and the layout kept every
connection it made for the previous user. Two halves:

- the SSE stream is a leak rather than staleness. `sseService.connect` is
  idempotent per workspace, so B's tab kept the EventSource A opened — still
  authorized, because signing in as B replaces the cookie without destroying
  A's session row, which is precisely the case BUG-3007's server-side close
  cannot see. Explicit `disconnect()` first. BUG-3011 tracks the server half.
- fourteen leaf route components own user-scoped state and key their reloads on
  the slug. `{#key authStore.identityEpoch}` remounts them structurally rather
  than fixing fourteen pages and leaving the fifteenth to be remembered.

AND THE REMOUNT SHIPPED BROKEN FIRST. `identityEpoch` was a plain `let`, so the
template never saw it change and the block never re-ran. Nothing about the code
looked wrong; a test that counted mounts is what found it. It is `$state` now,
with the reason in the comment, because it is the only value in that file read
from a template.

Also from round 2: `adminStore.loadStats` had no fence, so A's statistics landed
after the clear; `clear()` set `loading = true` and the admin layout loads only
on mount, which pinned the console on "Loading admin data…" forever; and the
starred page reloaded its private copy but not the shared store, so every OTHER
view showed all items unstarred.

MY BASELINE COMMENT WAS FACTUALLY WRONG and codex was right to call it. I wrote
that the root layout issues the workspace list concurrently with
/auth/session — taken from the neighbouring comment rather than read from the
layout. `loadAll()` is gated on `authReady`, and no children render until then,
so nothing can issue a fenced request before the baseline. The residual is
therefore currently UNREACHABLE rather than narrow, and the exemption stays for
a different reason: the bump and the listener notification are one event, and
firing listeners on the baseline is a live hazard. Corrected in place, with what
is actually true.

The lead's three folded rows: SSE (above), `toastStore` (the tray is a readable
log of A's actions — toast text names items), and the editor's
attachment-metadata memo, cleared from the ROOT layout rather than by
self-subscribing, because that module is deliberately rune-free and importing
the auth store breaks every `node`-project test with "$state is not defined"
before an assertion runs.

Eight new tests. Mutants, each dying to exactly one test: no flight
invalidation, invalidate without the sequence bump (pinned by an A->B->A leg in
`workspaceStore`, where the captured user id AGREES and the bump is the only
guard left), unfenced `loadStats`, toast history kept, the starred page not
reloading the store, no SSE disconnect, no remount.

Full web suite 2446 passed; svelte-check 0 errors.

Claude-Session: https://claude.ai/code/session_0115vpkjH5mmFdjRdRmZRuZt

* fix(web): stop enumerating surfaces — an identity change clears what a reload cannot drop, then reloads (BUG-3005, codex round 3 + lead ruling)

Three review rounds each found another layer of surfaces holding the previous
user's data: three stores, then fourteen route pages plus a connection and two
caches, then the console subtree, share pages, the command palette and an open
dialog. The population is "everything in the tab that ever held a fetch", and a
per-surface fix cannot terminate — it is a list somebody has to keep complete
forever. The lead's ruling, and it is the right one: reload.

`reloadForIdentityChange` clears the state a reload does NOT drop — the
palette's recent searches in localStorage, collab cursors in sessionStorage,
the attachment-metadata memo — and then reloads. What remains an enumeration is
bounded by STORAGE KEYS rather than by every module in the app, and a grep for
the storage APIs can check it. The clears run FIRST and that ordering has its
own test: reloading first tears the page down before they execute.

REMOVED, because one mechanism is the point: the `{#key}` remount added in
round 2, and the starred page's own listener. The starred page had three reload
paths on a single identity change. The remount's absence is pinned by a test
rather than left implicit, so re-adding it has to be deliberate.

KEPT, because none of it is redundant with the reload: the epoch, the four
store fences, and the SSE disconnect. They cover the window between the identity
changing and the page actually going away, during which in-flight responses are
still settling into live stores. The SSE close bounds that window to zero for a
stream that would otherwise stay authorized — signing in as B replaces the
cookie without destroying A's session row, which is the case BUG-3007's
server-side close cannot see (BUG-3011).

THE TWO WRITES NEED THEIR OWN RULE, AND THE RELOAD MAKES THEM WORSE rather than
better. A teardown that PERSISTS must discard when the identity moved, because
the reload's unload fires those handlers under the NEW cookie:

- `ItemDetail`'s raw-markdown saver flushes pending edits with `keepalive: true`
  from three sites, a request deliberately built to outlive the page. It now
  refuses when the epoch differs from the one captured when the item was
  LOADED — the identity whose typing the editor contains.
- `openChildrenDialog` holds the fetched 409 child list AND continuations that
  perform the mutation on confirm. It is mounted in the root layout, outside
  everything the workspace layout controls. All pending requests now resolve
  FALSE, so the producer takes its cancel path; rejecting would surface
  unhandled rejections at call sites that only ever expected a boolean.

Grepped the class (`keepalive: true`): three sites, all funnelling through the
one saver callback that now carries the guard.

MY BASELINE COMMENT WAS WRONG A SECOND TIME. Round 2 corrected it to "no child
renders before authReady"; round 3 found the share-page branch sets `authReady`
WITHOUT calling `authStore.load()`, and the auth-failure path proceeds
deliberately. So the residual is narrow, not unreachable. Corrected again, and
this time the exceptions are named rather than the rule restated.

Nine tests across the new mechanism; every mutant dies to exactly one: reload
before clear, no palette clear, a collab clear that takes the whole of
sessionStorage, the dialog abandoning only its active entry, no SSE disconnect,
a reinstated remount.

Full web suite 2450 passed; svelte-check 0 errors.

Claude-Session: https://claude.ai/code/session_0115vpkjH5mmFdjRdRmZRuZt

* fix(web): the identity reload was firing the teardown writes it was meant to outrun (BUG-3005, codex round 4)

Round 4 found two P1s, and both are defects the RELOAD created — which is the
shape of this whole unit by now: each fix's own mechanism becomes the next
round's hazard.

THE UNLOAD HANDLER WAS PERSISTING UNDER THE NEW COOKIE. A reload fires
`beforeunload`, and both persistence paths there use `keepalive` — a request
deliberately built to outlive the page. Round 3's guard covered the raw saver
only. The collab flush was unguarded, and MY GREP FOR THE CLASS MISSED IT: I
grepped `keepalive: true`, and that call passes the flag positionally as
`flushNow(ctx, true)`. An enumeration by literal, in the unit whose entire
lesson is that enumerations by shape are wrong.

THE UNLOAD HANDLER COULD ALSO CANCEL THE RELOAD. `preventDefault()` on a dirty
editor raises the native "unsaved changes" prompt, and "Stay" cancels the
navigation. The identity has already changed and the stores are already
cleared, so the answer would be the new user sitting in the previous user's
page shell — with the `{#key}` remount removed in round 3, nothing left to
rebuild it. The prompt protects the author's unsaved work, and after an
identity change the author is not the one sitting there.

Both close the same way: when the epoch differs from the one captured at LOAD,
the handler returns before it flushes or prompts. The $effect cleanup is a third
door into the same write and is gated too.

Also from round 4: `pad-last-route-<ws>` and `pad-last-scroll-<ws>-<pathname>`
are keyed by WORKSPACE with no user in them. The scroll key embeds the pathname
in the KEY, so a private item slug is legible from a key listing without
reading a single value. Both are cleared now, and the test asserts an ordinary
UI preference SURVIVES, because a clear that took all of localStorage would be
a different and undocumented change.

ACCEPTED, NOT FIXED, with the reasoning: `persistWipe` is fire-and-forget, so
an IndexedDB database can outlive the reload if its delete has not landed. It
is residual data on disk rather than a cross-user read — the databases are
named `pad-local-index-{userId}-{workspace}`, so the next user cannot open the
previous one's — and making the reload await an unbounded IDB delete trades a
privacy-hygiene residual for a tab that may not reload at all.

Six tests. The ItemDetail ones are SOURCE guards, the pattern this directory
already uses for that file (~7,900 lines of collab, SSE and pane wiring), and
the header says what a source guard cannot do rather than leaving it implied.
Mutants: remove the beforeunload guard, remove the collab cleanup gate, remove
the route/scroll clear — each dies to exactly one test.

Full web suite 2456 passed; svelte-check 0 errors.

Claude-Session: https://claude.ai/code/session_0115vpkjH5mmFdjRdRmZRuZt

* fix(web): the reload now happens even if a clear throws, and a test that passed for the wrong reason (BUG-3005, codex round 5)

No P1s. Two P2s and a P3, all addressed.

THE RELOAD IS EXCEPTION-SAFE NOW. `clearPersistentIdentityState()` ran outside
any try/finally, so a throw on the way through would have skipped
`location.reload()` entirely — leaving the tab mounted with the previous user's
page under the new identity, which is the state this whole fix exists to
prevent, reached by the fix itself failing quietly. Latent today (the only
uncaught call is a `Map.clear()`), and the ordering is now structural rather
than incidental.

A TEST THAT REPORTED PASSED WHILE THE PAGE CRASHED. The starred-page leg used
`mockReturnValueOnce` with no default, so a later call resolved `undefined`,
`items.map` threw, and vitest showed a green test with an unhandled error beside
it — the assertion was reading a page that had crashed rather than a fence that
had refused. Every mock carries a default now, `vi.resetModules()` is gone (it
handed the test a different authStore instance than the component closed over),
and there is a counterfactual leg where the identity holds still and the data
DOES render.

AND THE SAME LEG'S MUTANT SURVIVES, which is in the file rather than left for
the next reader to discover. Removing the page's identity fence leaves it green:
the page's own `$effect` re-runs during the transition and advances `loadSeq`,
so the NAVIGATION fence refuses A's settle first and the identity fence never
decides anything there. The leg stands as an end-state regression fence — A's
items must not appear — and the identity fence's coverage of the pre-reload
window is an argument, not a measurement. Recorded because a surviving mutant
filed as a passing test is exactly how this file already shipped one assertion
pointed the wrong way.

P3: a trailing blank line `git diff --check` flagged.

Full web suite 2457 passed, no unhandled errors; svelte-check 0 errors;
`git diff --check` clean.

Claude-Session: https://claude.ai/code/session_0115vpkjH5mmFdjRdRmZRuZt

* fix(web): a sign-in from an unauthenticated tab must not reload (BUG-3005, E2E failure + lead ruling)

E2E failed on 3aebbb58 and the suite was right. `web/e2e/lib/collab-helpers.ts`
signs in by fetching `/api/v1/auth/login` FROM INSIDE a mounted page, which is
an identity change from `''` to a real user — and the reload took the page out
from under Playwright.

The narrowing is correct on its own terms, which is why it lands whatever the
log finally says. The reload exists so one user's data cannot be visible to the
next, so which transitions need it is a question about WHOSE data is being
dropped: an anonymous baseline holds nobody's private data. `user -> user` and
`user -> ''` reload; `'' -> user` does not.

It also takes a full page load out of the primary login path, where it was
racing the login flow's own navigation. That cost was never intended and would
have shipped unnoticed if the E2E suite had not been driving a real login.

`onIdentityChange` listeners now receive the PREVIOUS established user id.
Every other listener ignores it and drops state as before; only the root layout
needs to tell a sign-in from a sign-out or a swap, and reading it from the
signal is better than the alternative of a second store field that can disagree
with the one the notification was computed from.

Three legs, and the mutant that removes the guard kills all three — the swap and
sign-out ones on the exact CALL COUNT rather than on an absence, because
`afterEach` clears the store so every test starts from an established empty
identity and its own setup performs a sign-in. That is written into the test
file, since a leg failing for a reason other than its name is how a suite stops
meaning what it says.

Full web suite 2460 passed; svelte-check 0 errors.

Claude-Session: https://claude.ai/code/session_0115vpkjH5mmFdjRdRmZRuZt

* fix(web): sign-out clears and lets its own navigation leave; only a swap reloads (BUG-3005, E2E account-delete failure)

The E2E failure was `account-delete.spec.ts:147` with
`net::ERR_ABORTED; maybe frame was detached?` and
`Navigation to /console/settings is interrupted by another navigation to
/login`. A NAVIGATION RACE, not a rule about identity transitions — and not the
sign-in I had guessed at before the log was readable. Recording that because
the previous commit's narrowing was proposed on a wrong diagnosis; it is right
on its own terms, and it was not this.

Both sign-out sites already navigate away by themselves. Account deletion does
`authStore.clear()` then `window.location.href = '/login'`, deliberately and
with a comment saying so. My listener fires inside `clear()` and started a
reload of the current URL; the delete flow then set `location.href`. Two
navigations, one aborts.

So the split, per the lead's ruling:

- SWAP (user -> user): reload. Nothing else navigates, so the reload is the
  whole mechanism.
- SIGN-OUT (user -> ''): run the persistent clears and return. The clears still
  have to happen — a hard navigation drops the tab's memory, not localStorage,
  sessionStorage or IndexedDB — but the reload is redundant with a navigation
  that is already underway, and racing it is what broke the flow.
- and the console logout now navigates HARD. `goto('/login')` is an SPA
  navigation that leaves every singleton, component and cache where it was,
  which is this bug's original defect arriving through the sign-out path
  itself. Account deletion has always done this; the two sites agree now.

The property is unchanged: every transition away from a real user drops that
user's in-tab state, by reload on a swap and by hard navigation on a sign-out.

Four legs in the root-layout test (swap reloads and does NOT take the clear-only
branch; sign-out clears and does NOT reload; sign-in does neither) and a source
guard on the console logout, since jsdom does not perform an assignment to
`window.location`. Mutants: sign-out reloading again, sign-out doing neither,
and the logout reverting to `goto` — each dies.

Full web suite 2464 passed; svelte-check 0 errors; `git diff --check` clean.

Claude-Session: https://claude.ai/code/session_0115vpkjH5mmFdjRdRmZRuZt
2026-09-10 19:42:26 -04:00
..
2026-03-26 01:52:36 +00:00
2026-03-26 01:52:36 +00:00
2026-03-26 01:52:36 +00:00
2026-03-26 01:52:36 +00:00

Pad Web UI

SvelteKit 2 + Svelte 5 frontend for Pad, compiled to static files and embedded into the Go binary.

Development

npm install
npm run dev          # Dev server at localhost:5173 (proxies API to localhost:7777)
npm run build        # Production build to build/
npm run check        # Type checking with svelte-check

When developing, run the Go backend separately with make dev from the project root.

Building for Production

Do not build in isolation. Always use make build from the project root — this builds the web frontend, then compiles the Go binary with the build output embedded via //go:embed.

Stack

  • Svelte 5 with runes ($state, $derived, $effect)
  • SvelteKit 2 with adapter-static (SPA mode)
  • Tiptap block editor with markdown round-trip
  • svelte-dnd-action for drag-and-drop in board/list views
  • SSE for real-time updates
  • TypeScript throughout

Structure

src/
  routes/                    SvelteKit pages
    +layout.svelte           App shell (sidebar + main)
    +page.svelte             Landing/redirect
    [workspace]/
      +page.svelte           Dashboard (collections, phases, activity)
      +layout.svelte         SSE connection per workspace
      [collection]/
        +page.svelte         Collection view (board/list)
      [collection]/[item]/
        +page.svelte         Item detail + editor
      conventions/            Purpose-built conventions page
      playbooks/              Purpose-built playbooks page
      settings/               Workspace settings
  lib/
    api/client.ts            HTTP API client
    components/
      layout/                Sidebar, navigation
      editor/                Tiptap editor, raw markdown editor
      fields/                FieldEditor, relation picker
      items/                 ItemCard, ItemDetail
      collections/           BoardView, ListView
      common/                StatusBadge, badges, modals
      search/                CommandPalette
    stores/                  Svelte 5 reactive stores
      workspace.svelte.ts    Workspace state
      collections.svelte.ts  Collection + item state
      ui.svelte.ts           Sidebar, mobile state
    types/index.ts           TypeScript types and constants
  app.css                    Global styles and design tokens