Commit Graph

302 Commits

Author SHA1 Message Date
xarmian 5035168e5c fix(security)!: a workspace-pinned API token is confined to its workspace (BUG-3982) (#2266)
* wip(security): BUG-3982 pinned-PAT enforcement, in progress

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

* fix(security): narrow a pinned token's token doors; MCP global-route test (BUG-3982 r1)

r1 rulings (lead, on BUG-3982):
- F1: docs/api.md says details.token_workspace is absent for a deleted pin.
- F2: for a workspace-pinned PAT, GET /auth/tokens lists only the user's
  tokens pinned to the same workspace, and DELETE /auth/tokens/{id} revokes
  only those (DeleteUserAPITokenInWorkspace: id, user and workspace in one
  statement); any other id is the missing-id 404.
- Q1: an MCP tool mapped to a global route (pad_workspace create) is
  refused for a pinned PAT, tested through wireMCP.

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

* test(e2e): the suite token is unpinned (BUG-3982 CI)

global-setup minted the browser suite's token with a workspace_id on a
stale premise (api_tokens once required one). Under BUG-3982 a pinned
token is refused the global routes the UI calls on every page, so the
E2E jobs failed on 403s for /me/workspace-tabs, /events/stream and
/auth/export. task-3415's deliberate pin is kept.

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

* fix(security): refuse import-status to a pinned token; the route walk checks admitted routes (BUG-3982 r3)

GET /api/v1/workspaces/import-status sits outside the /{slug} subrouter,
but the token route gate read "import-status" as a slug and admitted a
pinned token, which could then read the user's import outcome for any
workspace by key. It joins reorder, import and deleted as a refused
segment.

The route walk skipped every route the gate admits, so it could not
catch a route admitted wrongly. Each admitted route must now sit inside
the workspace subrouter or be in a hand-written expected list, and the
list must match what the gate admits.

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

* fix(security): pin refusals precede slug resolution; a foreign pin is signed out on public routes (BUG-3982 r4)

r4 rulings (lead, on BUG-3982):
- On every door that takes a workspace by name (RequireWorkspaceAccess,
  SSE /events?workspace=, search ?workspace=), a pinned token naming any
  other workspace is refused before the name is resolved
  (pinAdmitsWorkspaceRef), so a nonexistent, non-member and member slug
  get byte-identical refusals. The resolved-ID compares stay behind it.
- The email-restricted share link and the invitation preview read the
  caller through signedInFor: a token pinned to another workspace counts
  as signed out there.

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

* test: SSE compares a pinned token by resolved ID behind the by-name check (BUG-3982 r4)

The r4 pre-resolution check shadows the SSE ID compare, and the 14-mutant
rerun showed that mutant surviving. This direct test, shaped like
TestBUG3982_WorkspaceAccessComparesThePinByID, holds it.

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

* fix(security): copy target and ref resolver refuse a foreign pin before resolving (BUG-3982 r5)

r5 rulings (lead, on BUG-3982):
- authorizeCrossWorkspace refuses a pinned token naming any other
  target_workspace before resolving it, so copy and preflight give a
  missing, non-member and member target the same refusal. The OAuth
  allow-list check, reported after the role check, is unchanged.
- The /-/r/ resolver checks the pin by name before resolving; its
  answer was already the same 404.
- Both join the identical-bytes test; the resolver also gets a by-ID
  test, since its resolved-ID compare is now behind the by-name one.

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

* fix(security): cross-workspace authorization compares the pin by resolved ID; collab by-ID test; docs separators (BUG-3982 r6)

r6 rulings (lead, on BUG-3982):
- authorizeCrossWorkspace compares the pin by resolved ID behind its
  by-name check, like RequireWorkspaceAccess, SSE, the resolver and
  collab, with a by-ID test.
- Collab's resolved-ID compare gets the by-ID test the others have.
- docs/api.md: five BUG-3982 sentences appended without a separator
  now end the sentence before them.

Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4
2026-10-21 02:43:37 -04:00
xarmian a0c7d68aa9 build(web): SvelteKit 3 + adapter-static 4 (TASK-3423 PR B) (#1835)
* wip(web): SvelteKit 3.0.1 + adapter-static 4 with the $lib alias (TASK-3423 PR B, WIP)

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

* wip(web): kit 3 migration fixes - goto reset, page.url.href, $app/env, $app/state, cookie v2 (TASK-3423 PR B)

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

* fix(web): pane Back restores scroll through kit 3's snapshot() (TASK-3423 PR B)

PaneHost read page.state one task after popstate; kit 3 applies a popped
entry after an await, so the saved paneScrollTop was never seen. Kit's own
snapshot() keeps the position per history entry, captures it when the entry
is left, drops it on a replace, and restores only on a traversal after the
entry is applied. The controller's replaceState(paneScrollTop) write, the
getPaneScrollTop dep and readPaneScrollTop are gone. The 9 bug-2182 legs pass.

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

* test(e2e): the back-settle latch opens at the Back's popstate, where kit 3 arms it (TASK-3423 PR B)

Kit 3's popstate handler is async, so afterNavigate (where the settle is
armed) now runs after every popstate listener; measured 2-30ms after the
helper's listener. Not yet run green: the box is held.

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

* fix(web): the pane back-settle waits for an in-flight drill instead of minting the popped-to item (TASK-3423 PR B)

Kit 3 resolves a goto to afterNavigate across tasks, so a drill fired inside
the 140ms window could still be in flight when the settle fired; it minted
the popped-to item first (measured: URL still ?item=A at fire time, 2 of 4
runs). The settle now re-arms while navigating.current is set. Unit tests
cover the defer and the cancelled-navigation fallback; both die without
the defer. e2e not yet re-run on this commit.

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

* fix(web): defer the back-settle on navigating.type, not a nonexistent navigating.current (TASK-3423 PR B)

79175a8bc read navigating.current, which kit 3's $app/state navigating does
not have: undefined !== null held always, so the settle deferred forever.
The R14 drill legs passed for that wrong reason and 432 (a drill AFTER the
window still sees A minted) failed 5/5. navigating.type is null exactly
when no navigation is in flight. Measured at this commit: pane-async-race
373 and 432 and capstone 645, 5 repeats each, 15/15 pass.

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

* fix(web): the list's restore-skip is released by the next navigation, not a microtask (TASK-3423 PR B)

Closing the pane from a scrolled column jumped the list (bug-3165 e2e):
the handoff wrote the anchored position at 52ms, then the scroll restore
applied the list entry's saved offset (top 600) from 82ms on (measured with
a scroll-write probe after Escape). Kit 3 calls snapshot.restore after an
await in its navigate, so the queueMicrotask release ran before the
restore it was meant to skip. The skip now belongs to the navigation that
armed it: its own restore consumes it and the next beforeNavigate releases
any remainder. bug-3165 spec: 6/6 desktop legs pass.

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

