Commit Graph

26 Commits

Author SHA1 Message Date
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 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 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 439bda2278 feat(web): the auth pages follow the theme, with a toggle that survives sign-in, the Pad grid mark and a slim self-hosted header (TASK-3509) (#1924)
- AuthThemeToggle on every auth page writes 'pad-theme' and data-theme, the
  preference the root layout restores, so a choice made on /login is the
  theme the app opens in.
- AuthBrand shows the 3x3 grid mark (static/pad-mark.svg, colours sampled
  from the mobile app icon); favicon and app icons unchanged.
- Self-hosted gets a slim header: the mark, the host being signed in to,
  and the toggle. No marketing links.
- Visible field labels; placeholders are examples (you@example.com, Ada
  Lovelace, ada, 123456) or gone, and tests select by label.
- Auth submit buttons use --accent-primary-strong: white on --accent-blue
  failed AA contrast in dark.
2026-10-08 21:44:36 -04:00
xarmian 8c8deeeb89 fix: an invite asks an existing user instead of adding them; decline; CLI invitations (BUG-2136 U1) (#1831)
* test(server): an invite asks an existing user; re-invite replaces; decline by id and by code (BUG-2136, red)

* fix(server): an invite asks an existing user instead of adding them; a re-invite replaces the pending invitation; decline by id and by code (BUG-2136)

* feat(cli,mcp): pad workspace invitations/accept/decline; invite output says pending; ToolSurfaceVersion 0.66 (BUG-2136)

* test(e2e): invitees accept their invitation before the specs use the membership (BUG-2136)

* test(store): concurrent re-invites of one address leave one pending invitation (BUG-2136)

* test(store): an accept that loses to a decline adds no membership (BUG-2136, codex r1, red)

* test(server): an unverified invitee can decline by code, as they can accept (BUG-2136, codex r1, red)

* fix: an accept that loses to a decline or replace adds no membership; decline by code passes the verified-email gate; honest oracle comment (BUG-2136, codex r1)

* fix(server): the verified-email exemption matches exactly /invitations/{code}/accept|decline (BUG-2136, codex r2)

* test(server): drop the plan-limit harness's now-unused newUser (BUG-2136)
2026-10-06 12:16:32 -04:00
xarmian e62188ed15 docs: fresh 2x product screenshots (dark and light) and a tightened README (TASK-3426) (#1816) 2026-10-05 19:03:24 -04:00
xarmian 78f3c08240 test(e2e): a real installed app, end to end over https (TASK-3415) (#1804)
* feat(e2e): a test-build-only door for the app origin's CA (TASK-3415)

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

* test(e2e): a real install end to end over an https front and app origin (TASK-3415)

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

* test(e2e): the install review waits for a real manifest fetch (TASK-3415)

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

* chore: ignore the e2etest pad binary (TASK-3415)

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

* test(e2e): discovered https endpoints, awaited teardown, OS-assigned ports, an explicit timeout (codex r1 on TASK-3415)

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

* test(e2e): teardown survives a spawn failure, pad's port retries, the front drops hop-by-hop headers, cert setup cleans up (codex r2 on TASK-3415)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-05 10:21:28 -04:00
xarmian 2bd0862af2 fix(auth): platform administration refuses an API token (BUG-3361) (#1742)
* fix(auth): platform administration refuses an API token (BUG-3361)

BREAKING for anyone driving /api/v1/admin/* or admin account creation with
a personal access token: those now answer 403 session_required. Use a
`pad auth login` CLI session, or on self-host set PAD_ADMIN_API_TOKENS=allow.

An admin's PAT carried full platform authority. codex reproduced the chain
during BUG-3336's review: register an account with a chosen password,
promote it to admin, sign in as it, and reset any account's password. A
leaked admin token therefore escalated to interactive sessions for any
account.

- refuseAPITokenForAdmin guards the whole /api/v1/admin route group, so an
  admin route added later is refused by construction.
  TestBUG3361_EveryAdminRouteRefusesAnAdminPAT walks the router to hold it.
- handleRegister refuses admin-create signup with an API token.
- PAD_ADMIN_API_TOKENS=allow is the self-host escape hatch. It is read at
  startup, logs a WARN, and is never honoured in cloud mode. Documented in
  docs/deployment.md, Security.
- A browser session and a CLI session (padsess_) are unaffected. So is the
  Pad Cloud sidecar, which authenticates with the cloud secret, never a
  PAT. /audit-log already refused a PAT (BUG-3334).

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

* test: drive platform administration with the admin's session, not its API token (BUG-3361)

CI on the first push: 70 E2E specs and TestMCPCapability_UpgradeChangesNothingWhileOff
called /api/v1/admin/* or admin account creation with an admin PAT, exactly
the use this change refuses. Our own harness was the first operator script
the breaking change broke.

- E2E: global setup keeps the session token login already returns as
  fixture.adminSessionToken (padsess_ is not User-Agent bound when sent as
  a Bearer). The seven admin call sites use it: account creation in
  permission-walk, workspace-tabs, workspace-access-live, no-commerce and
  account-delete; the admin user search; and PUT /admin/mcp. Every other
  call keeps the PAT, including the MCP client config in plan-2310.
- mcp_capability_test: the admin drives /admin with a CLI session and keeps
  its PAT for /mcp, which takes one.

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

* test(e2e): the admin console specs use the admin's session (BUG-3361)

CI on c383e89c: task-2979 (admin tab wrap) and task-3121 (decision
provider settings) open /console/admin in a page authenticated with the
suite's PAT, so the console's own /api/v1/admin calls are now refused.
Those two specs, the only ones that open the admin console, set the page
context's credential to fixture.adminSessionToken, and task-3121's
direct admin calls use it too.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-10-02 19:57:23 -04:00
xarmian 552dee8ded feat(web): workspace tabs feel like Chrome's (TASK-3312) (#1676)
* feat(web): workspace tabs feel like Chrome's (TASK-3312)

Dave approved all six of the lead's proposals (day 83):
- Motion: a tab grows in and collapses out (CSS classes, not Svelte
  transitions, since the strip is a dndzone). Chrome's close-freeze holds
  the other widths until the pointer leaves, the window resizes, or the
  tab set changes from elsewhere. Reduced motion drops the animation but
  keeps the freeze.
- The arrow cursor instead of grab.
- The active tab flares into the page. The list now reaches over the
  bar's border, which overflow-x had kept a tab's own negative margin
  from doing.
- Dividers between inactive tabs, Chrome's rule. Tabs are 38px.
- A favicon-like filled square icon in both states.
- Middle-click closes a tab, so it no longer opens the workspace in a new
  browser tab. The × shows on every tab of 150px or wider.

The tab-bar e2e helpers move to e2e/lib/workspace-tabs.ts. The TopBar
close tests now wait out the 140ms collapse. Two of them kept passing
with the delay only because they asserted before the close ran, so they
now also assert that the close was called.

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

* fix(web): no line under the active tab, room for the edge flares, readable tab initials (TASK-3312)

The lead's review of #1676:
- The 1px line left under the active tab was the connect banner's (and
  the verify banner's) top border, sitting flush against the bar and
  doubling its border. On desktop they now sit 8px below the bar as
  cards; phones are unchanged. Pixel-sampled: the page colour now runs
  straight down under the active tab in both themes.
- The list clipped a first or last active tab's outer flare. It gets 9px
  of inline padding.
- White initials measured 1.39-2.75:1 on the palette. An inactive icon
  now takes --text-primary, and an active one a fixed dark ink: 4.19:1
  at worst.
Two e2e legs pin the flare room and the missing line. Both were red on
c1661c69 and are green here.

Claude-Session: https://claude.ai/code/session_01WiJ1kQCbNUdFRyq2FEi4tL
2026-09-30 16:47:53 -04:00
xarmian 973e6eaafc test(e2e): the permission walk's readiness wait allows 20s, sized from measured settle times, not the 5s expect default (BUG-3267 follow-up) (#1629)
waitForAccessSettled inherited the 5s expect default. Under 8 workers two
walk legs timed out on it with the page rendered. Measured across 952
waits (five walk specs, 8 workers x5): p50 1771 ms, p99 4416 ms, max
5222 ms, 3 above 5 s. 20 s is about 3.8x the max.

Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz
2026-09-27 20:48:04 -04:00
xarmian b4cca2c408 test(e2e): the permission walk waits for access to settle before pressing or asserting absence (BUG-3267) (#1627)
Every permission-walk leg navigated, waited for content, then pressed Cmd-N or
asserted chrome absent. Content is not the signal: the create decision needs
the caller's membership (GET /me) and the workspace's collection list, and both
can land after content. Before both, Cmd-N is consumed and ignored (BUG-3258's
ruled behaviour) and every absence holds for the owner too.

Measured at 8 workers: every failure caught pressed with membership known and
no sidebar collections listed, so a membership-only wait still failed 3/420.

- Sidebar publishes the store's own flag as data-membership (known|pending).
  Nothing in the app reads it.
- waitForAccessSettled(page) waits for data-membership="known" and a listed
  sidebar collection. All five walk specs call it after their one goto.
- Two owner control legs hold GET /me or GET /collections, and show a press
  made then is dropped (not deferred) and a press after settling opens
  quick-add.

Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz
2026-09-27 19:33:41 -04:00
xarmian cbbfb93ff7 test(e2e): seed a multi-account permission walk and check each account's sidebar (TASK-2866 U1) (#1613)
HT-1157 is BUG-984's quality gate, and it had never run. This adds the
fixture its walk needs: a workspace of its own, plus one account per
permission shape (owner, editor, viewer, viewer with Tasks collection-edit,
guest with one item-edit, guest with collection-edit plus item-view as the
precedence case, and editor restricted to Tasks). Each account holds its
own API token. The first checklist row is also here: the sidebar lists
exactly the collections each account may see.

The owner leg is the positive control. Every restricted leg also requires
its own Tasks entry, so an absence is measured beside a presence on the
same surface. Negative control: seeding the restricted editor with
collection_access=all turns its leg red with 3 extra collections.

The walk found one leak. GET /workspaces/{ws} omits is_guest, so a guest
loading the workspace renders member chrome (BUG-3254). That leg is marked
test.fail with the ref until the fix lands.

Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9
2026-09-27 11:45:54 -04:00
xarmian 225205052b test(e2e): the lightbox scale settles when its transform transition ends, not when two reads happen to agree (BUG-3213) (#1536)
* test(e2e): the lightbox scale settles when its transform transition ends, not when two reads happen to agree (BUG-3213)

settleScale accepted two equal reads of the rendered matrix as "settled".
Under an 8-worker load the renderer can produce no frame between two polls,
so a mid-transition scale read as final: the touch spec's pre-zoom stopped at
1.19-2.20 when it needed more than 2.3, on 11/120 runs on main and on c0819dd4^
alike.

"Settled" now means no live transform CSSTransition on the viewer image,
checked after flushing style. Two animation frames are awaited first: Chromium
delivers touch moves aligned to frames, and without the flush the pinch oracle
read one move step early (exactly 6.25px = 50px/8 in X, 17/120). The old
fixpoint's polling delay had been hiding that.

The zoom spec's private copy of the same fixpoint now uses the shared helper.

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

* test(e2e): settleScale awaits each transform transition's finished in the page instead of polling (BUG-3213)

expect.poll's 100/250/500ms backoff, paid once per zoom step, stretched
the zoom spec's 20-step zoomToMax toward its 30s budget under load. A
cancelled (retargeted) transition rejects finished, so the wait loops
until no transform transition is live.

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

* test(e2e): zoomToMax presses in bursts and settles once per burst, so an honest settle does not stretch its callers toward the timeout (BUG-3213)

Each '+' computes from the viewer's zoom state, so a burst of four lands
exactly four steps; settleScale waits for the transition to end, so a
burst that does not climb is the clamp. Measured x40 at 8 workers
(p50/max): controls-at-max 14.1/15.6 on main -> 9.7/12.9, pan-clamp
17.2/19.6 -> 14.2/18.7, resize-clamp 15.2/19.5 -> 11.6/17.2.

Claude-Session: https://claude.ai/code/session_01UcaMsmoCt8u7VpJhCNxiJJ
2026-09-25 05:13:44 -04:00
xarmian 4eb5b9ef19 test(BUG-3152): bug-2602 waits for its event stream; the collab editor first mount gets a 20s budget (#1494)
* test(BUG-3152): bug-2602's move waits until the page's event stream is subscribed

The move is observable only through this page's event stream. A fresh
EventSource has no Last-Event-ID, so a move committed before the page
subscribed was never delivered, and the refetch oracle timed out at 15s.
Under full-suite load the stream's headers took 532ms, and the move landed
inside that window (full run 3 on c49ee768).

Deterministic: with /events held 1.5s by route interception, the old spec
failed 3/3 with the identical timeout; with this wait it passes 3/3 under
the same delay, and 3/3 unmodified.

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

* test(BUG-3152): the collab editor's first mount waits EDITOR_MOUNT_TIMEOUT (20s), not the 5s default

version-restore-collab:209 (2/8) and collab-persistence:41 (1/8) failed in
local full runs at the first editor-visible wait after a goto or a reload:
one request in the mount chain stalled 1.4-2.2s under box contention (load
17-23 on 8 cores), and the socket was still connecting at 5s.

Measured as NOT a product defect: in isolation, under 40 concurrent writers
(94s of cumulative pool waits), the same reads stayed under 90ms. So mount
latency under load is not what these specs test.

expectEditorMounted() in collab-helpers waits the suite's existing
SYNC_TIMEOUT figure (20s) for the FIRST mount only. The nine default-timeout
first-mount waits (the population: every editor-visible expect in web/e2e)
use it. No content or ordering assertion changed.

Deterministic: with collections/docs held 6s, the old wait failed 2/2 at the
mount step; with the helper it passed 2/2.

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

* test(BUG-3152): SYNC_TIMEOUT has one definition, in collab-helpers; EDITOR_MOUNT_TIMEOUT is it

Six specs each declared const SYNC_TIMEOUT = 20_000. The value is
unchanged; they now import it, and the first-mount budget is defined as
that constant rather than a repeated 20_000 (lead ruling on #1494).

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

* test(BUG-3152): bug-2602's comment says why the oracle needs the subscription, not that the change is lost

A move before the subscription still reaches the pane, through the connect
catch-up (adopted from the /changes payload, no item GET); only this spec's
refetch oracle misses it. The earlier comment claimed the change was never
delivered, which a probe refuted (BUG-3187, closed).

Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX
2026-09-24 03:28:59 -04:00
xarmian ca004cf221 test(BUG-3166): the drill-after-Back race legs time the drill in the page from the settle's arming; a drill after the window mints the popped item first (#1476)
* test(BUG-3166): drill-after-Back legs time the drill in the page from the settle's arming; add the after-window leg

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

* test(BUG-3166): the settle helper latches only a 140ms timer armed inside the Back's popstate dispatch (codex r1: the pane-follow debounce is also 140ms)

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

* test(BUG-3166): the settle helper clears its safety timer and popstate listener on either outcome (codex r2)

Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX
2026-09-23 19:00:57 -04:00
xarmian 6cc2c7eb95 fix(items): create refuses reserved metadata keys; convention metadata goes through a typed create member; a full fields update may only carry them (BUG-3163, ToolSurfaceVersion 0.48) (#1470)
Item CREATE was the last door that stored a hand-written reserved metadata
key (implementation_notes, decision_log, github_pr, convention). A `--field`
or MCP `field` value landed as a string no extractor reads. For notes and
decisions, that also made `note` / `decide` refuse on the item until the row
was repaired.

Create's `fields` now refuses all four keys with 400 validation_error. The
check runs on the caller's map. Library activation, the only system writer that
used this door, moves to a new typed `convention` create member, which is
validated (an empty object is refused), normalized and lowered into the blob.
Every convention writer switches to it: the CLI and remote MCP library
activate, the web library activate, the web Conventions page create, and the
e2e seed helper. The CLI checks the STORED `convention` key to confirm the
member landed. It cannot use item.Convention for that, because that value is
derived from the sibling keys and is non-nil even when an older server has
dropped the member.

A FULL `fields` update may CARRY stored reserved values unchanged. A differing
value, or an OMITTED stored key (which the whole-blob replace would delete),
is refused with 400. The comparison uses decoded JSON, never bytes, and runs
in the update transaction against the row re-read under the write lock
(composed into the precheck, like the BUG-3133 guard). A note appended
concurrently therefore cannot be erased by a stale blob. A stored blob that is
not a JSON object has no readable reserved metadata, so a full write can
still repair it.

Artifact import shares createItemChecked but needs no gate, because
artifact.Decode fills Fields from a fixed frontmatter key list. Existing test
fixtures that put reserved keys in through the two closed doors now seed at
the store level. The stdio refusal texts are pinned to the classifier's
validation_failed table.

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
2026-09-23 16:23:06 -04:00
xarmian 84eef5dd38 test(viewer): CDP mobile touch-gesture proof + device checklist (TASK-2519)
PLAN-2392 phase 3d V3 (final task of the plan) — the mobile browser proof
for the attachment viewer's touch gestures shipped in V2 (TASK-2518). Real
touch is driven through the compositor via CDP Input.dispatchTouchEvent (a
CdpTouch helper in the e2e lib), since Playwright's touchscreen is
single-tap only.

New web/e2e/attachment-viewer-touch.spec.ts (mobile-chromium), 9 legs:
- two-finger spread/converge zoom in/out
- off-centre affine anchor oracle: a moving-midpoint translate+spread keeps
  a known image-local point under the midpoint (sub-pixel residual; a
  zoom-around-centre mutant misses by ~75px, TOL=6px)
- double-tap fit<->actual + single-image-tap-inert + backdrop-tap-close
- 2->1 lift degrade: jump-free hand-off + survivor pan arms
- '+' mid-pinch rebase (no stale-baseline snap; sampled on a tiny post-+ move)
- touchCancel all-cancel teardown + next gesture arms fresh
- letterbox touch never pans + letterbox tap closes
- image/stage touch-action:none, backdrop auto
- tap-to-load first-tap priority

CDP semantics empirically pinned (not assumed): touchStart/Move carry the
full active set; touchEnd names the ending point (pointerup#<id> observed);
touchCancel is all-or-nothing.

Emulation boundary recorded honestly in DOC-2521 (device-proof checklist):
the 2->1 survivor-pan CONTINUATION can't be expressed in CDP — synthetic
touch releases the survivor's implicit pointer-capture on the next move,
tearing the fresh pan down (a real digitiser keeps it), so the leg proves
arm+no-jump and the continuation is device-verified. Also checklisted:
gesture feel/arbitration, momentum, real touchCancel, iOS Safari (no WebKit
CI project), off-root release.

Mutation-verified (build web+go at worktree root, fresh CI server per run):
pinch handler disabled -> spread/oracle/rebase red; anchor->stage-centre ->
oracle red while spread stays green (discrimination); double-tap disabled ->
toggle red; degrade disabled -> 2->1 red; rebase disabled -> rebase red.

Codex: 3 rounds, final CLEAN (r1 flagged a stage-settle race -> fixed, and a
docs-based touchEnd objection -> refuted empirically; r2 flagged the rebase
test wasn't discriminating -> sampled on a tiny move + mutation-proved).

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-11 13:39:05 +00:00
xarmian 885871a638 test(web): browser-prove attachment lifecycle completeness (TASK-2514)
The falsifiable subset jsdom can't see for PLAN-2392 phase 3c-iii — one
Playwright leg per lifecycle mechanism the U1-U3 chain built, plus the fix
for the U3 count test that was authored but never run.

- Navigation-step (U3): a 3-image set whose viewer order is DERIVED at
  runtime (created_at-DESC ties are the DB's, not the upload order); the
  middle-navigated "arrival" is deleted via a SEPARATE API context so the
  process-local bus never tombstones it, its metadata is primed with a
  cacheable 200 in the PAGE context, and arrowing onto it after a reopen
  forces a no-store HEAD that 404s DESPITE the primed 200 (armed
  waitForResponse, causally the arrow's probe) → tombstone-advance to a
  distinct survivor.
- Restore-revalidate (U2): on an ISOLATED workspace (the shared suite's SSE
  stream starves the delta-sync cursor), archive via per-item event then
  RESTORE via the BULK endpoint (items_bulk_updated, no item_id) — proving
  the prop-driven strip revalidation covers what a per-item SSE subscription
  would miss. Asserts no attachments.list on archive, and a one-shot route
  HOLDS the restore's revalidation list in flight to prove the tiles never
  blank DURING the fetch, not just after.
- Timeline (U1): a strip-UI delete (so announceAttachmentDeleted runs on the
  process-local bus) reconciles a comment thumbnail img→missing live, with
  the document + timeline element stamped to prove no reload or remount.

Also fixes attachment-surface-chrome.spec.ts's U3 count barrier: a bodyless
HEAD is reported as net::ERR_ABORTED after its headers arrive, so it fires
requestfailed, never requestfinished — the completion barrier now keys on
the response.

New e2e/lib/attachment-viewer.ts helpers: createWorkspace, createDoc,
archiveItem, restoreItem, bulkItems (workspace-slug-aware), STRIP_DELETE.

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-11 04:38:37 +00:00
xarmian 9c9107176c test(e2e): reconcile attachment e2e with the converged surface (TASK-2493)
PLAN-2392 phase 3c-ii T7 — the e2e half of the convergence (one host,
one Lightbox for ANY attachment; the options-panel + image-viewer
channels retired).

Falsified + rewritten (the convergence changed the premise, so these
were rewritten to assert the new behaviour, not deleted):
- strip file-tile / editor file-chip open the role=dialog surface
  (no-bytes fallback arm), not a role=menu options panel
- modal two-stacked-viewers -> the SUPERSEDE invariant (one host mounts
  at most one Lightbox by construction)
- owner-4 BottomSheet source moved from the retired file-panel to the
  surviving strip delete-confirm menu
- parity/two-host exact dialog-name -> anchored RegExp (T2b grew the
  accessible name to "name, type · size"); enforced in the hostile-name leg
- zoom thumb->original timeline, switch-safety, and mobile deferred-load
  counts: filter to GET (the T6 always-revalidate-on-open no-store HEAD
  hits the same variant-less URL and polluted the counts)

New legs: PDF/ZIP fallback integration (Open for PDF, none for ZIP);
T6 no-store HEAD count (one per open, none on arrow, one on reopen);
DR-14 archived-parent probe-gate + archive-while-open close; dual-host
peeked addressing + un-peek; Pixel-7 sheet geometry / dock contiguity /
backdrop-vs-chrome dismissal / shortened-stage zoom / file route /
overlay-centring / DR-18 label reveal / native-pinch touch-action /
forced-colors Canvas plate; desktop-unchanged contrast.

Each of the four load-bearing behaviours was MUTATION-verified (break in
source, rebuild the worktree ./pad, confirm the targeted leg FAILS,
restore, confirm green): fallback admission, host event addressing,
archive-close transition, and the T6 forced no-store probe.

Codex-reviewed to CLEAN over five rounds. New selectors live in
web/e2e/lib/attachment-viewer.ts, addressed by class or accessible name
(never a bare [role="dialog"]); assertions are item-scoped / by-id /
by-anchored-name to avoid the BUG-2504 unscoped-list pagination trap.

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-10 21:59:41 +00:00
xarmian a593407a21 test(attachments): browser proof for the 3c-i surface chrome (TASK-2484)
DR-9's rule — the a11y and interaction work of the A-E chain is verified in a
browser or it is not verified. Desktop-chromium legs for the viewer's toolbar,
metadata header, delete flow, permission gate and gesture seams (the sheet
layout has no mobile e2e until 3c-ii).

New spec web/e2e/attachment-surface-chrome.spec.ts:
- Toolbar renders on all THREE origins (strip, timeline, body NodeView), with
  Open/Download as real anchors carrying the EXACT canonical variant-less URL
  (^/api/v1/workspaces/{ws}/attachments/{id}$) and the exact download filename.
- The permission gate: a peeked side withholds the delete affordance
  (mutationsEnabled=false reaches it) and the active side's viewer toolbar
  offers Delete.
- The delete flow: toolbar Delete → drill-down reached BY KEYBOARD with the
  roving tabindex asserted (0/-1 ↔ -1/0), confirmed with Enter, the viewer
  ADVANCES to the survivor (not the retired C1 close), and the deleted strip
  tile disappears (bus reconciliation).
- The metadata header: name/type/size visible, a 180-char filename clipped with
  a resolved text-overflow:ellipsis and the full value in title (DR-13), and the
  inert-label contract proven by a DRAG on the header that does not pan a
  zoomed BIG_PNG image nor dismiss the viewer.
- The gesture seams: a wheel over the toolbar zooms neither the image nor the
  inert page behind it, with a control wheel over the stage that DOES zoom.

Every leg was mutation-checked against this worktree's built binary (revert the
impl line, rebuild, confirm the test fails, restore) — wheel exclusion,
peek-permission (mutationGate canEdit && !peeking), header name, delete advance,
toolbar-render, and the header pointer-exclusion. The wheel and header
mutation-checks each surfaced a false-pass that was fixed (a zoom-out clamped to
fit; a too-small image with no pan bound).

The FALLBACK arm + no-bytes invariant is a documented test.fixme: it is not
reachable through the real producers (they snapshot the viewer set at open and
filter unsafe MIME before it reaches the viewer), so it is jsdom-proven
(TASK-2476, via direct prop mutation). The peeked-side no-Delete VIEWER is
similarly jsdom-proven (TASK-2474): the content click that opens a viewer
re-activates (un-peeks) that side under the invisible-freeze model.

Two existing modal-spec trap tests were updated: the toolbar added focusable
controls, so the "last control" is derived (focusViewerLastControl) rather than
named, and the wrap is still asserted by name at both edges.

https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-08 18:39:33 +00:00
xarmian 872612360a test(attachments): browser proof for viewer zoom — desktop and mobile (TASK-2461)
Phase 3b's final task: the browser-level proof of the attachment viewer's
zoom/pan/loading behaviour that jsdom structurally cannot give (no layout,
no CSS, no gestures). New Playwright spec e2e/attachment-viewer-zoom.spec.ts
runs on desktop-chromium and the Pixel 7 (mobile-chromium) project, with
shared fixtures/helpers added to e2e/lib/attachment-viewer.ts.

Desktop legs prove the RENDERED transform moves under wheel / ctrl-wheel /
keyboard / double-click; the anchored point stays under the cursor; pan
clamps in two legs (an in-bounds drag moves by the delta, an over-drag
stops at the edge with no further movement); a press-drag-return-to-start
over the backdrop does NOT dismiss while a plain backdrop click does; a
click on blank stage space inside the stage box but outside the image
dismisses (the letterbox is pointer-transparent); close/nav stay hit-
testable and Tab still cycles at maximum zoom; enlarging the window clamps
the stranded scale down to the new maximum (still zoomed, not reset);
reduced-motion suppresses the animation while normal mode keeps it; and
forced-colors keeps the image boundary visible. Loading legs prove the
thumb->original swap (the thumb response finishes before the original is
requested) and that a rapid A->B->A with a slow original leaves the live
image correct (the switch-safety end-state; Chromium aborts detached img
loads, so the detached-late-error fence itself stays unit-covered). The
mobile leg proves the DR-5b deferred cell issues no automatic request until
a real tap, then loads exactly one original.

Every assertion is mutation-checked against the binary the Playwright
webServer launches: each test fails when its implementation line is
reverted. Selectors are class-qualified or by accessible name, never a bare
[role=dialog]. Controls are addressed by accessible name; shared constants
live in the lib.

Also lands the DR-4 forced-colors CSS in Lightbox.svelte (deferred in the
zoom tasks): a system-colour border on the image boundary (its box-shadow
is stripped under forced-colors) plus explicit ButtonText borders on the
controls — the contract the browser proof verifies.

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-08 06:27:25 +00:00
xarmian 228f99318b test(e2e): prove the viewer's modal contract in a browser (TASK-2436)
Phase 3a deleted a native `<dialog>` that `showModal()` was giving five
guarantees for free — top-layer stacking, background inertness, a focus trap,
focus restore and Escape — and hand-wrote each one. jsdom's `<dialog>` polyfill
(`src/test/setup-jsdom.ts`) only toggles attributes, so the phase's unit suites
cannot see ANY of those five: no inertness, no top layer, no `:modal`, no real
Tab traversal, no stacking. DR-9 says this is verified in a real browser or it
is not verified.

Three specs, 32 tests, each written against "what mutation would this catch
that a jsdom-equivalent implementation would survive":

  attachment-viewer-modal.spec.ts — portal + MEASURED viewport geometry (a
  `transform`/`contain` ancestor changes the rect, not the declaration); focus
  entry; background inertness proven by injecting a focusable probe into every
  body child AND by the REAL top-bar control, which can only go inert by
  cascade; focus restore asserted as an ORDERING (the invoker is verified
  UNFOCUSABLE while the viewer is up, so a restore-before-release could not
  pass) AND on its DECLINE path, with a detached invoker — the ordinary case,
  since the NodeView that opens the viewer is re-rendered on any document
  change; the focus trap in BOTH directions, including the backward-wrap branch
  (`nextTrapTarget` returns `last` only for Shift+first) and the single-control
  viewer where first === last; `showModal()` vs `show()` vs a dialog mounted
  closed and shown later, plus a native modal opened OVER the viewer winning
  both Escape and Tab outright; paint order hit-tested against a 99999
  body-portaled rival, with a raised-z-index control so the measurement is
  provably sensitive to stacking (Chromium excludes inert subtrees from hit
  testing, which would otherwise make it vacuous); Escape through BOTH real
  route guards, asserting which layer closed; two stacked viewers; and the
  mobile pane integration, where the pane's nested `inert` writes and the
  backdrop's body-child writes are shown to be disjoint at every transition.

  attachment-viewer-owners.spec.ts — all seven TASK-2430 owners, each with a
  viewer-frontmost case AND an empty-stack regression: the six root shortcuts,
  the collection route's navigation half, DockedSheet, BottomSheet, the TopBar
  overflow menu, the sidebar edge swipe (including a gesture that STRADDLES the
  viewer opening) and the co-mounted item graph.

  attachment-viewer-parity.spec.ts — the finite parity matrix, four producers ×
  {open, ←/→, Escape, backdrop click, close}; Enter/Space activation of inline
  images including explicit `repeat: true` keydowns; Cmd/Ctrl+Enter still being
  the comment editor's SUBMIT; hostile/long/bidi accessible names and RTL
  geometry; the host lifecycle, driven through CLIENT-SIDE navigation with the
  document verifiably still mounted (a `page.goto()` version would prove only
  that unloading a document removes its DOM); and two-host isolation.

TWO KNOWN DEFECTS ARE RECORDED AS `test.fail()`, not papered over — BUG-2441.
One Escape over a DockedSheet or a BottomSheet closes BOTH that sheet and the
viewer. The sheets' `isBlockedByModal()` guards are correct; they are READ too
late. Both they and the route's escape driver are `window` keydown listeners,
and Svelte flushes the viewer's teardown synchronously inside the driver's
handler, so a sheet listener running later in the SAME dispatch sees an
already-empty lease stack. Invisible to the unit suites (one component's
handler, nothing releasing a lease mid-dispatch) and invisible to a
click-driven test — closing the same viewer with its Close button leaves the
sheet open, which is how it was isolated. The annotations are applied AFTER
setup, so a login/seed/navigation failure cannot hide behind them. The tests
assert the CONTRACT, so the day it is fixed they go red and the annotations
must come off. The TopBar overflow menu, checked the same way, is unaffected.

Documented gaps, stated rather than papered over: the paint-order rival is a
synthetic overlay at the picker's declared z-index (the real picker cannot be
co-present — opening the viewer by pointer dismisses it) and must be de-inerted
to be hit-testable; `expectBackgroundInert`'s floor is one behaviourally-proven
background child; the pane test's inert-set comparison identifies elements by
tag plus first class; and gestures under a frontmost viewer are dispatched
rather than delivered, since a real wheel or touch cannot reach a covered
element (the graph's baseline leg does use real input).

The shared fixture builds a real 200x150 PNG rather than reusing the 1x1 the
older attachment specs share: that one has a bad IDAT checksum, so thumbnail
decoding skips and the rendered `<img>` has no box — unclickable, and "not
visible" to Playwright. It also has to out-size the editor's image toolbar,
which is absolutely positioned over a small image's whole area.

Claude-Session: https://claude.ai/code/session_01LmbFxQFDjcYKBLcTnor6DC
2026-08-06 17:02:18 +00:00
xarmian 53252ee5d0 fix(web): close BUG-2129 stale-fields gap in the collection-edit switch fence (#961)
* fix(web): close BUG-2129 stale-fields gap in the collection-edit switch fence

The EditCollectionModal onupdated handler's switch fence used
`{@const keyedSlug = itemSlug}` to detect a superseded save, but Svelte 5
compiles that as a lazily-pulled derived signal — since keyedSlug was only
read inside the async callback, it always evaluated the CURRENT itemSlug,
not the value at modal-open time, making the fence a no-op. Replace it with
a genuine gen+id snapshot (pendingCollectionEditGen/-ItemId) captured
synchronously in the onmanage click handler, and use it to (a) skip
navigation/archive/close for a genuinely superseded save and (b) still
refresh the currently-shown item's fields when it's in the collection that
just migrated, closing the stale-fields clobber gap BUG-2129 describes.

Adds two Playwright regression tests: a same-collection pane-switch repro
(BUG-2129's literal scenario) and a cross-collection full-page navigation
test that mutation-testing confirms discriminates the fix (fails on the
pre-fix code with an observable wrong-page hijack).

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

* fix(web): thread editedCollectionId through EditCollectionModal to fix overlap + rename/archive gaps

Codex review of the initial fix found two real gaps: (1) the
pendingCollectionEditGen/-ItemId snapshot was a single shared mutable slot,
so opening a second collection-edit (for a different item) while an earlier
save was still in flight would overwrite it, letting the earlier save's
completion misapply the wrong item's context; (2) the superseded-but-same-
collection branch only handled the reload case, not a pending rename
(still uses the stale collSlug -> 404) or archive (silently did nothing).

Replace the parent-side snapshot with a value EditCollectionModal itself
captures synchronously (before its API call) and echoes back through
onupdated(updated, editedCollectionId). ItemDetail's fence collapses to one
check — does the currently-shown item still belong to editedCollectionId —
applied uniformly to the reload, rename-redirect, and archive-redirect
paths, so all three now behave correctly whether or not the pane switched
items mid-save.

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

* fix(web): gate the collection-edit fence on route/load settlement + fix a PATCH-timing test race

Codex round 2 found a narrower race: between a route change (collSlug/
itemSlug updating) and loadData()'s async resolution, item/collection can
transiently still hold the PREVIOUS item's data while collSlug/itemSlug
already reflect the new one. A mixed read across that window could pass
the collection-id fence using the stale `item` but build a navigation URL
from the already-updated collSlug/itemSlug, hijacking to a mismatched URL.
Gate the whole onupdated body on the existing `itemMatchesRef` invariant
(already used elsewhere in this file for the same "has loadData() caught
up" check) — bailing there is always safe since the route's own in-flight
loadData() will fetch fresh state regardless.

Also fixes a regression-test-only flake Codex flagged: the field-update
PATCH readback used waitForRequest (resolves on dispatch) instead of
waitForResponse (resolves on commit), so the immediate belt-and-suspenders
GET could race an in-flight write.

Deferred (documented in the PR, not fixed here): a further compound race
where a still-open second collection-edit modal on a different item could
be closed by an earlier, unrelated same-collection save's completion —
narrow, pre-existing-adjacent, and squarely in PLAN-2154 Phase 1's
dedicated R14 fence-sweep scope (TASK-2167) rather than this Phase 0 fix.

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

* fix(web): allow a safe corrective reload during route transitions; sync test on PATCH response

Codex round 3: gating the ENTIRE onupdated body on itemMatchesRef (added
last round) was too broad — it also suppressed the corrective void
loadData() call during a route-transition window, reopening BUG-2129's
core symptom (an in-flight load that raced the migration and lost gets no
second chance). loadData() is idempotent and gen-fenced against any
in-flight load, so calling it again is always safe, even mid-transition.
Split the behavior: the relevance + reload decision runs unconditionally
(using whatever `item` is currently known, stale or not — false positives
just cause a harmless redundant reload), while only the RISKY navigation
(rename-redirect / archive-redirect, which builds a URL from collSlug/
itemSlug) stays gated on itemMatchesRef, since those are what a mixed
stale-item/fresh-route read could misdirect (Codex round 2's finding).
collectionStore.loadCollections(wsSlug) now runs unconditionally too,
keeping the global sidebar in sync regardless of pane relevance.

Also tightens the cross-collection test: replaces a fixed 1.5s sleep
after releasing the migration with an explicit wait for the PATCH
response, so the assertion can't false-pass by running before the
(possibly buggy) navigation had a chance to fire.

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

* fix(web): fence the collection-edit callback on collSlug instead of item state

Codex round 4: gating rename/archive redirects on itemMatchesRef (added
last round) fixed the cross-collection hijack but broke the SAME-collection
case — during a same-pane item switch, collSlug never changes, so a
pending rename/archive that resolves mid-transition would skip the needed
redirect (itemMatchesRef false) and then loadData() 404s fetching the
now-stale/gone collSlug, with nothing to correct it afterward.

Replace the item-state-based fence entirely with a comparison against
`collSlug` — a plain reactive prop derived straight from the route params,
with no async lag (item/collection require a loadData() round-trip to
catch up; collSlug updates synchronously with navigation). EditCollectionModal
now also echoes back `editedCollectionSlug` (captured the same way as
editedCollectionId). `collSlug === editedCollectionSlug` is correct in
every case with no separate itemMatchesRef gate needed: unchanged for a
same-pane switch (so rename/archive/reload all still fire correctly),
and updated immediately for a cross-collection navigation (so a stale
edit's redirect correctly no-ops). This resolves the tension between
rounds 2-4 of Codex's findings with one simpler, more robust check.

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

* fix(web): guard the collection-edit callback against a destroyed instance + workspace reuse

PR-level Codex review found two more real gaps in the fence:

1. The onupdated closure can still fire after this ItemDetail instance is
   torn down entirely (pane closed, or the whole page navigated away)
   while a save was pending — JS doesn't cancel a lingering promise on
   unmount. Unguarded, that stale closure's goto()/collectionStore writes
   would visibly affect whatever the user has since navigated to. Added a
   one-way `destroyed` flag (set in onDestroy, alongside the existing
   loadGeneration bump) checked first in the callback.

2. Collection slugs are workspace-scoped, so if this component instance
   is ever reused across a workspace switch (no remount, same as the
   existing collSlug/itemSlug reuse this fence already relies on), a
   collSlug match alone can't tell two different workspaces' same-named
   collections apart. EditCollectionModal now also echoes back
   `editedWsSlug`, compared against the live `wsSlug` prop.

Also documents (not fixed here) a narrow pre-existing-adjacent edge case
Codex flagged: an embedded pane driven by a hand-crafted `?item=` whose
item lives in a different collection than the host page's collSlug won't
match this fence when its real collection is edited. This doesn't regress
anything (the prior fence was dead code and refreshed unconditionally
regardless of relevance) and is deferred to PLAN-2154 Phase 1 (TASK-2167).

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-18 14:57:52 -04:00
xarmian 5033bf6ef8 fix(web): harden split-pane collab teardown flush + regression e2e (TASK-2117) (#947)
Verify + harden provider/doc/flusher teardown across BOTH pane teardown
paths PLAN-2105's no-{#key} persistent pane introduced, plus the raw-mode
data-loss fix. The task anticipated a real defect ("if the child Editor
tears down before the parent collab $effect cleanup, capture the markdown
synchronously"); an independent Codex pass + an empirical Svelte probe
confirmed that IS the case.

Product hardening (ItemDetail.svelte):
- On unmount Svelte destroys the child <Editor> BEFORE this component's
  top-level collab $effect cleanup (top-level $effects are deferred to
  component pop(), so they tear down AFTER the template render effect that
  owns <Editor> — proven by src/lib/collab/teardownOrder/order.svelte.test.ts).
  So at teardown-flush time the editor is already destroyed; persistence
  survived only because Tiptap happens to still serve storage after
  editor.destroy() — a fragile implementation detail.
- Add `lastEditorMarkdown`, a shadow captured on every edit
  (handleContentUpdate), reset per provider instance so it can't cross
  items. readEditorMarkdown now prefers live storage only while the editor
  is alive (!isDestroyed) and falls back to the shadow otherwise — making
  the teardown flush correct-by-construction regardless of destroy order
  or Tiptap's post-destroy behavior. The earlier comment (which claimed
  the reverse ordering) is corrected.

Verified findings (unchanged, correct):
- Item-switch: collab $effect cleanup flushes → destroys provider+doc →
  nulls slots; loadData reset + editorInstance-nulling fire.
- Raw-mode: loadData flushes the raw saver (keepalive) before
  clearPending() when dirty.
- CollabProvider: per-itemID sessionStorage cursor isolates A/B; no change.

Regression e2e (pane-collab-teardown.spec.ts) — each proven to fail when
its target fix is reverted (mutation-tested), so they can't false-pass on
a timer-based backup (the risk the Codex pass flagged):
- close pane → edit reaches items.content; the collab-snapshot PATCH must
  DISPATCH within 3s of close (the teardown flush), not ~5s later (the
  idle backup).
- switch A->B in-pane → outgoing edit flushed (loadData cancels the idle,
  so the cleanup flush is the sole path), exactly one collab WS at a time,
  close → 0 WS, no A->B cross-write.
- switch mid raw-debounce → outgoing raw edit flushed (loadData cancels
  the debounce, so the keepalive flush is the sole path).
Explicit 20s waits on "Synced" cover a slow handshake + reconnect backoff.

Extracts shared login/seed scaffolding into e2e/lib/collab-helpers.ts
(reused by collab-persistence.spec.ts).

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-16 21:36:51 -04:00
xarmian 954c84d0bf refactor(e2e): extract demo data into shared module (TASK-1201) (#431)
Lift the static "realistic workspace" data out of seedRealisticContent
into web/e2e/lib/demo-data.ts so it can be consumed by pad-remotion (a
sibling repo) without dragging in Playwright as a dependency. Single
source of truth for what a real-feeling Pad demo looks like.

Wire-shape compatibility is preserved exactly:
- demoPlan: same title, status, content
- demoTasks: same 7 tasks in same order, same status/priority/effort,
  same parent-to-plan linkage (now expressed via parentToPlan: boolean
  rather than carrying the plan id inline — the seeder maps it back
  to the freshly-created plan id at post time)
- demoIdeas: same 2 ideas, same fields

Behavior verified by running the gated screenshot spec:
  PAD_SCREENSHOTS=1 npx playwright test e2e/screenshots.spec.ts \
    --project=desktop-chromium
seedRealisticContent runs to completion and the dashboard / board /
list / table screenshots regenerate identically (reverted; not part
of this PR's diff).

demoConventions is also exported (4 representative entries) for the
pad-remotion ContextScene to render ghost-cards. demo-seed.ts itself
doesn't consume it — seedConventions takes caller-supplied input — but
the shape lives here so the shared data module is complete.

The companion pad-remotion file (src/data/demoItems.ts) lands as a
separate PR in PerpetualSoftware/pad-remotion. We chose copy-with-
manual-sync over a path import / symlink because pad-remotion is a
distinct git repo; a CI drift check is a possible follow-up if this
duplication starts to bite.

Parent: PLAN-1198.
2026-05-07 10:32:52 -04:00
xarmian cf04e16b5e chore(e2e): blog screenshot capture spec + shared seed helpers (TASK-1031) (#374)
Adds infrastructure for capturing Pad UI screenshots that ship inside
blog posts on getpad.dev.

* web/e2e/lib/demo-seed.ts (new) — extracts the realistic-content seed
  (1 active plan + 7 tasks + 2 ideas) from screenshots.spec.ts into a
  shared module, plus two new helpers:
    - seedConventions(fixture, request, [...])
    - activateLibraryConventions(fixture, request, titles)
  Both consumers now share the same source of truth.

* web/e2e/blog-screenshots.spec.ts (new) — gated on
  PAD_BLOG_SCREENSHOTS=1. One test.describe per blog post; each owns
  its post-specific seed and captures into ../../pad-web/static/blog/
  <slug>/. First consumer is BLOG-1007 (Conventions and Playbooks);
  subsequent posts add a describe block per shot.

* web/e2e/screenshots.spec.ts — refactored to import seedRealisticContent
  from the shared lib. No behavior change; PAD_SCREENSHOTS=1 README
  capture still passes.

Companion publish helper lives in pad-web at scripts/blog-publish.mjs.

Capture command:
  make build-go && cd web && PAD_BLOG_SCREENSHOTS=1 \
    npx playwright test blog-screenshots --project=desktop-chromium

Refs TASK-1031, unblocks BLOG-1022 / BLOG-1004 / BLOG-1003 backfill
which all want screenshots.
2026-05-02 13:05:55 -04:00