Files
pad/web
xarmian 53c567dd4a fix(web): the sync cursor is only ever a server time taken before the reads it vouches for (BUG-3207) (#1525)
* fix(web): the sync cursor is only ever a server time taken before the reads it vouches for (BUG-3207)

Three cursor writes used the client's wall clock: the initial value, a failed
seed, and markSynced() after every successful full reload. A client running
ahead of the server by D skipped every change committed in that window, on
the hottest of the three paths. markSynced also stamped at the END of the
reload, which skips a change committed between the reads and the stamp even
with a perfect clock.

- serverClock.ts estimates the server clock from response Date headers,
  projected by monotonic elapsed time, minus 1 s. It errs early only: an
  early cursor re-delivers (a duplicate), a late one skips (a miss).
- stamp() is taken BEFORE a full reload's reads and passed to
  markSynced(stamp). With no response seen yet it asks /changes once.
- A failed seed writes nothing. An unseeded cursor (0) answers full_refresh
  instead of asking since=0.
- The seed's since uses the estimate, so a client running ahead still gets
  the changes between its page load and the seed.
- ItemDetail no longer calls markSynced (lead ruling): a single-item reload
  cannot vouch for the workspace cursor. The layout is the only writer.

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

* test(web): re-sign the ItemDetail fence rows the markSynced removal moved, and state what the duplicate legs measure (BUG-3207)

The AST gate hashes each async unit with its enclosing onMount, so removing
the sync callback's markSynced call moved both the SSE and the sync row. Both
were re-read against the new code: the SSE callback is untouched, and the
sync change removes one synchronous call after the existing fence, adding no
await and no commit. Both rows hold, and their hashes are updated.

The reconcile-duplicate legs now say what mutants measured: the same-row
duplicate is idempotent by content (no guard mutant can redden it), and the
late older copy is held by the cursor-floor and existing-seq guards together.

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

* fix(web): the server-clock estimate is a whole millisecond, so /changes accepts the cursor it stamps (BUG-3207)

performance.now() is fractional, and handleGetChanges parses `since` with
ParseInt. A stamp taken from the estimate became a fractional cursor, every
/changes asked from it answered 400, and each sync degraded to a full reload
for the rest of the tab. Measured on the e2e trail: 11 of 11 first-attempt
failures of bug-2539-sync-window sent a fractional since after the archive,
0 of 49 passes did.

Floor rather than round, so the estimate still never runs ahead. The cursor
model's /changes mock now refuses a non-integer since the way the server does.

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

* fix(web): a sync signal that beats the seed waits for it instead of reloading the workspace (BUG-3207)

An unseeded cursor answers full_refresh, and the SSE connect and the first
events routinely arrive while the seed request is still out. Each of those
signals cost a whole-workspace reconcile where main asked one incremental
/changes: 77 of 160 instrumented page loads under load. A signal reaching an
unseeded cursor now awaits the in-flight seed and asks from its server_time,
which is taken before the seed's reads, so the pass covers everything the
seed's own delta did not. A tab whose seed failed still answers full_refresh.

bug-2539-sync-window: the oracle now awaits every /changes issued after the
archive before reading the record. One issued after the archive but not yet
answered at check time read as "nothing carried it".

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

* fix(web): an early sync signal waits for the seed at most SEED_WAIT_MS, and a failed seed still ends in full_refresh (BUG-3207)

The API client has no request timeout, so an unbounded wait on a seed that
never answers held the signal, and the sync lock with it, forever. The wait is
bounded at 5 s against a measured seed latency of p50 160 ms / max 874 ms at 8
workers. Two legs pin the lead's question: a FAILED seed and a HUNG seed each
leave the awaited signal answering full_refresh, never dropped.

bug-2539-sync-window: a non-seed /changes that carried the deletion passes
whenever the client sent it. Client send time was the wrong test: a response
that carries the deletion was read after the archive committed. Negative
control: with ItemsModifiedSince comparing deleted_at with > instead of >=,
20 of 20 same-second runs fail.

Claude-Session: https://claude.ai/code/session_01UcaMsmoCt8u7VpJhCNxiJJ
2026-09-25 01:24:09 -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