* fix(web): editor menus unsubscribe from the editor they subscribed to, not the live prop (TASK-3423 PR B)

version-restore-collab (BUG-2264's e2e) blanked the item page after a
restore under live collab, 3/3 on kit 3: the force-refresh remount ran
EditorBubbleMenu's and EditorLinkPopover's effect cleanups, which read the
live editor prop. That read re-entered Svelte's flush and destroyed the
editor mid-transaction (state_unsafe_mutation from ProseMirror's blur
handler), and a null prop then threw on .off, killing the page (stacks
captured with Error.stackTraceLimit=100). The cleanups now close over the
editor their run subscribed to.

Evidence is the e2e: red 3/3 at 347942e17, green 3/3 (2 tests x 3) with
this change alone. A jsdom unit test could not reproduce it: there Svelte
hands a teardown the pre-teardown prop value, so the old cleanup passed
too, and a test that passes either way was not kept.

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

* fix(web): kit 3 in code main added since PR B: $app/env, goto reset in the library tabs (TASK-3423)

* test(web): re-review the identity-gate rows the rebase onto main re-hashed (TASK-3423)

collection page: quickCreateInColumn, the two pane-follow continuations,
installPaneTestHook, loadUrlFilters and updateUrlFilters. Only kit 3 API swaps
reach them (page.url.href, goto reset:false); no await or fence moved.
library page: the tab-switch goto now restores focus to the tab after the
navigation (kit 3 has no keepFocus); its continuation writes no state and is
tabled.

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

* test(e2e): quick capture's Open lands in the title editor under kit 3 (TASK-3423)

The captured item's URL carries ?new=1, which opens it with its title in
the editor. Under kit 2 the follow-up goto's focus reset closed the editor
at once, so the title read as text; kit 3's reset:false keeps it open
(site 3 of the day-87 ruling). Assert the editor's value instead.

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

* test(e2e): login honours ?redirect= and still refuses off-site targets (TASK-3423)

The login page now reads ?redirect= through $app/state's page, and that
value is validateRedirect's input. Nothing exercised the read: the share
and invite specs check only the link that points at /login. Covers the
form path from a signed-out context, the signed-in forward, and three
off-site shapes (absolute, protocol-relative, encoded slash).

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

* test(e2e): name the encoded-slash case by what the guard receives; off-site on the form path too (TASK-3423 r2)

Codex r2: the encoded-slash case is a value that still holds %2f after
searchParams decodes it once; the label and a comment now say so. Hunter
r2 L1: the form sign-in also gets an off-site target. Hunter r2 L2: the
off-site cases wait on one poll for "this origin, past /login", so an
intermediate URL cannot satisfy half of it.

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

* fix(web): the activity page's URL clear uses kit 3's goto options (TASK-3423)

#2226 landed dropFromUrl with goto's noScroll/keepFocus, which kit 3 removed
(they throw in dev and are ignored in prod), and new URL(page.url) on the
$app/state proxy. reset: false keeps scroll and focus as both options did;
page.url.href gives URL a string.

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF
2026-10-18 19:03:13 -04:00
xarmian ed559deb4d feat(web): catch-up card, Mark caught up, and See everything into Activity (TASK-3615, PR B) (#2226)
* wip(web): catch-up card on the workspace home, Mark caught up, and See everything into Activity (TASK-3615, PR B)

The card renders GET /catch-up above Active Work, fenced on identity and
keyed on (user, workspace). Mark caught up PUTs /me/seen and refetches.
See everything opens Activity in Audit with ?since= set to the window's
start, so the activity endpoint's since now also accepts an RFC3339
instant (shape item 5, moved from PR A to PR B by the lead's ruling).

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

* test(e2e): catch-up card flow against the real binary (TASK-3615, PR B)

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

* test(e2e): register the catch-up teammate with the admin session, not its token (TASK-3615, PR B)

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

* fix(web): catch-up r1 fixes: since validation matches Go, loads are ordered, a failed refetch keeps the card, a mounted Activity page applies a linked view (TASK-3615, PR B)

- sinceFromUrl range-checks the fields Go's time.Parse checks instead of
  trusting Date.parse, which rolls 2026-02-30 over and accepts T24:00:00
  (and refused offsets Go takes, up to +-24:60). The table's verdicts are
  pasted from go1.26.
- CatchUpCard numbers its loads, so A->B->A cannot let the first A answer
  paint over the second.
- A mark that lands but whose refetch fails keeps the card and says the mark
  worked, instead of clearing it.
- The Activity page applies a changed ?view= on a mounted page, as it
  already did for ?since=, still without persisting it.
- The e2e waits for a new second before Mark, since the window floor is
  inclusive at second precision.

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

* fix(web): clearing the since chip or Clear filters drops since from the URL (TASK-3615, PR B)

Clearing only filterSince left ?since= in the URL, so a reload re-applied a
cleared filter, and re-following the same link changed no URL value, so the
URL effect never re-ran. Both clear paths now navigate (replacing history)
to the URL without since. The shallow replaceState would leave page.url,
and so the derived since, stale. The route has no load function, so the
navigation re-fetches nothing.

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

* fix(web): a clear whose navigation rejects still drops since from the address bar; the e2e reload leg asserts on the request (TASK-3615, PR B)

r3 findings. clearSince falls back to the shallow replaceState when its goto
rejects, so a reload stays cleared. A superseded goto needs nothing, since
the newer navigation decides the URL. The e2e reload leg now checks the
reloaded feed request carries no since, instead of a chip absence that
passes before a late effect could add one.

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

* fix(web): choosing Live or Audit drops view from the URL, so a reload keeps the choice (TASK-3615, PR B)

r4 Medium, the same class as the since fix: a Live or Audit click cleared
viewFromUrl but left ?view= in the URL, so a reload or a revisit re-applied
the linked view over the user's choice. The page reads two URL values, since
and view, and every path that clears or overrides either now drops it from
the URL through one helper.

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

* fix(server): refuse an activity since whose UTC instant passes year 9999 (TASK-3615, PR B)

r5 finding. An offset can carry 9999-12-31 into year 10000, and the store
compares the cutoff as RFC3339 text, so "10000-..." sorted below every row
and opened the window to everything. The handler now refuses it with 400
invalid_since, and sinceFromUrl mirrors the bound so such a link loads the
unfiltered feed instead of an error. The new rows' verdicts come from a Go
probe of the handler's condition. docs/api.md says so.

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

* test(web): census entry for CatchUpCard's keyed sections each (TASK-3615, PR B)

eachKeyCensus.test.ts requires every keyed each to name why its keys cannot
repeat. CatchUpCard's sections come from a four-entry literal with distinct
ids, only filtered.

Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
2026-10-18 14:42:40 -04:00
xarmian b901ec629c feat(web): choose an API key's access when creating it, and see every key's access (TASK-3866) (#2216)
* feat(web): choose an API key's access when creating it, and see every key's access (TASK-3866)

Console settings now offers Read and write (["*"], the default, the same
value the store writes when scopes are omitted) or Read only (["read"])
when making a key, and the token list says what each key can do. The
label mirrors tokenScopeAllows, so legacy ""/[] keys read as
unrestricted and null, unparseable or all-unknown values read as No
access, never as full access. One fixture,
internal/server/testdata/token_scope_levels.json, is checked by both the
Go test and the vitest test, so the label cannot drift from the server.

Both create doors (POST /auth/tokens and POST /workspaces/{ws}/tokens)
now refuse scopes the server would not honour, with 400 validation_error
naming the value: non-arrays, the string "null", the legacy [] form,
and unknown values. They used to be stored verbatim, minting dead keys.
Omitted still means ["*"]. Documented in docs/api.md.

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

* test(e2e): mint through the admin session; the suite PAT is refused session_required (TASK-3866)

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

* test: fixture rows where JS trim and Go TrimSpace disagree, and read after write; scopes is always a string (TASK-3866)

Mutants T2, T6 and T8 survived the first set: NBSP is trimmed by both
runtimes, no row put read after write, and the null branch was
unreachable because the API's scopes is a Go string.

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

* fix(server): an explicit "scopes": null is refused, not read as omitted (TASK-3866)

Codex r1 (High): decoded into APITokenCreate, a JSON null scopes field
arrived as "" and minted a full-access key. Both create doors now keep
the field raw (apiTokenCreateRequest), so a null, an array instead of a
string, or a number is refused 400 validation_error naming the value.
Omitted or "" still keeps the ["*"] default.

describeTokenScopes treats any non-string as No access instead of
throwing: the existing settings tests build tokens without scopes, and
the string-only signature from the previous commit crashed them.

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

* fix(server): only an omitted scopes field gets the default; "" is refused (TASK-3866)

Codex r2 M1: an explicit "scopes": "" minted a full-access key. "" is
the stored legacy unrestricted form, refused for the reason [] is. No
caller sends it: APITokenCreate.Scopes has carried omitempty since the
model was added, and the web sends no field or a non-empty one.

The raw field now decodes into a *string, so a null is nil however it
is spaced. Codex r2 H1 (a spaced null bypassing the check) did not
reproduce: encoding/json hands RawMessage the bare literal.

Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ
2026-10-17 06:13:14 -04:00
xarmian c086457939 fix(web): a shown kept draft is held, so newer kept text cannot replace it (BUG-3863) (#2215)
* fix(web): a kept draft the notice shows is held, so newer kept text cannot replace it (BUG-3863)

The crash keeper (and every other producer) writes one slot per user and
item, so text kept while the notice showed an earlier draft D replaced D's
only stored copy, and a reload before Restore lost it. A shown draft from
before this tab's keeper now moves to a second, held slot that producers
never write or clear; Restore, Discard and the clear arm resolve it, and
the pending text is offered after it. The live keeper's own in-session
draft stays pending, where its stale rewrite and confirm-clear reach it.

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

* fix(web): a crash keeper reaches a held copy of its own draft by its id (BUG-3863)

Another tab can hold a draft this tab's keeper is still tracking. Each
keeper now stamps its drafts with its own id; a send flags a held copy
carrying that id stale whatever its text, and a confirmation removes it
only when its text is exactly the confirmed body. A held draft with no id
or another keeper's id is never touched (lead two-tab ruling).

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

* test(e2e): the held-draft restore answers the pending-edits dialog; the stale rewrite reaches the held copy by id (BUG-3863)

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

* test(e2e): after D is restored the held-draft spec expects E offered at once (BUG-3863)

Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ
2026-10-16 19:28:08 -04:00
xarmian c0faa52b2e fix(web): a kept draft with a stale base says it can't be applied and offers only Copy (TASK-3856) (#2213)
* fix(web): a kept draft with a stale base says it can't be applied and offers only Copy (TASK-3856)

A crash draft flagged baseStale can never patch: Restore went straight to
the conflict notice. The notice still promised "restoring applies only your
changes", so most online crash drafts offered a one-click restore that ended
in a refusal. It now says up front that the changes can't be applied on top
of the newer version, leads with Copy, and offers no Restore. No diff is
shown, because the base is the part that is not known. Non-stale drafts and
whole-body restores are unchanged.

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

* fix(web): the stale-draft notice says why the changes can't be applied, and shows no diff (TASK-3856 r1)

"The version you started from isn't known" was false when a remote update or
this tab's own send outran a confirmed snapshot: the start is known, but the
server may already hold text the draft counts as its own. The notice now says
the changes can't be told apart from the newer ones. The component and e2e
tests also assert that no diff is shown in that state.

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

* fix(web): Restore reads the stored draft first, and a stale draft's notice says some of it may be on the server (TASK-3856 r2)

The crash keeper rewrites a draft it kept as stale when this tab sends, and
that write does not refresh the copy the notice shows. Restore patched from a
base the keeper had already marked untrusted, which can send text the server
already holds a second time. restoreRefusedDraft now re-reads the stored
draft; when it has the same text and is now stale, the notice re-renders
without Restore and nothing is written.

In the stale state the notice no longer says the edits did not reach the
server: some may have, and a user re-applying by hand would duplicate them.
That state now says they "may not all have reached the server"; every other
state keeps the shared line.

The ItemDetail AST review row for restoreRefusedDraft is re-reviewed with its
new hash.

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

* fix(web): a stale rewrite of the shown draft stops only a patch restore (TASK-3856 r3)

The r2 check switched the notice to its stale state for any draft the keeper
rewrote as stale, including one restored whole because the item had not
changed. There the notice cannot change, so the first Restore click did
nothing. The check now also requires a patch restore; a whole-body restore
never reads the base and goes ahead.

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

* test(web): the whole-body stale-rewrite e2e pins the request the first click sends (TASK-3856 r3)

With the editor open, the server can refuse a whole-body restore over pending
edits and the existing flow asks first, so the row is not the right
observable. The test now asserts that the first click sends the whole kept
text, which the r3 guard regression would never send.

Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ
2026-10-16 15:57:01 -04:00
xarmian 64398c95c8 fix(web): rich crash drafts anchor on the editor's confirmed snapshot; patch restore waits for the open editor (BUG-3850) (#2207)
* wip(web): rich crash drafts anchor on the editor's confirmed snapshot (BUG-3850)

* test(web): BUG-3850 confirmed base, restore planner and keeper pins

* test(web): BUG-3850 e2e for a body stored with a trailing newline; AST row re-reviewed

* fix(web): the confirmed base is taken on the barrier ack, and its row is read with it (BUG-3850 r1)

* test(web): onConfirmed stays silent when an edit made while closing never went out (BUG-3850 r1)

* test(web): BUG-3850 ItemDetail binds the confirmed base (source-level wiring)

* fix(web): the confirmed base is refreshed before the first local change after a remote one (BUG-3850, lead ruling c9db9612)

* fix(web): a crash draft too large with its raw row is kept without it before other drafts are evicted (BUG-3850 r2)

* fix(web): restore retries on the raw row only when it is the same document as the base (BUG-3850 r2)

* test(web): collabFlusher.storageForm (BUG-3850)

* fix(web): a rich patch restore waits for the open editor; the raw-row retry is removed (BUG-3850, lead ruling 0337bf77)

A rich crash draft's anchors are in the editor's form. The server applies a
restore patch to the document in that form only through an open editor; with
none it matches the stored row, where a raw-only form (a trailing newline,
underscore emphasis) makes them miss. The guarded raw-row retry could not run
there either: with no room the editor is not mounted, so the same-document
check had no canonicalizer and never passed (measured by e2e).

So the patch restore waits until this tab's editor is mounted and its CURRENT
connection is synced (provider.state, not `synced`, which persists across a
reconnect). While it waits, Restore is disabled with the reason and Copy leads.
The retry, rawBaseBody, storageForm and the raw-row eviction step are removed
(reverts a612c0680, ab711ec35, bb26af25f); applyRefusedDraftChanges is main's
again.

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

* fix(web): a crash draft whose base predates a remote update restores through the conflict notice (BUG-3850, lead ruling baa63dc3)

The confirmed base is taken only while nothing local is unconfirmed, so a
collaborator's text that lands during a typing streak (or after a disconnect
that left edits at risk) cannot be in it. A patch from that base sends the
text again and reports success: 'A' -> 'A C U' over a stored 'A C' restores
as 'A C U C'.

The provider now counts remote updates alone (remoteDocVersion, bumped where
handleDocUpdate already branches on origin === this). The tracker records it
with the held snapshot and reports remoteAfter once it moves; the crash draft
stores baseStale in the same write, and restoreEdits returns no edits for it,
so restore takes the existing conflict branch: Copy, nothing written. A later
confirmed refresh re-takes the base with the remote text and clears the flag.

Also destroys the Tiptap editors confirmedBaseRemote's tests create: a live
view's DOMObserver timer firing after jsdom teardown was an unhandled error
that failed the full run.

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

* fix(web): a kept draft is rewritten when its base moves, and a rich draft with no confirmed base is flagged stale (BUG-3850 r5)

The crash keeper skipped a write whenever the markdown matched its last
one, so a base that had moved or turned stale with the same text was
never stored (r5 Codex Low: a remote ' C' deleted again within one tick
leaves the draft unflagged over a server that holds 'A C'). The skip now
compares the base seq, body and stale flag too.

With no confirmed snapshot, a rich draft fell back to the row as an
unflagged base. The row is read before the provider's sync, so the tab
cannot say the server holds nothing it lacks; under lead ruling baa63dc3
that draft now restores through the conflict notice (r5 hunter-L1).

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

* fix(web): a crash draft whose base this tab's own send outran restores through the conflict notice (BUG-3850, lead ruling a6380212)

A rich draft's base is the last confirmed snapshot, but the draft also
counts edits this tab sent that the server may already hold: a crash
before the barrier ack, or a typing streak the ack never catches up
with. At the end of the document an anchor is a substring, so restore
sent that text again and reported success ('A' -> 'A U1 U2' over a
stored 'A U1' gave 'A U1 U2 U1'; an applier patch gave 'A X U X').

The provider exposes localSentVersion (its local send count); the
tracker records it with the snapshot and current() reports sentAfter
when it has moved, and the kept draft is flagged stale on remoteAfter or
sentAfter. A confirmed refresh on an unchanged document takes the count
without serializing, so a reconnect's catch-up resend does not flag the
next offline draft. Edits never sent still patch.

A stale draft no longer waits for the editor: it can only reach the
conflict notice (r7 hunter-L1, permitted by the ruling).

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

* fix(web): a kept crash draft is flagged stale before a local send leaves, not at the next tick (BUG-3850 r8)

Offline typing keeps an unflagged draft, which is right: nothing was
sent. A reconnect's catch-up then sends it, nothing is at risk any more,
and the keeper's next tick clears the draft. A tab that dies between the
send and that tick left an unflagged draft while the server may hold the
text, and restore duplicated it at the end of the document ('A' -> 'A U'
over a stored 'A U' gives 'A U U'; r8 hunter-1).

The provider calls onLocalSend just before any local update goes out
(markLocalSent: a live edit, the pre-anchor flush, a catch-up). Through
it the keeper rewrites the draft it holds for the shown item flagged
stale, once, before the frame is queued. A tick-side key could not
close this: no tick runs in the window.

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

* fix(web): the pre-send rewrite never overwrites a newer draft another producer kept (BUG-3850 r9)

The keeper's onSent rewrite wrote its last markdown unconditionally. If
keepAtRiskText (session expiry, an identity change) had kept the
editor's newer text since the keeper's last tick, a send replaced it
with the older copy, and a tab that died before the next tick lost the
difference (r9 hunter-2). The rewrite now runs only while the stored
draft is still the one this keeper wrote, the guard clearRefusedRawDraft
already uses; otherwise the keeper gives up on it, so later sends do not
read the store on every keystroke.

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

* test(web): re-review the three ItemDetail AST rows the r7/r8 changes moved (BUG-3850)

restoreRefusedDraft's body is unchanged; restoreBlocked, which it reads,
gained a synchronous !refusedDraft.baseStale read. The two force-refresh
continuations are unchanged; their enclosing collab effect gained
onLocalSend (synchronous, no await, no generation or identity write).
Each row records the re-review and its new hash.

Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ
2026-10-16 07:22:21 -04:00
xarmian 6ca32c2b14 fix(web): the playbook editor offers a built-in's library update (BUG-3852) (#2209)
* fix(web): the playbook editor offers a built-in's library update (BUG-3852)

The Library's "Update available" card for a built-in playbook opens the
dedicated playbook editor, which had no review dialog, so the update could be
neither reviewed nor accepted from the web UI. The editor now mounts
BuiltinUpdateNotice, the same badge and dialog the item pane has. Accept
refuses while the form is dirty or saving, and an accepted update reloads the
form, so a later Save cannot send the old body back.

An e2e test stages a real upgraded origin (the item holds its seed text, and
that text is not the library's), then goes Library card → editor → review →
accept.

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

* test(e2e): BUG-3852 asserts the update_available dialog's own text

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

* fix(web): the playbook editor locks its form while a built-in update is accepted (BUG-3852)

Review r1 (Codex M1 and hunter L1): the dialog can be closed while Accept's
request is in flight, which hands the page back, so an edit or a Save could
race the update and onAccepted then skipped the reload silently.

- BuiltinUpdateNotice gains an optional onBusyChange callback. The editor
  locks the title, the settings, the body and both Save buttons while an
  accept is in flight. If the form is dirty anyway, a backstop toast says
  the update landed.
- The notice's props are null-safe. The accept's reload clears `item` while
  the notice's request is still settling, and its finally read `item.id` and
  threw. The new unit test caught it.
- The identity gate rows for loadItem and save are re-reviewed (accepting is
  cleared synchronously at entry, and the entry return is `locked`).

Claude-Session: https://claude.ai/code/session_01MPeShv3DBfM9C94PTvMnqi
2026-10-16 03:20:06 -04:00
xarmian 5ea7fde86b fix(web): keys typed into an editor frozen by force_refresh recovery stay in the editor (BUG-3786) (#2138)
* fix(web): keys typed into an editor frozen by force_refresh recovery stay in the editor (BUG-3786)

BUG-3697 made the editor read-only through a force_refresh recovery. A
read-only Tiptap editor is contenteditable=false with no tabindex, so the
browser drops its focus to <body>, outside the item pane. The page and
layout shortcut guards then no longer see a text field: the BUG-3697 spec's
"Typed during recovery." opened the quick-add dialog on its `c`, and the
rest of the text went into that dialog. For a user, an arrow or Enter would
also move the pane to another item.

While frozen, the editor keeps a tabindex of -1, so it stays focusable
inside the pane and behind the pane's guards, and a typed character stops
propagating at it: exactly what characterKey() returns, which is what
every single-key shortcut reads. Escape, Tab, arrows, Enter, space and
chords still reach their window owners (DockedSheet, the PaneHost focus
trap, scroll restore), as they do from an editable editor; codex r2 and
r3 found those owners when the hold was wider. ProseMirror's listener is
on the same node and still sees every key.

The spec is live again (the test.fixme that #2137 parked it under is
removed), and now asserts that the frozen editor holds focus and that no
quick-add dialog opened. Counterfactuals: without the tabindex the focus
premise fails; with the tabindex but without the keydown hold, the
quick-add assertion fails.

Main's CI failure (the pane ending on another item) did not reproduce
locally, alone or with the shard's neighbouring specs. The quick-add leak
reproduced on every local run, including passing ones.

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

* test(e2e): the BUG-3697 spec focuses the frozen editor instead of force-clicking it (BUG-3786)

#2138's first CI run went red on E2E desktop 1/2 at the focus premise,
before any typing. The trace (run 38472264516) shows the pane closing
during `click({ force: true })`. The click point (1098, 619) is
(686, 387) on the 0.625-scaled screencast frame, where an
"E2E Admin created 2 items →" activity toast sat, stacked above the
recovery's own toast. A forced click skips the hit-target check, so it
clicked the toast's link and the pane left. Main's original failure
(the pane ending on another spec's CONVE-868) is the same thing: a
toast's item link.

editor.focus() reaches the editor without a coordinate. On main's
ItemDetail the frozen editor has no tabindex, so focus() leaves it
unfocused and the premise still goes red there.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
2026-10-13 19:42:29 -04:00
xarmian c5b7c2aa35 test(e2e): park the BUG-3697 recovery spec as fixme while BUG-3786 finds why it fails on main (#2137)
Main is red on this spec at 7f342ef33: after the recovery refetch the
editor shows the original seed body, not the server body. It passed in
#2128's own CI, whose base lacked #2133 and #2124. Lead ruling: no revert
(that would reintroduce a data-loss bug); park the spec so the other PRs'
E2E means something again. BUG-3786 owns the cause and removes the fixme.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
2026-10-13 17:39:09 -04:00
xarmian a5409ed2e1 fix(collab): the editor is read-only through a force_refresh recovery, so no typing is lost (BUG-3697) (#2128)
On force_refresh the provider is destroyed, the tab's text is captured for
the recovery notice, and the editor is rebuilt only after the item refetch
resolves. The editor stayed editable through that refetch, so typing in the
window reached no Y.Doc, missed the recovery copy, and vanished on the
rebuild; if the refetch failed it stayed editable indefinitely, against a
comment calling it read-only.

recoveryFrozen is set in the same task as the capture, before it, and cleared
only when the rebuilt provider is wired; the failure arm leaves it set. The
editor's editable reads it. e2e bug-3697-typing-during-recovery parks the
refetch and asserts the editor refuses input inside the window and is
editable again after.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-13 15:44:59 -04:00
xarmian b6f2d41043 fix(server): an account export that times out or fails says so, instead of arriving short (BUG-3626) (#2109)
* fix(server): an account export that times out or hits a store error fails the download instead of arriving short, and says it is complete only when it is (BUG-3626)

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

* fix(server): the account export deadline bounds a stalled store read, not just the gaps between workspaces (BUG-3626, Codex r1)

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

* test(server): the account export hooks live on the Server, not in package variables, so the race detector sees them ordered (BUG-3626, CI -race)

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-12 15:12:22 -04:00
xarmian 43d7e05042 feat(items): restoring a kept draft applies only your changes, so a newer version is kept (TASK-3607) (#2093)
* feat(items): restoring a kept draft applies only your changes, so a newer version is kept (TASK-3607)

A crash draft keeps the body it was edited from (TASK-3606). When the item
has been saved since, Restore no longer replaces the whole body. It sends
the base-to-draft changes as anchored edits through the patch endpoint
(PLAN-3578), which rebases them over the newer writes while every anchor
still matches. When one does not (the same text changed on both sides),
nothing is written: the notice shows the diff from where the user started
and leads with Copy. On success it shows the server's diff of what was
applied.

draftPatch.ts builds the edits: changed line runs with context widened
until every anchor is unique when applied in order, as bodypatch.Apply
checks it. It returns null when nothing can be anchored safely.

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

* fix(items): a restore's continuation is fenced by identity, and a raw tab follows an editor-applied patch (TASK-3607)

Codex round 1 on #2093:
- an identity change need not move loadGeneration, so both restore paths
  also check the captured identity after every await;
- when an open editor applied the patch, a raw tab drops its seed (unless
  it has typed since) and follows the row as the flush lands;
- the conflict state is keyed by the draft's savedAt and base, not its text.

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

* fix(items): a failed refresh after a landed restore patch is not reported as a failed restore (TASK-3607)

Codex round 2 on #2093: the patch's success is kept once its answer
arrives; a failed follow-up read says the changes were applied and asks
for a reload.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-12 00:04:50 -04:00
xarmian 8eb4519fe7 feat(editor): a tab that dies with unconfirmed edits offers them on the next open (TASK-3606) (#2086)
* feat(editor): a tab that dies with unconfirmed edits offers them on the next open (TASK-3606)

The unload prompt and teardown flush never run when a tab dies outright
(a mobile process kill, a browser crash). This is the simple alternative
to IDEA-3494's Y.Doc persistence: nothing CRDT-shaped is stored.

- crashDraft.ts: while the open item holds edits the server may not
  have (editsAtRiskOnClose, or a dirty raw editor), its markdown is kept
  every 2s in the kept-draft store with the seq and body it was edited
  from. It is cleared only when the same item reports nothing at risk
  and the kept text is still its own. Switching items clears nothing.
  Bodies over 1M characters are not kept, and nothing is kept during a
  confirmed sign-out discard.
- refusedRawDraft: optional baseSeq/baseBody (back-compatible). A full
  store sweeps expired drafts, evicts the user's oldest other one, and
  retries once. Storage stays localStorage: on :7777 the largest body
  is 158K chars (about 316 KB UTF-16) against a ~5 MB quota (design note
  on TASK-3606).
- The next open offers it through the existing notice and expected_seq
  restore. The notice is producer-neutral now. When the item has moved
  past the draft's base seq, it warns that restoring replaces the newer
  version and makes Copy the primary button (lead ruling). baseBody is
  stored for the follow-up "apply my changes" restore.

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

* perf(editor): the crash-draft keeper serializes only when the document changed (TASK-3606 codex r1)

Every tick while edits were at risk serialized the whole document, even
an unchanged one. The provider now counts every doc update (docVersion,
local and remote), and the keeper skips serializing while that version,
or a raw editor's pending text, matches its last write.

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

* fix(editor): a crash draft is never filed under a user who did not type it (TASK-3606 codex r2)

The keeper keys drafts by the signed-in user. After an identity change,
and before the reload re-stamps identityEpochAtLoad, the editor still
held the previous user's text. The snapshot now returns nothing unless
the identity is the one the item was loaded under, the teardown flush's
own fence. The identity listener already keeps that text for its
author.

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

* test(items): the identity-epoch census names the crash-draft snapshot's fence (TASK-3606)

The page-load epoch is read at six sites now. The sixth is
crashDraftSnapshot's guard, which keeps a previous user's typing out of
the next user's draft key (codex round 2 on #2086).

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-11 21:50:06 -04:00
xarmian f3f90d0366 feat(billing): the billing page says what Pro covers and who pays (TASK-3603) (#2084)
* feat(billing): the billing page says what Pro covers and who pays (TASK-3603)

A buyer could not tell whether the price covered them, one workspace or
their team. Checked against the code first: workspace limits follow the
workspace owner's plan (store/limits.go), and the workspaces quota
counts only workspaces you own.
- The price says per what: "$8 per account / month".
- Before the purchase controls, the outcome, then who it covers: "Pro
  covers the workspaces you own. People you invite to them don't need a
  plan of their own."
- The current-plan line names the limits /api/v1/plan-limits serves
  (they are configurable) instead of "full access" vs "basic workspace
  management". The upgrade nudge appears only where commerce is allowed.

The compare table's storage and backups rows are unchanged, pending
Dave's call on both. Draft copy is on TASK-3603 for Dave.

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

* fix(billing): the Pro line in the apps states the plan only (TASK-3603 codex r1)

"People you invite don't need a plan" now shows only where commerce is
allowed, like the rest of the who-pays copy.

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

* test(billing): the app-mode test checks the pitch is absent too (TASK-3603 codex r2)

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

* docs(billing): cite why Pro's "no caps" holds whatever is configured (TASK-3603 codex r3)

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

* feat(billing): Pro storage reads "1 GB included" and the backups row goes (TASK-3603, Dave's rulings)

Dave: Pro is to include about 1 GB with metered overage (PLAN-3605,
open); the served 10 GB was a dev placeholder. Until the go-live
interim is decided, the Pro storage cell says "1 GB included" in both
plan tables. Free's number still comes from the limits API. The
"Automated backups" row is dropped: backups are a platform service for
every Cloud account, not a plan feature.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-11 19:48:09 -04:00
xarmian cff306e05a feat: a plan-limit refusal is a persistent prompt that names the limit and who can lift it (TASK-3601) (#2080)
* feat(billing): a plan-limit refusal says who can lift it (TASK-3601, server)

LimitResult carries the payer: the workspace owner for a workspace limit,
the user for a user limit (never serialized). planLimitDetails adds
payer_is_you, and payer_name when the payer is someone else (the owner's
display name, which members already see). Additive: a result with no
payer keeps today's shape.

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

* feat(web): a plan-limit refusal is a persistent prompt that names the limit and who can lift it (TASK-3601)

A refusal was a 6-second toast with a generic billing link, offered to
everyone alike. Now:
- planLimitPromptModel decides what the viewer can do. The payer, where
  Pro is sold, gets Upgrade. Anyone else is asked to get the owner to
  upgrade ("Ask Dana, the workspace owner, to upgrade.") and gets a note
  to copy. With billing off, or no commerce here (self-host, the mobile
  apps), it shows the statement alone.
- One store (planLimitPrompt) holds the prompt until it is dismissed.
  showPlanLimitToast keeps its name and raises it, so the 46 callers are
  unchanged. Inline hosts show it where the action was blocked: invite,
  create/import workspace (import refusals used to be a generic "Import
  failed" toast), sidebar quick-add, the mobile capture sheet, and the
  list's quick-create. Everything else uses the global host in the root
  layout. It is cleared on an identity change.
- Upgrade remembers the blocked flow in sessionStorage (upgradeResume:
  same-origin path, 1h, one-shot). After checkout, /console/billing
  offers "Continue: <action>" once the upgrade is confirmed. The action
  is never re-run for the user. No pad-cloud change.
- The no-commerce guard's allowlist names planLimitPrompt.ts. The
  identity-gate rows for handleInvite, quickCreate and quickCreateInColumn
  are re-reviewed: the change is to the plan-limit call's arguments only,
  still under each catch arm's identityHeld.

Copy as proposed to the lead, who is showing it to Dave.

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

* fix(web): an unknown payer gets no upgrade offer (TASK-3601 codex r1)

Checkout sells Pro to the current user, so offering it when the refusal
does not say who pays could sell the wrong account a plan that does not
lift the limit. An unknown payer now gets the statement alone. A test
pins that a newer refusal from another flow shows in its own host or the
global one, never nowhere. The no-commerce e2e's refusal mock says
payer_is_you, as the server now does.

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

* fix(web): a plan-limit prompt stays with the workspace it was raised in (TASK-3601 codex r2)

A route component can stay mounted across a workspace switch, so an
invite or item-limit prompt raised in one workspace could show in the
next, naming the first workspace's owner and blocked action. The prompt
records the workspace route (owner/slug) when raised. A host shows it
only there, and drops it once the page has left.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-11 18:39:37 -04:00
xarmian 5e914e79ff fix(web): onboarding says "Agent activity detected"; live signals say "connected" and "ready to receive" (TASK-3596) (#2077)
* fix(web): onboarding says "Agent activity detected"; live signals say "connected" and "ready to receive" (TASK-3596)

The onboarding launchpad labelled dashboard.has_agent_activity (historical
evidence that an agent worked in the workspace) "Agent connected". The
push dialog separately distinguishes connected sessions from sessions
accepting pushes, so a user told "connected" during onboarding could find
no agent to push to.

- Launchpad: the step and checklist read "Agent activity detected" ("An
  agent has already worked in this workspace").
- Live signals use one vocabulary everywhere: "connected" for a session
  that is there, "ready to receive" for one that will take a push (was
  "accepting pushes"). This covers the push dialog, the quick-actions
  status, the push dispatcher's fallback toast, and their tests.
- e2e/bug-3447 follows the launchpad label.

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

* fix(web): a standing agent grant reads "already has access", not "connected" (TASK-3596 codex r1)

ConnectWorkspaceModal's suppressed-claim panel described an existing grant
as "already connected". "Connected" is now reserved for live session
signals. Also fixes a stale launchpad CSS comment.

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

* docs(web): the launchpad's agentActive is past activity, never a live session (TASK-3596 codex r2)

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

* docs(web): the last comments that called past agent activity "connected" or quoted the old push copy (TASK-3596 codex r3)

Swept as a population (CONVE-35), not per finding. In the touched files,
"connected" is left only where it means a live session or the user's
act of connecting, and "accepting pushes" only in internal comments
describing the armed state, never quoted as copy.

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

* docs(e2e): bug-3447's comments say agent activity, not agent-connected (TASK-3596 codex r4)

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-11 18:18:37 -04:00
xarmian 910a1aeb8d feat(web): batch other actors' creation toasts; only their events count as unread (TASK-3593) (#2073)
* feat(web): batch other actors' creation toasts; only their events count as unread (TASK-3593)

Each item another actor created raised its own toast, and every toast,
confirmations of your own actions included, bumped the bell's unread
count. A busy agent buried the human, and the badge stopped meaning
anything.

- toastStore.showGrouped folds a burst from one source into one toast.
  While the toast is still up and the last event was under 10s ago, the
  next one updates the toast and its history entry in place and restarts
  its clock. The layout's item_created toast uses it, keyed by actor:
  "Agent created 18 items", linking to their collection, or to the
  workspace if they span several. A single item still reads
  "Agent created: <title>" and links to the item.
- show() counts toward unread only with { unread: true }. The
  other-actor creation toast passes it; confirmations of the user's own
  actions no longer bump the badge.
- e2e sse-toast-quiet CONTROL creates as an agent, so its probe cannot
  join another spec's user-actor burst, and it now asserts that three
  creations become one toast.
- identityResetRound2's toast counts as unread, so its reset-to-0
  assertion is no longer vacuous.

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

* fix(web): every error toast counts as unread; other kinds opt in (TASK-3593 review)

The first cut counted only the other-actor creation toast. Errors now
count toward the bell's unread badge whoever caused them; any other
toast counts only with { unread: true } (toastStore.showUnread). An
error is never a confirmation, and one raised by something running in
the background (a failed autosave or flush, a page-load fetch) may
auto-dismiss unseen. The rule lives in the store, so a new error toast
counts without its call site having to choose, and the files under the
identity-fence AST gates stay untouched.

The server-busy notice is the one non-error background toast that opts
in. The collab "External edit applied" notice stays uncounted on
purpose: it fires on every agent patch to an open item, and the change
is visible in the document.

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

* fix(web): a toast burst keeps a bounded summary and is forgotten when its toast goes (TASK-3593 codex r1)

Codex round 1, two P2s:
- A burst kept every event and re-rendered them all on each new one, so a
  steady agent grew it without bound. showGrouped now takes a fold and
  keeps one running summary. For creations that is foldCreated: count,
  first item, and the one shared collection or null.
- A group outlived its toast until the same actor fired again. It is now
  forgotten when its toast is dismissed, expires or is evicted.

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

* test(e2e): hold the control toast while its burst lands (TASK-3593 codex r2)

The control leg's first toast lives 4s. A slow create or SSE delivery
could let it expire mid-burst and start a second toast. Hovering pauses
it (TASK-2202).

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

* fix(web): an updated toast burst moves to the front of the history (TASK-3593 codex r3)

A burst's history entry took a new timestamp but kept its old position,
so a toast that landed mid-burst could sit above a newer-stamped entry.

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

* fix(web): a creation burst is keyed by workspace owner and slug (TASK-3593 codex r4)

Two owners can each have a workspace of the same slug. Keyed by slug
alone, one actor's creations in both within the window merged into one
toast, linking to the first.

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

* test(e2e): hold the control toast as soon as it shows (TASK-3593 codex r5)

Waiting on the list row before the toast could outlast the toast's 4s
clock. The leg now waits for the toast, hovers it, then checks the row.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-11 18:03:19 -04:00
xarmian edb20ae613 feat(web): a filtered collection view says it is filtered (TASK-3592) (#2072)
With the filter panel closed, a filtered view looked like the whole
collection: the only sign was a dot on the filter button, and the heading
counted every item.

- The heading count reads "12 of 84" when filtered.
- With filters active and the panel closed, a summary line reads
  "12 of 84 · status: open · tags: ui · search: “x”". Clicking it opens
  the panel, and its Clear button works like the View menu's "All".
  Parent and relation ids show as titles; an unresolvable one shows as
  the id.
- The view trigger (desktop menu and mobile chip) shows the active saved
  view's name, truncated if long, and falls back to the mode.

Helper: lib/collections/filterSummary.ts.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-11 16:31:41 -04:00
xarmian c62b2b80da feat(web): quick capture keeps your place (TASK-3590) (#2067)
Quick capture used to open every item it created, so jotting down an
interruption meant a context switch. Now "Add" saves the item, clears the
title and keeps focus for the next capture, and a toast names the new ref
with an Open action. "Add and open" (Ctrl/Cmd+Enter, or the mobile
sheet's secondary button) keeps the old behaviour. Applies to both the
desktop sidebar dialog and the mobile capture sheet.

The mobile sheet clears only the text it sent, so anything typed while
the create is in flight is kept. e2e/bug-3473 needs write-then-open, so
it now uses Ctrl/Cmd+Enter.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-11 13:45:45 -04:00
xarmian ebb2936272 feat(web): the mobile view sheet lists saved views and "All" (TASK-3589) (#2066)
On a phone, the "Choose view" sheet offered only List, Board and Table.
Saved views and "All" were only in the desktop menu, so a saved workflow
disappeared at phone width. The sheet now lists them under "Saved views",
using the same handlers as the desktop menu, and marks the default with
its pin. Managing views (save, delete, set default) stays desktop-only.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-11 13:16:31 -04:00
xarmian 8d910dc20a fix(editor): bold, italic and strike always serialize to markdown that reads back (BUG-3583) (#2057)
* fix(editor): bold, italic and strike always serialize to markdown that reads back (BUG-3583)

CommonMark lets a `**` / `*` / `~~` run open only where it is left-flanking
and close only where it is right-flanking. A mark whose text starts with
punctuation after a letter, or ends with punctuation before a letter, has no
delimiter that can do both. Measured on the headless editor with the editor's
own parser: every such case lost its mark on reload. tiptap-markdown slides an
unflankable delimiter inward (the punctuation silently loses the mark), and
skips that for a mark at output offset 0, so a bold run starting a block went
out as `**Hello.**World` and read back as literal asterisks.

flankingMarks.ts replaces bold, italic and strike (and re-adds code and
underline, keeping StarterKit's mark order) in the body editor, the
materializer (htmlProseExtensions, so parity holds) and the comment editor:
- each run is written with its delimiter and, when it closes, tested against
  the characters actually around it with markdown-it's scanDelims; a run that
  cannot flank is rewritten to <strong>/<em>/<s>, which parse back to the
  same mark (html: true) and render through renderMarkdown's allow-list;
- tiptap-markdown's sliding is switched off for these marks only (a guarded
  patch on prosemirror-markdown's base markString);
- a textblock whose output could still mis-pair (a run ending in punctuation
  before a letter, or merged same-character runs) is parsed back and
  compared per character; on a mismatch it is re-rendered with the HTML form.
Code needs nothing (measured).

Fuzz (6000 paragraphs of letters, punctuation and spaces under overlapping
marks, links and hard breaks, in paragraphs, quotes and list items): marks
lost 0 (old serializer 211-228 per 1500); text altered 0-1 (old 163-172, the
rest a pre-existing line-start escaping gap); output changed where the old
one already round-tripped: 3 per 1500, all one whitespace placement.
Serialization time on 500 paragraphs: equal to main for ordinary prose;
the extra parse runs only on the textblocks that can fail.

Corpus: a block with the failing shapes is added to the materializer parity
generator; corpus.json regenerated; Go materialize tests pass on the new
bundle. New e2e: typed bold "Hello." + "World" is stored as
<strong>Hello.</strong>World; on main it is stored as **Hello.**World.
(A reload of an open tab does not show the defect on main: it re-seeds from
the op-log. The stored body is what readers and a pruned editor see.)

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

* fix(web): backlink snippets drop inline tags; every reader of an HTML mark pinned (BUG-3583)

The lead's check before GO: the fix stores runs markdown delimiters cannot
express as inline HTML, so every other reader of items.content must show it
as formatting or plain text, never literal tags.

- Share pages and their collection views: renderMarkdown's allow-list renders
  <strong>/<em>/<s> (e2e).
- The editor seeding from the body (after an op-log prune): bold (e2e).
- Search palette snippets: already stripped tags (e2e pins it).
- Backlink snippets showed the raw slice, so `<strong>` appeared literally,
  and the slice could cut a tag in two (`…g>Hello.`). Tags and cut fragments
  are now dropped. Red on main in the new e2e, green here.
- Email/notification templates carry no item content; the workspace and
  artifact exports hand out the raw body, where inline HTML is valid
  CommonMark/GFM; nothing syncs bodies to GitHub (read from the code).

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-11 08:02:21 -04:00
xarmian bc3e23d55a feat(collab): a patch to an item open in an editor applies as a narrowed replace (PLAN-3578 step 2) (#2056)
* feat(collab): a patch to an item open in an editor applies as a narrowed replace (PLAN-3578 step 2)

A content-anchored patch to an item a browser editor holds now goes to a
patch-capable tab (one that connected with ?patch_capable=1; an older tab
would read an edits-only request as an empty body). The tab applies it to
its live document as a replace of exactly the top-level blocks it changes,
aligned under the ruled equivalence (equal, or differing only in a link's
title or bold/italic on whitespace; a trailing empty paragraph ignored), so
it merges with typing and leaves cursors in place. It acks with the
canonical before/after; the server records the version with ExternalContent
and answers with the diff and content_state applied_pending_flush. A block
with no equivalent refuses patch_unstable_roundtrip; refusals are counted
in pad_patch_refusals_total{reason}. MCP 0.88 (behavior).

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

* test(collab): a room with only a non-capable tab is never sent a patch (PLAN-3578 step 2)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 07:45:12 -04:00
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 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 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 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 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 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 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 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 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 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 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 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