Files
pad/web
xarmian a03c6d60bd fix(web): ten surfaces ask a field type before printing its value (BUG-3067) (#1358)
* fix(web): four schema-less surfaces ask the field's type before printing it (BUG-3067)

A `status` or `priority` field retyped to a `relation` stores a STRING — an
item id — so any surface that reads the value BY NAME and prints it shows a
raw uuid where a word belongs. BUG-3016 fixed every site that already had a
`FieldDef` in hand; these are the ones that read by name with no schema.

THE POPULATION WAS WIDENED THREE TIMES, twice by review and once here, so
this commit runs the instrument the item's own trail prescribes before
touching anything: grep the two KEY NAMES across `web/src`, rather than
grepping for field-rendering shapes. That found two sites nobody had listed
(`QuickActionsMenu` and `quick-action-preview`, see below) and confirmed the
four fixed here.

The body's premise that these need PLUMBING turns out to be too pessimistic,
and that is worth recording because it is what made the unit cheap:
`collectionStore` is a singleton any component may import, and every one of
these rows already carries its own `collection_slug`. `ChildItems` was even
importing `collectionStore` and `parseSchema` already — for the collection
PICKER, never for the row it was drawing. No prop threading was required
anywhere.

One shared helper (`$lib/collections/categoricalFieldValue`) rather than four
guards, for the reason `laneKeyCallers.test.ts` exists: four call sites asking
one question in four spellings is how the question comes to have four answers,
which is this class's documented history. Its core is pure — it takes the
collections rather than reading the store — so it tests without a store mock.

An unloaded store FAILS CLOSED: a row renders without its chip for a moment
rather than printing a value nothing has vouched for. Withholding a chip is
recoverable and printing an id is the bug. Asserted, and documented as the one
empty answer that is not an error.

`graph/DetailCard` gets both of its reads: `priority` from the item's fields
and `status` from the graph NODE, which is a server projection carrying no
schema — but it carries the collection slug, which is all the question needs,
and it renders before the item fetch lands.

Tests. The pure helper has its own; a source-level guard pins the CLASS (every
surface routes through the shared question, none answers it privately), since
a mutant that re-inlines a correct private copy is invisible to every
behavioural assertion; and `DetailCard` is driven end to end because it reads
both keys from two different places. Counterfactuals measured, not assumed:
reverting `NestedChildren` fails two legs of the source guard, reverting
`CommandPalette` fails one (its import leg — it renders `{meta.status}`, the
guarded function's OUTPUT, so the mustache leg cannot cover it, and the test
says so), and reverting `DetailCard` prints the raw uuid TWICE, which is the
defect reproduced directly.

NOT in this commit: `QuickActionsMenu.svelte` and `quick-action-preview.ts`
hold the same pair of schema-less by-name reads, but they substitute into a
PROMPT rather than into a chip, so "withhold it" may be the wrong remedy —
and the two are one site with two implementations that must agree, since the
preview exists to show what copying produces. Recorded on the trail for a
decision rather than folded in silently.

BUG-3067

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

* fix(web): a fifth surface, a workspace-scoping hole, and one helper instead of two (BUG-3067, round 1)

Three findings, all from the adversarial round, all real.

1. A MEMBER I DISMISSED WITHOUT CHECKING. `playbooks/+page.svelte` reads
   `fields.status` by name, prints it through `statusLabel`, and keys its card
   styling off it. I had put it in the non-member bucket with the reason
   "system collection, a retype is not reachable the same way" — and wrote,
   in the same comment, that I would check rather than assume. I did not.
   `handleUpdateCollection` has NO `is_system` gate on schema edits, so the
   playbooks `status` can be retyped exactly like any user collection's.

   The surface list in the source guard is data now, so a missed member is a
   missing ROW someone can see rather than a silent absence.

   (`conventions/+page.svelte` really is a non-member, re-checked this round:
   it compares and WRITES `fields.status`, never renders it as a chip.)

2. A SLUG IS ONLY UNIQUE WITHIN A WORKSPACE. `collectionStore.collections` is
   one global array that DELIBERATELY retains the previous workspace's
   collections while the next load is in flight — the store documents this
   itself, from BUG-1461. Two workspaces can each have a `tasks` whose
   `priority` is a select in one and a relation in the other, so during that
   window a slug-only lookup can answer with the wrong workspace's TYPE, in
   either direction: printing an id, or withholding a good value.

   `fieldDefFor` and both value forms now take the store's own
   `collectionsAreFreshFor(wsSlug)` answer and fail closed when it is false —
   the same direction as an unloaded store. It DEFAULTS to fresh, pinned by
   its own leg: a surface with no workspace slug in hand must not be silently
   blanked, and the stale window is the caller's to close where it can.

3. "ONE SHARED HELPER" WAS LITERALLY FALSE. `graph/DetailCard` called
   `categoricalChipValue` — the lower-level dependency — directly for its
   `status` read, because that read starts from a graph NODE rather than an
   Item. Added `categoricalValueForSlug` so the projection case is served by
   the same module, and the source guard now REFUSES an import of the
   lower-level helper instead of accepting it as an alternative. The guard
   previously treated the bypass as a valid spelling, which is how it passed
   a file that was performing the bypass.

BUG-3067

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

* fix(web): three more surfaces, and the freshness guard is now actually used (BUG-3067, round 2)

Round 2 found that my enumeration was wrong a SECOND time, and that the
previous commit described a guard nobody called.

1. "BOTH VALUE FORMS NOW TAKE THE STORE'S OWN collectionsAreFreshFor(wsSlug)"
   WAS FALSE IN PRODUCTION. The parameter existed, defaulted to true, and no
   call site passed it — so the cross-workspace hole was documented and tested
   and not actually closed anywhere. A guard nobody uses reads, in a commit
   message, exactly like a guard that works.

   It is passed now at every surface that can reach a workspace slug:
   `ChildItems` and `NestedChildren` from their `wsSlug` prop, `playbooks` and
   the dashboard from `page.params.workspace`. The two graph surfaces have no
   workspace slug in hand and are named below rather than quietly left.

2. THREE MORE MEMBERS, two fixed here. The dashboard's active AND starred
   cards (one block, twice on the page) and `graph/ItemGraph`'s selection panel
   all render SERVER PROJECTIONS — values the server itself obtained by name
   through `extractFieldValue` — so a sweep for client-side `parseFields` reads
   could not see them. That is the lesson rather than the fix: the class is "a
   value obtained BY NAME", and where the name was resolved does not matter.

   `OpenChildrenDialog` is the third and is NOT fixed: its DTO carries `ref`,
   `title` and `status` and no collection identifier at all, so the declared
   type is unanswerable client-side. It needs a server change, which is a
   different unit.

3. The `playbooks` lookup hardcodes the slug `'playbooks'` and therefore does
   not survive a collection rename. It inherits that from the page, which
   already loads its items and its collection by the same literal — so the
   page is already broken after a rename and this adds no new assumption. Said
   out loud rather than left for the next reader to discover.

The source guard's surface list grew by the two fixed surfaces. It remains an
import-and-mustache check: it cannot prove the imported helper is CALLED, only
that the raw read is not rendered and the lower-level helper is not reached
past. Stated here because the previous commit's phrasing implied more.

BUG-3067

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

* fix(web): narrow the staleness question, and close three rows the table exposed (BUG-3067, round 3)

Round 3 produced the POPULATION TABLE instead of another search, which is what
CONVE-35 asks for once successive rounds keep returning new members of one
class — at that point the rounds are measuring the enumeration, not the code.
The table found a regression I shipped last round and three rows I had called
covered or uncoverable.

1. MY ROUND-2 FRESHNESS GUARD BLANKED VALID CHIPS. It asked the store's
   `collectionsAreFreshFor(wsSlug)`, which is false in TWO situations: the
   loaded collections belong to another workspace, and no load has been stamped
   at all. Only the first is a reason to withhold.

   The second is routine rather than exceptional: `playbooks` and the dashboard
   both fetch collections into PAGE-LOCAL state and never stamp the shared
   store, so the stamp comes from the workspace layout. Treating unstamped as
   stale blanked every status pill on those pages until the layout's load
   landed — and permanently if that load failed while their own fetch
   succeeded. Withholding a value for a load nobody is waiting on is not
   fail-closed, it is just wrong.

   `collectionsNotStaleFor` asks only "does the stamp name a DIFFERENT
   workspace", with one spelling shared by all six call sites. The unloaded
   case was always covered by the data — an empty array has no field to find —
   which is the argument for narrowing the flag rather than widening it.

2. A FILE IS NOT A RENDERER. `ChildItems` draws twice: interactive rows
   (`priority`) and a print list (`status`). Two passes fixed the first and
   reported the file done, because both were looking for the first hit per
   file. The guard now carries a second-renderer row that the file-level leg
   cannot satisfy.

3. `OpenChildrenDialog` IS fixable client-side. I filed it as needing a server
   change, claiming its DTO carried no collection identifier. It carries
   `collection_slug` — the link rendered two lines from the status is built
   from it. A claim about a shape I had not opened, in an item whose entire
   history is enumeration claims that were not checked.

4. `ItemGraph` has a `workspace` prop, so my "the two graph surfaces have no
   workspace slug in hand" was false for one of them. It passes staleness now.
   `graph/DetailCard` genuinely has none and is named rather than left silent.

Also corrected: the stale "five surfaces" phrasing, and the source guard's
overstated reach — it proves the files and keys in its own list, and does not
sweep server projections, print markup, share routes or prompt substitution.
It says so now.

Still uncovered, deliberately and by name: the individual public-share route
(IDEA-3066, entangled with per-target redaction) and the `QuickActionsMenu` /
`quick-action-preview` pair, which substitutes into a PROMPT rather than a chip
— an empty substitution and a resolved title are both defensible and it is one
site with two implementations that must agree.

BUG-3067

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

* fix(web): prompt variables resolve to a title, and two pages use the schema they fetched (BUG-3067, round 4)

The lead's ruling on the quick-action row, plus round 4's finding that my own
staleness plumbing was asking about the wrong array.

1. THE PROMPT SUBSTITUTION, per the ruling on the trail. A `status` or
   `priority` retyped to a relation stores an item id, and both
   `QuickActionsMenu` and `quick-action-preview` pasted that id straight into a
   prompt the user hands to an agent. Chips answer this by WITHHOLDING; a
   prompt variable cannot, because `{status}` has to become something and a
   blank is a lie of a different kind. So, matching #1352: resolve to the
   target's TITLE, fall back to its REF when the row will not resolve, never
   emit the raw id, join a list with ', '. An unresolvable id yields empty only
   because nothing true is left to say, not by choice.

   The structural half matters as much: the two files held byte-identical
   copies, and the preview module's own doc says it MIRRORS the menu so the
   preview shows what copying produces — which is precisely what makes a drift
   between them invisible, since the artifact that would reveal it is the one
   built to match. One function now, in the preview module, imported by the
   menu, with a guard asserting neither file carries the old spelling.

2. TWO PAGES NOW USE THE SCHEMA THEY ALREADY FETCHED. Round 3 narrowed the
   staleness question; round 4 pointed out I was asking it about the wrong
   array. The dashboard fetches collections alongside its dashboard under a
   sequence guard, and `playbooks` fetches its collection under a documented
   stale-response guard — and both then resolved the field type from the SHARED
   store, whose stamp is set by the workspace layout and not by them. So a page
   whose own fetch had succeeded could still render blank because a different
   global had not loaded.

   Both resolve from their own state now. The staleness question does not arise
   there: the fetch is already workspace-guarded. The playbooks read also uses
   that object's OWN slug, so it survives a rename of the playbooks collection
   where the hardcoded literal would not — the rest of the page still addresses
   it by literal, so the page as a whole does not, and the comment says so
   rather than implying the rename is handled.

Still open from round 4, named rather than silently left: `graph/DetailCard`
and `OpenChildrenDialog` pass no staleness argument and have no workspace slug
in hand, so they default to permissive; and nine of the ten guarded renderers
have no behavioural coverage, only the source guard plus `DetailCard`'s.

Corrected: "six call sites" was wrong — there are seven guarded invocations and
two unguarded — and the source guard's comment claimed per-surface render tests
prove two surfaces call the helper, when only `DetailCard` has such coverage.

BUG-3067

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

* fix(web): three defects round 5 found, plus both of the lead's ruled rows (BUG-3067, round 5)

1. I IMPLEMENTED THE RULING'S FALLBACK AND THEN DESCRIBED IT WRONGLY. My
   comment and commit said the substitution falls back to the REF "when the
   index cannot resolve the row". It does not, and it cannot: a ref is built
   FROM the row (its collection prefix and item number), so an id that resolves
   to nothing yields no row and therefore no ref. The fallback is for a row that
   resolves but has NO TITLE, which is what the code does and what the test
   pins. An unresolvable id yields empty because nothing true is left to say —
   the one case the ruling's "never empty by choice" does not reach. Prose
   corrected rather than code changed; the code was right.

2. `EditCollectionModal` called `contextFromItem` WITHOUT a resolver, which I
   had defaulted to one that resolves nothing — so every relation-valued
   `{status}`/`{priority}` in that preview rendered empty, quietly disagreeing
   with the menu the preview exists to mirror. A default that silently produces
   the wrong answer for an existing caller is worse than a required argument;
   the caller passes the local-index resolver now.

3. "NO SCHEMA YET" IS NOT "DRAFT". The playbooks page's two loaders are
   independent — `loadPlaybooks` clears `loading` on its own — so cards render
   while `playbooksCollection` is still null. The helper answers '' for that,
   which fell through to the `'draft'` default and reported every card as Draft:
   a MISREPORT, not a missing chip, and permanent if the schema fetch fails. The
   raw stored value stands in while the schema is unknown, and the declared
   answer takes over the moment it lands.

THE LEAD'S TWO RULED ROWS, both closed:

- The permissive staleness default was allowed to stand only on a grep showing
  every mount site sits under the `[workspace]` layout. `DetailCard` has ONE
  mount site and it qualifies — and the slug is threaded anyway, because a prop
  is checkable where an argument about mount sites goes stale the first time
  someone mounts it elsewhere. `OpenChildrenDialog` FAILS that test: it is
  mounted twice in the ROOT layout, so it can be on screen while the store holds
  another workspace entirely. It takes the workspace from the route.

- Behavioural coverage: a parametrised leg over the shared helper across all ten
  renderer shapes, plus the control that would catch a helper returning '' for
  everything. A `NestedChildren` mount leg was written and REMOVED rather than
  shipped — it asserted only the id's absence from a self-fetching component
  with no precondition that any row rendered, so it passed on an empty
  container. Making it real means mocking that component's loading, at which
  point it measures the mock. The file says so instead of carrying the green.

BUG-3067

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

* fix(web): my own fallback printed the id it was written to hide (BUG-3067, round 6)

1. THE REGRESSION I PREDICTED AND SHIPPED ANYWAY. Round 5's playbooks fix stood
   the RAW stored value in while the schema was still null, reasoning that it is
   what the page showed before this unit. For a retyped status the raw value IS
   the id — so the fallback printed exactly what the unit exists to prevent, for
   the duration of the window it was meant to fix, and worse than the "Draft"
   misreport it replaced. I named this as the finding I most expected from the
   round, in the round's own prompt, and it was there.

   The honest answer for an unknown schema is to claim NOTHING: no status chip
   and no draft/deprecated styling until the schema lands. The card still
   renders with its title, trigger and steps.

2. A CONTROL LEG FOUND A REAL HOLE the moment the DetailCard test started
   passing its now-required `wsSlug`: `collectionsNotStaleFor` compared the
   stamp against `null` alone, so an `undefined` fell through to the equality
   and answered STALE for every workspace. The store's field is typed
   `string | null`, which is why it read as safe — but a guard whose answer
   depends on WHICH empty it is handed is not a guard. Both spellings now mean
   "nothing stamped"; the mutant reverting it dies.

   The same test was passing no `wsSlug` at all and hiding it behind `as never`,
   so it had been exercising a branch the component no longer takes. Fixed, and
   its store mock now supplies the stamp so the legs run production's path.

3. `OpenChildrenDialog`'s staleness check is INERT on routes with no workspace
   param — the console and the root, where this root-layout dialog can also
   appear. That follows from treating an absent slug as permissive, which is
   deliberate (blanking a value for a caller that cannot ask the question is the
   failure that already cost this unit two pages of chips). Documented in the
   component rather than closed: closing it needs the dialog to learn which
   workspace the ITEM belongs to, which its DTO does not carry.

Also removed the stale "five surfaces" / "six call sites" counts from comments
that had outlived them — numbers in prose go stale silently, and this branch has
now changed that count four times.

Still open and named, not silently dropped: `playbooks/[slug]/+page.svelte`
assigns `fields.status` into a form control and writes it back. That is an
EDITOR rather than a renderer — a different sub-class, since the failure is a
relation id round-tripping through a select rather than being displayed — and it
wants its own unit.

BUG-3067

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

* fix(web): a label that contradicted its own action, and a guard fed the wrong input (BUG-3067, round 7)

1. WITHHOLDING THE CHIP BROKE A BUTTON. Round 6 made the playbooks list claim
   nothing while its schema is unknown, which was right for the chip — and the
   toggle's label is computed from that same `status`, so every card read "Mark
   as Draft" regardless of what it held, while the handler read the real stored
   value and did something else. A label contradicting its own action is worse
   than the wrong chip round 6 was fixing. The button waits for the schema
   instead of guessing.

   That is this unit's shape one more time: a value withheld at one site is a
   value some other site was reading.

2. `OpenChildrenDialog` WAS COMPARING AGAINST A SLUG THAT MAY BE WRONG. Round 5
   gave it the route's workspace param; round 7 pointed out the dialog is
   root-mounted and its request can outlive a navigation, so that param can name
   a workspace the children do not belong to. A guard fed the wrong input does
   not fail safe — it answers "stale" for a store that is correct and "fresh"
   for one that is not, confidently either way. Worse than no comparison.

   It makes none now and says so. The real fix is for the children DTO to carry
   the item's workspace, which it does not. The residual risk is rated and
   stated: the value is still type-checked against whatever schema is loaded, so
   the failure mode is a withheld or shown chip under a same-slug collision
   between two workspaces — not a printed id, which is the thing this unit
   exists to prevent.

3. The REF-fallback misstatement was still sitting in the ruling's own test
   file, one commit after I corrected it in the implementation. Same wrong
   sentence, second artifact — which is the pre-push habit this seat already
   owes and did not run: a claim corrected in the source is not corrected in the
   file that pins it.

BUG-3067

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

* fix(web): sort on the declared value too, and stop pretending to compare (BUG-3067, round 8)

Round 8 returned the whole withheld-value table I asked for instead of one more
instance, which is what finally made this converge.

1. THE SORT WAS STILL READING THE RAW VALUE. Withholding the chip fixed what a
   playbook card SHOWED and left what it was ordered BY — the same value wearing
   a different hat. A retyped status stores an item id, which is not an order
   key: `STATUS_ORDER` misses it and every such row collapses to the same
   default rank. The sort asks the same question the cards ask now.

   This is the third instance of one shape in this branch, and the reason to
   state it as a shape: a value withheld at one site is a value some other site
   was reading. Round 7 found it in a button LABEL, round 8 in a SORT KEY. The
   table is on the trail so the next person starts from it.

2. NOTHING PRETENDS TO COMPARE ANY MORE. `OpenChildrenDialog` was left holding
   `notStale = $derived(true)` and an unused import — a comparison that reads
   like a guard and is a constant. Both are gone: the call simply does not pass
   the argument, and the comment says why no comparison is possible here rather
   than leaving a variable to imply one. Two dead imports on the dashboard page
   went with them.

3. The dialog's prose still carried the round-5 paragraph calling the route
   workspace the "honest source", directly above the round-7 paragraph
   explaining that it is not. Corrected: one account, not two contradicting
   ones, with the residual risk rated in it.

Left alone deliberately, from the same table: `isDone` / `doneCount` in
`ChildItems` and `NestedChildren` still test the raw status against the terminal
list. A relation id matches nothing there, so such a child reads as NOT DONE —
which is the honest answer for a status nobody can interpret, and changing it
would make a checkbox depend on a schema fetch.

BUG-3067

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

* perf(web): resolve the sort's field once, through a door the module was missing (BUG-3067, round 9)

Round 9 found no functional defect. Three things, all small, and one of them is
the source guard earning its keep.

1. The playbooks sort called the helper INSIDE the comparator, so it reparsed
   the collection's schema JSON on every comparison — O(n log n) parses of one
   string to answer a question that is per-COLLECTION, not per-row. Resolved
   once in a `$derived` and reused.

2. MY OWN GUARD CAUGHT THE FIX. Hoisting meant holding a `FieldDef` and needing
   a way to answer with it, and the shortest path was to import
   `categoricalChipValue` — the lower-level dependency the guard forbids. The
   test failed the moment I ran it, which is the whole point: a bypass is how a
   shared question acquires a second answer, and round 1 of this branch found
   `DetailCard` doing exactly that. The guard was right and the module was
   missing a door; `categoricalValueForField` is that door.

3. Two comments outlived their facts. `categoricalFieldValue.ts` still counted
   the call sites, a number this branch has changed five times — it names them
   without counting now. And `schemalessReaders.test.ts` claimed "per-surface
   render tests prove two of the surfaces call it", which was never true after
   the render test for the second surface was deleted as vacuous; it now states
   exactly what it proves (no surface IN ITS OWN LIST answers privately) and
   what it cannot (it does not sweep server projections, print markup, share
   routes or prompt substitution, and cannot tell an imported helper that is
   called from one that is ignored).

BUG-3067

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu
2026-09-14 03:15:47 -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