Commit Graph

1224 Commits

Author SHA1 Message Date
xarmian f7b2558196 fix(web): ESC closes the pane before its ItemDetail has loaded (BUG-3148) (#2053)
ItemDetail is imported on demand (TASK-2226), so a cold open shows the
PaneHost shell (.item-pane) before ItemDetail exists. The pane-close ESC
layer was registered by ItemDetail, so in that window the escape stack held
only the list-focus layer and ESC did nothing. Measured with
pane-a11y-focus :200's own steps at 4 workers: 14 of 20 runs had the stack
top at the list priority, and each of those failed to close; every passing
run had the pane layer on top and closed via replace-delete. That is the
:200 failure on #2046 (?item= still set at :222).

The shell now registers the pane-close layer for as long as it is mounted;
ItemDetail no longer does (PaneHost is its only embedded mount).

New e2e holds the ItemDetail chunk (found by a string only it contains),
presses ESC on the empty shell, and expects the pane to close: red on main
(?item= stays), green 5/5. Pane + escape e2e specs 71 passed; vitest
5684/5684; check 0 errors.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-11 05:30:53 -04:00
xarmian e5997aee09 fix(web): an agent's [[REF]] applied to an open tab is a link, not bracket text (BUG-3582) (#2052)
The designated applier handed the raw markdown to setContent, skipping the
wiki-link → link conversion the load and the collab seed apply, so every
open tab showed an agent write's [[TASK-5]] as literal text until reload.

toEditorMarkdown (utils/markdown) is now the one conversion every path that
hands the editor a body calls: the load's editorContent, the seed capture,
the lazy seed, and the applier. rawCanonical's open-tab prediction (BUG-3540)
mirrors the applier, so decideRawStale converts its texts the same way
before the await; measured: a title link [[Fix it]] is now stored by an open
tab as [[TASK-5]], and the unconverted prediction would have raised a false
stale dialog.

The stored body survives intact (the item's open question): [[TASK-5]] and
[[TASK-5|shown text]] round-trip unchanged through the headless pipeline.

e2e: an API PATCH while a tab is connected renders the link and stores the
wiki-link. Red on main (no link), green 3/3.

Identity gate: four rows re-reviewed (the two force-refresh continuations
and the lazy seed moved only by their enclosing context; decideRawStale by
the pre-await conversion). The shared $lib/utils/markdown import line is
left unchanged (wikiLinksToMarkdown now unused there) because the gate
hashes it into ~30 unrelated rows; toEditorMarkdown is on its own line.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-11 04:24:42 -04:00
xarmian 22a32efdb5 fix(web): skipped board cards keep their placeholder height, so a scroll to a far card lands on it (BUG-3553) (#2050)
Board cards use content-visibility: auto (TASK-1347) inside a
height-constrained flex column that scrolls. With the default
flex-shrink: 1, flex layout shrank every skipped card to 0px, so the
80px contain-intrinsic-size placeholder never applied and the lane's
geometry for off-screen cards was wrong by a card each. Measured on a
200-card lane: 178 cards at 0px, and scrollIntoView on the last card left
the column at scrollTop ~2900 instead of ~24500, the CI failures' figure
(2921). A click aimed there landed on another card: the pane-controller
:568/:642 pair and the anchors :290 case.

flex-shrink: 0 on .card-wrapper. List and table rows do not collapse
(measured: their parents are not height-constrained scrollers).

New e2e, in its own collection so the shared Docs board gets no cards:
80 cards, none at 0px, and the last card centres and is hit after
scrollIntoView. Red on main (61 cards at 0px), green with the fix 3/3.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-11 03:17:00 -04:00
xarmian d8a44bb5ee test(e2e): record the geometry when a board-card click fails (BUG-3553 probe) (#2049)
* test(e2e): record the geometry when a board-card click fails (BUG-3553 probe)

pane-controller :583 and pane-content-link-anchors :290 fail on CI with
another card intercepting the hit point, and then "not stable" until the
timeout. It has not reproduced locally (a CI-shaped desktop shard 1/2 at
2 workers passed clean), so the mechanism has to be read from a failing
CI run. clickBoardCard clicks as before; on failure it records the target's
box over ten frames, its lane index, the column's scroll, neighbours'
content-visibility state, transforms, and the element under the hit
point, attaches it, and rethrows. No wait is added.

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

* test(e2e): BUG-2871 spec opens its own parent on a retry; its click is probed (BUG-3553)

On #2049's first run the spec's first attempt clicked the parent card and
no pane opened (the BUG-3553 family), and its retry then opened the FIRST
attempt's parent: the locator matched the title prefix and took .first().
It now selects the card by the seeded slug. With --repeat-each=3 on one
server the old locator fails exactly as CI did; the new one passes 3/3.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-11 02:21:30 -04:00
xarmian d6a45bf339 feat(billing): billing availability and prices come from the sidecar; PAD_BILLING=auto|off (TASK-3577) (#2048)
* feat(billing): billing availability and prices come from the sidecar; PAD_BILLING=auto|off (TASK-3577)

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

* style: gofmt (TASK-3577)

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

* test(billing): session offer matrix, contract decode, served-price e2e (TASK-3577)

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

* fix(web): sweep every user's expired kept drafts at boot (BUG-3571 follow-up)

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-11 01:29:49 -04:00
xarmian 78ec772226 fix(collab): recovery refuses an incomplete op-log replay, and a failed compaction snapshot keeps the log (TASK-3574 (1) and (3)) (#2045)
From the day-91 outside review's op-log durability claims, both measured
and ruled by the lead.

(1) Recovery (materializeWith) serialized an op-log whose replay left
structs PENDING, because they referenced a row the log did not hold. It
returned a truncated but nonblank body ("alpha" for "alpha beta gamma"),
which the store's only guard (blank output over a nonblank body) let
through: the recovery flush stored it and moved the watermark over content
it never saw. Compaction (snapshotWith) always refused the same input.
- The bundle now refuses it too ("pending structs").
- The Runner and the supervisor map that to a typed
  materialize.ErrIncompleteLog (wrapped with ErrJobFailed through the
  supervisor).
- Recovery treats it as deterministic: it exhausts at once with kind
  incomplete_log, so the existing WARN exhaustion line and
  pad_materialize_giveup_total{kind="incomplete_log"} are the refusal's log
  and metric. The body and watermark do not move, and there is no retry
  until the op-log changes.
- The GC sweep already leaves such an item's rows alone: it prunes only
  fully flushed logs. A test pins it.

(3) With compaction on (PAD_OPLOG_COMPACT, default off), a failed snapshot
left the item to the sweep, which deleted its log and with it the Yjs
identity compaction exists to keep. A failed (or unreadable) snapshot now
KEEPS the log and retries with in-memory backoff: 10 minutes, doubling,
capped at 24h; a restart retries once. Success clears the backoff, and
entries for items no longer listed are dropped. The test that asserted the
delete is flipped to assert keep, back off, then compact (lead ruling).

Tests: the bundle refuses the incomplete log while the complete one
materializes; recovery leaves the body, seq and state untouched, gives up
once with incomplete_log, and the sweep does not list the item; the
compaction keep/backoff/retry test. Counterfactuals: without the bundle's
refusal, the bundle returns "alpha" and recovery APPLIES the truncated body;
without the keep, all 27 rows are deleted.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 01:23:31 -04:00
xarmian db6f0cde1f fix(collab): a schema rebuild sets aside the WHOLE op-log, so set-aside edits can be replayed (TASK-3574 (4)) (#2047)
SetAsideAndClearOpLog kept only the content-bearing rows above the flush
watermark. Those rows build on structs in the rows below it, so replayed
alone they leave every edit pending and render nothing (measured: pending,
''), and no decoder could ever recover them from the set-aside rows; the
base existed elsewhere only as markdown in items.content.

When the log holds any unflushed content-bearing row, the rebuild now sets
aside the WHOLE op-log, every row verbatim and in op-log order, the flushed
base and the non-content-bearing frames included (a SyncStep2 can be the
only copy of an edit, TASK-3574 (2); the rest replay as no-ops). A fully
flushed log still sets aside nothing and the item stays clean. Not live
while SCHEMA_VERSION is frozen; TASK-3246's decoder now gets a replayable
quarantine.

Tests: the store sets aside all four rows of [u1 flushed, u2, step2, u3]
verbatim and in order; a flushed log sets aside nothing; a vitest shows
those rows replay to 'alpha beta gamma' while the old suffix replays to ''.
TEST CHANGE (the ruling replaces what it pinned):
TestBUG3244SchemaRebuildKeepsUnflushedEditsMarked's 'only unflushed
content-bearing rows' subtest now asserts the whole log, in order.
Counterfactual: with the old selection the store keeps 2 of 4 rows.
docs/api.md updated.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 00:17:53 -04:00
xarmian d93a94a42c fix(collab): recovery replays SyncStep2 rows, which can hold the only copy of an edit (TASK-3574 (2)) (#2046)
A SyncStep2 is stored non-content-bearing (BUG-3523): it usually repeats
content other rows carry. But it can be the ONLY persisted copy of an edit:
the edit's own update append failed but was still broadcast, its tab closed
before its catch-up, and another tab answered a joiner with a step2 carrying
it. LoadMaterializeInput read bytes only for content-bearing rows, so
recovery never saw that edit. Measured: rows 0, pending 0 for a step2-only
item, and a recovered body of 'alpha' where the document held
'alpha beta gamma'.

LoadMaterializeInput now hands recovery every content-bearing row PLUS every
SyncStep2 (by the store's own strict yjsFrameIsSyncStep2 parse). Replaying a
step2 that repeats content is harmless (Yjs deduplicates); one that holds the
only copy recovers the edit. It still does not count toward Pending, so
content_state is unchanged; whether a step2 should count needs a design note
first (lead ruling). Byte-identical twins and SyncStep1 frames stay out.

Tests (real Yjs frames pinned as literals): recovery over [u1, a step2
carrying u1+u2, u3] stores 'alpha beta gamma'; a step2-only op-log hands its
bytes to recovery and still reads Pending 0. Counterfactual: without the
step2 branch, recovery stores 'alpha' and the input omits the step2.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 00:07:56 -04:00
xarmian 46490b0612 fix(web): keep unsaved text when the session ends under an open item (BUG-3571, part 2) (#2044)
* fix(web): keep unsaved text when the session ends under an open item (BUG-3571, part 2)

Ruling R1. Text an open item can no longer save under its author's session
is kept in this browser as a kept raw draft under the author's id, and the
existing kept-draft offer and its version-checked restore give it back:

- an identity change the tab did not start: the item's identity listener
  keeps the previous user's text first, before it retires and reloads;
- a session-expiry 401: every redirect to /login in the API client runs the
  open items' keepers before it navigates (the redirect does not move the
  identity epoch, and the unload flush after it goes out on a dead session).

Rich text is kept as the markdown a flush would send (collabFlusher.snapshot).
Kept drafts now expire after seven days, and an explicit sign-out purges all
of the user's. A confirmed sign-out discard still keeps nothing.

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

* test(web): e2e for the keep on session expiry (BUG-3571, part 2)

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-10 23:52:12 -04:00
xarmian 92305fba4d fix(web): marks never sit on inline atoms, so a tab's editor equals its Y.Doc (BUG-3568) (#2038)
ProseMirror lets a mark sit on an inline atom (a hard break, an inline
attachment image or chip). Bolding, striking or linking a range that held
one marked it, and markdown or HTML parsing marked it when the source
wrapped it (**a\
b**). y-tiptap never writes marks on non-text inline nodes to the Y.Doc,
so that tab's editor held a document its own Y.Doc did not. It flushed one
body while every other tab, a reload and the materializer derived another.
This is the ~0.7% tab divergence materializerParity found. Measured: both
Y.Docs identical, and only the marking tab's ProseMirror doc differs; a
one-tab repro with no relay reproduces it 4 of 4.

InlineAtomNoMarks (extensions/inlineAtomNoMarks.ts) is an appendTransaction
plugin that strips marks from inline non-text nodes in the same update. It
fires only on AddMarkStep, AddNodeMarkStep, or a replace whose slice holds a
marked atom, so typing pays nothing. It is registered in Editor.svelte and
the headless materializer. No node spec or persisted shape changes, so no
SCHEMA_VERSION question; NodeSpec.marks cannot do this, since it governs a
node's content (measured: it left the marks in place).

One-time normalisation: a body holding a mark across a hard break
(**a\
b**) flushes as **a**\
**b**, the form every Y-derived door already produced, so this touches only
bodies that already flip-flopped. It is stable from then on.

materializerParity: MAX_TAB_DISAGREEMENTS 2 -> 0. The corpus is regenerated;
seed-300922 is now a case. The generator is not deterministic run to run
(Yjs client ids), so a regen also rewrites a couple of other cases'
expectations; each corpus is self-consistent.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-10 21:57:46 -04:00
xarmian 2b0652d192 fix(web): signing out asks first while an open item holds edits the server may not have (BUG-3571, part 1) (#2041)
Signing out with unsaved typing discarded it silently. Lead ruling,
part 1: an explicit sign-out asks first, Cancel is the default, and a
confirm is the user choosing to discard: their text is dropped and
nothing is kept on the device.

signOutGuard: an open ItemDetail registers atRisk() (the provider's
editsAtRiskOnClose from BUG-3556, or a dirty raw editor) and discard()
(cancel the raw saver, clear the item's kept draft). Every sign-out
control awaits confirmSignOut() first; the population is found by
search in the wiring test (seven sites). A confirm sets
signOutDiscarding(), so the navigation to /login (which unloads BEFORE
the identity changes) neither asks again nor flushes or keeps the text;
a failed logout resets it.

Identity gate: runTeardownFlush and the onBeforeUnload row re-reviewed
(one early return on a module read; no await, no write). The registration
effect sits at the end of the script and adds no component binding, so
no other row re-hashes.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-10 21:33:52 -04:00
xarmian c0beb4da34 fix(web): closing or leaving an item asks first when typing may not have reached the server (BUG-3556) (#2037)
The leave and unload prompts read unsentLocalEdits, which misses two
at-risk states the provider already tracks: typing sent on a socket that
closed before the server confirmed it (folded into catchUpOwed at
close), and a resend after the server reported a failed op-log append
(barrier_ack ok:false), which clears unsentLocalEdits while the resend
is unconfirmed. Either way the tab could close with no prompt, and if
the unload flush was over the keepalive budget the typing was lost.

The provider gains editsAtRiskOnClose: unsentLocalEdits, catchUpOwed,
or a failed-append repair not yet confirmed (repairPending, set on
ok:false and cleared by the barrier that confirms the resend). An
ordinary send awaiting its barrier is left out: that is every keystroke
for a moment. Both prompts read it.

Identity gate: the onBeforeUnload row re-reviewed (a getter read, no
await, no write). The TASK-2199 wiring tests now pin the new read.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-10 20:43:08 -04:00
xarmian 1eecf710c8 fix(web): angle-bracketed prose survives the editor: <Button>, Promise<T>, comments and unknown tags are text, not dropped markup (BUG-3557) (#2035)
The editor parsed markdown with html enabled, so `<Name>` in prose was a
tag the schema could not hold: dropped on the next flush (one keystroke,
or an agent's write applied by an open tab, which answered 200 while the
text vanished). `<Table>` became an empty table that was then removed.

htmlProse.ts derives the allowlist from the schema's own parse rules, so
the HTML the editor itself writes (tables with spans, HTML-only marks)
still reads back:
- an inline tag passes only if written lower case, matching an inline
  node's or a mark's rule, and closing one that passed; anything else
  is literal text;
- an HTML block passes only if every tag in it matches a rule; otherwise
  it is kept whole as an HtmlBlock (stored as a ```html fence);
- comments are visible text.
PadText escapes `<` only where the parse would read a tag, so
`Promise<T>` round-trips byte for byte. Both the editor and the headless
materializer take the same extensions, right after StarterKit.

materializerParity now skips and counts cases where the two live tabs
converge to different documents (BUG-3568, pre-existing: 2 of 300 seeds
on main), at most 2 of 50, and still requires the materializer to match
one of them; such cases stay out of the Go corpus (lead ruling).

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-10 20:12:18 -04:00
xarmian c0e66c6700 fix(models): the fallback terminal lists cover every "won't fix" spelling (BUG-3546) (#2033)
A collection whose status option is the hyphenated wont-fix, with no
terminal_options declared, never closed those items: the fallback lists
spelled it only wontfix / won't fix. Seen in the APM workspace, where plans
needed --force to close over wont-fix children.

DefaultTerminalStatuses and NegativeTerminals now list wontfix, wont-fix,
won't fix and won't-fix: exact spellings rather than a normalizing compare,
because the list also feeds SQL IN (...) filters, which match exactly. The
web mirrors (types/index.ts) carry the same values, and
TestTerminalListsMatchWeb parses them and fails on any drift; until now a
comment asked for that. The CLI and board colour the new spellings as
negative terminals. Pad's own web Bug Tracker template, which uses wont-fix,
now declares terminal_options [fixed, wont-fix] and abandoned_options
[wont-fix].

childProgress.test.ts pinned DEFAULT_ABANDONED_STATUSES to the old literal;
its expected value now includes the new spellings.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-10 18:59:25 -04:00
xarmian 28872c2410 feat(admin): the MCP audit page names users and connections instead of truncated UUIDs (TASK-2255 C110) (#2032)
GET /admin/mcp-audit gains user_name (the name, else the email) and
connection_name (the OAuth client's name, else the connection's
label; a PAT's name), resolved in one IN-list query per kind per page
and omitted when the id no longer resolves. The page shows them,
falling back to the short id. docs/api.md documents the route.

MCPAuditNames reads oauth_refresh_tokens and oauth_connections, so it
is classified in the TASK-3399 reader census as censusPerGrant: it is
addressed by request ids already present in audit rows and never lists
a user's chains.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-10 18:32:29 -04:00
xarmian 5551e9fd52 test(e2e): mermaid and the graph view draw on the embedded bundle, each with a counterfactual (TASK-3552) (#2029)
* test(e2e): mermaid and the graph view draw on the embedded bundle, each with a counterfactual (TASK-3552)

Dependabot moves mermaid and 3d-force-graph, and nothing checked either
still paints. Each leg measures through one helper, run where the thing
must be drawn and where it must not: a two-node flowchart vs invalid
source, and two linked items vs an empty workspace.

The graph measure hides everything over the WebGL canvas, the
renderer's own navigation hint included: that text alone is ~120
colours and made an empty graph read as drawn. Receipt: drawn
237-264 colours, empty exactly 1, against a bound of > 3.

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

* test(e2e): TASK-3552 legs write to workspaces of their own, not the shared one (BUG-3553)

Docs created in the suite's shared workspace reflow its docs board
under other specs' clicks; pane-content-link-anchors :290 was
intercepted that way on this PR's first CI run. Each leg now makes its
own workspace. Receipt re-measured there: drawn 198-225 (desktop) and
1134-1146 (mobile) colours, empty 1 and 2, against a bound of > 3.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-10 17:47:23 -04:00
xarmian 0015f49c1e fix(web): settings say whose they are, roles and grants explain themselves, webhooks say where they live (TASK-2255 C59/C60/C109) (#2030)
C59: the user menu and the sidebar both said "Settings" and went to
different pages. The workspace page is now "Workspace settings", the
user menu says "Account settings", and each page names and links the
other. Theme says it is saved in this browser only.

C60: the invite form explains Owner, Editor and Viewer in one line
each, and the share dialog says a share is not a membership.

C109: plans sell a webhook quota and the web UI has no webhook screen;
the Apps tab now says webhooks are managed with `pad webhook` or the
API.

C110 (MCP audit names) is a server-side join and lands separately.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-10 17:34:24 -04:00
dependabot[bot] 356eb8a4d4 chore(deps)(deps): bump the npm-minor-and-patch group across 1 directory with 4 updates (#1999)
Bumps the npm-minor-and-patch group with 4 updates in the /web directory: [3d-force-graph](https://github.com/vasturiano/3d-force-graph), [mermaid](https://github.com/mermaid-js/mermaid), [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) and [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest).


Updates `3d-force-graph` from 1.80.0 to 1.80.1
- [Commits](https://github.com/vasturiano/3d-force-graph/compare/v1.80.0...v1.80.1)

Updates `mermaid` from 12.0.0 to 12.1.0
- [Release notes](https://github.com/mermaid-js/mermaid/releases)
- [Commits](https://github.com/mermaid-js/mermaid/compare/mermaid@12.0.0...mermaid@12.1.0)

Updates `vite` from 8.3.1 to 8.3.2
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.3.2/packages/vite)

Updates `vitest` from 5.0.2 to 5.0.3
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v5.0.3/packages/vitest)

---
updated-dependencies:
- dependency-name: 3d-force-graph
  dependency-version: 1.80.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: mermaid
  dependency-version: 12.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: vite
  dependency-version: 8.3.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: vitest
  dependency-version: 5.0.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-10 16:24:24 -04:00
xarmian 1fc60d72b4 fix(web): a save refused for an open tab's unconfirmed typing asks, and Overwrite lands (TASK-3548) (#2025)
* fix(web): a content save refused for an open tab's unconfirmed typing asks, like a pending-flush refusal (TASK-3548)

Since BUG-3542 a content write to an item open in another tab answers 409
content_not_applied, apply_reason unconfirmed_edits, while that tab holds
typing the server has not stored. No content-write site recognised it:
the item pane's debounced raw save showed 'Failed to save content', its
teardown flush said nothing, and the rich fallback, the kept-draft
restore, the raw flush, the conventions page and the playbook editor all
fell through to their generic failure.

isEditsNotStoredRefusal() is either refusal; all seven sites use it, so
each asks 'Unsaved edits in an open tab' and Overwrite resends once with
overwrite_pending_edits (which lifts both), and the teardown reports the
pending-edits sentence. Other content_not_applied arms keep the generic
path.

Identity gates re-reviewed: ItemDetail's rows moved because the units
they call changed; the keepalive row's pinned code names the new helper;
the playbook save() row is re-read.

* fix(web): Overwrite after an unconfirmed-edits refusal lands (TASK-3548, option B)

An unconfirmed-edits refusal still commits its row write (it versions the
old body first), so seq moves under the caller's token and the overwrite
resent with it met update_conflict: the user chose Overwrite and the save
did not land (codex). On that refusal each token-carrying site now
re-reads the item by id, behind its fence, and when the stored body is
still the one the token was taken against (stillOnBase) resends with the
fresh token. The raw paths also keep the Overwrite answer armed across a
silent rebase.

task-3548-overwrite-lands.spec.ts pins it at every token-carrying site
(raw save, raw flush, kept-draft restore, conventions, playbook editor):
the route makes a real non-content change (seq moves) before answering
the refusal, the overwrite then goes to the real server, and the body is
read back. All five legs fail on the previous commit.

* fix(web): the fresh-token overwrite requires the row to hold what the save sent (TASK-3548, codex)

The playbook save sends its title and the conventions save its metadata;
re-sending them with a fresh token would put back a value someone else
changed after the refusal. holdsWhatWasSent requires the re-read row to
hold the sent title and every fields_patch value, else the save meets the
conflict as before. The conventions save fences its re-read on workspace
and identity. New leg: a title someone else set is not restored (it was,
without this).
2026-10-10 14:41:25 -04:00
xarmian c4354571d0 fix(web): keys typed before the slash or [[ menu opens are its query (BUG-3547) (#2024)
* fix(web): keys typed before the slash or [[ menu opens are its query (BUG-3547)

Both menus open in a deferred tick (so the caret rect is read after the
keystroke lands), and the query was tracked only while the menu was
open. Keys typed before that tick were lost: under load '/link' opened
the UNFILTERED list and Enter ran its first entry, which is the
task-2218 :77 flake (4 of 30 at 6 workers; the trace showed '/link'
deleted, no '[[', no picker). The deferred open now reads what is
already typed after '/' or '[[', with the same no-match close.

The new task-2218 leg freezes the page clock, types '/link' before the
open, then releases it: one entry, and Enter opens the picker. It fails
on main every run. The spec passes 75/75 at 6 workers.

* fix(web): the deferred [[ open checks its trigger is still there (BUG-3547, codex)
2026-10-10 11:49:06 -04:00
xarmian 4c733467ef fix(web): space between the pad wordmark and the first workspace tab (TASK-3545) (#2021)
* fix(web): space between the pad wordmark and the first workspace tab (TASK-3545)

The desktop top bar's tab strip started 72px from the left, about 6px past
the end of the 'pad' wordmark (7.1px measured). It now starts at 82px:
17.1px measured at 1280 and 820, light and dark. The mobile header has no
wordmark or tab strip, so it is unchanged.

task-3306's spec pins the gap at >= 12px (it fails on the old layout),
and runs its six-tabs-at-200px leg at 1460 instead of 1440: six tabs had
about 4px to spare there, and the gap took 10.

* fix(web): the logo sits in the desktop top bar's row, so the gap is fixed (TASK-3545)

CI measured 10.9px where local measured 17.1px: the wordmark uses the
system font stack, and with the logo absolutely placed and the strip at a
fixed offset, the space left over depended on how wide the font drew
'pad'. The logo is now in the row on desktop: the first tab starts the
bar's 8px gap plus the list's 9px flare inset after it, 17px whatever the
font. The spec widens the wordmark and asserts the gap does not move; it
fails on the fixed-offset version.
2026-10-10 10:29:55 -04:00
xarmian e303c16116 build(web): jsdom 30 (TASK-2586) (#2019)
* build(web): jsdom 30 (TASK-2586)

jsdom 30.1.2 declares node ^22.22.2 || ^24.15.0 || >=26.0.0; every path that
installs web deps runs Node 24.21.0 (setup-node "24" on Web/E2E/release,
nixpkgs nodejs-24.21.0 for Nix), and so does local dev.

jsdom 30 will not focus into an element that is not rendered:
- tests that focus inside a mocked-modal <dialog> now open it first, and
  the 'adrift' test asserts focus landed before it blurs;
- attachment-image's placeholder path blurred the <img> only after hiding
  it, which is a no-op on an unrendered element. It now blurs first
  (a browser's focus fixup covered it, later).

* build(web): lock jsdom 30.1.1, not 30.1.2 (TASK-2586)

30.1.2 was published 2026-10-04, inside the 7-day dependency-age window
(TASK-1391). 30.1.1 (2026-09-22) carries the same engine range and the
same focus change.

* build(web): resolve jsdom 30's new dependencies from before 2026-10-03 (TASK-2586)

npm install --before=2026-10-03 so the transitive versions it pulls are
outside the 7-day dependency-age window too.

* build(web): the lock's root range matches package.json (^30.1.1)
2026-10-10 09:45:55 -04:00
xarmian 186ffdba63 fix(web): the remaining native confirm() dialogs ask through the shared ConfirmDialog (TASK-3543) (#2018)
* fix(web): the remaining native confirm() dialogs ask through the shared ConfirmDialog (TASK-3543)

Settings (remove member, cancel invitation, give up owner role), roles
(delete role) and the collection create/edit discard guards use
confirmDialog. Each captures identity and workspace (and the role, or
the edited collection) before the dialog's await and re-checks after
it; the collection guards are async now, ask one question at a time,
and do nothing when the modal closed meanwhile. ConfirmDialog takes an
optional cancelLabel ('Keep invitation', 'Keep editing').

The beforeNavigate confirms (ItemDetail, playbook editor) stay native:
they must block synchronously.

* test(web): a self-demotion answered after a workspace switch sends nothing (TASK-3543, codex)

* test(e2e): the collection discard question is the app dialog now (TASK-3543)

task-2186's legs answered the native confirm() through page.once('dialog').
They now answer the app's ConfirmDialog, and match the collection modals
exactly: 'New Collection' is a substring of 'Discard this new collection?'.
2026-10-10 09:34:37 -04:00
xarmian 5960cf1978 fix(collab): an open tab refuses a content write over typing the server has not stored (BUG-3542) (#2016)
A content PATCH routed to an open tab (the designated applier) replaced
typing that was still on the wire. The BUG-3133 pending-edits check reads
the op-log when the PATCH arrives and cannot see it.

The server now marks the applier_request `guarded` unless the caller sent
overwrite_pending_edits. A tab whose edits may be missing
(WsProvider.editsMayBeMissing: unsent, catch-up owed, or sent without a
barrier ack) answers applier_refuse instead of applying. The server takes
that as final (no re-election) and the PATCH answers 409
content_not_applied with apply_reason unconfirmed_edits and content_landed
false; other fields land, as on every content_not_applied. Tokenless writes
are refused too, by ruling; overwrite_pending_edits is the only escape.

ToolSurfaceVersion 0.84 (BEHAVIOR bump). docs/api.md and docs/mcp.md updated.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-10 09:08:04 -04:00
xarmian 1a76ca3f58 fix(web): app dialog and toasts replace native confirm/alert; sticky save and sync status (TASK-2221) (#2015)
* fix(web): app dialog and toasts replace native confirm/alert; sticky save and sync status (TASK-2221)

C39: refresh-from-source and comment delete ask through a shared
ConfirmDialog (queue store, answered NO on identity change) instead of
native confirm(); each call fences on item, identity and editor after
the await. Editor and CommentEditor upload/transform failures are error
toasts, not window.alert. The beforeNavigate confirm stays native: it
must block synchronously.

C38: SaveSyncStatus in the full-page breadcrumb and the pane header
shows Saving, Saved, Reconnecting and Offline, with a polite
role=status region announcing Saved, Offline, Reconnecting and Back
online.

* fix(web): the save/sync chip shrinks before the pane controls do (TASK-2221, codex)
2026-10-10 07:41:26 -04:00
xarmian 77b2c256fd feat(web): list group headers get the board lane's bulk actions (TASK-2222) (#2012)
* feat(web): list group headers get the board lane's bulk actions (TASK-2222)

From the 2026-07-20 UX audit (C96): "tag/assign/set priority on everything in
the Open group" was one menu action on the board but needed a view switch from
list and table, and list users never discovered bulk ops. Each list group
header now carries the board's ⋯ LaneActionsMenu (move all, tag, untag, set
priority, assign) beside its archive button, on the group's currently-filtered
items, through the same bulk handlers and the same canBulkEdit gate the board
uses. Move all is offered only where the board offers it (a bulk-movable
group field, never a relation grouping).

The header is a role=button that toggles collapse, so it ignores clicks and
keys that start inside the menu (the menu's buttons stop propagation; its
panel and separators do not). An open group's .item-group is overflow:visible,
so the menu is not clipped in a short group. The ⋯ is always visible: 28px,
and 44px at touch widths (TASK-3311). Archive keeps its own button, which the
TASK-3311 spec pins.

The table view is not in this change.

* fix(web): the list group header is a toggle button with its actions beside it (TASK-2222)

Codex round 1: the header was a role=button div, so the new ⋯ button (and the
existing archive button) were interactive controls nested inside another.
The header is now a plain div whose empty space still toggles on a click (as
before, and as the BUG-3492 spec clicks it); the keyboard and assistive-tech
control is a real <button class="group-toggle" aria-expanded> holding the
collapse icon and title, and the count, archive and ⋯ menu sit beside it in
.group-actions, whose clicks the header ignores. Tests pin the structure (no
role on the header, no button inside the toggle, the toggle and the header
space both toggle).

* fix(web): no group drag while a list group's menu is open (TASK-2222)

Codex round 2: the menu sits inside a draggable group (one dndzone over the
groups), so a press-and-drag on it could reorder the groups mid-action, and a
drag ending outside could close it. The group zone's dragDisabled now also
holds while any group menu is open. dnd-action's zone config is not
observable in jsdom, so this is not unit-tested; the collection suites pass.

* fix(web): the list group ⋯ clears the archive button's touch target (TASK-2222)

CI (E2E mobile, TASK-3311 touch-targets spec): 24 points of the archive
button's 44x44 area landed on the new ⋯ group-menu button, whose box started
inside the archive extender's ~12px overhang. On touch widths the ⋯ now has a
14px left margin, past the overhang; the archive comment no longer claims the
header holds no other control. The spec also checks the ⋯ is 44x44 and takes
none of the archive area.

* fix(web): the ⋯ clearance margin goes on its wrapper, outside every box (TASK-2222)

CI again: the margin on the button widened .group-menu-wrap over the archive
extender's overhang, and the wrapper took those 24 points. A margin on the
wrapper itself sits outside every element's box.
2026-10-10 04:08:57 -04:00
xarmian c78037adbf feat(web): a Library link in the sidebar's Agent section and the mobile sheet (TASK-2258) (#2013)
From the 2026-07-20 UX audit (C43): the playbook and convention library, the
discovery surface for what agents can run, had no navigation entry anywhere;
its only inbound paths were buttons inside the Conventions and Playbooks
pages. The library is now a primary destination (nav/destinations.ts, so the
mobile Workspace sheet gets a tile) and a link at the end of the sidebar's
Agent section, active on /library and its tabs. Every member may browse it,
viewers included; the page already gates activation on edit rights
(canCreateIn). Hidden on a guest's shared workspace, like Settings: a guest
sees what was shared, not the workspace's agent setup.

C116: the sidebar nav overflowed right after the Agent header at 1440x900,
which read as an empty section. A fade now sticks to the nav's bottom edge
while it overflows (and sits after the last row when it does not), so there
is visibly more below.
2026-10-10 03:57:33 -04:00
xarmian edf7e22262 fix(web): a raw markdown save carries its base and asks, not replaces, over a changed body (BUG-3540) (#2011)
* fix(web): a raw markdown save carries its base and is asked about, not applied, over a changed body (BUG-3540)

A raw save was a blind replace. Tab B's raw text is seeded before tab A types;
once A's edit is STORED, nothing is pending, so refuse_pending_edits let B's
save replace it (reproduced 3/40 under load, now deterministic in e2e).

Raw saves (debounced, keepalive, toggle drain) now send expected_seq: the
row the raw text was seeded from (rawBase.ts), moving with each landed save.
A 409 update_conflict reads the item: a body this tab already has EXACTLY
(its base or text it sent; no looser match, lead ruling) rebases and resends
once; anything else asks, in the pending-edits dialog's shape, with no
default: Reload the stored text, Overwrite with my text, or close (send
nothing). An unload save that is refused stays kept in the browser and is
offered on the next open, as for pending edits. No server change.

ItemDetail identity gate: decideRawStale/resolveRawStale tabled and fenced
(the gate caught both missing fences); re-hashed rows re-reviewed, block note.

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

* fix(web): a raw save beside an open rich tab is not asked about its own text (BUG-3540)

With a rich tab open, a raw save goes through that tab's applier and its next
flush stores our text in the editor's form (BUG-2995: '* ' bullets become
'- '). Measured: bullets drew the stale dialog on 2 of 3 saves. A refused
raw save now also compares the stored body, EXACTLY, with the form an open
tab would store for each of this tab's own texts: rawCanonical.ts runs the
applier's setContent and the tab's flushPipeline on a headless editor
(loaded on demand).

The materializer's headless editor, its extension list and flushPipeline move
to materializer/headless.ts (no goja shims, no atBlank patch); the bundle
entry imports and re-exports them, so the bundle is unchanged in behaviour,
and the browser never installs the atBlank patch on a live tab.

e2e: three raw saves beside a rich tab ask nothing, plain and bullets (0 of 3
each; bullets was 2 of 3 before). Go internal/materialize passes on the
rebuilt bundle.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-10 03:56:32 -04:00
xarmian 602e57ad86 test(web): a census guard for keyed {#each} keys (TASK-3539, PR C) (#2010)
* test(web): a census guard for keyed {#each} keys (TASK-3539, PR C)

Lead ruling, full table. A repeated key in a keyed each throws
each_key_duplicate during render in Svelte 5 production builds and blanks the
whole view (BUG-3538). eachKeyCensus.test.ts scans every .svelte file under
web/src and requires each keyed each to either key on its own position or
name, in eachKeyCensus.ts, why its keys cannot repeat. It fails:
- on a keyed each with no entry, printing the exact line to paste (the file,
  the whitespace-collapsed `each <expr> as <item> (<key>)`, the reason
  choices);
- on an entry whose each is gone or changed (stale);
- on a reason outside the closed set, or an empty note.

The seven reasons each carry a one-line definition of what "unique by
construction" must mean: static-literal, object-or-map-keys, db-primary-key,
deduped, grouped-by-key, position-or-counter, strictly-increasing.

Seeded from the TASK-3539 census: 257 entries, from the four traced passes'
verdicts plus a hand classification of the sites PR A changed and of the
literal and Object.entries sites, after reviewing every label. A new keyed
each fails until someone adds its line; the two mutants (an unlisted each,
a removed entry) go red.

* test(web): the each-key census scan reads strings, comments and repeats right (TASK-3539)

Codex round 1 on PR C:
- The head scan skips braces inside '…', "…" and `…` strings, with a
  template literal's ${…} counted as code; HTML comments and <script> /
  <style> blocks are blanked (line numbers kept) before scanning.
- Only a position-safe key is exempt: exactly the index, ${index} in a
  template literal, a trailing `+ index`, or the index as an array element.
  `i % 2`, `row.i`, a conditional, or a function of the index (rowKey) need
  an entry; the two backlinks panels' rowKey entries are added.
- A repeated identical head in one file gets an ordinal (#2), so a new copy
  needs its own entry rather than riding on the first; 12 entries added.
- The failure line prints `reason: '???', note: '???'` with the choices
  beside it, where it printed an un-pasteable placeholder.
- ShareDialog's grants are db-primary-key (fresh ids; a repeat grant is
  refused 409), not deduped.
Controls cover each case.

* test(web): read keyed eaches with Svelte's parser; a stricter position rule (TASK-3539)

Codex round 2 found more hand-scanner holes (a `}` in a comment or regex
literal inside a head). Rather than patch the scanner a third time, the census
now walks svelte/compiler's AST (EachBlock: expression, context, index, key),
which reads strings, comments, regex literals and script and comment text
exactly as Svelte does. The id format is unchanged; one entry is renamed
because the parser drops a wrapping pair of parentheses.

The position rule now reads the key's AST and exempts only: the index itself;
a template literal ending `<non-digit>${index}`; `… + '<non-digit>' + index`;
JSON.stringify([…, index]). With the index LAST after a non-digit separator,
the trailing digits recover it, so two positions cannot share a key.
`${i}${x}`, `x.id + i`, `i % 2`, `row.i`, a conditional or a function of the
index need an entry. Controls cover each, and the mutant dropping the
separator condition fails them.

* test(web): no call is a position-safe key, JSON.stringify included (TASK-3539)

Codex round 3: the JSON.stringify([..., index]) exemption trusted a name
that a component can rebind. The exemption is dropped; the one site using it
(EditCollectionModal's schema impacts, twice) has census entries instead.

* test(web): pin why a non-digit literal before the index is position-safe (TASK-3539)

Codex round 4 called x.id + 'a' + i unsafe. The char before the index is the
literal's last, a non-digit, so the key's trailing digit run is the index and
two positions cannot collide. A control states it.
2026-10-10 03:10:29 -04:00
xarmian cf3cdefd0b refactor(web): drop the dead terminalStatuses prop chain (TASK-3537) (#2007)
Since PLAN-3535 PR 3, per-row done styling in ChildItems and NestedChildren
asks childProgress's hub about each child's own collection. ItemDetail still
gathered a union of terminal statuses (getTerminalOptions, with a ['done',
'cancelled'] fallback) and passed it to ChildItems, which no longer read it.
Removed: the computation, the $state, the prop pass, ChildItems' prop, a test
that still passed it, and the two ItemDetail allowances in the PLAN-3535
lifecycle guard.

ItemDetail's identity gate: 10 rows and one helper re-hashed. Their bodies
are unchanged (the diff touches only the import line, the state declaration,
the synchronous handleChildrenChange and the prop); they name the
$lib/types import line, which lost getTerminalOptions. Re-reviewed and
updated with a TASK-3537 note each.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-10 02:03:38 -04:00
xarmian f8faed647a fix(web): keyed lists render repeated keys instead of blanking the view (TASK-3539, PR A) (#2006)
* fix(web): keyed lists render repeated keys instead of blanking the view (TASK-3539, PR A)

A keyed {#each} throws each_key_duplicate during render in Svelte 5
production builds, so one repeated key blanks the whole view (BUG-3538). The
census of all 313 keyed eaches found about 65 sites whose source can repeat a
key. This makes every one of them render.

- Stored schemas: parseSchema now normalises for rendering. A field key
  appears once (first definition wins, the server's library_option_widen
  rule) and a select field's options are distinct and non-empty. That covers
  the item pane, table, list, board, filter bar, lane menu, status picker,
  settings, edit modal and the playbook and convention pages. The public share
  page's visibleFields gets the same normalisation. Board and list lanes also
  never take '' from options, which collided with the ungrouped lane.
- Option lists that can repeat outside a schema: FieldEditor (one optionList
  for the list and the keyboard index; a stored multi_select value can repeat),
  StatusPicker, PlaybookFormFields (triggers, scopes, statuses and the
  argument enum, deduped at render only, so typing "merge|merge-commit" is not
  cut mid-keystroke), TagInput, DisplaySettingsEditor, convention commands,
  connected-app redirect URIs, the tags page.
- Offset "Load more" appends dedupe by id: command palette, admin MCP audit,
  admin users. Local appends that race a reload are guarded by id: saved
  views, convention create, workspace create, item links.
- Entries that may repeat by design key by position: quick actions (no id),
  app manifest events, upgrade review rows, tutorial chapters, the copy
  dialog's carried and needs-value rows. Tutorial paths and slugs from the
  remote catalog dedupe. The schema-impact key is a separator-safe tuple.
- Toast ids take a counter, unique by construction.

Identity gates: handleCreateLink (ItemDetail) and saveCurrentView (collection
page) re-recorded after their append guards; neither moved an await or a
check. The two mutant fixtures anchored on those appends are re-pointed to the
new text, expressing the same defect.

Tests: shared helpers and the parseSchema normalisation; render tests (status
picker, share table, select and multi_select editor) that throw
each_key_duplicate against main's components and pass here.

* fix(web): a schema write sends the stored form; the editor names its repairs (TASK-3539)

Codex round 1: parseSchema's render normalisation leaked into two writes.
- The board's column reorder (handleGroupReorder) sends the schema back; it
  now builds it from parseStoredSchema, so only the reordered field's
  options change, as the user saw them.
- The collection editor still seeds from the render-safe schema (seeding
  from a repeated key would throw in its own keyed lists), and now says so:
  schemaRenderRepairs lists each repeated field, repeated option and empty
  option it removed, above the fields, so a save is a repair the user was
  told about, never a silent one.
Collection identity gate: handleGroupReorder re-recorded; no await or check
moved.

* test(web): re-apply the saveCurrentView gate row and mutant anchor after rebasing over #2001 (TASK-3539)

The rebase conflict in collectionIdentityGate.test.ts was with #2001's own
rows; the resolution took main's table, so the two edits from the first
commit are re-applied here unchanged (same hash, 13a6f5001771).
2026-10-10 00:52:48 -04:00
xarmian 88a26afde0 feat(web): reference children are listed apart and leave progress (PLAN-3535 PR 3) (#2005)
- childProgress: isReferenceChild / splitReferenceChildren; countChildProgress
  and countedChildren leave reference children out; childRowState is the
  per-row done styling (a wrong-shape value read as its lane reads it,
  BUG-3052); statusState judges a status tally by the collection's rule.
- ChildItems: a References group below the status groups (no done styling,
  no reorder), progress bar, chart and fallback count over work children,
  print view likewise; per-row done styling asks the hub instead of a
  hardcoded/inherited terminal list. NestedChildren the same.
- Dashboard collection cards: the done rule per collection instead of a
  hardcoded list; no progress bar for a reference collection.
- Insights picker: filters on tracks_work instead of is_system (the server's
  default report rule since PR 2).
- Web classification guard over every lifecycle-helper call and any
  hardcoded done list; unit and component tests; e2e for the parent page.

ItemDetail still passes terminalStatuses (now unread): removing it re-flags
every unit of ItemDetail's identity gate, so it is a follow-up.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-10 00:23:07 -04:00
xarmian 33e7a503d5 fix(dashboard): a task under two active plans no longer pins the skeleton (BUG-3538) (#2002)
suggested_next added one candidate per active plan, so an item that is a
child of two (a parent link to one, an implements link to the other) was
suggested twice. The web keyed that list by item_slug, and Svelte 5 throws on
a duplicate key during render in production builds too: the dashboard never
left its loading skeleton (cookie-sales, day 90; the link was made 10-02, so
this predates PLAN-3535).

- Server: one plan-children candidate per item; the first active plan names it.
- Web: the Up Next list is keyed by position. item_slug is not unique there
  by design (two fired reminders on one item are two entries).

Tests: a server test with a task under two active plans (fails without the
dedupe), and a dashboard page test with a repeated item (stays on the
skeleton with the old key).

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-09 23:25:20 -04:00
xarmian f61a07fe9a feat(search): include_archived on /search, and an is:archived search token (TASK-2864) (#2001)
* feat(search): include_archived on /search, and an is:archived search token (TASK-2864)

/search hard-filtered soft-deleted items on every query path, so the web
search's Show archived toggle could not reach a body: query. It now takes
include_archived=true, which widens the full-text query, the ref and number
lookups, the total and the facets alike; an archived result carries its
deleted_at. The permission filter is unchanged. An individual item grant
names live items only, so a guest sees an archived item only through a
collection grant, as on the item index's include_archived.

The web search box recognises is:archived (the token the TASK-1367 prefix
parser had prototyped and pulled): the collection page shows archived rows
for it as the toggle does, sends it on body: queries, and fences the
response on it; the command palette sends it and strips it from the text it
sends. The collection identity gate re-records 16 hashes: 14 units reach
the items view, which now reads the token, and none moved an await or a
check.

* test(search): drive is:archived through the palette and page bindings (TASK-2864)

Codex round 1: the parser tests could not see the call sites. The palette
test mounts CommandPalette and reads api.search on the body, cold-index and
content-supplement paths; the collection fence harness reads the body
search. Mutants (filter dropped, token left in the text on each path, the
page ignoring the token or not sending it) each turn a case red.

* test(search): an item-grant guest with a live grant sees no archived ungranted item (TASK-2864)

Codex round 2: the all-archived access cases short-circuit to empty access
before the store, so they could not catch include_archived widening a
genuinely applied item-grant filter. Mutant (skip the permission filter
when IncludeArchived) turns the new case red.

* docs(search): the archived filter comment does not claim facets cover direct hits (TASK-2864)
2026-10-09 23:10:51 -04:00
xarmian f5a00d5b13 feat: a collection can be reference material, not work (PLAN-3535 PR 1) (#1996)
* feat: a collection can be reference material, not work (PLAN-3535 PR 1)

The switch, with no counting change yet (PR 2 server, PR 3 web).
CollectionSettings.tracks_work: false makes a collection REFERENCE
material; absent means work, today's behaviour, so old rows, old writers
and a client/server skew are unchanged. One Go predicate
(models.CollectionTracksWork) and one TS twin (collectionTracksWork).

- New collections: the creator's value, else system collections are
  reference and others are work iff their done field can finish. New
  template Docs collections are created as reference (Dave).
- Migration 135 / PG 107: existing Conventions and Playbooks become
  reference; everything else, Docs included, stays work.
- Settings are written wholesale, so an update whose settings omit the
  key KEEPS the stored value (CarryTracksWork), for every writer.
- A typed top-level `tracks_work` on POST/PATCH sets just that key
  server-side. CLI: --tracks-work=true|false (a string flag, so false
  survives stdio MCP). MCP: pad_collection tracks_work, v0.81. A
  non-boolean is refused 400; the CLI checks the response, since an
  older server ignores the member.
- Web: the Display tab's "Track as work" toggle; the editor now starts
  its settings write from the STORED settings (buildCollectionSettings),
  so keys it does not render (content_template, tracks_work) survive.

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

* fix(store): carry tracks_work from the locked row; order the SQLite migration's JSON checks (PLAN-3535, codex)

The update now computes the carried settings from the row it re-read under
the write lock, so a concurrent settings write cannot be reverted by a carry
read before the lock. The SQLite migration evaluates json_type/json_extract
only inside a CASE guarded by json_valid, so malformed settings on a system
row cannot abort it.

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

* fix(models): a collection whose options the default terminals close is work (PLAN-3535)

DefaultTracksWork required declared terminal_options, but every lifecycle
reader falls back to DefaultTerminalStatuses when a done field declares
none, so a `status: open, done` collection finishes items and was being
created as reference. Work now means one of the done field's options is
terminal by the same resolver the readers use. Found by PR 2's store tests,
whose fixture collection is exactly that shape. Also corrects api.md's
MCP version (0.81).

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-09 21:47:50 -04:00
xarmian a53160ec79 fix(server): a shared collection lists items in the owner's arranged order (BUG-3530) (#1990)
* fix(server): a shared collection lists items in the owner's arranged order (BUG-3530)

The public share resolve called ListItems with no sort, so readers got
its default, pinned and then most recently updated: every edit reshuffled
a shared collection, and the owner's manual order never showed. It now
orders by sort_order, then oldest first, then id, the manual order every
collection page opens in (the web comparator, and the child lists since
BUG-3527). The share page renders the payload in the order it arrives.

buildItemSort gains `_id` for the last tie-break; spelled with an
underscore because a field may be keyed `id` and no field can be keyed
`_id`.

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

* docs(web): the share page's order note says what the server now sends (BUG-3530)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-09 20:32:51 -04:00
xarmian 23c8e4f811 feat(collab): the GC compacts dormant op-logs and a covered resume merges (TASK-3531 U3-U5) (#1985)
* feat(collab): the GC tick compacts dormant op-logs and Join admits a covered resume (TASK-3531 U3-U5)

The second half of TASK-3531, behind PAD_OPLOG_COMPACT=on (default off).

U3: with a compactor set, each op-log GC tick first compacts every
dormant, flushed op-log into one Yjs snapshot (#1984's primitives): the
snapshot is built on the materializer worker WITHOUT the item lock, then
swapped in under the lock with no room open, and CompactItemOpLog
re-checks the log is the one it read. The sweep then runs as
PruneSweepKeeping and keeps compacted logs. Anything not compacted (a
failed job, a refused swap, set-aside rows, another schema) is left to
the sweep, which deletes it as before. cmd/pad builds the materializer
before the GC starts and hands it over as the compactor only when
PAD_OPLOG_COMPACT=on and the materializer is enabled.

U4: Join admits a resume cursor the snapshot covers (CompactedResumeCovers,
which requires the snapshot row to exist) instead of force_refreshing it;
the tab replays the snapshot and its own edits merge. A failed check
refreshes.

Telemetry for the default flip (lead ruling): pad_oplog_compactions_total
{outcome}, pad_oplog_compaction_snapshot_bytes_total against
pad_oplog_compaction_markdown_bytes_total, pad_collab_compacted_resumes_total.

U5: server tests (compact then keep, the no-compactor and failed-job
deletes, a room opened mid-job blocks the swap, a covered resume admitted
vs refreshed) and an opt-in e2e (a tab asleep across the sweep merges,
or with compaction off is handed its text back).

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

* fix(server): cap compaction jobs per GC tick and keep the deferred items (TASK-3531, codex)

Compaction shares the materializer's single job slot with op-log recovery,
which carries unflushed edits, so a dormant backlog must not hold it for a
whole pass. At most 25 snapshot jobs a tick; items past the cap are kept
from that tick's sweep (deleting them would lose their chance) and
compacted by a later tick. The opt-in e2e's mode=off timeout now names
the likely cause.

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

* test(materialize): updates built on pre-compaction structs merge onto the snapshot (TASK-3531)

The merge property in CI, not only in the opt-in e2e (lead): over the
corpus, a snapshot of the first k rows followed by the rows after k
materializes to the full document (150 cases). Those later rows depend on
the prefix's structs (lone, 114 of them are pending), so this is the
dependency resolution a deleted-and-reseeded log fails.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-09 20:32:46 -04:00
xarmian 4ac7f16084 feat(web): gapped sort keys, so a reorder is one write (TASK-3525) (#1975)
* feat(web): gapped sort keys, so a reorder is one write (TASK-3525)

Lane order was dense integers, so a drop between two cards renumbered one
side of the lane: up to ~510 rows (and ~510 'reordered' activity rows) for
a middle drop in a 1,020-card lane. The planner now keeps the longest run
of cards already in order and writes every other card into the gap between
its kept neighbours: 1,024 past it at an end, spread evenly in the middle.
Moving one card in a gapped lane is one write. With no room (a dense lane
from before this, a never-dragged lane of 0s, or a gap closed by repeated
drops in one spot) it shifts the shorter side as before, but 1,024 apart,
so the next drops are single writes again. A plan leaving +/-2^30 re-spaces
the lane around 0.

Client only: the server stores whatever integers it is sent, so no schema,
API or MCP change, and API writers keep working with any values that sort.
The roles board shares the planner and gets the same behaviour.

Also: the timeline says "moved up/down" instead of raw sort values; the
share page's comment about its order is corrected (it is ListItems' default,
not sort_order); CLAUDE.md says a copy does not carry sort_order; the CLI's
--sort-order help says it is a key, not a position.

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

* fix(web): a reorder writes the card the user moved when that is enough (TASK-3525)

A one-write plan can often write either card of a swap, and each written
card gets the 'reordered' activity row. The board, list, table, child
list and roles board now hand the planner the moved card's id, and it is
written alone whenever every other card already sorts and its gap has
room. The identity-gate rows for handleReorder and handleDndFinalize are
re-reviewed: the id is a synchronous planner argument, nothing moved.

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

* fix(web): the reorder row shows a direction only for two differing numbers (TASK-3525, codex)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-09 17:34:58 -04:00
xarmian 1a80389341 feat(collab): compact a dormant op-log into one Yjs snapshot (TASK-3531 U1+U2) (#1984)
* feat(collab): compact a dormant op-log into one Yjs snapshot (TASK-3531 U1+U2)

The first half of TASK-3531. Nothing calls it yet: the sweep (U3) and
Join (U4) come in the second PR, behind PAD_OPLOG_COMPACT (default off).

U1, the materializer: snapshot() replays an op-log and returns its
markdown plus the WHOLE document as one y-protocols Update frame. It
refuses a frame whose replay alone does not give the same Yjs snapshot
and markdown, and an op-log that leaves structs pending. A row that does
not replay at all contributes nothing (here or in any tab) and is
compacted away. Runner.Snapshot, a `mode` on the worker protocol, and
Supervisor.Snapshot share Materialize's job slot, caps and deadline.

U2, the store: items.yjs_compacted_through (the highest op-log id a
snapshot covers) and items.yjs_snapshot_op_id (the snapshot row).
CompactItemOpLog swaps the rows for the snapshot in one transaction iff
the log is unchanged since it was read, still dormant and fully flushed,
and moves the flush watermark to the snapshot. CompactedResumeCovers
admits a cursor only while the snapshot row exists, so a path that
deletes the log can never leave the columns admitting a stale tab; the
three that delete the whole log also clear them.

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

* docs(store): CompactItemOpLog's no-append contract, and why the tx is not the exclusion (TASK-3531, codex)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-09 15:03:53 -04:00
xarmian a867a8f774 fix(web): icon-only buttons carry accessible names (TASK-2240) (#1974)
* fix(web): icon-only buttons carry accessible names (TASK-2240)

The open parts of audit C111, except the ItemCard star (it lands after
#1968, which rewrites that file):

- Mobile editor toolbar: the list buttons were named by their glyphs
  (•, 1., ☐) and B / I / S / H2 / H3 / code / quote by their letters. Each
  now has an aria-label ('Bulleted list', 'Bold', 'Heading 2', 'Code
  block', 'Quote', ...), the toggles say whether they are on with
  aria-pressed, and the toolbar is named 'Formatting'. Outdent, Indent,
  Insert block and Attach file had only a title; they keep it and gain an
  aria-label.
- EditorLinkPopover's open / edit / remove: aria-label beside the title.
- The create-workspace modal's ✕ and the roles page's three dialog ✕s
  were unnamed: 'Close', with the glyph aria-hidden.

* fix(web): the card star is a named toggle (TASK-2240)

The last C111 part, after #1968 rewrote ItemCard: the star had only a
title. It is now aria-label 'Star <title>' (constant, so a list of cards
does not read 'Star, Star, Star'), aria-pressed carries the state, and
the glyph is aria-hidden. An e2e leg finds it by that name and flips
aria-pressed both ways.
2026-10-09 14:32:52 -04:00
xarmian 9103aaeb24 fix(web): section tab titles, one Mod+\ chrome state, a reachable and complete shortcuts sheet (TASK-2261) (#1972)
The three parts of audit C99/C101/C102 still open:

- C99: Starred, Tags (and a tag page, '#tag'), Library, Settings, Roles,
  Conventions and Playbooks now name their section in the browser tab,
  with the same one-liner the activity page uses. They read
  '{Workspace} · Pad', the same as the dashboard.
- C101: Mod+\ toggled the sidebar and the workspace bar independently,
  so with one hidden it showed that one and hid the other. It is now one
  chrome state (uiStore.toggleChrome): either bar open closes both, both
  closed opens both. On mobile, where the workspace bar is not rendered,
  only the sidebar moves. The four bar tooltips and the sheet say so.
- C102: the shortcuts sheet opened only with the undocumented '?'. The
  account menu has a 'Keyboard shortcuts' entry (the sheet's state moved
  from the root layout to uiStore so it can), and the sheet now lists
  Mod+F (filter the list), Tab into the open item pane, and the palette's
  go-to by number or ref.
2026-10-09 13:30:10 -04:00
xarmian bf61e5fb94 test(e2e): the board-drag leg brings its card into view first (TASK-2237, after BUG-3527) (#1977)
Main's E2E desktop job fails 'a board drag started on the title moves the
card' (the card never entered the Done lane, first try and retry). Since
#1969 a new card joins the END of an un-dragged lane, and in the suite's
shared workspace the Open lane holds enough cards to put the test's card
below the fold (CI page snapshot: last of the Open column), so the
gesture started off-screen and no drag began.

The leg now scrolls the title link into view before measuring it, so it
no longer depends on how many cards the shared lane happens to hold.
Reproduced locally: 20 filler tasks created a second before the card
fail the unfixed leg with the same error, and pass with this change.
2026-10-09 12:30:19 -04:00
xarmian b0f325e751 fix(web): hand back an edit lost with its socket on force_refresh (BUG-3526) (#1973)
* fix(web): hand back an edit lost with its socket on force_refresh (BUG-3526)

TASK-2199 hands a tab's text back when a force_refresh discards its
Y.Doc, but it keyed only on edits made while no socket was open. An
edit that went out on a socket that died before the server stored it,
followed by an op-log prune, was dropped without a word. The provider
now exposes editsMayBeMissing (unsentLocalEdits, or a send no barrier
confirmed on the current or a closed socket, BUG-3523), and the page
hands the text back whenever it is true.

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

* test(e2e): read the handed-back version from its textarea (BUG-3526)

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

* test(web): re-review the force-refresh rows for the new capture input (BUG-3526)

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

* test(e2e): prove the lost edit went out and was dropped (BUG-3526, codex)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-09 11:32:10 -04:00
xarmian cbf90f4542 fix(web): cards, sidebar rows and table headers stop nesting controls (TASK-2237) (#1968)
* fix(web): cards, sidebar rows and table headers stop nesting controls (TASK-2237)

The three parts of audit C29 still open:

- ItemCard was an <a> wrapping real buttons (PR badge, copy-ref, star,
  status and priority pickers, tags, the reorder menu): invalid markup,
  a link named by the whole card's text, and controls assistive tech
  flattened. The card is now a div; the title is the link, and its
  ::after covers the card, so a click anywhere that is not a control
  still opens the item (plain click to the pane, modifier and middle
  clicks to the full page). Every control is a sibling raised above the
  overlay, and the card shows the focus ring when its link has focus.
- A sidebar collection row nested its quick-add button inside the nav
  link. The row is now a wrapper holding the link and the button as
  siblings (the wrapper is the drag item); the button sits over the
  link's reserved right padding and is opaque on row hover or focus.
- TableView's sorted header carries aria-sort (ascending/descending) on
  the active column only; the arrow glyph is aria-hidden.

Two consumers asked for the card anchor and now ask for its link: the
pane's return-focus target (which would otherwise pick the PR badge,
first in DOM order) and the list-to-pane Tab bridge. E2E specs that
clicked or read the card element follow the link.

* test(e2e): a drag started on a card's title moves it, and starts no native link drag (TASK-2237)

Lead review on #1968: the card's whole surface is now an <a> (the stretched
::after), and an anchor can start a native link drag that fights
svelte-dnd-action's pointer drag. Measured: it does not. dnd-action
preventDefaults the drag item's mousedown, so no dragstart fires at all
(0 seen with every dragstart counted). The legs pin it: a board drag
started on the title text lands in the Done lane and stores status=done,
without opening or navigating, and no un-cancelled dragstart is seen on a
board card or a sidebar row. The probe counts in the capture phase and
reads defaultPrevented after dispatch; on a plain link outside any
dndzone it counts 1.

The quick-add leg reaches the button by Tab from its own link: Firefox
does not match :focus-visible after a programmatic focus().
2026-10-09 11:10:32 -04:00
xarmian 71f21dd29c fix(web): a json field keeps its type in the schema editor (TASK-2194) (#1971)
FIELD_TYPES omitted json, so a json field (Pad's own seeded
Playbooks.arguments is one) rendered a BLANK type select in the schema
editor, and touching it could only convert a structured field to a scalar
type, after which its array and object values failed validation on the
next full save.

A json field now shows json in a disabled select with a 'structured
(json), type locked' badge, like the computed one. json is not offered
for a new field: the editor has no way to edit a json value, so such a
field is declared through the API or the CLI.
2026-10-09 11:07:05 -04:00
xarmian 0b2996114e test(e2e): the quick-add picker's clip check measures the dialog's clip, at any collection count (BUG-3529) (#1970)
The leg hit-tested the picker's LAST OPTION. The picker scrolls its list
past 280px, and the suite's shared workspace gains collections as other
specs run, so under full-suite load that option was scrolled out of the
picker and the hit missed: reproduced deterministically by adding 15
collections first. Not a transition, which BUG-3529's first hypothesis
guessed (the picker has none).

The check was also vacuous at the fixture's own size: with four
collections the last option ends inside the dialog's box (319 against
324), where no clip can show. It now hit-tests the PICKER's bottom edge,
which reaches 33px past the dialog, and requires that it does. Both go red
on a dialog forced to overflow:hidden (it scrolls its content to keep the
focused picker in view, leaving the picker -0.2px past it); scrolling the
last option into view instead would have hidden that, since scrollIntoView
scrolls the dialog too.
2026-10-09 11:00:25 -04:00
xarmian f323d810a6 fix(web): manual order breaks a sort_order tie oldest first, as the server does (BUG-3527) (#1969)
Every create path writes sort_order = 0, so ties are the normal state for
any lane or child list nobody has dragged, and the two sides broke them in
opposite directions: the server (getChildItems, GetChildItemsForParents)
orders created_at ASC and child lists and the dashboard render that as
delivered, while the web comparator's manual mode broke ties created_at
DESC. The same two children read in opposite orders on an item's page and
on its collection board.

Lead ruling 2026-10-09: the server's order wins. itemComparator's manual
tie-break is now created_at ASC, then id. A new item joins the END of an
un-dragged lane; dragging still places it anywhere. Unit tests and the
BUG-3492 j/k spec (which pinned the old on-screen order) follow; the spec
still discriminates, since the page holds updated order and from 'old'
that says 'c' while the screen says 'b'.
2026-10-09 10:55:15 -04:00
xarmian 416aea2d14 fix(collab): stop appending the whole document to the op-log on every reconnect (BUG-3523) (#1965)
* fix(collab): reconnect catch-up only when this tab's frames can be missing (BUG-3523)

The provider sent the whole document as one update on every reconnect,
appending a full copy to the op-log on each laptop wake or network blip.
It now sends it only when an edit was made with no socket open, or a
local update went out on a socket that then closed and no barrier ack
cleared it.

The barrier: once local sends go quiet (2s), the client sends one
`barrier {n}`; the server answers `barrier_ack {n, ok}` in readLoop
order, after every earlier frame was persisted, with ok=false when an
op-log append on that conn failed since the last barrier. An ok ack
covering every local send clears the debt; ok=false keeps it and logs
the item; a server that never answers leaves the tab owed.

Also counts materializer give-ups (pad_materialize_giveup_total{kind}).

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

* fix(collab): a SyncStep2 answer is stored but not counted as content (BUG-3523)

A step2 is y-protocols' answer to a peer's step1 and repeats content other
rows carry: the answering tab's own edits went out as updates (and are
repaired with a full catch-up if they may be missing), and everything
else it holds came through the relay. Counting it kept items pending and
made the materializer replay a full copy of the document per joining tab.

Measured first: recorded multi-tab op-logs, across a dormancy prune,
materialize to identical text with and without their step2 rows.

Applied at append only. The startup backfill keeps the older rule for
legacy rows, written by clients with no failed-append repair.

Also: a barrier_ack with ok=false now resends the document on the live
socket at once (lead ruling), instead of only owing the next connection.

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

* docs(collab): say what the barrier ack's write deadline bounds (BUG-3523)

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

* test(e2e): take the server's data dir from one module (BUG-3523)

The step2 spec resolved .pad-e2e one level too shallow: it passed where
PAD_E2E_DATA_DIR was set and could not open the database in CI.
playwright.config.ts and the spec now read e2e/lib/data-dir.ts.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-09 10:42:55 -04:00
xarmian 9a90368f66 fix(web): one connection-recovered signal heals failed loads, and the dashboard and activity show stream status (TASK-2201) (#1960)
* fix(web): one connection-recovered signal heals failed loads, and the dashboard and activity pages show stream status (TASK-2201)

* fix(web): a stream recovery waits a random 0-5 s before pages reload (TASK-2201, lead review)
2026-10-09 09:54:50 -04:00
xarmian 35e5f4809e fix(web): playbooks say how they run, and the library keeps its tab (TASK-2256) (#1966)
Four presentation gaps in the invokable-playbook story (audit C64-C67):

- C64: the editor's Test invocation section rendered copyable run
  commands for a draft playbook, which the server refuses (BUG-2020). It
  now says so where the commands are, naming --allow-draft /
  allow_draft, or Create as Active on the create form.
- C65: the workspace's own Playbooks list showed no slug or argument
  count, so telling which playbooks are callable meant opening each one.
  The cards carry the same ▶ slug and N args chips as the library (one
  shared PlaybookInvokeChips component), and the expanded card says how
  to run it, and that a non-active one can't be run yet.
- C66: the library's ?tab= was read once and never written, so a
  refresh landed on Conventions and both tabs shared one scroll key. The
  tab is now derived from the URL and written with goto(replaceState),
  and the tabs follow the tabs pattern (tablist, aria-selected,
  aria-controls, roving tabindex, arrow/Home/End).
- C67: a library card can show its full text before Activate ("Show
  full text", aria-expanded), and "Active" links to the item activation
  created: by built-in key, else by title, else the item the activate
  call just returned.

The library identity gate's rows are re-stamped (fencing unchanged; the
two map writes sit behind the same checks) and its mutant anchors follow
the `const created =` lines; the fence test mounts a reactive page double
because a tab click now navigates.
2026-10-09 09:50:16 -04:00