mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 06:19:49 +00:00
a03c6d60bd
* 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
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