mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 14:30:13 +00:00
0cc033edd889544c9d5cafbac45f4761ba96d63f
1224 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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> |
||
|
|
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). |
||
|
|
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) |
||
|
|
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. |
||
|
|
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) |
||
|
|
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?'.
|
||
|
|
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 |
||
|
|
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) |
||
|
|
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. |
||
|
|
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. |
||
|
|
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 |
||
|
|
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.
|
||
|
|
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 |
||
|
|
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).
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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) |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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 |
||
|
|
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(). |
||
|
|
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. |
||
|
|
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. |
||
|
|
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'. |
||
|
|
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
|
||
|
|
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) |
||
|
|
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.
|