Commit Graph

1024 Commits

Author SHA1 Message Date
xarmian 84ddb3f693 fix(web): the list cursor no longer snaps back while the pane follows j/k (BUG-3204) (#1863)
* test(e2e): a list change while the pane follows j does not snap the cursor back (BUG-3204)

* fix(web): a list change while the pane follows j/k no longer snaps the cursor back (BUG-3204)

The collection route keeps the row highlight on the open pane's item: an
effect sets the cursor to ?item='s row whenever the list changes. j/k move the
cursor at once and the pane follows 140ms later, so a list change inside that
window snapped the cursor back to the old item, and the follow snapped it
forward again: a visible flicker, and attachment-viewer-owners' owner-2 flake
(CI: alpha, alpha, BRAVO, alpha after one j).

While a follow is pending the cursor is the newer intent, so the effect
yields; the follow's own ?item= change re-runs it.

Lever (e2e/bug-3204-cursor-snapback.spec.ts holds the /items-changes fetch a
rename triggers until j is pressed): main 10/10 fail, this 20/20 pass.

* fix(web): the cursor follows a reactive pending target, and a click supersedes a pending follow (BUG-3204, codex r1)

Codex r1: the first fix gated the snap-back on the non-reactive timer handle,
so a follow that ended without navigating (target deleted, already open)
left the cursor blank with nothing to re-run the effect, and a click during
the debounce was overridden by the follow.

pendingFollow {id, fromRef} is $state. While set, the snap-back targets that
row by id. It clears when ?item= reaches the row, when the follow opens
nothing, or when ?item= moves by another door, which also cancels the follow.

The spec freezes the page clock and holds the list fetch that carries the
change, so each case is deterministic. Main: case 1 (snap-back) and case 3
(click overridden) fail 10/10, and case 2 passes. Fix: 180/180 at 8 workers.

* fix(web): pendingFollow is per-session state; the follow timer keeps its identity fence first (BUG-3204)

The collection identity gate caught both: a new $state the identity reset
did not clear, and a timer whose identityHeld check had moved into a helper.
resetPerSessionState clears pendingFollow; the timer checks identityHeld
before anything else; the gate rows are re-reviewed.

* fix(web): a follow the controller would drop, or that never lands, releases its pending target (BUG-3204, codex r2)

follow() reports false when a pane navigation is in flight, since the
controller silently drops the open then. A follow that navigates arms a
fenced settle timer that drops a target still pending after 1s, covering a
goto superseded without ?item= moving. The identity gate's timer inventory
and rows are updated. Case 2 of the spec now claims only what it exercises.

* fix(web): the settle check shares the follow's timer handle, so teardown clears it (BUG-3204, codex r3)
2026-10-07 17:42:35 -04:00
xarmian e95135c499 fix(web): links to the /-/r/ resolver open their target (TASK-3119) (#1862)
* fix(web): links to the /-/r/ resolver open their target (TASK-3119)

/-/r/{ws}/{ref} is a server redirect route with no client route behind it.
Three doors handed it to the SvelteKit router, which rendered it as
[username]=- / [workspace]=r: 'Could not load item / Workspace not found'.

- The 'Possibly breaks CONVE-N' chip: data-sveltekit-reload.
- Cross-workspace wiki-links rendered from markdown: rel="external" (already
  on the sanitizer allowlist, so the allowlist does not widen).
- Editor link clicks: planHrefClick sends a resolver href to its full-load
  branch instead of goto. Its old test pinned the goto.

Found by clicking the live chip on :7777. The new e2e clicks the chip and a
comment's wiki-link and asserts where the browser lands; both fail with the
fixes reverted.

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

* test(web): the resolver click-through e2e cleans up its fixture items (TASK-3119)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-07 15:32:36 -04:00
xarmian 10f722e9fe feat(web): "Possibly breaks CONVE-N" chips on the item page (TASK-3119 U1b) (#1860)
* feat(web): "Possibly breaks CONVE-N" chips on items (TASK-3119 U1b)

The item page's decision chips gain the conventions set: a current
conv:<ref> answer at or above 0.9 is a chip reading "Possibly breaks
CONVE-N", linking to the convention through the ref resolver
(/-/r/{ws}/{ref}, so a renamed conventions collection still resolves).

The U0 eval measured the check as precise but shy, so a missing chip
means nothing: no "complies" state, no green, and an answer below the
threshold produces no chip at all. Same fetch, same switched-item and
identity fences as the attention chips.

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

* fix(web): convention chip title makes no compliance claim (TASK-3119 U1b)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-07 13:39:38 -04:00
xarmian 6903eeeea5 feat(web): choose monthly or annual Pad Pro at checkout (TASK-3468) (#1856)
The billing page offers Monthly ($8 / month) and Annual ($80 / year),
with a local-currency note, above both Upgrade buttons, sharing one
choice. Monthly is the default. The checkout sends {interval}.

The page opens Stripe only for a session priced at the picked interval
(decideCheckout, against the sidecar's echo, pad-cloud #62). A missing
echo means a sidecar from before TASK-3367, which sells monthly: a
monthly pick still proceeds, an annual pick is refused. A differing echo
is refused. A 503 for the interval shows a plain message, disables that
option and moves the choice to the other; other failures stay toasts.

The picker shows prices, so it renders only under commerceAllowed and
joins noCommerceOutsideGate's gated list; that guard also gains a
price-copy pattern, so a price outside the gate fails it.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-07 10:21:54 -04:00
xarmian 9b7ffdf897 fix(web): long Active Plans titles stay inside their card at every width (BUG-3440) (#1855)
* test(e2e): Active Plans rows hold a long title at desktop through 375px in both themes (BUG-3440)

* fix(web): a long Active Plans title clips with an ellipsis and its full text on hover, and takes its own line on a phone (BUG-3440)
2026-10-07 09:33:37 -04:00
xarmian 13fa0c99e9 fix(web): quick-add is c, and one switch turns every single-key shortcut off (BUG-3465) (#1851)
* fix(web): quick-add is `c`, and one switch turns every single-key shortcut off (BUG-3465)

Mod+N reaches a page in no browser tab: Chromium reserves IDC_NEW_WINDOW
(IsReservedCommandOrKey), Firefox declares accel+N reserved="true", and
Safari reportedly does not dispatch it. Quick-add moves to `c` (Linear,
GitHub and Jira's create key), outside editable fields and with no
modifier held. Mod+N stays bound for installed-app windows but is no
longer advertised.

WCAG 2.1.4 needs a way to turn character-only shortcuts off, and the app
already shipped `?` and j/k/h/l without one. characterKey() is now the
single gate: `c`, `?`, j/k/h/l (mapped onto the arrow cases, so Ctrl+J
no longer moves the list or swallows the browser's Downloads key) and
the image viewer's + = - 0 read their key through it. A per-device
"Single-key shortcuts" switch sits in the shortcuts modal and on the
account settings page (the way back once `?` is off).

Shortcut labels follow the platform: ⌘ on macOS, Ctrl elsewhere, in the
modal and every tooltip.

A source census fails on any raw single-character key comparison outside
characterKey(), with a reasoned allow-list for focus-scoped input
triggers; it is red on the unconverted bindings.

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

* test(e2e): the palette's dark-theme axe scan waits for the theme's transitions (BUG-3465)

The scan ran the instant data-theme flipped, while the filter chips were
still fading their colours (`transition: all 0.15s`), so axe read
in-between values: #9b9baa on #3b3b44, 4.04:1, failing #1851's CI twice
while passing 13/13 locally. Neither colour belongs to either theme.

The scan now waits until no CSS transition is running. With every
transition stretched to 2s, the old order fails with CI's assertion and
the new one passes.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-07 06:51:38 -04:00
xarmian c17865462f feat: built-ins record their origin; an item made from one can be offered Pad's current text (TASK-3462 U1+U2) (#1852)
* feat: built-ins record their origin, and an item made from one can be offered Pad's current text (TASK-3462 U1+U2)

U1: every convention and playbook Pad ships has a stable key and a content
hash; template seeding and the new server-side POST /library/activate record
{key, seed hash, seed text} in item_builtin_origin (migration 128 / pg 102).
CLI, remote MCP and the web Library page activate through the endpoint;
seeded and activated copies of an entry store the same fields. Export/import
carry the origin; copies get none; a request body cannot claim one.

U2: GET /items/{ref}/builtin derives current / update_available / diverged /
unknown_origin / unknown_entry; POST /items/{ref}/builtin/update takes the
library text through the ordinary PATCH path (expected_seq required, status
and title untouched, a forced version row keeps a diverged item's edits);
GET /builtins lists states in one join, showing only fully visible
collections.

* fix: the origin's seed text is NUL-protected (trigger migration 129); the MCP activate test asserts the endpoint call; activation follows the declared kind server-side (TASK-3462)

* fix: the built-in seed moves in the update's own transaction; a NUL-bearing imported origin is skipped; the listing computes visibility once; numbers hash by value (TASK-3462, codex r1)

* fix: on the applier path the built-in seed is recorded only after the apply confirms; a library change that only drops a field is offered (TASK-3462, codex r2)

* fix: a field the library added that the user already set reads diverged; an update clears convention metadata the library dropped (TASK-3462, codex r3)

* test: the lifecycle test finds the imported item by slug (the copy shares its title); ScanNUL's cost figures count the two new columns (TASK-3462)

* test(e2e): Library activate posts the entry to /library/activate and the item records its built-in origin (TASK-3462)
2026-10-07 06:40:06 -04:00
xarmian 35ca07bd6e fix(web): drop the ⌘] binding that cancelled browser Forward for nothing (BUG-2666) (#1849)
⌘] toggled uiStore.detailPanelOpen, which no component has read since the
first release; the real detail pane is URL-derived (?item=). Chrome does
not reserve Back/Forward (IsReservedCommandOrKey in Chromium's
browser_command_controller.cc), so it delivered the key, and the handler's
preventDefault took Forward away in Chrome, Safari and Firefox on macOS
and Firefox on Linux while changing nothing visible.

Removes the binding, the dead state (detailPanelOpen, its toggle/open/
close, the pad-detail-panel key, the mobile-crossing force-close, the
unused --detail-panel-width var) and the e2e probes that measured it. A
layout test pins that Mod+[ / Mod+] are never prevented, with either
modifier; it is red on main.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-07 04:45:59 -04:00
xarmian a7c559e431 fix(web): the command palette is an accessible combobox, axe-clean in both themes (TASK-2234) (#1847)
* test(e2e): the command palette is a keyboard- and screen-reader-usable combobox, axe-clean with results and recents (TASK-2234); adds @axe-core/playwright

* fix(web): the command palette is an ARIA combobox (listbox options, active descendant, announced count) and the keyboard works on recent searches; status pills clear 4.5:1 (TASK-2234)

* test(e2e): reopen the palette only once the item page has settled (TASK-2234)

* test: navigation follows the grouped render order; a body-only match is announced; the e2e reads aria-controls as a list and really scans dark (TASK-2234, codex r1)

* fix(web): palette navigation walks the grouped results in rendered order; the announcement counts content matches (TASK-2234, codex r1)

* test: the selection follows its item across a grouped append; nothing is announced while the content search runs (TASK-2234, codex r2)

* fix(web): the palette selection follows its item across Load more; the live region waits for the content search (TASK-2234, codex r2)
2026-10-07 03:22:58 -04:00
xarmian f392847024 fix(web): no field suppresses its focus ring any more (TASK-3461) (#1845)
WIP: 40 component rules removed outline under :focus on a field, which
beat the global :focus-visible ring (TASK-2262). Removed from all 40; the
danger input's ring takes its red. A vitest guard
(src/lib/a11y/fieldFocusIndicator.test.ts) refuses a new one unless the
rule gives another indicator; red on the old code, green now.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-07 03:02:53 -04:00
xarmian 56f8a49fe0 fix(web): SSE bursts coalesce their reconcile and collections reload; the timeline ignores other items' comments (TASK-2224) (#1844)
* test(e2e): a tab's request budget for external updates, a 20-update burst and comments on another item (TASK-2224)

* fix(web): SSE bursts coalesce their reconcile and collections reload; the timeline ignores comments on other items (TASK-2224)

* fix(web): a cancelled coalesced run settles its callers; a solitary call is capped; the budget gate bursts concurrently and proves the last update was reconciled (TASK-2224, codex r1)

* test(web): an SSE callback whose coalesced run was cancelled by destroy stops (TASK-2224, codex r2)

* fix(web): a cancelled SSE callback stops; the budget gate scales with the burst's duration and waits out the reconcile queue (TASK-2224, codex r2)

* test(web): cancelling a coalesced run already in flight rejects its callers (TASK-2224, codex r3)

* fix(web): cancelling the coalescer abandons runs in flight too; the budget gate requires a burst short enough to discriminate (TASK-2224, codex r3)

* test(web): a synchronously throwing coalesced function rejects its callers (TASK-2224, codex r4)

* fix(web): a synchronous throw rejects the coalesced callers; the budget gate counts until the tab is quiet and skips only the budget on a box too slow to discriminate (TASK-2224, codex r4)

* test(e2e): the budget's windows come from when the page received the 20 events (TASK-2224, codex r5)

* test(e2e): the budget counts only once no API request is in flight (TASK-2224, codex r6)

* test(e2e): a budget run that never goes quiet is skipped, not counted (TASK-2224, codex r7)

* test(e2e): room for the budget's waits (TASK-2224)
2026-10-07 02:13:33 -04:00
xarmian 7222505d41 fix(web): every field shows a focus ring, and the sidebar add buttons reach 3:1 (TASK-2262) (#1843)
The 2026-07-20 audit's C9 / C10, re-measured on current main.

- --text-muted already meets AA: a runtime survey of every element rendered
  in the muted colour against its effective background, 13 pages x 2
  themes, 458 elements each, has a minimum of 5.05:1 dark and 5.09:1
  light (the audit-era #666666 measures 2.94 with the same instrument). No
  token change; the dead --status-archived token (0 uses) is removed.
- Focus (C10): app.css stripped every field's outline and gave only input
  and textarea a 1px border shift; selects had NO indicator (settings >
  Members invite selects, measured with a keyboard-focus probe). A global
  :is(input, textarea, select):focus-visible 2px ring, with the
  pseudo-class doubled so it beats components' plain outline resets; fields
  showing the ring went from 0 to 10 of 14 probed, none left without an
  indicator. The 44 per-component ':focus { outline: none }' field rules
  are their own task (lead ruling).
- The sidebar's icon-only '+' controls rendered muted at opacity 0.5:
  2.29:1 dark, 2.12:1 light. At 0.8: 3.94 / 3.76 (WCAG 1.4.11, 3:1).

e2e task-2262-focus-and-contrast: 4/4 green here, 4/4 red on main.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-07 00:19:35 -04:00
xarmian 1d022040e6 fix(web): the field editor's advanced-section sync no longer depends on the Svelte runtime to stay alive (TASK-2186) (#1841)
The 2026-07-20 audit's critical C1: typing the first letter of a new
field's label wedged both collection modals (key stuck at one letter,
add-field dead, unclosable). FieldEditor's $effect.pre read and wrote
lastSyncedKey as $state, the self-invalidating class BUG-1687 fixed in
CreateCollectionModal. Svelte 5.56 stopped the wedge (b759898d5, #1156,
bumped it from 5.55.8 incidentally); the code was unchanged and only safe
by that. lastSyncedKey is now a plain variable.

Measured with the new e2e (type a label, then key auto-sync, a second add,
Escape, no page errors):
- audit code 3725d968 + Svelte 5.55.8: RED (key stuck at 's');
- the same, Svelte raised to 5.56.8 or 5.57.1: green;
- the same, Svelte 5.55.8, with this change only: green 3/3;
- main with this change: Create and Edit modals green 3/3.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-06 23:40:35 -04:00
xarmian 4fdc6ed625 feat: in-app tutorials on Pad Cloud, links to getpad.dev/learn elsewhere (TASK-3452) (#1839)
Design and rulings on TASK-3452 (lead, day 89).

- Placement: a first-time card on /console's empty state (Start a new
  project) and in the workspace launchpad's step 2 (Set up your workspace
  with /pad onboard); each vanishes with its screen. The stable way back is
  a Tutorials entry in the user menu's Resources block, right after Docs.
- Dismissal: users.ui_dismissals, a JSON set of keys behind an allow-list
  (models.UIDismissalKeys), read-modify-written under the users row lock,
  so a dismissal holds on every device. GET/PUT /me/ui-dismissals, web only.
- Pad Cloud: the server fetches https://www.getpad.dev/learn/catalog.json
  (a constant; 1h cache, last good copy kept, 5 min back-off) and serves it
  same-origin at /tutorials, with posters proxied at /tutorials/posters/
  {slug} from www.getpad.dev only. The page CSP gains frame-src for
  youtube-nocookie, Cloud only. /console/tutorials hosts the click-to-load
  player and chapters; transcripts link to getpad.dev.
- Self-hosted: both routes 404, no outbound request, no CSP change; every
  surface links to getpad.dev/learn.

Migration 127 / pg 101, after #1837's 126 / 100 (rides rc.7). No CLI or MCP surface.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-06 22:23:29 -04:00
xarmian 1d94d038a4 fix: the first-run launchpad follows an onboarding agent live (BUG-3447) (#1837)
* test: collection create publishes an event; the launchpad and connect banner follow agent writes live (BUG-3447)

* fix(web,server): collection create publishes collection_updated; the launchpad and connect banner follow agent writes live (BUG-3447)

* test(web): a debounce armed in one workspace does not fire into the next (BUG-3447, codex r1)

* fix(web): the live reload and the banner re-check fire only for the workspace they were armed in (BUG-3447, codex r1)

* test(web): an event during the first load or check still triggers the live refresh (BUG-3447, codex r2)

* fix(web): an event before the first load or check commits still triggers the live refresh (BUG-3447, codex r2)

* test(web): a failed quiet re-check that overtook the first check leaves the banner showing (BUG-3447, codex r3)

* fix(web): a failed quiet re-check from the unknown state falls back to showing the banner (BUG-3447, codex r3)

* test: an agent's collection is agent activity (dashboard, banner, e2e incl. the Agent-connected step) (BUG-3447, lead)

* fix: a collection records its source; an agent-created collection is agent activity for the launchpad and the banner (BUG-3447, lead)

* test(e2e): the launchpad follows an onboarding agent from outside the tab, red on main for each outcome (BUG-3447, lead)

* test(store): collection provenance survives export/import; the census sees the new column (BUG-3447, codex r5)

* fix(store): collection provenance travels in the workspace bundle, validated on import; census records the column (BUG-3447, codex r5)

* test(store): an unrecognised bundle source is stored as '' (BUG-3447)
2026-10-06 18:31:29 -04:00
xarmian a91fcce7f9 fix: blank workspaces accept the library's own triggers and scopes (BUG-3446) (#1836)
* fix: a blank workspace's conventions and playbooks take the library's own triggers and scopes (BUG-3446)

The blank template seeds Conventions with trigger [always] / scope [all]
(Playbooks [manual] / [all]) and nothing widened them, so all 18 library
conventions were refused on every door: CLI and MCP library activate, the
web Activate button, and the onboard playbook's literal B3 create.

A create in a system collection declaring the convention or playbook
artifact kind now adds a missing trigger/scope value to the select's
options when the LIBRARY itself uses that word
(collections.LibraryOptionVocabulary, computed from the library). The write
goes through UpdateCollection with its concurrency token, ahead of
validation, and is reported in warnings.options_added (CLI: stderr) and in
the created activity's metadata, which the item timeline renders. Any other
value is refused as before. Editors may trigger it (lead ruling). The
onboard playbook's B3 now says how an owner adds an invented trigger.

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

* chore(mcp): ToolSurfaceVersion 0.67 for BUG-3446 (additive)

Lead ruling: a create in a system conventions/playbooks collection now
adds a library trigger/scope word instead of refusing it, and its result
gains an omitempty warnings.options_added. No tool name, enum or param
moved; tools/list golden bytes are unchanged, initialize moves for the
version and the instructions note.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-06 16:48:50 -04:00
xarmian f74f626fcb fix(server): register-with-code claims the invitation atomically; a gone invitation refuses and rolls the account back (BUG-3438) (#1834)
* test(server): register-with-code through an invitation gone before its claim joins nothing (BUG-3438)

* fix(server): register-with-code claims the invitation in the membership's transaction; a gone invitation refuses and rolls the account back (BUG-3438)

* test(server): a cloud signup refused for a gone invitation is rolled back; a failed claim beside a membership is not a join (BUG-3438, codex r1)

* fix(server): the signup rollback removes the account's verification tokens with it; a membership the claim did not write is not a join (BUG-3438, codex r1)

* test(server): a session minted in the window does not block the signup rollback; a refused signup keeps the emailed proof for the retry (BUG-3438, codex r2)

* fix(server): the signup rollback removes the account's sessions under its row lock and restores a spent invitation proof (BUG-3438, codex r2)

* test(server): a reset token minted in the window does not block the signup rollback (BUG-3438, codex r3)

* fix(server): the signup rollback is the full account deletion; a proof whose consume reported an error counts as possibly spent (BUG-3438, codex r3)

* fix(server): an invitation proof whose consume errored is never restored from what the caller sent (BUG-3438, codex r4)

* test(server): an invited signup reports the role the account holds (BUG-3438, codex r5)

* fix(server): an invited signup reports the role the claim kept (BUG-3438, codex r5)

* fix(server): the signup rollback kicks the account's connections, as every account deletion does (BUG-3438, codex r6)

* fix(server): the signup rollback kicks even when the deletion reports an error (BUG-3438, codex r7)

* test(server): on open registration a refused invited signup keeps the account, joins nothing and says why (BUG-3438, lead ruling)

* test(web): a registration kept without its invitation says so on the join page and lands nowhere (BUG-3438)

* fix(server,web): a refused invited signup is rolled back only when the invitation was its admission; on open registration the account is kept and told (BUG-3438, lead ruling)

* test(server): a claim refused before commit needs no reconcile read; a failed rollback is not reported as one (BUG-3438, codex r9)

* fix(server): a claim refused before commit skips the reconcile read; a failed rollback answers 500; docs name the admin case (BUG-3438, codex r9)
2026-10-06 15:48:30 -04:00
xarmian b831e9fe09 feat(web): + badge, Decline, and an accept/decline card on invitation links (BUG-2136 U2) (#1833)
* test(web): red tests for the invitation badge, Decline, and the join-page consent card (BUG-2136 U2)

* feat(web): pending-invitation badge on +, Decline in the list, and an accept/decline card on /join (BUG-2136 U2)

* test(web): a list fetch issued before a decline cannot resurface the row; accept locks Decline (BUG-2136 U2)

* fix(web): a decline orders out list fetches already in flight (BUG-2136 U2)

* test(e2e): the + badge counts and clears, and a decline from + joins nothing (BUG-2136 U2)

* docs: invitations are pending until accepted; the CLI, + menu and join link (BUG-2136)

* test(web): stale list responses across an identity change or an accept; the 2FA sign-in lands on the card (BUG-2136 U2, codex r1)

* fix(web): list responses are fenced by identity and ordered out by an accept; 2FA button says Verify; decline routes documented (BUG-2136 U2, codex r1)

* test(web): /join accept and decline refresh the + badge; the 2FA button reads exactly Verify (BUG-2136 U2, codex r2)

* fix(web): /join accept and decline force-refresh the + badge (BUG-2136 U2, codex r2)

* test(web): the TopBar mount refresh skips the badge throttle (BUG-2136 U2, codex r3)

* fix(web): the TopBar mount refresh is forced (BUG-2136 U2, codex r3)

* test(web): /join follows a code change under the mounted page, drops a late preview, and announces results (BUG-2136 U2, codex r4)

* fix(web): /join restarts its flow per code and acts on the code it shows; results are announced; docs name the CLI (BUG-2136 U2, codex r4)

* test(web): an accept, decline or sign-in in flight across a /join code change does not land on the new code's page (BUG-2136 U2, codex r5)

* fix(web): /join fences every action continuation by flow, and a late sign-in restarts the current flow (BUG-2136 U2, codex r5)

* refactor(web): /join accept keeps one fence, at the landing; a spent proof is cleared whichever page shows (BUG-2136 U2)

* test(web): a /join code change empties the form, keeps an in-flight sign-in busy, and a late signup clears its proof (BUG-2136 U2, codex r6)

* fix(web): a /join code change empties the form and keeps an in-flight sign-in busy; a late signup clears its proof; the username check is fenced (BUG-2136 U2, codex r6)

* test(web): the + list answers the badge under a token reserved before it fetched (BUG-2136 U2, codex r7)

* fix(web): the + list and the badge refresh share one order (BUG-2136 U2, codex r7)

* test(web): the + rows follow the badge store's one order (BUG-2136 U2, codex r8)

* fix(web): the + rows render from the badge store, so one order decides both (BUG-2136 U2, codex r8)

* test(web): the TopBar store double carries the list the + surface renders (BUG-2136 U2)
2026-10-06 13:46:05 -04:00
xarmian 2f3cf402b1 fix(timeline): 'commented on update' only for a comment an update carried (BUG-3437) (#1832)
* fix(timeline): 'commented on update' only for a comment an update carried (BUG-3437)

TimelineCommentCard showed 'commented on update' whenever the comment had
an activity_id, and every comment has one: a comment or reply written on
its own links to its own 'commented' activity, so a plain
'pad item comment' read as if it were attached to a change.

The store's comment list join (the one that already reads the agent name
off the linked activity) now also reads its action into
Comment.LinkedActivityAction (json:"-"), and the timeline sets an
additive, omitempty TimelineEntry.comment_on_update when that action is
anything but 'commented'. The card keys the label on it. The comments
API, the CLI and MCP keep their shape (the new field is never serialized
outside the timeline, which MCP does not expose).

Tests: the server test writes a standalone comment, a reply and an update
carrying a comment through the real routes (web, CLI and MCP share the
comments route) and checks the flag, with the old rule and an always-false
mutant each red; the web test pins the card and the entry list's
pass-through, each red with its half reverted.

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

* test(timeline): a comment that arrives live keeps 'commented on update' (BUG-3437)

The label comes from the server-set comment_on_update. Comments that arrive
while the timeline is open come through the SSE refresh, which re-reads the
timeline endpoint (nothing builds a comment entry client-side), so they carry
the flag. This pins it: red if the entry list stops passing the flag.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-06 12:57:14 -04:00
xarmian 7aa5fa67f7 fix(web): an open mobile sheet stops page pull-to-refresh, and a pull from its list top closes it (BUG-3386) (#1830)
With the mobile Workspace sheet open, a downward swipe in the collection
list did not close it, and at the list's top it pull-to-refreshed the page.
DockedSheet put overscroll-behavior: contain on .ds-panel, which never
scrolls, so it did nothing: the list (.ds-content) is the scroller, and its
overscroll chained to the page.

- .ds-content gets overscroll-behavior: contain;
- while any DockedSheet is open the root is overscroll-behavior-y: none
  (html/body :has(.ds-panel)), so a drag on the backdrop, the grip, or a
  list too short to scroll cannot refresh the page either;
- a downward drag that starts in the list with the list at its top closes
  the sheet, the grip's existing close gesture continued into the content.
  A drag on a scrolled list, or one that moves up first, stays a scroll.

Applies to all three DockedSheets (Workspace, You, Quick capture).

Unit tests cover the content pull (close, snap back, scrolled list,
up-first, viewer in front); mutants removing the handler, the scrollTop
check and the up-first check each turn one red. The mobile-chromium e2e
drives a real CDP touch drag: red on main (root overscroll still auto
under the open sheet) and red on a mutant without the content handler
(the sheet stays open).

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-06 10:59:53 -04:00
xarmian d39cb9f00a fix(web): the selected tab is marked by an accent, not a background step (BUG-3420) (#1829)
In dark mode the active workspace tab was hard to tell from the others:
its background is the page colour (the tab flows into the page), which
measured 1.07:1 against the strip and its neighbours (1.09:1 in light),
with the same text colour and weight. It now carries a 2px accent line
along its top edge (inset box-shadow): 4.85:1 / 5.20:1 in dark, 5.70:1 /
5.24:1 in light, against the strip and the tab.

A survey of every selected-state control with siblings, across 12 pages
in both themes, found three tab-like segmented toggles failing the same
way (background steps of 1.05-1.16:1): the item pane's Rich / Markdown
toggle, the tag page's view toggle and the Insights window selector. Each
selected segment gets a 1px inset accent ring (>= 4.47:1). Underline tab
bars (item pane, settings, library, admin) already met 3:1 and are
unchanged.

The e2e pins the requirement for the tab bar in both colour schemes: the
active tab's indicator reaches 3:1 against an inactive tab and the strip.
Red on the old build (no indicator) and on a mutant drawing the line in
the border grey.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-06 10:49:45 -04:00
xarmian b454f17928 build(web): move the SvelteKit and Svelte config into vite.config.ts (TASK-3423, PR A) (#1823)
SvelteKit 3 refuses svelte.config.js, and kit 2.62+ already takes its config
through sveltekit({...}), so the move lands on kit 2.70.3 first. Kit's own
keys (adapter) stay with kit; dynamicCompileOptions is forwarded to
vite-plugin-svelte.

The vitest jsdom project used a bare svelte(), which got runes mode only by
loading svelte.config.js itself. Both configs now import dynamicCompileOptions
from one module, svelteCompileOptions.ts, and the jsdom plugin passes
configFile: false. Deleting the file without that would have dropped runes
mode from component tests silently.

Proofs:
- runesMode.svelte.test.ts: importing a component with a legacy $: statement
  is refused in the jsdom project; red without the vitest change.
- svelteCompileOptions.test.ts: resolving the real vite.config.ts yields
  vite-plugin-svelte options carrying the forcing ({runes: true} for ours,
  undefined for node_modules) and configFile false; red without the forward.

The unused @sveltejs/adapter-auto is removed.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-05 23:38:37 -04:00
xarmian f0c65fe08c chore(web): adopt Tiptap 3.31.4 and regenerate the materializer corpus (TASK-3421) (#1811)
* chore(web): adopt Tiptap 3.31.4 and regenerate the materializer corpus (TASK-3421)

3.31.4's TableCell/TableHeader renderHTML stop rendering a default
colspan="1" / rowspan="1". parseHTML is unchanged, so a stored body
carrying them parses to the same document. Replaying all 50 old corpus
op-logs through the 3.31.4 bundle reproduces the old output exactly once
those attributes are removed (and fails on 28 cases without removing them).

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

* test(web): pin that a pre-3.31.4 table body still dedupes on open (TASK-3421)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-05 15:52:54 -04:00
xarmian 8b8124ab5c feat(apps): item-pane link-out for installed apps' item actions (TASK-3413, SPEC-6 U9d) (#1794)
* feat(apps): the item pane's app actions: one link-out button per action, the tab opened inside the click with no opener, only web URLs followed, refusals and sign-outs close it (TASK-3413, SPEC-6 U9d)

* test(apps): e2e for the item action: new tab with no opener at the minted URL, a refused mint closes it, no button without actions (real server) (TASK-3413, U9d)

* fix(apps): an item action whose pane moved on before the mint answered closes its tab; the referrer the app sees is documented (TASK-3413, U9d codex r1)

* fix(apps): the item action fences with authStore.identityFence() and joins ItemDetail's reviewed child population; its tab cleanup is classified (BUG-3095 guard; TASK-3413, U9d)
2026-10-05 10:15:06 -04:00
xarmian 83ca50f861 fix(apps): the apps UI tolerates null lists from the server (BUG-3417, client half) (#1795)
* fix(apps): every list the apps UI reads is made an array where its response lands, so a null list from the server no longer crashes the install or upgrade review (BUG-3417)

* test(apps): a changed artifact with null lists in the upgrade review (BUG-3417)
2026-10-05 07:50:11 -04:00
xarmian d5c2ecfe08 feat(apps): 'via <App>' attribution on items, comments and history, and the members Apps section (TASK-3413, SPEC-6 U9c UI) (#1793)
* feat(apps): the 'via <App>' label on items, comments, replies and history, history keeping a person's own edits apart from their edits through an app, and the members list's separate Apps section (TASK-3413, SPEC-6 U9c)

* test(apps): the via <App> label on comments and replies, and history's writer rules with an app (TASK-3413, U9c)

* test(apps): history labels writes through an app and keeps them apart from direct edits (TASK-3413, U9c mutant V5)

* test(apps): e2e for the via <App> label on an item and its comment, and the members Apps section, real rows with the app fields added through route.fetch (TASK-3413, U9c)

* fix(apps): app names are bidi-isolated in the Members section and wrap in every via label (TASK-3413, U9c codex r1)

* test(apps): the long-name leg checks each via label ends inside the viewport; the page-width check could not fail, an ancestor clips (TASK-3413, U9c)

* fix(apps): WorkspaceApp gets its own import line, so the identity gate re-reviews only load() and handleInvite(), whose new apps writes sit beside members behind the same checks (TASK-3413, U9c)
2026-10-05 07:28:37 -04:00
xarmian 9ae4a0be45 feat(apps): upgrade review and draft activation in Settings → Apps (TASK-3413, SPEC-6 U9b) (#1789)
* feat(apps): the install view lists the items its pack provisioned (source_pack = origin@version), for draft activation (TASK-3413, SPEC-6 U9b)

* refactor(apps): the per-artifact review is its own component, shared by install and upgrade (TASK-3413, U9b)

* feat(apps): the upgrade review (diff by review/auto, released companions, new drafts, the reviewed hash) and the drafts panel with per-item activation (TASK-3413, SPEC-6 U9b)

* test(apps): each upgrade diff line sits in exactly one group (TASK-3413, U9b mutant U2)

* test(apps): e2e for the upgrade review and draft activation through page.route (TASK-3413, U9b)

* fix(apps): Activate addresses the item by id, not slug; the origin prefix is compared in characters; the one-live-install-per-origin rule is documented (TASK-3413, U9b codex r1)

* fix(apps): Activate moves focus to its row and announces it; the draft list retries after a failure; the upgrade's loading line is a status (TASK-3413, U9b codex r2)

* fix(apps): activation moves focus only when it was lost, and to the panel heading when the row is gone (TASK-3413, U9b codex r3)

* test(apps): an app token, service or a delegated owner's, cannot activate its own draft artifact (§2 step 7; TASK-3413, U9b lead ruling)
2026-10-05 06:09:12 -04:00
xarmian 48c3ca066c fix(test): test temp dirs stop leaking into /tmp (BUG-3412) (#1786)
* fix(test): template and fixture temp dirs no longer leak into /tmp (BUG-3412)

The store test templates are named for the owning pid, and each build
first removes the template dirs of processes that are gone (and pid-less
legacy dirs over a day old), so a test binary killed before TestMain's
Cleanup no longer leaves its template on the tmpfs for good. The fence
source tests remove their fixture dirs when the file finishes.

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

* fix(test): never sweep pid-less template dirs; age proves no owner gone (codex r1 on BUG-3412)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-05 04:50:02 -04:00
xarmian 41be1bf113 feat(apps): human-facing JSON names the app that wrote an item, comment or version (TASK-3413, SPEC-6 U9c) (#1787)
* feat(apps): human-facing JSON names the app that wrote an item, comment or version (TASK-3413, SPEC-6 U9c)

Items (the creating app), comments and version rows carry via_app (the
install id) and via_app_name (the bot's name, else the origin; kept after
uninstall) on the web API, the MCP get / list summary / history summary
projections, and the TS types, with WorkspaceApp naming the members apps
array. ToolSurface 0.65, additive.

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

* fix(apps): attribution on search, /changes, playbook show and restore; chunked lookup (codex r1 on U9c)

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

* fix(apps): attribution on the role board, a comment edit's response and the account export (codex r2 on U9c)

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

* docs: README tool catalog at v0.65 (TASK-3413)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-05 04:49:25 -04:00
xarmian ede0c22fc2 feat(apps): Settings → Apps: install review, install-code handoff, and lifecycle controls (TASK-3413, SPEC-6 U9a) (#1785)
* feat(apps): GET /workspaces/{ws}/apps lists the workspace's installs for the owner, and says when apps are off (TASK-3413, SPEC-6 U9a)

* feat(apps): Settings → Apps: the install list, the review and install-code handoff, and disable/rotate/re-enable/uninstall with their consequences (TASK-3413, SPEC-6 U9a)

* test(apps): e2e for Settings → Apps (apps-off against the real server; install, handoff, disable, re-enable through page.route), the Apps tab in the permission walk, and the owner tab count 5 → 6 (TASK-3413, U9a)

* fix(apps): rotate's copy says the webhook waits for the new code too (TASK-3413, U9a codex r1)

* fix(apps): focus follows each step: the code panel, the install heading after an action, the opening button on cancel, the app card on back (TASK-3413, U9a codex r2)

* fix(apps): an install_state refusal moves focus to the install heading (TASK-3413, U9a codex r3)

* test(apps): the refusal focus test focuses the confirm button first, as a real click does, so it fails without the fix (TASK-3413, U9a)
2026-10-05 03:53:12 -04:00
xarmian 82c45f24cd feat(apps): app item actions and context codes (TASK-3414, SPEC-6 U11) (#1784)
* feat(apps): app item actions and context codes (TASK-3414, SPEC-6 U11) [wip]

Item actions are written as app_item_actions rows at provisioning, kept in
step by a reviewed upgrade (a changed action gets a new revision, a
removed one is retired), deleted at uninstall and backfilled at start.
A viewer's click mints a single-use, two-minute context code; the app
redeems it with its service token. Mint and redeem take the install, item
and action rows in that order (FOR SHARE on Postgres), re-check the
install, the item's current companion collection, the action's revision
and the viewer's current visibility (the human item read's rule), and
refuse with one 404 body. Redeem consumes the code first. The frozen TS
type ItemAppAction and api.items.appActions / mintAppAction ship for U9.

Migrations 125 / pg 099.

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

* test(apps): U11 mint, redeem, refusal and revocation tests; redeem in the read census; NUL baseline (TASK-3414)

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

* test(apps): invalidating mutations wait for a redeem holding the section 6 locks (TASK-3414)

Deterministic: a hook pauses the redeem with its locks held while disable,
an upgrade's action change, a move or a delete is started; each stays
blocked until the redeem commits. On Postgres, dropping the install lock
lets disable through, dropping the item lock lets move and delete through.

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

* test(apps): U11 mutant kills: epoch, deactivation, companion moves, release, admin non-member, backfill and workspace guards (TASK-3414)

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

* fix(apps): redeem at the token's workspace, service tokens only; no pool reads under the context locks; normalized base URL (codex r1 on U11, TASK-3414)

Redeem moves to POST /api/app/v1/context/redeem (§6's path), outside
/workspaces/{ws}: the workspace is the token's, injected before the same
per-route stack, so no workspace-path refusal can answer before the code
is consumed. It refuses a delegated token with the same 404.

The viewer re-admission reads the user, role, membership and grants on
the mint or redeem transaction (GetUserQ, ViewerRoleQ,
UserHasGrantsInWorkspaceQ). A one-connection pool runs mint and redeem for
a full member, a restricted member and a guest without hanging; switching
any of those reads back to the pool deadlocks it.

The link-out URL is built from the validated origin, refused before a
code is written when base_url does not normalize.

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

* fix(apps): a delegated redeem attempt consumes the code before it is refused (codex r2 on U11, TASK-3414)

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

* fix(apps): build the context code expiry from its constant (lint, TASK-3414)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-05 02:29:48 -04:00
xarmian 7396ec42bb feat(oauth): delegated sign-in for installed apps through the issuance barrier, shipped closed (TASK-3399, SPEC-6 U5b-1) (#1779)
* feat(oauth): delegated install grants through the issuance barrier: code, PKCE, access and refresh for a live human member, the consented access bound and capped by the manifest (TASK-3399, U5b-1)

* feat(oauth): delegated app sign-in: a fixed-workspace consent page, code+PKCE for install clients, the person's access choice in the grant, and an ownership check before fosite on code and refresh (TASK-3399, U5b-1)

* feat(oauth): app grants are not MCP connections: the readers skip install chains, the console lists and revokes them through the binding, the sweep clears orphan bindings, and revoking a person reaches their delegated codes (TASK-3399, U5b-1)

* test(server): the console lists an app grant on its own, refuses every connection mutation on it, and revokes it through the binding (TASK-3399, U5b-1)

* test(server): the console revoke ends an app grant through its binding (TASK-3399, U5b-1)

* feat(web): Connected apps lists installed apps acting as you, read-only plus revoke (TASK-3399, U5b-1)

* test(oauth): pin the binding's person and a resource-less sign-in; name the fosite layers behind the consent gate's PKCE and audience checks (TASK-3399, U5b-1)

* fix(oauth): U5b-1 codex r1: a delegated binding remembers the person's credential epoch and membership and is tombstoned on revoke; issuance share-locks the person on Postgres; app sign-in and grant management work with MCP off (TASK-3399, U5b-1)

* fix(oauth): U5b-1 codex r2: only a delegated grant's code writes its binding; member removal, disable and claim end a person's delegated grants through their installs; users before installs before tokens in every path (TASK-3399, U5b-1)

* fix(oauth): U5b-1 codex r3: delegated revocation takes no install lock (the person's row orders it against issuance) and writes child rows in teardown's order, closing two deadlocks with owner-account deletion (TASK-3399, U5b-1)

* fix(oauth): U5b-1 codex r4: install teardown and a person's delegated revocation lock their bindings in request_id order before any child row (TASK-3399, U5b-1)

* feat(store): bounded retry on a Postgres deadlock for delegated-grant revocation, member removal, account disable and claim, and the OAuth sweep, with a per-site metric (TASK-3399, U5b-1, lead ruling (a))

* fix(oauth): U5b-1 codex r6: the console does not list an app grant the install's epoch has moved past (TASK-3399, U5b-1)

* fix(oauth): U5b-1 codex r7: a consent carries the person's credential epoch and the barrier refuses it after a disable/re-enable; the console lists a grant not yet exchanged (TASK-3399, U5b-1)

* feat(oauth): delegated app sign-in stays closed (access_denied, not yet available) behind one named guard until U5b-2 opens it with the app API (TASK-3399, U5b-1, lead ruling)

* fix(oauth): U5b-1 codex r8: a consent carries the install epoch and membership its gate read, so a disable/re-enable or removal/re-add after the gate refuses the code (TASK-3399, U5b-1)

* fix(oauth): U5b-1 codex r9: a delegated grant needs a live workspace, at the consent gate and the barrier (TASK-3399, U5b-1)

* docs(oauth): name the accepted one-second residual of the consent's membership check (TASK-3399, U5b-1)

* test: census the delegated consent's form readers, the app-grant revoke's user agent, and four binding columns that carry no caller text (TASK-3399, U5b-1)
2026-10-04 19:22:21 -04:00
xarmian b572f0739c feat(auth): the bot principal for installed apps: users.kind, kind gates on every sign-in door and credential, people-only counts (TASK-3392) (#1766)
* feat(store): the bot principal: users.kind, CreateAppUserTx, kind gates on every credential mint and claim, people-only counts, install-gated app projection (TASK-3392)

* test(store): record users.kind in the NUL census baseline (store-written, two values) (TASK-3392)

* feat(server): kind gates at the sign-in doors, admin 404 and member 409 for a bot, apps array on the member list, reserved domain refused (TASK-3392)

* docs+web: the members apps array (TS type, CLAUDE.md); people-only admin member count test (TASK-3392)

* test(server): the U3 owner-webhook strip test plants an install (TASK-3392)

* test(server): forgot-password and resend refuse a bot before attempting a mint (TASK-3392)

* test(server): a usable base URL so the resend door would really mint (TASK-3392)

* test(server): the session doors' own kind checks, with the bot already resolved (TASK-3392)

* fix: stored reset/verification tokens, PAT rotation, grants and OAuth bearers refuse a bot (TASK-3392, codex r1)

* docs: SPEC-6 §11 Q2 is decided (separate Apps section, no seat); the switches stay pinned (TASK-3392)

* docs(server): name the two layers in front of CLI approve's kind check (TASK-3392)

* test(appstore): the projection's install gate joins the reviewed door helpers (TASK-3392, rebased on #1765)
2026-10-03 19:41:18 -04:00
xarmian cd6dfdb318 fix(settings): Conventions and Playbooks start unchecked when a member is restricted (TASK-3384) (#1759)
Dave's ruling on TASK-3376's picker default: switching a member from All
to Specific collections no longer pre-checks the system collections. They
start unchecked, like every other collection. The hint under the list now
names the consequence: unchecked, they stay hidden from that member, and
their agents won't load the workspace's conventions or playbooks.

Migration 108's backfill is unchanged: members restricted before TASK-3376
keep exactly the access they had. A member already restricted keeps their
saved list.
2026-10-03 10:17:27 -04:00
xarmian 98871c7e01 fix(auth): a verification link verifies only for its account's session; the mailbox owner can claim (BUG-3382) (#1758)
* fix(auth): a verification link verifies only for its account's session; the mailbox owner can claim (BUG-3382)

Registration mails the verification link to the address, and the address
may belong to someone other than whoever registered it. Clicking it used to
verify the squatter's account in place, keeping the squatter's password and
sessions.

- POST /auth/verify-email now verifies only when the request is signed in as
  the account the token names. Otherwise it answers 409 verify_needs_session
  with {email, can_claim} and leaves the token unspent.
- New POST /auth/verify-email/claim {token}: the mailbox owner claims a
  never-verified account. One transaction spends the token, resets every
  credential (an unusable password, 2FA, sessions, PATs, OAuth, CLI
  handoffs, reset and verification tokens), marks the address verified,
  strips memberships, grants, watches and tabs in workspaces the account
  does not own, and soft-deletes the ones it owns (TASK-3351 ruling 3). An
  account with a billing customer is refused with
  account_claim_needs_support (ruling 4). The answer carries a
  set-password path and the affected workspace names.
- users.credential_epoch (SQLite 110 / PG 084): bumped by a password change,
  a disable and a claim. Every session mint is fenced on the epoch read
  when the credential was checked (login, 2FA via the challenge token, CLI
  approval, reset completion, rotation, OAuth login), so a sign-in checked
  before a claim mints nothing after it (401 credentials_changed).
- The web verify page offers "Sign in to verify" (back to the link), and,
  for an unverified address, a confirmed claim listing what was removed.
- A regression test shows that an unverified account's cookie cannot use
  the cookie-fallback routes.

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

* fix(auth): fence password writes on the checked epoch; own verify bucket; switch account (BUG-3382)

Codex round 1:
- A password change (PATCH /me) or a reset completion that started before
  a claim could still overwrite the claim's reset. UserUpdate.ExpectedEpoch
  makes the write conditional on the epoch its authority was checked under:
  the current password's read, or the reset token's spend. The spend now
  reads the epoch in the same statement, and a test hook puts a claim
  between the spend and the read-back. A mismatch writes nothing
  (credentials_changed).
- verify-email and its claim move to their own EmailVerify bucket (10/hour),
  so opening the link, refreshing and claiming no longer spends the
  PasswordReset budget the claimant needs to set a password.
- The verify page, signed in as a different account, signs out before
  sending the user to sign in. A plain /login link bounced straight back.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-10-03 09:48:19 -04:00
xarmian 3bcef0e04d fix(access): system collections are ordinary collections for restricted members (TASK-3376) (#1756)
A member restricted to specific collections saw every system collection
(Conventions, Playbooks) whether it was listed for them or not, and an
editor among them could write it, because requireEditPermission passes
any editor before it looks at collections. Eight sites each unioned the
system collections in. They now follow member_collection_access and
grants like any other collection, for reads and writes.

- Store: VisibleCollectionIDsQ, GetUserWorkspaces' freshness MAX and
  ResolveBacklinksVisibility drop the union; ListSystemCollectionIDs(Q)
  had no caller left and is removed.
- Server: the item-visibility rule, /me's full-access set, watch access,
  search and the SSE full set drop it.
- Migrations 108 / 082 list every live system collection for every
  member already restricted, so the upgrade changes nobody's access. A
  system collection created later is not added for them.
- Settings: Conventions and Playbooks are real checkboxes in the member
  access picker, checked by default when an owner moves a member from
  All to Specific, with a line saying what unchecking them does.

Also (PR #1756 codex round 2 P1): an editor or owner ROLE applies only
inside the collections a member's access reaches ('all', or listed in
member_collection_access). Before, requireEditPermission let any editor
through before it looked at the collection, so a restricted editor who
could see an item only through a VIEW grant could edit it, comment on it,
create beside it and bulk-mutate it. That was true on main for ordinary
collections too. Outside the member's reach the grant decides, and only
'edit' writes. The same rule now governs the collab socket's write
decision, the bulk per-item and move-target gates, and the cross-workspace
edit authorizer, and ResolveUserPermission's membership step checks the
member's listed collections instead of the navigation set (which lists a
collection because one item in it is granted).

The collection check binds the EDITOR role only (codex round 3, lead
ruling): an owner can set any member's collection access, their own
included, so the owner role keeps its full edit bypass, and the server
agrees with the web client's canEditItem / canEditCollection.
2026-10-03 09:19:10 -04:00
xarmian 0929f65705 feat(invitations): a mailbox-only proof on invitation emails verifies the invitee (TASK-3352) (#1757)
* feat(invitations): a mailbox-only proof on invitation emails verifies the invitee (TASK-3352)

BUG-3348 stopped an invitation code from verifying the invitee's address,
because the inviter sees the code; a cloud invitee then had to click a
separate verification email before writing. The invitation email now
carries a second secret that only the mailbox receives.

- CreateInvitation mints a 128-bit proof next to the code and stores only
  its SHA-256 (workspace_invitations.proof_hash, migrations 109 / 083).
  The plaintext is on the returned struct for the email sender only and
  never serialises.
- Both senders (member invite, admin resend, which re-creates the
  invitation and so rotates the proof) email /join/<code>#proof=<proof>:
  in the fragment, so it never reaches request logs or a Referer. The
  invite response's join_url, the members and admin invitation lists and
  copy-link carry the code alone.
- Store.ConsumeInvitationProof spends it in one transaction (user row
  locked first, live accounts only): clear the hash, mark the address
  verified. Single-use and bound to its invitation.
- Accept (`proof` in the body, after a successful accept, unverified
  accounts only; the email match already ties the account to the
  address) and register with an invitation (`invitation_proof`, which
  then skips the separate verification email) spend it. A wrong or spent
  proof is ignored, not refused. The code alone never verifies.
- The join page reads #proof=, keeps it in sessionStorage for the code
  across a sign-in round trip, strips it from the address bar and sends
  it with the accept or signup.

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

* fix(invitations): an expired invitation's proof verifies nothing (TASK-3352)

Codex review round 1: the consume checked only the invitation id and the
hash, so a request that passed the handler's expiry check and crossed
the expiry before consuming still verified the account (reproduced at
537 ms past expiry). The expiry is now decided inside the consume's own
statement.

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

* fix(invitations): an empty expiry verifies nothing, and a malformed proof fragment is ignored (TASK-3352)

Codex review round 2:
- expires_at = '' was let through by the consume, though IsExpired reads
  it as expired; it now verifies nothing, like any expired invitation.
- #proof=% made decodeURIComponent throw before the join page's error
  handling, leaving it loading forever; a malformed escape is now no
  proof.

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

* docs(invitations): name the email-match duty of ConsumeInvitationProof's callers (TASK-3352)

Lead's review nit.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-10-03 07:59:37 -04:00
xarmian 82395bde1c feat(import): mark rows a workspace import wrote, author not verified (BUG-3379) (#1755)
* feat(import): mark rows a workspace import wrote, author not verified (BUG-3379)

Workspace import keeps the export's comment authors, version kinds and
attachment uploader ids verbatim (BUG-3372 ruling (1)), and nothing
distinguished those rows from authenticated authorship, so an importer
could present a fabricated trail. Nothing stored could tell them apart
either: user_id is NULL after an account deletion too, the export
supplies the timestamps, and import writes no activity.

- Migrations 107 / 081 add imported (INTEGER 0/1) to comments,
  item_versions and attachments. Only the import doors set it (JSON and
  bundle; a derived thumbnail or transform inherits its original's).
- Comments, versions and attachment rows serialise an omitempty
  imported: true.
- An imported attachment's uploaded_by is never resolved to a local
  name: the member route sends X-Pad-Attachment-Imported: 1 instead.
  That also closes an importer naming a real member as uploader by
  putting their id in the manifest.
- The web marks them: "· imported" beside a comment or reply author, an
  Imported chip on a history row (and an imported row never merges with
  a native one), and "imported" in place of the uploader in the viewer.
- Rows imported before this are not marked: there is nothing to backfill
  from.
- MCP tool surface 0.64 (additive): the flag reaches comment and
  attachment responses on both transports.

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

* fix(import): keep the imported mark through recovery, autosave runs and copies (BUG-3379)

Codex review round 1:
- An imported version claiming recovery or system lost its marker in
  the history; it is kept.
- The History tab's autosave row had no marker; it shows the chip.
- The server's autosave burst grouping could fold an imported snapshot
  into a native one (both often have no user id); it no longer does.
- A cross-workspace copy carried imported=1 onto a clone the copier
  uploaded, hiding its real local uploader; the clone is not imported.
- A store test inserting attachments by column list is updated for the
  new column.
- The 0.64 changelog names the MCP responses that actually carry the
  flag: comments, attachment list and full history (not history
  summaries, attachment show or the attachment resource).

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

* fix(import): a local edit never lands inside an imported version (BUG-3379)

Codex review round 2: the version throttle ignored the flag, so a local
edit within the hour, by the same kind and source as the newest imported
version, wrote no version and read as part of the imported change. An
imported predecessor now always starts a new version.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-10-03 05:18:30 -04:00
xarmian db62001ad4 fix(billing): a plan past its expiry entitles nothing, and Stripe plan writes are ordered (BUG-3356) (#1753)
* fix(billing): a plan past its expiry entitles nothing, and Stripe plan writes are ordered (BUG-3356)

/admin/plan was last-write-wins under the plan-source rule, so two
Stripe-derived writes arriving out of order could leave the older one
standing and restore an entitlement a newer one removed; and
plan_expires_at was stored but never read.

- models.User.EffectivePlan / PlanExpired: an expiry at or before now
  (or one that does not parse) makes the plan free. Every entitlement
  reader uses it: the workspace and user limit checks and workspace
  storage info; the session and account responses report it. Admin
  responses keep the stored plan and add plan_expired.
- Migrations 106 / 080 add users.plan_revision and
  plan_subscription_id. /admin/plan takes optional revision (the
  sidecar's fetch time, unix ms) and subscription_id; a write with a
  revision applies only if it is newer than the stored one, decided in
  the same UPDATE as the source rule. A refusal answers 200
  applied:false with reason "source" or "stale_revision".
- A stripe write with no revision (a sidecar that predates it) still
  applies, unordered, and is logged and counted in
  pad_plan_writes_total{outcome="no_revision"}; a later change refuses
  it once the sidecar sends one.

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

* fix(billing): bigint revisions, manual writes fence in-flight Stripe writes, expiry-aware billing counts (BUG-3356)

Codex review round 1:
- Revisions are unix ms, beyond int4; Postgres inferred int4 for the
  bare placeholders compared with 0 and refused real values (500).
  Every revision placeholder is now CAST AS BIGINT; a test uses real
  millisecond values and fails on Postgres without the casts.
- An admin downgrade left the stored revision alone, so a Stripe fetch
  taken before it and landing after it restored pro. A manual write now
  raises plan_revision to now (unix ms): a fetch from before the
  operator's decision is refused as stale, one from after still applies.
  Pad and the sidecar share a host, so the two clocks are close; skew
  moves the fence by that much.
- The admin billing counts (customers by plan, new pro signups, the
  admin user list's plan counts) ignored expiry; they count the
  effective plan, decided in Go because a stored RFC3339 value may carry
  an offset and does not sort against now.
- A refusal's reason was read after the UPDATE in a fresh snapshot; the
  row is now read under its lock first and both decisions use it.
- subscription_id is validated at the door (Stripe-shaped, at most 255
  bytes), since the column takes the NUL baseline's exemption.

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

* fix(billing): a manual write cannot carry a revision, and the admin update validates expiry (BUG-3356)

Codex review round 2:
- /admin/plan accepted a revision on a manual write, which replaced the
  current-time fence with a value the caller chose; a revision is now
  refused (400, and by the store) unless the source is stripe.
- The admin user update stored an expiry without parsing it; under
  expiry enforcement an unparsable one stores pro and grants nothing.
  It is now refused as /admin/plan refuses it.
- The over-length subscription id case used NUL bytes, so JSON
  rejection masked the length check; it is now a valid-shaped id.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-10-02 23:32:54 -04:00
xarmian 5749671dd9 fix(oauth): include_future_workspaces is no longer a control; limit to current is an action (BUG-3338) (#1751)
* fix(oauth): include_future_workspaces is no longer a control; limit to current is an action (BUG-3338)

The wildcard (all_current_workspaces) is live and covers workspaces
joined later, and nothing ever auto-added on include_future_workspaces,
so the Connected Apps checkbox promised something false either way:
unticked under the wildcard it still granted workspaces joined later,
and ticked on a specific list it added nothing.

- Connected Apps drops the checkbox and labels the wildcard "All my
  workspaces, including ones I join later".
- PATCH /connected-apps/{id}/flags ignores include_future_workspaces
  (an older page may still send it); the stored value follows
  all_current_workspaces, as the consent screen always wrote it.
- POST /connected-apps/{id}/limit-to-current copies the user's live
  memberships into the list and turns the wildcard off in one
  transaction: the explicit form of "only my current workspaces".
- The consent screen is unchanged.

Rows already stored with the wildcard on and include_future off are
narrowed by a separate migration, held for a production count.

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

* fix(oauth): limit acts only on a wildcard, keeps guest workspaces, and the empty-list rule holds under the connection lock (BUG-3338)

Codex review round 1:
- A repeated limit (a stale tab) on an already specific list added the
  workspaces joined since; it now changes nothing unless the wildcard
  is on.
- The snapshot left out workspaces the user reaches only as a guest
  through a collection or item grant, which the wildcard reaches; it
  now includes them.
- Removing a workspace and turning the wildcard off checked the
  empty-list rule outside any lock, so a concurrent limit or flags
  change could leave a specific list empty. Both now check and write
  in one transaction holding the connection row's lock.
- Turning the wildcard off and limiting now re-check live connections,
  as a removal does (TASK-3365).

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

* fix(oauth): the limit snapshot counts only live guest grants (BUG-3338)

Codex review round 2: a grant on a soft-deleted item or collection
confers no access, but put its workspace in the snapshot, where it
counted toward the not-empty rule and would come back to life if the
content were restored. The grant subqueries now use the live-grant
filter UserHasGrantsInWorkspace uses.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-10-02 22:41:29 -04:00
xarmian 66b4a9d477 fix(auth): disabling an account ends every credential, and a disabled account gets no new one (BUG-3349) (#1741)
(b) Admin disable deleted sessions only, so the account's PATs and MCP
grants kept working. DisableUserAndRevokeAccess does all of it in one
transaction:
- disables the account;
- deletes its sessions and API tokens;
- deactivates every OAuth access and refresh token (by subject), and every
  unexchanged authorization code (through its connection's request id);
- deletes its PKCE and OAuth connection rows.
EnableUser restores none of them.

(c) A disabled account acts nowhere and gets no credential:
- requireActiveUserTx is the one gate every credential insert passes. It
  reads the user row in the insert's own transaction, FOR SHARE on
  Postgres, and refuses a disabled or missing account with ErrUserDisabled.
  It covers sessions, API tokens, OAuth codes, access and refresh tokens,
  and OAuth connections. FOR SHARE conflicts with the disable's row lock,
  so a mint admitted before a disable cannot land after it.
  TestBUG3349_MintWaitsForAConcurrentDisable_Postgres proves the wait with
  two connections.
  TestEveryCredentialInsertRequiresAnActiveUser fails on any new credential
  insert that skips the gate.
- RequireAuth checks disabled status before the /api/v1/auth/* exemption.
  Only POST /auth/logout and GET /auth/session stay open, and the session
  check answers account_disabled.
- The MCP door, /oauth/token, /oauth/authorize and /authorize/decide, 2FA
  login-verify, and email verification (before its token is spent) refuse
  a disabled account. The login page shows "Your account has been
  disabled".
- Share links the account created are suspended (lead's ruling):
  byte-identical to an unknown token while it is disabled, serving again
  once it is re-enabled.

Webhooks are unchanged by design; they belong to the workspace. This is
documented in docs/deployment.md, "Disabling an account".

Part (a) shipped in BUG-3336. codex review: two rounds, eleven findings, all
fixed.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-10-02 18:34:56 -04:00
xarmian 9e886ddc00 fix(web): a link that opens elsewhere never keeps window.opener (BUG-3359) (#1739)
Raw HTML in markdown bypasses the safe link renderer, and both sanitizers
kept the author's target and rel, so <a target="_blank" rel="opener">
reached {@html} with window.opener live in the new tab. An
afterSanitizeAttributes hook in sanitizeMarkdownHtml and sanitizeHtmlBlock
now drops the opener token and forces noopener noreferrer on any element
whose target is not _self, keeping the author's other rel tokens.
2026-10-02 17:20:07 -04:00
xarmian 3d21213b57 fix(account): account deletion needs an interactive, recently signed-in session (BUG-3336) (#1733)
* fix(account): an API token cannot delete the account; confirm-only is for passwordless cloud accounts (BUG-3336)

POST /auth/delete-account deletes the user and soft-deletes every
workspace they own, and two holes let it go through with less proof than
the caller should have to show.

1. Confirm-only (no password) was accepted from ANY cloud account. The path
   exists for OAuth-only accounts, which have no password to give, but it
   never checked HasPassword, so a cloud session alone could delete an
   account that has one. It now requires !HasPassword. This matches the web
   client, which offers confirm-only only when password_set is false.
2. A PAT was accepted like a session, so a leaked write token could delete
   the account (and, with the password, on self-host too). An API token is
   now refused with 403 session_required, before the body is read, so it
   cannot probe the password either. This is the line BUG-2890 drew for
   token minting. A browser or `pad auth login` CLI session still works.
   No CLI or MCP command deletes an account.

TOTP is unchanged and still runs after either identity check.

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

* fix(account): confirm-only deletion needs a recent sign-in; rotation keeps the sign-in time (BUG-3336)

codex review of the first commit: password_set=false does not prove an
account has no password. An account that signed up with a password before
migration 043 and later linked OAuth reads false and still holds a real
hash, and CreateOAuthUser stores a hash too (a random placeholder), so the
data cannot tell the two apart. A session alone could still confirm-delete
such an account.

Confirm-only now also needs a session signed in within
accountDeleteReauthWindow (10 minutes, measured from sessions.created_at).
Otherwise it answers 403 reauth_required, "For your security, sign in again
to delete your account", and the settings page offers a "Sign in again"
button: it signs out and returns to /login?redirect=/console/settings,
because /login sends a signed-in visitor straight on.

A credential-change rotation (password change, 2FA, OAuth unlink) no longer
restarts the clock. CreateSessionIssuedAt gives the new session the
replaced one's created_at, which also stops a rotation from extending
SessionMaxLifetime (measured from created_at); its expiry is capped there.
ValidateSession now returns CreatedAt.

TestWorkspaceAccessChanged_AccountDeletion deleted through a PAT, the path
the first commit refuses; it now uses an interactive session.

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

* fix(auth): no session-minting door hands out a fresh sign-in without one (BUG-3336, BUG-3349a)

codex review, round 2: the re-auth window could be beaten by minting a
session whose created_at reads as new.

- CLI approval minted a 30-day session from any authenticated caller. It
  now refuses an API token (403 session_required), and the CLI session it
  mints carries the APPROVING session's sign-in time
  (CreateSessionIssuedAt). An approver whose session cannot be read is
  refused.
- requestSessionInfo read `Bearer padsess_` literally and missed a padded
  header that TokenAuth accepts after trimming. It now reads the header
  the way TokenAuth does, and a bearer of any kind is the credential.
- Rotation (rotateSessionsAfterCredentialChange) fell back to a fresh
  created_at when it could not find the request's session. Every caller is
  a session-only door, so it now revokes every session and asks for a new
  sign-in instead of minting one.
- oauth-unlink and PATCH /auth/me with a password change refuse an API
  token, so rotation is reachable only from a session (TOTP disable already
  was). A PAT can still edit the profile.

These three gates are BUG-3349's part (a), moved here because the re-auth
window depends on them (lead's ruling). Legs: CLI approval from an old
session or a PAT, the padded bearer, rotation with no session, and the
SessionMaxLifetime cap on rotation.

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

* fix(auth): admin password reset refuses an API token; the request's session is the one that authenticated it (BUG-3336)

codex review, round 3.

- An admin's PAT could reset its own account, get a temp_password back,
  and sign in as a fresh session. handleAdminResetPassword now refuses an
  API token (403 session_required).
- requestSessionInfo re-read the Authorization header. An invalid bearer
  on /auth/* falls through to the cookie in TokenAuth, so a valid cookie
  session beside a stale PAT header read as "no session": CLI approval
  answered 401, and the rotation doors revoked every session and answered
  500. It now follows ctxAuthKind, the credential the middleware actually
  used (as credentialLiveness does): a session bearer is read from the
  header, an API token is no session, and anything else from the cookie.
- Legs added:
  - the password-change, oauth-unlink and admin-reset PAT gates, each
    refusing with nothing changed or revoked behind the refusal;
  - a stale bearer beside a valid cookie;
  - a rotation with no session revoking every session.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-10-02 15:43:57 -04:00
xarmian 82064526e3 fix(brand): name the entity as Perpetual Software LLC everywhere (TASK-3321) (#1722)
Dave's ruling: the legal entity is Perpetual Software LLC and there is
no dba, so "Perpetual Software" never names the entity without "LLC".

Fixed: the ChatGPT plugin's author and developerName, both Claude
plugin manifests' author/owner, the brand spec's copyright format, the
cloud email footers (HTML and plain), and the auth-page footer. The
guard test now pins the ChatGPT package's developerName and author to
the full name, and the email tests expect it. LICENSE already said
"Perpetual Software LLC"; goreleaser, the Dockerfiles and Nix carry no
entity name.

The self-hosted email test keeps asserting the bare substring is
ABSENT: the shorter string is the stricter check there.

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-02 08:22:41 -04:00
xarmian a4079d12e3 chore(deps): npm update undici markdown-it (Dependabot #9, #11-14, #17-18) (#1701)
Lockfile only: undici 7.29.0 -> 7.30.0 (dev-only, via jsdom for vitest)
and markdown-it 14.3.0 -> 14.3.2 (runtime, via tiptap-markdown). Both stay
inside their parents' ranges, and neither advisory path was reachable: we
never use undici's BalancedPool, retry, dump or cache, and linkify is off
at every Markdown.configure site. The materializer site, which runs on
the server, now says linkify must stay off below markdown-it 14.3.1.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-10-01 00:10:26 -04:00
xarmian 1c6348edc5 fix(auth): OAuth login and password reset no longer mint a session past TOTP (BUG-3322) (#1699)
* fix(auth): OAuth login and password reset no longer mint a session past TOTP (BUG-3322)

Two session-minting doors skipped the second factor that password login
enforces:

- POST /auth/oauth-login (pad-cloud's GitHub/Google/Apple sign-in) minted
  a full session for a user with TOTP on. It now answers 403
  two_factor_required with the same login challenge password login issues
  (in details.challenge_token) and no session. A sidecar that predates this
  reads the 403 as a failed login, so it fails closed.
- POST /auth/reset-password minted a session after the reset, so access to
  the mailbox alone defeated 2FA. A TOTP user now gets the new password,
  every prior session deleted as before, and {ok, requires_login} with no
  session.

The web login page opens the code step from a #challenge= fragment (kept
out of server logs and Referer), and the reset page sends a TOTP user to
/login. pad-cloud's redirect and IP forwarding are U2.

The other session-minting doors were swept on the item trail: each checks
TOTP, mints for a brand-new account, or inherits an existing session.

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

* fix(auth): fill a missing avatar only after the oauth-login checks pass (BUG-3322 review)

Codex round 1, P3: the avatar of an existing account was updated before
the TOTP refusal. It now runs after every check, including the second
factor and the disabled-account check.

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

* fix(auth): the two_factor_required message never asks for a password (BUG-3322 review)

A provider-created account has a random password nobody knows, and TOTP
setup does not require one, so "sign in with your password" was wrong for
it. The message now sends a native shell to the web code step.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-09-30 22:50:45 -04:00
xarmian a4f16bfa22 fix(web): 44px touch targets round 2: group archive button, board lane +/⋯, card tags (TASK-3311) (#1698)
* fix(web): 44px touch targets round 2: group archive button reachable on touch, board lane + and ⋯, card tags (TASK-3311)

* fix(web): card tags are visibly 28 tall with a 16px row gap; the extender gives the 44 (TASK-3311, Dave's ruling)
2026-09-30 20:41:55 -04:00
xarmian c6df08f054 feat(attachments): the viewer says when a file was uploaded, and by whom (TASK-3319) (#1697)
* feat(attachments): the viewer says when a file was uploaded, and by whom (TASK-3319)

* fix(attachments): the upload caption has its own class, so the type/size line's selector stays unambiguous (TASK-3319)
2026-09-30 16:49:25 -04:00
xarmian d44c4f6545 fix(web): one workspace-access stream per browser, so open tabs cannot starve a new page over HTTP/1.1 (BUG-3318) (#1696)
* test(e2e): open pad tabs must not starve a new page of HTTP/1.1 connections (BUG-3318, red)

* fix(web): one workspace-access stream per browser, not per tab, so open tabs cannot starve a new page over HTTP/1.1 (BUG-3318)
2026-09-30 16:49:09 -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