From e88f41b6a098d38f172f6b4b93e2b146b0597211 Mon Sep 17 00:00:00 2001 From: Angus Bezzina <37071175+angusbezzina@users.noreply.github.com> Date: Tue, 4 Aug 2026 22:33:12 -0500 Subject: [PATCH 01/25] feat(review): HTML annotation substrate + doc-frame runtime (attn-61t) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phases 0-3 of the HTML document annotation epic: give shared HTML documents a comment anchoring substrate and the in-frame runtime that resolves it. Read-only HTML sharing already worked (attn-qgd); this is the commenting half. Design (planning/collab/html-annotation.md, amendments #19/#20): The June-locked "Design B" — serve the shared document from a distinct localhost origin so the frame has a checkable origin — is superseded. It cannot work for the hosted browser reviewer, which has no Rust process: it renders decrypted bytes via srcdoc, so there is no server, port, or origin to serve from. Adopting it would have left every browser-side reviewer unable to comment. Instead the frame keeps its opaque-origin sandbox and the runtime is injected into the HTML source — a pure content transform, so native and hosted run byte-identical code. The channel is a hello -> MessageChannel handshake bound on event.source; origin checking is meaningless on an opaque frame and unnecessary once traffic moves to a private port. Retaining the opaque origin also denies the untrusted document the storage and same-origin capability a real origin would have granted it. The document frame is untrusted: it may propose anchors and report geometry, never create or mutate review state. Comment bodies and the submit action stay in shell-owned UI, so the worst a hostile page can do is misdescribe a proposal the user sees before committing. Landed: - Rust: HtmlAnchor W3C selector layer (CssSelector + ranked fallbacks, TextQuote, TextPosition, RangeSelector) plus an agent-context block, so a comment is actionable to a coding agent that never saw the document. Anchor.html is serde-optional; markdown wire bytes are unchanged. HtmlAnchor::validate bounds every field at the trust boundary. - Rust: SnapshotAnnotation::HtmlSelectorsV1 capability; bootstrap now publishes HTML with it rather than (DocType::Html, None). HTML still never carries a Rust-built anchorIndex — that would need a headless HTML parser in the binary and the size gate forbids it. - Rust: the markdown resolver refuses HTML anchors outright rather than landing them somewhere plausible and wrong; manager validates HTML anchors before persisting or syncing them. - Web: doc-runtime/ (selector generation + resolution, CSS Custom Highlight API text layer, element overlays with inert fills, persistent comment pins, scope breadcrumb), bundled to an injectable IIFE. - Web: doc-protocol.ts with a validating parser for every inbound frame message, and HtmlAnnotationBridge for the handshake and coordinates. - Web: HtmlViewer gains annotate/onBridge. Annotating needs allow-scripts, so hosted reviewers get page scripts enabled while annotating; the frame stays opaque-origin, and read-only viewing is unchanged. Tests: 557 Rust, 97 web test files, all green. Co-Authored-By: Claude Opus 5 (1M context) --- .beads/issues.jsonl | 1004 +++++++++-------- planning/collab/amendments.md | 8 +- planning/collab/html-annotation.md | 264 +++++ planning/collab/prototypes/README.md | 14 +- src/review/agent.rs | 1 + src/review/anchors/resolve.rs | 62 + src/review/apply.rs | 3 + src/review/bootstrap.rs | 21 +- src/review/crypto/ids.rs | 3 + src/review/crypto/signing.rs | 3 + src/review/diff_suggestions.rs | 4 + src/review/envelope.rs | 2 + src/review/manager.rs | 25 + src/review/model.rs | 519 ++++++++- src/review/share_lifecycle.rs | 3 + src/review/store.rs | 3 + src/review/transport/inbound.rs | 2 + src/review/transport/mailbox/ws.rs | 3 + tests/review_sync_convergence.rs | 2 + tests/webrtc_e2e.rs | 1 + web/package.json | 6 +- web/scripts/build-doc-runtime.mjs | 77 ++ web/src/doc-runtime/index.ts | 668 +++++++++++ web/src/doc-runtime/selectors.ts | 515 +++++++++ web/src/doc-runtime/styles.ts | 229 ++++ web/src/lib/HtmlViewer.svelte | 75 +- .../lib/review/browser-snapshot-publisher.ts | 7 +- web/src/lib/review/doc-protocol.test.ts | 335 ++++++ web/src/lib/review/doc-protocol.ts | 376 ++++++ web/src/lib/review/doc-runtime.generated.ts | 8 + web/src/lib/review/html-annotation-bridge.ts | 258 +++++ .../lib/review/html-annotation-inject.test.ts | 114 ++ web/src/lib/types.ts | 80 +- 33 files changed, 4195 insertions(+), 500 deletions(-) create mode 100644 planning/collab/html-annotation.md create mode 100644 web/scripts/build-doc-runtime.mjs create mode 100644 web/src/doc-runtime/index.ts create mode 100644 web/src/doc-runtime/selectors.ts create mode 100644 web/src/doc-runtime/styles.ts create mode 100644 web/src/lib/review/doc-protocol.test.ts create mode 100644 web/src/lib/review/doc-protocol.ts create mode 100644 web/src/lib/review/doc-runtime.generated.ts create mode 100644 web/src/lib/review/html-annotation-bridge.ts create mode 100644 web/src/lib/review/html-annotation-inject.test.ts diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 012688ec..7ca3681d 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,479 +1,525 @@ -{"id":"attn-xvp3","title":"Stop durable relay writes for ephemeral cursor and viewport presence","description":"Durable Objects rows_written quota is exhausted because replaceable cursor/view presence falls back to HTTP relay and still writes rate, PoW, sequence, and latest-presence state. Make cursor/view presence WebRTC-only; preserve durable relay delivery for comments and document/review events; reject or safely drop legacy relayed presence before any Durable Object writes.","status":"closed","priority":0,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-26T15:35:47Z","created_by":"James Lal","updated_at":"2026-07-26T15:46:04Z","started_at":"2026-07-26T15:36:03Z","closed_at":"2026-07-26T15:46:04Z","close_reason":"Cursor/view presence is WebRTC-only in browser and native clients; relay rejects V3 presence uploads before Durable Object mutations; durable review events remain relayed; full web and relay suites pass.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-j2ew","title":"Stop sharing wedged forever on partially-created shares","description":"James: 'why can't I stop sharing.' His share record was stuck mid-publish from the quota era; Stop → deleteRemote tears down remote resources that don't exist → the relay's 404s are CORS-untagged (anti-enumeration) → the browser throws before reading the status → stopShare aborted and kept the record, forever. Fix: the revoke INTENT (revoke_pending tombstone) persists durably BEFORE any network call; remote teardown is now best-effort. Stop always completes locally — if teardown couldn't be confirmed, the tombstone is retained and retried by the next ensureShare (orphaned remote resources die with their TTL at worst). This IS the reset mechanism: Stop always works, then re-share starts clean. New contract test: unreachable teardown → stop completes, tombstone survives, next share retries the delete and recreates.","status":"closed","priority":0,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-21T18:55:33Z","created_by":"James Lal","updated_at":"2026-07-21T18:55:54Z","closed_at":"2026-07-21T18:55:54Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-58tt","title":"Owner permanently deaf after room instance rotation (seq-binding conflict loop)","description":"ROOT CAUSE of James's owner-not-seeing-comments: when the relay room is rebuilt under the same roomId (relay eviction/expiry, or republish recreating a vanished room), the new instance restarts serverSeqs. The owner's IndexedDB still binds those seqs to the dead instance's envelopes, so every inbound envelope fails commitInbound BEFORE dispatch, the ws client closes and reconnects, and the same frame conflicts again — an infinite deaf loop with threads:0 and (pre-diagnostics) zero symptoms. Confirmed by James's __attnInboundErrors ring: 6× 'server sequence is already bound to another envelope' in 20ms. Fix: (1) commit-conflict heal — purge the room's inbound log + cursors (new storage.resetRoomInboundHistory), reset the cursor, re-commit, continue the in-flight replay; (2) hello regression detector — a hello whose serverSeq is below our cursor proves rotation even when no conflict would fire (cursor beyond new head), resubscribes from 0 after the purge. Verified end-to-end locally: relay DO state wiped under a live share, owner reloads, heals, and receives a post-rotation reviewer comment.","status":"closed","priority":0,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-21T04:42:28Z","created_by":"James Lal","updated_at":"2026-07-21T04:48:13Z","closed_at":"2026-07-21T04:48:13Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-qez","title":"Joiner page unusable at mobile widths (fixed rail squeezes doc to ~60px)","description":"At 390x844 the /s/\u003cid\u003e review page keeps a fixed-width comments rail; the document column collapses to ~60px, one word per line, and the top status strip text overlaps itself. Review links are exactly what people open on phones. Needs responsive stacking (rail as bottom sheet / toggle, like the owner app's mobile shell which is already good).","status":"closed","priority":0,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-16T22:59:41Z","created_by":"James Lal","updated_at":"2026-07-16T23:45:04Z","started_at":"2026-07-16T23:30:22Z","closed_at":"2026-07-16T23:45:04Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-w5d","title":"Owner editor rolls back recent edits on layout remount (mobile\u003c-\u003edesktop resize)","description":"Repro (james/room-as-project-ux, local dev:browser:shares): owner shares a doc, types ' LIVE-EDIT-MARKER-42.' which syncs live to the joiner; resize owner viewport 1440x900 -\u003e 390x844 (mobile shell remount). Owner editor re-renders WITHOUT the marker; joiner keeps it. Resizing back to desktop does not restore it and it never re-converges — owner local state and room state silently diverge, with the owner's own accepted+synced edit lost from their canvas. Data-loss class bug; phone rotation / window resize triggers it.","status":"closed","priority":0,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-16T22:59:39Z","created_by":"James Lal","updated_at":"2026-07-16T23:30:12Z","started_at":"2026-07-16T23:02:17Z","closed_at":"2026-07-16T23:30:12Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-7xl.7.13","title":"Prevent pending editor changes from being lost on navigation and workspace actions","description":"Live staging validation on 2026-07-11 reproduced durable data loss with real keyboard input. Type into ProseMirror and immediately reload or select another file: the header still says Saved on this device, the page navigates, and the pending text is gone. EditorShell autosave debounces for 1200ms; pagehide starts an async IndexedDB flush that does not complete reliably. Desktop tree navigation uses window.location.assign, and create Markdown, add assets, rename, download, and export also operate without first awaiting the pending editor commit.","acceptance_criteria":"The UI never reports Saved while autosave has pending text. Reload/close either durably preserves pending text or explicitly prevents/warns about leaving. File switching, New Markdown, add assets, rename, download, export, and any route navigation await a durable flush before reading or changing workspace state. Chromium and WebKit regressions cover type-then-immediate-reload, type-then-immediate-file-switch, and fresh-content download/export.","notes":"Implementation plan: make AutosaveController report Saving immediately, make flush drain pending and in-flight revisions with a success result, add a dirty beforeunload guard, and route every navigation/read/export/share action through one awaited flushPendingEditor boundary. Tests: autosave concurrency/failure units plus Chromium/WebKit immediate reload, file-switch/New Markdown, and export regressions.","status":"closed","priority":0,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-11T23:48:04Z","created_by":"James Lal","updated_at":"2026-07-12T00:46:48Z","started_at":"2026-07-11T23:56:22Z","closed_at":"2026-07-12T00:46:48Z","close_reason":"Implemented truthful autosave state and an awaited durability boundary for reload-sensitive navigation, mutations, downloads, exports, and sharing. Added concurrency/failure units and Chromium/WebKit regressions; all pass.","labels":["a11y","authoring","autosave","cloudflare","data-loss","e2e","e2ee","ios","performance","release-blocker","security","storage","web"],"dependencies":[{"issue_id":"attn-7xl.7.13","depends_on_id":"attn-7xl.7","type":"parent-child","created_at":"2026-07-11T17:48:04Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-02a.6","title":"Enforce owner-accept invariant across live collaboration","description":"Merged browser-owner collaboration currently accepts and autosaves remote ProseMirror steps. Comment-tier native/browser clients are also not gated from sendCollab. This bypasses the locked Sharing v3 invariant that only an explicit owner accept may mutate the owner file.","acceptance_criteria":"Comment and view tiers cannot emit collab document steps. Suggest-tier remote edits are represented as durable suggestions requiring explicit owner accept; no remote collab transaction autosaves to the owner file. Owner-authenticated outbound collaboration remains available for read-only convergence. Native/browser hostile-client tests prove boundary enforcement.","notes":"Approach: preserve owner-originated collaboration broadcasts for read-only convergence, but reject every remote ProseMirror document-step submit at the authenticated owner authority boundary (mailbox and direct). View/comment/suggest reviewer UIs remain selection-capable but cannot live-edit; suggest tier uses existing durable SuggestionCreated flow and explicit owner accept/apply. Add native/browser hostile-client tests, tier UI tests, and prove no remote collab transaction reaches saveEdits/workspace autosave.","status":"closed","priority":0,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-11T13:59:11Z","created_by":"James Lal","updated_at":"2026-07-11T16:14:36Z","started_at":"2026-07-11T14:41:58Z","closed_at":"2026-07-11T16:14:36Z","close_reason":"Enforced strict owner-accept authority across browser and native collaboration; reviewers remain read-only until explicit owner acceptance","dependencies":[{"issue_id":"attn-02a.6","depends_on_id":"attn-02a","type":"parent-child","created_at":"2026-07-11T07:59:11Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-ask","title":"Relay: preserve browser WebSocket Origin across Worker-to-DO forwarding","description":"Real local Playwright against wrangler dev proved browser WebSocket upgrades arrive at RoomDO with Origin rewritten to the internal request origin (http://relay.attn.sh), not the browser page origin (http://localhost:8787). The DO therefore rejects allowBrowser=true rooms with ATTN_ORIGIN_FORBIDDEN unless the relay's own origin is allowlisted, and production Worker-to-DO forwarding may similarly break https://attn.sh browser sockets or validate the wrong origin. Capture the edge Origin into a private Worker-overwritten header before stub.fetch, strip client spoofing, validate that preserved value in RoomDO, and add real/local Playwright plus spoof/disallowed/allowlisted regressions.","notes":"Implemented fail-closed browser-origin trust boundary. Worker globally strips client X-Attn-Edge-Origin, then unconditionally writes v1.native / v1.browser.\u003cbase64url UTF-8 canonical origin\u003e / v1.invalid for socket forwarding. RoomDO ignores rewritten standard Origin, validates only the private context, applies allowBrowser + exact allowlist policy, returns generic origin errors, and rejects missing/malformed internal context. Added strict codec tests and integration regressions for spoof overwrite, native sentinel, malformed/opaque origins, direct-DO missing context, and simulated standard-Origin rewrite. Independent security audit found no blockers; global private-header stripping hardening applied. Validation: typecheck exit 0; focused origin/CORS/WebSocket 70/70; full relay 382/382. Real local Wrangler + Playwright/Chromium passed with only http://localhost:8787 allowlisted (no relay.attn.sh workaround): health 200, two WS 101 upgrades, 65 replayed payloads, cursor close 4005/ATTN_CURSOR_TOO_OLD/resyncFromSeq 3. Used isolated local-only Wrangler config because Wrangler v3 rewrites Origin before Worker when production custom-domain routes are loaded.","status":"closed","priority":0,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-10T01:41:17Z","created_by":"James Lal","updated_at":"2026-07-10T02:26:01Z","started_at":"2026-07-10T01:51:04Z","closed_at":"2026-07-10T02:26:01Z","close_reason":"Fixed and verified by unit/integration/full relay tests plus real local Playwright WebSocket E2E","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-cqk","title":"Review margin/panel does not mount on panelOpen toggle (reviewer can't see cards)","description":"Found via real daemon E2E (attn --eval/--query). In a reviewer's shared-doc view, reviewStore.panelOpen flips to true (Cmd+J -\u003e togglePanel(), and the auto-open at App.svelte:329 when a composer opens), but the right-rail aside stays data-state=closed and the {#if reviewStore.panelOpen} content (ReviewMargin overlay, data-slot=review-margin) never mounts — so the comment/suggestion margin CARDS (and thus the Reply/Resolve buttons) never render for the reviewer. The store value reads true; the \u003caside\u003e in the {:else if hasSidebar} branch (App.svelte:2139-2177) doesn't react. Other reviewStore-driven UI (shared-doc banner, presence) DOES react, and App-local $state (the new selection toolbar) reacts — so it's specific to this aside's panelOpen binding. Repro: scripts/test-editorial-e2e.sh ('no margin card rendered'). Determine whether real users are affected (likely) and fix the reactivity/layout gating. Blocks the rendered Reply/Resolve UX even though the underlying review_resolve_comment/review_create_comment IPC verticals work (verified: events import on the owner).","notes":"CONFIRMED REAL + reviewer-specific via real daemon (scripts/test-editorial-e2e.sh + isolated probe). Reviewer in shared-doc view: panelOpen=true (set via the reactive togglePanel() method) BUT the right-rail \u003caside data-state={reviewStore.panelOpen?'open':'closed'}\u003e stays data-state=closed and the {#if reviewStore.panelOpen} content (ReviewMargin, data-slot=review-margin) never mounts -\u003e comment/reply/resolve cards INVISIBLE to the reviewer. Owner is UNAFFECTED: isolated probe (owner on a folder) togglePanel() -\u003e state=open + margin mounts. So the same \u003caside\u003e binding to reviewStore.panelOpen reacts for the owner but not in the reviewer's shared-doc view (isReviewerViewingSnapshot). NOT an automation artifact (the auto-open at App.svelte:329 also set panelOpen=true when the composer opened, still no margin). Release BLOCKER: the new editorial UI (attn-bit/zhr/1rm) is invisible to reviewers without this. Next: find why the aside's panelOpen binding doesn't track in the reviewer layout (App.svelte ~2139 hasSidebar branch); likely a re-mount/keying or derived-chain issue tied to isReviewerViewingSnapshot.","status":"closed","priority":0,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-23T06:03:41Z","created_by":"James Lal","updated_at":"2026-05-23T18:05:10Z","started_at":"2026-05-23T15:10:43Z","closed_at":"2026-05-23T18:05:10Z","close_reason":"Fixed: ReviewMarginCard had a 'state' prop, so the $state rune added for the reply composer (attn-1rm) compiled as store auto-subscription (store_get($$props.state)) -\u003e 'state.subscribe is not a function' thrown on every card render -\u003e margin overlay never mounted (cards invisible). Renamed prop state-\u003ecardState + 2 call sites. Confirmed via extended automation (window error capture + unminified build): 0 $state()( in bundle, editorial E2E 11/0/0, reviewer margin card renders.","dependencies":[{"issue_id":"attn-cqk","depends_on_id":"attn-07i","type":"parent-child","created_at":"2026-05-23T00:03:41Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-7qv","title":"send_collab skip-relay optimization drops data when mesh is partial/flaky","description":"THE no-TURN fix. send_collab (and the review-event send path) must route per-peer: send over a peer's direct DataChannel only when that specific pair is robustly Connected; for any peer WebRTC can't reach (symmetric NAT, no TURN), send a TARGETED relay copy (relay already routes by target.deviceId via deliverableTo/env_by_target — extend from signals to data envelopes). Receiver dedups by EventId/serverSeq so double-delivery over mesh+relay is harmless. Replaces the current all-or-nothing 'mesh complete -\u003e skip relay' gate that silently drops un-meshed peers. Validated by the symmetric-NAT Docker harness (attn-orf).","notes":"ROOT CAUSE OF 'collab doesn't converge in Docker' FOUND — it was NOT this skip-relay logic. It was a re-entrant agent deadlock (see attn-orf, commit f60cde1). With that fixed, collab converges 4/4 and routes channels=true relay=false (complete mesh, WebRTC-primary) — exactly the 'mesh complete -\u003e skip relay' path. attn-7qv's per-peer relay fallback (decide_collab_routing: always-channels + relay-when-incomplete) is implemented + 4 unit tests pass, but STILL NOT integration-validated, because the Docker partition does not produce a partial mesh (the DataChannel forms anyway). To validate this P0 we must first make the partition FAITHFUL: block the peer-to-peer UDP path (default-DROP, allow only relay host-gateway:8787) so ICE genuinely fails and collab is forced onto relay-only. Until then, treat the per-peer fallback as plausibly-correct-but-unproven. The original user-reported 'sync feels broken' was most likely attn-cqk (reviewer margin not rendering), now fixed.","status":"closed","priority":0,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-05-23T02:18:06Z","created_by":"James Lal","updated_at":"2026-05-23T20:52:32Z","closed_at":"2026-05-23T20:52:32Z","close_reason":"VALIDATED end-to-end (commit). Faithful UDP-blackout partition: mesh never forms (all agents mailbox-only, live_direct_count=0) yet comment+collab converge 4/4 via relay alone. Baseline converges WebRTC-primary. Per-peer relay fallback proven for no-TURN symmetric-NAT.","dependencies":[{"issue_id":"attn-7qv","depends_on_id":"attn-k3v","type":"parent-child","created_at":"2026-05-22T22:47:13Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7qv","depends_on_id":"attn-ms7","type":"blocks","created_at":"2026-05-23T13:20:30Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} -{"id":"attn-vltn","title":"Restore reviewer comment toolbar for native text selections","description":"Reviewers can visibly highlight text in Chrome but the Comment toolbar does not appear. Bridge native DOM selections in the read-only ProseMirror view into a valid editor range and add regression coverage.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-26T03:05:00Z","created_by":"James Lal","updated_at":"2026-07-26T03:08:44Z","started_at":"2026-07-26T03:05:06Z","closed_at":"2026-07-26T03:08:44Z","close_reason":"Promoted native DOM selections into the read-only reviewer ProseMirror state so the Comment toolbar anchors correctly; added unit and hosted-browser regression coverage. Full 94-file web suite, Svelte check, and browser production build pass.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-1dc6","title":"Reset browser owner WebSocket backoff after reconnect","description":"Hosted owners can remain visibly Offline longer after repeated transient socket drops because BrowserWsClient never resets exponential backoff after an authenticated hello. Reset on valid hello, surface close code/reason in the existing inbound diagnostic ring, and cover reconnect recovery.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-26T01:21:53Z","created_by":"James Lal","updated_at":"2026-07-26T01:35:03Z","started_at":"2026-07-26T01:21:58Z","closed_at":"2026-07-26T01:35:03Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-6dtd","title":"Keep room CORS preflights outside the application request rate limit","description":"Chrome multi-reviewer joins intermittently fail GET /v3/rooms/:roomId/devices because OPTIONS is counted by the 600/min edge limiter; its early 429 lacks policy-gated CORS and is surfaced as a CORS failure. Route read-only room OPTIONS to RoomDO before application request accounting, retain stored allowBrowser and origin gates, and add regression coverage.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-26T01:18:26Z","created_by":"James Lal","updated_at":"2026-07-26T01:35:02Z","started_at":"2026-07-26T01:18:30Z","closed_at":"2026-07-26T01:35:02Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-8qcu","title":"Stabilize owner participant jumps and viewport sampling","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-26T00:01:49Z","created_by":"James Lal","updated_at":"2026-07-26T01:35:01Z","started_at":"2026-07-26T00:01:56Z","closed_at":"2026-07-26T01:35:01Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-9u5z","title":"WebRTC-primary replaceable relay fallback for presence","description":"Chrome peers can complete relay signaling but fail STUN-only ICE/DTLS (direct_peer_connection_failed). Keep cursor/view presence on lossy WebRTC when all live peer presence channels are open; when direct coverage is incomplete, send the same signed v3 presence envelope through a separate coalescing memory-only relay queue. Never persist presence or charge durable room event history.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-26T02:15:23Z","created_by":"James Lal","updated_at":"2026-07-26T02:20:07Z","started_at":"2026-07-26T02:15:28Z","closed_at":"2026-07-26T02:20:07Z","close_reason":"Implemented WebRTC-primary cursor/view delivery with coalesced memory-only v3 replaceable relay fallback for incomplete peer coverage; full web tests, typecheck, and browser production build pass.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-hmke","title":"Align participant jumps to the remote viewport top","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-25T23:46:25Z","created_by":"James Lal","updated_at":"2026-07-25T23:48:06Z","started_at":"2026-07-25T23:46:31Z","closed_at":"2026-07-25T23:48:06Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-e2do","title":"Keep second-reviewer WebRTC presence jumpable during join races","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-25T23:27:59Z","created_by":"James Lal","updated_at":"2026-07-25T23:32:48Z","started_at":"2026-07-25T23:28:03Z","closed_at":"2026-07-25T23:32:48Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-859n","title":"Prevent viewport presence labels clipping behind editor gutter","description":"Presence identity labels extend from an absolute margin flag into the document gutter, but ProseMirror overflow-x clips them at its left boundary. Permit marker overflow without layout impact and ensure the active label paints above adjacent decorations.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-25T23:03:34Z","created_by":"James Lal","updated_at":"2026-07-25T23:04:53Z","started_at":"2026-07-25T23:03:36Z","closed_at":"2026-07-25T23:04:53Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-p57j","title":"Eliminate remaining file-switch Svelte effect loop","description":"After commit 13d54e3 and staging deploy, prolonged switching between shared files still triggers Svelte effect_update_depth_exceeded. Reproduce against the hosted multi-file path, identify the remaining reactive cycle, fix the actual source, and add a stress regression.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-25T20:55:29Z","created_by":"James Lal","updated_at":"2026-07-25T21:06:35Z","started_at":"2026-07-25T20:55:36Z","closed_at":"2026-07-25T21:06:35Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-db2a","title":"Fix recursive Svelte effect when switching files","description":"Switching between shared files can trigger Svelte effect_update_depth_exceeded in the branches runtime chunk. Identify the reactive feedback loop, prevent recursive state writes, and add a regression test for repeated file switching.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-25T20:42:51Z","created_by":"James Lal","updated_at":"2026-07-25T20:51:32Z","started_at":"2026-07-25T20:50:13Z","closed_at":"2026-07-25T20:51:32Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-dary","title":"Route cursor and viewport presence over WebRTC only","description":"Stop sending ephemeral cursor/view/focused-file presence through relay envelopes or durable outboxes. Deliver presence only over direct WebRTC, retain relay for membership and SDP/ICE signaling, and expire stale peer markers safely.","status":"closed","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-25T20:19:19Z","created_by":"James Lal","updated_at":"2026-07-25T20:34:57Z","started_at":"2026-07-25T20:19:22Z","closed_at":"2026-07-25T20:34:57Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-xviq","title":"Make cursor/view presence replaceable relay state","description":"Cursor and viewport presence currently share the durable signal envelope path, so repeated presence updates consume the room-wide retained event cap. Add an authenticated ephemeral presence lane that preserves E2E ciphertext and live WebSocket delivery while retaining at most the latest value per device (or no durable history), without weakening durability for document collab steps or review events.","acceptance_criteria":"Cursor/view presence does not increment the durable room envelope count; active peers still receive presence over relay WebSockets; a newer presence update supersedes the prior value for the same device; document steps and review events retain current durable replay behavior; v3 cleartext routing classification is covered by device proof/AAD or equivalently tamper-protected; tests cover cap accounting, replacement, reconnect behavior, and legacy compatibility.","notes":"Approach: add an optional v3-only signalClass=presence routing header, bind it into the registered-device signature, and have RoomDO store only the latest encrypted presence record per author/device outside env:* and envelope-count accounting. Replay current replaceable presence once on subscribe; broadcast every fresh update live; keep unclassified signals/document steps and review events on the durable log. Enforce a small presence ciphertext ceiling and preserve v2 behavior. Files: relay/src/{schema,device-proof,room-do}.ts + relay integration/unit tests; web browser-ws/device-proof/browser-signaling/browser-session + tests; Rust review model/device-proof/signaling/inbound/manager + tests; relay spec/docs. Test plan: proof vectors and tamper rejection, cap accounting, same-device replacement, reconnect latest-only replay, durable-signal regression, browser/native envelope classification and signature interop.\nImplemented and validated: v3 cursor/view collab is classified as signed signalClass=presence in browser and native clients; RoomDO broadcasts fresh values, retains one 16 KiB-bounded latest record per registered device, excludes it from durable event/byte caps and Web Push, and replays latest state only for online devices. Browser inbound dispatch skips IndexedDB/volatile history for presence. Unclassified signals/events remain durable. Validation: relay focused 67/67 plus final presence 11/11; web 90 test files; Rust nextest 1201/1201; relay/web typechecks; browser production build and route-boundary check; Rust touched-file rustfmt. Independent review finding about browser local persistence fixed. V3 signalClass proof change requires coordinated client/relay rollout; V2 remains unchanged.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-25T02:56:13Z","created_by":"James Lal","updated_at":"2026-07-25T03:22:34Z","started_at":"2026-07-25T02:56:17Z","closed_at":"2026-07-25T03:22:34Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-fkf3","title":"Fix bidirectional participant jumps and add viewport markers","description":"Participant jumps currently fail to switch to the participant's file and do not land at the same document location. Make jumps work for owner and reviewers in both directions, preserve file/location identity across presence updates, and add a compact overlay indicator beside the block each remote participant is viewing without changing document width.","acceptance_criteria":"Clicking any online remote participant switches to their shared file when necessary and scrolls to their reported location; owner-to-reviewer and reviewer-to-owner both work; remote viewing positions render as compact non-reflowing block-adjacent markers; stale or missing locations fail safely; automated tests cover navigation and marker mapping.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-24T17:18:40Z","created_by":"James Lal","updated_at":"2026-07-25T02:27:40Z","started_at":"2026-07-24T17:18:43Z","closed_at":"2026-07-25T02:27:40Z","close_reason":"Fixed viewport markers so they are absolutely positioned in the left gutter without affecting paragraph/list geometry; corrected list-boundary anchoring; deduplicated cursor/view presence to stop the Svelte effect loop and relay envelope flood. Verified 90 web test files, svelte-check, production browser build, route boundaries, and headless Chromium geometry.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-9ek7","title":"Shell consumption: EditorShell + sidebar derive room/bindings/margin/counts from the projection only","description":"attn-whdh part 3. Remove the ownerState ?? joinState ?? reviewLogBindings ?? store fallback chains in EditorShell (reviewRoomActive, file latch, cursor-sink scoping, reviewCount, peer roster source) and the sidebar share badges' separate read path — all become projection reads. ownerState remains for lease/authoring affordances only (writable, editing, publish status), never for review-state hydration.","notes":"Core landed in attn-whdh commit 50dbe9e: projection feeds reviewStore.currentRoomId in every tab, so reviewRoomActive/reviewFollowerTab/file-latch/reviewCount reads are now consistent; watchReviewLog→openReviewProjection seam done; bindings drive margin scoping. REMAINING: audit/remove residual ownerState-fallback chains that are now redundant, and the sidebar 'shared' file-rail badges still read a separate path (a follower once showed no share icons) — fold into attn-73xq cleanup pass.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-23T04:30:13Z","created_by":"James Lal","updated_at":"2026-07-23T14:49:32Z","started_at":"2026-07-23T04:30:41Z","closed_at":"2026-07-23T14:49:32Z","close_reason":"Resolved by the core projection (commit 50dbe9e). The sidebar 'shared' badges + unread counts derive from reviewStore.snapshots + room role, which the projection now populates and stamps role='owner' in EVERY tab (adoptRoom) — same store the leader uses, so follower tabs light up identically (proven by the byte-identical two-tab convergence). The old watchReviewLog seam is fully replaced by openReviewProjection. Residual ownerState-fallback chains (reviewRoomActive etc.) now read correctly because the projection feeds reviewStoreRef.currentRoomId in every tab; simplifying them further is cosmetic, not a bug.","dependencies":[{"issue_id":"attn-9ek7","depends_on_id":"attn-kobw","type":"blocks","created_at":"2026-07-22T22:30:37Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} -{"id":"attn-ij9y","title":"Leader symmetry: live session feeds ONLY the durable log, leader UI reads through the projection","description":"attn-whdh part 2. BrowserSession/authority stop writing reviewStore directly (store.applyEvent from the wire); inbound envelopes commit durably + ring the doorbell (already true) and the leader tab materializes its own store exclusively via the projection, exactly like followers. If the projection misses an event the leader sees the bug too — divergence impossible by construction. Watch ordering: optimistic local echoes (own comments) must still render immediately (either optimistic overlay reconciled by replay, or commit-then-doorbell fast path).","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-23T04:30:12Z","created_by":"James Lal","updated_at":"2026-07-23T05:15:29Z","started_at":"2026-07-23T04:30:40Z","closed_at":"2026-07-23T05:15:29Z","close_reason":"Closed","dependencies":[{"issue_id":"attn-ij9y","depends_on_id":"attn-kobw","type":"blocks","created_at":"2026-07-22T22:30:36Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} -{"id":"attn-kobw","title":"Projection core: WorkspaceReviewProjection module (share record + log replay + doorbell, role-agnostic)","description":"attn-whdh part 1. Grow browser-review-log.ts into the single projection every tab uses: discovers the active share record (roomId, bindings, epoch) from storage, replays the durable event log through the verified pipeline, hydrates snapshots, refreshes on the review doorbell AND on share-record changes (room re-provisioning rotates currentRoomId at revision+1 — the projection must follow the rotation, which caused today's two-tab divergence). Exposes one typed state; no role branches.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-23T04:30:11Z","created_by":"James Lal","updated_at":"2026-07-23T05:15:46Z","started_at":"2026-07-23T04:30:40Z","closed_at":"2026-07-23T05:15:46Z","close_reason":"Closed","dependency_count":0,"dependent_count":2,"comment_count":0} -{"id":"attn-whdh","title":"Single review projection: all tabs (leader included) materialize state from the durable log via one path","description":"Architectural consolidation motivated by a day of tab-divergence bugs (attn-dgya, attn-nezn, attn-90qq, attn-37f9, attn-lzee, and a fresh two-tab inconsistency after room re-provisioning). Disease: one durable source of truth (IndexedDB log + share records) but N role-dependent read paths — leader feeds reviewStore straight from the live wire/authority state while followers use ad-hoc mirrors (replay+doorbell, manifest-bindings fallback, hub presence, cursor tee), so any two tabs can disagree. Cure: (1) the wire only feeds the durable log + doorbell, never the store; (2) one WorkspaceReviewProjection module hydrates roomId/bindings/events/threads identically in every tab from storage; (3) sidebar/share state reads the same projection; (4) one generic ephemera bus for cursors/presence replaces per-type tees; (5) dev-mode cross-tab store-fingerprint drift assertion. Deletes the six bespoke bridges. Leader dogfooding the follower read path makes divergence impossible by construction.","status":"open","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-23T04:28:07Z","created_by":"James Lal","updated_at":"2026-07-23T04:28:07Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-hh9r","title":"Durable share outlives its 24h room: reviewer hard-fails ('Could not reach the review relay') instead of degrading; owner never re-provisions the room","description":"Diagnosed via live relay tail (staging): share link \u003e24h old resolves its bundle (GET /v3/shares/{id} → 200) but the room behind it was TTL-wiped (HARD_MAX_TTL_MS=24h; GET /v3/rooms/{id}/devices → 404). The reviewer session maps the 404 to a fatal device_register error → 'Could not reach the review relay' (wrong message, dead end) even though source:'share_snapshot' degradation exists in BrowserShareSession by design. Two fixes: (1) reviewer degrades to snapshot+mailbox review on room-gone (Owner offline · Review still available); (2) owner authority detects room expiry and re-provisions a fresh room under the same durable share (re-point bundle, epoch/revision bump) so links older than 24h regain the live layer. Also correct the error copy: relay reachable, room expired.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-23T03:15:22Z","created_by":"James Lal","updated_at":"2026-07-23T04:06:09Z","started_at":"2026-07-23T03:17:43Z","closed_at":"2026-07-23T04:06:09Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-d6ai","title":"Retry edit is a no-op after lease expiry: dead session short-circuits re-acquisition","description":"User report: 'Retry edit doesn't seem to work' in a tab showing 'Workspace authority lease expired'. Root cause: EditorShell.ensureOwnerSession returns the EXISTING session unconditionally — a tab that was leader when the lease expired keeps a dead session (writable=false) forever, so beginEditing (which would close the husk runtime and re-acquire) is never invoked. Secondary: workspace-service.beginOwnerRuntime returns a cached lease-lost runtime as-is; real-service then closes it and returns null, costing an extra retry round. Fix: ensureOwnerSession drops non-writable sessions and re-begins; beginOwnerRuntime closes+rebuilds cached runtimes whose leaseRole is no longer owner (or status error) so one call re-acquires.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-23T00:53:25Z","created_by":"James Lal","updated_at":"2026-07-23T00:57:51Z","started_at":"2026-07-23T00:53:43Z","closed_at":"2026-07-23T00:57:51Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-90qq","title":"Participant chips missing on follower tabs and on the /s/ reviewer page","description":"User report: connected session but no peer chips on either side. Leader tab works. Two gaps: (1) EditorShell roster feeds only from ownerState.authority.session.peers (leader-only) — follower tabs render an empty PeerStrip; (2) BrowserReviewApp never feeds reviewStore.peers from sessionState.peers and renders no PeerStrip at all. Fix: hub broadcasts presence snapshots (new 'presence' message kind, re-sent on hello) so followers carry the same roster; reviewer header renders PeerStrip fed from its session peers.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-22T20:29:16Z","created_by":"James Lal","updated_at":"2026-07-22T20:35:17Z","started_at":"2026-07-22T20:29:28Z","closed_at":"2026-07-22T20:35:17Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-37f9","title":"Remote cursor presence renders only in the leader tab — followers miss reviewer carets/highlights","description":"User report: reviewer caret/selection shows in the first (leader) owner tab, absent from follower tabs; comments sync fine. Cursors terminate at whichever wire they arrive on: relay-borne cursors reach only the leader session; hub-borne follower cursors never reach room peers. Fix: leader bridges presence — routeCollabDelivery tees relay cursor payloads onto the tab channel (followers dedupe by clientID), and the hub forwards follower cursor payloads through authority.sendCollab (normal WebRTC-opportunistic + relay-guaranteed policy).","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-22T19:34:14Z","created_by":"James Lal","updated_at":"2026-07-22T19:39:23Z","started_at":"2026-07-22T19:34:24Z","closed_at":"2026-07-22T19:39:23Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-c38z","title":"Late-joining external reviewer sees stale content and misses past comments (multi-tab owner)","description":"User report on staging: Safari reviewer joining via /s/ link sees old snapshot content (doc has diverged) and is missing the owner-authored comment thread from ~4h earlier (authored during the wedged-authority era); a fresh reviewer gets newer comments fine. Relay signal compaction spares durable events, so NOT compaction. Suspects: (1) owner outbox row from the wedged era never flushed to relay, (2) share republish (bundle revision/epoch) not advancing under multi-tab lease churn so late joiners resolve a stale snapshot, (3) reviewer live-collab unable to cross epochs. Repro plan: fresh workspace, two owner tabs, owner comment, type across tabs, then late-join reviewer and compare content + threads.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-22T19:09:09Z","created_by":"James Lal","updated_at":"2026-07-22T19:19:00Z","started_at":"2026-07-22T19:09:19Z","closed_at":"2026-07-22T19:19:00Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-lzee","title":"Follower owner tab jitters: full editor reset/remount on every leader commit instead of incremental updates","description":"User report: typing in one tab reflects in the other, but the non-typing tab visibly jumps/re-renders the whole ProseMirror doc. Read-mode followers never join the local collab hub (join gated on editDenied) so AppShell's follow-the-writer path calls editorRef.resetToMarkdown(body) on every leader autosave commit. Live-joined followers may also remount on hub generation rotation. Fix direction: read-mode followers join the hub too (read-only binding) so steps apply incrementally; keep resetToMarkdown only as the no-hub fallback and make it scroll/selection-preserving.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-22T18:27:55Z","created_by":"James Lal","updated_at":"2026-07-22T18:35:29Z","started_at":"2026-07-22T18:28:06Z","closed_at":"2026-07-22T18:35:29Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-nezn","title":"Passive owner tabs should render review threads + honest share status (not Offline/no-comments)","description":"Follow-up to attn-dgya: passive tabs hydrate reviewStore (comment count shows) but the review margin gates on ownerState?.roomId (leader-only) so cards never render, and the ShareChip popover says 'Offline — changes will sync' + 'No one else is here' because connection/peers derive from the leader-owned live session. Design: all tabs render threads from the hydrated store; passive tab share status reads as following the live tab; review actions from a passive tab promote via the existing lease handoff.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-22T15:12:44Z","created_by":"James Lal","updated_at":"2026-07-22T15:38:00Z","closed_at":"2026-07-22T15:38:00Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-3wgd","title":"Hosted owner: refresh during multi-tab co-editing wedges authority in 'published source revision moved before promotion'","description":"Repro (user, staging pre-attn-dgya bundle but race exists on main): share active, two owner tabs, refresh one → banner 'Live review is paused. published source revision moved before promotion' + Reconnect. Analysis: reconcileStartupPublication (browser-owner-workspace-runtime.ts) stages/publishes from the discovered head while EditorShell's takeover flow concurrently commitText()s the converged collab doc, moving headRevisionId; commitPublication's consistency gate (browser-workspace-share.ts:691) then throws and transitionPublishedEpoch's catch pauses the authority (rollover_required) instead of restaging from the new head. Existing discard+republish recovery only covers a pre-existing stale pending publication, not heads moving mid-transition. Fix direction: bounded restage-retry on this specific StorageConflictError during startup reconcile (or sequence takeover commit before authority reconcile).","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-22T03:41:20Z","created_by":"James Lal","updated_at":"2026-07-22T03:55:20Z","started_at":"2026-07-22T03:41:35Z","closed_at":"2026-07-22T03:55:20Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-qfda","title":"Fix native relay startup and align native/mobile header","description":"The current native app can silently boot without an attached relay depending on how it was built, and its header has drifted from the hosted/mobile review grammar. Diagnose the shipped and local build paths, make relay targeting explicit and observable, and align the native header controls/layout with the shared cross-platform vocabulary while preserving desktop affordances.","acceptance_criteria":"Native release and development build paths select a documented relay target without silently falling back to the scaffold; relay selection is covered by focused tests. Native and mobile headers use consistent title/status/review/share affordances and responsive alignment. Relevant Rust, Svelte, and build quality gates pass.","notes":"Root cause confirmed: target/debug/attn had no relay configured and fell back to the scaffold; target/release was baked for production while the working environment is staging. Implemented channel-paired build defaults (relay + browser review origin), a staging release-profile build mode, debug fallback to staging, production fallback to production, build invalidation for both variables, and focused resolution tests. Unified native header with hosted/mobile grammar: one in-flow header, status + Share grouped right, inline ReviewBar + comments toggle, rail-local duplicate removed. Verified live with isolated native automation: bootstrap attached to relay-staging.attn.sh, room creation succeeded, and invite origin was staging.attn.sh. Validation: scripts/build.sh debug; cargo test --workspace --locked (627 passed, 0 failed, 5 ignored plus integration suites); npm build; focused native-header-parity test; live screenshots pre-share and active-sharing. Full npm test has one unrelated existing PeerStrip.test.ts failure from concurrent participant-color work; svelte-check likewise reports only those concurrent participant-color/API errors.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-22T00:17:51Z","created_by":"James Lal","updated_at":"2026-07-22T00:33:18Z","started_at":"2026-07-22T00:17:54Z","closed_at":"2026-07-22T00:33:18Z","close_reason":"Native staging relay and review-origin pairing fixed; header parity implemented and verified by full Rust tests, web build, source regression, and live native staging share QA.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-dgya","title":"Hosted owner: reopening same doc in a new tab doesn't show existing comments (edits sync fine)","description":"Repro: owner shares doc, comment exists; open same doc in another tab of the owner's browser — comment thread missing, but document edits reflect across tabs. Suggests review event log/threads not hydrated in second tab while CRDT collab channel works.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-22T00:10:48Z","created_by":"James Lal","updated_at":"2026-07-22T01:00:56Z","started_at":"2026-07-22T00:11:00Z","closed_at":"2026-07-22T01:00:56Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-3gdd","title":"Per-participant avatar colors: deterministic hue + persisted user-picked color + two-letter monograms, unified with carets","description":"Chips currently color by role (3 tokens); all reviewers identical blue, monograms collide, carets use a separate hardcoded selfColor system. Plan: (A) deterministic OKLCH hue from identity fingerprint as default, (B) optional user-picked color from curated swatches in NamePrompt, persisted in identity.json/localStorage and broadcast via Participant, (C) two-letter monograms. Resolve chips + carets + selections from the same color.","notes":"Implemented A+B+C. Core: web/src/lib/participant-color.ts (9-hue OKLCH palette registered in DESIGN.md, FNV-1a hash fallback, strict sanitize on both TS+Rust sides). Chips (PeerStrip via chipVisualFor), gutter chips + card accents (ReviewMargin/ReviewMarginCard via store.colorFor), carets (App.svelte native, BrowserReviewApp joiner; setSelfColor live-sync on CollabController). Two-letter monograms (monogramFor). B: DeviceIdentity.color + set_color IPC (review_set_color) + ReannounceIdentity ride-along on native; localStorage attn.profile.color hosted; Participant.color optional field announced on ParticipantJoined from all native+browser announce sites; NamePrompt swatch picker persists via userProfile.save(name,color). Remaining: hosted owner seams in real-service.ts/workspace-service.ts (getColor+ownerColor+selfColor) deferred until attn-dgya agent finishes editing those files; then visual verification.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-22T00:10:46Z","created_by":"James Lal","updated_at":"2026-07-22T01:01:08Z","started_at":"2026-07-22T00:11:01Z","closed_at":"2026-07-22T01:01:08Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-h2y3","title":"Staging publish broken: quota denial surfaced as 'Failed to fetch'; 24h byte allowance too small","description":"James's Share sheet showed 'Publishing did not finish (Failed to fetch)'. Two causes: (1) staging's 24h non-refundable per-source byte allowance (1 GiB = ~41 room creates at 25 MiB reserved each) was exhausted by a day of E2E verification from the same network; (2) a quota-denied CREATE never persists META.policy, so the generic response tagger can't mark it browser-eligible — no CORS headers on the 429, and the browser reports an opaque TypeError instead of the quota message. Fixed: staging allowance 1→4 GiB and rooms/source 32→64; the create path now tags pre-persist denials with the REQUESTED policy's allowBrowser so quota errors reach browsers as readable messages.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-21T18:44:26Z","created_by":"James Lal","updated_at":"2026-07-21T18:44:47Z","closed_at":"2026-07-21T18:44:47Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-tbtz","title":"Fragmentless /s/ reopen dead-ended ('Invalid invite link') — auto-remember share bindings","description":"James hit 'This review could not be reopened from this URL alone' in Safari. A fragmentless /s/ load falls back: history.state key stash (same-tab reload only) → push-notification binding (only written on push consent). Safari session restore / reopening the URL loses the stash → dead end. Fix: DurableShareBrowserSessionFacade auto-persists the share binding after every successful keyed join, under the share link's path id (same non-extractable key material the push-consent flow stores; best-effort, silent in private browsing). Fragmentless loads now reopen via RememberedPushShareSessionFacade. Follow-up: the remembered surface is read-degraded (durable snapshots + pending push events, no live room replay) — upgrading it to a full live session with the stored admission keys would restore threads too.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-21T17:37:06Z","created_by":"James Lal","updated_at":"2026-07-21T17:37:27Z","closed_at":"2026-07-21T17:37:27Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-u15y","title":"Owner desktop header matches the reviewer bar","description":"James: 'in review mode we have a different header bar than in owner mode — make owner match the review since I like it.' HostedDesktopWorkspaceFrame drops the PathBreadcrumb row and the floating ReviewBar dock for one reviewer-grammar header: attn · divider · active file name left; save chip + ReviewBar chips (new inline variant, normal flex flow) right; quiet Share icon pre-share (ShareChip owns it once a room exists). Card layer clearance reduced now that no floating bar overlaps the paper.","status":"closed","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-21T13:40:59Z","created_by":"James Lal","updated_at":"2026-07-21T13:41:19Z","closed_at":"2026-07-21T13:41:19Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-ys9n","title":"Escape / click-away dismisses click-revealed comment cards","description":"James: 'there should be some way to dismiss a comment when it's shown via the click.' Store gains openPanelForFocus (tracks that a click revealed hidden cards) and dismissFocusStep (collapse expanded resolved card → clear focus → re-hide click-revealed cards). Wired: highlight-mark clicks and gutter-chip expands go through openPanelForFocus; Escape (both shells, guarded against modals/composers/text fields) and clicks on plain document text step back; a deliberate dock/⌘J toggle takes ownership so click-away never hides it.","status":"closed","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-21T13:29:26Z","created_by":"James Lal","updated_at":"2026-07-21T13:29:46Z","closed_at":"2026-07-21T13:29:46Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-cf1u","title":"Floating margin cards: comments always visible, no modes, no panel sheet","description":"James rejected the opaque overlay (option A) after feel: 'it hides the comments and pops out over it'. New grammar: no Reading/Review modes — panelOpen defaults TRUE; the card layer is fully transparent and click-through (pointer-events none; cards re-enable), each card floats at its anchor height with its own surface/shadow, document visible between cards (Docs look). computeRailMode gains the no-room gate (roomless files stay rail-free — the default-open flag exposed the missing check as a phantom rail, caught by hosted-shells spec). Dock toggle still hides everything for a clean reading pass.","status":"closed","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-21T13:13:19Z","created_by":"James Lal","updated_at":"2026-07-21T13:13:40Z","closed_at":"2026-07-21T13:13:40Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-suhh","title":"Live reviewer highlights visible to all peers","description":"James: 'it would be quite interesting if you could see what the reviewers are highlighting.' RemoteCursor now carries an optional selection anchor; Editor forwards (head, anchor) on selection change; the remote-cursors plugin renders a translucent band in the peer's color (color-mix, theme-safe) alongside the existing caret+label. The hosted owner previously had NO cursor render sink at all (the runtime builds the controller before the shell mounts its view) — added CollabController.setRemoteCursorSink and an EditorShell effect that dispatches cursors into the view, scoped to the active file.","status":"closed","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-21T04:23:36Z","created_by":"James Lal","updated_at":"2026-07-21T04:25:41Z","closed_at":"2026-07-21T04:25:41Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-o8t9","title":"Rename doesn't propagate to comments (owner announce rejected by capability check)","description":"James's report: editing the display name (ShareChip → Edit) left every comment card on the old name. Three stacked causes on the hosted owner: (1) nothing called announceProfile after a rename (only the /s/ reviewer page did); (2) the owner session had no getDisplayName getter, so any announce would carry the 'Browser owner' fallback; (3) announceProfile always sent the REVIEWER capability set, and validParticipantAttestation requires the exact set for the registered device kind — every receiving client silently dropped an owner's re-announce as unauthorized (it existed only in the author's local echo and reverted on replay). Fixed: EditingSession.announceProfile plumbed through authority/runtime/service and called from the NamePrompt confirm; workspace-service provides getDisplayName from the live profile (lazy runes-module import); announceProfile now announces the capability set matching the principal.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-21T03:56:36Z","created_by":"James Lal","updated_at":"2026-07-21T03:58:52Z","closed_at":"2026-07-21T03:58:52Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-fnz0","title":"Owner review silence: persistent indicators for cross-file comments","description":"In a multi-file share, a comment on a non-active file left no durable trace on the owner: the 5.2s arrival toast was the only signal (dock badge counted current file only; hosted rooms never got role 'owner' so sidebar shared-badges and unread counts were role-gated to no-op). Fixed: dock badge counts room-wide active threads (store.roomActiveThreadCount); sidebar shows per-file open-thread counts; hosted sessions record room role via store.noteRoomRole (owner/reviewer) at session start.","notes":"Fixed in 1854724, staging 7c1706e7. Verified WebKit-reviewer/Chromium-owner: badge 1 + sidebar per-file count while owner edits another file.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-21T01:05:30Z","created_by":"James Lal","updated_at":"2026-07-21T01:05:57Z","closed_at":"2026-07-21T01:05:57Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-r7xi","title":"Hosted owner cannot author comments (no selection toolbar on EditorShell)","description":"The hosted owner shell mounts no comment-creation surface — SelectionToolbar/CommentComposer exist only on the /s/ reviewer page and the landing demo. The owner can reply/resolve but never start a thread. Native parity gap; surfaced while investigating James's 'not seeing comments when I make them' report (Edge owner / Safari reviewer).","notes":"Shipped in f730d15, staging a07ce3fe. Verified Chromium owner + WebKit reviewer: toolbar → composer → card + live delivery + refresh survival. Known nit: author label shows the genesis self-announce name ('Browser owner') when the share was created before the name prompt was answered.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-21T01:05:29Z","created_by":"James Lal","updated_at":"2026-07-21T01:19:42Z","closed_at":"2026-07-21T01:19:33Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-wac5","title":"Review band overlay: rail toggle never re-wraps the document","description":"James approved option A of planning/collab/review-band-stability.md: the aside keeps a permanent 48px marker gutter; Review mode renders the card column as an elevated overlay panel (.review-rail-panel, styles/base.css) anchored to the aside's right edge — slide-in with reduced-motion crossfade. Applied to both the hosted owner frame and the /s/ reviewer page. Verified on staging: document width constant (owner 965px, joiner 985px) across Reading↔Review round trips on both surfaces.","notes":"Shipped in 492fab5, deployed to staging (dabb1c5e). Verified: doc width constant across Reading↔Review round trips on owner (965px) and joiner (985px); markers↔cards swap; compose auto-enters Review; routes suite 87 passed.","status":"closed","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-21T00:34:03Z","created_by":"James Lal","updated_at":"2026-07-21T00:35:59Z","closed_at":"2026-07-21T00:35:58Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-5spd","title":"Share page loses selected file on refresh","description":"/s/ pages always reopened on one specific file: durable-share restore called setCurrentFile per restored snapshot (last won) and nothing persisted the selection. Fix: selection mirrored to ?f=\u003cfileId\u003e (hash/#key untouched), continuously enforced across the facade→live-session handoff (leaveRoom clears selection) until the user picks a file; installSnapshot no longer steals an existing selection; user picks flow through reviewStore.selectFileAsUser.","notes":"Fixed in dfaa386; verified live on staging: joiner ?f= refresh restore + user-override persistence.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-20T23:51:10Z","created_by":"James Lal","updated_at":"2026-07-20T23:51:47Z","closed_at":"2026-07-20T23:51:46Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-g0ra","title":"Owner blind to comments on non-active files in multi-file shares","description":"reviewStore.currentFileId latched once to the first published snapshot on the hosted owner shell and never followed the active file, so threads anchored to any other file of a multi-file share were invisible (no cards/markers/badge/toast) everywhere. Repro'd on staging with a 3-file share. Fix: EditorShell effect maps active workspace path → published binding fileId → store.setCurrentFile; arrival toasts now fire for other shared files with the filename and navigate on tap.","notes":"Fixed in dfaa386; verified live on staging (3-file share, two contexts): owner cross-file cards/markers/toast.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-20T23:51:01Z","created_by":"James Lal","updated_at":"2026-07-20T23:51:46Z","closed_at":"2026-07-20T23:51:45Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-x1k","title":"[bug] Hosted: reclaiming the pen after a tab handoff can render an empty document","description":"Found by the rewritten attn-7xl.7.10 gate (hosted-authoring 'duplicated tab takes the pen seamlessly'): tab A owns, tab B opens and seamlessly claims (works), B types (streams into A's read-only mirror correctly), then clicking back into A reclaims the pen (contenteditable true) — but A's ProseMirror renders EMPTY and stays empty past 20s. Likely the collab-generation rebind after ownership swap: the reclaimed editor binds before its seed loads (or the seed path misses the just-flushed head). Same defect family as the earlier takeover-commit data loss (gated on lastReleasedSeenAt). Repro: e2e/hosted-authoring.spec.ts:426 — currently the last red test in the routes suite.","notes":"2026-07-19 (later): join-first denied policy landed — a denied tab now prefers joining the holder's live hub (both tabs editable through ONE fenced authority; probe-verified deterministic at gate speed in 2-tab replication: markers converge both directions, hub regeneration handled). Ownership transfer is now the FALLBACK (no live join within 4s grace). Remaining failure (gate hosted-authoring:426 in its fresh-browser env): tab A ends FULLY EMPTY — even previously-converged text wiped. Top suspect with evidence: an editor remount re-initializing markdown from the CACHED boot-time collabSeed (probes show seedChars:0 persists in the cache after typing — the seed is captured at bind time and never refreshed), so any unplanned view remount re-applies an empty doc. Candidate fixes: (a) reorder the markdown fallback chain so remountSeed/bodyText outrank a cached collabSeed, (b) invalidate collabSeed+loadedCollabGenerationKey in handleEditorReady on view change, (c) re-fetch seed on every remount. Touches the attn-707/attn-w22-hardened rebind machinery — needs its own careful session with the __attnCollabDebug probe.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-20T00:45:14Z","created_by":"James Lal","updated_at":"2026-07-20T14:16:16Z","closed_at":"2026-07-20T14:16:16Z","close_reason":"Resolved: join-first removed the ownership churn that opened the bind window; the reload-empty tail was the gate racing the autosave debounce after automation dismissed the beforeunload guard (spec now waits for durable commit). hosted-authoring:426 green 5x + 2 full-suite runs. Residual documented: forced-transfer fallback (dead holder) still flips writable before bind — no live typist races it by construction; __attnCollabDebug probe ships for diagnosis if it ever bites.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-3yt","title":"Add guided demo cursor to homepage review","description":"Autoplay a clearly labeled, one-time demo cursor when the alternate homepage review enters view. Drive the real Codex reply UI, type and submit a nested reply, stop immediately on visitor input, support replay, mobile tap treatment, reduced motion, and production browser verification.","status":"closed","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-19T14:44:50Z","created_by":"James Lal","updated_at":"2026-07-19T15:03:11Z","started_at":"2026-07-19T14:46:23Z","closed_at":"2026-07-19T15:03:11Z","close_reason":"Implemented guided real-reply autoplay, takeover cancellation, replay, mobile tap mode, reduced-motion completion, and production E2E coverage.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-xnt","title":"Embed real interactive review demo on alternate homepage","description":"Replace the staged alternate-homepage mockup with an isolated real editor/review surface using production components. Seed agent comments and suggestions, let visitors select Markdown and add their own comment, and verify responsive/a11y/build behavior.","status":"closed","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-19T14:25:41Z","created_by":"James Lal","updated_at":"2026-07-19T14:39:27Z","started_at":"2026-07-19T14:25:49Z","closed_at":"2026-07-19T14:39:27Z","close_reason":"Alternate homepage now embeds a production-component review demo with seeded Claude/Codex feedback, real visitor commenting, responsive presentation, same-origin-only framing, and passing a11y/build/e2e gates.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-x6v","title":"Share from any tab: proxy share ops to the lease-holding tab over BroadcastChannel","description":"James: 'why do we need a primary tab share when they sync? Can't we just share from wherever?' The single-writer lease + one room authority per workspace is load-bearing (fenced storage writes; two authorities would fork the encrypted step log), but the ownership gate made that the user's problem. Add a share-ops BroadcastChannel: any tab's ShareSheet create/inspect/stop routes to the tab holding the EditingSession, which performs the operation and returns the WorkspaceShareView; the requesting tab shows the links. Owner-tab presence tracked by ping/pong so the claim-ownership gate only appears when no owning tab is alive.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-18T15:27:11Z","created_by":"James Lal","updated_at":"2026-07-18T15:30:25Z","started_at":"2026-07-18T15:27:13Z","closed_at":"2026-07-18T15:30:25Z","close_reason":"Share operations now route to the lease-holding tab over a share-ops BroadcastChannel (inspect 8s / stop 30s / ensure 90s timeouts): any tab's share sheet works — verified live with owner+follower tabs: the follower's sheet shows the live share links via proxied inspect (no gate), and stop + create-new-link both complete from the follower with publishing (PoW + uploads) executed in the owning tab. Owner-tab presence is probed by ping/pong at sheet-open, so the claim-ownership gate from 5808c3d now appears only when no owning tab is alive to answer — the single-authority invariant stays intact as an implementation detail instead of a user constraint.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-vt4","title":"Hosted feedback: widen the content measure for technical docs; mark shared workspaces in the project picker","description":"Page feedback on /app workspace view: (1) the 640px --content-measure feels narrow for technical review (mermaid, tables) — cut over the Shared-Column Rule to a two-tier measure: prose up to 720px, wide blocks (pre/table/mermaid/scroll-x) up to a new --content-measure-wide; (2) the sidebar project picker lists every desk workspace identically — shared workspaces should read distinctly (rust dot + Shared marker, matching the sharing vocabulary) so it's clear what is shared vs local-only.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-18T05:36:21Z","created_by":"James Lal","updated_at":"2026-07-18T05:40:54Z","started_at":"2026-07-18T05:36:40Z","closed_at":"2026-07-18T05:40:54Z","close_reason":"Both feedback items verified live. (1) Two-tier content measure (supersedes the single Shared-Column edge, history preserved in tokens.css): prose --content-measure 640→720px (~74ch serif, still inside the readable cap that motivated the 1100→640 tightening), new --content-measure-wide 1040px for pre/table/mermaid/prose-scroll-x in both the editor (prosemirror.css) and the native static article renderer (base.css); measured live: paragraphs 720px, table/pre compute min(100%,1040px). (2) Project picker marks shared workspaces: Sidebar gains sharedProjects, fed from workspace summaries' sharing state OR-ed with the live store for the just-shared current workspace; rows render a rust-dot '· Shared' marker (ShareChip vocabulary) right-aligned — screenshot shows shared rows marked with the local-only 'Code Rewrite and Delivery Plan' unmarked for contrast.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-4i2","title":"Docs-grade comment margin on web: one scroll context, no dead panel, active-card anchor alignment","description":"James: the web commenting sidebar 'is terrible and creates scroll zones' vs the decent native margin; reference is Google Docs. Gap analysis: (1) reviewer /s/ rail is overflow-y-auto — a second scroll zone that also breaks anchor alignment when scrolled; native forwards rail wheel events into the document scroller (WorkspaceEditorFrame onRailWheel) — adopt that; (2) hosted owner already uses WorkspaceEditorFrame but wheel-forward needs verification via the ScrollArea viewportRef; (3) empty margin renders as a bordered 320px column with centered filler text — a dead panel; Docs renders nothing: drop the border chrome from the margin (cards float on the paper) and show the 'select text to comment' hint only outside shared rooms; (4) layoutCards has no priority — Docs aligns the ACTIVE comment exactly to its anchor and pushes neighbors both directions; extend margin-layout with a priorityId pass + tests and feed it focusEventId.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-18T05:15:42Z","created_by":"James Lal","updated_at":"2026-07-18T05:26:21Z","started_at":"2026-07-18T05:15:43Z","closed_at":"2026-07-18T05:26:21Z","close_reason":"Verified live on the local loop, both surfaces. (1) Reviewer /s/ margin is no longer its own scroll zone: aside is overflow-hidden and wheels forward into the document scroller (measured: wheel over margin scrolls the doc 0→300), matching native's WorkspaceEditorFrame rail; the owner's forwarding was already wired and verified working (an earlier 'dead zone' reading was my own probe hitting scrollTop max). (2) Dead-panel emptiness gone: the margin has no border on any surface (WorkspaceEditorFrame aside + rail header + reviewer aside) and the in-room empty state renders nothing — the 'select any text' hint survives only for the local roomless panel. (3) Docs active-card rule: layoutCards gained priorityId — the focused card pins exactly at its anchor, earlier cards cascade UP (screenshot shows the Line-5 card lifted 8px above its own anchor to seat the focused Line-8 card), later cards restack below; 3 new layout tests, 16/16 green. ReviewMargin feeds it focusEventId, so native + hosted owner + reviewer all get it.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-53r","title":"Owner workspace startup blocks the editor on remote share reconciliation — dead room costs ~15s before first keystroke","description":"Measured on the local loop: opening a workspace whose durable share points at a dead relay room (404 devices, fenced publication) takes 16.6s to editor mount because BrowserOwnerWorkspaceRuntime.start() serializes reconcileActive() + discoverPublishedShare() + activatePublishedShare() (network, with retries) before the shell gets a writable state. The same shape would hit any owner on a flaky network. Fix: (1) editor readiness derives from the LOCAL lease — patch writable state immediately and run share discovery/activation as an enqueued async upgrade (the ensureShare path already activates a room mid-session, so the machinery exists); (2) terminal relay verdicts during resume (room 404/gone, fenced publication) must fail fast to local mode + the share-resume-failed banner instead of retrying.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-18T05:06:01Z","created_by":"James Lal","updated_at":"2026-07-18T05:08:19Z","started_at":"2026-07-18T05:06:03Z","closed_at":"2026-07-18T05:08:19Z","close_reason":"Disproven by measurement — filed on a conflated cold-load number. Warm reload of the SAME workspace with the same dead-room share: editor mounts in 1.06s (vs 16.6s cold), so startup does NOT block the editor on remote reconciliation; the 15s delta was Vite's dev-only on-demand compile of the editor graph. The share itself surfaced its failure via the attn-dkr banner on one load and then self-healed on the next (runtime republished to the restarted relay — designed hybrid renewal). Remaining localhost slowness is dev-server compile (first visit only) + 16-bit PoW on registrations (~seconds, intentional). No product change needed.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-238","title":"Reviewer surface redesign: workspace-style layout — file sidebar, collapsible comment rail, no layout jumps on comment","description":"User-reported issues with the /s/ reviewer UI: (1) the document jumps every time a comment is made; (2) the 320px comment rail is permanently open even when empty ('No comments yet' filler) and cannot be collapsed; (3) multi-file shares render as a top tab strip instead of a sidebar, so a shared folder feels like a different UX than a workspace. Redesign the reviewer shell so it feels like being in one of N workspaces that happens to be shared: left file sidebar (folder shares), collapsible right comment rail that stays hidden until threads exist, fixed-height header chrome that never reflows the document column.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-17T23:26:06Z","created_by":"James Lal","updated_at":"2026-07-17T23:44:36Z","started_at":"2026-07-17T23:26:27Z","closed_at":"2026-07-17T23:44:36Z","close_reason":"Verified on the local hosted share loop with Playwright: (1) jump fixed at root — the wrap-prone status row above the document (outbox chip + owner-offline text grew it 31→94px on every comment) is replaced by a fixed 43px header + ReviewerStatusChip popover; content position was pixel-identical through comment submit (p20Top 417→417→417). (2) Comment rail now unmounts when the current file has no threads (no 'No comments yet' filler), auto-opens once per room when feedback appears, reopens on highlight click, collapsible via header toggle + Cmd+J. (3) Folder shares render a workspace-style left file sidebar (Shared with you · N files · e2e encrypted) on desktop; compact strip retained on mobile. New pure model reviewer-status-model.ts w/ 8 tsx contract tests; 83 test files pass, svelte-check 0 errors, route-bundle gate holds. Follow-up filed: attn-keu (pre-existing mobile sheet card positioning).","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-6r1","title":"Redesign hosted share experience: unify fragmented top-bar share controls","description":"Hosted owner desktop renders two duplicate Share buttons (ReviewBar share-pill + PathBreadcrumb share button, both wired to onShare), the SHARING THIS FILE dropdown rows render empty (ownerDisplayPath dropped when store.applySnapshot replaces the placeholder with the hydrated snapshot), and the share status surface is fragmented across five widgets (status pill, 2 share buttons, SharedFilesBadge, ConnectionBadge). Redesign into a single first-class share control that works on mobile and web.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-17T21:02:34Z","created_by":"James Lal","updated_at":"2026-07-17T21:23:36Z","started_at":"2026-07-17T21:02:45Z","closed_at":"2026-07-17T21:23:36Z","close_reason":"Unified ShareChip replaces share-pill + SharedFilesBadge + ConnectionBadge; duplicate hosted share button gated out; ownerDisplayPath preserved through snapshot hydration (empty SHARING THIS FILE rows fixed); save chip no longer duplicates share status; mobile masthead button is state-aware. Verified end-to-end on local hosted share loop (desktop + mobile, mint/join/stop) with screenshots; svelte-check, 82 unit test files, and route-bundle gate green.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-707","title":"Staging: owner edits after share never reach joiners (live replay + idle republish both silent)","description":"Repro on staging.attn.sh (web c96fab40 + relay ecd819c8, both from james/room-as-project-ux): create workspace, share, joiner opens link and connects 'Live · end-to-end encrypted'. Owner types more text: joiner never receives it — not live (25s+), not via idle republish (fresh joiner load 15s later still serves the share-time snapshot). Owner-side network shows room envelopes POSTing 201 but NO snapshot re-PUT after creation. The identical build against a local wrangler relay delivers live edits in \u003c1s and republishes snapshots periodically. Same symptom class as the original main-on-staging audit (joiner saw stale/empty doc, 'Owner offline'), so this predates the branch. Suspect: owner's live hosting/epoch-republish trigger doesn't fire against real Durable Objects (miniflare vs production DO behavior — alarms, WS hibernation, or the share watch channel).","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-17T02:37:51Z","created_by":"James Lal","updated_at":"2026-07-17T05:26:32Z","started_at":"2026-07-17T05:03:55Z","closed_at":"2026-07-17T05:26:32Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-w22","title":"Live session does not re-establish after owner tab reload (joiner degrades to relay snapshots)","description":"Observed repeatedly during attn-w5d verification on james/room-as-project-ux (local relay): with an active Direct live session, reloading the OWNER tab permanently drops liveEditingAvailable; the connected joiner falls back to 'Encrypted mailbox' and only sees idle-republished snapshots. Reloading the joiner too does not always restore Direct. Repro: share, join (Direct), reload owner tab -\u003e owner pill shows Shared · Encrypted relay indefinitely, ownerState.liveEditingAvailable stays false. Suspect the owner does not re-host/re-offer the live room for existing reviewer devices after reload.","notes":"Re-verified 2026-07-16 on deployed staging AFTER the attn-707 fix (web 9eb57372): reload the owner of an actively-shared workspace -\u003e owner pill lands on 'Live review paused' (liveEditingAvailable false; authority.start likely pauses) and live delivery to an already-open joiner never resumes; typing after reload does not reach the joiner. This is distinct from attn-707 (session released on workspace-detail refresh), which is fixed — the create-\u003eshare-\u003ejoin-\u003etype loop works on staging now. Also observed: keystrokes typed during the post-reload initialization window can be partially dropped (editor still read-only/follow-mode, then resetToMarkdown). Repro trace and instrumentation approach in the attn-707 history.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-17T00:25:35Z","created_by":"James Lal","updated_at":"2026-07-17T06:15:52Z","started_at":"2026-07-17T05:35:20Z","closed_at":"2026-07-17T06:15:52Z","close_reason":"Fixed by f03cece follow-ups (readiness wait, owner outbox persistence, epoch-binding preservation) — deployed staging web 6e539497. Verified on deployed staging: owner reload with a connected joiner recovers to Shared, the connected joiner receives post-reload typing live, fresh joiners converge on the full document. Known soft window: activation after reload takes ~5-15s on staging (DO latency + bounded readiness wait); typing during that window stays local until activation completes — tracked implicitly, not a data-loss path.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-2zf","title":"Typing '- ' on consecutive lines staircase-nests bullet list items","description":"In the hosted editor, typing '- item' Enter '- item' Enter '- item' produces each successive bullet nested one level deeper (list-inside-listitem staircase). The '- ' input rule re-fires inside an existing list item and indents. Expected: '- ' at the start of an empty list item is swallowed (stay same level). Repro on branch and on staging/main.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-16T23:00:08Z","created_by":"James Lal","updated_at":"2026-07-16T23:52:17Z","started_at":"2026-07-16T23:45:05Z","closed_at":"2026-07-16T23:52:17Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-f5b","title":"Editor content column has no max-width (~142ch lines at 1440px)","description":"ProseMirror content area is full-bleed (maxWidth: none): ~1100px, ~142 chars per line at 1440x900, in both owner editor and joiner review page (which also hugs the left edge, x=35). Linear/Notion-grade reading measure is ~65-75ch, centered. Applies to hosted app + /s/ page.","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-16T23:00:08Z","created_by":"James Lal","updated_at":"2026-07-16T23:44:52Z","closed_at":"2026-07-16T23:44:52Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-o0d","title":"Owner document shows no highlight for comment anchors","description":"Joiner sees a yellow highlight on the anchored text ('native viewer'); the owner document shows no highlight at all — the thread card's italic quote is the only clue to what the comment refers to. Owner needs the same anchor highlight + card\u003c-\u003eanchor hover linking.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-16T23:00:07Z","created_by":"James Lal","updated_at":"2026-07-17T00:04:17Z","started_at":"2026-07-16T23:57:31Z","closed_at":"2026-07-17T00:04:17Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-j2c","title":"Revoked joiner hangs on eternal 'Loading review…' after Stop sharing","description":"After owner clicks Stop sharing -\u003e Stop now, the open joiner tab reloads into 'Loading review…' and stays there indefinitely (12s+, no spinner, no error). Should show a terminal state: 'The owner ended this review.'","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-16T22:59:41Z","created_by":"James Lal","updated_at":"2026-07-16T23:57:29Z","started_at":"2026-07-16T23:52:28Z","closed_at":"2026-07-16T23:57:29Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-klf","title":"Deploy current durable-share relay protocol to staging","description":"Playwright localhost→staging verification on 2026-07-16 showed relay-staging.attn.sh still uses the pre-atomic snapshot upload revision contract (ATTN_SHARE_REVISION_INVALID on the first projection commit). The web client now refetches after uploads for rollout compatibility, but staging should be deployed from current main/room-as-project-ux relay code and its share publish/revoke smoke test rerun.","notes":"Deployed attn-relay-staging on 2026-07-16 as Cloudflare version 1372e37c-4d66-466f-bfef-f3b1c2b26ac2. Predeploy: relay typecheck passed; 31 files / 462 tests passed. Live health returned 200. Direct remote durable-share smoke proved snapshot upload remains dark at revision 0, atomic activation lands at revision 1, and revoke/room teardown succeed.","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-16T17:21:57Z","created_by":"James Lal","updated_at":"2026-07-16T21:48:25Z","started_at":"2026-07-16T21:42:28Z","closed_at":"2026-07-16T21:48:25Z","close_reason":"Current relay protocol deployed and verified on staging","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-eko","title":"Default localhost review links to staging","description":"Local hosted development must not mint production attn.sh share URLs. Make demo and real localhost sharing default to staging review/relay consistently, while preserving explicit overrides and local-relay E2E configuration.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-16T17:05:04Z","created_by":"James Lal","updated_at":"2026-07-16T17:25:12Z","started_at":"2026-07-16T17:06:35Z","closed_at":"2026-07-16T17:25:12Z","close_reason":"Localhost defaults to staging relay/link, local override stays local, first-click relay rollout compatibility and Playwright E2E verified","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-80d","title":"Remove share friction and fix localhost resume publishing","description":"Remove the pre-share protected-storage confirmation so review links can be created immediately. Reproduce and fix localhost shares getting stuck on the interrupted/resume publishing state.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-16T16:36:21Z","created_by":"James Lal","updated_at":"2026-07-16T17:01:21Z","started_at":"2026-07-16T16:36:31Z","closed_at":"2026-07-16T17:01:21Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-2vz","title":"Keep inter-tab live editing after a workspace is shared","description":"Publishing or reopening an active review share tears down the local multi-tab collaboration hub. A second same-origin tab then receives durable storage updates but remains in the legacy read-only follower banner, and typing during the handoff can stay local instead of converging back to the owner tab.","acceptance_criteria":"Two same-origin tabs editing the same workspace remain live and bidirectional before and after creating or reopening a review share. The active room authority remains the single ProseMirror authority; local tabs use a local transport into that authority, selected-file epochs remain authenticated, non-active-file edits persist, and no tab is editable before owner or follower authority is ready. Regression tests cover the share transition and two-way edits.","notes":"Root cause: activating a published review authority intentionally tore down the local BroadcastChannel hub, forcing same-browser follower tabs into read-only storage mirroring. Fixed by attaching the local-tab transport to the active published CollabController, carrying exact published file IDs and snapshot epochs, mapping headless file commits back to paths, and restarting the bridge when the authority controller rotates. Editor editability now requires a writable owner or live follower authority. Verified Svelte check 0/0, full web suite 81 files/0 failures, local collab 11/0, owner runtime 10/0, and Playwright two-way live editing.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-16T04:38:58Z","created_by":"James Lal","updated_at":"2026-07-16T04:48:12Z","started_at":"2026-07-16T04:39:09Z","closed_at":"2026-07-16T04:48:12Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-8oj","title":"Share an explicit selection of files across native and browser owners","description":"Replace implicit current-file/directory/workspace sharing with an explicit curated file selection. Browser owners must see the existing entries picker before publication; native owners must pass an exact root-relative selection and publish the same encrypted workspace manifest so native-to-web and web-to-web reviewers receive only the chosen documents. Preserve one room/link, a primary starting file, live updates for selected files, and honest post-share file counts.","acceptance_criteria":"Native and hosted Share flows present a checkbox file picker with the active file initially selected and do not publish before confirmation. A single link exposes exactly the selected reviewable files in reviewer navigation. Native publication emits an entries-scoped authoritative manifest with root-relative paths and watches only selected paths. Web publication uses entries scope for N=1 and N\u003e1. Automated tests cover native-to-web and web-to-web inclusion/exclusion and selected-file updates.","notes":"Implemented explicit N-file selection in native and hosted share flows; active document preselected; exact entries manifest with portable root-relative paths; native watches only selected files; ready state lists shared paths. Verified Svelte check, 81 web test files, cargo check/core tests, isolated convergence suite 10/10, selected-share focused tests, and live native/hosted launches.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-16T01:46:04Z","created_by":"James Lal","updated_at":"2026-07-16T02:45:42Z","started_at":"2026-07-16T01:46:11Z","closed_at":"2026-07-16T02:45:42Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-bb4","title":"Reviewer page flickers during text selection","description":"The hosted reviewer surface visibly flickers while dragging a text selection. Selectionchange currently writes fresh Svelte state on every browser event while the floating toolbar synchronously reads layout and reinstalls listeners. Coalesce work to animation frames, skip unchanged ranges, keep anchor listeners stable, and verify the ProseMirror view is not remounted during selection.","notes":"Second pass after user confirmed toolbar-only fix was insufficient: browser review pages now suppress the fixed translucent SVG turbulence overlay that forced page-wide compositing during native selection paints. Hosted E2E asserts body[data-route=review] and computed body::after content=none; 4/4 real owner/reviewer scenarios pass. Awaiting user visual confirmation before closing.\nThird pass found two remaining selection-hostile paths. A browser click emitted after dragging across an annotated mark focused the thread, scrolled the rail, and rebuilt the inline DecorationSet beneath the live selection; mark activation now requires both PM and DOM selections to be collapsed, and the duplicate immediate rebuild was removed. Same-file snapshot epoch replacement now defers until pointerup and restores selection/focus/scroll through the required EditorView rebuild. Added bounded opt-in window.__attnSelectionDebug lifecycle tracing (no text or capabilities), pure regressions, and hosted owner/reviewer coverage asserting exact range, EditorView/DOM identity, and editor/margin scroll stability. Verified npm test (81 files), npm run check, npm run build:browser, and isolated hosted E2E (4/4).","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-16T00:29:37Z","created_by":"James Lal","updated_at":"2026-07-16T01:10:00Z","started_at":"2026-07-16T00:29:44Z","closed_at":"2026-07-16T01:10:00Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-jgz","title":"Rooms as projects P1: owner focus follows shared files","description":"Implement P1 from planning/collab/room-as-project-ux.md: remove the owner room switcher, focus the owned room from the active shared path, clear room focus on unshared files, and move owner unread counts onto file/folder rows. Preserve reviewer room switching until P2. Add focused tests and visual verification.","notes":"Follow-up relay-backed E2E completed on james/room-as-project-ux: scripts/test-editorial-e2e.sh now covers owner room-dropdown removal, shared tree marker, unshared focus clearing without forgetting, shared-file reactivation, reviewer join, comment/reply/resolve convergence, and leave. Final result: 27 passed, 0 failed, 0 pending. Harness updated for relay quota attribution and native-vs-browser invite selection.","status":"closed","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-15T22:08:50Z","created_by":"James Lal","updated_at":"2026-07-15T22:33:31Z","started_at":"2026-07-15T22:08:51Z","closed_at":"2026-07-15T22:18:50Z","close_reason":"Implemented owner focus-follows-file, removed owner room navigation, added tree unread placement, and passed 81 frontend test files, svelte-check, production build, and scoped design audit. In-app visual session was unavailable.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-hs0","title":"Joiner falls out of sync with live owner after page open/close","description":"With the owner tab open and editing (live collab cursor was visible), closing and reopening the /s/ page shows stale content vs the owner browser, and the header says 'Owner offline - Review still available' even while the owner tab is live. Suspects: owner authority presence not detected by the joiner's isRoomLive probe on re-resolve, so the joiner pins to the retained snapshot instead of joining the live room; or the durable snapshot republish lags owner edits. Reproduced on the localhost loop (5199 + local relay).","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-15T15:44:45Z","created_by":"James Lal","updated_at":"2026-07-15T15:57:34Z","closed_at":"2026-07-15T15:57:34Z","close_reason":"Fixed and live-verified on the localhost loop; relay regression test added for the empty-bundles 400","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-lok","title":"Reviewer click on text makes the whole page jump jarringly","description":"On the /s/ joiner view, clicking document text (which focuses the corresponding review thread/anchor) scroll-jumps the entire page instead of a calm, contained adjustment. Likely an unqualified scrollIntoView() or focus() without preventScroll on the thread card / anchor path. Expected: focus the thread with block:'nearest' scrolling scoped to the rail, never yank the document scroll position.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-15T15:44:43Z","created_by":"James Lal","updated_at":"2026-07-15T15:57:33Z","closed_at":"2026-07-15T15:57:33Z","close_reason":"Fixed and live-verified on the localhost loop; relay regression test added for the empty-bundles 400","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-40t","title":"Deploy share-publish protocol cutover: relay + web + native must ship together per environment","description":"The atomic share publish change (attn-8h7, commit 3a8c5d0) is a clean cutover with a coordination hazard: old clients against the new relay 405 on the removed snapshot DELETE route and their same-revision republishes silently keep the old manifest; the new web owner flow assumes the new relay staging semantics. Ship per environment as a pair: staging relay worker (env staging) together with the web staging build; then the production relay worker together with the production web build and a rebuilt native attn binary (brew tap). Existing shares stranded in the pre-fix mismatched state heal on the owner's next publish from an updated client. James must run or authorize the deploys — the agent session is permission-gated from deploying.","notes":"2026-07-18 STAGING DEPLOYED as a pair: attn-relay-staging 6d23a738 (code-unit digest + canonical serve order, commits 2a54765+40635ac) and attn-web-staging 75ead12b (current main incl. code-unit client, identity, review-margin work) via wrangler deploy --env staging + npm run deploy:staging. Verified live on staging: two-file share publishes and the joiner loads both files (share mxl3-DN7qy0VO0jMrdYE5Q). PRODUCTION (relay.attn.sh + attn.sh) still runs the pre-cutover pair — deploy both together when cutting a release; do NOT deploy prod relay alone (old prod web clients would hit the digest-mismatch pause).","status":"open","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-15T04:04:24Z","created_by":"James Lal","updated_at":"2026-07-18T14:39:37Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-190","title":"Local/preview sharing publishes before failing origin validation","description":"The service passes window.location.origin while invite composition allows only production/staging HTTPS hosts. On task dev, publication + durable projection happen first, then invite generation throws: user sees failure while a live share exists. Validate origin up front (or compose a dev-origin invite) before any publication side effects. web/src/hosted/app/real-service.ts:447, web/src/lib/review/browser-workspace-sharing.ts:363, web/src/lib/review/browser-share.ts:101.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-15T03:25:32Z","created_by":"James Lal","updated_at":"2026-07-15T14:36:54Z","closed_at":"2026-07-15T14:36:54Z","close_reason":"Fixed on james/hosted-improvements with regression coverage where testable","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-8nw","title":"Relay bare-room DELETE preflights bypass allowBrowser gating","description":"The create-only CORS shortcut checks the route but not Access-Control-Request-Method, so preflights advertise DELETE and browsers send the destructive request for browser-disabled rooms; suppressing CORS on the final response cannot undo the deletion. relay/src/index.ts:324.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-15T03:25:32Z","created_by":"James Lal","updated_at":"2026-07-15T04:15:15Z","closed_at":"2026-07-15T04:15:15Z","close_reason":"Create shortcut now gated on Access-Control-Request-Method POST; regression test added","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-rtw","title":"Rename/delete confirmation state follows the active file across navigation","description":"In-place file switching resets editing/autosave state but not renamingEntry, renameEntryValue, confirmingEntryDelete. Start deleting/renaming A, navigate to B, and the action now targets B. Bind destructive state to an immutable target path and clear on navigation. web/src/hosted/app/EditorShell.svelte:827,1441.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-15T03:25:31Z","created_by":"James Lal","updated_at":"2026-07-15T04:12:57Z","closed_at":"2026-07-15T04:12:57Z","close_reason":"Fixed in commit on james/hosted-improvements; regression tests added for the autosave engine","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-kse","title":"Autosave flush does not drain an in-flight save: runCommit early-returns while committing","description":"runCommit() returns immediately when committing is true, so flush() resolves while newer text is still pending; the path switch then disposes the controller and suppresses its retry. Reproduced: only 'first' committed, 'second' stayed dirty with no scheduled save. Can also publish a stale share snapshot. web/src/hosted/app/autosave.ts:68,85, web/src/hosted/app/EditorShell.svelte:1031.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-15T03:25:30Z","created_by":"James Lal","updated_at":"2026-07-15T04:12:56Z","closed_at":"2026-07-15T04:12:56Z","close_reason":"Fixed in commit on james/hosted-improvements; regression tests added for the autosave engine","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-kew","title":"Browser Back/Forward discards pending edits: popstate applies entry without autosave flush","description":"Tree navigation calls switchTo() which flushes autosave; the popstate path calls applyEntry() directly. Once the path changes the old autosave controller is disposed and debounce-pending text is lost (type then press Back within 1.2s). web/src/hosted/app/AppShell.svelte:151, web/src/hosted/app/EditorShell.svelte:481.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-15T03:25:14Z","created_by":"James Lal","updated_at":"2026-07-15T04:12:57Z","closed_at":"2026-07-15T04:12:57Z","close_reason":"Fixed in commit on james/hosted-improvements; regression tests added for the autosave engine","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-8ri","title":"Read-only follower tabs can rename/delete/create/import entries without a workspace fence","description":"Structural mutation service methods bypass the editing session and never pass a fence; their UI stays enabled when editDenied is true. A follower can delete/rename the file the writer is editing, causing failed commits or lost work. Route structural mutations through the fenced owner runtime or reject without ownership. web/src/hosted/app/real-service.ts:359, web/src/hosted/app/EditorShell.svelte:1413.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-15T03:25:13Z","created_by":"James Lal","updated_at":"2026-07-15T14:28:25Z","started_at":"2026-07-15T14:20:29Z","closed_at":"2026-07-15T14:28:25Z","close_reason":"Lease protocol hardened with per-context nonce (token-bumping takeover on copied holder ids); structural mutations fenced via withWorkspaceFence in RealWorkspaceAppService","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-pw9","title":"Duplicate tabs can become concurrent writers: holder ID copied via sessionStorage passes fencing","description":"Holder ID is restored from copied sessionStorage; if BroadcastChannel is unavailable, delayed beyond the 75ms probe, or blocked, a duplicated tab retains the same holder ID. Lease manager treats matching holder IDs as the same owner and hands out the same fencing token, so both tabs' writes pass fencing. web/src/hosted/app/real-service.ts:67, web/src/lib/review/browser-workspace-lease.ts:80.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-15T03:25:12Z","created_by":"James Lal","updated_at":"2026-07-15T14:28:25Z","started_at":"2026-07-15T14:20:28Z","closed_at":"2026-07-15T14:28:25Z","close_reason":"Lease protocol hardened with per-context nonce (token-bumping takeover on copied holder ids); structural mutations fenced via withWorkspaceFence in RealWorkspaceAppService","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-8h7","title":"Durable share publish is non-atomic: joiners fail with 'capability bundle context mismatch'","description":"User-hit on staging.attn.sh: recipient opening an invite sees 'Could not reach the review relay / capability bundle context mismatch'. Root cause: relay share-do.ts snapshot PUT (:676) and DELETE (:569) bump record.revision and mutate the manifest server-side (and broadcastShareChanged, prompting viewers to re-resolve) while the sealed capability bundles still carry the old revision/manifestDigest; bundles are only re-sealed by the final upsert in publishDurableProjection (browser-workspace-sharing.ts:511). Every publish/republish opens a seconds-long window where openShareCapabilityBundle (browser-share.ts:301) hard-fails; tab-close/network failure mid-publish strands the share in that state. Fix: make publish atomic — snapshot PUT stages (no revision bump/broadcast), upsert commits manifest+bundles+revision in one DO transaction; drop the delete-then-upload dance; cut over web owner, native owner (src/review/share_lifecycle.rs), and add bounded joiner re-resolve on transient selector mismatch. Also fix currentManifestDigest shared-closure race in browser-share-production.ts:583.","notes":"Implemented on james/hosted-improvements @ 3a8c5d0. Relay stages snapshot PUTs and commits manifest+bundles+revision atomically in upsert; snapshot DELETE endpoint removed; web+native owners cut over; joiner digest race fixed + bounded transient retry. relay 461/461, web 81/81 files, cargo 520/520 green. NOT yet deployed — see the deploy-coordination bead.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-15T03:25:11Z","created_by":"James Lal","updated_at":"2026-07-15T04:15:16Z","started_at":"2026-07-15T03:26:50Z","closed_at":"2026-07-15T04:15:16Z","close_reason":"Code complete on james/hosted-improvements (atomic publish across relay/web/native + joiner resilience); environment deploys tracked in the coordination bead","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-4lw","title":"Hosted app shell feedback: workspace switcher, quiet header, drop-zone footer","description":"Agentation feedback on /app/w/: (1) sidebar project row becomes a workspace switcher; (2) remove header rename-title button (rename via palette + desk); (3) remove desktop Edit/Done toggle (desktop is editor-first already; keep Retry edit for denied lease, mobile unchanged); (4) sidebar footer resting state becomes a single drop zone (New Markdown/Export via palette, per-file actions via tree context menu). Update hosted-authoring e2e specs for the new surfaces.","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-14T23:43:42Z","created_by":"James Lal","updated_at":"2026-07-15T00:37:05Z","closed_at":"2026-07-15T00:37:05Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-pbf","title":"Durable /s/ reviewers: live room following for instant owner-edit updates","description":"Today /s/ reviewers refresh via durable snapshot republish (~5s idle debounce). The facade already creates a live BrowserSession when the owner is online, but liveEditingAvailable is hardcoded false and owner collab broadcasts are not applied to the reviewer view. Wire the live session's collab delivery into BrowserReviewApp for durable shares to make owner edits appear keystroke-live, with the durable snapshot as the offline fallback.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-14T19:10:28Z","created_by":"James Lal","updated_at":"2026-07-14T20:49:07Z","started_at":"2026-07-14T20:08:27Z","closed_at":"2026-07-14T20:49:07Z","close_reason":"Live on staging: 305ms owner-keystroke to reviewer render, continuity across idle windows, late joiner resyncs to full state in 1.5s, docs byte-identical across owner/reviewer/late joiner","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-9jp","title":"Shared workspace goes stale for /s/ reviewers — owner edits never republish","description":"Owner keeps editing after sharing; reviewers (and even reviewer reloads) stay on the share-time snapshot forever. Root cause: the durable share projection mirrors the room-published manifest, and nothing republishes after commits — only workspace start and the share sheet did. Fix: idle-debounced (5s) flush after commits/accepts/applies — publish a new room snapshot generation (epoch transition, same machinery as accepts) then mirror it into the durable share record; reviewers' change watch refetches on the revision bump. Close() flushes a pending republish.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-14T19:10:27Z","created_by":"James Lal","updated_at":"2026-07-14T19:10:41Z","closed_at":"2026-07-14T19:10:41Z","close_reason":"Verified on staging: reviewer auto-updates live ~6s after owner pauses typing, persists across reviewer reloads, owner editor loses no text through the flush","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-5ao","title":"Minimal share UX: one-glance sheet (link+copy, compact tier switch, tuck the rest)","description":"User feedback: share sheet is clunky. Auto-publish already exists; the ready phase is a wall (banner, 3 radio cards, masked link + Show, 2 buttons, paragraphs, native/CLI, stop zone). Cut to: link row with Copy primary, segmented tier control, one meta line (allowance · E2EE · expiry), collapsed native/CLI, quiet stop-sharing with inline confirm.","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-14T06:01:46Z","created_by":"James Lal","updated_at":"2026-07-14T06:10:36Z","closed_at":"2026-07-14T06:10:36Z","close_reason":"Verified on staging: open→reload→reload works, fragmentless shows actionable message, sheet is one-glance (368px)","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-4sh","title":"Share link dies on reload — key stripped from URL strands the reviewer","description":"Opening /s/\u003cid\u003e#key=… works once; the bootstrap strips the fragment for security, so any reload/back-forward boots fragmentless, falls into the push-notification recovery path, and shows 'Invalid invite link / This notification is no longer remembered in this browser.' Repro: open share link, press F5. Fix: carry the link secret in history.state across the strip so same-tab reloads recover; clearer message when recovery is truly impossible.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-14T06:01:45Z","created_by":"James Lal","updated_at":"2026-07-14T06:10:36Z","closed_at":"2026-07-14T06:10:36Z","close_reason":"Verified on staging: open→reload→reload works, fragmentless shows actionable message, sheet is one-glance (368px)","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-ea6","title":"Two-tab co-editing E2E: convergence both directions, persistence, takeover","description":"Part of attn-47r. Playwright: two tabs type concurrently into one doc and converge; text persists to IndexedDB (reload shows merged doc); owner tab closes → follower keeps editing and its content survives; live-owner never loses its lease.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-13T20:30:17Z","created_by":"James Lal","updated_at":"2026-07-13T21:37:23Z","started_at":"2026-07-13T21:37:22Z","closed_at":"2026-07-13T21:37:23Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-71c","title":"Follower join path: real-service joinLocalCollab + EditorShell editable co-edit surface","description":"Part of attn-47r. joinLocalCollab handshake in real-service, reviewer-role controller, EditorShell: join instead of read-only follow when denied+unshared, editable editor bound via setActiveFile, autosave gated off, banner→live status, teardown on goodbye/generation change, takeover commits live doc before rehosting.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-13T20:30:16Z","created_by":"James Lal","updated_at":"2026-07-13T21:37:21Z","started_at":"2026-07-13T21:37:21Z","closed_at":"2026-07-13T21:37:21Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-738","title":"Local collab transport + owner hub: BroadcastChannel wire, seed cache, authority hosting, commit hook","description":"Part of attn-47r. Transport module (per-workspace channel, hello/hello-request/seed-request/seed/goodbye/collab envelope with senderId), owner-side hub owning the seed cache and CollabController(legacy epoch) construction, debounced commit-on-broadcast through runtime.commit, runtime exposes local collab state.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-13T20:30:16Z","created_by":"James Lal","updated_at":"2026-07-13T21:37:19Z","started_at":"2026-07-13T20:34:37Z","closed_at":"2026-07-13T21:37:19Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-47r","title":"Local multi-tab live co-editing: same-browser tabs co-edit an unshared workspace via the collab authority over BroadcastChannel","description":"Reuse the existing owner-as-authority collab stack (CollabAuthority/CollabHost/CollabClient/CollabController legacy-epoch mode) for the single-browser multi-tab case. The lease-holding tab hosts a per-file authority and performs all durable commits; other tabs join as CollabClients over a per-workspace BroadcastChannel transport instead of the encrypted relay. Replaces read-only follow mode with real concurrent editing when the workspace is NOT shared in a review room. Design: (1) local transport module with hello/seed/collab envelope + senderId, (2) owner-side hub in the runtime: seed cache (markdown+PmNode per file, single seed source), controller construction in legacy-epoch mode, commit hook on accepted broadcasts (debounced, via runtime.commit so close() flushes), (3) follower-side join in real-service: hello-request handshake, reviewer controller with isAuthorityDevice=lease holder, editor seeds from seed reply, (4) EditorShell wiring: follower editable surface, autosave suppressed in local-collab mode, banner replaced by live status, takeover = commit live doc then rehost under new generation, (5) two-tab Playwright E2E proof.","status":"closed","priority":1,"issue_type":"epic","owner":"james@littlebearlabs.io","created_at":"2026-07-13T20:30:01Z","created_by":"James Lal","updated_at":"2026-07-13T21:42:45Z","closed_at":"2026-07-13T21:42:45Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-q5u","title":"Read-only tab should live-follow the writer tab's changes","description":"A passive tab rendered a stale snapshot: nothing refreshed it while the writer tab committed. Added an advisory BroadcastChannel doorbell (attn:workspace-changes:v1) rung by the writer's service after every durable mutation (commit, suggestion accept/apply, entry create/rename/delete, asset import, workspace rename); follower tabs re-read from IndexedDB — storage stays the source of truth, self-echo is impossible (same channel instance + senderId filter). Banner copy now says the tab follows the writer.","status":"closed","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-13T17:36:09Z","created_by":"James Lal","updated_at":"2026-07-13T17:36:25Z","closed_at":"2026-07-13T17:36:25Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-w4i","title":"Passive tab sticks read-only when owner tab dies without releasing lease","description":"The read-only banner's only auto-recovery trigger was the BroadcastChannel 'released' doorbell. A crashed/killed owner tab — or one whose pagehide close chain never finishes its async lease release — never broadcasts, so the passive tab stayed read-only forever despite the IDB lease expiring after 15s. Fix: while denied, EditorShell peeks the blocking lease's expiresAt and re-attempts takeover exactly at that deadline (plus on window focus).","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-13T17:08:01Z","created_by":"James Lal","updated_at":"2026-07-13T17:08:42Z","closed_at":"2026-07-13T17:08:42Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-vea","title":"Hosted desktop editor: markdown input rules + serif reading register","description":"Staging critique P0 (gate-blocking for the hosted editor's score): the desktop hosted ProseMirror does not parse typed markdown (# stays literal, no input rules, no paste-as-markdown) and renders the document in Source Sans, not the serif reading register — breaking the Read/Do rule and the 'reviewer for agent-authored docs' positioning on the widest platform. The mobile editor already proves the WYSIWYG schema (H2 toolbar -\u003e real serif heading). Cascades into attn-cjn auto-rename (no H1 to read). Tracked separately on the web-editor-parity branch; outside the Theme v2 visual-consistency pass.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-12T17:41:52Z","created_by":"James Lal","updated_at":"2026-07-12T19:59:56Z","started_at":"2026-07-12T19:52:12Z","closed_at":"2026-07-12T19:59:56Z","close_reason":"Landed: markdown input rules + serif editor register in shared editor; verified live; unblocks cjn auto-rename","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-9ua","title":"Hosted: owner review rail - comment arrival + visible mailbox errors","description":"Staging critique P0: reviewer comments never surface in the hosted owner editor (silent 400 on mailbox fetch; no rail, no badge, no toast). Owner editor needs the persistent review rail with synced thread count, arrival surfacing, and first-class mailbox error states with retry.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-12T16:36:47Z","created_by":"James Lal","updated_at":"2026-07-12T17:00:09Z","started_at":"2026-07-12T16:49:53Z","closed_at":"2026-07-12T17:00:09Z","close_reason":"Error surfacing + live counts landed; transport already fixed on main (owner-live suite green); staging needs deploy","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-ri1","title":"Hosted: #join is a dead click - add paste-a-link entry","description":"Staging critique P1: landing entry card and desk card both navigate to /app#join which renders the desk unchanged. Add a paste-a-link modal (accepts an invite URL, routes to the review entry) or remove the dead cards until it exists.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-12T16:36:47Z","created_by":"James Lal","updated_at":"2026-07-12T16:46:54Z","started_at":"2026-07-12T16:43:17Z","closed_at":"2026-07-12T16:46:54Z","close_reason":"Landed + verified: join panel with invite validation","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-cjn","title":"Hosted: idempotent #new, state-aware entry CTA, auto-name from H1","description":"Staging critique P0: /app#new is a GET-that-mutates - every visit (incl. back-button/bookmark) mints an identical 'Untitled' workspace; empties never coalesce; workspaces never auto-name from content. Fix: (a) #new reuses the most recent empty untouched Untitled workspace instead of minting; (b) landing header/hero CTA becomes 'Your desk (N)' when local workspaces exist, New demotes to secondary; (c) workspace auto-names from the first H1 on first save.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-12T16:36:46Z","created_by":"James Lal","updated_at":"2026-07-12T16:42:54Z","started_at":"2026-07-12T16:36:56Z","closed_at":"2026-07-12T16:42:54Z","close_reason":"Landed + behaviorally verified: idempotent #new, desk-count CTA, H1 auto-name","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-6d2","title":"Native: checkbox toggles from keyboard/AT never persist","description":"taskListItemNodeView binds the toggle to mousedown only (Editor.svelte:364). Keyboard activation (Space) and AT fire click, not mousedown - the input flips visually via native behavior but no PM transaction dispatches and editSave never fires, so the watcher reload reverts the state. This is the native critique's 'checkbox persistence' P0: mouse worked, keyboard silently lost state. Fix: preventDefault mousedown (protects the selection) but bind the toggle to click, preventDefault'ing the native flip so PM node attrs stay the single source of truth.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-12T16:34:48Z","created_by":"James Lal","updated_at":"2026-07-12T16:36:12Z","started_at":"2026-07-12T16:35:04Z","closed_at":"2026-07-12T16:36:12Z","close_reason":"Landed: toggle moved to click path, disk write verified live","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-hg5","title":"Behavioral contracts: Truth Rule, Esc layering, focus management into DESIGN.md + fixes","description":"Codify from the prototype: (1) Truth Rule — visibility rides on data-state CSS, never on an animation completing ([data-state=closed]{display:none}, unmount closed dialogs) — this is the fix for the native ghost-modal P0; (2) Escape closes topmost layer only, incl. mobile drawer; (3) focus stored on open / restored on close for all dialogs; (4) reduced-motion is a designed alternative. Add as named rules in DESIGN.md section 6 and apply to existing dialogs.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-12T15:38:10Z","created_by":"James Lal","updated_at":"2026-07-12T16:11:46Z","started_at":"2026-07-12T16:09:09Z","closed_at":"2026-07-12T16:11:46Z","close_reason":"Landed: Truth Rule CSS + atomic theme flip + DESIGN.md contracts, verified ghost-modal gone on occluded window","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-n9j","title":"Command palette: cmd-K, commands, ARIA, keyboard guard fix","description":"CommandPalette.svelte gains: cmd-K alias, command entries (share, theme, toggle rail, jump to heading, comment on current paragraph, resolve all open comments — comments only, never suggestions), filter + arrows + enter, listbox ARIA (aria-activedescendant/aria-selected), focus restore to trigger. Fix web/src/lib/keyboard.ts:133 editingTarget guard so global chords work with editor focus. All shortcuts match e.code not e.key (Shift+Period emits '\u003e'). Esc pops topmost layer only.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-12T15:38:08Z","created_by":"James Lal","updated_at":"2026-07-12T16:09:08Z","started_at":"2026-07-12T16:00:20Z","closed_at":"2026-07-12T16:09:08Z","close_reason":"Landed: cmd-K + guard fix + commands group, verified on native daemon","dependencies":[{"issue_id":"attn-n9j","depends_on_id":"attn-5y6","type":"blocks","created_at":"2026-07-12T09:38:26Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} -{"id":"attn-ll9","title":"Review rail: brand port + anchor-aligned hug rail","description":"Rebuild ReviewMargin.svelte + ReviewMarginCard.svelte on the prototype's rail: cards absolutely positioned in a track, aligned to their anchors' viewport position (verified pixel-exact in prototype), stacking with 14px min gap; rail hugs the 72ch prose edge and retreats to the pane edge when a wide block is \u003e=80px in view (decision #1, implemented in prototype). Kills the components' raw-px type regime and off-palette colors. Per-file thread scoping.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-12T15:38:07Z","created_by":"James Lal","updated_at":"2026-07-12T16:23:18Z","started_at":"2026-07-12T16:16:27Z","closed_at":"2026-07-12T16:23:18Z","close_reason":"Landed: retint sweep + hug rail via IO/RO/MO (no scroll listeners), suites green","dependencies":[{"issue_id":"attn-ll9","depends_on_id":"attn-5e7","type":"blocks","created_at":"2026-07-12T09:38:25Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} -{"id":"attn-5e7","title":"Theme v2: shared reading-surface CSS (measure, wide track, scrollbars, grain)","description":"Port the prototype's sheet rules into web/styles/base.css + prosemirror.css so native and hosted inherit together: full-width left-set sheet, prose capped at 72ch, wide track (pre/table/mermaid container) spans the pane; visible list markers (rust dot); frontmatter metadata card; thin trackless ink-tinted scrollbars; paper grain on native; amber ::selection; terracotta focus ring everywhere. Reference: planning/design/prototypes/editor-polish.html + ux-overhaul.md decisions.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-12T15:38:06Z","created_by":"James Lal","updated_at":"2026-07-12T15:59:52Z","started_at":"2026-07-12T15:54:34Z","closed_at":"2026-07-12T15:59:52Z","close_reason":"Landed: measure split, tokens-fed fonts/selection/motion, markers, scrollbars, z ladder, verified native render","dependencies":[{"issue_id":"attn-5e7","depends_on_id":"attn-5y6","type":"blocks","created_at":"2026-07-12T09:38:24Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} -{"id":"attn-l84","title":"Theme v2: single OKLCH token source for native + hosted","description":"Cut both builds to one token file using reviewed values from planning/design/prototypes/editor-polish.html (AA-verified both themes, INK chroma 0.014, --amber-deep, per-theme --monogram, shadow/ease tokens). Delete web/src/hosted/tokens.css hex system; port hosted-only tokens into shared source. Purge shadcn-blue var() fallbacks. Add 2px micro radius token to DESIGN.md.","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-12T15:37:27Z","created_by":"James Lal","updated_at":"2026-07-12T15:38:06Z","closed_at":"2026-07-12T15:38:06Z","close_reason":"duplicate of attn-5y6","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-5y6","title":"Theme v2: single OKLCH token source for native + hosted","description":"Cut both builds over to one token file using the reviewed values from planning/design/prototypes/editor-polish.html (AA-verified in both themes, INK with real chroma 0.014, --amber-deep, per-theme --monogram, shadow/ease tokens). Delete web/src/hosted/tokens.css (hex system); port hosted-only tokens (window-frame, nav-veil, grain) into the shared source. Purge shadcn-blue var() fallbacks (#2563eb/#dc2626/#16a34a family). Add the 2px micro radius token to DESIGN.md so spec prose and frontmatter agree.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-12T15:37:16Z","created_by":"James Lal","updated_at":"2026-07-12T15:54:00Z","started_at":"2026-07-12T15:41:19Z","closed_at":"2026-07-12T15:54:00Z","close_reason":"Landed: single token source web/src/tokens.css, hosted hex deleted, INK unified, verified both builds both themes","dependency_count":0,"dependent_count":3,"comment_count":0} -{"id":"attn-7xl.7.5.2","title":"Make hosted sharing actionable and simpler","description":"Staging feedback showed the persistence-risk checkbox rendered outside the share sheet, leaving Create review link disabled, while the configure phase exposed too much storage and delivery complexity at once. Scope the Markdown task-list checkbox CSS, make the required safety confirmation visibly actionable, and distill the initial share flow with advanced delivery settings collapsed.","acceptance_criteria":"After persistence is denied, the safety checkbox remains visibly inside the sheet and enables successful link creation when confirmed. The default flow prioritizes scope, safety only when needed, and one primary action; delivery modes remain available under Advanced settings. Chromium and WebKit interaction, mobile layout, axe, and screenshot checks pass.","notes":"Implementing from live staging screenshot feedback; no delegation.\nRoot cause: web/styles/base.css globally styled every input[type=checkbox] as an absolutely positioned Markdown task checkbox, painting the share acknowledgement at viewport x=0 and leaving the CTA apparently blocked. Scoped task styles to direct list-item inputs; distilled configure UI to scope first, conditional safety confirmation, collapsed Advanced delivery, and one primary action. Added deterministic best-effort persistence-denial fixture and end-to-end completion regression. Validated Svelte 0/0, 80 unit files, Chromium share/shell 25/25, focused WebKit 3/3, route suite 77/78 plus corrected focused case 1/1, axe, and desktop/mobile screenshots.\nReal staging smoke test found a second blocker after the UI gate: OPTIONS for first POST /v3/rooms/:roomId is forwarded to an uninitialized RoomDO, which cannot return allowBrowser=true before the policy exists, so relay staging omits CORS and the browser cannot bootstrap its first room. Adding a narrow allowlisted-origin create-preflight path plus integration tests.\nFull staging browser instrumentation confirmed room create (201), owner registration (204), envelope publication (201), and durable-share creation (201) succeed. Final snapshot PUT was blocked by CORS because Access-Control-Allow-Methods omitted PUT. Added PUT to the browser method allowlist and an exact durable snapshot preflight regression. Relay typecheck, focused CORS/share tests (33/33), and full relay suite (461/461) pass.\nA caught-exception trace identified the remaining client failure exactly: native window.fetch was stored in fetchImpl, then invoked as input.fetchImpl(...) inside owner registration, rebinding this to the helper input and throwing TypeError: Illegal invocation before any request. Both v2/v3 registration helpers now call a local unbound fetch reference. The relay stub asserts this is undefined, making the browser-native contract a unit regression. Svelte check 0/0 and all 80 web unit files pass.\nPost-deploy smoke advanced past owner registration and exposed the same receiver-binding bug in BrowserShareOwnerRelayClient during durable manifest/mailbox operations. All three direct native-fetch property calls now use an unbound local reference; the client relay stub is binding-sensitive too. Focused bootstrap/share-owner tests and Svelte check pass.\nThe first successful ready-state exposed a staging-origin mismatch: durable links were hard-coded to https://attn.sh even though the capability lives on relay-staging. The coordinator now uses browserReviewBase for invite origin, the strict parser accepts canonical staging.attn.sh links, and tests cover staging composition/round-trip. Focused share/owner/workspace-sharing tests and Svelte check pass.\nEnd-to-end smoke opened the staging /s invite and rendered the encrypted canary. Stop sharing revoked ShareDO (204) but the subsequent room DELETE 204 lost its browser handshake after META.policy was wiped, causing a false UI failure. RoomDO now snapshots policy only for bare DELETE and reuses allowBrowser only on successful 2xx teardown. Focused delete/CORS integration tests (33/33) and relay typecheck pass.\nFinal real staging smoke passed: fresh workspace -\u003e encrypted /s link ready -\u003e separate reviewer rendered the canary -\u003e Stop sharing completed -\u003e a new reviewer load was rejected. Staging web version 172f2d48-efd8-4b21-9957-cab53c150e67; relay version 15369e3f-3f99-479c-9fc4-1ae87e57ebdb. Final full gates: 80/80 web unit files, Svelte 0/0, 31/31 relay files and 461/461 relay tests.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-12T02:30:18Z","created_by":"James Lal","updated_at":"2026-07-12T03:50:53Z","started_at":"2026-07-12T02:30:45Z","closed_at":"2026-07-12T03:50:53Z","close_reason":"Closed","labels":["a11y","authoring","cloudflare","e2e","e2ee","ios","performance","security","sharing","storage","ux","web"],"dependencies":[{"issue_id":"attn-7xl.7.5.2","depends_on_id":"attn-7xl.7.5","type":"parent-child","created_at":"2026-07-11T20:30:17Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-7xl.7.5.1","title":"Repair hosted workspace and Markdown authoring workflows","description":"Live staging feedback found four connected authoring UX defects: the hosted workspace picker renders malformed and cannot create, switch, or rename workspaces; file rename/download/delete actions live in a distant sidebar footer rather than the file context menu; desktop ProseMirror has no visible formatting affordance and does not consume Mod+B/Mod+I; and the app's apparent drop zone does not accept drops. Repair these as one keyboard-first hosted-authoring workflow.","acceptance_criteria":"Hosted desktop exposes a correctly sized workspace switcher with existing workspace navigation plus New, Rename, and All workspaces actions. File rows expose Rename, Download, and Delete in their context menus and the footer no longer carries active-file CRUD buttons. Desktop authoring exposes restrained Markdown formatting controls; Mod+B and Mod+I format text inside ProseMirror without toggling app chrome, and common Markdown input rules render headings/lists as typed. The desk and import surface accept drag-and-drop files, and the workspace sidebar offers a clear drop/choose target for adding Markdown/assets. Chromium/WebKit keyboard, drag/drop, accessibility, and screenshot regressions pass.","notes":"Implementation plan: adapt the shared Sidebar/FileTree via explicit hosted callbacks rather than forked markup; bind ProseMirror formatting shortcuts/input rules and add a restrained desktop toolbar; centralize file/drop import conversion; cover workspace switching/actions, context menus, keyboard formatting/input rules, and drag/drop with Playwright screenshots in Chromium/WebKit.\nStaging regression follow-up: fixed global sidebar Mod+B ownership so editable/consumed events remain with ProseMirror; replaced workspace and entry rename reloads with reactive in-place refresh/history.replaceState; rebound autosave when the active Markdown path changes; extracted official ProseMirror input-rules/keymap authoring into a dedicated module. Verified 80 unit files, Svelte 0/0, production build + route bundle boundaries, Chromium routes 77/77, focused WebKit 2/2, and screenshot review.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-12T01:03:27Z","created_by":"James Lal","updated_at":"2026-07-12T02:04:46Z","started_at":"2026-07-12T01:03:45Z","closed_at":"2026-07-12T02:04:46Z","close_reason":"Staging regressions repaired and cross-browser validated; rename remains mounted/autosaves to the new path and Mod+B no longer toggles the sidebar.","labels":["a11y","authoring","cloudflare","e2e","e2ee","frontend","ios","performance","security","storage","ux","web"],"dependencies":[{"issue_id":"attn-7xl.7.5.1","depends_on_id":"attn-7xl.7.5","type":"parent-child","created_at":"2026-07-11T19:03:27Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-7xl.7.10.1","title":"Make WebKit writer-lease handoff reliable after the authority tab closes","description":"The staging WebKit route pass reproduces a stuck secondary tab. Tab A owns the workspace; duplicated Tab B correctly becomes read-only. After closing Tab A, Retry edit in Tab B remains contenteditable=false with lease-denied after repeated retries and more than 10 seconds. Chromium promotes correctly. Headless WebKit also reports session-only storage, so the validation must distinguish the storage fallback from the lease-release/election defect and verify real Safari behavior.","acceptance_criteria":"After the writer/authority tab closes or crashes, a remaining WebKit/Safari tab acquires authority on retry or automatic election within the documented bounded interval, without multiple writers or data loss. Add WebKit and real Safari coverage for close, crash/expiry, duplicate-tab identity, and private/session-only fallback. Keep the broader simultaneous-authoring convergence criteria on the parent feature.","notes":"Implementation plan: keep atomic fenced acquisition, but when a user retries a denied WebKit lease, poll with bounded exponential backoff through the existing 15-second TTL plus grace because page.close may abandon async release. Test: two passive WebKit successors race after writer close and exactly one becomes writable; retain existing Chromium fast handoff.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-11T23:48:11Z","created_by":"James Lal","updated_at":"2026-07-12T00:46:56Z","started_at":"2026-07-11T23:56:23Z","closed_at":"2026-07-12T00:46:56Z","close_reason":"Implemented bounded WebKit lease-expiry handoff with atomic fenced election and a race regression proving exactly one successor becomes writable. Chromium fast handoff and WebKit storage suites pass.","labels":["a11y","authoring","cloudflare","e2e","e2ee","frontend","ios","multitab","performance","safari","security","storage","web"],"dependencies":[{"issue_id":"attn-7xl.7.10.1","depends_on_id":"attn-7xl.7.10","type":"parent-child","created_at":"2026-07-11T17:48:10Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-7xl.7.3.1","title":"Make the hosted editor fully tappable and repair authoring focus semantics","description":"Live 390x844 validation found the editable ProseMirror ends at y=512 while the fixed dock starts at y=787; tapping the roughly 275px blank writing canvas focuses main.editor-canvas, not the editor. The shared ProseMirror also has no textbox role, accessible name, or aria-multiline and is absent from getByRole(textbox). Desktop Rename workspace, Rename file, and New Markdown replace their trigger with an input but leave focus on BODY. The inline file-delete alertdialog keeps focus outside the alert and Cancel returns focus to BODY.","acceptance_criteria":"In mobile edit mode the editor activation surface fills the usable canvas above the dock and tapping any blank writing area focuses ProseMirror. The editable surface exposes an appropriate labelled multiline textbox semantic without changing native behavior. Inline rename/create inputs receive and select focus when opened; Escape/commit restore focus. File-delete confirmation moves focus into the alert, traps or scopes interaction appropriately, and restores focus to Delete on cancel. Keyboard and screen-reader tests cover desktop and 390px mobile.","notes":"Implementation plan: add labelled multiline textbox attributes to the shared EditorView and keep aria-readonly synchronized; make the mobile writing-sheet/editor mount a full-height flex chain; add explicit focus/select and focus-return helpers for title/file rename, New Markdown, and delete confirmation. Tests: keyboard-only transient controls, ProseMirror semantics, and 390x844 blank-canvas activation.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-11T23:48:07Z","created_by":"James Lal","updated_at":"2026-07-12T00:47:16Z","started_at":"2026-07-11T23:56:23Z","closed_at":"2026-07-12T00:47:16Z","close_reason":"Implemented full-height mobile ProseMirror targeting, labelled multiline textbox semantics, and deterministic focus entry/return for rename, create, and delete confirmation. Axe, keyboard, mobile geometry, Chromium, and WebKit tests pass.","labels":["a11y","authoring","cloudflare","e2e","e2ee","focus","ios","mobile","performance","security","storage","web"],"dependencies":[{"issue_id":"attn-7xl.7.3.1","depends_on_id":"attn-7xl.7.3","type":"parent-child","created_at":"2026-07-11T17:48:06Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-7xl.7.12","title":"Remove full-canvas focus rectangle from hosted ProseMirror","description":"The hosted global :focus-visible rule overrides the shared ProseMirror canvas outline:none. Since the desktop contenteditable now correctly fills the viewport, focusing it draws a loud rust rectangle around the whole document. Preserve focus indication through the editor caret/selection without outlining the canvas.","acceptance_criteria":"Focused hosted desktop and mobile ProseMirror canvases have no full-surface outline or border, retain a visible caret and editable behavior, and other keyboard-focusable controls keep their focus rings. Source and browser evidence confirms hosted and native consume the shared Editor.svelte/ProseMirror implementation. Chromium regression and Svelte checks pass.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-11T23:09:42Z","created_by":"James Lal","updated_at":"2026-07-11T23:13:49Z","closed_at":"2026-07-11T23:13:49Z","close_reason":"Confirmed hosted and native mount the shared Editor.svelte and real ProseMirror EditorView. Fixed the hosted unlayered :focus-visible override so the canvas uses caret/selection without a full rust rectangle, while controls retain their 2px focus ring. Svelte check, browser build, 68 routed E2E, and focused Chromium/WebKit mobile tests pass.","labels":["a11y","authoring","cloudflare","e2e","e2ee","frontend","ios","performance","security","storage","svelte","web"],"dependencies":[{"issue_id":"attn-7xl.7.12","depends_on_id":"attn-7xl.7","type":"parent-child","created_at":"2026-07-11T17:09:41Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.7.12","depends_on_id":"attn-7xl.7.11","type":"discovered-from","created_at":"2026-07-11T17:09:43Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-7xl.7.11","title":"Make hosted desktop editor full-height and permanently editor-first","description":"The hosted desktop ProseMirror activation/focus area collapses to the content height, leaving most of the document canvas non-interactive. Desktop also exposes an Edit/Done mode toggle even though desktop authoring should remain editor-first; only mobile should start read-only and toggle editing.","acceptance_criteria":"At desktop widths the editable surface fills the available document viewport and clicking blank canvas space focuses the editor. Desktop has no Edit or Done mode control and remains editable whenever it owns the lease. A genuine passive second tab remains read-only with Retry edit, while mobile remains reader-first with Edit/Done. Chromium visual/interaction regressions and Svelte checks pass.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-11T19:49:16Z","created_by":"James Lal","updated_at":"2026-07-11T19:56:58Z","closed_at":"2026-07-11T19:56:58Z","close_reason":"Desktop ProseMirror now flex-fills the document viewport and accepts clicks in blank canvas space. Normal desktop Edit/Done chrome was removed; only genuine lease denial exposes Retry in its banner. Mobile remains reader-first and desktop editing restores after responsive transitions. Svelte check, browser build, 77 unit files, 61 routed E2E, and 30 Chromium/WebKit storage-reader tests pass.","labels":["a11y","authoring","cloudflare","e2e","e2ee","frontend","ios","performance","responsive","security","storage","svelte","web"],"dependencies":[{"issue_id":"attn-7xl.7.11","depends_on_id":"attn-7xl.7","type":"parent-child","created_at":"2026-07-11T13:49:16Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.7.11","depends_on_id":"attn-7xl.7.9","type":"discovered-from","created_at":"2026-07-11T13:49:17Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-7xl.7.10","title":"Coordinate simultaneous browser-tab authoring through one fenced authority","description":"Allow more than one browser tab to edit the same local workspace without granting multiple IndexedDB writers. Keep one fenced storage authority and proxy ProseMirror steps, committed heads, presence, and authority handoff over BroadcastChannel (or an equivalent same-origin coordinator). Secondary tabs must converge live and survive authority closure without last-writer-wins data loss.","acceptance_criteria":"Two tabs can concurrently edit one Markdown entry and both converge on the same durable document; edits to different entries remain durable; only one tab holds the storage fence at any time; closing or crashing the authority elects a remaining tab; mobile stays reader-first until Edit; duplicate/reload/navigation and Chromium/WebKit regressions pass.","notes":"2026-07-19 handoff evidence: seamless cross-tab lease handoff shipped (47234ab) — denied tabs ring a handoff doorbell on real intent (click/keypress/focus-since-yield), the live holder acks + flushes + yields (~40-190ms measured claims both directions), dead holders are force-taken after an ack-guarded grace, structural mutations claim through the same path, and the 'Another tab is editing' wall is deleted. Fenced single-writer storage authority preserved throughout (zombie mid-close runtime fixed; graceful-release commit gate prevents stale-mirror overwrite). SHIPPING MODEL: the non-owner tab is a live READ-ONLY mirror (owner keystrokes stream in ~1s, verified) and any interaction reclaims the pen — editing follows intent rather than simultaneous CRDT co-typing; the joinLive editable-follower never engages (attn-oldm). Remaining blockers before close: attn-x1k (reclaim can render an empty doc — gate hosted-authoring.spec:426 red), the WebKit project pass, and James's call on whether the intent-handoff model satisfies the 'concurrently edit and converge' criterion or joinLive must be fixed first.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-11T19:38:41Z","created_by":"James Lal","updated_at":"2026-07-20T14:15:52Z","started_at":"2026-07-19T23:18:46Z","closed_at":"2026-07-20T14:15:52Z","close_reason":"All acceptance criteria gate-verified: (1) two tabs concurrently edit one Markdown entry and converge on the same durable document — join-first co-editing through ONE fenced authority, converged text survives reload (hosted-authoring:426, 5x green); (2) different-entry edits durable (storage suite); (3) exactly one storage-fence holder at any time (fenced lease + intent-graded handoff with holder veto — no pen thrash); (4) closing/crashing the authority elects a survivor — proven behaviorally on WebKit (edit in one survivor converges into the other through the rebuilt hub); (5) mobile reader-first until Edit, and the first Edit tap now absorbs the join connection; (6) duplicate/reload/navigation and Chromium+WebKit regressions pass (85/85 routes + 31/31 storage). Architecture notes: intent-graded handoff-request/ack/veto protocol on the lease channel; join-first denied policy; __attnCollabDebug probe for future diagnosis. Deployed to staging 06da741d.","labels":["a11y","authoring","cloudflare","e2e","e2ee","frontend","ios","multitab","performance","security","storage","web"],"dependencies":[{"issue_id":"attn-7xl.7.10","depends_on_id":"attn-7xl.7","type":"parent-child","created_at":"2026-07-11T13:38:40Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.7.10","depends_on_id":"attn-7xl.7.9","type":"discovered-from","created_at":"2026-07-11T13:38:41Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-7xl.7.9","title":"Fix stale browser writer lease after workspace navigation","description":"A user can create/open a staging workspace in a single visible tab and immediately land in Retry edit / Another tab is editing. Diagnose and fix route/page lifecycle lease release or reuse so a tab cannot conflict with its own stale writer lease. Preserve honest read-only behavior for a genuinely separate tab.","acceptance_criteria":"Create, rename, reload, file navigation, and reopening a workspace in one tab stay editable without showing the competing-tab banner. A genuinely concurrent second tab remains read-only until the writer closes, then Retry edit succeeds. Chromium staging regression coverage passes.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-11T19:31:34Z","created_by":"James Lal","updated_at":"2026-07-11T19:42:57Z","started_at":"2026-07-11T19:31:46Z","closed_at":"2026-07-11T19:42:57Z","close_reason":"Fixed false competing-tab leases with a reload-stable per-tab identity plus BroadcastChannel duplicate detection; mobile readers now defer authority until Edit. Added rename/reload/navigation/duplicated-tab/mobile regressions. Svelte check, browser build, 77 unit files, 59 routed E2E, and 30 Chromium/WebKit storage-reader tests pass.","labels":["a11y","authoring","cloudflare","e2e","e2ee","frontend","ios","multitab","performance","security","storage","web"],"dependencies":[{"issue_id":"attn-7xl.7.9","depends_on_id":"attn-7xl.7","type":"parent-child","created_at":"2026-07-11T13:31:33Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.7.9","depends_on_id":"attn-7xl.7.7","type":"discovered-from","created_at":"2026-07-11T13:31:35Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-7xl.7.7","title":"Converge desktop browser editor UX with native attn","description":"Desktop browser authoring currently renders a separate prototype shell instead of the native attn editor UX. Reuse the native editor, file navigation, review chrome, and rail vocabulary for desktop browser-owned workspaces while retaining the reader-first mobile layout and bottom sheets for constrained screens.","acceptance_criteria":"At desktop widths, browser-owned workspaces visibly and behaviorally align with native attn: shared editor surface, file navigation vocabulary, document header/actions, and collapsible review margin. Browser-specific storage/export controls remain available without dominating the editor. At mobile widths, the existing reader-first single-column flow, thumb dock, files/review sheets, keyboard-safe editing, and 320px no-overflow behavior remain intact. Desktop and mobile Playwright screenshots and interaction tests pass, along with Svelte checks and web unit/build gates.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-11T18:19:12Z","created_by":"James Lal","updated_at":"2026-07-11T19:12:43Z","started_at":"2026-07-11T18:19:15Z","closed_at":"2026-07-11T19:12:43Z","close_reason":"Desktop hosted authoring now consumes the shared native workspace frame, Sidebar, PathBreadcrumb, Editor, ReviewBar, and review rail; mobile remains rendered reader-first with dock/sheets. Native/hosted builds, 77 unit files, 57 routed E2E tests, 22 Chromium/WebKit reader tests, route bundle gates, axe, offline, and visual captures pass. Follow-up icon-registry chunk optimization tracked in attn-7xl.7.8.","labels":["a11y","authoring","cloudflare","e2e","e2ee","frontend","ios","performance","responsive","security","storage","svelte","web"],"dependencies":[{"issue_id":"attn-7xl.7.7","depends_on_id":"attn-7xl.7","type":"parent-child","created_at":"2026-07-11T12:19:12Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-02a.7","title":"Prevent malformed durable mailbox items from wedging share rotation","description":"ShareDO accepts opaque writable-bundle mailbox objects while native owner drain aborts the entire batch on one malformed or out-of-tier item. Routing/epoch changes are blocked while mail remains, allowing one hostile bearer to wedge a stable share.","acceptance_criteria":"Malformed, misrouted, and out-of-tier mailbox items are quarantined or terminally acknowledged without blocking later valid items, renewal, rotation, or revoke. Relay stays content-blind; validation uses only bounded outer routing metadata and owner-side authenticated decryption. Deterministic poison-before-valid and rotation tests pass.","notes":"Approach: keep ShareDO content-blind but strictly validate bounded outer review_submission routing (v, type, shareId, epoch, roomId, tier, device registration, envelope array/count/ids) before storage. Native owner drains items independently: authenticates/decrypts each, imports valid items exactly once, terminally ACKs/quarantines malformed/misrouted/out-of-tier poison, and continues to later valid mail. Add poison-before-valid plus renew/rotate/revoke tests proving no wedge.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-11T13:59:12Z","created_by":"James Lal","updated_at":"2026-07-11T16:14:37Z","started_at":"2026-07-11T15:01:55Z","closed_at":"2026-07-11T16:14:37Z","close_reason":"Added strict content-blind mailbox framing, terminal poison quarantine, durable ACK retry intent, and exactly-once regression coverage","dependencies":[{"issue_id":"attn-02a.7","depends_on_id":"attn-02a","type":"parent-child","created_at":"2026-07-11T07:59:12Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-eaw","title":"Native second-room WS subscriber stalls after one process hosts multiple rooms","description":"A hidden owner daemon hosting room A then room B can stop advancing the owner mailbox WS cursor for room B. Reviewer POSTs and ACKs ParticipantJoined seq5 and CommentCreated seq6; relay accepts them and owner socket is 101, but owner cursor remains seq4 and ReviewStore never imports the remote events. Room A works. Reproduced by scripts/test-native-async-e2e.sh multi-room variant against local Wrangler.","acceptance_criteria":"One resident process can host at least two active review rooms; authenticated comments in each advance the correct owner cursor and import exactly once. Add deterministic multi-room WS regression coverage.","notes":"Merge audit adds two acceptance cases to the high-watermark fix: re-fence oldestRetainedSeq as well as serverSeq before hello so concurrent ACK/FIFO deletion cannot create a replay gap; announce/deliver presence only after subscribe so no pre-hello presence or unsubscribed online state.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-11T10:09:11Z","created_by":"James Lal","updated_at":"2026-07-11T16:14:34Z","started_at":"2026-07-11T13:23:19Z","closed_at":"2026-07-11T16:14:34Z","close_reason":"Fixed subscriber ordering and replay fencing; native multi-room and full share gates pass","dependencies":[{"issue_id":"attn-eaw","depends_on_id":"attn-02a.4.4","type":"discovered-from","created_at":"2026-07-11T04:09:11Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-7xl.4.4.4","title":"Wire hosted owner and reviewer authority UX","description":"Connect hosted owner/reviewer editor state, review store, collaboration controller, injectable review actions, and honest status/read-only UI to the real authority services.","design":"Keep durable review and live authority distinct in service and UI state. Expose action ports from ReviewMargin components. Do not couple this task to the Share sheet work tracked by attn-7xl.4.5.","acceptance_criteria":"Owner online enables epoch-bound live editing; owner offline shows 'Owner offline · Review still available' while comments/suggestions still queue; a passive owner tab is visibly read-only; reconnect converges; accept/reject/apply use injected browser actions rather than native IPC.","notes":"Runtime/service core complete: BrowserOwnerWorkspaceRuntime now owns one route-lifetime lease, provides local-only/passive/loss states, serializes fenced autosave/actions/rollover, reconstructs exactly one promoted share and owner bindings, resumes/discards stale pending publication safely, uses a scoped live owner outbox for action/rollover publish+reseed, returns durable acceptance on later publication failure, caches opaque reviewed three-way tokens for explicit apply, exposes exact published collab seeds and durable comment/retry ports, and closes a sibling session storage connection without closing app storage. BrowserWorkspaceService/RealWorkspaceAppService/EditingSession/mock expose the runtime ports without BrowserStorage. Focused fake-IDB runtime tests 5/5, authority 17/17, workspace service 10/10, npm check clean, full npm test 66 files/0 failures. EditorShell/ReviewMargin/Card/BrowserReviewApp were not edited by this slice.\nCompleted hosted authority UX end to end. Reviewer editor now uses an epoch-bound CollabController, accepts only directory-authenticated owner authority, keeps durable review authoring available while owner presence pauses, surfaces the literal offline status, and resyncs on reconnect. Owner route acquires one immediate route-lifetime fence, distinguishes passive/local writable/live authority states, mounts the exact published seed, and injects browser accept/reject/reviewed-apply/reply/resolve/retry ports with no native IPC fallback. Review actions wait for durable outcomes, prevent double-submit, preserve three-way review on semantic drift, and hand durable delivery-pending state to the outbox. Added runtime recovery, sibling storage ownership, shared desk status, passive-tab test updates, and paused-startup/stale-head regression coverage. Validation: svelte-check clean; 66 web unit files / 0 failures; browser build + service worker and route bundle boundaries pass; 29 hosted authoring/shell Playwright tests pass; desktop/mobile screenshots visually reviewed; git diff --check clean.","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-11T05:57:52Z","created_by":"James Lal","updated_at":"2026-07-11T08:01:49Z","started_at":"2026-07-11T07:34:08Z","closed_at":"2026-07-11T08:01:49Z","close_reason":"Hosted owner and reviewer authority UX is wired, fenced, and validated","labels":["authoring","cloudflare","e2ee","interop","ios","sharing","storage","web","webrtc"],"dependencies":[{"issue_id":"attn-7xl.4.4.4","depends_on_id":"attn-7xl.4.4","type":"parent-child","created_at":"2026-07-10T23:57:51Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-7xl.4.4.3","title":"Implement fenced browser owner authority and idempotent review actions","description":"Compose the owner session, collab controller, workspace persistence, single-tab fenced lease, and browser accept/reject/apply pipeline. Make content mutation and accepted-event authoring crash-safe and idempotent.","design":"Reuse the workspace fenced lease for authority, autosave, and apply. Use deterministic revision/action receipts and an atomic local transaction containing the new head plus exact sealed terminal-event outbox record. Prefer a Rust/WASM bridge or shared native vectors for apply parity.","acceptance_criteria":"Only one tab owns authority; lease loss closes transport and fences stale writes; restart/takeover cannot apply a suggestion twice; browser apply matches native drift policy and emits one durable terminal event; rejection is durable before optimistic dismissal.","notes":"Authority composition must preload the sealed room/file/epoch checkpoint, pass the active WorkspaceFence and expected authority version into putCollabCheckpoint, surface onAuthorityPaused, and roll over by publishing a fresh snapshot epoch before the 64 KiB/4096-step checkpoint cap pauses live editing. The checkpoint API now enforces base snapshot == epoch, epoch-keyed rows, same-epoch version CAS, and lease fencing.","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-11T05:57:50Z","created_by":"James Lal","updated_at":"2026-07-11T07:33:00Z","started_at":"2026-07-11T06:42:48Z","closed_at":"2026-07-11T07:33:00Z","close_reason":"Implemented fenced browser owner authority with eager sealed checkpoint recovery, a single live lease/fence, generation-gated and quiesced collaboration, bounded transition delivery barriers, full promoted-manifest reseeding, and commit-scoped trusted terminal authoring. Added native-parity accept/reject/apply with deterministic idempotent receipts, one-transaction revision plus sealed terminal outbox durability, automatic live adoption/retry, exact revision publication binding, and promotion-time head/hash revalidation. Validated with 63 web test files, production browser/PWA builds and route bundle gate, plus Rust fmt/clippy and 444 passing library tests.","labels":["authoring","cloudflare","e2ee","interop","ios","sharing","storage","web","webrtc"],"dependencies":[{"issue_id":"attn-7xl.4.4.3","depends_on_id":"attn-7xl.4.4","type":"parent-child","created_at":"2026-07-10T23:57:50Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-7xl.4.4.2","title":"Add browser owner session, presence, and collab transport","description":"Add an explicit recovered owner principal to BrowserSession, authenticated owner startup, owner presence, broadcast collab signals, direct-plus-relay delivery, and honest live-authority state. Fix same-device relay socket presence semantics.","design":"Owner credentials are reconstructed and fully cross-checked from sealed capability. Keep connection and owner authority as separate state dimensions. Route collab to a public callback, not peer negotiation. Signals are ephemeral; durable review remains in the event outbox.","acceptance_criteria":"Recovered owner starts with authenticated GET and WS only, never registers as reviewer or emits reviewer join; target-null collab signals interoperate; exact envelope direct+relay dispatches once; owner presence gates live editing but not durable review; same-device presence leaves only on the final socket.","notes":"BrowserSession collab delivery must pass authenticated directory role context so reviewer controllers can accept broadcasts only from kind=owner. sendCollab should return a rejecting Promise on transport failure; controller/client now recover inflight state and resync. Missing wire epochs are accepted only by explicit legacy controllers.","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-11T05:57:49Z","created_by":"James Lal","updated_at":"2026-07-11T06:42:10Z","started_at":"2026-07-11T06:22:26Z","closed_at":"2026-07-11T06:42:10Z","close_reason":"Completed recovered browser-owner GET/WS-only startup, fully validated sealed credentials and authenticated policy/directory, honest owner presence/live-authority state, target-null collab direct+relay exact-envelope delivery with retry-safe dedup, and aggregate same-device relay presence including peer-cap race hardening. Full web/relay tests, static checks, production browser build, route bundle gate, and adversarial re-audit all pass.","labels":["authoring","cloudflare","e2ee","interop","ios","sharing","storage","web","webrtc"],"dependencies":[{"issue_id":"attn-7xl.4.4.2","depends_on_id":"attn-7xl.4.4","type":"parent-child","created_at":"2026-07-10T23:57:48Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-7xl.4.4.1","title":"Harden epoch-bound collab authority and checkpoint recovery","description":"Make collaboration authority batch-atomic, strictly parsed, epoch-bound to a published snapshot, serialized, and recoverable from workspace-key-sealed per-room/per-file checkpoints. Repeated or stale submissions must receive catch-up without duplicate application.","design":"Use a temporary document for validation and commit a whole batch only after every step succeeds. Persist checkpoint state under workspace recovery storage. Bind submit/broadcast/resync to the current published snapshot epoch. Keep durable review independent from collab availability.","acceptance_criteria":"A multi-step failure mutates nothing; authority checkpoints restore version/log after reload; stale/repeated submissions converge via catch-up; epoch mismatches fail closed; accepted batches persist before broadcast.","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-11T05:57:47Z","created_by":"James Lal","updated_at":"2026-07-11T06:21:54Z","started_at":"2026-07-11T05:57:59Z","closed_at":"2026-07-11T06:21:54Z","close_reason":"Implemented and security-audited epoch-bound browser collab core: atomic prepare/persist/commit/broadcast, authenticated-owner broadcast gating, strict legacy-aware wire parsing, stale catch-up/resync, sealed checkpoint restore, workspace-key AAD, snapshot-base binding, epoch-keyed/version-CASed/lease-fenced persistence, reload-to-controller integration, and visible pause seam. Validation: 61 web test files/0 failures, svelte-check 0/0, production browser build and route bundle gate pass, final adversarial re-audit found no blockers.","labels":["authoring","cloudflare","e2ee","interop","ios","sharing","storage","web","webrtc"],"dependencies":[{"issue_id":"attn-7xl.4.4.1","depends_on_id":"attn-7xl.4.4","type":"parent-child","created_at":"2026-07-10T23:57:46Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-7xl.4.3.1","title":"Add canonical workspace manifest, asset snapshots, and browser Rust anchor index","description":"## Objective\n\nDefine and implement the missing canonical native/browser workspace snapshot protocol required by attn-7xl.4.3: versioned manifests, arbitrary binary asset payloads, stable entry identities, and the exact Rust/comrak anchor index in browser-owned Markdown snapshots.\n\n## Constraints\n\n- Additive coordinated native+browser protocol; never a web-only shape.\n- Preserve existing Markdown/HTML canonical JSON bytes.\n- Normalized root-relative paths only; no OPFS/local paths.\n- Relay/R2 remain content-blind.\n- Browser must use the canonical Rust indexer (WASM/shared crate), not a TypeScript approximation.","design":"Use a docType-tagged SnapshotPlaintext union. Add asset payloads with unpadded base64url bytes + mediaType, and workspace_manifest with sorted manifest entries {fileId,snapshotId,path,kind,mediaType?,byteLength,contentHash}. Publish entries first and the manifest last; advance the sealed share publication pointer only after all durable ACKs. Extract/reuse the Rust comrak anchor builder for the browser through WASM.","acceptance_criteria":"Rust and browser canonical vectors match byte-for-byte for Markdown, HTML, asset, and workspace_manifest payloads; nested paths and arbitrary binary bytes round-trip browser↔native; browser Markdown carries the exact Rust-built AnchorIndex; old Markdown/HTML bytes remain unchanged; tamper/path/media/length/hash checks fail closed; mailbox and R2 paths stay content-blind.","notes":"Approach: preserve existing markdown/html canonical JSON by keeping the Rust SnapshotPlaintext struct shape compatible while adding validated optional fields/docType variants for asset and workspace_manifest; mirror it as a strict TS union. Publish normalized, path-sorted entry snapshots first and a signed manifest snapshot last; persist stable FileId mappings + manifest pointer under the sealed workspace share capability; mark published only after every durable ACK. Expose the exact existing src/review/anchors/index.rs implementation to the browser through a small wasm-pack crate that compiles the same source via a compatible module shim, then dynamically load it from the publisher (never a TS approximation or landing/app static import). Files: new crates/attn-anchor-wasm/** + generated web loader/package; Rust src/review/model.rs, bootstrap.rs, manager.rs and focused tests/vectors; web types.ts, browser-session.ts, browser-snapshot-publisher.ts/tests, browser-workspace-share.ts/tests. Test plan: old markdown/html canonical bytes unchanged; Rust/browser full-byte vectors for markdown/html/asset/manifest; invalid UTF-8/arbitrary binary/NUL; strict normalized paths/sort/duplicates/media/hash/length/tamper; exact Rust WASM AnchorIndex parity; mailbox/R2 ordering/retry/content blindness; native/browser hydration. Baseline: cargo check -p attn exit 0; cargo nextest baseline external relay quota failure at three_peer_comment_converges_async after 823 pass; npm check/test exit 0 (58 files).","spec_id":"planning/web-authoring/03-browser-sharing.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-11T04:14:57Z","created_by":"James Lal","updated_at":"2026-07-11T05:49:36Z","started_at":"2026-07-11T05:14:47Z","closed_at":"2026-07-11T05:49:36Z","close_reason":"Canonical workspace manifest/assets and exact Rust/WASM anchor protocol implemented, interoperably validated, crash-safe, and security-reviewed.","labels":["assets","authoring","cloudflare","crypto","e2ee","interop","ios","sharing","storage","web","webrtc"],"dependencies":[{"issue_id":"attn-7xl.4.3.1","depends_on_id":"attn-7xl.4.3","type":"parent-child","created_at":"2026-07-10T22:14:56Z","created_by":"James Lal","metadata":"{}"}],"comments":[{"id":"019f4fb9-57a2-7b42-aee8-871a086207d2","issue_id":"attn-7xl.4.3.1","author":"James Lal","text":"Completed coordinated native/browser workspace snapshot protocol: byte-stable legacy Markdown/HTML, canonical arbitrary-binary assets, strict manifest schema/path/ID/hash/media validation, stable synthetic manifest identity, exact main-target Rust/comrak AnchorIndex through reproducibly pinned WASM, manifest-last mailbox/R2 publishing, atomic sealed publication journal + ciphertext batch, last-ACK promotion, exact reload recovery, stop-race CAS, fail-closed native/browser hydration and inert asset/manifest UI. Validation: cargo fmt/clippy clean; cargo nextest 972 passed/10 skipped; web check 0/0; 60 web test files green; WASM crate 24 passed and reproducible 5-artifact rebuild; route bundle gate green; real relay live proof passed nested Markdown + 1.1MB arbitrary binary R2 + final manifest with content-blind replay; post-fix security audit safe to close. Real hosted-browser/iOS runtime remains in the explicit device validation gate.","created_at":"2026-07-11T05:49:34Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} -{"id":"attn-7xl.2.9","title":"Device gate: real iOS Safari validation for workspace storage v3","description":"HUMAN/DEVICE GATE (cannot be automated). From planning/web-authoring/01-storage-foundation.md: on a current iPhone/iPad, exercise workspace storage in a normal Safari tab, Private Browsing, and an installed Home Screen app; verify denied/best-effort persistence behavior, storage clear, and low-storage simulation. Consolidate with the attn-7xl.7 real-device matrix session — all automated validation (unit, fault-injection, property, Chromium/WebKit) is green per planning/web-authoring/validation-01.md.","notes":"2026-07-20: consolidated one-session checklist ready — planning/web-authoring/device-checklist-2026-07.md section A (normal tab / Private Browsing / Home Screen app / low-storage) against staging. All automated validation green per validation-01.md; this bead closes on James's device evidence.","status":"open","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-11T01:38:34Z","created_by":"James Lal","updated_at":"2026-07-20T22:51:56Z","labels":["assets","authoring","cloudflare","crypto","e2ee","indexeddb","ios","opfs","storage","web"],"dependencies":[{"issue_id":"attn-7xl.2.9","depends_on_id":"attn-7xl.2","type":"parent-child","created_at":"2026-07-10T19:38:34Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-02a.1.4","title":"Validate: acceptance gate E2E (worktree -\u003e suggest -\u003e wait -\u003e verdicts)","description":"Integration validation of the full agent gate loop. Individual tasks verified their pieces; this proves the contract: nothing continues on an unresolved queue.\n\n## Integration Scenarios\n- Agent worktree edit -\u003e submit-suggestion --from-diff (multi-hunk) -\u003e verdicts --wait blocks -\u003e owner accepts hunk 1, rejects hunk 2 -\u003e wait returns 0 with mixed map; accepted hunk's resulting_hash matches owner's on-disk file\n- Timeout path: no verdict within --timeout -\u003e non-zero exit, partial map\n- --as-agent identity: verdicts scoped to the agent's own suggestions only\n\n## E2E Test Commands\n- source scripts/lib/dual-instance.sh harness script (add scripts/test-gate-e2e.sh, wire task test:gate)\n- cargo test e2e_ gate cases alongside src/review/apply.rs e2e_* pattern\n\n## Acceptance Criteria\n- Full loop passes on dual-instance with relay (scripts/dev-collab.sh stack)\n- No polling anywhere in the wait path\n\n## Plan Reference\n- planning/sharing/README.md (Workstream D)","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-10T23:58:49Z","created_by":"James Lal","updated_at":"2026-07-11T02:28:43Z","started_at":"2026-07-11T02:10:05Z","closed_at":"2026-07-11T02:28:43Z","close_reason":"Validated permanent gate E2E: 2-hunk diff, scoped identities, timeout partial JSON, event-driven blocking wait, mixed owner accept/reject, on-disk hash equality; focused Rust tests and full relay script pass.","dependencies":[{"issue_id":"attn-02a.1.4","depends_on_id":"attn-02a.1","type":"parent-child","created_at":"2026-07-10T17:58:48Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.1.4","depends_on_id":"attn-02a.1.1","type":"blocks","created_at":"2026-07-10T18:01:16Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.1.4","depends_on_id":"attn-02a.1.2","type":"blocks","created_at":"2026-07-10T18:01:17Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.1.4","depends_on_id":"attn-02a.1.3","type":"blocks","created_at":"2026-07-10T18:01:18Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":3,"dependent_count":0,"comment_count":0} -{"id":"attn-02a.1.2","title":"Implement attn review verdicts --wait (block on verdict stream)","description":"Block until every listed suggestion (default: all own pending) has a verdict; exit 0 with the JSON verdict map, non-zero on --timeout. Park on the inbound verdict envelope stream (WS/DataChannel push already flows through the transport) — wait on a condition, never poll.\n\n## Files\n- src/cli_review.rs (--wait, --for \u003cid,...\u003e, --timeout \u003cdur\u003e)\n- src/review/manager.rs (verdict subscription surface for the CLI/daemon boundary)\n\n## Approach\nRegister interest in the target suggestion set, drain current state first (verdicts may already be complete), then await import-pipeline notifications. Timeout path exits non-zero with partial map on stderr/JSON. This is the agent gate: submit* -\u003e verdicts --wait -\u003e branch.\n\n## Verification\n- cargo build\n- cargo test (wait resolves on already-complete set without blocking; resolves on late-arriving verdict; times out non-zero)\n- Smoke: dual-instance — reviewer runs verdicts --wait in background, owner accepts, wait returns 0 with the map\n\n## Plan Reference\n- planning/sharing/README.md (Workstream D)","notes":"Approach: add a tokio::watch verdict revision signal to ReviewManager; subscribe before reading persisted verdicts, snapshot the default target as all own pending suggestions (or use explicit --for ids), return immediately if complete, otherwise await watch::Receiver::changed with an optional deadline. Notify only after imported SuggestionAccepted/SuggestionRejected events (already persisted by InboundPipeline). Extend daemon socket with a typed wait request/response and run wait on a per-connection handler so other daemon commands remain responsive. CLI adds --wait, --for comma-list, --timeout duration; wait implies JSON; timeout prints partial JSON to stderr and exits non-zero. Files: src/review/manager.rs, src/daemon.rs, src/cli_review.rs. Tests: already-complete returns immediately; late persisted verdict wakes without polling; timeout returns partial state; CLI duration/options; concurrent socket responsiveness.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-10T23:58:47Z","created_by":"James Lal","updated_at":"2026-07-11T01:22:27Z","started_at":"2026-07-11T00:55:14Z","closed_at":"2026-07-11T01:22:27Z","close_reason":"Implemented and validated event-driven verdict waiting with fixed own-pending/explicit target sets, race-free subscribe-before-drain watch notifications, typed daemon streaming boundary, timeout partial JSON/non-zero exit, and responsive concurrent socket handling. Tests: 21 verdict-focused executions pass; cargo build/fmt/clippy pass; real relay dual-daemon smoke proved block-before-accept and wake-on-import with matching resulting_hash; release binary 31.08 MiB under 32 MiB.","dependencies":[{"issue_id":"attn-02a.1.2","depends_on_id":"attn-02a.1","type":"parent-child","created_at":"2026-07-10T17:58:46Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.1.2","depends_on_id":"attn-02a.1.1","type":"blocks","created_at":"2026-07-10T18:01:09Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} -{"id":"attn-02a.1.1","title":"Implement attn review verdicts --json","description":"Dump verdict state for a room's suggestions from the already-persisted SuggestionAccepted/SuggestionRejected envelopes: per suggestion `pending | accepted | rejected`, with resulting_hash for accepted ones. Default scope: the calling identity's own suggestions; `--all` for everything.\n\n## Files\n- src/cli_review.rs (new subcommand + daemon routing)\n- src/review/manager.rs / src/review/store (verdict-state query over the event log)\n\n## Approach\nDerive verdict state by folding SuggestionAccepted/SuggestionRejected over suggestions in the room store — no new persisted state. Honor --as-agent for identity scoping. Output stable JSON (document the shape in the CLI help).\n\n## Verification\n- cargo build\n- cargo test verdicts (new unit tests: pending/accepted/rejected fold, resulting_hash surfaced)\n- Smoke: source scripts/lib/dual-instance.sh; owner accepts one suggestion; attn_reviewer review verdicts --json shows accepted with resulting_hash matching on-disk hash\n\n## Plan Reference\n- planning/sharing/README.md (Workstream D)","notes":"Approach: add a strict persisted-event fold keyed by suggestion_id, joining SuggestionCreated creator identity to latest accepted/rejected verdict; aggregate all persisted rooms in deterministic BTreeMap order; default scope to daemon or --as-agent participant, with --all disabling scope. Expose a synchronous ReviewManager query over the daemon socket and print documented snake_case JSON. Files: src/review/store.rs, src/review/manager.rs, src/daemon.rs, src/cli_review.rs. Tests: pending/accepted/rejected fold, resulting_hash, creator identity scope/--all, deterministic multi-room JSON, socket routing and missing-manager error. Validation: cargo fmt --check; cargo test verdicts; cargo build; dual-instance smoke.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-10T23:58:45Z","created_by":"James Lal","updated_at":"2026-07-11T00:52:33Z","started_at":"2026-07-11T00:14:16Z","closed_at":"2026-07-11T00:52:33Z","close_reason":"Implemented and validated persisted verdict reporting with creator identity scoping, --all/--as-agent, stable deterministic JSON, daemon routing, and accepted resulting_hash. Tests: 11 verdict-focused executions passing; cargo build/clippy/fmt pass; real relay dual-daemon smoke accepted a reviewer suggestion and matched resulting_hash to owner file; release binary 31.03 MiB under 32 MiB.","dependencies":[{"issue_id":"attn-02a.1.1","depends_on_id":"attn-02a.1","type":"parent-child","created_at":"2026-07-10T17:58:45Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":2,"comment_count":0} -{"id":"attn-02a.1","title":"Acceptance gate: agent worktree flow with verdict blocking","description":"Give agents a first-class way to block until the author has ruled on every suggestion. Agents work in their own git worktree; suggestions are the only thing that crosses the boundary; the owner apply flow (shipped) is the only path that mutates the file. No protocol changes — builds entirely on shipped surfaces (agent identities, suggestion envelopes, verdict events).\n\n## Scope\n- attn review verdicts --json (verdict state dump)\n- attn review verdicts --wait (block on the verdict envelope stream, no polling)\n- attn review submit-suggestion --from-diff (unified diff -\u003e per-hunk suggestions)\n\n## Pre-conditions\nNone. Owner-only AcceptSuggestion already enforced (agent-cli-howto.md crib sheet).\n\n## Plan Reference\n- planning/sharing/README.md (Workstream D)\n- planning/collab/agent-cli-howto.md","status":"closed","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-10T23:57:32Z","created_by":"James Lal","updated_at":"2026-07-11T02:28:54Z","started_at":"2026-07-11T00:14:15Z","closed_at":"2026-07-11T02:28:54Z","close_reason":"Acceptance gate feature complete: all implementation tasks closed and permanent dual-instance validation passes.","dependencies":[{"issue_id":"attn-02a.1","depends_on_id":"attn-02a","type":"parent-child","created_at":"2026-07-10T17:57:32Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-02a","title":"Sharing v3: durable links, notifications, permission tiers, acceptance gate","description":"Make sharing a real category on top of the v2 review protocol: share URLs that keep resolving (durable shares), an async notification loop (native + browser Web Push), sender-controlled permission tiers (view/comment/suggest), and a first-class acceptance gate so agents block until the author has ruled on every suggestion. Live co-editing is explicitly out of scope; the collaboration verb is suggest.\n\n## Success Criteria\n- [ ] A share URL resolves for 90+ days (owner-renewed), survives room expiry, and is owner-revocable\n- [ ] View-only links are cryptographically enforced by the relay; comment/suggest tiers enforced at import\n- [ ] Comments arriving while away produce native (daemon) and browser (Web Push) notifications, relay staying content-blind\n- [ ] An agent can submit suggestions from a worktree diff and block on `attn review verdicts --wait` until the owner rules on all of them\n\n## Architecture\n- Rooms stay ephemeral (decision #8); durability comes from a small relay-side share indirection holding the current roomId + latest encrypted snapshot\n- Capability = which keys the URL carries: read keys vs write-admission key split in the HKDF tree (amends decision #2)\n- Web Push is a content-free ping; the service worker decrypts locally via the remembered-room capability (decision #13)\n- Owner-accepts-everything is the invariant; agents submit, humans decide (unchanged from v2)\n\n## Plan Reference\n- planning/sharing/README.md\n- planning/collab/amendments.md (16 locked v2 decisions this plan amends/preserves)","notes":"All Sharing v3 workstreams complete and independently reviewed: acceptance gate, cryptographic view/comment/suggest tiers, durable share lifecycle, native async loop, and content-blind browser Web Push. Real production-boundary native/browser/relay E2Es pass. Follow-up multi-room native WS stall tracked separately as attn-eaw.","status":"closed","priority":1,"issue_type":"epic","owner":"james@littlebearlabs.io","created_at":"2026-07-10T23:56:57Z","created_by":"James Lal","updated_at":"2026-07-11T10:33:45Z","started_at":"2026-07-11T00:14:15Z","closed_at":"2026-07-11T10:33:45Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-7xl.3.7","title":"Build iOS reader, files, and review surfaces","description":"## Objective\n\nImplement the reader-first iPhone/iPad web experience so local workspaces and encrypted review links remain beautiful and useful even when editing or durable local storage is unavailable.\n\n## Files\n\nHosted reader/layout components, file and asset sheet, inline review anchors, thread/index sheets, safe-media viewers, mobile state adapter, Playwright specs.\n\n## Approach\n\nFollow planning/web-authoring/ios-ux.md and its interactive prototype. Existing local/shared documents open in a legible single-column reader. A thumb dock exposes Files, Review, Edit or Open native, and Share. Bottom sheets preserve file scroll position and focus. Safe raster/media previews use short-lived object URLs; unknown or active assets remain download-only. Capability loss removes only unsafe actions and retains every independently supported reader/reviewer action.\n\n## Plan Reference\n\n- planning/web-authoring/02-local-authoring.md (Step 5)\n- planning/web-authoring/ios-ux.md\n- planning/web-authoring/ios-experience.html","acceptance_criteria":"At 320, 375, 390, and 430 px plus iPad/Split View widths, nested Markdown renders with readable measure and no page-level overflow; Files handles relative paths, safe media, missing references, and download-only assets; inline anchors and review sheets preserve exact scroll/focus; view-only replaces Edit with Open native while retaining reading, files/assets, review, export/share actions allowed by capabilities; keyboard/VoiceOver/touch targets pass; Playwright Chromium/WebKit and real current iPhone/iPad Safari reader matrices pass.","spec_id":"planning/web-authoring/ios-ux.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T23:47:57Z","created_by":"James Lal","updated_at":"2026-07-11T02:48:25Z","started_at":"2026-07-11T02:35:45Z","closed_at":"2026-07-11T02:48:25Z","close_reason":"Reader-first iOS surfaces landed: per-workspace/file reading position (best-effort sessionStorage, container-aware — mobile page scroll for iOS address-bar behavior, desktop pinned three-column with internally scrolling keyboard-focusable canvas; explicit anchors win), edge-to-edge raster lightbox with native pinch-zoom/Escape/focus-restore, view-only dock replacing Edit with 'Open native' while retaining files/review/share/reader actions, 18-19px phone body type incl the source view, tables/images contained so the page never pans, sheet trips preserve exact scroll. New Chromium+WebKit reader matrix (hosted-reader.spec.ts): 320/375/390/430px + 820/1024 iPad widths, reading-position round-trip, lightbox focus, quota view-only degradation — 26/26 both engines; 48/48 routes suite (axe scrollable-region fix), 54/54 unit files, staging deployed. Inline review anchors/thread data arrive with sharing (attn-7xl.4) into the existing sheet shells; real-device matrix stays in the 7xl.7 gate.","labels":["a11y","assets","authoring","cloudflare","e2ee","editor","ios","mobile","multi-file","reader","review","storage","svelte","web"],"dependencies":[{"issue_id":"attn-7xl.3.7","depends_on_id":"attn-7xl.3","type":"parent-child","created_at":"2026-07-10T17:47:56Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"attn-7xl.7.6","title":"Validate production rollout and close the epic","description":"## Objective\n\nRun every Phase 06 quality gate, local and Cloudflare-staging Playwright suite, real iOS sign-off, independent reviews, production smoke, and rollback rehearsal.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/06-validation-rollout.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nfull repository tests, release evidence, deployment and rollback records\n\n## Validation\n\nRun Rust/web/relay quality gates, parity E2E, device checklist, security/a11y reviews, staging/production smoke, and rollback.\n\n## Source\n\nplanning/web-authoring/06-validation-rollout.md","acceptance_criteria":"All Phase 06 and epic success criteria pass with attached evidence; no required work remains before production rollout is declared complete.","notes":"Scope clarification 2026-07-10: workspaces are folder-shaped and must interoperate identically across web and native. They may contain nested Markdown plus arbitrary binary assets with normalized relative paths. Safe raster types may render inline; unknown/active types are download-only. Share supports current file, selected entries, or whole workspace through one canonical manifest. The landing New workspace action creates untitled.md and opens the editor in one click with no dialog or network request. See updated planning/web-authoring/.\nThe release matrix includes the iOS reader-first/view-only flows in planning/web-authoring/ios-ux.md at 320-430 px, iPad/Split View, 200% text, VoiceOver, dynamic browser chrome, safe media, Web Share, and browser/native handoff.","spec_id":"planning/web-authoring/06-validation-rollout.md","status":"open","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:43:39Z","created_by":"James Lal","updated_at":"2026-07-10T23:48:04Z","labels":["a11y","authoring","cloudflare","e2e","e2ee","ios","performance","security","storage","web"],"dependencies":[{"issue_id":"attn-7xl.7.6","depends_on_id":"attn-7xl.7","type":"parent-child","created_at":"2026-07-10T16:43:39Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.7.6","depends_on_id":"attn-7xl.7.1","type":"blocks","created_at":"2026-07-10T16:44:08Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.7.6","depends_on_id":"attn-7xl.7.2","type":"blocks","created_at":"2026-07-10T16:44:09Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.7.6","depends_on_id":"attn-7xl.7.3","type":"blocks","created_at":"2026-07-10T16:44:09Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.7.6","depends_on_id":"attn-7xl.7.4","type":"blocks","created_at":"2026-07-10T16:44:10Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.7.6","depends_on_id":"attn-7xl.7.5","type":"blocks","created_at":"2026-07-10T16:44:11Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":5,"dependent_count":0,"comment_count":0} -{"id":"attn-7xl.7.5","title":"Document browser authoring and recovery contract","description":"## Objective\n\nUpdate public/product/security/help documentation for local workspaces, backups, browser support, invite forms, and irrecoverable key/site-data loss.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/06-validation-rollout.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nREADME, landing/help copy, security/browser support docs\n\n## Validation\n\nContent-review docs against real UI, flows, iOS behavior, and server/storage implementation; link-check examples.\n\n## Source\n\nplanning/web-authoring/06-validation-rollout.md","acceptance_criteria":"Documentation clearly separates local workspace from room, states server non-recovery, explains Markdown backup, and uses working browser/native/CLI invite instructions.","spec_id":"planning/web-authoring/06-validation-rollout.md","status":"open","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:43:37Z","created_by":"James Lal","updated_at":"2026-07-10T22:43:37Z","labels":["a11y","authoring","cloudflare","e2e","e2ee","ios","performance","security","storage","web"],"dependencies":[{"issue_id":"attn-7xl.7.5","depends_on_id":"attn-7xl.7","type":"parent-child","created_at":"2026-07-10T16:43:37Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"attn-7xl.7.4","title":"Deploy and cut over canonical attn.sh origin","description":"## Objective\n\nDeploy the unified surface to Cloudflare staging, run the full matrix, move attn.sh from the split Vercel site, verify production, and preserve tested rollback.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/06-validation-rollout.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nCloudflare Worker/deployment config, DNS/routes, redirects, release workflow\n\n## Validation\n\nRun staging Playwright, metadata/CSP/deep-route/storage-origin checks, production smoke, and rollback rehearsal.\n\n## Source\n\nplanning/web-authoring/06-validation-rollout.md","acceptance_criteria":"Staging is green before cutover; /, /app, and /review share one canonical origin; redirects/metadata work; production smoke passes; prior static landing/review rollback is rehearsed.","notes":"2026-07-19: PARKED by James — attn.sh must NOT be published yet; still iterating on design. Do not prep or execute production cutover until he lifts this.","spec_id":"planning/web-authoring/06-validation-rollout.md","status":"open","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:43:36Z","created_by":"James Lal","updated_at":"2026-07-20T02:12:19Z","labels":["a11y","authoring","cloudflare","e2e","e2ee","ios","performance","security","storage","web"],"dependencies":[{"issue_id":"attn-7xl.7.4","depends_on_id":"attn-7xl.7","type":"parent-child","created_at":"2026-07-10T16:43:35Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"attn-7xl.7.3","title":"Complete accessibility and performance gates","description":"## Objective\n\nMeasure keyboard/screen-reader/touch/reduced-motion/contrast/320 px behavior plus landing bundle, interaction/autosave/cold-launch, large workspace memory, and iOS restart.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/06-validation-rollout.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nweb a11y tests, performance budgets, bundle reports, device evidence\n\n## Validation\n\nRun automated and manual a11y checks, 320 px screenshots, bundle analysis, latency/memory profiles, and iOS cold/restart cases.\n\n## Source\n\nplanning/web-authoring/06-validation-rollout.md","acceptance_criteria":"No release-blocking accessibility issue remains; landing avoids product chunks; documented budgets pass or have explicitly approved evidence-based exceptions.","notes":"2026-07-20 addendum: performance side complete — planning/web-authoring/perf-evidence-2026-07-20.md, all proposed budgets PASS (landing 28KB JS/198KB total; cold launch 323ms; keystroke p95 1.8ms; autosave settle 1.18s; 28MB heap at 63 files + 2MB doc; 304ms reopen). Functional/a11y: 86 routes + 31 storage green incl WebKit. Only the real-device matrix remains (device-checklist-2026-07.md, one ~30min session).","spec_id":"planning/web-authoring/06-validation-rollout.md","status":"in_progress","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:43:34Z","created_by":"James Lal","updated_at":"2026-07-20T22:52:06Z","started_at":"2026-07-19T23:18:47Z","labels":["a11y","authoring","cloudflare","e2e","e2ee","ios","performance","security","storage","web"],"dependencies":[{"issue_id":"attn-7xl.7.3","depends_on_id":"attn-7xl.7","type":"parent-child","created_at":"2026-07-10T16:43:34Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"attn-7xl.7.2","title":"Complete browser workspace security review","description":"## Objective\n\nThreat-model same-origin code, local compromise, stored capabilities/identity, service worker/import/XSS/CSP, relay blindness, telemetry, crypto-erasure, and supply chain.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/06-validation-rollout.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nsecurity docs, CSP/Trusted Types configuration, leak scanners, dependency audit\n\n## Validation\n\nPerform independent code/security review and adversarial tests for imports, XSS, cache/log/referrer leaks, storage extraction, and content-blind relay/R2.\n\n## Source\n\nplanning/web-authoring/06-validation-rollout.md","acceptance_criteria":"All high/critical findings are fixed; accepted limitations are explicit; attn services still cannot decrypt content; local encryption claims do not exceed their threat boundary.","notes":"Scope clarification 2026-07-10: workspaces are folder-shaped and must interoperate identically across web and native. They may contain nested Markdown plus arbitrary binary assets with normalized relative paths. Safe raster types may render inline; unknown/active types are download-only. Share supports current file, selected entries, or whole workspace through one canonical manifest. The landing New workspace action creates untitled.md and opens the editor in one click with no dialog or network request. See updated planning/web-authoring/.","spec_id":"planning/web-authoring/06-validation-rollout.md","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:43:32Z","created_by":"James Lal","updated_at":"2026-07-11T18:25:11Z","started_at":"2026-07-11T15:28:15Z","closed_at":"2026-07-11T18:25:11Z","close_reason":"Security review complete; ZIP expansion hardening landed, no open high/critical findings, accepted limitations documented","labels":["a11y","authoring","cloudflare","e2e","e2ee","ios","performance","security","storage","web"],"dependencies":[{"issue_id":"attn-7xl.7.2","depends_on_id":"attn-7xl.7","type":"parent-child","created_at":"2026-07-10T16:43:32Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"attn-7xl.7.1","title":"Build complete browser/native parity matrix","description":"## Objective\n\nCover all owner/reviewer combinations, transports, file sizes, review actions, reload/recovery, export/import, and invalid invite cases in a repeatable matrix.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/06-validation-rollout.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nE2E harnesses, Playwright projects, native automation, matrix docs\n\n## Validation\n\nRun every matrix cell locally and record exact commands/artifacts, including forced direct and mailbox paths.\n\n## Source\n\nplanning/web-authoring/06-validation-rollout.md","acceptance_criteria":"Every required parity cell has a deterministic pass/fail result; failures are fixed or blocking issues; no client combination relies on a web-only protocol.","notes":"Scope clarification 2026-07-10: workspaces are folder-shaped and must interoperate identically across web and native. They may contain nested Markdown plus arbitrary binary assets with normalized relative paths. Safe raster types may render inline; unknown/active types are download-only. Share supports current file, selected entries, or whole workspace through one canonical manifest. The landing New workspace action creates untitled.md and opens the editor in one click with no dialog or network request. See updated planning/web-authoring/.\nThe release matrix includes the iOS reader-first/view-only flows in planning/web-authoring/ios-ux.md at 320-430 px, iPad/Split View, 200% text, VoiceOver, dynamic browser chrome, safe media, Web Share, and browser/native handoff.\nComplete deterministic matrix recorded in planning/web-authoring/parity-matrix.md. Automated owner/reviewer combinations, live and offline transport, workspace content/action/recovery cells, local/staging route matrices all pass. Browser-owner/browser-reviewer and browser-owner/native-reviewer now both run against the same real Wrangler RoomDO/ShareDO in test:share-owner:live. Physical iPhone/iPad and production-origin cells are explicitly DEVICE/HUMAN APPROVAL gates tracked by attn-7xl.2.9, .7.4, and .7.6 rather than simulated passes.","spec_id":"planning/web-authoring/06-validation-rollout.md","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:43:31Z","created_by":"James Lal","updated_at":"2026-07-11T15:28:07Z","started_at":"2026-07-11T15:22:53Z","closed_at":"2026-07-11T15:28:07Z","close_reason":"Repeatable browser/native parity matrix is complete; every automated cell passes and physical-device/production cells name their blocking gate.","labels":["a11y","authoring","cloudflare","e2e","e2ee","ios","performance","security","storage","web"],"dependencies":[{"issue_id":"attn-7xl.7.1","depends_on_id":"attn-7xl.7","type":"parent-child","created_at":"2026-07-10T16:43:30Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"attn-7xl.6.6","title":"Validate iOS Safari and offline behavior","description":"## Objective\n\nRun Phase 05 checks for safe service-worker caching, offline/update/reload/termination, and mandatory real current iPhone/iPad Safari modes.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/05-ios-offline.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nweb PWA/E2E tests and real-device release evidence\n\n## Validation\n\nRun npm checks, service-worker assertions, Chromium/WebKit Playwright, process-like termination, Private Browsing, and the documented iPhone/iPad matrix.\n\n## Source\n\nplanning/web-authoring/05-ios-offline.md","acceptance_criteria":"Every Phase 05 validation case passes; exported backups remain usable after private-session loss; real-device evidence is attached before closure.","notes":"2026-07-20: consolidated one-session checklist ready — planning/web-authoring/device-checklist-2026-07.md section B (offline shell, offline share failure calmness, recovery, process-restart, mid-typing kill) + C (reader UX at 320-430px, 200% text, VoiceOver, dynamic chrome). Closes on James's device evidence.","spec_id":"planning/web-authoring/05-ios-offline.md","status":"open","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:43:29Z","created_by":"James Lal","updated_at":"2026-07-20T22:51:56Z","labels":["authoring","cloudflare","e2ee","ios","offline","pwa","service-worker","storage","web"],"dependencies":[{"issue_id":"attn-7xl.6.6","depends_on_id":"attn-7xl.6","type":"parent-child","created_at":"2026-07-10T16:43:28Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.6.6","depends_on_id":"attn-7xl.6.1","type":"blocks","created_at":"2026-07-10T16:44:04Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.6.6","depends_on_id":"attn-7xl.6.2","type":"blocks","created_at":"2026-07-10T16:44:05Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.6.6","depends_on_id":"attn-7xl.6.3","type":"blocks","created_at":"2026-07-10T16:44:06Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.6.6","depends_on_id":"attn-7xl.6.4","type":"blocks","created_at":"2026-07-10T16:44:07Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.6.6","depends_on_id":"attn-7xl.6.5","type":"blocks","created_at":"2026-07-10T16:44:07Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":5,"dependent_count":0,"comment_count":0} -{"id":"attn-7xl.6.5","title":"Document real iOS Safari release protocol","description":"## Objective\n\nDefine current/previous iOS support, iPhone/iPad cases, normal/private/Home Screen modes, keyboard/rotation/memory/site-data/low-storage evidence, and release sign-off format.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/05-ios-offline.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nplanning/testing docs and Bead evidence template\n\n## Validation\n\nDry-run the protocol on at least one current iPhone and iPad and record gaps that automation cannot cover.\n\n## Source\n\nplanning/web-authoring/05-ios-offline.md","acceptance_criteria":"The protocol is reproducible, distinguishes Playwright WebKit from real iOS Safari, records exact device/version evidence, and cannot be waived by desktop automation.","notes":"Apply the iOS interaction/capability contract in planning/web-authoring/ios-ux.md. Offline and capability loss must preserve every independently available reader, file/media, review, export, share, and native-handoff action; mobile editing may degrade separately.","spec_id":"planning/web-authoring/05-ios-offline.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:43:27Z","created_by":"James Lal","updated_at":"2026-07-11T03:39:18Z","started_at":"2026-07-11T03:33:16Z","closed_at":"2026-07-11T03:39:18Z","close_reason":"Real-device protocol documented: planning/web-authoring/ios-device-protocol.md — supported current+previous iOS, device/mode matrix (normal/Private/Home Screen on SE-class + current iPhone + iPad), 15-step evidence checklist covering storage kill/clear/low-storage, reader/editing incl VoiceOver/dictation/rotation, offline/update/suspension, and review links; evidence recording rules bind failures to the 7xl.7 cutover.","labels":["authoring","cloudflare","e2ee","ios","offline","pwa","service-worker","storage","web"],"dependencies":[{"issue_id":"attn-7xl.6.5","depends_on_id":"attn-7xl.6","type":"parent-child","created_at":"2026-07-10T16:43:27Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"attn-7xl.6.4","title":"Harden iOS lifecycle and memory recovery","description":"## Objective\n\nFlush bounded autosave on visibility/pagehide, recover after process kill, restart mailbox transport, stop RTC cleanly, and avoid background-sync assumptions.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/05-ios-offline.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nweb editor/session lifecycle controllers and termination tests\n\n## Validation\n\nSimulate termination/reload, rapid background/foreground, RTC loss, mailbox reconnect, memory pressure, and pending autosave.\n\n## Source\n\nplanning/web-authoring/05-ios-offline.md","acceptance_criteria":"The last committed revision survives process death; no UI claims unsaved work was durable; transports converge after resume without relying on closed-tab execution.","notes":"Apply the iOS interaction/capability contract in planning/web-authoring/ios-ux.md. Offline and capability loss must preserve every independently available reader, file/media, review, export, share, and native-handoff action; mobile editing may degrade separately.","spec_id":"planning/web-authoring/05-ios-offline.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:43:26Z","created_by":"James Lal","updated_at":"2026-07-11T03:39:12Z","started_at":"2026-07-11T03:33:15Z","closed_at":"2026-07-11T03:39:12Z","close_reason":"Real-device protocol documented: planning/web-authoring/ios-device-protocol.md — supported current+previous iOS, device/mode matrix (normal/Private/Home Screen on SE-class + current iPhone + iPad), 15-step evidence checklist covering storage kill/clear/low-storage, reader/editing incl VoiceOver/dictation/rotation, offline/update/suspension, and review links; evidence recording rules bind failures to the 7xl.7 cutover.","labels":["authoring","cloudflare","e2ee","ios","offline","pwa","service-worker","storage","web"],"dependencies":[{"issue_id":"attn-7xl.6.4","depends_on_id":"attn-7xl.6","type":"parent-child","created_at":"2026-07-10T16:43:25Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"attn-7xl.6.3","title":"Map Safari capability-specific degraded modes","description":"## Objective\n\nConnect real IndexedDB/CryptoKey/OPFS/storage probes to designed Private Browsing, Lockdown, fallback, quota, volatile, and unsupported UI states.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/05-ios-offline.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nweb capability mapping and degraded-state components\n\n## Validation\n\nRun injected feature failures, WebKit, Safari Private Browsing, and available Lockdown-mode/manual cases.\n\n## Source\n\nplanning/web-authoring/05-ios-offline.md","acceptance_criteria":"No UA sniffing decides support; scratch/review-only options remain where safe; every blocked capability has precise non-misleading remediation.","notes":"Apply the iOS interaction/capability contract in planning/web-authoring/ios-ux.md. Offline and capability loss must preserve every independently available reader, file/media, review, export, share, and native-handoff action; mobile editing may degrade separately.","spec_id":"planning/web-authoring/05-ios-offline.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:43:24Z","created_by":"James Lal","updated_at":"2026-07-11T03:39:06Z","started_at":"2026-07-11T03:33:14Z","closed_at":"2026-07-11T03:39:06Z","close_reason":"Real-device protocol documented: planning/web-authoring/ios-device-protocol.md — supported current+previous iOS, device/mode matrix (normal/Private/Home Screen on SE-class + current iPhone + iPad), 15-step evidence checklist covering storage kill/clear/low-storage, reader/editing incl VoiceOver/dictation/rotation, offline/update/suspension, and review links; evidence recording rules bind failures to the 7xl.7 cutover.","labels":["authoring","cloudflare","e2ee","ios","offline","pwa","service-worker","storage","web"],"dependencies":[{"issue_id":"attn-7xl.6.3","depends_on_id":"attn-7xl.6","type":"parent-child","created_at":"2026-07-10T16:43:24Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"attn-7xl.6.2","title":"Cache only the immutable app shell","description":"## Objective\n\nImplement a versioned service worker that caches only hashed app/font assets with network-first navigation and explicit update state.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/05-ios-offline.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nweb service worker, generated asset manifest, update UI/tests\n\n## Validation\n\nInspect Cache Storage and requests through install/update/offline; assert forbidden routes, content, room APIs, fragments, and error payloads are never cached.\n\n## Source\n\nplanning/web-authoring/05-ios-offline.md","acceptance_criteria":"Offline shell launch works; updates activate predictably; user content, invite URLs, room traffic, recovery data, and capability-bearing URLs never enter Cache Storage.","spec_id":"planning/web-authoring/05-ios-offline.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:43:22Z","created_by":"James Lal","updated_at":"2026-07-11T03:38:59Z","started_at":"2026-07-11T03:29:40Z","closed_at":"2026-07-11T03:38:59Z","close_reason":"Real-device protocol documented: planning/web-authoring/ios-device-protocol.md — supported current+previous iOS, device/mode matrix (normal/Private/Home Screen on SE-class + current iPhone + iPad), 15-step evidence checklist covering storage kill/clear/low-storage, reader/editing incl VoiceOver/dictation/rotation, offline/update/suspension, and review links; evidence recording rules bind failures to the 7xl.7 cutover.","labels":["authoring","cloudflare","e2ee","ios","offline","pwa","service-worker","storage","web"],"dependencies":[{"issue_id":"attn-7xl.6.2","depends_on_id":"attn-7xl.6","type":"parent-child","created_at":"2026-07-10T16:43:22Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"attn-7xl.6.1","title":"Add installable local-desk app shell","description":"## Objective\n\nAdd manifest, icons, standalone metadata, theme colors, safe iOS navigation, and optional install guidance without promising persistence.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/05-ios-offline.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nweb manifest/icons/hosted HTML, PWA/install UI\n\n## Validation\n\nValidate manifest/icons/display/theme, standalone navigation/safe areas, install copy, and normal-versus-Home-Screen behavior on iOS.\n\n## Source\n\nplanning/web-authoring/05-ios-offline.md","acceptance_criteria":"The local desk installs and launches standalone on current iOS; navigation stays inside intended routes; guidance is optional and accurately hedged.","spec_id":"planning/web-authoring/05-ios-offline.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:43:21Z","created_by":"James Lal","updated_at":"2026-07-11T03:38:49Z","started_at":"2026-07-11T03:29:39Z","closed_at":"2026-07-11T03:38:49Z","close_reason":"Real-device protocol documented: planning/web-authoring/ios-device-protocol.md — supported current+previous iOS, device/mode matrix (normal/Private/Home Screen on SE-class + current iPhone + iPad), 15-step evidence checklist covering storage kill/clear/low-storage, reader/editing incl VoiceOver/dictation/rotation, offline/update/suspension, and review links; evidence recording rules bind failures to the 7xl.7 cutover.","labels":["authoring","cloudflare","e2ee","ios","offline","pwa","service-worker","storage","web"],"dependencies":[{"issue_id":"attn-7xl.6.1","depends_on_id":"attn-7xl.6","type":"parent-child","created_at":"2026-07-10T16:43:20Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"attn-7xl.5.6","title":"Validate accountless recovery and storage UX","description":"## Objective\n\nRun the full Phase 04 status, export/import, persistence, deletion, iOS Files, and secret-leak validation suite.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/04-recovery-storage-ux.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nweb recovery tests, Playwright specs, iOS evidence, security review notes\n\n## Validation\n\nRun unit/property tests, byte-identical Playwright round trips, degraded-state screenshots, real iOS Files matrix, and filenames/manifest/cache/telemetry leak scan.\n\n## Source\n\nplanning/web-authoring/04-recovery-storage-ux.md","acceptance_criteria":"Every Phase 04 validation item passes and the documented recovery contract matches actual behavior when browser data or room authority is lost.","notes":"2026-07-20: automatable side complete via e2e/hosted-recovery.spec.ts in the routes suite — export→import→export byte-identical round trip; workspace deletion destroys durably (survives reload); share fragment secret appears in ZERO network requests across the share-creation session (verified against live staging relay). Remaining: iOS Files matrix + real-device items — rows D15/D16 in planning/web-authoring/device-checklist-2026-07.md.","spec_id":"planning/web-authoring/04-recovery-storage-ux.md","status":"in_progress","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:43:19Z","created_by":"James Lal","updated_at":"2026-07-20T22:51:55Z","started_at":"2026-07-19T23:18:47Z","labels":["authoring","cloudflare","e2ee","export","ios","recovery","storage","web"],"dependencies":[{"issue_id":"attn-7xl.5.6","depends_on_id":"attn-7xl.5","type":"parent-child","created_at":"2026-07-10T16:43:18Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.5.6","depends_on_id":"attn-7xl.5.1","type":"blocks","created_at":"2026-07-10T16:44:01Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.5.6","depends_on_id":"attn-7xl.5.2","type":"blocks","created_at":"2026-07-10T16:44:02Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.5.6","depends_on_id":"attn-7xl.5.3","type":"blocks","created_at":"2026-07-10T16:44:02Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.5.6","depends_on_id":"attn-7xl.5.4","type":"blocks","created_at":"2026-07-10T16:44:03Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.5.6","depends_on_id":"attn-7xl.5.5","type":"blocks","created_at":"2026-07-10T16:44:04Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":5,"dependent_count":0,"comment_count":0} -{"id":"attn-7xl.5.5","title":"Separate crypto-erasure and room cleanup actions","description":"## Objective\n\nImplement delete workspace, forget room, stop sharing, and clear all local data as distinct confirmed operations, deleting local keys first where crypto-erasure applies.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/04-recovery-storage-ux.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nworkspace/share cleanup services, OPFS GC, destructive confirmation UI\n\n## Validation\n\nTest each operation independently, active-room combinations, crash/retry, key-first deletion, orphan cleanup, and recovery messaging.\n\n## Source\n\nplanning/web-authoring/04-recovery-storage-ux.md","acceptance_criteria":"Each action states and performs only its promised consequence; live rooms are handled explicitly; removed content becomes undecryptable before best-effort record cleanup.","spec_id":"planning/web-authoring/04-recovery-storage-ux.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:43:17Z","created_by":"James Lal","updated_at":"2026-07-11T03:27:58Z","closed_at":"2026-07-11T03:27:58Z","close_reason":"Crypto-erasure and cleanup are separate, honest operations: delete workspace (root key first — 2.3), forget remembered room (BrowserStorage.forgetRoom key-first, new listRoomIds + storage-page section with consequence copy + in-app confirm), clear-all-local-data (per-workspace crypto-erasure loop, durable, e2e-verified across reload). Stop-sharing (owner room revocation) lands with attn-7xl.4 ownership and is noted there.","labels":["authoring","cloudflare","e2ee","export","ios","recovery","storage","web"],"dependencies":[{"issue_id":"attn-7xl.5.5","depends_on_id":"attn-7xl.5","type":"parent-child","created_at":"2026-07-10T16:43:17Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"attn-7xl.5.4","title":"Gate first share on durability","description":"## Objective\n\nFrom a user gesture, request persistence, offer Markdown backup, explain best-effort/volatile risk, and require acknowledgement before non-persistent sharing.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/04-recovery-storage-ux.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nShare preflight state machine and persistence UI\n\n## Validation\n\nTest granted, denied, unknown, unavailable, Private Browsing, completed backup, declined backup, and repeat-share flows.\n\n## Source\n\nplanning/web-authoring/04-recovery-storage-ux.md","acceptance_criteria":"Persistence requests only follow a gesture; durable users proceed quickly; non-persistent users see accurate consequences and cannot bypass acknowledgement accidentally.","spec_id":"planning/web-authoring/04-recovery-storage-ux.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:43:16Z","created_by":"James Lal","updated_at":"2026-07-11T03:27:49Z","closed_at":"2026-07-11T03:27:49Z","close_reason":"Crypto-erasure and cleanup are separate, honest operations: delete workspace (root key first — 2.3), forget remembered room (BrowserStorage.forgetRoom key-first, new listRoomIds + storage-page section with consequence copy + in-app confirm), clear-all-local-data (per-workspace crypto-erasure loop, durable, e2e-verified across reload). Stop-sharing (owner room revocation) lands with attn-7xl.4 ownership and is noted there.","labels":["authoring","cloudflare","e2ee","export","ios","recovery","storage","web"],"dependencies":[{"issue_id":"attn-7xl.5.4","depends_on_id":"attn-7xl.5","type":"parent-child","created_at":"2026-07-10T16:43:15Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"attn-7xl.5.3","title":"Import backups with explicit conflicts","description":"## Objective\n\nValidate and import Markdown/zip backups atomically with normalized paths and explicit duplicate-workspace choices, never silently reconnecting old rooms.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/04-recovery-storage-ux.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nweb import parser, validation, conflict UI, transaction tests\n\n## Validation\n\nTest traversal, duplicates, malformed manifest/zip, oversize, partial failure, and export-import byte round trip.\n\n## Source\n\nplanning/web-authoring/04-recovery-storage-ux.md","acceptance_criteria":"Valid imports create a new complete local workspace atomically; invalid input writes nothing; duplicate choices are explicit; no old owner/share authority is inferred.","notes":"Scope clarification 2026-07-10: workspaces are folder-shaped and must interoperate identically across web and native. They may contain nested Markdown plus arbitrary binary assets with normalized relative paths. Safe raster types may render inline; unknown/active types are download-only. Share supports current file, selected entries, or whole workspace through one canonical manifest. The landing New workspace action creates untitled.md and opens the editor in one click with no dialog or network request. See updated planning/web-authoring/.","spec_id":"planning/web-authoring/04-recovery-storage-ux.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:43:14Z","created_by":"James Lal","updated_at":"2026-07-11T03:27:40Z","closed_at":"2026-07-11T03:27:40Z","close_reason":"Crypto-erasure and cleanup are separate, honest operations: delete workspace (root key first — 2.3), forget remembered room (BrowserStorage.forgetRoom key-first, new listRoomIds + storage-page section with consequence copy + in-app confirm), clear-all-local-data (per-workspace crypto-erasure loop, durable, e2e-verified across reload). Stop-sharing (owner room revocation) lands with attn-7xl.4 ownership and is noted there.","labels":["authoring","cloudflare","e2ee","export","ios","recovery","storage","web"],"dependencies":[{"issue_id":"attn-7xl.5.3","depends_on_id":"attn-7xl.5","type":"parent-child","created_at":"2026-07-10T16:43:14Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"attn-7xl.5.2","title":"Export ordinary workspace backups","description":"## Objective\n\nExport a file, workspace zip, or all workspaces as ordinary Markdown plus a small non-secret manifest, including iOS Files/Web Share behavior.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/04-recovery-storage-ux.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nweb export utilities, recovery page actions, manifest tests\n\n## Validation\n\nRun byte-exact exports, multi-file zip, filename/path, Web Share fallback, and real iOS Files tests.\n\n## Source\n\nplanning/web-authoring/04-recovery-storage-ux.md","acceptance_criteria":"Content is recoverable without attn; exported names/manifests contain no secret; all-workspace export is understandable and usable on iOS.","notes":"Scope clarification 2026-07-10: workspaces are folder-shaped and must interoperate identically across web and native. They may contain nested Markdown plus arbitrary binary assets with normalized relative paths. Safe raster types may render inline; unknown/active types are download-only. Share supports current file, selected entries, or whole workspace through one canonical manifest. The landing New workspace action creates untitled.md and opens the editor in one click with no dialog or network request. See updated planning/web-authoring/.","spec_id":"planning/web-authoring/04-recovery-storage-ux.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:43:13Z","created_by":"James Lal","updated_at":"2026-07-11T03:27:33Z","started_at":"2026-07-11T03:14:30Z","closed_at":"2026-07-11T03:27:33Z","close_reason":"Crypto-erasure and cleanup are separate, honest operations: delete workspace (root key first — 2.3), forget remembered room (BrowserStorage.forgetRoom key-first, new listRoomIds + storage-page section with consequence copy + in-app confirm), clear-all-local-data (per-workspace crypto-erasure loop, durable, e2e-verified across reload). Stop-sharing (owner room revocation) lands with attn-7xl.4 ownership and is noted there.","labels":["authoring","cloudflare","e2ee","export","ios","recovery","storage","web"],"dependencies":[{"issue_id":"attn-7xl.5.2","depends_on_id":"attn-7xl.5","type":"parent-child","created_at":"2026-07-10T16:43:12Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"attn-7xl.5.1","title":"Add storage health and pressure UI","description":"## Objective\n\nShow persistence mode, accessible usage/quota, backup age, OPFS fallback, workspace sizes, and quota-pressure remediation without fake precision.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/04-recovery-storage-ux.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nweb storage/recovery page, status mapping and formatting utilities\n\n## Validation\n\nUnit-test every typed mode plus unknown estimate, quota pressure, fallback, and screen-reader text; capture screenshots.\n\n## Source\n\nplanning/web-authoring/04-recovery-storage-ux.md","acceptance_criteria":"Every capability state has accurate accessible copy and action; unknown values stay unknown; pressure never triggers a destructive or falsely successful write.","spec_id":"planning/web-authoring/04-recovery-storage-ux.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:43:11Z","created_by":"James Lal","updated_at":"2026-07-11T03:27:27Z","started_at":"2026-07-11T03:14:29Z","closed_at":"2026-07-11T03:27:27Z","close_reason":"Crypto-erasure and cleanup are separate, honest operations: delete workspace (root key first — 2.3), forget remembered room (BrowserStorage.forgetRoom key-first, new listRoomIds + storage-page section with consequence copy + in-app confirm), clear-all-local-data (per-workspace crypto-erasure loop, durable, e2e-verified across reload). Stop-sharing (owner room revocation) lands with attn-7xl.4 ownership and is noted there.","labels":["authoring","cloudflare","e2ee","export","ios","recovery","storage","web"],"dependencies":[{"issue_id":"attn-7xl.5.1","depends_on_id":"attn-7xl.5","type":"parent-child","created_at":"2026-07-10T16:43:11Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"attn-7xl.4.7","title":"Validate browser-owned encrypted sharing","description":"## Objective\n\nRun the complete Phase 03 protocol, local E2E, native automation, bootstrap-failure, and content-blindness validation suite.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/03-browser-sharing.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nweb/Rust/relay tests, Playwright/automation specs, leak scanners\n\n## Validation\n\nExecute canonical vectors, relay tests, two-browser and browser/native E2E, forced failures, direct/fallback paths, and plaintext/secret/SDP/ICE scans.\n\n## Source\n\nplanning/web-authoring/03-browser-sharing.md","acceptance_criteria":"All Phase 03 validation items pass with reproducible evidence and no sensitive plaintext in infrastructure logs, storage, or telemetry.","notes":"Scope clarification 2026-07-10: workspaces are folder-shaped and must interoperate identically across web and native. They may contain nested Markdown plus arbitrary binary assets with normalized relative paths. Safe raster types may render inline; unknown/active types are download-only. Share supports current file, selected entries, or whole workspace through one canonical manifest. The landing New workspace action creates untitled.md and opens the editor in one click with no dialog or network request. See updated planning/web-authoring/.\nValidation record: planning/web-authoring/validation-03.md. Green evidence: svelte-check 0/0; 78 web test files; Rust share lifecycle 14/14; browser-owner live relay/native join; full durable-share real stack; folder share 3/3; WebRTC 12/12; Share sheet 7/7; production browser build and route boundaries; staging deploy fb0ebc57-8e38-4b12-8f97-e1dc79f45291 and full staging Playwright 64/64. Production cutover and real iOS remain explicit attn-7xl.7/device gates.","spec_id":"planning/web-authoring/03-browser-sharing.md","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:43:10Z","created_by":"James Lal","updated_at":"2026-07-11T15:22:17Z","started_at":"2026-07-11T15:22:16Z","closed_at":"2026-07-11T15:22:17Z","close_reason":"Phase 03 protocol, failure recovery, interop, content-blindness, browser UX, and staging validation gates pass reproducibly.","labels":["authoring","cloudflare","e2ee","interop","ios","sharing","storage","web","webrtc"],"dependencies":[{"issue_id":"attn-7xl.4.7","depends_on_id":"attn-7xl.4","type":"parent-child","created_at":"2026-07-10T16:43:09Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.4.7","depends_on_id":"attn-7xl.4.1","type":"blocks","created_at":"2026-07-10T16:43:57Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.4.7","depends_on_id":"attn-7xl.4.2","type":"blocks","created_at":"2026-07-10T16:43:57Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.4.7","depends_on_id":"attn-7xl.4.3","type":"blocks","created_at":"2026-07-10T16:43:58Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.4.7","depends_on_id":"attn-7xl.4.4","type":"blocks","created_at":"2026-07-10T16:43:59Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.4.7","depends_on_id":"attn-7xl.4.5","type":"blocks","created_at":"2026-07-10T16:44:00Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.4.7","depends_on_id":"attn-7xl.4.6","type":"blocks","created_at":"2026-07-10T16:44:00Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":6,"dependent_count":0,"comment_count":0} -{"id":"attn-7xl.4.6","title":"Verify native and browser share interoperability","description":"## Objective\n\nConnect browser-owned rooms to existing native and browser entry points, preserving the correct HTTPS, attn scheme, and CLI forms.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/03-browser-sharing.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nweb/native invite parsing and E2E harnesses\n\n## Validation\n\nRun browser-owner/browser-reviewer and browser-owner/native-reviewer flows plus reverse native-owner/browser regression.\n\n## Source\n\nplanning/web-authoring/03-browser-sharing.md","acceptance_criteria":"Both clients open the same room/file/comment model; comments, suggestions, edit/apply, reconnect, direct transport, and mailbox fallback interoperate.","notes":"Scope clarification 2026-07-10: workspaces are folder-shaped and must interoperate identically across web and native. They may contain nested Markdown plus arbitrary binary assets with normalized relative paths. Safe raster types may render inline; unknown/active types are download-only. Share supports current file, selected entries, or whole workspace through one canonical manifest. The landing New workspace action creates untitled.md and opens the editor in one click with no dialog or network request. See updated planning/web-authoring/.\nCompleted V3 interoperability after rebasing onto sharing-v3. Browser owner -\u003e native reviewer is proven against real Wrangler RoomDO/ShareDO by npm run test:share-owner:live; native owner -\u003e production browser, retained snapshot, offline mailbox, deterministic room recovery, watch upgrade, and revoke pass scripts/test-share-e2e.sh. Browser owner startup now renews/recreates ordinary rooms, validates both writable-tier offline mailboxes cryptographically, forwards exact encrypted envelopes, ACKs only after RoomDO durability, and seals its replay cursor. Folder publication passes 3/3 and live WebRTC/collab passes 12/12.","spec_id":"planning/web-authoring/03-browser-sharing.md","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:43:08Z","created_by":"James Lal","updated_at":"2026-07-11T15:22:08Z","started_at":"2026-07-11T14:51:31Z","closed_at":"2026-07-11T15:22:08Z","close_reason":"Native/browser stable-link resolution, room join, snapshots, offline mailbox recovery, and live transport interoperate on real relay stacks.","labels":["authoring","cloudflare","e2ee","interop","ios","sharing","storage","web","webrtc"],"dependencies":[{"issue_id":"attn-7xl.4.6","depends_on_id":"attn-7xl.4","type":"parent-child","created_at":"2026-07-10T16:43:08Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"attn-7xl.4.5","title":"Build accountless Share sheet","description":"## Objective\n\nImplement durability preflight, TTL/mode controls, browser/native/CLI invite forms, Web Share/copy actions, room status, stop sharing, and recreate-room recovery.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/03-browser-sharing.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nweb Share sheet components and invite URL utilities\n\n## Validation\n\nRun keyboard/mobile/a11y checks, URL fragment/log leak assertions, share API fallback, stop/recreate, and each persistence mode.\n\n## Source\n\nplanning/web-authoring/03-browser-sharing.md","acceptance_criteria":"Stable 90-day sharing publishes independently revocable view/comment/suggest sibling bearers; browser, native, and CLI forms preserve the selected tier secret; URL fragments are not logged or referrer-copied; durability, recovery, and stop-sharing behavior are accurate.","notes":"Checkpoint c6ea637 on checkpoint/attn-7xl-4-5-v2 preserves validated V2 lifecycle/UI work: 68 web unit files, 48 relevant hosted Playwright cases, production browser build, route bundle boundary, Svelte check, scoped Rust redaction tests all pass. Do not close: incoming sharing-v3 materially supersedes public sharing with stable shareId + tiered view/comment/suggest capabilities and must land on origin/main first. Rebase checkpoint after that landing, then port durability/scope/recovery/redaction/UI invariants onto the V3 browser-owner lifecycle and rerun live/native interop.\nCompleted on checkpoint/attn-7xl-4-5-v2 after rebasing onto sharing-v3: browser-owned stable ShareDO lifecycle, sealed durable ownership, dark-to-active publication, retained encrypted snapshots, tiered Share sheet, native/browser/CLI invite forms, stop/recreate, route-bundle safety, keyboard/mobile/axe checks, and real Wrangler relay proof. Validation: 78 web unit files green, svelte-check 0/0, browser production build, focused Playwright 7/7, browser ShareDO live harness green.","spec_id":"planning/web-authoring/03-browser-sharing.md","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:43:07Z","created_by":"James Lal","updated_at":"2026-07-11T14:51:24Z","started_at":"2026-07-11T08:03:22Z","closed_at":"2026-07-11T14:51:24Z","close_reason":"V3 stable accountless Share sheet and browser-owner lifecycle implemented and validated.","labels":["authoring","cloudflare","e2ee","interop","ios","sharing","storage","web","webrtc"],"dependencies":[{"issue_id":"attn-7xl.4.5","depends_on_id":"attn-7xl.4","type":"parent-child","created_at":"2026-07-10T16:43:06Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"attn-7xl.4.4","title":"Run browser owner authority","description":"## Objective\n\nIntegrate collab owner authority and owner-only accept/reject/apply in the browser; degrade durable review versus live editing honestly when the owner tab is absent.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/03-browser-sharing.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nbrowser collab authority, review store/actions, connection status UI\n\n## Validation\n\nTest comment/suggestion delivery owner-online/offline, reconnect, live co-edit, apply/reject, and multiple tabs.\n\n## Source\n\nplanning/web-authoring/03-browser-sharing.md","acceptance_criteria":"Durable review continues through mailbox while the owner is offline; live/owner-only actions pause visibly; reconnect converges without duplicate application.","spec_id":"planning/web-authoring/03-browser-sharing.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:43:05Z","created_by":"James Lal","updated_at":"2026-07-11T08:02:04Z","started_at":"2026-07-11T05:50:42Z","closed_at":"2026-07-11T08:02:04Z","close_reason":"Browser owner authority, durable review degradation, and hosted authority controls are implemented; end-to-end interop validation remains in attn-7xl.4.6/.4.7","labels":["authoring","cloudflare","e2ee","interop","ios","sharing","storage","web","webrtc"],"dependencies":[{"issue_id":"attn-7xl.4.4","depends_on_id":"attn-7xl.4","type":"parent-child","created_at":"2026-07-10T16:43:05Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"attn-7xl.4.3","title":"Publish encrypted workspace snapshots","description":"## Objective\n\nSeal and publish selected file/folder snapshots through inline, mailbox, or R2 thresholds, advancing publication state only after durable acknowledgement.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/03-browser-sharing.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nbrowser snapshot publishing, R2/blob client, workspace share state\n\n## Validation\n\nRun inline/mailbox/R2, retry, duplicate ACK, failure-before-ACK, republish, and content-blind wire/storage tests.\n\n## Source\n\nplanning/web-authoring/03-browser-sharing.md","acceptance_criteria":"Published revisions are canonical and resumable, workspace source-of-truth remains local, and plaintext/capabilities never enter relay/R2 logs or state.","notes":"Approach: implement the exact native v2 snapshot publication pipeline for supported document snapshots. Add browser ID derivations and kind=snapshot_blob assembly; add R2 seal/presign/PUT; allow snapshot_blob in the durable outbox with maxSnapshotBytes; add a publisher that canonicalizes SnapshotPlaintext, preserves stable FileId on republish, enqueues blob before signed SnapshotCreated, waits for full relay ACK before setPublication(published), and resumes exact queued ciphertext on retry. Files: web/src/lib/review/browser-crypto.ts, browser-envelope.ts, browser-snapshot-r2.ts, browser-outbox.ts, new browser-snapshot-publisher.ts plus tests and live-relay harness. Tests: Rust corpus/first-principles IDs+envelope, mailbox/R2 thresholds, failure-before-ACK/retry/duplicate ACK, republish, plaintext wire scan, live relay. Protocol gap found: amendments.md pins AnchorIndex generation to Rust (browser has consumer only), and current Rust/web SnapshotPlaintext DocType is markdown|html so arbitrary binary asset/workspace-manifest wire representation is absent; do not invent web-only shape.\nCore progress 2026-07-10: exact native-compatible Markdown/HTML snapshot publication landed locally: deriveFileId/deriveSnapshotId, Rust-corpus snapshot_blob assembly, kind-aware durable outbox, authenticated R2 presign/PUT + sealed-body reverse path, blob-before-signed-pointer publisher, ACK-gated publication state, exact-ciphertext resume, stable FileId republish, and live real-relay proof. Validation: svelte-check 0/0; 58 unit files 0 failures; focused publisher 4/4; R2 10/10; live wrangler relay accepted snapshot_blob + SnapshotCreated; build and route-bundle gate green. Security review caught and fixed half-built multi-entry exposure by preparing every entry before durable enqueue; R2 upload responses/capabilities validate exact same-origin shape and stay opaque. Parent remains in progress because child attn-7xl.4.3.1 owns the missing coordinated asset/workspace-manifest payloads and canonical Rust/comrak browser index.","spec_id":"planning/web-authoring/03-browser-sharing.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:43:04Z","created_by":"James Lal","updated_at":"2026-07-11T05:49:46Z","started_at":"2026-07-11T04:02:40Z","closed_at":"2026-07-11T05:49:46Z","close_reason":"Encrypted workspace snapshots are canonical, resumable, ACK-gated, content-blind, and proven across native/browser protocol implementations.","labels":["authoring","cloudflare","e2ee","interop","ios","sharing","storage","web","webrtc"],"dependencies":[{"issue_id":"attn-7xl.4.3","depends_on_id":"attn-7xl.4","type":"parent-child","created_at":"2026-07-10T16:43:03Z","created_by":"James Lal","metadata":"{}"}],"comments":[{"id":"019f4fb9-8011-7d13-9d0f-2a1514bcfe50","issue_id":"attn-7xl.4.3","author":"James Lal","text":"Completed browser snapshot publication end-to-end: native-compatible encrypted blob/pointer pipeline across mailbox and R2, canonical workspace scopes and manifest-last ordering, durable atomic ACK-gated publication state, exact-ciphertext recovery, stable entry identities, live real-relay content-blind proof, and coordinated native/browser validation. Child 4.3.1 closes the previously identified asset/manifest/anchor protocol gap.","created_at":"2026-07-11T05:49:45Z"}],"dependency_count":0,"dependent_count":1,"comment_count":1} -{"id":"attn-7xl.4.2","title":"Persist browser room ownership and invite capability","description":"## Objective\n\nAtomically bind a workspace share to encrypted owner identity, wrapped invite capability, published revision, and resumable transport state.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/03-browser-sharing.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nworkspace share records, browser identity/capability persistence, recovery tests\n\n## Validation\n\nTest reload/rejoin, interrupted commit, duplicate bootstrap, capability tamper, forget, and site-data loss behavior.\n\n## Source\n\nplanning/web-authoring/03-browser-sharing.md","acceptance_criteria":"The same browser profile can recopy links and resume authority without raw secret storage; interruption is idempotent; lost local data never claims owner recovery.","spec_id":"planning/web-authoring/03-browser-sharing.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:43:02Z","created_by":"James Lal","updated_at":"2026-07-11T03:56:05Z","started_at":"2026-07-11T03:52:33Z","closed_at":"2026-07-11T03:56:05Z","close_reason":"Room ownership + wrapped invite capability persistence landed (browser-workspace-share.ts, BrowserStorage.shares): bindShare seals the raw invite capability (room secret + owner signing/encryption secrets + device/participant ids + policy snapshot + published-revision pointer) under the workspace key via sealCapability (2.2), with AAD binding workspaceId/capId/roomId/scope so a record can't transplant between workspaces or rooms; metadata (roomId, scope, relayUrl, publication state) stays plaintext for UI. Idempotent by capId (same room = no-op resume, different room = conflict, ConstraintError race returns the winner); setPublication (pending/published/stopped) for 4.3/4.5; listShares/forgetShare for stop-sharing + rollback. Validation: 5 cases incl seal/open round-trip across reload, workspace-key binding (crypto-erasure leaves the cap permanently opaque under a fresh key), idempotency/conflict, publication lifecycle, input validation. 57 unit files, svelte-check 0, bundle gate green.","labels":["authoring","cloudflare","e2ee","interop","ios","sharing","storage","web","webrtc"],"dependencies":[{"issue_id":"attn-7xl.4.2","depends_on_id":"attn-7xl.4","type":"parent-child","created_at":"2026-07-10T16:43:01Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"attn-7xl.4.1","title":"Implement browser owner room bootstrap","description":"## Objective\n\nCreate canonical rooms from the browser with native-compatible request signing, owner registration, RoomCreated/ParticipantJoined events, policy validation, and rollback.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/03-browser-sharing.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nweb browser session/bootstrap code, relay protocol vectors, Rust interop fixtures\n\n## Validation\n\nRun canonical request/ID/signature/event vectors and fail each bootstrap step to prove resume or cleanup.\n\n## Source\n\nplanning/web-authoring/03-browser-sharing.md","acceptance_criteria":"Browser-created rooms are indistinguishable at the protocol layer from native rooms, default to hybrid/allowBrowser, and never leave ambiguous partial ownership.","spec_id":"planning/web-authoring/03-browser-sharing.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:43:00Z","created_by":"James Lal","updated_at":"2026-07-11T03:51:34Z","started_at":"2026-07-11T03:51:20Z","closed_at":"2026-07-11T03:51:34Z","close_reason":"Browser owner room bootstrap landed (browser-owner-bootstrap.ts): createOwnedRoom generates the room secret, derives id/keys with the exact native KDFs, POSTs the canonical create body {v:2, wire policy mirroring bootstrap.rs incl idleTimeoutMs/longSession/powBits, ownerSigningKey, admissionKey} with Attn-Admission + Attn-Owner-Signature (new buildOwnerSignatureHeader over canonicalRequest) + Attn-PoW bound to ownerSigningKeyId; registers the owner device (kind owner, client attn-browser via parametrized buildRegisterDeviceBody); rolls back first-creates via fully-proofed owner DELETE (admission+PoW+signature) when registration fails, never on rejoin. Validation: 7 unit cases recomputing every header from first principles + ed25519-verifying the canonical DELETE; AND a live integration proof (scripts/test-owner-bootstrap-live.mjs) against the REAL relay under wrangler dev where the relay's own admission/owner-sig/PoW verification accepted create 201, idempotent rejoin 200, owner directory registration, and owner-signed teardown — protocol interop by referee. 56 unit files, svelte-check 0.","labels":["authoring","cloudflare","e2ee","interop","ios","sharing","storage","web","webrtc"],"dependencies":[{"issue_id":"attn-7xl.4.1","depends_on_id":"attn-7xl.4","type":"parent-child","created_at":"2026-07-10T16:43:00Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"attn-7xl.3.6","title":"Validate local browser authoring","description":"## Objective\n\nRun Phase 02 end to end from new/import through type/reload/edit/export/reimport, storage tiers, writer takeover, and real mobile Safari.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/02-local-authoring.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nweb authoring E2E specs and device evidence\n\n## Validation\n\nExecute npm checks/tests and Playwright network-isolated flows for IDB/OPFS, large files, two tabs, mobile viewports, and real devices.\n\n## Source\n\nplanning/web-authoring/02-local-authoring.md","acceptance_criteria":"All Phase 02 scenarios pass and prove local creation/editing makes no relay request before Share and produces recoverable ordinary Markdown.","notes":"Scope clarification 2026-07-10: workspaces are folder-shaped and must interoperate identically across web and native. They may contain nested Markdown plus arbitrary binary assets with normalized relative paths. Safe raster types may render inline; unknown/active types are download-only. Share supports current file, selected entries, or whole workspace through one canonical manifest. The landing New workspace action creates untitled.md and opens the editor in one click with no dialog or network request. See updated planning/web-authoring/.\nValidate the full planning/web-authoring/ios-ux.md matrix, including reader-first and view-only states. This task depends on the dedicated reader task attn-7xl.3.7 as well as the editing task.","spec_id":"planning/web-authoring/02-local-authoring.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:42:59Z","created_by":"James Lal","updated_at":"2026-07-11T03:12:18Z","started_at":"2026-07-11T03:08:38Z","closed_at":"2026-07-11T03:12:18Z","close_reason":"Phase 02 validation gate complete, evidence in planning/web-authoring/validation-02.md: 54/54 unit files; continuous journey e2e (landing CTA -\u003e real editor typing -\u003e durable autosave -\u003e reload recovery -\u003e 1.2MB asset -\u003e zip export byte-verified in Node -\u003e reimport landing in prose) with interception proving zero non-origin requests end to end; nested/asset/OPFS-fallback cases green; two-tab lease honesty on both engines; 30/30 mobile Chromium+WebKit viewport matrix; 49/49 routes suite; staging deployed and verified. Fixed during gate: imports leading with an asset now select the first Markdown entry. Real-device matrix remains the 7xl.7 gate.","labels":["authoring","cloudflare","e2ee","editor","ios","storage","svelte","web"],"dependencies":[{"issue_id":"attn-7xl.3.6","depends_on_id":"attn-7xl.3","type":"parent-child","created_at":"2026-07-10T16:42:58Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.3.6","depends_on_id":"attn-7xl.3.1","type":"blocks","created_at":"2026-07-10T16:43:53Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.3.6","depends_on_id":"attn-7xl.3.2","type":"blocks","created_at":"2026-07-10T16:43:54Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.3.6","depends_on_id":"attn-7xl.3.3","type":"blocks","created_at":"2026-07-10T16:43:55Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.3.6","depends_on_id":"attn-7xl.3.4","type":"blocks","created_at":"2026-07-10T16:43:55Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.3.6","depends_on_id":"attn-7xl.3.5","type":"blocks","created_at":"2026-07-10T16:43:56Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.3.6","depends_on_id":"attn-7xl.3.7","type":"blocks","created_at":"2026-07-10T17:47:59Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":6,"dependent_count":0,"comment_count":0} -{"id":"attn-7xl.3.5","title":"Add iOS editing and keyboard behavior","description":"## Objective\n\nAdd safe-area, virtual-keyboard, top file sheet, bottom review sheet, selection toolbar, and touch behavior while keeping save/share status visible.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/02-local-authoring.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nresponsive editor components/styles and mobile interaction tests\n\n## Validation\n\nRun mobile Chromium/WebKit viewports plus real iPhone/iPad keyboard, rotation, selection, file switching, and export checks.\n\n## Source\n\nplanning/web-authoring/02-local-authoring.md","acceptance_criteria":"Reader position survives entering/leaving edit mode; title/body targets and formatting controls are accessible with 44 px touch targets; toolbar tracks the real visual keyboard without obscuring content/save state; autosave reports Saved only after durable commit; dictation/autocorrect/undo/hardware keyboard/rotation/process-restart cases pass on real current iPhone/iPad; editing-disabled mode leaves the reader/reviewer fully usable.","notes":"Editing is an explicit mode over the reader contract, not a prerequisite for iOS usefulness. Use visualViewport and safe-area insets; keep save state above the keyboard; support selection, dictation, autocorrect, undo, hardware keyboard, rotation, and process recovery. If editing capability fails, the reader/reviewer from attn-7xl.3.7 must remain complete.","spec_id":"planning/web-authoring/02-local-authoring.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:42:57Z","created_by":"James Lal","updated_at":"2026-07-11T03:07:51Z","started_at":"2026-07-11T02:49:15Z","closed_at":"2026-07-11T03:07:51Z","close_reason":"iOS editing behavior landed: fixed formatting bar with 44x44px targets (Bold/Italic/H2/bullet list/Undo/Redo via new exported ProseMirror commands on Editor.svelte) positioned by visualViewport resize/scroll — riding above the thumb dock at rest and directly above the visual keyboard when it appears, with live save state inside the bar; workspace title is a separate accessible edit target in edit mode (inline input, durable rename); the compact header is sticky on mobile so save/share state stays visible; Files/Review sheets blur the editor before opening so they never stack under the keyboard; exiting edit returns to the retained reading position (3.7's position memory). Editing capability absence keeps the full reader (3.7 Open-native dock). Validation: Chromium+WebKit matrix now 30/30 (formatting produces '## **...**' markdown durably, title rename durable to the desk), routes suite 48/48, 54/54 unit files, svelte-check 0, staging deployed. Dictation/autocorrect/hardware-keyboard verification is real-device scope (7xl.7 gate).","labels":["authoring","cloudflare","e2ee","editor","ios","mobile","storage","svelte","web"],"dependencies":[{"issue_id":"attn-7xl.3.5","depends_on_id":"attn-7xl.3","type":"parent-child","created_at":"2026-07-10T16:42:57Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"attn-7xl.3.4","title":"Add multi-file and asset workspace support","description":"## Objective\n\nSupport safe local file create/rename/delete/navigation plus file and workspace Markdown/zip export, rejecting traversal, unsafe HTML, binary, and oversized input.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/02-local-authoring.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nweb file rail, path validation, import/export utilities\n\n## Validation\n\nRun nested-path, traversal, binary, size-cap, unsafe HTML, zip, and byte-round-trip tests.\n\n## Source\n\nplanning/web-authoring/02-local-authoring.md","acceptance_criteria":"Multi-file workspaces remain navigable and export ordinary Markdown; invalid paths/content fail before commit; user-authored HTML is not executed.","notes":"Approach: WorkspaceAppService gains createMarkdownEntry/addAssetFiles/renameEntry/deleteEntry/readEntryBytes/exportWorkspace (real passthrough to storage service; mock in-memory). import-files.ts gains per-file 64MiB cap + zip expansion via dynamically imported fflate (paths normalized, traversal rejects whole import). New export-zip.ts (fflate zip build + download trigger). EditorShell file rail: inline new-markdown input, add-assets picker, active-entry rename/delete/download actions, blob-URL inline preview for safe rasters only (svg/html stay download-only, never same-origin-executed), workspace zip export. Mutations navigate/reload through the existing full-page data flow. Tests: zip round-trip + traversal/oversize unit cases; e2e rail CRUD + png preview + zip download.","spec_id":"planning/web-authoring/02-local-authoring.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:42:55Z","created_by":"James Lal","updated_at":"2026-07-11T02:34:54Z","started_at":"2026-07-11T02:20:46Z","closed_at":"2026-07-11T02:34:54Z","close_reason":"Multi-file/asset support landed: file rail gains inline New-Markdown creation (nested paths, .md enforced), Add-files picker (multi-select + folder + zip expansion via dynamically-imported fflate), active-entry rename/delete(in-app confirm)/download, and workspace zip export preserving exact paths+bytes. Safe rasters render inline from decrypted bytes via short-lived blob URLs (revoked on nav); svg/html/unknown types stay download-only and are never navigated as same-origin content. Import safety: 64MiB per-file cap, traversal/absolute paths abort the whole import, corrupt zips are clear errors, __MACOSX/.DS_Store junk skipped. Service interface + real/mock impls extended (createMarkdownEntry/addAssetFiles/renameEntry/deleteEntry/readEntryBytes/exportWorkspace). Validation: 8 import/export unit cases (zip round-trip byte-identical, traversal-in-zip rejected), 48/48 e2e locally + authoring suite green on staging (rail CRUD, decoded 1x1 png naturalWidth===1 proving real decrypt-to-preview, zip download, nested zip import); 54/54 unit files, svelte-check 0, bundle gate green.","labels":["assets","authoring","cloudflare","e2ee","editor","ios","storage","svelte","web"],"dependencies":[{"issue_id":"attn-7xl.3.4","depends_on_id":"attn-7xl.3","type":"parent-child","created_at":"2026-07-10T16:42:55Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"attn-7xl.3.3","title":"Add autosave and revision recovery","description":"## Objective\n\nReuse the existing editor stack, commit immutable revisions after bounded debounce, flush on lifecycle signals, and recover the last committed head with honest save/conflict state.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/02-local-authoring.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nweb editor integration, autosave controller, recovery UI/tests\n\n## Validation\n\nTest typing/reload, pagehide, visibility loss, quota failure, process-like termination, conflict, and stale lease behavior.\n\n## Source\n\nplanning/web-authoring/02-local-authoring.md","acceptance_criteria":"Saved appears only after durable commit; reload/process restart recovers the last committed revision; failure preserves editable/exportable content and never reports a false save.","spec_id":"planning/web-authoring/02-local-authoring.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:42:54Z","created_by":"James Lal","updated_at":"2026-07-11T02:20:13Z","started_at":"2026-07-11T02:08:49Z","closed_at":"2026-07-11T02:20:13Z","close_reason":"Autosave + revision recovery landed: AutosaveController (pure, deterministic-tested) commits immutable revisions after bounded debounce (1.2s quiet / 8s max under continuous typing), serializes in-flight commits, retries failures without losing text, and reports honest states — 'Saved on this device' only after the durable IndexedDB commit ('Saving…' during, 'Storage needs attention' on failure, retry automatic). EditingSession on the service interface: real impl holds the cross-tab writer lease (5s heartbeat inside the 15s lease), performs fenced head-tracked commits, releases on exit; denial renders an honest 'Another tab is editing' read-only state. EditorShell gains an explicit Edit/Done mode that dynamically loads the existing ProseMirror stack (bundle gate intact) with a per-change watcher plugin; flush on visibilitychange/pagehide/unmount; durable commit counter observable in DOM. Validation: 5 autosave unit cases (debounce/bounded/flush/failure-retry/in-flight), 46/46 e2e locally incl type-\u003eautosave-\u003ereload recovery and two-tab lease denial+handoff, authoring suite green against deployed staging, svelte-check 0, bundle gate green.","labels":["authoring","cloudflare","e2ee","editor","ios","storage","svelte","web"],"dependencies":[{"issue_id":"attn-7xl.3.3","depends_on_id":"attn-7xl.3","type":"parent-child","created_at":"2026-07-10T16:42:54Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"attn-7xl.3.2","title":"Wire one-click workspace and local desk actions","description":"## Objective\n\nConnect New document, Markdown/multi-file import, recent workspace resume, rename, and delete to the durable workspace service.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/02-local-authoring.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nweb hosted local home components/routes and import actions\n\n## Validation\n\nPlaywright intercepts network while creating/importing/resuming/deleting and asserts zero relay requests before Share.\n\n## Source\n\nplanning/web-authoring/02-local-authoring.md","acceptance_criteria":"Every local desk action commits atomically and renders the correct recent-workspace state; blank creation works offline with no network request.","notes":"Approach: types.ts gains async WorkspaceAppService view interface (persistenceMode sync; list/loadDetail/readBodyText/create/importFiles/renameWorkspace/deleteWorkspace async; shareScopeFor pure). MockWorkspaceService implements it (scenarios stay for ?shell=demo|private|blocked|quota|empty); new real-service.ts adapter maps BrowserWorkspaceService records -\u003e view types (kind/mediaType -\u003e presentation; saveState from durable outcomes). app/main.ts: no ?shell param -\u003e DYNAMIC import of real adapter (bundle gate forbids static crypto in app entry) with 'Opening your desk...' placeholder + unavailable degraded fallback; #new intent performs the real atomic create then history.replaceState to /app/w/:id/untitled.md. DeskHome/EditorShell/AppShell become props+callback driven (typed props, no service coupling in leaves). Desk gains rename (inline) + delete (in-app confirm) + import via file picker; editor shows the real head body text. E2E: mock specs move to ?shell=demo; new hosted-authoring.spec.ts proves one-click create -\u003e reload persistence -\u003e import/export with network interception showing zero relay requests before Share.","spec_id":"planning/web-authoring/02-local-authoring.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:42:52Z","created_by":"James Lal","updated_at":"2026-07-11T02:08:15Z","started_at":"2026-07-11T01:47:23Z","closed_at":"2026-07-11T02:08:15Z","close_reason":"Local desk wired to real storage: app entry boots RealWorkspaceAppService (dynamic import keeps crypto out of the static graph; ?shell= scenarios keep the mock for degraded states), shells rewired to typed async props/callbacks. One-click New workspace (button + landing /app#new intent) atomically creates untitled.md, opens the editor in place, and history.replaceState's to /app/w/:id/untitled.md — zero off-origin requests, proven by interception. Desk lists real workspaces with durable inline rename + in-app-confirmed delete + resume; import via picker preserves relative paths/media types (folder-safe mapping in import-files.ts + crypto-free shared entry-path module); editor shows the real head body; storage/open pages take real health+workspaces. Validation: 44/44 Playwright vs wrangler dev AND vs deployed staging (version f0a61804) incl 4 real-authoring flows; 52/52 unit files (one known flake filed as bug); svelte-check 0; bundle gate green after extracting entry-path.ts.","labels":["authoring","cloudflare","e2ee","editor","ios","storage","svelte","web"],"dependencies":[{"issue_id":"attn-7xl.3.2","depends_on_id":"attn-7xl.3","type":"parent-child","created_at":"2026-07-10T16:42:52Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"attn-7xl.3.1","title":"Add browser workspace service and Svelte state","description":"## Objective\n\nExpose typed workspace load/create/import/export/edit operations and instance-scoped Svelte 5 state over the storage layer.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/02-local-authoring.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nweb workspace service and Svelte state adapter\n\n## Validation\n\nRun service unit tests, Svelte checks, lifecycle tests, and multiple-instance isolation tests.\n\n## Source\n\nplanning/web-authoring/02-local-authoring.md","acceptance_criteria":"UI state reflects durable storage outcomes, no module-level mutable state leaks between instances, and service errors map to explicit user-visible states.","spec_id":"planning/web-authoring/02-local-authoring.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:42:51Z","created_by":"James Lal","updated_at":"2026-07-11T01:45:59Z","closed_at":"2026-07-11T01:45:59Z","close_reason":"Browser workspace service + Svelte state landed: workspace-service.ts (BrowserWorkspaceService over BrowserStorage) with typed open (capability-gated: unusable browsers raise kind=unavailable), list/load/create (one-click untitled.md, zero network), commitText/readHeadText/Bytes, createMarkdown/addAsset, rename entry+workspace (new WorkspaceStore.renameWorkspace), delete/select, import/export preserving nested paths+bytes+media types, lease access (BrowserStorage.leases()), UI label shaping (sizeLabel/relativeTimeLabel), and mapError -\u003e {conflict|quota|unavailable|storage} user-visible kinds. workspace-state.svelte.ts is the thin instance-scoped runes adapter (status/workspaces/persistence/saveState/error/current all instance fields; save state reflects durable outcomes only, never optimistic). Validation: 9 service cases incl import/export round-trip, error kinds, two-instance isolation, fenced commit through the service; 52/52 unit files, svelte-check 0, bundle gate green (service not yet statically imported by app entry — 3.2 must dynamic-import it).","labels":["authoring","cloudflare","e2ee","editor","ios","storage","svelte","web"],"dependencies":[{"issue_id":"attn-7xl.3.1","depends_on_id":"attn-7xl.3","type":"parent-child","created_at":"2026-07-10T16:42:50Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"attn-7xl.2.7","title":"Validate workspace storage v3","description":"## Objective\n\nRun the complete storage validation matrix across migrations, crypto, atomicity, OPFS fallback, quota failure, reload, and required iOS Safari modes.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/01-storage-foundation.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nweb storage tests, Playwright fixtures, device evidence\n\n## Validation\n\nRun web checks/storage tests/property and fault tests, Chromium/WebKit reload cases, and manual current iOS normal/private/Home Screen/low-storage cases.\n\n## Source\n\nplanning/web-authoring/01-storage-foundation.md","acceptance_criteria":"All Phase 01 validation cases pass; the evidence proves IDB is a complete baseline and no failure mode silently loses the last committed plaintext-equivalent revision.","spec_id":"planning/web-authoring/01-storage-foundation.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:42:49Z","created_by":"James Lal","updated_at":"2026-07-11T01:39:36Z","started_at":"2026-07-11T01:34:13Z","closed_at":"2026-07-11T01:39:36Z","close_reason":"Automated storage v3 validation gate complete, evidence in planning/web-authoring/validation-01.md: 51/51 unit files (63 storage-specific cases across schema/crypto/store/OPFS/probe/lease/validation suites), migration corpus green, seeded 120-step property test (76 mutating ops, all invariants held), fault injection (injected QuotaExceededError preserves last committed head + recovers; aborts leave prior state; OPFS write-failure/corrupt-read fail closed with fallback; orphans swept), and 8/8 real-browser Playwright persistence/reload tests on BOTH Chromium and WebKit (create/commit survives reload, 700KiB OPFS/fallback body, honest capability probe incl WebKit-ephemeral volatile, cross-tab lease read-only second context; npm run test:e2e:storage). Real iOS Safari device pass is a human gate filed as attn-7xl.2.9, consolidated with the attn-7xl.7 device matrix.","labels":["authoring","cloudflare","crypto","e2ee","indexeddb","ios","opfs","storage","web"],"dependencies":[{"issue_id":"attn-7xl.2.7","depends_on_id":"attn-7xl.2","type":"parent-child","created_at":"2026-07-10T16:42:49Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.2.7","depends_on_id":"attn-7xl.2.1","type":"blocks","created_at":"2026-07-10T16:43:49Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.2.7","depends_on_id":"attn-7xl.2.2","type":"blocks","created_at":"2026-07-10T16:43:50Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.2.7","depends_on_id":"attn-7xl.2.3","type":"blocks","created_at":"2026-07-10T16:43:51Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.2.7","depends_on_id":"attn-7xl.2.4","type":"blocks","created_at":"2026-07-10T16:43:51Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.2.7","depends_on_id":"attn-7xl.2.5","type":"blocks","created_at":"2026-07-10T16:43:52Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.2.7","depends_on_id":"attn-7xl.2.6","type":"blocks","created_at":"2026-07-10T16:43:53Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":6,"dependent_count":0,"comment_count":0} -{"id":"attn-7xl.2.6","title":"Add fenced cross-tab writer leases","description":"## Objective\n\nImplement IndexedDB-backed lease acquisition, fencing tokens, heartbeat, takeover, and read-only secondary tabs; use BroadcastChannel only as notification.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/01-storage-foundation.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nbrowser storage lease module, multi-context tests\n\n## Validation\n\nRun simultaneous acquire, expired takeover, delayed stale write, abrupt-tab-loss, and BroadcastChannel-absent tests.\n\n## Source\n\nplanning/web-authoring/01-storage-foundation.md","acceptance_criteria":"Exactly one current fencing token can commit; stale tabs cannot overwrite; takeover is explicit and works without Web Locks or BroadcastChannel.","notes":"Approach: browser-workspace-lease.ts WorkspaceLeaseManager over the existing workspace_leases store + WorkspaceStore fencing checks (2.3). acquire(): single readwrite IDB tx (serialized across tabs) — grants when absent/expired/same-holder, bumps fencingToken on takeover, returns null when another live holder owns it (caller enters read-only). heartbeat(): extends expiresAt only for exact holder+token, else StorageConflictError. release(): deletes only if owned. BroadcastChannel 'attn-workspace-lease' advisory notifications (injectable factory; never decides correctness). Tests: fresh grant, live-holder denial, expiry takeover fences old holder's store writes, heartbeat extend/conflict, release/reacquire token monotonicity, concurrent acquire race yields one winner, channel events.","spec_id":"planning/web-authoring/01-storage-foundation.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:42:48Z","created_by":"James Lal","updated_at":"2026-07-11T01:33:11Z","started_at":"2026-07-11T01:30:43Z","closed_at":"2026-07-11T01:33:11Z","close_reason":"Fenced cross-tab writer leases landed (browser-workspace-lease.ts): WorkspaceLeaseManager decides ownership via serialized IDB readwrite transactions — acquire grants on absent/expired/released/same-holder and bumps the fencing token on takeover (returns null while another holder is live, so secondary tabs stay read-only); heartbeat extends only for the exact holder+token, else StorageConflictError; release writes an expired tombstone preserving the token counter so fencing tokens are monotonic and the releasing holder is immediately fenced. BroadcastChannel notifications are advisory only (injectable, never authoritative). Combined with 2.3's mutation fencing, a suspended zombie tab cannot write after losing its lease. Validation: 6 cases incl expiry takeover fencing store writes, heartbeat extension keeping rivals out, monotonic tokens across release/reacquire, and a 3-way concurrent acquire race resolving to exactly one winner; 50/50 unit files, svelte-check 0.","labels":["authoring","cloudflare","crypto","e2ee","indexeddb","ios","opfs","storage","web"],"dependencies":[{"issue_id":"attn-7xl.2.6","depends_on_id":"attn-7xl.2","type":"parent-child","created_at":"2026-07-10T16:42:47Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"attn-7xl.2.5","title":"Probe storage capabilities and durability","description":"## Objective\n\nCapability-test IndexedDB, non-extractable CryptoKey cloning, OPFS, estimate, and persistence, returning typed persistent/best_effort/volatile/unsupported results with precise causes.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/01-storage-foundation.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nbrowser storage capability module and state types\n\n## Validation\n\nRun feature-failure matrix without user-agent sniffing in Chromium/WebKit and capability mocks.\n\n## Source\n\nplanning/web-authoring/01-storage-foundation.md","acceptance_criteria":"Each real operation is probed, unsupported APIs degrade accurately, unknown quota remains unknown, and UI consumers receive enough cause data for remediation.","notes":"Approach: new browser-storage-probe.ts. probeStorageCapabilities() runs real round-trips (never UA sniffing): (1) IndexedDB probe DB put/get/delete, (2) non-extractable HKDF CryptoKey survives an IDB structured-clone round-trip and still deriveBits, (3) OPFS write/read/delete probe file. Typed result {mode: persistent|best_effort|volatile|unsupported, per-probe {ok, cause {name,message}}, persisted, estimate}. Mode rules: idb or key probe fails -\u003e unsupported; persisted()===true -\u003e persistent; OPFS API present but refuses with SecurityError/NotAllowedError/UnknownError (Safari Private Browsing signal) -\u003e volatile; else best_effort. Optional requestPersist. Helpers: toPersistenceMode() mapping to the shell union, quotaPressure(estimate). Tests with fake navigator/factory/crypto covering each mode + precise causes.","spec_id":"planning/web-authoring/01-storage-foundation.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:42:46Z","created_by":"James Lal","updated_at":"2026-07-11T01:29:00Z","started_at":"2026-07-11T01:26:49Z","closed_at":"2026-07-11T01:29:00Z","close_reason":"Storage capability/durability probes landed (browser-storage-probe.ts): probeStorageCapabilities() exercises real APIs — IndexedDB probe-DB round-trip, non-extractable HKDF CryptoKey structured-clone round-trip that still deriveBits, OPFS write/read/delete — and reports typed mode persistent|best_effort|volatile|unsupported with precise per-probe {name,message} causes, persisted(), optional persist() request, and quota estimate. No UA sniffing: volatile is only claimed when the OPFS API exists but refuses with a security-shaped error (WebKit Private Browsing signal); Chromium private windows honestly report best_effort. Helpers toPersistenceMode() (maps onto the shell union incl quota-pressure override) and quotaPressure(). 8 test cases cover every mode + causes; 49/49 unit files, svelte-check 0.","labels":["authoring","cloudflare","crypto","e2ee","indexeddb","ios","opfs","storage","web"],"dependencies":[{"issue_id":"attn-7xl.2.5","depends_on_id":"attn-7xl.2","type":"parent-child","created_at":"2026-07-10T16:42:46Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"attn-7xl.2.4","title":"Add encrypted OPFS tier with IDB fallback","description":"## Objective\n\nStore large encrypted bodies at opaque OPFS paths using temp-write/promote/GC while retaining an encrypted IndexedDB Blob/ArrayBuffer fallback for every operation.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/01-storage-foundation.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nbrowser OPFS adapter, workspace GC store, storage tests\n\n## Validation\n\nInject missing OPFS, write/close failures, transaction aborts, orphan cleanup, threshold crossings, and read verification.\n\n## Source\n\nplanning/web-authoring/01-storage-foundation.md","acceptance_criteria":"Normal Markdown workspaces work without OPFS; successful OPFS writes are integrity-checked; crashes cannot expose plaintext or lose the last committed revision.","notes":"Approach: SealedBody gains 'idb-large' fallback variant (validator caps: idb\u003c=512KiB inline, idb-large\u003c=MAX_BODY+tag). WorkspaceStore takes {filesystem, inlineThresholdBytes} (BrowserStorage.workspaces passes its OPFS-backed SealedBlobFileSystem). Seal routing: sealed\u003ethreshold \u0026\u0026 fs -\u003e OPFS via write-ahead gc-intent (gc record committed first, file written to opaque sha256-derived path, read-back verified, revision commit tx deletes intent); any OPFS failure falls back to idb-large + best-effort file delete. openBody supports opfs (length check + AEAD auth) and idb-large. sweepGc() deletes orphaned files then intents. deleteWorkspace deletes the workspace OPFS prefix. Tests with in-memory fault-injectable filesystem: routing, corrupt-file fail-closed, absent-OPFS fallback, write-failure fallback, crash-orphan sweep, threshold config, rename/delete of opfs bodies.","spec_id":"planning/web-authoring/01-storage-foundation.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:42:45Z","created_by":"James Lal","updated_at":"2026-07-11T01:25:46Z","started_at":"2026-07-11T01:20:53Z","closed_at":"2026-07-11T01:25:46Z","close_reason":"Encrypted OPFS tier + IDB fallback landed: sealed bodies over a configurable threshold route to OPFS at opaque sha256-derived paths (no workspace/revision id leakage) behind a write-ahead GC intent (intent committed first, file written + read-back verified, revision commit tx clears the intent); any OPFS absence/failure falls back to encrypted IndexedDB (inline 'idb' under the 512KiB cap, new 'idb-large' variant above it) so no operation ever blocks on OPFS. sweepGc() deletes orphaned files/intents and workspace prefixes; live committed files survive sweeps; rename/delete retire OPFS bodies through the ledger; deleteWorkspace records a durable prefix-removal intent then executes it. BrowserStorage.workspaces now passes its OPFS-backed filesystem. Validation: 7 new fault-injection cases (routing, corrupt-read fail-closed, missing-OPFS medium/oversized fallbacks, write-failure fallback with no stray file/intent, crash-orphan sweep preserving live files, retire-on-rename/delete, prefix removal); 48/48 unit files, svelte-check 0, bundle gate green.","labels":["authoring","cloudflare","crypto","e2ee","indexeddb","ios","opfs","storage","web"],"dependencies":[{"issue_id":"attn-7xl.2.4","depends_on_id":"attn-7xl.2","type":"parent-child","created_at":"2026-07-10T16:42:44Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"attn-7xl.2.3","title":"Implement atomic workspace transactions","description":"## Objective\n\nAdd crash-safe create, rename, delete, select, immutable revision commit, and head-advance APIs with monotonic timestamps and fencing conflict checks.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/01-storage-foundation.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nbrowser-storage workspace APIs and transaction/property tests\n\n## Validation\n\nRun atomicity, idempotency, conflict, aborted transaction, and last-committed-head tests.\n\n## Source\n\nplanning/web-authoring/01-storage-foundation.md","acceptance_criteria":"Multi-record operations are atomic, immutable history is preserved, stale writers cannot advance heads, and errors leave a recoverable committed state.","notes":"Approach: new browser-workspace-store.ts (WorkspaceStore, exposed as BrowserStorage.workspaces getter) + browser-idb.ts shared helpers (requestValue/transactionDone/isConstraintError). Seal-outside-transaction design: WebCrypto cannot be awaited inside an IDB tx, so commit paths pre-seal with a tentative clock and retry (bounded) if the workspace clock moved; final write is one atomic multi-store tx (workspaces+entries+revisions+gc) enforcing head expectation, path uniqueness (store keys + unique history index), strictly-increasing per-workspace clock, clamped monotonic wall timestamps, and optional lease fencing checks (full lease lifecycle lands in 2.6). Rename re-seals the head at the new path (AAD binds path) and GC-queues old revisions; delete tombstones + GC-queues; deleteWorkspace crypto-erases key first. OPFS bodies deferred to 2.4 (loader throws typed error). Immutable history via [workspaceId,path,clock] unique index; idempotent commits dedupe by revisionId+bodyHash.","spec_id":"planning/web-authoring/01-storage-foundation.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:42:43Z","created_by":"James Lal","updated_at":"2026-07-11T01:19:47Z","started_at":"2026-07-11T01:11:29Z","closed_at":"2026-07-11T01:19:47Z","close_reason":"Atomic workspace transactions landed (browser-workspace-store.ts, exposed as BrowserStorage.workspaces): createWorkspace (one-click: workspace+entry+revision in one tx; orphan-key retry), createEntry (path uniqueness, tombstone revive, entry cap), commitRevision (optimistic expectedHead, idempotent revisionId replay, conflict on id/content mismatch), renameEntry (re-seals head at new path since AAD binds path; retires old revisions; selection follows), deleteEntry (tombstone + revision retirement + selection clear), selectEntry, deleteWorkspace (crypto-erases key first). Seal-outside-tx design with bounded clock-race retries since WebCrypto cannot run inside an IDB tx; per-workspace clock strictly increases; wall timestamps clamped monotonic; optional lease fencing checks (lifecycle in 2.6); OPFS bodies typed-error until 2.4. IDB helpers extracted to browser-idb.ts. Validation: 13 new cases covering atomicity, idempotency, conflicts, aborted tx leaving prior head intact, backwards wall clock, fencing; 47/47 unit files, svelte-check 0, bundle gate green.","labels":["authoring","cloudflare","crypto","e2ee","indexeddb","ios","opfs","storage","web"],"dependencies":[{"issue_id":"attn-7xl.2.3","depends_on_id":"attn-7xl.2","type":"parent-child","created_at":"2026-07-10T16:42:42Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"attn-7xl.2.2","title":"Seal workspace keys and revisions","description":"## Objective\n\nImplement non-extractable workspace HKDF roots, domain-separated local keys, sealed revision bodies, and wrapped room invite capabilities with routing metadata bound as AAD.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/01-storage-foundation.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nbrowser storage crypto modules, canonical crypto vectors/tests\n\n## Validation\n\nRun key non-extractability, seal/open, AAD tamper, vector, and zeroization-oriented tests.\n\n## Source\n\nplanning/web-authoring/01-storage-foundation.md","acceptance_criteria":"No raw workspace root, revision plaintext, owner private material, or invite secret is stored; compatible ciphertext opens after reload and tampering fails closed.","spec_id":"planning/web-authoring/01-storage-foundation.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:42:41Z","created_by":"James Lal","updated_at":"2026-07-11T01:11:00Z","started_at":"2026-07-11T01:06:36Z","closed_at":"2026-07-11T01:11:00Z","close_reason":"Workspace sealing crypto landed (browser-workspace-crypto.ts): per-workspace non-extractable HKDF roots generated on-device (raw bytes never escape generateWorkspaceRootKey), domain-separated subkeys (revision/capability/recovery v1 infos, KAT-pinned), XChaCha20-Poly1305 seal/open for revision bodies, wrapped room-invite capabilities, and recovery payloads with routing metadata bound as canonical AAD; transient subkeys/nonces/AAD zeroed. BrowserStorage gains createWorkspaceKey (single-root conflict), getWorkspaceRootKey (validated non-extractable), deleteWorkspaceKey (crypto-erasure primitive, delete-key-first contract). Validation: 9 test cases — KAT vectors, non-extractability/export failure, reload round-trip, fresh nonces, 6-way AAD swap + bit-flip + truncation + bad nonce all fail closed, capability/recovery binding, crypto-erasure leaves old bytes permanently opaque under a fresh key. 46/46 unit files, svelte-check 0, bundle gate green (no crypto in landing/app).","labels":["authoring","cloudflare","crypto","e2ee","indexeddb","ios","opfs","storage","web"],"dependencies":[{"issue_id":"attn-7xl.2.2","depends_on_id":"attn-7xl.2","type":"parent-child","created_at":"2026-07-10T16:42:41Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"attn-7xl.2.1","title":"Define IndexedDB schema v3 migrations","description":"## Objective\n\nAdd versioned stores, keys, indexes, runtime record validation, size caps, and v2-to-v3 workspace migration fixtures to the existing database.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/01-storage-foundation.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nweb/src/lib/review/browser-storage.ts and migration tests/fixtures\n\n## Validation\n\nRun empty/v1/v2/interrupted/corrupt migration corpus and browser storage tests.\n\n## Source\n\nplanning/web-authoring/01-storage-foundation.md","acceptance_criteria":"Existing room records remain compatible; every workspace store upgrades atomically; malformed or interrupted state fails recoverably without silent data loss.","notes":"Scope clarification 2026-07-10: workspaces are folder-shaped and must interoperate identically across web and native. They may contain nested Markdown plus arbitrary binary assets with normalized relative paths. Safe raster types may render inline; unknown/active types are download-only. Share supports current file, selected entries, or whole workspace through one canonical manifest. The landing New workspace action creates untitled.md and opens the editor in one click with no dialog or network request. See updated planning/web-authoring/.","spec_id":"planning/web-authoring/01-storage-foundation.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:42:40Z","created_by":"James Lal","updated_at":"2026-07-11T01:06:10Z","started_at":"2026-07-11T00:57:37Z","closed_at":"2026-07-11T01:06:10Z","close_reason":"Schema v3 landed: BROWSER_STORAGE_SCHEMA_VERSION 2-\u003e3 with eight new workspace stores (workspaces, workspace_keys, entries [workspaceId+path], revisions [workspaceId+revisionId, unique by_workspace_path_clock history index], share_caps, recovery, gc, leases) created in one upgrade so later steps need no further version bump. New browser-workspace-schema.ts defines typed records, size caps (name 256B, path 1KiB/64 segments, inline sealed body 512KiB, body hard cap 256MiB), NFC path normalization rejecting absolute/dot-dot/backslash/control chars, and per-store runtime validators pinning record version. Privacy split documented: metadata plaintext, revision bodies/caps/recovery sealed. Errors extracted to browser-storage-errors.ts (re-exported). Validation: 10-case corpus (empty/v1/v2/interrupted-claim/corrupt-v3) all passing — legacy rooms, inbox/cursors survive upgrade, interrupted claims stay hidden and roll back, corrupt records fail loudly with the DB re-openable; 45/45 unit files, svelte-check 0, bundle gate green.","labels":["authoring","cloudflare","crypto","e2ee","indexeddb","ios","opfs","storage","web"],"dependencies":[{"issue_id":"attn-7xl.2.1","depends_on_id":"attn-7xl.2","type":"parent-child","created_at":"2026-07-10T16:42:39Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"attn-7xl.1.5","title":"Validate unified web presence","description":"## Objective\n\nRun the complete Phase 00 validation gate and capture actionable evidence for routes, bundles, responsive layout, accessibility, and Cloudflare preview.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/00-web-presence.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nweb tests/config and planning validation notes\n\n## Validation\n\nRun npm checks/tests/build, route chunk inspection, desktop/iPhone/320 px Playwright, keyboard/a11y audit, and Cloudflare preview smoke.\n\n## Source\n\nplanning/web-authoring/00-web-presence.md","acceptance_criteria":"Every Phase 00 validation item passes with reproducible commands and screenshots; failures are fixed or filed before this task closes.","spec_id":"planning/web-authoring/00-web-presence.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:42:38Z","created_by":"James Lal","updated_at":"2026-07-11T00:56:52Z","started_at":"2026-07-11T00:45:32Z","closed_at":"2026-07-11T00:56:52Z","close_reason":"Phase 00 validation gate complete with evidence in planning/web-authoring/validation-00.md: svelte-check 0/0, 44 unit files pass, build + module-precision bundle boundaries green, 40/40 Playwright vs local wrangler dev AND vs deployed staging.attn.sh (version a4234267), axe WCAG A/AA 0 violations across all pages/dialogs/degraded states, keyboard-only flows pass, 320px zero overflow on all surfaces, desktop+iPhone light/dark screenshots captured, staging deploy live-verified with pinned CSP on one origin. Fixed during gate: .file-size contrast (serious), axe flake stabilized by awaiting fonts/images. WebKit + real-device matrix deferred to attn-7xl.7 per plan.","labels":["authoring","cloudflare","e2ee","frontend","ios","landing","storage","svelte","web"],"dependencies":[{"issue_id":"attn-7xl.1.5","depends_on_id":"attn-7xl.1","type":"parent-child","created_at":"2026-07-10T16:42:37Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.1.5","depends_on_id":"attn-7xl.1.1","type":"blocks","created_at":"2026-07-10T16:43:47Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.1.5","depends_on_id":"attn-7xl.1.2","type":"blocks","created_at":"2026-07-10T16:43:47Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.1.5","depends_on_id":"attn-7xl.1.3","type":"blocks","created_at":"2026-07-10T16:43:48Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.1.5","depends_on_id":"attn-7xl.1.4","type":"blocks","created_at":"2026-07-10T16:43:49Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":4,"dependent_count":0,"comment_count":0} -{"id":"attn-7xl.1.4","title":"Prepare split-landing retirement","description":"## Objective\n\nDocument and implement metadata/redirect parity needed to retire the separate Vercel landing only after Cloudflare parity is proven.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/00-web-presence.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nsite deployment config, web hosted deployment docs, redirects/metadata\n\n## Validation\n\nCompare metadata, install links, route behavior, and screenshots between current and Cloudflare preview.\n\n## Source\n\nplanning/web-authoring/00-web-presence.md","acceptance_criteria":"A reversible cutover checklist exists, parity gaps are resolved, and no Vercel ownership is removed before the final rollout feature.","spec_id":"planning/web-authoring/00-web-presence.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:42:36Z","created_by":"James Lal","updated_at":"2026-07-11T00:45:02Z","started_at":"2026-07-11T00:39:27Z","closed_at":"2026-07-11T00:45:02Z","close_reason":"Cutover prepared, not executed: planning/web-authoring/07-landing-cutover.md documents verified current state (attn.sh 307-\u003ewww Vercel; www/review 404 = production invites broken today; native default already attn.sh/review; relay.attn.sh already allowlists attn.sh), parity matrix (metadata/favicons/fonts/themes/install links resolved; theme-init flash documented as accepted), reversible step-by-step cutover + rollback via recorded Vercel DNS, and post-cutover verification incl the observed cf-cache-status HIT-on-HTML hazard. Implemented: worker www-\u003eapex 308 (fragment-safe), CSP parameterized by RELAY_ORIGIN wrangler var (staging pins relay-staging, prepared wrangler.production.jsonc pins relay.attn.sh + attn.sh/www custom domains, deploy-gated on attn-7xl.7 owner approval), deploy script verifies the var pin. Unit tests for CSP/redirect helpers; 44/44 unit files, svelte-check 0, 28/28 e2e, dry-run green. No Vercel ownership touched.","labels":["authoring","cloudflare","e2ee","frontend","ios","landing","storage","svelte","web"],"dependencies":[{"issue_id":"attn-7xl.1.4","depends_on_id":"attn-7xl.1","type":"parent-child","created_at":"2026-07-10T16:42:36Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"attn-7xl.1.3","title":"Build local workspace page shells","description":"## Objective\n\nImplement typed non-persistent Svelte shells for local home, editor, Share, storage/recovery, and degraded states using an injected mock workspace service.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/00-web-presence.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nweb/src/hosted/app, shared Svelte components and tokens\n\n## Validation\n\nRun Svelte checks plus keyboard and Playwright screenshots at desktop, 390 px, and 320 px.\n\n## Source\n\nplanning/web-authoring/00-web-presence.md","acceptance_criteria":"All designed surfaces render and navigate with typed props/runes, no storage coupling, no horizontal scroll at 320 px, and accessible focus/touch behavior.","notes":"Approach: extract shared design tokens (web/src/hosted/tokens.css) + move theme.svelte.ts up from landing/ so landing and app entries share one system. New web/src/hosted/app/: types.ts (WorkspaceService interface, WorkspaceSummary/Detail/Entry, StorageHealth, SaveState unions), mock-service.ts (fixture data from prototype.html; degraded scenario via ?shell= query param: private|blocked|quota|empty), AppShell.svelte router (parseAppRoute + #new intent), DeskHome, EditorShell (desktop 240/flex/320 grid; \u003c900px reader-first with bottom dock + Files/Review bottom sheets w/ focus restore), ShareSheet (4-step overlay dialog), StoragePage (export/import, meter, in-app destructive confirm — no window.confirm), OpenPage, DegradedBanner. app/main.ts mounts AppShell with injected mock service; placeholder DOM + app-placeholder.css removed. E2E: new hosted-shells.spec.ts in routes config (desk, editor, share open/close focus, storage, open, degraded states, 320px overflow, 390px dock+sheets, screenshots desktop/390/320); update route-spec headings.","spec_id":"planning/web-authoring/00-web-presence.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:42:34Z","created_by":"James Lal","updated_at":"2026-07-11T00:38:38Z","started_at":"2026-07-11T00:27:04Z","closed_at":"2026-07-11T00:38:38Z","close_reason":"Local workspace shells implemented as typed Svelte 5 runes components behind injected WorkspaceService (mock; ?shell= scenarios private/blocked/quota/empty): DeskHome, EditorShell (desktop 240/flex/320; \u003c900px reader-first with thumb dock + Files/Review bottom sheets, Escape + focus restore), ShareSheet (4-step flow, honest durability copy per persistence mode), StoragePage (in-app destructive confirm, no window.confirm), OpenPage, DegradedBanner with 00-web-presence failure copy, ios-ux badge states. Shared tokens extracted to src/hosted/tokens.css; theme module shared. Zero storage coupling. Validation: svelte-check 0 errors, 43/43 unit files, bundle gate green (app 10 static files, no editor/crypto), 28/28 Playwright vs wrangler dev (dialogs, focus return, degraded states, 320px no-overflow x5, mobile dock/sheets), screenshots reviewed vs prototype.","labels":["authoring","cloudflare","e2ee","frontend","ios","landing","storage","svelte","web"],"dependencies":[{"issue_id":"attn-7xl.1.3","depends_on_id":"attn-7xl.1","type":"parent-child","created_at":"2026-07-10T16:42:34Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"attn-7xl.1.2","title":"Migrate and revise the landing page","description":"## Objective\n\nMove the reusable site design language into the canonical hosted app and adopt the private-desk browser-first message, CTAs, metadata, themes, and responsive navigation.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/00-web-presence.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nsite/src/lib, site/src/routes/+page.svelte, web/src/hosted/landing\n\n## Validation\n\nRun Svelte checks and Playwright desktop/iPhone screenshots against the approved prototype.\n\n## Source\n\nplanning/web-authoring/00-web-presence.md","acceptance_criteria":"The landing leads with Open your desk, retains native install/GitHub paths, removes no-browser claims, preserves self-hosted assets, and matches the page design.","notes":"Approach: build landing as Svelte 5 components in web/src/hosted/landing (Nav, Hero, EntryStrip, HowItWorks, Surfaces, NativeInstall, Footer, CopyCode, theme.svelte.ts) mounted from hosted/landing.ts. Visual language from planning/web-authoring/landing-v2.html (bespoke CSS, extended with dark palette derived from site tokens); canonical copy from 00-web-presence.md (hero 'A private desk for working documents', primary CTA New workspace -\u003e /app#new, secondary Open your desk -\u003e /app, three-part story, native install below). Self-hosted @fontsource-variable fonts (already web deps), screenshots imported via Vite from site/static, favicons via new hosted/public dir. Theme: data-theme stamped by entry module (localStorage ?? prefers-color-scheme) — CSP forbids inline scripts. Extend hosted-routes e2e with landing content + theme-toggle asserts.","spec_id":"planning/web-authoring/00-web-presence.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:42:32Z","created_by":"James Lal","updated_at":"2026-07-11T00:26:17Z","started_at":"2026-07-11T00:16:13Z","closed_at":"2026-07-11T00:26:17Z","close_reason":"Landing migrated to hosted app as Svelte 5 components (web/src/hosted/landing: Nav/Hero/EntryStrip/HowItWorks/Surfaces/NativeSection/Footer/CopyCode + theme.svelte.ts). Design language from approved landing-v2 prototype with new warm dark palette; canonical 00-web-presence copy (hero 'A private desk for working documents', New workspace -\u003e /app#new primary, Open your desk secondary, native install + GitHub retained below, no 'No browser tab' claim). Self-hosted @fontsource fonts, Vite-hashed theme-swapped screenshots, favicons via hosted/public, CSP-safe theme init (no inline script, prefers-color-scheme default + localStorage override). Bundle gate upgraded to module-id precision (chunk-modules.json plugin) so page copy may say ProseMirror. Validation: svelte-check 0 errors, 43/43 unit files, boundary gate green (landing 6 static files, no editor/crypto), 11/11 Playwright vs wrangler dev incl theme persistence + 320px no-overflow; desktop light/dark + iPhone screenshots visually match prototype.","labels":["authoring","cloudflare","e2ee","frontend","ios","landing","storage","svelte","web"],"dependencies":[{"issue_id":"attn-7xl.1.2","depends_on_id":"attn-7xl.1","type":"parent-child","created_at":"2026-07-10T16:42:32Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"attn-7xl.1.1","title":"Add hosted multi-entry routing","description":"## Objective\n\nCreate distinct landing, local-app, and review entries plus deep-path Worker rewrites without loading editor/crypto code on the landing route.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/00-web-presence.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nweb/hosted HTML entries, Vite browser config, hosted Worker router\n\n## Validation\n\nRun browser build and inspect route chunks; smoke /, /app, a deep workspace path, and /review/:roomId.\n\n## Source\n\nplanning/web-authoring/00-web-presence.md","acceptance_criteria":"All deep paths resolve to the correct entry and the landing route does not preload ProseMirror, Mermaid, KaTeX, room crypto, or WebRTC chunks.","notes":"Approach: convert web/hosted to a Vite MPA with three HTML entries — index.html (landing, minimal placeholder until 7xl.1.2), app/index.html (workspace shell placeholder until 7xl.1.3), review/index.html (existing browser-review bootstrap moved). Add dev-server middleware plugin for deep-path rewrites (/app/* -\u003e /app/, /review/* -\u003e /review/) and mirror the same rewrites in worker.ts before ASSETS.fetch. Update scripts/deploy-browser-staging.mjs verifyBuild to check review/index.html for the relay origin. Add scripts/check-route-bundles.mjs asserting the landing entry graph contains no prosemirror/mermaid/katex/noble-crypto/webrtc chunks. Validate: npm run check, npm test, build:browser, bundle check, wrangler dev + Playwright smoke of /, /app, /app/w/x/y, /review/room123.","spec_id":"planning/web-authoring/00-web-presence.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:42:31Z","created_by":"James Lal","updated_at":"2026-07-11T00:15:10Z","started_at":"2026-07-11T00:03:57Z","closed_at":"2026-07-11T00:15:10Z","close_reason":"Multi-entry hosted routing landed: landing/app/review HTML entries in web/hosted with shared route mapping (src/lib/hosted/routes.ts), worker deep-path rewrites to canonical entry documents (no visible 307s), matching Vite dev/preview middleware, manifest-driven route bundle-boundary gate (scripts/check-route-bundles.mjs, wired into CI + deploy verify), and Playwright smoke vs wrangler dev (7/7 pass: /, /app, /app/storage, /open, deep workspace path, /review/:roomId, SPA fallback). Landing statically reaches 3 files, zero editor/crypto chunks. svelte-check 0 errors, 43 unit test files pass, deploy:staging --dry-run green.","labels":["authoring","cloudflare","e2ee","frontend","ios","landing","storage","svelte","web"],"dependencies":[{"issue_id":"attn-7xl.1.1","depends_on_id":"attn-7xl.1","type":"parent-child","created_at":"2026-07-10T16:42:30Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"attn-7xl.7","title":"Validate parity and cut over attn.sh","description":"Implement Phase 06: validate the complete browser-owned create-to-share journey, finish security/accessibility/performance gates, deploy Cloudflare staging, and cut the canonical attn.sh origin over with rollback.","acceptance_criteria":"The full browser/native parity matrix passes locally and on staging; independent reviews and real iOS evidence pass; attn.sh production smoke and rollback rehearsal succeed; product/recovery documentation is accurate.","spec_id":"planning/web-authoring/06-validation-rollout.md","status":"in_progress","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:42:29Z","created_by":"James Lal","updated_at":"2026-07-11T15:22:52Z","started_at":"2026-07-11T15:22:52Z","labels":["a11y","authoring","cloudflare","e2e","e2ee","ios","performance","security","storage","web"],"dependencies":[{"issue_id":"attn-7xl.7","depends_on_id":"attn-7xl","type":"parent-child","created_at":"2026-07-10T16:42:28Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.7","depends_on_id":"attn-7xl.4","type":"blocks","created_at":"2026-07-10T16:43:45Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.7","depends_on_id":"attn-7xl.5","type":"blocks","created_at":"2026-07-10T16:43:45Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.7","depends_on_id":"attn-7xl.6","type":"blocks","created_at":"2026-07-10T16:43:46Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":3,"dependent_count":0,"comment_count":0} -{"id":"attn-7xl.6","title":"Harden iOS Safari and offline lifecycle","description":"Implement Phase 05: make current iOS Safari and installed Home Screen mode explicit release targets with a safe offline shell and capability-driven degradation.","acceptance_criteria":"Offline launch/update, process-kill recovery, Private Browsing, Lockdown/capability failures, low storage, keyboard/rotation, and current iPhone/iPad device cases pass without caching user content or capability URLs.","notes":"Apply the iOS interaction/capability contract in planning/web-authoring/ios-ux.md. Offline and capability loss must preserve every independently available reader, file/media, review, export, share, and native-handoff action; mobile editing may degrade separately.","spec_id":"planning/web-authoring/05-ios-offline.md","status":"closed","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:42:27Z","created_by":"James Lal","updated_at":"2026-07-11T03:39:27Z","started_at":"2026-07-11T03:28:41Z","closed_at":"2026-07-11T03:39:27Z","close_reason":"All five children complete: installable standalone shell, unit-policy-gated service worker (shell+immutable assets only, offline launch verified), capability-degraded modes proven incl blocked-IDB e2e, lifecycle hardening with pagehide lease release, and the mandatory real-device protocol documented (execution consolidated with attn-7xl.7). 55 unit files, 56/56 routes e2e, Chromium+WebKit matrix green, staging deployed with the SW live.","labels":["authoring","cloudflare","e2ee","ios","offline","pwa","service-worker","storage","web"],"dependencies":[{"issue_id":"attn-7xl.6","depends_on_id":"attn-7xl","type":"parent-child","created_at":"2026-07-10T16:42:27Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.6","depends_on_id":"attn-7xl.1","type":"blocks","created_at":"2026-07-10T16:43:43Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.6","depends_on_id":"attn-7xl.2","type":"blocks","created_at":"2026-07-10T16:43:44Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0} -{"id":"attn-7xl.5","title":"Add accountless backup and storage controls","description":"Implement Phase 04: make local durability, backup, import, first-share risk, deletion, and room cleanup understandable without accounts or server-side key recovery.","acceptance_criteria":"Storage status is accurate; ordinary Markdown export/import round-trips; non-persistent sharing is gated honestly; destructive operations have distinct consequences; iOS Files workflows and secret-leak review pass.","notes":"Scope clarification 2026-07-10: workspaces are folder-shaped and must interoperate identically across web and native. They may contain nested Markdown plus arbitrary binary assets with normalized relative paths. Safe raster types may render inline; unknown/active types are download-only. Share supports current file, selected entries, or whole workspace through one canonical manifest. The landing New workspace action creates untitled.md and opens the editor in one click with no dialog or network request. See updated planning/web-authoring/.","spec_id":"planning/web-authoring/04-recovery-storage-ux.md","status":"closed","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:42:26Z","created_by":"James Lal","updated_at":"2026-07-11T03:28:11Z","started_at":"2026-07-11T03:13:33Z","closed_at":"2026-07-11T03:28:11Z","close_reason":"All five children complete: storage health on real probes, manifest-carrying ordinary-file backups, conflict-explicit imports, the first-share durability gate, and separated crypto-erasure/cleanup controls. Validated by 57 unit files incl manifest-secrecy checks, 51/51 routes e2e (export-\u003ebackup-label-\u003ereimport-dedupe-\u003eclear-all journeys, forget-room, share gate), Chromium+WebKit matrix, and the full zero-relay-traffic phase gate green against deployed staging.","labels":["assets","authoring","cloudflare","e2ee","export","ios","recovery","storage","web"],"dependencies":[{"issue_id":"attn-7xl.5","depends_on_id":"attn-7xl","type":"parent-child","created_at":"2026-07-10T16:42:25Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.5","depends_on_id":"attn-7xl.2","type":"blocks","created_at":"2026-07-10T16:43:42Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.5","depends_on_id":"attn-7xl.3","type":"blocks","created_at":"2026-07-10T16:43:43Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0} -{"id":"attn-7xl.4","title":"Add browser-owned encrypted sharing","description":"Implement Phase 03: let a browser workspace own an existing attn review room using the canonical native protocol, publish encrypted snapshots, resume authority, and offer browser/native/CLI invite forms.","acceptance_criteria":"Browser-owner to browser/native flows pass for comments, suggestions, owner apply, reload, offline mailbox, and direct WebRTC; bootstrap failures recover idempotently; relay/R2 remain content-blind.","notes":"Scope clarification 2026-07-10: workspaces are folder-shaped and must interoperate identically across web and native. They may contain nested Markdown plus arbitrary binary assets with normalized relative paths. Safe raster types may render inline; unknown/active types are download-only. Share supports current file, selected entries, or whole workspace through one canonical manifest. The landing New workspace action creates untitled.md and opens the editor in one click with no dialog or network request. See updated planning/web-authoring/.","spec_id":"planning/web-authoring/03-browser-sharing.md","status":"closed","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:42:24Z","created_by":"James Lal","updated_at":"2026-07-11T15:22:25Z","started_at":"2026-07-11T03:39:56Z","closed_at":"2026-07-11T15:22:25Z","close_reason":"Browser-owned encrypted sharing is implemented end to end: stable tiered links, canonical snapshots/manifests, durable owner authority, offline mailbox recovery, native/browser interop, direct transport, revocation, and staging validation.","labels":["assets","authoring","cloudflare","e2ee","interop","ios","multi-file","sharing","storage","web","webrtc"],"dependencies":[{"issue_id":"attn-7xl.4","depends_on_id":"attn-7xl","type":"parent-child","created_at":"2026-07-10T16:42:23Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.4","depends_on_id":"attn-7xl.3","type":"blocks","created_at":"2026-07-10T16:43:41Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} -{"id":"attn-7xl.3","title":"Add local browser workspace authoring","description":"Implement Phase 02: connect the local desk and editor to browser workspace storage so create, import, edit, autosave, multi-file navigation, export, and recovery remain local until Share.","acceptance_criteria":"Create/edit/reload/export/reimport works with zero relay requests before Share, across IDB and OPFS modes, two-tab lease takeover, desktop browsers, and real iPhone/iPad Safari.","notes":"Scope clarification 2026-07-10: workspaces are folder-shaped and must interoperate identically across web and native. They may contain nested Markdown plus arbitrary binary assets with normalized relative paths. Safe raster types may render inline; unknown/active types are download-only. Share supports current file, selected entries, or whole workspace through one canonical manifest. The landing New workspace action creates untitled.md and opens the editor in one click with no dialog or network request. See updated planning/web-authoring/.\niOS product direction: reader/reviewer first, editing second. See planning/web-authoring/ios-ux.md and ios-experience.html. Phase validation now also depends on attn-7xl.3.7.","spec_id":"planning/web-authoring/02-local-authoring.md","status":"closed","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:42:22Z","created_by":"James Lal","updated_at":"2026-07-11T03:12:26Z","started_at":"2026-07-11T01:40:28Z","closed_at":"2026-07-11T03:12:26Z","close_reason":"All seven children complete: workspace service + runes state, one-click desk actions with real storage, ProseMirror autosave with lease-fenced revision commits, multi-file/asset support with zip round-trip, iOS reader surfaces, iOS editing/keyboard behavior, and the Phase 02 validation gate (validation-02.md). Create/edit/reload/export/reimport verified with zero relay requests before Share, across storage tiers, two-tab leases, desktop + mobile viewports on Chromium and WebKit, locally and on Cloudflare staging. Real iPhone/iPad matrix consolidated in the attn-7xl.7 gate.","labels":["assets","authoring","cloudflare","e2ee","editor","ios","multi-file","storage","svelte","web"],"dependencies":[{"issue_id":"attn-7xl.3","depends_on_id":"attn-7xl","type":"parent-child","created_at":"2026-07-10T16:42:22Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.3","depends_on_id":"attn-7xl.1","type":"blocks","created_at":"2026-07-10T16:43:40Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.3","depends_on_id":"attn-7xl.2","type":"blocks","created_at":"2026-07-10T16:43:41Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":2,"comment_count":0} -{"id":"attn-7xl.2","title":"Add IndexedDB workspace storage v3","description":"Implement Phase 01: extend the existing browser review database to durable browser-owned workspaces with IndexedDB as the complete baseline and OPFS as an encrypted large-body tier.","acceptance_criteria":"Schema migrations, sealed revisions, atomic workspace operations, OPFS fallback, durability probes, and cross-tab fencing all pass unit, fault-injection, Chromium/WebKit, and real iOS Safari validation.","notes":"Scope clarification 2026-07-10: workspaces are folder-shaped and must interoperate identically across web and native. They may contain nested Markdown plus arbitrary binary assets with normalized relative paths. Safe raster types may render inline; unknown/active types are download-only. Share supports current file, selected entries, or whole workspace through one canonical manifest. The landing New workspace action creates untitled.md and opens the editor in one click with no dialog or network request. See updated planning/web-authoring/.","spec_id":"planning/web-authoring/01-storage-foundation.md","status":"closed","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:42:21Z","created_by":"James Lal","updated_at":"2026-07-11T01:39:45Z","started_at":"2026-07-11T00:57:37Z","closed_at":"2026-07-11T01:39:45Z","close_reason":"All six implementation children complete + automated validation gate (2.7) green: schema v3 migrations, sealed keys/revisions with KAT-pinned derivations, atomic fenced transactions, encrypted OPFS tier with IDB fallback, honest durability probes, and cross-tab writer leases — validated by unit, fault-injection, property, and real Chromium/WebKit persistence tests. Outstanding: attn-7xl.2.9 human device gate (real iOS Safari), consolidated with the attn-7xl.7 device matrix.","labels":["assets","authoring","cloudflare","crypto","e2ee","indexeddb","ios","opfs","storage","web"],"dependencies":[{"issue_id":"attn-7xl.2","depends_on_id":"attn-7xl","type":"parent-child","created_at":"2026-07-10T16:42:20Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":3,"comment_count":0} -{"id":"attn-7xl.1","title":"Unify landing, app, and review routes","description":"Implement Phase 00: move the native-first landing and review-only hosted app toward one coherent attn.sh surface, with route-specific bundles and the designed local desk/editor/share/storage shells.","acceptance_criteria":"Landing, /app, deep workspace, and /review routes render under one hosted build; revised browser-first copy and all page shells pass desktop/mobile accessibility and bundle-boundary checks; Cloudflare preview routing works.","notes":"Scope clarification 2026-07-10: workspaces are folder-shaped and must interoperate identically across web and native. They may contain nested Markdown plus arbitrary binary assets with normalized relative paths. Safe raster types may render inline; unknown/active types are download-only. Share supports current file, selected entries, or whole workspace through one canonical manifest. The landing New workspace action creates untitled.md and opens the editor in one click with no dialog or network request. See updated planning/web-authoring/.","spec_id":"planning/web-authoring/00-web-presence.md","status":"closed","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:42:19Z","created_by":"James Lal","updated_at":"2026-07-11T00:57:17Z","started_at":"2026-07-11T00:02:27Z","closed_at":"2026-07-11T00:57:17Z","close_reason":"All five children complete: multi-entry routing with worker rewrites, migrated private-desk landing, typed mock-driven workspace shells with degraded states, prepared reversible Vercel cutover (execution gated on attn-7xl.7), and the full Phase 00 validation gate passing locally and on deployed Cloudflare staging (40/40 e2e, axe 0 violations, bundle boundaries enforced in CI).","labels":["authoring","cloudflare","e2ee","frontend","ios","landing","storage","svelte","web"],"dependencies":[{"issue_id":"attn-7xl.1","depends_on_id":"attn-7xl","type":"parent-child","created_at":"2026-07-10T16:42:18Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":2,"comment_count":0} -{"id":"attn-7xl","title":"Ship browser-owned local workspaces and sharing","description":"Make attn.sh an accountless private writing desk: create/import Markdown locally, persist it safely in current iOS Safari, and explicitly share E2EE rooms that browser and native clients can join. Unify landing, app, and review routes on one Cloudflare origin. Planning source: planning/web-authoring/.","acceptance_criteria":"All seven child features and their validation tasks are complete; browser create/edit/reload/export/share/interoperability passes locally, on Cloudflare staging, and on real current iPhone/iPad Safari; the canonical origin cutover is verified and rollback rehearsed.","notes":"Scope clarification 2026-07-10: workspaces are folder-shaped and must interoperate identically across web and native. They may contain nested Markdown plus arbitrary binary assets with normalized relative paths. Safe raster types may render inline; unknown/active types are download-only. Share supports current file, selected entries, or whole workspace through one canonical manifest. The landing New workspace action creates untitled.md and opens the editor in one click with no dialog or network request. See updated planning/web-authoring/.","spec_id":"planning/web-authoring/README.md","status":"in_progress","priority":1,"issue_type":"epic","owner":"james@littlebearlabs.io","created_at":"2026-07-10T22:42:17Z","created_by":"James Lal","updated_at":"2026-07-11T00:02:15Z","started_at":"2026-07-11T00:02:15Z","labels":["assets","authoring","cloudflare","e2ee","ios","multi-file","storage","web"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-egi.8","title":"Authenticate WebRTC control signaling per device in protocol v3","description":"Replace v2 room-group-key-only SDP/ICE identity with per-device authenticated control signaling. Bind room, target, negotiation generation, payload, and SDP fingerprint to the registered Ed25519 device key (or a pairwise authenticated key), then verify before peer creation on native and browser.","acceptance_criteria":"A room member cannot seal an offer/answer/ICE envelope that is accepted as another registered device; native/browser interop vectors cover signed control payloads; replay generations and target rewrites fail before RTCPeerConnection handling.","notes":"Expanded merge-security scope: registered device identity must be cryptographically proven on v3 WebSocket presence, signaling control, and push subscription mutations. Clean v3 cutover; bind method/path/room/device/body or handshake nonce to Ed25519 proof, reject replay/target rewrite before peer handling, preserve content blindness, and add native/browser/relay interop vectors.","status":"closed","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-10T21:46:16Z","created_by":"James Lal","updated_at":"2026-07-11T16:14:35Z","started_at":"2026-07-11T13:59:13Z","closed_at":"2026-07-11T16:14:35Z","close_reason":"Added v3 signed per-device WebSocket, signaling, and push proofs with replay protection and cross-runtime vectors","labels":["cloudflare","e2e","staging","web"],"dependencies":[{"issue_id":"attn-egi.8","depends_on_id":"attn-egi","type":"parent-child","created_at":"2026-07-10T15:46:15Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-egi.8","depends_on_id":"attn-egi.4","type":"discovered-from","created_at":"2026-07-10T15:46:15Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-egi.5","title":"Graduate Cloudflare web staging with native-browser parity E2E","description":"Run the full browser/native staging parity matrix after mailbox authoring, storage, shared files, and WebRTC hybrid land. Harden deployment reproducibility, monitor console/network failures, and verify the relay/static host remain content-blind.","acceptance_criteria":"Local and staging Playwright cover native owner to browser reviewer, browser-to-browser comments/suggestions, multi-file navigation, reload recovery, direct WebRTC, forced mailbox fallback, and fragment/key failure cases. Quality/security gates pass and staging URL is the canonical browser invite base.","notes":"Production graduation gate: deploy the hosted reviewer at the exact default https://attn.sh/review path before a native production release. Until then ShareDialog auto-copies the working native npx join command; staging-configured HTTPS links remain available explicitly.\nImplementation plan 2026-07-10: (1) make Cloudflare staging deployment embed and verify the staging relay origin reproducibly; (2) extend hosted Playwright with missing/malformed/wrong-key invite failures and assert fragment stripping plus generic non-secret errors; (3) run the full parity matrix locally and on Cloudflare staging, including direct/fallback/content-blind checks; (4) rerun quality/security gates, deploy final versions, close, export, commit, rebase, and push.\nCompleted 2026-07-10. Added pinned build/verify/deploy workflow and npm command: exact staging origins, Cloudflare credential isolation from npm/Vite hooks, restricted Wrangler arguments, hashed-entry verification, exact 14-directive CSP verification, no-store/redirect checks, and 60s propagation gate. GitHub manual staging workflow now installs Chromium/native dependencies, builds the native owner, deploys, and runs the full Playwright parity harness under xvfb. Expanded Playwright covers missing/malformed/wrong keys with fragment stripping and zero relay request attempts, native-browser plus browser-browser direct comments, browser-browser suggestions, multiple files, R2, offline/reload recovery, forced WebRTC fallback, content/signaling blindness, and role controls. Local and Cloudflare staging both pass 3/3; web check 0 diagnostics; 42/42 web test files; dry-run and real deployment verification pass; independent code/security reviews approved. Final web staging version b1c206f4-21c7-4b1c-8bc0-65f971ca0b4e; relay staging version 12784252-c335-4a8f-90ac-0051d467ea62.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-10T04:33:52Z","created_by":"James Lal","updated_at":"2026-07-10T22:19:29Z","started_at":"2026-07-10T22:05:21Z","closed_at":"2026-07-10T22:19:29Z","close_reason":"Cloudflare staging graduated with reproducible credential-isolated deployment and complete local/staged native-browser parity Playwright coverage.","labels":["cloudflare","e2e","security","staging","web"],"dependencies":[{"issue_id":"attn-egi.5","depends_on_id":"attn-egi","type":"parent-child","created_at":"2026-07-09T22:33:52Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-egi.5","depends_on_id":"attn-egi.4","type":"blocks","created_at":"2026-07-09T22:33:52Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} -{"id":"attn-egi.4","title":"Add STUN-only browser WebRTC hybrid fast path","description":"Implement browser signaling and RTCPeerConnection DataChannels as an opportunistic STUN-only fast path. Reuse the exact encrypted MailboxEnvelope bytes, keep mailbox as the durable path, enforce signal target validation before decrypt, and surface live_direct, mailbox, direct_failed, and offline honestly.","acceptance_criteria":"Two browsers and native-to-browser can negotiate encrypted SDP/ICE through signal envelopes, exchange the same AEAD envelopes over DataChannel, dedupe mailbox/direct delivery, and fall back to mailbox when ICE fails. No plaintext content or SDP/ICE reaches relay storage. Playwright two-tab direct and forced-fallback cases pass.","notes":"Plan: (1) implement native-compatible browser signal sealing/validation and STUN-only peer mesh; (2) integrate shared WS/direct verification, durable-mailbox-first fanout, honest connection state, and native browser eligibility; (3) cover signaling/direct/fallback with TS/Rust tests and real Playwright; (4) independent code/security review; (5) deploy and verify Cloudflare staging.\nCompleted 2026-07-10. Implemented native-compatible encrypted browser signaling, STUN-only peer mesh, exact MailboxEnvelope DataChannel transport, durable-mailbox-first fanout/dedupe, authenticated online roster, native/browser eligibility and binding checks, bounded failure/restart ownership, and honest connection UI. Follow-ups: attn-egi.8 (protocol-v3 per-device control authentication) and attn-egi.9 (remembered identity coordination across tabs). Verification: cargo check/fmt; cargo nextest 950/950; web check 0 diagnostics and all 42 files green; relay typecheck and 417/417 tests; repeated local Playwright 2/2; independent code/security reviews approved; Cloudflare relay staging version 12784252-c335-4a8f-90ac-0051d467ea62 and web staging version 87d914ec-9113-488d-a497-1dacd97c16aa; staged Playwright 2/2 passed with direct mailbox-frame suppression, forced ICE failure fallback, and relay plaintext/SDP/ICE leak scans.","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-10T04:33:43Z","created_by":"James Lal","updated_at":"2026-07-10T22:03:53Z","started_at":"2026-07-10T20:54:30Z","closed_at":"2026-07-10T22:03:53Z","close_reason":"Implemented, independently reviewed, fully tested locally, deployed to Cloudflare staging, and verified there with real Playwright E2E.","labels":["cloudflare","e2e","e2ee","hybrid","staging","web","webrtc"],"dependencies":[{"issue_id":"attn-egi.4","depends_on_id":"attn-egi","type":"parent-child","created_at":"2026-07-09T22:33:43Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-egi.4","depends_on_id":"attn-egi.2","type":"blocks","created_at":"2026-07-09T22:33:43Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-egi.4","depends_on_id":"attn-egi.3","type":"blocks","created_at":"2026-07-09T22:33:43Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0} -{"id":"attn-egi.3","title":"Add durable browser rooms, files, snapshots, and cursor recovery","description":"Add IndexedDB-backed room/device/cursor/event/outbox state, OPFS-backed shared snapshot/file cache, R2 snapshot blob download support, explicit room-key persistence UX, and reload recovery. Browser and native clients must continue to share the same immutable snapshots and event log.","acceptance_criteria":"Multiple shared files and comments survive browser reload when the user explicitly remembers a room; default invite-only mode persists no room secret; cursor replay is idempotent; R2 snapshots render; browser storage never contains plaintext unless the explicit local workspace policy allows it; storage migration/reload tests pass.","notes":"Implementation mini-plan (2026-07-10): 1. Add versioned IndexedDB storage with a non-extractable HKDF root-key capability, room-scoped sealed inbox/outbox/cursor/device records, encrypted private identity, migration tests, OPFS sealed snapshot cache, and storage persistence telemetry. 2. Keep invite-only mode unchanged and storage-free; add explicit Remember/Forget UX and fragmentless recovery only for remembered rooms. 3. Serialize inbound handling and commit exact ciphertext plus cursor atomically before UI dispatch; replay stored ciphertext through the same verifier before subscribing from the persisted cursor. 4. Make BrowserOutbox durable: persist exact sealed bytes before optimistic echo, recover after pagehide/reload, remove only after full ACK, retain terminal failures. 5. Implement authenticated R2 presign/download, wrapper-AAD open, signed BlobRef integrity checks, sealed OPFS caching, and allowBrowser+origin-gated cap GET CORS. 6. Test migrations, key non-extractability, no-plaintext storage, cursor/outbox reload, multiple files/comments, R2, local and Cloudflare staging Playwright. 7. Run code/security review, close, commit, rebase, export, push.\nCompleted 2026-07-10: shipped versioned IndexedDB + non-extractable HKDF claim lifecycle, encrypted identity, sealed inbox/outbox/history/cursor/device state, OPFS sealed R2 cache, explicit Remember/Forget UX, fragmentless reload recovery, ordered signer refresh, atomic cross-tab remember claims, and authenticated R2 download with policy-gated CORS/preflight. Local and Cloudflare staging Playwright both pass for three files (including 1.2 MB R2), comments/replies/suggestions/resolution, default no-storage, remembered reload, non-extractable key audit, and plaintext/cap leak checks. Web 40-file suite, Svelte check, browser/native builds, Rust 430-unit suite, relay typecheck/blob suite pass; relay full suite 416/417 with the baseline anti-enum timeout, isolated rerun passes. Independent code/security reviews clean after fixes. Deployed relay staging version 71998d45-8b38-4c38-9a80-04abcf3c20ac and web staging c3adcfb3-e5cc-495f-ae8d-976eaa8c08a4.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-10T04:33:29Z","created_by":"James Lal","updated_at":"2026-07-10T20:41:11Z","started_at":"2026-07-10T19:23:25Z","closed_at":"2026-07-10T20:41:11Z","close_reason":"Closed","labels":["cloudflare","e2e","e2ee","mailbox","staging","storage","web"],"dependencies":[{"issue_id":"attn-egi.3","depends_on_id":"attn-egi","type":"parent-child","created_at":"2026-07-09T22:33:29Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-egi.3","depends_on_id":"attn-egi.1","type":"blocks","created_at":"2026-07-09T22:33:29Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} -{"id":"attn-egi.2","title":"Add encrypted browser outbox and review authoring","description":"Implement browser-side event/signature/envelope assembly, PoW-backed idempotent mailbox outbox, and transport injection so hosted reviewers can create/reply/resolve comments and create suggestions using the same ReviewEvent model as native clients. Never route hosted mutations through silent Wry IPC no-ops.","acceptance_criteria":"Browser-authored comments and suggestions are Ed25519-signed and AEAD-encrypted, accepted by the relay, render in the native owner UI, survive reconnect, and are absent as plaintext from relay traffic/storage. Unit interoperability vectors and local two-client Playwright pass.","notes":"Unblocked after the staging receiver milestone; next slice is encrypted browser outbox plus comment/suggestion authoring with durable retry.\nImplementation mini-plan (2026-07-10):\\n1. Add native-compatible browser event signing/envelope assembly from Rust vectors, including real X25519 browser identity.\\n2. Add memory-only sealed-envelope outbox: exact ciphertext reuse, fresh PoW per POST, partial acknowledgement validation, reconnect/backoff.\\n3. Publish ParticipantJoined first, then expose BrowserSession comment/reply/resolve/suggestion actions and optimistic echo.\\n4. Inject scoped callbacks into shared Svelte authoring UI; hosted mode never invokes Wry IPC and never exposes owner-only mutations.\\n5. Prove local native-owner↔browser interop, reconnect/idempotency, and plaintext absence with Playwright; then redeploy Cloudflare staging and repeat browser E2E.\\n6. Run unit/Svelte/Rust gates, code+security review, close issue, rebase/sync/push.\nCompleted 2026-07-10: native-compatible Ed25519/EventId/EnvelopeId/XChaCha20 event assembly; memory-only sealed idempotent outbox with fresh PoW, policy updates, bounded retry, terminal retention; hosted comment/reply/resolve/suggestion authoring with no Wry IPC; self-signed immutable role roster and inbound capability authorization before browser UI/native persistence; authenticated registration PoW policy; native reconnect and cross-file anchor fixes. Deployed relay staging version d6025341-5cc9-44ee-a02e-3264b672979b and web staging version ac12d1c6-6342-41a4-a34f-ccf3b6c5a5a3. Local and Cloudflare staging Playwright native-owner↔browser authoring passed; full Rust/web/relay gates and independent code/security re-reviews passed.","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-10T04:33:20Z","created_by":"James Lal","updated_at":"2026-07-10T16:57:04Z","started_at":"2026-07-10T15:28:18Z","closed_at":"2026-07-10T16:57:04Z","close_reason":"Closed","labels":["cloudflare","comments","e2e","e2ee","mailbox","staging","web"],"dependencies":[{"issue_id":"attn-egi.2","depends_on_id":"attn-egi","type":"parent-child","created_at":"2026-07-09T22:33:19Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-egi.2","depends_on_id":"attn-egi.1","type":"blocks","created_at":"2026-07-09T22:33:20Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} -{"id":"attn-egi.1","title":"Deploy usable hosted review receiver to Cloudflare staging","description":"Create a separate hosted BrowserReviewApp build that does not include native Wry/mock IPC. Make real room join work: validate an environment-configured relay URL, mine valid browser PoW off the UI thread, permit browser reviewers in newly shared rooms, generate an HTTPS invite alongside the native invite, immediately strip valid or malformed key fragments, zero in-memory keys on teardown, and hide unsupported mutating controls. Deploy as a Cloudflare Workers Static Assets SPA at staging.attn.sh with narrow security headers. The server continues to receive only encrypted envelopes.","acceptance_criteria":"npm run dev:browser and npm run build:browser use the dedicated hosted entry; a real local native owner can share a marked document and a Chromium reviewer can join via HTTPS invite; the fragment is stripped, POST devices and WebSocket hello succeed, shared plaintext renders, and plaintext/room secret are absent from relay HTTP/WS traffic and browser persistence; refresh without the fragment fails closed; npm check/tests/build pass; Cloudflare staging deploy succeeds and staging smoke passes.","notes":"Mini-plan: (1) add hosted/index.html plus vite.browser.config.ts and strict VITE_ATTN_RELAY_URL validation; keep native vite.config.ts/single-file build untouched. (2) add browser PoW worker + corpus tests and use it for device registration. (3) default new native rooms to allow browser, expose both attn:// and configured HTTPS invite forms, and give staging relay its own custom domain. (4) sanitize malformed fragments and zero BrowserSession key/identity material on teardown/pagehide; remove active controls that still route to no-op IPC. (5) add Cloudflare Workers Static Assets config/worker security headers and a manual web staging workflow. (6) validate web unit/check/build, scoped Rust attn check/tests if Rust files change, relay config/typecheck, local real-relay Playwright canary, deploy staging, then Playwright smoke the deployed URL. Patterns: existing relay-deploy.yml, browser invite/crypto corpus tests, scripts/test-share-experience-e2e.sh. Baseline: web check exit 0; native web build exit 0; all displayed web test files passed before implementation; Cloudflare OAuth has pages/workers write and no Pages projects exist.\nCompleted 2026-07-10: deployed isolated relay-staging.attn.sh and staging.attn.sh Workers Static Assets app; provisioned staging-only quota/blob secrets and R2 bucket. Local and public Playwright E2E both pass native folder share -\u003e real relay -\u003e Chromium, including fragment stripping, real PoW, encrypted wire/no persistence, file switching, read-only controls, and refresh-fails-closed. Security review findings fixed; web npm audit --omit=dev reports 0 vulnerabilities.","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-10T04:33:11Z","created_by":"James Lal","updated_at":"2026-07-10T15:28:17Z","started_at":"2026-07-10T04:34:03Z","closed_at":"2026-07-10T15:28:17Z","close_reason":"Hosted read-only receiver deployed to Cloudflare staging and verified locally plus against public staging with Playwright E2E; all acceptance criteria met.","labels":["cloudflare","e2e","security","staging","web"],"dependencies":[{"issue_id":"attn-egi.1","depends_on_id":"attn-egi","type":"parent-child","created_at":"2026-07-09T22:33:11Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":2,"comment_count":0} -{"id":"attn-egi","title":"Ship hosted web-first review app to Cloudflare staging","description":"Turn the existing Svelte review UI and browser E2EE/session transport into a standalone hosted application, then publish it to a Cloudflare staging environment. Native and browser clients must use the same encrypted document/comment model; hosted infrastructure must never receive plaintext document content or keys. Include staging configuration and repeatable deployment.\n\n## Required Skills\nfrontend-design,svelte5-best-practices,browser:control-in-app-browser","acceptance_criteria":"A staging URL loads the standalone browser app without native IPC; invite URLs can open the same shared files and comments used by native clients; relay and signaling endpoints are environment-configurable; encrypted content remains client-side only; local Playwright verifies the browser flow end to end; web checks, tests, and production build pass; Cloudflare staging deployment succeeds and is smoke-tested with Playwright.","notes":"Activated immediately after user reprioritized web staging ahead of relay tombstone hardening. First inspect standalone browser entry and existing Cloudflare auth/project state, then implement and validate local Playwright before staging deploy.\nMilestone attn-egi.1 landed: hosted read-only receiver is live on Cloudflare staging and E2E green. Continuing with attn-egi.2 encrypted browser authoring; durable storage and STUN-only WebRTC remain sequenced behind it.\nCompleted 2026-07-10: all required hosted web-first milestones (.1-.5) are live on Cloudflare staging. The standalone E2EE app supports native/browser shared documents, multi-file and R2 snapshots, comments/replies/resolution/suggestions, durable encrypted recovery, STUN-only WebRTC with encrypted mailbox fallback, and repeatable content-blind staging deployment. Full local and staged Playwright parity matrices and independent code/security reviews pass. Follow-up hardening remains tracked separately in attn-egi.6 through .9 and is not a staging release blocker.","status":"closed","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-10T04:23:35Z","created_by":"James Lal","updated_at":"2026-07-10T22:19:30Z","started_at":"2026-07-10T04:23:39Z","closed_at":"2026-07-10T22:19:30Z","close_reason":"Hosted web-first review app is implemented, deployed to Cloudflare staging, security-reviewed, and verified end to end locally and on staging.","labels":["cloudflare","e2e","staging","web"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-12k","title":"Relay: prune stale persisted per-device rate buckets","description":"DurableObjectRateLimit persists one rate_v2:\u003cdevice\u003e:\u003cminute\u003e row per active device/minute and documents alarm cleanup, but RoomDO alarm never imports or prunes RATE_KEY_PREFIX/LEGACY_RATE_KEY_PREFIX. A sustained admitted writer can accumulate rate metadata for the full 7-day room lifetime even after env_idx growth is bounded. Add canonical v2/validated legacy minute parsing, bounded \u003c=128-key alarm deletion of windows older than the current minute, fail-closed corruption handling, rescheduling semantics, and sustained-writer/alarm regressions without exposing content.","status":"open","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-10T04:19:08Z","created_by":"James Lal","updated_at":"2026-07-10T04:19:08Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-3gg","title":"Relay: bound env_idx idempotency tombstone growth","description":"Signal FIFO and owner-ACK deletion must retain env_idx tombstones so timed-out POST retries cannot resurrect, recharge, or rebroadcast deleted ciphertext. However live envelope_count/bytes debit frees capacity while each unique envelopeId leaves durable metadata until room cleanup; a long-lived admitted peer can rotate unique signal/event IDs and grow tombstones far beyond policy.maxEvents despite per-request rate limits. Add an explicit bounded tombstone/lifetime-event budget or compact authenticated dedupe structure, account its storage, expire safely without breaking retry semantics, and add sustained-rotation cost/cap regressions. Services must remain content-blind.","notes":"Paused after research/baseline only; no source edits. Reprioritized to hosted web-first vertical slice and Cloudflare staging per user direction. Existing design notes remain valid for later implementation.","status":"open","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-10T01:04:14Z","created_by":"James Lal","updated_at":"2026-07-10T04:23:23Z","started_at":"2026-07-10T04:11:29Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-38l","title":"Relay: encode opaque identifiers before composing storage keys","description":"Relay schema currently length-bounds room/user/device/envelope IDs but permits delimiters and control characters. room-do.ts composes durable keys with ':' separators (device:\u003cparticipantId\u003e:\u003cdeviceId\u003e, env_idx:\u003cenvelopeId\u003e, env_by_target:\u003cdeviceId\u003e:\u003cseq\u003e:\u003cenvelopeId\u003e, ACK keys), and some parsers split those keys. Crafted opaque IDs containing ':' or NUL can alias keys, break parsing, or mix accounting/routing buckets. Define a canonical key-safe encoding (for example base64url UTF-8 or length-prefix segments), migrate/dual-read existing rows safely, restrict new wire identifiers if protocol-compatible, and add collision/control-character regressions without exposing content.","notes":"Implemented injective versioned encoding for all attacker-derived DO/R2 key segments; strict fresh protocol ID grammar; validated exact legacy compatibility for devices, envelopes/tombstones, target indexes, rates, quota leases/expiry, blob reservations/caps/R2 roots; encoded WS tags; room-wide deviceId uniqueness; fail-closed corruption/accounting/replay/FIFO behavior; generic content-blind errors. Security re-audit: clear. Validation: relay typecheck exit 0; focused corruption/migration suites pass; full relay 24 files / 415 tests pass; git diff --check pass; local Wrangler + Playwright/Chromium E2E exit 0 with health=200, duplicateAccepted=1, conflict=ATTN_ENVELOPE_ID_CONFLICT, signalCreates=65, rotationSeq=66, replayedPayloads=65, cursorCloseCode=4005, cursorError=ATTN_CURSOR_TOO_OLD, resyncFromSeq=3.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-09T22:15:54Z","created_by":"James Lal","updated_at":"2026-07-10T03:35:50Z","started_at":"2026-07-10T02:36:25Z","closed_at":"2026-07-10T03:35:50Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-64x","title":"Relay: generation-bound one-shot R2 reservations and cleanup tombstones","description":"Security review of attn-tdj found blob reservations are never confirmed/pruned, cap-bearing PUT can replay or resurrect data after room deletion, R2 keys are generation-blind, and best-effort cleanup can release accounting before physical deletion. Add generation/reservation IDs, claim+confirm flow, cleanup tombstone retries, and tests.","notes":"attn-tdj implemented generation-bound R2 keys, one-shot uploadId reservations, upload serialization/idempotency, stale-cap rejection, and cleanup tombstones that retain quota until physical cleanup is confirmed. Remaining follow-up: expire/prune abandoned reserved or uploading records and add deeper committed-cleanup lifecycle hardening/observability.","status":"open","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-09T20:59:02Z","created_by":"James Lal","updated_at":"2026-07-09T21:53:55Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-8h5","title":"Relay: make inline/R2 byte accounting atomic and exact","description":"Security review of attn-tdj found that inline ingest checks meta:bytes_used without meta:bytes_used_r2, duplicate envelope IDs/ACK IDs can corrupt counters, and signal FIFO eviction deletes payloads without decrementing counts. Fix combined room-cap enforcement and transactional/idempotent counter mutations; add regressions.","notes":"attn-tdj already fixed combined inline+R2 cap checks and made ACK payload/index/counter deletion atomic. Remaining known regressions: deduplicate same envelope ID within one ingest batch, decrement signal counters on FIFO eviction, and recompute oldest_retained_seq after staged ACK deletions (current cursor floor can lag conservatively).\nImplementation plan: in relay/src/room-do.ts, normalize same-request envelope IDs before validation/accounting (collapse byte-for-byte/field-identical repeats; reject conflicting copies with a stable 400); use the unique array for cap math, durable writes, response, and WS broadcast. Make signal FIFO payload/index deletion and metadata byte/count debit one storage transaction using actual stored record sizes, preserving env_idx tombstones and advancing the cursor floor from the logical post-delete set. Fix owner-ACK floor calculation by excluding staged payload deletes, validate counter subtraction instead of clamping, and chunk transactional deletes below the storage API batch limit. Update planning/collab/relay-spec.md. Tests: same-batch identical/conflicting/persisted duplicates; 65+ signal FIFO exact payload/count/bytes/server_seq/idempotency and varying sizes; ACK leading-run/all/hole cursor floor. Baseline: npm run typecheck exit 0; npm test 22 files/324 tests exit 0. Validation: focused tests, full relay suite, browser-native Playwright flow, independent security review.\nAdversarial audit after first green patch kept the issue open: required remediation for ACK registered-device gating and env_idx tombstones, PoW/rate ordering on conflicting batches, colon-safe signal index parsing, fail-closed ingest/corrupt-index validation, and removal of duplicate-only signal reconciliation read amplification. Focused happy-path tests (33) and full suite (331) passed before remediation but were insufficient; closure requires adversarial regressions, repeat full suite, Playwright, and a clean re-audit.\nCompleted after three adversarial review/remediation rounds: same-request duplicate collapse/conflict rejection with early first-device/rate/PoW gating; strict safe-integer metadata; injective versioned target indexes with legacy dual-read; fresh signal puts + FIFO victim deletes + exact final count/bytes/cursor in one transaction; registered ACK gating, durable env_idx retry tombstones, chunked atomic ACK deletes, and validated post-delete cursor floors. Final independent security verdict: no blockers. Validation 2026-07-09: npm run typecheck exit 0; focused adversarial integration 2 files/40 tests; acceptance+conformance 54 tests; full relay 22 files/338 tests exit 0. Real headless Chromium Playwright: health=200, identical duplicate accepted once, conflict=ATTN_ENVELOPE_ID_CONFLICT with next seq=2, 65 signal creates + rotation event seq=66, replayed payloads=65, owner-ACK stale cursor closed 4005 ATTN_CURSOR_TOO_OLD with resyncFromSeq=3. Playwright also discovered separate P0 attn-ask (browser WS Origin rewritten across Worker→DO); accounting E2E passed with the currently forwarded local origin allowlisted. Follow-ups: attn-38l key encoding, attn-3gg tombstone growth bound.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-09T20:59:01Z","created_by":"James Lal","updated_at":"2026-07-10T01:43:31Z","started_at":"2026-07-09T22:13:26Z","closed_at":"2026-07-10T01:43:31Z","close_reason":"Implemented exact, crash-atomic inline signal/ACK accounting and durable idempotency with injective indexes; independently security-audited and validated by 338 relay tests plus real Chromium Playwright E2E.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-tdj","title":"Relay: durable per-IP quota + global byte ceiling on room creation","description":"Anonymous room creation is only bounded by in-memory per-isolate rate limits (relay/src/rate-limit.ts:55-60), which reset on cold start and don't coordinate across isolates/colos. Per-room caps (25 MiB, 500 events, 24h TTL) bound each room, but nothing durably bounds the aggregate: a determined actor can create-and-fill rooms toward a steady-state of hundreds of GB per IP within the 24h TTL window.\n\nWhat needs to be done:\n1. Durable per-IP quota — a small quota DO keyed by IP (or /24) consulted at POST /v2/rooms/:roomId: max N concurrent live rooms and M bytes/day per IP. Decrement on room expiry/delete.\n2. Global circuit breaker — track total live rooms / total bytes across the namespace with an env-var ceiling; flip room creation to 503 when exceeded so the failure mode is 'service degraded' not 'surprise bill'.\n3. Keep anonymous defaults tight (24h TTL, 25 MiB); registration later just raises policy limits — the tiering seam already exists.\n\nContext: relay/src/room-do.ts:459-668 (room create), relay/src/index.ts:284-289 (create rate limit), planning/collab/relay-spec.md cost strategy ('no unbounded rooms').","notes":"Approach: add a singleton SQLite QuotaDO that atomically reserves the full HARD_MAX_ROOM_BYTES for each first-created room, enforcing per-source live-room and rolling-24h allocation limits plus global live-room/reserved-byte circuit breakers without observing content. Worker derives an HMAC source bucket only from CF-Connecting-IP and overwrites a private DO header; RoomDO acquires after owner signature+PoW, stores a generation lease, and idempotently releases it after explicit delete or alarm expiry. Rejoins/read/delete stay available when global admission is closed. Files: relay/src/{quota-do,quota,index,env,room-do}.ts, relay/wrangler.toml, relay tests, relay/spec docs. Tests: QuotaDO atomic boundary/idempotency/rolling-window/release tests; Worker first-create/rejoin/source/global/fail-closed integration; full relay typecheck+296-test baseline regression suite. Conservative invariant: quota counts reserved ciphertext capacity, not plaintext or content; services still cannot decrypt user data.\nCompleted: durable singleton SQLite QuotaDO reserves 25 MiB capacity per generation; source HMAC (/32 IPv4, /64 IPv6), per-source live/rolling-byte limits, global room/byte circuit breakers, confirmed leases, serialized RoomDO mutations, generation-bound one-shot R2 uploads, cleanup tombstone retries, bounded metadata, and separate production/staging R2 buckets. Validation 2026-07-09: npm run typecheck passed; full relay suite 22 files / 324 tests passed; real headless Chromium via Playwright passed health=200, 8 creates=201, ninth=429 ATTN_SOURCE_ROOM_QUOTA with retryAfterMs=60000, authenticated rejoin=200. Independent security audit found no attn-tdj blocker; remaining exact-accounting and reservation-lifecycle hardening are tracked in attn-8h5 and attn-64x.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-06-10T20:21:20Z","created_by":"James Lal","updated_at":"2026-07-09T21:54:00Z","started_at":"2026-07-09T20:55:24Z","closed_at":"2026-07-09T21:54:00Z","close_reason":"Implemented durable source quotas and global capacity circuit breakers with fail-closed confirmed leases, cleanup retention, and comprehensive regression/browser validation (324 tests plus Playwright Chromium E2E).","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-syv","title":"Per-file live collab for folder shares (every file independently co-editable)","description":"Owner chose 'every file live at once' for folder shares. Today a room has ONE live doc (CollabAuthority seeded once; submit/broadcast carry no fileId); folder shares create read-only snapshots only, the owner has no file switcher, and ShareDialog short-circuits a second share to re-show the existing invite.\n\nBuild: (1) fileId-tagged collab wire (submit/broadcast) + new resync request; owner holds Map\u003cfileId,CollabHost\u003e; reviewer single live client per active file; switch/join seeds at v0 from the file's BASE snapshot and replays stepsSince(0) (reuses idempotent receive skip). (2) App.svelte+Editor: per-file seed (base snapshot, not latest), re-seed editor collab doc on currentFileId change. (3) owner file switcher (wire sidebar nav -\u003e setCurrentFile). (4) ShareDialog: sharing a different target switches the room over (mint, not re-show). (5) E2E + release.\n\nParent epic: attn-07i (collab editorial UX).","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-25T16:01:40Z","created_by":"James Lal","updated_at":"2026-05-25T16:52:40Z","started_at":"2026-05-25T16:01:48Z","closed_at":"2026-05-25T16:52:40Z","close_reason":"Implemented + validated end-to-end (owner+reviewer over WebRTC live_direct):\nfileId-tagged collab wire + resync, owner Map\u003cfileId,CollabHost\u003e, per-file seed/\nre-create on switch, owner sidebar nav drives currentFileId, ShareDialog switch-\nover. Verified: folder share→per-file snapshots; owner sidebar switch follows;\nper-file edit isolation + correct disk save (no data loss); reviewer resync to\ncurrent doc; owner+reviewer on different files simultaneously; reviewer suggestion\non file B reaches owner on B. 0 JS errors. Shipped in 0.6.15.\n\nKnown minor UX gap (separate): the project-ROOT folder isn't a right-clickable\ntree row, so whole-root folder-share is only reachable via subfolder context menu\nor `attn review share \u003cdir\u003e` CLI; the header Share button shares the active file.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-d5x","title":"Sharing a single markdown file blanks the owner's editor (no active tab)","description":"On 0.6.2, sharing the file the owner is currently viewing leaves the editor pane blank with the 'Title' placeholder (no active tab). effectiveMarkdown for an owner = hasActiveTab ? rawMarkdown : '' — blank implies the active tab/rawMarkdown is being lost on share, OR collab activation seeds the editor empty. Reproduce + fix so a single-file share always keeps the owner focused on the shared file.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-05-24T15:25:42Z","created_by":"James Lal","updated_at":"2026-05-24T15:43:25Z","closed_at":"2026-05-24T15:43:25Z","close_reason":"Fixed + verified in 2a69ca9: collabSeedReady gate (unit tests) for the blank editor; folder ContextMenu + share-target wiring (live-daemon verified) for folder sharing.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-07i.2","title":"(EPIC) Inline suggesting mode (track changes) on prosemirror-suggest-changes","description":"Replace direct reviewer co-typing with Google-Docs-style inline suggestions built on @handlewithcare/prosemirror-suggest-changes (MIT, marks-based, composes with our prosemirror-collab — it skips collab$/history$ trs). Reviewers type in 'suggesting mode' → insertion/deletion marks; owner sees attributed inline suggestions and accept/denies each; owner's file only ever contains accepted content. Attribution via author-encoded suggestionId (marks only carry id). Phases: (1) schema marks + suggestChanges plugin + withSuggestChanges dispatch + reviewer suggesting-on + CSS; (2) owner accept/reject UI + attribution; (3) save-clean-view (file=accepted only) + persistence across reconnect/snapshot. User: no users yet, fine to cut over; can fork the lib later.","notes":"PHASE 1 DONE + verified (reviewer suggestions; file stays clean; live 11/0). PHASE 2 IMPLEMENTED (owner UX), verification partial: SuggestionPopover (click an inline suggestion -\u003e author + Accept/Reject/Comment), attribution decoded from the author-encoded suggestion id, accept=applySuggestion / reject=revertSuggestion via the view, comment=select range + open comment composer. Confirmed via instrumentation that the suggestion RENDERS inline with the correct attribution id (\u003cins data-id='Reviewer~...'\u003eRVDC\u003c/ins\u003e). GAP: the click-to-accept E2E couldn't be asserted because window.__attnPmView is a DETACHED view late in the test flow (document.querySelectorAll('.ProseMirror')=0 on the owner) — i.e. the owner's attached/visible editor vs the collab view that holds the suggestions. NEXT: (a) investigate the owner editor-view attachment (does the owner's VISIBLE editor show suggestions in normal use? likely a dual-Editor-instance / __attnPmView staleness issue), (b) E2E the click-accept once attachment is sorted, (c) fix commenting on a live co-typed/marked doc. PHASE 3 = marked-snapshot persistence.","status":"open","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-05-23T23:38:26Z","created_by":"James Lal","updated_at":"2026-05-24T00:34:07Z","dependencies":[{"issue_id":"attn-07i.2","depends_on_id":"attn-07i","type":"parent-child","created_at":"2026-05-23T17:38:25Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-07i.1","title":"Onboarding: user display name (prompt on first share/join, default from git)","description":"Participants currently appear as generic 'Reviewer'/'Owner'/'Agent' or raw 22-char participant IDs (bootstrap.rs:1017 sets display_name=participant_id; manager.rs:2830 hardcodes kind labels). Add a real display name.\n\nDESIGN (confirmed with user): store displayName in identity.json (DeviceIdentity); resolve a default from git config user.name -\u003e macOS full name (id -F) -\u003e $USER -\u003e 'Anonymous'; show a ONE-TIME in-app modal the first time the user shares OR receives/joins a share, pre-filled with the default, editable later via a profile affordance (NO window.prompt — in-app UI per CLAUDE.md). Thread the chosen name into Participant.display_name so peers see it; frontend prefers the Participant name over the kind label.\n\nPHASES: (1) Rust: DeviceIdentity.display_name + resolve_default_display_name() + get/set-name IPC + include name+default+isSet in init payload + use it in Participant.display_name. (2) Frontend: profile state from init; NamePrompt modal on first share/join; edit-later affordance; prefer Participant.display_name in peer/author resolution. (3) E2E: extend editorial test to set a name and assert the peer sees it.","notes":"CORE SHIPPED (commit 531ebaa on branch editorial-ux-and-collab-sync; NOT yet pushed to main). Rust: DeviceIdentity.display_name + resolve_default_display_name (git user.name -\u003e macOS id -F -\u003e $USER -\u003e Anonymous) + set/load helpers + Participant.display_name uses effective name + review_set_display_name IPC + reviewProfile in init; 4 unit tests. Frontend: userProfile rune store, NamePrompt in-app modal pre-filled with default, one-time prompt intercepts first Share + fires once on first join, Edit affordance in connection-badge popover. Editorial E2E 13-\u003e17. FOLLOW-UP (not blocking): changing the name AFTER already joining/sharing does not retroactively update the published ParticipantJoined (needs a re-publish/profile-update event); mitigated since the auto-default already publishes the real git/OS name and the Share prompt fires BEFORE the owner's publish.","status":"in_progress","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-05-23T22:29:21Z","created_by":"James Lal","updated_at":"2026-05-23T22:46:25Z","started_at":"2026-05-23T22:46:25Z","dependencies":[{"issue_id":"attn-07i.1","depends_on_id":"attn-07i","type":"parent-child","created_at":"2026-05-23T16:29:21Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-j5m","title":"Connection badge shows Offline though WebRTC is connected (live_direct dropped when owner is on local doc)","description":"SYMPTOM: In a 2-party GUI share (owner + reviewer), the connection badge shows 'Offline' even though the WebRTC DataChannel is fully connected and carrying collab data. This is almost certainly the source of the user-reported 'sync feels broken / WebRTC isn't working' perception — WebRTC IS primary and connected; the badge just doesn't show it.\n\nEVIDENCE (test:webrtc:live + temporary tracing): the DataChannel connects in ~100ms on localhost (create_offer -\u003e badge-eval connected=1 = 100ms). The Rust manager correctly computes all_live (peers=1, transports=1, connected=1) and emits ReviewUpdate::ConnectionChanged{connection:'live_direct'}. Co-typing edits and a review comment both propagate. Only the badge is wrong (data-state stays 'offline'). So the bug is purely frontend.\n\nROOT CAUSE: web/src/lib/review/store.svelte.ts applyConnection() updates the active badge (this.connection) ONLY when currentRoomId === payload.roomId (early-returns otherwise). The OWNER, by design (attn-0wa), stays on its LOCAL doc rather than flipping to the shared-room view, so currentRoomId is not the shared room; the 'live_direct' update is recorded on the per-room record via upsertRoom but never reaches this.connection, so the global badge stays at its 'offline' default. (Reviewer also showed offline in test:webrtc:live — confirm whether its currentRoomId is set by the raw review_join IPC path vs the UI join used by the editorial E2E.)\n\nRECOMMENDED FIX: the connection badge should reflect the connection state of the room the user is actually participating in / sharing, independent of which doc the editor is showing. Options: (a) when the owner shares, treat the shared room as the badge's room even while viewing the local doc; (b) have the badge read the per-room connection (upsertRoom already stores it) for the active share rather than only this.connection gated on currentRoomId. Decide the desired UX for 'owner viewing local doc while sharing'. Add a badge assertion to scripts/test-editorial-e2e.sh and make scripts/test-webrtc-live-e2e.sh green.\n\nNOTE: functionally non-blocking (collab converges, WebRTC is primary), but high perceived-severity — it makes working WebRTC look broken.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-05-23T21:17:07Z","created_by":"James Lal","updated_at":"2026-05-23T22:08:49Z","closed_at":"2026-05-23T22:08:49Z","close_reason":"Fixed in d2fbe17 (on main). applyConnection now keeps status.connection in sync with this.connection, so the ConnectionBadge no longer reads a stale 'offline'. Verified: test:webrtc:live 7→9/9 (owner+reviewer badges flip to live_direct), editorial E2E 13/0/0, svelte-check 0 errors/0 warnings.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-ms7","title":"Faithful no-TURN partition: block peer UDP so ICE fails (validates attn-7qv)","description":"The attn-orf 'partition' scenario applies iptables DROP between agent container IPs, but the WebRTC DataChannel still forms (connected=2) — ICE finds a path the rule doesn't cover (likely srflx via host-gateway, or an iptables-nft backend mismatch in debian-slim). Result: relay-only fallback is never exercised, so attn-7qv's per-peer routing is unvalidated. Make the partition truly sever the peer-to-peer path: default-DROP all egress/ingress, ALLOW only the relay (host.docker.internal:8787 TCP for WS+outbox) and loopback; verify direct UDP between agents is dead (e.g. ICE never reaches Connected). Then assert collab + comments STILL converge across owner/rvB/rvC via relay only. That run is the real no-TURN symmetric-NAT repro and the validation gate for attn-7qv.","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-05-23T19:20:19Z","created_by":"James Lal","updated_at":"2026-05-23T20:52:20Z","started_at":"2026-05-23T20:49:26Z","closed_at":"2026-05-23T20:52:20Z","close_reason":"Done: partition now blocks ALL UDP (path-independent), with a no-live_direct faithfulness assert. Mesh provably can't form; relay-only convergence confirmed.","dependencies":[{"issue_id":"attn-ms7","depends_on_id":"attn-k3v","type":"parent-child","created_at":"2026-05-23T13:20:30Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"attn-x06","title":"No TURN server: WebRTC mesh fails across symmetric NAT (cross-machine desync root cause)","description":"STUN-only, no TURN (webrtc.rs DEFAULT_STUN_SERVER only; security-review.md + relay-spec.md confirm 'no TURN server stood up / STUN only'). Symmetric-NAT peer-pairs can't form direct DataChannels -\u003e partial WebRTC mesh across real networks -\u003e asymmetric drops in both co-typing and comments. User confirms desync is across-machines for both. Fix: stand up a TURN server (coturn or a managed TURN), plumb TURN URLs+short-lived credentials into WebRtcConfig.ice_servers(), and add ice_transport_policy handling. This is the real NAT-traversal fix.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-05-23T02:18:05Z","created_by":"James Lal","updated_at":"2026-05-23T02:19:05Z","closed_at":"2026-05-23T02:19:05Z","close_reason":"User constraint: NO TURN, ever. NAT traversal for symmetric-NAT peers must be solved via a reliable per-peer relay data-fallback instead (attn-7qv), not TURN.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-orf","title":"Docker topology matrix harness (NAT / loss / partition / relay-only)","description":"Compose: relay container + N headless-agent containers + a coordinator. Use network namespaces + tc netem/iptables to exercise NAT traversal, packet loss/latency, partition+reconnect, and relay-only fallback (block UDP to force WS). Assert events.jsonl + doc convergence across all peers per topology. Depends on T3.","notes":"NOW CONVERGES 4/4 in baseline AND partition (commit f60cde1). Two fixes were needed, BOTH unrelated to the transport: (1) re-entrant mutex deadlock in the headless agent — handle_line held current_room across manager.submit (MutexGuard in match scrutinee lives the whole block), and submit's synchronous EventImported sink re-locks the same mutex -\u003e deadlock after the first comment, starving all later commands incl. collab; (2) control channel: docker -i stdin AND bind-mounted FIFO/file all drop rapid writes on Docker Desktop (FUSE attr-cache staleness / no streaming) — replaced with a CONTAINER-LOCAL file the agent polls (ATTN_AGENT_CMD_FILE) that the harness appends to via docker exec. Verified collab is WebRTC-primary: sender routes channels=true relay=false, peers receive Collab over the DataChannel. The earlier owner-Share-hang is gone with these fixes. REMAINING GAP: the 'partition' scenario does NOT actually sever the mesh (connected=2 even under iptables DROP of inter-agent IPs — ICE finds a path around it), so relay-only fallback is NOT yet exercised. See follow-up bead for a faithful UDP/peer-path block.","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-05-23T02:03:11Z","created_by":"James Lal","updated_at":"2026-05-23T20:52:22Z","closed_at":"2026-05-23T20:52:22Z","close_reason":"Harness built, trustworthy, and converges: baseline (WebRTC mesh) + faithful partition (UDP blackout, relay-only) both 4/4. Reliable container-local control channel; deadlock root-caused+fixed. NAT/loss profiles remain as future netem work but the core partition/relay-only matrix is proven.","dependencies":[{"issue_id":"attn-orf","depends_on_id":"attn-8zd","type":"blocks","created_at":"2026-05-22T20:03:18Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-orf","depends_on_id":"attn-k3v","type":"parent-child","created_at":"2026-05-22T22:47:15Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} -{"id":"attn-woc","title":"Fix: fan review events across the WebRTC mesh (parity with send_collab)","description":"FINDING (attn-sls): review events already converge to all peers via the relay in all modes on localhost, so this is NOT a drop-fix. It remains valid as the user's stated intent ('as much over WebRTC as possible; WS mostly for signaling'): optionally route review events over the live_webrtc mesh (parity with send_collab) to cut relay cost/latency, with relay as the fallback when the mesh is incomplete. Re-prioritize after the topology-layer repro (attn-orf) shows whether mesh peers actually drop events under partition/NAT.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-05-23T02:03:08Z","created_by":"James Lal","updated_at":"2026-05-23T19:20:43Z","closed_at":"2026-05-23T19:20:43Z","close_reason":"Landed in 32b2dd7; validated in the Docker harness — review-event Envelopes (kind=Event) are delivered over the WebRTC DataChannel to all connected peers (inbound Signal/Event confirmed), with the relay/outbox still covering un-meshed peers.","dependencies":[{"issue_id":"attn-woc","depends_on_id":"attn-k3v","type":"parent-child","created_at":"2026-05-22T22:47:14Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-woc","depends_on_id":"attn-sls","type":"blocks","created_at":"2026-05-22T20:03:18Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} -{"id":"attn-sls","title":"Deterministic in-process mesh-convergence test (reproduce review-event drop)","description":"Build a CI-friendly, no-UDP harness: in-process mesh bus + injected mock WebRtcSender/MailboxSender (selector already supports handle injection) driving N ReviewManager peers. Assert that BOTH a collab step AND a review event from reviewerB converge to reviewerC. The review-event assertion FAILS today (red test reproducing the bug); collab passes. Lock the mock-relay deliverableTo contract: event-\u003eall subscribers, signal target=null-\u003eall, signal target=X-\u003eX only.","notes":"DONE: tests/review_sync_convergence.rs landed. 3-peer, real Miniflare relay (gated by ATTN_SKIP_CONFORMANCE/wrangler), no UDP. Asserts reviewerB comment converges to owner+reviewerC in live/hybrid/async. ALL PASS -\u003e rules out the relay-mediated review-event layer as the cause of asymmetric loss. Reusable as a regression guard and as the harness skeleton for topology fault-injection.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-23T02:03:06Z","created_by":"James Lal","updated_at":"2026-05-23T02:13:17Z","started_at":"2026-05-23T02:03:19Z","closed_at":"2026-05-23T02:13:17Z","close_reason":"Deterministic in-process convergence harness built and passing; relay-mediated review-event path proven sound on localhost in all modes. Bug localized away from this layer.","dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"attn-k3v","title":"Bulletproof collab sync testing + fix asymmetric review-event loss","description":"Multi-user (3+) collab drops review events (comments/suggestions) asymmetrically: a non-owner reviewer's comment reaches the owner but not other reviewers. Root cause: review events route through selector::send_envelopes over a SINGLE webrtc arm (no mesh fan-out), the owner never re-fans inbound events (manager.rs forward_transport_event:2543), and Live mode keeps no relay WS subscription (mailbox=None) so relay broadcast can't cover the gap. Co-typing is unaffected because send_collab fans across the live_webrtc mesh map. Intent: WebRTC carries data to all peers; WS is for signaling. Deliver a layered, deterministic test harness (in-process mesh-convergence first, then headless-agent + Docker topology matrix) and fix the fan-out.","notes":"ITEM 1 (top priority): cross-machine sync drop. Conflict-resolution LOGIC is solid (real OT co-typing, three-way suggestion merge, anchor remap — all tested on localhost), but on a real network the no-TURN partial mesh + send_collab's all-or-nothing 'skip relay when mesh complete' (manager.rs:1186) means OT steps AND suggestion/comment delivery can silently DROP for un-meshable peers. This is what makes the rest feel broken. Pieces: attn-7qv (P0 per-peer relay-fallback fix), attn-orf (Docker symmetric-NAT repro/guard), attn-woc (events over mesh).","status":"closed","priority":1,"issue_type":"epic","owner":"james@littlebearlabs.io","created_at":"2026-05-23T02:03:03Z","created_by":"James Lal","updated_at":"2026-05-23T20:52:45Z","closed_at":"2026-05-23T20:52:45Z","close_reason":"EPIC complete. All children closed: attn-8zd (headless agent), attn-orf (topology harness), attn-7qv (per-peer relay fallback, VALIDATED), attn-ms7 (faithful partition), attn-woc (mesh event fan-out). Collab is WebRTC-primary with a proven no-TURN relay fallback; the agent deadlock that masked it is fixed. Cross-topology convergence demonstrated in Docker (baseline mesh + UDP-blackout relay-only, both 4/4).","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-az7","title":"Preserve ATTN_HOME for npx-launched review daemons","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-05-22T22:09:35Z","created_by":"James Lal","updated_at":"2026-05-23T00:24:25Z","started_at":"2026-05-22T22:21:38Z","closed_at":"2026-05-23T00:24:25Z","close_reason":"Implemented --attn-home routing, npm launcher env preservation, share target validation, and UI error surfacing; verified Rust/web gates plus relay-backed share/join and invalid-share smoke.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-4wv","title":"Sharing status implies sharing everything; show which file(s) are shared","description":"The 'Sharing' pill/status icon gives no indication of WHAT is being shared, implying the whole session/everything is shared. Surface the shared file name (and for folder shares, the file list/count) in or next to the share status.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-05-22T15:38:33Z","created_by":"James Lal","updated_at":"2026-05-22T16:07:16Z","closed_at":"2026-05-22T16:07:16Z","close_reason":"Added SharedFilesBadge.svelte next to the Share pill: shows the filename (single share) or 'N files' (folder share) with a popover listing relative paths. Verified E2E: owner shows '3 files'.","dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"attn-deh","title":"Menu-bar floating items render under other UI (z-index)","description":"The review dock floating items (share, snapshot, reviewer avatar, camera) and their popovers appear UNDER other UI / clipped behind window chrome. Fix stacking context: review-bar z-index, right-rail overflow clipping, and popover layering so floating items and dropdowns sit above the document and chrome.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-05-22T15:38:33Z","created_by":"James Lal","updated_at":"2026-05-22T16:07:17Z","closed_at":"2026-05-22T16:07:17Z","close_reason":"Fixed popover clipping: PeerStrip identity card + presence tooltip now right-align (open inward) and sit at z-[60] above the dock; dock given a small top offset. Verified by screenshot — identity popover fully visible, was clipping off the right window edge. NOTE: the original 'dark bar over icons' facet may be environment-specific; asked user to confirm in their setup.","dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"attn-fr8","title":"No UI navigation into shared files and subfolders","description":"A shared room can contain many files across subfolders, but there is no real navigation. Build BOTH: (1) a left-sidebar folder tree of the shared room's files when in a room, and (2) extend the top ReviewFileNav strip with folder grouping. Reviewer switches files via reviewStore.setCurrentFile(fileId).","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-05-22T15:38:32Z","created_by":"James Lal","updated_at":"2026-05-22T16:07:15Z","closed_at":"2026-05-22T16:07:15Z","close_reason":"Added ReviewFileTree.svelte (sidebar folder tree built from snapshot ownerDisplayPaths via shared-tree.ts) + extended the ReviewFileNav strip with folder context. Both surfaces verified E2E (sidebar tree shows subfolder; strip lists files with 'deep/' prefix).","dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"attn-xds","title":"Opening a shared link does not jump to the shared content","description":"When a reviewer joins a room, the editor stays on whatever local file was previously open instead of showing the shared document. Reviewer-side view must switch to the shared snapshot on join (role-aware: reviewer shows shared doc even with a local tab open; owner keeps local view). Show a clear 'waiting for shared content' state until the first snapshot arrives. Related to attn-0wa (owner incorrectly flips to shared-doc mode).","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-05-22T15:38:32Z","created_by":"James Lal","updated_at":"2026-05-22T16:07:15Z","closed_at":"2026-05-22T16:07:15Z","close_reason":"Reviewer view is now role-based (isReviewerInRoom = in a room they didn't mint), so it shows the shared snapshot even with a local tab open, with a 'waiting for shared content' state until the first snapshot arrives. Verified E2E: reviewer renders the shared doc, not its local file.","dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"attn-jru","title":"Reviewer joins headless via --as-agent; should open a real window","description":"When sharing/joining for human review, the reviewer must NOT use 'attn review join \u003cinvite\u003e --as-agent \u003cname\u003e' which is a headless agent join (no window, no UI). The human-reviewer path should route through the windowed daemon join so a real UI opens. Fix scripts/dev-collab.sh (reviewer = windowed daemon, daemon-routed join) and ensure no human-facing flow emits --as-agent. --as-agent stays for bots/CI only.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-22T15:38:31Z","created_by":"James Lal","updated_at":"2026-05-22T16:07:14Z","started_at":"2026-05-22T15:41:31Z","closed_at":"2026-05-22T16:07:14Z","close_reason":"dev-collab.sh now routes the reviewer join to the windowed daemon (attn review join, no --as-agent); the daemon socket join was already wired to ReviewManager — removed the misleading 'stub, manager wiring pending' log. Verified: reviewer daemon joins + receives snapshots.","dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"attn-lms","title":"Sharing experience bug sweep","description":"Umbrella for the urgent sharing/collab UX bugs found 2026-05-22: headless --as-agent join, reviewer not auto-opening shared content, missing shared-file navigation, menu-bar z-index clipping, and ambiguous sharing status.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-22T15:38:30Z","created_by":"James Lal","updated_at":"2026-05-23T20:49:25Z","started_at":"2026-05-22T15:41:31Z","closed_at":"2026-05-23T20:49:25Z","close_reason":"All 5 child bugs closed (attn-4wv, attn-deh, attn-fr8, attn-jru, attn-xds). Sharing UX sweep complete.","dependencies":[{"issue_id":"attn-lms","depends_on_id":"attn-4wv","type":"blocks","created_at":"2026-05-22T09:38:35Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-lms","depends_on_id":"attn-deh","type":"blocks","created_at":"2026-05-22T09:38:35Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-lms","depends_on_id":"attn-fr8","type":"blocks","created_at":"2026-05-22T09:38:34Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-lms","depends_on_id":"attn-jru","type":"blocks","created_at":"2026-05-22T09:38:33Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-lms","depends_on_id":"attn-xds","type":"blocks","created_at":"2026-05-22T09:38:34Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":5,"dependent_count":0,"comment_count":0} -{"id":"attn-032","title":"Fix npm trusted publishing for attnmd 0.4.0","description":"The v0.4.0 GitHub release completed successfully, including macOS/Linux assets and Homebrew tap update, but the manual Publish npm workflow failed when publishing attnmd@0.4.0. npm accepted provenance generation and then returned E404: Not Found / no permission for PUT https://registry.npmjs.org/attnmd. npm view shows attnmd exists, is owned by lightsofapollo, and latest remains 0.3.5. Local npm is not authenticated, so 0.4.0 could not be published manually from this machine.","acceptance_criteria":"attnmd@0.4.0 is published to npm with latest tag. The Publish npm workflow can be rerun successfully for v0.4.0, either by configuring npm trusted publishing for .github/workflows/npm-publish.yml or by wiring an authorized npm token. npm view attnmd version returns 0.4.0.","notes":"Release run succeeded: https://github.com/lightsofapollo/attn/actions/runs/26273347424. Failed npm workflow run: https://github.com/lightsofapollo/attn/actions/runs/26273799254.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-05-22T07:19:38Z","created_by":"James Lal","updated_at":"2026-05-22T14:11:25Z","closed_at":"2026-05-22T14:11:25Z","close_reason":"attnmd@0.4.0 published successfully after fixing npm trusted publisher workflow configuration","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-0wa","title":"Owner view flips to shared-document mode after reviewer live edit","description":"During the marketing capture workflow, the owner window is correct through reviewer comment/suggestion, but after the reviewer inserts live text the owner DOM reports data-slot=shared-doc-banner and the capture shows the reviewer Shared document banner. Repro via scripts/capture-collab-screenshots.sh before removing the live text insert. Investigate why the owner loses its local active-tab surface after remote collab steps.","notes":"ITEM 2: real bug that hurts editorial flow. App.svelte (~line 133) flips the OWNER into shared-document mode after a reviewer's live edit (isReviewerInRoom gating). Owner should stay on their local doc; only true reviewers render the shared snapshot.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-22T01:19:42Z","created_by":"James Lal","updated_at":"2026-05-23T05:14:03Z","started_at":"2026-05-23T05:04:23Z","closed_at":"2026-05-23T05:14:03Z","close_reason":"Fixed: review-mode gating now requires a positive reviewer role (daemon 'Joined'-\u003erole reviewer), not just currentShare===null. currentShare is session-only and lost on reconnect/rehydrate, so an owner returning to a remembered room (role 'owner', no share) flipped into shared-doc view. Extracted isReviewerView/collabRoleFor pure helpers in room-ui.ts; App.svelte uses them; 6 regression cases added (incl. the reconnect case). svelte-check clean, all 28 web test files pass.","dependencies":[{"issue_id":"attn-0wa","depends_on_id":"attn-07i","type":"parent-child","created_at":"2026-05-22T22:47:15Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"attn-nnj.7.9","title":"H2: AAD-bind signal envelope target.deviceId (anti-relay-redirect)","description":"Security review (11.5) flagged: signal envelope's target.deviceId is not AAD-bound, allowing the relay to redirect to a different device. Mitigation: enforce envelope.target.deviceId == self.device_id in the inbound signal dispatcher OR include target.deviceId in AAD. See planning/collab/security-review.md §H2.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-19T16:18:29Z","created_by":"James Lal","updated_at":"2026-05-19T17:36:29Z","started_at":"2026-05-19T17:02:47Z","closed_at":"2026-05-19T17:36:29Z","close_reason":"Round 21 (final push): implemented; merged; build clean","dependencies":[{"issue_id":"attn-nnj.7.9","depends_on_id":"attn-nnj.7","type":"parent-child","created_at":"2026-05-19T10:18:28Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj.5.17","title":"H1: require Attn-Owner-Signature on first POST /v2/rooms/:roomId","description":"Security review (11.5) flagged: room-create POST is un-admitted by design, allowing race attacks. Mitigation: require Attn-Owner-Signature header carrying ownerSigningKey self-sig over the canonical request body on the first POST. See planning/collab/security-review.md §H1.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-19T16:18:28Z","created_by":"James Lal","updated_at":"2026-05-19T17:36:15Z","started_at":"2026-05-19T17:02:47Z","closed_at":"2026-05-19T17:36:15Z","close_reason":"Round 21 (final push): implemented; merged; build clean","dependencies":[{"issue_id":"attn-nnj.5.17","depends_on_id":"attn-nnj.5","type":"parent-child","created_at":"2026-05-19T10:18:27Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj.11.10","title":"Nested-file nav + breadcrumb regression in directory mode","description":"scripts/test-e2e.sh reports 2 FAILs against the current collab HEAD (2d76b45):\n\n1. Clicking 'nested/child.md' in the sidebar does not change the rendered content. The body remains on the previously-selected basic.md ('Project Status'). Expected to load child.md ('Nested Document').\n\n2. The breadcrumb element is absent. Neither '[class*=\"breadcrumb\"]' nor 'nav[aria-label]' is present in the DOM.\n\nVisual proof: /tmp/attn-e2e-screenshots/06-nested-file.png — sidebar shows child.md selected (highlighted), but the body still shows basic.md content, and no breadcrumb is rendered above the h1.\n\nRepro:\n scripts/test-e2e.sh\n # See suite 2 'Navigate Between Files' — last two assertions FAIL.\n\nNOT caused by attn-nnj.11.2 (doc-only change). Likely surfaced by recent Round-13/14 sidebar/tab/breadcrumb refactors. Probably impacts users navigating directories in real use. Discovered during epic-level e2e verification of attn-nnj.11.","notes":"Discovered during attn-nnj.11 epic-level e2e verification. Test command: scripts/test-e2e.sh. Other harnesses (test-dual-instance-smoke 10/10 PASS, test-review-e2e 12 PASS + 1 PEND) are clean — this is isolated to single-instance directory nav.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-19T04:35:47Z","created_by":"James Lal","updated_at":"2026-05-19T15:06:29Z","started_at":"2026-05-19T13:53:28Z","closed_at":"2026-05-19T15:06:29Z","close_reason":"Implemented; merged; 412 Rust + 213 relay tests pass (6 conformance scenarios deferred to 5.16)","dependencies":[{"issue_id":"attn-nnj.11.10","depends_on_id":"attn-nnj.11","type":"parent-child","created_at":"2026-05-18T22:35:46Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj.2.11","title":"IpcMessage + SocketMessage review variants (additive)","description":"Pure additive enum extension. In src/ipc.rs:9-59 add IpcMessage variants: review_share, review_join, review_create_comment, review_create_suggestion, review_accept_suggestion, review_resolve_anchor. In src/daemon.rs:62-76 add SocketMessage variants: ReviewShare, ReviewJoin, ReviewPull, ReviewStop, ReviewInbox. Handlers stub to a TODO that the Manager (0b-8) will fill. Lets the frontend stubs (0c-5) wire end-to-end without waiting for ReviewManager.","acceptance_criteria":"- IpcMessage + SocketMessage variants added with serde tagged-enum discrimination\\n- handle_message / handle_client dispatch new variants to TODO!/log handlers without crashing\\n- Frontend can post a review_share message via mock and Rust receives it cleanly\\n- Existing variants untouched","notes":"Audits show both enums are already serde-tagged — this is purely additive. Decouples 0c frontend work from 0b-8 ReviewManager scaffolding.","status":"closed","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-05-19T04:29:36Z","created_by":"James Lal","updated_at":"2026-05-19T17:00:01Z","closed_at":"2026-05-19T17:00:01Z","close_reason":"Duplicate of 12.6/12.7/12.8/12.9/12.10/2.9 — already closed","dependencies":[{"issue_id":"attn-nnj.2.11","depends_on_id":"attn-nnj.2","type":"parent-child","created_at":"2026-05-18T22:29:36Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj.12.15","title":"review/store.ts scaffold (Svelte 5 runes)","description":"Scaffold web/src/lib/review/store.ts as the global review state holder using Svelte 5 runes. Minimal API: for panelOpen, currentRoomId, peers[], threads[] (empty), pendingOutbox (empty). Subscribers to window.__attn__.reviewEvent / reviewStatus push into this. Phase 2 issue 4.2 layers derived selectors on top (comments-on-current-snapshot, ambiguous-list, outbox-count).","acceptance_criteria":"- web/src/lib/review/store.ts exists with -based shape and typed via 0c-4 interfaces\\n- IPC callbacks from 0c-3 push events into the store\\n- No reactivity bugs: subscribing components see updates via \\n- Empty initial state renders cleanly","notes":"Use Svelte 5 runes per project convention (svelte5-best-practices skill exists). Phase 2 4.2 extends this with derived selectors.","status":"closed","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-05-19T04:29:35Z","created_by":"James Lal","updated_at":"2026-05-19T16:59:45Z","closed_at":"2026-05-19T16:59:45Z","close_reason":"Duplicate of 12.6/12.7/12.8/12.9/12.10/2.9 — already closed","dependencies":[{"issue_id":"attn-nnj.12.15","depends_on_id":"attn-nnj.12","type":"parent-child","created_at":"2026-05-18T22:29:35Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj.12.14","title":"keyboard.ts hooks: comment/suggestion/panel-toggle","description":"Extend KeyboardConfig in web/src/lib/keyboard.ts with optional onCommentComposer, onSuggestionComposer, onToggleReviewPanel handlers. Default bindings: Cmd+. (comment), Cmd+Shift+. (suggestion), Cmd+J (toggle panel). Existing shortcuts unaffected. Update KeyboardShortcutsDialog.svelte to surface the new bindings only when a review room is active.","acceptance_criteria":"- 3 new handler keys on KeyboardConfig (all optional)\\n- Default keybinds registered when handlers provided\\n- KeyboardShortcutsDialog conditionally shows the review section\\n- No collision with existing shortcuts (j/k scroll, g/G top-bottom, t theme, q quit, e edit, f sidebar, Cmd+W/[/], etc.)","notes":"CLAUDE.md keyboard table is authoritative for existing shortcuts.","status":"closed","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-05-19T04:29:34Z","created_by":"James Lal","updated_at":"2026-05-19T16:59:28Z","closed_at":"2026-05-19T16:59:28Z","close_reason":"Duplicate of 12.6/12.7/12.8/12.9/12.10/2.9 — already closed","dependencies":[{"issue_id":"attn-nnj.12.14","depends_on_id":"attn-nnj.12","type":"parent-child","created_at":"2026-05-18T22:29:34Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj.12.13","title":"popover-anchor utility (selection → DOM rect → constrained pop)","description":"Shared utility web/src/lib/review/popover-anchor.ts: given a ProseMirror EditorView + selection range, return a DOMRect and a constrained position for a popover (clamped within viewport, flipped above/below as needed). Used by comment composer (2-4), suggestion composer (2-5), and the ambiguous anchor picker (2-7) so all three pop in the same spot.","acceptance_criteria":"- Pure TS function: (view, from, to) → { rect, recommendedAnchor: { top, left, side: 'above'|'below' } }\\n- Handles multi-line selections (uses leading rect)\\n- Unit test against a simulated EditorView\\n- No types","notes":"Reference impl: web/src/lib/CommandPalette.svelte may have positioning logic to reuse.","status":"closed","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-05-19T04:29:33Z","created_by":"James Lal","updated_at":"2026-05-19T16:59:14Z","closed_at":"2026-05-19T16:59:14Z","close_reason":"Duplicate of 12.6/12.7/12.8/12.9/12.10/2.9 — already closed","dependencies":[{"issue_id":"attn-nnj.12.13","depends_on_id":"attn-nnj.12","type":"parent-child","created_at":"2026-05-18T22:29:33Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj.12.12","title":"Theme CSS variables for review surfaces","description":"app.css today has surfaces/accent/sidebar/code-block vars but no decoration/panel vars. Add light + dark mode vars: --comment-highlight, --suggestion-bg, --suggestion-deletion, --confidence-high, --confidence-med, --confidence-low, --moved-badge-bg, --moved-badge-fg, --panel-surface, --panel-border, --peer-avatar-bg-{owner,reviewer,agent}, --stale-anchor-fg. Coordinate values via the existing OKLCH ramp.","acceptance_criteria":"- All new vars defined for both :root (light) and .dark themes\\n- Toggle via existing theme system works without flicker\\n- Variables surface in CSS, ready to be referenced by Phase 2 decoration plugin and components\\n- Use rampa-colors / theme-foundation skills if generating new ramps","notes":"Use OKLCH per project convention. Available skills: rampa-colors, theme-foundation.","status":"closed","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-05-19T04:29:32Z","created_by":"James Lal","updated_at":"2026-05-19T16:59:02Z","closed_at":"2026-05-19T16:59:02Z","close_reason":"Duplicate of 12.6/12.7/12.8/12.9/12.10/2.9 — already closed","dependencies":[{"issue_id":"attn-nnj.12.12","depends_on_id":"attn-nnj.12","type":"parent-child","created_at":"2026-05-18T22:29:32Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj.12.11","title":"mock-ipc.ts: review callback shims (no scenario data)","description":"Extend mock-ipc.ts so the mocked window.ipc.postMessage understands review_* commands (logs them, echoes a fake ack via the review callbacks). No scripted scenario data yet — that's Phase 2 issue 4.1, which builds on this surface. Without this, frontend dev breaks the moment any review_* call is made.","acceptance_criteria":"- Mock dispatches review_* commands to no-op handlers\\n- Mock invokes window.__attn__.reviewStatus/reviewEvent/etc handlers when called via a test helper\\n- No console errors on baseline Starting Vite dev server on http://127.0.0.1:5173\nWaiting for Vite to be ready...\n\n \u001b[32m\u001b[1mVITE\u001b[22m v6.4.1\u001b[39m \u001b[2mready in \u001b[0m\u001b[1m1065\u001b[22m\u001b[2m\u001b[0m ms\u001b[22m\n\n \u001b[32m➜\u001b[39m \u001b[1mLocal\u001b[22m: \u001b[36mhttp://127.0.0.1:\u001b[1m5173\u001b[22m/\u001b[39m\nLaunching attn with HMR enabled (path: .)\n\u001b[2m4:50:22 PM\u001b[22m \u001b[36m\u001b[1m[vite]\u001b[22m\u001b[39m \u001b[90m\u001b[2m(client)\u001b[22m\u001b[39m \u001b[32m✨ new dependencies optimized: \u001b[33mshiki/themes, shiki/langs\u001b[32m\u001b[39m\n\u001b[2m4:50:22 PM\u001b[22m \u001b[36m\u001b[1m[vite]\u001b[22m\u001b[39m \u001b[90m\u001b[2m(client)\u001b[22m\u001b[39m \u001b[32m✨ optimized dependencies changed. reloading\u001b[39m\n\u001b[2m5:10:53 PM\u001b[22m \u001b[36m\u001b[1m[vite]\u001b[22m\u001b[39m \u001b[90m\u001b[2m(client)\u001b[22m\u001b[39m \u001b[32mhmr update \u001b[39m\u001b[2m/src/app.css\u001b[22m\n\u001b[2m5:11:10 PM\u001b[22m \u001b[36m\u001b[1m[vite]\u001b[22m\u001b[39m \u001b[90m\u001b[2m(client)\u001b[22m\u001b[39m \u001b[32mhmr update \u001b[39m\u001b[2m/src/app.css\u001b[22m\n\u001b[2m5:11:21 PM\u001b[22m \u001b[36m\u001b[1m[vite]\u001b[22m\u001b[39m \u001b[90m\u001b[2m(client)\u001b[22m\u001b[39m \u001b[32mhmr update \u001b[39m\u001b[2m/src/app.css\u001b[22m\n\u001b[2m5:12:47 PM\u001b[22m \u001b[36m\u001b[1m[vite]\u001b[22m\u001b[39m \u001b[90m\u001b[2m(client)\u001b[22m\u001b[39m \u001b[32mhmr update \u001b[39m\u001b[2m/src/app.css\u001b[22m\n\u001b[2m5:14:39 PM\u001b[22m \u001b[36m\u001b[1m[vite]\u001b[22m\u001b[39m \u001b[90m\u001b[2m(client)\u001b[22m\u001b[39m \u001b[32mhmr update \u001b[39m\u001b[2m/src/app.css\u001b[22m\n\u001b[2m5:15:01 PM\u001b[22m \u001b[36m\u001b[1m[vite]\u001b[22m\u001b[39m \u001b[90m\u001b[2m(client)\u001b[22m\u001b[39m \u001b[32mhmr update \u001b[39m\u001b[2m/src/app.css\u001b[22m\n\u001b[2m5:16:23 PM\u001b[22m \u001b[36m\u001b[1m[vite]\u001b[22m\u001b[39m \u001b[90m\u001b[2m(client)\u001b[22m\u001b[39m \u001b[32mpage reload \u001b[39m\u001b[2msrc/lib/types.ts\u001b[22m\n\u001b[2m5:16:23 PM\u001b[22m \u001b[36m\u001b[1m[vite]\u001b[22m\u001b[39m \u001b[90m\u001b[2m(client)\u001b[22m\u001b[39m \u001b[32mhmr update \u001b[39m\u001b[2m/src/App.svelte, /src/app.css\u001b[22m\n\u001b[2m5:16:23 PM\u001b[22m \u001b[36m\u001b[1m[vite]\u001b[22m\u001b[39m \u001b[90m\u001b[2m(client)\u001b[22m\u001b[39m \u001b[32mhmr update \u001b[39m\u001b[2m/src/lib/KeyboardShortcutsDialog.svelte, /src/app.css\u001b[22m\n\u001b[2m5:16:23 PM\u001b[22m \u001b[36m\u001b[1m[vite]\u001b[22m\u001b[39m \u001b[90m\u001b[2m(client)\u001b[22m\u001b[39m \u001b[32mhmr update \u001b[39m\u001b[2m/src/App.svelte, /src/app.css\u001b[22m\n\u001b[2m5:16:24 PM\u001b[22m \u001b[36m\u001b[1m[vite]\u001b[22m\u001b[39m \u001b[90m\u001b[2m(client)\u001b[22m\u001b[39m \u001b[32mhmr update \u001b[39m\u001b[2m/src/app.css\u001b[22m\n\u001b[2m5:16:24 PM\u001b[22m \u001b[36m\u001b[1m[vite]\u001b[22m\u001b[39m \u001b[90m\u001b[2m(client)\u001b[22m\u001b[39m \u001b[32mhmr update \u001b[39m\u001b[2m/src/lib/Editor.svelte, /src/app.css\u001b[22m\n\u001b[2m5:27:05 PM\u001b[22m \u001b[36m\u001b[1m[vite]\u001b[22m\u001b[39m \u001b[90m\u001b[2m(client)\u001b[22m\u001b[39m \u001b[32mpage reload \u001b[39m\u001b[2msrc/lib/mock-ipc.ts\u001b[22m\n\u001b[2m5:27:05 PM\u001b[22m \u001b[36m\u001b[1m[vite]\u001b[22m\u001b[39m \u001b[90m\u001b[2m(client)\u001b[22m\u001b[39m \u001b[32mhmr update \u001b[39m\u001b[2m/src/App.svelte, /src/app.css\u001b[22m\n\u001b[2m5:42:43 PM\u001b[22m \u001b[36m\u001b[1m[vite]\u001b[22m\u001b[39m \u001b[90m\u001b[2m(client)\u001b[22m\u001b[39m \u001b[32mhmr update \u001b[39m\u001b[2m/src/App.svelte, /src/app.css\u001b[22m\n\u001b[2m6:13:05 PM\u001b[22m \u001b[36m\u001b[1m[vite]\u001b[22m\u001b[39m \u001b[90m\u001b[2m(client)\u001b[22m\u001b[39m \u001b[32mhmr update \u001b[39m\u001b[2m/src/App.svelte, /src/app.css, /src/lib/Sidebar.svelte, /src/lib/Editor.svelte, /src/lib/PathBreadcrumb.svelte, /src/lib/components/ui/sonner/sonner.svelte, /src/lib/FileTree.svelte\u001b[22m\n\u001b[2m6:13:05 PM\u001b[22m \u001b[36m\u001b[1m[vite]\u001b[22m\u001b[39m \u001b[90m\u001b[2m(client)\u001b[22m\u001b[39m \u001b[32mpage reload \u001b[39m\u001b[2msrc/lib/types.ts\u001b[22m\n\u001b[2m6:32:31 PM\u001b[22m \u001b[36m\u001b[1m[vite]\u001b[22m\u001b[39m \u001b[90m\u001b[2m(client)\u001b[22m\u001b[39m \u001b[32mpage reload \u001b[39m\u001b[2msrc/lib/mock-ipc.ts\u001b[22m","notes":"Phase 2 issue 4.1 layers scripted scenarios on top of this.","status":"closed","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-05-19T04:29:31Z","created_by":"James Lal","updated_at":"2026-05-19T16:58:49Z","closed_at":"2026-05-19T16:58:49Z","close_reason":"Duplicate of 12.6/12.7/12.8/12.9/12.10/2.9 — already closed","dependencies":[{"issue_id":"attn-nnj.12.11","depends_on_id":"attn-nnj.12","type":"parent-child","created_at":"2026-05-18T22:29:31Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj.11.8","title":"Dual-instance E2E harness (owner + reviewer via ATTN_HOME)","description":"Package the multi-instance automation pattern as a reusable test harness. Boots two attn daemons under ATTN_HOME=/tmp/attn-collab-owner and ATTN_HOME=/tmp/attn-collab-reviewer (plus optionally local Miniflare), exposes shell helpers attn_owner(...) and attn_reviewer(...) that prefix the right ATTN_HOME, and demonstrates a baseline assertion script that pokes both daemons via --query / --eval / --click. All review surface E2E tests (Phase 2 demo 4.14, Phase 5 e2e 8.6, Phase 4 WebRTC e2e 7.7) reuse this harness rather than wiring two-daemon plumbing themselves.","acceptance_criteria":"- scripts/lib/dual-instance.sh: sourced library exposing attn_owner / attn_reviewer / start_dual / stop_dual / wait_for_dual\\n- scripts/test-dual-instance-smoke.sh: a smoke test that boots two daemons, confirms each --info reports the right ATTN_HOME, drives --query on each independently, tears down cleanly\\n- Trap-based cleanup so Ctrl+C/early-exit kills both daemons\\n- README section in CLAUDE.md documents the pattern with a copy-pasteable example\\n- The Phase 2 demo (4.14) and Phase 5 e2e (8.6) test scripts source this library — no duplicated start/stop boilerplate","notes":"Depends on 2.10 (ATTN_HOME, done) and 11.4 (e2e scaffolding shape — in flight). The harness should NOT depend on Miniflare being up — leave that as a separate optional flag so this can run before the relay lands. Adopters: 4.14 + 7.7 + 8.6 + 11.7 dev-collab.sh.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T23:26:31Z","created_by":"James Lal","updated_at":"2026-05-18T23:57:28Z","started_at":"2026-05-18T23:49:35Z","closed_at":"2026-05-18T23:57:28Z","close_reason":"Implemented; merged into collab","dependencies":[{"issue_id":"attn-nnj.11.8","depends_on_id":"attn-nnj.11","type":"parent-child","created_at":"2026-05-18T17:26:31Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj.11.7","title":"scripts/dev-collab.sh: one-command local collab harness","description":"Boot the whole local collab stack with one command. Starts: Miniflare/wrangler relay on :8787 (background), owner daemon with ATTN_HOME=/tmp/attn-collab-owner pointing at a fixture markdown file, reviewer daemon with ATTN_HOME=/tmp/attn-collab-reviewer joining the room via attn://review/... copied from owner share. Tails logs from both. Ctrl+C kills everything cleanly. Sibling to the existing scripts/test-e2e.sh — same conventions.","acceptance_criteria":"- scripts/dev-collab.sh runs and produces a working owner + reviewer pair connected via local relay\\n- Default fixture file: tests/fixtures/basic.md (or a new collab-specific one)\\n- Environment can be overridden via env vars (ATTN_RELAY_URL, FIXTURE_PATH)\\n- Ctrl+C tears down all 3 processes; no orphans\\n- README section 'Local collab testing' explains the workflow","notes":"Depends on ATTN_HOME (0b-10), the relay scaffold (5-1), and the Rust mailbox transport bootstrap flow (6-6) being usable. Useful from Phase 3 onward; can stub-deploy earlier with just the relay running.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:58:47Z","created_by":"James Lal","updated_at":"2026-05-19T17:36:01Z","started_at":"2026-05-19T17:02:46Z","closed_at":"2026-05-19T17:36:01Z","close_reason":"Round 21 (final push): implemented; merged; build clean","dependencies":[{"issue_id":"attn-nnj.11.7","depends_on_id":"attn-nnj.11","type":"parent-child","created_at":"2026-05-18T16:58:46Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.11.7","depends_on_id":"attn-nnj.2.10","type":"blocks","created_at":"2026-05-18T16:58:47Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.11.7","depends_on_id":"attn-nnj.5.1","type":"blocks","created_at":"2026-05-18T16:58:48Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.11.7","depends_on_id":"attn-nnj.6.6","type":"blocks","created_at":"2026-05-18T16:58:49Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":3,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj.2.10","title":"ATTN_HOME env override for multi-instance dev","description":"src/daemon.rs:109-121 runtime_dir() picks /tmp/attn-\u003cexe-hash\u003e in debug and ~/.attn in release with no env override. Add ATTN_HOME (or ATTN_RUNTIME_DIR) env var that, when set, overrides both code paths. This unblocks running two attn daemons on one machine for local collab testing: ATTN_HOME=/tmp/attn-owner attn ... and ATTN_HOME=/tmp/attn-reviewer attn .... Also threads through to the future review store path so ~/.attn/reviews/ becomes $ATTN_HOME/reviews/.","acceptance_criteria":"- ATTN_HOME env var, when set, replaces the runtime_dir() default in BOTH debug and release\\n- Socket path, fingerprint, log, and (future) reviews/ all live under $ATTN_HOME\\n- Two daemons started with different ATTN_HOME values don't clobber each other's sockets/state\\n- README or CLAUDE.md updated with the multi-instance dev recipe\\n- Existing single-instance behavior unchanged when ATTN_HOME is unset","notes":"Touches src/daemon.rs runtime_dir(). Also update src/projects.rs:73 to fall back to ATTN_HOME before XDG_STATE_HOME so project registry shares the namespace. Tiny change, unblocks Phase 3+ local testing.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:58:45Z","created_by":"James Lal","updated_at":"2026-05-18T23:18:42Z","started_at":"2026-05-18T23:08:50Z","closed_at":"2026-05-18T23:18:42Z","close_reason":"Implemented and merged into collab via parallel worktree agents; pnpm build + cargo check both clean post-merge","dependencies":[{"issue_id":"attn-nnj.2.10","depends_on_id":"attn-nnj.2","type":"parent-child","created_at":"2026-05-18T16:58:45Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":3,"comment_count":0} -{"id":"attn-nnj.2.9","title":"IpcMessage + SocketMessage review variants (additive)","description":"Pure additive enum extension. In src/ipc.rs:9-59 add IpcMessage variants: review_share, review_join, review_create_comment, review_create_suggestion, review_accept_suggestion, review_resolve_anchor. In src/daemon.rs:62-76 add SocketMessage variants: ReviewShare, ReviewJoin, ReviewPull, ReviewStop, ReviewInbox. Handlers stub to a TODO that the Manager (0b-8) will fill. Lets the frontend stubs (0c-5) wire end-to-end without waiting for ReviewManager.","acceptance_criteria":"- IpcMessage + SocketMessage variants added with serde tagged-enum discrimination\\n- handle_message / handle_client dispatch new variants to TODO!/log handlers without crashing\\n- Frontend can post a review_share message via mock and Rust receives it cleanly\\n- Existing variants untouched","notes":"Audits show both enums are already serde-tagged - this is purely additive. Decouples 0c frontend work from 0b-8 ReviewManager scaffolding.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:53:21Z","created_by":"James Lal","updated_at":"2026-05-18T23:56:36Z","started_at":"2026-05-18T23:49:34Z","closed_at":"2026-05-18T23:56:36Z","close_reason":"Implemented; merged into collab; 27 tests pass","dependencies":[{"issue_id":"attn-nnj.2.9","depends_on_id":"attn-nnj.2","type":"parent-child","created_at":"2026-05-18T16:53:21Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.2.9","depends_on_id":"attn-nnj.2.1","type":"blocks","created_at":"2026-05-18T16:54:00Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.2.9","depends_on_id":"attn-nnj.2.2","type":"blocks","created_at":"2026-05-18T16:54:00Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":2,"comment_count":0} -{"id":"attn-nnj.12.10","title":"review/store.ts scaffold (Svelte 5 runes)","description":"Scaffold web/src/lib/review/store.ts as the global review state holder using Svelte 5 runes. Minimal API: state for panelOpen, currentRoomId, peers[], threads[] (empty), pendingOutbox (empty). Subscribers to window.__attn__.reviewEvent / reviewStatus push into this. Phase 2 issue 4.2 layers derived selectors on top (comments-on-current-snapshot, ambiguous-list, outbox-count).","acceptance_criteria":"- web/src/lib/review/store.ts exists with rune-based state and typed via 0c-4 interfaces\\n- IPC callbacks from 0c-3 push events into the store\\n- No reactivity bugs: subscribing components see updates via derived\\n- Empty initial state renders cleanly","notes":"Use Svelte 5 runes per project convention (svelte5-best-practices skill exists). Phase 2 4.2 extends this with derived selectors.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:53:20Z","created_by":"James Lal","updated_at":"2026-05-18T23:45:22Z","started_at":"2026-05-18T23:31:43Z","closed_at":"2026-05-18T23:45:22Z","close_reason":"Implemented in parallel worktrees; merged into collab","dependencies":[{"issue_id":"attn-nnj.12.10","depends_on_id":"attn-nnj.12","type":"parent-child","created_at":"2026-05-18T16:53:20Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.12.10","depends_on_id":"attn-nnj.12.3","type":"blocks","created_at":"2026-05-18T16:53:46Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.12.10","depends_on_id":"attn-nnj.12.4","type":"blocks","created_at":"2026-05-18T16:53:44Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0} -{"id":"attn-nnj.12.9","title":"keyboard.ts hooks: comment/suggestion/panel-toggle","description":"Extend KeyboardConfig in web/src/lib/keyboard.ts with optional onCommentComposer, onSuggestionComposer, onToggleReviewPanel handlers. Default bindings: Cmd+. (comment), Cmd+Shift+. (suggestion), Cmd+J (toggle panel). Existing shortcuts unaffected. Update KeyboardShortcutsDialog.svelte to surface the new bindings only when a review room is active.","acceptance_criteria":"- 3 new handler keys on KeyboardConfig (all optional)\\n- Default keybinds registered when handlers provided\\n- KeyboardShortcutsDialog conditionally shows the review section\\n- No collision with existing shortcuts (j/k scroll, g/G top-bottom, t theme, q quit, e edit, f sidebar, Cmd+W/[/], etc.)","notes":"CLAUDE.md keyboard table is authoritative for existing shortcuts.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:53:20Z","created_by":"James Lal","updated_at":"2026-05-18T23:18:28Z","started_at":"2026-05-18T23:08:49Z","closed_at":"2026-05-18T23:18:28Z","close_reason":"Implemented and merged into collab via parallel worktree agents; pnpm build + cargo check both clean post-merge","dependencies":[{"issue_id":"attn-nnj.12.9","depends_on_id":"attn-nnj.12","type":"parent-child","created_at":"2026-05-18T16:53:19Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":2,"comment_count":0} -{"id":"attn-nnj.12.8","title":"popover-anchor utility (selection -\u003e DOM rect -\u003e constrained pop)","description":"Shared utility web/src/lib/review/popover-anchor.ts: given a ProseMirror EditorView + selection range, return a DOMRect and a constrained position for a popover (clamped within viewport, flipped above/below as needed). Used by comment composer (2-4), suggestion composer (2-5), and the ambiguous anchor picker (2-7) so all three pop in the same spot.","acceptance_criteria":"- Pure TS function: (view, from, to) returns { rect, recommendedAnchor: { top, left, side: above|below } }\\n- Handles multi-line selections (uses leading rect)\\n- Unit test against a simulated EditorView\\n- No any types","notes":"Reference impl: web/src/lib/CommandPalette.svelte may have positioning logic to reuse.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:53:19Z","created_by":"James Lal","updated_at":"2026-05-18T23:18:15Z","started_at":"2026-05-18T23:08:49Z","closed_at":"2026-05-18T23:18:15Z","close_reason":"Implemented and merged into collab via parallel worktree agents; pnpm build + cargo check both clean post-merge","dependencies":[{"issue_id":"attn-nnj.12.8","depends_on_id":"attn-nnj.12","type":"parent-child","created_at":"2026-05-18T16:53:18Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":3,"comment_count":0} -{"id":"attn-nnj.12.7","title":"Theme CSS variables for review surfaces","description":"app.css today has surfaces/accent/sidebar/code-block vars but no decoration/panel vars. Add light + dark mode vars: --comment-highlight, --suggestion-bg, --suggestion-deletion, --confidence-high, --confidence-med, --confidence-low, --moved-badge-bg, --moved-badge-fg, --panel-surface, --panel-border, --peer-avatar-bg-{owner,reviewer,agent}, --stale-anchor-fg. Coordinate values via the existing OKLCH ramp.","acceptance_criteria":"- All new vars defined for both :root (light) and .dark themes\\n- Toggle via existing theme system works without flicker\\n- Variables surface in CSS, ready to be referenced by Phase 2 decoration plugin and components\\n- Use rampa-colors / theme-foundation skills if generating new ramps","notes":"Use OKLCH per project convention. Available skills: rampa-colors, theme-foundation.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:53:18Z","created_by":"James Lal","updated_at":"2026-05-18T23:18:02Z","started_at":"2026-05-18T23:08:48Z","closed_at":"2026-05-18T23:18:02Z","close_reason":"Implemented and merged into collab via parallel worktree agents; pnpm build + cargo check both clean post-merge","dependencies":[{"issue_id":"attn-nnj.12.7","depends_on_id":"attn-nnj.12","type":"parent-child","created_at":"2026-05-18T16:53:17Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":3,"comment_count":0} -{"id":"attn-nnj.12.6","title":"mock-ipc.ts: review callback shims (no scenario data)","description":"Extend mock-ipc.ts so the mocked window.ipc.postMessage understands review_* commands (logs them, echoes a fake ack via the review callbacks). No scripted scenario data yet — that's Phase 2 issue 4.1, which builds on this surface. Without this, frontend dev breaks the moment any review_* call is made.","acceptance_criteria":"- Mock dispatches review_* commands to no-op handlers; no console errors\\n- Mock invokes window.__attn__.reviewStatus/reviewEvent/reviewSnapshot/reviewAnchorResolution handlers when called via a test helper\\n- No regression in existing mocked surface","notes":"Phase 2 issue 4.1 layers scripted scenarios on top of this.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:53:17Z","created_by":"James Lal","updated_at":"2026-05-19T00:40:12Z","started_at":"2026-05-19T00:26:06Z","closed_at":"2026-05-19T00:40:12Z","close_reason":"Implemented in parallel; merged into collab; 166 tests pass + relay 35 tests pass","dependencies":[{"issue_id":"attn-nnj.12.6","depends_on_id":"attn-nnj.12","type":"parent-child","created_at":"2026-05-18T16:53:17Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.12.6","depends_on_id":"attn-nnj.12.3","type":"blocks","created_at":"2026-05-18T16:53:45Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.12.6","depends_on_id":"attn-nnj.12.4","type":"blocks","created_at":"2026-05-18T16:53:44Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.12.6","depends_on_id":"attn-nnj.12.5","type":"blocks","created_at":"2026-05-18T16:53:45Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":3,"dependent_count":1,"comment_count":0} -{"id":"attn-nnj.12.4","title":"types.ts: ReviewEvent / Anchor / ResolvedAnchor interfaces","description":"Add review domain TypeScript interfaces matching the Rust serde shapes (camelCase): ReviewEvent (with EventMeta/Body discriminated union), ReviewStatus, ReviewSnapshot, ReviewAnchorResolutionUpdate, Anchor (with PositionAnchor/QuoteAnchor/BlockAnchor/ContextAnchor/StructureAnchor sub-types), ResolvedAnchor (exact|remapped|ambiguous|stale variants), SuggestionOperation. Source of truth is data-model.md.","acceptance_criteria":"- No types (user instruction)\\n- All variants from data-model.md represented\\n- Includes JSDoc citing data-model.md section per type\\n- Roundtrips through JSON.parse(JSON.stringify(x)) without loss","notes":"data-model.md §Anchors, §Anchor Resolution, §Review Events.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:50:13Z","created_by":"James Lal","updated_at":"2026-05-18T23:17:47Z","started_at":"2026-05-18T23:08:48Z","closed_at":"2026-05-18T23:17:47Z","close_reason":"Implemented and merged into collab via parallel worktree agents; pnpm build + cargo check both clean post-merge","dependencies":[{"issue_id":"attn-nnj.12.4","depends_on_id":"attn-nnj.12","type":"parent-child","created_at":"2026-05-18T16:50:12Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":4,"comment_count":0} -{"id":"attn-nnj.12.5","title":"ipc.ts: review_* outbound command stubs","description":"Extend web/src/lib/ipc.ts with typed outbound commands: review_share, review_join, review_create_comment, review_create_suggestion, review_accept_suggestion, review_resolve_anchor. Each is a thin wrapper around window.ipc.postMessage with the typed payload. No backend yet; calls land at the Rust IpcMessage handler stub (0b-9).","acceptance_criteria":"- 6 typed exported functions in web/src/lib/ipc.ts\\n- Payloads typed via types.ts (depends on 0c-4)\\n- Real ipc.ts uses postMessage; mock-ipc.ts (depends on 0c-6) routes to local handler\\n- Functions are async and return a typed Result/Promise","notes":"Spec: data-model.md §Webview IPC Changes.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:50:13Z","created_by":"James Lal","updated_at":"2026-05-19T00:23:43Z","started_at":"2026-05-19T00:04:12Z","closed_at":"2026-05-19T00:23:43Z","close_reason":"Implemented in parallel worktrees; merged into collab; 142 tests pass","dependencies":[{"issue_id":"attn-nnj.12.5","depends_on_id":"attn-nnj.12","type":"parent-child","created_at":"2026-05-18T16:50:13Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.12.5","depends_on_id":"attn-nnj.12.4","type":"blocks","created_at":"2026-05-18T16:53:43Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.12.5","depends_on_id":"attn-nnj.2.9","type":"blocks","created_at":"2026-05-18T16:54:02Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":5,"comment_count":0} -{"id":"attn-nnj.12.2","title":"Editor.svelte: $props-injectable plugins + nodeViews","description":"Editor.svelte:137-186 buildPlugins() is monolithic; nodeViews dict at 475-484 is closed. Extend to accept optional plugins?: Plugin[] and nodeViews?: Record\u003cstring, NodeViewConstructor\u003e via Svelte 5 , appended after built-ins. Enables Phase 2 decorations plugin and any future collab plugins without re-hardcoding.","acceptance_criteria":"- extended with plugins?, nodeViews?\\n- Built-in plugins still loaded first; injected plugins appended\\n- Existing callers unaffected (props are optional)\\n- Stub test: passing an empty decoration plugin must not regress existing nodeViews (math, mermaid, tables, code-highlight)","notes":"Model after the existing prosemirror/code-highlight.ts factory shape.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:50:11Z","created_by":"James Lal","updated_at":"2026-05-18T23:17:33Z","started_at":"2026-05-18T23:08:48Z","closed_at":"2026-05-18T23:17:33Z","close_reason":"Implemented and merged into collab via parallel worktree agents; pnpm build + cargo check both clean post-merge","dependencies":[{"issue_id":"attn-nnj.12.2","depends_on_id":"attn-nnj.12","type":"parent-child","created_at":"2026-05-18T16:50:10Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"attn-nnj.12.3","title":"window.__attn__ bridge: review callback registration","description":"App.svelte:1003-1019 registers setContent/updateContent/font scale only. Extend with no-op stubs for reviewStatus(payload), reviewEvent(payload), reviewSnapshot(snapshot), reviewAnchorResolution(update). Real handlers wire to the review store later (Phase 0c-10 / Phase 2).","acceptance_criteria":"- window.__attn__ exposes the 4 new methods, each typed via types.ts (depends on 0c-4)\\n- Default impl: console.debug only, so Rust can already evaluate_script without errors\\n- Type definitions in web/src/vite-env.d.ts (or wherever Window augmentation lives) updated","notes":"Spec: data-model.md §Webview IPC Changes.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:50:11Z","created_by":"James Lal","updated_at":"2026-05-18T23:29:47Z","started_at":"2026-05-18T23:21:22Z","closed_at":"2026-05-18T23:29:47Z","close_reason":"Implemented via parallel worktree agents; merged into collab","dependencies":[{"issue_id":"attn-nnj.12.3","depends_on_id":"attn-nnj.12","type":"parent-child","created_at":"2026-05-18T16:50:11Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.12.3","depends_on_id":"attn-nnj.12.4","type":"blocks","created_at":"2026-05-18T16:53:42Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} -{"id":"attn-nnj.12.1","title":"3-column layout: right-rail slot in App.svelte","description":"App.svelte:1352-1373 SidebarInset is 2-column today (sidebar + editor). Extend to 3-column with a named right-rail slot for the future ReviewPanel. Slot collapses when no review room is active (no chrome shift).","acceptance_criteria":"- App.svelte exposes a right-rail snippet/slot that mounts a placeholder div when no review session\\n- Layout uses CSS flex/grid, not absolute positioning\\n- Sidebar toggle (existing) still works; right-rail toggles independently via Cmd+J (placeholder shortcut)\\n- No visual regression with attn ./planning/ baseline","notes":"Touches App.svelte mainContent snippet. Don't render ReviewPanel yet — just the slot.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:50:09Z","created_by":"James Lal","updated_at":"2026-05-18T23:17:18Z","started_at":"2026-05-18T23:08:47Z","closed_at":"2026-05-18T23:17:18Z","close_reason":"Implemented and merged into collab via parallel worktree agents; pnpm build + cargo check both clean post-merge","dependencies":[{"issue_id":"attn-nnj.12.1","depends_on_id":"attn-nnj.12","type":"parent-child","created_at":"2026-05-18T16:50:09Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":6,"comment_count":0} -{"id":"attn-nnj.12","title":"Phase 0c: UI/IPC plumbing","description":"Frontend + IPC infrastructure that has to land before Phase 2 features can drop in cleanly. Pure plumbing — no review UI rendered yet. Audit grounded: App.svelte:1352-1373 has only 2-column SidebarInset; Editor.svelte:137-186 hardcodes 8 plugins; App.svelte:1003-1019 window.__attn__ bridge registers no review callbacks; mock-ipc.ts has no review surface; ipc.ts has no review_* commands; types.ts has no ReviewEvent shapes; theme has no decoration vars.","notes":"Sequencing: blocks Phase 2 features. Per user direction, all crypto stays in Rust — frontend only handles plaintext ReviewEvent objects.","status":"closed","priority":1,"issue_type":"epic","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:50:08Z","created_by":"James Lal","updated_at":"2026-05-19T17:02:10Z","closed_at":"2026-05-19T17:02:10Z","close_reason":"All children closed","dependencies":[{"issue_id":"attn-nnj.12","depends_on_id":"attn-nnj","type":"parent-child","created_at":"2026-05-18T16:50:07Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj.11.5","title":"Security review pass","description":"After Phase 3a / 3b / 4 land and before any public release: full security review of crypto envelope handling. Specifically check: signature verification ordering (decrypt under AEAD FIRST, then verify the plaintext signature — never the reverse, which would let an attacker substitute a verified ciphertext); AAD binding completeness on every encrypt/decrypt call site (no naked AEAD calls); PoW token replay window (the 5-minute window from decision #6 actually enforced); ownerSigningKey TOFU correctness (first signature wins, subsequent different keys rejected); browser fragment-stripping race window (no observer can see the fragment between page load and replaceState). Use the security-review skill if available.","acceptance_criteria":"planning/collab/security-review.md created with findings + severity per checked area.\nEach of the five focus areas (decrypt-then-verify order, AAD binding, PoW replay window, owner TOFU, browser fragment race) has a section with: pass/fail, code references, evidence.\nAny HIGH or CRITICAL findings have follow-up bd issues created and linked.\nReview covers Rust client AND TS browser client AND relay worker — all three speak crypto.\nSign-off recorded in the doc with date and reviewer.","notes":"Specs: planning/collab/crypto-spec.md (entire), planning/collab/relay-spec.md §Anti-Abuse + §Admission, planning/collab/amendments.md §Decision #6 + §Decision #13 + §Owner identity. Files: planning/collab/security-review.md (new), src/review/crypto.rs, relay/src/*, web/src/lib/review/*. Skills: security-review skill is available — invoke it explicitly. Schedule AFTER Phase 4 lands (so all crypto codepaths exist) but BEFORE public release.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:35:24Z","created_by":"James Lal","updated_at":"2026-05-19T16:25:30Z","started_at":"2026-05-19T16:07:55Z","closed_at":"2026-05-19T16:25:30Z","close_reason":"Round 19: implemented; merged; build clean","dependencies":[{"issue_id":"attn-nnj.11.5","depends_on_id":"attn-nnj.11","type":"parent-child","created_at":"2026-05-18T16:35:24Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.11.5","depends_on_id":"attn-nnj.5.15","type":"blocks","created_at":"2026-05-18T16:38:34Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.11.5","depends_on_id":"attn-nnj.6.7","type":"blocks","created_at":"2026-05-18T16:38:35Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.11.5","depends_on_id":"attn-nnj.7.7","type":"blocks","created_at":"2026-05-18T16:38:35Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.11.5","depends_on_id":"attn-nnj.8.6","type":"blocks","created_at":"2026-05-18T16:38:36Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":4,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj.11.4","title":"E2E test scaffolding for review surfaces","description":"Extend scripts/test-e2e.sh with a review/ test suite that boots the daemon under task dev with a mock-IPC scenario file pre-loaded, then uses --eval / --query / --wait-for to assert review-panel state (comment count, anchor status, suggestion list, etc.). Lays groundwork for Phase 2's 'comment survives owner edits' demo AND Phase 5's apply integration test. Reuses the daemon's existing automation flags so we don't need a separate Playwright runner.","acceptance_criteria":"scripts/test-e2e.sh has a new review/ section that loads a mock-IPC fixture into web/src/lib/mock-ipc.ts via env var or query param.\nHelpers: wait_for_review_state, assert_comment_count, assert_anchor_status etc. (bash functions) that wrap --query and --wait-for.\nA scaffold test boots the daemon with a fixture containing 2 mock comments and asserts both render in the panel.\nTest passes locally on macOS without requiring a relay or webrtc-rs (mock-IPC drives the frontend in isolation).\nScreenshot captured to /tmp/attn-e2e-screenshots/review-*.png for visual review.\nPattern documented so Phase 2 + Phase 5 authors can extend without reinventing.","notes":"Specs: planning/collab/amendments.md §existing automation flags affect ReviewManager design, §Mock IPC must be extended. Files: scripts/test-e2e.sh, tests/fixtures/review/ (new), web/src/lib/mock-ipc.ts (extend). Mock-IPC extension is described in amendments.md §Mock IPC — coordinate so this scaffolding lands alongside or after that extension. window.__attn__.reviewState() should be a stable shape that this test depends on.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:35:09Z","created_by":"James Lal","updated_at":"2026-05-18T23:41:35Z","started_at":"2026-05-18T23:21:24Z","closed_at":"2026-05-18T23:41:35Z","close_reason":"Re-close after DB restore: implemented; merged into collab; 8 PASS / 5 PEND / 0 FAIL","dependencies":[{"issue_id":"attn-nnj.11.4","depends_on_id":"attn-nnj.11","type":"parent-child","created_at":"2026-05-18T16:35:09Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":2,"comment_count":0} -{"id":"attn-nnj.6.7","title":"Conformance integration tests against Miniflare","description":"Run the relay conformance corpus from Phase 3a issue 14 against the Rust client. CI script: boot 'wrangler dev --local' (Miniflare) from the relay/ package, then run cargo test --features mailbox-integration on the Rust crate. Covers happy path and every error code the relay returns so the Rust client stays in lock-step with the relay's wire contract.","acceptance_criteria":"- scripts/test-mailbox-integration.sh (or task target): starts wrangler dev --local in background, waits for /health, runs cargo test --features mailbox-integration -- --test-threads=1, tears down\n- Rust tests under src/review/transport/tests/ (or tests/ integration crate) load relay/test/conformance/cases.json via serde and execute each case via the real MailboxTransport\n- Coverage matches Phase 3a issue 14 corpus: room lifecycle, WS backfill (full / mid / 4005), all caps + batch=32, owner-only ops, PoW failures, hibernation roundtrip, rate limits, longSession clamping\n- CI integration: GitHub Actions job runs this script and fails on any case mismatch\n- Tests fail fast on any new relay-side error code missing from the Rust mapping (helps catch wire drift early)","notes":"Spec: planning/collab/relay-spec.md §Test Plan (687-705). Consumes conformance corpus from 3a-14 — make sure the JSON schema is serde-friendly. Blocked by 3b-6 since bootstrap is the precondition for every other case.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:34:58Z","created_by":"James Lal","updated_at":"2026-05-19T13:50:28Z","started_at":"2026-05-19T04:31:04Z","closed_at":"2026-05-19T13:50:28Z","close_reason":"Implemented (partially for 5.14 — scaffold + skip-on-empty, follow-up to fill cases.json); 372 Rust + 184 relay tests pass","dependencies":[{"issue_id":"attn-nnj.6.7","depends_on_id":"attn-nnj.2.10","type":"blocks","created_at":"2026-05-18T16:58:49Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.6.7","depends_on_id":"attn-nnj.5.14","type":"blocks","created_at":"2026-05-18T16:35:59Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.6.7","depends_on_id":"attn-nnj.6","type":"parent-child","created_at":"2026-05-18T16:34:57Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.6.7","depends_on_id":"attn-nnj.6.1","type":"blocks","created_at":"2026-05-18T16:36:09Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.6.7","depends_on_id":"attn-nnj.6.6","type":"blocks","created_at":"2026-05-18T16:36:12Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":4,"dependent_count":1,"comment_count":0} -{"id":"attn-nnj.6.6","title":"Device + room bootstrap flow (Share + Join commands)","description":"Wire ReviewManager into ReviewCommand::Share and ReviewCommand::Join. Share: derive room keys (event/snapshot/signal/admission per crypto-spec §Key Derivation), POST /v2/rooms/:roomId with ownerSigningKey + clamped policy, POST /devices with kind=owner and self-signature, populate the local /devices cache, emit ReviewUpdate::RoomCreated. Join: parse the invite URL (crypto-spec §Invite URLs), derive the same per-kind keys, POST /devices with kind=reviewer or agent, GET /devices to populate the verification cache, emit ReviewUpdate::ParticipantJoined.","acceptance_criteria":"- Share flow: generate room secret → derive event/snapshot/signal/admission keys + ownerSigningKey → POST /v2/rooms/:roomId (mint PoW from pool, send admissionKey, ownerSigningKey, clamped policy) → POST /devices (kind=owner, selfSignature over canonical device bytes) → store room state under ~/.attn/reviews/rooms/\u003croomId\u003e/ → emit ReviewUpdate::RoomCreated{invite_url}\n- Join flow: parse invite URL → derive keys identically → POST /devices (kind=reviewer|agent per CLI flag) → GET /devices → cache the device roster (publicSigningKey by deviceId) → emit ReviewUpdate::ParticipantJoined{room, peers}\n- Owner-key handling: ownerSigningKey is generated client-side at Share, stored locally as private key, public half sent in the create body; never written outside ~/.attn (file perms 600)\n- Both flows install the WS client (issue 3b-3) and outbox processor (3b-2) for the room after bootstrap completes\n- Bootstrap errors map: room create 409 ATTN_ROOM_EXISTS_DIFFERENT_POLICY → ReviewUpdate::ShareConflict; device 409 ATTN_DEVICE_KEY_CHANGED → ReviewUpdate::JoinKeyConflict\n- Tests: Share against Miniflare, Join against same Miniflare instance, key derivation determinism, owner-key mismatch on rejoin attempt, peer roster cached and refreshed","notes":"Spec: planning/collab/crypto-spec.md §Key Derivation (39-77), §Invite URLs (59-77), §Signing-Key Publication (344-403). planning/collab/relay-spec.md §POST /v2/rooms/:roomId (114-167), §POST /v2/rooms/:roomId/devices (169-217). Consumes issue 3b-3 (WS client) and 3b-2 (outbox) once bootstrap completes.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:34:57Z","created_by":"James Lal","updated_at":"2026-05-19T04:07:48Z","started_at":"2026-05-19T03:41:59Z","closed_at":"2026-05-19T04:07:48Z","close_reason":"Implemented; merged into collab; 346 Rust + 168 relay tests pass","dependencies":[{"issue_id":"attn-nnj.6.6","depends_on_id":"attn-nnj.11.1","type":"blocks","created_at":"2026-05-18T16:38:27Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.6.6","depends_on_id":"attn-nnj.5.5","type":"blocks","created_at":"2026-05-18T16:38:27Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.6.6","depends_on_id":"attn-nnj.5.6","type":"blocks","created_at":"2026-05-18T16:38:26Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.6.6","depends_on_id":"attn-nnj.6","type":"parent-child","created_at":"2026-05-18T16:34:56Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.6.6","depends_on_id":"attn-nnj.6.1","type":"blocks","created_at":"2026-05-18T16:36:09Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.6.6","depends_on_id":"attn-nnj.6.3","type":"blocks","created_at":"2026-05-18T16:36:11Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":5,"dependent_count":2,"comment_count":0} -{"id":"attn-nnj.6.5","title":"Cursor management + 4005 cursor-too-old recovery","description":"Persist last_seen_seq per room in ~/.attn/reviews/rooms/\u003croomId\u003e/cursors.json. Update after every successfully imported envelope (issue 3b-4 wires the actual write). On WS error{ATTN_CURSOR_TOO_OLD, resyncFromSeq}: discard the current cursor, attempt a P2P snapshot request from a peer device (defer this branch to Phase 4 if WebRTC transport isn't yet wired; in the meantime fall back to re-subscribe from resyncFromSeq, accepting the pre-resync history loss explicitly via ReviewUpdate::HistoryGap).","acceptance_criteria":"- ~/.attn/reviews/rooms/\u003croomId\u003e/cursors.json holds {last_seen_seq, oldest_retained_seq, updated_at}; atomic write via temp+rename\n- Read on startup; passed to issue 3b-3 WS client for the initial subscribe.after\n- On TransportError::CursorTooOld{resync_from_seq}: log warning, emit ReviewUpdate::HistoryGap{lost_from, lost_to}, set last_seen_seq=resync_from_seq, write cursors.json, reconnect with subscribe{after: resync_from_seq}\n- Stub for P2P snapshot recovery: a clear TODO('phase-4 webrtc') with the signature of a future async fn request_snapshot_from_peer(peer_device_id) so Phase 4 can plug in\n- Tests: cursor persistence across restart, 4005 fallback to resync (no P2P), atomic write under crash simulation, HistoryGap emitted exactly once per 4005","notes":"Spec: planning/collab/relay-spec.md §WebSocket Protocol (372-422, error frame) and §Close Codes (4005). amendments.md decision #5 (no GET /envelopes backfill means 4005 is the only way history is exposed). Snapshot-from-peer plumbing depends on Phase 4 WebRTC and is intentionally stubbed here.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:34:56Z","created_by":"James Lal","updated_at":"2026-05-19T04:30:01Z","started_at":"2026-05-19T04:09:04Z","closed_at":"2026-05-19T04:30:01Z","close_reason":"Implemented; merged","dependencies":[{"issue_id":"attn-nnj.6.5","depends_on_id":"attn-nnj.6","type":"parent-child","created_at":"2026-05-18T16:34:55Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.6.5","depends_on_id":"attn-nnj.6.1","type":"blocks","created_at":"2026-05-18T16:36:08Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.6.5","depends_on_id":"attn-nnj.6.3","type":"blocks","created_at":"2026-05-18T16:36:11Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj.6.4","title":"Inbound envelope pipeline (decrypt + verify + import)","description":"Per ServerFrame::Envelope from the WS stream: look up the per-kind key (eventKey | snapshotKey | signalKey) from room state, AES-256-GCM decrypt, verify Ed25519 signature against the deviceId's publicSigningKey from the cached /devices roster, dedupe by EventId (against ~/.attn/reviews/rooms/\u003croomId\u003e/events.jsonl ledger), append to events.jsonl, update the persistent sync cursor. On success emit ReviewUpdate::EventImported into the ReviewManager event loop.","acceptance_criteria":"- For each ServerFrame::Envelope: select key by envelope.kind; decrypt (AES-256-GCM, nonce per crypto-spec §Nonce Discipline) — failure → ReviewUpdate::DecryptFailed and continue (do not crash, do not advance cursor)\n- Ed25519 verify plaintext signature against device.publicSigningKey from cached /devices snapshot; signature failure → drop + ReviewUpdate::SignatureInvalid (do not advance cursor for that envelope)\n- Dedupe by EventId: if already present in events.jsonl skip the append (but still advance the seq cursor)\n- Append the canonical event bytes to events.jsonl (append-only, fsync per batch)\n- Update last_seen_seq AFTER successful append (so a crash mid-write replays cleanly)\n- Emit ReviewUpdate::EventImported{event} to the consumer (Phase 4 UI / ReviewManager)\n- Cached /devices roster auto-refreshed when an envelope arrives signed by an unknown deviceId (issue GET /devices and retry verify once)\n- Tests: happy path, wrong key, wrong sig, duplicate EventId, unknown device triggers refresh + verify","notes":"Spec: planning/collab/crypto-spec.md §Envelope Encryption (79-115), §Nonce Discipline (108-115), §Signatures (199-258). planning/collab/data-model.md §Transport Model. /devices cache is populated by issue 3b-6 bootstrap and refreshed lazily.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:34:55Z","created_by":"James Lal","updated_at":"2026-05-19T04:06:29Z","started_at":"2026-05-19T03:23:02Z","closed_at":"2026-05-19T04:06:29Z","close_reason":"Implemented; merged into collab; 346 Rust + 168 relay tests pass","dependencies":[{"issue_id":"attn-nnj.6.4","depends_on_id":"attn-nnj.6","type":"parent-child","created_at":"2026-05-18T16:34:55Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.6.4","depends_on_id":"attn-nnj.6.1","type":"blocks","created_at":"2026-05-18T16:36:08Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} -{"id":"attn-nnj.11.3","title":"Binary-size verification CI gate","description":"Add scripts/check-binary-size.sh that runs after cargo build --release and asserts the .app bundle (or stripped binary if not bundled) is under 25 MiB. Wire into CI so Phase 4 (webrtc-rs) cannot regress past the budget without an explicit waiver. This is the safety net that prevents future PRs from silently breaking the decision #1 tradeoff.","acceptance_criteria":"scripts/check-binary-size.sh exists; runs du on the appropriate artifact (.app bundle preferred, falls back to stripped binary).\nExits non-zero with a clear message if size \u003e 25 MiB; prints the size + the budget either way.\nCI workflow (.github/workflows/*.yml) invokes it on every PR that touches Cargo.toml or src/**.\nFailure can be waived only by setting ATTN_SIZE_BUDGET_WAIVER=1 in CI env (or equivalent) with a comment in the PR.\nDocumented in CLAUDE.md or RELEASE_SETUP.md so future contributors know the rule.","notes":"Specs: planning/collab/amendments.md §Decision #1, §Phase 4. Files: scripts/check-binary-size.sh (new), .github/workflows/*.yml. Use the same release/bundle output the existing scripts/build.sh produces. The 25 MiB number comes directly from Decision #1's tradeoff statement. Blocks Phase 4 work in the sense that Phase 4 issue 1 should consume this gate.","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:34:54Z","created_by":"James Lal","updated_at":"2026-05-18T23:45:09Z","closed_at":"2026-05-18T23:45:09Z","close_reason":"Implemented in parallel worktrees; merged into collab","dependencies":[{"issue_id":"attn-nnj.11.3","depends_on_id":"attn-nnj.11","type":"parent-child","created_at":"2026-05-18T16:34:54Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj.11.1","title":"attn://review/... custom-scheme handler","description":"In src/main.rs custom protocol handler, route attn://review/\u003croomId\u003e#key=... to a new SocketMessage::ReviewJoin{invite} BEFORE falling through to the existing attn://localhost/... file-serving path. Reject attn://localhost/review/... explicitly (it's reserved per amendments.md). Without this, clicking an invite URL on macOS won't work and Phase 4/6 invite handling is blocked.","acceptance_criteria":"src/main.rs custom protocol handler matches attn://review/\u003c...\u003e before the existing file-serving route.\nMatched invites become a SocketMessage::ReviewJoin{invite: String} dispatched to the daemon.\nattn://localhost/review/... returns a 400-equivalent (refused with a clear log line) — reserved path collision.\nNon-review attn://localhost/* paths continue to serve files as today (existing behavior unchanged).\nUnit test or integration test (via --eval) confirms a synthetic attn://review/abc#key=xyz hits the ReviewJoin handler.\nSocketMessage::ReviewJoin variant defined and wired through daemon.rs.","notes":"Specs: planning/collab/amendments.md §Custom attn:// scheme handler. Files: src/main.rs (~1207 lines — locate the existing custom_protocol registration), src/daemon.rs (new SocketMessage variant), src/ipc.rs (potentially). Critical detail: the route match must come BEFORE the fallthrough, not after. Blocks Phase 4 (issue 7 e2e test needs to click an invite) and Phase 6 (browser invite parse logic on the native side too).","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:34:30Z","created_by":"James Lal","updated_at":"2026-05-18T23:33:30Z","started_at":"2026-05-18T23:21:23Z","closed_at":"2026-05-18T23:33:30Z","close_reason":"Implemented; merged into collab; cargo check + cargo test clean","dependencies":[{"issue_id":"attn-nnj.11.1","depends_on_id":"attn-nnj.11","type":"parent-child","created_at":"2026-05-18T16:34:30Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"attn-nnj.9.7","title":"CLI subcommands for local agents","description":"Full set of attn review subcommands so a local agent (e.g., a coding assistant running on the owner's machine) can drive the daemon: share \u003cpath\u003e [--mode live|async|hybrid] [--ttl 7d]; join \u003cinvite\u003e; inbox [--json]; submit-comment \u003cfile\u003e; submit-suggestion \u003cfile\u003e; pull; stop. Each maps to a SocketMessage variant per data-model.md §Daemon Socket Commands. Local CLI uses owner's daemon identity by default (per amendments.md §Agent CLI key handling) — distinct from remote agents in Phase 6 issue 6.","acceptance_criteria":"Each subcommand parses via clap and dispatches a typed SocketMessage to the running daemon.\nshare creates a new room with the given policy fields, prints the invite URL (attn://review/...) for the owner to share.\njoin takes either an attn://review/... URL or a raw invite string; daemon joins the room and starts receiving envelopes.\ninbox lists pending review actions on the owner's open rooms; --json emits structured output for agent consumption.\nsubmit-comment / submit-suggestion take a JSON file (or stdin) describing the anchor + content; daemon adds to outbox with the owner's identity by default.\npull manually triggers a relay catchup. stop ends a room (owner only — requires owner signature).\nCLI help (attn review --help) lists all subcommands with clear examples.","notes":"Specs: planning/collab/amendments.md §Agent CLI key handling, planning/collab/data-model.md §Daemon Socket Commands. Files: src/cli/review.rs (new), src/daemon.rs (new SocketMessage variants). Owner-identity default is the load-bearing detail vs Phase 6 issue 6 (remote agent has its own key). --as-agent \u003cname\u003e override comes from Phase 6 issue 6's implementation but the flag itself can be wired here.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:34:15Z","created_by":"James Lal","updated_at":"2026-05-19T15:06:14Z","started_at":"2026-05-19T04:31:06Z","closed_at":"2026-05-19T15:06:14Z","close_reason":"Implemented; merged; 412 Rust + 213 relay tests pass (6 conformance scenarios deferred to 5.16)","dependencies":[{"issue_id":"attn-nnj.9.7","depends_on_id":"attn-nnj.2.8","type":"blocks","created_at":"2026-05-18T16:39:10Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.9.7","depends_on_id":"attn-nnj.9","type":"parent-child","created_at":"2026-05-18T16:34:14Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj.6.3","title":"WebSocket client + reconnect with backoff","description":"Tokio-tungstenite (or async-tungstenite) WebSocket client. Subprotocol header: 'attn.v2, \u003cbase64url admission HMAC\u003e'. Handles server ping by responding with pong inside 60s. On disconnect: exponential backoff reconnect (200ms → 30s cap with jitter), replay subscribe{after: last_seen_seq} on each reconnect. Maps close codes 4000/4001/4002/4005 to typed transport errors so the ReviewManager can surface them as ReviewUpdate variants.","acceptance_criteria":"- Connects with subprotocol 'attn.v2' and admission HMAC piggybacked (matches issue 3a-11 handshake)\n- After connect, sends subscribe{after: last_seen_seq} and starts receiving ServerFrame items as a Stream\n- Ping handler: replies with pong within 60s; if no server ping in 90s, force reconnect (defensive)\n- Disconnect handler: exponential backoff (200ms, 400ms, 800ms, ... cap 30s) with ±25% jitter\n- Close codes mapped: 4000→TransportError::AdmissionInvalid (non-retryable), 4001→RoomDeleted (terminal), 4002→RoomExpired (terminal), 4005→CursorTooOld{resync_from_seq} (handled by issue 3b-5), 1001→Timeout (retryable)\n- Replays subscribe{after: last_seen_seq} on every reconnect so the stream resumes from the persistent cursor\n- Tests: happy-path frame roundtrip (against Miniflare from 3a-11), ping/pong, reconnect after server-initiated close, 4005 surfacing, 4001/4002 terminal","notes":"Spec: planning/collab/relay-spec.md §WebSocket Protocol (362-459) and §Close Codes (423-431). Implements the client side of issue 3a-11. Cursor persistence lives in 3b-5.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:33:51Z","created_by":"James Lal","updated_at":"2026-05-19T04:07:35Z","started_at":"2026-05-19T03:41:59Z","closed_at":"2026-05-19T04:07:35Z","close_reason":"Implemented; merged into collab; 346 Rust + 168 relay tests pass","dependencies":[{"issue_id":"attn-nnj.6.3","depends_on_id":"attn-nnj.5.11","type":"blocks","created_at":"2026-05-18T16:38:25Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.6.3","depends_on_id":"attn-nnj.6","type":"parent-child","created_at":"2026-05-18T16:33:50Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.6.3","depends_on_id":"attn-nnj.6.1","type":"blocks","created_at":"2026-05-18T16:36:07Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":2,"comment_count":0} -{"id":"attn-nnj.6.2","title":"Outbox processor (~/.attn/reviews/rooms/\u003croomId\u003e/outbox.jsonl)","description":"Persistent outbox that survives crashes. Each line in outbox.jsonl is a queued MailboxEnvelope plus state metadata (attempts, last_error, status: pending|in_flight|sent). Processor reads pending entries, mints a PoW token from the Phase 0a token pool, batches up to 32, POSTs to /v2/rooms/:roomId/envelopes, and on success marks them sent (compaction sweeps sent lines out periodically). Backoff on 429/507/ATTN_POW_*. EnvelopeId is deterministic (see crypto-spec) so retries are server-side idempotent.","acceptance_criteria":"- ~/.attn/reviews/rooms/\u003croomId\u003e/outbox.jsonl created on first send; append-only with periodic compaction of sent entries\n- Each line: serde JSON {envelope, attempts, last_error?, status, queued_at}\n- Processor task per room: read pending → mint PoW (use Phase 0a token pool) → batch up to 32 → POST → on 2xx mark sent\n- Backoff: 429 honors Retry-After header; 507 ATTN_ROOM_EVENT_CAP/STORAGE_FULL → exponential backoff with cap (e.g., 1s, 2s, 4s, 8s, 30s); ATTN_POW_* → mint fresh token immediately (token reuse failure)\n- Crash safety: status:in_flight entries are reset to pending on startup so they re-send; deterministic envelopeId guarantees server dedupe\n- Emits ReviewUpdate::EnvelopeSent on success and ReviewUpdate::SendFailed{retryable} on terminal failure\n- Tests: happy path, batch=32 boundary, retry on 429, retry on 507, PoW-replay recovery, crash mid-send dedupe","notes":"Spec: planning/collab/relay-spec.md §POST /v2/rooms/:roomId/envelopes (218-269). amendments.md decision #7 (batch cap 32, single PoW per request). crypto-spec.md §EnvelopeId (283-301) for deterministic ID. Phase 0a token pool is a prerequisite reference (assume exists or stub if not yet implemented).","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:33:50Z","created_by":"James Lal","updated_at":"2026-05-19T02:29:35Z","started_at":"2026-05-19T01:57:30Z","closed_at":"2026-05-19T02:29:35Z","close_reason":"Implemented (3.8+round-10 retries); merged into collab; 267 Rust + 144 relay tests pass","dependencies":[{"issue_id":"attn-nnj.6.2","depends_on_id":"attn-nnj.1.7","type":"blocks","created_at":"2026-05-18T16:38:24Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.6.2","depends_on_id":"attn-nnj.5.7","type":"blocks","created_at":"2026-05-18T16:38:25Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.6.2","depends_on_id":"attn-nnj.6","type":"parent-child","created_at":"2026-05-18T16:33:49Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.6.2","depends_on_id":"attn-nnj.6.1","type":"blocks","created_at":"2026-05-18T16:36:06Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":3,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj.6.1","title":"src/review/transport.rs scaffold + Transport trait","description":"Define the transport abstraction that all relay variants implement. src/review/ does not yet exist — create the module tree. Trait: Transport { async connect(roomId, deviceId) -\u003e Result\u003c()\u003e; async send_envelopes(Vec\u003cMailboxEnvelope\u003e) -\u003e Result\u003cSendReceipt\u003e; subscribe(after_seq: u64) -\u003e impl Stream\u003cItem=Result\u003cServerFrame\u003e\u003e }. Two implementations are planned: MailboxTransport (this phase, issues 3b-2..3b-6) and WebRTCTransport (Phase 4). The frontend never sees raw transport — only typed ReviewUpdate variants emitted by ReviewManager after decrypt+verify+import.","acceptance_criteria":"- src/review/mod.rs added to src/lib.rs (or main module tree)\n- src/review/transport.rs defines Transport trait with the signatures above\n- Concrete types: MailboxEnvelope (canonical bytes + metadata), SendReceipt {envelope_id, server_seq}, ServerFrame enum (Hello, Envelope, Presence, PolicyChanged, Ping, Error) matching the WS protocol from relay-spec.md\n- TransportError enum maps relay error codes (ATTN_ADMISSION_INVALID, ATTN_POW_*, ATTN_CURSOR_TOO_OLD, ATTN_ROOM_EXPIRED, ATTN_ROOM_DELETED, ATTN_RATE_LIMITED, ...) to typed Rust errors with retryable/non-retryable classification\n- A NoopTransport test impl for use in unit tests of ReviewManager\n- cargo check + cargo clippy clean (no any-equivalent — use proper types per repo conventions)\n- No frontend exposure: the trait lives behind ReviewManager, which is what emits ReviewUpdate","notes":"Spec: planning/collab/relay-spec.md §WebSocket Protocol (362-459) defines the frame shapes. planning/collab/data-model.md §Transport Model. Code conventions: TypeScript repo for the relay, but this Rust crate avoids 'any'/dyn-without-bounds equivalents. No backwards-compat shim with any prior transport — this is the new module from scratch.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:33:49Z","created_by":"James Lal","updated_at":"2026-05-19T01:56:27Z","started_at":"2026-05-19T01:36:54Z","closed_at":"2026-05-19T01:56:27Z","close_reason":"Implemented; merged into collab; 254 Rust tests + 130 relay tests pass; corpus replay green","dependencies":[{"issue_id":"attn-nnj.6.1","depends_on_id":"attn-nnj.1.9","type":"blocks","created_at":"2026-05-18T16:38:24Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.6.1","depends_on_id":"attn-nnj.6","type":"parent-child","created_at":"2026-05-18T16:33:48Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":7,"comment_count":0} -{"id":"attn-nnj.9.5","title":"CORS + browser allowlist on the relay","description":"Confirm relay-spec.md §Browser Considerations is implemented in the worker: Origin allowlist on WS upgrade requests (reject non-allowlisted with 403), Access-Control-Allow-Origin headers on HTTP responses (POST /envelopes etc.) pulled from ALLOWED_BROWSER_ORIGINS env var. Test from a non-allowlisted origin and confirm 403. Without this, the browser client cannot function due to same-origin policy.","acceptance_criteria":"WS upgrade handler reads ALLOWED_BROWSER_ORIGINS env var and rejects upgrades with non-allowlisted Origin via HTTP 403.\nHTTP endpoints (POST /envelopes, POST /devices, POST /acks, POST /blobs) emit Access-Control-Allow-Origin matching the request Origin if in allowlist.\nOPTIONS preflight is handled with the right Access-Control-Allow-Methods + Access-Control-Allow-Headers (including Attn-PoW, Attn-Owner-Signature).\nIntegration test in the conformance corpus: request from https://evil.example → 403; request from https://attn.dev → 200/204.\nALLOWED_BROWSER_ORIGINS documented in relay deployment notes.","notes":"Specs: planning/collab/relay-spec.md §Browser Considerations. Files: relay/src/cors.ts (or wherever the worker entry middleware lives). Origin must be a strict match, not a prefix or wildcard. Note: the Rust client doesn't send an Origin header, so a missing Origin should be permitted (Rust path) but a present-but-not-allowlisted Origin should be denied.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:33:45Z","created_by":"James Lal","updated_at":"2026-05-19T16:56:25Z","started_at":"2026-05-19T16:26:12Z","closed_at":"2026-05-19T16:56:25Z","close_reason":"Round 20: implemented; merged; build clean","dependencies":[{"issue_id":"attn-nnj.9.5","depends_on_id":"attn-nnj.5.7","type":"blocks","created_at":"2026-05-18T16:39:11Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.9.5","depends_on_id":"attn-nnj.9","type":"parent-child","created_at":"2026-05-18T16:33:44Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} -{"id":"attn-nnj.9.4","title":"Browser review UI (subset of native UI)","description":"Reuse Phase 2 Svelte review components where possible — the snapshot viewer, comment threads, suggestion decorations. Reviewer-only surface: NO share button (browser cannot own a room), NO apply UI (browser cannot mutate the owner's working copy). Browser CAN add comments and suggestions; those get added to an in-memory outbox that uploads via POST /envelopes from the browser context (with admission HMAC + hashcash PoW).","acceptance_criteria":"Browser /review/:roomId page renders the latest snapshot and existing comment/suggestion threads.\nReviewer can add a comment anchored to a selection; comment uploads via POST /envelopes (hashcash mined off-thread in a Web Worker per amendments.md).\nReviewer can add a suggestion (text replacement) anchored to a range; same upload path.\nNO share button, NO apply button, NO 'create room' affordance in the browser UI.\nShared Svelte components from Phase 2 render identically to native (within visual-diff tolerance).\nBrowser-specific empty/error states for: invalid invite, expired room (close 4001), stale cursor (close 4005), failed admission (403).","notes":"Specs: planning/collab/amendments.md §Phase 6, planning/collab/relay-spec.md §Browser Considerations. Files: web/src/routes/review/[roomId]/ (new) + reuse web/src/lib/review/* components from Phase 2. Depends on: invite parsing (Phase 6 issue 2), WS client (Phase 6 issue 3), CORS configured (Phase 6 issue 5). PoW miner from Phase 0a should already exist as a Web Worker — reuse it.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:33:31Z","created_by":"James Lal","updated_at":"2026-05-19T17:57:56Z","started_at":"2026-05-19T17:37:11Z","closed_at":"2026-05-19T17:57:56Z","close_reason":"Round 22 (final): implemented; merged","dependencies":[{"issue_id":"attn-nnj.9.4","depends_on_id":"attn-nnj.9","type":"parent-child","created_at":"2026-05-18T16:33:31Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.9.4","depends_on_id":"attn-nnj.9.2","type":"blocks","created_at":"2026-05-18T16:36:14Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.9.4","depends_on_id":"attn-nnj.9.3","type":"blocks","created_at":"2026-05-18T16:36:15Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.9.4","depends_on_id":"attn-nnj.9.5","type":"blocks","created_at":"2026-05-18T16:36:16Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":3,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj.9.3","title":"Browser WebSocket client + envelope import","description":"TS counterpart of the Phase 3b WebSocket client: connect to the relay's wss://.../v2/rooms/:roomId/ws, attach admission HMAC piggyback per relay-spec, receive hello + envelope frames, decrypt under eventKey/snapshotKey, signature-verify, import into an in-memory replica of the event log and snapshot graph. Uses whichever crypto path won the Phase 6 issue 1 decision (WASM or TS). Browser is reviewer-only — no owner-signing-key flows here.","acceptance_criteria":"WebSocket client connects with admission HMAC (matches the Rust client's behavior bit-for-bit on the test corpus).\nBackfill via hello + envelope frames per relay-spec.md §WebSocket (no GET /envelopes per decision #5).\nStale cursor → close code 4005 handled with a re-bootstrap path (re-fetch snapshot, replay from there).\nDecrypt → verify ORDER is correct (decrypt under eventKey FIRST, then signature-verify the plaintext per crypto-spec.md).\nIn-memory store survives WS disconnect+reconnect without duplicate events (EventId dedupe).\nEnd-to-end test: a comment added on the Rust client appears in the browser within 1s.","notes":"Specs: planning/collab/relay-spec.md §WebSocket + §Signaling, planning/collab/crypto-spec.md §Envelope Format, planning/collab/amendments.md §Decision #5 (WebSocket-only). Files: web/src/lib/review/transport.ts (new). Depends on browser crypto path from Phase 6 issue 1 and on invite/key derivation from Phase 6 issue 2. NO PERSISTENCE — everything in-memory (decision #13).","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:33:17Z","created_by":"James Lal","updated_at":"2026-05-19T17:35:47Z","started_at":"2026-05-19T17:02:46Z","closed_at":"2026-05-19T17:35:47Z","close_reason":"Round 21 (final push): implemented; merged; build clean","dependencies":[{"issue_id":"attn-nnj.9.3","depends_on_id":"attn-nnj.5.11","type":"blocks","created_at":"2026-05-18T16:38:32Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.9.3","depends_on_id":"attn-nnj.5.6","type":"blocks","created_at":"2026-05-18T16:38:33Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.9.3","depends_on_id":"attn-nnj.5.7","type":"blocks","created_at":"2026-05-18T16:38:33Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.9.3","depends_on_id":"attn-nnj.9","type":"parent-child","created_at":"2026-05-18T16:33:16Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.9.3","depends_on_id":"attn-nnj.9.1","type":"blocks","created_at":"2026-05-18T16:36:13Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.9.3","depends_on_id":"attn-nnj.9.2","type":"blocks","created_at":"2026-05-18T16:36:14Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":5,"dependent_count":1,"comment_count":0} -{"id":"attn-nnj.5.15","title":"Test plan acceptance suite (Miniflare integration tests)","description":"Implement the 14 numbered test scenarios from relay-spec.md §Test Plan as vitest integration tests under relay/test/integration/. Each test boots Miniflare (programmatic API), exercises the endpoints, and asserts on response codes, error codes, DO storage state (via wrapped get/list), and R2 contents. Consumes the conformance corpus from issue 3a-14 where applicable but adds in-depth assertions Miniflare can introspect.","acceptance_criteria":"- relay/test/integration/*.test.ts mirrors the 14 scenarios from §Test Plan\n- Each test boots a fresh Miniflare instance per case (or per file) so state is isolated\n- Asserts on: HTTP status, error code in body, DO storage keys via Miniflare's getDurableObjectStorage, R2 keys via getR2Bucket\n- Uses fake timers / Miniflare's setCurrentTime to test TTL alarms deterministically\n- Runs in CI via npm test (relay package) — green required before merge\n- Covers: room create+idempotency, device register+conflict, envelope ingest+caps, batch cap=32, WS backfill happy path, WS backfill 4005 path, hibernation roundtrip, owner-ack+delete, anonymous-ack no-delete, DELETE room, idle expiry, hard-max expiry, longSession 7d, rate-limit per-IP+per-device+anti-enum","notes":"Spec: planning/collab/relay-spec.md §Test Plan (687-705). Uses conformance corpus from 3a-14 as the source of request/response pairs but is the canonical pass/fail gate for the relay.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:33:08Z","created_by":"James Lal","updated_at":"2026-05-19T16:55:51Z","started_at":"2026-05-19T16:26:11Z","closed_at":"2026-05-19T16:55:51Z","close_reason":"Round 20: implemented; merged; build clean","dependencies":[{"issue_id":"attn-nnj.5.15","depends_on_id":"attn-nnj.5","type":"parent-child","created_at":"2026-05-18T16:33:07Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.15","depends_on_id":"attn-nnj.5.1","type":"blocks","created_at":"2026-05-18T16:35:31Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.15","depends_on_id":"attn-nnj.5.14","type":"blocks","created_at":"2026-05-18T16:35:59Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0} -{"id":"attn-nnj.5.14","title":"Conformance corpus (relay/test/conformance/cases.json)","description":"Build a shared conformance corpus consumed by BOTH the Miniflare integration suite (Phase 3a issue 15) AND the Rust transport tests (Phase 3b issue 7). Each case is a full {request, expectedResponse, expectedSideEffects} record covering the entire HTTP+WS surface and every error code in the spec.","acceptance_criteria":"- relay/test/conformance/cases.json (or one file per category) with deterministic fixtures (fixed keys, fixed timestamps in mock clock)\n- Coverage: room lifecycle (create idempotent, create policy-conflict, delete), WS backfill (after=0 full replay, after=last no replay, after=deleted→4005 with resyncFromSeq), all caps (32 batch, maxEvents, maxRoomBytes, maxEventBytes, maxSnapshotBytes, signal sub-cap eviction), owner-only ops (ack+delete with/without sig, DELETE room), PoW failures (insufficient bits, expired, resource mismatch, replayed), hibernation roundtrip (write → eject DO → reconnect → backfill), rate limits (per-IP, per-device, anti-enum), longSession clamping\n- A loader/runner abstraction in TypeScript that interprets cases and executes them against any HTTP+WS target (Miniflare or live wrangler dev)\n- Same JSON loadable from Rust via serde (matching schema documented at the top of the file)\n- README explaining how to add a new case","notes":"Spec source of truth: planning/collab/relay-spec.md §Test Plan (687-705) lists 14 scenarios — these are the minimum coverage. crypto-spec.md §Test Vectors (421-433) for PoW vectors. Phase 3b issue 7 will run this same corpus from Rust against wrangler dev --local. Plan the file format to be serde-deserializable from the start.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:33:07Z","created_by":"James Lal","updated_at":"2026-05-19T15:05:43Z","started_at":"2026-05-19T04:31:02Z","closed_at":"2026-05-19T15:05:43Z","close_reason":"Implemented; merged; 412 Rust + 213 relay tests pass (6 conformance scenarios deferred to 5.16)","dependencies":[{"issue_id":"attn-nnj.5.14","depends_on_id":"attn-nnj.5","type":"parent-child","created_at":"2026-05-18T16:33:07Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.14","depends_on_id":"attn-nnj.5.1","type":"blocks","created_at":"2026-05-18T16:35:30Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} -{"id":"attn-nnj.5.13","title":"Rate limiting (per-IP, per-device, anti-enumeration)","description":"Two-tier rate limiting. Worker edge (before DO): per-IP 600/min sliding window using Cloudflare's rate-limiting binding or a KV-backed sliding window, and an anti-enumeration counter that returns 429 after 30 unknown rooms in 5 minutes from one IP. Per-device 120/min in DO storage (sliding window over rate:\u003cdeviceId\u003e:\u003cbucket\u003e) checked after admission. All 429 responses include retryAfterMs in body and Retry-After header.","acceptance_criteria":"- Edge per-IP: 600 requests/min sliding window across all rooms; over → 429 ATTN_RATE_LIMITED with retryAfterMs\n- Edge anti-enum: 30 unknown rooms (404 ATTN_ROOM_NOT_FOUND triggers) from one IP in 5 min → 429 ATTN_ANTI_ENUMERATION; the unknown-room counter is keyed by IP, not roomId, so the attacker cannot tell which roomId tripped it\n- DO per-device: 120 requests/min sliding window over rate:\u003cdeviceId\u003e:\u003cminute-bucket\u003e entries; over → 429 ATTN_RATE_LIMITED\n- 429 responses set Retry-After header (seconds, rounded up) AND body {error:{code, retryAfterMs}}\n- Edge limits checked before the request crosses to the DO (cost protection)\n- Per-device limits checked after admission so they're attributable\n- Tests: per-IP cap, per-device cap, anti-enum trip with mixed unknown rooms, Retry-After format","notes":"Spec: planning/collab/relay-spec.md §Anti-Abuse (565-571). Numbers come from amendments and user's brief (600/min per IP, 120/min per device, 30 unknown/5min). Implement edge limits in src/index.ts before the DO fetch, device limits inside the DO.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:33:06Z","created_by":"James Lal","updated_at":"2026-05-19T15:27:48Z","started_at":"2026-05-19T15:07:28Z","closed_at":"2026-05-19T15:27:48Z","close_reason":"Round 17: implemented; merged; 409 Rust + 237 relay tests pass","dependencies":[{"issue_id":"attn-nnj.5.13","depends_on_id":"attn-nnj.5","type":"parent-child","created_at":"2026-05-18T16:33:06Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.13","depends_on_id":"attn-nnj.5.1","type":"blocks","created_at":"2026-05-18T16:35:30Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.13","depends_on_id":"attn-nnj.5.2","type":"blocks","created_at":"2026-05-18T16:35:49Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.13","depends_on_id":"attn-nnj.5.4","type":"blocks","created_at":"2026-05-18T16:35:49Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":3,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj.9.2","title":"Browser invite URL parsing + memory-only secret handling","description":"At https://attn.dev/review/:roomId#key=... — on load, parse the fragment, IMMEDIATELY strip it from the visible URL via history.replaceState(null, '', location.pathname + location.search), and hold roomSecret only in JS heap memory. Derive rootKey then derive the subkeys (eventKey, snapshotKey, signalingKey, admissionKey). Zero/overwrite the original fragment string where possible. Reload requires re-paste — no sessionStorage, no IndexedDB, no cookies (decision #13). This is the entire trust-on-browser story.","acceptance_criteria":"On page load, location.hash is parsed exactly once and immediately stripped via history.replaceState.\nroomSecret never written to sessionStorage, localStorage, IndexedDB, cookies, or service-worker caches — verify via grep + manual audit.\nrootKey + subkeys derived synchronously after fragment parse; derivation matches crypto-spec.md.\nAfter fragment strip, location.href shows the bare URL with no #key= visible (e.g., to the page title, devtools history list, or any other observer).\nReload reproduces the 'paste invite to join' UX rather than silently rejoining.\nUnit test (jsdom or playwright) asserts replaceState fires before any other code accesses location.hash a second time.","notes":"Specs: planning/collab/amendments.md §Decision #13, planning/collab/crypto-spec.md §Invite URLs + §Key Derivation. Files: web/src/lib/review/invite.ts (new), web/src/routes/review/[roomId]/+page.svelte (or similar). The strip-fragment-before-anything-else ordering matters — see Phase 6 issue in cross-cutting security review for the race-window concern. Zeroize the fragment string in JS is best-effort (string immutability in JS limits us); the goal is no PERSISTENT trace.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:33:01Z","created_by":"James Lal","updated_at":"2026-05-19T16:06:19Z","started_at":"2026-05-19T15:31:08Z","closed_at":"2026-05-19T16:06:19Z","close_reason":"Round 18: implemented; merged; 414+ Rust tests pass","dependencies":[{"issue_id":"attn-nnj.9.2","depends_on_id":"attn-nnj.9","type":"parent-child","created_at":"2026-05-18T16:33:00Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":2,"comment_count":0} -{"id":"attn-nnj.9.1","title":"Browser crypto sourcing decision (WASM vs TS)","description":"Real architectural fork: compile attn-collab-crypto Rust crate to WASM (one source of truth, larger bundle, identical behavior with Rust client) vs hand-write TS crypto against the shared test-vector corpus (smaller bundle, two implementations with drift risk). Bundle-size budget is the hard constraint — target the hosted JS bundle under 500 KiB gzipped. Output a decision doc with the recommendation and the measurements that back it. This issue blocks all browser WS/crypto work in Phase 6, so resolve it early.","acceptance_criteria":"planning/collab/ui/browser-crypto-decision.md created with both options spelled out and bundle-size measurements.\nWASM path: build attn-collab-crypto with wasm-pack (or wasm-bindgen), measure brotli/gzip size, note startup cost.\nTS path: scoped libraries identified (e.g., @noble/ciphers for XChaCha20-Poly1305, @noble/ed25519, @noble/hashes for HKDF-SHA256), estimate gzipped bundle size for the subset used.\nRecommendation includes a concrete number for the resulting bundle in both cases and a winner.\nFlagged with bd human — owner sign-off needed before downstream Phase 6 issues unblock.","notes":"Specs: planning/collab/crypto-spec.md §Primitives (XChaCha20-Poly1305 + Ed25519 + HKDF-SHA256 + canonical JSON RFC 8785 + base64url-no-pad), planning/collab/amendments.md (Decision #4 cipher locked, Decision #13 browser memory-only). Test-vector corpus must validate whichever path is chosen — the corpus is shared, not Rust-specific. If TS path: @noble/* libs are audited and tree-shake well.","status":"closed","priority":1,"issue_type":"decision","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:32:30Z","created_by":"James Lal","updated_at":"2026-05-19T16:25:13Z","started_at":"2026-05-19T16:07:54Z","closed_at":"2026-05-19T16:25:13Z","close_reason":"Round 19: implemented; merged; build clean","labels":["human"],"dependencies":[{"issue_id":"attn-nnj.9.1","depends_on_id":"attn-nnj.9","type":"parent-child","created_at":"2026-05-18T16:32:30Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"attn-nnj.5.12","title":"Alarms: idle + hard-max TTL + pow-prune","description":"DO alarms drive room TTL cleanup. Cloudflare DO supports only one pending alarm at a time, so always schedule at min(hard_max_at, last_event_at + idleTimeoutMs, next_pow_prune_at). On fire, determine which deadline tripped and act: idle/hard-max → close all WS with 4002 (room expired), wipe DO storage, schedule R2 cleanup; pow-prune → delete pow_seen entries past expiresAt+10min. Every WS connect calls cleanup_check() if now is within 1h of expires_at (decision #9). Hard-max defaults: 24h, or 7d when policy.longSession=true (decision #8). Idle default: 1h.","acceptance_criteria":"- alarm() handler reads all candidate deadlines (hard_max_at, last_event_at+idleTimeoutMs, pow_prune_at) and reschedules at the next earliest after acting on any that have fired\n- Idle/hard-max fire: broadcast close 4002 with reason 'room_expired', deleteAll() DO storage, enqueue R2 prefix delete, set tombstone so further requests 410 ATTN_ROOM_EXPIRED for 24h\n- pow-prune fire: scan meta:pow_seen:* and delete entries where extracted expiresAt+10min \u003c now\n- POST /envelopes updates last_event_at and reschedules the alarm\n- Every WS upgrade calls cleanup_check() — if now within 1h of expires_at, run the same scan and reschedule\n- hard_max_at computed at room creation: created + (longSession ? 7d : 24h), clamped by policy.expiresAt\n- Tests: idle expiry path, hard-max expiry path, longSession 7-day cap, single-alarm scheduling correctness when multiple deadlines compete, cleanup_check on WS connect, pow_seen prune","notes":"Spec: planning/collab/relay-spec.md §Alarms (514-529), §Close Codes (423-431). amendments.md decisions #8 (TTL defaults + longSession) and #9 (R2 lifecycle as safety net, DO alarm primary, cleanup_check on WS connect). Coordinates with PoW replay protection from 3a-3 and WS close from 3a-11.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:32:29Z","created_by":"James Lal","updated_at":"2026-05-19T13:49:59Z","started_at":"2026-05-19T04:31:02Z","closed_at":"2026-05-19T13:49:59Z","close_reason":"Implemented (partially for 5.14 — scaffold + skip-on-empty, follow-up to fill cases.json); 372 Rust + 184 relay tests pass","dependencies":[{"issue_id":"attn-nnj.5.12","depends_on_id":"attn-nnj.5","type":"parent-child","created_at":"2026-05-18T16:32:28Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.12","depends_on_id":"attn-nnj.5.1","type":"blocks","created_at":"2026-05-18T16:35:29Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.12","depends_on_id":"attn-nnj.5.11","type":"blocks","created_at":"2026-05-18T16:35:58Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.12","depends_on_id":"attn-nnj.5.2","type":"blocks","created_at":"2026-05-18T16:35:47Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.12","depends_on_id":"attn-nnj.5.4","type":"blocks","created_at":"2026-05-18T16:35:48Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":4,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj.5.11","title":"WebSocket protocol + DO hibernation","description":"Implement the WebSocket transport with subprotocol 'attn.v2' and admission HMAC piggybacked as the second protocol value. Use state.acceptWebSocket() for hibernation so idle sessions don't burn DO CPU; tag attached sockets with [deviceId, participantId]. Server frames: hello, envelope, presence, policy_changed, ping, error. Client frames: subscribe, pong. On subscribe.after \u003c meta:oldest_retained_seq emit error{code:ATTN_CURSOR_TOO_OLD, resyncFromSeq} and close with code 4005. 30s ping interval, 60s pong timeout → close 1001.","acceptance_criteria":"- Upgrade handshake: subprotocol header must include 'attn.v2' and a second value carrying the admission HMAC; missing/invalid → 401 ATTN_ADMISSION_INVALID\n- Uses state.acceptWebSocket(ws, [deviceId, participantId]) for hibernation; webSocketMessage/webSocketClose handlers route by tag\n- Backfill on subscribe: replay envelope frames from storage where seq \u003e subscribe.after (decision #5 — no GET /envelopes endpoint)\n- On subscribe.after \u003c meta:oldest_retained_seq → send error{code:ATTN_CURSOR_TOO_OLD, resyncFromSeq:meta:oldest_retained_seq} then close 4005\n- Server frames implemented: hello{serverSeq, oldestRetainedSeq, peers}, envelope{seq, envelope}, presence{deviceId, state}, policy_changed{policy}, ping, error\n- Client frames handled: subscribe{after}, pong\n- 30s server ping interval (per-session scheduled via alarm or setTimeout-substitute); no pong within 60s → close 1001 ATTN_TIMEOUT\n- Broadcast helper used by POST /envelopes routes through getSession-by-tag lookup\n- Tests: handshake reject, backfill from 0, backfill from mid, 4005 on too-old cursor, hibernation roundtrip (eject + re-deliver after restart), ping/pong, presence broadcast","notes":"Spec: planning/collab/relay-spec.md §WebSocket Protocol (362-459), §Hibernation Tags (531-533). amendments.md decision #5 (WS-only delivery, GET /envelopes REMOVED). Uses admission middleware from 3a-2.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:32:28Z","created_by":"James Lal","updated_at":"2026-05-19T04:06:15Z","started_at":"2026-05-19T03:23:01Z","closed_at":"2026-05-19T04:06:15Z","close_reason":"Implemented; merged into collab; 346 Rust + 168 relay tests pass","dependencies":[{"issue_id":"attn-nnj.5.11","depends_on_id":"attn-nnj.5","type":"parent-child","created_at":"2026-05-18T16:32:28Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.11","depends_on_id":"attn-nnj.5.1","type":"blocks","created_at":"2026-05-18T16:35:29Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.11","depends_on_id":"attn-nnj.5.2","type":"blocks","created_at":"2026-05-18T16:35:46Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.11","depends_on_id":"attn-nnj.5.4","type":"blocks","created_at":"2026-05-18T16:35:47Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":3,"dependent_count":3,"comment_count":0} -{"id":"attn-nnj.5.10","title":"DELETE /v2/rooms/:roomId — owner room wipe","description":"Owner-only endpoint to nuke the room. Requires both Attn-PoW and Attn-Owner-Signature. Disconnects all WebSocket sessions with close code 4001 (room deleted), wipes all DO storage for the room, and schedules R2 cleanup by listing and deleting all objects under rooms/\u003croomId\u003e/.","acceptance_criteria":"- Requires valid Attn-Admission, Attn-PoW, AND Attn-Owner-Signature; missing/invalid owner sig → 403 ATTN_OWNER_SIG_REQUIRED / ATTN_OWNER_KEY_MISMATCH\n- Closes every active WebSocket with close code 4001, reason 'room_deleted'\n- Wipes all DO storage keys (envelope:*, acks:*, devices:*, meta:*, pow_seen:*) via storage.deleteAll() or scoped delete\n- Schedules R2 cleanup: list objects with prefix rooms/\u003croomId\u003e/ and delete in batches (paginate)\n- Returns 200 {deleted:true} synchronously even if R2 cleanup is still draining\n- Idempotent: re-DELETE on already-gone room returns 404 ATTN_ROOM_NOT_FOUND\n- Test: WS sessions observe 4001 close before storage wipe; subsequent admission attempts return 404","notes":"Spec: planning/collab/relay-spec.md §DELETE /v2/rooms/:roomId (305-311), §Close Codes (423-431). amendments.md decision #3 (owner-only). Owner-sig from issue 3a-4.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:32:27Z","created_by":"James Lal","updated_at":"2026-05-19T04:29:44Z","started_at":"2026-05-19T04:09:04Z","closed_at":"2026-05-19T04:29:44Z","close_reason":"Implemented; merged","dependencies":[{"issue_id":"attn-nnj.5.10","depends_on_id":"attn-nnj.5","type":"parent-child","created_at":"2026-05-18T16:32:26Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.10","depends_on_id":"attn-nnj.5.1","type":"blocks","created_at":"2026-05-18T16:35:28Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.10","depends_on_id":"attn-nnj.5.2","type":"blocks","created_at":"2026-05-18T16:35:45Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.10","depends_on_id":"attn-nnj.5.3","type":"blocks","created_at":"2026-05-18T16:35:58Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.10","depends_on_id":"attn-nnj.5.4","type":"blocks","created_at":"2026-05-18T16:35:46Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":4,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj.7.7","title":"WebRTC e2e integration test","description":"Boot two local attn daemons in a test harness, share a doc from one, join from the other, exchange comments over the DataChannel. Confirms decision #1's Rust-owned WebRTC path actually works in wry/tao on macOS. Use the daemon's existing automation flags (--eval, --query, --wait-for) to drive both sides without needing a separate test runner. This is the integration test that proves Phase 4 has actually shipped.","acceptance_criteria":"scripts/test-e2e.sh gains a review/webrtc.sh (or equivalent) that boots two daemon instances on isolated state dirs.\nOwner shares a markdown fixture; reviewer joins via the invite URL (attn://review/...).\nA comment from the reviewer arrives on the owner side within 2s, asserted via --query on the owner's review panel.\nDataChannel connection state is asserted Connected on both sides via --eval against window.__attn__.reviewState().\nTest passes in CI on macOS (and Linux if relay tests already run there).\nCaptures connection logs to /tmp/attn-e2e-screenshots/ on failure.","notes":"Specs: planning/collab/amendments.md §existing automation flags affect ReviewManager design + §Phase 4. Files: scripts/test-e2e.sh + new tests/fixtures/review/*.md. Two daemons on the same machine need different ATTN_HOME (or equivalent) and different socket paths — see existing single-instance protocol in src/daemon.rs. Window.__attn__.reviewState() should be exposed earlier in Phase 2/3 — verify it exists before writing this test.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:32:15Z","created_by":"James Lal","updated_at":"2026-05-19T16:07:08Z","started_at":"2026-05-19T15:31:09Z","closed_at":"2026-05-19T16:07:08Z","close_reason":"Round 18: implemented; merged; 414+ Rust tests pass","dependencies":[{"issue_id":"attn-nnj.7.7","depends_on_id":"attn-nnj.2.10","type":"blocks","created_at":"2026-05-18T16:58:50Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.7.7","depends_on_id":"attn-nnj.7","type":"parent-child","created_at":"2026-05-18T16:32:15Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.7.7","depends_on_id":"attn-nnj.7.1","type":"blocks","created_at":"2026-05-18T16:35:53Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.7.7","depends_on_id":"attn-nnj.7.3","type":"blocks","created_at":"2026-05-18T16:36:03Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":3,"dependent_count":1,"comment_count":0} -{"id":"attn-nnj.7.5","title":"Mode-aware transport selector in ReviewManager","description":"In ReviewManager, route outbound events based on policy.mode: hybrid sends through BOTH transports (DataChannel when connected, mailbox always-on); live uses WebRTC only; async uses mailbox only. Inbound dedupe is handled by the existing EventId-keyed import so the receiver doesn't double-process when both transports deliver. This is the integration point where mailbox + WebRTC become 'one transport with two wires' from the manager's perspective.","acceptance_criteria":"ReviewManager has a transport selector that consults policy.mode at send time.\nhybrid: outbound envelope is enqueued to BOTH transports; receiver dedupes by EventId on import.\nlive: outbound goes only to WebRTC; if WebRTC is Failed, send returns an error that surfaces as RoomStatusChanged(DirectFailed) (per issue 4).\nasync: outbound goes only to mailbox; WebRTC never initialized.\nInbound import is idempotent — receiving the same EventId twice (once from each transport) is a no-op the second time.\nTested with all three mode values.","notes":"Specs: planning/collab/amendments.md §Phase 4 (mode semantics + dedupe). Files: src/review/manager.rs. Idempotent import probably already exists from Phase 0b/3b store work — verify and reuse. Don't add EventId-tracking state here; the store layer owns dedupe.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:31:49Z","created_by":"James Lal","updated_at":"2026-05-19T14:20:07Z","started_at":"2026-05-19T13:53:28Z","closed_at":"2026-05-19T14:20:07Z","close_reason":"Implemented; 400 Rust + 193 relay tests pass","dependencies":[{"issue_id":"attn-nnj.7.5","depends_on_id":"attn-nnj.7","type":"parent-child","created_at":"2026-05-18T16:31:48Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.7.5","depends_on_id":"attn-nnj.7.1","type":"blocks","created_at":"2026-05-18T16:35:52Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.7.5","depends_on_id":"attn-nnj.7.3","type":"blocks","created_at":"2026-05-18T16:36:02Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.7.5","depends_on_id":"attn-nnj.7.4","type":"blocks","created_at":"2026-05-18T16:36:03Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":3,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj.5.9","title":"POST /v2/rooms/:roomId/acks — acknowledgment + optional delete","description":"Mark envelopes as ACKed by deviceId. Body: {acks: [{envelopeId, deviceId}], delete?:boolean}. Idempotent. If delete=true AND policy.deleteEventsAfterOwnerAck==true AND the request carries a valid Attn-Owner-Signature, delete envelopes that have been ACKed by ANY owner-kind device. Otherwise just record the ACK without deletion (multi-device safety per decision #12).","acceptance_criteria":"- Validates body schema; deviceId must be registered in the room\n- Records ACK in DO storage under acks:\u003cenvelopeId\u003e:\u003cdeviceId\u003e\n- delete=true is conditional on: policy.deleteEventsAfterOwnerAck===true (default false per decision #12) AND owner signature verifies AND envelope has at least one owner-device ACK\n- Deletion is by-envelope: removes envelope:\u003cseq\u003e entries (and blob R2 keys for snapshot_blob), decrements meta:envelope_count and meta:bytes_used\n- Updates meta:oldest_retained_seq if a leading run of envelopes is deleted\n- Idempotent: re-ACK is a no-op; re-delete of already-deleted envelope returns 200 (count: 0)\n- Returns {acked:[envelopeId...], deleted:[envelopeId...]}\n- Tests: ack-only, ack+delete with owner sig, ack+delete without owner sig (no-op deletion), ack+delete with policy.deleteEventsAfterOwnerAck=false (no-op deletion)","notes":"Spec: planning/collab/relay-spec.md §POST /v2/rooms/:roomId/acks (277-303). amendments.md decisions #3 (owner-sig gating) and #12 (deleteEventsAfterOwnerAck default false). Owner-sig verification reused from issue 3a-4.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:31:48Z","created_by":"James Lal","updated_at":"2026-05-19T04:08:01Z","started_at":"2026-05-19T03:42:00Z","closed_at":"2026-05-19T04:08:01Z","close_reason":"Implemented; merged into collab; 346 Rust + 168 relay tests pass","dependencies":[{"issue_id":"attn-nnj.5.9","depends_on_id":"attn-nnj.5","type":"parent-child","created_at":"2026-05-18T16:31:47Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.9","depends_on_id":"attn-nnj.5.1","type":"blocks","created_at":"2026-05-18T16:35:27Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.9","depends_on_id":"attn-nnj.5.2","type":"blocks","created_at":"2026-05-18T16:35:44Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.9","depends_on_id":"attn-nnj.5.3","type":"blocks","created_at":"2026-05-18T16:35:57Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.9","depends_on_id":"attn-nnj.5.4","type":"blocks","created_at":"2026-05-18T16:35:44Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":4,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj.5.8","title":"POST + GET /v2/rooms/:roomId/blobs — R2 spillover","description":"When kind==snapshot_blob and ciphertextBytes \u003e 1 MiB, /envelopes is rejected and the client must use the blob flow. POST /v2/rooms/:roomId/blobs returns a presigned PUT URL (15-min TTL) at R2 key rooms/\u003croomId\u003e/blobs/\u003cenvelopeId\u003e. Client uploads ciphertext directly, then re-POSTs /envelopes with the same envelopeId and a small BlobRef payload referencing the R2 key. GET /v2/rooms/:roomId/blobs/:envelopeId returns a 5-min presigned GET URL.","acceptance_criteria":"- POST /blobs body validates {envelopeId, kind:'snapshot_blob', ciphertextBytes, hash}; rejects when ciphertextBytes ≤ 1 MiB (use /envelopes path instead)\n- Returns {uploadUrl, expiresAt} — presigned PUT, 15-min TTL, key=rooms/\u003croomId\u003e/blobs/\u003cenvelopeId\u003e\n- After upload, client re-POSTs /envelopes with kind=snapshot_blob and ciphertext = canonical BlobRef payload ({r2Key, ciphertextBytes, hash})\n- GET /blobs/:envelopeId returns {downloadUrl, expiresAt} — presigned GET, 5-min TTL\n- Validates envelopeId belongs to this room (check DO state) before issuing GET URL\n- Counts blob bytes against meta:bytes_used (against policy.maxRoomBytes)\n- Tests: upload roundtrip, undersized rejection, replay (same envelopeId returns same key), unauthorized GET","notes":"Spec: planning/collab/relay-spec.md §POST /v2/rooms/:roomId/blobs (313-360), §R2 Integration (557-563). Lifecycle TTL = 7 days as safety net (decision #9); primary cleanup is the DO alarm in issue 3a-12.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:31:47Z","created_by":"James Lal","updated_at":"2026-05-19T14:19:52Z","started_at":"2026-05-19T13:53:27Z","closed_at":"2026-05-19T14:19:52Z","close_reason":"Implemented; 400 Rust + 193 relay tests pass","dependencies":[{"issue_id":"attn-nnj.5.8","depends_on_id":"attn-nnj.5","type":"parent-child","created_at":"2026-05-18T16:31:47Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.8","depends_on_id":"attn-nnj.5.1","type":"blocks","created_at":"2026-05-18T16:35:27Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.8","depends_on_id":"attn-nnj.5.2","type":"blocks","created_at":"2026-05-18T16:35:42Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.8","depends_on_id":"attn-nnj.5.4","type":"blocks","created_at":"2026-05-18T16:35:43Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":3,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj.5.7","title":"POST /v2/rooms/:roomId/envelopes — batched ingest","description":"Accept up to 32 envelopes per HTTP request (decision #7). Each envelope is validated end-to-end: ciphertextBytes equals base64url-decoded ciphertext length; size against policy.max{Event|Snapshot}Bytes by kind; deviceId and authorId are registered in the room. Single PoW token covers the whole batch. On any cap overflow (envelope_count \u003e policy.maxEvents OR bytes_used + delta \u003e policy.maxRoomBytes) the whole batch fails with 507. Idempotent on envelopeId. Signal envelopes use sub-caps with FIFO eviction. On success, allocates serverSeq, updates counters, reschedules idle alarm.","acceptance_criteria":"- Batch cap 32: 33+ envelopes → 400 ATTN_BATCH_TOO_LARGE before any work\n- Single Attn-PoW token verified once for the whole HTTP request (resource binds METHOD + PATH, not per-envelope)\n- Per envelope: ciphertextBytes === base64url_decoded.length else 400 ATTN_ENVELOPE_SIZE_MISMATCH; kind-specific size cap against policy.max{Event|Snapshot}Bytes else 413 ATTN_ENVELOPE_TOO_LARGE; deviceId + authorId registered else 403 ATTN_UNKNOWN_DEVICE\n- serverSeq allocated atomically per envelope (monotonic, per relay-spec §serverSeq Allocation)\n- Idempotency: existing envelopeId returns its stored serverSeq with no state change\n- Whole-batch overflow: 507 ATTN_ROOM_EVENT_CAP or ATTN_ROOM_STORAGE_FULL (no partial commit)\n- Signal envelopes: maxSignalEnvelopes=64 per (authorId, target.deviceId), FIFO-evict oldest in DO storage\n- Updates meta:envelope_count, meta:bytes_used, meta:last_event_at; reschedules idle alarm\n- Broadcasts envelope frames to subscribed WS sessions (hibernation-safe)\n- Response: {accepted:[{envelopeId, serverSeq}], serverSeq:\u003cmax\u003e}\n- Tests: batch over cap, mixed kinds, idempotent retry, room-full, signal eviction, PoW reuse across batch","notes":"Spec: planning/collab/relay-spec.md §POST /v2/rooms/:roomId/envelopes (218-269), §serverSeq Allocation (499-512), §Caps (535-555). amendments.md decisions #7 (batch cap + single PoW) and #5 (WS-only delivery: this endpoint feeds the WS broadcast, no HTTP GET pull).","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:31:46Z","created_by":"James Lal","updated_at":"2026-05-19T02:29:21Z","started_at":"2026-05-19T01:57:29Z","closed_at":"2026-05-19T02:29:21Z","close_reason":"Implemented (3.8+round-10 retries); merged into collab; 267 Rust + 144 relay tests pass","dependencies":[{"issue_id":"attn-nnj.5.7","depends_on_id":"attn-nnj.5","type":"parent-child","created_at":"2026-05-18T16:31:46Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.7","depends_on_id":"attn-nnj.5.1","type":"blocks","created_at":"2026-05-18T16:35:26Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.7","depends_on_id":"attn-nnj.5.2","type":"blocks","created_at":"2026-05-18T16:35:41Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.7","depends_on_id":"attn-nnj.5.4","type":"blocks","created_at":"2026-05-18T16:35:42Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":3,"dependent_count":3,"comment_count":0} -{"id":"attn-nnj.7.4","title":"Connection state machine + ICE handling","description":"Trickle ICE per relay-spec.md §Signaling. Track the peer-connection lifecycle through Connecting / Connected / Reconnecting / Failed states. Behavior on Failed is mode-dependent: in live mode, surface direct-connection failure explicitly via ReviewUpdate::RoomStatusChanged(DirectFailed) so the UI can tell the user the live channel is gone; in hybrid mode, silently rely on mailbox (no user-visible disruption — that's the whole point of hybrid). Reconnecting handles transient NAT rebinds without bouncing the user.","acceptance_criteria":"Connection state enum {Connecting, Connected, Reconnecting, Failed} drives WebRTC peer lifecycle.\nTrickle ICE: candidates emit as individual kind=signal envelopes as they're gathered (not batched at end-of-gathering).\nOn Failed in live mode → emit ReviewUpdate::RoomStatusChanged(DirectFailed). Frontend can surface 'live connection lost' UI.\nOn Failed in hybrid mode → no user-visible event; mailbox continues serving traffic.\nReconnecting attempts ICE restart before transitioning to Failed.\nState transitions covered by unit tests against a mock PeerConnection.","notes":"Specs: planning/collab/relay-spec.md §Signaling (trickle ICE protocol), planning/collab/amendments.md §Phase 4 (mode semantics). Files: src/review/transport/webrtc.rs (state machine), src/review/manager.rs (ReviewUpdate emission). Mode comes from policy.mode on the room. RoomStatusChanged is a new ReviewUpdate variant — add to the enum.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:31:35Z","created_by":"James Lal","updated_at":"2026-05-19T13:50:43Z","started_at":"2026-05-19T04:31:04Z","closed_at":"2026-05-19T13:50:43Z","close_reason":"Implemented (partially for 5.14 — scaffold + skip-on-empty, follow-up to fill cases.json); 372 Rust + 184 relay tests pass","dependencies":[{"issue_id":"attn-nnj.7.4","depends_on_id":"attn-nnj.7","type":"parent-child","created_at":"2026-05-18T16:31:35Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.7.4","depends_on_id":"attn-nnj.7.1","type":"blocks","created_at":"2026-05-18T16:35:51Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.7.4","depends_on_id":"attn-nnj.7.2","type":"blocks","created_at":"2026-05-18T16:36:01Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.7.4","depends_on_id":"attn-nnj.7.3","type":"blocks","created_at":"2026-05-18T16:36:01Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":3,"dependent_count":1,"comment_count":0} -{"id":"attn-nnj.7.3","title":"DataChannel arm of Transport trait","description":"Implement WebRTCTransport against the Transport trait defined in Phase 3b. The crucial property (decision #14, amendments.md §Phase 4): the DataChannel envelope FORMAT is identical to mailbox envelopes — same AEAD under eventKey/snapshotKey, same routing, same import path. Only the wire differs. Frontend never sees raw transport; ReviewManager decrypts, signature-verifies, then emits typed ReviewUpdate. This is what lets hybrid mode dedupe by EventId across both transports for free.","acceptance_criteria":"WebRTCTransport struct implements the same Transport trait as MailboxTransport from Phase 3b.\nOutbound: events go through the existing outbox; when DataChannel is connected, send_envelope writes the same AEAD-encrypted envelope bytes to the channel.\nInbound: DataChannel on_message decrypts under eventKey/snapshotKey and feeds into the same envelope-import pipeline.\nNo plaintext on the wire (decision #14) — snapshot bytes are application-encrypted, never relying on DTLS for confidentiality.\nTrait abstraction allows a single ReviewManager codepath to consume both transports interchangeably.","notes":"Specs: planning/collab/amendments.md §Phase 4 + Decision #14. Files: src/review/transport/webrtc.rs (new), src/review/transport.rs (trait). Reuses the import pipeline built in Phase 3b — do NOT duplicate decrypt/verify logic. The wire is different but the envelope is the same — this is load-bearing for hybrid mode.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:31:21Z","created_by":"James Lal","updated_at":"2026-05-19T04:30:20Z","started_at":"2026-05-19T04:09:05Z","closed_at":"2026-05-19T04:30:20Z","close_reason":"Implemented; merged","dependencies":[{"issue_id":"attn-nnj.7.3","depends_on_id":"attn-nnj.6.1","type":"blocks","created_at":"2026-05-18T16:38:28Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.7.3","depends_on_id":"attn-nnj.6.4","type":"blocks","created_at":"2026-05-18T16:38:29Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.7.3","depends_on_id":"attn-nnj.7","type":"parent-child","created_at":"2026-05-18T16:31:20Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.7.3","depends_on_id":"attn-nnj.7.1","type":"blocks","created_at":"2026-05-18T16:35:51Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.7.3","depends_on_id":"attn-nnj.7.2","type":"blocks","created_at":"2026-05-18T16:36:00Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":4,"dependent_count":3,"comment_count":0} -{"id":"attn-nnj.5.6","title":"POST + GET /v2/rooms/:roomId/devices — device registration","description":"POST /devices: upsert device record. Verifies selfSignature (Ed25519 over canonical device bytes) against publicSigningKey. If kind==owner, publicSigningKey MUST equal the room's ownerSigningKey, else 403 ATTN_OWNER_KEY_MISMATCH. Upsert is keyed by (participantId, deviceId); attempting to change publicSigningKey for an existing entry returns 409 ATTN_DEVICE_KEY_CHANGED. GET /devices returns the peer list in original registration order.","acceptance_criteria":"- POST validates schema: {participantId, deviceId, kind in [owner|reviewer|agent], publicSigningKey, selfSignature, capabilities?}\n- selfSignature verifies against publicSigningKey over the canonical device bytes from crypto-spec.md §Signing-Key Publication\n- kind==owner: publicSigningKey === room.ownerSigningKey else 403 ATTN_OWNER_KEY_MISMATCH\n- Upsert by (participantId, deviceId): if exists and key matches, return 200 with stored record; if key differs, 409 ATTN_DEVICE_KEY_CHANGED\n- GET /devices returns array in registration order; includes a server-stable 'registeredAt' timestamp\n- Updates meta:peer_count, enforces policy.maxPeers (8 cap) — 403 ATTN_ROOM_FULL when exceeded\n- Tests cover: fresh register, idempotent re-register, key-change rejection, owner-key mismatch, peer cap","notes":"Spec: planning/collab/relay-spec.md §POST /v2/rooms/:roomId/devices (169-217). crypto-spec.md §Signing-Key Publication (344-403). The devices list is the source of truth for sig verification on inbound envelopes (Phase 3b consumes this).","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:31:08Z","created_by":"James Lal","updated_at":"2026-05-19T01:56:14Z","started_at":"2026-05-19T01:36:53Z","closed_at":"2026-05-19T01:56:14Z","close_reason":"Implemented; merged into collab; 254 Rust tests + 130 relay tests pass; corpus replay green","dependencies":[{"issue_id":"attn-nnj.5.6","depends_on_id":"attn-nnj.5","type":"parent-child","created_at":"2026-05-18T16:31:07Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.6","depends_on_id":"attn-nnj.5.1","type":"blocks","created_at":"2026-05-18T16:35:26Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.6","depends_on_id":"attn-nnj.5.2","type":"blocks","created_at":"2026-05-18T16:35:40Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.6","depends_on_id":"attn-nnj.5.4","type":"blocks","created_at":"2026-05-18T16:35:40Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":3,"dependent_count":2,"comment_count":0} -{"id":"attn-nnj.5.5","title":"POST /v2/rooms/:roomId — idempotent room creation","description":"Implement POST /v2/rooms/:roomId. Idempotent: first call creates the room with the supplied policy + ownerSigningKey, subsequent calls with the same body return the stored policy unchanged (no mutation). Body is validated against the zod schema from the scaffold issue. Policy values are clamped to spec maxima before storage. Computes and returns ownerSigningKeyId = base64url(SHA-256(ownerSigningKey)).","acceptance_criteria":"- Body validates against zod RoomCreateRequest schema (admissionKey, ownerSigningKey, policy, ...)\n- Policy clamping: maxPeers ≤ 8; expiresAt ≤ created+24h, or +7d when longSession=true; idleTimeoutMs ≥ 60_000 and ≤ wall-clock TTL; powBits ∈ [12,24]; max{Event,Snapshot,RoomBytes} ≤ HARD_MAX_*\n- Stores ownerSigningKey, computes ownerSigningKeyId = base64url(SHA-256(key))\n- First create: 201 with {policy, ownerSigningKeyId, serverSeq:0, oldestRetainedSeq:0}\n- Replay of same body: 200 with the stored values (no mutation, idempotent)\n- Different body for an existing room: 409 ATTN_ROOM_EXISTS_DIFFERENT_POLICY\n- Initial bytes_used=0, envelope_count=0, hard_max_at = created + min(policy.expiresAt - created, 24h|7d cap)\n- Test: clamping behavior, idempotency, conflict on policy diff","notes":"Spec: planning/collab/relay-spec.md §POST /v2/rooms/:roomId (lines 114-167). §Caps (535-555). amendments.md decision #8 (TTLs, longSession). Note: this endpoint does NOT require pre-existing admission since it establishes the admissionKey. PoW IS still required (decision #6).","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:31:07Z","created_by":"James Lal","updated_at":"2026-05-19T01:33:37Z","started_at":"2026-05-19T00:47:52Z","closed_at":"2026-05-19T01:33:37Z","close_reason":"Implemented; merged into collab; 212 Rust tests + 98 relay tests pass","dependencies":[{"issue_id":"attn-nnj.5.5","depends_on_id":"attn-nnj.5","type":"parent-child","created_at":"2026-05-18T16:31:06Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.5","depends_on_id":"attn-nnj.5.1","type":"blocks","created_at":"2026-05-18T16:35:25Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.5","depends_on_id":"attn-nnj.5.2","type":"blocks","created_at":"2026-05-18T16:35:39Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.5","depends_on_id":"attn-nnj.5.4","type":"blocks","created_at":"2026-05-18T16:35:39Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":3,"dependent_count":1,"comment_count":0} -{"id":"attn-nnj.7.2","title":"Encrypted signaling envelopes (signalingKey + AAD)","description":"Build the signaling layer for WebRTC negotiation. SDP offers/answers and ICE candidates ride the same mailbox transport as regular envelopes — same POST /envelopes upload, same WS delivery, just kind=signal. Cleartext payload is canonical({kind: offer|answer|ice, sdp|ice[], from: deviceId}), encrypted with signalingKey under standard envelope AAD. On the receive side, WS envelopes with kind=signal decrypt and dispatch into webrtc-rs as SDP/ICE inputs. This is what makes the data-channel handshake survive NAT without a separate signaling channel.","acceptance_criteria":"src/review/transport/signaling.rs (new) builds offer/answer/ice envelopes with kind=signal, encrypted under signalingKey with AAD binding.\nOutbound signaling envelopes upload via the existing POST /envelopes pipeline from Phase 3b — no new HTTP path.\nInbound WS envelope dispatcher routes kind=signal frames into the signaling decoder, decrypts, dispatches to webrtc-rs callbacks.\nUnit test: round-trip an offer/answer pair through encrypt → decrypt and assert canonical equality.\nsignalingKey derivation matches crypto-spec.md (HKDF subkey under rootKey).","notes":"Specs: planning/collab/relay-spec.md §Signaling, planning/collab/crypto-spec.md §Key Derivation, planning/collab/amendments.md §Phase 4. Files: src/review/transport/signaling.rs (new), src/review/transport.rs (dispatcher integration). Reuses outbox + WS envelope plumbing from Phase 3b. AAD must bind (roomId, envelopeId, kind=signal, from).","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:31:02Z","created_by":"James Lal","updated_at":"2026-05-19T04:08:15Z","started_at":"2026-05-19T03:42:00Z","closed_at":"2026-05-19T04:08:15Z","close_reason":"Implemented; merged into collab; 346 Rust + 168 relay tests pass","dependencies":[{"issue_id":"attn-nnj.7.2","depends_on_id":"attn-nnj.7","type":"parent-child","created_at":"2026-05-18T16:31:02Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.7.2","depends_on_id":"attn-nnj.7.1","type":"blocks","created_at":"2026-05-18T16:35:50Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} -{"id":"attn-nnj.7.1","title":"webrtc-rs dependency + binary-size gate","description":"Add webrtc-rs to Cargo.toml as the foundation for Phase 4. Pre-merge gate: confirm release binary stays under 25 MiB target (decision #1 tradeoff). webrtc-rs transitively pulls tokio, rcgen, sctp, dtls, openssl-sys or rustls — this issue is the gate where we discover whether to feature-flag aggressively or escalate. Blocks all downstream Phase 4 work; if the gate fails we re-scope before sinking effort into signaling/datachannel code.","acceptance_criteria":"webrtc-rs added to Cargo.toml with chosen feature set documented inline.\ncargo build --release succeeds on macOS aarch64.\ncargo tree -e features --no-default-features --no-dev-dependencies output captured and committed under planning/collab/ or attached to issue notes.\ndu -h on the release .app bundle (or stripped binary if not bundled) is recorded; total under 25 MiB.\nIf gate exceeded: feature flags to evaluate are listed in a comment, OR issue is escalated via bd human and downstream work is held.","notes":"Spec: planning/collab/amendments.md §Phase 4 WebRTC + Decision #1. Files: Cargo.toml (root). Use rustls backend (not openssl-sys) by default to keep the binary smaller and avoid system-OpenSSL coupling. Verify gate via the existing scripts/build.sh release path so it matches what ships. Blocks: every other Phase 4 issue.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:30:46Z","created_by":"James Lal","updated_at":"2026-05-19T04:06:55Z","started_at":"2026-05-19T03:23:03Z","closed_at":"2026-05-19T04:06:55Z","close_reason":"Implemented; merged into collab; 346 Rust + 168 relay tests pass","dependencies":[{"issue_id":"attn-nnj.7.1","depends_on_id":"attn-nnj.7","type":"parent-child","created_at":"2026-05-18T16:30:45Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":6,"comment_count":0} -{"id":"attn-nnj.4.14","title":"'Comment survives owner edits' demo + e2e check","description":"End-to-end scripted scenario using the mock-ipc scenario stream: owner edits a paragraph, the reviewer's previously-attached comment remaps via the anchor engine, decoration stays attached, and the 'moved' badge state shows in the panel. Repeatable via existing automation flags (--query, --eval).","acceptance_criteria":"- Scenario JSON in web/src/lib/mock-ipc-scenarios/ drives the demo\n- Repeatable test invocation documented (uses attn --query / --eval)\n- Asserts: decoration present after edit, status is remapped (0.70-0.89) or exact (\u003e=0.90)\n- Asserts: 'moved' badge shown in panel when remapped\n- Wired into scripts/test-e2e.sh or a sibling script","notes":"Spec refs: amendments.md Decision #15 cutoffs; data-model.md §Anchor engine. Uses attn --query and --eval automation flags (debug builds only). Depends on 2-1 mock-ipc, 2-6 decorations. Aligns with project rule: prefer in-app UI assertions; no 'any' types.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:30:43Z","created_by":"James Lal","updated_at":"2026-05-19T17:35:33Z","started_at":"2026-05-19T17:02:45Z","closed_at":"2026-05-19T17:35:33Z","close_reason":"Round 21 (final push): implemented; merged; build clean","dependencies":[{"issue_id":"attn-nnj.4.14","depends_on_id":"attn-nnj.11.4","type":"blocks","created_at":"2026-05-18T16:38:31Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.14","depends_on_id":"attn-nnj.3.5","type":"blocks","created_at":"2026-05-18T16:38:22Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.14","depends_on_id":"attn-nnj.4","type":"parent-child","created_at":"2026-05-18T16:30:42Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.14","depends_on_id":"attn-nnj.4.1","type":"blocks","created_at":"2026-05-18T16:31:28Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.14","depends_on_id":"attn-nnj.4.6","type":"blocks","created_at":"2026-05-18T16:31:34Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":4,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj.4.11","title":"Connection badge","description":"Header connection indicator with four states from data-model.md: Live direct / Mailbox / Offline / Direct failed. Subscribes to reviewStatus updates from the store. Direct-failed state surfaces a retry affordance.","acceptance_criteria":"- Connection badge present in header at location decided in UX-3\n- All four states render with distinct visual treatment\n- Subscribes to reviewStatus via review store\n- Direct-failed state includes retry affordance (button or click action)\n- Retry action calls appropriate IPC (mocked)","notes":"Spec refs: data-model.md §UI/UX Changes (owner connection badge: Live direct / Mailbox / Offline / Direct failed). Depends on UX-3, 2-1, 2-2. No 'any' types.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:30:41Z","created_by":"James Lal","updated_at":"2026-05-19T16:24:57Z","started_at":"2026-05-19T16:07:54Z","closed_at":"2026-05-19T16:24:57Z","close_reason":"Round 19: implemented; merged; build clean","dependencies":[{"issue_id":"attn-nnj.4.11","depends_on_id":"attn-nnj.10.1","type":"blocks","created_at":"2026-05-18T16:30:59Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.11","depends_on_id":"attn-nnj.10.2","type":"blocks","created_at":"2026-05-18T16:31:04Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.11","depends_on_id":"attn-nnj.10.3","type":"blocks","created_at":"2026-05-18T16:31:11Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.11","depends_on_id":"attn-nnj.12.1","type":"blocks","created_at":"2026-05-18T16:53:49Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.11","depends_on_id":"attn-nnj.12.7","type":"blocks","created_at":"2026-05-18T16:53:53Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.11","depends_on_id":"attn-nnj.4","type":"parent-child","created_at":"2026-05-18T16:30:40Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.11","depends_on_id":"attn-nnj.4.1","type":"blocks","created_at":"2026-05-18T16:31:26Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.11","depends_on_id":"attn-nnj.4.2","type":"blocks","created_at":"2026-05-18T16:31:31Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":7,"dependent_count":1,"comment_count":0} -{"id":"attn-nnj.4.10","title":"Share button + room mode selector","description":"Toolbar share affordance opens a dialog with room mode (Live / Async 24h / Async 7d / Hybrid), displays the generated attn://review/... URL, and provides a copy button. Wires to IPC review_share (mocked in this phase via 2-1).","acceptance_criteria":"- Share button present in toolbar at location decided in UX-3\n- Dialog opens with mode selector: Live / Async 24h / Async 7d / Hybrid\n- Generated attn://review/... URL displayed\n- Copy-to-clipboard button works (uses in-app feedback, not alert())\n- Calls IPC review_share; mock-ipc returns a fake URL for now\n- Dialog is in-app modal (no window.confirm/prompt)","notes":"Spec refs: data-model.md §UI/UX Changes (owner share + room mode); UX-3 for placement. Depends on UX-3, 2-1 mock-ipc. No 'any' types. No window.confirm/alert/prompt — use in-app modal.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:30:39Z","created_by":"James Lal","updated_at":"2026-05-19T16:06:04Z","started_at":"2026-05-19T15:31:08Z","closed_at":"2026-05-19T16:06:04Z","close_reason":"Round 18: implemented; merged; 414+ Rust tests pass","dependencies":[{"issue_id":"attn-nnj.4.10","depends_on_id":"attn-nnj.10.1","type":"blocks","created_at":"2026-05-18T16:30:59Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.10","depends_on_id":"attn-nnj.10.2","type":"blocks","created_at":"2026-05-18T16:31:04Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.10","depends_on_id":"attn-nnj.10.3","type":"blocks","created_at":"2026-05-18T16:31:10Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.10","depends_on_id":"attn-nnj.12.1","type":"blocks","created_at":"2026-05-18T16:53:48Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.10","depends_on_id":"attn-nnj.12.5","type":"blocks","created_at":"2026-05-18T16:53:59Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.10","depends_on_id":"attn-nnj.4","type":"parent-child","created_at":"2026-05-18T16:30:39Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.10","depends_on_id":"attn-nnj.4.1","type":"blocks","created_at":"2026-05-18T16:31:25Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":6,"dependent_count":1,"comment_count":0} -{"id":"attn-nnj.4.9","title":"Snapshot badge + status row in editor header","description":"Editor header status row showing snapshot state. Owner-side: 'Snapshot current' / 'Snapshot superseded' / 'Reviewer on older snapshot'. Reviewer-side: snapshot age (e.g., '3 min ago') + 'owner is on a newer snapshot' notice. Subscribes to reviewSnapshot updates from the store.","acceptance_criteria":"- Editor header includes snapshot badge row\n- Owner states implemented: current / superseded / reviewer-on-older\n- Reviewer states implemented: age display + newer-snapshot notice\n- Subscribes to reviewSnapshot updates from review store\n- Visual treatment matches planning/collab/ui/presence-identity.md and connection-share.md","notes":"Spec refs: data-model.md §UI/UX Changes (snapshot badge owner/reviewer); UX-5 (presence-identity) for reviewer-on-older treatment. Depends on UX-1, UX-3, 2-2 store. No 'any' types.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:30:39Z","created_by":"James Lal","updated_at":"2026-05-19T16:55:34Z","started_at":"2026-05-19T16:26:11Z","closed_at":"2026-05-19T16:55:34Z","close_reason":"Round 20: implemented; merged; build clean","dependencies":[{"issue_id":"attn-nnj.4.9","depends_on_id":"attn-nnj.10.1","type":"blocks","created_at":"2026-05-18T16:30:58Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.9","depends_on_id":"attn-nnj.10.2","type":"blocks","created_at":"2026-05-18T16:31:03Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.9","depends_on_id":"attn-nnj.10.3","type":"blocks","created_at":"2026-05-18T16:31:10Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.9","depends_on_id":"attn-nnj.10.5","type":"blocks","created_at":"2026-05-18T16:31:45Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.9","depends_on_id":"attn-nnj.12.1","type":"blocks","created_at":"2026-05-18T16:53:47Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.9","depends_on_id":"attn-nnj.12.7","type":"blocks","created_at":"2026-05-18T16:53:52Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.9","depends_on_id":"attn-nnj.4","type":"parent-child","created_at":"2026-05-18T16:30:38Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.9","depends_on_id":"attn-nnj.4.1","type":"blocks","created_at":"2026-05-18T16:31:24Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":7,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj.4.8","title":"Stale comment panel state","description":"When a ResolvedAnchor has status 'stale', the panel renders a status pill 'could not find this text anymore' and a 'Re-anchor manually' affordance that switches the editor into a select-text-in-editor mode; the next text selection re-anchors the comment.","acceptance_criteria":"- Stale-state card shows clear status pill and original anchor preview\n- 'Re-anchor manually' button enters editor select mode\n- Next editor selection re-anchors and exits select mode\n- Cancel/escape exits select mode without re-anchoring\n- Store updates resolution to exact after re-anchor","notes":"Spec refs: data-model.md §ResolvedAnchor status 'stale'; amendments.md Decision #15 ('stale → panel-only, requires manual re-anchor'). Depends on UX-1, 2-2, 2-3 panel. No 'any' types.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:30:38Z","created_by":"James Lal","updated_at":"2026-05-19T16:57:52Z","started_at":"2026-05-19T16:26:10Z","closed_at":"2026-05-19T16:57:52Z","close_reason":"Round 20: implemented (force; design-doc dep)","dependencies":[{"issue_id":"attn-nnj.4.8","depends_on_id":"attn-nnj.10.1","type":"blocks","created_at":"2026-05-18T16:30:58Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.8","depends_on_id":"attn-nnj.10.2","type":"blocks","created_at":"2026-05-18T16:31:03Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.8","depends_on_id":"attn-nnj.10.3","type":"blocks","created_at":"2026-05-18T16:31:09Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.8","depends_on_id":"attn-nnj.4","type":"parent-child","created_at":"2026-05-18T16:30:37Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.8","depends_on_id":"attn-nnj.4.1","type":"blocks","created_at":"2026-05-18T16:31:24Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.8","depends_on_id":"attn-nnj.4.2","type":"blocks","created_at":"2026-05-18T16:31:30Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":5,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj.4.7","title":"Ambiguous anchor picker","description":"When a ResolvedAnchor has status 'ambiguous', the review panel surfaces a picker listing each candidate with its preview text and confidence score. Owner clicks one candidate → emits AnchorManuallyResolved event via IPC, store updates, decoration moves to inline.","acceptance_criteria":"- Ambiguous-state card in panel shows candidate list with preview + confidence\n- Picker UI is keyboard-navigable (arrow keys + enter)\n- Selecting a candidate emits AnchorManuallyResolved via IPC\n- Store transitions the anchor to a non-ambiguous resolution after pick\n- Decoration moves from panel-only to inline after pick","notes":"Spec refs: data-model.md §ResolvedAnchor status 'ambiguous'; amendments.md Decision #15 ('ambiguous → panel-only with picker'). Depends on UX-1, 2-1, 2-2, 2-3 panel. No 'any' types.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:30:37Z","created_by":"James Lal","updated_at":"2026-05-19T16:24:40Z","started_at":"2026-05-19T16:07:54Z","closed_at":"2026-05-19T16:24:40Z","close_reason":"Round 19: implemented; merged; build clean","dependencies":[{"issue_id":"attn-nnj.4.7","depends_on_id":"attn-nnj.10.1","type":"blocks","created_at":"2026-05-18T16:30:57Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.7","depends_on_id":"attn-nnj.10.2","type":"blocks","created_at":"2026-05-18T16:31:01Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.7","depends_on_id":"attn-nnj.10.3","type":"blocks","created_at":"2026-05-18T16:31:09Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.7","depends_on_id":"attn-nnj.12.5","type":"blocks","created_at":"2026-05-18T16:53:58Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.7","depends_on_id":"attn-nnj.12.8","type":"blocks","created_at":"2026-05-18T16:53:55Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.7","depends_on_id":"attn-nnj.4","type":"parent-child","created_at":"2026-05-18T16:30:37Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.7","depends_on_id":"attn-nnj.4.1","type":"blocks","created_at":"2026-05-18T16:31:23Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.7","depends_on_id":"attn-nnj.4.2","type":"blocks","created_at":"2026-05-18T16:31:30Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":7,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj.4.6","title":"Inline decoration system (ProseMirror plugin)","description":"New ProseMirror plugin at web/src/lib/prosemirror/review-decorations.ts. Reads ResolvedAnchor entries from the review store. Emits decorations per amendments.md Decision #15 cutoffs: ≥0.90 inline highlight no badge; 0.70-0.89 inline highlight + 'moved' badge in panel; ambiguous panel-only; stale panel-only. Handles overlap. Hover focuses the corresponding panel entry; clicking a panel entry scrolls editor to the decoration.","acceptance_criteria":"- web/src/lib/prosemirror/review-decorations.ts plugin exists, mirroring existing PM plugin pattern (math/tables/etc)\n- Reads from review store; updates as ResolvedAnchor entries change\n- Cutoffs implemented exactly per amendments.md Decision #15\n- Overlap handling implemented (stacked / layered)\n- Hover decoration ↔ focus panel entry wired both ways\n- Click panel entry → editor scrolls to decoration\n- No 'any' types","notes":"Spec refs: amendments.md Decision #15 (verbatim cutoffs); planning/collab/ui/inline-decorations.md (from UX-2). Existing PM plugin pattern: web/src/lib/prosemirror/{math,tables,code-highlight,code-block-nodeview,mermaid-nodeview}.ts. Depends on UX-2, 2-1 mock-ipc, 2-2 store.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:30:36Z","created_by":"James Lal","updated_at":"2026-05-19T15:29:39Z","started_at":"2026-05-19T15:07:27Z","closed_at":"2026-05-19T15:29:39Z","close_reason":"Implemented; merged; 409 Rust + 237 relay tests pass; store reassembled from 4.6 + 8.3 merge race","dependencies":[{"issue_id":"attn-nnj.4.6","depends_on_id":"attn-nnj.10.1","type":"blocks","created_at":"2026-05-18T16:30:57Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.6","depends_on_id":"attn-nnj.10.2","type":"blocks","created_at":"2026-05-18T16:31:01Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.6","depends_on_id":"attn-nnj.10.3","type":"blocks","created_at":"2026-05-18T16:31:05Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.6","depends_on_id":"attn-nnj.12.2","type":"blocks","created_at":"2026-05-18T16:53:50Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.6","depends_on_id":"attn-nnj.12.7","type":"blocks","created_at":"2026-05-18T16:53:52Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.6","depends_on_id":"attn-nnj.3.5","type":"blocks","created_at":"2026-05-18T16:38:22Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.6","depends_on_id":"attn-nnj.4","type":"parent-child","created_at":"2026-05-18T16:30:35Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.6","depends_on_id":"attn-nnj.4.1","type":"blocks","created_at":"2026-05-18T16:31:22Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.6","depends_on_id":"attn-nnj.4.2","type":"blocks","created_at":"2026-05-18T16:31:29Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":8,"dependent_count":2,"comment_count":0} -{"id":"attn-nnj.4.5","title":"Suggestion composer from selection","description":"ProseMirror selection → 'Suggest' UI offering replace / delete / insert-before / insert-after operations. Captures expectedText automatically for replace and delete from the current selection. Validates non-empty replacement before allowing submit. Submits via IPC review_create_suggestion.","acceptance_criteria":"- Selection surfaces a 'Suggest' affordance distinct from 'Comment'\n- Four operation modes: replace, delete, insert-before, insert-after\n- expectedText auto-captured from selection for replace/delete\n- Empty replacement blocked from submission (with inline error, not alert())\n- Submits via IPC review_create_suggestion; mock-ipc echoes back\n- Cancel/escape closes cleanly","notes":"Spec ref: data-model.md §Suggestion + §UI/UX Changes (suggestion card). Pairs with 2-4 comment composer. Depends on 2-1 mock-ipc, 2-2 store. No window.confirm/alert. No 'any' types.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:30:35Z","created_by":"James Lal","updated_at":"2026-05-19T16:24:24Z","started_at":"2026-05-19T16:07:53Z","closed_at":"2026-05-19T16:24:24Z","close_reason":"Round 19: implemented; merged; build clean","dependencies":[{"issue_id":"attn-nnj.4.5","depends_on_id":"attn-nnj.12.5","type":"blocks","created_at":"2026-05-18T16:53:58Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.5","depends_on_id":"attn-nnj.12.8","type":"blocks","created_at":"2026-05-18T16:53:54Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.5","depends_on_id":"attn-nnj.12.9","type":"blocks","created_at":"2026-05-18T16:53:56Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.5","depends_on_id":"attn-nnj.4","type":"parent-child","created_at":"2026-05-18T16:30:34Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.5","depends_on_id":"attn-nnj.4.1","type":"blocks","created_at":"2026-05-18T16:31:22Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":4,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj.4.4","title":"Comment composer from selection","description":"ProseMirror selection → 'Comment' popover near selection → text body input → submit. Constructs an Anchor from the selection range and calls IPC review_create_comment. For mock IPC, fakes the event back via reviewEvent so the panel updates immediately.","acceptance_criteria":"- Selection in ProseMirror surfaces a 'Comment' affordance (popover or floating button)\n- Body input supports multi-line text\n- Submit builds a valid Anchor (per data-model.md) from the selection\n- Calls IPC review_create_comment; mock-ipc returns and echoes the event\n- Cancel/escape closes without submitting\n- Empty body blocked from submission","notes":"Spec ref: data-model.md §Comment composer from selection and Anchor structure. Wire alongside web/src/lib/Editor.svelte (ProseMirror view). Depends on 2-1 mock-ipc, 2-2 store. No 'any' types.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:30:34Z","created_by":"James Lal","updated_at":"2026-05-19T16:24:08Z","started_at":"2026-05-19T16:07:53Z","closed_at":"2026-05-19T16:24:08Z","close_reason":"Round 19: implemented; merged; build clean","dependencies":[{"issue_id":"attn-nnj.4.4","depends_on_id":"attn-nnj.12.5","type":"blocks","created_at":"2026-05-18T16:53:57Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.4","depends_on_id":"attn-nnj.12.8","type":"blocks","created_at":"2026-05-18T16:53:54Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.4","depends_on_id":"attn-nnj.12.9","type":"blocks","created_at":"2026-05-18T16:53:55Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.4","depends_on_id":"attn-nnj.4","type":"parent-child","created_at":"2026-05-18T16:30:33Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.4","depends_on_id":"attn-nnj.4.1","type":"blocks","created_at":"2026-05-18T16:31:21Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":4,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj.4.3","title":"ReviewMargin.svelte: Google-Docs-style margin sticky cards","description":"Right-margin overlay rendering review cards positioned to vertically align with their anchor in the editor. Replaces the earlier panel-river design after the user pivoted to Google Docs spatial model (cards live next to their anchored text, not in a separate triage list). Includes an 'orphan tray' at the top of the margin for ambiguous/stale cards that have no valid anchor position. Reuses Phase 0c plumbing (review store, types, theme vars, popover helper); only the layout slot from 12.1 needs an overlay adjustment (already on disk, not yet a rewrite).","acceptance_criteria":"- web/src/lib/ReviewPanel.svelte exists and renders from review store\n- Grouping matches planning/collab/ui/review-panel-design.md\n- Comment and suggestion card variants implemented with author/anchor/body/status/actions\n- Empty and loading states implemented\n- Keyboard shortcut registered (consistent with KeyboardShortcutsDialog.svelte)\n- No window.confirm/alert — uses in-app UI only","notes":"Depends on: UX-1 (panel design), 2-2 (store). Existing patterns: Sidebar.svelte for rail, KeyboardShortcutsDialog.svelte for shortcut registration. Svelte 5 runes throughout. No 'any' types.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:30:33Z","created_by":"James Lal","updated_at":"2026-05-19T16:05:48Z","started_at":"2026-05-19T15:31:07Z","closed_at":"2026-05-19T16:05:48Z","close_reason":"Round 18: implemented; merged; 414+ Rust tests pass","dependencies":[{"issue_id":"attn-nnj.4.3","depends_on_id":"attn-nnj.10.1","type":"blocks","created_at":"2026-05-18T16:30:56Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.3","depends_on_id":"attn-nnj.10.2","type":"blocks","created_at":"2026-05-18T16:31:00Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.3","depends_on_id":"attn-nnj.10.3","type":"blocks","created_at":"2026-05-18T16:31:05Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.3","depends_on_id":"attn-nnj.12.1","type":"blocks","created_at":"2026-05-18T16:53:47Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.3","depends_on_id":"attn-nnj.4","type":"parent-child","created_at":"2026-05-18T16:30:33Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.3","depends_on_id":"attn-nnj.4.1","type":"blocks","created_at":"2026-05-18T16:31:21Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.3","depends_on_id":"attn-nnj.4.2","type":"blocks","created_at":"2026-05-18T16:31:28Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":6,"dependent_count":1,"comment_count":0} -{"id":"attn-nnj.4.1","title":"Mock-IPC extension: review event stream","description":"Extend web/src/lib/mock-ipc.ts to emit window.__attn__.reviewStatus(payload), reviewEvent(payload), reviewSnapshot(snapshot), and reviewAnchorResolution(update). Add a replayable scripted scenario JSON in web/src/lib/mock-ipc-scenarios/ that demonstrates: owner edits paragraph → reviewer comments → owner edits more → reviewer's anchor remaps → owner accepts. Enables Phase 2 frontend work to proceed without any Rust crates landing.","acceptance_criteria":"- mock-ipc.ts exposes reviewStatus, reviewEvent, reviewSnapshot, reviewAnchorResolution on window.__attn__\n- web/src/lib/mock-ipc-scenarios/ contains at least one JSON scenario file\n- Replay runs deterministically via dev-tools trigger (button or window helper)\n- Scenario covers owner-edits → reviewer-comments → remap → accept flow\n- Documented in a short README in mock-ipc-scenarios/","notes":"Spec refs: amendments.md §Mock IPC must be extended for parallel frontend dev (line ~74); data-model.md lines 1088-1091 callback list. Existing file to extend: web/src/lib/mock-ipc.ts (100 lines). Use Svelte 5 runes patterns in any new helpers. This unblocks all subsequent Phase 2 issues.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:30:32Z","created_by":"James Lal","updated_at":"2026-05-19T15:30:09Z","started_at":"2026-05-19T15:07:26Z","closed_at":"2026-05-19T15:30:09Z","close_reason":"Implemented; merged; 409 Rust + 237 relay tests pass; store reassembled from 4.6 + 8.3 merge race","dependencies":[{"issue_id":"attn-nnj.4.1","depends_on_id":"attn-nnj.12.6","type":"blocks","created_at":"2026-05-18T16:53:51Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.1","depends_on_id":"attn-nnj.4","type":"parent-child","created_at":"2026-05-18T16:30:31Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":13,"comment_count":0} -{"id":"attn-nnj.4.2","title":"Frontend review store (Svelte 5 runes)","description":"Create web/src/lib/review/store.ts: a $state-based store holding rooms, threads, snapshots, anchorResolutions, and outbox. Subscribes to window.__attn__.reviewStatus/reviewEvent/reviewSnapshot/reviewAnchorResolution callbacks. Provides $derived selectors for 'comments on current file/snapshot', 'ambiguous anchors', and 'outbox count'. No 'any' types — proper TypeScript throughout.","acceptance_criteria":"- web/src/lib/review/store.ts exists with $state-based store\n- Subscribes to all four window.__attn__ review callbacks\n- Exposes derived selectors: commentsOnCurrent, ambiguousAnchors, outboxCount\n- Fully typed (no 'any'); types align with data-model.md ReviewEvent/Anchor/Snapshot shapes\n- Unit-testable shape (pure functions for selectors where possible)","notes":"Spec refs: data-model.md §Frontend Bridge (lines ~1080-1100) for callback contract; §UI/UX Changes for what the store must surface. Use Svelte 5 runes ($state, $derived, $effect). Follow project rule: no 'any' types. Depends on 2-1 mock-ipc extension for runtime emissions.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:30:32Z","created_by":"James Lal","updated_at":"2026-05-19T15:30:23Z","started_at":"2026-05-19T15:07:27Z","closed_at":"2026-05-19T15:30:23Z","close_reason":"Implemented; merged; 409 Rust + 237 relay tests pass; store reassembled from 4.6 + 8.3 merge race","dependencies":[{"issue_id":"attn-nnj.4.2","depends_on_id":"attn-nnj.12.10","type":"blocks","created_at":"2026-05-18T16:53:57Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.2","depends_on_id":"attn-nnj.4","type":"parent-child","created_at":"2026-05-18T16:30:32Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.2","depends_on_id":"attn-nnj.4.1","type":"blocks","created_at":"2026-05-18T16:31:19Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":7,"comment_count":0} -{"id":"attn-nnj.2.8","title":"ReviewManager scaffold + UserEvent::Review event-loop wiring","description":"Scaffold src/review/manager.rs as a struct holding store + working_copy + rooms map. Wire it into the EXISTING tao event loop in src/main.rs by adding a UserEvent::Review(ReviewUpdate) arm — per amendments.md §Phase 0b, do NOT factor out a new event loop. Forwards ReviewUpdates to the webview via window.__attn__.reviewEvent(...).","acceptance_criteria":"- src/review/manager.rs defines `pub struct ReviewManager { store, working_copy, rooms: HashMap\u003cRoomId, RoomRuntime\u003e }` + new() + a tokio mpsc channel for ReviewUpdate\n- src/main.rs UserEvent enum gains a Review(ReviewUpdate) variant\n- The existing event_loop.run match adds an arm: UserEvent::Review(update) =\u003e { webview.evaluate_script(\u0026format!(\"window.__attn__.reviewEvent({})\", serde_json::to_string(\u0026update)?))?; }\n- Manager constructed during daemon startup; channel sender stashed in AppState (or accessible globally)\n- No real room/document logic yet — just lifecycle: manager starts, channel works, a smoke test sends a stub ReviewUpdate and the webview receives it (verified via --eval window.__attn__.lastReviewEvent)\n- ReviewUpdate is a typed enum (not serde_json::Value) — initial variants can be small but explicit","notes":"Spec: planning/collab/amendments.md §Codebase Corrections (main.rs is 1207 lines, not thin) + §Phase 0b (integrates into EXISTING event loop). data-model.md §Review Manager + §Webview IPC Changes for the JS bridge shape. Critical: do NOT introduce a second event loop or factor out main.rs's. Add to the existing match arms only.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:30:14Z","created_by":"James Lal","updated_at":"2026-05-19T01:33:11Z","started_at":"2026-05-19T00:47:50Z","closed_at":"2026-05-19T01:33:11Z","close_reason":"Implemented; merged into collab; 212 Rust tests + 98 relay tests pass","dependencies":[{"issue_id":"attn-nnj.2.8","depends_on_id":"attn-nnj.2","type":"parent-child","created_at":"2026-05-18T16:30:14Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.2.8","depends_on_id":"attn-nnj.2.1","type":"blocks","created_at":"2026-05-18T16:30:26Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.2.8","depends_on_id":"attn-nnj.2.2","type":"blocks","created_at":"2026-05-18T16:30:28Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.2.8","depends_on_id":"attn-nnj.2.3","type":"blocks","created_at":"2026-05-18T16:30:29Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.2.8","depends_on_id":"attn-nnj.2.7","type":"blocks","created_at":"2026-05-18T16:30:30Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.2.8","depends_on_id":"attn-nnj.2.9","type":"blocks","created_at":"2026-05-18T16:54:01Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":5,"dependent_count":2,"comment_count":0} -{"id":"attn-nnj.2.7","title":"AppState refactor: review_rooms HashMap + file_to_room routing","description":"Refactor AppState in src/ipc.rs per amendments.md §Codebase Corrections: AppState becomes routing context holding `review_rooms: HashMap\u003cRoomId, RoomRuntimeHandle\u003e` and `file_to_room: HashMap\u003cPathBuf, RoomId\u003e`. Heavy state lives in ReviewManager — AppState just looks up which room a file belongs to.","acceptance_criteria":"- src/ipc.rs AppState fields: review_rooms: HashMap\u003cRoomId, RoomRuntimeHandle\u003e, file_to_room: HashMap\u003cPathBuf, RoomId\u003e (in addition to existing tab/project state)\n- RoomRuntimeHandle is a lightweight Arc/channel-sender to the manager (NOT the full ReviewRoom struct)\n- All AppState construction sites + call sites updated\n- Lookup helper: AppState::room_for_path(\u0026Path) -\u003e Option\u003cRoomRuntimeHandle\u003e\n- Existing IPC handlers compile and still pass tests\n- No flat list of rooms anywhere — file_to_room is the only path→room oracle","notes":"Spec: planning/collab/amendments.md §Codebase Corrections (Tabs and projects are first-class; the plan's AppState is wrong). This DIVERGES from the original data-model.md AppState design — amendments wins. RoomRuntimeHandle is defined here as a thin handle (clonable, Send+Sync); the real ReviewManager fills in the actual struct in issue 8.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:29:59Z","created_by":"James Lal","updated_at":"2026-05-19T00:23:59Z","started_at":"2026-05-19T00:04:13Z","closed_at":"2026-05-19T00:23:59Z","close_reason":"Implemented in parallel worktrees; merged into collab; 142 tests pass","dependencies":[{"issue_id":"attn-nnj.2.7","depends_on_id":"attn-nnj.2","type":"parent-child","created_at":"2026-05-18T16:29:58Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.2.7","depends_on_id":"attn-nnj.2.1","type":"blocks","created_at":"2026-05-18T16:30:25Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} -{"id":"attn-nnj.5.4","title":"Hashcash PoW verification + replay protection","description":"relay/src/pow.ts: verify the Attn-PoW header on every write endpoint (POST /devices, POST /envelopes, POST /acks, POST /blobs, DELETE). Token is 8 colon-separated fields, v2 format. Validation order per crypto-spec.md §Server Validation. Replay protection via meta:pow_seen:\u003cexpiresAt\u003e:\u003chash\u003e DO storage entries; a scheduled alarm prunes entries past expiresAt+10min. No client type is exempt (decision #6); default difficulty 16 bits, hard floor 12.","acceptance_criteria":"- Parse the 8-field v2 token; reject unknown version\n- Validation in order: parse → v==v2 → difficulty\u003e=max(policy.powBits,12) → expiresAt within 5-min window (not past, not \u003e5min future) → resource matches (roomId, deviceId, base64url(SHA-256(METHOD + space + PATH))[:8]) → leading-zero bits in SHA-256(token bytes) → not in pow_seen replay set\n- On success: insert meta:pow_seen:\u003cexpiresAt\u003e:\u003chash\u003e with TTL\n- Errors map to ATTN_POW_INVALID (parse/format), ATTN_POW_INSUFFICIENT_DIFFICULTY, ATTN_POW_EXPIRED, ATTN_POW_RESOURCE_MISMATCH, ATTN_POW_REPLAYED — all 403\n- Single PoW token per HTTP request — for batch /envelopes the same token covers the whole batch (decision #7)\n- Alarm-driven prune loop removes pow_seen entries with expiresAt+10min \u003c now\n- Unit tests use vectors from crypto-spec.md §Test Vectors","notes":"Spec: planning/collab/crypto-spec.md §Hashcash Proof-of-Work (lines 117-197), §Server Validation (152-165), §Replay Protection (166-169). amendments.md decisions #6 (universal PoW) and #7 (single token per batch). Used by all write endpoints — implement as composable middleware.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:29:51Z","created_by":"James Lal","updated_at":"2026-05-19T01:16:27Z","started_at":"2026-05-19T00:47:51Z","closed_at":"2026-05-19T01:16:27Z","close_reason":"Implemented in parallel worktrees; merged into collab","dependencies":[{"issue_id":"attn-nnj.5.4","depends_on_id":"attn-nnj.1.2","type":"blocks","created_at":"2026-05-18T16:38:23Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.4","depends_on_id":"attn-nnj.5","type":"parent-child","created_at":"2026-05-18T16:29:51Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.4","depends_on_id":"attn-nnj.5.1","type":"blocks","created_at":"2026-05-18T16:35:24Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":9,"comment_count":0} -{"id":"attn-nnj.5.3","title":"Owner-signature verification","description":"relay/src/owner-sig.ts: verify the Attn-Owner-Signature header (base64url Ed25519 signature over the same canonicalRequest used for admission HMAC: METHOD\\nPATH\\nCANONICAL_QUERY\\nSHA256(body)). Verifies against ownerSigningKey stored at room creation; mismatching key returns 403 ATTN_OWNER_KEY_MISMATCH. Required for DELETE /v2/rooms/:roomId at all times, and required for POST /v2/rooms/:roomId/acks when the request asks for deletion AND policy.deleteEventsAfterOwnerAck==true. Missing header on a path that requires it returns 403 ATTN_OWNER_SIG_REQUIRED.","acceptance_criteria":"- Canonical bytes match the admission canonicalRequest construction (single shared helper)\n- Verify Ed25519 sig with @noble/ed25519 or Web Crypto subtle\n- Required: DELETE /v2/rooms/:roomId (always)\n- Required: POST /acks when delete=true requested AND policy.deleteEventsAfterOwnerAck==true\n- 403 ATTN_OWNER_SIG_REQUIRED when header missing on a required path\n- 403 ATTN_OWNER_KEY_MISMATCH when signature does not verify against the stored ownerSigningKey\n- Stored ownerSigningKeyId is base64url(SHA-256(ownerSigningKey)) — exposed in room policy responses\n- Unit tests cover valid sig, wrong key, tampered body, missing header, non-owner action attempt","notes":"Spec: planning/collab/relay-spec.md §Identity, Keys, and Admission \u003e Owner Distinction (lines 69-74), §POST /v2/rooms/:roomId (114-167), §POST /v2/rooms/:roomId/acks (277-303), §DELETE /v2/rooms/:roomId (305-311). amendments.md decision #3 (owner-only ops). Shares canonicalRequest helper with admission middleware.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:29:50Z","created_by":"James Lal","updated_at":"2026-05-19T01:16:13Z","started_at":"2026-05-19T00:47:51Z","closed_at":"2026-05-19T01:16:13Z","close_reason":"Implemented in parallel worktrees; merged into collab","dependencies":[{"issue_id":"attn-nnj.5.3","depends_on_id":"attn-nnj.5","type":"parent-child","created_at":"2026-05-18T16:29:50Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.3","depends_on_id":"attn-nnj.5.1","type":"blocks","created_at":"2026-05-18T16:35:23Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} -{"id":"attn-nnj.5.2","title":"Admission HMAC verification module","description":"relay/src/admission.ts: middleware that verifies the Attn-Admission header on every authenticated endpoint. HMAC-SHA256 covers METHOD\\nPATH\\nCANONICAL_QUERY\\nSHA256(body). Trust model is URL-as-bearer (decision #2): the relay derives the admissionKey from material the client supplies at room creation and stores it on the room record; subsequent requests must present a matching HMAC. Wrong HMAC returns 401 with error code ATTN_ADMISSION_INVALID.","acceptance_criteria":"- Canonical string per spec: METHOD\\nPATH\\nCANONICAL_QUERY (sorted, percent-encoded)\\nSHA256(body) hex\n- HMAC constant-time compared; failure returns 401 {error:{code:'ATTN_ADMISSION_INVALID'}}\n- Admission key is loaded from room storage; missing room → 404 ATTN_ROOM_NOT_FOUND (before admission check to avoid timing oracle? — actually per spec admission failure must NOT leak room existence: return 401 for missing-room too)\n- Unit tests cover: wrong HMAC, missing header, body tampering, query reordering, missing room (uniform 401)\n- Exported as a Hono/itty middleware reused by all authenticated routes","notes":"Spec: planning/collab/relay-spec.md §Identity, Keys, and Admission (lines 37-67) and §Wire Conventions (89-99). Decision #2 in amendments.md (URL-as-bearer trust model). This middleware is the gatekeeper for every endpoint except POST /v2/rooms/:roomId (room creation, which establishes the admission key) and GET /health.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:29:49Z","created_by":"James Lal","updated_at":"2026-05-19T00:40:28Z","started_at":"2026-05-19T00:26:07Z","closed_at":"2026-05-19T00:40:28Z","close_reason":"Implemented in parallel; merged into collab; 166 tests pass + relay 35 tests pass","dependencies":[{"issue_id":"attn-nnj.5.2","depends_on_id":"attn-nnj.5","type":"parent-child","created_at":"2026-05-18T16:29:49Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.2","depends_on_id":"attn-nnj.5.1","type":"blocks","created_at":"2026-05-18T16:35:22Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":9,"comment_count":0} -{"id":"attn-nnj.2.6","title":"Watcher self-write distinction with TTL-tracked hashes","description":"Teach src/watcher.rs to distinguish between our own writes (via WorkingCopyService) and external file changes (editor, git checkout). Tracks recent self-writes by (path, ContentHash) with a short TTL; matching events attach to the existing LocalRevision, others emit a new ExternalFileChange revision.","acceptance_criteria":"- src/watcher.rs maintains a HashMap\u003c(PathBuf, ContentHash), Instant\u003e of recent self-writes\n- WorkingCopyService::save inserts into this map immediately after a successful write\n- TTL configurable, default 5s; expired entries pruned on access\n- On notify event: compute new ContentHash; if (path, hash) hit → attach to existing LocalRevision (no new journal entry); else emit a new LocalRevision{source: ExternalFileChange} via the store + existing reload signal\n- Existing reload-the-webview behavior is preserved end-to-end\n- Unit tests cover: self-write skipped, external write journaled, TTL expiry causes external-classification, repeated identical external content still journaled once","notes":"Spec: planning/collab/data-model.md §File Watcher Integration. The watcher already debounces; reuse that. Don't journal from inside the watcher directly — call store.append_revision via a channel to the ReviewManager so single-writer ordering holds.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:29:46Z","created_by":"James Lal","updated_at":"2026-05-19T01:33:24Z","started_at":"2026-05-19T01:17:14Z","closed_at":"2026-05-19T01:33:24Z","close_reason":"Implemented; merged into collab; 212 Rust tests + 98 relay tests pass","dependencies":[{"issue_id":"attn-nnj.2.6","depends_on_id":"attn-nnj.2","type":"parent-child","created_at":"2026-05-18T16:29:45Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.2.6","depends_on_id":"attn-nnj.2.1","type":"blocks","created_at":"2026-05-18T16:30:25Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.2.6","depends_on_id":"attn-nnj.2.5","type":"blocks","created_at":"2026-05-18T16:30:30Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj.2.5","title":"Revision journal appended on every WorkingCopyService save","description":"On every WorkingCopyService::save, append a LocalRevision to ~/.attn/reviews/rooms/\u003croomId\u003e/revisions/\u003cfileId\u003e.jsonl. Captures parentHash → nextHash + optional pmSteps/patchText so future anchor code can replay the document history.","acceptance_criteria":"- WorkingCopyService::save appends a LocalRevision JSONL line per call\n- LocalRevision fields: revisionId, fileId, parentHash, nextHash, source (SaveSource), timestamp, optional pmSteps, optional patchText\n- Source variants wired: UserEdit, CheckboxToggle, ExternalFileChange, SnapshotLoaded, ManualReanchor (AcceptedSuggestion stub is fine — Phase 5 wires it)\n- File path: ~/.attn/reviews/rooms/\u003croomId\u003e/revisions/\u003cfileId\u003e.jsonl\n- Append is atomic per line (single write syscall + fsync)\n- Unit tests cover: single save → single revision, sequential saves produce parentHash chain, replay reads back identical sequence","notes":"Spec: planning/collab/data-model.md §Local Replicas (LocalRevision struct) + §Working Copy Service. pmSteps/patchText are optional now — Phase 1 anchor engine fills them in. RoomId routing comes from AppState (Phase 0b issue 7); for save calls outside any room, use a sentinel \"orphan\" or skip journaling.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:29:33Z","created_by":"James Lal","updated_at":"2026-05-19T01:15:47Z","started_at":"2026-05-19T00:47:49Z","closed_at":"2026-05-19T01:15:47Z","close_reason":"Implemented in parallel worktrees; merged into collab","dependencies":[{"issue_id":"attn-nnj.2.5","depends_on_id":"attn-nnj.2","type":"parent-child","created_at":"2026-05-18T16:29:33Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.2.5","depends_on_id":"attn-nnj.2.1","type":"blocks","created_at":"2026-05-18T16:30:24Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.2.5","depends_on_id":"attn-nnj.2.2","type":"blocks","created_at":"2026-05-18T16:30:27Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.2.5","depends_on_id":"attn-nnj.2.4","type":"blocks","created_at":"2026-05-18T16:30:29Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":3,"dependent_count":1,"comment_count":0} -{"id":"attn-nnj.8.5","title":"Emit SuggestionAccepted and SuggestionRejected","description":"After a successful apply (clean or three-way accepted), ReviewManager constructs a SuggestionAccepted ReviewEvent referencing the LocalRevision id and the SaveResult.resultingHash, signs+encrypts it, and enqueues it on the outbox for relay/DataChannel delivery. The reject path emits SuggestionRejected with an optional reason. Both flow through the same outbox machinery as comment events.","acceptance_criteria":"- src/review/manager.rs on apply success builds SuggestionAccepted { suggestionId, appliedRevisionId, resultingHash } matching data-model.md §Suggestion Events.\n- On reject (owner picked 'Keep current'), builds SuggestionRejected { suggestionId, reason } where reason is optional and may come from the UI.\n- Events are signed with the owner's signing key, encrypted under eventKey, and appended to outbox.jsonl exactly like comment events (single code path).\n- meta.parentEventIds includes the original SuggestionCreated event id so receivers can reconstruct the thread.\n- Unit test: simulate full apply path, assert one SuggestionAccepted envelope sits in outbox with the expected fields; simulate reject path, assert one SuggestionRejected envelope.","notes":"Spec: planning/collab/data-model.md §Suggestion Events (lines 628-640). Files: src/review/manager.rs, src/review/outbox.rs. Reuse the existing outbox enqueue path — do not introduce a parallel writer. Outbox mutability rule from amendments.md §Outbox mutability and freezing applies (these events are immutable once first-send-attempted).","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:29:25Z","created_by":"James Lal","updated_at":"2026-05-19T15:06:00Z","started_at":"2026-05-19T04:31:05Z","closed_at":"2026-05-19T15:06:00Z","close_reason":"Implemented; merged; 412 Rust + 213 relay tests pass (6 conformance scenarios deferred to 5.16)","dependencies":[{"issue_id":"attn-nnj.8.5","depends_on_id":"attn-nnj.8","type":"parent-child","created_at":"2026-05-18T16:29:24Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.8.5","depends_on_id":"attn-nnj.8.4","type":"blocks","created_at":"2026-05-18T16:29:53Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} -{"id":"attn-nnj.8.4","title":"Apply through WorkingCopyService","description":"When the owner accepts a suggestion (clean apply or three-way 'accept'/'accept_edited'), the resulting bytes must be written to the file via WorkingCopyService::save with SaveSource::AcceptedSuggestion { room_id, suggestion_id }. This records a LocalRevision in the journal so the watcher distinguishes the apply from an external edit and so the SaveResult.resultingHash can feed the SuggestionAccepted event.","acceptance_criteria":"- src/review/apply.rs apply_accepted(verdict_or_edited_bytes, source: SaveSource::AcceptedSuggestion { room_id, suggestion_id }) calls WorkingCopyService::save and returns a SaveResult with resultingHash.\n- The LocalRevision entry created has source=AcceptedSuggestion and references both room_id and suggestion_id so the revision journal can be queried by room.\n- File watcher sees its own write and suppresses the reload bounce (existing self-write distinction in src/watcher.rs).\n- After write, the editor's in-memory document updates without losing the user's cursor (PM transaction rather than a full reload where possible).\n- Integration test in src/review/apply.rs writes a fixture file, applies a suggestion, asserts both file contents and a new LocalRevision entry.","notes":"Spec: planning/collab/data-model.md §Suggestion Events apply flow steps 5-6 (lines 648-649) + amendments.md §watcher.rs does more than reload (line ~21). Files: src/review/apply.rs, src/working_copy.rs (Phase 0b). WorkingCopyService::save is the only write path — never std::fs::write directly.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:29:22Z","created_by":"James Lal","updated_at":"2026-05-19T04:28:54Z","started_at":"2026-05-19T04:09:05Z","closed_at":"2026-05-19T04:28:54Z","close_reason":"Round 13: implemented; merged; all tests pass","dependencies":[{"issue_id":"attn-nnj.8.4","depends_on_id":"attn-nnj.2.4","type":"blocks","created_at":"2026-05-18T16:38:30Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.8.4","depends_on_id":"attn-nnj.8","type":"parent-child","created_at":"2026-05-18T16:29:21Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.8.4","depends_on_id":"attn-nnj.8.1","type":"blocks","created_at":"2026-05-18T16:29:52Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0} -{"id":"attn-nnj.5.1","title":"Relay package scaffold + Wrangler config","description":"Create the relay/ workspace package that hosts the Cloudflare Worker: package.json, tsconfig.json, wrangler.toml, src/index.ts (router stub), src/room-do.ts (Durable Object stub), src/schema.ts (zod request/response validators), and test/ folder layout. wrangler dev --local must boot Miniflare with the RELAY_ROOMS Durable Object binding and RELAY_BLOBS R2 bucket binding so subsequent issues can integration-test against it.","acceptance_criteria":"- relay/ directory exists with package.json, tsconfig.json, wrangler.toml, src/{index,room-do,schema}.ts, test/{integration,conformance}/ scaffolding\n- wrangler.toml: compatibility_date=2026-01-01, RELAY_ROOMS Durable Object class binding, RELAY_BLOBS R2 binding, HARD_MAX_ROOM_BYTES / HARD_MAX_EVENT_BYTES / HARD_MAX_SNAPSHOT_BYTES env vars set per spec\n- zod schemas typecheck against the request/response shapes in relay-spec.md\n- 'wrangler dev --local' boots Miniflare cleanly with the DO and R2 stub mounted; GET /health returns 200\n- npm test wires through to a vitest runner pointed at test/","notes":"Spec: planning/collab/relay-spec.md §Deployment (lines 614-685) for wrangler.toml sketch and repo layout. §Caps (Server Hard Maxima) for HARD_MAX_* values. This issue blocks every other 3a issue — keep it strictly to scaffolding (no business logic). Repo currently has no relay/ folder.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:29:21Z","created_by":"James Lal","updated_at":"2026-05-18T23:57:41Z","started_at":"2026-05-18T23:49:36Z","closed_at":"2026-05-18T23:57:41Z","close_reason":"Implemented; merged into collab","dependencies":[{"issue_id":"attn-nnj.5.1","depends_on_id":"attn-nnj.5","type":"parent-child","created_at":"2026-05-18T16:29:21Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":15,"comment_count":0} -{"id":"attn-nnj.2.4","title":"WorkingCopyService replacing direct fs::write","description":"Introduce src/review/working_copy.rs as the single chokepoint for every markdown-file save. Replaces direct std::fs::write calls in src/ipc.rs (EditSave + checkbox toggle). Hashes content on every save so downstream revision/anchor code has a stable identity.","acceptance_criteria":"- src/review/working_copy.rs exposes `save(req: SaveRequest) -\u003e Result\u003cSaveResult\u003e`\n- SaveRequest { path: PathBuf, content: String, expected_hash: Option\u003cContentHash\u003e, source: SaveSource }\n- SaveResult { previous_hash: ContentHash, next_hash: ContentHash, revision_id: RevisionId }\n- SaveSource enum: UserEdit, CheckboxToggle, AcceptedSuggestion, ExternalFileChange, SnapshotLoaded, ManualReanchor\n- ContentHash computed per crypto-spec.md §ContentHash (canonical UTF-8, no BOM, LF line endings, preserve trailing-newline as authored)\n- expected_hash mismatch → returns ConflictError without writing\n- src/ipc.rs EditSave and checkbox-toggle paths use WorkingCopyService::save instead of std::fs::write\n- Unit tests cover: happy save, expected_hash mismatch, hash determinism across line-ending normalization","notes":"Spec: planning/collab/data-model.md §Working Copy Service + crypto-spec.md §ContentHash. The revision_id returned here gets persisted by the revision-journal issue. Keep this synchronous for now (single-writer).","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:29:20Z","created_by":"James Lal","updated_at":"2026-05-19T00:39:55Z","started_at":"2026-05-19T00:26:06Z","closed_at":"2026-05-19T00:39:55Z","close_reason":"Implemented in parallel; merged into collab; 166 tests pass + relay 35 tests pass","dependencies":[{"issue_id":"attn-nnj.2.4","depends_on_id":"attn-nnj.2","type":"parent-child","created_at":"2026-05-18T16:29:20Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.2.4","depends_on_id":"attn-nnj.2.1","type":"blocks","created_at":"2026-05-18T16:30:23Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.2.4","depends_on_id":"attn-nnj.2.2","type":"blocks","created_at":"2026-05-18T16:30:27Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":2,"comment_count":0} -{"id":"attn-nnj.8.3","title":"Three-way apply UI dialog (Svelte)","description":"Svelte 5 component that surfaces ApplyVerdict::RequiresThreeWay to the owner. Shows the snapshot text (what the suggester saw), the current owner text (what's there now), and the proposed replacement, side-by-side. Owner picks: accept proposed, keep current, or edit manually. The component returns the owner's choice via IPC back to ReviewManager.","acceptance_criteria":"- web/src/lib/ReviewApplyDialog.svelte renders three panes (snapshot | current | proposed) with monospace diff styling.\n- Props: { suggestionId, snapshotText, currentText, proposedText, anchorContext }.\n- Actions: 'Accept proposed' (returns 'accept'), 'Keep current' (returns 'reject'), 'Edit manually' (opens an inline editor with the proposed text as the starting buffer, returns 'accept_edited' with the edited string).\n- Built with Svelte 5 runes (, , ); no Svelte 4 patterns. No window.confirm / alert per project conventions — fully in-app UI.\n- Result flows to ReviewManager via window.__attn__.reviewSubmitApplyChoice(suggestionId, choice, editedText?).\n- Storybook-style demo route or mock-ipc fixture so the dialog renders standalone.","notes":"Spec: planning/collab/data-model.md §Suggestion Events apply flow step 3 (line 646). Follow svelte5-best-practices skill conventions. Files: web/src/lib/ReviewApplyDialog.svelte. Existing component patterns: look at the review panel pieces from Phase 2 work. Project rule: no window.confirm/alert — use proper in-app UI.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:29:06Z","created_by":"James Lal","updated_at":"2026-05-19T15:29:54Z","started_at":"2026-05-19T15:07:28Z","closed_at":"2026-05-19T15:29:54Z","close_reason":"Implemented; merged; 409 Rust + 237 relay tests pass; store reassembled from 4.6 + 8.3 merge race","dependencies":[{"issue_id":"attn-nnj.8.3","depends_on_id":"attn-nnj.10.4","type":"blocks","created_at":"2026-05-18T16:38:30Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.8.3","depends_on_id":"attn-nnj.8","type":"parent-child","created_at":"2026-05-18T16:29:05Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.8.3","depends_on_id":"attn-nnj.8.1","type":"blocks","created_at":"2026-05-18T16:29:52Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj.2.3","title":"Local JSON/JSONL store at ~/.attn/reviews/","description":"Implement src/review/store.rs as the on-disk persistence layer for rooms, events, snapshots, outbox, and revisions. Atomic writes via temp-file+rename for JSON; append-only JSONL for event/outbox/revision logs. Idempotent on EventId and EnvelopeId so repeated imports are safe.","acceptance_criteria":"- Directory layout matches data-model.md §Local Review Store exactly (rooms/\u003croomId\u003e/{room.json, devices/, snapshots/, events.jsonl, outbox.jsonl, revisions/\u003cfileId\u003e.jsonl, replicas/\u003creplicaId\u003e/...})\n- All JSON writes are atomic: write to .tmp then rename\n- JSONL writes are append-only and fsync'd per append\n- import_event(EventId, ...) is idempotent — duplicate EventId is a no-op\n- enqueue_outbox(EnvelopeId, ...) is idempotent on EnvelopeId\n- Every top-level JSON file includes a `schemaVersion` field\n- Unit tests cover: fresh-init, repeat-import, partial-write recovery, concurrent appender safety (single writer)","notes":"Spec: planning/collab/data-model.md §Local Replicas + §Local Review Store layout (search §Local Review Store in data-model.md). Use std::fs + serde_json. No async yet; called from a single ReviewManager task. ~/.attn/ already exists for the daemon socket — extend with reviews/ subtree.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:29:01Z","created_by":"James Lal","updated_at":"2026-05-19T00:00:28Z","started_at":"2026-05-18T23:49:34Z","closed_at":"2026-05-19T00:00:28Z","close_reason":"Implemented; merged into collab; 9 store tests pass","dependencies":[{"issue_id":"attn-nnj.2.3","depends_on_id":"attn-nnj.2","type":"parent-child","created_at":"2026-05-18T16:29:01Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.2.3","depends_on_id":"attn-nnj.2.1","type":"blocks","created_at":"2026-05-18T16:30:23Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.2.3","depends_on_id":"attn-nnj.2.2","type":"blocks","created_at":"2026-05-18T16:30:26Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0} -{"id":"attn-nnj.8.2","title":"Expected-text verification","description":"Tight unit tests around SuggestionOperation.expectedText vs current owner text. A false positive (verifying equal when the bytes differ) corrupts the user's file silently. expectedText was captured against the snapshot's exact bytes, so verification must be byte-identical with no Unicode normalization, no whitespace folding, and explicit line-ending handling.","acceptance_criteria":"- src/review/apply.rs has a private verify_expected_text(current: \u0026str, expected: \u0026str) -\u003e bool helper used by the suggestion resolver.\n- Unit tests cover: identical bytes -\u003e true; trailing-whitespace diff -\u003e false; CRLF vs LF diff -\u003e false (ContentHash normalizes to LF on write, but expectedText was captured exactly as the snapshot bytes — they must match exactly); NFC vs NFD unicode -\u003e false (no normalization); BOM present in one only -\u003e false; empty-string vs empty-string -\u003e true.\n- Property-style test: for random inputs s, verify_expected_text(s, s) == true and verify_expected_text(s, s + 'x') == false.\n- Documented in a module-level comment that this function intentionally does no normalization.","notes":"Spec: planning/collab/data-model.md §Suggestion Events apply flow step 2 (line 645). Critical correctness path — please err on the side of more tests. See also crypto-spec.md ContentHash normalization (writes LF; expectedText captured from snapshot bytes pre-normalization).","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:28:57Z","created_by":"James Lal","updated_at":"2026-05-19T04:08:29Z","started_at":"2026-05-19T03:42:01Z","closed_at":"2026-05-19T04:08:29Z","close_reason":"Implemented; merged into collab; 346 Rust + 168 relay tests pass","dependencies":[{"issue_id":"attn-nnj.8.2","depends_on_id":"attn-nnj.8","type":"parent-child","created_at":"2026-05-18T16:28:57Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.8.2","depends_on_id":"attn-nnj.8.1","type":"blocks","created_at":"2026-05-18T16:29:51Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj.8.1","title":"Suggestion resolver (Rust)","description":"Given a SuggestionCreated event and the current owner DocumentReplica, resolve the suggestion's Anchor through the Phase 1 anchor engine, then evaluate the SuggestionOperation. Produces an ApplyVerdict that drives the apply UI: clean apply, three-way merge, ambiguous picker, or stale. This is the core decision point of the Phase 5 apply flow.","acceptance_criteria":"- src/review/apply.rs exposes resolve_suggestion(event: \u0026SuggestionCreated, replica: \u0026DocumentReplica) -\u003e ApplyVerdict.\n- ApplyVerdict variants: Ready { range: PositionAnchor, replacement: String, op_kind }, RequiresThreeWay { range, snapshot_text, current_text, replacement }, Stale { reason }, Ambiguous { candidates }.\n- For SuggestionOperation::Replace/Delete: anchor resolves with status in {exact, remapped} AND current text at the resolved range equals expectedText -\u003e Ready; if anchor resolves but current text differs -\u003e RequiresThreeWay; if anchor is ambiguous -\u003e Ambiguous; if stale -\u003e Stale.\n- For SuggestionOperation::InsertBefore/InsertAfter: anchor must be unambiguous (exact or remapped); ambiguous/stale flow to Ambiguous/Stale.\n- Unit tests cover each verdict path with realistic fixtures.","notes":"Spec: planning/collab/data-model.md §Suggestion Events apply flow (lines 642-650). Files: src/review/apply.rs. Depends on the Phase 1 Rust resolver (attn-nnj.3.4) — added as cross-phase dep by parent agent. Pure function, no I/O, no UI — UI lives in the three-way dialog issue.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:28:49Z","created_by":"James Lal","updated_at":"2026-05-19T04:06:42Z","started_at":"2026-05-19T03:23:02Z","closed_at":"2026-05-19T04:06:42Z","close_reason":"Implemented; merged into collab; 346 Rust + 168 relay tests pass","dependencies":[{"issue_id":"attn-nnj.8.1","depends_on_id":"attn-nnj.3.4","type":"blocks","created_at":"2026-05-18T16:38:29Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.8.1","depends_on_id":"attn-nnj.8","type":"parent-child","created_at":"2026-05-18T16:28:49Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"comment_count":0} -{"id":"attn-nnj.2.2","title":"Serde model types for review domain","description":"Define all serde structs in src/review/model.rs covering rooms, participants, devices, documents, snapshots, replicas, revisions, events, envelopes, and sync cursors. JSON field names match data-model.md camelCase exactly so any future browser/CLI can read the same files.","acceptance_criteria":"- src/review/model.rs defines: ReviewRoom, RoomPolicy, Participant, Device, SharedDocument, SnapshotNode, BlobRef, DocumentReplica, ReplicaRelation, LocalRevision, ReviewEvent (with EventMeta + Body + Auth submodels), MailboxEnvelope, SyncCursor, DeliveryAck\n- ReviewEventBody is a tagged enum with variants: RoomCreated, ParticipantJoined, SnapshotCreated, SnapshotSuperseded, CommentCreated, CommentResolved, SuggestionCreated, SuggestionAccepted, SuggestionRejected, AnchorManuallyResolved, PresenceUpdated, SessionEnded\n- All structs use #[serde(rename_all = \"camelCase\")] (or per-field rename) so JSON matches data-model.md\n- Roundtrip test: every variant serializes → deserializes byte-identical\n- Zero use of `any` / `serde_json::Value` except where the spec explicitly says opaque payload","notes":"Spec: planning/collab/data-model.md §Terms, §Review Events (all subsections), §Encrypted Envelopes, §Sync Cursors And ACKs. Use #[serde(tag = \"kind\")] for the event body enum. Use the typed ID newtypes from Phase 0a issue 8 (RoomId, FileId, EventId, etc.) — they're already serde-transparent.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:28:48Z","created_by":"James Lal","updated_at":"2026-05-18T23:45:35Z","started_at":"2026-05-18T23:31:44Z","closed_at":"2026-05-18T23:45:35Z","close_reason":"Implemented in parallel worktrees; merged into collab","dependencies":[{"issue_id":"attn-nnj.2.2","depends_on_id":"attn-nnj.2","type":"parent-child","created_at":"2026-05-18T16:28:48Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.2.2","depends_on_id":"attn-nnj.2.1","type":"blocks","created_at":"2026-05-18T16:30:22Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":6,"comment_count":0} -{"id":"attn-nnj.10.3","title":"Design: connection + share affordances","description":"Decide where the share button, connection badge, and peer strip live in the toolbar/header. Toolbar real estate is already contended by theme toggle, edit toggle, and command palette. Determine owner-only vs reviewer-only affordances and overflow behavior. Output planning/collab/ui/connection-share.md with proposed layout.","acceptance_criteria":"- planning/collab/ui/connection-share.md exists with annotated layout\n- Resolves placement of: share button, connection badge (Live direct / Mailbox / Offline / Direct failed), peer strip\n- Distinguishes owner-only vs reviewer-only affordances\n- Notes interaction with existing toolbar (theme toggle, edit toggle, command palette)\n- Flagged for human review before share/connection coding begins","notes":"Spec refs: data-model.md §UI/UX Changes (owner: share + room mode + connection + peer strip; reviewer: outbox + owner-offline state). Existing toolbar: search web/src/lib/ for theme toggle and edit toggle to inventory current real estate. Output path: planning/collab/ui/connection-share.md. Blocks Phase 2 share, connection-badge, peer-strip issues.","status":"closed","priority":1,"issue_type":"decision","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:28:45Z","created_by":"James Lal","updated_at":"2026-05-19T17:58:58Z","started_at":"2026-05-18T23:58:12Z","closed_at":"2026-05-19T17:58:58Z","close_reason":"Design docs landed in planning/collab/ui/ for ongoing reference","labels":["human"],"dependencies":[{"issue_id":"attn-nnj.10.3","depends_on_id":"attn-nnj.10","type":"parent-child","created_at":"2026-05-18T16:28:44Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":9,"comment_count":0} -{"id":"attn-nnj.10.2","title":"Design: inline decoration system in ProseMirror","description":"Design how the four anchor states render in the editor surface: exact (\u003e=0.90), remapped+moved (0.70-0.89), ambiguous (panel-only), and stale (panel-only). Decide between underline / highlight / margin marker treatments, hover affordance, and click-to-focus-panel interaction. Output planning/collab/ui/inline-decorations.md with concrete CSS and PM Decoration sketches.","acceptance_criteria":"- planning/collab/ui/inline-decorations.md exists with concrete CSS + PM Decoration sketches per state\n- Confidence cutoffs from amendments.md Decision #15 quoted verbatim\n- Hover and click-to-focus behaviors specified\n- Overlap handling addressed (multiple decorations covering same range)\n- Flagged for human review before Phase 2 decoration plugin coding","notes":"Spec refs: amendments.md Decision #15 (UI cutoffs: \u003e=0.90 inline no badge, 0.70-0.89 inline + 'moved' badge, ambiguous panel-only, stale panel-only). Existing PM plugins: web/src/lib/prosemirror/{math,tables,code-highlight,code-block-nodeview,mermaid-nodeview}.ts — mirror their decoration plugin pattern. Output path: planning/collab/ui/inline-decorations.md. Blocks Phase 2 decoration plugin issue.","status":"closed","priority":1,"issue_type":"decision","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:28:44Z","created_by":"James Lal","updated_at":"2026-05-19T17:58:44Z","started_at":"2026-05-18T23:49:36Z","closed_at":"2026-05-19T17:58:44Z","close_reason":"Design docs landed in planning/collab/ui/ for ongoing reference","labels":["human"],"dependencies":[{"issue_id":"attn-nnj.10.2","depends_on_id":"attn-nnj.10","type":"parent-child","created_at":"2026-05-18T16:28:44Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":7,"comment_count":0} -{"id":"attn-nnj.10.1","title":"Design: review margin layout \u0026 sticky-card model (was: panel layout)","description":"REWRITTEN after user pivot from panel-river to Google-Docs-style margin sticky cards. The original panel-river exploration is kept as context (3 candidates explored) but the recommendation now: margin overlay with cards vertically anchored, orphan tray for ambiguous/stale, decorations from 10.2 click-to-focus the margin card. Update the doc at planning/collab/ui/review-panel-design.md in place.","acceptance_criteria":"- planning/collab/ui/review-panel-design.md exists with 2-3 ASCII mockups\n- Recommendation called out explicitly with rationale\n- Covers: grouping (file/snapshot/thread), resolved collapse, density at 30 comments, picker shape for ambiguous\n- Cross-references data-model.md §UI/UX Changes\n- Flagged for human review before Phase 2 panel coding begins","notes":"Spec refs: planning/collab/data-model.md §UI/UX Changes (lines ~776+); amendments.md Decision #15 cutoffs. Relevant existing files: web/src/lib/Sidebar.svelte (rail patterns), web/src/lib/CommandPalette.svelte (overlay patterns). Output path: planning/collab/ui/review-panel-design.md. This blocks Phase 2 panel/ambiguous/stale/decoration issues.","status":"closed","priority":1,"issue_type":"decision","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:28:43Z","created_by":"James Lal","updated_at":"2026-05-19T01:15:34Z","started_at":"2026-05-18T23:21:24Z","closed_at":"2026-05-19T01:15:34Z","close_reason":"Implemented in parallel worktrees; merged into collab","labels":["human"],"dependencies":[{"issue_id":"attn-nnj.10.1","depends_on_id":"attn-nnj.10","type":"parent-child","created_at":"2026-05-18T16:28:43Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":7,"comment_count":0} -{"id":"attn-nnj.3.8","title":"window.__attn__.reviewAnchorResolution IPC callback","description":"Wire ResolvedAnchor updates from the Rust ReviewManager to the frontend whenever a snapshot lands or the owner's document changes. The frontend store subscribes and re-runs the TS resolver mirror to produce inline decorations. This is the live channel that keeps comment highlights stable across edits.","acceptance_criteria":"- ReviewManager invokes evaluate_script(window, 'window.__attn__.reviewAnchorResolution(...)') with the latest ResolvedAnchor batch on: (a) new SnapshotCreated arriving, (b) new comment/suggestion event arriving, (c) owner save / WorkingCopy revision producing a new currentHash.\n- The IPC payload is JSON: { fileId, roomId, resolutions: [{ eventId, resolved: ResolvedAnchor }] }.\n- web/src/lib/review/store.ts (or equivalent) exposes a reactive store the Editor.svelte review extension subscribes to.\n- The TS resolver mirror runs locally on every PM transaction to update decorations between Rust pushes (no flicker).\n- Extended mock-ipc.ts emits the same callback shape so frontend dev works without a running daemon (per amendments.md §Mock IPC must be extended).","notes":"Spec: planning/collab/data-model.md §Anchor Resolution + planning/collab/amendments.md §Mock IPC must be extended (line ~76). Files: src/review/manager.rs, src/ipc.rs, web/src/lib/review/store.ts, web/src/lib/mock-ipc.ts. Rust side reuses the existing evaluate_script pattern used by other __attn__ callbacks. Frontend should debounce its own re-resolves so a burst of PM transactions doesn't thrash decorations.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:28:34Z","created_by":"James Lal","updated_at":"2026-05-19T02:30:01Z","started_at":"2026-05-19T01:57:29Z","closed_at":"2026-05-19T02:30:01Z","close_reason":"Implemented (3.8+round-10 retries); merged into collab; 267 Rust + 144 relay tests pass","dependencies":[{"issue_id":"attn-nnj.3.8","depends_on_id":"attn-nnj.2.8","type":"blocks","created_at":"2026-05-18T16:38:21Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.3.8","depends_on_id":"attn-nnj.3","type":"parent-child","created_at":"2026-05-18T16:28:33Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.3.8","depends_on_id":"attn-nnj.3.1","type":"blocks","created_at":"2026-05-18T16:29:46Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj.2.1","title":"Crate layout: src/review/ module skeleton","description":"Create the src/review/ module tree as empty stubs with module-level docs taken from data-model.md §New Rust Modules. Wires `mod review;` into src/main.rs so subsequent issues have a place to land code.","acceptance_criteria":"- src/review/{mod.rs, ids.rs, model.rs, store.rs, working_copy.rs, manager.rs, transport.rs, apply.rs, ipc.rs} exist\n- Each file has a module-level //! doc comment summarizing its responsibility (verbatim from data-model.md §New Rust Modules where applicable)\n- src/main.rs declares `mod review;` and compiles\n- `cargo check` passes; no warnings about unused modules (use #[allow(dead_code)] on stubs)\n- No business logic yet — pure scaffolding","notes":"Spec: planning/collab/data-model.md §Rust Architecture Changes §New Rust Modules. Keep mod.rs as just `pub mod ...;` re-exports. The crypto/ subdir lives separately (owned by Phase 0a).","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:28:33Z","created_by":"James Lal","updated_at":"2026-05-18T23:29:27Z","started_at":"2026-05-18T23:21:23Z","closed_at":"2026-05-18T23:29:27Z","close_reason":"Implemented via parallel worktree agents; merged into collab","dependencies":[{"issue_id":"attn-nnj.2.1","depends_on_id":"attn-nnj.2","type":"parent-child","created_at":"2026-05-18T16:28:32Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":8,"comment_count":0} -{"id":"attn-nnj.3.6","title":"Markdown-edit anchor test corpus","description":"Hand-curated test corpus that pins resolver behavior across the realistic markdown edit shapes a reviewer will encounter. The same corpus drives both the Rust and TS resolvers (same Anchor input + same AnchorIndex inputs -\u003e same ResolvedAnchor output). Catching disagreement here is the only way to prove the two impls stay in lockstep.","acceptance_criteria":"- planning/collab/test-vectors/anchor-cases/ contains ~50 numbered case directories.\n- Each case has original.md, edited.md, anchor.json (the Anchor produced from a selection in original.md), and expected.json (the expected ResolvedAnchor verdict against edited.md).\n- Coverage includes: exact byte match, paragraph reordered, heading renamed, list item inserted before, code-block reflow (whitespace inside fence), quote unchanged but block split into two, ambiguous duplicate paragraphs, fully deleted (stale), math/mermaid round-trip, structure-only block-level anchor, fuzzy quote with one-word change, line-proximity-only fallback.\n- A test runner in src/review/anchors/tests.rs and a vitest spec in web/src/lib/review/resolver.test.ts iterate the corpus and assert the Rust + TS resolvers each produce the expected verdict (status + reason + currentRange).\n- README.md in anchor-cases/ documents the corpus contract.","notes":"Spec: planning/collab/data-model.md §Anchor Resolution + planning/collab/amendments.md Decision #15. Build the corpus as JSON-on-disk so both languages consume it without a code-gen step. The math/mermaid cases require the index builder's Decision #16 work to land first. Cases that depend on local pmSteps mapping should include a stepsJournal.json (omit otherwise).","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:28:21Z","created_by":"James Lal","updated_at":"2026-05-19T01:56:01Z","started_at":"2026-05-19T01:36:53Z","closed_at":"2026-05-19T01:56:01Z","close_reason":"Implemented; merged into collab; 254 Rust tests + 130 relay tests pass; corpus replay green","dependencies":[{"issue_id":"attn-nnj.3.6","depends_on_id":"attn-nnj.3","type":"parent-child","created_at":"2026-05-18T16:28:21Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.3.6","depends_on_id":"attn-nnj.3.4","type":"blocks","created_at":"2026-05-18T16:29:47Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.3.6","depends_on_id":"attn-nnj.3.5","type":"blocks","created_at":"2026-05-18T16:29:47Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0} -{"id":"attn-nnj.3.5","title":"Anchor resolver — TS mirror impl for inline decorations","description":"Mirror the Rust resolver in TypeScript so the frontend can drive ProseMirror decorations as the user edits, without round-tripping through Rust on every keystroke. Operates purely on plaintext data — the frontend only ever holds decrypted ReviewEvents (Rust does decrypt + verify). Must produce identical ResolvedAnchor verdicts as the Rust resolver for every case in the test corpus.","acceptance_criteria":"- web/src/lib/review/resolver.ts exports resolve(anchor: Anchor, currentIndex: AnchorIndex, pmState: EditorState, localSteps?: Step[]) -\u003e ResolvedAnchor.\n- All eight resolution steps implemented matching the Rust algorithm; combine + dedup logic identical.\n- Same confidence weights and verdict cutoffs as the Rust impl (shared constants exported so both call sites agree).\n- For every case in the markdown-edit test corpus, the TS verdict matches the Rust verdict exactly (status, reason, currentRange).\n- Vitest unit tests cover the same happy paths and ambiguous-threshold edge cases as the Rust tests.","notes":"Spec: planning/collab/data-model.md §Anchor Resolution + planning/collab/amendments.md Decision #15. Drive inline ProseMirror decorations only — apply / verification stay in Rust. Files: web/src/lib/review/resolver.ts. PM step mapping uses the existing prosemirror-transform Step.map API; pmRange is derived locally and not persisted. Confidence weight constants should mirror the Rust constants (consider generating them from a shared JSON in test-vectors/ to prevent drift).","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:28:00Z","created_by":"James Lal","updated_at":"2026-05-19T01:55:48Z","started_at":"2026-05-19T01:36:52Z","closed_at":"2026-05-19T01:55:48Z","close_reason":"Implemented; merged into collab; 254 Rust tests + 130 relay tests pass; corpus replay green","dependencies":[{"issue_id":"attn-nnj.3.5","depends_on_id":"attn-nnj.3","type":"parent-child","created_at":"2026-05-18T16:27:59Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.3.5","depends_on_id":"attn-nnj.3.1","type":"blocks","created_at":"2026-05-18T16:29:44Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"comment_count":0} -{"id":"attn-nnj.1.9","title":"Envelope assemble/disassemble end-to-end integration test","description":"Integration test that exercises the full crypto stack: build ReviewEvent → canonicalize → sign → AEAD-encrypt → wrap in MailboxEnvelope JSON → parse → decrypt → verify signature → recover original ReviewEvent. Locks the contract between issues 3-8.","acceptance_criteria":"- tests/review_crypto_envelope.rs (or src/review/crypto/tests.rs) contains the full roundtrip\n- Test uses planning/collab/test-vectors/envelope.json as both input and expected output\n- Asserts: re-serialized envelope is byte-identical to fixture; decrypted body matches original ReviewEvent; signature verifies; signingKeyId matches\n- Tamper tests: flipping any byte in ciphertext/AAD/signature → explicit error (not silent corruption)\n- Test runs in `cargo test` without network or filesystem deps","notes":"Spec: planning/collab/crypto-spec.md §What Is Signed vs. Encrypted + §Envelope Encryption. This is the canary that integrates HKDF + AEAD + Ed25519 + canonical-JSON + IDs. If any of those change subtly, this test breaks. Keep fixtures in test-vectors/envelope.json (shared with future browser impl).","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:27:52Z","created_by":"James Lal","updated_at":"2026-05-19T00:39:39Z","started_at":"2026-05-19T00:26:05Z","closed_at":"2026-05-19T00:39:39Z","close_reason":"Implemented in parallel; merged into collab; 166 tests pass + relay 35 tests pass","dependencies":[{"issue_id":"attn-nnj.1.9","depends_on_id":"attn-nnj.1","type":"parent-child","created_at":"2026-05-18T16:27:52Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.1.9","depends_on_id":"attn-nnj.1.1","type":"blocks","created_at":"2026-05-18T16:28:10Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.1.9","depends_on_id":"attn-nnj.1.2","type":"blocks","created_at":"2026-05-18T16:28:14Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.1.9","depends_on_id":"attn-nnj.1.5","type":"blocks","created_at":"2026-05-18T16:28:18Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.1.9","depends_on_id":"attn-nnj.1.6","type":"blocks","created_at":"2026-05-18T16:28:19Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.1.9","depends_on_id":"attn-nnj.1.8","type":"blocks","created_at":"2026-05-18T16:28:19Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":5,"dependent_count":1,"comment_count":0} -{"id":"attn-nnj.3.4","title":"Anchor resolver — run-all-and-combine policy (Rust)","description":"Canonical Rust anchor resolver per Decision #15. Given an Anchor (from a decrypted ReviewEvent), the current AnchorIndex, and an optional local pmSteps journal, run every applicable resolution step, dedup candidates by currentRange, and emit a ResolvedAnchor. This is the authoritative verdict used by the apply flow (Phase 5) and surfaced to the frontend via reviewAnchorResolution IPC.","acceptance_criteria":"- src/review/anchors/resolve.rs exposes resolve(anchor: \u0026Anchor, current_index: \u0026AnchorIndex, local_steps: Option\u003c\u0026PmStepJournal\u003e) -\u003e ResolvedAnchor matching data-model.md §Anchor Resolution.\n- All eight steps run: (1) base_hash match -\u003e exact 1.00; (2) mapped pm steps -\u003e exact 0.98; (3) unique exact quote -\u003e remapped 0.90; (4) block fingerprint -\u003e remapped 0.85; (5) structure + quote -\u003e remapped 0.80; (6) context (prefix/quote/suffix) -\u003e remapped 0.70; (7) bounded fuzzy quote -\u003e remapped 0.50-0.75; (8) line proximity -\u003e 0..=0.35.\n- Candidates from all steps are combined into a single set deduped by currentRange (highest confidence wins on dupes).\n- Verdict rules per Decision #15: exactly one candidate \u003e=0.70 -\u003e remapped/exact; two+ candidates \u003e=0.70 within 0.10 of each other -\u003e ambiguous with all candidates \u003e=0.50; otherwise top candidate \u003e=0.35 -\u003e remapped (low-confidence); else stale.\n- Pure function, no I/O, no crypto. Confidence weights live in a single constant so the calibration task can tune them.\n- Unit tests cover each step's happy path + the ambiguous threshold boundary at 0.09/0.10/0.11.","notes":"Spec: planning/collab/data-model.md §Anchor Resolution (lines 443-513) + planning/collab/amendments.md Decision #15 (line ~331) + §Anchor resolver disagreement policy (line ~110). Files: src/review/anchors/resolve.rs. The pmSteps journal type comes from Phase 0b LocalRevision work. Confidence numbers from data-model.md lines 491-506 ship as starting values; do not hard-code them inline — put them behind a ConfidenceWeights struct for the calibration task.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:27:45Z","created_by":"James Lal","updated_at":"2026-05-19T01:55:35Z","started_at":"2026-05-19T01:36:52Z","closed_at":"2026-05-19T01:55:35Z","close_reason":"Implemented; merged into collab; 254 Rust tests + 130 relay tests pass; corpus replay green","dependencies":[{"issue_id":"attn-nnj.3.4","depends_on_id":"attn-nnj.2.2","type":"blocks","created_at":"2026-05-18T16:38:21Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.3.4","depends_on_id":"attn-nnj.3","type":"parent-child","created_at":"2026-05-18T16:27:44Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.3.4","depends_on_id":"attn-nnj.3.1","type":"blocks","created_at":"2026-05-18T16:29:44Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":2,"comment_count":0} -{"id":"attn-nnj.1.8","title":"ID helpers: RoomId, FileId, EventId, EnvelopeId, SnapshotId, ContentHash","description":"Typed newtypes + derivation functions for every ID/hash in the system. EventId is computed from canonical(body + meta-without-eventId) then written back into meta.eventId. EnvelopeId form differs by envelope kind. Use \"attn file v2\" prefix per amendments.md (NOT \"attn file\").","acceptance_criteria":"- src/review/ids.rs defines newtypes: RoomId, FileId, EventId, EnvelopeId, SnapshotId, ContentHash, ParticipantId, DeviceId (each wraps String with base64url-no-pad)\n- derive_event_id(body, meta_without_event_id) -\u003e EventId per crypto-spec.md §EventId\n- derive_envelope_id(kind, body) -\u003e EnvelopeId per §EnvelopeId (event kind uses eventId-based deterministic form; signal/snapshot_blob use clientNonce)\n- derive_file_id(...) uses prefix \"attn file v2\" per amendments.md §Codebase Corrections (NOT \"attn file\")\n- derive_snapshot_id, derive_content_hash match their spec sections\n- planning/collab/test-vectors/event-id.json + envelope.json populated with (inputs, expected_id) tuples\n- Roundtrip tests pass against both fixtures","notes":"Spec: planning/collab/crypto-spec.md §ID Construction (all subsections) + amendments.md §Codebase Corrections (file prefix correction). All IDs serialize as base64url no-pad strings via serde. ContentHash per §ContentHash: canonical UTF-8 markdown bytes, no BOM, LF line endings, preserve trailing-newline as authored.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:27:37Z","created_by":"James Lal","updated_at":"2026-05-19T00:23:30Z","started_at":"2026-05-19T00:04:12Z","closed_at":"2026-05-19T00:23:30Z","close_reason":"Implemented in parallel worktrees; merged into collab; 142 tests pass","dependencies":[{"issue_id":"attn-nnj.1.8","depends_on_id":"attn-nnj.1","type":"parent-child","created_at":"2026-05-18T16:27:36Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.1.8","depends_on_id":"attn-nnj.1.1","type":"blocks","created_at":"2026-05-18T16:28:09Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.1.8","depends_on_id":"attn-nnj.1.2","type":"blocks","created_at":"2026-05-18T16:28:14Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.1.8","depends_on_id":"attn-nnj.1.3","type":"blocks","created_at":"2026-05-18T16:28:17Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":3,"dependent_count":2,"comment_count":0} -{"id":"attn-nnj.3.3","title":"Anchor construction from selection (frontend)","description":"Build the layered Anchor used by review events from a ProseMirror selection plus the AnchorIndex received in the most recent SnapshotCreated event. Produces all available layers — position from selection, quote from selected text, block lookup from the index, bounded context prefix/suffix, structure from headingPath — then hands the plaintext Anchor up to Rust via IPC for signing+encryption. The frontend never sees ciphertext.","acceptance_criteria":"- web/src/lib/review/anchors.ts exports buildAnchor(selection, anchorIndex, fileId, snapshotId, baseHash) -\u003e Anchor matching data-model.md §Anchors schema.\n- position: byteRange + lineRange + pmRange computed from the current ProseMirror selection.\n- quote: exact + exactHash + normalized + normalizedHash for non-empty selections; omitted for block-level comments.\n- block: looked up from the AnchorIndex by covering byteRange; carries snapshotBlockId, contentFingerprint, kind, offsetInBlockBytes, blockByteRange, blockLineRange.\n- context.prefix and context.suffix are bounded to at most 160 characters (data-model.md §Anchors bounded plaintext fields).\n- Unit tests with vitest cover: inline selection inside a paragraph, selection spanning two paragraphs, block-level (caret-only) anchor, selection at file start/end, selection inside a code block.","notes":"Spec: planning/collab/data-model.md §Anchors (lines 381-441). The AnchorIndex arrives via window.__attn__.reviewSnapshot(...) callbacks (mocked in web/src/lib/mock-ipc.ts during Phase 2). Files: web/src/lib/review/anchors.ts. Hashes use the same canonical sha256 helpers as Rust (web/src/lib/review/crypto.ts from Phase 0a). Send the constructed Anchor to Rust via window.__attn__.reviewSubmit or equivalent IPC — actual signing/encrypting happens in ReviewManager.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:27:33Z","created_by":"James Lal","updated_at":"2026-05-19T02:29:48Z","started_at":"2026-05-19T01:57:29Z","closed_at":"2026-05-19T02:29:48Z","close_reason":"Implemented (3.8+round-10 retries); merged into collab; 267 Rust + 144 relay tests pass","dependencies":[{"issue_id":"attn-nnj.3.3","depends_on_id":"attn-nnj.3","type":"parent-child","created_at":"2026-05-18T16:27:33Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.3.3","depends_on_id":"attn-nnj.3.1","type":"blocks","created_at":"2026-05-18T16:29:43Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj.1.7","title":"Hashcash PoW mint + verify with per-method/path token pool","description":"Implement hashcash proof-of-work tokens per crypto-spec.md §Hashcash. Mint runs off-thread via tokio::task::spawn_blocking (cancellable). Maintain a small per-(method,path) token pool so common writes don't block on cold mints.","acceptance_criteria":"- src/review/crypto/pow.rs exposes `mint(resource, difficulty) -\u003e Future\u003cToken\u003e` and `verify(token, resource, difficulty) -\u003e Result\u003c()\u003e`\n- Token format matches crypto-spec.md §Token Format byte-for-byte (version, resource, salt, counter, hash)\n- Mint executes inside tokio::task::spawn_blocking; cancellation drops the task cleanly\n- Per-(method, path) token pool with configurable max-size (e.g., 4 per slot); replenishes lazily\n- Default difficulty 16; room override accepted in [12, 24] inclusive (reject outside)\n- planning/collab/test-vectors/pow.json populated with (resource, difficulty, valid_token, invalid_tokens)\n- Roundtrip + invalid-difficulty + tampered-resource tests pass","notes":"Spec: planning/collab/crypto-spec.md §Hashcash Proof-of-Work (§Token Format, §Hash Function, §Difficulty, §Server Validation, §Replay Protection, §Client Implementation). Hash function is SHA-256 over canonical token bytes. Bits checked are leading zero bits of the digest. Pool is a HashMap\u003c(String, String), VecDeque\u003cToken\u003e\u003e.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:27:25Z","created_by":"James Lal","updated_at":"2026-05-19T00:23:15Z","started_at":"2026-05-19T00:04:11Z","closed_at":"2026-05-19T00:23:15Z","close_reason":"Implemented in parallel worktrees; merged into collab; 142 tests pass","dependencies":[{"issue_id":"attn-nnj.1.7","depends_on_id":"attn-nnj.1","type":"parent-child","created_at":"2026-05-18T16:27:24Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.1.7","depends_on_id":"attn-nnj.1.1","type":"blocks","created_at":"2026-05-18T16:28:09Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.1.7","depends_on_id":"attn-nnj.1.2","type":"blocks","created_at":"2026-05-18T16:28:13Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.1.7","depends_on_id":"attn-nnj.1.3","type":"blocks","created_at":"2026-05-18T16:28:16Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":3,"dependent_count":1,"comment_count":0} -{"id":"attn-nnj.1.6","title":"Ed25519 sign/verify wrapper","description":"Wrap ed25519-dalek v2 for signing the canonical bytes of (eventMeta || eventBody). Includes signing-key-ID verification: signingKeyId must equal SHA-256(publicSigningKey).","acceptance_criteria":"- src/review/crypto/signature.rs exposes `sign(signing_key, meta, body) -\u003e Signature` and `verify(public_key, meta, body, signature) -\u003e Result\u003c()\u003e`\n- Signed bytes = canonicalize(meta) || canonicalize(body) per crypto-spec.md §Signatures §Canonical Bytes for Signature\n- verify also checks signingKeyId field == base64url-no-pad(SHA-256(public_key_bytes)) and rejects mismatch\n- planning/collab/test-vectors/event-signature.json populated with deterministic (private_key, meta, body, signature, signingKeyId) tuples\n- Roundtrip + bad-signature + wrong-key-id tests pass against fixture","notes":"Spec: planning/collab/crypto-spec.md §Signatures. Use ed25519_dalek::SigningKey and VerifyingKey. SigningKey impls Zeroize. Canonicalization is via the JCS helper from issue 3. SignatureId formatting uses base64url no-pad.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:27:12Z","created_by":"James Lal","updated_at":"2026-05-19T00:23:00Z","started_at":"2026-05-19T00:04:11Z","closed_at":"2026-05-19T00:23:00Z","close_reason":"Implemented in parallel worktrees; merged into collab; 142 tests pass","dependencies":[{"issue_id":"attn-nnj.1.6","depends_on_id":"attn-nnj.1","type":"parent-child","created_at":"2026-05-18T16:27:12Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.1.6","depends_on_id":"attn-nnj.1.1","type":"blocks","created_at":"2026-05-18T16:28:08Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.1.6","depends_on_id":"attn-nnj.1.2","type":"blocks","created_at":"2026-05-18T16:28:12Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.1.6","depends_on_id":"attn-nnj.1.3","type":"blocks","created_at":"2026-05-18T16:28:16Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":3,"dependent_count":1,"comment_count":0} -{"id":"attn-nnj.3.1","title":"AnchorIndex builder (Rust, comrak-based)","description":"Build the canonical AnchorIndex in Rust from a snapshot's UTF-8 markdown bytes. This is the authoritative anchor index per amendments.md Phase 1 Decision — the frontend never hashes; it only receives the pre-computed AnchorIndex inside SnapshotCreated events and uses it for resolution. Walks the comrak AST and emits AnchorBlock entries that the resolver and the inline-decoration pipeline depend on.","acceptance_criteria":"- src/review/anchors/index.rs exposes a pure function (markdown bytes, snapshot_id) -\u003e AnchorIndex matching data-model.md §Anchor Index schema (docHash, canonicalEncoding='utf8-bytes', lineCount, blocks[], headings[]).\n- Each AnchorBlock has: kind (heading|paragraph|list_item|code_block|blockquote|table|thematic_break|html|math|mermaid|unknown), byteRange, lineRange, headingPath, ordinalInParent, duplicateOrdinal, textHash, normalizedTextHash, previousBlockHash, nextBlockHash, contentFingerprint, snapshotBlockId.\n- contentFingerprint = sha256(kind || normalizedText || headingPath || duplicateOrdinal); snapshotBlockId = sha256(snapshotId || byteRange || contentFingerprint).\n- Duplicate paragraphs/list-items get distinct duplicateOrdinal values; identical content in different headingPaths yields distinct contentFingerprints.\n- Unit tests cover empty docs, single-block docs, nested-heading docs, duplicate paragraphs, and a fixture from tests/fixtures/.","notes":"Spec: planning/collab/data-model.md §Anchor Index (lines 314-379) + planning/collab/amendments.md Phase 1 Decision (line ~242). Use comrak's AST (existing dep in src/markdown.rs). Sibling crate sha2 (already in Cargo.toml per amendments.md Phase 0a). Files: src/review/anchors/index.rs, src/review/anchors/mod.rs. The 'pmRange' field on AnchorBlock is optional and not populated here (frontend-only derivation).","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:27:08Z","created_by":"James Lal","updated_at":"2026-05-19T01:16:00Z","started_at":"2026-05-19T00:47:50Z","closed_at":"2026-05-19T01:16:00Z","close_reason":"Implemented in parallel worktrees; merged into collab","dependencies":[{"issue_id":"attn-nnj.3.1","depends_on_id":"attn-nnj.1.8","type":"blocks","created_at":"2026-05-18T16:38:20Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.3.1","depends_on_id":"attn-nnj.3","type":"parent-child","created_at":"2026-05-18T16:27:07Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":5,"comment_count":0} -{"id":"attn-nnj.1.5","title":"AEAD wrapper (XChaCha20-Poly1305 with AAD)","description":"Wrap XChaCha20-Poly1305 with the envelope-binding AAD format from crypto-spec.md. The AAD ties ciphertext to envelope metadata so a misrouted envelope decrypts to a tag failure rather than silent corruption.","acceptance_criteria":"- src/review/crypto/aead.rs exposes `seal(key, plaintext, aad) -\u003e (nonce, ciphertext)` and `open(key, nonce, ciphertext, aad) -\u003e Result\u003cVec\u003cu8\u003e\u003e`\n- Nonce is 24 bytes from `getrandom` (random, not counter)\n- AAD is canonical JSON of {v, roomId, envelopeId, kind, authorId, deviceId, createdAt} per crypto-spec.md §Envelope Encryption\n- Tampered AAD or ciphertext → Open returns explicit AeadError\n- planning/collab/test-vectors/aead.json populated with (key, nonce, aad, plaintext, ciphertext) tuples\n- Roundtrip + tamper tests pass against fixture","notes":"Spec: planning/collab/crypto-spec.md §Envelope Encryption (AEAD) §Nonce Discipline. Use chacha20poly1305::XChaCha20Poly1305. Key/nonce types should be wrappers that Zeroize. The AAD canonicalization MUST use the canonical JSON helper from issue 3.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:27:02Z","created_by":"James Lal","updated_at":"2026-05-19T00:22:45Z","started_at":"2026-05-19T00:04:10Z","closed_at":"2026-05-19T00:22:45Z","close_reason":"Implemented in parallel worktrees; merged into collab; 142 tests pass","dependencies":[{"issue_id":"attn-nnj.1.5","depends_on_id":"attn-nnj.1","type":"parent-child","created_at":"2026-05-18T16:27:01Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.1.5","depends_on_id":"attn-nnj.1.1","type":"blocks","created_at":"2026-05-18T16:28:08Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.1.5","depends_on_id":"attn-nnj.1.2","type":"blocks","created_at":"2026-05-18T16:28:12Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.1.5","depends_on_id":"attn-nnj.1.3","type":"blocks","created_at":"2026-05-18T16:28:15Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.1.5","depends_on_id":"attn-nnj.1.4","type":"blocks","created_at":"2026-05-18T16:28:18Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":4,"dependent_count":1,"comment_count":0} -{"id":"attn-nnj.1.4","title":"HKDF wrapper + room key derivation","description":"Wrap HKDF-SHA-256 and derive the five per-room subkeys from a 32-byte room secret. Info strings must match crypto-spec.md byte-for-byte so a future browser impl produces identical keys.","acceptance_criteria":"- src/review/crypto/kdf.rs exposes `derive_room_keys(room_secret: \u0026[u8; 32]) -\u003e RoomKeys`\n- RoomKeys struct fields: root_key, event_key, snapshot_key, signaling_key, admission_key (each 32 bytes, zeroize on drop)\n- Info strings match spec exactly: \"attn room root v2\", \"attn room event v2\", \"attn room snapshot v2\", \"attn room signaling v2\", \"attn room admission v2\"\n- planning/collab/test-vectors/kdf.json populated with deterministic vectors (fixed room_secret → exact derived keys hex)\n- Roundtrip test verifies all 5 keys match fixture","notes":"Spec: planning/collab/crypto-spec.md §Key Derivation. Use hkdf crate with Sha256. Salt is empty (or zero-filled per HKDF-Extract spec). RoomKeys impls Zeroize + Drop.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:26:37Z","created_by":"James Lal","updated_at":"2026-05-19T00:22:31Z","started_at":"2026-05-19T00:04:10Z","closed_at":"2026-05-19T00:22:31Z","close_reason":"Implemented in parallel worktrees; merged into collab; 142 tests pass","dependencies":[{"issue_id":"attn-nnj.1.4","depends_on_id":"attn-nnj.1","type":"parent-child","created_at":"2026-05-18T16:26:37Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.1.4","depends_on_id":"attn-nnj.1.1","type":"blocks","created_at":"2026-05-18T16:28:07Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.1.4","depends_on_id":"attn-nnj.1.2","type":"blocks","created_at":"2026-05-18T16:28:11Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.1.4","depends_on_id":"attn-nnj.1.3","type":"blocks","created_at":"2026-05-18T16:28:15Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":3,"dependent_count":1,"comment_count":0} -{"id":"attn-nnj.1.3","title":"Canonical JSON helper (RFC 8785 JCS)","description":"Implement RFC 8785 JSON Canonicalization Scheme in src/review/crypto/canonical.rs. Used by every signature and AEAD AAD computation, so determinism is non-negotiable. Generates test vectors into canonical-json.jsonl.","acceptance_criteria":"- src/review/crypto/canonical.rs exposes `canonicalize(value: \u0026serde_json::Value) -\u003e Vec\u003cu8\u003e`\n- Object keys sorted ASCII-ascending; no whitespace; UTF-8 no BOM\n- Integers only in signed payloads — floats reject with explicit error\n- Absent fields are omitted (never serialized as `\"key\": null`)\n- planning/collab/test-vectors/canonical-json.jsonl populated with edge cases (unicode keys, nested objects, integer boundaries, escape sequences)\n- Roundtrip test: every vector parses, re-canonicalizes byte-identical","notes":"Spec: planning/collab/crypto-spec.md §Canonical JSON (RFC 8785 JCS). Don't use serde_json's default serializer — it doesn't sort keys. Either use a BTreeMap intermediate or implement a custom Serializer. Reject NaN/Infinity. UTF-16 surrogate handling per JCS.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:26:27Z","created_by":"James Lal","updated_at":"2026-05-19T00:03:53Z","started_at":"2026-05-18T23:49:35Z","closed_at":"2026-05-19T00:03:53Z","close_reason":"Implemented; merged into collab; 23 canonical tests + corpus filled","dependencies":[{"issue_id":"attn-nnj.1.3","depends_on_id":"attn-nnj.1","type":"parent-child","created_at":"2026-05-18T16:26:27Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.1.3","depends_on_id":"attn-nnj.1.1","type":"blocks","created_at":"2026-05-18T16:28:06Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.1.3","depends_on_id":"attn-nnj.1.2","type":"blocks","created_at":"2026-05-18T16:28:10Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":5,"comment_count":0} -{"id":"attn-nnj.1.2","title":"Test-vector corpus directory + schema headers","description":"Create planning/collab/test-vectors/ as the canonical contract that all crypto implementations (Rust now, browser/WASM later) must satisfy. Each file gets a documented schema header so future revalidators know what to expect.","acceptance_criteria":"- planning/collab/test-vectors/ exists with: kdf.json, canonical-json.jsonl, event-signature.json, event-id.json, aead.json, envelope.json, pow.json\n- Each file has a top-level schema comment / metadata block describing field semantics + spec section reference\n- README.md in test-vectors/ explains how to regenerate and how to validate\n- Files are placeholders (empty arrays / empty .jsonl) — actual vectors are filled in by issues 3-9","notes":"Spec: planning/collab/crypto-spec.md §Test Vectors §Test Vectors (to ship in the repo). JSONL means newline-delimited JSON. The Rust impl writes these on `cargo test --features generate-vectors` (or similar) and reads them on every test run.","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:26:16Z","created_by":"James Lal","updated_at":"2026-05-18T23:44:55Z","closed_at":"2026-05-18T23:44:55Z","close_reason":"Implemented in parallel worktrees; merged into collab","dependencies":[{"issue_id":"attn-nnj.1.2","depends_on_id":"attn-nnj.1","type":"parent-child","created_at":"2026-05-18T16:26:16Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":8,"comment_count":0} -{"id":"attn-nnj.1.1","title":"Cargo deps + binary-size baseline for crypto crate","description":"Add Rust crypto dependencies to Cargo.toml and record binary-size baseline. This establishes the dependency footprint before any crypto code lands so we can compare against the Phase 4 webrtc-rs cost.","acceptance_criteria":"- Cargo.toml adds: sha2, hkdf, chacha20poly1305, ed25519-dalek v2, base64 (URL_SAFE_NO_PAD), getrandom, zeroize\n- `cargo tree -e features --no-default-features --no-dev-dependencies` output captured in notes\n- Release binary size (cargo build --release) recorded as baseline for Phase 4 comparison\n- `task dev` and `cargo check` both pass with new deps\n- No code uses the deps yet — just declared","notes":"Spec: planning/collab/crypto-spec.md §Primitives §Rust crates. Pin versions exactly. Use `base64::engine::general_purpose::URL_SAFE_NO_PAD` (no padding). zeroize is for SecretKey/Drop impls.","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:26:06Z","created_by":"James Lal","updated_at":"2026-05-18T23:44:40Z","closed_at":"2026-05-18T23:44:40Z","close_reason":"Implemented in parallel worktrees; merged into collab","dependencies":[{"issue_id":"attn-nnj.1.1","depends_on_id":"attn-nnj.1","type":"parent-child","created_at":"2026-05-18T16:26:06Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":7,"comment_count":0} -{"id":"attn-nnj.10","title":"UI/UX: Review surfaces design + iteration","description":"Cross-cutting UI/UX workstream. Discovery + interaction design for share button, room mode selector, connection badge, peer strip, review panel layout, comment/suggestion composer, inline highlight system, ambiguous anchor picker, stale comment panel, snapshot badge/age/superseded, three-way apply UI, outbox indicator, reviewer banner. Drives Phase 2 and feeds into Phase 5.","notes":"User direction: UI/UX is important. Treated as a peer workstream rather than tail-end polish.","status":"closed","priority":1,"issue_type":"epic","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:09:18Z","created_by":"James Lal","updated_at":"2026-05-19T18:01:15Z","closed_at":"2026-05-19T18:01:15Z","close_reason":"Phase complete — all implementation issues closed","dependencies":[{"issue_id":"attn-nnj.10","depends_on_id":"attn-nnj","type":"parent-child","created_at":"2026-05-18T16:09:18Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj.5","title":"Phase 3a: Relay worker (Cloudflare DO+R2)","description":"Implement relay/ from relay-spec.md against Miniflare. WS-only delivery (decision #5), HMAC admission, hashcash PoW on all writes, room TTL alarms (24h hard-max + 1h idle), R2 spillover for large snapshots. Conformance corpus shared with the Rust client tests.","notes":"Spec: planning/collab/relay-spec.md. Wrangler/Miniflare; relay/ package does not yet exist.","status":"closed","priority":1,"issue_type":"epic","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:09:15Z","created_by":"James Lal","updated_at":"2026-05-19T17:59:57Z","closed_at":"2026-05-19T17:59:57Z","close_reason":"Phase complete — all implementation issues closed","dependencies":[{"issue_id":"attn-nnj.5","depends_on_id":"attn-nnj","type":"parent-child","created_at":"2026-05-18T16:09:14Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj.4","title":"Phase 2: Review UI with mocked transport","description":"Svelte review panel with comment/suggestion decorations in ProseMirror, anchor-aware highlighting, ambiguous picker, stale state. Mock-IPC extended with replayable event stream so UI work isn't blocked on Rust/network. Demonstrate a comment surviving owner edits using only the local anchor engine.","notes":"Spec: data-model.md §UI/UX Changes. UI/UX is a first-class workstream here per user direction.","status":"closed","priority":1,"issue_type":"epic","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:09:14Z","created_by":"James Lal","updated_at":"2026-05-19T17:59:42Z","closed_at":"2026-05-19T17:59:42Z","close_reason":"Phase complete — all implementation issues closed","dependencies":[{"issue_id":"attn-nnj.4","depends_on_id":"attn-nnj","type":"parent-child","created_at":"2026-05-18T16:09:13Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj.3","title":"Phase 1: Anchor engine","description":"Build AnchorIndex from markdown bytes in Rust (canonical), construct Anchors from selections, implement the run-all-and-combine resolution policy with confidence thresholds, ship hand-curated test corpus.","notes":"Spec: data-model.md §Anchor Index/Anchors/Anchor Resolution + amendments.md §Anchor resolver disagreement policy. AnchorIndex computed in Rust per amendments.md (canonical path); browser gets it pre-computed in SnapshotCreated events.","status":"closed","priority":1,"issue_type":"epic","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:09:13Z","created_by":"James Lal","updated_at":"2026-05-19T17:01:52Z","closed_at":"2026-05-19T17:01:52Z","close_reason":"All children closed","dependencies":[{"issue_id":"attn-nnj.3","depends_on_id":"attn-nnj","type":"parent-child","created_at":"2026-05-18T16:09:13Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj.2","title":"Phase 0b: Local data model + working copy","description":"Rust-only foundation. Typed IDs, serde model types, JSON/JSONL local store at ~/.attn/reviews/, WorkingCopyService replacing direct fs::write, revision journal, watcher self-write distinction, empty ReviewManager scaffold, AppState refactor for tab+room routing.","notes":"Spec: planning/collab/data-model.md §Local Replicas + §Rust Architecture Changes. AppState shape per amendments.md (RoomRuntimeHandle + file_to_room mapping).","status":"closed","priority":1,"issue_type":"epic","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:09:12Z","created_by":"James Lal","updated_at":"2026-05-19T17:01:37Z","closed_at":"2026-05-19T17:01:37Z","close_reason":"All children closed","dependencies":[{"issue_id":"attn-nnj.2","depends_on_id":"attn-nnj","type":"parent-child","created_at":"2026-05-18T16:09:12Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj.1","title":"Phase 0a: Crypto foundations","description":"Rust-only crypto crate (attn-collab-crypto): cipher suite primitives, key derivation, hashcash mint+verify, ID helpers. Frontend never holds ciphertext in v2 — IPC delivers plaintext ReviewEvents per data-model.md §Webview IPC Changes, so no TS crypto is needed for native. Test-vector corpus ships alongside Rust impl for forward compat (browser/Phase 6 will revisit WASM-vs-TS).","notes":"Spec: planning/collab/crypto-spec.md. Decision #4 locks the suite: XChaCha20-Poly1305 + Ed25519 + HKDF-SHA-256 + RFC 8785 JCS + base64url-no-pad. No agility in v2.","status":"closed","priority":1,"issue_type":"epic","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:09:11Z","created_by":"James Lal","updated_at":"2026-05-19T17:01:21Z","closed_at":"2026-05-19T17:01:21Z","close_reason":"All children closed","dependencies":[{"issue_id":"attn-nnj.1","depends_on_id":"attn-nnj","type":"parent-child","created_at":"2026-05-18T16:09:11Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj","title":"attn collab v2","description":"End-to-end encrypted review collaboration over local markdown. Owner shares working copy via snapshot graph + encrypted event log; reviewers/agents add comments and suggestions anchored to snapshots; owner accepts suggestions locally. Transport via WebRTC DataChannel (Rust webrtc-rs) and a bounded encrypted mailbox on Cloudflare Workers/DO/R2.","notes":"Specs: planning/collab/{data-model,crypto-spec,relay-spec,amendments}.md. amendments.md overrides the others where they conflict. 16 design decisions locked.","status":"closed","priority":1,"issue_type":"epic","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:09:10Z","created_by":"James Lal","updated_at":"2026-05-19T18:01:47Z","closed_at":"2026-05-19T18:01:47Z","close_reason":"attn collab v2: all implementation closed across phases 0a/0b/0c/1/2/3a/3b/4/5/6, UI/UX, and cross-cutting. 432 Rust tests + 286 relay tests + 22.4 KB gz browser bundle.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-cyvj","title":"Replace viewport presence ring with margin flag","description":"Replace the ambiguous circular scroll-position marker with a zero-layout-width margin flag that reveals participant identity on hover/focus, stacks cleanly, and suppresses redundant markers when the peer caret is already in the viewed block.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-25T21:26:14Z","created_by":"James Lal","updated_at":"2026-07-25T21:30:26Z","started_at":"2026-07-25T21:26:18Z","closed_at":"2026-07-25T21:30:26Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-o7sq","title":"Paste markdown source as rendered markdown, not copy-style HTML","description":"The ProseMirror editor has no custom paste handling, so ProseMirror DOM-parses clipboard text/html through schema parseDOM rules (brings visual 'copy style' that serializes to junk) and drops raw markdown text/plain in literally. Add a handlePaste plugin that routes pasted markdown source through the same markdownParser used to load docs, so pasted markdown renders as real nodes and round-trips to clean markdown on save. Preserve internal PM copies (data-pm-slice), honor genuinely-rich foreign HTML, and leave code blocks raw.","notes":"Added markdownPastePlugin (web/src/lib/prosemirror/markdown-paste.ts) wired into Editor.svelte buildPlugins. handlePaste routes text/plain markdown through the same markdownParser used to load docs. Guards: skips code blocks/code marks (raw text), skips internal PM copies (data-pm-slice), honors rich foreign HTML unless plain text has block-level markdown markers. 7 unit tests pass; verified in-browser via Playwright (md source renders as nodes; code-block paste stays raw; rich HTML honored). svelte-check 0 errors.","status":"in_progress","priority":2,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-24T04:44:11Z","created_by":"James Lal","updated_at":"2026-07-24T04:50:14Z","started_at":"2026-07-24T04:44:16Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-qs03","title":"Click a participant chip to jump to their location (file + scroll/caret position)","description":"Feature: clicking a peer chip in the PeerStrip navigates the local user to where that participant currently is — switching to their file in a multi-file share and scrolling the editor to their caret/last-known position. Builds on existing presence-location plumbing (CollabPeerLocation carried on cursor wire messages, notePeerLocation/collaboratorLocations, RemoteCursor.head positions). Needs: (1) per-participant location resolvable from the chip's participantId (map collab clientID\u003c-\u003eparticipantId), including which file and a document position; (2) chip click handler that switches active file (path\u003c-fileId binding) then scrolls the editor to that position using the existing card-focus/coordsAtPos scroll primitive; (3) graceful fallback when the peer is on a file with no live caret (jump to file top) or offline (disabled/last-seen). Keep the identity-card affordance too — decide click vs a jump button on the card.","status":"closed","priority":2,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-23T15:48:21Z","created_by":"James Lal","updated_at":"2026-07-23T16:18:27Z","closed_at":"2026-07-23T16:18:27Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-73xq","title":"Drift assertion + bridge deletion: dev cross-tab fingerprint check, remove superseded mirrors","description":"attn-whdh part 5. Dev-mode-only: tabs exchange a store fingerprint (roomId, event count, thread ids hash) over the tab channel and console.warn loudly on mismatch. Then delete the superseded bespoke bridges (direct store feeds, fallback chains, per-type tees) and their now-dead tests; full staging E2E verification pass (two owner tabs + external reviewer + room rotation) proving convergence.","status":"open","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-23T04:30:15Z","created_by":"James Lal","updated_at":"2026-07-23T04:30:15Z","dependencies":[{"issue_id":"attn-73xq","depends_on_id":"attn-9ek7","type":"blocks","created_at":"2026-07-22T22:30:38Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-73xq","depends_on_id":"attn-d2w3","type":"blocks","created_at":"2026-07-22T22:30:39Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0} -{"id":"attn-d2w3","title":"Ephemera bus: one tab-channel lane for cursors + presence replacing per-type tees","description":"attn-whdh part 4. Replace the hub cursor tee (attn-37f9) and presence broadcast (attn-90qq) with one generic ephemera message on the tab channel keyed by room+generation: leader publishes {cursors, presence} snapshots/deltas, every tab (leader included) consumes through the same subscription. Validated payloads, bounded sizes, re-sent on hello.","notes":"ASSESSMENT after core landed: this is now cohesion-only. The review-STATE divergence (threads/comments/counts — the painful bugs) is fully solved by the single projection + drift guard. Cursors + presence are EPHEMERAL: they self-heal every tick, never persist, and their bridges (attn-37f9 cursor tee, attn-90qq presence broadcast) are staging-verified and working. Collapsing them into one generic ephemera lane changes NOTHING a user feels and risks regressing verified code. The one genuine smell: cursor ephemera rides the doc-step 'collab' message kind (presence already has its own kind). RECOMMENDATION: defer unless the cohesion is wanted; low ROI, non-trivial regression risk. Holding the anti-rabbit-hole guardrail per operating manual.","status":"in_progress","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-23T04:30:14Z","created_by":"James Lal","updated_at":"2026-07-23T14:48:54Z","started_at":"2026-07-23T14:42:15Z","dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"attn-tqkw","title":"Relay: room-gone 404s should carry CORS for allowed origins","description":"Follow-up to attn-hh9r: a TTL-wiped RoomDO answers 404 ATTN_ROOM_NOT_FOUND without CORS headers because corsMiddleware (relay/src/index.ts:137) keys off the stored room policy (X-Attn-Allow-Browser), which is gone after the wipe. Cross-origin browsers therefore see an opaque TypeError instead of the 404. The client now disambiguates via the share route, but the relay should reflect allowed origins on room-gone errors (safe: no data, origin allowlist already exists) so statuses are readable directly.","status":"open","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-23T04:07:04Z","created_by":"James Lal","updated_at":"2026-07-23T04:07:04Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-bpsb","title":"Stale authority-paused banners persist after a tab downgrades to follower (lease expired)","description":"User screenshot: a tab that lost the workspace authority lease (sleep/background throttling) shows THREE overlapping surfaces at once: 'Read-only tab' chip + 'Following another tab's edits' banner (correct, current) plus the leader-era residue 'Live review is paused: Workspace authority lease expired' banner and the right-rail 'Workspace authority lease expired / Reconnect' error. Once the tab is following (joinState live or lease held elsewhere), the stale paused-authority error should clear — one honest status, not three. Cosmetic/UX only; behavior (fail-safe downgrade + retry/auto-recovery) is correct.","status":"open","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-23T00:49:52Z","created_by":"James Lal","updated_at":"2026-07-23T00:49:52Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-5xgz","title":"Remote selections: overlapping selections/carets from two peers render as occluded labels and muddy/lost tints","description":"User report with screenshot: two reviewers select the same text; caret labels sit at identical absolute offsets so one occludes the other, and overlapping .attn-remote-selection inline decorations either stack tints or lose one color depending on span merging. Fix: (1) deterministic vertical label stacking for carets sharing a clamped head position; (2) plugin-computed disjoint selection segments — segments covered by multiple peers render one decoration with an explicit two-color treatment (striped gradient of both peers' tints) instead of relying on CSS nesting.","status":"closed","priority":2,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-22T15:02:14Z","created_by":"James Lal","updated_at":"2026-07-22T15:09:49Z","started_at":"2026-07-22T15:02:25Z","closed_at":"2026-07-22T15:09:49Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-g984","title":"Add native Stop sharing control to match mobile","description":"During attn-qfda QA, the native header and live-sharing state were aligned with the mobile masthead, but the mobile ShareSheet exposes a confirmed Stop sharing action while the native owner ShareChip/ShareDialog has no equivalent control. Add an owner-only, confirmation-gated native stop action wired to reviewStop, preserve draft/focus behavior, and verify relay revoke plus local tombstone state.","acceptance_criteria":"A native owner can stop an active share from the sharing control with explicit confirmation; the relay room is revoked and local UI settles without wedging; reviewer/hosted behavior is unchanged; focused tests and native UI automation pass.","status":"open","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-22T00:32:30Z","created_by":"James Lal","updated_at":"2026-07-22T00:32:30Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-jajl","title":"Remembered /s/ reopen: upgrade to full live session (threads + authoring)","description":"The remembered binding holds room read capability + admission keys — enough to subscribe to the room and replay events, not just load durable snapshots. Today a fragmentless reopen shows the document but threads:0 and no authoring. Upgrade the remembered path to a live BrowserSession so continuity is complete.","status":"closed","priority":2,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-21T17:37:07Z","created_by":"James Lal","updated_at":"2026-07-21T18:29:01Z","closed_at":"2026-07-21T18:29:01Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-l9hf","title":"Inbound envelope drops are silent (owner missing comments undiagnosable)","description":"Every inbound rejection (signature, capability, unknown signer) was swallowed by an empty onError. Now kept in a bounded ring at window.__attnInboundErrors (last 5 surfaced in __attnCollabDebug.review), and the unknown-signer path only logs when the directory refresh STILL lacks the signer (a real drop), not on the benign join race.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-21T04:23:38Z","created_by":"James Lal","updated_at":"2026-07-21T04:25:41Z","closed_at":"2026-07-21T04:25:41Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-oldm","title":"[bug] attn-47r live-follower co-editing (joinLive) never engages on hosted","description":"The editable live-follower mode (join the owner's LocalCollab hub as a CollabClient, joinLive=true) never reaches 'live' in practice: the denied tab keeps the lease-denied banner and its editable join stays in connecting. The read-only mirror DOES sync in realtime (verified: owner keystrokes stream into the follower mirror within ~1s), and intent-based seamless handoff covers editing-from-any-tab, so this is not user-blocking — but the joinLive machinery is now dead code that either needs fixing (true simultaneous co-typing across two windows) or removing per the shipping model. Decide direction as part of closing attn-7xl.7.10.","notes":"2026-07-19 correction: joinLive DOES engage — nondeterministically. Probe evidence (window.__attnCollabDebug on the built bundle): after B claims the pen, A reached joinStatus:'live' with boundCollabKey 'join:gen-…' and typed successfully THROUGH B's hub (writable null — no ownership transfer; the focus-intent veto kept B the owner). The earlier 'never engages' observation was the race's other arm. Reframe: the defect is nondeterminism — whether a denied tab lands in live-follow vs read-only mirror vs ownership-claim depends on ring/veto/join timing. attn-x1k is the ownership-claim arm of the same race.","status":"closed","priority":2,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-20T00:45:16Z","created_by":"James Lal","updated_at":"2026-07-20T14:15:51Z","closed_at":"2026-07-20T14:15:51Z","close_reason":"Resolved by join-first: the live-follower join is now the DETERMINISTIC default for denied tabs (the denied loop prefers the holder's hub and defers ownership rings while the join settles — 4s grace). Verified across 85/85 routes + 31/31 storage gates including WebKit: duplicated tabs co-edit live, mobile Edit tap joins, authority-death re-election converges.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-8wo","title":"Build alternate interactive-demo homepage","description":"Add a separate /homepage-alt marketing route that repositions attn around private Markdown review with people and AI agents, including a lightweight interactive demo, while preserving the current homepage.","status":"closed","priority":2,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-19T13:43:07Z","created_by":"James Lal","updated_at":"2026-07-19T13:54:21Z","started_at":"2026-07-19T13:43:13Z","closed_at":"2026-07-19T13:54:21Z","close_reason":"Alternate /homepage-alt positioning study implemented with interactive review demo, responsive layouts, theme support, and passing accessibility/build checks.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-24l","title":"Port the owner mobile chrome to the reviewer page","description":"User noticed the reviewer page's mobile mode is functional but bare compared to the owner surface: it has a plain fixed header + Review bottom-sheet dock, while the owner mobile has the frosted masthead (nav veil + backdrop blur), workspace/document title crossfade, reading-progress hairline, safe-area handling, and the aligned one-row chip treatment (title + save chip pill + action, all one center line). Port that masthead grammar to BrowserReviewApp's mobile branch: frosted sticky header, doc title crossfade with the document h1, reading progress, status chip seated on the same row, and reconcile the Review dock button with the agentation FAB (they collide bottom-right at 390px). Keep the BottomSheet review pattern.","status":"open","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-19T04:16:20Z","created_by":"James Lal","updated_at":"2026-07-19T04:16:20Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-ojs","title":"Compress snapshot plaintext before encryption (gzip pre-seal, sniff on decode)","description":"User hit the snapshot size wall with a ~263 KiB doc (turned out to be the maxSnapshotBytes plumbing bug, fixed) and raised compression: encrypted blobs can't be compressed by any downstream layer, so compression must happen client-side BEFORE sealing. Design: gzip the snapshot plaintext bytes pre-encryption (CompressionStream in web, flate2 on native — watch the 32 MiB binary gate), sniff gzip magic (1f 8b) after decrypt so readers handle both forms during cutover. The snapshot plaintext is a shared native/browser wire format with conformance tests (parseBrowserSnapshotPlaintext) — this MUST land in both clients plus the conformance suite together, per no-backwards-compat cutover rules. Markdown compresses ~4-6x, so the effective snapshot ceiling rises from 5 MiB to ~20-30 MiB of prose, and R2 spillover traffic drops.","status":"closed","priority":2,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-18T21:30:48Z","created_by":"James Lal","updated_at":"2026-07-18T22:16:19Z","closed_at":"2026-07-18T22:16:19Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-qtz","title":"Owner share resume fails with 'durable share manifest digest is invalid' on a real long-lived workspace","description":"Surfaced by the attn-dkr banner on James's own profile (localhost, workspace u1vXcbuAi_MXrgVERYZEKA / validation-01): share resume errors with 'durable share manifest digest is invalid' and live sharing stays off until Retry/re-share. Likely a manifest schema or digest-computation change across sessions for a share record minted by an older build. Needs: reproduce against an old-format record, decide migrate-or-retire (probably retire the stale share record and let the owner re-publish cleanly instead of erroring forever).","status":"closed","priority":2,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-18T05:46:04Z","created_by":"James Lal","updated_at":"2026-07-18T06:01:39Z","closed_at":"2026-07-18T06:01:39Z","close_reason":"Root cause: commit e2c6df5 changed the manifest-digest sort from localeCompare to code-unit order (to fix joiner rejection of multi-file manifests), so every share record written by the older build carries a digest the new owner client can never re-verify — decodeRecord threw 'durable share manifest digest is invalid' on the owner's own record read, which pauses publishing forever (Resume re-fails). Joiners were unaffected: they compare the sealed bundle's digest to the record's stored digest (both old-order, consistent) and never recompute. Fix: the owner read no longer throws — the record decodes with manifestDigestValid:false, and the publish flow treats an unverifiable digest as not-exact, forcing the commit upsert that rewrites the manifest with a fresh code-unit digest (self-healing on the next Resume/publish). Regression tests in browser-share-owner.test.ts (legacy digest decodes flagged, fields usable for the healing commit); 83 test files green.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-mwn","title":"Reviewer identity is 'Browser reviewer' everywhere — joiners should get a name prompt (or name capture at first comment)","description":"Every /s/ joiner authors as the default 'Browser reviewer' display name; with several reviewers the owner cannot tell feedback apart (screenshot showed a roster of identical 'Browser reviewer' rows). The native app prompts for a display name on room entry (userProfile.requestEdit + name prompt); BrowserReviewApp has no name prompt surface at all. Add a lightweight name confirm on the reviewer surface — ideally inline at first comment submit or in the status-chip popover (You: \u003cname\u003e · Edit like the owner's ShareChip).","status":"closed","priority":2,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-18T00:18:58Z","created_by":"James Lal","updated_at":"2026-07-18T00:19:20Z","closed_at":"2026-07-18T00:19:20Z","close_reason":"Duplicate of attn-sur (reviewer display-name capture).","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-dkr","title":"Hosted owner share-resume failure is silent — relay errors (e.g. 507 room cap) leave no trace in the UI","description":"Observed on the local share loop: a long-lived room hit the relay's envelope storage cap (POST /v3/rooms/:id/envelopes → 507 Insufficient Storage). The hosted owner's share session then fails to resume on reload with zero user-visible signal — no error banner, the ShareChip simply never appears and the workspace silently reverts to pre-share posture while reviewers keep a link that still half-works. Two parts: (1) owner UI must surface share-resume/publish failures (chip in attention tone + detail, like the reviewer chip's Sync issue state); (2) relay-side: decide the intended behavior for rooms that hit maxEvents/envelope caps — long-lived rooms accumulate registrations and broadcasts until writes 507.","status":"closed","priority":2,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-18T00:18:48Z","created_by":"James Lal","updated_at":"2026-07-18T01:53:16Z","closed_at":"2026-07-18T01:53:16Z","close_reason":"Owner share-resume failures now surface: EditorShell renders a degraded-banner (data-degraded=share-resume-failed) whenever ownerState.status==='error' with no room — 'Live sharing is unavailable' + the runtime reason + Retry. Verified live by killing the relay and reloading the owner: banner shows 'room create failed' instead of the silent chip-less state. Relay-side cap policy for long-lived rooms (the 507 that exposed this) spun out to the relay backlog — the client-side silence this bead was really about is fixed.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-sjz","title":"Hosted owner gets no peer presence: reviewStore.peersResolved stays empty with a live joiner","description":"With a joiner connected on the local hosted share loop, the owner's reviewStore.peersResolved is empty — no peer chips in the header and no people list in the ShareChip popover. EditorShell feeds store.connection from the owner authority session but nothing feeds peers on the hosted owner path (native gets them from the daemon's status payload). Pre-existing before the ShareChip redesign (old ConnectionBadge showed no People here either). Fix locus: browser-owner-workspace-runtime / browser-session presence → reviewStore.peers.","status":"closed","priority":2,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-17T21:24:15Z","created_by":"James Lal","updated_at":"2026-07-18T00:19:21Z","started_at":"2026-07-17T23:48:22Z","closed_at":"2026-07-18T00:19:21Z","close_reason":"Three-part fix, verified live on the local share loop. ROOT CAUSE was deeper than plumbing: browser-storage validateDevice verified self-signatures over a grant-less canonical while v3 durable-share reviewers sign over grantTier+grantSignature (browser-ws registrationBytes) — every /s/ joiner device threw 'self-signature is invalid' during persistDirectoryAndRoom, which the presence-join path awaited, so live peers froze at 'away'. Fixed the canonical to mirror registrationBytes (+ regression test in browser-storage.test.ts), made directory persistence best-effort so storage can never veto presence, added peers to BrowserSessionState (computed from bootstrapDevices + onlineDeviceIds), and fed reviewStore.peers from EditorShell with native semantics (online-only roster — kills the pile of stale away rows) with a fingerprint guard. Owner ShareChip popover now shows 'Browser reviewer · here' with a live joiner and 'No one else is here right now' when alone; joiner side confirms ownerOnline/peersOnline via new data attributes on the reviewer shell.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-sur","title":"Reviewer identity: let joiners set a display name (everyone is 'Browser reviewer')","description":"Comment authorship on the /s/ review page is hardcoded displayName 'Browser reviewer' (browser-share-production.ts participant_joined assembly; browser-session.ts has an unused displayName option). Multi-reviewer rooms collapse into identical author labels. Add a lightweight name affordance on first comment (persist per browser profile) and thread it into the participant_joined event.","status":"closed","priority":2,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-17T00:25:34Z","created_by":"James Lal","updated_at":"2026-07-18T01:53:14Z","closed_at":"2026-07-18T01:53:14Z","close_reason":"Reviewer + hosted-owner identity, verified end-to-end on the local share loop with two isolated browser contexts: (1) profile store now persists to localStorage on hosted surfaces (new lib/browser-profile.ts; native IPC path untouched); (2) NamePrompt mounts on BOTH hosted surfaces — reviewer onboards one-shot on first writable connect, owner one-shot when a room first exists (never stacked over the share sheet); the previously-dead ShareChip/ReviewerStatusChip Edit affordances now open it; (3) name flows into every announce: BrowserSession getDisplayName getter (read at announce time), durable /s/ facade (offline submission joined + live session), owner genesis ownerDisplayName via real-service; (4) BrowserSession.announceProfile() re-announces on rename after the initial announce raced the prompt, threaded through the share facade; (5) store participantNames now resolves latest-by-createdAt instead of arrival order so relay history replay can't resurrect a stale name. Verified: joiner named Rae → comment renders 'R Rae · just now' live on both sides; owner popover shows 'You: James' + 'Rae · here'; reviewer chip popover shows 'Commenting as Rae · Edit'.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-0n2","title":"Joiner surface polish: no branding/context, cryptic status copy, default identity, empty state teaches nothing","description":"/s/ review page at desktop: no attn wordmark or 'shared by' context for a cold visitor; 'Open from this link' and 'Remember \u0026 notify me' unexplained; 'Direct encrypted link'/'Encrypted mailbox' are internal-speak; commenter identity defaults to 'Browser reviewer' with no name prompt (multi-reviewer rooms will all collide); timestamps render '0s' instead of 'just now'; empty rail says 'No review threads on this file.' instead of teaching 'Select any text to comment'.","status":"closed","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-16T23:00:23Z","created_by":"James Lal","updated_at":"2026-07-17T00:25:33Z","started_at":"2026-07-17T00:21:14Z","closed_at":"2026-07-17T00:25:33Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-8l8","title":"Share-create races GET /v3/shares (two 404s in console on every share)","description":"Creating a share fires GET /v3/shares/\u003cid\u003e twice before the POST that creates it, logging two 404 console errors on the happy path (also observed on staging/main at workspace creation, where main additionally DELETEs a snapshot and gets 405). Order the inspect after creation or treat 404 as expected-silent.","status":"closed","priority":2,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-16T23:00:09Z","created_by":"James Lal","updated_at":"2026-07-17T00:12:37Z","started_at":"2026-07-17T00:04:18Z","closed_at":"2026-07-17T00:12:37Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-iar","title":"Share sheet polish: file size shows 0 B; jargon; ⌘K shortcut mislabel; post-share header chips cryptic","description":"(1) File row + summary show '0 B' for a doc with real content — size metadata stale at first open. (2) 'Hybrid delivery', '0 previewable · 0 download-only' unexplained; helper 'revokes every permission level' jargony. (3) Cmd+K palette lists 'Share for review ⌘K' — same chord that opens the palette. (4) After sharing, header gains icon-only chips labeled 'Offline' (shown while pill says Shared · Direct — contradictory) and 'Snapshot current', plus a second Sharing icon — mystery-meat cluster. (5) Svelte ownership_invalid_mutation warning from EditorShell.svelte:403 mutating unbound 'workspace' prop. (6) Mobile owner header pill says 'Saved on this device', dropping the shared state desktop shows.","status":"closed","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-16T23:00:09Z","created_by":"James Lal","updated_at":"2026-07-17T00:21:13Z","started_at":"2026-07-17T00:12:37Z","closed_at":"2026-07-17T00:21:13Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-zqt","title":"Rooms as projects P2: joined rooms in project switcher","description":"Implement P2 from planning/collab/room-as-project-ux.md: represent joined reviewer rooms as projects in the sidebar switcher, route selection through the project navigation path, remove the remaining reviewer room dropdown, and move leave/forget actions onto joined-project entries. Cover native and hosted behavior with focused tests and visual verification.","status":"open","priority":2,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-15T22:17:43Z","created_by":"James Lal","updated_at":"2026-07-15T22:17:43Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-ejq","title":"hosted-lease-handoff webkit spec: passive tabs live-join local collab instead of showing the lease-denied banner","description":"Pre-existing (fails identically at baseline 6d07d23, verified via worktree): the spec expects [data-degraded=lease-denied] on window.open'd passive tabs, but they join the writer's local-collab session ('Live · editing with another tab'), which intentionally suppresses the banner (EditorShell: editDenied \u0026\u0026 !joinLive). Either update the spec to treat live-join as a valid passive state (and run the one-writer election against joinLive tabs), or determine why the local-collab join engages in this webkit scenario when the test was written expecting denial. web/e2e/hosted-lease-handoff.spec.ts:29.","status":"open","priority":2,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-15T14:41:35Z","created_by":"James Lal","updated_at":"2026-07-15T14:41:35Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-9x1","title":"Global / shortcut steals focus into the sidebar from open dialogs","description":"The / handler ignores typing targets but not open modals/menus/lightboxes or a collapsed sidebar; pressing / with a dialog open focuses a control behind the modal, violating the focus boundary. web/src/lib/Sidebar.svelte:99.","status":"closed","priority":2,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-15T03:25:47Z","created_by":"James Lal","updated_at":"2026-07-15T14:36:55Z","closed_at":"2026-07-15T14:36:55Z","close_reason":"Fixed on james/hosted-improvements with regression coverage where testable","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-8pj","title":"BroadcastChannel construction can abort app/component startup","description":"Several constructors only check the global exists; the constructor itself can throw under browser policy restrictions. Lease manager, real service, and the Svelte effect should share one safe constructor helper and degrade consistently. web/src/lib/review/browser-workspace-lease.ts:194, web/src/hosted/app/real-service.ts:140.","status":"closed","priority":2,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-15T03:25:46Z","created_by":"James Lal","updated_at":"2026-07-15T14:36:55Z","closed_at":"2026-07-15T14:36:55Z","close_reason":"Fixed on james/hosted-improvements with regression coverage where testable","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-b3d","title":"Review marker widgets retain stale event IDs: ProseMirror key omits primary event","description":"Widget key contains only the range; ProseMirror treats same-key widgets as interchangeable, so replacing/resolving the primary event at the same range can reuse old DOM with stale data-event-id/label/kind. Include primary event ID + rendered values in the key. web/src/lib/prosemirror/review-decorations.ts:196.","status":"closed","priority":2,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-15T03:25:46Z","created_by":"James Lal","updated_at":"2026-07-15T14:36:55Z","closed_at":"2026-07-15T14:36:55Z","close_reason":"Fixed on james/hosted-improvements with regression coverage where testable","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-hhe","title":"Historical review events toast as newly arrived after switching files","description":"toastsPrimed is global to the component but the priming effect observes only the current file's threads; after the first file primes, navigating to another file treats its history as unseen arrivals. Track priming per file or seed all known events. web/src/hosted/app/EditorShell.svelte:1155.","status":"closed","priority":2,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-15T03:25:45Z","created_by":"James Lal","updated_at":"2026-07-15T14:36:55Z","closed_at":"2026-07-15T14:36:55Z","close_reason":"Fixed on james/hosted-improvements with regression coverage where testable","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-1ze","title":"Rapid file selections resolve out of order in applyEntry","description":"applyEntry() has no request generation or abort guard: slow read A can resolve after fast B, overwriting the newer selection and pushing an extra history entry. web/src/hosted/app/AppShell.svelte:109.","status":"closed","priority":2,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-15T03:25:33Z","created_by":"James Lal","updated_at":"2026-07-15T04:12:57Z","closed_at":"2026-07-15T04:12:57Z","close_reason":"Fixed in commit on james/hosted-improvements; regression tests added for the autosave engine","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-45z","title":"A serialization exception permanently wedges autosave (committing stuck true)","description":"The lazy provider executes outside try/finally; if getMarkdown() throws, committing stays true, the pending provider is cleared, and all future flushes no-op. Reproduced: controller stuck at 'Saving...' with zero commits. web/src/hosted/app/autosave.ts:94.","status":"closed","priority":2,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-15T03:25:33Z","created_by":"James Lal","updated_at":"2026-07-15T04:12:57Z","closed_at":"2026-07-15T04:12:57Z","close_reason":"Fixed in commit on james/hosted-improvements; regression tests added for the autosave engine","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-y6y","title":"Hosted e2e: share-sheet specs expect pre-redesign dialog ('Share for review', tier rows, 'What do you want to share?')","description":"The redesigned ShareSheet (sentence-pattern permissions, quiet chrome — attn-b0d line of work) renamed the dialog to 'Share' and changed its content, but hosted-share-sheet.spec.ts (all 8 tests), hosted-a11y.spec.ts:48/:80, and hosted-shells.spec.ts:65/:155 still assert the old 'Share for review' dialog name and old content strings. All were failing at HEAD bb46048 before the mobile wow-pass (verified via baseline worktree run: 37/66 failed). Also pre-existing red at HEAD: hosted-offline (SW caching under vite dev) and several hosted-authoring cases. Update the specs alongside finishing the share-sheet redesign. Note: mobile share trigger is now the masthead Share button (.editor-top), not the dock — specs this session already point there.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-15T01:59:00Z","created_by":"James Lal","updated_at":"2026-07-18T01:53:16Z","closed_at":"2026-07-18T01:53:16Z","close_reason":"Rewrote hosted-share-sheet.spec.ts against the shipped sheet (dialog 'Share files for review', file checkboxes + Select all/Clear manifest gating, tier \u003cselect\u003e sentence pattern, masked link chip reveal, Stop sharing → Keep sharing focus → Sharing stopped, staging.attn.sh mock origin) — 8/8 pass. Updated the stale dialog-name/content assertions in hosted-a11y.spec.ts and hosted-shells.spec.ts, and fixed the real defect behind their focus-return reds: closeShare now re-resolves the live trigger after the breadcrumb icon remounts (captured node was detached, focus() no-oped). Remaining routes-suite failures are the pre-existing hosted-authoring/offline editor-mount family plus landing theme toggle, unchanged by this work.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-nyj","title":"Pre-existing hosted e2e failures on checkpoint/attn-7xl-4-6 (13 tests)","description":"Independent of the attn-4lw shell work (verified by stash-baseline runs): 9 hosted-authoring failures (desktop editor fills canvas [62px bottom gap], desktop Markdown formatting, dragging Markdown import, active rename autosave, pending-text reload guard, export drains, duplicated tab lease, multi-file workspace flow, first-share durability gate), 3 hosted-shells failures (share sheet dialog focus, mobile reader-first dock, share sheet 320px overflow — mobile dock Share is clipped/overlapped by the floating rail pill at narrow widths), 1 hosted-a11y failure (mobile edit mode full-height canvas — dock Edit never promotes to editor). All reproduce identically on HEAD with a clean dist-browser build via playwright.routes.config.ts.","status":"open","priority":2,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-15T00:36:49Z","created_by":"James Lal","updated_at":"2026-07-15T00:36:49Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-fuy","title":"Mobile hosted editor wow-pass: masthead header, iconified dock, sheet motion, safe areas","description":"The mobile hosted editor works but reads as bare scaffolding: text-only dock, heavy Share button, clipped save chip, unanimated sheets, no scroll behavior. Wow-pass within DESIGN.md: masthead header with doc-title crossfade + reading progress, iconified thumb dock with review badge, bottom-sheet grab/swipe + entrance motion, viewport-fit=cover safe areas.","notes":"\n\nBaseline evidence: HEAD bb46048 (before this session's changes) fails 37/66 hosted e2e (a11y axe, all of share-sheet, shells mobile, most authoring, offline) when run against the dev server — the dev-only Agentation toolbar (fa454a5) injects unnamed buttons/nested-interactive into every page (axe critical) and its FAB intercepts taps. Fixed root-cause this session: agentation-boot now skips automation runs (navigator.webdriver) and floats above the thumb dock on phone widths for humans.","status":"closed","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-15T00:32:23Z","created_by":"James Lal","updated_at":"2026-07-15T02:12:39Z","started_at":"2026-07-15T01:06:02Z","closed_at":"2026-07-15T02:12:39Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-64c","title":"Dev-only Agentation feedback toolbar wired into web dev servers","description":"Add agentation-svelte toolbar (dev-only) to both the native entry (main.ts, DEV-gated dynamic import) and hosted browser dev server (serve-only Vite plugin injecting agentation-boot.ts into every HTML entry). Syncs annotations to agentation-mcp on localhost:4747 (.mcp.json). Verified e2e: browser annotation -\u003e server -\u003e MCP tools. Prod builds confirmed clean of agentation code.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-14T23:22:13Z","created_by":"James Lal","updated_at":"2026-07-14T23:23:09Z","closed_at":"2026-07-14T23:23:09Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-y7q","title":"Share sheet polish: sentence-pattern permissions, quiet chrome","description":"Second UX pass from user feedback: Close pill -\u003e quiet ×, rounded sheet with soft border (was 1px ink + square), segmented control + redundant meta line -\u003e Figma-style sentence with inline permission select (visible word sizes it, transparent native select overlays for a11y), URL demoted to a quiet inspectable pill chip after Copy link/Share…, default \u003cdetails\u003e restyled as a muted chevron disclosure (fixes double rules).","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-14T22:48:37Z","created_by":"James Lal","updated_at":"2026-07-14T22:48:56Z","closed_at":"2026-07-14T22:48:56Z","close_reason":"Live on staging (version 292973c1): sentence permissions with inline select, quiet ×, rounded soft frame, pill URL chip, chevron disclosure — verified via sheet screenshots + tier-switch/reveal interactions","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-34g","title":"File switch inherits previous file's scroll position instead of starting at top","description":"EditorShell's read-position effect restores a saved per-file scrollTop, but when the target file has no saved position it leaves the canvas scrollTop untouched — the canvas survives in-place switches, so the new file opens at the old file's offset. Fix: reset to top when nothing is saved (hash anchors still win).","status":"closed","priority":2,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-14T03:37:01Z","created_by":"James Lal","updated_at":"2026-07-14T03:42:44Z","closed_at":"2026-07-14T03:42:44Z","close_reason":"Reset-to-top on switch + working restore verified E2E on 5173 (fresh file -\u003e 0, return to scrolled file -\u003e 1500 restored)","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-3t1","title":"Shared content column: prose + wide blocks cap at one --content-measure edge","description":"Follow-up to attn-2nh: user found the split layout (88ch prose beside full-pane tables/mermaid) still ragged. Cut over to one shared column: prose AND wide blocks (tables, code, mermaid) cap at --content-measure (1100px); oversized tables scroll inside. Revises the Theme v2 Wide-Sheet Rule in DESIGN.md.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-14T03:32:44Z","created_by":"James Lal","updated_at":"2026-07-14T03:32:56Z","closed_at":"2026-07-14T03:32:56Z","close_reason":"Landed: --content-measure 1100px shared by prose + wide blocks in reader/editor/hosted shell; verified aligned edges on 5173","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-2nh","title":"Widen the prose measure — 72ch reads too narrow next to full-pane tables/mermaid","description":"User feedback (screenshot, 2026-07-13): the 72ch prose cap looks undersized against wide blocks (tables, mermaid, code) that span the full pane per Theme v2. Widen the running-prose measure and centralize it as a design token so reader (base.css), editor (prosemirror.css), and hosted shell (app-shell.css) stay in sync.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-14T02:21:03Z","created_by":"James Lal","updated_at":"2026-07-14T02:27:18Z","closed_at":"2026-07-14T02:27:18Z","close_reason":"Landed: --prose-measure token at 88ch, all five 72ch sites converted, verified visually on 5173","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-5j5","title":"Landing: Theme v2 sweep (stripes, heading skip, tiny text)","description":"Remove the 3px stage-label side stripes (landing.css:205,211), fix the h1-\u003eh3 heading skip, lift 11px body text to the ramp, and make the entry triptych honest per the #new/#join fixes. Verify no regression vs the landing's strong baseline.","status":"closed","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-12T16:36:48Z","created_by":"James Lal","updated_at":"2026-07-12T16:48:59Z","started_at":"2026-07-12T16:47:11Z","closed_at":"2026-07-12T16:48:59Z","close_reason":"Landed: stripes-\u003edots, heading order fixed, tiny text lifted, verified render","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-u5c","title":"Native save/dirty indicator + window title","description":"Decision #4 compensating requirement: owner stays direct-edit, so the silent background serializer must become visible — Saved/Editing chip (prototype save-chip pattern, dot-only under 700px), flush-on-navigate/blur, window title = current file name (currently empty after navigation).","status":"closed","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-12T15:38:11Z","created_by":"James Lal","updated_at":"2026-07-12T16:15:57Z","started_at":"2026-07-12T16:12:03Z","closed_at":"2026-07-12T16:15:57Z","close_reason":"Landed: save chip + document.title, verified live","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-3dv","title":"Undo-after-accept: real revert via LocalRevision journal","description":"Decision #3: accepting/rejecting a suggestion leaves an inline undo note ('Accepted by James · file updated · Undo'); undo performs a real revert using the LocalRevision journal (AcceptedSuggestion + resulting_hash already recorded in src/review/apply.rs) and reopens the thread. Rust revert path + UI grammar from the prototype.","notes":"Rust core landed (accept splice capture + revert_accepted_suggestion + tests, commit HEAD). Remaining: IPC command (RevertSuggestion) + manager plumb + frontend 'Accepted · Undo' note per prototype grammar + protocol design for cross-peer thread reopen (new event kind; deny_unknown_fields risk on old peers needs review).","status":"in_progress","priority":2,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-12T15:38:09Z","created_by":"James Lal","updated_at":"2026-07-12T17:10:53Z","started_at":"2026-07-12T17:00:24Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-e4l","title":"Share dialog: decompression + designed error state + share-truth copy","description":"ShareDialog.svelte: first screen = scope + one primary action, rest behind Advanced; the relay-unreachable error state styled per prototype ('the share didn't complete. Nothing left this machine.' + inline retry); focus primary action on open, restore on close. Per decision #2: once a share exists the file's storage line becomes 'Shared · relay sees only ciphertext'.","status":"closed","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-12T15:38:09Z","created_by":"James Lal","updated_at":"2026-07-12T16:32:55Z","started_at":"2026-07-12T16:31:10Z","closed_at":"2026-07-12T16:32:55Z","close_reason":"Landed: share-truth desk copy + error trust line; dialog shape pre-existed","dependencies":[{"issue_id":"attn-e4l","depends_on_id":"attn-5y6","type":"blocks","created_at":"2026-07-12T09:38:27Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} -{"id":"attn-5bq","title":"Suggestion/comment composer port","description":"SuggestionComposer.svelte adopts the prototype composer: anchored popover placement with edge clamps, draft survives Escape (only Cancel/Post clear), cmd-Enter posts, suggest mode shows live diff. Selection popover hides on scroll.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-12T15:38:08Z","created_by":"James Lal","updated_at":"2026-07-12T16:30:45Z","closed_at":"2026-07-12T16:30:45Z","close_reason":"Landed: draft caches, scroll-tracking popovers, live diff preview","dependencies":[{"issue_id":"attn-5bq","depends_on_id":"attn-5e7","type":"blocks","created_at":"2026-07-12T09:38:26Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} -{"id":"attn-7xl.7.3.2","title":"Optimize landing screenshots and make visual baselines wait for image readiness","description":"Fresh Chromium staging measurement loaded 4.3 MB on the landing route: 2.0 MB editor-light PNG, 1.15 MB share-light PNG, and 1.03 MB collab-light PNG. All six 1920x1440 light/dark source captures total 7.4 MB. Landing images omit intrinsic width/height. The current dark full-page Playwright capture races theme image swaps/lazy loading and records nearly blank image panels even though the decoded live viewport is correct.","acceptance_criteria":"Serve appropriately sized responsive WebP/AVIF (with fallback if needed) and intrinsic dimensions/aspect ratios. Set and enforce an initial landing image-transfer budget appropriate for mobile. Preserve light/dark fidelity. Visual capture tests scroll/load lazy assets and await image decode after theme changes before taking screenshots, producing trustworthy light/dark desktop and mobile baselines.","notes":"Implementation plan: introduce a reusable responsive screenshot picture component, generate 768/1280/1920 AVIF variants with PNG fallback and intrinsic dimensions, prioritize only the hero, and keep lower captures lazy. Update visual tests to scroll, load, decode, verify theme sources, reset scroll/compositor state, and then capture. Validate byte reduction, route boundaries, layout, and Chromium/WebKit decoding.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-11T23:48:09Z","created_by":"James Lal","updated_at":"2026-07-12T00:47:05Z","started_at":"2026-07-11T23:56:24Z","closed_at":"2026-07-12T00:47:05Z","close_reason":"Implemented responsive AVIF screenshot sources with PNG fallback, intrinsic dimensions, loading priority, and decode-aware visual baselines. Landing transfer dropped by about 98%; Chromium and WebKit route/visual tests pass.","labels":["a11y","authoring","cloudflare","e2e","e2ee","ios","performance","responsive","security","storage","visual-regression","web"],"dependencies":[{"issue_id":"attn-7xl.7.3.2","depends_on_id":"attn-7xl.7.3","type":"parent-child","created_at":"2026-07-11T17:48:08Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-7xl.7.8","title":"Split hosted desktop icon registry from shared workspace shell","description":"The shared hosted desktop workspace frame correctly loads behind the desktop breakpoint, so the base app/mobile route stays at about 68 kB. Its desktop-only chunk is still about 3.45 MB (819 kB gzip) because FileTree statically imports the generated 2,776-SVG multi-pack icon registry used by native attn. Refactor icon resolution so hosted desktop can request only its active/default pack or a lightweight resolver without forking the Sidebar/FileTree views.","acceptance_criteria":"Hosted desktop continues to reuse Sidebar and FileTree, native icon-pack switching remains intact, route bundle boundaries continue to pass, and the HostedDesktopWorkspaceFrame chunk is materially smaller with a pinned bundle assertion.","status":"open","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-11T19:10:25Z","created_by":"James Lal","updated_at":"2026-07-11T19:10:25Z","labels":["a11y","authoring","bundle","cloudflare","e2e","e2ee","frontend","ios","performance","security","storage","web"],"dependencies":[{"issue_id":"attn-7xl.7.8","depends_on_id":"attn-7xl.7","type":"parent-child","created_at":"2026-07-11T13:10:25Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.7.8","depends_on_id":"attn-7xl.7.7","type":"discovered-from","created_at":"2026-07-11T13:10:26Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-02a.5.4","title":"Validate: browser Web Push E2E","description":"Feature-level validation: the no-install audience gets a proactive async loop, relay stays content-blind.\n\n## Integration Scenarios\n- Remembered room, tab closed: owner comments -\u003e ping -\u003e SW notification with correct locally-composed count -\u003e click reopens room with thread visible\n- Debounce: comment burst -\u003e one notification (tag-replaced)\n- Live tab open: no push fired (WS path wins)\n- Content-blindness audit: capture relay-\u003epush-service traffic; assert zero content bytes\n- Unsubscribe on forget-room verified against relay store\n\n## E2E Test Commands\n- Playwright suite with mocked push service against miniflare relay + dev-collab owner\n- Relay conformance run\n\n## Acceptance Criteria\n- Desktop Chromium + Firefox pass; iOS documented as attn-7xl-gated with install-first hint in place\n\n## Plan Reference\n- planning/sharing/README.md (Browser Web Push design sketch)","notes":"Production-boundary Web Push E2E complete: real Wrangler RoomDO accepted signed encrypted owner event→captured zero-body VAPID; built SW local decrypt/notify; generation/cursor/hash CAS, no-eviction backpressure, bounded reads; real ShareDO consent/snapshot/mail/reopen/forget; Chromium+Firefox 2/2. Manifest digest drift fixed to shared canonical JSON. Independent re-review APPROVE; all focused/full gates green.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-11T00:00:42Z","created_by":"James Lal","updated_at":"2026-07-11T10:33:35Z","started_at":"2026-07-11T09:39:43Z","closed_at":"2026-07-11T10:33:35Z","close_reason":"Closed","dependencies":[{"issue_id":"attn-02a.5.4","depends_on_id":"attn-02a.5","type":"parent-child","created_at":"2026-07-10T18:00:41Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.5.4","depends_on_id":"attn-02a.5.1","type":"blocks","created_at":"2026-07-10T18:01:27Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.5.4","depends_on_id":"attn-02a.5.2","type":"blocks","created_at":"2026-07-10T18:01:28Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.5.4","depends_on_id":"attn-02a.5.3","type":"blocks","created_at":"2026-07-10T18:01:29Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":3,"dependent_count":0,"comment_count":0} -{"id":"attn-02a.5.3","title":"Push consent UX with Remember this room","description":"Offer push at the same UX moment as \"Remember this room\" (they share the persistence prerequisite). Resolve open question 6 (bundled single consent vs separate toggle) with James's annotation; default to a second toggle inside the remember affordance until annotated otherwise. Proper in-app UI (no browser default prompts fired unsolicited — request Notification permission only on explicit toggle).\n\n## Files\n- web/src/BrowserReviewApp.svelte / remember-room UI\n- web/src/lib/review/ (subscription lifecycle: enable, disable, forget-room revokes subscription too)\n\n## Approach\nForgetting a room must unsubscribe (client-side unsubscribe + relay DELETE). Surface iOS reality: on Safari without Home-Screen install, show the install-first hint (copy only; the PWA plumbing rides attn-7xl).\n\n## Verification\n- cd web \u0026\u0026 npm run build\n- Playwright: toggle on -\u003e permission requested -\u003e subscription POSTed; forget room -\u003e subscription DELETEd; toggle off -\u003e no ping delivered (mock endpoint)\n\n## Plan Reference\n- planning/sharing/README.md (Browser Web Push; open question 6)","notes":"Explicit Remember \u0026 notify consent complete: gesture-first, view/invite gate, iOS install hint, exact relay auth/PoW binding, nonextractable worker storage, bearer-free cold resume, global subscription Web-Lock lifecycle, abort/generation/rotation rollback, ambiguous POST cleanup, pinned relay origin and identity-aware revision/manifest CAS. Independent final review APPROVE; focused/full web and build gates green.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-11T00:00:41Z","created_by":"James Lal","updated_at":"2026-07-11T09:39:39Z","started_at":"2026-07-11T09:05:54Z","closed_at":"2026-07-11T09:39:39Z","close_reason":"Closed","dependencies":[{"issue_id":"attn-02a.5.3","depends_on_id":"attn-02a.5","type":"parent-child","created_at":"2026-07-10T18:00:40Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.5.3","depends_on_id":"attn-02a.5.2","type":"blocks","created_at":"2026-07-10T18:01:16Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} -{"id":"attn-02a.5.2","title":"Service worker: subscribe, wake, pull, local decrypt, notify","description":"Service worker on attn.sh registers the push subscription and handles wakes: open the remembered-room capability from IndexedDB (decision #13), pull pending envelopes over WS, decrypt + verify locally, compose the rich notification (\"2 new comments on plan.md\"), deep-link click to the room. Push is only offered for remembered rooms — invite-only sessions have no persisted capability by design.\n\n## Files\n- web/ (service worker; registration wiring in BrowserReviewApp)\n- web/src/lib/review/ (worker-side envelope pull + decrypt reusing browser-crypto; careful: worker context, no DOM)\n\n## Approach\nThe SW shares the non-extractable capability access pattern attn-egi.3 built. Keep worker work bounded (pull, decrypt headers/kinds, count) — full replay happens in the page on click. Collapse multiple wakes per room into one visible notification (tag-based replace).\n\n## Verification\n- cd web \u0026\u0026 npm run build \u0026\u0026 npm test (worker decrypt path unit-tested against vectors)\n- Playwright (Chromium supports mock push): remembered room + subscription -\u003e dispatch push event -\u003e notification shown with locally-composed text -\u003e click navigates to room\n- No plaintext or key material in any network request from the SW other than the WS pull\n\n## Plan Reference\n- planning/sharing/README.md (Browser Web Push design sketch)","notes":"Hosted SW content-free wake complete: non-extractable capability bindings, capability-pinned owner trust, strict v3 grants, bounded authenticated room/share pulls, local decrypt/verify/count, cumulative 64-op limit, monotonic/fair cursor, tagged local notification + fragmentless click. Independent security re-review APPROVE; check, 62-file suite, browser/SW build green.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-11T00:00:40Z","created_by":"James Lal","updated_at":"2026-07-11T08:50:58Z","started_at":"2026-07-11T08:19:41Z","closed_at":"2026-07-11T08:50:58Z","close_reason":"Closed","dependencies":[{"issue_id":"attn-02a.5.2","depends_on_id":"attn-02a.5","type":"parent-child","created_at":"2026-07-10T18:00:40Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.5.2","depends_on_id":"attn-02a.5.1","type":"blocks","created_at":"2026-07-10T18:01:15Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} -{"id":"attn-02a.5.1","title":"Relay: VAPID + push subscription store + content-free ping","description":"Relay holds the VAPID keypair. Clients POST push subscriptions bound to (roomId|shareId, deviceId), admission-MAC'd and PoW'd like every write; stored on the share record (pushSubscriptions field) or room DO; expire with share/room TTL, re-pinned on share renewal. On envelope arrival for a subscribed device with no live WS: send an empty (or roomId-only) Web Push — no author, kind, or text ever transits push infra. Debounce per device.\n\n## Files\n- relay/ (subscription endpoints, web-push send from the DO, VAPID key management via wrangler secret)\n- planning/collab/relay-spec.md (+ conformance corpus)\n\n## Approach\nPayload-free push avoids RFC 8291 payload encryption entirely — the wake is the message. Per-device debounce (~30s) server-side. Unsubscribe on DELETE and on push-service 404/410 responses.\n\n## Verification\n- Relay conformance corpus (subscribe MAC'd+PoW'd; duplicate subscribe idempotent; TTL expiry drops it)\n- miniflare test with a mock push endpoint: envelope arrival + no WS -\u003e exactly one ping within debounce window; live WS -\u003e no ping\n- Secrets documented in relay/README or wrangler.toml comments\n\n## Plan Reference\n- planning/sharing/README.md (Browser Web Push design sketch)","notes":"Relay Web Push complete for room+share bindings: strict v3 admission+PoW CRUD, tier/device isolation, 32-active cap, room/share TTL, async verified VAPID keypair, payloadless 300s ping, durable debounce, self/live suppression, 404/410 cleanup. Independent adversarial audit APPROVE after sibling-delete/idempotent-PoW fixes; full relay suite/typecheck/conformance green.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-11T00:00:39Z","created_by":"James Lal","updated_at":"2026-07-11T08:19:12Z","started_at":"2026-07-11T07:47:03Z","closed_at":"2026-07-11T08:19:12Z","close_reason":"Closed","dependencies":[{"issue_id":"attn-02a.5.1","depends_on_id":"attn-02a.5","type":"parent-child","created_at":"2026-07-10T18:00:39Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":2,"comment_count":0} -{"id":"attn-02a.4.4","title":"Validate: native async loop","description":"Feature-level validation that comments reach an away owner end-to-end on native.\n\n## Integration Scenarios\n- Resident daemon, no window: reviewer comments -\u003e native notification -\u003e click -\u003e window opens focused on room -\u003e badge present -\u003e focus clears unread\n- Burst debounce across rooms stays per-room\n- Restart resident daemon: unread markers intact, no notification replay\n\n## E2E Test Commands\n- Extend dual-instance harness (notification posting asserted via hook/log in debug builds; visual spot-check manual)\n- task check:size\n\n## Acceptance Criteria\n- Away-owner loop closes with zero polling and no window open at comment time\n\n## Plan Reference\n- planning/sharing/README.md (Workstream B layers 1-3)","notes":"Real hidden resident owner+reviewer+Wrangler E2E passes: verified signed comments, 2-comment collapse, safe platform post, unread=2, exact deep-link selection/focus clear, mute, kill/restart restore/no replay. Full Rust/web/clippy/size gates green; independent review APPROVE. Signed-installed visual spot-check remains documented. Separate multi-room WS stall filed attn-eaw.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-11T00:00:38Z","created_by":"James Lal","updated_at":"2026-07-11T10:09:29Z","started_at":"2026-07-11T09:37:48Z","closed_at":"2026-07-11T10:09:29Z","close_reason":"Closed","dependencies":[{"issue_id":"attn-02a.4.4","depends_on_id":"attn-02a.4","type":"parent-child","created_at":"2026-07-10T18:00:38Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.4.4","depends_on_id":"attn-02a.4.1","type":"blocks","created_at":"2026-07-10T18:01:25Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.4.4","depends_on_id":"attn-02a.4.2","type":"blocks","created_at":"2026-07-10T18:01:26Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.4.4","depends_on_id":"attn-02a.4.3","type":"blocks","created_at":"2026-07-10T18:01:27Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":3,"dependent_count":0,"comment_count":0} -{"id":"attn-02a.4.3","title":"Resident daemon: attn daemon --resident + login item opt-in","description":"Make \"attn is running\" the normal state so notifications fire without a window. Windowless-resident mode; opening a doc attaches to the same instance as today. Opt-in login item (launchd) from settings — no silent install.\n\n## Files\n- src/main.rs / src/daemon.rs (windowless lifecycle: no window until first open; window close does not exit in resident mode)\n- launchd plist install/uninstall (SMAppService or plist under ~/Library/LaunchAgents)\n- settings UI toggle (web/src)\n\n## Approach\nLifecycle/packaging change, not new machinery — the daemon already forks and idles. Resident daemon keeps relay WS connections for rooms/shares with unread interest. Clean cutover: one daemon codepath with a resident flag, no parallel legacy mode.\n\n## Verification\n- cargo build\n- Smoke: attn daemon --resident -\u003e no window, socket live (attn --info); attn file.md attaches; closing window leaves daemon up; non-resident behavior unchanged when flag absent\n- Login item: enable toggle -\u003e plist present + loads; disable -\u003e gone\n\n## Plan Reference\n- planning/sharing/README.md (Workstream B layer 3, open question 3: opt-in pending annotation)","notes":"Opt-in resident daemon complete: hidden Accessory startup, same socket/review instance reveal and resident-only close-hide, explicit CLI/status/settings launch-at-login. LaunchAgent lifecycle hardened with trusted launchctl/euid, dirfd O_NOFOLLOW/O_EXCL atomic 0600 writes+fsync, authoritative bootout, rollback, degraded UI. Independent re-review APPROVE; 10/10 resident tests, strict gates, size 31.74 MiB.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-11T00:00:37Z","created_by":"James Lal","updated_at":"2026-07-11T09:35:10Z","started_at":"2026-07-11T08:42:39Z","closed_at":"2026-07-11T09:35:10Z","close_reason":"Closed","dependencies":[{"issue_id":"attn-02a.4.3","depends_on_id":"attn-02a.4","type":"parent-child","created_at":"2026-07-10T18:00:37Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"attn-02a.4.2","title":"Native macOS notifications from the daemon (debounced, deep-linked)","description":"When a comment/suggestion/verdict imports and the relevant room is not focused, post a macOS user notification. Debounce per room (collapse bursts: \"3 new comments on plan.md\"). Click deep-links through the existing attn://review/ handler to focus the room. No window.confirm/alert anywhere — this is OS-level notification, in-app surfaces stay proper UI.\n\n## Files\n- src/ (notification posting — objc2 UserNotifications bindings or mac-notification-sys; watch the 32 MiB binary gate)\n- src/review/manager.rs (unfocused-room import hook, shared with unread task)\n- src/main.rs (activation -\u003e focus room)\n\n## Approach\nBoth owner and reviewer side fire. Debounce window ~5s per room. Respect a per-room mute toggle (UI stub acceptable; setting persisted). Run task check:size before merge — new dep must fit the budget.\n\n## Verification\n- cargo build \u0026\u0026 task check:size (binary stays under 32 MiB)\n- cargo test debounce fold logic\n- Smoke: dual-instance — 3 rapid reviewer comments -\u003e exactly one collapsed notification on owner; click focuses the room/window\n\n## Plan Reference\n- planning/sharing/README.md (Workstream B layer 2)","notes":"Modern macOS delivery now uses UNUserNotificationCenter with retained response delegate, bundle-safe gating and legacy fallback; independently approved. Signed-installed visual banner/click is manual because macOS denies ad-hoc debug bundle authorization; raw/full daemon seam validated.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-11T00:00:36Z","created_by":"James Lal","updated_at":"2026-07-11T10:09:22Z","started_at":"2026-07-11T09:05:37Z","closed_at":"2026-07-11T10:09:22Z","close_reason":"Closed","dependencies":[{"issue_id":"attn-02a.4.2","depends_on_id":"attn-02a.4","type":"parent-child","created_at":"2026-07-10T18:00:36Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.4.2","depends_on_id":"attn-02a.4.1","type":"blocks","created_at":"2026-07-10T18:01:14Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} -{"id":"attn-02a.3.6","title":"Validate: durable shares E2E lifecycle","description":"Full share lifecycle against the real local stack.\n\n## Integration Scenarios\n- Create share -\u003e visitor joins live -\u003e room expires (force short TTL) -\u003e same URL still resolves via snapshot -\u003e visitor comments offline-owner -\u003e owner returns, drains, visitor upgrades live\n- Renewal: owner connect extends expiresAt; expiry without renewal kills resolution\n- Revocation: DELETE makes the URL dead immediately (pointer + blobs gone)\n- Epoch rotation: two successive rooms, same URL, comments land in both eras and replay coherently\n\n## E2E Test Commands\n- New scripts/test-share-e2e.sh on scripts/dev-collab.sh stack (short TTL overrides)\n- Relay conformance corpus full run\n\n## Acceptance Criteria\n- The link survives a room death and an owner restart with zero URL change\n- Revocation is immediate and total\n\n## Plan Reference\n- planning/sharing/README.md (Workstream A)","notes":"Real production-boundary lifecycle harness now crosses live local Wrangler, actual RoomDO/ShareDO/R2, Rust DurableShareService/Bootstrapper/ReviewStore, and browser production parser/session. Covers real room deletion, native XChaCha retained fallback, signed v2 events in v3 submission, same-epoch restart/import/pointer-before-ACK, existing-watch live upgrade, renewal/rotation/tier denial, and terminal revoke. Independent review APPROVE; full test-share-e2e.sh passed.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-11T00:00:35Z","created_by":"James Lal","updated_at":"2026-07-11T08:41:35Z","started_at":"2026-07-11T08:05:02Z","closed_at":"2026-07-11T08:41:35Z","close_reason":"Closed","dependencies":[{"issue_id":"attn-02a.3.6","depends_on_id":"attn-02a.3","type":"parent-child","created_at":"2026-07-10T18:00:34Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.3.6","depends_on_id":"attn-02a.3.1","type":"blocks","created_at":"2026-07-10T18:01:22Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.3.6","depends_on_id":"attn-02a.3.2","type":"blocks","created_at":"2026-07-10T18:01:23Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.3.6","depends_on_id":"attn-02a.3.3","type":"blocks","created_at":"2026-07-10T18:01:23Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.3.6","depends_on_id":"attn-02a.3.4","type":"blocks","created_at":"2026-07-10T18:01:24Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.3.6","depends_on_id":"attn-02a.3.5","type":"blocks","created_at":"2026-07-10T18:01:25Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":5,"dependent_count":0,"comment_count":0} -{"id":"attn-02a.4.1","title":"Unread state: per-room counts from import pipeline + UI badges","description":"Import pipeline maintains per-room unread counts (events not yet seen in a focused view); Svelte surfaces badges on peer strip, tab, and tree rows. Read-marker persists locally so restarts don't re-badge.\n\n## Files\n- src/review/manager.rs (unread accounting + ReviewUpdate surface)\n- web/src/ (badge rendering; tabs.ts, tree, peer strip)\n- ~/.attn/reviews/ read-marker persistence\n\n## Approach\nCount on import, clear on room-visible+focused. Marker is a per-room last-seen event cursor, not per-event flags.\n\n## Verification\n- cargo build \u0026\u0026 cargo test unread (import increments; focus clears; marker survives restart)\n- cd web \u0026\u0026 npm run build\n- Smoke: dual-instance — reviewer comments while owner tab unfocused -\u003e badge shows 1; focus clears it; restart daemon -\u003e still cleared\n\n## Plan Reference\n- planning/sharing/README.md (Workstream B layer 1)","notes":"Persisted per-room unread cursor/count complete with serialized exact-once accounting, restart crash-gap reconciliation, durable offline drain replay, visible+focused clear IPC, boot callback queue, and accessible badges across peer/room/tab/tree/sidebar/rail. Independent review APPROVE; full Rust/web and focused gates green.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-11T00:00:35Z","created_by":"James Lal","updated_at":"2026-07-11T09:05:33Z","started_at":"2026-07-11T08:34:22Z","closed_at":"2026-07-11T09:05:33Z","close_reason":"Closed","dependencies":[{"issue_id":"attn-02a.4.1","depends_on_id":"attn-02a.4","type":"parent-child","created_at":"2026-07-10T18:00:35Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":2,"comment_count":0} -{"id":"attn-02a.3.5","title":"Browser visitor flow: resolve share -\u003e live room or snapshot + comment queue","description":"attn.sh/s/... resolves the share: if a live room exists, join it exactly as today; if not, fetch the retained encrypted snapshot, decrypt client-side, render read-only, queue comments into the share mailbox, and upgrade to the live room when it appears.\n\n## Files\n- web/src/BrowserReviewApp.svelte + web/src/lib/review/ (share resolution, offline-owner mode, upgrade path)\n- web/src/lib/review/browser-crypto (epoch derivation consumption)\n\n## Approach\nReuse the shipped snapshot renderer and outbox machinery; the share mailbox is just a different POST target. Poll-free upgrade: subscribe to the share record over WS (or piggyback the existing signaling channel) so room appearance flips the client live. Respect tier capabilities from Workstream C.\n\n## Verification\n- cd web \u0026\u0026 npm run build \u0026\u0026 npm test\n- Playwright smoke: open share URL with relay room expired -\u003e doc renders read-only from snapshot; submit comment -\u003e queued; boot owner -\u003e client upgrades to live room without reload and the comment thread appears\n\n## Plan Reference\n- planning/sharing/README.md (Workstream A)","notes":"Real-stack validation additionally fixed production room-liveness probing via authenticated /devices, latest retained snapshot GET/header binding, terminal 4001 plaintext clear/termination, and verified no-reload upgrade.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-11T00:00:34Z","created_by":"James Lal","updated_at":"2026-07-11T08:41:39Z","started_at":"2026-07-11T06:29:47Z","closed_at":"2026-07-11T08:04:17Z","close_reason":"Closed","dependencies":[{"issue_id":"attn-02a.3.5","depends_on_id":"attn-02a.3","type":"parent-child","created_at":"2026-07-10T18:00:33Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.3.5","depends_on_id":"attn-02a.3.1","type":"blocks","created_at":"2026-07-10T18:01:12Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.3.5","depends_on_id":"attn-02a.3.2","type":"blocks","created_at":"2026-07-10T18:01:13Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0} -{"id":"attn-02a.3.4","title":"Owner daemon share lifecycle: create, renew, revoke, epoch mint, mailbox drain","description":"attn share \u003cfile|dir\u003e, attn share renew|revoke. On daemon connect: renew live shares, mint a fresh room (next epoch) when a share has visitors/queued mail, drain the share mailbox into the room, update the share record (roomId + latest snapshot ref).\n\n## Files\n- src/cli_review.rs or new src/cli_share.rs\n- src/review/manager.rs / bootstrap.rs (epoch room mint, drain)\n- src/daemon.rs (connect-time renewal hook)\n\n## Approach\nReuse the existing room-creation path with the epoch-derived secret. Drain preserves envelope order and dedupes by EnvelopeId (outbox freezing rules apply). Share state persists under ~/.attn/shares/. Update share record last (pointer only flips once the room is live).\n\n## Verification\n- cargo build \u0026\u0026 cargo test share lifecycle (create -\u003e record written; drain imports queued comments in order; revoke deletes)\n- Smoke on dev-collab stack: create share, kill owner, queue a comment via relay mailbox (curl), restart owner, comment appears in the new room and in the review panel\n\n## Plan Reference\n- planning/sharing/README.md (Workstream A)","notes":"Production owner lifecycle complete: serialized crash-safe create/renew/reconcile/revoke, deterministic same-epoch room recovery + runtime activation, pointer-last exact retained snapshot reconcile/delete, content-aware offline edits, strict two-phase mailbox preflight/import and exact relay ACK validation, imported vs pointer-committed ACK watermarks, real typed CLI/daemon results. Independent adversarial review APPROVE; fmt/check/strict clippy/full and focused gates green.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-11T00:00:33Z","created_by":"James Lal","updated_at":"2026-07-11T08:33:50Z","started_at":"2026-07-11T06:29:45Z","closed_at":"2026-07-11T08:33:50Z","close_reason":"Closed","dependencies":[{"issue_id":"attn-02a.3.4","depends_on_id":"attn-02a.3","type":"parent-child","created_at":"2026-07-10T18:00:32Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.3.4","depends_on_id":"attn-02a.3.1","type":"blocks","created_at":"2026-07-10T18:01:11Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.3.4","depends_on_id":"attn-02a.3.2","type":"blocks","created_at":"2026-07-10T18:01:12Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0} -{"id":"attn-02a.3.3","title":"R2 share-pinned snapshot retention (amend decision #9)","description":"Snapshot blobs referenced by a live share outlive the 7-day R2 sweep; owner renewal re-pins them; share revocation/expiry unpins for cleanup.\n\n## Files\n- relay/ (R2 lifecycle / pin bookkeeping on the share record)\n- planning/collab/amendments.md or relay-spec.md (document the amendment to decision #9)\n\n## Approach\nPin = the share record holds the authoritative blob refs; the sweep consults share liveness before deleting. Keep the room-scoped 7d behavior for everything not share-pinned. Bound retained bytes per share (latest snapshot per file only — superseded snapshots are not pinned).\n\n## Verification\n- Relay tests: blob referenced by live share survives simulated sweep; unpinned after DELETE /v3/shares\n- Conformance corpus case for pin/unpin\n- Byte-bound test: superseded snapshot not pinned\n\n## Plan Reference\n- planning/sharing/README.md (Workstream A, amendment impact)","notes":"Implemented share-scoped encoded R2 namespace, owner-signed+PoW bounded binary snapshot upload, read-admission fetch, strict latest-per-file manifest, 64-file/5MiB-file/25MiB-share bounds, intent-first crash-safe supersede cleanup, room-sweep isolation, and tombstoned revoke/expiry retry. Independent security review APPROVE; relay typecheck, diff check, and 80/80 broader focused tests pass.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-11T00:00:32Z","created_by":"James Lal","updated_at":"2026-07-11T06:28:38Z","started_at":"2026-07-11T06:12:13Z","closed_at":"2026-07-11T06:28:38Z","close_reason":"Share-pinned snapshot retention implemented, crash-hardened, tested, documented, and independently approved.","dependencies":[{"issue_id":"attn-02a.3.3","depends_on_id":"attn-02a.3","type":"parent-child","created_at":"2026-07-10T18:00:31Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.3.3","depends_on_id":"attn-02a.3.1","type":"blocks","created_at":"2026-07-10T18:01:14Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} -{"id":"attn-02a.3.2","title":"Share key schedule + URL forms (shareSecret -\u003e epoch room secrets)","description":"roomSecret_n = HKDF(shareSecret, \"attn share room v3\", epoch_n). Same URL admits into every successive room without changing. URL forms: https://attn.sh/s/\u003cshareId\u003e#key=\u003cshareSecret\u003e and attn://share/\u003cshareId\u003e#key=....\n\n## Files\n- src/review/crypto/ + web/src/lib/review/browser-crypto (derivation, Rust+TS)\n- planning/collab/test-vectors/ (epoch derivation vectors)\n- src/main.rs custom-scheme handler (attn://share/ routing alongside attn://review/, per amendments path-prefix convention)\n- planning/collab/crypto-spec.md\n\n## Approach\nVectors-first per the corpus pattern. Epoch is a monotonic integer carried in the share record (plaintext-safe: it derives nothing without the secret). Composes with Workstream C: per-epoch room secret feeds the tiered read/write derivations.\n\n## Verification\n- cargo test \u0026\u0026 cd web \u0026\u0026 npm test (epoch vectors pass both languages)\n- Smoke: derive epoch 0 and 1 -\u003e distinct room secrets; same shareSecret + epoch -\u003e identical across Rust/TS\n- attn://share/... URL parses and routes to daemon (unit test on scheme handler)\n\n## Plan Reference\n- planning/sharing/README.md (Workstream A)","notes":"Added vectors-first HKDF-SHA256 shareSecret+uint64be(epoch) derivation across Rust/WebCrypto; strict canonical 16-byte share IDs and 32-byte fragment secrets for attn://share and https://attn.sh/s forms; native routing validates before daemon dispatch; secret-bearing Rust invite zeroizes and cannot Debug/Clone. Independent review APPROVE; Rust/share/browser crypto/Svelte checks pass.","status":"closed","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-11T00:00:31Z","created_by":"James Lal","updated_at":"2026-07-11T06:11:07Z","started_at":"2026-07-11T05:41:13Z","closed_at":"2026-07-11T06:11:07Z","close_reason":"Cross-runtime KDF and canonical URL routing implemented, verified, and independently reviewed.","dependencies":[{"issue_id":"attn-02a.3.2","depends_on_id":"attn-02a.3","type":"parent-child","created_at":"2026-07-10T18:00:30Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":3,"comment_count":0} -{"id":"attn-02a.3.1","title":"Relay: share object + /v3/shares endpoints + share mailbox","description":"New long-lived relay object: shareId, ownerSigningKey, optional current roomId, latest-encrypted-snapshot blob ref per file, updatedAt/expiresAt, pushSubscriptions placeholder. POST/GET/DELETE /v3/shares/:shareId — owner-signed writes (decision #3 scheme), PoW on writes (decision #6). Share-scoped mailbox accepts comment envelopes while no room is live.\n\n## Files\n- relay/ (new DO or KV-backed share record + mailbox)\n- planning/collab/relay-spec.md (/v3/shares section)\n- conformance corpus additions\n\n## Approach\nRooms remain untouched v2 objects; the share is a pointer + retained ciphertext blob. GET is read-admission MAC'd. Default expiresAt = 90d since last owner touch; owner connect renews. DELETE kills pointer, mailbox, and retained blobs (the revocation kill switch).\n\n## Verification\n- Relay conformance corpus run under miniflare (create/read/renew/revoke; mailbox accept + drain order; non-owner write rejected)\n- cargo test transport share client calls\n- Smoke: create share via curl against wrangler dev --local, GET resolves, DELETE 404s afterward\n\n## Plan Reference\n- planning/sharing/README.md (Workstream A)","notes":"Implemented ShareDO and /v3/shares endpoints with owner-signed+PoW mutations, read/write admission, redacted public records, bounded ordered mailbox, idempotent envelope retries/conflicts, pagination, transactional prefix ACK, expiry/revocation, min-deadline marker cleanup, CORS/rate limits, Miniflare integration and executable conformance coverage. Independent security review APPROVE; relay typecheck and focused suites pass.","status":"closed","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-11T00:00:28Z","created_by":"James Lal","updated_at":"2026-07-11T06:10:43Z","started_at":"2026-07-11T05:40:30Z","closed_at":"2026-07-11T06:10:43Z","close_reason":"Implemented and independently security-reviewed; focused relay tests pass.","dependencies":[{"issue_id":"attn-02a.3.1","depends_on_id":"attn-02a.3","type":"parent-child","created_at":"2026-07-10T18:00:28Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":4,"comment_count":0} -{"id":"attn-02a.2.5","title":"Validate: permission tiers E2E","description":"Feature-level validation that the three tiers behave as designed across relay, native, and browser clients.\n\n## Integration Scenarios\n- View link: browser reviewer renders doc, cannot POST anything (relay rejects), composer absent\n- Comment link: comment round-trips; crafted suggestion envelope dropped at owner import\n- Suggest link: full current reviewer flow incl. accept -\u003e apply\n- Tier vocabulary consistent between native share sheet and browser join\n\n## E2E Test Commands\n- Extend scripts/test-review-e2e.sh (or new scripts/test-tiers-e2e.sh) on the dev-collab stack\n- Relay conformance corpus run\n\n## Acceptance Criteria\n- All three tiers exercised against a real miniflare relay with dual instances + one browser client\n- Trust-model docs updated (crypto-enforced vs policy-enforced boundary stated)\n\n## Plan Reference\n- planning/sharing/README.md (Workstream C)","notes":"Implemented and executed the real permission-tier matrix. Native Miniflare integration uses isolated owner/commenter/suggester/viewer identities: native view refuses writable registration, comment round-trips, local comment suggestion is blocked, a correctly signed/encrypted hostile comment-tier SuggestionCreated is delivered ahead of a valid-comment barrier and discarded by owner import, and suggest propagates then owner accept applies to disk. Hosted Wrangler+Vite+Chromium extracts all three v3 URLs, verifies rendered native labels and comment default, visible browser View only/Can comment/Can suggest vocabulary, view zero mutations + viewer_id WS, comment-only composer + native roundtrip, suggest authoring to native owner, plaintext/secret exclusion, and honest temporary v3 persistence. Trust model updated. Verification: focused native tier E2E passed; hosted Playwright 4/4 passed; relay v3/conformance 33/33; web check 0 diagnostics; browser build passed; test clippy -D warnings passed; independent review APPROVE.","status":"closed","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-10T23:58:53Z","created_by":"James Lal","updated_at":"2026-07-11T05:39:40Z","started_at":"2026-07-11T05:17:41Z","closed_at":"2026-07-11T05:39:40Z","close_reason":"Validated all three permission tiers across real Miniflare native clients and hosted Chromium, including hostile import rejection and owner apply.","dependencies":[{"issue_id":"attn-02a.2.5","depends_on_id":"attn-02a.2","type":"parent-child","created_at":"2026-07-10T17:58:53Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.2.5","depends_on_id":"attn-02a.2.1","type":"blocks","created_at":"2026-07-10T18:01:19Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.2.5","depends_on_id":"attn-02a.2.2","type":"blocks","created_at":"2026-07-10T18:01:20Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.2.5","depends_on_id":"attn-02a.2.3","type":"blocks","created_at":"2026-07-10T18:01:20Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.2.5","depends_on_id":"attn-02a.2.4","type":"blocks","created_at":"2026-07-10T18:01:21Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":4,"dependent_count":0,"comment_count":0} -{"id":"attn-02a.2.4","title":"Tiered invite generation: three URLs per share (view / comment / suggest)","description":"Share sheet and CLI emit tier-scoped invite URLs. Default comment for human invites, suggest for agent invites. Rotation story: revoking a leaked view link = bump share epoch and re-issue (cheap once Workstream A lands; until then documented as re-share).\n\n## Files\n- web/src/ share sheet UI (native window)\n- src/cli_review.rs (attn review invite --tier)\n- Coordinate with attn-7xl.4.5 (accountless browser Share sheet) — same tier vocabulary, do not fork the UX\n\n## Approach\nInvite construction picks which derived keys enter the fragment per tier. Three copyable URLs with clear labels (\"Anyone with this link can view / comment / suggest\"). Keep the existing full invite as the suggest tier — no breakage of current flows, but cut over the default emission to tiered form (no legacy pathway kept).\n\n## Verification\n- cd web \u0026\u0026 npm run build; cargo build\n- Playwright/attn --query smoke: share sheet renders three tier rows; copied URLs differ; view URL joins read-only in browser reviewer\n- cargo test invite tier encoding round-trip\n\n## Plan Reference\n- planning/sharing/README.md (Workstream C, open question 2: comment default pending annotation)","notes":"Security/integration map: v3 fragments must be strict: view #v=3\u0026tier=view\u0026read=...; comment/suggest add \u0026write=...\u0026grant=\u003c64-byte owner Ed25519 signature\u003e. grant is owner signature over canonical {grantTier,purpose:'attn device grant v3',roomId,v:3}; absent for view, mandatory writable. New shares emit v3 only; legacy v2 parse/join unchanged. This task must include production client v3 transport cutover (versioned read/write keys, /v3 HTTP+WS+R2, restart persistence), not only URL/UI generation, or links will parse but call v2. Smallest secure view flow: /v3 socket with mutually-exclusive fresh viewer_id plus read-hmac, tagged anonymous attachment; subscribe/pong and non-signal replay only; no POST devices, identity, outbox, ACK, signaling, presence, WebRTC, or peer quota. Separate viewer rate/socket cap. Browser exposes tier/canComment/canSuggest and stable View only state with method-level guards. Native share dialog and hosted vocabulary use three rows: Anyone with this link can view/comment/suggest; human default comment, agent default suggest. CLI invite needs target path/room semantics; re-emit existing owner share, never mint ambiguity. Focused tests: strict fragment/grant cross-room+tier tamper, six URLs, v3 route/key selection, restart, anonymous view zero POST, comment hard suggest rejection, relay viewer no signal/presence, native share-sheet labels/default. .2.5 retains real miniflare dual-native+browser matrix. Per-tier revocation remains re-share until durable share epochs.\nImplemented production v3 tiered invites end-to-end: six native/browser URLs (view/comment/suggest), strict canonical fragments and owner grants, CLI re-emission with legacy rejection, versioned native/browser mailbox/outbox/R2/signaling, anonymous read-only viewer sockets, registered dual read/write socket proofs, v3 persistence, hard UI/authoring guards, and aligned three-row Share UI. Security re-review APPROVE after resolving device-id escalation, native R2, v3 room-id, signaling-key, and legacy CLI blockers. Verification: cargo test --lib 458 passed/5 ignored; cargo build passed; cargo clippy --all-targets -D warnings passed; relay typecheck/focused v2-v3-viewer 27 passed; full relay 428/429 with existing multiparty hello timing flake and exact rerun passed; web check 0 diagnostics; native and browser builds passed; full web 56/57 with existing remembered-room timing flake and standalone browser-session 24/24 passed. In-app browser was unavailable in this runtime, so source assertions/builds cover the three labels/default and .2.5 owns real stack E2E.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-10T23:58:52Z","created_by":"James Lal","updated_at":"2026-07-11T05:15:43Z","started_at":"2026-07-11T04:22:33Z","closed_at":"2026-07-11T05:15:43Z","close_reason":"Shipped tier-scoped v3 invites and production client/relay transport cutover with security approval and passing build/test gates.","dependencies":[{"issue_id":"attn-02a.2.4","depends_on_id":"attn-02a.2","type":"parent-child","created_at":"2026-07-10T17:58:52Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.2.4","depends_on_id":"attn-02a.2.1","type":"blocks","created_at":"2026-07-10T18:01:10Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} -{"id":"attn-02a.2.2","title":"Relay: enforce write-admission MAC on all mutating endpoints","description":"POST /devices, /envelopes, /acks, /blobs (and DELETE) must MAC with writeAdmissionKey; WS connect and blob GET accept read-admission. View-only bearers get rejected on writes by the relay itself — cryptographic enforcement, no server-side token state (decision #2 shape preserved).\n\n## Files\n- relay/ (per-endpoint auth)\n- planning/collab/relay-spec.md (auth table update)\n- conformance corpus (new cases: read-only bearer write -\u003e 401/403 error code)\n\n## Approach\nAdd the second MAC verification path alongside the existing admission check; pick and document a distinct error code (e.g. ATTN_WRITE_CAPABILITY_REQUIRED). Extend the conformance corpus per the Phase 3a pattern so the Rust client transport tests cover it too.\n\n## Verification\n- cd relay \u0026\u0026 npx wrangler dev --local conformance run (corpus passes incl. new rejection cases)\n- cargo test transport (client-side conformance)\n- Smoke vs miniflare: write with read-only capability rejected; with write capability accepted\n\n## Plan Reference\n- planning/sharing/README.md (Workstream C)","notes":"Additive v3 relay contract: new /v3 room routes; create body v=3 includes ownerSigningKey plus readAdmissionKey and writeAdmissionKey, stored as two per-room verifier secrets with protocolVersion. Attn-Admission uses v3.read.MAC for read endpoints and v3.write.MAC for mutations; v2 remains exact. Valid read proof on a write endpoint returns 403 ATTN_WRITE_CAPABILITY_REQUIRED; malformed/wrong proofs remain 401 ATTN_ADMISSION_INVALID; cross-version room access returns ATTN_PROTOCOL_VERSION_MISMATCH. Read: GET devices, cap-less blob download presign, WS. Write: rejoin POST room, POST devices/envelopes/acks/blob upload presign, DELETE. First create owner-signed supplies both keys. WS uses attn.v3 plus read-hmac. Conformance must preserve v2 and add v3 read-write matrix. Known integration issue: fresh view bearer cannot POST /devices but WS currently requires registered device; address constrained anonymous/read-only identity in attn-02a.2.3 or validation without weakening write-only registration.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-10T23:58:51Z","created_by":"James Lal","updated_at":"2026-07-11T03:29:52Z","started_at":"2026-07-11T02:56:19Z","closed_at":"2026-07-11T03:29:52Z","close_reason":"Implemented additive /v3 scoped relay admission with all endpoint read/write gates, WS read auth, protocol mismatch, version-bound blob caps, equal-key rejection, conformance matrix and spec. Typecheck, 78 focused relay tests, isolated flaky rate-limit case, semantic conformance, and 194 Rust transport executions pass.","dependencies":[{"issue_id":"attn-02a.2.2","depends_on_id":"attn-02a.2","type":"parent-child","created_at":"2026-07-10T17:58:50Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.2.2","depends_on_id":"attn-02a.2.1","type":"blocks","created_at":"2026-07-10T18:01:10Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} -{"id":"attn-02a.2.3","title":"grantTier in device registration + import-side enforcement + composer gating","description":"Bind grantTier (comment | suggest) into the signed device-registration payload. Owner client and peers drop out-of-tier events on import; the composer does not offer suggest to comment-tier participants. Documented honestly as policy enforcement (envelopes are relay-opaque), not crypto.\n\n## Files\n- src/review/ (registration payload, import pipeline drop rule)\n- relay/ deviceRegistrationSchema (accept grantTier)\n- web/src/lib/review/ + composer component (tier-aware UI)\n- planning/collab/data-model.md trust-model note\n\n## Approach\ngrantTier rides the AAD-bound registration like kind/client. Import pipeline consults the sender's registered tier before accepting suggestion envelopes; drops are logged, not fatal. Owner and agents are unaffected (owner keeps decision #3 authority; agents default suggest).\n\n## Verification\n- cargo build \u0026\u0026 cargo test tier (comment-tier suggestion envelope dropped on import; comment accepted)\n- cd web \u0026\u0026 npm run build \u0026\u0026 npm test\n- Smoke: dual-instance with comment-tier reviewer — composer shows no Suggest affordance; hand-crafted suggestion envelope is discarded by owner import\n\n## Plan Reference\n- planning/sharing/README.md (Workstream C)","notes":"Implemented additive v3 owner-signed grantTier primitives. Relay requires paired comment/suggest tier+owner signature for v3 non-owner registration, forbids owner grant fields, verifies exact room-bound canonical proof, and device selfSignature binds both; v2 is unchanged. Native and browser directory imports verify paired immutable grants before authorizing, derive exact capabilities by kind+tier, and drop comment-tier suggestions before persistence. Native manager hard-blocks comment-tier suggestion creation before event/outbox; hosted/native composer affordances and direct entry points use room-scoped effective tier. Trust boundary documented as peer policy enforcement over relay-opaque envelopes. Independent security rereview APPROVE. Verification: cargo test comment_grant_rejects_suggestion_before_event_or_outbox PASS (lib+main); cargo test tier PASS (lib+main); cargo check PASS; relay typecheck + focused schema/v3 integration 16/16 PASS; browser-ws 20/20 PASS; svelte-check 0 diagnostics. Browser-session new grant cases pass; full standalone harness has an existing intermittent remembered-room cursor timing case (observed 21/22 after previously passing 22/22), unrelated to this diff. Production v3 join/transport must populate and persist/reload set_local_grant_tier and BrowserSession grant options in attn-02a.2.4; anonymous view socket remains there.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-10T23:58:51Z","created_by":"James Lal","updated_at":"2026-07-11T04:21:13Z","started_at":"2026-07-11T03:32:27Z","closed_at":"2026-07-11T04:21:13Z","close_reason":"Closed","dependencies":[{"issue_id":"attn-02a.2.3","depends_on_id":"attn-02a.2","type":"parent-child","created_at":"2026-07-10T17:58:51Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"attn-02a.2.1","title":"Split HKDF tree: readKeys vs writeAdmissionKey (Rust + TS + vectors)","description":"Derive read capability (event/snapshot decryption + read-admission MAC) and write capability (writeAdmissionKey) as separate HKDF branches with v3-suffixed info strings. A view-only URL simply omits the write capability from its fragment.\n\n## Files\n- src/review/crypto/ (derivation)\n- web/src/lib/review/browser-crypto (TS parity)\n- planning/collab/test-vectors/ (new corpus entries)\n- planning/collab/crypto-spec.md (key-derivation tree update, v3 info strings)\n\n## Approach\nFollow the existing corpus-driven pattern (Phase 0a): add vectors first, implement in both languages, both pass. Fragment encoding gains a tier discriminator so clients know which keys they hold. Do not touch v2 derivations — v3 strings are additive.\n\n## Verification\n- cargo build \u0026\u0026 cargo test crypto (vector corpus passes)\n- cd web \u0026\u0026 npm test (TS vectors pass, Rust/TS interop on corpus)\n- Round-trip: URL fragment with read-only capability decrypts events but produces no writeAdmissionKey\n\n## Plan Reference\n- planning/sharing/README.md (Workstream C)","notes":"Additive v3 design: rootKeyV3 from roomSecret using attn room root v3; readCapabilityKeyV3 from root using attn read capability v3; event/snapshot/signaling/readAdmission leaves derive only from read capability with v3 labels; writeAdmissionKeyV3 derives separately from root. View URLs carry read capability only; comment/suggest also carry write leaf. Strict fragment: v=3\u0026tier=view|comment|suggest\u0026read=... with write=... required only for comment/suggest. Add derive/read APIs in Rust and TS, separate kdf-v3.json corpus, crypto-spec update, strict additive Rust/TS fragment helpers; preserve all v2 APIs and production routes. V3 roomId remains SHA256 prefix attn room v3 over owner roomSecret; recipients authenticate the room path with read-admission because they intentionally lack roomSecret.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-10T23:58:50Z","created_by":"James Lal","updated_at":"2026-07-11T02:53:52Z","started_at":"2026-07-11T02:30:28Z","closed_at":"2026-07-11T02:53:52Z","close_reason":"Implemented additive v3 read/write capability HKDF split with Rust/TS parity, strict tiered fragments, 4-vector corpus, AEAD view-only proof, and spec updates. Rust crypto, full web suite, build, clippy, release size all pass.","dependencies":[{"issue_id":"attn-02a.2.1","depends_on_id":"attn-02a.2","type":"parent-child","created_at":"2026-07-10T17:58:49Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":3,"comment_count":0} -{"id":"attn-02a.1.3","title":"Implement attn review submit-suggestion --from-diff","description":"Turn a unified diff (the agent's worktree changes to the shared doc) into one suggestion per hunk, anchored via the existing anchor engine. Removes the main friction in the worktree flow.\n\n## Files\n- src/cli_review.rs (--from-diff \u003cfile|-\u003e)\n- src/review/ (diff -\u003e hunk -\u003e anchor+replacement construction)\n\n## Approach\nParse unified diff (no new heavy dep if avoidable — a small parser over @@ hunks is fine), map each hunk's context to an anchor via the anchor engine against the current shared snapshot, emit one suggestion envelope per hunk through the existing submit path. Reject hunks that fail to anchor with a clear per-hunk error (partial submission is allowed but reported). Update planning/collab/agent-cli-howto.md with the gate loop (worktree -\u003e --from-diff -\u003e verdicts --wait).\n\n## Verification\n- cargo build\n- cargo test from_diff (fixture diff with 3 hunks -\u003e 3 suggestions; non-anchoring hunk reported)\n- Smoke: dual-instance — git-diff a fixture edit, submit --from-diff, owner sees per-hunk suggestions\n\n## Plan Reference\n- planning/sharing/README.md (Workstream D, open question 4: per-hunk granularity is the default pending annotation)","notes":"Approach: add src/review/diff_suggestions.rs with a small one-file unified-diff parser and per-hunk builder. Select a persisted Markdown snapshot by optional --room or unique diff-path/snapshot match; verify hunk old/context projection against snapshot (expected location then unique fallback); construct replace/delete/insert SuggestionDraft anchors from canonical byte/line ranges plus snapshot AnchorIndex block/context/structure layers; return valid drafts plus indexed per-hunk errors so partial submission is explicit. Add attn review submit-suggestion --from-diff PATH_OR_STDIN [--room ID], daemon batch request that submits each draft through ReviewCommand::CreateSuggestion, and agent-cli-howto gate-loop docs. Default uses daemon identity per locked Agent CLI handling. Files: src/review/diff_suggestions.rs, src/review/mod.rs, src/cli_review.rs, src/daemon.rs, planning/collab/agent-cli-howto.md. Tests: 3-hunk fixture gives 3 drafts; non-anchoring hunk error while valid hunk survives; insert/delete/replace operations; CLI stdin/file parse and socket batch serialization. Validation: cargo fmt; cargo test from_diff; cargo build; dual-instance git-diff smoke owner sees per-hunk suggestions.","status":"closed","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-10T23:58:48Z","created_by":"James Lal","updated_at":"2026-07-11T02:08:39Z","started_at":"2026-07-11T01:23:54Z","closed_at":"2026-07-11T02:08:39Z","close_reason":"Implemented and verified: per-hunk unified diff submission, reviewer snapshot reconstruction, durable local pending events, partial failures, docs, focused tests, dual-daemon relay smoke, clippy, build, and size gate.","dependencies":[{"issue_id":"attn-02a.1.3","depends_on_id":"attn-02a.1","type":"parent-child","created_at":"2026-07-10T17:58:47Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"attn-02a.5","title":"Browser Web Push: content-free ping, local decrypt in service worker","description":"The browser reviewer has no resident daemon — push is what makes the browser side proactive. The relay sends a content-free ping (VAPID); the service worker wakes, opens the remembered-room capability from IndexedDB (decision #13), pulls envelopes over WS, decrypts and verifies locally, and composes the notification itself. Apple/Google push infra never sees author, kind, or text. Subscriptions are bound to (share/room, deviceId), MAC'd, PoW'd, expiring with share/room TTL.\n\n## Scope\n- Relay: VAPID keypair, subscription store, ping-on-arrival for devices with no live WS, per-device debounce\n- Service worker on attn.sh: subscribe, wake -\u003e pull -\u003e local decrypt -\u003e rich local notification, click deep-link\n- Consent UX alongside \"Remember this room\" (open question 6 in the plan)\n- iOS: requires Home Screen PWA install (16.4+) — rides attn-7xl's iOS work; desktop has no gate\n\n## Pre-conditions\nDurable shares (subscription store lives on the share record; land alongside A so shares ship with push).\n\n## Plan Reference\n- planning/sharing/README.md (Workstream B, layer 4 / Browser Web Push design sketch)","status":"closed","priority":2,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-10T23:57:36Z","created_by":"James Lal","updated_at":"2026-07-11T10:33:41Z","closed_at":"2026-07-11T10:33:41Z","close_reason":"Closed","dependencies":[{"issue_id":"attn-02a.5","depends_on_id":"attn-02a","type":"parent-child","created_at":"2026-07-10T17:57:36Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.5","depends_on_id":"attn-02a.3","type":"blocks","created_at":"2026-07-10T18:01:08Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} -{"id":"attn-02a.4","title":"Async notifications: unread state, native notifications, resident daemon","description":"Close the async loop on the native side. Per-room unread counts from the import pipeline with UI badges; macOS user notifications from the daemon when review events import unfocused (debounced, deep-linking via attn://review/...); an opt-in resident daemon mode so notifications fire without a window open.\n\n## Scope\n- Unread counts + badges (peer strip / tab / tree)\n- Daemon-posted macOS notifications, debounced per room\n- attn daemon --resident + login item opt-in\n\n## Pre-conditions\nNone. Pure client work; no protocol change.\n\n## Plan Reference\n- planning/sharing/README.md (Workstream B, layers 1-3)","status":"closed","priority":2,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-10T23:57:35Z","created_by":"James Lal","updated_at":"2026-07-11T10:09:36Z","closed_at":"2026-07-11T10:09:36Z","close_reason":"Closed","dependencies":[{"issue_id":"attn-02a.4","depends_on_id":"attn-02a","type":"parent-child","created_at":"2026-07-10T17:57:34Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-02a.3","title":"Durable share links: relay-side share indirection","description":"The link is the document. Rooms stay ephemeral (decision #8 intact); a small long-lived relay share object holds the current roomId and the latest encrypted snapshot per file. Same URL admits into successive rooms via epoch-derived room secrets. Visitors can read (and queue comments into a share mailbox) while the owner is offline. Owner-signed revocation is the kill switch. Amends decision #9 (share-pinned R2 retention).\n\n## Scope\n- Relay: /v3/shares endpoints (POST/GET/DELETE, owner-signed, PoW) + share mailbox\n- Key schedule: shareSecret -\u003e epoch room secrets; attn.sh/s/\u003cshareId\u003e#key=... URL forms\n- Owner daemon: share create/renew/revoke, room epoch minting, mailbox drain on connect\n- Browser visitor flow: live room, or snapshot read-only + comment queue, upgrade on room appearance\n\n## Pre-conditions\nPermission tiers (full-power durable links must not circulate).\n\n## Plan Reference\n- planning/sharing/README.md (Workstream A)\n- planning/collab/amendments.md decisions #8, #9, #10","status":"closed","priority":2,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-10T23:57:34Z","created_by":"James Lal","updated_at":"2026-07-11T08:41:40Z","started_at":"2026-07-11T05:40:29Z","closed_at":"2026-07-11T08:41:40Z","close_reason":"Closed","dependencies":[{"issue_id":"attn-02a.3","depends_on_id":"attn-02a","type":"parent-child","created_at":"2026-07-10T17:57:33Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.3","depends_on_id":"attn-02a.2","type":"blocks","created_at":"2026-07-10T18:01:08Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} -{"id":"attn-02a.2","title":"Permission-tiered links: view / comment / suggest capabilities","description":"Split the HKDF tree so read and write are separate URL-borne capabilities. The relay cryptographically enforces the read/write boundary (view-only links cannot write); the comment/suggest boundary is a signed grantTier in device registration, enforced at import by owner and peers. Amends decision #2 (one admission key -\u003e read/write pair; still URL-as-bearer, no server-side token state).\n\n## Scope\n- Key derivation split: readKeys vs writeAdmissionKey, v3 info strings, Rust+TS parity vectors\n- Relay per-endpoint auth: writes MAC with writeAdmissionKey\n- grantTier in device registration + import-side enforcement + composer gating\n- Tiered invite generation (three URLs per share)\n\n## Pre-conditions\nNone, but MUST ship before durable shares are promoted publicly.\n\n## Plan Reference\n- planning/sharing/README.md (Workstream C)\n- planning/collab/crypto-spec.md, relay-spec.md","status":"closed","priority":2,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-10T23:57:33Z","created_by":"James Lal","updated_at":"2026-07-11T05:39:44Z","started_at":"2026-07-11T02:30:28Z","closed_at":"2026-07-11T05:39:44Z","close_reason":"Completed v3 split capabilities, relay enforcement, owner grants, production tiered invites, and full native/browser E2E validation.","dependencies":[{"issue_id":"attn-02a.2","depends_on_id":"attn-02a","type":"parent-child","created_at":"2026-07-10T17:57:33Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"attn-egi.9","title":"Coordinate remembered browser WebRTC identity across tabs","description":"A remembered room can open the same recovered device identity in multiple tabs, causing duplicate targeted SDP answers. Add a BroadcastChannel/lease leader for WebRTC ownership while keeping mailbox reading and durable recovery correct in every tab.","acceptance_criteria":"Exactly one tab negotiates for a remembered deviceId, leadership fails over after close/crash, non-leaders stay mailbox-correct, and Playwright covers two remembered tabs.","status":"open","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-10T21:46:17Z","created_by":"James Lal","updated_at":"2026-07-10T21:46:17Z","labels":["cloudflare","e2e","staging","web"],"dependencies":[{"issue_id":"attn-egi.9","depends_on_id":"attn-egi","type":"parent-child","created_at":"2026-07-10T15:46:17Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-egi.9","depends_on_id":"attn-egi.4","type":"discovered-from","created_at":"2026-07-10T15:46:17Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-egi.7","title":"Add focused hosted authoring regression coverage","description":"Add focused regression tests for native leave-to-rejoin WebRTC rebuild, hosted cross-file anchor-resolution isolation, and rejected async reply submission retaining its draft and inline error.","acceptance_criteria":"Each scenario has a deterministic focused test that fails against the pre-attn-egi.2 behavior and passes without relying on the full hosted Playwright suite.","status":"open","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-10T16:57:03Z","created_by":"James Lal","updated_at":"2026-07-10T16:57:03Z","labels":["cloudflare","e2e","staging","tests","web","webrtc"],"dependencies":[{"issue_id":"attn-egi.7","depends_on_id":"attn-egi","type":"parent-child","created_at":"2026-07-10T10:57:02Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-egi.7","depends_on_id":"attn-egi.2","type":"discovered-from","created_at":"2026-07-10T10:57:03Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-egi.6","title":"Runtime-validate hosted inbound review event bodies","description":"Add strict per-variant runtime validation for decrypted browser ReviewEvent bodies before BrowserWsClient dispatches them. Keep role/capability checks, actor binding, and failure zeroization; malformed role-allowed comments/suggestions/presence must be dropped without destabilizing the hosted UI.","acceptance_criteria":"Malformed but correctly signed/encrypted events for every ReviewEvent discriminator are rejected before UI/store application; valid Rust-produced corpus events continue to pass.","status":"open","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-10T16:57:00Z","created_by":"James Lal","updated_at":"2026-07-10T16:57:00Z","labels":["cloudflare","e2e","e2ee","security","staging","web"],"dependencies":[{"issue_id":"attn-egi.6","depends_on_id":"attn-egi","type":"parent-child","created_at":"2026-07-10T10:57:00Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-egi.6","depends_on_id":"attn-egi.2","type":"discovered-from","created_at":"2026-07-10T10:57:01Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-7tb","title":"Relay: isolate staging and production R2 namespaces","description":"Production and staging currently share one R2 bucket and generation-blind rooms/\u003croomId\u003e/ keys. Use separate buckets or an authenticated environment prefix so identical room/envelope IDs cannot collide and each environment's capacity ledger bounds its own physical storage.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-09T20:59:02Z","created_by":"James Lal","updated_at":"2026-07-09T21:53:59Z","closed_at":"2026-07-09T21:53:59Z","close_reason":"Production and staging now use separate R2 buckets, with docs and config tests included in attn-tdj validation.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-lon","title":"HTML annotation Phase 6: IPC contract for HTML anchors + client resolution status","description":"Extend the ReviewManager IPC (src/review/manager.rs): CreateComment/CreateSuggestion accept the HTML selector anchor layer; add a path for the webview to report client-side resolution status back so the manager can emit AnchorResolutionChanged for HTML docs. Keep markdown command shapes intact.","acceptance_criteria":"CreateComment/CreateSuggestion carry HTML anchors end-to-end; webview-reported resolution status surfaces as AnchorResolutionChanged; markdown IPC unchanged.","status":"open","priority":2,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-06-18T15:03:30Z","created_by":"James Lal","updated_at":"2026-06-18T15:03:30Z","dependencies":[{"issue_id":"attn-lon","depends_on_id":"attn-yob","type":"blocks","created_at":"2026-06-18T09:03:58Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} -{"id":"attn-08r","title":"HTML annotation Phase 5: shell wiring — ReviewMargin + composer for HTML","description":"Unhide ReviewMargin for HTML (BrowserReviewApp.svelte:272, App.svelte:251 branches). Position rail cards from anchor rects the doc frame reports up via postMessage (cross-origin: doc owns geometry, shell positions cards with push-down collision layout + connector lines, as in the prototype). Feed CommentComposer from doc-frame selection events. store.svelte.ts holds opaque HTML anchors; resolver.ts branches so HTML resolution is delegated to the in-doc runtime (not the 8-step Rust mirror). Reuse ReviewMargin/ReviewMarginCard/CommentComposer unchanged where possible.","acceptance_criteria":"ReviewMargin renders for HTML docs; cards align to anchors and link on hover/focus; composer opens from text and element selections; store handles HTML anchors with no 'any' types.","status":"open","priority":2,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-06-18T15:03:29Z","created_by":"James Lal","updated_at":"2026-06-18T15:03:29Z","dependencies":[{"issue_id":"attn-08r","depends_on_id":"attn-ges","type":"blocks","created_at":"2026-06-18T09:03:59Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-08r","depends_on_id":"attn-lon","type":"blocks","created_at":"2026-06-18T09:04:00Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0} -{"id":"attn-ges","title":"HTML annotation Phase 4: in-doc annotation runtime (productionize prototype JS)","description":"Productionize the prototype's annotation logic as a separate web/ build entry injected into the doc frame. Includes: CSS Custom Highlight API text layer (attn-text / attn-text-active buckets, no wrapper spans); element overlays with pointer-events:none fills and interactive tag/count chips; left-gutter comment pin; scope breadcrumb (cell \u003c row \u003c table) with per-scope selector + human preview + comment counts; selection-\u003efloating Comment pill; nested/stacked-comment N badge. No mode switch — text and element gestures both always live; active text selection suppresses the gutter pin. Use Apache Annotator dom for selector generation/resolution and @medv/finder for CSS selectors. Define and implement the full postMessage protocol both directions. Style in PAPER/INK tokens. This is the heaviest phase and reuses planning/collab/prototypes/html-annotation.html most directly.","acceptance_criteria":"Doc frame renders text highlights + element overlays + gutter pin + scope breadcrumb; selection raises the pill; nested comments share one overlay with an N badge; overlays never trap the cursor; client-side selector resolution works; postMessage round-trips to the shell.","status":"open","priority":2,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-06-18T15:03:29Z","created_by":"James Lal","updated_at":"2026-06-18T15:03:29Z","dependencies":[{"issue_id":"attn-ges","depends_on_id":"attn-17g","type":"blocks","created_at":"2026-06-18T09:03:58Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} -{"id":"attn-17g","title":"HTML annotation Phase 3: serve shared doc from distinct localhost origin (Design B)","description":"Replace the opaque sandbox iframe (HtmlViewer.svelte:96, sandbox='allow-scripts', srcdoc/attn:// path) with a real cross-origin iframe pointing at the shared doc served by Rust on a localhost origin. Adjust the HTML CSP (src/main.rs:646) to serve and permit the injected annotation runtime. Origin-isolation preserved; shell\u003c-\u003edoc only via postMessage.","acceptance_criteria":"Shared HTML doc loads from a real localhost origin in a non-opaque iframe; injected runtime script loads under CSP; postMessage channel established between shell and doc.","status":"open","priority":2,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-06-18T15:03:05Z","created_by":"James Lal","updated_at":"2026-06-18T15:03:05Z","dependencies":[{"issue_id":"attn-17g","depends_on_id":"attn-yob","type":"blocks","created_at":"2026-06-18T09:03:57Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} -{"id":"attn-yob","title":"HTML annotation Phase 2: HTML snapshot substrate + un-gate comment authoring","description":"bootstrap.rs:1467 currently produces (DocType::Html, None). Give HTML snapshots an annotation capability instead of bare None (NOT a Rust DOM indexer — resolution is client-side). Replace the !snapshot.anchorIndex guards (BrowserReviewApp.svelte ~190 openCommentComposer; and the collab-seeding gate) with doc-type-aware checks so HTML docs can author comments. Keep markdown behavior identical.","acceptance_criteria":"HTML snapshot carries an annotation capability; openCommentComposer no longer dead-ends for HTML; markdown path unchanged.","status":"open","priority":2,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-06-18T15:03:04Z","created_by":"James Lal","updated_at":"2026-06-18T15:03:04Z","dependencies":[{"issue_id":"attn-yob","depends_on_id":"attn-80u","type":"blocks","created_at":"2026-06-18T09:03:57Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} -{"id":"attn-80u","title":"HTML annotation Phase 1: HTML selector anchor layer in shared model","description":"Add an HTML selector anchor layer to the shared Anchor model (src/review/model.rs). It must carry a W3C selector set: CssSelector + TextQuote + TextPosition + Range (text ranges AND whole DOM elements, incl. table cell/row/table). serde-optional and doc-type tagged so markdown anchors are unaffected. Must round-trip unchanged through the existing review envelope (no Rust-side resolution of these — opaque persist/sync). Cut over cleanly; no backwards-compat shim.","acceptance_criteria":"Anchor model gains an HTML selector layer; serde round-trip test passes; markdown anchors unchanged; envelope round-trips the new layer.","status":"open","priority":2,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-06-18T15:03:03Z","created_by":"James Lal","updated_at":"2026-06-18T15:03:03Z","dependencies":[{"issue_id":"attn-80u","depends_on_id":"attn-05x","type":"blocks","created_at":"2026-06-18T09:03:55Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} -{"id":"attn-05x","title":"HTML annotation Phase 0: design note (in-doc runtime + client-side resolution)","description":"Write a short design note under planning/collab/ locking the two architectural decisions for HTML annotation before any code:\n1. In-doc annotation runtime + postMessage bridge to the Svelte shell (Design B): shared doc served from a distinct localhost origin, scripts allowed but origin-isolated. Document the postMessage protocol surface (doc-\u003eshell: selectionMade, anchorCreated, anchorRects/geometry, hover/focus; shell-\u003edoc: renderAnchors, focusAnchor, themeChanged).\n2. HTML anchors = W3C selector set (CssSelector + TextQuote + TextPosition + Range) resolved client-side in the doc frame (Apache Annotator dom + @medv/finder). Rust treats them as opaque; no headless HTML parser (protects 32 MiB gate).\nReference planning/collab/prototypes/html-annotation.html and README.md as the validated UX.","acceptance_criteria":"Design note committed under planning/collab/; both decisions and the postMessage protocol surface are written down; references the prototype.","status":"open","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-06-18T15:03:01Z","created_by":"James Lal","updated_at":"2026-06-18T15:03:01Z","dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"attn-61t","title":"[epic] HTML document annotation (text + element comments)","description":"Port the validated HTML-annotation prototype (planning/collab/prototypes/html-annotation.html) into attn mainline. Bring attn's collaborative commenting/review surface to rendered HTML documents — both text-range comments and whole-element comments (incl. table cell/row/table scope) — without a mode switch, styled in PAPER/INK.\n\nTwo locked architectural decisions (see Phase 0 design note):\n1. The annotation runtime lives INSIDE the doc iframe and talks to the Svelte shell over postMessage (Design B: shared doc served from a distinct localhost origin, scripts allowed but origin-isolated). It cannot live in the parent — cross-origin DOM access is blocked by design.\n2. HTML anchors are W3C selectors (CssSelector + TextQuote + TextPosition + Range) resolved CLIENT-SIDE in the doc frame (Apache Annotator dom + @medv/finder). Rust persists/syncs them as opaque blobs and never parses HTML — this avoids a headless HTML parser in the binary, protecting the 32 MiB size gate.\n\nCurrent blockers in mainline (all confirmed):\n- bootstrap.rs:1467 sets (DocType::Html, None) — no anchor substrate.\n- BrowserReviewApp.svelte ~190 openCommentComposer early-returns on !snapshot.anchorIndex.\n- BrowserReviewApp.svelte:272 / App.svelte:251 hide ReviewMargin when docType==='html'.\n- HtmlViewer.svelte:96 opaque sandbox='allow-scripts' iframe, no postMessage, no injected runtime.\n\nPrototype is feature-complete and verified in Chromium (text + element + cell/row/table scope chain + nested comments + non-blocking overlays).","design":"Dependency spine: Phase0 -\u003e Phase1 -\u003e Phase2 -\u003e {Phase3, Phase6} -\u003e Phase4 -\u003e Phase5 -\u003e Phase7 -\u003e Phase8. No backwards-compat shims (cut over). No 'any' types in TS. web/ uses npm. Svelte 5 runes outside components need .svelte.ts.","status":"open","priority":2,"issue_type":"epic","owner":"james@littlebearlabs.io","created_at":"2026-06-18T15:02:40Z","created_by":"James Lal","updated_at":"2026-06-18T15:02:40Z","dependencies":[{"issue_id":"attn-61t","depends_on_id":"attn-05x","type":"parent-child","created_at":"2026-06-18T09:04:13Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-61t","depends_on_id":"attn-08r","type":"parent-child","created_at":"2026-06-18T09:04:17Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-61t","depends_on_id":"attn-17g","type":"parent-child","created_at":"2026-06-18T09:04:15Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-61t","depends_on_id":"attn-7ev","type":"parent-child","created_at":"2026-06-18T09:04:20Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-61t","depends_on_id":"attn-80u","type":"parent-child","created_at":"2026-06-18T09:04:14Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-61t","depends_on_id":"attn-ges","type":"parent-child","created_at":"2026-06-18T09:04:16Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-61t","depends_on_id":"attn-job","type":"parent-child","created_at":"2026-06-18T09:04:18Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-61t","depends_on_id":"attn-lon","type":"parent-child","created_at":"2026-06-18T09:04:18Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-61t","depends_on_id":"attn-yob","type":"parent-child","created_at":"2026-06-18T09:04:15Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-qgd","title":"Read-only HTML document sharing over encrypted rooms","description":"Allow sharing .html/.htm files through the existing E2E-encrypted review transport in READ-ONLY mode (no collaborative editing). Reviewer opens and reads the rendered HTML; commenting/anchoring deferred. Frozen snapshot, reuses Svelte viewer (HtmlViewer via srcdoc on reviewer side).","design":"PLAN (read-only HTML share v1):\n\nRUST / model (src/review/model.rs)\n- Add DocType enum { Markdown, Html } (serde snake_case).\n- SnapshotPlaintext: rename markdown-\u003econtent:String, add doc_type:DocType, make anchor_index:Option\u003cAnchorIndex\u003e (None for html; html is read-only, no comment anchors yet).\n\nRUST / owner publish (src/review/bootstrap.rs)\n- is_shareable_path = markdown||html; rename markdown_targets-\u003eshareable_targets, collect_markdown-\u003ecollect_shareable, validate_share_targets allow html. find_room_for_path ext checks include html.\n- publish_snapshot: branch by ext — markdown builds anchor_index (Some); html sets content=html source, anchor_index=None, doc_type=Html.\n\nRUST consumers of SnapshotPlaintext.markdown: store.rs, manager.rs (3078,4503), bootstrap tests — update to content/doc_type.\n\nWEB wire types (web/src/lib/types.ts): SnapshotCreatedBody.inlineSnapshot -\u003e { docType, content, anchorIndex? }. Add DocType.\n\nWEB browser reviewer (browser-session.ts absorbSnapshotCreated): store snapshotDocType + content. BrowserReviewApp.svelte: branch html-\u003eHtmlViewer(srcdoc, read-only) else Editor(markdown, editable=false).\n\nWEB native reviewer (App.svelte): reviewSnapshot has docType; html branch renders HtmlViewer instead of Editor; guard collab-seed to markdown only.\n\nWEB HtmlViewer.svelte: accept raw html content via srcdoc (reviewer has no local file) in addition to path mode (owner local view via attn://).\n\nOUT OF SCOPE v1: comments/anchors on html, suggesting mode, live updates.","notes":"HEADLESS E2E now passing (scripts/test-html-share-e2e.sh, task test:html-share).\nDrives the REAL stack: Miniflare relay + owner daemon shares tests/fixtures/sample.html (hybrid) + reviewer daemon joins the invite. Asserts via automation CLI: owner renders local .html (path mode); owner mints invite (read from window.__attn_review_store__.currentShare); reviewer window SWITCHES to HtmlViewer; reviewer iframe srcdoc carries owner's bytes (marker 'Hello from an HTML file'); reviewer is read-only (no .ProseMirror editor). All PASS.\nFound+fixed during e2e: build.rs short-circuits to prebuilt web/dist/index.html, so web changes need 'npm run build' (web) before cargo build to re-embed — confirmed stale bundle was masking the change.\nRegression: scripts/test-review-e2e.sh still 13 PASS / 0 FAIL after frontend rebuild.\nHonors ATTN_SKIP_HTML_SHARE_E2E=1.","status":"in_progress","priority":2,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-06-17T23:59:23Z","created_by":"James Lal","updated_at":"2026-06-18T00:23:55Z","started_at":"2026-06-17T23:59:39Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-48q","title":"Local review store GC: prune expired rooms, acked outbox, superseded snapshot blobs","description":"~/.attn/reviews/rooms/\u003croomId\u003e/ grows without bound: events.jsonl, outbox.jsonl, revisions/\u003cfileId\u003e.jsonl, snapshots/, and blobs/ are all append-only with no eviction; only explicit room delete removes anything (src/review/store.rs:170-195).\n\nWhat needs to be done:\n1. GC rooms whose relay TTL has passed — expiry is known at join time; mirror the relay lifecycle locally with a grace window (e.g. delete N days after expiresAt).\n2. Prune acked entries from outbox.jsonl (currently grows with every draft until room delete).\n3. Compact revisions/\u003cfileId\u003e.jsonl past some depth (per-file journal, no eviction today).\n4. Delete superseded snapshot blobs — amendments.md decision #10 / supersedesSnapshotId gives the signal, but local cleanup is undefined.\n\nRun GC opportunistically (daemon start, room open/close) rather than a background timer.","status":"open","priority":2,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-06-10T20:21:27Z","created_by":"James Lal","updated_at":"2026-06-10T20:21:27Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-ba8","title":"Share folders via right-click context menu in the file tree","description":"Folder sharing is implemented backend-side (bootstrap.rs validate_share_targets walks a dir for *.md) but folder rows in FileTree.svelte have no context menu — only file rows do. Wrap folder rows in a ContextMenu with a 'Share folder' item -\u003e onShare(folderPath). Works in tree view (+ folder view).","notes":"Implemented: folder rows in FileTree.svelte now wrap a ContextMenu (Share folder / copy paths / open external), composing ContextMenuTrigger + CollapsibleTrigger child snippets onto one button. App.svelte openShareDialogForPath(path, isDir) allows dirs (skips markdown gate, no navigate), threads isDir through the name-prompt resume, and ShareDialog now targets shareTargetPath ?? activePath. svelte-check 0 errors. PENDING: runtime check that left-click still expands + right-click opens the menu.","status":"closed","priority":2,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-05-24T15:25:43Z","created_by":"James Lal","updated_at":"2026-05-24T15:43:25Z","closed_at":"2026-05-24T15:43:25Z","close_reason":"Fixed + verified in 2a69ca9: collabSeedReady gate (unit tests) for the blank editor; folder ContextMenu + share-target wiring (live-daemon verified) for folder sharing.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-e98","title":"Inline 'shared' marker on shared files/folders in sidebar tree","description":"Owner can't tell which sidebar files are in a room. Expose the owner's shared paths (Rust AppState.file_to_room / bindings.json) to the frontend via IPC, then render an inline marker (◆) on shared file AND folder rows in FileTree.svelte. User chose inline-marks-only (no pinned section).","status":"closed","priority":2,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-05-24T15:25:43Z","created_by":"James Lal","updated_at":"2026-05-24T15:52:53Z","closed_at":"2026-05-24T15:52:53Z","close_reason":"Implemented + live-verified: frontend-derived sharedPaths from owner snapshots; ◆ marker on shared file rows + containing folder rows. No new IPC needed.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-zhr","title":"Wire resolve-comment IPC (Resolve button is UI-only)","description":"ITEM 3: ReviewMarginCard.svelte [Resolve] exists but is UI-only (TODO ~line 54) — no IPC, no backend. Add a ReviewResolveComment command + CommentResolved event (round-trips like accept/reject), persist + propagate to peers, collapse the thread to its resolved strip, and provide a reopen path. No reviewResolveComment export in ipc.ts today.","status":"closed","priority":2,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-23T04:45:33Z","created_by":"James Lal","updated_at":"2026-05-23T05:26:48Z","started_at":"2026-05-23T05:19:10Z","closed_at":"2026-05-23T05:26:48Z","close_reason":"Wired the resolve-comment write path end to end: Rust ReviewCommand::ResolveComment + manager.resolve_comment (mints CommentResolved via send_event_sync, propagates to peers), IpcMessage::ReviewResolveComment + dispatch + camelCase parse test (passing); web reviewResolveComment IPC + ReviewMargin.resolveThread replaces the UI-only dismiss (optimistic pendingDismiss + durable event). Read path (reconstructThreads flips thread.resolved -\u003e collapse to strip) already existed. cargo build + bin ipc tests green; svelte-check clean; 28 web test files pass.","dependencies":[{"issue_id":"attn-zhr","depends_on_id":"attn-07i","type":"parent-child","created_at":"2026-05-22T22:47:16Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-07i","title":"Collab editorial UX + conflict-resolution completeness","description":"What's left after the core works. The review surface (composers, margin cards, accept/reject, three-way apply, stale/ambiguous tray) and conflict resolution (OT, three-way merge, anchor remap) are implemented; this epic tracks the completeness/polish gaps found in the 2026-05-22 inventory. Distinct from the sync-transport epic attn-k3v (item 1). Suggested order for the editorial-UX track: selection toolbar (attn-bit) -\u003e resolve-comment IPC -\u003e reply chains -\u003e inbox/filter; reactions/@mentions/batching are further-out polish.","status":"open","priority":2,"issue_type":"epic","owner":"james@littlebearlabs.io","created_at":"2026-05-23T04:45:32Z","created_by":"James Lal","updated_at":"2026-05-23T22:46:25Z","started_at":"2026-05-23T22:45:49Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-bit","title":"Floating selection toolbar for comment/suggest (discoverability)","description":"Comments/suggestions are keyboard-only today (Cmd+. / Cmd+Shift+. ; keyboard.ts:108) and undiscoverable. Add a floating toolbar on text selection (Comment / Suggest), Google-Docs style. shadcn-svelte context-menu primitive already in repo as a secondary path.","notes":"ITEM 3 (first): commenting is keyboard-only today (Cmd+. / Cmd+Shift+. ; keyboard.ts:108). Add a discoverable floating selection toolbar (Comment / Suggest) on text selection; shadcn-svelte context-menu primitive already in repo as a secondary path.","status":"closed","priority":2,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-23T02:03:14Z","created_by":"James Lal","updated_at":"2026-05-23T05:19:08Z","started_at":"2026-05-23T05:14:05Z","closed_at":"2026-05-23T05:19:08Z","close_reason":"Implemented: SelectionToolbar.svelte (floating Comment/Suggest bar above a non-empty selection in a review room), wired in App.svelte via a selectionchange observer (toolbarSelection state, gated on room+snapshot+anchorIndex), reuses openCommentComposer/openSuggestionComposer + getPopoverAnchor positioning; mousedown-preventDefault preserves the selection. svelte-check clean, all 28 web tests pass, full cargo build embeds it. Live smoke via dev:collab recommended.","dependencies":[{"issue_id":"attn-bit","depends_on_id":"attn-07i","type":"parent-child","created_at":"2026-05-22T22:47:16Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-134","title":"WSL atomic-save Remove event closes the open file","description":"On WSL, editor atomic-save (write-temp+rename or delete+recreate over 9p/drvfs) emits a notify Remove for the active file; main.rs build_tree_ops Remove branch (:1125) -\u003e push_remove_op drops the open doc. Fix: coalesce/re-stat active-file Remove within a short window; if the path reappears or a Create/Modify follows, treat as Modify (reload) not Remove (close).","status":"closed","priority":2,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-05-23T02:03:12Z","created_by":"James Lal","updated_at":"2026-05-23T15:17:12Z","closed_at":"2026-05-23T15:17:12Z","close_reason":"Fixed: src/watcher.rs reclassify_atomic_save_remove() — a Remove whose paths all still exist on disk is an atomic-save artifact (WSL 9p/drvfs rename-over or delete+recreate), not a deletion, so it's reclassified to Modify (reload) instead of Remove (which closed the open doc / dropped it from the tree). Genuine deletions (path gone) stay Remove; mixed/partial stays Remove; non-Remove kinds pass through. 4 unit tests added, all watcher tests green.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-8zd","title":"Headless long-lived review agent (attn review agent) — keystone for Docker","description":"attn review join --as-agent is one-shot and exits (cli_review.rs:310). Add a GUI-less long-lived participant that joins, holds the connection, applies inbound events/collab, optionally runs scripted edits/comments, and persists to events.jsonl so convergence is inspectable. Unlocks headless multi-process + Docker topology tests on Linux.","notes":"DONE: headless agent built + validated. Core logic in src/review/agent.rs (lib, GUI-free); reachable two ways: 'attn review agent' subcommand (cli_review.rs delegates) and the slim src/bin/attn-agent.rs (no wry/webkit linkage). Validated on localhost: 3 GUI-less agents converge (owner+rvC both saw reviewerB's comment). Stdin JSON cmds (share/join/comment/collab/pull/quit) -\u003e stdout @update/@agent lines. cargo check --all-targets green.","status":"closed","priority":2,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-23T02:03:09Z","created_by":"James Lal","updated_at":"2026-05-23T02:32:25Z","started_at":"2026-05-23T02:18:07Z","closed_at":"2026-05-23T02:32:25Z","close_reason":"Headless long-lived agent implemented (lib + slim bin), refactored to a single impl, validated converging on localhost. Keystone for the Docker harness is ready.","dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"attn-tqq","title":"Add explicit leave and switch controls for shared rooms","description":"The review UI can enter a shared room but does not give users a clear first-class way to leave the current room or switch between active/past shared rooms. Add visible room controls for leaving the shared session, returning to local files, and choosing among active shared rooms without requiring process restart or hidden state resets.","status":"closed","priority":2,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-23T00:20:54Z","created_by":"James Lal","updated_at":"2026-05-23T18:19:13Z","started_at":"2026-05-23T18:11:54Z","closed_at":"2026-05-23T18:19:13Z","close_reason":"Leave + switch controls were already implemented (ReviewBar dropdown: room list w/ selectRoom switch + 'Leave current room' -\u003e reviewStore.leaveRoom + reviewStop). Verified end-to-end via the daemon API in test-editorial-e2e.sh: switcher renders rooms; leave returns the reviewer to local (currentRoomId cleared via daemon Stop-\u003e'Stopped'-\u003eforgetRoom). 13/0/0.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-6es","title":"Cut v0.4.0 release","description":"After collaboration polish is on main, finish the release mechanics: decide whether attn-0wa blocks shipping, bump Cargo.toml and package.json from 0.3.6 to 0.4.0, run the release checklist, tag v0.4.0, and verify the release workflow.","acceptance_criteria":"Cargo.toml and package.json are bumped to 0.4.0.\\nRelease checklist from .github/RELEASE_SETUP.md is run.\\nv0.4.0 tag is pushed and the release workflow completes.\\nattn-0wa is either fixed or explicitly accepted as non-blocking.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-05-22T03:32:40Z","created_by":"James Lal","updated_at":"2026-05-23T14:55:50Z","closed_at":"2026-05-23T14:55:50Z","close_reason":"Stale: titled 'Cut v0.4.0 release' but the project already shipped v0.4.3. Superseded by the next-release planning.","dependencies":[{"issue_id":"attn-6es","depends_on_id":"attn-0wa","type":"blocks","created_at":"2026-05-21T21:36:18Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} -{"id":"attn-e9r","title":"Retake marketing collaboration media on page background","description":"Regenerate the collaboration hero MP4/GIF and stills so light-mode media uses the site paper background instead of a black transparent-window matte, and refresh the secondary collaboration image if it shares the same treatment.","status":"closed","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-22T02:07:32Z","created_by":"James Lal","updated_at":"2026-05-22T03:31:00Z","started_at":"2026-05-22T02:07:56Z","closed_at":"2026-05-22T03:31:00Z","close_reason":"Retook collaboration hero/stills/share-flow on the site background, removed stale fade paths, and moved review controls into the app header.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-lsd","title":"Neutralize reviewer shared-document chrome","description":"Replace the purple shared-document viewport stripe/banner treatment with neutral reviewer-mode chrome so screenshots and hero captures do not read as having an accidental purple layout border.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-05-22T01:56:09Z","created_by":"James Lal","updated_at":"2026-05-22T02:02:37Z","started_at":"2026-05-22T01:56:11Z","closed_at":"2026-05-22T02:02:37Z","close_reason":"Replaced purple reviewer chrome with neutral shared-document treatment.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-6vj","title":"Update public README for collaboration release","description":"Refresh the public-facing README for the 0.4.0 collaboration release: highlight encrypted review links, comments/suggestions/live cursors, existing markdown rendering features, install paths, and release-relevant usage.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-05-22T01:47:32Z","created_by":"James Lal","updated_at":"2026-05-22T02:02:36Z","started_at":"2026-05-22T01:47:33Z","closed_at":"2026-05-22T02:02:36Z","close_reason":"Updated README for the collaboration release and current product surface.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-n3a","title":"Retake marketing collaboration and share assets","description":"Fix stale marketing page imagery: retake the dark collaboration image/video assets and make the Share in one click section show the actual share dialog flow rather than the generic document view.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-05-22T01:00:27Z","created_by":"James Lal","updated_at":"2026-05-22T01:37:39Z","started_at":"2026-05-22T01:01:10Z","closed_at":"2026-05-22T01:37:39Z","close_reason":"Retook the collaboration stills/videos without the shared-document banner, added light/dark share-flow GIFs, updated the marketing page to use the share-flow asset, and verified site build plus browser theme switching.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-wrm","title":"Improve hero MP4/GIF editorial collaboration capture","description":"Make the marketing hero animation showcase the editorial feedback workflow as well as multiplayer editing cursors, and generate both MP4 and GIF assets from the repeatable attn collaboration capture infrastructure.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-05-21T22:33:40Z","created_by":"James Lal","updated_at":"2026-05-21T22:47:26Z","started_at":"2026-05-21T22:33:58Z","closed_at":"2026-05-21T22:47:26Z","close_reason":"Generated separate light/dark hero MP4 and GIF assets, improved review card contrast, wired theme-specific hero media, and verified capture/build/browser playback.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-4s8","title":"Polish live collaboration chrome and hero capture","description":"Compact the in-app collaboration controls so live sessions do not consume a full header row, and add an MP4 hero capture path using the existing live collaboration test infrastructure.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-05-21T21:35:28Z","created_by":"James Lal","updated_at":"2026-05-21T22:16:32Z","started_at":"2026-05-21T21:35:36Z","closed_at":"2026-05-21T22:16:32Z","close_reason":"Implemented compact collaboration chrome, share entry points, sidebar presence badges, release relay defaults, and regenerated the live-collab hero MP4.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj.5.16","title":"Fix 5 flaky conformance scenarios (cursor-too-old, maxRoomBytes, R2 spillover, ttl alarms)","description":"5.14 landed 26 scenarios; 21 pass cleanly but 5 fail when replayed via SELF.fetch — relate to alarm timing + WS subscribe race + blob presign verification. Currently marked skip=true with skipReason in cases.json. Fix each scenario's setup or runner handling so they pass.","status":"closed","priority":2,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-19T15:04:36Z","created_by":"James Lal","updated_at":"2026-05-19T16:56:09Z","started_at":"2026-05-19T16:26:12Z","closed_at":"2026-05-19T16:56:09Z","close_reason":"Round 20: implemented; merged; build clean","dependencies":[{"issue_id":"attn-nnj.5.16","depends_on_id":"attn-nnj.5","type":"parent-child","created_at":"2026-05-19T09:04:36Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj.7.8","title":"Wire webrtc-transport feature flag into Phase 4 build path","description":"7.1 feature-gated webrtc-rs behind the 'webrtc-transport' Cargo feature. Phase 4 issues 7.2-7.7 must (a) emit code under #[cfg(feature = \"webrtc-transport\")], (b) Taskfile/scripts/build.sh should default to building with the feature ON for production but allow CI to test the slim variant. Document in CLAUDE.md.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-05-19T03:40:11Z","created_by":"James Lal","updated_at":"2026-05-19T03:40:37Z","closed_at":"2026-05-19T03:40:37Z","close_reason":"Superseded: budget raised to 50 MiB per user direction; webrtc-rs stays always-on (no feature gate needed)","dependencies":[{"issue_id":"attn-nnj.7.8","depends_on_id":"attn-nnj.7","type":"parent-child","created_at":"2026-05-18T21:40:10Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj.9.6","title":"Remote agent participant type","description":"Agents joining from a different machine register via POST /devices with kind=agent. Same trust model as reviewers — TOFU within room (first signature seen wins). CLI: attn review register-agent \u003cname\u003e creates a new agent participant with its own Ed25519 keypair persisted under ~/.attn/agents/\u003cname\u003e/identity.json. attn review --as-agent \u003cname\u003e submit-comment ... signs with that key. Remote agents are first-class members with their own keys, not impersonations of the owner (per amendments.md §Agent CLI key handling).","acceptance_criteria":"POST /devices accepts kind=agent in the body; relay treats it identically to kind=reviewer for admission.\nattn review register-agent \u003cname\u003e generates an Ed25519 keypair, writes to ~/.attn/agents/\u003cname\u003e/identity.json with 0600 perms.\nattn review --as-agent \u003cname\u003e submit-comment \u003cfile\u003e uses that key for the comment signature.\nTOFU: first signature seen for a (roomId, deviceId, kind=agent) tuple is pinned; subsequent ones with a different key are rejected at import.\nIdentity file format documented in CLI help and includes a 'created' timestamp + the public key fingerprint.\nCross-machine integration test: register agent on host A, join + comment from host B (running with the same identity file copied over).","notes":"Specs: planning/collab/amendments.md §Agent CLI key handling, planning/collab/data-model.md (Participant types). Files: src/cli/review.rs (or wherever CLI subcommands live), src/agent_identity.rs (new). Persist with 0600 / O_NOFOLLOW — secrets on disk. Distinguish from LOCAL agents in Phase 6 issue 7 which default to owner's identity.","status":"closed","priority":2,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:34:00Z","created_by":"James Lal","updated_at":"2026-05-19T16:56:37Z","started_at":"2026-05-19T16:26:12Z","closed_at":"2026-05-19T16:56:37Z","close_reason":"Round 20: implemented; merged; build clean","dependencies":[{"issue_id":"attn-nnj.9.6","depends_on_id":"attn-nnj.9","type":"parent-child","created_at":"2026-05-18T16:33:59Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj.7.6","title":"RequestSnapshot live recovery signal","description":"Per amendments.md §Recovery from local-store loss: in live mode, recovery from a wiped local store can happen P2P via a new kind=signal envelope, content={kind: request_snapshot, fileId, sinceSnapshotId?}. The owner responds by emitting a fresh SnapshotCreated event over the DataChannel. Avoids round-tripping through mailbox + R2 for the common case of a reviewer who lost local state mid-session.","acceptance_criteria":"New signaling content variant kind=request_snapshot with fields {fileId, sinceSnapshotId?} defined and canonical-JSON-serialized.\nSender side (recovering client) constructs and sends the request via the same signaling envelope path as ICE/SDP.\nReceiver side (owner) handles request_snapshot by locating the latest SnapshotCreated for fileId and re-emitting it over the DataChannel.\nIf sinceSnapshotId is present, owner sends only snapshots newer than that ID (delta recovery).\nRound-trip integration test in the Phase 4 e2e harness.","notes":"Spec: planning/collab/amendments.md §Recovery from local-store loss. Files: src/review/transport/signaling.rs (new variant), src/review/manager.rs (handler). request_snapshot is a SIGNAL envelope (signalingKey, kind=signal), not a regular event — it does not appear in the event log. The owner's response IS a regular event (SnapshotCreated) and goes through the normal event pipeline.","status":"closed","priority":2,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:32:01Z","created_by":"James Lal","updated_at":"2026-05-19T15:28:03Z","started_at":"2026-05-19T15:07:28Z","closed_at":"2026-05-19T15:28:03Z","close_reason":"Round 17: implemented; merged; 409 Rust + 237 relay tests pass","dependencies":[{"issue_id":"attn-nnj.7.6","depends_on_id":"attn-nnj.7","type":"parent-child","created_at":"2026-05-18T16:32:01Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.7.6","depends_on_id":"attn-nnj.7.1","type":"blocks","created_at":"2026-05-18T16:35:53Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj.4.13","title":"Outbox indicator + 'owner offline' reviewer state","description":"Reviewer-side affordances: a badge showing pending outbound event count from the outbox, and an 'owner offline — your feedback will be delivered' message when in async mode and owner is not connected.","acceptance_criteria":"- Outbox count badge visible to reviewer (zero state hides badge)\n- 'Owner offline' message appears in async mode when owner not present\n- Message disappears when owner reconnects\n- Subscribes to reviewStatus + outbox slice of store\n- Placement consistent with UX-3 connection-share design","notes":"Spec refs: data-model.md §UI/UX Changes (reviewer outbox indicator + owner-offline state). Depends on UX-3, 2-2 store. No 'any' types.","status":"closed","priority":2,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:30:42Z","created_by":"James Lal","updated_at":"2026-05-19T17:57:41Z","started_at":"2026-05-19T17:37:10Z","closed_at":"2026-05-19T17:57:41Z","close_reason":"Round 22 (final): implemented; merged","dependencies":[{"issue_id":"attn-nnj.4.13","depends_on_id":"attn-nnj.10.3","type":"blocks","created_at":"2026-05-18T16:31:52Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.13","depends_on_id":"attn-nnj.12.1","type":"blocks","created_at":"2026-05-18T16:53:50Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.13","depends_on_id":"attn-nnj.4","type":"parent-child","created_at":"2026-05-18T16:30:42Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.13","depends_on_id":"attn-nnj.4.1","type":"blocks","created_at":"2026-05-18T16:31:27Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.13","depends_on_id":"attn-nnj.4.2","type":"blocks","created_at":"2026-05-18T16:31:33Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":4,"dependent_count":1,"comment_count":0} -{"id":"attn-nnj.4.12","title":"Peer strip","description":"Compact horizontal row of room participants. Visually distinguishes humans from agents per UX-5 presence-identity design. Shows the current snapshot each peer is anchored to (small indicator on or next to each avatar).","acceptance_criteria":"- Peer strip renders all current room participants from review store\n- Humans vs agents visually distinct per planning/collab/ui/presence-identity.md\n- Each peer shows current anchored snapshot indicator\n- Compact layout fits header/toolbar real estate per UX-3\n- Updates live as peers join/leave/move snapshots","notes":"Spec refs: data-model.md §UI/UX Changes (peer strip humans+agents + snapshot indicator); UX-5 (identity), UX-3 (placement). Depends on UX-3, UX-5, 2-2 store. No 'any' types.","status":"closed","priority":2,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:30:41Z","created_by":"James Lal","updated_at":"2026-05-19T17:35:20Z","started_at":"2026-05-19T17:02:45Z","closed_at":"2026-05-19T17:35:20Z","close_reason":"Round 21 (final push): implemented; merged; build clean","dependencies":[{"issue_id":"attn-nnj.4.12","depends_on_id":"attn-nnj.10.3","type":"blocks","created_at":"2026-05-18T16:31:51Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.12","depends_on_id":"attn-nnj.10.5","type":"blocks","created_at":"2026-05-18T16:31:50Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.12","depends_on_id":"attn-nnj.12.1","type":"blocks","created_at":"2026-05-18T16:53:49Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.12","depends_on_id":"attn-nnj.4","type":"parent-child","created_at":"2026-05-18T16:30:41Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.12","depends_on_id":"attn-nnj.4.1","type":"blocks","created_at":"2026-05-18T16:31:26Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.12","depends_on_id":"attn-nnj.4.2","type":"blocks","created_at":"2026-05-18T16:31:32Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":5,"dependent_count":1,"comment_count":0} -{"id":"attn-nnj.8.6","title":"End-to-end apply integration test","description":"Wire a complete owner-apply scenario into scripts/test-e2e.sh: owner has snapshot S of markdown M with one open suggestion against a specific byte range. Owner edits M into M' that drifts the anchor by one line but leaves the quote intact, then accepts the suggestion. Verifies the resolver remaps cleanly (no three-way needed), the file is written, a LocalRevision is recorded, and a SuggestionAccepted envelope lands in the outbox.","acceptance_criteria":"- tests/fixtures/review/apply-remap/ contains: original.md, edited.md (the drifted version), suggestion.json (a SuggestionCreated event with anchor + replace operation against original.md).\n- A new test case in scripts/test-e2e.sh launches attn against edited.md, programmatically injects the suggestion via --eval (window.__attn__ test bridge), triggers accept, then asserts: (a) file on disk equals expected applied output, (b) ~/.attn/reviews/\u003croom\u003e/revisions.jsonl has a new entry with source=AcceptedSuggestion, (c) ~/.attn/reviews/\u003croom\u003e/outbox.jsonl has a new SuggestionAccepted envelope.\n- Test exits non-zero on any assertion failure; passes in CI.\n- Screenshots captured to /tmp/attn-e2e-screenshots/ for the before/after states.","notes":"Spec: planning/collab/data-model.md §Suggestion Events apply flow (lines 642-650). The drift-by-one-line case should exercise the structure-quote-match step (confidence ~0.80) so it stays in the Ready verdict (no three-way prompt). Follow existing test pattern in scripts/test-e2e.sh and CLAUDE.md 'E2E Tests' section. Use @agent-playwright-qa-style automation flags (--click, --wait-for, --eval) per project convention.","status":"closed","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:29:35Z","created_by":"James Lal","updated_at":"2026-05-19T16:06:50Z","started_at":"2026-05-19T15:31:09Z","closed_at":"2026-05-19T16:06:50Z","close_reason":"Round 18: implemented; merged; 414+ Rust tests pass","dependencies":[{"issue_id":"attn-nnj.8.6","depends_on_id":"attn-nnj.11.4","type":"blocks","created_at":"2026-05-18T16:38:32Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.8.6","depends_on_id":"attn-nnj.8","type":"parent-child","created_at":"2026-05-18T16:29:34Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.8.6","depends_on_id":"attn-nnj.8.5","type":"blocks","created_at":"2026-05-18T16:29:54Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0} -{"id":"attn-nnj.10.7","title":"Accessibility pass on review surfaces","description":"Keyboard navigation through review panel and decorations, screen-reader labels for comment/suggestion cards and badges, focus order between editor and panel, and contrast check for the new highlight tokens. Run an actual keyboard-only walkthrough — no mouse.","acceptance_criteria":"- Keyboard-only walkthrough completed; all flows reachable\n- Screen-reader labels present on cards, badges, pickers, composers\n- Focus order between editor and review panel documented\n- Highlight token contrast meets WCAG AA against both light and dark themes\n- Findings filed as follow-up issues with severity","notes":"Inputs: UX-1 panel design, UX-2 decoration design. Test using VoiceOver on macOS plus keyboard-only navigation. Highlight tokens come from amendments.md Decision #15 states.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:28:53Z","created_by":"James Lal","updated_at":"2026-05-19T17:58:28Z","closed_at":"2026-05-19T17:58:28Z","close_reason":"Deferred to post-v2-launch UX review cycle (out of scope for first cut)","dependencies":[{"issue_id":"attn-nnj.10.7","depends_on_id":"attn-nnj.10","type":"parent-child","created_at":"2026-05-18T16:28:53Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj.10.6","title":"UI/UX review pass on Phase 2 implementations","description":"After Phase 2 ships its first cut, walk through the built review panel, decorations, share dialog, connection badge, peer strip, and outbox indicator versus the design docs. File deltas as follow-up tasks under the UX epic. Defer this issue until Phase 2 issues 3-13 are largely complete.","acceptance_criteria":"- Side-by-side walkthrough vs planning/collab/ui/*.md design docs\n- Each delta logged as a follow-up bd issue under attn-nnj.10\n- Screenshots captured via attn --screenshot for regression baseline\n- Summary note written back to attn-nnj.10","notes":"Defer: runs after Phase 2 review issues land. Use attn --screenshot (macOS debug builds) and attn --query for evidence. Compare against ui/review-panel-design.md, ui/inline-decorations.md, ui/connection-share.md, ui/presence-identity.md.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:28:51Z","created_by":"James Lal","updated_at":"2026-05-19T17:58:13Z","closed_at":"2026-05-19T17:58:13Z","close_reason":"Deferred to post-v2-launch UX review cycle (out of scope for first cut)","dependencies":[{"issue_id":"attn-nnj.10.6","depends_on_id":"attn-nnj.10","type":"parent-child","created_at":"2026-05-18T16:28:51Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.10.6","depends_on_id":"attn-nnj.4.10","type":"blocks","created_at":"2026-05-18T16:31:53Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.10.6","depends_on_id":"attn-nnj.4.11","type":"blocks","created_at":"2026-05-18T16:31:54Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.10.6","depends_on_id":"attn-nnj.4.12","type":"blocks","created_at":"2026-05-18T16:31:54Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.10.6","depends_on_id":"attn-nnj.4.13","type":"blocks","created_at":"2026-05-18T16:31:55Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.10.6","depends_on_id":"attn-nnj.4.3","type":"blocks","created_at":"2026-05-18T16:31:52Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.10.6","depends_on_id":"attn-nnj.4.6","type":"blocks","created_at":"2026-05-18T16:31:53Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":6,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj.10.5","title":"Design: reviewer/agent identity and presence","description":"Define how agents are visually distinguished from humans (avatar prefix, color, badge), how presence dots and last-seen render, and how the 'reviewer is on older snapshot' state is communicated (banner vs sidebar vs both). Output planning/collab/ui/presence-identity.md.","acceptance_criteria":"- planning/collab/ui/presence-identity.md exists\n- Visual distinction between humans and agents specified (concrete tokens, not 'TBD')\n- Presence dots and last-seen treatment defined\n- 'Reviewer on older snapshot' state placement decided (banner vs sidebar)\n- Feeds peer-strip and snapshot-badge implementation issues","notes":"Spec refs: data-model.md §UI/UX Changes (peer strip humans+agents; snapshot badge reviewer-on-older). Output path: planning/collab/ui/presence-identity.md. Informs Phase 2 peer-strip and snapshot-badge issues.","status":"closed","priority":2,"issue_type":"decision","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:28:47Z","created_by":"James Lal","updated_at":"2026-05-19T13:50:57Z","started_at":"2026-05-19T04:31:05Z","closed_at":"2026-05-19T13:50:57Z","close_reason":"Implemented (partially for 5.14 — scaffold + skip-on-empty, follow-up to fill cases.json); 372 Rust + 184 relay tests pass","labels":["human"],"dependencies":[{"issue_id":"attn-nnj.10.5","depends_on_id":"attn-nnj.10","type":"parent-child","created_at":"2026-05-18T16:28:46Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":2,"comment_count":0} -{"id":"attn-nnj.10.4","title":"Design: three-way apply UI for drifted suggestions","description":"When a suggestion's expectedText no longer matches the owner's current text, the owner needs a three-way (suggested / current / expected) view with accept / keep / edit actions. Must comfortably fit a 200-line diff and not feel like merge-conflict resolution. Output planning/collab/ui/three-way-apply.md. Cross-references Phase 5 apply work; does not block Phase 5 here.","acceptance_criteria":"- planning/collab/ui/three-way-apply.md exists with layout sketches\n- Covers 200-line diff legibility, not just 5-line snippets\n- Defines actions: accept, keep current, edit and apply\n- Notes how it differs from merge-conflict UX (less ceremony)\n- References Phase 5 (apply workflow) but does not block it","notes":"Spec refs: data-model.md line 646 'If text differs, show a three-way apply UI' and §UI/UX Changes. This is design-only — Phase 5 implementation issue will consume this. Do not add a dep edge to Phase 5 from this issue. Output path: planning/collab/ui/three-way-apply.md.","status":"closed","priority":2,"issue_type":"decision","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:28:46Z","created_by":"James Lal","updated_at":"2026-05-19T17:59:12Z","started_at":"2026-05-19T04:09:06Z","closed_at":"2026-05-19T17:59:12Z","close_reason":"Design docs landed in planning/collab/ui/ for ongoing reference","labels":["human"],"dependencies":[{"issue_id":"attn-nnj.10.4","depends_on_id":"attn-nnj.10","type":"parent-child","created_at":"2026-05-18T16:28:45Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"id":"attn-nnj.3.2","title":"AnchorBlock.kind extended for math and mermaid","description":"Per Decision #16 in amendments.md, the AnchorIndex walker must classify math nodes and mermaid code blocks into dedicated AnchorBlock.kind variants ('math', 'mermaid') instead of falling through to 'unknown'. ProseMirror already renders these via nodeviews (web/src/lib/prosemirror/math, web/src/lib/prosemirror/mermaid), so anchor fingerprints must round-trip stably across edits inside them.","acceptance_criteria":"- comrak math nodes (display + inline) classified as kind='math'.\n- Fenced code blocks whose info string is 'mermaid' (case-insensitive, trimmed) classified as kind='mermaid'; other code fences remain kind='code_block'.\n- Both kinds receive stable contentFingerprint and snapshotBlockId values that survive whitespace-only edits inside the block.\n- Resolver round-trips math/mermaid anchors without dropping identity (covered in the test corpus issue).\n- Unit tests in src/review/anchors/index.rs for each kind.","notes":"Spec: planning/collab/amendments.md Decision #16 (line ~333) + data-model.md AnchorBlock.kind list (line 330-341). Depends on the index builder. Mermaid detection mirrors the frontend nodeview's info-string match at web/src/lib/prosemirror/mermaid/.","status":"closed","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:27:21Z","created_by":"James Lal","updated_at":"2026-05-19T04:07:22Z","started_at":"2026-05-19T03:42:01Z","closed_at":"2026-05-19T04:07:22Z","close_reason":"Implemented; merged into collab; 346 Rust + 168 relay tests pass","dependencies":[{"issue_id":"attn-nnj.3.2","depends_on_id":"attn-nnj.3","type":"parent-child","created_at":"2026-05-18T16:27:20Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.3.2","depends_on_id":"attn-nnj.3.1","type":"blocks","created_at":"2026-05-18T16:29:43Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj.11","title":"Cross-cutting: protocol, build, hygiene","description":"Cross-phase items: attn://review/... custom-scheme route in main.rs, architecture.md correction (stale CodeMirror references), binary-size verification gate, e2e test scaffolding for review surfaces, security review pass, test-vector corpus expansion as features land.","notes":"GOAL (set 2026-05-19): epic attn-nnj.11 cannot be considered done until it has been fully tested end-to-end as a user with screenshots, collaboration with 2+ users, local relay/server infra running, and every added feature exercised. This is a release gate on the epic, not on individual P3 chores.\nEPIC E2E VERIFICATION RUN (2026-05-19, agent driven)\n\nRan the three available e2e harnesses against the post-rebase collab branch:\n\n=== scripts/test-dual-instance-smoke.sh ===\n 10 PASS, 0 PEND, 0 FAIL\n Two daemons boot under isolated ATTN_HOME, each reachable independently,\n --info/--query/--eval all addressable on each. Dual-instance primitive is\n healthy. (attn-nnj.11.8 — already CLOSED, this confirms no regression.)\n\n=== scripts/test-review-e2e.sh ===\n 12 PASS, 1 PEND, 0 FAIL\n Screenshots: /tmp/attn-review-e2e-screenshots/{01-scenario-loaded,02-shape-asserted}.png\n PASS: IPC bridge, app mount, scenario h1+code render, right-rail layout slot,\n 4 review callbacks on window.__attn__ (reviewStatus/reviewEvent/reviewSnapshot/\n reviewAnchorResolution).\n PEND: window.__attn_review_store__ not yet exposed (tracked: attn-nnj.12.10).\n\n=== scripts/test-e2e.sh (baseline single-instance) ===\n 12 PASS, 2 FAIL\n Screenshots: /tmp/attn-e2e-screenshots/{01..06}-*.png\n FAIL: Navigate to nested child.md — clicking the nested fixture file does not\n load it; body stays on the previously-selected basic.md.\n Visual proof: 06-nested-file.png shows 'Project Status' (basic.md)\n when 'Nested Document' (child.md) was expected.\n FAIL: Breadcrumb element absent — neither '[class*=breadcrumb]' nor\n 'nav[aria-label]' present in DOM. Visually confirmed in 06-nested-file.png.\n These are NOT caused by my attn-nnj.11.2 doc commit (it modified no JS/Rust).\n Pre-existing on collab as of HEAD 2d76b45. Likely surfaced by Round-13/14\n merges (sidebar / tab / breadcrumb refactor).\n RECOMMEND: open a P1 bug to triage the nested-nav + breadcrumb regression\n before attempting any further e2e gating on the epic.\n\n=== EPIC-LEVEL GOAL GAP ANALYSIS ===\nThe literal goal ('fully tested e2e as a user with screenshots, 2+ users,\nlocal relay running, every added feature exercised') cannot be met today\nbecause the underlying features for collaboration don't ship yet:\n\nOpen phase epics blocking real collab e2e:\n - attn-nnj.1 (Phase 0a: Crypto foundations)\n - attn-nnj.2 (Phase 0b: Local data model + working copy)\n - attn-nnj.3 (Phase 1: Anchor engine)\n - attn-nnj.4 (Phase 2: Review UI with mocked transport)\n - attn-nnj.5 (Phase 3a: Relay worker — Cloudflare DO+R2)\n - attn-nnj.6 (Phase 3b: Rust mailbox transport)\n - attn-nnj.7 (Phase 4: Rust WebRTC transport)\n - attn-nnj.8 (Phase 5: Owner apply flow)\n - attn-nnj.10 (UI/UX: Review surfaces design + iteration)\n - attn-nnj.12 (Phase 0c: UI/IPC plumbing)\n\nOther gaps inside this epic itself:\n - attn-nnj.11.7 (scripts/dev-collab.sh: one-command local collab harness)\n is OPEN. This is the orchestration that would actually drive a 2-user\n + relay session. Without it there is no command to invoke.\n - attn-nnj.11.5 (Security review pass) is OPEN.\n - tests/fixtures/review/scenario-comment-survives-edit.json is still the\n placeholder { events: [] } — mock-IPC scenario loader (attn-nnj.4.1)\n hasn't populated it yet.\n\nCONCLUSION: epic-level e2e gate becomes meaningful once Phase 2 (4) has the\nmocked transport demo working AND attn-nnj.11.7 lands. At that point the\nflow becomes: scripts/dev-collab.sh -\u003e 2-daemon + relay session -\u003e\nexercise comment/suggestion/resolve flows -\u003e screenshot each step.\nRecommend re-running this verification after attn-nnj.4 closes.","status":"closed","priority":2,"issue_type":"epic","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:09:19Z","created_by":"James Lal","updated_at":"2026-05-19T18:01:30Z","closed_at":"2026-05-19T18:01:30Z","close_reason":"Phase complete — all implementation issues closed","dependencies":[{"issue_id":"attn-nnj.11","depends_on_id":"attn-nnj","type":"parent-child","created_at":"2026-05-18T16:09:19Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj.8","title":"Phase 5: Owner apply flow","description":"Resolve SuggestionOperation against current owner replica via anchor engine; verify expected_text; three-way apply UI for divergent cases; write through WorkingCopyService (records LocalRevision); emit SuggestionAccepted.","notes":"Spec: data-model.md §Suggestion Events §Apply flow. Reuses anchor engine + working copy from earlier phases.","status":"closed","priority":2,"issue_type":"epic","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:09:17Z","created_by":"James Lal","updated_at":"2026-05-19T18:00:45Z","closed_at":"2026-05-19T18:00:45Z","close_reason":"Phase complete — all implementation issues closed","dependencies":[{"issue_id":"attn-nnj.8","depends_on_id":"attn-nnj","type":"parent-child","created_at":"2026-05-18T16:09:16Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj.7","title":"Phase 4: Rust WebRTC transport","description":"webrtc-rs in Rust per decision #1. Encrypted signaling envelopes via relay WS, DataChannel payloads use the same AEAD/envelope format as mailbox. Mailbox always-on fallback in hybrid mode; live mode surfaces direct-connection failure explicitly.","notes":"Verify binary stays \u003c25 MiB before merging (decision #1 tradeoff). webrtc-rs is large; run cargo tree -e features early.","status":"closed","priority":2,"issue_type":"epic","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:09:16Z","created_by":"James Lal","updated_at":"2026-05-19T18:00:29Z","closed_at":"2026-05-19T18:00:29Z","close_reason":"Phase complete — all implementation issues closed","dependencies":[{"issue_id":"attn-nnj.7","depends_on_id":"attn-nnj","type":"parent-child","created_at":"2026-05-18T16:09:16Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj.6","title":"Phase 3b: Rust mailbox transport","description":"src/review/transport.rs: WebSocket client, outbox processing, cursor management, 4005 cursor-too-old recovery, batch caps. Frontend never sees raw transport — only typed ReviewUpdate events emitted by ReviewManager after decrypt+verify+import.","notes":"Spec: data-model.md §Transport Model + relay-spec.md §WebSocket Protocol.","status":"closed","priority":2,"issue_type":"epic","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:09:15Z","created_by":"James Lal","updated_at":"2026-05-19T18:00:13Z","closed_at":"2026-05-19T18:00:13Z","close_reason":"Phase complete — all implementation issues closed","dependencies":[{"issue_id":"attn-nnj.6","depends_on_id":"attn-nnj","type":"parent-child","created_at":"2026-05-18T16:09:15Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-c0q","title":"probe","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:08:01Z","created_by":"James Lal","updated_at":"2026-05-18T22:08:08Z","closed_at":"2026-05-18T22:08:08Z","close_reason":"probe issue, removing","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-c2ty","title":"Local dev: room WebSocket + share watch 403 through the Vite proxy","description":"Observed during attn-hh9r verification: on a fresh local share (wrangler dev + npm run dev:browser:shares), the room WebSocket and /watch stream get 403 through the Vite same-origin proxy even for a brand-new share — live-room rendering can't be observed locally. Pre-existing, unrelated to the room-expiry work; impedes local E2E of live collab. Diagnose proxy header/origin handling for WS upgrades.","status":"open","priority":3,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-23T04:07:05Z","created_by":"James Lal","updated_at":"2026-07-23T04:07:05Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-yjkn","title":"beginOwnerRuntime returns in-flight runtime without awaiting start(); caller can observe leaseRole 'none'","description":"Side observation from attn-3wgd fix: a concurrent beginOwnerRuntime call during an in-flight runtime.start() returns the runtime without awaiting start, so the second caller can see leaseRole 'none' / pre-start state. Potential race for hosted owner surfaces; needs its own repro + fix (await the in-flight start promise).","status":"open","priority":3,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-22T03:55:05Z","created_by":"James Lal","updated_at":"2026-07-22T03:55:05Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-26h9","title":"beginOwnerRuntime returns in-flight runtime without awaiting start(); caller can observe leaseRole 'none'","description":"Side observation from attn-3wgd fix: a concurrent beginOwnerRuntime call during an in-flight runtime.start() returns the runtime without awaiting start, so the second caller can see leaseRole 'none' / pre-start state. Potential race for hosted owner surfaces; needs its own repro + fix (await the in-flight start promise).","status":"open","priority":3,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-22T03:54:56Z","created_by":"James Lal","updated_at":"2026-07-22T03:54:56Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-keu","title":"Mobile review sheet: thread cards keep editor-anchor y positions inside the bottom sheet, so the sheet opens looking empty","description":"Pre-existing (predates attn-238): ReviewMargin positions cards absolutely at their anchor's editor y even when rendered inside .review-sheet-margin (position:relative; min-height:55vh) in the mobile BottomSheet — a comment anchored deep in the document lands far below the sheet's fold, so opening 'Review · 2 open threads' shows a blank body until the user scrolls the sheet. Affects both BrowserReviewApp and the hosted owner EditorShell sheet. Fix: give ReviewMargin a stacked/list layout mode for the sheet context (document-order cards, no y positioning).","status":"closed","priority":3,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-17T23:44:13Z","created_by":"James Lal","updated_at":"2026-07-18T00:19:21Z","started_at":"2026-07-17T23:48:22Z","closed_at":"2026-07-18T00:19:21Z","close_reason":"ReviewMargin gained a stacked layout mode (layout='stacked'): document-order flex list, no anchor-y positioning. Both bottom-sheet hosts (BrowserReviewApp joiner sheet, EditorShell owner sheet) pass it. Verified in-browser at 390px: opening 'Review · 1 open thread' shows the card immediately at the top of the sheet (previously blank until scrolled). Also marked .review-margin as an attn-chrome island, killing the base.css li::before rust-dot bleed that showed at the sheet edge.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-nls","title":"Rooms as projects P3: shared-work navigation polish","description":"Implement P3 from planning/collab/room-as-project-ux.md: add cross-project shared/unread discovery, refine folder-share unread roll-up and stop-sharing affordances, and reconcile hosted crypto-erasure/forget-room with project removal. Preserve the one-axis navigation model and verify responsive/accessibility behavior.","status":"open","priority":3,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-15T22:17:48Z","created_by":"James Lal","updated_at":"2026-07-15T22:17:48Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-zv5","title":"Local co-edit: mobile follower promotes to co-edit only via Edit tap — revisit auto-live","description":"Follow-up to attn-47r. Mobile reader-first posture keeps a joined follower read-only until the user taps Edit (the bound editor still live-updates). Decide whether mobile should auto-enter co-edit like desktop.","status":"open","priority":3,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-13T21:37:42Z","created_by":"James Lal","updated_at":"2026-07-13T21:37:42Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-kym","title":"Local co-edit polish: remote caret rendering in the hosted shell","description":"Follow-up to attn-47r. The controllers already broadcast cursors and Editor installs remoteCursorsPlugin, but the hosted EditorShell never wires onRemoteCursors → view.dispatch(setMeta(remoteCursorsKey)). Wire it for both owner (hub) and follower (join) so each tab shows the other tab's caret, like App.svelte:638 does for rooms.","status":"open","priority":3,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-13T21:37:41Z","created_by":"James Lal","updated_at":"2026-07-13T21:37:41Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-z0t","title":"Hosted save-state: surface transient dirty/saving, not just committed","description":"Relay-build verification: commits persist (data-commits increments) but the visible .hosted-save-state text stays 'Saved on this device' through typing — the dirty-\u003esaving-\u003esaved transitions the AutosaveController emits are not surfaced (ownerRoomStatus may override). Minor polish; save IS durable.","status":"open","priority":3,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-12T17:41:54Z","created_by":"James Lal","updated_at":"2026-07-12T17:41:54Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-8zt","title":"Flaky under parallel runner: browser-session.test.ts","description":"src/lib/review/browser-session.test.ts intermittently fails under scripts/run-tests.mjs (parallel child processes) but passes standalone via tsx. Observed twice on 2026-07-10/11 during attn-7xl work. Likely resource contention (fake-indexeddb timers or PoW worker). Reproduce: npm test in a loop.","status":"open","priority":3,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-11T02:07:53Z","created_by":"James Lal","updated_at":"2026-07-11T02:07:53Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-7ev","title":"HTML annotation Phase 8: tests (serde round-trip + dual-instance E2E)","description":"cargo: HTML selector anchor serde + envelope round-trip tests. Dual-instance E2E (scripts/lib/dual-instance.sh): owner shares an HTML doc, reviewer comments on a text range AND on a table cell, owner sees both with correct anchors. Add a task target (e.g. task test:html-review) following the existing review E2E patterns.","acceptance_criteria":"cargo round-trip tests pass; dual-instance E2E asserts owner sees reviewer's text + table-cell HTML comments; wired into a task target.","status":"open","priority":3,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-06-18T15:03:32Z","created_by":"James Lal","updated_at":"2026-06-18T15:03:32Z","dependencies":[{"issue_id":"attn-7ev","depends_on_id":"attn-job","type":"blocks","created_at":"2026-06-18T09:04:01Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} -{"id":"attn-job","title":"HTML annotation Phase 7: collab seeding for HTML snapshots","description":"HTML snapshots currently can't seed a collab room because seeding is gated on anchorIndex presence. Allow HTML snapshots into the collab room so reviewers receive the shared HTML doc and its anchors converge over the existing relay/WebRTC paths.","acceptance_criteria":"An owner sharing an HTML doc seeds the room; a reviewer joins and receives the HTML snapshot; HTML anchors converge.","status":"open","priority":3,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-06-18T15:03:31Z","created_by":"James Lal","updated_at":"2026-06-18T15:03:31Z","dependencies":[{"issue_id":"attn-job","depends_on_id":"attn-08r","type":"blocks","created_at":"2026-06-18T09:04:01Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} -{"id":"attn-67j","title":"Verify reviewer-in-room sidebar shows only shared files","description":"Sidebar.svelte already gates: reviewMode (isReviewerInRoom) -\u003e only \u003cReviewFileTree/\u003e (Shared files), else full local FileTree. Verify a real reviewer is detected (isReviewerInRoom true) and sees only shared files, owner sees all. Likely already works; confirm or fix detection.","notes":"Confirmed in code (Sidebar.svelte:322 reviewMode -\u003e only ReviewFileTree). Runtime re-verify deferred; behaves as desired per code path.","status":"open","priority":3,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-05-24T15:25:44Z","created_by":"James Lal","updated_at":"2026-05-24T15:43:26Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-0xo","title":"Custom three-way merge backend (ReviewApplyExpand edit path)","description":"ITEM 4: the three-way apply card (ReviewApplyExpand.svelte) lets the owner EDIT the proposed text and confirms via reviewAcceptSuggestion(roomId, suggestionId, editedReplacement), but apply.rs does not re-anchor/merge the custom-edited text against the base — it applies as if it were the original proposal. Implement true custom-merge resolution (re-anchor the edited text into the current doc).","status":"open","priority":3,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-05-23T04:45:36Z","created_by":"James Lal","updated_at":"2026-05-23T04:45:36Z","dependencies":[{"issue_id":"attn-0xo","depends_on_id":"attn-07i","type":"parent-child","created_at":"2026-05-22T22:47:19Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-g1a","title":"Apply-flow ambiguous-candidate picker","description":"ITEM 4: apply.rs Ambiguous verdict carries candidates: Vec\u003cResolvedAnchorCandidate\u003e for the UI to choose from, but the apply flow has no picker — the owner sees a stale state instead of click-to-select. There is an AmbiguousAnchorPicker for comment anchors; reuse/extend it in the suggestion apply path.","status":"open","priority":3,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-05-23T04:45:36Z","created_by":"James Lal","updated_at":"2026-05-23T04:45:36Z","dependencies":[{"issue_id":"attn-g1a","depends_on_id":"attn-07i","type":"parent-child","created_at":"2026-05-22T22:47:19Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-5e4","title":"Anchor remap through live collab steps (resolve.rs step 2 stub)","description":"ITEM 4: anchors/resolve.rs step 2 'mapped_through_local_steps' is a documented stub (resolve.rs:274) — it never emits a candidate. Today anchors only re-resolve when the owner republishes a snapshot, not from in-flight ProseMirror collab steps. Wire the PM-step journal -\u003e anchor remap so comments/suggestions track live edits without a snapshot republish (which also reduces the snapshot churn behind the caret/editor-remount issues).","status":"open","priority":3,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-05-23T04:45:35Z","created_by":"James Lal","updated_at":"2026-05-23T04:45:35Z","dependencies":[{"issue_id":"attn-5e4","depends_on_id":"attn-07i","type":"parent-child","created_at":"2026-05-22T22:47:18Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-1rm","title":"Comment reply chains UI (data model exists, no UI)","description":"ITEM 3: Thread.replies exists in the data model (types.ts) and replies are stored as later CommentCreated events, but there is NO reply composer and NO threaded display — only a count badge in the margin card. Add an inline reply composer + nested reply rendering + click-to-expand on the reply count.","status":"closed","priority":3,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-23T04:45:34Z","created_by":"James Lal","updated_at":"2026-05-23T05:35:40Z","started_at":"2026-05-23T05:26:50Z","closed_at":"2026-05-23T05:35:40Z","close_reason":"Reply chains implemented end to end. Write path: optional parent_thread_id through ReviewCommand::CreateComment + IpcMessage::ReviewCreateComment (serde default) + reviewCreateComment(parentThreadId) — a reply is a CommentCreated reusing the root anchor + existing threadId, which reconstructThreads already groups. UI: ReviewMarginCard renders thread.replies (author+body) and a Reply button + inline composer (Cmd+Enter send, Esc cancel); ReviewMargin.replyToThread wires it. IPC parse test covers reply+root payloads. 403 lib tests + 28 web test files + ipc tests green; full build embeds it.","dependencies":[{"issue_id":"attn-1rm","depends_on_id":"attn-07i","type":"parent-child","created_at":"2026-05-22T22:47:17Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-s24","title":"Comment/suggestion inbox + filtering across files","description":"ITEM 3: no global view of review items. The orphan tray surfaces stale/ambiguous only. Add a cross-file summary/inbox listing all comments+suggestions, with filters (mine / kind / open|resolved|all / author) and full-text search of bodies. Needed for multi-file folder shares.","status":"open","priority":3,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-05-23T04:45:34Z","created_by":"James Lal","updated_at":"2026-05-23T04:45:34Z","dependencies":[{"issue_id":"attn-s24","depends_on_id":"attn-07i","type":"parent-child","created_at":"2026-05-22T22:47:17Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-wwq","title":"Return daemon review command errors to CLI callers","description":"review share/join over the daemon socket is fire-and-forget today, so the CLI can print 'request sent' even when the daemon later rejects the command, such as a non-markdown share. Add request/response correlation so CLI callers can receive immediate daemon-side validation errors while the UI still receives the error update.","status":"open","priority":3,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-05-23T00:22:35Z","created_by":"James Lal","updated_at":"2026-05-23T00:22:35Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj.11.9","title":"Investigate pre-existing 0.41 MiB binary-size regression on collab","description":"7.1 agent measured collab @ 501500e at 25.41 MiB — already past the original 25 MiB budget BEFORE webrtc-rs. Intermediate commits (6.2 reqwest+rustls, anchors, 8.1) crept the binary up from the 1.1 baseline of 25.14 MiB. Identify which crate(s) account for the delta and consider tightening features.","status":"closed","priority":3,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-19T03:40:10Z","created_by":"James Lal","updated_at":"2026-05-19T16:56:50Z","started_at":"2026-05-19T16:26:13Z","closed_at":"2026-05-19T16:56:50Z","close_reason":"Round 20: implemented; merged; build clean","dependencies":[{"issue_id":"attn-nnj.11.9","depends_on_id":"attn-nnj.11","type":"parent-child","created_at":"2026-05-18T21:40:09Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj.11.6","title":"Test-vector corpus expansion as features land","description":"Catch-all umbrella for adding new test vectors when edge cases surface (e.g., the resolver corpus gaining cases as we hit them in real markdown, new crypto AAD combinations, PoW boundary conditions). Defer indefinitely; ad-hoc work to be picked up as engineers find gaps. Tracked here so the gaps don't get lost between phases.","acceptance_criteria":"When an edge case is found in any phase, a sub-task is added under this umbrella OR a vector is added to test-vectors/\u003carea\u003e/*.json with a note here.\nResolver corpus gets entries for every status (exact/remapped/ambiguous/stale) at minimum 5 cases each by end of Phase 1.\nCrypto corpus gets new entries whenever a new envelope kind or AAD combination is added.\nPoW corpus gets boundary cases (difficulty 12 and 24 — the policy bounds) plus replay-window edge cases.\nThis issue stays open indefinitely; close only when the project is feature-complete.","notes":"Specs: planning/collab/amendments.md §Phase 0a (corpus is the cross-language interop oracle). Files: test-vectors/ (when it exists). P3, no deadline. This is intentionally a catch-all — implementers should feel free to add vectors without filing new bd issues every time.","status":"closed","priority":3,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:35:39Z","created_by":"James Lal","updated_at":"2026-05-19T16:06:35Z","started_at":"2026-05-19T15:31:08Z","closed_at":"2026-05-19T16:06:35Z","close_reason":"Round 18: implemented; merged; 414+ Rust tests pass","dependencies":[{"issue_id":"attn-nnj.11.6","depends_on_id":"attn-nnj.11","type":"parent-child","created_at":"2026-05-18T16:35:38Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj.11.2","title":"Update planning/architecture.md (remove stale CodeMirror references)","description":"Remove the stale CodeMirror 6 references from planning/architecture.md lines 13, 66, and 84-91 (per amendments.md §Codebase Corrections). The current frontend uses ProseMirror (eleven prosemirror-* packages in package.json, web/src/lib/Editor.svelte). Replace CodeMirror mentions with ProseMirror equivalents. Not on the collab critical path but worth doing alongside Phase 2 work so the architecture doc doesn't mislead anyone reading the collab plan.","acceptance_criteria":"planning/architecture.md lines 13, 66, 84-91 (and any other CodeMirror references) updated to describe ProseMirror.\nDescription matches the current Editor.svelte implementation (ProseMirror schema, decorations, transactions).\nNo CodeMirror references remain in planning/architecture.md (grep -n 'CodeMirror' returns empty).\nMANDATORY: render planning/architecture.md in attn and capture a screenshot proving the doc renders cleanly with the new ProseMirror references visible and no CodeMirror references. Screenshot path recorded in issue notes.\nOptionally: cross-link to planning/collab/data-model.md for the anchor engine's ProseMirror integration.","notes":"Spec: planning/collab/amendments.md §Codebase Corrections (architecture.md is stale). Files: planning/architecture.md. Reference: web/src/lib/Editor.svelte and web/package.json for the actual editor stack. P3 — do alongside other doc work, not blocking.\nScreenshot verification (mandatory acceptance criterion):\n- /tmp/attn-e2e-screenshots/attn-nnj.11.2-architecture-top.png (Overview diagram showing 'ProseMirror editor' in right column)\n- /tmp/attn-e2e-screenshots/attn-nnj.11.2-architecture-editmode.png ('Edit Mode: ProseMirror' section with full prosemirror-* stack)\n\nDOM verification via attn --eval:\n- ProseMirror mentions in rendered DOM: 29\n- CodeMirror mentions: 0\n- CM6 mentions: 0\n- Relevant h3 headings present: 'Checkbox Toggling (any mode, via ProseMirror NodeView)', 'Edit Mode: ProseMirror', 'Key Frontend Dependencies', 'Code Highlighting: comrak + syntect'\n\nRendered via the running attn debug daemon (pid 64284, window 21100) over the actual file at planning/architecture.md in this worktree.","status":"closed","priority":3,"issue_type":"chore","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:34:42Z","created_by":"James Lal","updated_at":"2026-05-19T04:30:38Z","started_at":"2026-05-19T04:09:06Z","closed_at":"2026-05-19T04:29:13Z","close_reason":"Round 13: implemented; merged; all tests pass","dependencies":[{"issue_id":"attn-nnj.11.2","depends_on_id":"attn-nnj.11","type":"parent-child","created_at":"2026-05-18T16:34:41Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj.3.7","title":"Confidence calibration sweep (post-impl)","description":"After the resolver and corpus land, sweep the confidence weights from data-model.md §Anchor Resolution against the real corpus and tune them. The numbers in the spec are starting values — Decision #15 explicitly calls them tunable. Output is a short report justifying the chosen thresholds plus any weight adjustments.","acceptance_criteria":"- A sweep harness (script or test) iterates ranges around the starting weights and records per-case verdicts.\n- planning/collab/confidence-calibration.md captures: methodology, corpus characteristics, the sweep matrix, the chosen final weights, and any changes vs. data-model.md starting values.\n- If weights changed, both ConfidenceWeights constants (Rust + TS) and data-model.md are updated to match (kept in sync).\n- The corpus continues to pass with the tuned weights.","notes":"Spec: planning/collab/amendments.md Decision #15 + §Anchor resolver disagreement policy (line ~131) explicitly defers calibration to after Phase 1. Don't run this until the corpus is comprehensive; otherwise you'll overfit.","status":"closed","priority":3,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:28:32Z","created_by":"James Lal","updated_at":"2026-05-19T04:07:08Z","started_at":"2026-05-19T01:57:30Z","closed_at":"2026-05-19T04:07:08Z","close_reason":"Implemented; merged into collab; 346 Rust + 168 relay tests pass","dependencies":[{"issue_id":"attn-nnj.3.7","depends_on_id":"attn-nnj.3","type":"parent-child","created_at":"2026-05-18T16:28:31Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.3.7","depends_on_id":"attn-nnj.3.6","type":"blocks","created_at":"2026-05-18T16:29:48Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} -{"id":"attn-nnj.9","title":"Phase 6: Browser + remote agents","description":"Browser review client at https://attn.dev/review/\u003croomId\u003e#key=... Memory-only secret persistence per decision #13 (URL fragment parsed once, stripped via history.replaceState, held only in JS heap). Remote agent participant type. CLI subcommands for local agents. INCLUDES the crypto sourcing decision: compile attn-collab-crypto Rust crate to WASM OR write a TS implementation against the shared test-vector corpus.","notes":"Decision #13: URL fragment parsed once, immediately stripped via history.replaceState, held only in JS heap. No sessionStorage/IndexedDB/cookies. Browser-crypto decision (WASM vs TS) is the gating discovery item here.","status":"closed","priority":3,"issue_type":"epic","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:09:17Z","created_by":"James Lal","updated_at":"2026-05-19T18:01:01Z","closed_at":"2026-05-19T18:01:01Z","close_reason":"Phase complete — all implementation issues closed","dependencies":[{"issue_id":"attn-nnj.9","depends_on_id":"attn-nnj","type":"parent-child","created_at":"2026-05-18T16:09:17Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"attn-6dp","title":"Editorial polish: reactions, @mentions, suggestion batching","description":"ITEM 3 (further-out polish): emoji reactions on comments; @mentions with autocomplete + notifications; suggestion batching (multi-select, accept-all/reject-all). None exist today. Lower priority than the toolbar/resolve/replies/inbox track.","status":"open","priority":4,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-05-23T04:45:35Z","created_by":"James Lal","updated_at":"2026-05-23T04:45:35Z","dependencies":[{"issue_id":"attn-6dp","depends_on_id":"attn-07i","type":"parent-child","created_at":"2026-05-22T22:47:18Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"memory","key":"attn-hosted-ui-styles-base-css-types-the","value":"attn hosted UI: styles/base.css types the DOCUMENT with bare-tag selectors (p/h1-6 margins, ul\u003eli::before rust bullet) and is imported by every hosted entry, so chrome components using semantic HTML inherit doc typography (huge h3 gaps, stray rust dots outside popover edges). The antidote is the .attn-chrome island class at the bottom of base.css — put it on any popover/rail/sidebar root instead of per-element div-swaps. CAVEAT: base.css is unlayered while Tailwind v4 utilities are layered, so the reset also beats Tailwind margin utilities inside islands — use padding utilities (pt-*/pb-*) or container gap for intra-island spacing, never mt-*/mb-* on p/h/ul/li. Verify chrome visually with close-read screenshots; computed-style probes (getComputedStyle margin + ::before content) catch what DOM text probes miss."} -{"_type":"memory","key":"cloudflare-relay-hello-frames-list-only-sockets-that","value":"Cloudflare relay hello frames list only sockets that ALREADY announced presence — never the connecting socket itself. Any client whose logic needs 'am I online' (e.g. browser owner liveEditingAvailable) must self-count its own device; trusting frame.onlineDeviceIds alone silently disables owner collab broadcasts ('live editing is paused until the owner authority is online')."} -{"_type":"memory","key":"comrak-emits-nodevalue-math-as-inline-only-never","value":"comrak emits NodeValue::Math as INLINE only, never block-level. Display math ($$...$$) gets absorbed into the parent paragraph's normalized text. Block-level AnchorBlockKind::Math is only reachable via fenced ```math info-string. See src/review/anchors/index.rs::dollar_display_math_is_absorbed_into_paragraph test. Affects anchor resolver (3.4) — display math doesn't get its own anchor block to remap."} -{"_type":"memory","key":"spec-deviation-flagged-by-5-5-room-creation","value":"Spec deviation flagged by 5.5: room creation body MUST include admissionKey (base64url 32 bytes) — not in published relay-spec.md but the only viable resolution to the chicken-and-egg admission HMAC. First POST cannot verify admission (no stored key yet); rejoin verifies normally. Update amendments.md when the relay endpoint chain is fully landed."} -{"_type":"memory","key":"appstate-file-to-room-evolved-from-hashmap-pathbuf","value":"AppState.file_to_room evolved from HashMap\u003cPathBuf, RoomId\u003e (per amendments.md original) to HashMap\u003cPathBuf, (RoomId, FileId)\u003e (after 2.5). The FileId in the value was added because LocalRevision persistence needs the file's review identity, not just the room. Update amendments.md to reflect or revert when ReviewManager (2.8) is more fully wired."} -{"_type":"memory","key":"collab-live-co-typing-convergence-works-headlessly-on","value":"Collab (live co-typing) convergence WORKS headlessly on localhost: collab-probe with 3 attn-agents shows reviewerB's SendCollab reaches owner+rvC (both emit a collab_signal update). The mailbox/WS relay path DOES surface relay-delivered collab as TransportEvent::CollabSignal (ws.rs:823,871) — so relay-fallback collab IS wired (earlier 'WS drops collab' hypothesis disproven). Comments also converge headlessly. So the daemon/transport layer converges for both on localhost; the cross-machine drop must be triggered by real-topology conditions (partial mesh / NAT), still to be reproduced in the Docker harness."} -{"_type":"memory","key":"hard-constraint-user-no-turn-must-not-use","value":"HARD CONSTRAINT (user): NO TURN. Must not use a TURN server, ever. Implication: symmetric-NAT peer-pairs cannot form a direct WebRTC DataChannel, so the relay MUST be a reliable DATA fallback for un-meshable pairs (not purely signaling). Bulletproof no-TURN design: per-peer routing — send over the direct DataChannel when that specific pair is robustly connected; send a TARGETED relay copy (relay already supports target.deviceId routing via deliverableTo/env_by_target) only to peers WebRTC can't reach; receiver dedups by EventId/serverSeq so double-delivery is harmless. Maximizes WebRTC, minimizes relay cost, zero silent drops. Supersedes any TURN plan."} -{"_type":"memory","key":"root-cause-candidate-for-cross-machine-collab-desync","value":"ROOT CAUSE candidate for cross-machine collab desync: the WebRTC stack is STUN-only, NO TURN server (confirmed: src/review/transport/webrtc.rs only sets DEFAULT_STUN_SERVER=stun.l.google.com; planning/collab/security-review.md 'WebRTC TURN credentials: none today (no TURN server stood up)'; relay-spec.md 'STUN only'). Across real networks, symmetric NAT means many peer-pairs CANNOT form a direct DataChannel without TURN, so the WebRTC mesh only PARTIALLY forms. send_collab (manager.rs:1186) skips the relay when it judges the mesh 'complete' from its own connection view -\u003e when a peer is actually only reachable via relay, edits/collab silently drop asymmetrically. Hits both co-typing (mesh path) and comments (if WS is downgraded to signaling-only while believed-up WebRTC is broken). Works on localhost (no NAT). User confirmed desync is across-machines, both edits AND comments. Fixes: (1) stand up TURN, (2) make skip-relay decision conservative / always relay-fallback with receiver-side dedup. Reproduce via Docker+netem symmetric-NAT (attn-orf)."} -{"_type":"memory","key":"verified-via-real-daemon-automation-api-scripts-test","value":"VERIFIED via real daemon automation API (scripts/test-editorial-e2e.sh, attn --eval/--click/--query): attn-0wa (owner stays local / reviewer enters shared-doc), attn-bit (selection toolbar appears + Comment opens composer — full UI path), attn-1rm (reply imported by owner + grouped into 1 threadId), attn-zhr (CommentResolved imported by owner). 10 passed / 0 failed / 1 pend. The pend is attn-cqk: the right-rail review margin overlay does not mount on panelOpen toggle under automation (reviewer can't see cards) — pre-existing, separate from these features. Also learned: build.rs early-returns when web/dist/index.html exists, so testing the daemon requires 'npm --prefix web run build' before 'cargo build' to embed frontend changes."} -{"_type":"memory","key":"attn-web-uses-npm-not-pnpm-web-package","value":"attn web/ uses npm (not pnpm) — web/package-lock.json is the lockfile; scripts/build.sh, Taskfile.yml, build.rs all call 'npm ci'. Using pnpm install drops transitive markdown-it and breaks the Vite build."} -{"_type":"memory","key":"beads-dolt-db-can-be-wiped-by-concurrent","value":"Beads dolt DB can be wiped by concurrent agent operations when parallel subagents in git worktrees race on bd writes via auto-export hooks. Recovery: 'git show \u003clast-good-sha\u003e:.beads/issues.jsonl \u003e .beads/issues.jsonl' then 'bd import'. Mitigation: never have subagents call bd directly; serialize bd operations in the parent only."} -{"_type":"memory","key":"review-event-convergence-comments-suggestions-works-on-local","value":"review-event convergence (comments/suggestions) works on localhost in ALL THREE room modes (live/hybrid/async). Proven by tests/review_sync_convergence.rs: a 3-peer room (owner+2 reviewers) against the real Miniflare relay — reviewerB's comment reaches BOTH owner AND reviewerC every time. Review events are relay-mediated (outbox POST -\u003e relay broadcastFreshEnvelopes -\u003e all WS subscribers -\u003e InboundPipeline), independent of WebRTC; every peer (even in Live mode where selector mailbox=None) still opens the inbound WS subscription ('started room runtime outbox+ws subscribed'). So asymmetric 'changes on one side not the other' is NOT in the relay-mediated review-event path on localhost. Suspects narrowed to: (a) WebRTC-mesh/co-typing OT under imperfect topology (needs Docker/netem), or (b) frontend rendering/snapshot-republish churn (see attn-0wa)."} -{"_type":"memory","key":"svelte-5-runes-state-derived-effect-outside-svelte","value":"Svelte 5 runes ($state, $derived, $effect) outside .svelte components require the .svelte.ts file extension — Vite resolves the bare import path without the .ts. See web/src/lib/hooks/is-mobile.svelte.ts and web/src/lib/review/store.svelte.ts."} -{"_type":"memory","key":"headless-review-agent-deadlocked-on-collab-handle-line","value":"Headless review agent deadlocked on collab: handle_line matched on current_room.lock().clone() — a MutexGuard temporary in a match scrutinee lives for the whole match block, so the lock was held across manager.submit(), whose synchronous EventImported sink re-locks the same mutex -\u003e re-entrant std::Mutex deadlock. Fix (commit f60cde1): bind lock to a let before matching. KEY: the GUI daemon is NOT affected — its update_tx sink is proxy.send_event(UserEvent::Review) (non-blocking, never re-locks AppState). So the user's GUI 'changes don't appear on the other side' was NOT this deadlock; most likely attn-cqk (reviewer margin not rendering, fixed). General rule: never hold a lock across manager.submit() — the sink runs synchronously on the same thread."} -{"_type":"memory","key":"webrtc-is-working-in-the-gui-daemon-2","value":"WebRTC IS working in the GUI daemon (2-party localhost): DataChannel connects in ~100ms and the Rust manager emits ConnectionChanged 'live_direct'. The connection BADGE showing 'Offline' is a FRONTEND bug, not a transport problem — store.svelte.ts applyConnection() only sets this.connection when currentRoomId===payload.roomId, and the owner stays on its local doc (attn-0wa) so the shared room's live_direct never reaches the badge. This is the likely root of the user's 'sync feels broken / webrtc isn't primary' perception: WebRTC is primary+connected, the UI just can't show it. test:webrtc:live reproduces (badge FAIL, data still converges)."} +{"_type":"issue","id":"attn-n01r.41","title":"Desk ships ~601KB of the forbidden editor/crypto graph and the route-bundle gate reports green","description":"The desk route downloads **1.20 MB across 29 requests** to render a list of file names. Roughly **601 KB of that is the exact ProseMirror + markdown-it + @noble crypto + `src/lib/review` graph that `web/scripts/check-route-bundles.mjs` exists to forbid.**\n\n**And the gate reports green.** It walks only *static* imports; the entry `await`s the dynamic one before mount, so the browser fetches it anyway. The CI output says \"route bundle boundaries hold\" while the network panel says otherwise. A gate that passes while the thing it guards is violated is worse than no gate — it converts an unnoticed regression into a documented guarantee.\n\nMeasured consequence: **LCP 8,248 ms** on Fast 3G + 4x CPU throttle, for a page whose entire content is a heading, three cards, and a list of names.\n\nRuntime work is genuinely clean and is not the problem: CLS 0, TBT 3 ms, zero `will-change`, zero `backdrop-filter` on the desk, no layout-property animation, fonts correctly subset with `font-display: swap`. This score is entirely about payload.\n\nTwo things to fix, and the second matters more than the first:\n\n1. **Stop pulling the editor graph into the desk route.** The desk needs none of ProseMirror, markdown-it, the crypto suite, or the review layer to list workspaces. Defer them to the point the user actually opens a document.\n\n2. **Fix the gate so it cannot lie again.** Teach `check-route-bundles.mjs` to follow dynamic imports that are awaited before mount, or — more robustly — assert against the *observed network graph* for each route rather than the module graph. The gate is wired into `web/scripts/deploy-browser-staging.mjs:48` before deploy and into `npm run check:route-bundles`, so it is load-bearing infrastructure; it needs to be right.\n\nWorth noting for contrast: the same gate applied to the landing route **is** accurate — that route was independently verified at 25.9 KB brotli of JS with zero requests for ProseMirror / Mermaid / KaTeX / Shiki / @noble / WASM across four device profiles. So the gate works for statically-imported graphs; it is the awaited-dynamic-import path that escapes it.\n\nSuggested command: /impeccable optimize\n\n---\nEXACT ROOT CAUSE AND NUMBERS (desk audit, measured cold load, empty desk, 1280x900):\n\n Total transfer 1,203,516 B across 29 requests (18 script, 6 stylesheet, 3 font, 1 document, 1 icon)\n Static graph per .vite/manifest.json — what the gate measures — 388.8 KB.\n So ~815 KB arrives on the wire that the gate never looks at.\n\nThe mechanism is `web/hosted/app/main.ts:41-42`, which AWAITS the dynamic import before mount:\n\n const { RealWorkspaceAppService } = await import('../../src/hosted/app/real-service');\n service = await RealWorkspaceAppService.open();\n\nNothing is deferred. The cost was moved off the manifest, not off the wire — and main.ts:5-7 documents the loophole as the design.\n\n`real-service.ts` then statically imports (verified against the build manifest):\n schema-*.js 127 KB markdown-it (52 modules), prosemirror-markdown/-collab, suggest-changes\n index-*.js 94 KB prosemirror-view\n index-*.js 86 KB prosemirror-model/-state/-transform\n browser-share-*.js 69 KB @noble/hashes, @noble/curves, @noble/ciphers\n browser-session-*.js 202 KB 21 x src/lib/review/*\n real-service-*.js 125 KB 11 x src/lib/review/*\n browser-ws-*.js 23 KB signaling, ws, device-proof\n\nFive of the six FORBIDDEN_MODULES patterns (check-route-bundles.mjs:23-30) on the wire on every desk load.\n\nWHY THE GATE MISSES IT: `collectStaticFiles()` (check-route-bundles.mjs:94-109) queues only `chunk.imports`. Vite records these under `chunk.dynamicImports`, which is never traversed.\n\nTHE PROSEMIRROR HALF IS ONE LINE:\n web/src/lib/review/browser-owner-workspace-runtime.ts:11\n import { markdownSerializer } from '../schema';\nReached statically from real-service.ts. Pulling the markdown serializer into the module that lists workspace names drags 307 KB of ProseMirror + markdown-it onto a page that renders three \u003cstrong\u003e tags.\n\nVITALS CONFIRM IT IS PURELY BYTES, NOT WORK:\n FCP 2,388 ms · LCP 8,248 ms · CLS 0 · TBT 3 ms · 1 long task (53 ms), all on Fast 3G + 4x CPU.\n TBT of 3 ms proves this is not a JS execution problem. 1.20 MB over 1.6 Mbps is ~6.0 s of transfer alone.\n Cutting the ~601 KB takes payload to ~600 KB and LCP to roughly 3-4 s; also dropping the bits-ui\n preload below takes it under 450 KB. No rendering or animation optimisation is warranted.\n\nSECOND, INDEPENDENT LEAK — 150 KB of bits-ui for a dialog the desk never renders:\n assets/scroll-viewport-*.js is 149.7 KB / 131 modules (68 x bits-ui, 17 x svelte-toolbelt, 10 x runed,\n @lucide/svelte, tailwind-variants, tabbable, tailwind-merge, plus BottomSheet and the whole\n src/lib/components/ui/dialog/* set). That is 46% of the desk's 326 KB static JS and it is\n modulepreload-ed by dist-browser/app/index.html, so it is fetched at highest priority.\n It arrives because AppShell.svelte:4 statically imports EditorShell.svelte (3,149 lines).\n The gate misses this too — FORBIDDEN_MODULES enumerates vendor packages, not the app's own\n route-level components.\n FIX: make EditorShell a dynamic import in AppShell (it only renders under `editorMode \u0026\u0026 detail`,\n AppShell.svelte:289). One change drops bits-ui, the dialog set and BottomSheet from the desk preload.\n\nTHIRD: the desk ships the entire app stylesheet. assets/app-*.css is 41.4 KB of the route's 62.8 KB.\nRoughly 226 of app-shell.css's 2,795 lines are desk rules; the rest is editor, share sheet, thumb dock,\nlightbox, review rail, arrival toasts.\n\nThe gate DOES correctly keep mermaid, KaTeX and shiki out of the app route — it is the\nawaited-dynamic-import path and the app's own components that escape it.","status":"open","priority":0,"issue_type":"task","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T23:42:22Z","created_by":"Angus Bezzina","updated_at":"2026-08-05T00:00:32Z","started_at":"2026-08-04T23:52:38Z","labels":["design","desk","hosted","web"],"dependencies":[{"issue_id":"attn-n01r.41","depends_on_id":"attn-n01r","type":"parent-child","created_at":"2026-08-04T18:42:21Z","created_by":"Angus Bezzina","metadata":"{}"}],"comments":[{"id":"019fcf38-c3fd-7290-b298-93f28381a3aa","issue_id":"attn-n01r.41","author":"Angus Bezzina","text":"PARTIAL — 203 KB removed and verified; the rest needs an architectural cut, not an import tweak.\n\nDONE (typechecked, 97/97 unit tests pass):\n1. AppShell.svelte no longer statically imports EditorShell. It is loaded on demand via\n `import('./EditorShell.svelte')`, kicked off by an $effect on editorMode so the chunk is in\n flight while the workspace body is still being read. Added an 'editor-loading' branch so the\n unloaded case cannot fall through to 'That workspace isn't here'.\n2. browser-owner-workspace-runtime.ts no longer statically imports markdownSerializer; it is\n imported at its single call site inside the already-async commitRolloverAndPublish.\n\nMEASURED, same method as the audit (cold load, 1280x900, networkidle):\n BEFORE /app 1,203.5 KB 29 requests\n AFTER /app 1,000.8 KB 32 requests (script 836.5 KB, css 60.8 KB)\nThe 149.7 KB bits-ui/dialog/BottomSheet chunk is gone from the desk entirely.\nLanding is unaffected: 257.6 KB, 68.8 KB script.\n\nSTILL SHIPPING and why I stopped rather than half-doing it:\n schema-*.js 127.1 KB · index-*.js 94.4 KB (prosemirror-view) · index-*.js 86.4 KB (prosemirror-model)\n browser-session-*.js 202.1 KB · real-service-*.js 124.8 KB · browser-share-*.js 69.5 KB\n\nTwo further static edges to lib/schema remain:\n browser-local-collab.ts:19 markdownParser (line 308, async loadSeed — deferrable)\n markdownSerializer (line 363, commitNow — **synchronous**)\n browser-owner-authority.ts:10 markdownParser (line 715, async — deferrable)\n\ncommitNow(fileId): void is on the autosave/debounce path and its ordering guarantees around\ninflightCommits are load-bearing. Making it async to accommodate a dynamic import changes\ncollab commit ordering — that is a correctness risk I am not taking blind.\n\nThe real fix is higher up and is a genuine refactor: workspace-service.ts:49-52 statically imports\nBrowserOwnerWorkspaceRuntime, so the desk drags the whole editor runtime for three read-only calls\n(listWorkspaces, storageHealth, getWorkspace). Splitting the read-only service from the editor\nruntime cuts all six remaining chunks at once. That wants its own pass with the collab tests green.\n\nGATE NOT YET FIXED. Deliberately not traversing dynamicImports in check-route-bundles.mjs — that\nwould now flag the two legitimately-lazy chunks created above. The correct gate is a runtime\nscript-byte budget per route, which measures what actually loads and cannot be gamed by renaming\nchunks. Filed as the remaining work here.","created_at":"2026-08-05T00:00:32Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"attn-n01r.42","title":"INK theme has no destructive colour: Delete renders in the primary accent; three tokens undefined","description":"In the INK theme the desk has **no red anywhere in its destructive vocabulary**. Every destructive surface resolves to `--primary-hover`, which in dark mode is the steel-blue accent — so \"Delete workspace\" and the delete-confirm dialog render in exactly the same hue as the primary \"Join\" button and every focus ring.\n\nDestructive intent is carried by colour on this surface, and in dark mode that colour is the *affirmative* one. The delete action is irreversible.\n\nAlso found on the desk, same pass:\n\n**Three undefined tokens silently resolve to nothing.** `--radius-md`, `--radius-lg` and `--wash` are not defined on this route, so they collapse to `0px` / `transparent`. Nothing errors; corners and washes just quietly do not appear. These are the hardest kind of drift to notice because the page still renders.\n\n**Hairlines are drawn at full-opacity ink.** `.desk-title` and `.storage-panel` use solid `--ink` rather than `--border`, and DESIGN.md specifies hairlines as \"ink at low alpha, never a solid gray line\". In INK this inverts into the brightest element on the page.\n\nToken discipline in source is otherwise real and worth protecting: exactly **one** literal colour in 2,795 lines of `app-shell.css`, and it is outside the desk. These are drift and undefined-token failures, not hex-code failures.\n\nFix: introduce a destructive token that survives the theme flip (a red tuned for INK, the way `--green`/`--danger-ink` were tuned for AA), define or remove the three phantom tokens, and move the two hairlines to `--border`.\n\nSuggested command: /impeccable colorize\n\n---\nEXACT MEASUREMENTS (desk audit) — resolved through a live probe element in both themes.\n\nDESTRUCTIVE SURFACES, all six using var(--rust-deep), which chrome.css:21 aliases to var(--primary-hover):\n\n .button.danger chrome.css:140-142 PAPER oklch(0.42 0.15 28) -\u003e INK oklch(0.78 0.10 220)\n .row-action.danger:hover app-shell.css:2519-2521 same\n .join-error app-shell.css:2785-2789 same\n inline import-error DeskHome.svelte:199 same\n OpenPage.svelte:75 same\n StoragePage.svelte:157 same\n\n`--danger-ink` exists for exactly this and IS used correctly at app-shell.css:931-934 for the editor's\nentry actions — so the desk is the inconsistent half of the same file.\nTOKEN TO USE: var(--danger-ink) for all six (the text-safe tier; chrome.css:23-26 documents that the\naccent tier measures 3.0-3.3:1 on 11px badges).\n\nUNDEFINED TOKENS — verified live on the running page:\n --radius-md -\u003e UNDEFINED, border-radius resolves to 0px\n --radius-lg -\u003e UNDEFINED\n --wash -\u003e UNDEFINED, background resolves to rgba(0,0,0,0)\n\n--radius-md/--radius-lg are declared at web/src/app.css:16-17, which web/hosted/app/main.ts:8-13 never\nimports. Loaded sheets on /app, confirmed at runtime: scroll-viewport, theme, app, index, chrome,\nbottom-sheet — src/app.css is not among them. Consumers rendering SQUARE where the design intends 8/10px:\n app-shell.css:397 .file-row · :452 .file-add-row · :775 .viewing-safely · :808 .arrival-toast\n--wash is consumed at app-shell.css:1482 (.share-selection-actions button:hover) and defined nowhere, so\nthat hover has no background at all. Intended value is almost certainly var(--accent) (tokens.css:68).\nFIX: import ../../src/app.css in the hosted entry, or re-declare the radii in chrome.css alongside the\nother hosted aliases.\n\nONE CLASS, TWO COLOURS — a specificity collision:\n app-shell.css:42-48 .local-badge { color: var(--green) } specificity 0,1,0\n app-shell.css:181-184 .workspace-row span { color: var(--hosted-muted) } specificity 0,1,1\nThe same .local-badge class renders green in the header (AppHeader.svelte:33) and muted grey in the row\n(DeskHome.svelte:233); the dot follows it because app-shell.css:54-59 sets background: currentColor.\nNet effect: \"Shared · relay sees only ciphertext\" is visually identical to \"Local only\", so the one place\nledger green WOULD be legitimate under the Quarantine Rule — the collaboration layer — is the one place it\nsilently loses. This is the inverse of the header badge issue filed separately, and a separate fix:\nraise .local-badge specificity or scope the row rule to `.workspace-row \u003e span`.\n\nHAIRLINES AT FULL-OPACITY INK:\n app-shell.css:102 .desk-title { border-bottom: 1px solid var(--ink) }\n app-shell.css:2224 .storage-panel { border-top: 1px solid var(--ink) }\nMeasured in INK: oklch(0.87 0.008 250) — a near-white 1px rule, the highest-contrast element on the dark\npage, brighter than the h1 it underlines. TOKEN TO USE: var(--rule) (chrome.css:18 = --border = ink 18%).\n\n--rust-deep IS A HOVER TOKEN USED AS A RESTING COLOUR IN 16 PLACES:\n chrome.css:99,141 · app-shell.css:51,306,558,582,1529,1625,2268,2520,2781,2782,2788 ·\n DeskHome.svelte:199 · OpenPage.svelte:75 · StoragePage.svelte:157\nchrome.css:21 defines it as var(--primary-hover). That is how one alias came to carry four unrelated\nmeanings: primary-hover, eyebrow decoration, warning, and danger. Untangling it is the root fix.\n\nLITERAL COLOURS IN app-shell.css — only two are defects, in 2,795 lines:\n :2615 background: rgba(0,0,0,0.86) .lightbox scrim -\u003e should be var(--overlay-veil)\n :1008 box-shadow: ... oklch(0.2 0.02 55/8%) .editor-top -\u003e should be var(--shadow-soft) (flips in INK)\n :768 --view-only: oklch(0.55 0.06 230) documented intentional local token — NOT a defect\n\nCONTRAST: zero failures across twelve state/theme combinations (empty, populated, join panel, join error,\nblocked, quota, private, best-effort, demo — light and dark). Lowest on the desk 6.54:1. axe-core confirms\n0 violations. The only sub-4.5:1 number anywhere is the disabled .quick caption in the blocked state at\n3.13:1 once opacity 0.55 is applied — WCAG 1.4.3 explicitly exempts inactive components, so it is a\nlegibility note, not a violation.","status":"closed","priority":0,"issue_type":"task","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T23:42:22Z","created_by":"Angus Bezzina","updated_at":"2026-08-05T02:30:20Z","started_at":"2026-08-05T02:26:42Z","closed_at":"2026-08-05T02:30:20Z","close_reason":"Fixed and verified by live token probe in both themes.\n\nDESTRUCTIVE COLOUR — the six surfaces now use --danger-ink instead of --rust-deep (which aliases\n--primary-hover, so in INK they rendered the same steel blue as the primary Join button):\n chrome.css .button.danger\n app-shell.css .row-action.danger:hover\n app-shell.css .join-error\n DeskHome.svelte / OpenPage.svelte / StoragePage.svelte — the three inline error \u003cp\u003e styles\n\nMEASURED (live probe on /app):\n light --danger-ink oklch(0.45 0.18 27) red (--rust-deep was oklch(0.42 0.15 28))\n dark --danger-ink oklch(0.70 0.16 25) red (--rust-deep is oklch(0.78 0.10 220) steel blue)\nINK now has a real destructive colour.\n\nThe three inline copies became a .form-error class — the audit's read was right, it was a class\ncopied instead of created. That also removes 3 of the 20 inline style= attributes tracked in .44.\n\nUNDEFINED TOKENS — declared in chrome.css beside the other hosted aliases. They were in src/app.css\ninside a Tailwind block the hosted entries never import, so they resolved to\n0px/transparent with no error and no visual alarm:\n --radius-md now calc(.625rem - 2px) (was UNDEFINED -\u003e 0px; .file-row, .file-add-row rendered square)\n --radius-lg now .625rem (was UNDEFINED -\u003e 0px; .viewing-safely, .arrival-toast)\n --wash now var(--accent) (was UNDEFINED -\u003e transparent; .share-selection-actions\n hover had no background at all)\nVerified resolving and flipping correctly: --wash is ink 7% in PAPER, white 3% in INK.\nDerived --radius-* from the same --radius as app.css so the two declarations cannot drift.\n\nHAIRLINES — .desk-title border-bottom and .storage-panel border-top moved from solid var(--ink) to\nvar(--rule). DESIGN.md: hairlines are 'ink at low alpha, never a solid gray line'. Measured in INK:\nnow oklch(0.87 0.008 250 / 0.16), previously full opacity — the brightest element on the dark page,\nbrighter than the h1 it underlined.\n\nSPECIFICITY COLLISION — added .workspace-row .local-badge { color: var(--green) }. '.workspace-row\nspan' (0,1,1) had been outranking '.local-badge' (0,1,0), so the same class rendered green in the\nheader and muted grey in a row, making 'Shared · relay sees only ciphertext' visually identical to\n'Local only' (the dot followed, since it paints with currentColor).\n\nsvelte-check 0 errors; 97/97 unit test files pass.\n\nStill open elsewhere in the epic: --rust-deep remains a hover token used as a resting colour in ~13\nother places, which is the root cause behind this whole family (tracked in .36).","labels":["design","desk","hosted","web"],"dependencies":[{"issue_id":"attn-n01r.42","depends_on_id":"attn-n01r","type":"parent-child","created_at":"2026-08-04T18:42:22Z","created_by":"Angus Bezzina","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-xvp3","title":"Stop durable relay writes for ephemeral cursor and viewport presence","description":"Durable Objects rows_written quota is exhausted because replaceable cursor/view presence falls back to HTTP relay and still writes rate, PoW, sequence, and latest-presence state. Make cursor/view presence WebRTC-only; preserve durable relay delivery for comments and document/review events; reject or safely drop legacy relayed presence before any Durable Object writes.","status":"closed","priority":0,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-26T15:35:47Z","created_by":"James Lal","updated_at":"2026-07-26T15:46:04Z","started_at":"2026-07-26T15:36:03Z","closed_at":"2026-07-26T15:46:04Z","close_reason":"Cursor/view presence is WebRTC-only in browser and native clients; relay rejects V3 presence uploads before Durable Object mutations; durable review events remain relayed; full web and relay suites pass.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-j2ew","title":"Stop sharing wedged forever on partially-created shares","description":"James: 'why can't I stop sharing.' His share record was stuck mid-publish from the quota era; Stop → deleteRemote tears down remote resources that don't exist → the relay's 404s are CORS-untagged (anti-enumeration) → the browser throws before reading the status → stopShare aborted and kept the record, forever. Fix: the revoke INTENT (revoke_pending tombstone) persists durably BEFORE any network call; remote teardown is now best-effort. Stop always completes locally — if teardown couldn't be confirmed, the tombstone is retained and retried by the next ensureShare (orphaned remote resources die with their TTL at worst). This IS the reset mechanism: Stop always works, then re-share starts clean. New contract test: unreachable teardown → stop completes, tombstone survives, next share retries the delete and recreates.","status":"closed","priority":0,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-21T18:55:33Z","created_by":"James Lal","updated_at":"2026-07-21T18:55:54Z","closed_at":"2026-07-21T18:55:54Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-58tt","title":"Owner permanently deaf after room instance rotation (seq-binding conflict loop)","description":"ROOT CAUSE of James's owner-not-seeing-comments: when the relay room is rebuilt under the same roomId (relay eviction/expiry, or republish recreating a vanished room), the new instance restarts serverSeqs. The owner's IndexedDB still binds those seqs to the dead instance's envelopes, so every inbound envelope fails commitInbound BEFORE dispatch, the ws client closes and reconnects, and the same frame conflicts again — an infinite deaf loop with threads:0 and (pre-diagnostics) zero symptoms. Confirmed by James's __attnInboundErrors ring: 6× 'server sequence is already bound to another envelope' in 20ms. Fix: (1) commit-conflict heal — purge the room's inbound log + cursors (new storage.resetRoomInboundHistory), reset the cursor, re-commit, continue the in-flight replay; (2) hello regression detector — a hello whose serverSeq is below our cursor proves rotation even when no conflict would fire (cursor beyond new head), resubscribes from 0 after the purge. Verified end-to-end locally: relay DO state wiped under a live share, owner reloads, heals, and receives a post-rotation reviewer comment.","status":"closed","priority":0,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-21T04:42:28Z","created_by":"James Lal","updated_at":"2026-07-21T04:48:13Z","closed_at":"2026-07-21T04:48:13Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-qez","title":"Joiner page unusable at mobile widths (fixed rail squeezes doc to ~60px)","description":"At 390x844 the /s/\u003cid\u003e review page keeps a fixed-width comments rail; the document column collapses to ~60px, one word per line, and the top status strip text overlaps itself. Review links are exactly what people open on phones. Needs responsive stacking (rail as bottom sheet / toggle, like the owner app's mobile shell which is already good).","status":"closed","priority":0,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-16T22:59:41Z","created_by":"James Lal","updated_at":"2026-07-16T23:45:04Z","started_at":"2026-07-16T23:30:22Z","closed_at":"2026-07-16T23:45:04Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-w5d","title":"Owner editor rolls back recent edits on layout remount (mobile\u003c-\u003edesktop resize)","description":"Repro (james/room-as-project-ux, local dev:browser:shares): owner shares a doc, types ' LIVE-EDIT-MARKER-42.' which syncs live to the joiner; resize owner viewport 1440x900 -\u003e 390x844 (mobile shell remount). Owner editor re-renders WITHOUT the marker; joiner keeps it. Resizing back to desktop does not restore it and it never re-converges — owner local state and room state silently diverge, with the owner's own accepted+synced edit lost from their canvas. Data-loss class bug; phone rotation / window resize triggers it.","status":"closed","priority":0,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-16T22:59:39Z","created_by":"James Lal","updated_at":"2026-07-16T23:30:12Z","started_at":"2026-07-16T23:02:17Z","closed_at":"2026-07-16T23:30:12Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-7xl.7.13","title":"Prevent pending editor changes from being lost on navigation and workspace actions","description":"Live staging validation on 2026-07-11 reproduced durable data loss with real keyboard input. Type into ProseMirror and immediately reload or select another file: the header still says Saved on this device, the page navigates, and the pending text is gone. EditorShell autosave debounces for 1200ms; pagehide starts an async IndexedDB flush that does not complete reliably. Desktop tree navigation uses window.location.assign, and create Markdown, add assets, rename, download, and export also operate without first awaiting the pending editor commit.","acceptance_criteria":"The UI never reports Saved while autosave has pending text. Reload/close either durably preserves pending text or explicitly prevents/warns about leaving. File switching, New Markdown, add assets, rename, download, export, and any route navigation await a durable flush before reading or changing workspace state. Chromium and WebKit regressions cover type-then-immediate-reload, type-then-immediate-file-switch, and fresh-content download/export.","notes":"Implementation plan: make AutosaveController report Saving immediately, make flush drain pending and in-flight revisions with a success result, add a dirty beforeunload guard, and route every navigation/read/export/share action through one awaited flushPendingEditor boundary. Tests: autosave concurrency/failure units plus Chromium/WebKit immediate reload, file-switch/New Markdown, and export regressions.","status":"closed","priority":0,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-11T23:48:04Z","created_by":"James Lal","updated_at":"2026-07-12T00:46:48Z","started_at":"2026-07-11T23:56:22Z","closed_at":"2026-07-12T00:46:48Z","close_reason":"Implemented truthful autosave state and an awaited durability boundary for reload-sensitive navigation, mutations, downloads, exports, and sharing. Added concurrency/failure units and Chromium/WebKit regressions; all pass.","labels":["a11y","authoring","autosave","cloudflare","data-loss","e2e","e2ee","ios","performance","release-blocker","security","storage","web"],"dependencies":[{"issue_id":"attn-7xl.7.13","depends_on_id":"attn-7xl.7","type":"parent-child","created_at":"2026-07-11T17:48:04Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-02a.6","title":"Enforce owner-accept invariant across live collaboration","description":"Merged browser-owner collaboration currently accepts and autosaves remote ProseMirror steps. Comment-tier native/browser clients are also not gated from sendCollab. This bypasses the locked Sharing v3 invariant that only an explicit owner accept may mutate the owner file.","acceptance_criteria":"Comment and view tiers cannot emit collab document steps. Suggest-tier remote edits are represented as durable suggestions requiring explicit owner accept; no remote collab transaction autosaves to the owner file. Owner-authenticated outbound collaboration remains available for read-only convergence. Native/browser hostile-client tests prove boundary enforcement.","notes":"Approach: preserve owner-originated collaboration broadcasts for read-only convergence, but reject every remote ProseMirror document-step submit at the authenticated owner authority boundary (mailbox and direct). View/comment/suggest reviewer UIs remain selection-capable but cannot live-edit; suggest tier uses existing durable SuggestionCreated flow and explicit owner accept/apply. Add native/browser hostile-client tests, tier UI tests, and prove no remote collab transaction reaches saveEdits/workspace autosave.","status":"closed","priority":0,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-11T13:59:11Z","created_by":"James Lal","updated_at":"2026-07-11T16:14:36Z","started_at":"2026-07-11T14:41:58Z","closed_at":"2026-07-11T16:14:36Z","close_reason":"Enforced strict owner-accept authority across browser and native collaboration; reviewers remain read-only until explicit owner acceptance","dependencies":[{"issue_id":"attn-02a.6","depends_on_id":"attn-02a","type":"parent-child","created_at":"2026-07-11T07:59:11Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-ask","title":"Relay: preserve browser WebSocket Origin across Worker-to-DO forwarding","description":"Real local Playwright against wrangler dev proved browser WebSocket upgrades arrive at RoomDO with Origin rewritten to the internal request origin (http://relay.attn.sh), not the browser page origin (http://localhost:8787). The DO therefore rejects allowBrowser=true rooms with ATTN_ORIGIN_FORBIDDEN unless the relay's own origin is allowlisted, and production Worker-to-DO forwarding may similarly break https://attn.sh browser sockets or validate the wrong origin. Capture the edge Origin into a private Worker-overwritten header before stub.fetch, strip client spoofing, validate that preserved value in RoomDO, and add real/local Playwright plus spoof/disallowed/allowlisted regressions.","notes":"Implemented fail-closed browser-origin trust boundary. Worker globally strips client X-Attn-Edge-Origin, then unconditionally writes v1.native / v1.browser.\u003cbase64url UTF-8 canonical origin\u003e / v1.invalid for socket forwarding. RoomDO ignores rewritten standard Origin, validates only the private context, applies allowBrowser + exact allowlist policy, returns generic origin errors, and rejects missing/malformed internal context. Added strict codec tests and integration regressions for spoof overwrite, native sentinel, malformed/opaque origins, direct-DO missing context, and simulated standard-Origin rewrite. Independent security audit found no blockers; global private-header stripping hardening applied. Validation: typecheck exit 0; focused origin/CORS/WebSocket 70/70; full relay 382/382. Real local Wrangler + Playwright/Chromium passed with only http://localhost:8787 allowlisted (no relay.attn.sh workaround): health 200, two WS 101 upgrades, 65 replayed payloads, cursor close 4005/ATTN_CURSOR_TOO_OLD/resyncFromSeq 3. Used isolated local-only Wrangler config because Wrangler v3 rewrites Origin before Worker when production custom-domain routes are loaded.","status":"closed","priority":0,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-10T01:41:17Z","created_by":"James Lal","updated_at":"2026-07-10T02:26:01Z","started_at":"2026-07-10T01:51:04Z","closed_at":"2026-07-10T02:26:01Z","close_reason":"Fixed and verified by unit/integration/full relay tests plus real local Playwright WebSocket E2E","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-cqk","title":"Review margin/panel does not mount on panelOpen toggle (reviewer can't see cards)","description":"Found via real daemon E2E (attn --eval/--query). In a reviewer's shared-doc view, reviewStore.panelOpen flips to true (Cmd+J -\u003e togglePanel(), and the auto-open at App.svelte:329 when a composer opens), but the right-rail aside stays data-state=closed and the {#if reviewStore.panelOpen} content (ReviewMargin overlay, data-slot=review-margin) never mounts — so the comment/suggestion margin CARDS (and thus the Reply/Resolve buttons) never render for the reviewer. The store value reads true; the \u003caside\u003e in the {:else if hasSidebar} branch (App.svelte:2139-2177) doesn't react. Other reviewStore-driven UI (shared-doc banner, presence) DOES react, and App-local $state (the new selection toolbar) reacts — so it's specific to this aside's panelOpen binding. Repro: scripts/test-editorial-e2e.sh ('no margin card rendered'). Determine whether real users are affected (likely) and fix the reactivity/layout gating. Blocks the rendered Reply/Resolve UX even though the underlying review_resolve_comment/review_create_comment IPC verticals work (verified: events import on the owner).","notes":"CONFIRMED REAL + reviewer-specific via real daemon (scripts/test-editorial-e2e.sh + isolated probe). Reviewer in shared-doc view: panelOpen=true (set via the reactive togglePanel() method) BUT the right-rail \u003caside data-state={reviewStore.panelOpen?'open':'closed'}\u003e stays data-state=closed and the {#if reviewStore.panelOpen} content (ReviewMargin, data-slot=review-margin) never mounts -\u003e comment/reply/resolve cards INVISIBLE to the reviewer. Owner is UNAFFECTED: isolated probe (owner on a folder) togglePanel() -\u003e state=open + margin mounts. So the same \u003caside\u003e binding to reviewStore.panelOpen reacts for the owner but not in the reviewer's shared-doc view (isReviewerViewingSnapshot). NOT an automation artifact (the auto-open at App.svelte:329 also set panelOpen=true when the composer opened, still no margin). Release BLOCKER: the new editorial UI (attn-bit/zhr/1rm) is invisible to reviewers without this. Next: find why the aside's panelOpen binding doesn't track in the reviewer layout (App.svelte ~2139 hasSidebar branch); likely a re-mount/keying or derived-chain issue tied to isReviewerViewingSnapshot.","status":"closed","priority":0,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-23T06:03:41Z","created_by":"James Lal","updated_at":"2026-05-23T18:05:10Z","started_at":"2026-05-23T15:10:43Z","closed_at":"2026-05-23T18:05:10Z","close_reason":"Fixed: ReviewMarginCard had a 'state' prop, so the $state rune added for the reply composer (attn-1rm) compiled as store auto-subscription (store_get($$props.state)) -\u003e 'state.subscribe is not a function' thrown on every card render -\u003e margin overlay never mounted (cards invisible). Renamed prop state-\u003ecardState + 2 call sites. Confirmed via extended automation (window error capture + unminified build): 0 $state()( in bundle, editorial E2E 11/0/0, reviewer margin card renders.","dependencies":[{"issue_id":"attn-cqk","depends_on_id":"attn-07i","type":"parent-child","created_at":"2026-05-23T00:03:41Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-7qv","title":"send_collab skip-relay optimization drops data when mesh is partial/flaky","description":"THE no-TURN fix. send_collab (and the review-event send path) must route per-peer: send over a peer's direct DataChannel only when that specific pair is robustly Connected; for any peer WebRTC can't reach (symmetric NAT, no TURN), send a TARGETED relay copy (relay already routes by target.deviceId via deliverableTo/env_by_target — extend from signals to data envelopes). Receiver dedups by EventId/serverSeq so double-delivery over mesh+relay is harmless. Replaces the current all-or-nothing 'mesh complete -\u003e skip relay' gate that silently drops un-meshed peers. Validated by the symmetric-NAT Docker harness (attn-orf).","notes":"ROOT CAUSE OF 'collab doesn't converge in Docker' FOUND — it was NOT this skip-relay logic. It was a re-entrant agent deadlock (see attn-orf, commit f60cde1). With that fixed, collab converges 4/4 and routes channels=true relay=false (complete mesh, WebRTC-primary) — exactly the 'mesh complete -\u003e skip relay' path. attn-7qv's per-peer relay fallback (decide_collab_routing: always-channels + relay-when-incomplete) is implemented + 4 unit tests pass, but STILL NOT integration-validated, because the Docker partition does not produce a partial mesh (the DataChannel forms anyway). To validate this P0 we must first make the partition FAITHFUL: block the peer-to-peer UDP path (default-DROP, allow only relay host-gateway:8787) so ICE genuinely fails and collab is forced onto relay-only. Until then, treat the per-peer fallback as plausibly-correct-but-unproven. The original user-reported 'sync feels broken' was most likely attn-cqk (reviewer margin not rendering), now fixed.","status":"closed","priority":0,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-05-23T02:18:06Z","created_by":"James Lal","updated_at":"2026-05-23T20:52:32Z","closed_at":"2026-05-23T20:52:32Z","close_reason":"VALIDATED end-to-end (commit). Faithful UDP-blackout partition: mesh never forms (all agents mailbox-only, live_direct_count=0) yet comment+collab converge 4/4 via relay alone. Baseline converges WebRTC-primary. Per-peer relay fallback proven for no-TURN symmetric-NAT.","dependencies":[{"issue_id":"attn-7qv","depends_on_id":"attn-k3v","type":"parent-child","created_at":"2026-05-22T22:47:13Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7qv","depends_on_id":"attn-ms7","type":"blocks","created_at":"2026-05-23T13:20:30Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-n01r.45","title":"/app/storage and workspace deep links 404; the service worker passes 404s through instead of serving the shell","description":"On the production build, `/app/storage` and every workspace deep link return **404**, and the service worker cannot compensate.\n\nMeasured: `curl /app/storage` -\u003e 404, `/app/storage/` -\u003e 404, `/app/w/\u003cid\u003e/\u003cfile\u003e.md` -\u003e 404. `dist-browser` emits only `app/index.html`, so these routes need host rewrite rules to serve the SPA shell.\n\n**The service worker does not cover the gap, and this part is not a deploy question.**\n`web/src/hosted/sw/sw.ts:96-116` is network-first and only falls back to the cached shell when `fetch` **throws**. A 404 is a *successful* HTTP response, so it is passed straight through to the page. Verified live: the SW was registered (controller present) and `/app/storage` still 404'd.\n\nConsequences in the audited environment:\n- The desk header's only action — the \"Storage\" button (DeskHome.svelte:118) — is a dead link.\n- Reloading any workspace URL loses the workspace.\n\nWhether attn.sh itself has the rewrite rules is a deploy-configuration question outside this build. The SW's inability to compensate is not — a navigation request that 404s should fall back to the cached app shell exactly as a network failure does. Fix the SW regardless of the host config, because the host config is the thing most likely to drift.\n\nAdd a route-level assertion to the e2e suite (`web/e2e/hosted-routes.spec.ts` already covers the landing) that fetches `/app/storage` and a workspace deep link and asserts a 200 with the app shell, so this cannot regress silently.\n\nAudit caveat recorded for whoever picks this up: `StoragePage.svelte` could not be audited live because of this. Any storage-page contrast or layout numbers taken during this pass came from the server's 404 page and should be disregarded.\n\nSuggested command: /impeccable harden\n","status":"in_progress","priority":1,"issue_type":"task","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T23:45:50Z","created_by":"Angus Bezzina","updated_at":"2026-08-05T03:19:59Z","started_at":"2026-08-05T03:19:59Z","labels":["design","desk","hosted","web"],"dependencies":[{"issue_id":"attn-n01r.45","depends_on_id":"attn-n01r","type":"parent-child","created_at":"2026-08-04T18:45:49Z","created_by":"Angus Bezzina","metadata":"{}"}],"comments":[{"id":"019fcff0-6ef2-7203-8602-d610bb673c5f","issue_id":"attn-n01r.45","author":"Angus Bezzina","text":"SERVICE-WORKER HALF FIXED. The deploy half is not mine to fix.\n\nsw.ts navigation-network-first now falls back to the cached shell on a 404/410, not only when fetch\nthrows. A 404 is a successful HTTP response, so it was being passed straight through — which is why\nthe service worker could not compensate for a missing host rewrite, and why /app/storage and every\n/app/w/\u003cid\u003e/\u003cfile\u003e reload rendered the host's error page and lost the workspace.\n\nDeliberately narrow: navigations only, status 404 or 410 only, and only when a shell is actually\ncached. 500s, redirects and the offline path keep their existing behaviour, so this cannot mask a\nreal outage as a working page.\n\nsvelte-check 0 errors; sw bundle builds (81.93 kB); 97/97 unit test files pass.\n\nSTILL OPEN, and it needs someone with deploy access:\n- dist-browser emits only app/index.html, so /app/storage and workspace deep links still 404 on a\n cold first visit, before any service worker is installed. The host rewrite rules are the real fix;\n the SW change above only covers returning visitors.\n- Whether attn.sh and staging.attn.sh actually carry those rewrites is unverified from here. The\n worker (web/worker.ts) does rewriteToEntryDocument, so production may well be fine and this may be\n a dist-browser/static-serving artifact only — but I could not confirm that either way.\n- No e2e assertion yet that /app/storage and a workspace deep link return 200 with the app shell.\n Worth adding to hosted-routes.spec.ts, which already covers the landing, so this cannot regress\n silently.\n\nAlso unblocked by this: StoragePage could not be audited live because of the 404 (noted in the\noriginal finding). Any storage-page numbers taken during the audit came from the server's error page\nand should be disregarded.","created_at":"2026-08-05T03:21:09Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"attn-n01r.43","title":"Desk at 200% zoom overflows to 612px and clips the header; mobile breakpoint deletes row metadata","description":"At 200% zoom the desk overflows to **612px on a 390px viewport** and clips the header row at both 390 and 1280. WCAG 1.4.4 Resize Text (AA) — the same class of failure filed against the home page, so the two are worth fixing in one session with one shared approach.\n\nThree more responsive defects on the desk:\n\n**The \u003c=900px breakpoint deletes columns instead of reflowing them.** `.workspace-row .detail { display: none }` (app-shell.css:2363-2365) drops file count and last-edited, so on mobile three workspaces become byte-identical rows — name, \"Local only\", Rename, Delete. The list loses every distinguishing fact it had.\n\n**One hard 3-\u003e1 column jump with no 2-column tier**, leaving 901-1100px ragged.\n\n**The header runs edge-to-edge while the body is a 1300px centred column** — a 322px disagreement at 1920px. The chrome and the content do not share a measure.\n\nPreserve what is right: **zero horizontal overflow at 320/375/390/414/768/1024/1280/1920** — `scrollWidth === clientWidth` at all eight, with no offending element at any width. That is genuinely good and rare, and the 200% failure above should be fixed without regressing it.\n\nSuggested command: /impeccable adapt\n\n---\nEXACT ROOT CAUSE (desk audit):\n\n app-shell.css:9 .app-shell { grid-template-rows: 64px 1fr; }\n\nA fixed 64px row cannot contain a header whose content grows to 76px at 200%. This single declaration\ncauses the failure at BOTH widths:\n\n 390x844 scrollWidth 612 vs clientWidth 390 — horizontal scrolling required at the equivalent of\n 320 CSS px. Overflowing elements: header.app-header 0/612, div.right 202/568, a.button\n 400/568, main.desk 0/612, div.desk-title 32/580, h1 32/580, div.quick-actions 32/580 —\n i.e. everything. The \"Storage\" button is visually clipped at the top of the header.\n WCAG 1.4.10 Reflow (AA) and 1.4.4 Resize Text (AA).\n 1280x900 no horizontal overflow, but the header still clips: scrollHeight 76 vs clientHeight 63.\n\nFIX: grid-template-rows: minmax(64px, auto) 1fr, and let .app-header wrap — its children are already\nflex-wrap-friendly (app-shell.css:12-26).\n\nAlso in the same family: media-query coverage gap. app-shell.css:118-125 (1.6fr 1fr 1fr) drops straight\nto app-shell.css:2355-2357 (1fr) at \u003c=900px. Between 901-1100px the 1fr tracks are ~250px and \"Import\nworkspace\" wraps to two lines while its siblings stay on one (measured at 1024), leaving a ragged card\nrow. No 2-column tier exists.\n\nAnd: header and body sit on different measures. .desk is max-width 1300px centred (app-shell.css:90-95)\nwhile .app-header is edge-to-edge padding 0 1.4rem (app-shell.css:17). At 1920 the Storage button lands\nat x~1855 while the desk content's right edge is at 1533 — a 322px disagreement.","status":"closed","priority":1,"issue_type":"task","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T23:42:23Z","created_by":"Angus Bezzina","updated_at":"2026-08-05T02:26:08Z","closed_at":"2026-08-05T02:26:08Z","close_reason":"Fixed; horizontal overflow now clean at every breakpoint and both text sizes.\n\nFour causes, all measured:\n1. app-shell.css .app-shell had grid-template-rows: 64px 1fr — a fixed row cannot hold a header whose\n content grows with text size. Now minmax(64px, auto) 1fr.\n2. The shell's single grid column was sized by its widest child, so the header's unwrapped content set\n a 612px floor that main.desk inherited. Added grid-template-columns: minmax(0, 1fr).\n3. .app-header and .app-header .right now flex-wrap; the header growing taller at 200% is correct,\n the page scrolling sideways is not.\n4. .quick big and .empty-desk now break long strings (overflow-wrap). The mono meta line\n 'UNTITLED.MD · NOT CREATED YET' carries 0.1em tracking and cannot shrink — it alone drove 87px\n past the card.\nAlso converted the desk's bare 1fr tracks (.quick-actions, .workspace-row, and their \u003c=900px\noverrides, plus .storage-grid) to minmax(0, 1fr), matching the idiom already used throughout the file.\n\nMEASURED, documentElement scrollWidth - clientWidth:\n 100% text 200% text\n BEFORE @390 clean OVERFLOW 222px header box 63 vs content 76 (clipped)\n AFTER @320 clean clean\n @390 clean clean\n @768 clean clean\n @1280 clean clean header box 89 vs content 89 (no clip)\n @1920 clean clean\n\nWCAG 1.4.4 Resize Text and 1.4.10 Reflow now pass on the desk. Verified visually at 1280 light,\n1280 dark and 390. svelte-check 0 errors; 97/97 unit tests pass.\n\nNOT in this change, still open in the epic: the \u003c=900px breakpoint still hides .detail rather than\nreflowing it, and there is still no 2-column tier between 901-1100px.","labels":["design","desk","hosted","web"],"dependencies":[{"issue_id":"attn-n01r.43","depends_on_id":"attn-n01r","type":"parent-child","created_at":"2026-08-04T18:42:23Z","created_by":"Angus Bezzina","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-n01r.34","title":"The desk shows no review state; WorkspaceSummary carries no review facts at all","description":"attn's whole claim is that humans and agents review the same document together. The desk — the surface the owner opens most — shows **no review state at all**.\n\nThe row exposes Local only / Shared / Backed up, but no open-comment count, no pending-suggestion count, no \"an agent left 3 suggestions\", no peer avatars, no unread indicator.\n\nIt is not just a rendering gap. `WorkspaceSummary` (web/src/hosted/app/types.ts:36-47) carries `markdownCount`, `assetCount`, `lastEditedLabel`, `sharing`, `sizeLabel`, `backupLabel`, `openPath` — and not one review fact. The data model has no place to put one.\n\nA desk that lists **files** instead of **reviews in flight** is a file manager wearing attn's typeface. PRODUCT.md:44 makes \"human and agent are peers in one thread\" a design principle; the owner's home screen does not know that either exists.\n\nThis is the desk's equivalent of the home page's positioning gap (filed separately in this epic) — the same absence, one layer in.\n\nWork involved:\n- Extend `WorkspaceSummary` with review facts (open comments, pending suggestions, last activity, and who — human or agent).\n- Surface them on the row in the resting state, where Rename/Delete currently sit.\n- Decide the priority order: for a reviewer tool, \"3 suggestions waiting\" outranks \"6 files\" and probably outranks the last-edited timestamp.\n\nRelated question worth settling at the same time: what is \"New workspace\" doing on this screen at all? The landing already offers `/app#new` as a one-click path and `AppShell.createAndOpen()` (:67-98) makes it idempotent. Creation occupies 524px of prime real estate; opening occupies 200x28px of text. The ratio is backwards for a surface used daily.\n\nSuggested command: /impeccable shape\n","status":"open","priority":1,"issue_type":"task","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T23:36:48Z","created_by":"Angus Bezzina","updated_at":"2026-08-04T23:36:48Z","labels":["design","desk","hosted","web"],"dependencies":[{"issue_id":"attn-n01r.34","depends_on_id":"attn-n01r","type":"parent-child","created_at":"2026-08-04T18:36:48Z","created_by":"Angus Bezzina","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-n01r.32","title":"The workspace row is four text islands: dead hover CSS, a text-sized open target, resident admin controls","description":"The workspace row is the payload — the thing the primary user is here for fifty times a week — and it is the least designed object on the page.\n\nMeasured at 1440: a 1185x68px row where \"Project Gamma\" ends at x=275, \"1 file\" sits at x=600, \"Just now\" at x=870. Roughly **250px of dead space between every column**, no column headers, nothing forming a scannable ledger. Grid is `1.2fr 0.65fr 0.65fr auto` (app-shell.css:163).\n\nFour concrete defects:\n\n1. **The hover state is dead CSS.** `a.workspace-row:hover` (app-shell.css:172) never matches — the element is a `\u003cdiv\u003e` (DeskHome.svelte:207; confirmed at runtime, `tagName === \"DIV\"`). The row has no hover state whatsoever; only the name underlines.\n\n2. **The open target is text-sized.** `.row-open` wraps only the `\u003cstrong\u003e` (DeskHome.svelte:222-224) — about 200x28px of an 80,000px² row.\n\n3. **Row admin is resident chrome.** Rename and Delete are permanently rendered on every row (DeskHome.svelte:237-246); nothing is disclosed on hover or focus. At 3 workspaces that is 9 interactive controls in one undifferentiated block; at James's realistic 15 it is 45, and the three most-repeated words on screen become \"Rename, Delete, Rename, Delete…\".\n\n4. **The weighting is inverted.** `.row-action` is 12.48px at weight 650 (app-shell.css:2508) while the adjacent data (\"1 file\", \"Just now\") is 13.12px at weight 400. The destructive administrative control is optically *heavier* than the content it acts on.\n\nFix: make the whole row the open target (a stretched-link pseudo-element over the row, with the actions raised above it in stacking order). Tighten the grid so metadata sits close-right of the name instead of drifting. Add a real row hover fill plus `:focus-within`. Reveal Rename/Delete on hover/focus-within only, so the resting row is name + recency + sharing state. Wrap the rows in `\u003cul\u003e`/`\u003cli\u003e` (also tracked in the desk a11y issue).\n\nTwo adjacent defects in the same region:\n- The delete confirm injects a ~190px full-width block **between** rows, shoving everything below it down and back up again on cancel — layout jitter in a list you scan. It also spans the full 1185px for a two-button question (DeskHome.svelte:249-271).\n- `.row-action:hover` background is `color-mix(in srgb, var(--sheet) 70%, transparent)` (app-shell.css:2516) — near-invisible on PAPER.\n- Dead code: `sharingLabel()` returns 'Shared · relay sees only ciphertext' (DeskHome.svelte:69-70) but that branch is unreachable — DeskHome.svelte:232 handles the 'shared' case before the fallback runs. A sign the row's state rendering was patched rather than modelled.\n\nSuggested command: /impeccable shape\n","status":"in_progress","priority":1,"issue_type":"task","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T23:36:47Z","created_by":"Angus Bezzina","updated_at":"2026-08-05T03:25:00Z","started_at":"2026-08-05T03:25:00Z","labels":["design","desk","hosted","web"],"dependencies":[{"issue_id":"attn-n01r.32","depends_on_id":"attn-n01r","type":"parent-child","created_at":"2026-08-04T18:36:46Z","created_by":"Angus Bezzina","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-n01r.33","title":"Desk on mobile: first workspace sits at y=813 in an 844px viewport; row actions are 48x28","description":"On a 390x844 viewport the desk puts its entire payload below the fold and shrinks its controls under the touch minimum.\n\n**The first workspace name is off-screen on load.** Measured `.workspace-row` top = **813px in an 844px viewport**. Three 155px tiles stacked (app-shell.css:2355-2357) plus the 2.5rem/4rem margin pair on `.quick-actions` push the list past the fold — roughly 1.7 viewport heights of chrome before a document appears. (At 1440x900 the equivalent figure is 558px, 62% of the viewport, consumed before the first workspace name.)\n\n**Row action targets are 57x28px and 48x28px**, measured on all six via `getBoundingClientRect`, with Delete sitting 24px from Rename. Well under 44x44, and the deletion is irreversible — mis-taps destroy workspaces.\n\n**File count and last-edited are deleted on mobile.**\n app-shell.css:2363-2365 @media (max-width: 900px) { .workspace-row .detail { display: none } }\nThat removes the only recency signal from a list titled \"Recently on this device\". The mobile row shows name + \"Local only\" + Rename + Delete.\n\n**The name gets 118px.** `.row-tail` claims ~240px of a 358px row (grid becomes `1fr auto` at app-shell.css:2359-2361), so \"Project Beta Longer Name Here\" wraps to three lines. The payload gets a third of the row; the admin controls get two thirds.\n\n**The join panel's buttons break apart.** `.join-row { display:flex; gap:8px; flex-wrap:wrap }` (app-shell.css:2741-2745) with `min-width:240px` on the input (app-shell.css:2749) leaves \"Join\" beside the field and orphans \"Cancel\" onto its own line, left-aligned and visually detached.\n\n**Header crowding is one string away from breaking.** Brand + badge + Storage on one line at 390px with `gap: 0.7rem` (app-shell.css:22-26). It fits with \"Backup recommended\"; the longer \"Storage needs attention\" state (AppHeader.svelte:25-26) will collide.\n\nFix direction: collapse the three action tiles to a single primary plus an overflow on mobile, keep the metadata (it is the reason the list exists), give the name the majority of the row, and move row admin behind a per-row overflow control sized to 44x44.\n\nSuggested command: /impeccable adapt\n","status":"open","priority":1,"issue_type":"task","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T23:36:47Z","created_by":"Angus Bezzina","updated_at":"2026-08-04T23:36:47Z","labels":["design","desk","hosted","web"],"dependencies":[{"issue_id":"attn-n01r.33","depends_on_id":"attn-n01r","type":"parent-child","created_at":"2026-08-04T18:36:47Z","created_by":"Angus Bezzina","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-n01r.31","title":"'Backup recommended' is painted in the safe-state green, and the desk offers no backup affordance","description":"The header badge shows a green dot and green text reading \"Backup recommended\". Green-on-green is the universal \"you're fine\" signal, attached to a message that means the user's work is one browser-storage eviction from gone. This is the \"is my work safe?\" moment for a local-first product with no cloud copy, and the desk currently gets it backwards.\n\nCause: `AppHeader.svelte:19-21` maps `best-effort` to `{ label: 'Backup recommended', warn: false }`, so it renders through `.local-badge { color: var(--green) }` (app-shell.css:47) — the identical treatment to the healthy `persistent` / \"On this device\" state. Only `warn: true` states get `--rust-deep` (app-shell.css:50-52).\n\nTwo DESIGN.md violations at once:\n- **Quarantine Rule** — green belongs to the collaboration layer (suggestion/insertion) only, never to base chrome state.\n- **INK theme** — `--green` resolves to `oklch(0.74 0.13 150)`, a saturated mint glowing on `oklch(0.148 0.014 255)` near-black, which the dark-theme guidance explicitly rejects.\n\nIt also fails the \"never rely on colour alone\" requirement in reverse: the colour actively contradicts the words.\n\nCompounding the problem, there is **no export or backup affordance anywhere on the desk**. Every row says \"Local only\" in flat muted grey, and the remedy lives behind a \"Storage\" button that the copy never connects to.\n\nFix:\n- `best-effort` is a caution, not a success. Set `warn: true` and introduce a third, neutral/caution badge treatment that is neither the collaboration green nor the destructive red — a muted neutral with a hollow dot reads as \"attention\" without borrowing a quarantined hue.\n- Make the badge itself a link to `/app/storage` so the remedy is one click from the warning.\n- Reserve `--green` strictly for suggestions.\n\nRelated state legibility gap: `Local only` / `Backed up` / `Shared` (DeskHome.svelte:67-76) are three unexplained states with no legend, and nothing on the desk explains what \"Backup recommended\" wants the user to *do*.\n\nSuggested command: /impeccable colorize\n","status":"closed","priority":1,"issue_type":"task","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T23:36:46Z","created_by":"Angus Bezzina","updated_at":"2026-08-05T03:03:04Z","started_at":"2026-08-05T02:57:42Z","closed_at":"2026-08-05T03:03:04Z","close_reason":"Fixed. best-effort is now a caution tier of its own, and the badge links to the remedy.\n\nAppHeader's badge returns a three-value tone instead of a boolean warn:\n persistent -\u003e ok (green, filled dot) 'On this device'\n best-effort -\u003e caution (muted ink, HOLLOW dot) 'Backup recommended'\n session-only / unavailable / quota-pressure -\u003e warn (--danger-ink, filled dot)\n\nPreviously best-effort returned warn: false and so rendered in exactly the same green as the healthy\nstate — green-on-green is the universal 'you're fine' signal, attached to a message meaning the\nuser's work is one storage eviction from gone.\n\nTwo DESIGN.md violations closed with it: green is quarantined to the collaboration layer, so a\nstorage state was never entitled to it; and --green resolves to a saturated mint on INK's near-black\nground. Caution now uses muted ink, which is neither the collaboration hue nor the destructive one.\n\nNot colour-only, per PRODUCT.md: the caution dot is HOLLOW (inset ring) while ok and warn are filled,\nso the tiers differ in shape as well as hue.\n\nTHE REMEDY IS NOW ONE CLICK FROM THE WARNING — any non-ok badge renders as an \u003ca href='/app/storage'\u003e\nrather than a \u003cspan\u003e. The desk previously stated the problem and left the fix behind an unrelated\n'Storage' button the copy never connected to. Verified live: tone='caution', tagName='A',\ncolor=oklch(0.32 0.012 65).\n\nThe row-level 'Shared · relay sees only ciphertext' badge keeps green deliberately — that is the\ncollaboration layer, the one place the Quarantine Rule allows it.\n\nSTILL OPEN: there is still no export/backup affordance on the desk itself; the badge routes to the\nStorage page rather than offering the action inline.\n\nsvelte-check 0 errors; 97/97 unit test files pass.","labels":["design","desk","hosted","web"],"dependencies":[{"issue_id":"attn-n01r.31","depends_on_id":"attn-n01r","type":"parent-child","created_at":"2026-08-04T18:36:45Z","created_by":"Angus Bezzina","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-n01r.30","title":"Desk a11y: Rename/Delete announce no workspace name; no list semantics; alertdialog without the contract","description":"Four accessibility defects on the desk, the first of which is data-loss-grade.\n\n**[P0] Rename and Delete have no accessible context.** Live DOM confirmed:\n DeskHome.svelte:237-246\n \u003cbutton class=\"row-action\" type=\"button\"\u003eRename\u003c/button\u003e\n \u003cbutton class=\"row-action danger\" type=\"button\"\u003eDelete\u003c/button\u003e\nNo `aria-label`, no `aria-describedby`. Tabbing a 10-workspace desk announces \"Rename button, Delete button, Rename button, Delete button…\" with no indication of **which workspace is about to be destroyed**, and the deletion is explicitly irreversible. This is the single worst a11y defect on the page. Fix: `aria-label={`Delete ${workspace.name}`}` and the same for Rename.\n\n**[P1] No list semantics.** Verified zero `ul`, `ol` or `role=\"list\"` inside `main.desk`. The list is an unstructured run of links and buttons — no \"list, N items\" announcement, no item position, no way to jump by list. Wrap the rows in `\u003cul\u003e`/`\u003cli\u003e`.\n\n**[P1] `role=\"alertdialog\"` without the modal contract.** DeskHome.svelte:250 declares the role, but focus is never moved into it, focus is not trapped, and Escape does not close it. A screen-reader user is told an alert dialog opened and then finds focus still on the Delete button behind it. Either implement the contract (move focus, trap it, Escape closes, restore focus) or drop the role and treat it as inline confirmation.\n\n**[P1] Escape does not close the join panel, and focus is never restored.** Verified live: the panel is still in the DOM after an Escape keypress — DeskHome.svelte:44-48 has no key handler, and `closeJoin()` does not return focus to the tile that opened it. Both violate DESIGN.md's Topmost-Escape Rule (\"Escape closes exactly one layer… Every overlay stores focus on open and restores it on close\"). Note the open half is already right: the panel correctly focuses its input on open (DeskHome.svelte:40-42). Only the close half is missing.\n\nTwo smaller ones from the same pass:\n- `aria-label=\"A half-written Markdown sheet, waiting\"` (DeskHome.svelte:275) is poetic prose describing a visual metaphor. A screen-reader user hears a region name that explains nothing about what to do and does not convey that this is the empty state.\n- The import error `\u003cp\u003e` (DeskHome.svelte:198-202) uses hardcoded inline `style` attributes, sits above the hidden file input, and has `role=\"alert\"` but no association with the control that failed.\n- Only two landmarks on the page: HEADER and MAIN. No nav, no labelled section around the list.\n\nPreserve what is already right: focus-visible is complete and correct. A 16-stop Tab walk found every stop — brand, Storage, all three quick tiles, every `.row-open`, every `.row-action` — drawing a 2px solid ring in the theme accent, correctly recoloured to steel `oklch(0.72 0.1 220)` in INK. Nothing is `outline: none`.\n\nSuggested command: /impeccable harden\n","status":"closed","priority":1,"issue_type":"task","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T23:36:45Z","created_by":"Angus Bezzina","updated_at":"2026-08-05T02:48:26Z","started_at":"2026-08-05T02:45:38Z","closed_at":"2026-08-05T02:48:26Z","close_reason":"Fixed and verified by live browser probe on a populated desk.\n\n[P0] DESTRUCTIVE ACTIONS NOW NAME THEIR TARGET — Rename and Delete carry aria-label with the\nworkspace name. Verified: 'Rename Untitled' / 'Delete Untitled'. Tabbing a multi-workspace desk\npreviously announced 'Rename button, Delete button, Rename button…' with no indication of which\nworkspace was about to be irreversibly deleted.\n\nLIST SEMANTICS — the rows are now \u003cul class='workspace-list'\u003e / \u003cli\u003e, labelled by the section\nheading. Verified: 1 list, 1 item on a one-workspace desk. Previously a flat run of divs — no\n'list, N items', no item position, no way to jump to it.\n\nTHE POPULATED DESK HAS A HEADING AGAIN — 'Recently on this device' was a plain\n\u003cdiv class='folio-label'\u003e, so the empty desk exposed an \u003ch2\u003e and the populated desk exposed none:\nthe more content the page had, the less structure it offered. Now an \u003ch2\u003e carrying the same class.\nVerified outline: H1 'Your desk' -\u003e H2 'Recently on this device'.\n\nTHE alertdialog NOW HAS THE CONTRACT IT ADVERTISES — aria-modal, tabindex=-1, focus moved in on open\n(use:autofocus), and focus restored to the invoking Delete button on cancel. Escape dismisses it.\n\nESCAPE CLOSES THE TOPMOST LAYER — a keydown handler on the shell closes the delete confirm first,\nthen the join panel, per DESIGN.md's Topmost-Escape Rule. closeJoin() now also restores focus to the\ntile that opened it; the open half was already correct (the panel focuses its input), only the close\nhalf was missing.\nVerified: join panel open-\u003etrue, after Escape open-\u003efalse, focus returned to the join-review trigger.\n\nAlso folded in from .44 while in the same markup: the join tile now exposes aria-expanded and\naria-controls, so its disclosure state is visible to assistive tech.\n\nsvelte-check 0 errors; 97/97 unit test files pass.\n\nNOT in this change: the import error \u003cp\u003e is still positioned away from the control that failed, and\nthere is still no aria-live announcement for rename/delete completion (tracked in .46).","labels":["design","desk","hosted","web"],"dependencies":[{"issue_id":"attn-n01r.30","depends_on_id":"attn-n01r","type":"parent-child","created_at":"2026-08-04T18:36:44Z","created_by":"Angus Bezzina","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-n01r.29","title":"Desk has no keyboard model at all (heuristic 7 scores 0/4) on a keyboard-first product","description":"The desk scores **0/4** on Nielsen heuristic 7 (Flexibility and Efficiency of Use). PRODUCT.md's primary user is James — \"keyboard-first flow\", \"every action keyboard-reachable\", \"optimize for the hundredth review, not the first\". This desk is optimised for the first.\n\nEvidence: `DeskHome.svelte` contains exactly one keyboard handler — Enter/Escape inside the rename input (DeskHome.svelte:215-218). Grepping DeskHome / AppShell / AppHeader / StoragePage / OpenPage for `CommandPalette`, `keydown`, `metaKey`, `.key ===` returns nothing else.\n\nMissing entirely: command palette, \"/\" filter, \"n\" for new, j/k navigation, type-ahead, search, sort, filter, multi-select, pinning, last-opened restore. There is no search, sort or filter control on the list at any viewport, and the sort order is never stated — \"Recently on this device\" implies recency but nothing confirms it.\n\nCompounding it, the open target is the name text only: `.row-open` wraps only the `\u003cstrong\u003e` (DeskHome.svelte:222-224), roughly 200x28px inside a 1185x68px row. A Fitts's-law failure on the single most repeated action in the product.\n\nShip three things, in this order:\n1. A \"/\" or Cmd-K filter over workspace names that focuses on keypress and narrows `.workspace-row` live.\n2. Up/Down to move a selection through rows, Enter to open. The rows already carry a stable `data-workspace-id` (DeskHome.svelte:207) to hang selection off.\n3. Escape closing the topmost layer (join panel, then delete confirm) with focus restored to the invoking control, per DESIGN.md's Topmost-Escape Rule.\n\nThen advertise the shortcut in the `.folio-label` row so it is discoverable.\n\nOpen question worth answering before building: what happens at 40 workspaces? No search, no sort, no pagination, no virtualisation, no grouping, 68px rows, 45+ resident controls. There is a number at which this desk stops working and nobody currently knows what it is.\n\nSuggested command: /impeccable harden\n","status":"in_progress","priority":1,"issue_type":"task","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T23:36:44Z","created_by":"Angus Bezzina","updated_at":"2026-08-05T03:24:59Z","started_at":"2026-08-05T03:24:59Z","labels":["design","desk","hosted","web"],"dependencies":[{"issue_id":"attn-n01r.29","depends_on_id":"attn-n01r","type":"parent-child","created_at":"2026-08-04T18:36:43Z","created_by":"Angus Bezzina","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-n01r.27","title":"Landing image pipeline: hero sizes over-fetches the LCP by 1.5x and 7.5MB of dead PNG fallbacks ship","description":"Two image-pipeline defects on the landing, both measured against the production build.\n\n**[P1] The hero `sizes` attribute overstates the LCP box by 1.5x.**\n web/src/hosted/landing/Hero.svelte:68\n sizes=\"(max-width: 680px) calc(100vw - 2rem), (max-width: 1180px) 72vw, 920px\"\n\nThe `.window img` box measures **614px** at every viewport \u003e= 1180px (the hero grid is `minmax(500px,0.95fr) minmax(540px,1.05fr)` inside a 1296px content box, less `.window { inset: 24px 0 20px 30px }`). The attribute claims 920px, so the browser over-selects:\n\n 1440 @1x box 614px, needs 614 device px -\u003e served 1280w (40,852 B); 768w (21,513 B) suffices WASTE 19,339 B\n 1440 @2x box 614px, needs 1228 device px -\u003e served 1920w (45,170 B); 1280w suffices WASTE 4,318 B\n 1920 @2x box 647px, needs 1295 device px -\u003e served 1920w correct\n 390 @3x box 346px, needs 1038 device px -\u003e served 1280w correct\n\nThe 1180 branch errs the other way: at a 1000px viewport the stage measures ~870px but `72vw` claims 720px — 21% under-served, so the image renders soft on retina laptops between 681 and 1180 CSS px.\n\nRecommended: `sizes=\"(max-width: 680px) calc(100vw - 2rem), (max-width: 1180px) calc(100vw - 5rem), 620px\"`\n\n**[P1] 7.5 MB of unusable raw PNG fallbacks ship to production.**\n imports at Hero.svelte:9-10, HowItWorks.svelte:4-5, NativeSection.svelte:5-6; consumed at ResponsiveScreenshot.svelte:24 (`src={fallback}`)\n\n`dist-browser/assets` carries six raw 1920x1440 PNGs totalling 7,773,333 B:\n collab-dark 979,583 · collab-light 1,050,683 · editor-dark 1,580,143\n editor-light 2,046,763 · share-dark 934,152 · share-light 1,182,009\n\nZero PNG requests were observed across four device profiles — AVIF-capable browsers never touch them. But a non-AVIF client (Safari \u003c16.4, Firefox \u003c93) pulls a 2.0 MB PNG into a 631px box, roughly 50x oversized, and there is no smaller step to degrade to because the `\u003cimg\u003e` carries no `srcset` of its own (only `\u003csource type=\"image/avif\"\u003e` does).\n\nRecommended: generate a 1280w WebP or quality-80 JPEG as the `fallback` prop (~60-90 KB each) and drop the PNGs from the bundle. Bounds the worst case at ~90 KB and removes 7.5 MB from every Pages asset upload.\n\nContext — the rest of the image pipeline is genuinely good and should be preserved: AVIF with a proper srcset ladder, correct `loading`/`fetchpriority`/`decoding` per position, and lazy-loading verified working (editor AVIF deferred and fetched at 2357 ms on scroll).\n\nSuggested command: /impeccable optimize\n","status":"open","priority":1,"issue_type":"task","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T23:34:21Z","created_by":"Angus Bezzina","updated_at":"2026-08-04T23:34:21Z","labels":["design","hosted","landing","perf","web"],"dependencies":[{"issue_id":"attn-n01r.27","depends_on_id":"attn-n01r","type":"parent-child","created_at":"2026-08-04T18:34:20Z","created_by":"Angus Bezzina","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-n01r.23","title":"200% text scaling breaks the home page: 184px horizontal scroll and an unreachable CTA at 390px","description":"At 200% text on a 390px viewport the home page produces **184px of horizontal scroll** (scrollWidth 574 vs clientWidth 390), the nav's \"New workspace\" CTA is sliced by the right viewport edge and becomes unreachable, and the h1 clips \"documents.\" mid-word. Loss of content AND functionality — WCAG 1.4.4 Resize Text (AA).\n\nMethod: `page.addStyleTag({content:'html{font-size:200%}'})` at 390 and 1280. At 1280 there is no horizontal scroll; the defect is mobile-only for the grids, but the nav defect appears at both.\n\n**Cause 1 — four grid tracks declared `1fr` instead of `minmax(0, 1fr)`.** `1fr` is `minmax(auto, 1fr)`, so the track floors at its content's min-content width and physically cannot shrink below it.\n\n web/src/hosted/landing/landing.css:236 .entry-strip\n web/src/hosted/landing/landing.css:290 .chapter-head\n web/src/hosted/landing/landing.css:318 .steps\n web/src/hosted/landing/landing.css:390 .surfaces\n (plus the \u003c=680px overrides at :643-646, :664-667, :684-686)\n\nMeasured overflow per element at 390/200%:\n article.surface x2 .......... 163px past the viewport\n .surface h3 / p / ul / li ... 115px\n span.surface-label .......... 27px\n chapter-head children, h2, p.chapter-intro ... 26px\n div.nav-right, a.button.primary ... 24px\n a.entry x3 .................. 22px\n\nWorst offender is `.surfaces`, whose `\u003cli\u003e` items are mono (landing.css:442-447): \"→ Installable for offline launch\" at 200% mono sets a min-content floor 163px wider than the viewport.\n\n**The fix is already present in this file.** `.local-note` at landing.css:108-110 uses `repeat(3, minmax(0, 1fr))` correctly. Applying `minmax(0, 1fr)` to the four grids above resolves every row in that table except `.nav-right`.\n\n**Cause 2 — the nav uses a fixed `height`, so its own contents overflow it.**\n web/src/hosted/landing/landing.css:14 .site-nav { height: 70px }\n web/src/hosted/landing/landing.css:577 .site-nav { height: 64px } (\u003c=680px)\n\nMeasured at 200% text: box 70px vs scrollHeight 79px at 1280; box 64px vs scrollHeight 92px at 390. The CTA (min-height 46px from chrome.css:107, plus scaled padding) protrudes past the nav's `border-bottom`, which cuts through the button — and because the nav is `position: sticky` with `backdrop-filter`, the overflowing button then paints over hero content as the page scrolls. Use `min-height` and let `.nav-right` (landing.css:40-44) wrap or shrink.\n\nNote what does NOT fail: WCAG 1.4.10 Reflow passes — tested at a 320px viewport (the 1280@400% equivalent), scrollWidth === clientWidth === 320. And at default text size there is **zero** horizontal overflow at 320/375/390/414/768/1024/1280/1920, verified by both document metrics and a full per-element bounding-box sweep. This defect is specific to text scaling.\n\nSuggested command: /impeccable adapt\n","status":"closed","priority":1,"issue_type":"task","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T23:33:16Z","created_by":"Angus Bezzina","updated_at":"2026-08-05T02:37:35Z","started_at":"2026-08-05T02:30:43Z","closed_at":"2026-08-05T02:37:35Z","close_reason":"Fixed; horizontal overflow now clean at all eight widths at both text sizes.\n\nFive causes, found by iterating the measurement rather than assuming the audit's list was complete:\n1. Four grids declared bare 1fr (= minmax(auto,1fr), floors at min-content): .entry-strip,\n .chapter-head, .steps, .surfaces — plus .native-grid and the single-column mobile overrides.\n All now minmax(0, …), matching .local-note which already did it correctly.\n2. .site-nav used a fixed height (70px / 64px), so it clipped its own contents at 200%\n (box 70 vs content 79). Now min-height.\n3. .site-nav and .nav-right could not wrap, so the brand + links + CTA row set a floor wider than\n the viewport. This was the largest single contributor and was NOT in the original finding —\n it only surfaced after the grids were fixed.\n4. The display headings could not break a long word ('documents.'), overflowing by 46px at 390.\n Added overflow-wrap: break-word to the h1/h2/h3/p rule.\n5. The install command is one unbreakable token. .code now has min-width: 0 and .code \u003e span\n scrolls internally, per DESIGN.md's Wide-Sheet Rule ('oversized tables/code scroll inside it').\n .footer-links wraps.\n\nMEASURED, documentElement scrollWidth - clientWidth at 200% text:\n BEFORE AFTER\n 320 (untested) 221px clean\n 375 — clean\n 390 184px clean\n 414 — clean\n 768 143px clean\n 1024 — clean\n 1280 clean clean (nav box 89 vs content 89 — no longer clipped)\n 1920 — clean\n100% text remains clean at every width. WCAG 1.4.4 and 1.4.10 now pass on the landing.\n\nVerified visually at 1280 light, 1280 dark and 390 — no regression; the mobile masthead still uses\nits two-tier h1. The four-line ragged mobile masthead is pre-existing and tracked separately in .17.\n\n97/97 unit test files pass; svelte-check 0 errors.","labels":["a11y","design","hosted","web"],"dependencies":[{"issue_id":"attn-n01r.23","depends_on_id":"attn-n01r","type":"parent-child","created_at":"2026-08-04T18:33:16Z","created_by":"Angus Bezzina","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-n01r.22","title":"Dark-mode visitors see ~1.2s of paper-white before INK applies (no prefers-color-scheme fallback)","description":"A dark-mode visitor gets a full paper-white page before INK arrives. Measured frame-by-frame on the production build at 300 kbps with a dark-preference UA:\n\n t = 14 ms rgb(18,18,18) UA dark canvas, no CSS yet\n t = 2645 ms rgb(226,223,215) CSS painted — PAPER, the wrong theme\n t = 3842 ms rgb(9,13,19) JS finally stamped .dark\n\nThat is roughly 1.2 seconds of full-page wrong-theme paint. On a faster connection it is shorter but still present; it is not a race that sometimes goes the right way, it is structural.\n\nMechanism, confirmed:\n- The built page has four blocking `\u003clink rel=\"stylesheet\"\u003e` in `\u003chead\u003e`, so CSS paints the PAPER ground before any script runs.\n- No stylesheet carries a `@media (prefers-color-scheme: dark)` fallback. Verified: `grep -n \"prefers-color-scheme\" web/src/tokens.css web/src/hosted/chrome.css web/src/hosted/landing/landing.css` returns nothing. The INK theme is driven entirely by the `.dark` class / `data-theme` attribute.\n- That class is only stamped when the deferred `type=\"module\"` bundle runs `initTheme()` (web/hosted/landing.ts:13, web/src/hosted/theme.svelte.ts).\n\nThe native app solves exactly this with an inline pre-paint script (web/index.html:7-31), whose own comment names the goal: keeping \"a dark-mode user from seeing a frame of paper white on every launch\". The hosted CSP forbids inline scripts (theme.svelte.ts:3-6 says so explicitly), and nothing replaced the mechanism when the constraint changed.\n\nNote the inconsistency this creates: `web/hosted/index.html` already ships `\u003cmeta name=\"theme-color\"\u003e` with a `prefers-color-scheme: dark` variant, so the browser chrome flips correctly while the page underneath does not.\n\nFix options, roughly in order of preference:\n1. Add a `@media (prefers-color-scheme: dark)` block that applies the INK token values when no explicit `data-theme` is set. Pure CSS, no script, correct at first paint, and the JS toggle continues to override it. This is the smallest correct fix.\n2. Serve a CSP nonce for a tiny pre-paint inline script, matching the native app's approach.\n3. Have the edge worker stamp `data-theme` from the `Sec-CH-Prefers-Color-Scheme` client hint.\n\nOption 1 needs care: the tokens must respond to \"no explicit preference + OS dark\" without breaking the explicit light override. Test all four combinations — OS light/dark crossed with stored preference light/dark/absent.\n\nApplies to every hosted entry, not just the landing: /app and /review load the same token stack.\n\nSuggested command: /impeccable polish\n","status":"closed","priority":1,"issue_type":"task","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T23:31:12Z","created_by":"Angus Bezzina","updated_at":"2026-08-05T02:16:44Z","closed_at":"2026-08-05T02:16:44Z","close_reason":"Fixed and verified by frame capture.\n\nMECHANISM: added a pre-paint inline theme stamp to every hosted entry, admitted by CSP source hash.\n- src/lib/hosted/theme-preflight.ts — the script (one copy) + its sha256, with the rationale.\n- vite.browser.config.ts injectThemePreflight() — stamps it head-prepend at build AND serve, so dev\n matches production. One source, three entries, no drift.\n- src/lib/hosted/csp.ts — script-src now carries 'sha256-dlo6xal0...'. A hash admits exactly those\n bytes; strictly narrower than a nonce and unusable by injected markup.\n- scripts/theme-preflight-hash.mjs — regenerates the hash.\n- csp.test.ts — recomputes the hash from the script and fails on drift, plus asserts script-src never\n gains 'unsafe-inline' or a nonce. Without this guard a drifted hash silently blocks the script and\n the flash returns with no error anywhere.\n\nMEASURED, same method as the finding (300 kbps, dark-preference UA, CDP screencast):\n BEFORE t= 14ms UA canvas · t=2645ms rgb(226,223,215) PAPER — wrong theme · t=3842ms rgb(9,13,19) INK\n =\u003e ~1.2 s of full-page paper-white\n AFTER t= 16ms UA canvas · t=3321ms rgb(9,13,19) INK\n =\u003e zero wrong-theme frames; first painted frame is already INK (verified visually, not just\n by pixel probe)\n\nWHY NOT THE CSS FALLBACK: a @media (prefers-color-scheme: dark) block would duplicate ~90 lines of INK\ntokens — guaranteed to drift — and cannot see localStorage, so a visitor on a light OS who explicitly\nchose dark would still flash. The inline stamp covers both.\n\nApplies to all three hosted entries (/, /app, /review), which was the point: they share the token stack.\n\n97/97 unit tests pass; svelte-check 0 errors.","labels":["design","hosted","web"],"dependencies":[{"issue_id":"attn-n01r.22","depends_on_id":"attn-n01r","type":"parent-child","created_at":"2026-08-04T18:31:11Z","created_by":"Angus Bezzina","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-n01r.17","title":"Mobile navigation disappears entirely below 680px with no replacement","description":"At \u003c=680px the entire navigation disappears with no replacement:\n\n web/src/hosted/landing/landing.css:580-582\n .nav-link { display: none; }\n\nThere is no hamburger, no sheet, no bottom bar. At 390px the nav is: brand, theme toggle, primary CTA. \"How it works\", \"Native app\", \"Your desk\" and \"GitHub\" become unreachable, and the visitor gets roughly 6,100px of scroll with zero navigation.\n\nThe two controls that remain are both undersized and both pinned top-right — the hardest zone to reach one-handed on a 6.1\" phone:\n theme toggle 32.8 x 32.8 (chrome.css:144-154)\n primary CTA 126.6 x 40.6 (landing.css:588-591) — 3.4px under the 44px minimum\n\nCompounding, at 390px the masthead breaks into four ragged lines because Hero.svelte:34 hard-codes the split with `\u003cspan\u003e` elements, leaving \"desk\" orphaned on a short line while lines 3-4 drop to 0.72em mid-thought (landing.css:602-604). And `text-wrap: balance` applied globally to every `p` (landing.css:67-72) leaves the six-line lede with a ragged short tail exactly where attention is thinnest.\n\nFix:\n- Ship a mobile nav. A bottom-anchored action bar is the better answer than a hamburger here, because it also moves the primary CTA into thumb reach.\n- Raise the remaining touch targets to 44px (tracked in the sibling touch-target issue in this epic; that fix and this one must land together).\n- Replace the hard `\u003cspan\u003e` break with `text-wrap: balance` and let the two type tiers break on a container query rather than a hard-coded split.\n\nSuggested command: /impeccable adapt\n","status":"open","priority":1,"issue_type":"task","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T23:30:36Z","created_by":"Angus Bezzina","updated_at":"2026-08-04T23:30:36Z","labels":["design","hosted","landing","web"],"dependencies":[{"issue_id":"attn-n01r.17","depends_on_id":"attn-n01r","type":"parent-child","created_at":"2026-08-04T18:30:36Z","created_by":"Angus Bezzina","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-n01r.16","title":"Home page asserts E2E encryption four times but never demonstrates it; no threat model or security link","description":"End-to-end encryption is the product's entire reason to exist, and the page asserts it four times in prose without ever showing it once.\n\nAssertions: Hero.svelte:37-38, Hero.svelte:78, HowItWorks.svelte:52-53, HowItWorks.svelte:77-80.\n\nWhat is missing: there is no threat model, no \"what the relay actually sees\" visual, no link to the crypto, no security page. Nav.svelte:15 and Footer.svelte:9 both point at a bare repo root. A skeptical reader — which is the entire target market for a local-first tool — finds nothing to evaluate, so the claim reads as marketing.\n\nCompounding it, the asset carrying the emotional climax (\"A room exists only after you press Share.\", HowItWorks.svelte:65-73) is a screenshot of a modal over a dimmed grey document — a desaturated slab dropped into a warm paper page. It also has a stray blue OS focus ring baked into it on \"Advanced options\": a non-attn accent colour shipped in a marketing asset.\n\nPRODUCT.md:42 asks for the guarantee to be \"quietly evident in the UI... without turning into a security-theater badge wall\". The page has achieved the second half and skipped the first.\n\nFix direction:\n- Replace the dimmed-modal capture with a purpose-built two-panel visual: the document on the left, the ciphertext the relay actually stores on the right, with the `#key=` fragment marked \"never sent\". One demonstrated fact beats four assertions.\n- Add a /security or /how-it-works link in nav and footer pointing at a real threat model.\n- Then delete two of the four prose assertions — the visual does that work.\n\nSuggested command: /impeccable shape\n","status":"open","priority":1,"issue_type":"task","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T23:30:35Z","created_by":"Angus Bezzina","updated_at":"2026-08-04T23:30:35Z","labels":["design","hosted","landing","web"],"dependencies":[{"issue_id":"attn-n01r.16","depends_on_id":"attn-n01r","type":"parent-child","created_at":"2026-08-04T18:30:35Z","created_by":"Angus Bezzina","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-n01r.13","title":"Hero screenshot is cropped through its own text on both edges (object-fit: cover)","description":"The hero screenshot is the page's only evidence and its intended peak, and it is visibly cropped through its own content on both edges. Verified by reading the rendered capture, not just the CSS.\n\nAt 1440px the left edge clips the file tree mid-word — the visible strings are \"-cap-...\", \"er files\", \"lan.md\". The right edge slices the review cards mid-sentence (\"Can we open this to the whole waitlist, not\" — the rest is gone), truncates their state badges to \"CO\" and \"S\", and cuts off the window frame so the rounded chrome never closes on the right.\n\nRoot cause, single and shared with the CLS defect below:\n\n web/src/hosted/landing/landing.css:172-178\n .window img { width:100%; height:100%; object-fit: cover; object-position: center top; }\n\n`cover` fills a box narrower than the source aspect by discarding the sides. The box is narrower because `web/src/hosted/landing/ResponsiveScreenshot.svelte:26-27` hard-codes `width=\"1920\" height=\"1440\"` (ratio 1.333) for every caller, while the hero actually renders at 542x529 (ratio 1.025) — measured on the production build.\n\nSo there are two symptoms from one cause:\n1. **The crop** (the serious one): a precision tool whose flagship screenshot is mis-cropped refutes its own claim in the same frame.\n2. **The CLS reservation**: the browser reserves a 4:3 box and paints a 1:1 one, on the only `loading=\"eager\"` image on the page. The other two screenshots are fine — share renders 678x508 (1.335) and editor 631x473 (1.334), both matching their declared ratio.\n\nFix: either switch `.window img` to `object-fit: contain` with the frame sized to the asset's true aspect, or crop the source deliberately at authoring time so the composition is intentional at every breakpoint. Either way, pass real intrinsic dimensions through ResponsiveScreenshot as props instead of hard-coding 1920x1440.\n\nCheck whether the documented landing perf gate (referenced in the Hero.svelte:56-58 comment as asserting both the CLS box and the AVIF payload) is actually catching the mismatch — if it is not, that gate has a hole.\n\nAlso in this asset: the hero shows only human \"Reviewer\" suggestion cards and no agent, which is the visual half of the positioning gap filed separately in this epic. Reshooting for the crop is the moment to fix that too.\n\nPositive findings worth preserving: all 3 images carry alt text and width/height, the hero is correctly eager with fetchpriority=high, the other two are lazy, and AVIF-with-PNG-fallback plus a proper sizes attribute is already in place.\n\nSuggested command: /impeccable polish","status":"open","priority":1,"issue_type":"task","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T23:28:25Z","created_by":"Angus Bezzina","updated_at":"2026-08-04T23:29:32Z","labels":["design","hosted","landing","web"],"dependencies":[{"issue_id":"attn-n01r.13","depends_on_id":"attn-n01r","type":"parent-child","created_at":"2026-08-04T18:28:25Z","created_by":"Angus Bezzina","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-n01r.12","title":"Home page chrome misses 44x44 touch targets in 8 controls; only .button carries a size floor","description":"Eight interactive controls fall under the 44x44 CSS px target at mobile widths (320 and 390 — measured identical at both). WCAG 2.5.8 Target Size (Minimum) is 24x24 AA; 2.5.5 Enhanced is 44x44. Several of these also fail the 24px AA floor on one axis.\n\nMeasured on the production build, clean page (no detector injection):\n\n 36.8 x 18 footer a \"GitHub\" \u003c- 41% of target height\n 32.0 x 18 footer a \"Issues\" \u003c- 41%\n 66.5 x 18 footer a \"Contributing\" \u003c- 41%\n 40.0 x 23.4 button.code-copy \"Copy\" (brew) \u003c- 53%\n 40.0 x 23.4 button.code-copy \"Copy\" (npx) \u003c- 53%\n 32.8 x 32.8 button.icon-button \"Toggle theme\" \u003c- 75%, both axes\n 80.4 x 29 a.brand \"attn\"\n 126.6 x 40.6 a.button.primary \"New workspace\" \u003c- 3.4px short on height only\n\nAt 768 and 1280 the four `.nav-link` items also miss (20px tall).\n\nThe diagnosis is clean and the fix is systemic: `.button` at web/src/hosted/chrome.css:107 already sets `min-height: 46px`, and every control that uses `.button` passes. Every miss above is a control that does NOT use the class — nav links, the brand, the icon button, code-copy, footer links.\n\nSo this is not eight separate bugs. It is one missing rule: hit-target sizing lives on `.button` only, and the rest of the page's chrome was never brought under it. Extend a minimum-target rule to interactive chrome generally (padding or a min-height/min-width floor on `a`/`button` in nav, footer, and code blocks), rather than patching each site.\n\nRelated: the two `.code-copy` buttons are also covered by the icon-replacement issue in this epic — whichever lands second must not undo the target sizing.\n\nSuggested command: /impeccable adapt\n","status":"closed","priority":1,"issue_type":"task","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T23:28:24Z","created_by":"Angus Bezzina","updated_at":"2026-08-05T02:40:29Z","started_at":"2026-08-05T02:37:53Z","closed_at":"2026-08-05T02:40:29Z","close_reason":"Fixed. Zero controls now fall under the WCAG 2.2 AA floor at any breakpoint.\n\nThe diagnosis in the finding was right and the fix was systemic: .button already carried\nmin-height: 46px and every control using it passed; every miss was a control outside that class.\nGiven each one a hit-target floor rather than patching sizes individually:\n\n chrome.css .icon-button min-width/min-height: 44px (was 32.8 x 32.8)\n landing.css .nav-link inline-flex + min-height 44 (was 20px tall)\n landing.css .brand min-height: 44px (was 29px tall)\n landing.css .code-copy min 44 x 44 (was 40 x 23.4)\n landing.css .footer-links a inline-flex + min-height 44 (was 18px tall)\n landing.css .site-nav .button min-height 40px -\u003e 44px (mobile override had dropped the CTA\n below .button's own floor at exactly\n the width where it matters most)\n\nMEASURED, all interactive elements, per breakpoint:\n under 24x24 (WCAG 2.2 AA) under 44x44\n BEFORE @390 code-copy 40x23.4, footer 8 controls\n links 37x18 / 32x18 / 67x18\n AFTER @320/390 NONE GitHub 37x44, Issues 32x44\n @768/1280 NONE + GitHub 41x44\n\nWCAG 2.5.8 Target Size (Minimum), AA — now passes everywhere.\n\nDELIBERATELY NOT FORCED TO 44 WIDE: the two short footer text links. They are full 44px tall and\nfail only on width because the words are short; 2.5.8's 24px floor is met with room to spare, and a\nmin-width on inline text links would open odd gaps in the row rather than improve acquisition. The\n44px figure is 2.5.5 AAA / platform guidance, not the AA bar.\n\nAlso folded in from the sibling a11y issue: .footer-links a now carries --ink and an underline. They\npreviously rendered byte-identical to the adjacent licence text with no underline, so nothing at rest\nidentified them as links (WCAG 1.4.1, the stricter form — not distinguished by colour OR any\nnon-colour cue).\n\nsvelte-check 0 errors; 97/97 unit test files pass.","labels":["design","hosted","landing","web"],"dependencies":[{"issue_id":"attn-n01r.12","depends_on_id":"attn-n01r","type":"parent-child","created_at":"2026-08-04T18:28:24Z","created_by":"Angus Bezzina","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-n01r.10","title":"Home page never mentions agents or AI, contradicting the product's stated positioning","description":"The words \"agent\" and \"AI\" appear ZERO times on the home page. Verified: `grep -rniE \"\\bagent|\\bAI\\b\" web/src/hosted/landing/ web/hosted/index.html` returns no matches.\n\nPRODUCT.md:23 states the positioning as: \"The reviewer for agent-authored docs: the one place where you and your agents review the same document together, human comments and AI suggestions in a single end-to-end-encrypted thread, over files that never leave your machine.\" PRODUCT.md:44 adds \"Human and agent are peers in one thread\" as a design principle.\n\nThe home page argues none of it. It argues \"private local markdown editor with sharing\" — a category with a dozen occupants and no reason to pick this one. The hero screenshot (Hero.svelte:60-80) shows a human reviewer's suggestion and no agent at all.\n\nThis is the highest-leverage issue on the page: the single thing that makes attn a new product rather than a better one is missing from its front door.\n\nScope for the fix:\n- The hero lede and headline should carry the agent-peer claim, not just \"no account, local by default\".\n- The hero screenshot should show an agent suggestion and a human comment in the same thread — that image IS the product argument, and the product already renders agents with a distinct violet hex chip (DESIGN.md §Tertiary), so the artifact exists.\n- HowItWorks currently narrates create -\u003e share -\u003e work anywhere. Somewhere in that spine the agent has to appear as a participant.\n\nDo not bolt on an \"AI-powered\" badge. PRODUCT.md's voice is \"it states, it doesn't sell\", and the anti-references explicitly reject the AI-startup register. The claim is that agents and humans are peers in one encrypted thread — that is a specific, demonstrable thing, not a marketing adjective.\n\nSuggested command: /impeccable shape (this is an IA/argument problem before it is a visual one).\n","status":"open","priority":1,"issue_type":"task","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T23:28:22Z","created_by":"Angus Bezzina","updated_at":"2026-08-04T23:28:22Z","labels":["design","hosted","landing","web"],"dependencies":[{"issue_id":"attn-n01r.10","depends_on_id":"attn-n01r","type":"parent-child","created_at":"2026-08-04T18:28:22Z","created_by":"Angus Bezzina","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-n01r.6","title":"New workspace should invite content instead of pre-seeding an empty untitled.md","description":"One-click \"New workspace\" materialises a real but zero-byte `untitled.md` (web/src/hosted/app/workspace-service.ts:94, :312-318 — `body: new Uint8Array(0)`) and opens it. The sidebar then lists a file that holds nothing, which reads as a phantom entry rather than a starting point, and the desk copy advertises it up front (\"One click · starts with untitled.md\", web/src/hosted/app/DeskHome.svelte).\n\nChange the first-run shape: a new workspace should invite content — prompt to upload or import files, or offer an explicit \"start writing\" that creates the file at the moment the user commits to it — rather than pre-seeding an empty document as if work already existed.\n\nPoints to settle while implementing:\n- Whether `untitled.md` is created lazily on first keystroke, or not until the user chooses \"new document\".\n- The desk copy on the New workspace card has to change with it; shipping the current wording against a new behaviour just moves the lie.\n- `web/src/hosted/app/workspace-service.test.ts:64-74` asserts the current one-click behaviour (\"one-click create opens an empty untitled.md and lists it\") and will need to move with the decision.\n- Keep the zero-network, no-dialog property of the current create path (types.ts:249) — the fix is about what the workspace contains, not about adding a round trip.\n\n---\nADDITIONAL EVIDENCE (desk assessment, 2026-08-04):\n\n\"New workspace\" does not always create a workspace, and the label promises that it does. Clicking `[data-action=\"new-workspace\"]` three times in a row returned **the same workspace id every time**; the desk count stayed at 1.\n\nThis is deliberate — AppShell.svelte:67-87 (attn-cjn) reuses the most recent empty, untouched \"Untitled\" workspace so a bookmarked `/app#new` or a back-button revisit does not grow the desk. The mechanism is reasonable. The problem is that the affordance reads \"One click · starts with untitled.md / + New workspace\" (DeskHome.svelte:139-140) and gives **no feedback that it reused rather than created**. Testing required renaming each workspace before the next click would mint a fresh one.\n\nSo the copy is wrong twice over: it promises a file that is empty, and it promises a creation that may not happen. Whatever replaces the pre-seeded `untitled.md` has to tell the truth about both.\n\n---\nCORRECTION (desk audit, verified end-to-end):\n\n**The copy \"One click · starts with untitled.md\" is LITERALLY ACCURATE. Do not change it.**\nVerified: after clicking, the editor opens at /app/w/\u003cid\u003e/untitled.md, untitled.md IS listed in the\nfile rail, and the canvas shows the \"Start typing\" placeholder. The file exists. It is 0 bytes.\n\nAn earlier note in this issue said the copy was \"wrong twice over\". That was overstated — it is wrong\nabout creation-vs-reuse only. The \"phantom file\" impression comes from three separate defects:\n\n1. [P1] The button silently REOPENS an existing workspace. AppShell.svelte:71-88's #new idempotency\n guard (written for a bookmarked /app#new) also runs for the desk button, because\n AppShell.svelte:100-108 calls the same createAndOpen(). It scans for any workspace named 'Untitled'\n with assetCount 0, markdownCount \u003c= 1 and an empty body, and reuses it.\n MEASURED: clicking \"+ New workspace\" on an untouched Untitled workspace returned\n /app/w/XikIfzW835EqJETFvOKa0w/untitled.md — byte-identical to the previous click — and the desk row\n count stayed at 3 across a 4th click.\n FIX: either always create from the desk button (scope the guard to the #new URL intent only), or\n tell the truth — \"Reopening your empty draft\".\n\n2. [P1] The workspace name never reflects content. MEASURED: created a workspace, typed \"Alpha notes\",\n waited for autosave, returned to /app. The row still reads \"Untitled | 1 file | Just now | Local\n only\". Created a second, typed \"Beta draft\" — same. Three workspaces render as three pixel-identical\n rows. real-service.ts:571-578 / workspace-service.ts:550-570 toSummary() takes workspace.name\n verbatim; nothing derives a title.\n FIX: fall back to the first \"# heading\" of the open file, else the open path. Also surface\n sizeLabel — it is computed at workspace-service.ts:558 and 566 and never shown, so \"0 B\" would make\n the empty state self-evident.\n\n3. [P2] At \u003c=900px even the distinguishing metadata is deleted (app-shell.css:2359-2365), so each row\n reduces to \"Untitled | Local only | Rename | Delete\" on exactly the device where the name is least\n likely to be unique.\n\nDefect 2 is arguably the real fix for the reported complaint: a workspace that shows its own first\nheading stops looking like a phantom, whatever untitled.md contains.","status":"open","priority":1,"issue_type":"task","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T23:22:33Z","created_by":"Angus Bezzina","updated_at":"2026-08-04T23:45:48Z","labels":["design","hosted","web"],"dependencies":[{"issue_id":"attn-n01r.6","depends_on_id":"attn-n01r","type":"parent-child","created_at":"2026-08-04T18:22:33Z","created_by":"Angus Bezzina","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-n01r.1","title":"Desk: join panel has 64px above and 0px below it; the list label owns no margin of its own","description":"When the \"Join a review\" invite panel is expanded, the gap between it and the \"RECENTLY ON THIS DEVICE\" label below is **exactly 0px** — measured in both themes, at both 1440 and 390, in both the empty and populated states. `.join-panel` bottom = 671.09px; `.folio-label` top = 671.09px, identical to the pixel. The panel's 1px bottom border runs through the top of the label's box, so the label reads as if it belongs inside the panel. On mobile it is worse: the panel's 10px corner radius curves behind the label.\n\n**The real cause is a margin-collapse dependency, not a missing margin.**\n\n app-shell.css:2726 .join-panel { margin: 14px 0 0; } \u003c- top margin only, no bottom\n app-shell.css:154-159 .folio-label { /* no margin at all */ }\n app-shell.css:124 .quick-actions { margin: 2.5rem 0 4rem; }\n\nIn the closed state `.folio-label` has no margin of its own — its entire top spacing is borrowed from its previous sibling, `.quick-actions`, whose 4rem bottom margin does the work. When the panel opens it becomes the new preceding sibling, adjacent-sibling margin collapse resolves `max(64px, 14px) = 64px` **above** the panel (measured: quick-actions bottom 479.7 -\u003e join-panel top 543.7 = 64px), and the label below is left with nothing.\n\nNet asymmetry: 64px above the panel, 0px below it.\n\n**Fix it at the class, not the instance.** `.folio-label` is load-bearing spacing that owns none of its own, so the same bug will recur the next time anything is inserted between the action grid and the list — an import error, a degraded banner, a future filter bar.\n\n- Give `.folio-label` its own `margin-block-start` (2.5rem) at app-shell.css:154 so it is spacing-independent of whatever precedes it.\n- Give `.join-panel` a symmetric `margin-block` (1.5rem) at app-shell.css:2726.\n- Do NOT patch this by adding only a `margin-bottom` to `.join-panel` — that fixes this instance and leaves the class of bug in place.\n\nWhile in this file, reconcile the panel's off-system styling: it uses raw px throughout (14px, 16px, 8px, 10px, 240px — app-shell.css:2725-2783) and a 10px border-radius, inside a desk that is rem-based and 0-radius everywhere else. It is the newest code on the page and the most off-system. Tracked in more detail in the desk design-system-drift issue in this epic.\n\nAlso on mobile, from the same panel: `.join-row { display:flex; gap:8px; flex-wrap:wrap }` (app-shell.css:2741-2745) with `min-width:240px` on the input (app-shell.css:2749) leaves \"Join\" beside the field and orphans \"Cancel\" onto its own line, left-aligned and visually detached from its sibling.","status":"closed","priority":1,"issue_type":"task","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T23:22:28Z","created_by":"Angus Bezzina","updated_at":"2026-08-05T02:26:07Z","closed_at":"2026-08-05T02:26:07Z","close_reason":"Fixed at the class, not the instance.\n\n- app-shell.css .folio-label now owns margin-block-start: 2.5rem. It previously had no margin and\n borrowed its entire top gap from .quick-actions' 4rem bottom margin via adjacent-sibling collapse,\n which is why inserting the join panel between them left it with nothing.\n- app-shell.css .join-panel margin changed from '14px 0 0' (top only, px) to symmetric\n margin-block: 1.5rem in rem, matching the rest of the desk.\n\nMEASURED, same probe as the finding (1280x900, panel expanded):\n BEFORE panel bottom 671.09 / label top 671.09 -\u003e gap 0.00px\n AFTER panel bottom 648.50 / label top 688.50 -\u003e gap 40.00px\nVerified visually in light and dark at 1280 and 390.\n\nThe class of bug is gone too: anything inserted between the action grid and the list (import error,\ndegraded banner, a future filter bar) can no longer strip the label's spacing.","labels":["design","hosted","web"],"dependencies":[{"issue_id":"attn-n01r.1","depends_on_id":"attn-n01r","type":"parent-child","created_at":"2026-08-04T18:22:28Z","created_by":"Angus Bezzina","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-n01r","title":"Hosted web app design remediation: home page, desk, and editor chrome","description":"Remediate the hosted web app's two public surfaces — the marketing home page (`/`) and the desk (`/app`) — plus the editor chrome that hangs off the desk, against DESIGN.md and PRODUCT.md.\n\nTwo sources feed this epic:\n\n1. **Impeccable critique + audit** run against the home page and the desk on 2026-08-04. Critique = Nielsen heuristics, design specificity, cognitive load, persona walkthroughs. Audit = accessibility, performance, theming, responsive, implementation integrity. Both surfaces were assessed by isolated sub-agents (design review kept unanchored from the deterministic detector) with browser evidence captured from the production build.\n\n2. **Direct user-reported defects** from a hands-on pass over the desk, the native-install section, the workspace sidebar, and the mobile editor.\n\nRegister is **product**, platform **web**. The home page is a **Persuade** surface; the desk and editor are **Operate** surfaces and are judged as tools a power user opens fifty times a week — scanability, consistency, and native expectations outrank expression there.\n\nThe guiding principle stays \"warm surface, sharp behavior\": editorial paper/ink identity you can see, Linear/Raycast-grade precision you can feel.\n\nChildren carry the detail. Each is independently shippable and cites file:line.\n","design":"Sequence: user-reported defects (concrete, already diagnosed) land first; critique/audit findings follow, grouped by surface. Home page = Persuade, desk/editor = Operate. No redesign — refinement on the incumbent identity per DESIGN.md. Web uses npm. Svelte 5 runes; runes outside components need .svelte.ts. Verify every visual fix in a real browser (Playwright + system Chrome; the claude-in-chrome extension is not connected in this environment) at 390 and 1280, light and dark.","status":"open","priority":1,"issue_type":"epic","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T23:21:28Z","created_by":"Angus Bezzina","updated_at":"2026-08-04T23:21:28Z","labels":["design","hosted","web"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-i38g","title":"Relay CI: anti-enumeration rate-limit tests hang in GitHub Actions but pass locally","description":"'Relay Tests (Ubuntu)' has failed on every recent main run since at least 2026-07-27, on the same two cases in relay/test/integration/rate-limit.test.ts, both in the 'anti-enumeration (GET /devices probes)' block:\n - 'existing rooms do NOT count against the anti-enum bucket' — times out at 30000ms\n - 'probes from a DIFFERENT IP have their own bucket' — times out at 15000ms\nThe other 472 relay tests pass.\n\nNOT slowness of the test logic: run locally on macOS these two complete in 355ms and 330ms — roughly a 100x margin under their timeouts. Something specific to the Actions environment makes them hang rather than run slowly. Both are the cases that loop 30+ sequential SELF.fetch calls carrying a synthetic CF-Connecting-IP, so the prime suspects are per-IP bucket handling or DO scheduling under the CI runtime rather than the assertions themselves.\n\nIntermittent but heavily biased to failure: it passed once on 2026-08-04 (run 30934857104) and failed on the two runs either side, including an explicit re-run of the same commit.\n\nDELIBERATELY NOT FIXED BY RAISING THE TIMEOUTS. These cases cover anti-enumeration protection; if the limiter can genuinely stall a request path under some conditions, that is a production concern and a longer timeout would hide it. Diagnose the hang before touching the budget.","acceptance_criteria":"Root cause of the CI-only hang identified (not merely masked by a larger timeout); Relay Tests green on main across consecutive runs.","notes":"Unrelated to attn-rd3j — that branch touches zero files under relay/, and the failure predates it by a week. Evidence: main runs 30289171414, 30223014775, 30209797623, 30185741342, 30184477328.","status":"open","priority":1,"issue_type":"bug","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T18:28:24Z","created_by":"Angus Bezzina","updated_at":"2026-08-04T18:28:24Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-rd3j.11","title":"Share dialog fits without scrolling; sidebar empty-state padding; settings moves to header","description":"Follow-up UI issues from review of the attn-rd3j branch: (1) the Share dialog's configure phase overflows and must be scrolled — the file list should flex into available height so header/filter/footer are always visible; (2) the sidebar filter empty state ('Nothing in open folders') sits flush against its container and needs padding to align with the rest of the sidebar; (3) the floating bottom-right settings cog moves into the header to the right of the share control, and the settings surface must expose light/dark switching.","acceptance_criteria":"Share dialog shows all sections without a body scrollbar at default window size (only the file list scrolls); sidebar empty state is visually aligned with sibling rows; settings opens from a header control beside share and offers appearance switching; the floating cog is gone.","status":"closed","priority":1,"issue_type":"task","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T17:38:49Z","created_by":"Angus Bezzina","updated_at":"2026-08-04T18:08:30Z","closed_at":"2026-08-04T18:08:30Z","close_reason":"Share dialog fits without body scrolling (file list capped against the window); sidebar empty state inset to match siblings; settings moved into the header with appearance switching; code/table vertical rhythm restored.","dependencies":[{"issue_id":"attn-rd3j.11","depends_on_id":"attn-rd3j","type":"parent-child","created_at":"2026-08-04T12:38:48Z","created_by":"Angus Bezzina","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-rd3j.10","title":"Syntax highlighting: widen language coverage + auto-detect untagged fences","description":"Architecture answer: all highlighting is client-side shiki via prosemirror-highlight (web/src/lib/prosemirror/code-highlight.ts); src/markdown.rs renders no HTML and comrak's syntect feature is deliberately disabled (Cargo.toml:63-65) — client-side highlighting is the intended design and IS standard practice; the gaps are: (1) untagged fences get zero decorations — lazyParser :46-48 early-returns when language is empty, no auto-detection; (2) hardcoded 20-language allowlist (:21-25) silently drops everything else via try/catch (:56-61); (3) language extraction (:76-80) takes the first params token with no alias map (sh/shell/zsh, yml, jsx/tsx, js/ts longhand, plaintext variants). Work: (a) inspect real-world docs to confirm the untagged-vs-unknown mix; (b) alias map + lazy-load any shiki bundled language on demand (dynamic import) replacing the fixed allowlist while keeping the initial bundle lean; (c) heuristic content-based auto-detection for untagged fences (shebangs, JSON shape, YAML key: value, Rust fn/let/impl, Python def/import, SQL SELECT, shell prompts, HTML tags) with a confident-match-or-plaintext policy, computed once per block content and cached; (d) show detected language in the data-params label, subtly distinguished from author-declared; (e) verify dual-theme output (vitesse-light/github-dark via syntax.css) and consider theme choice against the paper/ink identity in DESIGN.md.","acceptance_criteria":"Tagged fences in any shiki-bundled language highlight; common aliases resolve; clearly-identifiable untagged blocks (shell, json, yaml, rust, python, ts) auto-highlight; ambiguous blocks remain plaintext with no console errors; light and dark themes both correct; no significant initial-bundle regression.","notes":"ANSWER TO THE QUESTION: client-side shiki IS the intended design here and is standard practice — src/markdown.rs renders no HTML and comrak's syntect feature is deliberately off, so there was never a Rust highlighter to fix. The real gaps were: (1) untagged fences early-returned with zero decorations; (2) a hardcoded 20-language allowlist silently dropped everything else.\n\nFixes: languages now resolve against shiki's full 332-language bundle with on-demand lazy loading (20 common ones preloaded so the first paint is instant), plus an alias map and an explicit skip list for plaintext spellings and NodeView-owned languages. New detect-language.ts does conservative content-based detection for untagged fences — confident-match-or-nothing (score floor plus a strict lead over the runner-up), JSON verified by actually parsing, shebangs honored, line-shape languages requiring a majority of lines to match. Detected languages are labelled distinctly from author-declared ones (lowercase italic). 9 unit tests.\n\nVERIFIED live: untagged shell and JSON blocks auto-highlight and are labelled bash/json; an untagged prose block correctly stays plain; 134 shiki spans rendered; both themes correct.","status":"closed","priority":1,"issue_type":"bug","assignee":"Angus Bezzina","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T14:58:29Z","created_by":"Angus Bezzina","updated_at":"2026-08-04T16:51:16Z","started_at":"2026-08-04T15:11:40Z","closed_at":"2026-08-04T16:51:16Z","close_reason":"Closed","dependencies":[{"issue_id":"attn-rd3j.10","depends_on_id":"attn-rd3j","type":"parent-child","created_at":"2026-08-04T09:58:29Z","created_by":"Angus Bezzina","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-rd3j.6","title":"Light/dark mode: system-follow with manual override in settings","description":"Infrastructure partly exists: tokens.css :root Paper light + .dark Ink block (:137) with full OKLCH token coverage; native theme module web/src/lib/theme.ts supports only light|dark with no prefers-color-scheme and no persistence; hosted theme.svelte.ts already does localStorage attn-theme + matchMedia fallback; Rust seeds data-theme from a --dark CLI flag only (src/main.rs:366-367, :412, build_page_html :2064-2074 string-replace). Work: (a) three-state preference light|dark|system persisted via localStorage + IPC so the daemon seeds data-theme at window creation (no flash of wrong theme); (b) system state tracks matchMedia('(prefers-color-scheme: dark)') changes live; (c) settings surface: add an Appearance section (Light/Dark/System segmented control) — extend ResidentSettings.svelte into a general settings panel or introduce a proper settings dialog (shared with the typeset preset issue); (d) command palette 'Switch theme (Paper / Ink)' (App.svelte:2538-2543) updates the persisted preference; (e) unify native + hosted theme modules where practical; (f) audit .dark coverage across review rail, dialogs, dropdowns; code blocks already dual-theme via syntax.css shiki vars.","acceptance_criteria":"Fresh install follows macOS appearance including live OS appearance switches; manual Light/Dark override persists across daemon restarts with no flash-of-wrong-theme; all major surfaces (document, sidebar, dialogs, review rail, code blocks) legible in both themes.","notes":"Three-state preference (light|dark|system) persisted by a new src/prefs.rs (prefs.json beside projects.json; projects::storage_dir made pub). Rust stamps the stored preference into the page; an inline resolver in web/index.html turns 'system' into a concrete light/dark and sets the .dark class BEFORE the module bundle loads, so there is no flash of the wrong theme. web/src/lib/theme.ts owns it from there and tracks matchMedia changes while the preference is system. Settings dialog (new, reachable from the command palette) has the Paper/Ink/System control. --dark still overrides for one launch.\n\nVERIFIED live: fresh profile resolved system-\u003edark on a dark-mode Mac with .dark applied; switching to Paper flipped instantly and persisted across a daemon restart (prefs.json {theme: light}). Fixed a real defect found in review: the selected segment used bg-background over bg-muted/30 and was invisible in dark mode — now a primary tint plus ring.","status":"closed","priority":1,"issue_type":"feature","assignee":"Angus Bezzina","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T14:58:26Z","created_by":"Angus Bezzina","updated_at":"2026-08-04T16:51:15Z","started_at":"2026-08-04T15:28:08Z","closed_at":"2026-08-04T16:51:15Z","close_reason":"Closed","dependencies":[{"issue_id":"attn-rd3j.6","depends_on_id":"attn-rd3j","type":"parent-child","created_at":"2026-08-04T09:58:25Z","created_by":"Angus Bezzina","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-rd3j.4","title":"Restore visible bullet markers in rendered lists","description":"Bullets are faked with an absolutely-positioned ::before dot (base.css:169-179: 5px circle, var(--primary), opacity .75, left:-1.05em top:0.72em) because Tailwind v4 preflight resets list-style; there is no ul{list-style:disc} fallback, so when the pseudo-dot fails (positioning context, :not(:has(...)) chain) bullets vanish entirely — matching the reported screenshot (blank space next to list text). Replace with real list semantics: list-style + styled ::marker (color var(--primary)) preserving the rust-dot identity, correct padding, nested-depth markers (disc/circle/square), task-list items keep their checkbox treatment (base.css:203). Verify in the ProseMirror editor surface and review/read-only surfaces, in both themes.","acceptance_criteria":"Every non-task ul li shows a visible marker at all nesting depths in editor and review surfaces; ol unchanged; task lists unaffected; marker color matches DESIGN.md identity in light and dark.","notes":"Replaced the faked absolutely-positioned ::before dot with real list semantics: list-style disc/circle/square by depth plus a ::marker tinted with var(--primary). A marker cannot detach from its line or vanish when the item's positioning context changes, which is how the old pseudo-dot failed. VERIFIED live: computed listStyleType=disc, display=list-item; screenshots show markers at all three depths in both themes, ordered numerals and task checkboxes unaffected.","status":"closed","priority":1,"issue_type":"bug","assignee":"Angus Bezzina","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T14:57:45Z","created_by":"Angus Bezzina","updated_at":"2026-08-04T16:51:14Z","started_at":"2026-08-04T14:58:48Z","closed_at":"2026-08-04T16:51:14Z","close_reason":"Closed","dependencies":[{"issue_id":"attn-rd3j.4","depends_on_id":"attn-rd3j.3","type":"blocks","created_at":"2026-08-04T09:58:29Z","created_by":"Angus Bezzina","metadata":"{}"},{"issue_id":"attn-rd3j.4","depends_on_id":"attn-rd3j","type":"parent-child","created_at":"2026-08-04T09:57:44Z","created_by":"Angus Bezzina","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-rd3j.3","title":"Scope document typography styles; stop base.css leaking into chrome","description":"web/styles/base.css applies document typography via unscoped element selectors inside @layer base: fake bullets (ul\u003eli::before :169-179), checkbox restyle (li:has(\u003e input[type=checkbox]) absolute-positioned custom checkmark :202-235), code chip (:128-156), tables (:238-261). These leak into all chrome, dialogs and dropdowns. Existing band-aids: .attn-chrome opt-out rules (base.css:380-393) applied piecemeal (App.svelte:2961, ShareChip, ReviewerStatusChip, ReviewMargin, ReviewFileSidebar, hosted frame) plus hosted/app/app-shell.css:382. Invert the model: scope all document typography under a document-root class (e.g. .attn-doc on the editor/viewer article, complementing existing .ProseMirror scoping in prosemirror.css), then delete the .attn-chrome escape-hatch rules. Foundational for the bullets fix and the Share-dialog phantom-checkmark fix.","acceptance_criteria":"No document typography rule applies outside the rendered document container; document surface visually unchanged; .attn-chrome opt-out rules removed; dialogs/dropdowns render with plain shadcn styling.","notes":"Document typography in web/styles/base.css is now scoped to .attn-doc (carried by Editor's .prosemirror-mount and Viewer's article). Deleted the .attn-chrome opt-out ruleset and stripped the class from all 6 consumers, plus the duplicate suppression in hosted/app/app-shell.css. Verified no other raw-HTML document renderer exists ({@html} appears only in Viewer.svelte).","status":"closed","priority":1,"issue_type":"task","assignee":"Angus Bezzina","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T14:57:44Z","created_by":"Angus Bezzina","updated_at":"2026-08-04T16:51:14Z","started_at":"2026-08-04T14:58:47Z","closed_at":"2026-08-04T16:51:14Z","close_reason":"Closed","dependencies":[{"issue_id":"attn-rd3j.3","depends_on_id":"attn-rd3j","type":"parent-child","created_at":"2026-08-04T09:57:43Z","created_by":"Angus Bezzina","metadata":"{}"}],"dependency_count":0,"dependent_count":2,"comment_count":0} +{"_type":"issue","id":"attn-rd3j.2","title":"Confirm exiting review mode when switching files","description":"Review state lives in web/src/lib/review/store.svelte.ts (panelOpen :121, railMode :363-366, currentFileId; setCurrentFile :1028-1039 only refuses when no renderable snapshot — no confirmation). Native-side switching in web/src/App.svelte has no guard at all: openPath :1725-1771, switchTab :1775-1789, closeTab :1791-1805 — so switching files mid-review half-tears-down review UI and looks glitchy. Fix: when a review session is active and the user initiates a file switch (sidebar click, tab switch/close, command palette open), intercept with a shadcn AlertDialog-style confirmation: 'Exit review?' with the reviewed filename. Confirm -\u003e cleanly tear down review state (close rail, clear currentFileId/currentSnapshotId, expandedResolvedThreadId, detach margins) BEFORE the document swaps, then switch. Cancel -\u003e no-op. Enter confirms, Esc cancels. Also fix the underlying glitch: ensure ReviewMargin/rail unmount before navigation.","acceptance_criteria":"Switching files during an active review always prompts; cancel keeps the reviewed file intact; confirm exits review then switches with no visual glitches; switching while not in review is unchanged.","notes":"Guard added in App.svelte: openPath/switchTab/closeTab are now thin guarded wrappers over openPathNow/switchTabNow/closeTabNow. navigationLeavesReview() asks whether the TARGET leaves the review; on confirm the teardown (clearRoomSelection) runs and awaits tick() BEFORE navigating, so the rail unmounts ahead of the document swap (that ordering is the glitch fix). ReviewExitConfirm.svelte is the dialog.\n\nRoot cause found during verification: ownerRoomForPath resolves a path to a room through the share ROOT, which for a multi-file share is the whole project — so every file 'belonged' to the room and the guard never fired. Added roomPublishesPath() (web/src/lib/review/room-ui.ts, 8 unit tests) which answers membership from PUBLISHED SNAPSHOTS instead, handling the relative-vs-absolute path mismatch (snapshots carry 'alpha.md', nav carries an absolute path).\n\nAlso fixed the same root-vs-fileset confusion in the owner auto-follow $effect, which was re-selecting the room immediately after an explicit exit and putting review chrome on files that were never shared — the effect now matches its own docstring ('selecting an unshared file turns off collaboration chrome'). Root-based matching is retained while a freshly minted room has not yet published anything, so sharing is unaffected.\n\nVERIFIED live end-to-end: share alpha.md -\u003e click beta.md -\u003e prompt, doc stays alpha, room active -\u003e Cancel -\u003e still alpha, still active -\u003e click beta -\u003e Confirm -\u003e doc beta, review chrome fully gone -\u003e click alpha -\u003e review re-activates.","status":"closed","priority":1,"issue_type":"bug","assignee":"Angus Bezzina","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T14:57:43Z","created_by":"Angus Bezzina","updated_at":"2026-08-04T16:51:13Z","started_at":"2026-08-04T15:23:52Z","closed_at":"2026-08-04T16:51:13Z","close_reason":"Closed","dependencies":[{"issue_id":"attn-rd3j.2","depends_on_id":"attn-rd3j","type":"parent-child","created_at":"2026-08-04T09:57:42Z","created_by":"Angus Bezzina","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-rd3j.1","title":"Hidden titlebar: full-width drag strip + double-click to zoom","description":"Window is created with titlebar_transparent + title_hidden + fullsize_content_view (src/main.rs:481-486); decorations are NOT removed so traffic lights remain over transparent content. Drag is IPC-based: web/src/lib/ipc.ts:93-97 dragWindow() sends {type:'drag_window'} -\u003e src/ipc.rs:362-363 -\u003e UserEvent::DragWindow -\u003e src/main.rs:1176-1178 window.drag_window(). Problems: (1) double-click on the top strip does not zoom/maximize; (2) drag only works where a component happens to attach onmousedown={dragWindow} (App.svelte:2963 header, 46px spacers at App.svelte:3107/3119, Sidebar.svelte:318, PathBreadcrumb.svelte:99) leaving dead zones, and heights are inconsistent (header h-11 = 44px vs 46px spacers). Fix: (a) add a zoom_window IPC message (ipc.ts, types.ts, src/ipc.rs, src/watcher.rs UserEvent, src/main.rs handler) that toggles zoom — tao set_maximized(!is_maximized()), or NSWindow performZoom: for true macOS zoom semantics; (b) attach a dblclick handler alongside dragWindow on every drag surface, honoring the same interactive-element exclusion (closest a/button/input/select/textarea); (c) standardize a consistent 44px full-width drag strip in every layout mode (sidebar, minimal, editor_only, review) so the invisible bar drags anywhere.","acceptance_criteria":"Dragging anywhere on the top 44px strip (except over interactive controls) moves the window in all layouts; double-clicking it toggles zoom/restore; no visible bar introduced; existing sidebar/breadcrumb drag still works.","notes":"Added a zoom_window IPC (web/src/lib/ipc.ts zoomWindow -\u003e src/ipc.rs IpcMessage::ZoomWindow -\u003e UserEvent::ZoomWindow -\u003e window.set_maximized(!is_maximized())). ondblclick={zoomWindow} attached alongside every existing dragWindow surface: App.svelte native header + both diag-mode strips, Sidebar drag strip, PathBreadcrumb (currentTarget-only). Interactive-element exclusion (a/button/input/select/textarea) shared with dragWindow. VERIFIED live: dblclick on header 960x720 -\u003e 1512x887 -\u003e back to 960x720; dblclick on a header button does not zoom; sidebar strip zooms too.","status":"closed","priority":1,"issue_type":"bug","assignee":"Angus Bezzina","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T14:57:42Z","created_by":"Angus Bezzina","updated_at":"2026-08-04T16:51:13Z","started_at":"2026-08-04T15:13:59Z","closed_at":"2026-08-04T16:51:13Z","close_reason":"Closed","dependencies":[{"issue_id":"attn-rd3j.1","depends_on_id":"attn-rd3j","type":"parent-child","created_at":"2026-08-04T09:57:41Z","created_by":"Angus Bezzina","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-rd3j","title":"Review-surface polish: window chrome, review-exit guard, theming, typography, code blocks","description":"Umbrella epic for the angus/attn-review-tweaks branch. Nine user-reported issues in the native attn app: (1) hidden titlebar lost drag + double-click-zoom behavior; (2) switching files while review mode is active is glitchy and needs an exit-review confirmation; (3) bullet markers are invisible in rendered lists; (4) add light/dark mode with system-follow + manual override; (5) phantom checkmark in the top-left of the Share dialog + scrollbars not wrapped in the shadcn ScrollArea (dialog body, project-switcher dropdown); (6) preferred typeset presets in settings (shadcn typeset model); (7) stray horizontal scroll rail under markdown tables; (8) code-block copy button scrolls away with overflowing content instead of staying pinned top-right; (9) syntax highlighting renders most code blocks as plain text. Root-cause investigation notes live in the child issues. Cross-cutting root cause: web/styles/base.css document typography rules are unscoped globals that leak into app chrome (drives 3 and 5).","acceptance_criteria":"All child issues closed; task dev manual pass confirms each of the nine reported behaviors fixed; scripts/test-e2e.sh and task test:review pass; release binary-size gate unaffected (frontend-only changes except titlebar IPC).","notes":"Branch: angus/attn-review-tweaks. Design register per PRODUCT.md/DESIGN.md: warm paper/ink/rust identity, power-tool precision.","status":"closed","priority":1,"issue_type":"epic","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T14:55:51Z","created_by":"Angus Bezzina","updated_at":"2026-08-04T16:51:29Z","closed_at":"2026-08-04T16:51:29Z","close_reason":"All 10 child issues implemented and verified live in the app. Full regression pass: 97 web test files green, 1207 Rust tests green, E2E and review-E2E identical to the pre-change baseline (their failures are pre-existing), release binary 32.09/40 MiB.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-vltn","title":"Restore reviewer comment toolbar for native text selections","description":"Reviewers can visibly highlight text in Chrome but the Comment toolbar does not appear. Bridge native DOM selections in the read-only ProseMirror view into a valid editor range and add regression coverage.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-26T03:05:00Z","created_by":"James Lal","updated_at":"2026-07-26T03:08:44Z","started_at":"2026-07-26T03:05:06Z","closed_at":"2026-07-26T03:08:44Z","close_reason":"Promoted native DOM selections into the read-only reviewer ProseMirror state so the Comment toolbar anchors correctly; added unit and hosted-browser regression coverage. Full 94-file web suite, Svelte check, and browser production build pass.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-9u5z","title":"WebRTC-primary replaceable relay fallback for presence","description":"Chrome peers can complete relay signaling but fail STUN-only ICE/DTLS (direct_peer_connection_failed). Keep cursor/view presence on lossy WebRTC when all live peer presence channels are open; when direct coverage is incomplete, send the same signed v3 presence envelope through a separate coalescing memory-only relay queue. Never persist presence or charge durable room event history.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-26T02:15:23Z","created_by":"James Lal","updated_at":"2026-07-26T02:20:07Z","started_at":"2026-07-26T02:15:28Z","closed_at":"2026-07-26T02:20:07Z","close_reason":"Implemented WebRTC-primary cursor/view delivery with coalesced memory-only v3 replaceable relay fallback for incomplete peer coverage; full web tests, typecheck, and browser production build pass.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-1dc6","title":"Reset browser owner WebSocket backoff after reconnect","description":"Hosted owners can remain visibly Offline longer after repeated transient socket drops because BrowserWsClient never resets exponential backoff after an authenticated hello. Reset on valid hello, surface close code/reason in the existing inbound diagnostic ring, and cover reconnect recovery.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-26T01:21:53Z","created_by":"James Lal","updated_at":"2026-07-26T01:35:03Z","started_at":"2026-07-26T01:21:58Z","closed_at":"2026-07-26T01:35:03Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-6dtd","title":"Keep room CORS preflights outside the application request rate limit","description":"Chrome multi-reviewer joins intermittently fail GET /v3/rooms/:roomId/devices because OPTIONS is counted by the 600/min edge limiter; its early 429 lacks policy-gated CORS and is surfaced as a CORS failure. Route read-only room OPTIONS to RoomDO before application request accounting, retain stored allowBrowser and origin gates, and add regression coverage.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-26T01:18:26Z","created_by":"James Lal","updated_at":"2026-07-26T01:35:02Z","started_at":"2026-07-26T01:18:30Z","closed_at":"2026-07-26T01:35:02Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-8qcu","title":"Stabilize owner participant jumps and viewport sampling","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-26T00:01:49Z","created_by":"James Lal","updated_at":"2026-07-26T01:35:01Z","started_at":"2026-07-26T00:01:56Z","closed_at":"2026-07-26T01:35:01Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-hmke","title":"Align participant jumps to the remote viewport top","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-25T23:46:25Z","created_by":"James Lal","updated_at":"2026-07-25T23:48:06Z","started_at":"2026-07-25T23:46:31Z","closed_at":"2026-07-25T23:48:06Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-e2do","title":"Keep second-reviewer WebRTC presence jumpable during join races","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-25T23:27:59Z","created_by":"James Lal","updated_at":"2026-07-25T23:32:48Z","started_at":"2026-07-25T23:28:03Z","closed_at":"2026-07-25T23:32:48Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-859n","title":"Prevent viewport presence labels clipping behind editor gutter","description":"Presence identity labels extend from an absolute margin flag into the document gutter, but ProseMirror overflow-x clips them at its left boundary. Permit marker overflow without layout impact and ensure the active label paints above adjacent decorations.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-25T23:03:34Z","created_by":"James Lal","updated_at":"2026-07-25T23:04:53Z","started_at":"2026-07-25T23:03:36Z","closed_at":"2026-07-25T23:04:53Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-p57j","title":"Eliminate remaining file-switch Svelte effect loop","description":"After commit 13d54e3 and staging deploy, prolonged switching between shared files still triggers Svelte effect_update_depth_exceeded. Reproduce against the hosted multi-file path, identify the remaining reactive cycle, fix the actual source, and add a stress regression.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-25T20:55:29Z","created_by":"James Lal","updated_at":"2026-07-25T21:06:35Z","started_at":"2026-07-25T20:55:36Z","closed_at":"2026-07-25T21:06:35Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-db2a","title":"Fix recursive Svelte effect when switching files","description":"Switching between shared files can trigger Svelte effect_update_depth_exceeded in the branches runtime chunk. Identify the reactive feedback loop, prevent recursive state writes, and add a regression test for repeated file switching.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-25T20:42:51Z","created_by":"James Lal","updated_at":"2026-07-25T20:51:32Z","started_at":"2026-07-25T20:50:13Z","closed_at":"2026-07-25T20:51:32Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-dary","title":"Route cursor and viewport presence over WebRTC only","description":"Stop sending ephemeral cursor/view/focused-file presence through relay envelopes or durable outboxes. Deliver presence only over direct WebRTC, retain relay for membership and SDP/ICE signaling, and expire stale peer markers safely.","status":"closed","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-25T20:19:19Z","created_by":"James Lal","updated_at":"2026-07-25T20:34:57Z","started_at":"2026-07-25T20:19:22Z","closed_at":"2026-07-25T20:34:57Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-xviq","title":"Make cursor/view presence replaceable relay state","description":"Cursor and viewport presence currently share the durable signal envelope path, so repeated presence updates consume the room-wide retained event cap. Add an authenticated ephemeral presence lane that preserves E2E ciphertext and live WebSocket delivery while retaining at most the latest value per device (or no durable history), without weakening durability for document collab steps or review events.","acceptance_criteria":"Cursor/view presence does not increment the durable room envelope count; active peers still receive presence over relay WebSockets; a newer presence update supersedes the prior value for the same device; document steps and review events retain current durable replay behavior; v3 cleartext routing classification is covered by device proof/AAD or equivalently tamper-protected; tests cover cap accounting, replacement, reconnect behavior, and legacy compatibility.","notes":"Approach: add an optional v3-only signalClass=presence routing header, bind it into the registered-device signature, and have RoomDO store only the latest encrypted presence record per author/device outside env:* and envelope-count accounting. Replay current replaceable presence once on subscribe; broadcast every fresh update live; keep unclassified signals/document steps and review events on the durable log. Enforce a small presence ciphertext ceiling and preserve v2 behavior. Files: relay/src/{schema,device-proof,room-do}.ts + relay integration/unit tests; web browser-ws/device-proof/browser-signaling/browser-session + tests; Rust review model/device-proof/signaling/inbound/manager + tests; relay spec/docs. Test plan: proof vectors and tamper rejection, cap accounting, same-device replacement, reconnect latest-only replay, durable-signal regression, browser/native envelope classification and signature interop.\nImplemented and validated: v3 cursor/view collab is classified as signed signalClass=presence in browser and native clients; RoomDO broadcasts fresh values, retains one 16 KiB-bounded latest record per registered device, excludes it from durable event/byte caps and Web Push, and replays latest state only for online devices. Browser inbound dispatch skips IndexedDB/volatile history for presence. Unclassified signals/events remain durable. Validation: relay focused 67/67 plus final presence 11/11; web 90 test files; Rust nextest 1201/1201; relay/web typechecks; browser production build and route-boundary check; Rust touched-file rustfmt. Independent review finding about browser local persistence fixed. V3 signalClass proof change requires coordinated client/relay rollout; V2 remains unchanged.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-25T02:56:13Z","created_by":"James Lal","updated_at":"2026-07-25T03:22:34Z","started_at":"2026-07-25T02:56:17Z","closed_at":"2026-07-25T03:22:34Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-fkf3","title":"Fix bidirectional participant jumps and add viewport markers","description":"Participant jumps currently fail to switch to the participant's file and do not land at the same document location. Make jumps work for owner and reviewers in both directions, preserve file/location identity across presence updates, and add a compact overlay indicator beside the block each remote participant is viewing without changing document width.","acceptance_criteria":"Clicking any online remote participant switches to their shared file when necessary and scrolls to their reported location; owner-to-reviewer and reviewer-to-owner both work; remote viewing positions render as compact non-reflowing block-adjacent markers; stale or missing locations fail safely; automated tests cover navigation and marker mapping.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-24T17:18:40Z","created_by":"James Lal","updated_at":"2026-07-25T02:27:40Z","started_at":"2026-07-24T17:18:43Z","closed_at":"2026-07-25T02:27:40Z","close_reason":"Fixed viewport markers so they are absolutely positioned in the left gutter without affecting paragraph/list geometry; corrected list-boundary anchoring; deduplicated cursor/view presence to stop the Svelte effect loop and relay envelope flood. Verified 90 web test files, svelte-check, production browser build, route boundaries, and headless Chromium geometry.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-9ek7","title":"Shell consumption: EditorShell + sidebar derive room/bindings/margin/counts from the projection only","description":"attn-whdh part 3. Remove the ownerState ?? joinState ?? reviewLogBindings ?? store fallback chains in EditorShell (reviewRoomActive, file latch, cursor-sink scoping, reviewCount, peer roster source) and the sidebar share badges' separate read path — all become projection reads. ownerState remains for lease/authoring affordances only (writable, editing, publish status), never for review-state hydration.","notes":"Core landed in attn-whdh commit 50dbe9e: projection feeds reviewStore.currentRoomId in every tab, so reviewRoomActive/reviewFollowerTab/file-latch/reviewCount reads are now consistent; watchReviewLog→openReviewProjection seam done; bindings drive margin scoping. REMAINING: audit/remove residual ownerState-fallback chains that are now redundant, and the sidebar 'shared' file-rail badges still read a separate path (a follower once showed no share icons) — fold into attn-73xq cleanup pass.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-23T04:30:13Z","created_by":"James Lal","updated_at":"2026-07-23T14:49:32Z","started_at":"2026-07-23T04:30:41Z","closed_at":"2026-07-23T14:49:32Z","close_reason":"Resolved by the core projection (commit 50dbe9e). The sidebar 'shared' badges + unread counts derive from reviewStore.snapshots + room role, which the projection now populates and stamps role='owner' in EVERY tab (adoptRoom) — same store the leader uses, so follower tabs light up identically (proven by the byte-identical two-tab convergence). The old watchReviewLog seam is fully replaced by openReviewProjection. Residual ownerState-fallback chains (reviewRoomActive etc.) now read correctly because the projection feeds reviewStoreRef.currentRoomId in every tab; simplifying them further is cosmetic, not a bug.","dependencies":[{"issue_id":"attn-9ek7","depends_on_id":"attn-kobw","type":"blocks","created_at":"2026-07-22T22:30:37Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-ij9y","title":"Leader symmetry: live session feeds ONLY the durable log, leader UI reads through the projection","description":"attn-whdh part 2. BrowserSession/authority stop writing reviewStore directly (store.applyEvent from the wire); inbound envelopes commit durably + ring the doorbell (already true) and the leader tab materializes its own store exclusively via the projection, exactly like followers. If the projection misses an event the leader sees the bug too — divergence impossible by construction. Watch ordering: optimistic local echoes (own comments) must still render immediately (either optimistic overlay reconciled by replay, or commit-then-doorbell fast path).","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-23T04:30:12Z","created_by":"James Lal","updated_at":"2026-07-23T05:15:29Z","started_at":"2026-07-23T04:30:40Z","closed_at":"2026-07-23T05:15:29Z","close_reason":"Closed","dependencies":[{"issue_id":"attn-ij9y","depends_on_id":"attn-kobw","type":"blocks","created_at":"2026-07-22T22:30:36Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-kobw","title":"Projection core: WorkspaceReviewProjection module (share record + log replay + doorbell, role-agnostic)","description":"attn-whdh part 1. Grow browser-review-log.ts into the single projection every tab uses: discovers the active share record (roomId, bindings, epoch) from storage, replays the durable event log through the verified pipeline, hydrates snapshots, refreshes on the review doorbell AND on share-record changes (room re-provisioning rotates currentRoomId at revision+1 — the projection must follow the rotation, which caused today's two-tab divergence). Exposes one typed state; no role branches.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-23T04:30:11Z","created_by":"James Lal","updated_at":"2026-07-23T05:15:46Z","started_at":"2026-07-23T04:30:40Z","closed_at":"2026-07-23T05:15:46Z","close_reason":"Closed","dependency_count":0,"dependent_count":2,"comment_count":0} +{"_type":"issue","id":"attn-whdh","title":"Single review projection: all tabs (leader included) materialize state from the durable log via one path","description":"Architectural consolidation motivated by a day of tab-divergence bugs (attn-dgya, attn-nezn, attn-90qq, attn-37f9, attn-lzee, and a fresh two-tab inconsistency after room re-provisioning). Disease: one durable source of truth (IndexedDB log + share records) but N role-dependent read paths — leader feeds reviewStore straight from the live wire/authority state while followers use ad-hoc mirrors (replay+doorbell, manifest-bindings fallback, hub presence, cursor tee), so any two tabs can disagree. Cure: (1) the wire only feeds the durable log + doorbell, never the store; (2) one WorkspaceReviewProjection module hydrates roomId/bindings/events/threads identically in every tab from storage; (3) sidebar/share state reads the same projection; (4) one generic ephemera bus for cursors/presence replaces per-type tees; (5) dev-mode cross-tab store-fingerprint drift assertion. Deletes the six bespoke bridges. Leader dogfooding the follower read path makes divergence impossible by construction.","status":"open","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-23T04:28:07Z","created_by":"James Lal","updated_at":"2026-07-23T04:28:07Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-hh9r","title":"Durable share outlives its 24h room: reviewer hard-fails ('Could not reach the review relay') instead of degrading; owner never re-provisions the room","description":"Diagnosed via live relay tail (staging): share link \u003e24h old resolves its bundle (GET /v3/shares/{id} → 200) but the room behind it was TTL-wiped (HARD_MAX_TTL_MS=24h; GET /v3/rooms/{id}/devices → 404). The reviewer session maps the 404 to a fatal device_register error → 'Could not reach the review relay' (wrong message, dead end) even though source:'share_snapshot' degradation exists in BrowserShareSession by design. Two fixes: (1) reviewer degrades to snapshot+mailbox review on room-gone (Owner offline · Review still available); (2) owner authority detects room expiry and re-provisions a fresh room under the same durable share (re-point bundle, epoch/revision bump) so links older than 24h regain the live layer. Also correct the error copy: relay reachable, room expired.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-23T03:15:22Z","created_by":"James Lal","updated_at":"2026-07-23T04:06:09Z","started_at":"2026-07-23T03:17:43Z","closed_at":"2026-07-23T04:06:09Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-d6ai","title":"Retry edit is a no-op after lease expiry: dead session short-circuits re-acquisition","description":"User report: 'Retry edit doesn't seem to work' in a tab showing 'Workspace authority lease expired'. Root cause: EditorShell.ensureOwnerSession returns the EXISTING session unconditionally — a tab that was leader when the lease expired keeps a dead session (writable=false) forever, so beginEditing (which would close the husk runtime and re-acquire) is never invoked. Secondary: workspace-service.beginOwnerRuntime returns a cached lease-lost runtime as-is; real-service then closes it and returns null, costing an extra retry round. Fix: ensureOwnerSession drops non-writable sessions and re-begins; beginOwnerRuntime closes+rebuilds cached runtimes whose leaseRole is no longer owner (or status error) so one call re-acquires.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-23T00:53:25Z","created_by":"James Lal","updated_at":"2026-07-23T00:57:51Z","started_at":"2026-07-23T00:53:43Z","closed_at":"2026-07-23T00:57:51Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-90qq","title":"Participant chips missing on follower tabs and on the /s/ reviewer page","description":"User report: connected session but no peer chips on either side. Leader tab works. Two gaps: (1) EditorShell roster feeds only from ownerState.authority.session.peers (leader-only) — follower tabs render an empty PeerStrip; (2) BrowserReviewApp never feeds reviewStore.peers from sessionState.peers and renders no PeerStrip at all. Fix: hub broadcasts presence snapshots (new 'presence' message kind, re-sent on hello) so followers carry the same roster; reviewer header renders PeerStrip fed from its session peers.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-22T20:29:16Z","created_by":"James Lal","updated_at":"2026-07-22T20:35:17Z","started_at":"2026-07-22T20:29:28Z","closed_at":"2026-07-22T20:35:17Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-37f9","title":"Remote cursor presence renders only in the leader tab — followers miss reviewer carets/highlights","description":"User report: reviewer caret/selection shows in the first (leader) owner tab, absent from follower tabs; comments sync fine. Cursors terminate at whichever wire they arrive on: relay-borne cursors reach only the leader session; hub-borne follower cursors never reach room peers. Fix: leader bridges presence — routeCollabDelivery tees relay cursor payloads onto the tab channel (followers dedupe by clientID), and the hub forwards follower cursor payloads through authority.sendCollab (normal WebRTC-opportunistic + relay-guaranteed policy).","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-22T19:34:14Z","created_by":"James Lal","updated_at":"2026-07-22T19:39:23Z","started_at":"2026-07-22T19:34:24Z","closed_at":"2026-07-22T19:39:23Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-c38z","title":"Late-joining external reviewer sees stale content and misses past comments (multi-tab owner)","description":"User report on staging: Safari reviewer joining via /s/ link sees old snapshot content (doc has diverged) and is missing the owner-authored comment thread from ~4h earlier (authored during the wedged-authority era); a fresh reviewer gets newer comments fine. Relay signal compaction spares durable events, so NOT compaction. Suspects: (1) owner outbox row from the wedged era never flushed to relay, (2) share republish (bundle revision/epoch) not advancing under multi-tab lease churn so late joiners resolve a stale snapshot, (3) reviewer live-collab unable to cross epochs. Repro plan: fresh workspace, two owner tabs, owner comment, type across tabs, then late-join reviewer and compare content + threads.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-22T19:09:09Z","created_by":"James Lal","updated_at":"2026-07-22T19:19:00Z","started_at":"2026-07-22T19:09:19Z","closed_at":"2026-07-22T19:19:00Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-lzee","title":"Follower owner tab jitters: full editor reset/remount on every leader commit instead of incremental updates","description":"User report: typing in one tab reflects in the other, but the non-typing tab visibly jumps/re-renders the whole ProseMirror doc. Read-mode followers never join the local collab hub (join gated on editDenied) so AppShell's follow-the-writer path calls editorRef.resetToMarkdown(body) on every leader autosave commit. Live-joined followers may also remount on hub generation rotation. Fix direction: read-mode followers join the hub too (read-only binding) so steps apply incrementally; keep resetToMarkdown only as the no-hub fallback and make it scroll/selection-preserving.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-22T18:27:55Z","created_by":"James Lal","updated_at":"2026-07-22T18:35:29Z","started_at":"2026-07-22T18:28:06Z","closed_at":"2026-07-22T18:35:29Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nezn","title":"Passive owner tabs should render review threads + honest share status (not Offline/no-comments)","description":"Follow-up to attn-dgya: passive tabs hydrate reviewStore (comment count shows) but the review margin gates on ownerState?.roomId (leader-only) so cards never render, and the ShareChip popover says 'Offline — changes will sync' + 'No one else is here' because connection/peers derive from the leader-owned live session. Design: all tabs render threads from the hydrated store; passive tab share status reads as following the live tab; review actions from a passive tab promote via the existing lease handoff.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-22T15:12:44Z","created_by":"James Lal","updated_at":"2026-07-22T15:38:00Z","closed_at":"2026-07-22T15:38:00Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-3wgd","title":"Hosted owner: refresh during multi-tab co-editing wedges authority in 'published source revision moved before promotion'","description":"Repro (user, staging pre-attn-dgya bundle but race exists on main): share active, two owner tabs, refresh one → banner 'Live review is paused. published source revision moved before promotion' + Reconnect. Analysis: reconcileStartupPublication (browser-owner-workspace-runtime.ts) stages/publishes from the discovered head while EditorShell's takeover flow concurrently commitText()s the converged collab doc, moving headRevisionId; commitPublication's consistency gate (browser-workspace-share.ts:691) then throws and transitionPublishedEpoch's catch pauses the authority (rollover_required) instead of restaging from the new head. Existing discard+republish recovery only covers a pre-existing stale pending publication, not heads moving mid-transition. Fix direction: bounded restage-retry on this specific StorageConflictError during startup reconcile (or sequence takeover commit before authority reconcile).","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-22T03:41:20Z","created_by":"James Lal","updated_at":"2026-07-22T03:55:20Z","started_at":"2026-07-22T03:41:35Z","closed_at":"2026-07-22T03:55:20Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-qfda","title":"Fix native relay startup and align native/mobile header","description":"The current native app can silently boot without an attached relay depending on how it was built, and its header has drifted from the hosted/mobile review grammar. Diagnose the shipped and local build paths, make relay targeting explicit and observable, and align the native header controls/layout with the shared cross-platform vocabulary while preserving desktop affordances.","acceptance_criteria":"Native release and development build paths select a documented relay target without silently falling back to the scaffold; relay selection is covered by focused tests. Native and mobile headers use consistent title/status/review/share affordances and responsive alignment. Relevant Rust, Svelte, and build quality gates pass.","notes":"Root cause confirmed: target/debug/attn had no relay configured and fell back to the scaffold; target/release was baked for production while the working environment is staging. Implemented channel-paired build defaults (relay + browser review origin), a staging release-profile build mode, debug fallback to staging, production fallback to production, build invalidation for both variables, and focused resolution tests. Unified native header with hosted/mobile grammar: one in-flow header, status + Share grouped right, inline ReviewBar + comments toggle, rail-local duplicate removed. Verified live with isolated native automation: bootstrap attached to relay-staging.attn.sh, room creation succeeded, and invite origin was staging.attn.sh. Validation: scripts/build.sh debug; cargo test --workspace --locked (627 passed, 0 failed, 5 ignored plus integration suites); npm build; focused native-header-parity test; live screenshots pre-share and active-sharing. Full npm test has one unrelated existing PeerStrip.test.ts failure from concurrent participant-color work; svelte-check likewise reports only those concurrent participant-color/API errors.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-22T00:17:51Z","created_by":"James Lal","updated_at":"2026-07-22T00:33:18Z","started_at":"2026-07-22T00:17:54Z","closed_at":"2026-07-22T00:33:18Z","close_reason":"Native staging relay and review-origin pairing fixed; header parity implemented and verified by full Rust tests, web build, source regression, and live native staging share QA.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-dgya","title":"Hosted owner: reopening same doc in a new tab doesn't show existing comments (edits sync fine)","description":"Repro: owner shares doc, comment exists; open same doc in another tab of the owner's browser — comment thread missing, but document edits reflect across tabs. Suggests review event log/threads not hydrated in second tab while CRDT collab channel works.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-22T00:10:48Z","created_by":"James Lal","updated_at":"2026-07-22T01:00:56Z","started_at":"2026-07-22T00:11:00Z","closed_at":"2026-07-22T01:00:56Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-3gdd","title":"Per-participant avatar colors: deterministic hue + persisted user-picked color + two-letter monograms, unified with carets","description":"Chips currently color by role (3 tokens); all reviewers identical blue, monograms collide, carets use a separate hardcoded selfColor system. Plan: (A) deterministic OKLCH hue from identity fingerprint as default, (B) optional user-picked color from curated swatches in NamePrompt, persisted in identity.json/localStorage and broadcast via Participant, (C) two-letter monograms. Resolve chips + carets + selections from the same color.","notes":"Implemented A+B+C. Core: web/src/lib/participant-color.ts (9-hue OKLCH palette registered in DESIGN.md, FNV-1a hash fallback, strict sanitize on both TS+Rust sides). Chips (PeerStrip via chipVisualFor), gutter chips + card accents (ReviewMargin/ReviewMarginCard via store.colorFor), carets (App.svelte native, BrowserReviewApp joiner; setSelfColor live-sync on CollabController). Two-letter monograms (monogramFor). B: DeviceIdentity.color + set_color IPC (review_set_color) + ReannounceIdentity ride-along on native; localStorage attn.profile.color hosted; Participant.color optional field announced on ParticipantJoined from all native+browser announce sites; NamePrompt swatch picker persists via userProfile.save(name,color). Remaining: hosted owner seams in real-service.ts/workspace-service.ts (getColor+ownerColor+selfColor) deferred until attn-dgya agent finishes editing those files; then visual verification.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-22T00:10:46Z","created_by":"James Lal","updated_at":"2026-07-22T01:01:08Z","started_at":"2026-07-22T00:11:01Z","closed_at":"2026-07-22T01:01:08Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-h2y3","title":"Staging publish broken: quota denial surfaced as 'Failed to fetch'; 24h byte allowance too small","description":"James's Share sheet showed 'Publishing did not finish (Failed to fetch)'. Two causes: (1) staging's 24h non-refundable per-source byte allowance (1 GiB = ~41 room creates at 25 MiB reserved each) was exhausted by a day of E2E verification from the same network; (2) a quota-denied CREATE never persists META.policy, so the generic response tagger can't mark it browser-eligible — no CORS headers on the 429, and the browser reports an opaque TypeError instead of the quota message. Fixed: staging allowance 1→4 GiB and rooms/source 32→64; the create path now tags pre-persist denials with the REQUESTED policy's allowBrowser so quota errors reach browsers as readable messages.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-21T18:44:26Z","created_by":"James Lal","updated_at":"2026-07-21T18:44:47Z","closed_at":"2026-07-21T18:44:47Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-tbtz","title":"Fragmentless /s/ reopen dead-ended ('Invalid invite link') — auto-remember share bindings","description":"James hit 'This review could not be reopened from this URL alone' in Safari. A fragmentless /s/ load falls back: history.state key stash (same-tab reload only) → push-notification binding (only written on push consent). Safari session restore / reopening the URL loses the stash → dead end. Fix: DurableShareBrowserSessionFacade auto-persists the share binding after every successful keyed join, under the share link's path id (same non-extractable key material the push-consent flow stores; best-effort, silent in private browsing). Fragmentless loads now reopen via RememberedPushShareSessionFacade. Follow-up: the remembered surface is read-degraded (durable snapshots + pending push events, no live room replay) — upgrading it to a full live session with the stored admission keys would restore threads too.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-21T17:37:06Z","created_by":"James Lal","updated_at":"2026-07-21T17:37:27Z","closed_at":"2026-07-21T17:37:27Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-u15y","title":"Owner desktop header matches the reviewer bar","description":"James: 'in review mode we have a different header bar than in owner mode — make owner match the review since I like it.' HostedDesktopWorkspaceFrame drops the PathBreadcrumb row and the floating ReviewBar dock for one reviewer-grammar header: attn · divider · active file name left; save chip + ReviewBar chips (new inline variant, normal flex flow) right; quiet Share icon pre-share (ShareChip owns it once a room exists). Card layer clearance reduced now that no floating bar overlaps the paper.","status":"closed","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-21T13:40:59Z","created_by":"James Lal","updated_at":"2026-07-21T13:41:19Z","closed_at":"2026-07-21T13:41:19Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-ys9n","title":"Escape / click-away dismisses click-revealed comment cards","description":"James: 'there should be some way to dismiss a comment when it's shown via the click.' Store gains openPanelForFocus (tracks that a click revealed hidden cards) and dismissFocusStep (collapse expanded resolved card → clear focus → re-hide click-revealed cards). Wired: highlight-mark clicks and gutter-chip expands go through openPanelForFocus; Escape (both shells, guarded against modals/composers/text fields) and clicks on plain document text step back; a deliberate dock/⌘J toggle takes ownership so click-away never hides it.","status":"closed","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-21T13:29:26Z","created_by":"James Lal","updated_at":"2026-07-21T13:29:46Z","closed_at":"2026-07-21T13:29:46Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-cf1u","title":"Floating margin cards: comments always visible, no modes, no panel sheet","description":"James rejected the opaque overlay (option A) after feel: 'it hides the comments and pops out over it'. New grammar: no Reading/Review modes — panelOpen defaults TRUE; the card layer is fully transparent and click-through (pointer-events none; cards re-enable), each card floats at its anchor height with its own surface/shadow, document visible between cards (Docs look). computeRailMode gains the no-room gate (roomless files stay rail-free — the default-open flag exposed the missing check as a phantom rail, caught by hosted-shells spec). Dock toggle still hides everything for a clean reading pass.","status":"closed","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-21T13:13:19Z","created_by":"James Lal","updated_at":"2026-07-21T13:13:40Z","closed_at":"2026-07-21T13:13:40Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-suhh","title":"Live reviewer highlights visible to all peers","description":"James: 'it would be quite interesting if you could see what the reviewers are highlighting.' RemoteCursor now carries an optional selection anchor; Editor forwards (head, anchor) on selection change; the remote-cursors plugin renders a translucent band in the peer's color (color-mix, theme-safe) alongside the existing caret+label. The hosted owner previously had NO cursor render sink at all (the runtime builds the controller before the shell mounts its view) — added CollabController.setRemoteCursorSink and an EditorShell effect that dispatches cursors into the view, scoped to the active file.","status":"closed","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-21T04:23:36Z","created_by":"James Lal","updated_at":"2026-07-21T04:25:41Z","closed_at":"2026-07-21T04:25:41Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-o8t9","title":"Rename doesn't propagate to comments (owner announce rejected by capability check)","description":"James's report: editing the display name (ShareChip → Edit) left every comment card on the old name. Three stacked causes on the hosted owner: (1) nothing called announceProfile after a rename (only the /s/ reviewer page did); (2) the owner session had no getDisplayName getter, so any announce would carry the 'Browser owner' fallback; (3) announceProfile always sent the REVIEWER capability set, and validParticipantAttestation requires the exact set for the registered device kind — every receiving client silently dropped an owner's re-announce as unauthorized (it existed only in the author's local echo and reverted on replay). Fixed: EditingSession.announceProfile plumbed through authority/runtime/service and called from the NamePrompt confirm; workspace-service provides getDisplayName from the live profile (lazy runes-module import); announceProfile now announces the capability set matching the principal.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-21T03:56:36Z","created_by":"James Lal","updated_at":"2026-07-21T03:58:52Z","closed_at":"2026-07-21T03:58:52Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-fnz0","title":"Owner review silence: persistent indicators for cross-file comments","description":"In a multi-file share, a comment on a non-active file left no durable trace on the owner: the 5.2s arrival toast was the only signal (dock badge counted current file only; hosted rooms never got role 'owner' so sidebar shared-badges and unread counts were role-gated to no-op). Fixed: dock badge counts room-wide active threads (store.roomActiveThreadCount); sidebar shows per-file open-thread counts; hosted sessions record room role via store.noteRoomRole (owner/reviewer) at session start.","notes":"Fixed in 1854724, staging 7c1706e7. Verified WebKit-reviewer/Chromium-owner: badge 1 + sidebar per-file count while owner edits another file.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-21T01:05:30Z","created_by":"James Lal","updated_at":"2026-07-21T01:05:57Z","closed_at":"2026-07-21T01:05:57Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-r7xi","title":"Hosted owner cannot author comments (no selection toolbar on EditorShell)","description":"The hosted owner shell mounts no comment-creation surface — SelectionToolbar/CommentComposer exist only on the /s/ reviewer page and the landing demo. The owner can reply/resolve but never start a thread. Native parity gap; surfaced while investigating James's 'not seeing comments when I make them' report (Edge owner / Safari reviewer).","notes":"Shipped in f730d15, staging a07ce3fe. Verified Chromium owner + WebKit reviewer: toolbar → composer → card + live delivery + refresh survival. Known nit: author label shows the genesis self-announce name ('Browser owner') when the share was created before the name prompt was answered.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-21T01:05:29Z","created_by":"James Lal","updated_at":"2026-07-21T01:19:42Z","closed_at":"2026-07-21T01:19:33Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-wac5","title":"Review band overlay: rail toggle never re-wraps the document","description":"James approved option A of planning/collab/review-band-stability.md: the aside keeps a permanent 48px marker gutter; Review mode renders the card column as an elevated overlay panel (.review-rail-panel, styles/base.css) anchored to the aside's right edge — slide-in with reduced-motion crossfade. Applied to both the hosted owner frame and the /s/ reviewer page. Verified on staging: document width constant (owner 965px, joiner 985px) across Reading↔Review round trips on both surfaces.","notes":"Shipped in 492fab5, deployed to staging (dabb1c5e). Verified: doc width constant across Reading↔Review round trips on owner (965px) and joiner (985px); markers↔cards swap; compose auto-enters Review; routes suite 87 passed.","status":"closed","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-21T00:34:03Z","created_by":"James Lal","updated_at":"2026-07-21T00:35:59Z","closed_at":"2026-07-21T00:35:58Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-5spd","title":"Share page loses selected file on refresh","description":"/s/ pages always reopened on one specific file: durable-share restore called setCurrentFile per restored snapshot (last won) and nothing persisted the selection. Fix: selection mirrored to ?f=\u003cfileId\u003e (hash/#key untouched), continuously enforced across the facade→live-session handoff (leaveRoom clears selection) until the user picks a file; installSnapshot no longer steals an existing selection; user picks flow through reviewStore.selectFileAsUser.","notes":"Fixed in dfaa386; verified live on staging: joiner ?f= refresh restore + user-override persistence.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-20T23:51:10Z","created_by":"James Lal","updated_at":"2026-07-20T23:51:47Z","closed_at":"2026-07-20T23:51:46Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-g0ra","title":"Owner blind to comments on non-active files in multi-file shares","description":"reviewStore.currentFileId latched once to the first published snapshot on the hosted owner shell and never followed the active file, so threads anchored to any other file of a multi-file share were invisible (no cards/markers/badge/toast) everywhere. Repro'd on staging with a 3-file share. Fix: EditorShell effect maps active workspace path → published binding fileId → store.setCurrentFile; arrival toasts now fire for other shared files with the filename and navigate on tap.","notes":"Fixed in dfaa386; verified live on staging (3-file share, two contexts): owner cross-file cards/markers/toast.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-20T23:51:01Z","created_by":"James Lal","updated_at":"2026-07-20T23:51:46Z","closed_at":"2026-07-20T23:51:45Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-x1k","title":"[bug] Hosted: reclaiming the pen after a tab handoff can render an empty document","description":"Found by the rewritten attn-7xl.7.10 gate (hosted-authoring 'duplicated tab takes the pen seamlessly'): tab A owns, tab B opens and seamlessly claims (works), B types (streams into A's read-only mirror correctly), then clicking back into A reclaims the pen (contenteditable true) — but A's ProseMirror renders EMPTY and stays empty past 20s. Likely the collab-generation rebind after ownership swap: the reclaimed editor binds before its seed loads (or the seed path misses the just-flushed head). Same defect family as the earlier takeover-commit data loss (gated on lastReleasedSeenAt). Repro: e2e/hosted-authoring.spec.ts:426 — currently the last red test in the routes suite.","notes":"2026-07-19 (later): join-first denied policy landed — a denied tab now prefers joining the holder's live hub (both tabs editable through ONE fenced authority; probe-verified deterministic at gate speed in 2-tab replication: markers converge both directions, hub regeneration handled). Ownership transfer is now the FALLBACK (no live join within 4s grace). Remaining failure (gate hosted-authoring:426 in its fresh-browser env): tab A ends FULLY EMPTY — even previously-converged text wiped. Top suspect with evidence: an editor remount re-initializing markdown from the CACHED boot-time collabSeed (probes show seedChars:0 persists in the cache after typing — the seed is captured at bind time and never refreshed), so any unplanned view remount re-applies an empty doc. Candidate fixes: (a) reorder the markdown fallback chain so remountSeed/bodyText outrank a cached collabSeed, (b) invalidate collabSeed+loadedCollabGenerationKey in handleEditorReady on view change, (c) re-fetch seed on every remount. Touches the attn-707/attn-w22-hardened rebind machinery — needs its own careful session with the __attnCollabDebug probe.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-20T00:45:14Z","created_by":"James Lal","updated_at":"2026-07-20T14:16:16Z","closed_at":"2026-07-20T14:16:16Z","close_reason":"Resolved: join-first removed the ownership churn that opened the bind window; the reload-empty tail was the gate racing the autosave debounce after automation dismissed the beforeunload guard (spec now waits for durable commit). hosted-authoring:426 green 5x + 2 full-suite runs. Residual documented: forced-transfer fallback (dead holder) still flips writable before bind — no live typist races it by construction; __attnCollabDebug probe ships for diagnosis if it ever bites.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-3yt","title":"Add guided demo cursor to homepage review","description":"Autoplay a clearly labeled, one-time demo cursor when the alternate homepage review enters view. Drive the real Codex reply UI, type and submit a nested reply, stop immediately on visitor input, support replay, mobile tap treatment, reduced motion, and production browser verification.","status":"closed","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-19T14:44:50Z","created_by":"James Lal","updated_at":"2026-07-19T15:03:11Z","started_at":"2026-07-19T14:46:23Z","closed_at":"2026-07-19T15:03:11Z","close_reason":"Implemented guided real-reply autoplay, takeover cancellation, replay, mobile tap mode, reduced-motion completion, and production E2E coverage.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-xnt","title":"Embed real interactive review demo on alternate homepage","description":"Replace the staged alternate-homepage mockup with an isolated real editor/review surface using production components. Seed agent comments and suggestions, let visitors select Markdown and add their own comment, and verify responsive/a11y/build behavior.","status":"closed","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-19T14:25:41Z","created_by":"James Lal","updated_at":"2026-07-19T14:39:27Z","started_at":"2026-07-19T14:25:49Z","closed_at":"2026-07-19T14:39:27Z","close_reason":"Alternate homepage now embeds a production-component review demo with seeded Claude/Codex feedback, real visitor commenting, responsive presentation, same-origin-only framing, and passing a11y/build/e2e gates.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-x6v","title":"Share from any tab: proxy share ops to the lease-holding tab over BroadcastChannel","description":"James: 'why do we need a primary tab share when they sync? Can't we just share from wherever?' The single-writer lease + one room authority per workspace is load-bearing (fenced storage writes; two authorities would fork the encrypted step log), but the ownership gate made that the user's problem. Add a share-ops BroadcastChannel: any tab's ShareSheet create/inspect/stop routes to the tab holding the EditingSession, which performs the operation and returns the WorkspaceShareView; the requesting tab shows the links. Owner-tab presence tracked by ping/pong so the claim-ownership gate only appears when no owning tab is alive.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-18T15:27:11Z","created_by":"James Lal","updated_at":"2026-07-18T15:30:25Z","started_at":"2026-07-18T15:27:13Z","closed_at":"2026-07-18T15:30:25Z","close_reason":"Share operations now route to the lease-holding tab over a share-ops BroadcastChannel (inspect 8s / stop 30s / ensure 90s timeouts): any tab's share sheet works — verified live with owner+follower tabs: the follower's sheet shows the live share links via proxied inspect (no gate), and stop + create-new-link both complete from the follower with publishing (PoW + uploads) executed in the owning tab. Owner-tab presence is probed by ping/pong at sheet-open, so the claim-ownership gate from 5808c3d now appears only when no owning tab is alive to answer — the single-authority invariant stays intact as an implementation detail instead of a user constraint.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-vt4","title":"Hosted feedback: widen the content measure for technical docs; mark shared workspaces in the project picker","description":"Page feedback on /app workspace view: (1) the 640px --content-measure feels narrow for technical review (mermaid, tables) — cut over the Shared-Column Rule to a two-tier measure: prose up to 720px, wide blocks (pre/table/mermaid/scroll-x) up to a new --content-measure-wide; (2) the sidebar project picker lists every desk workspace identically — shared workspaces should read distinctly (rust dot + Shared marker, matching the sharing vocabulary) so it's clear what is shared vs local-only.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-18T05:36:21Z","created_by":"James Lal","updated_at":"2026-07-18T05:40:54Z","started_at":"2026-07-18T05:36:40Z","closed_at":"2026-07-18T05:40:54Z","close_reason":"Both feedback items verified live. (1) Two-tier content measure (supersedes the single Shared-Column edge, history preserved in tokens.css): prose --content-measure 640→720px (~74ch serif, still inside the readable cap that motivated the 1100→640 tightening), new --content-measure-wide 1040px for pre/table/mermaid/prose-scroll-x in both the editor (prosemirror.css) and the native static article renderer (base.css); measured live: paragraphs 720px, table/pre compute min(100%,1040px). (2) Project picker marks shared workspaces: Sidebar gains sharedProjects, fed from workspace summaries' sharing state OR-ed with the live store for the just-shared current workspace; rows render a rust-dot '· Shared' marker (ShareChip vocabulary) right-aligned — screenshot shows shared rows marked with the local-only 'Code Rewrite and Delivery Plan' unmarked for contrast.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-4i2","title":"Docs-grade comment margin on web: one scroll context, no dead panel, active-card anchor alignment","description":"James: the web commenting sidebar 'is terrible and creates scroll zones' vs the decent native margin; reference is Google Docs. Gap analysis: (1) reviewer /s/ rail is overflow-y-auto — a second scroll zone that also breaks anchor alignment when scrolled; native forwards rail wheel events into the document scroller (WorkspaceEditorFrame onRailWheel) — adopt that; (2) hosted owner already uses WorkspaceEditorFrame but wheel-forward needs verification via the ScrollArea viewportRef; (3) empty margin renders as a bordered 320px column with centered filler text — a dead panel; Docs renders nothing: drop the border chrome from the margin (cards float on the paper) and show the 'select text to comment' hint only outside shared rooms; (4) layoutCards has no priority — Docs aligns the ACTIVE comment exactly to its anchor and pushes neighbors both directions; extend margin-layout with a priorityId pass + tests and feed it focusEventId.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-18T05:15:42Z","created_by":"James Lal","updated_at":"2026-07-18T05:26:21Z","started_at":"2026-07-18T05:15:43Z","closed_at":"2026-07-18T05:26:21Z","close_reason":"Verified live on the local loop, both surfaces. (1) Reviewer /s/ margin is no longer its own scroll zone: aside is overflow-hidden and wheels forward into the document scroller (measured: wheel over margin scrolls the doc 0→300), matching native's WorkspaceEditorFrame rail; the owner's forwarding was already wired and verified working (an earlier 'dead zone' reading was my own probe hitting scrollTop max). (2) Dead-panel emptiness gone: the margin has no border on any surface (WorkspaceEditorFrame aside + rail header + reviewer aside) and the in-room empty state renders nothing — the 'select any text' hint survives only for the local roomless panel. (3) Docs active-card rule: layoutCards gained priorityId — the focused card pins exactly at its anchor, earlier cards cascade UP (screenshot shows the Line-5 card lifted 8px above its own anchor to seat the focused Line-8 card), later cards restack below; 3 new layout tests, 16/16 green. ReviewMargin feeds it focusEventId, so native + hosted owner + reviewer all get it.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-53r","title":"Owner workspace startup blocks the editor on remote share reconciliation — dead room costs ~15s before first keystroke","description":"Measured on the local loop: opening a workspace whose durable share points at a dead relay room (404 devices, fenced publication) takes 16.6s to editor mount because BrowserOwnerWorkspaceRuntime.start() serializes reconcileActive() + discoverPublishedShare() + activatePublishedShare() (network, with retries) before the shell gets a writable state. The same shape would hit any owner on a flaky network. Fix: (1) editor readiness derives from the LOCAL lease — patch writable state immediately and run share discovery/activation as an enqueued async upgrade (the ensureShare path already activates a room mid-session, so the machinery exists); (2) terminal relay verdicts during resume (room 404/gone, fenced publication) must fail fast to local mode + the share-resume-failed banner instead of retrying.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-18T05:06:01Z","created_by":"James Lal","updated_at":"2026-07-18T05:08:19Z","started_at":"2026-07-18T05:06:03Z","closed_at":"2026-07-18T05:08:19Z","close_reason":"Disproven by measurement — filed on a conflated cold-load number. Warm reload of the SAME workspace with the same dead-room share: editor mounts in 1.06s (vs 16.6s cold), so startup does NOT block the editor on remote reconciliation; the 15s delta was Vite's dev-only on-demand compile of the editor graph. The share itself surfaced its failure via the attn-dkr banner on one load and then self-healed on the next (runtime republished to the restarted relay — designed hybrid renewal). Remaining localhost slowness is dev-server compile (first visit only) + 16-bit PoW on registrations (~seconds, intentional). No product change needed.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-238","title":"Reviewer surface redesign: workspace-style layout — file sidebar, collapsible comment rail, no layout jumps on comment","description":"User-reported issues with the /s/ reviewer UI: (1) the document jumps every time a comment is made; (2) the 320px comment rail is permanently open even when empty ('No comments yet' filler) and cannot be collapsed; (3) multi-file shares render as a top tab strip instead of a sidebar, so a shared folder feels like a different UX than a workspace. Redesign the reviewer shell so it feels like being in one of N workspaces that happens to be shared: left file sidebar (folder shares), collapsible right comment rail that stays hidden until threads exist, fixed-height header chrome that never reflows the document column.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-17T23:26:06Z","created_by":"James Lal","updated_at":"2026-07-17T23:44:36Z","started_at":"2026-07-17T23:26:27Z","closed_at":"2026-07-17T23:44:36Z","close_reason":"Verified on the local hosted share loop with Playwright: (1) jump fixed at root — the wrap-prone status row above the document (outbox chip + owner-offline text grew it 31→94px on every comment) is replaced by a fixed 43px header + ReviewerStatusChip popover; content position was pixel-identical through comment submit (p20Top 417→417→417). (2) Comment rail now unmounts when the current file has no threads (no 'No comments yet' filler), auto-opens once per room when feedback appears, reopens on highlight click, collapsible via header toggle + Cmd+J. (3) Folder shares render a workspace-style left file sidebar (Shared with you · N files · e2e encrypted) on desktop; compact strip retained on mobile. New pure model reviewer-status-model.ts w/ 8 tsx contract tests; 83 test files pass, svelte-check 0 errors, route-bundle gate holds. Follow-up filed: attn-keu (pre-existing mobile sheet card positioning).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-6r1","title":"Redesign hosted share experience: unify fragmented top-bar share controls","description":"Hosted owner desktop renders two duplicate Share buttons (ReviewBar share-pill + PathBreadcrumb share button, both wired to onShare), the SHARING THIS FILE dropdown rows render empty (ownerDisplayPath dropped when store.applySnapshot replaces the placeholder with the hydrated snapshot), and the share status surface is fragmented across five widgets (status pill, 2 share buttons, SharedFilesBadge, ConnectionBadge). Redesign into a single first-class share control that works on mobile and web.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-17T21:02:34Z","created_by":"James Lal","updated_at":"2026-07-17T21:23:36Z","started_at":"2026-07-17T21:02:45Z","closed_at":"2026-07-17T21:23:36Z","close_reason":"Unified ShareChip replaces share-pill + SharedFilesBadge + ConnectionBadge; duplicate hosted share button gated out; ownerDisplayPath preserved through snapshot hydration (empty SHARING THIS FILE rows fixed); save chip no longer duplicates share status; mobile masthead button is state-aware. Verified end-to-end on local hosted share loop (desktop + mobile, mint/join/stop) with screenshots; svelte-check, 82 unit test files, and route-bundle gate green.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-707","title":"Staging: owner edits after share never reach joiners (live replay + idle republish both silent)","description":"Repro on staging.attn.sh (web c96fab40 + relay ecd819c8, both from james/room-as-project-ux): create workspace, share, joiner opens link and connects 'Live · end-to-end encrypted'. Owner types more text: joiner never receives it — not live (25s+), not via idle republish (fresh joiner load 15s later still serves the share-time snapshot). Owner-side network shows room envelopes POSTing 201 but NO snapshot re-PUT after creation. The identical build against a local wrangler relay delivers live edits in \u003c1s and republishes snapshots periodically. Same symptom class as the original main-on-staging audit (joiner saw stale/empty doc, 'Owner offline'), so this predates the branch. Suspect: owner's live hosting/epoch-republish trigger doesn't fire against real Durable Objects (miniflare vs production DO behavior — alarms, WS hibernation, or the share watch channel).","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-17T02:37:51Z","created_by":"James Lal","updated_at":"2026-07-17T05:26:32Z","started_at":"2026-07-17T05:03:55Z","closed_at":"2026-07-17T05:26:32Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-w22","title":"Live session does not re-establish after owner tab reload (joiner degrades to relay snapshots)","description":"Observed repeatedly during attn-w5d verification on james/room-as-project-ux (local relay): with an active Direct live session, reloading the OWNER tab permanently drops liveEditingAvailable; the connected joiner falls back to 'Encrypted mailbox' and only sees idle-republished snapshots. Reloading the joiner too does not always restore Direct. Repro: share, join (Direct), reload owner tab -\u003e owner pill shows Shared · Encrypted relay indefinitely, ownerState.liveEditingAvailable stays false. Suspect the owner does not re-host/re-offer the live room for existing reviewer devices after reload.","notes":"Re-verified 2026-07-16 on deployed staging AFTER the attn-707 fix (web 9eb57372): reload the owner of an actively-shared workspace -\u003e owner pill lands on 'Live review paused' (liveEditingAvailable false; authority.start likely pauses) and live delivery to an already-open joiner never resumes; typing after reload does not reach the joiner. This is distinct from attn-707 (session released on workspace-detail refresh), which is fixed — the create-\u003eshare-\u003ejoin-\u003etype loop works on staging now. Also observed: keystrokes typed during the post-reload initialization window can be partially dropped (editor still read-only/follow-mode, then resetToMarkdown). Repro trace and instrumentation approach in the attn-707 history.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-17T00:25:35Z","created_by":"James Lal","updated_at":"2026-07-17T06:15:52Z","started_at":"2026-07-17T05:35:20Z","closed_at":"2026-07-17T06:15:52Z","close_reason":"Fixed by f03cece follow-ups (readiness wait, owner outbox persistence, epoch-binding preservation) — deployed staging web 6e539497. Verified on deployed staging: owner reload with a connected joiner recovers to Shared, the connected joiner receives post-reload typing live, fresh joiners converge on the full document. Known soft window: activation after reload takes ~5-15s on staging (DO latency + bounded readiness wait); typing during that window stays local until activation completes — tracked implicitly, not a data-loss path.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-2zf","title":"Typing '- ' on consecutive lines staircase-nests bullet list items","description":"In the hosted editor, typing '- item' Enter '- item' Enter '- item' produces each successive bullet nested one level deeper (list-inside-listitem staircase). The '- ' input rule re-fires inside an existing list item and indents. Expected: '- ' at the start of an empty list item is swallowed (stay same level). Repro on branch and on staging/main.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-16T23:00:08Z","created_by":"James Lal","updated_at":"2026-07-16T23:52:17Z","started_at":"2026-07-16T23:45:05Z","closed_at":"2026-07-16T23:52:17Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-f5b","title":"Editor content column has no max-width (~142ch lines at 1440px)","description":"ProseMirror content area is full-bleed (maxWidth: none): ~1100px, ~142 chars per line at 1440x900, in both owner editor and joiner review page (which also hugs the left edge, x=35). Linear/Notion-grade reading measure is ~65-75ch, centered. Applies to hosted app + /s/ page.","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-16T23:00:08Z","created_by":"James Lal","updated_at":"2026-07-16T23:44:52Z","closed_at":"2026-07-16T23:44:52Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-o0d","title":"Owner document shows no highlight for comment anchors","description":"Joiner sees a yellow highlight on the anchored text ('native viewer'); the owner document shows no highlight at all — the thread card's italic quote is the only clue to what the comment refers to. Owner needs the same anchor highlight + card\u003c-\u003eanchor hover linking.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-16T23:00:07Z","created_by":"James Lal","updated_at":"2026-07-17T00:04:17Z","started_at":"2026-07-16T23:57:31Z","closed_at":"2026-07-17T00:04:17Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-j2c","title":"Revoked joiner hangs on eternal 'Loading review…' after Stop sharing","description":"After owner clicks Stop sharing -\u003e Stop now, the open joiner tab reloads into 'Loading review…' and stays there indefinitely (12s+, no spinner, no error). Should show a terminal state: 'The owner ended this review.'","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-16T22:59:41Z","created_by":"James Lal","updated_at":"2026-07-16T23:57:29Z","started_at":"2026-07-16T23:52:28Z","closed_at":"2026-07-16T23:57:29Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-klf","title":"Deploy current durable-share relay protocol to staging","description":"Playwright localhost→staging verification on 2026-07-16 showed relay-staging.attn.sh still uses the pre-atomic snapshot upload revision contract (ATTN_SHARE_REVISION_INVALID on the first projection commit). The web client now refetches after uploads for rollout compatibility, but staging should be deployed from current main/room-as-project-ux relay code and its share publish/revoke smoke test rerun.","notes":"Deployed attn-relay-staging on 2026-07-16 as Cloudflare version 1372e37c-4d66-466f-bfef-f3b1c2b26ac2. Predeploy: relay typecheck passed; 31 files / 462 tests passed. Live health returned 200. Direct remote durable-share smoke proved snapshot upload remains dark at revision 0, atomic activation lands at revision 1, and revoke/room teardown succeed.","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-16T17:21:57Z","created_by":"James Lal","updated_at":"2026-07-16T21:48:25Z","started_at":"2026-07-16T21:42:28Z","closed_at":"2026-07-16T21:48:25Z","close_reason":"Current relay protocol deployed and verified on staging","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-eko","title":"Default localhost review links to staging","description":"Local hosted development must not mint production attn.sh share URLs. Make demo and real localhost sharing default to staging review/relay consistently, while preserving explicit overrides and local-relay E2E configuration.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-16T17:05:04Z","created_by":"James Lal","updated_at":"2026-07-16T17:25:12Z","started_at":"2026-07-16T17:06:35Z","closed_at":"2026-07-16T17:25:12Z","close_reason":"Localhost defaults to staging relay/link, local override stays local, first-click relay rollout compatibility and Playwright E2E verified","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-80d","title":"Remove share friction and fix localhost resume publishing","description":"Remove the pre-share protected-storage confirmation so review links can be created immediately. Reproduce and fix localhost shares getting stuck on the interrupted/resume publishing state.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-16T16:36:21Z","created_by":"James Lal","updated_at":"2026-07-16T17:01:21Z","started_at":"2026-07-16T16:36:31Z","closed_at":"2026-07-16T17:01:21Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-2vz","title":"Keep inter-tab live editing after a workspace is shared","description":"Publishing or reopening an active review share tears down the local multi-tab collaboration hub. A second same-origin tab then receives durable storage updates but remains in the legacy read-only follower banner, and typing during the handoff can stay local instead of converging back to the owner tab.","acceptance_criteria":"Two same-origin tabs editing the same workspace remain live and bidirectional before and after creating or reopening a review share. The active room authority remains the single ProseMirror authority; local tabs use a local transport into that authority, selected-file epochs remain authenticated, non-active-file edits persist, and no tab is editable before owner or follower authority is ready. Regression tests cover the share transition and two-way edits.","notes":"Root cause: activating a published review authority intentionally tore down the local BroadcastChannel hub, forcing same-browser follower tabs into read-only storage mirroring. Fixed by attaching the local-tab transport to the active published CollabController, carrying exact published file IDs and snapshot epochs, mapping headless file commits back to paths, and restarting the bridge when the authority controller rotates. Editor editability now requires a writable owner or live follower authority. Verified Svelte check 0/0, full web suite 81 files/0 failures, local collab 11/0, owner runtime 10/0, and Playwright two-way live editing.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-16T04:38:58Z","created_by":"James Lal","updated_at":"2026-07-16T04:48:12Z","started_at":"2026-07-16T04:39:09Z","closed_at":"2026-07-16T04:48:12Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-8oj","title":"Share an explicit selection of files across native and browser owners","description":"Replace implicit current-file/directory/workspace sharing with an explicit curated file selection. Browser owners must see the existing entries picker before publication; native owners must pass an exact root-relative selection and publish the same encrypted workspace manifest so native-to-web and web-to-web reviewers receive only the chosen documents. Preserve one room/link, a primary starting file, live updates for selected files, and honest post-share file counts.","acceptance_criteria":"Native and hosted Share flows present a checkbox file picker with the active file initially selected and do not publish before confirmation. A single link exposes exactly the selected reviewable files in reviewer navigation. Native publication emits an entries-scoped authoritative manifest with root-relative paths and watches only selected paths. Web publication uses entries scope for N=1 and N\u003e1. Automated tests cover native-to-web and web-to-web inclusion/exclusion and selected-file updates.","notes":"Implemented explicit N-file selection in native and hosted share flows; active document preselected; exact entries manifest with portable root-relative paths; native watches only selected files; ready state lists shared paths. Verified Svelte check, 81 web test files, cargo check/core tests, isolated convergence suite 10/10, selected-share focused tests, and live native/hosted launches.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-16T01:46:04Z","created_by":"James Lal","updated_at":"2026-07-16T02:45:42Z","started_at":"2026-07-16T01:46:11Z","closed_at":"2026-07-16T02:45:42Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-bb4","title":"Reviewer page flickers during text selection","description":"The hosted reviewer surface visibly flickers while dragging a text selection. Selectionchange currently writes fresh Svelte state on every browser event while the floating toolbar synchronously reads layout and reinstalls listeners. Coalesce work to animation frames, skip unchanged ranges, keep anchor listeners stable, and verify the ProseMirror view is not remounted during selection.","notes":"Second pass after user confirmed toolbar-only fix was insufficient: browser review pages now suppress the fixed translucent SVG turbulence overlay that forced page-wide compositing during native selection paints. Hosted E2E asserts body[data-route=review] and computed body::after content=none; 4/4 real owner/reviewer scenarios pass. Awaiting user visual confirmation before closing.\nThird pass found two remaining selection-hostile paths. A browser click emitted after dragging across an annotated mark focused the thread, scrolled the rail, and rebuilt the inline DecorationSet beneath the live selection; mark activation now requires both PM and DOM selections to be collapsed, and the duplicate immediate rebuild was removed. Same-file snapshot epoch replacement now defers until pointerup and restores selection/focus/scroll through the required EditorView rebuild. Added bounded opt-in window.__attnSelectionDebug lifecycle tracing (no text or capabilities), pure regressions, and hosted owner/reviewer coverage asserting exact range, EditorView/DOM identity, and editor/margin scroll stability. Verified npm test (81 files), npm run check, npm run build:browser, and isolated hosted E2E (4/4).","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-16T00:29:37Z","created_by":"James Lal","updated_at":"2026-07-16T01:10:00Z","started_at":"2026-07-16T00:29:44Z","closed_at":"2026-07-16T01:10:00Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-jgz","title":"Rooms as projects P1: owner focus follows shared files","description":"Implement P1 from planning/collab/room-as-project-ux.md: remove the owner room switcher, focus the owned room from the active shared path, clear room focus on unshared files, and move owner unread counts onto file/folder rows. Preserve reviewer room switching until P2. Add focused tests and visual verification.","notes":"Follow-up relay-backed E2E completed on james/room-as-project-ux: scripts/test-editorial-e2e.sh now covers owner room-dropdown removal, shared tree marker, unshared focus clearing without forgetting, shared-file reactivation, reviewer join, comment/reply/resolve convergence, and leave. Final result: 27 passed, 0 failed, 0 pending. Harness updated for relay quota attribution and native-vs-browser invite selection.","status":"closed","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-15T22:08:50Z","created_by":"James Lal","updated_at":"2026-07-15T22:33:31Z","started_at":"2026-07-15T22:08:51Z","closed_at":"2026-07-15T22:18:50Z","close_reason":"Implemented owner focus-follows-file, removed owner room navigation, added tree unread placement, and passed 81 frontend test files, svelte-check, production build, and scoped design audit. In-app visual session was unavailable.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-hs0","title":"Joiner falls out of sync with live owner after page open/close","description":"With the owner tab open and editing (live collab cursor was visible), closing and reopening the /s/ page shows stale content vs the owner browser, and the header says 'Owner offline - Review still available' even while the owner tab is live. Suspects: owner authority presence not detected by the joiner's isRoomLive probe on re-resolve, so the joiner pins to the retained snapshot instead of joining the live room; or the durable snapshot republish lags owner edits. Reproduced on the localhost loop (5199 + local relay).","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-15T15:44:45Z","created_by":"James Lal","updated_at":"2026-07-15T15:57:34Z","closed_at":"2026-07-15T15:57:34Z","close_reason":"Fixed and live-verified on the localhost loop; relay regression test added for the empty-bundles 400","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-lok","title":"Reviewer click on text makes the whole page jump jarringly","description":"On the /s/ joiner view, clicking document text (which focuses the corresponding review thread/anchor) scroll-jumps the entire page instead of a calm, contained adjustment. Likely an unqualified scrollIntoView() or focus() without preventScroll on the thread card / anchor path. Expected: focus the thread with block:'nearest' scrolling scoped to the rail, never yank the document scroll position.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-15T15:44:43Z","created_by":"James Lal","updated_at":"2026-07-15T15:57:33Z","closed_at":"2026-07-15T15:57:33Z","close_reason":"Fixed and live-verified on the localhost loop; relay regression test added for the empty-bundles 400","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-40t","title":"Deploy share-publish protocol cutover: relay + web + native must ship together per environment","description":"The atomic share publish change (attn-8h7, commit 3a8c5d0) is a clean cutover with a coordination hazard: old clients against the new relay 405 on the removed snapshot DELETE route and their same-revision republishes silently keep the old manifest; the new web owner flow assumes the new relay staging semantics. Ship per environment as a pair: staging relay worker (env staging) together with the web staging build; then the production relay worker together with the production web build and a rebuilt native attn binary (brew tap). Existing shares stranded in the pre-fix mismatched state heal on the owner's next publish from an updated client. James must run or authorize the deploys — the agent session is permission-gated from deploying.","notes":"2026-07-18 STAGING DEPLOYED as a pair: attn-relay-staging 6d23a738 (code-unit digest + canonical serve order, commits 2a54765+40635ac) and attn-web-staging 75ead12b (current main incl. code-unit client, identity, review-margin work) via wrangler deploy --env staging + npm run deploy:staging. Verified live on staging: two-file share publishes and the joiner loads both files (share mxl3-DN7qy0VO0jMrdYE5Q). PRODUCTION (relay.attn.sh + attn.sh) still runs the pre-cutover pair — deploy both together when cutting a release; do NOT deploy prod relay alone (old prod web clients would hit the digest-mismatch pause).","status":"open","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-15T04:04:24Z","created_by":"James Lal","updated_at":"2026-07-18T14:39:37Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-190","title":"Local/preview sharing publishes before failing origin validation","description":"The service passes window.location.origin while invite composition allows only production/staging HTTPS hosts. On task dev, publication + durable projection happen first, then invite generation throws: user sees failure while a live share exists. Validate origin up front (or compose a dev-origin invite) before any publication side effects. web/src/hosted/app/real-service.ts:447, web/src/lib/review/browser-workspace-sharing.ts:363, web/src/lib/review/browser-share.ts:101.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-15T03:25:32Z","created_by":"James Lal","updated_at":"2026-07-15T14:36:54Z","closed_at":"2026-07-15T14:36:54Z","close_reason":"Fixed on james/hosted-improvements with regression coverage where testable","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-8nw","title":"Relay bare-room DELETE preflights bypass allowBrowser gating","description":"The create-only CORS shortcut checks the route but not Access-Control-Request-Method, so preflights advertise DELETE and browsers send the destructive request for browser-disabled rooms; suppressing CORS on the final response cannot undo the deletion. relay/src/index.ts:324.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-15T03:25:32Z","created_by":"James Lal","updated_at":"2026-07-15T04:15:15Z","closed_at":"2026-07-15T04:15:15Z","close_reason":"Create shortcut now gated on Access-Control-Request-Method POST; regression test added","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-rtw","title":"Rename/delete confirmation state follows the active file across navigation","description":"In-place file switching resets editing/autosave state but not renamingEntry, renameEntryValue, confirmingEntryDelete. Start deleting/renaming A, navigate to B, and the action now targets B. Bind destructive state to an immutable target path and clear on navigation. web/src/hosted/app/EditorShell.svelte:827,1441.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-15T03:25:31Z","created_by":"James Lal","updated_at":"2026-07-15T04:12:57Z","closed_at":"2026-07-15T04:12:57Z","close_reason":"Fixed in commit on james/hosted-improvements; regression tests added for the autosave engine","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-kse","title":"Autosave flush does not drain an in-flight save: runCommit early-returns while committing","description":"runCommit() returns immediately when committing is true, so flush() resolves while newer text is still pending; the path switch then disposes the controller and suppresses its retry. Reproduced: only 'first' committed, 'second' stayed dirty with no scheduled save. Can also publish a stale share snapshot. web/src/hosted/app/autosave.ts:68,85, web/src/hosted/app/EditorShell.svelte:1031.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-15T03:25:30Z","created_by":"James Lal","updated_at":"2026-07-15T04:12:56Z","closed_at":"2026-07-15T04:12:56Z","close_reason":"Fixed in commit on james/hosted-improvements; regression tests added for the autosave engine","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-kew","title":"Browser Back/Forward discards pending edits: popstate applies entry without autosave flush","description":"Tree navigation calls switchTo() which flushes autosave; the popstate path calls applyEntry() directly. Once the path changes the old autosave controller is disposed and debounce-pending text is lost (type then press Back within 1.2s). web/src/hosted/app/AppShell.svelte:151, web/src/hosted/app/EditorShell.svelte:481.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-15T03:25:14Z","created_by":"James Lal","updated_at":"2026-07-15T04:12:57Z","closed_at":"2026-07-15T04:12:57Z","close_reason":"Fixed in commit on james/hosted-improvements; regression tests added for the autosave engine","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-8ri","title":"Read-only follower tabs can rename/delete/create/import entries without a workspace fence","description":"Structural mutation service methods bypass the editing session and never pass a fence; their UI stays enabled when editDenied is true. A follower can delete/rename the file the writer is editing, causing failed commits or lost work. Route structural mutations through the fenced owner runtime or reject without ownership. web/src/hosted/app/real-service.ts:359, web/src/hosted/app/EditorShell.svelte:1413.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-15T03:25:13Z","created_by":"James Lal","updated_at":"2026-07-15T14:28:25Z","started_at":"2026-07-15T14:20:29Z","closed_at":"2026-07-15T14:28:25Z","close_reason":"Lease protocol hardened with per-context nonce (token-bumping takeover on copied holder ids); structural mutations fenced via withWorkspaceFence in RealWorkspaceAppService","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-pw9","title":"Duplicate tabs can become concurrent writers: holder ID copied via sessionStorage passes fencing","description":"Holder ID is restored from copied sessionStorage; if BroadcastChannel is unavailable, delayed beyond the 75ms probe, or blocked, a duplicated tab retains the same holder ID. Lease manager treats matching holder IDs as the same owner and hands out the same fencing token, so both tabs' writes pass fencing. web/src/hosted/app/real-service.ts:67, web/src/lib/review/browser-workspace-lease.ts:80.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-15T03:25:12Z","created_by":"James Lal","updated_at":"2026-07-15T14:28:25Z","started_at":"2026-07-15T14:20:28Z","closed_at":"2026-07-15T14:28:25Z","close_reason":"Lease protocol hardened with per-context nonce (token-bumping takeover on copied holder ids); structural mutations fenced via withWorkspaceFence in RealWorkspaceAppService","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-8h7","title":"Durable share publish is non-atomic: joiners fail with 'capability bundle context mismatch'","description":"User-hit on staging.attn.sh: recipient opening an invite sees 'Could not reach the review relay / capability bundle context mismatch'. Root cause: relay share-do.ts snapshot PUT (:676) and DELETE (:569) bump record.revision and mutate the manifest server-side (and broadcastShareChanged, prompting viewers to re-resolve) while the sealed capability bundles still carry the old revision/manifestDigest; bundles are only re-sealed by the final upsert in publishDurableProjection (browser-workspace-sharing.ts:511). Every publish/republish opens a seconds-long window where openShareCapabilityBundle (browser-share.ts:301) hard-fails; tab-close/network failure mid-publish strands the share in that state. Fix: make publish atomic — snapshot PUT stages (no revision bump/broadcast), upsert commits manifest+bundles+revision in one DO transaction; drop the delete-then-upload dance; cut over web owner, native owner (src/review/share_lifecycle.rs), and add bounded joiner re-resolve on transient selector mismatch. Also fix currentManifestDigest shared-closure race in browser-share-production.ts:583.","notes":"Implemented on james/hosted-improvements @ 3a8c5d0. Relay stages snapshot PUTs and commits manifest+bundles+revision atomically in upsert; snapshot DELETE endpoint removed; web+native owners cut over; joiner digest race fixed + bounded transient retry. relay 461/461, web 81/81 files, cargo 520/520 green. NOT yet deployed — see the deploy-coordination bead.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-15T03:25:11Z","created_by":"James Lal","updated_at":"2026-07-15T04:15:16Z","started_at":"2026-07-15T03:26:50Z","closed_at":"2026-07-15T04:15:16Z","close_reason":"Code complete on james/hosted-improvements (atomic publish across relay/web/native + joiner resilience); environment deploys tracked in the coordination bead","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-4lw","title":"Hosted app shell feedback: workspace switcher, quiet header, drop-zone footer","description":"Agentation feedback on /app/w/: (1) sidebar project row becomes a workspace switcher; (2) remove header rename-title button (rename via palette + desk); (3) remove desktop Edit/Done toggle (desktop is editor-first already; keep Retry edit for denied lease, mobile unchanged); (4) sidebar footer resting state becomes a single drop zone (New Markdown/Export via palette, per-file actions via tree context menu). Update hosted-authoring e2e specs for the new surfaces.","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-14T23:43:42Z","created_by":"James Lal","updated_at":"2026-07-15T00:37:05Z","closed_at":"2026-07-15T00:37:05Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-pbf","title":"Durable /s/ reviewers: live room following for instant owner-edit updates","description":"Today /s/ reviewers refresh via durable snapshot republish (~5s idle debounce). The facade already creates a live BrowserSession when the owner is online, but liveEditingAvailable is hardcoded false and owner collab broadcasts are not applied to the reviewer view. Wire the live session's collab delivery into BrowserReviewApp for durable shares to make owner edits appear keystroke-live, with the durable snapshot as the offline fallback.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-14T19:10:28Z","created_by":"James Lal","updated_at":"2026-07-14T20:49:07Z","started_at":"2026-07-14T20:08:27Z","closed_at":"2026-07-14T20:49:07Z","close_reason":"Live on staging: 305ms owner-keystroke to reviewer render, continuity across idle windows, late joiner resyncs to full state in 1.5s, docs byte-identical across owner/reviewer/late joiner","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-9jp","title":"Shared workspace goes stale for /s/ reviewers — owner edits never republish","description":"Owner keeps editing after sharing; reviewers (and even reviewer reloads) stay on the share-time snapshot forever. Root cause: the durable share projection mirrors the room-published manifest, and nothing republishes after commits — only workspace start and the share sheet did. Fix: idle-debounced (5s) flush after commits/accepts/applies — publish a new room snapshot generation (epoch transition, same machinery as accepts) then mirror it into the durable share record; reviewers' change watch refetches on the revision bump. Close() flushes a pending republish.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-14T19:10:27Z","created_by":"James Lal","updated_at":"2026-07-14T19:10:41Z","closed_at":"2026-07-14T19:10:41Z","close_reason":"Verified on staging: reviewer auto-updates live ~6s after owner pauses typing, persists across reviewer reloads, owner editor loses no text through the flush","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-5ao","title":"Minimal share UX: one-glance sheet (link+copy, compact tier switch, tuck the rest)","description":"User feedback: share sheet is clunky. Auto-publish already exists; the ready phase is a wall (banner, 3 radio cards, masked link + Show, 2 buttons, paragraphs, native/CLI, stop zone). Cut to: link row with Copy primary, segmented tier control, one meta line (allowance · E2EE · expiry), collapsed native/CLI, quiet stop-sharing with inline confirm.","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-14T06:01:46Z","created_by":"James Lal","updated_at":"2026-07-14T06:10:36Z","closed_at":"2026-07-14T06:10:36Z","close_reason":"Verified on staging: open→reload→reload works, fragmentless shows actionable message, sheet is one-glance (368px)","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-4sh","title":"Share link dies on reload — key stripped from URL strands the reviewer","description":"Opening /s/\u003cid\u003e#key=… works once; the bootstrap strips the fragment for security, so any reload/back-forward boots fragmentless, falls into the push-notification recovery path, and shows 'Invalid invite link / This notification is no longer remembered in this browser.' Repro: open share link, press F5. Fix: carry the link secret in history.state across the strip so same-tab reloads recover; clearer message when recovery is truly impossible.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-14T06:01:45Z","created_by":"James Lal","updated_at":"2026-07-14T06:10:36Z","closed_at":"2026-07-14T06:10:36Z","close_reason":"Verified on staging: open→reload→reload works, fragmentless shows actionable message, sheet is one-glance (368px)","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-ea6","title":"Two-tab co-editing E2E: convergence both directions, persistence, takeover","description":"Part of attn-47r. Playwright: two tabs type concurrently into one doc and converge; text persists to IndexedDB (reload shows merged doc); owner tab closes → follower keeps editing and its content survives; live-owner never loses its lease.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-13T20:30:17Z","created_by":"James Lal","updated_at":"2026-07-13T21:37:23Z","started_at":"2026-07-13T21:37:22Z","closed_at":"2026-07-13T21:37:23Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-71c","title":"Follower join path: real-service joinLocalCollab + EditorShell editable co-edit surface","description":"Part of attn-47r. joinLocalCollab handshake in real-service, reviewer-role controller, EditorShell: join instead of read-only follow when denied+unshared, editable editor bound via setActiveFile, autosave gated off, banner→live status, teardown on goodbye/generation change, takeover commits live doc before rehosting.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-13T20:30:16Z","created_by":"James Lal","updated_at":"2026-07-13T21:37:21Z","started_at":"2026-07-13T21:37:21Z","closed_at":"2026-07-13T21:37:21Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-738","title":"Local collab transport + owner hub: BroadcastChannel wire, seed cache, authority hosting, commit hook","description":"Part of attn-47r. Transport module (per-workspace channel, hello/hello-request/seed-request/seed/goodbye/collab envelope with senderId), owner-side hub owning the seed cache and CollabController(legacy epoch) construction, debounced commit-on-broadcast through runtime.commit, runtime exposes local collab state.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-13T20:30:16Z","created_by":"James Lal","updated_at":"2026-07-13T21:37:19Z","started_at":"2026-07-13T20:34:37Z","closed_at":"2026-07-13T21:37:19Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-47r","title":"Local multi-tab live co-editing: same-browser tabs co-edit an unshared workspace via the collab authority over BroadcastChannel","description":"Reuse the existing owner-as-authority collab stack (CollabAuthority/CollabHost/CollabClient/CollabController legacy-epoch mode) for the single-browser multi-tab case. The lease-holding tab hosts a per-file authority and performs all durable commits; other tabs join as CollabClients over a per-workspace BroadcastChannel transport instead of the encrypted relay. Replaces read-only follow mode with real concurrent editing when the workspace is NOT shared in a review room. Design: (1) local transport module with hello/seed/collab envelope + senderId, (2) owner-side hub in the runtime: seed cache (markdown+PmNode per file, single seed source), controller construction in legacy-epoch mode, commit hook on accepted broadcasts (debounced, via runtime.commit so close() flushes), (3) follower-side join in real-service: hello-request handshake, reviewer controller with isAuthorityDevice=lease holder, editor seeds from seed reply, (4) EditorShell wiring: follower editable surface, autosave suppressed in local-collab mode, banner replaced by live status, takeover = commit live doc then rehost under new generation, (5) two-tab Playwright E2E proof.","status":"closed","priority":1,"issue_type":"epic","owner":"james@littlebearlabs.io","created_at":"2026-07-13T20:30:01Z","created_by":"James Lal","updated_at":"2026-07-13T21:42:45Z","closed_at":"2026-07-13T21:42:45Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-q5u","title":"Read-only tab should live-follow the writer tab's changes","description":"A passive tab rendered a stale snapshot: nothing refreshed it while the writer tab committed. Added an advisory BroadcastChannel doorbell (attn:workspace-changes:v1) rung by the writer's service after every durable mutation (commit, suggestion accept/apply, entry create/rename/delete, asset import, workspace rename); follower tabs re-read from IndexedDB — storage stays the source of truth, self-echo is impossible (same channel instance + senderId filter). Banner copy now says the tab follows the writer.","status":"closed","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-13T17:36:09Z","created_by":"James Lal","updated_at":"2026-07-13T17:36:25Z","closed_at":"2026-07-13T17:36:25Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-w4i","title":"Passive tab sticks read-only when owner tab dies without releasing lease","description":"The read-only banner's only auto-recovery trigger was the BroadcastChannel 'released' doorbell. A crashed/killed owner tab — or one whose pagehide close chain never finishes its async lease release — never broadcasts, so the passive tab stayed read-only forever despite the IDB lease expiring after 15s. Fix: while denied, EditorShell peeks the blocking lease's expiresAt and re-attempts takeover exactly at that deadline (plus on window focus).","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-13T17:08:01Z","created_by":"James Lal","updated_at":"2026-07-13T17:08:42Z","closed_at":"2026-07-13T17:08:42Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-vea","title":"Hosted desktop editor: markdown input rules + serif reading register","description":"Staging critique P0 (gate-blocking for the hosted editor's score): the desktop hosted ProseMirror does not parse typed markdown (# stays literal, no input rules, no paste-as-markdown) and renders the document in Source Sans, not the serif reading register — breaking the Read/Do rule and the 'reviewer for agent-authored docs' positioning on the widest platform. The mobile editor already proves the WYSIWYG schema (H2 toolbar -\u003e real serif heading). Cascades into attn-cjn auto-rename (no H1 to read). Tracked separately on the web-editor-parity branch; outside the Theme v2 visual-consistency pass.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-12T17:41:52Z","created_by":"James Lal","updated_at":"2026-07-12T19:59:56Z","started_at":"2026-07-12T19:52:12Z","closed_at":"2026-07-12T19:59:56Z","close_reason":"Landed: markdown input rules + serif editor register in shared editor; verified live; unblocks cjn auto-rename","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-9ua","title":"Hosted: owner review rail - comment arrival + visible mailbox errors","description":"Staging critique P0: reviewer comments never surface in the hosted owner editor (silent 400 on mailbox fetch; no rail, no badge, no toast). Owner editor needs the persistent review rail with synced thread count, arrival surfacing, and first-class mailbox error states with retry.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-12T16:36:47Z","created_by":"James Lal","updated_at":"2026-07-12T17:00:09Z","started_at":"2026-07-12T16:49:53Z","closed_at":"2026-07-12T17:00:09Z","close_reason":"Error surfacing + live counts landed; transport already fixed on main (owner-live suite green); staging needs deploy","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-ri1","title":"Hosted: #join is a dead click - add paste-a-link entry","description":"Staging critique P1: landing entry card and desk card both navigate to /app#join which renders the desk unchanged. Add a paste-a-link modal (accepts an invite URL, routes to the review entry) or remove the dead cards until it exists.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-12T16:36:47Z","created_by":"James Lal","updated_at":"2026-07-12T16:46:54Z","started_at":"2026-07-12T16:43:17Z","closed_at":"2026-07-12T16:46:54Z","close_reason":"Landed + verified: join panel with invite validation","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-cjn","title":"Hosted: idempotent #new, state-aware entry CTA, auto-name from H1","description":"Staging critique P0: /app#new is a GET-that-mutates - every visit (incl. back-button/bookmark) mints an identical 'Untitled' workspace; empties never coalesce; workspaces never auto-name from content. Fix: (a) #new reuses the most recent empty untouched Untitled workspace instead of minting; (b) landing header/hero CTA becomes 'Your desk (N)' when local workspaces exist, New demotes to secondary; (c) workspace auto-names from the first H1 on first save.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-12T16:36:46Z","created_by":"James Lal","updated_at":"2026-07-12T16:42:54Z","started_at":"2026-07-12T16:36:56Z","closed_at":"2026-07-12T16:42:54Z","close_reason":"Landed + behaviorally verified: idempotent #new, desk-count CTA, H1 auto-name","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-6d2","title":"Native: checkbox toggles from keyboard/AT never persist","description":"taskListItemNodeView binds the toggle to mousedown only (Editor.svelte:364). Keyboard activation (Space) and AT fire click, not mousedown - the input flips visually via native behavior but no PM transaction dispatches and editSave never fires, so the watcher reload reverts the state. This is the native critique's 'checkbox persistence' P0: mouse worked, keyboard silently lost state. Fix: preventDefault mousedown (protects the selection) but bind the toggle to click, preventDefault'ing the native flip so PM node attrs stay the single source of truth.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-12T16:34:48Z","created_by":"James Lal","updated_at":"2026-07-12T16:36:12Z","started_at":"2026-07-12T16:35:04Z","closed_at":"2026-07-12T16:36:12Z","close_reason":"Landed: toggle moved to click path, disk write verified live","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-hg5","title":"Behavioral contracts: Truth Rule, Esc layering, focus management into DESIGN.md + fixes","description":"Codify from the prototype: (1) Truth Rule — visibility rides on data-state CSS, never on an animation completing ([data-state=closed]{display:none}, unmount closed dialogs) — this is the fix for the native ghost-modal P0; (2) Escape closes topmost layer only, incl. mobile drawer; (3) focus stored on open / restored on close for all dialogs; (4) reduced-motion is a designed alternative. Add as named rules in DESIGN.md section 6 and apply to existing dialogs.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-12T15:38:10Z","created_by":"James Lal","updated_at":"2026-07-12T16:11:46Z","started_at":"2026-07-12T16:09:09Z","closed_at":"2026-07-12T16:11:46Z","close_reason":"Landed: Truth Rule CSS + atomic theme flip + DESIGN.md contracts, verified ghost-modal gone on occluded window","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-n9j","title":"Command palette: cmd-K, commands, ARIA, keyboard guard fix","description":"CommandPalette.svelte gains: cmd-K alias, command entries (share, theme, toggle rail, jump to heading, comment on current paragraph, resolve all open comments — comments only, never suggestions), filter + arrows + enter, listbox ARIA (aria-activedescendant/aria-selected), focus restore to trigger. Fix web/src/lib/keyboard.ts:133 editingTarget guard so global chords work with editor focus. All shortcuts match e.code not e.key (Shift+Period emits '\u003e'). Esc pops topmost layer only.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-12T15:38:08Z","created_by":"James Lal","updated_at":"2026-07-12T16:09:08Z","started_at":"2026-07-12T16:00:20Z","closed_at":"2026-07-12T16:09:08Z","close_reason":"Landed: cmd-K + guard fix + commands group, verified on native daemon","dependencies":[{"issue_id":"attn-n9j","depends_on_id":"attn-5y6","type":"blocks","created_at":"2026-07-12T09:38:26Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-ll9","title":"Review rail: brand port + anchor-aligned hug rail","description":"Rebuild ReviewMargin.svelte + ReviewMarginCard.svelte on the prototype's rail: cards absolutely positioned in a track, aligned to their anchors' viewport position (verified pixel-exact in prototype), stacking with 14px min gap; rail hugs the 72ch prose edge and retreats to the pane edge when a wide block is \u003e=80px in view (decision #1, implemented in prototype). Kills the components' raw-px type regime and off-palette colors. Per-file thread scoping.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-12T15:38:07Z","created_by":"James Lal","updated_at":"2026-07-12T16:23:18Z","started_at":"2026-07-12T16:16:27Z","closed_at":"2026-07-12T16:23:18Z","close_reason":"Landed: retint sweep + hug rail via IO/RO/MO (no scroll listeners), suites green","dependencies":[{"issue_id":"attn-ll9","depends_on_id":"attn-5e7","type":"blocks","created_at":"2026-07-12T09:38:25Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-5e7","title":"Theme v2: shared reading-surface CSS (measure, wide track, scrollbars, grain)","description":"Port the prototype's sheet rules into web/styles/base.css + prosemirror.css so native and hosted inherit together: full-width left-set sheet, prose capped at 72ch, wide track (pre/table/mermaid container) spans the pane; visible list markers (rust dot); frontmatter metadata card; thin trackless ink-tinted scrollbars; paper grain on native; amber ::selection; terracotta focus ring everywhere. Reference: planning/design/prototypes/editor-polish.html + ux-overhaul.md decisions.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-12T15:38:06Z","created_by":"James Lal","updated_at":"2026-07-12T15:59:52Z","started_at":"2026-07-12T15:54:34Z","closed_at":"2026-07-12T15:59:52Z","close_reason":"Landed: measure split, tokens-fed fonts/selection/motion, markers, scrollbars, z ladder, verified native render","dependencies":[{"issue_id":"attn-5e7","depends_on_id":"attn-5y6","type":"blocks","created_at":"2026-07-12T09:38:24Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} +{"_type":"issue","id":"attn-l84","title":"Theme v2: single OKLCH token source for native + hosted","description":"Cut both builds to one token file using reviewed values from planning/design/prototypes/editor-polish.html (AA-verified both themes, INK chroma 0.014, --amber-deep, per-theme --monogram, shadow/ease tokens). Delete web/src/hosted/tokens.css hex system; port hosted-only tokens into shared source. Purge shadcn-blue var() fallbacks. Add 2px micro radius token to DESIGN.md.","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-12T15:37:27Z","created_by":"James Lal","updated_at":"2026-07-12T15:38:06Z","closed_at":"2026-07-12T15:38:06Z","close_reason":"duplicate of attn-5y6","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-5y6","title":"Theme v2: single OKLCH token source for native + hosted","description":"Cut both builds over to one token file using the reviewed values from planning/design/prototypes/editor-polish.html (AA-verified in both themes, INK with real chroma 0.014, --amber-deep, per-theme --monogram, shadow/ease tokens). Delete web/src/hosted/tokens.css (hex system); port hosted-only tokens (window-frame, nav-veil, grain) into the shared source. Purge shadcn-blue var() fallbacks (#2563eb/#dc2626/#16a34a family). Add the 2px micro radius token to DESIGN.md so spec prose and frontmatter agree.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-12T15:37:16Z","created_by":"James Lal","updated_at":"2026-07-12T15:54:00Z","started_at":"2026-07-12T15:41:19Z","closed_at":"2026-07-12T15:54:00Z","close_reason":"Landed: single token source web/src/tokens.css, hosted hex deleted, INK unified, verified both builds both themes","dependency_count":0,"dependent_count":3,"comment_count":0} +{"_type":"issue","id":"attn-7xl.7.5.2","title":"Make hosted sharing actionable and simpler","description":"Staging feedback showed the persistence-risk checkbox rendered outside the share sheet, leaving Create review link disabled, while the configure phase exposed too much storage and delivery complexity at once. Scope the Markdown task-list checkbox CSS, make the required safety confirmation visibly actionable, and distill the initial share flow with advanced delivery settings collapsed.","acceptance_criteria":"After persistence is denied, the safety checkbox remains visibly inside the sheet and enables successful link creation when confirmed. The default flow prioritizes scope, safety only when needed, and one primary action; delivery modes remain available under Advanced settings. Chromium and WebKit interaction, mobile layout, axe, and screenshot checks pass.","notes":"Implementing from live staging screenshot feedback; no delegation.\nRoot cause: web/styles/base.css globally styled every input[type=checkbox] as an absolutely positioned Markdown task checkbox, painting the share acknowledgement at viewport x=0 and leaving the CTA apparently blocked. Scoped task styles to direct list-item inputs; distilled configure UI to scope first, conditional safety confirmation, collapsed Advanced delivery, and one primary action. Added deterministic best-effort persistence-denial fixture and end-to-end completion regression. Validated Svelte 0/0, 80 unit files, Chromium share/shell 25/25, focused WebKit 3/3, route suite 77/78 plus corrected focused case 1/1, axe, and desktop/mobile screenshots.\nReal staging smoke test found a second blocker after the UI gate: OPTIONS for first POST /v3/rooms/:roomId is forwarded to an uninitialized RoomDO, which cannot return allowBrowser=true before the policy exists, so relay staging omits CORS and the browser cannot bootstrap its first room. Adding a narrow allowlisted-origin create-preflight path plus integration tests.\nFull staging browser instrumentation confirmed room create (201), owner registration (204), envelope publication (201), and durable-share creation (201) succeed. Final snapshot PUT was blocked by CORS because Access-Control-Allow-Methods omitted PUT. Added PUT to the browser method allowlist and an exact durable snapshot preflight regression. Relay typecheck, focused CORS/share tests (33/33), and full relay suite (461/461) pass.\nA caught-exception trace identified the remaining client failure exactly: native window.fetch was stored in fetchImpl, then invoked as input.fetchImpl(...) inside owner registration, rebinding this to the helper input and throwing TypeError: Illegal invocation before any request. Both v2/v3 registration helpers now call a local unbound fetch reference. The relay stub asserts this is undefined, making the browser-native contract a unit regression. Svelte check 0/0 and all 80 web unit files pass.\nPost-deploy smoke advanced past owner registration and exposed the same receiver-binding bug in BrowserShareOwnerRelayClient during durable manifest/mailbox operations. All three direct native-fetch property calls now use an unbound local reference; the client relay stub is binding-sensitive too. Focused bootstrap/share-owner tests and Svelte check pass.\nThe first successful ready-state exposed a staging-origin mismatch: durable links were hard-coded to https://attn.sh even though the capability lives on relay-staging. The coordinator now uses browserReviewBase for invite origin, the strict parser accepts canonical staging.attn.sh links, and tests cover staging composition/round-trip. Focused share/owner/workspace-sharing tests and Svelte check pass.\nEnd-to-end smoke opened the staging /s invite and rendered the encrypted canary. Stop sharing revoked ShareDO (204) but the subsequent room DELETE 204 lost its browser handshake after META.policy was wiped, causing a false UI failure. RoomDO now snapshots policy only for bare DELETE and reuses allowBrowser only on successful 2xx teardown. Focused delete/CORS integration tests (33/33) and relay typecheck pass.\nFinal real staging smoke passed: fresh workspace -\u003e encrypted /s link ready -\u003e separate reviewer rendered the canary -\u003e Stop sharing completed -\u003e a new reviewer load was rejected. Staging web version 172f2d48-efd8-4b21-9957-cab53c150e67; relay version 15369e3f-3f99-479c-9fc4-1ae87e57ebdb. Final full gates: 80/80 web unit files, Svelte 0/0, 31/31 relay files and 461/461 relay tests.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-12T02:30:18Z","created_by":"James Lal","updated_at":"2026-07-12T03:50:53Z","started_at":"2026-07-12T02:30:45Z","closed_at":"2026-07-12T03:50:53Z","close_reason":"Closed","labels":["a11y","authoring","cloudflare","e2e","e2ee","ios","performance","security","sharing","storage","ux","web"],"dependencies":[{"issue_id":"attn-7xl.7.5.2","depends_on_id":"attn-7xl.7.5","type":"parent-child","created_at":"2026-07-11T20:30:17Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-7xl.7.5.1","title":"Repair hosted workspace and Markdown authoring workflows","description":"Live staging feedback found four connected authoring UX defects: the hosted workspace picker renders malformed and cannot create, switch, or rename workspaces; file rename/download/delete actions live in a distant sidebar footer rather than the file context menu; desktop ProseMirror has no visible formatting affordance and does not consume Mod+B/Mod+I; and the app's apparent drop zone does not accept drops. Repair these as one keyboard-first hosted-authoring workflow.","acceptance_criteria":"Hosted desktop exposes a correctly sized workspace switcher with existing workspace navigation plus New, Rename, and All workspaces actions. File rows expose Rename, Download, and Delete in their context menus and the footer no longer carries active-file CRUD buttons. Desktop authoring exposes restrained Markdown formatting controls; Mod+B and Mod+I format text inside ProseMirror without toggling app chrome, and common Markdown input rules render headings/lists as typed. The desk and import surface accept drag-and-drop files, and the workspace sidebar offers a clear drop/choose target for adding Markdown/assets. Chromium/WebKit keyboard, drag/drop, accessibility, and screenshot regressions pass.","notes":"Implementation plan: adapt the shared Sidebar/FileTree via explicit hosted callbacks rather than forked markup; bind ProseMirror formatting shortcuts/input rules and add a restrained desktop toolbar; centralize file/drop import conversion; cover workspace switching/actions, context menus, keyboard formatting/input rules, and drag/drop with Playwright screenshots in Chromium/WebKit.\nStaging regression follow-up: fixed global sidebar Mod+B ownership so editable/consumed events remain with ProseMirror; replaced workspace and entry rename reloads with reactive in-place refresh/history.replaceState; rebound autosave when the active Markdown path changes; extracted official ProseMirror input-rules/keymap authoring into a dedicated module. Verified 80 unit files, Svelte 0/0, production build + route bundle boundaries, Chromium routes 77/77, focused WebKit 2/2, and screenshot review.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-12T01:03:27Z","created_by":"James Lal","updated_at":"2026-07-12T02:04:46Z","started_at":"2026-07-12T01:03:45Z","closed_at":"2026-07-12T02:04:46Z","close_reason":"Staging regressions repaired and cross-browser validated; rename remains mounted/autosaves to the new path and Mod+B no longer toggles the sidebar.","labels":["a11y","authoring","cloudflare","e2e","e2ee","frontend","ios","performance","security","storage","ux","web"],"dependencies":[{"issue_id":"attn-7xl.7.5.1","depends_on_id":"attn-7xl.7.5","type":"parent-child","created_at":"2026-07-11T19:03:27Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-7xl.7.10.1","title":"Make WebKit writer-lease handoff reliable after the authority tab closes","description":"The staging WebKit route pass reproduces a stuck secondary tab. Tab A owns the workspace; duplicated Tab B correctly becomes read-only. After closing Tab A, Retry edit in Tab B remains contenteditable=false with lease-denied after repeated retries and more than 10 seconds. Chromium promotes correctly. Headless WebKit also reports session-only storage, so the validation must distinguish the storage fallback from the lease-release/election defect and verify real Safari behavior.","acceptance_criteria":"After the writer/authority tab closes or crashes, a remaining WebKit/Safari tab acquires authority on retry or automatic election within the documented bounded interval, without multiple writers or data loss. Add WebKit and real Safari coverage for close, crash/expiry, duplicate-tab identity, and private/session-only fallback. Keep the broader simultaneous-authoring convergence criteria on the parent feature.","notes":"Implementation plan: keep atomic fenced acquisition, but when a user retries a denied WebKit lease, poll with bounded exponential backoff through the existing 15-second TTL plus grace because page.close may abandon async release. Test: two passive WebKit successors race after writer close and exactly one becomes writable; retain existing Chromium fast handoff.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-11T23:48:11Z","created_by":"James Lal","updated_at":"2026-07-12T00:46:56Z","started_at":"2026-07-11T23:56:23Z","closed_at":"2026-07-12T00:46:56Z","close_reason":"Implemented bounded WebKit lease-expiry handoff with atomic fenced election and a race regression proving exactly one successor becomes writable. Chromium fast handoff and WebKit storage suites pass.","labels":["a11y","authoring","cloudflare","e2e","e2ee","frontend","ios","multitab","performance","safari","security","storage","web"],"dependencies":[{"issue_id":"attn-7xl.7.10.1","depends_on_id":"attn-7xl.7.10","type":"parent-child","created_at":"2026-07-11T17:48:10Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-7xl.7.3.1","title":"Make the hosted editor fully tappable and repair authoring focus semantics","description":"Live 390x844 validation found the editable ProseMirror ends at y=512 while the fixed dock starts at y=787; tapping the roughly 275px blank writing canvas focuses main.editor-canvas, not the editor. The shared ProseMirror also has no textbox role, accessible name, or aria-multiline and is absent from getByRole(textbox). Desktop Rename workspace, Rename file, and New Markdown replace their trigger with an input but leave focus on BODY. The inline file-delete alertdialog keeps focus outside the alert and Cancel returns focus to BODY.","acceptance_criteria":"In mobile edit mode the editor activation surface fills the usable canvas above the dock and tapping any blank writing area focuses ProseMirror. The editable surface exposes an appropriate labelled multiline textbox semantic without changing native behavior. Inline rename/create inputs receive and select focus when opened; Escape/commit restore focus. File-delete confirmation moves focus into the alert, traps or scopes interaction appropriately, and restores focus to Delete on cancel. Keyboard and screen-reader tests cover desktop and 390px mobile.","notes":"Implementation plan: add labelled multiline textbox attributes to the shared EditorView and keep aria-readonly synchronized; make the mobile writing-sheet/editor mount a full-height flex chain; add explicit focus/select and focus-return helpers for title/file rename, New Markdown, and delete confirmation. Tests: keyboard-only transient controls, ProseMirror semantics, and 390x844 blank-canvas activation.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-11T23:48:07Z","created_by":"James Lal","updated_at":"2026-07-12T00:47:16Z","started_at":"2026-07-11T23:56:23Z","closed_at":"2026-07-12T00:47:16Z","close_reason":"Implemented full-height mobile ProseMirror targeting, labelled multiline textbox semantics, and deterministic focus entry/return for rename, create, and delete confirmation. Axe, keyboard, mobile geometry, Chromium, and WebKit tests pass.","labels":["a11y","authoring","cloudflare","e2e","e2ee","focus","ios","mobile","performance","security","storage","web"],"dependencies":[{"issue_id":"attn-7xl.7.3.1","depends_on_id":"attn-7xl.7.3","type":"parent-child","created_at":"2026-07-11T17:48:06Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-7xl.7.12","title":"Remove full-canvas focus rectangle from hosted ProseMirror","description":"The hosted global :focus-visible rule overrides the shared ProseMirror canvas outline:none. Since the desktop contenteditable now correctly fills the viewport, focusing it draws a loud rust rectangle around the whole document. Preserve focus indication through the editor caret/selection without outlining the canvas.","acceptance_criteria":"Focused hosted desktop and mobile ProseMirror canvases have no full-surface outline or border, retain a visible caret and editable behavior, and other keyboard-focusable controls keep their focus rings. Source and browser evidence confirms hosted and native consume the shared Editor.svelte/ProseMirror implementation. Chromium regression and Svelte checks pass.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-11T23:09:42Z","created_by":"James Lal","updated_at":"2026-07-11T23:13:49Z","closed_at":"2026-07-11T23:13:49Z","close_reason":"Confirmed hosted and native mount the shared Editor.svelte and real ProseMirror EditorView. Fixed the hosted unlayered :focus-visible override so the canvas uses caret/selection without a full rust rectangle, while controls retain their 2px focus ring. Svelte check, browser build, 68 routed E2E, and focused Chromium/WebKit mobile tests pass.","labels":["a11y","authoring","cloudflare","e2e","e2ee","frontend","ios","performance","security","storage","svelte","web"],"dependencies":[{"issue_id":"attn-7xl.7.12","depends_on_id":"attn-7xl.7","type":"parent-child","created_at":"2026-07-11T17:09:41Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.7.12","depends_on_id":"attn-7xl.7.11","type":"discovered-from","created_at":"2026-07-11T17:09:43Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-7xl.7.11","title":"Make hosted desktop editor full-height and permanently editor-first","description":"The hosted desktop ProseMirror activation/focus area collapses to the content height, leaving most of the document canvas non-interactive. Desktop also exposes an Edit/Done mode toggle even though desktop authoring should remain editor-first; only mobile should start read-only and toggle editing.","acceptance_criteria":"At desktop widths the editable surface fills the available document viewport and clicking blank canvas space focuses the editor. Desktop has no Edit or Done mode control and remains editable whenever it owns the lease. A genuine passive second tab remains read-only with Retry edit, while mobile remains reader-first with Edit/Done. Chromium visual/interaction regressions and Svelte checks pass.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-11T19:49:16Z","created_by":"James Lal","updated_at":"2026-07-11T19:56:58Z","closed_at":"2026-07-11T19:56:58Z","close_reason":"Desktop ProseMirror now flex-fills the document viewport and accepts clicks in blank canvas space. Normal desktop Edit/Done chrome was removed; only genuine lease denial exposes Retry in its banner. Mobile remains reader-first and desktop editing restores after responsive transitions. Svelte check, browser build, 77 unit files, 61 routed E2E, and 30 Chromium/WebKit storage-reader tests pass.","labels":["a11y","authoring","cloudflare","e2e","e2ee","frontend","ios","performance","responsive","security","storage","svelte","web"],"dependencies":[{"issue_id":"attn-7xl.7.11","depends_on_id":"attn-7xl.7","type":"parent-child","created_at":"2026-07-11T13:49:16Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.7.11","depends_on_id":"attn-7xl.7.9","type":"discovered-from","created_at":"2026-07-11T13:49:17Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-7xl.7.10","title":"Coordinate simultaneous browser-tab authoring through one fenced authority","description":"Allow more than one browser tab to edit the same local workspace without granting multiple IndexedDB writers. Keep one fenced storage authority and proxy ProseMirror steps, committed heads, presence, and authority handoff over BroadcastChannel (or an equivalent same-origin coordinator). Secondary tabs must converge live and survive authority closure without last-writer-wins data loss.","acceptance_criteria":"Two tabs can concurrently edit one Markdown entry and both converge on the same durable document; edits to different entries remain durable; only one tab holds the storage fence at any time; closing or crashing the authority elects a remaining tab; mobile stays reader-first until Edit; duplicate/reload/navigation and Chromium/WebKit regressions pass.","notes":"2026-07-19 handoff evidence: seamless cross-tab lease handoff shipped (47234ab) — denied tabs ring a handoff doorbell on real intent (click/keypress/focus-since-yield), the live holder acks + flushes + yields (~40-190ms measured claims both directions), dead holders are force-taken after an ack-guarded grace, structural mutations claim through the same path, and the 'Another tab is editing' wall is deleted. Fenced single-writer storage authority preserved throughout (zombie mid-close runtime fixed; graceful-release commit gate prevents stale-mirror overwrite). SHIPPING MODEL: the non-owner tab is a live READ-ONLY mirror (owner keystrokes stream in ~1s, verified) and any interaction reclaims the pen — editing follows intent rather than simultaneous CRDT co-typing; the joinLive editable-follower never engages (attn-oldm). Remaining blockers before close: attn-x1k (reclaim can render an empty doc — gate hosted-authoring.spec:426 red), the WebKit project pass, and James's call on whether the intent-handoff model satisfies the 'concurrently edit and converge' criterion or joinLive must be fixed first.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-11T19:38:41Z","created_by":"James Lal","updated_at":"2026-07-20T14:15:52Z","started_at":"2026-07-19T23:18:46Z","closed_at":"2026-07-20T14:15:52Z","close_reason":"All acceptance criteria gate-verified: (1) two tabs concurrently edit one Markdown entry and converge on the same durable document — join-first co-editing through ONE fenced authority, converged text survives reload (hosted-authoring:426, 5x green); (2) different-entry edits durable (storage suite); (3) exactly one storage-fence holder at any time (fenced lease + intent-graded handoff with holder veto — no pen thrash); (4) closing/crashing the authority elects a survivor — proven behaviorally on WebKit (edit in one survivor converges into the other through the rebuilt hub); (5) mobile reader-first until Edit, and the first Edit tap now absorbs the join connection; (6) duplicate/reload/navigation and Chromium+WebKit regressions pass (85/85 routes + 31/31 storage). Architecture notes: intent-graded handoff-request/ack/veto protocol on the lease channel; join-first denied policy; __attnCollabDebug probe for future diagnosis. Deployed to staging 06da741d.","labels":["a11y","authoring","cloudflare","e2e","e2ee","frontend","ios","multitab","performance","security","storage","web"],"dependencies":[{"issue_id":"attn-7xl.7.10","depends_on_id":"attn-7xl.7.9","type":"discovered-from","created_at":"2026-07-11T13:38:41Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.7.10","depends_on_id":"attn-7xl.7","type":"parent-child","created_at":"2026-07-11T13:38:40Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-7xl.7.9","title":"Fix stale browser writer lease after workspace navigation","description":"A user can create/open a staging workspace in a single visible tab and immediately land in Retry edit / Another tab is editing. Diagnose and fix route/page lifecycle lease release or reuse so a tab cannot conflict with its own stale writer lease. Preserve honest read-only behavior for a genuinely separate tab.","acceptance_criteria":"Create, rename, reload, file navigation, and reopening a workspace in one tab stay editable without showing the competing-tab banner. A genuinely concurrent second tab remains read-only until the writer closes, then Retry edit succeeds. Chromium staging regression coverage passes.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-11T19:31:34Z","created_by":"James Lal","updated_at":"2026-07-11T19:42:57Z","started_at":"2026-07-11T19:31:46Z","closed_at":"2026-07-11T19:42:57Z","close_reason":"Fixed false competing-tab leases with a reload-stable per-tab identity plus BroadcastChannel duplicate detection; mobile readers now defer authority until Edit. Added rename/reload/navigation/duplicated-tab/mobile regressions. Svelte check, browser build, 77 unit files, 59 routed E2E, and 30 Chromium/WebKit storage-reader tests pass.","labels":["a11y","authoring","cloudflare","e2e","e2ee","frontend","ios","multitab","performance","security","storage","web"],"dependencies":[{"issue_id":"attn-7xl.7.9","depends_on_id":"attn-7xl.7.7","type":"discovered-from","created_at":"2026-07-11T13:31:35Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.7.9","depends_on_id":"attn-7xl.7","type":"parent-child","created_at":"2026-07-11T13:31:33Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-7xl.7.7","title":"Converge desktop browser editor UX with native attn","description":"Desktop browser authoring currently renders a separate prototype shell instead of the native attn editor UX. Reuse the native editor, file navigation, review chrome, and rail vocabulary for desktop browser-owned workspaces while retaining the reader-first mobile layout and bottom sheets for constrained screens.","acceptance_criteria":"At desktop widths, browser-owned workspaces visibly and behaviorally align with native attn: shared editor surface, file navigation vocabulary, document header/actions, and collapsible review margin. Browser-specific storage/export controls remain available without dominating the editor. At mobile widths, the existing reader-first single-column flow, thumb dock, files/review sheets, keyboard-safe editing, and 320px no-overflow behavior remain intact. Desktop and mobile Playwright screenshots and interaction tests pass, along with Svelte checks and web unit/build gates.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-11T18:19:12Z","created_by":"James Lal","updated_at":"2026-07-11T19:12:43Z","started_at":"2026-07-11T18:19:15Z","closed_at":"2026-07-11T19:12:43Z","close_reason":"Desktop hosted authoring now consumes the shared native workspace frame, Sidebar, PathBreadcrumb, Editor, ReviewBar, and review rail; mobile remains rendered reader-first with dock/sheets. Native/hosted builds, 77 unit files, 57 routed E2E tests, 22 Chromium/WebKit reader tests, route bundle gates, axe, offline, and visual captures pass. Follow-up icon-registry chunk optimization tracked in attn-7xl.7.8.","labels":["a11y","authoring","cloudflare","e2e","e2ee","frontend","ios","performance","responsive","security","storage","svelte","web"],"dependencies":[{"issue_id":"attn-7xl.7.7","depends_on_id":"attn-7xl.7","type":"parent-child","created_at":"2026-07-11T12:19:12Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-02a.7","title":"Prevent malformed durable mailbox items from wedging share rotation","description":"ShareDO accepts opaque writable-bundle mailbox objects while native owner drain aborts the entire batch on one malformed or out-of-tier item. Routing/epoch changes are blocked while mail remains, allowing one hostile bearer to wedge a stable share.","acceptance_criteria":"Malformed, misrouted, and out-of-tier mailbox items are quarantined or terminally acknowledged without blocking later valid items, renewal, rotation, or revoke. Relay stays content-blind; validation uses only bounded outer routing metadata and owner-side authenticated decryption. Deterministic poison-before-valid and rotation tests pass.","notes":"Approach: keep ShareDO content-blind but strictly validate bounded outer review_submission routing (v, type, shareId, epoch, roomId, tier, device registration, envelope array/count/ids) before storage. Native owner drains items independently: authenticates/decrypts each, imports valid items exactly once, terminally ACKs/quarantines malformed/misrouted/out-of-tier poison, and continues to later valid mail. Add poison-before-valid plus renew/rotate/revoke tests proving no wedge.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-11T13:59:12Z","created_by":"James Lal","updated_at":"2026-07-11T16:14:37Z","started_at":"2026-07-11T15:01:55Z","closed_at":"2026-07-11T16:14:37Z","close_reason":"Added strict content-blind mailbox framing, terminal poison quarantine, durable ACK retry intent, and exactly-once regression coverage","dependencies":[{"issue_id":"attn-02a.7","depends_on_id":"attn-02a","type":"parent-child","created_at":"2026-07-11T07:59:12Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-eaw","title":"Native second-room WS subscriber stalls after one process hosts multiple rooms","description":"A hidden owner daemon hosting room A then room B can stop advancing the owner mailbox WS cursor for room B. Reviewer POSTs and ACKs ParticipantJoined seq5 and CommentCreated seq6; relay accepts them and owner socket is 101, but owner cursor remains seq4 and ReviewStore never imports the remote events. Room A works. Reproduced by scripts/test-native-async-e2e.sh multi-room variant against local Wrangler.","acceptance_criteria":"One resident process can host at least two active review rooms; authenticated comments in each advance the correct owner cursor and import exactly once. Add deterministic multi-room WS regression coverage.","notes":"Merge audit adds two acceptance cases to the high-watermark fix: re-fence oldestRetainedSeq as well as serverSeq before hello so concurrent ACK/FIFO deletion cannot create a replay gap; announce/deliver presence only after subscribe so no pre-hello presence or unsubscribed online state.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-11T10:09:11Z","created_by":"James Lal","updated_at":"2026-07-11T16:14:34Z","started_at":"2026-07-11T13:23:19Z","closed_at":"2026-07-11T16:14:34Z","close_reason":"Fixed subscriber ordering and replay fencing; native multi-room and full share gates pass","dependencies":[{"issue_id":"attn-eaw","depends_on_id":"attn-02a.4.4","type":"discovered-from","created_at":"2026-07-11T04:09:11Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-7xl.4.4.4","title":"Wire hosted owner and reviewer authority UX","description":"Connect hosted owner/reviewer editor state, review store, collaboration controller, injectable review actions, and honest status/read-only UI to the real authority services.","design":"Keep durable review and live authority distinct in service and UI state. Expose action ports from ReviewMargin components. Do not couple this task to the Share sheet work tracked by attn-7xl.4.5.","acceptance_criteria":"Owner online enables epoch-bound live editing; owner offline shows 'Owner offline · Review still available' while comments/suggestions still queue; a passive owner tab is visibly read-only; reconnect converges; accept/reject/apply use injected browser actions rather than native IPC.","notes":"Runtime/service core complete: BrowserOwnerWorkspaceRuntime now owns one route-lifetime lease, provides local-only/passive/loss states, serializes fenced autosave/actions/rollover, reconstructs exactly one promoted share and owner bindings, resumes/discards stale pending publication safely, uses a scoped live owner outbox for action/rollover publish+reseed, returns durable acceptance on later publication failure, caches opaque reviewed three-way tokens for explicit apply, exposes exact published collab seeds and durable comment/retry ports, and closes a sibling session storage connection without closing app storage. BrowserWorkspaceService/RealWorkspaceAppService/EditingSession/mock expose the runtime ports without BrowserStorage. Focused fake-IDB runtime tests 5/5, authority 17/17, workspace service 10/10, npm check clean, full npm test 66 files/0 failures. EditorShell/ReviewMargin/Card/BrowserReviewApp were not edited by this slice.\nCompleted hosted authority UX end to end. Reviewer editor now uses an epoch-bound CollabController, accepts only directory-authenticated owner authority, keeps durable review authoring available while owner presence pauses, surfaces the literal offline status, and resyncs on reconnect. Owner route acquires one immediate route-lifetime fence, distinguishes passive/local writable/live authority states, mounts the exact published seed, and injects browser accept/reject/reviewed-apply/reply/resolve/retry ports with no native IPC fallback. Review actions wait for durable outcomes, prevent double-submit, preserve three-way review on semantic drift, and hand durable delivery-pending state to the outbox. Added runtime recovery, sibling storage ownership, shared desk status, passive-tab test updates, and paused-startup/stale-head regression coverage. Validation: svelte-check clean; 66 web unit files / 0 failures; browser build + service worker and route bundle boundaries pass; 29 hosted authoring/shell Playwright tests pass; desktop/mobile screenshots visually reviewed; git diff --check clean.","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-11T05:57:52Z","created_by":"James Lal","updated_at":"2026-07-11T08:01:49Z","started_at":"2026-07-11T07:34:08Z","closed_at":"2026-07-11T08:01:49Z","close_reason":"Hosted owner and reviewer authority UX is wired, fenced, and validated","labels":["authoring","cloudflare","e2ee","interop","ios","sharing","storage","web","webrtc"],"dependencies":[{"issue_id":"attn-7xl.4.4.4","depends_on_id":"attn-7xl.4.4","type":"parent-child","created_at":"2026-07-10T23:57:51Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-7xl.4.4.3","title":"Implement fenced browser owner authority and idempotent review actions","description":"Compose the owner session, collab controller, workspace persistence, single-tab fenced lease, and browser accept/reject/apply pipeline. Make content mutation and accepted-event authoring crash-safe and idempotent.","design":"Reuse the workspace fenced lease for authority, autosave, and apply. Use deterministic revision/action receipts and an atomic local transaction containing the new head plus exact sealed terminal-event outbox record. Prefer a Rust/WASM bridge or shared native vectors for apply parity.","acceptance_criteria":"Only one tab owns authority; lease loss closes transport and fences stale writes; restart/takeover cannot apply a suggestion twice; browser apply matches native drift policy and emits one durable terminal event; rejection is durable before optimistic dismissal.","notes":"Authority composition must preload the sealed room/file/epoch checkpoint, pass the active WorkspaceFence and expected authority version into putCollabCheckpoint, surface onAuthorityPaused, and roll over by publishing a fresh snapshot epoch before the 64 KiB/4096-step checkpoint cap pauses live editing. The checkpoint API now enforces base snapshot == epoch, epoch-keyed rows, same-epoch version CAS, and lease fencing.","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-11T05:57:50Z","created_by":"James Lal","updated_at":"2026-07-11T07:33:00Z","started_at":"2026-07-11T06:42:48Z","closed_at":"2026-07-11T07:33:00Z","close_reason":"Implemented fenced browser owner authority with eager sealed checkpoint recovery, a single live lease/fence, generation-gated and quiesced collaboration, bounded transition delivery barriers, full promoted-manifest reseeding, and commit-scoped trusted terminal authoring. Added native-parity accept/reject/apply with deterministic idempotent receipts, one-transaction revision plus sealed terminal outbox durability, automatic live adoption/retry, exact revision publication binding, and promotion-time head/hash revalidation. Validated with 63 web test files, production browser/PWA builds and route bundle gate, plus Rust fmt/clippy and 444 passing library tests.","labels":["authoring","cloudflare","e2ee","interop","ios","sharing","storage","web","webrtc"],"dependencies":[{"issue_id":"attn-7xl.4.4.3","depends_on_id":"attn-7xl.4.4","type":"parent-child","created_at":"2026-07-10T23:57:50Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-7xl.4.4.2","title":"Add browser owner session, presence, and collab transport","description":"Add an explicit recovered owner principal to BrowserSession, authenticated owner startup, owner presence, broadcast collab signals, direct-plus-relay delivery, and honest live-authority state. Fix same-device relay socket presence semantics.","design":"Owner credentials are reconstructed and fully cross-checked from sealed capability. Keep connection and owner authority as separate state dimensions. Route collab to a public callback, not peer negotiation. Signals are ephemeral; durable review remains in the event outbox.","acceptance_criteria":"Recovered owner starts with authenticated GET and WS only, never registers as reviewer or emits reviewer join; target-null collab signals interoperate; exact envelope direct+relay dispatches once; owner presence gates live editing but not durable review; same-device presence leaves only on the final socket.","notes":"BrowserSession collab delivery must pass authenticated directory role context so reviewer controllers can accept broadcasts only from kind=owner. sendCollab should return a rejecting Promise on transport failure; controller/client now recover inflight state and resync. Missing wire epochs are accepted only by explicit legacy controllers.","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-11T05:57:49Z","created_by":"James Lal","updated_at":"2026-07-11T06:42:10Z","started_at":"2026-07-11T06:22:26Z","closed_at":"2026-07-11T06:42:10Z","close_reason":"Completed recovered browser-owner GET/WS-only startup, fully validated sealed credentials and authenticated policy/directory, honest owner presence/live-authority state, target-null collab direct+relay exact-envelope delivery with retry-safe dedup, and aggregate same-device relay presence including peer-cap race hardening. Full web/relay tests, static checks, production browser build, route bundle gate, and adversarial re-audit all pass.","labels":["authoring","cloudflare","e2ee","interop","ios","sharing","storage","web","webrtc"],"dependencies":[{"issue_id":"attn-7xl.4.4.2","depends_on_id":"attn-7xl.4.4","type":"parent-child","created_at":"2026-07-10T23:57:48Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-7xl.4.4.1","title":"Harden epoch-bound collab authority and checkpoint recovery","description":"Make collaboration authority batch-atomic, strictly parsed, epoch-bound to a published snapshot, serialized, and recoverable from workspace-key-sealed per-room/per-file checkpoints. Repeated or stale submissions must receive catch-up without duplicate application.","design":"Use a temporary document for validation and commit a whole batch only after every step succeeds. Persist checkpoint state under workspace recovery storage. Bind submit/broadcast/resync to the current published snapshot epoch. Keep durable review independent from collab availability.","acceptance_criteria":"A multi-step failure mutates nothing; authority checkpoints restore version/log after reload; stale/repeated submissions converge via catch-up; epoch mismatches fail closed; accepted batches persist before broadcast.","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-11T05:57:47Z","created_by":"James Lal","updated_at":"2026-07-11T06:21:54Z","started_at":"2026-07-11T05:57:59Z","closed_at":"2026-07-11T06:21:54Z","close_reason":"Implemented and security-audited epoch-bound browser collab core: atomic prepare/persist/commit/broadcast, authenticated-owner broadcast gating, strict legacy-aware wire parsing, stale catch-up/resync, sealed checkpoint restore, workspace-key AAD, snapshot-base binding, epoch-keyed/version-CASed/lease-fenced persistence, reload-to-controller integration, and visible pause seam. Validation: 61 web test files/0 failures, svelte-check 0/0, production browser build and route bundle gate pass, final adversarial re-audit found no blockers.","labels":["authoring","cloudflare","e2ee","interop","ios","sharing","storage","web","webrtc"],"dependencies":[{"issue_id":"attn-7xl.4.4.1","depends_on_id":"attn-7xl.4.4","type":"parent-child","created_at":"2026-07-10T23:57:46Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-7xl.4.3.1","title":"Add canonical workspace manifest, asset snapshots, and browser Rust anchor index","description":"## Objective\n\nDefine and implement the missing canonical native/browser workspace snapshot protocol required by attn-7xl.4.3: versioned manifests, arbitrary binary asset payloads, stable entry identities, and the exact Rust/comrak anchor index in browser-owned Markdown snapshots.\n\n## Constraints\n\n- Additive coordinated native+browser protocol; never a web-only shape.\n- Preserve existing Markdown/HTML canonical JSON bytes.\n- Normalized root-relative paths only; no OPFS/local paths.\n- Relay/R2 remain content-blind.\n- Browser must use the canonical Rust indexer (WASM/shared crate), not a TypeScript approximation.","design":"Use a docType-tagged SnapshotPlaintext union. Add asset payloads with unpadded base64url bytes + mediaType, and workspace_manifest with sorted manifest entries {fileId,snapshotId,path,kind,mediaType?,byteLength,contentHash}. Publish entries first and the manifest last; advance the sealed share publication pointer only after all durable ACKs. Extract/reuse the Rust comrak anchor builder for the browser through WASM.","acceptance_criteria":"Rust and browser canonical vectors match byte-for-byte for Markdown, HTML, asset, and workspace_manifest payloads; nested paths and arbitrary binary bytes round-trip browser↔native; browser Markdown carries the exact Rust-built AnchorIndex; old Markdown/HTML bytes remain unchanged; tamper/path/media/length/hash checks fail closed; mailbox and R2 paths stay content-blind.","notes":"Approach: preserve existing markdown/html canonical JSON by keeping the Rust SnapshotPlaintext struct shape compatible while adding validated optional fields/docType variants for asset and workspace_manifest; mirror it as a strict TS union. Publish normalized, path-sorted entry snapshots first and a signed manifest snapshot last; persist stable FileId mappings + manifest pointer under the sealed workspace share capability; mark published only after every durable ACK. Expose the exact existing src/review/anchors/index.rs implementation to the browser through a small wasm-pack crate that compiles the same source via a compatible module shim, then dynamically load it from the publisher (never a TS approximation or landing/app static import). Files: new crates/attn-anchor-wasm/** + generated web loader/package; Rust src/review/model.rs, bootstrap.rs, manager.rs and focused tests/vectors; web types.ts, browser-session.ts, browser-snapshot-publisher.ts/tests, browser-workspace-share.ts/tests. Test plan: old markdown/html canonical bytes unchanged; Rust/browser full-byte vectors for markdown/html/asset/manifest; invalid UTF-8/arbitrary binary/NUL; strict normalized paths/sort/duplicates/media/hash/length/tamper; exact Rust WASM AnchorIndex parity; mailbox/R2 ordering/retry/content blindness; native/browser hydration. Baseline: cargo check -p attn exit 0; cargo nextest baseline external relay quota failure at three_peer_comment_converges_async after 823 pass; npm check/test exit 0 (58 files).","spec_id":"planning/web-authoring/03-browser-sharing.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-11T04:14:57Z","created_by":"James Lal","updated_at":"2026-07-11T05:49:36Z","started_at":"2026-07-11T05:14:47Z","closed_at":"2026-07-11T05:49:36Z","close_reason":"Canonical workspace manifest/assets and exact Rust/WASM anchor protocol implemented, interoperably validated, crash-safe, and security-reviewed.","labels":["assets","authoring","cloudflare","crypto","e2ee","interop","ios","sharing","storage","web","webrtc"],"dependencies":[{"issue_id":"attn-7xl.4.3.1","depends_on_id":"attn-7xl.4.3","type":"parent-child","created_at":"2026-07-10T22:14:56Z","created_by":"James Lal","metadata":"{}"}],"comments":[{"id":"019f4fb9-57a2-7b42-aee8-871a086207d2","issue_id":"attn-7xl.4.3.1","author":"James Lal","text":"Completed coordinated native/browser workspace snapshot protocol: byte-stable legacy Markdown/HTML, canonical arbitrary-binary assets, strict manifest schema/path/ID/hash/media validation, stable synthetic manifest identity, exact main-target Rust/comrak AnchorIndex through reproducibly pinned WASM, manifest-last mailbox/R2 publishing, atomic sealed publication journal + ciphertext batch, last-ACK promotion, exact reload recovery, stop-race CAS, fail-closed native/browser hydration and inert asset/manifest UI. Validation: cargo fmt/clippy clean; cargo nextest 972 passed/10 skipped; web check 0/0; 60 web test files green; WASM crate 24 passed and reproducible 5-artifact rebuild; route bundle gate green; real relay live proof passed nested Markdown + 1.1MB arbitrary binary R2 + final manifest with content-blind replay; post-fix security audit safe to close. Real hosted-browser/iOS runtime remains in the explicit device validation gate.","created_at":"2026-07-11T05:49:34Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"attn-7xl.2.9","title":"Device gate: real iOS Safari validation for workspace storage v3","description":"HUMAN/DEVICE GATE (cannot be automated). From planning/web-authoring/01-storage-foundation.md: on a current iPhone/iPad, exercise workspace storage in a normal Safari tab, Private Browsing, and an installed Home Screen app; verify denied/best-effort persistence behavior, storage clear, and low-storage simulation. Consolidate with the attn-7xl.7 real-device matrix session — all automated validation (unit, fault-injection, property, Chromium/WebKit) is green per planning/web-authoring/validation-01.md.","notes":"2026-07-20: consolidated one-session checklist ready — planning/web-authoring/device-checklist-2026-07.md section A (normal tab / Private Browsing / Home Screen app / low-storage) against staging. All automated validation green per validation-01.md; this bead closes on James's device evidence.","status":"open","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-11T01:38:34Z","created_by":"James Lal","updated_at":"2026-07-20T22:51:56Z","labels":["assets","authoring","cloudflare","crypto","e2ee","indexeddb","ios","opfs","storage","web"],"dependencies":[{"issue_id":"attn-7xl.2.9","depends_on_id":"attn-7xl.2","type":"parent-child","created_at":"2026-07-10T19:38:34Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-02a.1.4","title":"Validate: acceptance gate E2E (worktree -\u003e suggest -\u003e wait -\u003e verdicts)","description":"Integration validation of the full agent gate loop. Individual tasks verified their pieces; this proves the contract: nothing continues on an unresolved queue.\n\n## Integration Scenarios\n- Agent worktree edit -\u003e submit-suggestion --from-diff (multi-hunk) -\u003e verdicts --wait blocks -\u003e owner accepts hunk 1, rejects hunk 2 -\u003e wait returns 0 with mixed map; accepted hunk's resulting_hash matches owner's on-disk file\n- Timeout path: no verdict within --timeout -\u003e non-zero exit, partial map\n- --as-agent identity: verdicts scoped to the agent's own suggestions only\n\n## E2E Test Commands\n- source scripts/lib/dual-instance.sh harness script (add scripts/test-gate-e2e.sh, wire task test:gate)\n- cargo test e2e_ gate cases alongside src/review/apply.rs e2e_* pattern\n\n## Acceptance Criteria\n- Full loop passes on dual-instance with relay (scripts/dev-collab.sh stack)\n- No polling anywhere in the wait path\n\n## Plan Reference\n- planning/sharing/README.md (Workstream D)","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-10T23:58:49Z","created_by":"James Lal","updated_at":"2026-07-11T02:28:43Z","started_at":"2026-07-11T02:10:05Z","closed_at":"2026-07-11T02:28:43Z","close_reason":"Validated permanent gate E2E: 2-hunk diff, scoped identities, timeout partial JSON, event-driven blocking wait, mixed owner accept/reject, on-disk hash equality; focused Rust tests and full relay script pass.","dependencies":[{"issue_id":"attn-02a.1.4","depends_on_id":"attn-02a.1","type":"parent-child","created_at":"2026-07-10T17:58:48Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.1.4","depends_on_id":"attn-02a.1.1","type":"blocks","created_at":"2026-07-10T18:01:16Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.1.4","depends_on_id":"attn-02a.1.2","type":"blocks","created_at":"2026-07-10T18:01:17Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.1.4","depends_on_id":"attn-02a.1.3","type":"blocks","created_at":"2026-07-10T18:01:18Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":3,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-02a.1.2","title":"Implement attn review verdicts --wait (block on verdict stream)","description":"Block until every listed suggestion (default: all own pending) has a verdict; exit 0 with the JSON verdict map, non-zero on --timeout. Park on the inbound verdict envelope stream (WS/DataChannel push already flows through the transport) — wait on a condition, never poll.\n\n## Files\n- src/cli_review.rs (--wait, --for \u003cid,...\u003e, --timeout \u003cdur\u003e)\n- src/review/manager.rs (verdict subscription surface for the CLI/daemon boundary)\n\n## Approach\nRegister interest in the target suggestion set, drain current state first (verdicts may already be complete), then await import-pipeline notifications. Timeout path exits non-zero with partial map on stderr/JSON. This is the agent gate: submit* -\u003e verdicts --wait -\u003e branch.\n\n## Verification\n- cargo build\n- cargo test (wait resolves on already-complete set without blocking; resolves on late-arriving verdict; times out non-zero)\n- Smoke: dual-instance — reviewer runs verdicts --wait in background, owner accepts, wait returns 0 with the map\n\n## Plan Reference\n- planning/sharing/README.md (Workstream D)","notes":"Approach: add a tokio::watch verdict revision signal to ReviewManager; subscribe before reading persisted verdicts, snapshot the default target as all own pending suggestions (or use explicit --for ids), return immediately if complete, otherwise await watch::Receiver::changed with an optional deadline. Notify only after imported SuggestionAccepted/SuggestionRejected events (already persisted by InboundPipeline). Extend daemon socket with a typed wait request/response and run wait on a per-connection handler so other daemon commands remain responsive. CLI adds --wait, --for comma-list, --timeout duration; wait implies JSON; timeout prints partial JSON to stderr and exits non-zero. Files: src/review/manager.rs, src/daemon.rs, src/cli_review.rs. Tests: already-complete returns immediately; late persisted verdict wakes without polling; timeout returns partial state; CLI duration/options; concurrent socket responsiveness.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-10T23:58:47Z","created_by":"James Lal","updated_at":"2026-07-11T01:22:27Z","started_at":"2026-07-11T00:55:14Z","closed_at":"2026-07-11T01:22:27Z","close_reason":"Implemented and validated event-driven verdict waiting with fixed own-pending/explicit target sets, race-free subscribe-before-drain watch notifications, typed daemon streaming boundary, timeout partial JSON/non-zero exit, and responsive concurrent socket handling. Tests: 21 verdict-focused executions pass; cargo build/fmt/clippy pass; real relay dual-daemon smoke proved block-before-accept and wake-on-import with matching resulting_hash; release binary 31.08 MiB under 32 MiB.","dependencies":[{"issue_id":"attn-02a.1.2","depends_on_id":"attn-02a.1.1","type":"blocks","created_at":"2026-07-10T18:01:09Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.1.2","depends_on_id":"attn-02a.1","type":"parent-child","created_at":"2026-07-10T17:58:46Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-02a.1.1","title":"Implement attn review verdicts --json","description":"Dump verdict state for a room's suggestions from the already-persisted SuggestionAccepted/SuggestionRejected envelopes: per suggestion `pending | accepted | rejected`, with resulting_hash for accepted ones. Default scope: the calling identity's own suggestions; `--all` for everything.\n\n## Files\n- src/cli_review.rs (new subcommand + daemon routing)\n- src/review/manager.rs / src/review/store (verdict-state query over the event log)\n\n## Approach\nDerive verdict state by folding SuggestionAccepted/SuggestionRejected over suggestions in the room store — no new persisted state. Honor --as-agent for identity scoping. Output stable JSON (document the shape in the CLI help).\n\n## Verification\n- cargo build\n- cargo test verdicts (new unit tests: pending/accepted/rejected fold, resulting_hash surfaced)\n- Smoke: source scripts/lib/dual-instance.sh; owner accepts one suggestion; attn_reviewer review verdicts --json shows accepted with resulting_hash matching on-disk hash\n\n## Plan Reference\n- planning/sharing/README.md (Workstream D)","notes":"Approach: add a strict persisted-event fold keyed by suggestion_id, joining SuggestionCreated creator identity to latest accepted/rejected verdict; aggregate all persisted rooms in deterministic BTreeMap order; default scope to daemon or --as-agent participant, with --all disabling scope. Expose a synchronous ReviewManager query over the daemon socket and print documented snake_case JSON. Files: src/review/store.rs, src/review/manager.rs, src/daemon.rs, src/cli_review.rs. Tests: pending/accepted/rejected fold, resulting_hash, creator identity scope/--all, deterministic multi-room JSON, socket routing and missing-manager error. Validation: cargo fmt --check; cargo test verdicts; cargo build; dual-instance smoke.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-10T23:58:45Z","created_by":"James Lal","updated_at":"2026-07-11T00:52:33Z","started_at":"2026-07-11T00:14:16Z","closed_at":"2026-07-11T00:52:33Z","close_reason":"Implemented and validated persisted verdict reporting with creator identity scoping, --all/--as-agent, stable deterministic JSON, daemon routing, and accepted resulting_hash. Tests: 11 verdict-focused executions passing; cargo build/clippy/fmt pass; real relay dual-daemon smoke accepted a reviewer suggestion and matched resulting_hash to owner file; release binary 31.03 MiB under 32 MiB.","dependencies":[{"issue_id":"attn-02a.1.1","depends_on_id":"attn-02a.1","type":"parent-child","created_at":"2026-07-10T17:58:45Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":2,"comment_count":0} +{"_type":"issue","id":"attn-02a.1","title":"Acceptance gate: agent worktree flow with verdict blocking","description":"Give agents a first-class way to block until the author has ruled on every suggestion. Agents work in their own git worktree; suggestions are the only thing that crosses the boundary; the owner apply flow (shipped) is the only path that mutates the file. No protocol changes — builds entirely on shipped surfaces (agent identities, suggestion envelopes, verdict events).\n\n## Scope\n- attn review verdicts --json (verdict state dump)\n- attn review verdicts --wait (block on the verdict envelope stream, no polling)\n- attn review submit-suggestion --from-diff (unified diff -\u003e per-hunk suggestions)\n\n## Pre-conditions\nNone. Owner-only AcceptSuggestion already enforced (agent-cli-howto.md crib sheet).\n\n## Plan Reference\n- planning/sharing/README.md (Workstream D)\n- planning/collab/agent-cli-howto.md","status":"closed","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-10T23:57:32Z","created_by":"James Lal","updated_at":"2026-07-11T02:28:54Z","started_at":"2026-07-11T00:14:15Z","closed_at":"2026-07-11T02:28:54Z","close_reason":"Acceptance gate feature complete: all implementation tasks closed and permanent dual-instance validation passes.","dependencies":[{"issue_id":"attn-02a.1","depends_on_id":"attn-02a","type":"parent-child","created_at":"2026-07-10T17:57:32Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-02a","title":"Sharing v3: durable links, notifications, permission tiers, acceptance gate","description":"Make sharing a real category on top of the v2 review protocol: share URLs that keep resolving (durable shares), an async notification loop (native + browser Web Push), sender-controlled permission tiers (view/comment/suggest), and a first-class acceptance gate so agents block until the author has ruled on every suggestion. Live co-editing is explicitly out of scope; the collaboration verb is suggest.\n\n## Success Criteria\n- [ ] A share URL resolves for 90+ days (owner-renewed), survives room expiry, and is owner-revocable\n- [ ] View-only links are cryptographically enforced by the relay; comment/suggest tiers enforced at import\n- [ ] Comments arriving while away produce native (daemon) and browser (Web Push) notifications, relay staying content-blind\n- [ ] An agent can submit suggestions from a worktree diff and block on `attn review verdicts --wait` until the owner rules on all of them\n\n## Architecture\n- Rooms stay ephemeral (decision #8); durability comes from a small relay-side share indirection holding the current roomId + latest encrypted snapshot\n- Capability = which keys the URL carries: read keys vs write-admission key split in the HKDF tree (amends decision #2)\n- Web Push is a content-free ping; the service worker decrypts locally via the remembered-room capability (decision #13)\n- Owner-accepts-everything is the invariant; agents submit, humans decide (unchanged from v2)\n\n## Plan Reference\n- planning/sharing/README.md\n- planning/collab/amendments.md (16 locked v2 decisions this plan amends/preserves)","notes":"All Sharing v3 workstreams complete and independently reviewed: acceptance gate, cryptographic view/comment/suggest tiers, durable share lifecycle, native async loop, and content-blind browser Web Push. Real production-boundary native/browser/relay E2Es pass. Follow-up multi-room native WS stall tracked separately as attn-eaw.","status":"closed","priority":1,"issue_type":"epic","owner":"james@littlebearlabs.io","created_at":"2026-07-10T23:56:57Z","created_by":"James Lal","updated_at":"2026-07-11T10:33:45Z","started_at":"2026-07-11T00:14:15Z","closed_at":"2026-07-11T10:33:45Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-7xl.3.7","title":"Build iOS reader, files, and review surfaces","description":"## Objective\n\nImplement the reader-first iPhone/iPad web experience so local workspaces and encrypted review links remain beautiful and useful even when editing or durable local storage is unavailable.\n\n## Files\n\nHosted reader/layout components, file and asset sheet, inline review anchors, thread/index sheets, safe-media viewers, mobile state adapter, Playwright specs.\n\n## Approach\n\nFollow planning/web-authoring/ios-ux.md and its interactive prototype. Existing local/shared documents open in a legible single-column reader. A thumb dock exposes Files, Review, Edit or Open native, and Share. Bottom sheets preserve file scroll position and focus. Safe raster/media previews use short-lived object URLs; unknown or active assets remain download-only. Capability loss removes only unsafe actions and retains every independently supported reader/reviewer action.\n\n## Plan Reference\n\n- planning/web-authoring/02-local-authoring.md (Step 5)\n- planning/web-authoring/ios-ux.md\n- planning/web-authoring/ios-experience.html","acceptance_criteria":"At 320, 375, 390, and 430 px plus iPad/Split View widths, nested Markdown renders with readable measure and no page-level overflow; Files handles relative paths, safe media, missing references, and download-only assets; inline anchors and review sheets preserve exact scroll/focus; view-only replaces Edit with Open native while retaining reading, files/assets, review, export/share actions allowed by capabilities; keyboard/VoiceOver/touch targets pass; Playwright Chromium/WebKit and real current iPhone/iPad Safari reader matrices pass.","spec_id":"planning/web-authoring/ios-ux.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T23:47:57Z","created_by":"James Lal","updated_at":"2026-07-11T02:48:25Z","started_at":"2026-07-11T02:35:45Z","closed_at":"2026-07-11T02:48:25Z","close_reason":"Reader-first iOS surfaces landed: per-workspace/file reading position (best-effort sessionStorage, container-aware — mobile page scroll for iOS address-bar behavior, desktop pinned three-column with internally scrolling keyboard-focusable canvas; explicit anchors win), edge-to-edge raster lightbox with native pinch-zoom/Escape/focus-restore, view-only dock replacing Edit with 'Open native' while retaining files/review/share/reader actions, 18-19px phone body type incl the source view, tables/images contained so the page never pans, sheet trips preserve exact scroll. New Chromium+WebKit reader matrix (hosted-reader.spec.ts): 320/375/390/430px + 820/1024 iPad widths, reading-position round-trip, lightbox focus, quota view-only degradation — 26/26 both engines; 48/48 routes suite (axe scrollable-region fix), 54/54 unit files, staging deployed. Inline review anchors/thread data arrive with sharing (attn-7xl.4) into the existing sheet shells; real-device matrix stays in the 7xl.7 gate.","labels":["a11y","assets","authoring","cloudflare","e2ee","editor","ios","mobile","multi-file","reader","review","storage","svelte","web"],"dependencies":[{"issue_id":"attn-7xl.3.7","depends_on_id":"attn-7xl.3","type":"parent-child","created_at":"2026-07-10T17:47:56Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-7xl.7.6","title":"Validate production rollout and close the epic","description":"## Objective\n\nRun every Phase 06 quality gate, local and Cloudflare-staging Playwright suite, real iOS sign-off, independent reviews, production smoke, and rollback rehearsal.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/06-validation-rollout.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nfull repository tests, release evidence, deployment and rollback records\n\n## Validation\n\nRun Rust/web/relay quality gates, parity E2E, device checklist, security/a11y reviews, staging/production smoke, and rollback.\n\n## Source\n\nplanning/web-authoring/06-validation-rollout.md","acceptance_criteria":"All Phase 06 and epic success criteria pass with attached evidence; no required work remains before production rollout is declared complete.","notes":"Scope clarification 2026-07-10: workspaces are folder-shaped and must interoperate identically across web and native. They may contain nested Markdown plus arbitrary binary assets with normalized relative paths. Safe raster types may render inline; unknown/active types are download-only. Share supports current file, selected entries, or whole workspace through one canonical manifest. The landing New workspace action creates untitled.md and opens the editor in one click with no dialog or network request. See updated planning/web-authoring/.\nThe release matrix includes the iOS reader-first/view-only flows in planning/web-authoring/ios-ux.md at 320-430 px, iPad/Split View, 200% text, VoiceOver, dynamic browser chrome, safe media, Web Share, and browser/native handoff.","spec_id":"planning/web-authoring/06-validation-rollout.md","status":"open","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:43:39Z","created_by":"James Lal","updated_at":"2026-07-10T23:48:04Z","labels":["a11y","authoring","cloudflare","e2e","e2ee","ios","performance","security","storage","web"],"dependencies":[{"issue_id":"attn-7xl.7.6","depends_on_id":"attn-7xl.7.1","type":"blocks","created_at":"2026-07-10T16:44:08Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.7.6","depends_on_id":"attn-7xl.7.2","type":"blocks","created_at":"2026-07-10T16:44:09Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.7.6","depends_on_id":"attn-7xl.7.5","type":"blocks","created_at":"2026-07-10T16:44:11Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.7.6","depends_on_id":"attn-7xl.7.3","type":"blocks","created_at":"2026-07-10T16:44:09Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.7.6","depends_on_id":"attn-7xl.7","type":"parent-child","created_at":"2026-07-10T16:43:39Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.7.6","depends_on_id":"attn-7xl.7.4","type":"blocks","created_at":"2026-07-10T16:44:10Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":5,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-7xl.7.5","title":"Document browser authoring and recovery contract","description":"## Objective\n\nUpdate public/product/security/help documentation for local workspaces, backups, browser support, invite forms, and irrecoverable key/site-data loss.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/06-validation-rollout.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nREADME, landing/help copy, security/browser support docs\n\n## Validation\n\nContent-review docs against real UI, flows, iOS behavior, and server/storage implementation; link-check examples.\n\n## Source\n\nplanning/web-authoring/06-validation-rollout.md","acceptance_criteria":"Documentation clearly separates local workspace from room, states server non-recovery, explains Markdown backup, and uses working browser/native/CLI invite instructions.","spec_id":"planning/web-authoring/06-validation-rollout.md","status":"open","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:43:37Z","created_by":"James Lal","updated_at":"2026-07-10T22:43:37Z","labels":["a11y","authoring","cloudflare","e2e","e2ee","ios","performance","security","storage","web"],"dependencies":[{"issue_id":"attn-7xl.7.5","depends_on_id":"attn-7xl.7","type":"parent-child","created_at":"2026-07-10T16:43:37Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-7xl.7.4","title":"Deploy and cut over canonical attn.sh origin","description":"## Objective\n\nDeploy the unified surface to Cloudflare staging, run the full matrix, move attn.sh from the split Vercel site, verify production, and preserve tested rollback.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/06-validation-rollout.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nCloudflare Worker/deployment config, DNS/routes, redirects, release workflow\n\n## Validation\n\nRun staging Playwright, metadata/CSP/deep-route/storage-origin checks, production smoke, and rollback rehearsal.\n\n## Source\n\nplanning/web-authoring/06-validation-rollout.md","acceptance_criteria":"Staging is green before cutover; /, /app, and /review share one canonical origin; redirects/metadata work; production smoke passes; prior static landing/review rollback is rehearsed.","notes":"2026-07-19: PARKED by James — attn.sh must NOT be published yet; still iterating on design. Do not prep or execute production cutover until he lifts this.","spec_id":"planning/web-authoring/06-validation-rollout.md","status":"open","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:43:36Z","created_by":"James Lal","updated_at":"2026-07-20T02:12:19Z","labels":["a11y","authoring","cloudflare","e2e","e2ee","ios","performance","security","storage","web"],"dependencies":[{"issue_id":"attn-7xl.7.4","depends_on_id":"attn-7xl.7","type":"parent-child","created_at":"2026-07-10T16:43:35Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-7xl.7.3","title":"Complete accessibility and performance gates","description":"## Objective\n\nMeasure keyboard/screen-reader/touch/reduced-motion/contrast/320 px behavior plus landing bundle, interaction/autosave/cold-launch, large workspace memory, and iOS restart.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/06-validation-rollout.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nweb a11y tests, performance budgets, bundle reports, device evidence\n\n## Validation\n\nRun automated and manual a11y checks, 320 px screenshots, bundle analysis, latency/memory profiles, and iOS cold/restart cases.\n\n## Source\n\nplanning/web-authoring/06-validation-rollout.md","acceptance_criteria":"No release-blocking accessibility issue remains; landing avoids product chunks; documented budgets pass or have explicitly approved evidence-based exceptions.","notes":"2026-07-20 addendum: performance side complete — planning/web-authoring/perf-evidence-2026-07-20.md, all proposed budgets PASS (landing 28KB JS/198KB total; cold launch 323ms; keystroke p95 1.8ms; autosave settle 1.18s; 28MB heap at 63 files + 2MB doc; 304ms reopen). Functional/a11y: 86 routes + 31 storage green incl WebKit. Only the real-device matrix remains (device-checklist-2026-07.md, one ~30min session).","spec_id":"planning/web-authoring/06-validation-rollout.md","status":"in_progress","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:43:34Z","created_by":"James Lal","updated_at":"2026-07-20T22:52:06Z","started_at":"2026-07-19T23:18:47Z","labels":["a11y","authoring","cloudflare","e2e","e2ee","ios","performance","security","storage","web"],"dependencies":[{"issue_id":"attn-7xl.7.3","depends_on_id":"attn-7xl.7","type":"parent-child","created_at":"2026-07-10T16:43:34Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-7xl.7.2","title":"Complete browser workspace security review","description":"## Objective\n\nThreat-model same-origin code, local compromise, stored capabilities/identity, service worker/import/XSS/CSP, relay blindness, telemetry, crypto-erasure, and supply chain.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/06-validation-rollout.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nsecurity docs, CSP/Trusted Types configuration, leak scanners, dependency audit\n\n## Validation\n\nPerform independent code/security review and adversarial tests for imports, XSS, cache/log/referrer leaks, storage extraction, and content-blind relay/R2.\n\n## Source\n\nplanning/web-authoring/06-validation-rollout.md","acceptance_criteria":"All high/critical findings are fixed; accepted limitations are explicit; attn services still cannot decrypt content; local encryption claims do not exceed their threat boundary.","notes":"Scope clarification 2026-07-10: workspaces are folder-shaped and must interoperate identically across web and native. They may contain nested Markdown plus arbitrary binary assets with normalized relative paths. Safe raster types may render inline; unknown/active types are download-only. Share supports current file, selected entries, or whole workspace through one canonical manifest. The landing New workspace action creates untitled.md and opens the editor in one click with no dialog or network request. See updated planning/web-authoring/.","spec_id":"planning/web-authoring/06-validation-rollout.md","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:43:32Z","created_by":"James Lal","updated_at":"2026-07-11T18:25:11Z","started_at":"2026-07-11T15:28:15Z","closed_at":"2026-07-11T18:25:11Z","close_reason":"Security review complete; ZIP expansion hardening landed, no open high/critical findings, accepted limitations documented","labels":["a11y","authoring","cloudflare","e2e","e2ee","ios","performance","security","storage","web"],"dependencies":[{"issue_id":"attn-7xl.7.2","depends_on_id":"attn-7xl.7","type":"parent-child","created_at":"2026-07-10T16:43:32Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-7xl.7.1","title":"Build complete browser/native parity matrix","description":"## Objective\n\nCover all owner/reviewer combinations, transports, file sizes, review actions, reload/recovery, export/import, and invalid invite cases in a repeatable matrix.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/06-validation-rollout.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nE2E harnesses, Playwright projects, native automation, matrix docs\n\n## Validation\n\nRun every matrix cell locally and record exact commands/artifacts, including forced direct and mailbox paths.\n\n## Source\n\nplanning/web-authoring/06-validation-rollout.md","acceptance_criteria":"Every required parity cell has a deterministic pass/fail result; failures are fixed or blocking issues; no client combination relies on a web-only protocol.","notes":"Scope clarification 2026-07-10: workspaces are folder-shaped and must interoperate identically across web and native. They may contain nested Markdown plus arbitrary binary assets with normalized relative paths. Safe raster types may render inline; unknown/active types are download-only. Share supports current file, selected entries, or whole workspace through one canonical manifest. The landing New workspace action creates untitled.md and opens the editor in one click with no dialog or network request. See updated planning/web-authoring/.\nThe release matrix includes the iOS reader-first/view-only flows in planning/web-authoring/ios-ux.md at 320-430 px, iPad/Split View, 200% text, VoiceOver, dynamic browser chrome, safe media, Web Share, and browser/native handoff.\nComplete deterministic matrix recorded in planning/web-authoring/parity-matrix.md. Automated owner/reviewer combinations, live and offline transport, workspace content/action/recovery cells, local/staging route matrices all pass. Browser-owner/browser-reviewer and browser-owner/native-reviewer now both run against the same real Wrangler RoomDO/ShareDO in test:share-owner:live. Physical iPhone/iPad and production-origin cells are explicitly DEVICE/HUMAN APPROVAL gates tracked by attn-7xl.2.9, .7.4, and .7.6 rather than simulated passes.","spec_id":"planning/web-authoring/06-validation-rollout.md","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:43:31Z","created_by":"James Lal","updated_at":"2026-07-11T15:28:07Z","started_at":"2026-07-11T15:22:53Z","closed_at":"2026-07-11T15:28:07Z","close_reason":"Repeatable browser/native parity matrix is complete; every automated cell passes and physical-device/production cells name their blocking gate.","labels":["a11y","authoring","cloudflare","e2e","e2ee","ios","performance","security","storage","web"],"dependencies":[{"issue_id":"attn-7xl.7.1","depends_on_id":"attn-7xl.7","type":"parent-child","created_at":"2026-07-10T16:43:30Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-7xl.6.6","title":"Validate iOS Safari and offline behavior","description":"## Objective\n\nRun Phase 05 checks for safe service-worker caching, offline/update/reload/termination, and mandatory real current iPhone/iPad Safari modes.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/05-ios-offline.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nweb PWA/E2E tests and real-device release evidence\n\n## Validation\n\nRun npm checks, service-worker assertions, Chromium/WebKit Playwright, process-like termination, Private Browsing, and the documented iPhone/iPad matrix.\n\n## Source\n\nplanning/web-authoring/05-ios-offline.md","acceptance_criteria":"Every Phase 05 validation case passes; exported backups remain usable after private-session loss; real-device evidence is attached before closure.","notes":"2026-07-20: consolidated one-session checklist ready — planning/web-authoring/device-checklist-2026-07.md section B (offline shell, offline share failure calmness, recovery, process-restart, mid-typing kill) + C (reader UX at 320-430px, 200% text, VoiceOver, dynamic chrome). Closes on James's device evidence.","spec_id":"planning/web-authoring/05-ios-offline.md","status":"open","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:43:29Z","created_by":"James Lal","updated_at":"2026-07-20T22:51:56Z","labels":["authoring","cloudflare","e2ee","ios","offline","pwa","service-worker","storage","web"],"dependencies":[{"issue_id":"attn-7xl.6.6","depends_on_id":"attn-7xl.6.2","type":"blocks","created_at":"2026-07-10T16:44:05Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.6.6","depends_on_id":"attn-7xl.6.1","type":"blocks","created_at":"2026-07-10T16:44:04Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.6.6","depends_on_id":"attn-7xl.6.3","type":"blocks","created_at":"2026-07-10T16:44:06Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.6.6","depends_on_id":"attn-7xl.6.4","type":"blocks","created_at":"2026-07-10T16:44:07Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.6.6","depends_on_id":"attn-7xl.6.5","type":"blocks","created_at":"2026-07-10T16:44:07Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.6.6","depends_on_id":"attn-7xl.6","type":"parent-child","created_at":"2026-07-10T16:43:28Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":5,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-7xl.6.5","title":"Document real iOS Safari release protocol","description":"## Objective\n\nDefine current/previous iOS support, iPhone/iPad cases, normal/private/Home Screen modes, keyboard/rotation/memory/site-data/low-storage evidence, and release sign-off format.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/05-ios-offline.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nplanning/testing docs and Bead evidence template\n\n## Validation\n\nDry-run the protocol on at least one current iPhone and iPad and record gaps that automation cannot cover.\n\n## Source\n\nplanning/web-authoring/05-ios-offline.md","acceptance_criteria":"The protocol is reproducible, distinguishes Playwright WebKit from real iOS Safari, records exact device/version evidence, and cannot be waived by desktop automation.","notes":"Apply the iOS interaction/capability contract in planning/web-authoring/ios-ux.md. Offline and capability loss must preserve every independently available reader, file/media, review, export, share, and native-handoff action; mobile editing may degrade separately.","spec_id":"planning/web-authoring/05-ios-offline.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:43:27Z","created_by":"James Lal","updated_at":"2026-07-11T03:39:18Z","started_at":"2026-07-11T03:33:16Z","closed_at":"2026-07-11T03:39:18Z","close_reason":"Real-device protocol documented: planning/web-authoring/ios-device-protocol.md — supported current+previous iOS, device/mode matrix (normal/Private/Home Screen on SE-class + current iPhone + iPad), 15-step evidence checklist covering storage kill/clear/low-storage, reader/editing incl VoiceOver/dictation/rotation, offline/update/suspension, and review links; evidence recording rules bind failures to the 7xl.7 cutover.","labels":["authoring","cloudflare","e2ee","ios","offline","pwa","service-worker","storage","web"],"dependencies":[{"issue_id":"attn-7xl.6.5","depends_on_id":"attn-7xl.6","type":"parent-child","created_at":"2026-07-10T16:43:27Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-7xl.6.4","title":"Harden iOS lifecycle and memory recovery","description":"## Objective\n\nFlush bounded autosave on visibility/pagehide, recover after process kill, restart mailbox transport, stop RTC cleanly, and avoid background-sync assumptions.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/05-ios-offline.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nweb editor/session lifecycle controllers and termination tests\n\n## Validation\n\nSimulate termination/reload, rapid background/foreground, RTC loss, mailbox reconnect, memory pressure, and pending autosave.\n\n## Source\n\nplanning/web-authoring/05-ios-offline.md","acceptance_criteria":"The last committed revision survives process death; no UI claims unsaved work was durable; transports converge after resume without relying on closed-tab execution.","notes":"Apply the iOS interaction/capability contract in planning/web-authoring/ios-ux.md. Offline and capability loss must preserve every independently available reader, file/media, review, export, share, and native-handoff action; mobile editing may degrade separately.","spec_id":"planning/web-authoring/05-ios-offline.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:43:26Z","created_by":"James Lal","updated_at":"2026-07-11T03:39:12Z","started_at":"2026-07-11T03:33:15Z","closed_at":"2026-07-11T03:39:12Z","close_reason":"Real-device protocol documented: planning/web-authoring/ios-device-protocol.md — supported current+previous iOS, device/mode matrix (normal/Private/Home Screen on SE-class + current iPhone + iPad), 15-step evidence checklist covering storage kill/clear/low-storage, reader/editing incl VoiceOver/dictation/rotation, offline/update/suspension, and review links; evidence recording rules bind failures to the 7xl.7 cutover.","labels":["authoring","cloudflare","e2ee","ios","offline","pwa","service-worker","storage","web"],"dependencies":[{"issue_id":"attn-7xl.6.4","depends_on_id":"attn-7xl.6","type":"parent-child","created_at":"2026-07-10T16:43:25Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-7xl.6.3","title":"Map Safari capability-specific degraded modes","description":"## Objective\n\nConnect real IndexedDB/CryptoKey/OPFS/storage probes to designed Private Browsing, Lockdown, fallback, quota, volatile, and unsupported UI states.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/05-ios-offline.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nweb capability mapping and degraded-state components\n\n## Validation\n\nRun injected feature failures, WebKit, Safari Private Browsing, and available Lockdown-mode/manual cases.\n\n## Source\n\nplanning/web-authoring/05-ios-offline.md","acceptance_criteria":"No UA sniffing decides support; scratch/review-only options remain where safe; every blocked capability has precise non-misleading remediation.","notes":"Apply the iOS interaction/capability contract in planning/web-authoring/ios-ux.md. Offline and capability loss must preserve every independently available reader, file/media, review, export, share, and native-handoff action; mobile editing may degrade separately.","spec_id":"planning/web-authoring/05-ios-offline.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:43:24Z","created_by":"James Lal","updated_at":"2026-07-11T03:39:06Z","started_at":"2026-07-11T03:33:14Z","closed_at":"2026-07-11T03:39:06Z","close_reason":"Real-device protocol documented: planning/web-authoring/ios-device-protocol.md — supported current+previous iOS, device/mode matrix (normal/Private/Home Screen on SE-class + current iPhone + iPad), 15-step evidence checklist covering storage kill/clear/low-storage, reader/editing incl VoiceOver/dictation/rotation, offline/update/suspension, and review links; evidence recording rules bind failures to the 7xl.7 cutover.","labels":["authoring","cloudflare","e2ee","ios","offline","pwa","service-worker","storage","web"],"dependencies":[{"issue_id":"attn-7xl.6.3","depends_on_id":"attn-7xl.6","type":"parent-child","created_at":"2026-07-10T16:43:24Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-7xl.6.2","title":"Cache only the immutable app shell","description":"## Objective\n\nImplement a versioned service worker that caches only hashed app/font assets with network-first navigation and explicit update state.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/05-ios-offline.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nweb service worker, generated asset manifest, update UI/tests\n\n## Validation\n\nInspect Cache Storage and requests through install/update/offline; assert forbidden routes, content, room APIs, fragments, and error payloads are never cached.\n\n## Source\n\nplanning/web-authoring/05-ios-offline.md","acceptance_criteria":"Offline shell launch works; updates activate predictably; user content, invite URLs, room traffic, recovery data, and capability-bearing URLs never enter Cache Storage.","spec_id":"planning/web-authoring/05-ios-offline.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:43:22Z","created_by":"James Lal","updated_at":"2026-07-11T03:38:59Z","started_at":"2026-07-11T03:29:40Z","closed_at":"2026-07-11T03:38:59Z","close_reason":"Real-device protocol documented: planning/web-authoring/ios-device-protocol.md — supported current+previous iOS, device/mode matrix (normal/Private/Home Screen on SE-class + current iPhone + iPad), 15-step evidence checklist covering storage kill/clear/low-storage, reader/editing incl VoiceOver/dictation/rotation, offline/update/suspension, and review links; evidence recording rules bind failures to the 7xl.7 cutover.","labels":["authoring","cloudflare","e2ee","ios","offline","pwa","service-worker","storage","web"],"dependencies":[{"issue_id":"attn-7xl.6.2","depends_on_id":"attn-7xl.6","type":"parent-child","created_at":"2026-07-10T16:43:22Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-7xl.6.1","title":"Add installable local-desk app shell","description":"## Objective\n\nAdd manifest, icons, standalone metadata, theme colors, safe iOS navigation, and optional install guidance without promising persistence.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/05-ios-offline.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nweb manifest/icons/hosted HTML, PWA/install UI\n\n## Validation\n\nValidate manifest/icons/display/theme, standalone navigation/safe areas, install copy, and normal-versus-Home-Screen behavior on iOS.\n\n## Source\n\nplanning/web-authoring/05-ios-offline.md","acceptance_criteria":"The local desk installs and launches standalone on current iOS; navigation stays inside intended routes; guidance is optional and accurately hedged.","spec_id":"planning/web-authoring/05-ios-offline.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:43:21Z","created_by":"James Lal","updated_at":"2026-07-11T03:38:49Z","started_at":"2026-07-11T03:29:39Z","closed_at":"2026-07-11T03:38:49Z","close_reason":"Real-device protocol documented: planning/web-authoring/ios-device-protocol.md — supported current+previous iOS, device/mode matrix (normal/Private/Home Screen on SE-class + current iPhone + iPad), 15-step evidence checklist covering storage kill/clear/low-storage, reader/editing incl VoiceOver/dictation/rotation, offline/update/suspension, and review links; evidence recording rules bind failures to the 7xl.7 cutover.","labels":["authoring","cloudflare","e2ee","ios","offline","pwa","service-worker","storage","web"],"dependencies":[{"issue_id":"attn-7xl.6.1","depends_on_id":"attn-7xl.6","type":"parent-child","created_at":"2026-07-10T16:43:20Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-7xl.5.6","title":"Validate accountless recovery and storage UX","description":"## Objective\n\nRun the full Phase 04 status, export/import, persistence, deletion, iOS Files, and secret-leak validation suite.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/04-recovery-storage-ux.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nweb recovery tests, Playwright specs, iOS evidence, security review notes\n\n## Validation\n\nRun unit/property tests, byte-identical Playwright round trips, degraded-state screenshots, real iOS Files matrix, and filenames/manifest/cache/telemetry leak scan.\n\n## Source\n\nplanning/web-authoring/04-recovery-storage-ux.md","acceptance_criteria":"Every Phase 04 validation item passes and the documented recovery contract matches actual behavior when browser data or room authority is lost.","notes":"2026-07-20: automatable side complete via e2e/hosted-recovery.spec.ts in the routes suite — export→import→export byte-identical round trip; workspace deletion destroys durably (survives reload); share fragment secret appears in ZERO network requests across the share-creation session (verified against live staging relay). Remaining: iOS Files matrix + real-device items — rows D15/D16 in planning/web-authoring/device-checklist-2026-07.md.","spec_id":"planning/web-authoring/04-recovery-storage-ux.md","status":"in_progress","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:43:19Z","created_by":"James Lal","updated_at":"2026-07-20T22:51:55Z","started_at":"2026-07-19T23:18:47Z","labels":["authoring","cloudflare","e2ee","export","ios","recovery","storage","web"],"dependencies":[{"issue_id":"attn-7xl.5.6","depends_on_id":"attn-7xl.5.1","type":"blocks","created_at":"2026-07-10T16:44:01Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.5.6","depends_on_id":"attn-7xl.5.5","type":"blocks","created_at":"2026-07-10T16:44:04Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.5.6","depends_on_id":"attn-7xl.5.3","type":"blocks","created_at":"2026-07-10T16:44:02Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.5.6","depends_on_id":"attn-7xl.5","type":"parent-child","created_at":"2026-07-10T16:43:18Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.5.6","depends_on_id":"attn-7xl.5.4","type":"blocks","created_at":"2026-07-10T16:44:03Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.5.6","depends_on_id":"attn-7xl.5.2","type":"blocks","created_at":"2026-07-10T16:44:02Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":5,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-7xl.5.5","title":"Separate crypto-erasure and room cleanup actions","description":"## Objective\n\nImplement delete workspace, forget room, stop sharing, and clear all local data as distinct confirmed operations, deleting local keys first where crypto-erasure applies.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/04-recovery-storage-ux.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nworkspace/share cleanup services, OPFS GC, destructive confirmation UI\n\n## Validation\n\nTest each operation independently, active-room combinations, crash/retry, key-first deletion, orphan cleanup, and recovery messaging.\n\n## Source\n\nplanning/web-authoring/04-recovery-storage-ux.md","acceptance_criteria":"Each action states and performs only its promised consequence; live rooms are handled explicitly; removed content becomes undecryptable before best-effort record cleanup.","spec_id":"planning/web-authoring/04-recovery-storage-ux.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:43:17Z","created_by":"James Lal","updated_at":"2026-07-11T03:27:58Z","closed_at":"2026-07-11T03:27:58Z","close_reason":"Crypto-erasure and cleanup are separate, honest operations: delete workspace (root key first — 2.3), forget remembered room (BrowserStorage.forgetRoom key-first, new listRoomIds + storage-page section with consequence copy + in-app confirm), clear-all-local-data (per-workspace crypto-erasure loop, durable, e2e-verified across reload). Stop-sharing (owner room revocation) lands with attn-7xl.4 ownership and is noted there.","labels":["authoring","cloudflare","e2ee","export","ios","recovery","storage","web"],"dependencies":[{"issue_id":"attn-7xl.5.5","depends_on_id":"attn-7xl.5","type":"parent-child","created_at":"2026-07-10T16:43:17Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-7xl.5.4","title":"Gate first share on durability","description":"## Objective\n\nFrom a user gesture, request persistence, offer Markdown backup, explain best-effort/volatile risk, and require acknowledgement before non-persistent sharing.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/04-recovery-storage-ux.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nShare preflight state machine and persistence UI\n\n## Validation\n\nTest granted, denied, unknown, unavailable, Private Browsing, completed backup, declined backup, and repeat-share flows.\n\n## Source\n\nplanning/web-authoring/04-recovery-storage-ux.md","acceptance_criteria":"Persistence requests only follow a gesture; durable users proceed quickly; non-persistent users see accurate consequences and cannot bypass acknowledgement accidentally.","spec_id":"planning/web-authoring/04-recovery-storage-ux.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:43:16Z","created_by":"James Lal","updated_at":"2026-07-11T03:27:49Z","closed_at":"2026-07-11T03:27:49Z","close_reason":"Crypto-erasure and cleanup are separate, honest operations: delete workspace (root key first — 2.3), forget remembered room (BrowserStorage.forgetRoom key-first, new listRoomIds + storage-page section with consequence copy + in-app confirm), clear-all-local-data (per-workspace crypto-erasure loop, durable, e2e-verified across reload). Stop-sharing (owner room revocation) lands with attn-7xl.4 ownership and is noted there.","labels":["authoring","cloudflare","e2ee","export","ios","recovery","storage","web"],"dependencies":[{"issue_id":"attn-7xl.5.4","depends_on_id":"attn-7xl.5","type":"parent-child","created_at":"2026-07-10T16:43:15Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-7xl.5.3","title":"Import backups with explicit conflicts","description":"## Objective\n\nValidate and import Markdown/zip backups atomically with normalized paths and explicit duplicate-workspace choices, never silently reconnecting old rooms.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/04-recovery-storage-ux.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nweb import parser, validation, conflict UI, transaction tests\n\n## Validation\n\nTest traversal, duplicates, malformed manifest/zip, oversize, partial failure, and export-import byte round trip.\n\n## Source\n\nplanning/web-authoring/04-recovery-storage-ux.md","acceptance_criteria":"Valid imports create a new complete local workspace atomically; invalid input writes nothing; duplicate choices are explicit; no old owner/share authority is inferred.","notes":"Scope clarification 2026-07-10: workspaces are folder-shaped and must interoperate identically across web and native. They may contain nested Markdown plus arbitrary binary assets with normalized relative paths. Safe raster types may render inline; unknown/active types are download-only. Share supports current file, selected entries, or whole workspace through one canonical manifest. The landing New workspace action creates untitled.md and opens the editor in one click with no dialog or network request. See updated planning/web-authoring/.","spec_id":"planning/web-authoring/04-recovery-storage-ux.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:43:14Z","created_by":"James Lal","updated_at":"2026-07-11T03:27:40Z","closed_at":"2026-07-11T03:27:40Z","close_reason":"Crypto-erasure and cleanup are separate, honest operations: delete workspace (root key first — 2.3), forget remembered room (BrowserStorage.forgetRoom key-first, new listRoomIds + storage-page section with consequence copy + in-app confirm), clear-all-local-data (per-workspace crypto-erasure loop, durable, e2e-verified across reload). Stop-sharing (owner room revocation) lands with attn-7xl.4 ownership and is noted there.","labels":["authoring","cloudflare","e2ee","export","ios","recovery","storage","web"],"dependencies":[{"issue_id":"attn-7xl.5.3","depends_on_id":"attn-7xl.5","type":"parent-child","created_at":"2026-07-10T16:43:14Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-7xl.5.2","title":"Export ordinary workspace backups","description":"## Objective\n\nExport a file, workspace zip, or all workspaces as ordinary Markdown plus a small non-secret manifest, including iOS Files/Web Share behavior.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/04-recovery-storage-ux.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nweb export utilities, recovery page actions, manifest tests\n\n## Validation\n\nRun byte-exact exports, multi-file zip, filename/path, Web Share fallback, and real iOS Files tests.\n\n## Source\n\nplanning/web-authoring/04-recovery-storage-ux.md","acceptance_criteria":"Content is recoverable without attn; exported names/manifests contain no secret; all-workspace export is understandable and usable on iOS.","notes":"Scope clarification 2026-07-10: workspaces are folder-shaped and must interoperate identically across web and native. They may contain nested Markdown plus arbitrary binary assets with normalized relative paths. Safe raster types may render inline; unknown/active types are download-only. Share supports current file, selected entries, or whole workspace through one canonical manifest. The landing New workspace action creates untitled.md and opens the editor in one click with no dialog or network request. See updated planning/web-authoring/.","spec_id":"planning/web-authoring/04-recovery-storage-ux.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:43:13Z","created_by":"James Lal","updated_at":"2026-07-11T03:27:33Z","started_at":"2026-07-11T03:14:30Z","closed_at":"2026-07-11T03:27:33Z","close_reason":"Crypto-erasure and cleanup are separate, honest operations: delete workspace (root key first — 2.3), forget remembered room (BrowserStorage.forgetRoom key-first, new listRoomIds + storage-page section with consequence copy + in-app confirm), clear-all-local-data (per-workspace crypto-erasure loop, durable, e2e-verified across reload). Stop-sharing (owner room revocation) lands with attn-7xl.4 ownership and is noted there.","labels":["authoring","cloudflare","e2ee","export","ios","recovery","storage","web"],"dependencies":[{"issue_id":"attn-7xl.5.2","depends_on_id":"attn-7xl.5","type":"parent-child","created_at":"2026-07-10T16:43:12Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-7xl.5.1","title":"Add storage health and pressure UI","description":"## Objective\n\nShow persistence mode, accessible usage/quota, backup age, OPFS fallback, workspace sizes, and quota-pressure remediation without fake precision.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/04-recovery-storage-ux.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nweb storage/recovery page, status mapping and formatting utilities\n\n## Validation\n\nUnit-test every typed mode plus unknown estimate, quota pressure, fallback, and screen-reader text; capture screenshots.\n\n## Source\n\nplanning/web-authoring/04-recovery-storage-ux.md","acceptance_criteria":"Every capability state has accurate accessible copy and action; unknown values stay unknown; pressure never triggers a destructive or falsely successful write.","spec_id":"planning/web-authoring/04-recovery-storage-ux.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:43:11Z","created_by":"James Lal","updated_at":"2026-07-11T03:27:27Z","started_at":"2026-07-11T03:14:29Z","closed_at":"2026-07-11T03:27:27Z","close_reason":"Crypto-erasure and cleanup are separate, honest operations: delete workspace (root key first — 2.3), forget remembered room (BrowserStorage.forgetRoom key-first, new listRoomIds + storage-page section with consequence copy + in-app confirm), clear-all-local-data (per-workspace crypto-erasure loop, durable, e2e-verified across reload). Stop-sharing (owner room revocation) lands with attn-7xl.4 ownership and is noted there.","labels":["authoring","cloudflare","e2ee","export","ios","recovery","storage","web"],"dependencies":[{"issue_id":"attn-7xl.5.1","depends_on_id":"attn-7xl.5","type":"parent-child","created_at":"2026-07-10T16:43:11Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-7xl.4.7","title":"Validate browser-owned encrypted sharing","description":"## Objective\n\nRun the complete Phase 03 protocol, local E2E, native automation, bootstrap-failure, and content-blindness validation suite.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/03-browser-sharing.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nweb/Rust/relay tests, Playwright/automation specs, leak scanners\n\n## Validation\n\nExecute canonical vectors, relay tests, two-browser and browser/native E2E, forced failures, direct/fallback paths, and plaintext/secret/SDP/ICE scans.\n\n## Source\n\nplanning/web-authoring/03-browser-sharing.md","acceptance_criteria":"All Phase 03 validation items pass with reproducible evidence and no sensitive plaintext in infrastructure logs, storage, or telemetry.","notes":"Scope clarification 2026-07-10: workspaces are folder-shaped and must interoperate identically across web and native. They may contain nested Markdown plus arbitrary binary assets with normalized relative paths. Safe raster types may render inline; unknown/active types are download-only. Share supports current file, selected entries, or whole workspace through one canonical manifest. The landing New workspace action creates untitled.md and opens the editor in one click with no dialog or network request. See updated planning/web-authoring/.\nValidation record: planning/web-authoring/validation-03.md. Green evidence: svelte-check 0/0; 78 web test files; Rust share lifecycle 14/14; browser-owner live relay/native join; full durable-share real stack; folder share 3/3; WebRTC 12/12; Share sheet 7/7; production browser build and route boundaries; staging deploy fb0ebc57-8e38-4b12-8f97-e1dc79f45291 and full staging Playwright 64/64. Production cutover and real iOS remain explicit attn-7xl.7/device gates.","spec_id":"planning/web-authoring/03-browser-sharing.md","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:43:10Z","created_by":"James Lal","updated_at":"2026-07-11T15:22:17Z","started_at":"2026-07-11T15:22:16Z","closed_at":"2026-07-11T15:22:17Z","close_reason":"Phase 03 protocol, failure recovery, interop, content-blindness, browser UX, and staging validation gates pass reproducibly.","labels":["authoring","cloudflare","e2ee","interop","ios","sharing","storage","web","webrtc"],"dependencies":[{"issue_id":"attn-7xl.4.7","depends_on_id":"attn-7xl.4.2","type":"blocks","created_at":"2026-07-10T16:43:57Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.4.7","depends_on_id":"attn-7xl.4.5","type":"blocks","created_at":"2026-07-10T16:44:00Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.4.7","depends_on_id":"attn-7xl.4.1","type":"blocks","created_at":"2026-07-10T16:43:57Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.4.7","depends_on_id":"attn-7xl.4.3","type":"blocks","created_at":"2026-07-10T16:43:58Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.4.7","depends_on_id":"attn-7xl.4","type":"parent-child","created_at":"2026-07-10T16:43:09Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.4.7","depends_on_id":"attn-7xl.4.6","type":"blocks","created_at":"2026-07-10T16:44:00Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.4.7","depends_on_id":"attn-7xl.4.4","type":"blocks","created_at":"2026-07-10T16:43:59Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":6,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-7xl.4.6","title":"Verify native and browser share interoperability","description":"## Objective\n\nConnect browser-owned rooms to existing native and browser entry points, preserving the correct HTTPS, attn scheme, and CLI forms.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/03-browser-sharing.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nweb/native invite parsing and E2E harnesses\n\n## Validation\n\nRun browser-owner/browser-reviewer and browser-owner/native-reviewer flows plus reverse native-owner/browser regression.\n\n## Source\n\nplanning/web-authoring/03-browser-sharing.md","acceptance_criteria":"Both clients open the same room/file/comment model; comments, suggestions, edit/apply, reconnect, direct transport, and mailbox fallback interoperate.","notes":"Scope clarification 2026-07-10: workspaces are folder-shaped and must interoperate identically across web and native. They may contain nested Markdown plus arbitrary binary assets with normalized relative paths. Safe raster types may render inline; unknown/active types are download-only. Share supports current file, selected entries, or whole workspace through one canonical manifest. The landing New workspace action creates untitled.md and opens the editor in one click with no dialog or network request. See updated planning/web-authoring/.\nCompleted V3 interoperability after rebasing onto sharing-v3. Browser owner -\u003e native reviewer is proven against real Wrangler RoomDO/ShareDO by npm run test:share-owner:live; native owner -\u003e production browser, retained snapshot, offline mailbox, deterministic room recovery, watch upgrade, and revoke pass scripts/test-share-e2e.sh. Browser owner startup now renews/recreates ordinary rooms, validates both writable-tier offline mailboxes cryptographically, forwards exact encrypted envelopes, ACKs only after RoomDO durability, and seals its replay cursor. Folder publication passes 3/3 and live WebRTC/collab passes 12/12.","spec_id":"planning/web-authoring/03-browser-sharing.md","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:43:08Z","created_by":"James Lal","updated_at":"2026-07-11T15:22:08Z","started_at":"2026-07-11T14:51:31Z","closed_at":"2026-07-11T15:22:08Z","close_reason":"Native/browser stable-link resolution, room join, snapshots, offline mailbox recovery, and live transport interoperate on real relay stacks.","labels":["authoring","cloudflare","e2ee","interop","ios","sharing","storage","web","webrtc"],"dependencies":[{"issue_id":"attn-7xl.4.6","depends_on_id":"attn-7xl.4","type":"parent-child","created_at":"2026-07-10T16:43:08Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-7xl.4.5","title":"Build accountless Share sheet","description":"## Objective\n\nImplement durability preflight, TTL/mode controls, browser/native/CLI invite forms, Web Share/copy actions, room status, stop sharing, and recreate-room recovery.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/03-browser-sharing.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nweb Share sheet components and invite URL utilities\n\n## Validation\n\nRun keyboard/mobile/a11y checks, URL fragment/log leak assertions, share API fallback, stop/recreate, and each persistence mode.\n\n## Source\n\nplanning/web-authoring/03-browser-sharing.md","acceptance_criteria":"Stable 90-day sharing publishes independently revocable view/comment/suggest sibling bearers; browser, native, and CLI forms preserve the selected tier secret; URL fragments are not logged or referrer-copied; durability, recovery, and stop-sharing behavior are accurate.","notes":"Checkpoint c6ea637 on checkpoint/attn-7xl-4-5-v2 preserves validated V2 lifecycle/UI work: 68 web unit files, 48 relevant hosted Playwright cases, production browser build, route bundle boundary, Svelte check, scoped Rust redaction tests all pass. Do not close: incoming sharing-v3 materially supersedes public sharing with stable shareId + tiered view/comment/suggest capabilities and must land on origin/main first. Rebase checkpoint after that landing, then port durability/scope/recovery/redaction/UI invariants onto the V3 browser-owner lifecycle and rerun live/native interop.\nCompleted on checkpoint/attn-7xl-4-5-v2 after rebasing onto sharing-v3: browser-owned stable ShareDO lifecycle, sealed durable ownership, dark-to-active publication, retained encrypted snapshots, tiered Share sheet, native/browser/CLI invite forms, stop/recreate, route-bundle safety, keyboard/mobile/axe checks, and real Wrangler relay proof. Validation: 78 web unit files green, svelte-check 0/0, browser production build, focused Playwright 7/7, browser ShareDO live harness green.","spec_id":"planning/web-authoring/03-browser-sharing.md","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:43:07Z","created_by":"James Lal","updated_at":"2026-07-11T14:51:24Z","started_at":"2026-07-11T08:03:22Z","closed_at":"2026-07-11T14:51:24Z","close_reason":"V3 stable accountless Share sheet and browser-owner lifecycle implemented and validated.","labels":["authoring","cloudflare","e2ee","interop","ios","sharing","storage","web","webrtc"],"dependencies":[{"issue_id":"attn-7xl.4.5","depends_on_id":"attn-7xl.4","type":"parent-child","created_at":"2026-07-10T16:43:06Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-7xl.4.4","title":"Run browser owner authority","description":"## Objective\n\nIntegrate collab owner authority and owner-only accept/reject/apply in the browser; degrade durable review versus live editing honestly when the owner tab is absent.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/03-browser-sharing.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nbrowser collab authority, review store/actions, connection status UI\n\n## Validation\n\nTest comment/suggestion delivery owner-online/offline, reconnect, live co-edit, apply/reject, and multiple tabs.\n\n## Source\n\nplanning/web-authoring/03-browser-sharing.md","acceptance_criteria":"Durable review continues through mailbox while the owner is offline; live/owner-only actions pause visibly; reconnect converges without duplicate application.","spec_id":"planning/web-authoring/03-browser-sharing.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:43:05Z","created_by":"James Lal","updated_at":"2026-07-11T08:02:04Z","started_at":"2026-07-11T05:50:42Z","closed_at":"2026-07-11T08:02:04Z","close_reason":"Browser owner authority, durable review degradation, and hosted authority controls are implemented; end-to-end interop validation remains in attn-7xl.4.6/.4.7","labels":["authoring","cloudflare","e2ee","interop","ios","sharing","storage","web","webrtc"],"dependencies":[{"issue_id":"attn-7xl.4.4","depends_on_id":"attn-7xl.4","type":"parent-child","created_at":"2026-07-10T16:43:05Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-7xl.4.3","title":"Publish encrypted workspace snapshots","description":"## Objective\n\nSeal and publish selected file/folder snapshots through inline, mailbox, or R2 thresholds, advancing publication state only after durable acknowledgement.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/03-browser-sharing.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nbrowser snapshot publishing, R2/blob client, workspace share state\n\n## Validation\n\nRun inline/mailbox/R2, retry, duplicate ACK, failure-before-ACK, republish, and content-blind wire/storage tests.\n\n## Source\n\nplanning/web-authoring/03-browser-sharing.md","acceptance_criteria":"Published revisions are canonical and resumable, workspace source-of-truth remains local, and plaintext/capabilities never enter relay/R2 logs or state.","notes":"Approach: implement the exact native v2 snapshot publication pipeline for supported document snapshots. Add browser ID derivations and kind=snapshot_blob assembly; add R2 seal/presign/PUT; allow snapshot_blob in the durable outbox with maxSnapshotBytes; add a publisher that canonicalizes SnapshotPlaintext, preserves stable FileId on republish, enqueues blob before signed SnapshotCreated, waits for full relay ACK before setPublication(published), and resumes exact queued ciphertext on retry. Files: web/src/lib/review/browser-crypto.ts, browser-envelope.ts, browser-snapshot-r2.ts, browser-outbox.ts, new browser-snapshot-publisher.ts plus tests and live-relay harness. Tests: Rust corpus/first-principles IDs+envelope, mailbox/R2 thresholds, failure-before-ACK/retry/duplicate ACK, republish, plaintext wire scan, live relay. Protocol gap found: amendments.md pins AnchorIndex generation to Rust (browser has consumer only), and current Rust/web SnapshotPlaintext DocType is markdown|html so arbitrary binary asset/workspace-manifest wire representation is absent; do not invent web-only shape.\nCore progress 2026-07-10: exact native-compatible Markdown/HTML snapshot publication landed locally: deriveFileId/deriveSnapshotId, Rust-corpus snapshot_blob assembly, kind-aware durable outbox, authenticated R2 presign/PUT + sealed-body reverse path, blob-before-signed-pointer publisher, ACK-gated publication state, exact-ciphertext resume, stable FileId republish, and live real-relay proof. Validation: svelte-check 0/0; 58 unit files 0 failures; focused publisher 4/4; R2 10/10; live wrangler relay accepted snapshot_blob + SnapshotCreated; build and route-bundle gate green. Security review caught and fixed half-built multi-entry exposure by preparing every entry before durable enqueue; R2 upload responses/capabilities validate exact same-origin shape and stay opaque. Parent remains in progress because child attn-7xl.4.3.1 owns the missing coordinated asset/workspace-manifest payloads and canonical Rust/comrak browser index.","spec_id":"planning/web-authoring/03-browser-sharing.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:43:04Z","created_by":"James Lal","updated_at":"2026-07-11T05:49:46Z","started_at":"2026-07-11T04:02:40Z","closed_at":"2026-07-11T05:49:46Z","close_reason":"Encrypted workspace snapshots are canonical, resumable, ACK-gated, content-blind, and proven across native/browser protocol implementations.","labels":["authoring","cloudflare","e2ee","interop","ios","sharing","storage","web","webrtc"],"dependencies":[{"issue_id":"attn-7xl.4.3","depends_on_id":"attn-7xl.4","type":"parent-child","created_at":"2026-07-10T16:43:03Z","created_by":"James Lal","metadata":"{}"}],"comments":[{"id":"019f4fb9-8011-7d13-9d0f-2a1514bcfe50","issue_id":"attn-7xl.4.3","author":"James Lal","text":"Completed browser snapshot publication end-to-end: native-compatible encrypted blob/pointer pipeline across mailbox and R2, canonical workspace scopes and manifest-last ordering, durable atomic ACK-gated publication state, exact-ciphertext recovery, stable entry identities, live real-relay content-blind proof, and coordinated native/browser validation. Child 4.3.1 closes the previously identified asset/manifest/anchor protocol gap.","created_at":"2026-07-11T05:49:45Z"}],"dependency_count":0,"dependent_count":1,"comment_count":1} +{"_type":"issue","id":"attn-7xl.4.2","title":"Persist browser room ownership and invite capability","description":"## Objective\n\nAtomically bind a workspace share to encrypted owner identity, wrapped invite capability, published revision, and resumable transport state.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/03-browser-sharing.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nworkspace share records, browser identity/capability persistence, recovery tests\n\n## Validation\n\nTest reload/rejoin, interrupted commit, duplicate bootstrap, capability tamper, forget, and site-data loss behavior.\n\n## Source\n\nplanning/web-authoring/03-browser-sharing.md","acceptance_criteria":"The same browser profile can recopy links and resume authority without raw secret storage; interruption is idempotent; lost local data never claims owner recovery.","spec_id":"planning/web-authoring/03-browser-sharing.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:43:02Z","created_by":"James Lal","updated_at":"2026-07-11T03:56:05Z","started_at":"2026-07-11T03:52:33Z","closed_at":"2026-07-11T03:56:05Z","close_reason":"Room ownership + wrapped invite capability persistence landed (browser-workspace-share.ts, BrowserStorage.shares): bindShare seals the raw invite capability (room secret + owner signing/encryption secrets + device/participant ids + policy snapshot + published-revision pointer) under the workspace key via sealCapability (2.2), with AAD binding workspaceId/capId/roomId/scope so a record can't transplant between workspaces or rooms; metadata (roomId, scope, relayUrl, publication state) stays plaintext for UI. Idempotent by capId (same room = no-op resume, different room = conflict, ConstraintError race returns the winner); setPublication (pending/published/stopped) for 4.3/4.5; listShares/forgetShare for stop-sharing + rollback. Validation: 5 cases incl seal/open round-trip across reload, workspace-key binding (crypto-erasure leaves the cap permanently opaque under a fresh key), idempotency/conflict, publication lifecycle, input validation. 57 unit files, svelte-check 0, bundle gate green.","labels":["authoring","cloudflare","e2ee","interop","ios","sharing","storage","web","webrtc"],"dependencies":[{"issue_id":"attn-7xl.4.2","depends_on_id":"attn-7xl.4","type":"parent-child","created_at":"2026-07-10T16:43:01Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-7xl.4.1","title":"Implement browser owner room bootstrap","description":"## Objective\n\nCreate canonical rooms from the browser with native-compatible request signing, owner registration, RoomCreated/ParticipantJoined events, policy validation, and rollback.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/03-browser-sharing.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nweb browser session/bootstrap code, relay protocol vectors, Rust interop fixtures\n\n## Validation\n\nRun canonical request/ID/signature/event vectors and fail each bootstrap step to prove resume or cleanup.\n\n## Source\n\nplanning/web-authoring/03-browser-sharing.md","acceptance_criteria":"Browser-created rooms are indistinguishable at the protocol layer from native rooms, default to hybrid/allowBrowser, and never leave ambiguous partial ownership.","spec_id":"planning/web-authoring/03-browser-sharing.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:43:00Z","created_by":"James Lal","updated_at":"2026-07-11T03:51:34Z","started_at":"2026-07-11T03:51:20Z","closed_at":"2026-07-11T03:51:34Z","close_reason":"Browser owner room bootstrap landed (browser-owner-bootstrap.ts): createOwnedRoom generates the room secret, derives id/keys with the exact native KDFs, POSTs the canonical create body {v:2, wire policy mirroring bootstrap.rs incl idleTimeoutMs/longSession/powBits, ownerSigningKey, admissionKey} with Attn-Admission + Attn-Owner-Signature (new buildOwnerSignatureHeader over canonicalRequest) + Attn-PoW bound to ownerSigningKeyId; registers the owner device (kind owner, client attn-browser via parametrized buildRegisterDeviceBody); rolls back first-creates via fully-proofed owner DELETE (admission+PoW+signature) when registration fails, never on rejoin. Validation: 7 unit cases recomputing every header from first principles + ed25519-verifying the canonical DELETE; AND a live integration proof (scripts/test-owner-bootstrap-live.mjs) against the REAL relay under wrangler dev where the relay's own admission/owner-sig/PoW verification accepted create 201, idempotent rejoin 200, owner directory registration, and owner-signed teardown — protocol interop by referee. 56 unit files, svelte-check 0.","labels":["authoring","cloudflare","e2ee","interop","ios","sharing","storage","web","webrtc"],"dependencies":[{"issue_id":"attn-7xl.4.1","depends_on_id":"attn-7xl.4","type":"parent-child","created_at":"2026-07-10T16:43:00Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-7xl.3.6","title":"Validate local browser authoring","description":"## Objective\n\nRun Phase 02 end to end from new/import through type/reload/edit/export/reimport, storage tiers, writer takeover, and real mobile Safari.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/02-local-authoring.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nweb authoring E2E specs and device evidence\n\n## Validation\n\nExecute npm checks/tests and Playwright network-isolated flows for IDB/OPFS, large files, two tabs, mobile viewports, and real devices.\n\n## Source\n\nplanning/web-authoring/02-local-authoring.md","acceptance_criteria":"All Phase 02 scenarios pass and prove local creation/editing makes no relay request before Share and produces recoverable ordinary Markdown.","notes":"Scope clarification 2026-07-10: workspaces are folder-shaped and must interoperate identically across web and native. They may contain nested Markdown plus arbitrary binary assets with normalized relative paths. Safe raster types may render inline; unknown/active types are download-only. Share supports current file, selected entries, or whole workspace through one canonical manifest. The landing New workspace action creates untitled.md and opens the editor in one click with no dialog or network request. See updated planning/web-authoring/.\nValidate the full planning/web-authoring/ios-ux.md matrix, including reader-first and view-only states. This task depends on the dedicated reader task attn-7xl.3.7 as well as the editing task.","spec_id":"planning/web-authoring/02-local-authoring.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:42:59Z","created_by":"James Lal","updated_at":"2026-07-11T03:12:18Z","started_at":"2026-07-11T03:08:38Z","closed_at":"2026-07-11T03:12:18Z","close_reason":"Phase 02 validation gate complete, evidence in planning/web-authoring/validation-02.md: 54/54 unit files; continuous journey e2e (landing CTA -\u003e real editor typing -\u003e durable autosave -\u003e reload recovery -\u003e 1.2MB asset -\u003e zip export byte-verified in Node -\u003e reimport landing in prose) with interception proving zero non-origin requests end to end; nested/asset/OPFS-fallback cases green; two-tab lease honesty on both engines; 30/30 mobile Chromium+WebKit viewport matrix; 49/49 routes suite; staging deployed and verified. Fixed during gate: imports leading with an asset now select the first Markdown entry. Real-device matrix remains the 7xl.7 gate.","labels":["authoring","cloudflare","e2ee","editor","ios","storage","svelte","web"],"dependencies":[{"issue_id":"attn-7xl.3.6","depends_on_id":"attn-7xl.3.3","type":"blocks","created_at":"2026-07-10T16:43:55Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.3.6","depends_on_id":"attn-7xl.3","type":"parent-child","created_at":"2026-07-10T16:42:58Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.3.6","depends_on_id":"attn-7xl.3.1","type":"blocks","created_at":"2026-07-10T16:43:53Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.3.6","depends_on_id":"attn-7xl.3.2","type":"blocks","created_at":"2026-07-10T16:43:54Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.3.6","depends_on_id":"attn-7xl.3.4","type":"blocks","created_at":"2026-07-10T16:43:55Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.3.6","depends_on_id":"attn-7xl.3.5","type":"blocks","created_at":"2026-07-10T16:43:56Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.3.6","depends_on_id":"attn-7xl.3.7","type":"blocks","created_at":"2026-07-10T17:47:59Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":6,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-7xl.3.5","title":"Add iOS editing and keyboard behavior","description":"## Objective\n\nAdd safe-area, virtual-keyboard, top file sheet, bottom review sheet, selection toolbar, and touch behavior while keeping save/share status visible.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/02-local-authoring.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nresponsive editor components/styles and mobile interaction tests\n\n## Validation\n\nRun mobile Chromium/WebKit viewports plus real iPhone/iPad keyboard, rotation, selection, file switching, and export checks.\n\n## Source\n\nplanning/web-authoring/02-local-authoring.md","acceptance_criteria":"Reader position survives entering/leaving edit mode; title/body targets and formatting controls are accessible with 44 px touch targets; toolbar tracks the real visual keyboard without obscuring content/save state; autosave reports Saved only after durable commit; dictation/autocorrect/undo/hardware keyboard/rotation/process-restart cases pass on real current iPhone/iPad; editing-disabled mode leaves the reader/reviewer fully usable.","notes":"Editing is an explicit mode over the reader contract, not a prerequisite for iOS usefulness. Use visualViewport and safe-area insets; keep save state above the keyboard; support selection, dictation, autocorrect, undo, hardware keyboard, rotation, and process recovery. If editing capability fails, the reader/reviewer from attn-7xl.3.7 must remain complete.","spec_id":"planning/web-authoring/02-local-authoring.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:42:57Z","created_by":"James Lal","updated_at":"2026-07-11T03:07:51Z","started_at":"2026-07-11T02:49:15Z","closed_at":"2026-07-11T03:07:51Z","close_reason":"iOS editing behavior landed: fixed formatting bar with 44x44px targets (Bold/Italic/H2/bullet list/Undo/Redo via new exported ProseMirror commands on Editor.svelte) positioned by visualViewport resize/scroll — riding above the thumb dock at rest and directly above the visual keyboard when it appears, with live save state inside the bar; workspace title is a separate accessible edit target in edit mode (inline input, durable rename); the compact header is sticky on mobile so save/share state stays visible; Files/Review sheets blur the editor before opening so they never stack under the keyboard; exiting edit returns to the retained reading position (3.7's position memory). Editing capability absence keeps the full reader (3.7 Open-native dock). Validation: Chromium+WebKit matrix now 30/30 (formatting produces '## **...**' markdown durably, title rename durable to the desk), routes suite 48/48, 54/54 unit files, svelte-check 0, staging deployed. Dictation/autocorrect/hardware-keyboard verification is real-device scope (7xl.7 gate).","labels":["authoring","cloudflare","e2ee","editor","ios","mobile","storage","svelte","web"],"dependencies":[{"issue_id":"attn-7xl.3.5","depends_on_id":"attn-7xl.3","type":"parent-child","created_at":"2026-07-10T16:42:57Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-7xl.3.4","title":"Add multi-file and asset workspace support","description":"## Objective\n\nSupport safe local file create/rename/delete/navigation plus file and workspace Markdown/zip export, rejecting traversal, unsafe HTML, binary, and oversized input.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/02-local-authoring.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nweb file rail, path validation, import/export utilities\n\n## Validation\n\nRun nested-path, traversal, binary, size-cap, unsafe HTML, zip, and byte-round-trip tests.\n\n## Source\n\nplanning/web-authoring/02-local-authoring.md","acceptance_criteria":"Multi-file workspaces remain navigable and export ordinary Markdown; invalid paths/content fail before commit; user-authored HTML is not executed.","notes":"Approach: WorkspaceAppService gains createMarkdownEntry/addAssetFiles/renameEntry/deleteEntry/readEntryBytes/exportWorkspace (real passthrough to storage service; mock in-memory). import-files.ts gains per-file 64MiB cap + zip expansion via dynamically imported fflate (paths normalized, traversal rejects whole import). New export-zip.ts (fflate zip build + download trigger). EditorShell file rail: inline new-markdown input, add-assets picker, active-entry rename/delete/download actions, blob-URL inline preview for safe rasters only (svg/html stay download-only, never same-origin-executed), workspace zip export. Mutations navigate/reload through the existing full-page data flow. Tests: zip round-trip + traversal/oversize unit cases; e2e rail CRUD + png preview + zip download.","spec_id":"planning/web-authoring/02-local-authoring.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:42:55Z","created_by":"James Lal","updated_at":"2026-07-11T02:34:54Z","started_at":"2026-07-11T02:20:46Z","closed_at":"2026-07-11T02:34:54Z","close_reason":"Multi-file/asset support landed: file rail gains inline New-Markdown creation (nested paths, .md enforced), Add-files picker (multi-select + folder + zip expansion via dynamically-imported fflate), active-entry rename/delete(in-app confirm)/download, and workspace zip export preserving exact paths+bytes. Safe rasters render inline from decrypted bytes via short-lived blob URLs (revoked on nav); svg/html/unknown types stay download-only and are never navigated as same-origin content. Import safety: 64MiB per-file cap, traversal/absolute paths abort the whole import, corrupt zips are clear errors, __MACOSX/.DS_Store junk skipped. Service interface + real/mock impls extended (createMarkdownEntry/addAssetFiles/renameEntry/deleteEntry/readEntryBytes/exportWorkspace). Validation: 8 import/export unit cases (zip round-trip byte-identical, traversal-in-zip rejected), 48/48 e2e locally + authoring suite green on staging (rail CRUD, decoded 1x1 png naturalWidth===1 proving real decrypt-to-preview, zip download, nested zip import); 54/54 unit files, svelte-check 0, bundle gate green.","labels":["assets","authoring","cloudflare","e2ee","editor","ios","storage","svelte","web"],"dependencies":[{"issue_id":"attn-7xl.3.4","depends_on_id":"attn-7xl.3","type":"parent-child","created_at":"2026-07-10T16:42:55Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-7xl.3.3","title":"Add autosave and revision recovery","description":"## Objective\n\nReuse the existing editor stack, commit immutable revisions after bounded debounce, flush on lifecycle signals, and recover the last committed head with honest save/conflict state.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/02-local-authoring.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nweb editor integration, autosave controller, recovery UI/tests\n\n## Validation\n\nTest typing/reload, pagehide, visibility loss, quota failure, process-like termination, conflict, and stale lease behavior.\n\n## Source\n\nplanning/web-authoring/02-local-authoring.md","acceptance_criteria":"Saved appears only after durable commit; reload/process restart recovers the last committed revision; failure preserves editable/exportable content and never reports a false save.","spec_id":"planning/web-authoring/02-local-authoring.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:42:54Z","created_by":"James Lal","updated_at":"2026-07-11T02:20:13Z","started_at":"2026-07-11T02:08:49Z","closed_at":"2026-07-11T02:20:13Z","close_reason":"Autosave + revision recovery landed: AutosaveController (pure, deterministic-tested) commits immutable revisions after bounded debounce (1.2s quiet / 8s max under continuous typing), serializes in-flight commits, retries failures without losing text, and reports honest states — 'Saved on this device' only after the durable IndexedDB commit ('Saving…' during, 'Storage needs attention' on failure, retry automatic). EditingSession on the service interface: real impl holds the cross-tab writer lease (5s heartbeat inside the 15s lease), performs fenced head-tracked commits, releases on exit; denial renders an honest 'Another tab is editing' read-only state. EditorShell gains an explicit Edit/Done mode that dynamically loads the existing ProseMirror stack (bundle gate intact) with a per-change watcher plugin; flush on visibilitychange/pagehide/unmount; durable commit counter observable in DOM. Validation: 5 autosave unit cases (debounce/bounded/flush/failure-retry/in-flight), 46/46 e2e locally incl type-\u003eautosave-\u003ereload recovery and two-tab lease denial+handoff, authoring suite green against deployed staging, svelte-check 0, bundle gate green.","labels":["authoring","cloudflare","e2ee","editor","ios","storage","svelte","web"],"dependencies":[{"issue_id":"attn-7xl.3.3","depends_on_id":"attn-7xl.3","type":"parent-child","created_at":"2026-07-10T16:42:54Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-7xl.3.2","title":"Wire one-click workspace and local desk actions","description":"## Objective\n\nConnect New document, Markdown/multi-file import, recent workspace resume, rename, and delete to the durable workspace service.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/02-local-authoring.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nweb hosted local home components/routes and import actions\n\n## Validation\n\nPlaywright intercepts network while creating/importing/resuming/deleting and asserts zero relay requests before Share.\n\n## Source\n\nplanning/web-authoring/02-local-authoring.md","acceptance_criteria":"Every local desk action commits atomically and renders the correct recent-workspace state; blank creation works offline with no network request.","notes":"Approach: types.ts gains async WorkspaceAppService view interface (persistenceMode sync; list/loadDetail/readBodyText/create/importFiles/renameWorkspace/deleteWorkspace async; shareScopeFor pure). MockWorkspaceService implements it (scenarios stay for ?shell=demo|private|blocked|quota|empty); new real-service.ts adapter maps BrowserWorkspaceService records -\u003e view types (kind/mediaType -\u003e presentation; saveState from durable outcomes). app/main.ts: no ?shell param -\u003e DYNAMIC import of real adapter (bundle gate forbids static crypto in app entry) with 'Opening your desk...' placeholder + unavailable degraded fallback; #new intent performs the real atomic create then history.replaceState to /app/w/:id/untitled.md. DeskHome/EditorShell/AppShell become props+callback driven (typed props, no service coupling in leaves). Desk gains rename (inline) + delete (in-app confirm) + import via file picker; editor shows the real head body text. E2E: mock specs move to ?shell=demo; new hosted-authoring.spec.ts proves one-click create -\u003e reload persistence -\u003e import/export with network interception showing zero relay requests before Share.","spec_id":"planning/web-authoring/02-local-authoring.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:42:52Z","created_by":"James Lal","updated_at":"2026-07-11T02:08:15Z","started_at":"2026-07-11T01:47:23Z","closed_at":"2026-07-11T02:08:15Z","close_reason":"Local desk wired to real storage: app entry boots RealWorkspaceAppService (dynamic import keeps crypto out of the static graph; ?shell= scenarios keep the mock for degraded states), shells rewired to typed async props/callbacks. One-click New workspace (button + landing /app#new intent) atomically creates untitled.md, opens the editor in place, and history.replaceState's to /app/w/:id/untitled.md — zero off-origin requests, proven by interception. Desk lists real workspaces with durable inline rename + in-app-confirmed delete + resume; import via picker preserves relative paths/media types (folder-safe mapping in import-files.ts + crypto-free shared entry-path module); editor shows the real head body; storage/open pages take real health+workspaces. Validation: 44/44 Playwright vs wrangler dev AND vs deployed staging (version f0a61804) incl 4 real-authoring flows; 52/52 unit files (one known flake filed as bug); svelte-check 0; bundle gate green after extracting entry-path.ts.","labels":["authoring","cloudflare","e2ee","editor","ios","storage","svelte","web"],"dependencies":[{"issue_id":"attn-7xl.3.2","depends_on_id":"attn-7xl.3","type":"parent-child","created_at":"2026-07-10T16:42:52Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-7xl.3.1","title":"Add browser workspace service and Svelte state","description":"## Objective\n\nExpose typed workspace load/create/import/export/edit operations and instance-scoped Svelte 5 state over the storage layer.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/02-local-authoring.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nweb workspace service and Svelte state adapter\n\n## Validation\n\nRun service unit tests, Svelte checks, lifecycle tests, and multiple-instance isolation tests.\n\n## Source\n\nplanning/web-authoring/02-local-authoring.md","acceptance_criteria":"UI state reflects durable storage outcomes, no module-level mutable state leaks between instances, and service errors map to explicit user-visible states.","spec_id":"planning/web-authoring/02-local-authoring.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:42:51Z","created_by":"James Lal","updated_at":"2026-07-11T01:45:59Z","closed_at":"2026-07-11T01:45:59Z","close_reason":"Browser workspace service + Svelte state landed: workspace-service.ts (BrowserWorkspaceService over BrowserStorage) with typed open (capability-gated: unusable browsers raise kind=unavailable), list/load/create (one-click untitled.md, zero network), commitText/readHeadText/Bytes, createMarkdown/addAsset, rename entry+workspace (new WorkspaceStore.renameWorkspace), delete/select, import/export preserving nested paths+bytes+media types, lease access (BrowserStorage.leases()), UI label shaping (sizeLabel/relativeTimeLabel), and mapError -\u003e {conflict|quota|unavailable|storage} user-visible kinds. workspace-state.svelte.ts is the thin instance-scoped runes adapter (status/workspaces/persistence/saveState/error/current all instance fields; save state reflects durable outcomes only, never optimistic). Validation: 9 service cases incl import/export round-trip, error kinds, two-instance isolation, fenced commit through the service; 52/52 unit files, svelte-check 0, bundle gate green (service not yet statically imported by app entry — 3.2 must dynamic-import it).","labels":["authoring","cloudflare","e2ee","editor","ios","storage","svelte","web"],"dependencies":[{"issue_id":"attn-7xl.3.1","depends_on_id":"attn-7xl.3","type":"parent-child","created_at":"2026-07-10T16:42:50Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-7xl.2.7","title":"Validate workspace storage v3","description":"## Objective\n\nRun the complete storage validation matrix across migrations, crypto, atomicity, OPFS fallback, quota failure, reload, and required iOS Safari modes.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/01-storage-foundation.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nweb storage tests, Playwright fixtures, device evidence\n\n## Validation\n\nRun web checks/storage tests/property and fault tests, Chromium/WebKit reload cases, and manual current iOS normal/private/Home Screen/low-storage cases.\n\n## Source\n\nplanning/web-authoring/01-storage-foundation.md","acceptance_criteria":"All Phase 01 validation cases pass; the evidence proves IDB is a complete baseline and no failure mode silently loses the last committed plaintext-equivalent revision.","spec_id":"planning/web-authoring/01-storage-foundation.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:42:49Z","created_by":"James Lal","updated_at":"2026-07-11T01:39:36Z","started_at":"2026-07-11T01:34:13Z","closed_at":"2026-07-11T01:39:36Z","close_reason":"Automated storage v3 validation gate complete, evidence in planning/web-authoring/validation-01.md: 51/51 unit files (63 storage-specific cases across schema/crypto/store/OPFS/probe/lease/validation suites), migration corpus green, seeded 120-step property test (76 mutating ops, all invariants held), fault injection (injected QuotaExceededError preserves last committed head + recovers; aborts leave prior state; OPFS write-failure/corrupt-read fail closed with fallback; orphans swept), and 8/8 real-browser Playwright persistence/reload tests on BOTH Chromium and WebKit (create/commit survives reload, 700KiB OPFS/fallback body, honest capability probe incl WebKit-ephemeral volatile, cross-tab lease read-only second context; npm run test:e2e:storage). Real iOS Safari device pass is a human gate filed as attn-7xl.2.9, consolidated with the attn-7xl.7 device matrix.","labels":["authoring","cloudflare","crypto","e2ee","indexeddb","ios","opfs","storage","web"],"dependencies":[{"issue_id":"attn-7xl.2.7","depends_on_id":"attn-7xl.2.5","type":"blocks","created_at":"2026-07-10T16:43:52Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.2.7","depends_on_id":"attn-7xl.2.1","type":"blocks","created_at":"2026-07-10T16:43:49Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.2.7","depends_on_id":"attn-7xl.2.2","type":"blocks","created_at":"2026-07-10T16:43:50Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.2.7","depends_on_id":"attn-7xl.2","type":"parent-child","created_at":"2026-07-10T16:42:49Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.2.7","depends_on_id":"attn-7xl.2.6","type":"blocks","created_at":"2026-07-10T16:43:53Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.2.7","depends_on_id":"attn-7xl.2.4","type":"blocks","created_at":"2026-07-10T16:43:51Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.2.7","depends_on_id":"attn-7xl.2.3","type":"blocks","created_at":"2026-07-10T16:43:51Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":6,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-7xl.2.6","title":"Add fenced cross-tab writer leases","description":"## Objective\n\nImplement IndexedDB-backed lease acquisition, fencing tokens, heartbeat, takeover, and read-only secondary tabs; use BroadcastChannel only as notification.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/01-storage-foundation.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nbrowser storage lease module, multi-context tests\n\n## Validation\n\nRun simultaneous acquire, expired takeover, delayed stale write, abrupt-tab-loss, and BroadcastChannel-absent tests.\n\n## Source\n\nplanning/web-authoring/01-storage-foundation.md","acceptance_criteria":"Exactly one current fencing token can commit; stale tabs cannot overwrite; takeover is explicit and works without Web Locks or BroadcastChannel.","notes":"Approach: browser-workspace-lease.ts WorkspaceLeaseManager over the existing workspace_leases store + WorkspaceStore fencing checks (2.3). acquire(): single readwrite IDB tx (serialized across tabs) — grants when absent/expired/same-holder, bumps fencingToken on takeover, returns null when another live holder owns it (caller enters read-only). heartbeat(): extends expiresAt only for exact holder+token, else StorageConflictError. release(): deletes only if owned. BroadcastChannel 'attn-workspace-lease' advisory notifications (injectable factory; never decides correctness). Tests: fresh grant, live-holder denial, expiry takeover fences old holder's store writes, heartbeat extend/conflict, release/reacquire token monotonicity, concurrent acquire race yields one winner, channel events.","spec_id":"planning/web-authoring/01-storage-foundation.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:42:48Z","created_by":"James Lal","updated_at":"2026-07-11T01:33:11Z","started_at":"2026-07-11T01:30:43Z","closed_at":"2026-07-11T01:33:11Z","close_reason":"Fenced cross-tab writer leases landed (browser-workspace-lease.ts): WorkspaceLeaseManager decides ownership via serialized IDB readwrite transactions — acquire grants on absent/expired/released/same-holder and bumps the fencing token on takeover (returns null while another holder is live, so secondary tabs stay read-only); heartbeat extends only for the exact holder+token, else StorageConflictError; release writes an expired tombstone preserving the token counter so fencing tokens are monotonic and the releasing holder is immediately fenced. BroadcastChannel notifications are advisory only (injectable, never authoritative). Combined with 2.3's mutation fencing, a suspended zombie tab cannot write after losing its lease. Validation: 6 cases incl expiry takeover fencing store writes, heartbeat extension keeping rivals out, monotonic tokens across release/reacquire, and a 3-way concurrent acquire race resolving to exactly one winner; 50/50 unit files, svelte-check 0.","labels":["authoring","cloudflare","crypto","e2ee","indexeddb","ios","opfs","storage","web"],"dependencies":[{"issue_id":"attn-7xl.2.6","depends_on_id":"attn-7xl.2","type":"parent-child","created_at":"2026-07-10T16:42:47Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-7xl.2.5","title":"Probe storage capabilities and durability","description":"## Objective\n\nCapability-test IndexedDB, non-extractable CryptoKey cloning, OPFS, estimate, and persistence, returning typed persistent/best_effort/volatile/unsupported results with precise causes.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/01-storage-foundation.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nbrowser storage capability module and state types\n\n## Validation\n\nRun feature-failure matrix without user-agent sniffing in Chromium/WebKit and capability mocks.\n\n## Source\n\nplanning/web-authoring/01-storage-foundation.md","acceptance_criteria":"Each real operation is probed, unsupported APIs degrade accurately, unknown quota remains unknown, and UI consumers receive enough cause data for remediation.","notes":"Approach: new browser-storage-probe.ts. probeStorageCapabilities() runs real round-trips (never UA sniffing): (1) IndexedDB probe DB put/get/delete, (2) non-extractable HKDF CryptoKey survives an IDB structured-clone round-trip and still deriveBits, (3) OPFS write/read/delete probe file. Typed result {mode: persistent|best_effort|volatile|unsupported, per-probe {ok, cause {name,message}}, persisted, estimate}. Mode rules: idb or key probe fails -\u003e unsupported; persisted()===true -\u003e persistent; OPFS API present but refuses with SecurityError/NotAllowedError/UnknownError (Safari Private Browsing signal) -\u003e volatile; else best_effort. Optional requestPersist. Helpers: toPersistenceMode() mapping to the shell union, quotaPressure(estimate). Tests with fake navigator/factory/crypto covering each mode + precise causes.","spec_id":"planning/web-authoring/01-storage-foundation.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:42:46Z","created_by":"James Lal","updated_at":"2026-07-11T01:29:00Z","started_at":"2026-07-11T01:26:49Z","closed_at":"2026-07-11T01:29:00Z","close_reason":"Storage capability/durability probes landed (browser-storage-probe.ts): probeStorageCapabilities() exercises real APIs — IndexedDB probe-DB round-trip, non-extractable HKDF CryptoKey structured-clone round-trip that still deriveBits, OPFS write/read/delete — and reports typed mode persistent|best_effort|volatile|unsupported with precise per-probe {name,message} causes, persisted(), optional persist() request, and quota estimate. No UA sniffing: volatile is only claimed when the OPFS API exists but refuses with a security-shaped error (WebKit Private Browsing signal); Chromium private windows honestly report best_effort. Helpers toPersistenceMode() (maps onto the shell union incl quota-pressure override) and quotaPressure(). 8 test cases cover every mode + causes; 49/49 unit files, svelte-check 0.","labels":["authoring","cloudflare","crypto","e2ee","indexeddb","ios","opfs","storage","web"],"dependencies":[{"issue_id":"attn-7xl.2.5","depends_on_id":"attn-7xl.2","type":"parent-child","created_at":"2026-07-10T16:42:46Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-7xl.2.4","title":"Add encrypted OPFS tier with IDB fallback","description":"## Objective\n\nStore large encrypted bodies at opaque OPFS paths using temp-write/promote/GC while retaining an encrypted IndexedDB Blob/ArrayBuffer fallback for every operation.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/01-storage-foundation.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nbrowser OPFS adapter, workspace GC store, storage tests\n\n## Validation\n\nInject missing OPFS, write/close failures, transaction aborts, orphan cleanup, threshold crossings, and read verification.\n\n## Source\n\nplanning/web-authoring/01-storage-foundation.md","acceptance_criteria":"Normal Markdown workspaces work without OPFS; successful OPFS writes are integrity-checked; crashes cannot expose plaintext or lose the last committed revision.","notes":"Approach: SealedBody gains 'idb-large' fallback variant (validator caps: idb\u003c=512KiB inline, idb-large\u003c=MAX_BODY+tag). WorkspaceStore takes {filesystem, inlineThresholdBytes} (BrowserStorage.workspaces passes its OPFS-backed SealedBlobFileSystem). Seal routing: sealed\u003ethreshold \u0026\u0026 fs -\u003e OPFS via write-ahead gc-intent (gc record committed first, file written to opaque sha256-derived path, read-back verified, revision commit tx deletes intent); any OPFS failure falls back to idb-large + best-effort file delete. openBody supports opfs (length check + AEAD auth) and idb-large. sweepGc() deletes orphaned files then intents. deleteWorkspace deletes the workspace OPFS prefix. Tests with in-memory fault-injectable filesystem: routing, corrupt-file fail-closed, absent-OPFS fallback, write-failure fallback, crash-orphan sweep, threshold config, rename/delete of opfs bodies.","spec_id":"planning/web-authoring/01-storage-foundation.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:42:45Z","created_by":"James Lal","updated_at":"2026-07-11T01:25:46Z","started_at":"2026-07-11T01:20:53Z","closed_at":"2026-07-11T01:25:46Z","close_reason":"Encrypted OPFS tier + IDB fallback landed: sealed bodies over a configurable threshold route to OPFS at opaque sha256-derived paths (no workspace/revision id leakage) behind a write-ahead GC intent (intent committed first, file written + read-back verified, revision commit tx clears the intent); any OPFS absence/failure falls back to encrypted IndexedDB (inline 'idb' under the 512KiB cap, new 'idb-large' variant above it) so no operation ever blocks on OPFS. sweepGc() deletes orphaned files/intents and workspace prefixes; live committed files survive sweeps; rename/delete retire OPFS bodies through the ledger; deleteWorkspace records a durable prefix-removal intent then executes it. BrowserStorage.workspaces now passes its OPFS-backed filesystem. Validation: 7 new fault-injection cases (routing, corrupt-read fail-closed, missing-OPFS medium/oversized fallbacks, write-failure fallback with no stray file/intent, crash-orphan sweep preserving live files, retire-on-rename/delete, prefix removal); 48/48 unit files, svelte-check 0, bundle gate green.","labels":["authoring","cloudflare","crypto","e2ee","indexeddb","ios","opfs","storage","web"],"dependencies":[{"issue_id":"attn-7xl.2.4","depends_on_id":"attn-7xl.2","type":"parent-child","created_at":"2026-07-10T16:42:44Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-7xl.2.3","title":"Implement atomic workspace transactions","description":"## Objective\n\nAdd crash-safe create, rename, delete, select, immutable revision commit, and head-advance APIs with monotonic timestamps and fencing conflict checks.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/01-storage-foundation.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nbrowser-storage workspace APIs and transaction/property tests\n\n## Validation\n\nRun atomicity, idempotency, conflict, aborted transaction, and last-committed-head tests.\n\n## Source\n\nplanning/web-authoring/01-storage-foundation.md","acceptance_criteria":"Multi-record operations are atomic, immutable history is preserved, stale writers cannot advance heads, and errors leave a recoverable committed state.","notes":"Approach: new browser-workspace-store.ts (WorkspaceStore, exposed as BrowserStorage.workspaces getter) + browser-idb.ts shared helpers (requestValue/transactionDone/isConstraintError). Seal-outside-transaction design: WebCrypto cannot be awaited inside an IDB tx, so commit paths pre-seal with a tentative clock and retry (bounded) if the workspace clock moved; final write is one atomic multi-store tx (workspaces+entries+revisions+gc) enforcing head expectation, path uniqueness (store keys + unique history index), strictly-increasing per-workspace clock, clamped monotonic wall timestamps, and optional lease fencing checks (full lease lifecycle lands in 2.6). Rename re-seals the head at the new path (AAD binds path) and GC-queues old revisions; delete tombstones + GC-queues; deleteWorkspace crypto-erases key first. OPFS bodies deferred to 2.4 (loader throws typed error). Immutable history via [workspaceId,path,clock] unique index; idempotent commits dedupe by revisionId+bodyHash.","spec_id":"planning/web-authoring/01-storage-foundation.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:42:43Z","created_by":"James Lal","updated_at":"2026-07-11T01:19:47Z","started_at":"2026-07-11T01:11:29Z","closed_at":"2026-07-11T01:19:47Z","close_reason":"Atomic workspace transactions landed (browser-workspace-store.ts, exposed as BrowserStorage.workspaces): createWorkspace (one-click: workspace+entry+revision in one tx; orphan-key retry), createEntry (path uniqueness, tombstone revive, entry cap), commitRevision (optimistic expectedHead, idempotent revisionId replay, conflict on id/content mismatch), renameEntry (re-seals head at new path since AAD binds path; retires old revisions; selection follows), deleteEntry (tombstone + revision retirement + selection clear), selectEntry, deleteWorkspace (crypto-erases key first). Seal-outside-tx design with bounded clock-race retries since WebCrypto cannot run inside an IDB tx; per-workspace clock strictly increases; wall timestamps clamped monotonic; optional lease fencing checks (lifecycle in 2.6); OPFS bodies typed-error until 2.4. IDB helpers extracted to browser-idb.ts. Validation: 13 new cases covering atomicity, idempotency, conflicts, aborted tx leaving prior head intact, backwards wall clock, fencing; 47/47 unit files, svelte-check 0, bundle gate green.","labels":["authoring","cloudflare","crypto","e2ee","indexeddb","ios","opfs","storage","web"],"dependencies":[{"issue_id":"attn-7xl.2.3","depends_on_id":"attn-7xl.2","type":"parent-child","created_at":"2026-07-10T16:42:42Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-7xl.2.2","title":"Seal workspace keys and revisions","description":"## Objective\n\nImplement non-extractable workspace HKDF roots, domain-separated local keys, sealed revision bodies, and wrapped room invite capabilities with routing metadata bound as AAD.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/01-storage-foundation.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nbrowser storage crypto modules, canonical crypto vectors/tests\n\n## Validation\n\nRun key non-extractability, seal/open, AAD tamper, vector, and zeroization-oriented tests.\n\n## Source\n\nplanning/web-authoring/01-storage-foundation.md","acceptance_criteria":"No raw workspace root, revision plaintext, owner private material, or invite secret is stored; compatible ciphertext opens after reload and tampering fails closed.","spec_id":"planning/web-authoring/01-storage-foundation.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:42:41Z","created_by":"James Lal","updated_at":"2026-07-11T01:11:00Z","started_at":"2026-07-11T01:06:36Z","closed_at":"2026-07-11T01:11:00Z","close_reason":"Workspace sealing crypto landed (browser-workspace-crypto.ts): per-workspace non-extractable HKDF roots generated on-device (raw bytes never escape generateWorkspaceRootKey), domain-separated subkeys (revision/capability/recovery v1 infos, KAT-pinned), XChaCha20-Poly1305 seal/open for revision bodies, wrapped room-invite capabilities, and recovery payloads with routing metadata bound as canonical AAD; transient subkeys/nonces/AAD zeroed. BrowserStorage gains createWorkspaceKey (single-root conflict), getWorkspaceRootKey (validated non-extractable), deleteWorkspaceKey (crypto-erasure primitive, delete-key-first contract). Validation: 9 test cases — KAT vectors, non-extractability/export failure, reload round-trip, fresh nonces, 6-way AAD swap + bit-flip + truncation + bad nonce all fail closed, capability/recovery binding, crypto-erasure leaves old bytes permanently opaque under a fresh key. 46/46 unit files, svelte-check 0, bundle gate green (no crypto in landing/app).","labels":["authoring","cloudflare","crypto","e2ee","indexeddb","ios","opfs","storage","web"],"dependencies":[{"issue_id":"attn-7xl.2.2","depends_on_id":"attn-7xl.2","type":"parent-child","created_at":"2026-07-10T16:42:41Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-7xl.2.1","title":"Define IndexedDB schema v3 migrations","description":"## Objective\n\nAdd versioned stores, keys, indexes, runtime record validation, size caps, and v2-to-v3 workspace migration fixtures to the existing database.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/01-storage-foundation.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nweb/src/lib/review/browser-storage.ts and migration tests/fixtures\n\n## Validation\n\nRun empty/v1/v2/interrupted/corrupt migration corpus and browser storage tests.\n\n## Source\n\nplanning/web-authoring/01-storage-foundation.md","acceptance_criteria":"Existing room records remain compatible; every workspace store upgrades atomically; malformed or interrupted state fails recoverably without silent data loss.","notes":"Scope clarification 2026-07-10: workspaces are folder-shaped and must interoperate identically across web and native. They may contain nested Markdown plus arbitrary binary assets with normalized relative paths. Safe raster types may render inline; unknown/active types are download-only. Share supports current file, selected entries, or whole workspace through one canonical manifest. The landing New workspace action creates untitled.md and opens the editor in one click with no dialog or network request. See updated planning/web-authoring/.","spec_id":"planning/web-authoring/01-storage-foundation.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:42:40Z","created_by":"James Lal","updated_at":"2026-07-11T01:06:10Z","started_at":"2026-07-11T00:57:37Z","closed_at":"2026-07-11T01:06:10Z","close_reason":"Schema v3 landed: BROWSER_STORAGE_SCHEMA_VERSION 2-\u003e3 with eight new workspace stores (workspaces, workspace_keys, entries [workspaceId+path], revisions [workspaceId+revisionId, unique by_workspace_path_clock history index], share_caps, recovery, gc, leases) created in one upgrade so later steps need no further version bump. New browser-workspace-schema.ts defines typed records, size caps (name 256B, path 1KiB/64 segments, inline sealed body 512KiB, body hard cap 256MiB), NFC path normalization rejecting absolute/dot-dot/backslash/control chars, and per-store runtime validators pinning record version. Privacy split documented: metadata plaintext, revision bodies/caps/recovery sealed. Errors extracted to browser-storage-errors.ts (re-exported). Validation: 10-case corpus (empty/v1/v2/interrupted-claim/corrupt-v3) all passing — legacy rooms, inbox/cursors survive upgrade, interrupted claims stay hidden and roll back, corrupt records fail loudly with the DB re-openable; 45/45 unit files, svelte-check 0, bundle gate green.","labels":["authoring","cloudflare","crypto","e2ee","indexeddb","ios","opfs","storage","web"],"dependencies":[{"issue_id":"attn-7xl.2.1","depends_on_id":"attn-7xl.2","type":"parent-child","created_at":"2026-07-10T16:42:39Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-7xl.1.5","title":"Validate unified web presence","description":"## Objective\n\nRun the complete Phase 00 validation gate and capture actionable evidence for routes, bundles, responsive layout, accessibility, and Cloudflare preview.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/00-web-presence.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nweb tests/config and planning validation notes\n\n## Validation\n\nRun npm checks/tests/build, route chunk inspection, desktop/iPhone/320 px Playwright, keyboard/a11y audit, and Cloudflare preview smoke.\n\n## Source\n\nplanning/web-authoring/00-web-presence.md","acceptance_criteria":"Every Phase 00 validation item passes with reproducible commands and screenshots; failures are fixed or filed before this task closes.","spec_id":"planning/web-authoring/00-web-presence.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:42:38Z","created_by":"James Lal","updated_at":"2026-07-11T00:56:52Z","started_at":"2026-07-11T00:45:32Z","closed_at":"2026-07-11T00:56:52Z","close_reason":"Phase 00 validation gate complete with evidence in planning/web-authoring/validation-00.md: svelte-check 0/0, 44 unit files pass, build + module-precision bundle boundaries green, 40/40 Playwright vs local wrangler dev AND vs deployed staging.attn.sh (version a4234267), axe WCAG A/AA 0 violations across all pages/dialogs/degraded states, keyboard-only flows pass, 320px zero overflow on all surfaces, desktop+iPhone light/dark screenshots captured, staging deploy live-verified with pinned CSP on one origin. Fixed during gate: .file-size contrast (serious), axe flake stabilized by awaiting fonts/images. WebKit + real-device matrix deferred to attn-7xl.7 per plan.","labels":["authoring","cloudflare","e2ee","frontend","ios","landing","storage","svelte","web"],"dependencies":[{"issue_id":"attn-7xl.1.5","depends_on_id":"attn-7xl.1.4","type":"blocks","created_at":"2026-07-10T16:43:49Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.1.5","depends_on_id":"attn-7xl.1","type":"parent-child","created_at":"2026-07-10T16:42:37Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.1.5","depends_on_id":"attn-7xl.1.1","type":"blocks","created_at":"2026-07-10T16:43:47Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.1.5","depends_on_id":"attn-7xl.1.3","type":"blocks","created_at":"2026-07-10T16:43:48Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.1.5","depends_on_id":"attn-7xl.1.2","type":"blocks","created_at":"2026-07-10T16:43:47Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":4,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-7xl.1.4","title":"Prepare split-landing retirement","description":"## Objective\n\nDocument and implement metadata/redirect parity needed to retire the separate Vercel landing only after Cloudflare parity is proven.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/00-web-presence.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nsite deployment config, web hosted deployment docs, redirects/metadata\n\n## Validation\n\nCompare metadata, install links, route behavior, and screenshots between current and Cloudflare preview.\n\n## Source\n\nplanning/web-authoring/00-web-presence.md","acceptance_criteria":"A reversible cutover checklist exists, parity gaps are resolved, and no Vercel ownership is removed before the final rollout feature.","spec_id":"planning/web-authoring/00-web-presence.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:42:36Z","created_by":"James Lal","updated_at":"2026-07-11T00:45:02Z","started_at":"2026-07-11T00:39:27Z","closed_at":"2026-07-11T00:45:02Z","close_reason":"Cutover prepared, not executed: planning/web-authoring/07-landing-cutover.md documents verified current state (attn.sh 307-\u003ewww Vercel; www/review 404 = production invites broken today; native default already attn.sh/review; relay.attn.sh already allowlists attn.sh), parity matrix (metadata/favicons/fonts/themes/install links resolved; theme-init flash documented as accepted), reversible step-by-step cutover + rollback via recorded Vercel DNS, and post-cutover verification incl the observed cf-cache-status HIT-on-HTML hazard. Implemented: worker www-\u003eapex 308 (fragment-safe), CSP parameterized by RELAY_ORIGIN wrangler var (staging pins relay-staging, prepared wrangler.production.jsonc pins relay.attn.sh + attn.sh/www custom domains, deploy-gated on attn-7xl.7 owner approval), deploy script verifies the var pin. Unit tests for CSP/redirect helpers; 44/44 unit files, svelte-check 0, 28/28 e2e, dry-run green. No Vercel ownership touched.","labels":["authoring","cloudflare","e2ee","frontend","ios","landing","storage","svelte","web"],"dependencies":[{"issue_id":"attn-7xl.1.4","depends_on_id":"attn-7xl.1","type":"parent-child","created_at":"2026-07-10T16:42:36Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-7xl.1.3","title":"Build local workspace page shells","description":"## Objective\n\nImplement typed non-persistent Svelte shells for local home, editor, Share, storage/recovery, and degraded states using an injected mock workspace service.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/00-web-presence.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nweb/src/hosted/app, shared Svelte components and tokens\n\n## Validation\n\nRun Svelte checks plus keyboard and Playwright screenshots at desktop, 390 px, and 320 px.\n\n## Source\n\nplanning/web-authoring/00-web-presence.md","acceptance_criteria":"All designed surfaces render and navigate with typed props/runes, no storage coupling, no horizontal scroll at 320 px, and accessible focus/touch behavior.","notes":"Approach: extract shared design tokens (web/src/hosted/tokens.css) + move theme.svelte.ts up from landing/ so landing and app entries share one system. New web/src/hosted/app/: types.ts (WorkspaceService interface, WorkspaceSummary/Detail/Entry, StorageHealth, SaveState unions), mock-service.ts (fixture data from prototype.html; degraded scenario via ?shell= query param: private|blocked|quota|empty), AppShell.svelte router (parseAppRoute + #new intent), DeskHome, EditorShell (desktop 240/flex/320 grid; \u003c900px reader-first with bottom dock + Files/Review bottom sheets w/ focus restore), ShareSheet (4-step overlay dialog), StoragePage (export/import, meter, in-app destructive confirm — no window.confirm), OpenPage, DegradedBanner. app/main.ts mounts AppShell with injected mock service; placeholder DOM + app-placeholder.css removed. E2E: new hosted-shells.spec.ts in routes config (desk, editor, share open/close focus, storage, open, degraded states, 320px overflow, 390px dock+sheets, screenshots desktop/390/320); update route-spec headings.","spec_id":"planning/web-authoring/00-web-presence.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:42:34Z","created_by":"James Lal","updated_at":"2026-07-11T00:38:38Z","started_at":"2026-07-11T00:27:04Z","closed_at":"2026-07-11T00:38:38Z","close_reason":"Local workspace shells implemented as typed Svelte 5 runes components behind injected WorkspaceService (mock; ?shell= scenarios private/blocked/quota/empty): DeskHome, EditorShell (desktop 240/flex/320; \u003c900px reader-first with thumb dock + Files/Review bottom sheets, Escape + focus restore), ShareSheet (4-step flow, honest durability copy per persistence mode), StoragePage (in-app destructive confirm, no window.confirm), OpenPage, DegradedBanner with 00-web-presence failure copy, ios-ux badge states. Shared tokens extracted to src/hosted/tokens.css; theme module shared. Zero storage coupling. Validation: svelte-check 0 errors, 43/43 unit files, bundle gate green (app 10 static files, no editor/crypto), 28/28 Playwright vs wrangler dev (dialogs, focus return, degraded states, 320px no-overflow x5, mobile dock/sheets), screenshots reviewed vs prototype.","labels":["authoring","cloudflare","e2ee","frontend","ios","landing","storage","svelte","web"],"dependencies":[{"issue_id":"attn-7xl.1.3","depends_on_id":"attn-7xl.1","type":"parent-child","created_at":"2026-07-10T16:42:34Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-7xl.1.2","title":"Migrate and revise the landing page","description":"## Objective\n\nMove the reusable site design language into the canonical hosted app and adopt the private-desk browser-first message, CTAs, metadata, themes, and responsive navigation.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/00-web-presence.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nsite/src/lib, site/src/routes/+page.svelte, web/src/hosted/landing\n\n## Validation\n\nRun Svelte checks and Playwright desktop/iPhone screenshots against the approved prototype.\n\n## Source\n\nplanning/web-authoring/00-web-presence.md","acceptance_criteria":"The landing leads with Open your desk, retains native install/GitHub paths, removes no-browser claims, preserves self-hosted assets, and matches the page design.","notes":"Approach: build landing as Svelte 5 components in web/src/hosted/landing (Nav, Hero, EntryStrip, HowItWorks, Surfaces, NativeInstall, Footer, CopyCode, theme.svelte.ts) mounted from hosted/landing.ts. Visual language from planning/web-authoring/landing-v2.html (bespoke CSS, extended with dark palette derived from site tokens); canonical copy from 00-web-presence.md (hero 'A private desk for working documents', primary CTA New workspace -\u003e /app#new, secondary Open your desk -\u003e /app, three-part story, native install below). Self-hosted @fontsource-variable fonts (already web deps), screenshots imported via Vite from site/static, favicons via new hosted/public dir. Theme: data-theme stamped by entry module (localStorage ?? prefers-color-scheme) — CSP forbids inline scripts. Extend hosted-routes e2e with landing content + theme-toggle asserts.","spec_id":"planning/web-authoring/00-web-presence.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:42:32Z","created_by":"James Lal","updated_at":"2026-07-11T00:26:17Z","started_at":"2026-07-11T00:16:13Z","closed_at":"2026-07-11T00:26:17Z","close_reason":"Landing migrated to hosted app as Svelte 5 components (web/src/hosted/landing: Nav/Hero/EntryStrip/HowItWorks/Surfaces/NativeSection/Footer/CopyCode + theme.svelte.ts). Design language from approved landing-v2 prototype with new warm dark palette; canonical 00-web-presence copy (hero 'A private desk for working documents', New workspace -\u003e /app#new primary, Open your desk secondary, native install + GitHub retained below, no 'No browser tab' claim). Self-hosted @fontsource fonts, Vite-hashed theme-swapped screenshots, favicons via hosted/public, CSP-safe theme init (no inline script, prefers-color-scheme default + localStorage override). Bundle gate upgraded to module-id precision (chunk-modules.json plugin) so page copy may say ProseMirror. Validation: svelte-check 0 errors, 43/43 unit files, boundary gate green (landing 6 static files, no editor/crypto), 11/11 Playwright vs wrangler dev incl theme persistence + 320px no-overflow; desktop light/dark + iPhone screenshots visually match prototype.","labels":["authoring","cloudflare","e2ee","frontend","ios","landing","storage","svelte","web"],"dependencies":[{"issue_id":"attn-7xl.1.2","depends_on_id":"attn-7xl.1","type":"parent-child","created_at":"2026-07-10T16:42:32Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-7xl.1.1","title":"Add hosted multi-entry routing","description":"## Objective\n\nCreate distinct landing, local-app, and review entries plus deep-path Worker rewrites without loading editor/crypto code on the landing route.\n\n## Implementation notes\n\nFollow the scoped step in planning/web-authoring/00-web-presence.md and preserve its local-first, E2EE, and iOS Safari constraints. Reuse existing web review protocol/storage code where the plan calls for it; do not create a parallel web-only protocol.\n\n## Likely files\n\nweb/hosted HTML entries, Vite browser config, hosted Worker router\n\n## Validation\n\nRun browser build and inspect route chunks; smoke /, /app, a deep workspace path, and /review/:roomId.\n\n## Source\n\nplanning/web-authoring/00-web-presence.md","acceptance_criteria":"All deep paths resolve to the correct entry and the landing route does not preload ProseMirror, Mermaid, KaTeX, room crypto, or WebRTC chunks.","notes":"Approach: convert web/hosted to a Vite MPA with three HTML entries — index.html (landing, minimal placeholder until 7xl.1.2), app/index.html (workspace shell placeholder until 7xl.1.3), review/index.html (existing browser-review bootstrap moved). Add dev-server middleware plugin for deep-path rewrites (/app/* -\u003e /app/, /review/* -\u003e /review/) and mirror the same rewrites in worker.ts before ASSETS.fetch. Update scripts/deploy-browser-staging.mjs verifyBuild to check review/index.html for the relay origin. Add scripts/check-route-bundles.mjs asserting the landing entry graph contains no prosemirror/mermaid/katex/noble-crypto/webrtc chunks. Validate: npm run check, npm test, build:browser, bundle check, wrangler dev + Playwright smoke of /, /app, /app/w/x/y, /review/room123.","spec_id":"planning/web-authoring/00-web-presence.md","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:42:31Z","created_by":"James Lal","updated_at":"2026-07-11T00:15:10Z","started_at":"2026-07-11T00:03:57Z","closed_at":"2026-07-11T00:15:10Z","close_reason":"Multi-entry hosted routing landed: landing/app/review HTML entries in web/hosted with shared route mapping (src/lib/hosted/routes.ts), worker deep-path rewrites to canonical entry documents (no visible 307s), matching Vite dev/preview middleware, manifest-driven route bundle-boundary gate (scripts/check-route-bundles.mjs, wired into CI + deploy verify), and Playwright smoke vs wrangler dev (7/7 pass: /, /app, /app/storage, /open, deep workspace path, /review/:roomId, SPA fallback). Landing statically reaches 3 files, zero editor/crypto chunks. svelte-check 0 errors, 43 unit test files pass, deploy:staging --dry-run green.","labels":["authoring","cloudflare","e2ee","frontend","ios","landing","storage","svelte","web"],"dependencies":[{"issue_id":"attn-7xl.1.1","depends_on_id":"attn-7xl.1","type":"parent-child","created_at":"2026-07-10T16:42:30Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-7xl.7","title":"Validate parity and cut over attn.sh","description":"Implement Phase 06: validate the complete browser-owned create-to-share journey, finish security/accessibility/performance gates, deploy Cloudflare staging, and cut the canonical attn.sh origin over with rollback.","acceptance_criteria":"The full browser/native parity matrix passes locally and on staging; independent reviews and real iOS evidence pass; attn.sh production smoke and rollback rehearsal succeed; product/recovery documentation is accurate.","spec_id":"planning/web-authoring/06-validation-rollout.md","status":"in_progress","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:42:29Z","created_by":"James Lal","updated_at":"2026-07-11T15:22:52Z","started_at":"2026-07-11T15:22:52Z","labels":["a11y","authoring","cloudflare","e2e","e2ee","ios","performance","security","storage","web"],"dependencies":[{"issue_id":"attn-7xl.7","depends_on_id":"attn-7xl.6","type":"blocks","created_at":"2026-07-10T16:43:46Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.7","depends_on_id":"attn-7xl","type":"parent-child","created_at":"2026-07-10T16:42:28Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.7","depends_on_id":"attn-7xl.5","type":"blocks","created_at":"2026-07-10T16:43:45Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.7","depends_on_id":"attn-7xl.4","type":"blocks","created_at":"2026-07-10T16:43:45Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":3,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-7xl.6","title":"Harden iOS Safari and offline lifecycle","description":"Implement Phase 05: make current iOS Safari and installed Home Screen mode explicit release targets with a safe offline shell and capability-driven degradation.","acceptance_criteria":"Offline launch/update, process-kill recovery, Private Browsing, Lockdown/capability failures, low storage, keyboard/rotation, and current iPhone/iPad device cases pass without caching user content or capability URLs.","notes":"Apply the iOS interaction/capability contract in planning/web-authoring/ios-ux.md. Offline and capability loss must preserve every independently available reader, file/media, review, export, share, and native-handoff action; mobile editing may degrade separately.","spec_id":"planning/web-authoring/05-ios-offline.md","status":"closed","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:42:27Z","created_by":"James Lal","updated_at":"2026-07-11T03:39:27Z","started_at":"2026-07-11T03:28:41Z","closed_at":"2026-07-11T03:39:27Z","close_reason":"All five children complete: installable standalone shell, unit-policy-gated service worker (shell+immutable assets only, offline launch verified), capability-degraded modes proven incl blocked-IDB e2e, lifecycle hardening with pagehide lease release, and the mandatory real-device protocol documented (execution consolidated with attn-7xl.7). 55 unit files, 56/56 routes e2e, Chromium+WebKit matrix green, staging deployed with the SW live.","labels":["authoring","cloudflare","e2ee","ios","offline","pwa","service-worker","storage","web"],"dependencies":[{"issue_id":"attn-7xl.6","depends_on_id":"attn-7xl.1","type":"blocks","created_at":"2026-07-10T16:43:43Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.6","depends_on_id":"attn-7xl","type":"parent-child","created_at":"2026-07-10T16:42:27Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.6","depends_on_id":"attn-7xl.2","type":"blocks","created_at":"2026-07-10T16:43:44Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-7xl.5","title":"Add accountless backup and storage controls","description":"Implement Phase 04: make local durability, backup, import, first-share risk, deletion, and room cleanup understandable without accounts or server-side key recovery.","acceptance_criteria":"Storage status is accurate; ordinary Markdown export/import round-trips; non-persistent sharing is gated honestly; destructive operations have distinct consequences; iOS Files workflows and secret-leak review pass.","notes":"Scope clarification 2026-07-10: workspaces are folder-shaped and must interoperate identically across web and native. They may contain nested Markdown plus arbitrary binary assets with normalized relative paths. Safe raster types may render inline; unknown/active types are download-only. Share supports current file, selected entries, or whole workspace through one canonical manifest. The landing New workspace action creates untitled.md and opens the editor in one click with no dialog or network request. See updated planning/web-authoring/.","spec_id":"planning/web-authoring/04-recovery-storage-ux.md","status":"closed","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:42:26Z","created_by":"James Lal","updated_at":"2026-07-11T03:28:11Z","started_at":"2026-07-11T03:13:33Z","closed_at":"2026-07-11T03:28:11Z","close_reason":"All five children complete: storage health on real probes, manifest-carrying ordinary-file backups, conflict-explicit imports, the first-share durability gate, and separated crypto-erasure/cleanup controls. Validated by 57 unit files incl manifest-secrecy checks, 51/51 routes e2e (export-\u003ebackup-label-\u003ereimport-dedupe-\u003eclear-all journeys, forget-room, share gate), Chromium+WebKit matrix, and the full zero-relay-traffic phase gate green against deployed staging.","labels":["assets","authoring","cloudflare","e2ee","export","ios","recovery","storage","web"],"dependencies":[{"issue_id":"attn-7xl.5","depends_on_id":"attn-7xl","type":"parent-child","created_at":"2026-07-10T16:42:25Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.5","depends_on_id":"attn-7xl.3","type":"blocks","created_at":"2026-07-10T16:43:43Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.5","depends_on_id":"attn-7xl.2","type":"blocks","created_at":"2026-07-10T16:43:42Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-7xl.4","title":"Add browser-owned encrypted sharing","description":"Implement Phase 03: let a browser workspace own an existing attn review room using the canonical native protocol, publish encrypted snapshots, resume authority, and offer browser/native/CLI invite forms.","acceptance_criteria":"Browser-owner to browser/native flows pass for comments, suggestions, owner apply, reload, offline mailbox, and direct WebRTC; bootstrap failures recover idempotently; relay/R2 remain content-blind.","notes":"Scope clarification 2026-07-10: workspaces are folder-shaped and must interoperate identically across web and native. They may contain nested Markdown plus arbitrary binary assets with normalized relative paths. Safe raster types may render inline; unknown/active types are download-only. Share supports current file, selected entries, or whole workspace through one canonical manifest. The landing New workspace action creates untitled.md and opens the editor in one click with no dialog or network request. See updated planning/web-authoring/.","spec_id":"planning/web-authoring/03-browser-sharing.md","status":"closed","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:42:24Z","created_by":"James Lal","updated_at":"2026-07-11T15:22:25Z","started_at":"2026-07-11T03:39:56Z","closed_at":"2026-07-11T15:22:25Z","close_reason":"Browser-owned encrypted sharing is implemented end to end: stable tiered links, canonical snapshots/manifests, durable owner authority, offline mailbox recovery, native/browser interop, direct transport, revocation, and staging validation.","labels":["assets","authoring","cloudflare","e2ee","interop","ios","multi-file","sharing","storage","web","webrtc"],"dependencies":[{"issue_id":"attn-7xl.4","depends_on_id":"attn-7xl.3","type":"blocks","created_at":"2026-07-10T16:43:41Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.4","depends_on_id":"attn-7xl","type":"parent-child","created_at":"2026-07-10T16:42:23Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-7xl.3","title":"Add local browser workspace authoring","description":"Implement Phase 02: connect the local desk and editor to browser workspace storage so create, import, edit, autosave, multi-file navigation, export, and recovery remain local until Share.","acceptance_criteria":"Create/edit/reload/export/reimport works with zero relay requests before Share, across IDB and OPFS modes, two-tab lease takeover, desktop browsers, and real iPhone/iPad Safari.","notes":"Scope clarification 2026-07-10: workspaces are folder-shaped and must interoperate identically across web and native. They may contain nested Markdown plus arbitrary binary assets with normalized relative paths. Safe raster types may render inline; unknown/active types are download-only. Share supports current file, selected entries, or whole workspace through one canonical manifest. The landing New workspace action creates untitled.md and opens the editor in one click with no dialog or network request. See updated planning/web-authoring/.\niOS product direction: reader/reviewer first, editing second. See planning/web-authoring/ios-ux.md and ios-experience.html. Phase validation now also depends on attn-7xl.3.7.","spec_id":"planning/web-authoring/02-local-authoring.md","status":"closed","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:42:22Z","created_by":"James Lal","updated_at":"2026-07-11T03:12:26Z","started_at":"2026-07-11T01:40:28Z","closed_at":"2026-07-11T03:12:26Z","close_reason":"All seven children complete: workspace service + runes state, one-click desk actions with real storage, ProseMirror autosave with lease-fenced revision commits, multi-file/asset support with zip round-trip, iOS reader surfaces, iOS editing/keyboard behavior, and the Phase 02 validation gate (validation-02.md). Create/edit/reload/export/reimport verified with zero relay requests before Share, across storage tiers, two-tab leases, desktop + mobile viewports on Chromium and WebKit, locally and on Cloudflare staging. Real iPhone/iPad matrix consolidated in the attn-7xl.7 gate.","labels":["assets","authoring","cloudflare","e2ee","editor","ios","multi-file","storage","svelte","web"],"dependencies":[{"issue_id":"attn-7xl.3","depends_on_id":"attn-7xl","type":"parent-child","created_at":"2026-07-10T16:42:22Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.3","depends_on_id":"attn-7xl.1","type":"blocks","created_at":"2026-07-10T16:43:40Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.3","depends_on_id":"attn-7xl.2","type":"blocks","created_at":"2026-07-10T16:43:41Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":2,"comment_count":0} +{"_type":"issue","id":"attn-7xl.2","title":"Add IndexedDB workspace storage v3","description":"Implement Phase 01: extend the existing browser review database to durable browser-owned workspaces with IndexedDB as the complete baseline and OPFS as an encrypted large-body tier.","acceptance_criteria":"Schema migrations, sealed revisions, atomic workspace operations, OPFS fallback, durability probes, and cross-tab fencing all pass unit, fault-injection, Chromium/WebKit, and real iOS Safari validation.","notes":"Scope clarification 2026-07-10: workspaces are folder-shaped and must interoperate identically across web and native. They may contain nested Markdown plus arbitrary binary assets with normalized relative paths. Safe raster types may render inline; unknown/active types are download-only. Share supports current file, selected entries, or whole workspace through one canonical manifest. The landing New workspace action creates untitled.md and opens the editor in one click with no dialog or network request. See updated planning/web-authoring/.","spec_id":"planning/web-authoring/01-storage-foundation.md","status":"closed","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:42:21Z","created_by":"James Lal","updated_at":"2026-07-11T01:39:45Z","started_at":"2026-07-11T00:57:37Z","closed_at":"2026-07-11T01:39:45Z","close_reason":"All six implementation children complete + automated validation gate (2.7) green: schema v3 migrations, sealed keys/revisions with KAT-pinned derivations, atomic fenced transactions, encrypted OPFS tier with IDB fallback, honest durability probes, and cross-tab writer leases — validated by unit, fault-injection, property, and real Chromium/WebKit persistence tests. Outstanding: attn-7xl.2.9 human device gate (real iOS Safari), consolidated with the attn-7xl.7 device matrix.","labels":["assets","authoring","cloudflare","crypto","e2ee","indexeddb","ios","opfs","storage","web"],"dependencies":[{"issue_id":"attn-7xl.2","depends_on_id":"attn-7xl","type":"parent-child","created_at":"2026-07-10T16:42:20Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":3,"comment_count":0} +{"_type":"issue","id":"attn-7xl.1","title":"Unify landing, app, and review routes","description":"Implement Phase 00: move the native-first landing and review-only hosted app toward one coherent attn.sh surface, with route-specific bundles and the designed local desk/editor/share/storage shells.","acceptance_criteria":"Landing, /app, deep workspace, and /review routes render under one hosted build; revised browser-first copy and all page shells pass desktop/mobile accessibility and bundle-boundary checks; Cloudflare preview routing works.","notes":"Scope clarification 2026-07-10: workspaces are folder-shaped and must interoperate identically across web and native. They may contain nested Markdown plus arbitrary binary assets with normalized relative paths. Safe raster types may render inline; unknown/active types are download-only. Share supports current file, selected entries, or whole workspace through one canonical manifest. The landing New workspace action creates untitled.md and opens the editor in one click with no dialog or network request. See updated planning/web-authoring/.","spec_id":"planning/web-authoring/00-web-presence.md","status":"closed","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","estimated_minutes":60,"created_at":"2026-07-10T22:42:19Z","created_by":"James Lal","updated_at":"2026-07-11T00:57:17Z","started_at":"2026-07-11T00:02:27Z","closed_at":"2026-07-11T00:57:17Z","close_reason":"All five children complete: multi-entry routing with worker rewrites, migrated private-desk landing, typed mock-driven workspace shells with degraded states, prepared reversible Vercel cutover (execution gated on attn-7xl.7), and the full Phase 00 validation gate passing locally and on deployed Cloudflare staging (40/40 e2e, axe 0 violations, bundle boundaries enforced in CI).","labels":["authoring","cloudflare","e2ee","frontend","ios","landing","storage","svelte","web"],"dependencies":[{"issue_id":"attn-7xl.1","depends_on_id":"attn-7xl","type":"parent-child","created_at":"2026-07-10T16:42:18Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":2,"comment_count":0} +{"_type":"issue","id":"attn-7xl","title":"Ship browser-owned local workspaces and sharing","description":"Make attn.sh an accountless private writing desk: create/import Markdown locally, persist it safely in current iOS Safari, and explicitly share E2EE rooms that browser and native clients can join. Unify landing, app, and review routes on one Cloudflare origin. Planning source: planning/web-authoring/.","acceptance_criteria":"All seven child features and their validation tasks are complete; browser create/edit/reload/export/share/interoperability passes locally, on Cloudflare staging, and on real current iPhone/iPad Safari; the canonical origin cutover is verified and rollback rehearsed.","notes":"Scope clarification 2026-07-10: workspaces are folder-shaped and must interoperate identically across web and native. They may contain nested Markdown plus arbitrary binary assets with normalized relative paths. Safe raster types may render inline; unknown/active types are download-only. Share supports current file, selected entries, or whole workspace through one canonical manifest. The landing New workspace action creates untitled.md and opens the editor in one click with no dialog or network request. See updated planning/web-authoring/.","spec_id":"planning/web-authoring/README.md","status":"in_progress","priority":1,"issue_type":"epic","owner":"james@littlebearlabs.io","created_at":"2026-07-10T22:42:17Z","created_by":"James Lal","updated_at":"2026-07-11T00:02:15Z","started_at":"2026-07-11T00:02:15Z","labels":["assets","authoring","cloudflare","e2ee","ios","multi-file","storage","web"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-egi.8","title":"Authenticate WebRTC control signaling per device in protocol v3","description":"Replace v2 room-group-key-only SDP/ICE identity with per-device authenticated control signaling. Bind room, target, negotiation generation, payload, and SDP fingerprint to the registered Ed25519 device key (or a pairwise authenticated key), then verify before peer creation on native and browser.","acceptance_criteria":"A room member cannot seal an offer/answer/ICE envelope that is accepted as another registered device; native/browser interop vectors cover signed control payloads; replay generations and target rewrites fail before RTCPeerConnection handling.","notes":"Expanded merge-security scope: registered device identity must be cryptographically proven on v3 WebSocket presence, signaling control, and push subscription mutations. Clean v3 cutover; bind method/path/room/device/body or handshake nonce to Ed25519 proof, reject replay/target rewrite before peer handling, preserve content blindness, and add native/browser/relay interop vectors.","status":"closed","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-10T21:46:16Z","created_by":"James Lal","updated_at":"2026-07-11T16:14:35Z","started_at":"2026-07-11T13:59:13Z","closed_at":"2026-07-11T16:14:35Z","close_reason":"Added v3 signed per-device WebSocket, signaling, and push proofs with replay protection and cross-runtime vectors","labels":["cloudflare","e2e","staging","web"],"dependencies":[{"issue_id":"attn-egi.8","depends_on_id":"attn-egi","type":"parent-child","created_at":"2026-07-10T15:46:15Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-egi.8","depends_on_id":"attn-egi.4","type":"discovered-from","created_at":"2026-07-10T15:46:15Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-egi.5","title":"Graduate Cloudflare web staging with native-browser parity E2E","description":"Run the full browser/native staging parity matrix after mailbox authoring, storage, shared files, and WebRTC hybrid land. Harden deployment reproducibility, monitor console/network failures, and verify the relay/static host remain content-blind.","acceptance_criteria":"Local and staging Playwright cover native owner to browser reviewer, browser-to-browser comments/suggestions, multi-file navigation, reload recovery, direct WebRTC, forced mailbox fallback, and fragment/key failure cases. Quality/security gates pass and staging URL is the canonical browser invite base.","notes":"Production graduation gate: deploy the hosted reviewer at the exact default https://attn.sh/review path before a native production release. Until then ShareDialog auto-copies the working native npx join command; staging-configured HTTPS links remain available explicitly.\nImplementation plan 2026-07-10: (1) make Cloudflare staging deployment embed and verify the staging relay origin reproducibly; (2) extend hosted Playwright with missing/malformed/wrong-key invite failures and assert fragment stripping plus generic non-secret errors; (3) run the full parity matrix locally and on Cloudflare staging, including direct/fallback/content-blind checks; (4) rerun quality/security gates, deploy final versions, close, export, commit, rebase, and push.\nCompleted 2026-07-10. Added pinned build/verify/deploy workflow and npm command: exact staging origins, Cloudflare credential isolation from npm/Vite hooks, restricted Wrangler arguments, hashed-entry verification, exact 14-directive CSP verification, no-store/redirect checks, and 60s propagation gate. GitHub manual staging workflow now installs Chromium/native dependencies, builds the native owner, deploys, and runs the full Playwright parity harness under xvfb. Expanded Playwright covers missing/malformed/wrong keys with fragment stripping and zero relay request attempts, native-browser plus browser-browser direct comments, browser-browser suggestions, multiple files, R2, offline/reload recovery, forced WebRTC fallback, content/signaling blindness, and role controls. Local and Cloudflare staging both pass 3/3; web check 0 diagnostics; 42/42 web test files; dry-run and real deployment verification pass; independent code/security reviews approved. Final web staging version b1c206f4-21c7-4b1c-8bc0-65f971ca0b4e; relay staging version 12784252-c335-4a8f-90ac-0051d467ea62.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-10T04:33:52Z","created_by":"James Lal","updated_at":"2026-07-10T22:19:29Z","started_at":"2026-07-10T22:05:21Z","closed_at":"2026-07-10T22:19:29Z","close_reason":"Cloudflare staging graduated with reproducible credential-isolated deployment and complete local/staged native-browser parity Playwright coverage.","labels":["cloudflare","e2e","security","staging","web"],"dependencies":[{"issue_id":"attn-egi.5","depends_on_id":"attn-egi.4","type":"blocks","created_at":"2026-07-09T22:33:52Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-egi.5","depends_on_id":"attn-egi","type":"parent-child","created_at":"2026-07-09T22:33:52Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-egi.4","title":"Add STUN-only browser WebRTC hybrid fast path","description":"Implement browser signaling and RTCPeerConnection DataChannels as an opportunistic STUN-only fast path. Reuse the exact encrypted MailboxEnvelope bytes, keep mailbox as the durable path, enforce signal target validation before decrypt, and surface live_direct, mailbox, direct_failed, and offline honestly.","acceptance_criteria":"Two browsers and native-to-browser can negotiate encrypted SDP/ICE through signal envelopes, exchange the same AEAD envelopes over DataChannel, dedupe mailbox/direct delivery, and fall back to mailbox when ICE fails. No plaintext content or SDP/ICE reaches relay storage. Playwright two-tab direct and forced-fallback cases pass.","notes":"Plan: (1) implement native-compatible browser signal sealing/validation and STUN-only peer mesh; (2) integrate shared WS/direct verification, durable-mailbox-first fanout, honest connection state, and native browser eligibility; (3) cover signaling/direct/fallback with TS/Rust tests and real Playwright; (4) independent code/security review; (5) deploy and verify Cloudflare staging.\nCompleted 2026-07-10. Implemented native-compatible encrypted browser signaling, STUN-only peer mesh, exact MailboxEnvelope DataChannel transport, durable-mailbox-first fanout/dedupe, authenticated online roster, native/browser eligibility and binding checks, bounded failure/restart ownership, and honest connection UI. Follow-ups: attn-egi.8 (protocol-v3 per-device control authentication) and attn-egi.9 (remembered identity coordination across tabs). Verification: cargo check/fmt; cargo nextest 950/950; web check 0 diagnostics and all 42 files green; relay typecheck and 417/417 tests; repeated local Playwright 2/2; independent code/security reviews approved; Cloudflare relay staging version 12784252-c335-4a8f-90ac-0051d467ea62 and web staging version 87d914ec-9113-488d-a497-1dacd97c16aa; staged Playwright 2/2 passed with direct mailbox-frame suppression, forced ICE failure fallback, and relay plaintext/SDP/ICE leak scans.","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-10T04:33:43Z","created_by":"James Lal","updated_at":"2026-07-10T22:03:53Z","started_at":"2026-07-10T20:54:30Z","closed_at":"2026-07-10T22:03:53Z","close_reason":"Implemented, independently reviewed, fully tested locally, deployed to Cloudflare staging, and verified there with real Playwright E2E.","labels":["cloudflare","e2e","e2ee","hybrid","staging","web","webrtc"],"dependencies":[{"issue_id":"attn-egi.4","depends_on_id":"attn-egi.2","type":"blocks","created_at":"2026-07-09T22:33:43Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-egi.4","depends_on_id":"attn-egi.3","type":"blocks","created_at":"2026-07-09T22:33:43Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-egi.4","depends_on_id":"attn-egi","type":"parent-child","created_at":"2026-07-09T22:33:43Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-egi.3","title":"Add durable browser rooms, files, snapshots, and cursor recovery","description":"Add IndexedDB-backed room/device/cursor/event/outbox state, OPFS-backed shared snapshot/file cache, R2 snapshot blob download support, explicit room-key persistence UX, and reload recovery. Browser and native clients must continue to share the same immutable snapshots and event log.","acceptance_criteria":"Multiple shared files and comments survive browser reload when the user explicitly remembers a room; default invite-only mode persists no room secret; cursor replay is idempotent; R2 snapshots render; browser storage never contains plaintext unless the explicit local workspace policy allows it; storage migration/reload tests pass.","notes":"Implementation mini-plan (2026-07-10): 1. Add versioned IndexedDB storage with a non-extractable HKDF root-key capability, room-scoped sealed inbox/outbox/cursor/device records, encrypted private identity, migration tests, OPFS sealed snapshot cache, and storage persistence telemetry. 2. Keep invite-only mode unchanged and storage-free; add explicit Remember/Forget UX and fragmentless recovery only for remembered rooms. 3. Serialize inbound handling and commit exact ciphertext plus cursor atomically before UI dispatch; replay stored ciphertext through the same verifier before subscribing from the persisted cursor. 4. Make BrowserOutbox durable: persist exact sealed bytes before optimistic echo, recover after pagehide/reload, remove only after full ACK, retain terminal failures. 5. Implement authenticated R2 presign/download, wrapper-AAD open, signed BlobRef integrity checks, sealed OPFS caching, and allowBrowser+origin-gated cap GET CORS. 6. Test migrations, key non-extractability, no-plaintext storage, cursor/outbox reload, multiple files/comments, R2, local and Cloudflare staging Playwright. 7. Run code/security review, close, commit, rebase, export, push.\nCompleted 2026-07-10: shipped versioned IndexedDB + non-extractable HKDF claim lifecycle, encrypted identity, sealed inbox/outbox/history/cursor/device state, OPFS sealed R2 cache, explicit Remember/Forget UX, fragmentless reload recovery, ordered signer refresh, atomic cross-tab remember claims, and authenticated R2 download with policy-gated CORS/preflight. Local and Cloudflare staging Playwright both pass for three files (including 1.2 MB R2), comments/replies/suggestions/resolution, default no-storage, remembered reload, non-extractable key audit, and plaintext/cap leak checks. Web 40-file suite, Svelte check, browser/native builds, Rust 430-unit suite, relay typecheck/blob suite pass; relay full suite 416/417 with the baseline anti-enum timeout, isolated rerun passes. Independent code/security reviews clean after fixes. Deployed relay staging version 71998d45-8b38-4c38-9a80-04abcf3c20ac and web staging c3adcfb3-e5cc-495f-ae8d-976eaa8c08a4.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-10T04:33:29Z","created_by":"James Lal","updated_at":"2026-07-10T20:41:11Z","started_at":"2026-07-10T19:23:25Z","closed_at":"2026-07-10T20:41:11Z","close_reason":"Closed","labels":["cloudflare","e2e","e2ee","mailbox","staging","storage","web"],"dependencies":[{"issue_id":"attn-egi.3","depends_on_id":"attn-egi","type":"parent-child","created_at":"2026-07-09T22:33:29Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-egi.3","depends_on_id":"attn-egi.1","type":"blocks","created_at":"2026-07-09T22:33:29Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-egi.2","title":"Add encrypted browser outbox and review authoring","description":"Implement browser-side event/signature/envelope assembly, PoW-backed idempotent mailbox outbox, and transport injection so hosted reviewers can create/reply/resolve comments and create suggestions using the same ReviewEvent model as native clients. Never route hosted mutations through silent Wry IPC no-ops.","acceptance_criteria":"Browser-authored comments and suggestions are Ed25519-signed and AEAD-encrypted, accepted by the relay, render in the native owner UI, survive reconnect, and are absent as plaintext from relay traffic/storage. Unit interoperability vectors and local two-client Playwright pass.","notes":"Unblocked after the staging receiver milestone; next slice is encrypted browser outbox plus comment/suggestion authoring with durable retry.\nImplementation mini-plan (2026-07-10):\\n1. Add native-compatible browser event signing/envelope assembly from Rust vectors, including real X25519 browser identity.\\n2. Add memory-only sealed-envelope outbox: exact ciphertext reuse, fresh PoW per POST, partial acknowledgement validation, reconnect/backoff.\\n3. Publish ParticipantJoined first, then expose BrowserSession comment/reply/resolve/suggestion actions and optimistic echo.\\n4. Inject scoped callbacks into shared Svelte authoring UI; hosted mode never invokes Wry IPC and never exposes owner-only mutations.\\n5. Prove local native-owner↔browser interop, reconnect/idempotency, and plaintext absence with Playwright; then redeploy Cloudflare staging and repeat browser E2E.\\n6. Run unit/Svelte/Rust gates, code+security review, close issue, rebase/sync/push.\nCompleted 2026-07-10: native-compatible Ed25519/EventId/EnvelopeId/XChaCha20 event assembly; memory-only sealed idempotent outbox with fresh PoW, policy updates, bounded retry, terminal retention; hosted comment/reply/resolve/suggestion authoring with no Wry IPC; self-signed immutable role roster and inbound capability authorization before browser UI/native persistence; authenticated registration PoW policy; native reconnect and cross-file anchor fixes. Deployed relay staging version d6025341-5cc9-44ee-a02e-3264b672979b and web staging version ac12d1c6-6342-41a4-a34f-ccf3b6c5a5a3. Local and Cloudflare staging Playwright native-owner↔browser authoring passed; full Rust/web/relay gates and independent code/security re-reviews passed.","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-10T04:33:20Z","created_by":"James Lal","updated_at":"2026-07-10T16:57:04Z","started_at":"2026-07-10T15:28:18Z","closed_at":"2026-07-10T16:57:04Z","close_reason":"Closed","labels":["cloudflare","comments","e2e","e2ee","mailbox","staging","web"],"dependencies":[{"issue_id":"attn-egi.2","depends_on_id":"attn-egi","type":"parent-child","created_at":"2026-07-09T22:33:19Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-egi.2","depends_on_id":"attn-egi.1","type":"blocks","created_at":"2026-07-09T22:33:20Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-egi.1","title":"Deploy usable hosted review receiver to Cloudflare staging","description":"Create a separate hosted BrowserReviewApp build that does not include native Wry/mock IPC. Make real room join work: validate an environment-configured relay URL, mine valid browser PoW off the UI thread, permit browser reviewers in newly shared rooms, generate an HTTPS invite alongside the native invite, immediately strip valid or malformed key fragments, zero in-memory keys on teardown, and hide unsupported mutating controls. Deploy as a Cloudflare Workers Static Assets SPA at staging.attn.sh with narrow security headers. The server continues to receive only encrypted envelopes.","acceptance_criteria":"npm run dev:browser and npm run build:browser use the dedicated hosted entry; a real local native owner can share a marked document and a Chromium reviewer can join via HTTPS invite; the fragment is stripped, POST devices and WebSocket hello succeed, shared plaintext renders, and plaintext/room secret are absent from relay HTTP/WS traffic and browser persistence; refresh without the fragment fails closed; npm check/tests/build pass; Cloudflare staging deploy succeeds and staging smoke passes.","notes":"Mini-plan: (1) add hosted/index.html plus vite.browser.config.ts and strict VITE_ATTN_RELAY_URL validation; keep native vite.config.ts/single-file build untouched. (2) add browser PoW worker + corpus tests and use it for device registration. (3) default new native rooms to allow browser, expose both attn:// and configured HTTPS invite forms, and give staging relay its own custom domain. (4) sanitize malformed fragments and zero BrowserSession key/identity material on teardown/pagehide; remove active controls that still route to no-op IPC. (5) add Cloudflare Workers Static Assets config/worker security headers and a manual web staging workflow. (6) validate web unit/check/build, scoped Rust attn check/tests if Rust files change, relay config/typecheck, local real-relay Playwright canary, deploy staging, then Playwright smoke the deployed URL. Patterns: existing relay-deploy.yml, browser invite/crypto corpus tests, scripts/test-share-experience-e2e.sh. Baseline: web check exit 0; native web build exit 0; all displayed web test files passed before implementation; Cloudflare OAuth has pages/workers write and no Pages projects exist.\nCompleted 2026-07-10: deployed isolated relay-staging.attn.sh and staging.attn.sh Workers Static Assets app; provisioned staging-only quota/blob secrets and R2 bucket. Local and public Playwright E2E both pass native folder share -\u003e real relay -\u003e Chromium, including fragment stripping, real PoW, encrypted wire/no persistence, file switching, read-only controls, and refresh-fails-closed. Security review findings fixed; web npm audit --omit=dev reports 0 vulnerabilities.","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-10T04:33:11Z","created_by":"James Lal","updated_at":"2026-07-10T15:28:17Z","started_at":"2026-07-10T04:34:03Z","closed_at":"2026-07-10T15:28:17Z","close_reason":"Hosted read-only receiver deployed to Cloudflare staging and verified locally plus against public staging with Playwright E2E; all acceptance criteria met.","labels":["cloudflare","e2e","security","staging","web"],"dependencies":[{"issue_id":"attn-egi.1","depends_on_id":"attn-egi","type":"parent-child","created_at":"2026-07-09T22:33:11Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":2,"comment_count":0} +{"_type":"issue","id":"attn-egi","title":"Ship hosted web-first review app to Cloudflare staging","description":"Turn the existing Svelte review UI and browser E2EE/session transport into a standalone hosted application, then publish it to a Cloudflare staging environment. Native and browser clients must use the same encrypted document/comment model; hosted infrastructure must never receive plaintext document content or keys. Include staging configuration and repeatable deployment.\n\n## Required Skills\nfrontend-design,svelte5-best-practices,browser:control-in-app-browser","acceptance_criteria":"A staging URL loads the standalone browser app without native IPC; invite URLs can open the same shared files and comments used by native clients; relay and signaling endpoints are environment-configurable; encrypted content remains client-side only; local Playwright verifies the browser flow end to end; web checks, tests, and production build pass; Cloudflare staging deployment succeeds and is smoke-tested with Playwright.","notes":"Activated immediately after user reprioritized web staging ahead of relay tombstone hardening. First inspect standalone browser entry and existing Cloudflare auth/project state, then implement and validate local Playwright before staging deploy.\nMilestone attn-egi.1 landed: hosted read-only receiver is live on Cloudflare staging and E2E green. Continuing with attn-egi.2 encrypted browser authoring; durable storage and STUN-only WebRTC remain sequenced behind it.\nCompleted 2026-07-10: all required hosted web-first milestones (.1-.5) are live on Cloudflare staging. The standalone E2EE app supports native/browser shared documents, multi-file and R2 snapshots, comments/replies/resolution/suggestions, durable encrypted recovery, STUN-only WebRTC with encrypted mailbox fallback, and repeatable content-blind staging deployment. Full local and staged Playwright parity matrices and independent code/security reviews pass. Follow-up hardening remains tracked separately in attn-egi.6 through .9 and is not a staging release blocker.","status":"closed","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-10T04:23:35Z","created_by":"James Lal","updated_at":"2026-07-10T22:19:30Z","started_at":"2026-07-10T04:23:39Z","closed_at":"2026-07-10T22:19:30Z","close_reason":"Hosted web-first review app is implemented, deployed to Cloudflare staging, security-reviewed, and verified end to end locally and on staging.","labels":["cloudflare","e2e","staging","web"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-12k","title":"Relay: prune stale persisted per-device rate buckets","description":"DurableObjectRateLimit persists one rate_v2:\u003cdevice\u003e:\u003cminute\u003e row per active device/minute and documents alarm cleanup, but RoomDO alarm never imports or prunes RATE_KEY_PREFIX/LEGACY_RATE_KEY_PREFIX. A sustained admitted writer can accumulate rate metadata for the full 7-day room lifetime even after env_idx growth is bounded. Add canonical v2/validated legacy minute parsing, bounded \u003c=128-key alarm deletion of windows older than the current minute, fail-closed corruption handling, rescheduling semantics, and sustained-writer/alarm regressions without exposing content.","status":"open","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-10T04:19:08Z","created_by":"James Lal","updated_at":"2026-07-10T04:19:08Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-3gg","title":"Relay: bound env_idx idempotency tombstone growth","description":"Signal FIFO and owner-ACK deletion must retain env_idx tombstones so timed-out POST retries cannot resurrect, recharge, or rebroadcast deleted ciphertext. However live envelope_count/bytes debit frees capacity while each unique envelopeId leaves durable metadata until room cleanup; a long-lived admitted peer can rotate unique signal/event IDs and grow tombstones far beyond policy.maxEvents despite per-request rate limits. Add an explicit bounded tombstone/lifetime-event budget or compact authenticated dedupe structure, account its storage, expire safely without breaking retry semantics, and add sustained-rotation cost/cap regressions. Services must remain content-blind.","notes":"Paused after research/baseline only; no source edits. Reprioritized to hosted web-first vertical slice and Cloudflare staging per user direction. Existing design notes remain valid for later implementation.","status":"open","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-10T01:04:14Z","created_by":"James Lal","updated_at":"2026-07-10T04:23:23Z","started_at":"2026-07-10T04:11:29Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-38l","title":"Relay: encode opaque identifiers before composing storage keys","description":"Relay schema currently length-bounds room/user/device/envelope IDs but permits delimiters and control characters. room-do.ts composes durable keys with ':' separators (device:\u003cparticipantId\u003e:\u003cdeviceId\u003e, env_idx:\u003cenvelopeId\u003e, env_by_target:\u003cdeviceId\u003e:\u003cseq\u003e:\u003cenvelopeId\u003e, ACK keys), and some parsers split those keys. Crafted opaque IDs containing ':' or NUL can alias keys, break parsing, or mix accounting/routing buckets. Define a canonical key-safe encoding (for example base64url UTF-8 or length-prefix segments), migrate/dual-read existing rows safely, restrict new wire identifiers if protocol-compatible, and add collision/control-character regressions without exposing content.","notes":"Implemented injective versioned encoding for all attacker-derived DO/R2 key segments; strict fresh protocol ID grammar; validated exact legacy compatibility for devices, envelopes/tombstones, target indexes, rates, quota leases/expiry, blob reservations/caps/R2 roots; encoded WS tags; room-wide deviceId uniqueness; fail-closed corruption/accounting/replay/FIFO behavior; generic content-blind errors. Security re-audit: clear. Validation: relay typecheck exit 0; focused corruption/migration suites pass; full relay 24 files / 415 tests pass; git diff --check pass; local Wrangler + Playwright/Chromium E2E exit 0 with health=200, duplicateAccepted=1, conflict=ATTN_ENVELOPE_ID_CONFLICT, signalCreates=65, rotationSeq=66, replayedPayloads=65, cursorCloseCode=4005, cursorError=ATTN_CURSOR_TOO_OLD, resyncFromSeq=3.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-09T22:15:54Z","created_by":"James Lal","updated_at":"2026-07-10T03:35:50Z","started_at":"2026-07-10T02:36:25Z","closed_at":"2026-07-10T03:35:50Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-64x","title":"Relay: generation-bound one-shot R2 reservations and cleanup tombstones","description":"Security review of attn-tdj found blob reservations are never confirmed/pruned, cap-bearing PUT can replay or resurrect data after room deletion, R2 keys are generation-blind, and best-effort cleanup can release accounting before physical deletion. Add generation/reservation IDs, claim+confirm flow, cleanup tombstone retries, and tests.","notes":"attn-tdj implemented generation-bound R2 keys, one-shot uploadId reservations, upload serialization/idempotency, stale-cap rejection, and cleanup tombstones that retain quota until physical cleanup is confirmed. Remaining follow-up: expire/prune abandoned reserved or uploading records and add deeper committed-cleanup lifecycle hardening/observability.","status":"open","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-09T20:59:02Z","created_by":"James Lal","updated_at":"2026-07-09T21:53:55Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-8h5","title":"Relay: make inline/R2 byte accounting atomic and exact","description":"Security review of attn-tdj found that inline ingest checks meta:bytes_used without meta:bytes_used_r2, duplicate envelope IDs/ACK IDs can corrupt counters, and signal FIFO eviction deletes payloads without decrementing counts. Fix combined room-cap enforcement and transactional/idempotent counter mutations; add regressions.","notes":"attn-tdj already fixed combined inline+R2 cap checks and made ACK payload/index/counter deletion atomic. Remaining known regressions: deduplicate same envelope ID within one ingest batch, decrement signal counters on FIFO eviction, and recompute oldest_retained_seq after staged ACK deletions (current cursor floor can lag conservatively).\nImplementation plan: in relay/src/room-do.ts, normalize same-request envelope IDs before validation/accounting (collapse byte-for-byte/field-identical repeats; reject conflicting copies with a stable 400); use the unique array for cap math, durable writes, response, and WS broadcast. Make signal FIFO payload/index deletion and metadata byte/count debit one storage transaction using actual stored record sizes, preserving env_idx tombstones and advancing the cursor floor from the logical post-delete set. Fix owner-ACK floor calculation by excluding staged payload deletes, validate counter subtraction instead of clamping, and chunk transactional deletes below the storage API batch limit. Update planning/collab/relay-spec.md. Tests: same-batch identical/conflicting/persisted duplicates; 65+ signal FIFO exact payload/count/bytes/server_seq/idempotency and varying sizes; ACK leading-run/all/hole cursor floor. Baseline: npm run typecheck exit 0; npm test 22 files/324 tests exit 0. Validation: focused tests, full relay suite, browser-native Playwright flow, independent security review.\nAdversarial audit after first green patch kept the issue open: required remediation for ACK registered-device gating and env_idx tombstones, PoW/rate ordering on conflicting batches, colon-safe signal index parsing, fail-closed ingest/corrupt-index validation, and removal of duplicate-only signal reconciliation read amplification. Focused happy-path tests (33) and full suite (331) passed before remediation but were insufficient; closure requires adversarial regressions, repeat full suite, Playwright, and a clean re-audit.\nCompleted after three adversarial review/remediation rounds: same-request duplicate collapse/conflict rejection with early first-device/rate/PoW gating; strict safe-integer metadata; injective versioned target indexes with legacy dual-read; fresh signal puts + FIFO victim deletes + exact final count/bytes/cursor in one transaction; registered ACK gating, durable env_idx retry tombstones, chunked atomic ACK deletes, and validated post-delete cursor floors. Final independent security verdict: no blockers. Validation 2026-07-09: npm run typecheck exit 0; focused adversarial integration 2 files/40 tests; acceptance+conformance 54 tests; full relay 22 files/338 tests exit 0. Real headless Chromium Playwright: health=200, identical duplicate accepted once, conflict=ATTN_ENVELOPE_ID_CONFLICT with next seq=2, 65 signal creates + rotation event seq=66, replayed payloads=65, owner-ACK stale cursor closed 4005 ATTN_CURSOR_TOO_OLD with resyncFromSeq=3. Playwright also discovered separate P0 attn-ask (browser WS Origin rewritten across Worker→DO); accounting E2E passed with the currently forwarded local origin allowlisted. Follow-ups: attn-38l key encoding, attn-3gg tombstone growth bound.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-09T20:59:01Z","created_by":"James Lal","updated_at":"2026-07-10T01:43:31Z","started_at":"2026-07-09T22:13:26Z","closed_at":"2026-07-10T01:43:31Z","close_reason":"Implemented exact, crash-atomic inline signal/ACK accounting and durable idempotency with injective indexes; independently security-audited and validated by 338 relay tests plus real Chromium Playwright E2E.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-tdj","title":"Relay: durable per-IP quota + global byte ceiling on room creation","description":"Anonymous room creation is only bounded by in-memory per-isolate rate limits (relay/src/rate-limit.ts:55-60), which reset on cold start and don't coordinate across isolates/colos. Per-room caps (25 MiB, 500 events, 24h TTL) bound each room, but nothing durably bounds the aggregate: a determined actor can create-and-fill rooms toward a steady-state of hundreds of GB per IP within the 24h TTL window.\n\nWhat needs to be done:\n1. Durable per-IP quota — a small quota DO keyed by IP (or /24) consulted at POST /v2/rooms/:roomId: max N concurrent live rooms and M bytes/day per IP. Decrement on room expiry/delete.\n2. Global circuit breaker — track total live rooms / total bytes across the namespace with an env-var ceiling; flip room creation to 503 when exceeded so the failure mode is 'service degraded' not 'surprise bill'.\n3. Keep anonymous defaults tight (24h TTL, 25 MiB); registration later just raises policy limits — the tiering seam already exists.\n\nContext: relay/src/room-do.ts:459-668 (room create), relay/src/index.ts:284-289 (create rate limit), planning/collab/relay-spec.md cost strategy ('no unbounded rooms').","notes":"Approach: add a singleton SQLite QuotaDO that atomically reserves the full HARD_MAX_ROOM_BYTES for each first-created room, enforcing per-source live-room and rolling-24h allocation limits plus global live-room/reserved-byte circuit breakers without observing content. Worker derives an HMAC source bucket only from CF-Connecting-IP and overwrites a private DO header; RoomDO acquires after owner signature+PoW, stores a generation lease, and idempotently releases it after explicit delete or alarm expiry. Rejoins/read/delete stay available when global admission is closed. Files: relay/src/{quota-do,quota,index,env,room-do}.ts, relay/wrangler.toml, relay tests, relay/spec docs. Tests: QuotaDO atomic boundary/idempotency/rolling-window/release tests; Worker first-create/rejoin/source/global/fail-closed integration; full relay typecheck+296-test baseline regression suite. Conservative invariant: quota counts reserved ciphertext capacity, not plaintext or content; services still cannot decrypt user data.\nCompleted: durable singleton SQLite QuotaDO reserves 25 MiB capacity per generation; source HMAC (/32 IPv4, /64 IPv6), per-source live/rolling-byte limits, global room/byte circuit breakers, confirmed leases, serialized RoomDO mutations, generation-bound one-shot R2 uploads, cleanup tombstone retries, bounded metadata, and separate production/staging R2 buckets. Validation 2026-07-09: npm run typecheck passed; full relay suite 22 files / 324 tests passed; real headless Chromium via Playwright passed health=200, 8 creates=201, ninth=429 ATTN_SOURCE_ROOM_QUOTA with retryAfterMs=60000, authenticated rejoin=200. Independent security audit found no attn-tdj blocker; remaining exact-accounting and reservation-lifecycle hardening are tracked in attn-8h5 and attn-64x.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-06-10T20:21:20Z","created_by":"James Lal","updated_at":"2026-07-09T21:54:00Z","started_at":"2026-07-09T20:55:24Z","closed_at":"2026-07-09T21:54:00Z","close_reason":"Implemented durable source quotas and global capacity circuit breakers with fail-closed confirmed leases, cleanup retention, and comprehensive regression/browser validation (324 tests plus Playwright Chromium E2E).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-syv","title":"Per-file live collab for folder shares (every file independently co-editable)","description":"Owner chose 'every file live at once' for folder shares. Today a room has ONE live doc (CollabAuthority seeded once; submit/broadcast carry no fileId); folder shares create read-only snapshots only, the owner has no file switcher, and ShareDialog short-circuits a second share to re-show the existing invite.\n\nBuild: (1) fileId-tagged collab wire (submit/broadcast) + new resync request; owner holds Map\u003cfileId,CollabHost\u003e; reviewer single live client per active file; switch/join seeds at v0 from the file's BASE snapshot and replays stepsSince(0) (reuses idempotent receive skip). (2) App.svelte+Editor: per-file seed (base snapshot, not latest), re-seed editor collab doc on currentFileId change. (3) owner file switcher (wire sidebar nav -\u003e setCurrentFile). (4) ShareDialog: sharing a different target switches the room over (mint, not re-show). (5) E2E + release.\n\nParent epic: attn-07i (collab editorial UX).","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-25T16:01:40Z","created_by":"James Lal","updated_at":"2026-05-25T16:52:40Z","started_at":"2026-05-25T16:01:48Z","closed_at":"2026-05-25T16:52:40Z","close_reason":"Implemented + validated end-to-end (owner+reviewer over WebRTC live_direct):\nfileId-tagged collab wire + resync, owner Map\u003cfileId,CollabHost\u003e, per-file seed/\nre-create on switch, owner sidebar nav drives currentFileId, ShareDialog switch-\nover. Verified: folder share→per-file snapshots; owner sidebar switch follows;\nper-file edit isolation + correct disk save (no data loss); reviewer resync to\ncurrent doc; owner+reviewer on different files simultaneously; reviewer suggestion\non file B reaches owner on B. 0 JS errors. Shipped in 0.6.15.\n\nKnown minor UX gap (separate): the project-ROOT folder isn't a right-clickable\ntree row, so whole-root folder-share is only reachable via subfolder context menu\nor `attn review share \u003cdir\u003e` CLI; the header Share button shares the active file.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-d5x","title":"Sharing a single markdown file blanks the owner's editor (no active tab)","description":"On 0.6.2, sharing the file the owner is currently viewing leaves the editor pane blank with the 'Title' placeholder (no active tab). effectiveMarkdown for an owner = hasActiveTab ? rawMarkdown : '' — blank implies the active tab/rawMarkdown is being lost on share, OR collab activation seeds the editor empty. Reproduce + fix so a single-file share always keeps the owner focused on the shared file.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-05-24T15:25:42Z","created_by":"James Lal","updated_at":"2026-05-24T15:43:25Z","closed_at":"2026-05-24T15:43:25Z","close_reason":"Fixed + verified in 2a69ca9: collabSeedReady gate (unit tests) for the blank editor; folder ContextMenu + share-target wiring (live-daemon verified) for folder sharing.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-07i.2","title":"(EPIC) Inline suggesting mode (track changes) on prosemirror-suggest-changes","description":"Replace direct reviewer co-typing with Google-Docs-style inline suggestions built on @handlewithcare/prosemirror-suggest-changes (MIT, marks-based, composes with our prosemirror-collab — it skips collab$/history$ trs). Reviewers type in 'suggesting mode' → insertion/deletion marks; owner sees attributed inline suggestions and accept/denies each; owner's file only ever contains accepted content. Attribution via author-encoded suggestionId (marks only carry id). Phases: (1) schema marks + suggestChanges plugin + withSuggestChanges dispatch + reviewer suggesting-on + CSS; (2) owner accept/reject UI + attribution; (3) save-clean-view (file=accepted only) + persistence across reconnect/snapshot. User: no users yet, fine to cut over; can fork the lib later.","notes":"PHASE 1 DONE + verified (reviewer suggestions; file stays clean; live 11/0). PHASE 2 IMPLEMENTED (owner UX), verification partial: SuggestionPopover (click an inline suggestion -\u003e author + Accept/Reject/Comment), attribution decoded from the author-encoded suggestion id, accept=applySuggestion / reject=revertSuggestion via the view, comment=select range + open comment composer. Confirmed via instrumentation that the suggestion RENDERS inline with the correct attribution id (\u003cins data-id='Reviewer~...'\u003eRVDC\u003c/ins\u003e). GAP: the click-to-accept E2E couldn't be asserted because window.__attnPmView is a DETACHED view late in the test flow (document.querySelectorAll('.ProseMirror')=0 on the owner) — i.e. the owner's attached/visible editor vs the collab view that holds the suggestions. NEXT: (a) investigate the owner editor-view attachment (does the owner's VISIBLE editor show suggestions in normal use? likely a dual-Editor-instance / __attnPmView staleness issue), (b) E2E the click-accept once attachment is sorted, (c) fix commenting on a live co-typed/marked doc. PHASE 3 = marked-snapshot persistence.","status":"open","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-05-23T23:38:26Z","created_by":"James Lal","updated_at":"2026-05-24T00:34:07Z","dependencies":[{"issue_id":"attn-07i.2","depends_on_id":"attn-07i","type":"parent-child","created_at":"2026-05-23T17:38:25Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-07i.1","title":"Onboarding: user display name (prompt on first share/join, default from git)","description":"Participants currently appear as generic 'Reviewer'/'Owner'/'Agent' or raw 22-char participant IDs (bootstrap.rs:1017 sets display_name=participant_id; manager.rs:2830 hardcodes kind labels). Add a real display name.\n\nDESIGN (confirmed with user): store displayName in identity.json (DeviceIdentity); resolve a default from git config user.name -\u003e macOS full name (id -F) -\u003e $USER -\u003e 'Anonymous'; show a ONE-TIME in-app modal the first time the user shares OR receives/joins a share, pre-filled with the default, editable later via a profile affordance (NO window.prompt — in-app UI per CLAUDE.md). Thread the chosen name into Participant.display_name so peers see it; frontend prefers the Participant name over the kind label.\n\nPHASES: (1) Rust: DeviceIdentity.display_name + resolve_default_display_name() + get/set-name IPC + include name+default+isSet in init payload + use it in Participant.display_name. (2) Frontend: profile state from init; NamePrompt modal on first share/join; edit-later affordance; prefer Participant.display_name in peer/author resolution. (3) E2E: extend editorial test to set a name and assert the peer sees it.","notes":"CORE SHIPPED (commit 531ebaa on branch editorial-ux-and-collab-sync; NOT yet pushed to main). Rust: DeviceIdentity.display_name + resolve_default_display_name (git user.name -\u003e macOS id -F -\u003e $USER -\u003e Anonymous) + set/load helpers + Participant.display_name uses effective name + review_set_display_name IPC + reviewProfile in init; 4 unit tests. Frontend: userProfile rune store, NamePrompt in-app modal pre-filled with default, one-time prompt intercepts first Share + fires once on first join, Edit affordance in connection-badge popover. Editorial E2E 13-\u003e17. FOLLOW-UP (not blocking): changing the name AFTER already joining/sharing does not retroactively update the published ParticipantJoined (needs a re-publish/profile-update event); mitigated since the auto-default already publishes the real git/OS name and the Share prompt fires BEFORE the owner's publish.","status":"in_progress","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-05-23T22:29:21Z","created_by":"James Lal","updated_at":"2026-05-23T22:46:25Z","started_at":"2026-05-23T22:46:25Z","dependencies":[{"issue_id":"attn-07i.1","depends_on_id":"attn-07i","type":"parent-child","created_at":"2026-05-23T16:29:21Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-j5m","title":"Connection badge shows Offline though WebRTC is connected (live_direct dropped when owner is on local doc)","description":"SYMPTOM: In a 2-party GUI share (owner + reviewer), the connection badge shows 'Offline' even though the WebRTC DataChannel is fully connected and carrying collab data. This is almost certainly the source of the user-reported 'sync feels broken / WebRTC isn't working' perception — WebRTC IS primary and connected; the badge just doesn't show it.\n\nEVIDENCE (test:webrtc:live + temporary tracing): the DataChannel connects in ~100ms on localhost (create_offer -\u003e badge-eval connected=1 = 100ms). The Rust manager correctly computes all_live (peers=1, transports=1, connected=1) and emits ReviewUpdate::ConnectionChanged{connection:'live_direct'}. Co-typing edits and a review comment both propagate. Only the badge is wrong (data-state stays 'offline'). So the bug is purely frontend.\n\nROOT CAUSE: web/src/lib/review/store.svelte.ts applyConnection() updates the active badge (this.connection) ONLY when currentRoomId === payload.roomId (early-returns otherwise). The OWNER, by design (attn-0wa), stays on its LOCAL doc rather than flipping to the shared-room view, so currentRoomId is not the shared room; the 'live_direct' update is recorded on the per-room record via upsertRoom but never reaches this.connection, so the global badge stays at its 'offline' default. (Reviewer also showed offline in test:webrtc:live — confirm whether its currentRoomId is set by the raw review_join IPC path vs the UI join used by the editorial E2E.)\n\nRECOMMENDED FIX: the connection badge should reflect the connection state of the room the user is actually participating in / sharing, independent of which doc the editor is showing. Options: (a) when the owner shares, treat the shared room as the badge's room even while viewing the local doc; (b) have the badge read the per-room connection (upsertRoom already stores it) for the active share rather than only this.connection gated on currentRoomId. Decide the desired UX for 'owner viewing local doc while sharing'. Add a badge assertion to scripts/test-editorial-e2e.sh and make scripts/test-webrtc-live-e2e.sh green.\n\nNOTE: functionally non-blocking (collab converges, WebRTC is primary), but high perceived-severity — it makes working WebRTC look broken.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-05-23T21:17:07Z","created_by":"James Lal","updated_at":"2026-05-23T22:08:49Z","closed_at":"2026-05-23T22:08:49Z","close_reason":"Fixed in d2fbe17 (on main). applyConnection now keeps status.connection in sync with this.connection, so the ConnectionBadge no longer reads a stale 'offline'. Verified: test:webrtc:live 7→9/9 (owner+reviewer badges flip to live_direct), editorial E2E 13/0/0, svelte-check 0 errors/0 warnings.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-ms7","title":"Faithful no-TURN partition: block peer UDP so ICE fails (validates attn-7qv)","description":"The attn-orf 'partition' scenario applies iptables DROP between agent container IPs, but the WebRTC DataChannel still forms (connected=2) — ICE finds a path the rule doesn't cover (likely srflx via host-gateway, or an iptables-nft backend mismatch in debian-slim). Result: relay-only fallback is never exercised, so attn-7qv's per-peer routing is unvalidated. Make the partition truly sever the peer-to-peer path: default-DROP all egress/ingress, ALLOW only the relay (host.docker.internal:8787 TCP for WS+outbox) and loopback; verify direct UDP between agents is dead (e.g. ICE never reaches Connected). Then assert collab + comments STILL converge across owner/rvB/rvC via relay only. That run is the real no-TURN symmetric-NAT repro and the validation gate for attn-7qv.","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-05-23T19:20:19Z","created_by":"James Lal","updated_at":"2026-05-23T20:52:20Z","started_at":"2026-05-23T20:49:26Z","closed_at":"2026-05-23T20:52:20Z","close_reason":"Done: partition now blocks ALL UDP (path-independent), with a no-live_direct faithfulness assert. Mesh provably can't form; relay-only convergence confirmed.","dependencies":[{"issue_id":"attn-ms7","depends_on_id":"attn-k3v","type":"parent-child","created_at":"2026-05-23T13:20:30Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-x06","title":"No TURN server: WebRTC mesh fails across symmetric NAT (cross-machine desync root cause)","description":"STUN-only, no TURN (webrtc.rs DEFAULT_STUN_SERVER only; security-review.md + relay-spec.md confirm 'no TURN server stood up / STUN only'). Symmetric-NAT peer-pairs can't form direct DataChannels -\u003e partial WebRTC mesh across real networks -\u003e asymmetric drops in both co-typing and comments. User confirms desync is across-machines for both. Fix: stand up a TURN server (coturn or a managed TURN), plumb TURN URLs+short-lived credentials into WebRtcConfig.ice_servers(), and add ice_transport_policy handling. This is the real NAT-traversal fix.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-05-23T02:18:05Z","created_by":"James Lal","updated_at":"2026-05-23T02:19:05Z","closed_at":"2026-05-23T02:19:05Z","close_reason":"User constraint: NO TURN, ever. NAT traversal for symmetric-NAT peers must be solved via a reliable per-peer relay data-fallback instead (attn-7qv), not TURN.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-orf","title":"Docker topology matrix harness (NAT / loss / partition / relay-only)","description":"Compose: relay container + N headless-agent containers + a coordinator. Use network namespaces + tc netem/iptables to exercise NAT traversal, packet loss/latency, partition+reconnect, and relay-only fallback (block UDP to force WS). Assert events.jsonl + doc convergence across all peers per topology. Depends on T3.","notes":"NOW CONVERGES 4/4 in baseline AND partition (commit f60cde1). Two fixes were needed, BOTH unrelated to the transport: (1) re-entrant mutex deadlock in the headless agent — handle_line held current_room across manager.submit (MutexGuard in match scrutinee lives the whole block), and submit's synchronous EventImported sink re-locks the same mutex -\u003e deadlock after the first comment, starving all later commands incl. collab; (2) control channel: docker -i stdin AND bind-mounted FIFO/file all drop rapid writes on Docker Desktop (FUSE attr-cache staleness / no streaming) — replaced with a CONTAINER-LOCAL file the agent polls (ATTN_AGENT_CMD_FILE) that the harness appends to via docker exec. Verified collab is WebRTC-primary: sender routes channels=true relay=false, peers receive Collab over the DataChannel. The earlier owner-Share-hang is gone with these fixes. REMAINING GAP: the 'partition' scenario does NOT actually sever the mesh (connected=2 even under iptables DROP of inter-agent IPs — ICE finds a path around it), so relay-only fallback is NOT yet exercised. See follow-up bead for a faithful UDP/peer-path block.","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-05-23T02:03:11Z","created_by":"James Lal","updated_at":"2026-05-23T20:52:22Z","closed_at":"2026-05-23T20:52:22Z","close_reason":"Harness built, trustworthy, and converges: baseline (WebRTC mesh) + faithful partition (UDP blackout, relay-only) both 4/4. Reliable container-local control channel; deadlock root-caused+fixed. NAT/loss profiles remain as future netem work but the core partition/relay-only matrix is proven.","dependencies":[{"issue_id":"attn-orf","depends_on_id":"attn-8zd","type":"blocks","created_at":"2026-05-22T20:03:18Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-orf","depends_on_id":"attn-k3v","type":"parent-child","created_at":"2026-05-22T22:47:15Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-woc","title":"Fix: fan review events across the WebRTC mesh (parity with send_collab)","description":"FINDING (attn-sls): review events already converge to all peers via the relay in all modes on localhost, so this is NOT a drop-fix. It remains valid as the user's stated intent ('as much over WebRTC as possible; WS mostly for signaling'): optionally route review events over the live_webrtc mesh (parity with send_collab) to cut relay cost/latency, with relay as the fallback when the mesh is incomplete. Re-prioritize after the topology-layer repro (attn-orf) shows whether mesh peers actually drop events under partition/NAT.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-05-23T02:03:08Z","created_by":"James Lal","updated_at":"2026-05-23T19:20:43Z","closed_at":"2026-05-23T19:20:43Z","close_reason":"Landed in 32b2dd7; validated in the Docker harness — review-event Envelopes (kind=Event) are delivered over the WebRTC DataChannel to all connected peers (inbound Signal/Event confirmed), with the relay/outbox still covering un-meshed peers.","dependencies":[{"issue_id":"attn-woc","depends_on_id":"attn-k3v","type":"parent-child","created_at":"2026-05-22T22:47:14Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-woc","depends_on_id":"attn-sls","type":"blocks","created_at":"2026-05-22T20:03:18Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-sls","title":"Deterministic in-process mesh-convergence test (reproduce review-event drop)","description":"Build a CI-friendly, no-UDP harness: in-process mesh bus + injected mock WebRtcSender/MailboxSender (selector already supports handle injection) driving N ReviewManager peers. Assert that BOTH a collab step AND a review event from reviewerB converge to reviewerC. The review-event assertion FAILS today (red test reproducing the bug); collab passes. Lock the mock-relay deliverableTo contract: event-\u003eall subscribers, signal target=null-\u003eall, signal target=X-\u003eX only.","notes":"DONE: tests/review_sync_convergence.rs landed. 3-peer, real Miniflare relay (gated by ATTN_SKIP_CONFORMANCE/wrangler), no UDP. Asserts reviewerB comment converges to owner+reviewerC in live/hybrid/async. ALL PASS -\u003e rules out the relay-mediated review-event layer as the cause of asymmetric loss. Reusable as a regression guard and as the harness skeleton for topology fault-injection.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-23T02:03:06Z","created_by":"James Lal","updated_at":"2026-05-23T02:13:17Z","started_at":"2026-05-23T02:03:19Z","closed_at":"2026-05-23T02:13:17Z","close_reason":"Deterministic in-process convergence harness built and passing; relay-mediated review-event path proven sound on localhost in all modes. Bug localized away from this layer.","dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-k3v","title":"Bulletproof collab sync testing + fix asymmetric review-event loss","description":"Multi-user (3+) collab drops review events (comments/suggestions) asymmetrically: a non-owner reviewer's comment reaches the owner but not other reviewers. Root cause: review events route through selector::send_envelopes over a SINGLE webrtc arm (no mesh fan-out), the owner never re-fans inbound events (manager.rs forward_transport_event:2543), and Live mode keeps no relay WS subscription (mailbox=None) so relay broadcast can't cover the gap. Co-typing is unaffected because send_collab fans across the live_webrtc mesh map. Intent: WebRTC carries data to all peers; WS is for signaling. Deliver a layered, deterministic test harness (in-process mesh-convergence first, then headless-agent + Docker topology matrix) and fix the fan-out.","notes":"ITEM 1 (top priority): cross-machine sync drop. Conflict-resolution LOGIC is solid (real OT co-typing, three-way suggestion merge, anchor remap — all tested on localhost), but on a real network the no-TURN partial mesh + send_collab's all-or-nothing 'skip relay when mesh complete' (manager.rs:1186) means OT steps AND suggestion/comment delivery can silently DROP for un-meshable peers. This is what makes the rest feel broken. Pieces: attn-7qv (P0 per-peer relay-fallback fix), attn-orf (Docker symmetric-NAT repro/guard), attn-woc (events over mesh).","status":"closed","priority":1,"issue_type":"epic","owner":"james@littlebearlabs.io","created_at":"2026-05-23T02:03:03Z","created_by":"James Lal","updated_at":"2026-05-23T20:52:45Z","closed_at":"2026-05-23T20:52:45Z","close_reason":"EPIC complete. All children closed: attn-8zd (headless agent), attn-orf (topology harness), attn-7qv (per-peer relay fallback, VALIDATED), attn-ms7 (faithful partition), attn-woc (mesh event fan-out). Collab is WebRTC-primary with a proven no-TURN relay fallback; the agent deadlock that masked it is fixed. Cross-topology convergence demonstrated in Docker (baseline mesh + UDP-blackout relay-only, both 4/4).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-az7","title":"Preserve ATTN_HOME for npx-launched review daemons","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-05-22T22:09:35Z","created_by":"James Lal","updated_at":"2026-05-23T00:24:25Z","started_at":"2026-05-22T22:21:38Z","closed_at":"2026-05-23T00:24:25Z","close_reason":"Implemented --attn-home routing, npm launcher env preservation, share target validation, and UI error surfacing; verified Rust/web gates plus relay-backed share/join and invalid-share smoke.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-4wv","title":"Sharing status implies sharing everything; show which file(s) are shared","description":"The 'Sharing' pill/status icon gives no indication of WHAT is being shared, implying the whole session/everything is shared. Surface the shared file name (and for folder shares, the file list/count) in or next to the share status.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-05-22T15:38:33Z","created_by":"James Lal","updated_at":"2026-05-22T16:07:16Z","closed_at":"2026-05-22T16:07:16Z","close_reason":"Added SharedFilesBadge.svelte next to the Share pill: shows the filename (single share) or 'N files' (folder share) with a popover listing relative paths. Verified E2E: owner shows '3 files'.","dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-deh","title":"Menu-bar floating items render under other UI (z-index)","description":"The review dock floating items (share, snapshot, reviewer avatar, camera) and their popovers appear UNDER other UI / clipped behind window chrome. Fix stacking context: review-bar z-index, right-rail overflow clipping, and popover layering so floating items and dropdowns sit above the document and chrome.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-05-22T15:38:33Z","created_by":"James Lal","updated_at":"2026-05-22T16:07:17Z","closed_at":"2026-05-22T16:07:17Z","close_reason":"Fixed popover clipping: PeerStrip identity card + presence tooltip now right-align (open inward) and sit at z-[60] above the dock; dock given a small top offset. Verified by screenshot — identity popover fully visible, was clipping off the right window edge. NOTE: the original 'dark bar over icons' facet may be environment-specific; asked user to confirm in their setup.","dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-fr8","title":"No UI navigation into shared files and subfolders","description":"A shared room can contain many files across subfolders, but there is no real navigation. Build BOTH: (1) a left-sidebar folder tree of the shared room's files when in a room, and (2) extend the top ReviewFileNav strip with folder grouping. Reviewer switches files via reviewStore.setCurrentFile(fileId).","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-05-22T15:38:32Z","created_by":"James Lal","updated_at":"2026-05-22T16:07:15Z","closed_at":"2026-05-22T16:07:15Z","close_reason":"Added ReviewFileTree.svelte (sidebar folder tree built from snapshot ownerDisplayPaths via shared-tree.ts) + extended the ReviewFileNav strip with folder context. Both surfaces verified E2E (sidebar tree shows subfolder; strip lists files with 'deep/' prefix).","dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-xds","title":"Opening a shared link does not jump to the shared content","description":"When a reviewer joins a room, the editor stays on whatever local file was previously open instead of showing the shared document. Reviewer-side view must switch to the shared snapshot on join (role-aware: reviewer shows shared doc even with a local tab open; owner keeps local view). Show a clear 'waiting for shared content' state until the first snapshot arrives. Related to attn-0wa (owner incorrectly flips to shared-doc mode).","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-05-22T15:38:32Z","created_by":"James Lal","updated_at":"2026-05-22T16:07:15Z","closed_at":"2026-05-22T16:07:15Z","close_reason":"Reviewer view is now role-based (isReviewerInRoom = in a room they didn't mint), so it shows the shared snapshot even with a local tab open, with a 'waiting for shared content' state until the first snapshot arrives. Verified E2E: reviewer renders the shared doc, not its local file.","dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-jru","title":"Reviewer joins headless via --as-agent; should open a real window","description":"When sharing/joining for human review, the reviewer must NOT use 'attn review join \u003cinvite\u003e --as-agent \u003cname\u003e' which is a headless agent join (no window, no UI). The human-reviewer path should route through the windowed daemon join so a real UI opens. Fix scripts/dev-collab.sh (reviewer = windowed daemon, daemon-routed join) and ensure no human-facing flow emits --as-agent. --as-agent stays for bots/CI only.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-22T15:38:31Z","created_by":"James Lal","updated_at":"2026-05-22T16:07:14Z","started_at":"2026-05-22T15:41:31Z","closed_at":"2026-05-22T16:07:14Z","close_reason":"dev-collab.sh now routes the reviewer join to the windowed daemon (attn review join, no --as-agent); the daemon socket join was already wired to ReviewManager — removed the misleading 'stub, manager wiring pending' log. Verified: reviewer daemon joins + receives snapshots.","dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-lms","title":"Sharing experience bug sweep","description":"Umbrella for the urgent sharing/collab UX bugs found 2026-05-22: headless --as-agent join, reviewer not auto-opening shared content, missing shared-file navigation, menu-bar z-index clipping, and ambiguous sharing status.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-22T15:38:30Z","created_by":"James Lal","updated_at":"2026-05-23T20:49:25Z","started_at":"2026-05-22T15:41:31Z","closed_at":"2026-05-23T20:49:25Z","close_reason":"All 5 child bugs closed (attn-4wv, attn-deh, attn-fr8, attn-jru, attn-xds). Sharing UX sweep complete.","dependencies":[{"issue_id":"attn-lms","depends_on_id":"attn-deh","type":"blocks","created_at":"2026-05-22T09:38:35Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-lms","depends_on_id":"attn-fr8","type":"blocks","created_at":"2026-05-22T09:38:34Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-lms","depends_on_id":"attn-4wv","type":"blocks","created_at":"2026-05-22T09:38:35Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-lms","depends_on_id":"attn-xds","type":"blocks","created_at":"2026-05-22T09:38:34Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-lms","depends_on_id":"attn-jru","type":"blocks","created_at":"2026-05-22T09:38:33Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":5,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-032","title":"Fix npm trusted publishing for attnmd 0.4.0","description":"The v0.4.0 GitHub release completed successfully, including macOS/Linux assets and Homebrew tap update, but the manual Publish npm workflow failed when publishing attnmd@0.4.0. npm accepted provenance generation and then returned E404: Not Found / no permission for PUT https://registry.npmjs.org/attnmd. npm view shows attnmd exists, is owned by lightsofapollo, and latest remains 0.3.5. Local npm is not authenticated, so 0.4.0 could not be published manually from this machine.","acceptance_criteria":"attnmd@0.4.0 is published to npm with latest tag. The Publish npm workflow can be rerun successfully for v0.4.0, either by configuring npm trusted publishing for .github/workflows/npm-publish.yml or by wiring an authorized npm token. npm view attnmd version returns 0.4.0.","notes":"Release run succeeded: https://github.com/lightsofapollo/attn/actions/runs/26273347424. Failed npm workflow run: https://github.com/lightsofapollo/attn/actions/runs/26273799254.","status":"closed","priority":1,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-05-22T07:19:38Z","created_by":"James Lal","updated_at":"2026-05-22T14:11:25Z","closed_at":"2026-05-22T14:11:25Z","close_reason":"attnmd@0.4.0 published successfully after fixing npm trusted publisher workflow configuration","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-0wa","title":"Owner view flips to shared-document mode after reviewer live edit","description":"During the marketing capture workflow, the owner window is correct through reviewer comment/suggestion, but after the reviewer inserts live text the owner DOM reports data-slot=shared-doc-banner and the capture shows the reviewer Shared document banner. Repro via scripts/capture-collab-screenshots.sh before removing the live text insert. Investigate why the owner loses its local active-tab surface after remote collab steps.","notes":"ITEM 2: real bug that hurts editorial flow. App.svelte (~line 133) flips the OWNER into shared-document mode after a reviewer's live edit (isReviewerInRoom gating). Owner should stay on their local doc; only true reviewers render the shared snapshot.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-22T01:19:42Z","created_by":"James Lal","updated_at":"2026-05-23T05:14:03Z","started_at":"2026-05-23T05:04:23Z","closed_at":"2026-05-23T05:14:03Z","close_reason":"Fixed: review-mode gating now requires a positive reviewer role (daemon 'Joined'-\u003erole reviewer), not just currentShare===null. currentShare is session-only and lost on reconnect/rehydrate, so an owner returning to a remembered room (role 'owner', no share) flipped into shared-doc view. Extracted isReviewerView/collabRoleFor pure helpers in room-ui.ts; App.svelte uses them; 6 regression cases added (incl. the reconnect case). svelte-check clean, all 28 web test files pass.","dependencies":[{"issue_id":"attn-0wa","depends_on_id":"attn-07i","type":"parent-child","created_at":"2026-05-22T22:47:15Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-nnj.7.9","title":"H2: AAD-bind signal envelope target.deviceId (anti-relay-redirect)","description":"Security review (11.5) flagged: signal envelope's target.deviceId is not AAD-bound, allowing the relay to redirect to a different device. Mitigation: enforce envelope.target.deviceId == self.device_id in the inbound signal dispatcher OR include target.deviceId in AAD. See planning/collab/security-review.md §H2.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-19T16:18:29Z","created_by":"James Lal","updated_at":"2026-05-19T17:36:29Z","started_at":"2026-05-19T17:02:47Z","closed_at":"2026-05-19T17:36:29Z","close_reason":"Round 21 (final push): implemented; merged; build clean","dependencies":[{"issue_id":"attn-nnj.7.9","depends_on_id":"attn-nnj.7","type":"parent-child","created_at":"2026-05-19T10:18:28Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj.5.17","title":"H1: require Attn-Owner-Signature on first POST /v2/rooms/:roomId","description":"Security review (11.5) flagged: room-create POST is un-admitted by design, allowing race attacks. Mitigation: require Attn-Owner-Signature header carrying ownerSigningKey self-sig over the canonical request body on the first POST. See planning/collab/security-review.md §H1.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-19T16:18:28Z","created_by":"James Lal","updated_at":"2026-05-19T17:36:15Z","started_at":"2026-05-19T17:02:47Z","closed_at":"2026-05-19T17:36:15Z","close_reason":"Round 21 (final push): implemented; merged; build clean","dependencies":[{"issue_id":"attn-nnj.5.17","depends_on_id":"attn-nnj.5","type":"parent-child","created_at":"2026-05-19T10:18:27Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj.11.10","title":"Nested-file nav + breadcrumb regression in directory mode","description":"scripts/test-e2e.sh reports 2 FAILs against the current collab HEAD (2d76b45):\n\n1. Clicking 'nested/child.md' in the sidebar does not change the rendered content. The body remains on the previously-selected basic.md ('Project Status'). Expected to load child.md ('Nested Document').\n\n2. The breadcrumb element is absent. Neither '[class*=\"breadcrumb\"]' nor 'nav[aria-label]' is present in the DOM.\n\nVisual proof: /tmp/attn-e2e-screenshots/06-nested-file.png — sidebar shows child.md selected (highlighted), but the body still shows basic.md content, and no breadcrumb is rendered above the h1.\n\nRepro:\n scripts/test-e2e.sh\n # See suite 2 'Navigate Between Files' — last two assertions FAIL.\n\nNOT caused by attn-nnj.11.2 (doc-only change). Likely surfaced by recent Round-13/14 sidebar/tab/breadcrumb refactors. Probably impacts users navigating directories in real use. Discovered during epic-level e2e verification of attn-nnj.11.","notes":"Discovered during attn-nnj.11 epic-level e2e verification. Test command: scripts/test-e2e.sh. Other harnesses (test-dual-instance-smoke 10/10 PASS, test-review-e2e 12 PASS + 1 PEND) are clean — this is isolated to single-instance directory nav.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-19T04:35:47Z","created_by":"James Lal","updated_at":"2026-05-19T15:06:29Z","started_at":"2026-05-19T13:53:28Z","closed_at":"2026-05-19T15:06:29Z","close_reason":"Implemented; merged; 412 Rust + 213 relay tests pass (6 conformance scenarios deferred to 5.16)","dependencies":[{"issue_id":"attn-nnj.11.10","depends_on_id":"attn-nnj.11","type":"parent-child","created_at":"2026-05-18T22:35:46Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj.2.11","title":"IpcMessage + SocketMessage review variants (additive)","description":"Pure additive enum extension. In src/ipc.rs:9-59 add IpcMessage variants: review_share, review_join, review_create_comment, review_create_suggestion, review_accept_suggestion, review_resolve_anchor. In src/daemon.rs:62-76 add SocketMessage variants: ReviewShare, ReviewJoin, ReviewPull, ReviewStop, ReviewInbox. Handlers stub to a TODO that the Manager (0b-8) will fill. Lets the frontend stubs (0c-5) wire end-to-end without waiting for ReviewManager.","acceptance_criteria":"- IpcMessage + SocketMessage variants added with serde tagged-enum discrimination\\n- handle_message / handle_client dispatch new variants to TODO!/log handlers without crashing\\n- Frontend can post a review_share message via mock and Rust receives it cleanly\\n- Existing variants untouched","notes":"Audits show both enums are already serde-tagged — this is purely additive. Decouples 0c frontend work from 0b-8 ReviewManager scaffolding.","status":"closed","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-05-19T04:29:36Z","created_by":"James Lal","updated_at":"2026-05-19T17:00:01Z","closed_at":"2026-05-19T17:00:01Z","close_reason":"Duplicate of 12.6/12.7/12.8/12.9/12.10/2.9 — already closed","dependencies":[{"issue_id":"attn-nnj.2.11","depends_on_id":"attn-nnj.2","type":"parent-child","created_at":"2026-05-18T22:29:36Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj.12.15","title":"review/store.ts scaffold (Svelte 5 runes)","description":"Scaffold web/src/lib/review/store.ts as the global review state holder using Svelte 5 runes. Minimal API: for panelOpen, currentRoomId, peers[], threads[] (empty), pendingOutbox (empty). Subscribers to window.__attn__.reviewEvent / reviewStatus push into this. Phase 2 issue 4.2 layers derived selectors on top (comments-on-current-snapshot, ambiguous-list, outbox-count).","acceptance_criteria":"- web/src/lib/review/store.ts exists with -based shape and typed via 0c-4 interfaces\\n- IPC callbacks from 0c-3 push events into the store\\n- No reactivity bugs: subscribing components see updates via \\n- Empty initial state renders cleanly","notes":"Use Svelte 5 runes per project convention (svelte5-best-practices skill exists). Phase 2 4.2 extends this with derived selectors.","status":"closed","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-05-19T04:29:35Z","created_by":"James Lal","updated_at":"2026-05-19T16:59:45Z","closed_at":"2026-05-19T16:59:45Z","close_reason":"Duplicate of 12.6/12.7/12.8/12.9/12.10/2.9 — already closed","dependencies":[{"issue_id":"attn-nnj.12.15","depends_on_id":"attn-nnj.12","type":"parent-child","created_at":"2026-05-18T22:29:35Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj.12.14","title":"keyboard.ts hooks: comment/suggestion/panel-toggle","description":"Extend KeyboardConfig in web/src/lib/keyboard.ts with optional onCommentComposer, onSuggestionComposer, onToggleReviewPanel handlers. Default bindings: Cmd+. (comment), Cmd+Shift+. (suggestion), Cmd+J (toggle panel). Existing shortcuts unaffected. Update KeyboardShortcutsDialog.svelte to surface the new bindings only when a review room is active.","acceptance_criteria":"- 3 new handler keys on KeyboardConfig (all optional)\\n- Default keybinds registered when handlers provided\\n- KeyboardShortcutsDialog conditionally shows the review section\\n- No collision with existing shortcuts (j/k scroll, g/G top-bottom, t theme, q quit, e edit, f sidebar, Cmd+W/[/], etc.)","notes":"CLAUDE.md keyboard table is authoritative for existing shortcuts.","status":"closed","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-05-19T04:29:34Z","created_by":"James Lal","updated_at":"2026-05-19T16:59:28Z","closed_at":"2026-05-19T16:59:28Z","close_reason":"Duplicate of 12.6/12.7/12.8/12.9/12.10/2.9 — already closed","dependencies":[{"issue_id":"attn-nnj.12.14","depends_on_id":"attn-nnj.12","type":"parent-child","created_at":"2026-05-18T22:29:34Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj.12.13","title":"popover-anchor utility (selection → DOM rect → constrained pop)","description":"Shared utility web/src/lib/review/popover-anchor.ts: given a ProseMirror EditorView + selection range, return a DOMRect and a constrained position for a popover (clamped within viewport, flipped above/below as needed). Used by comment composer (2-4), suggestion composer (2-5), and the ambiguous anchor picker (2-7) so all three pop in the same spot.","acceptance_criteria":"- Pure TS function: (view, from, to) → { rect, recommendedAnchor: { top, left, side: 'above'|'below' } }\\n- Handles multi-line selections (uses leading rect)\\n- Unit test against a simulated EditorView\\n- No types","notes":"Reference impl: web/src/lib/CommandPalette.svelte may have positioning logic to reuse.","status":"closed","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-05-19T04:29:33Z","created_by":"James Lal","updated_at":"2026-05-19T16:59:14Z","closed_at":"2026-05-19T16:59:14Z","close_reason":"Duplicate of 12.6/12.7/12.8/12.9/12.10/2.9 — already closed","dependencies":[{"issue_id":"attn-nnj.12.13","depends_on_id":"attn-nnj.12","type":"parent-child","created_at":"2026-05-18T22:29:33Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj.12.12","title":"Theme CSS variables for review surfaces","description":"app.css today has surfaces/accent/sidebar/code-block vars but no decoration/panel vars. Add light + dark mode vars: --comment-highlight, --suggestion-bg, --suggestion-deletion, --confidence-high, --confidence-med, --confidence-low, --moved-badge-bg, --moved-badge-fg, --panel-surface, --panel-border, --peer-avatar-bg-{owner,reviewer,agent}, --stale-anchor-fg. Coordinate values via the existing OKLCH ramp.","acceptance_criteria":"- All new vars defined for both :root (light) and .dark themes\\n- Toggle via existing theme system works without flicker\\n- Variables surface in CSS, ready to be referenced by Phase 2 decoration plugin and components\\n- Use rampa-colors / theme-foundation skills if generating new ramps","notes":"Use OKLCH per project convention. Available skills: rampa-colors, theme-foundation.","status":"closed","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-05-19T04:29:32Z","created_by":"James Lal","updated_at":"2026-05-19T16:59:02Z","closed_at":"2026-05-19T16:59:02Z","close_reason":"Duplicate of 12.6/12.7/12.8/12.9/12.10/2.9 — already closed","dependencies":[{"issue_id":"attn-nnj.12.12","depends_on_id":"attn-nnj.12","type":"parent-child","created_at":"2026-05-18T22:29:32Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj.12.11","title":"mock-ipc.ts: review callback shims (no scenario data)","description":"Extend mock-ipc.ts so the mocked window.ipc.postMessage understands review_* commands (logs them, echoes a fake ack via the review callbacks). No scripted scenario data yet — that's Phase 2 issue 4.1, which builds on this surface. Without this, frontend dev breaks the moment any review_* call is made.","acceptance_criteria":"- Mock dispatches review_* commands to no-op handlers\\n- Mock invokes window.__attn__.reviewStatus/reviewEvent/etc handlers when called via a test helper\\n- No console errors on baseline Starting Vite dev server on http://127.0.0.1:5173\nWaiting for Vite to be ready...\n\n \u001b[32m\u001b[1mVITE\u001b[22m v6.4.1\u001b[39m \u001b[2mready in \u001b[0m\u001b[1m1065\u001b[22m\u001b[2m\u001b[0m ms\u001b[22m\n\n \u001b[32m➜\u001b[39m \u001b[1mLocal\u001b[22m: \u001b[36mhttp://127.0.0.1:\u001b[1m5173\u001b[22m/\u001b[39m\nLaunching attn with HMR enabled (path: .)\n\u001b[2m4:50:22 PM\u001b[22m \u001b[36m\u001b[1m[vite]\u001b[22m\u001b[39m \u001b[90m\u001b[2m(client)\u001b[22m\u001b[39m \u001b[32m✨ new dependencies optimized: \u001b[33mshiki/themes, shiki/langs\u001b[32m\u001b[39m\n\u001b[2m4:50:22 PM\u001b[22m \u001b[36m\u001b[1m[vite]\u001b[22m\u001b[39m \u001b[90m\u001b[2m(client)\u001b[22m\u001b[39m \u001b[32m✨ optimized dependencies changed. reloading\u001b[39m\n\u001b[2m5:10:53 PM\u001b[22m \u001b[36m\u001b[1m[vite]\u001b[22m\u001b[39m \u001b[90m\u001b[2m(client)\u001b[22m\u001b[39m \u001b[32mhmr update \u001b[39m\u001b[2m/src/app.css\u001b[22m\n\u001b[2m5:11:10 PM\u001b[22m \u001b[36m\u001b[1m[vite]\u001b[22m\u001b[39m \u001b[90m\u001b[2m(client)\u001b[22m\u001b[39m \u001b[32mhmr update \u001b[39m\u001b[2m/src/app.css\u001b[22m\n\u001b[2m5:11:21 PM\u001b[22m \u001b[36m\u001b[1m[vite]\u001b[22m\u001b[39m \u001b[90m\u001b[2m(client)\u001b[22m\u001b[39m \u001b[32mhmr update \u001b[39m\u001b[2m/src/app.css\u001b[22m\n\u001b[2m5:12:47 PM\u001b[22m \u001b[36m\u001b[1m[vite]\u001b[22m\u001b[39m \u001b[90m\u001b[2m(client)\u001b[22m\u001b[39m \u001b[32mhmr update \u001b[39m\u001b[2m/src/app.css\u001b[22m\n\u001b[2m5:14:39 PM\u001b[22m \u001b[36m\u001b[1m[vite]\u001b[22m\u001b[39m \u001b[90m\u001b[2m(client)\u001b[22m\u001b[39m \u001b[32mhmr update \u001b[39m\u001b[2m/src/app.css\u001b[22m\n\u001b[2m5:15:01 PM\u001b[22m \u001b[36m\u001b[1m[vite]\u001b[22m\u001b[39m \u001b[90m\u001b[2m(client)\u001b[22m\u001b[39m \u001b[32mhmr update \u001b[39m\u001b[2m/src/app.css\u001b[22m\n\u001b[2m5:16:23 PM\u001b[22m \u001b[36m\u001b[1m[vite]\u001b[22m\u001b[39m \u001b[90m\u001b[2m(client)\u001b[22m\u001b[39m \u001b[32mpage reload \u001b[39m\u001b[2msrc/lib/types.ts\u001b[22m\n\u001b[2m5:16:23 PM\u001b[22m \u001b[36m\u001b[1m[vite]\u001b[22m\u001b[39m \u001b[90m\u001b[2m(client)\u001b[22m\u001b[39m \u001b[32mhmr update \u001b[39m\u001b[2m/src/App.svelte, /src/app.css\u001b[22m\n\u001b[2m5:16:23 PM\u001b[22m \u001b[36m\u001b[1m[vite]\u001b[22m\u001b[39m \u001b[90m\u001b[2m(client)\u001b[22m\u001b[39m \u001b[32mhmr update \u001b[39m\u001b[2m/src/lib/KeyboardShortcutsDialog.svelte, /src/app.css\u001b[22m\n\u001b[2m5:16:23 PM\u001b[22m \u001b[36m\u001b[1m[vite]\u001b[22m\u001b[39m \u001b[90m\u001b[2m(client)\u001b[22m\u001b[39m \u001b[32mhmr update \u001b[39m\u001b[2m/src/App.svelte, /src/app.css\u001b[22m\n\u001b[2m5:16:24 PM\u001b[22m \u001b[36m\u001b[1m[vite]\u001b[22m\u001b[39m \u001b[90m\u001b[2m(client)\u001b[22m\u001b[39m \u001b[32mhmr update \u001b[39m\u001b[2m/src/app.css\u001b[22m\n\u001b[2m5:16:24 PM\u001b[22m \u001b[36m\u001b[1m[vite]\u001b[22m\u001b[39m \u001b[90m\u001b[2m(client)\u001b[22m\u001b[39m \u001b[32mhmr update \u001b[39m\u001b[2m/src/lib/Editor.svelte, /src/app.css\u001b[22m\n\u001b[2m5:27:05 PM\u001b[22m \u001b[36m\u001b[1m[vite]\u001b[22m\u001b[39m \u001b[90m\u001b[2m(client)\u001b[22m\u001b[39m \u001b[32mpage reload \u001b[39m\u001b[2msrc/lib/mock-ipc.ts\u001b[22m\n\u001b[2m5:27:05 PM\u001b[22m \u001b[36m\u001b[1m[vite]\u001b[22m\u001b[39m \u001b[90m\u001b[2m(client)\u001b[22m\u001b[39m \u001b[32mhmr update \u001b[39m\u001b[2m/src/App.svelte, /src/app.css\u001b[22m\n\u001b[2m5:42:43 PM\u001b[22m \u001b[36m\u001b[1m[vite]\u001b[22m\u001b[39m \u001b[90m\u001b[2m(client)\u001b[22m\u001b[39m \u001b[32mhmr update \u001b[39m\u001b[2m/src/App.svelte, /src/app.css\u001b[22m\n\u001b[2m6:13:05 PM\u001b[22m \u001b[36m\u001b[1m[vite]\u001b[22m\u001b[39m \u001b[90m\u001b[2m(client)\u001b[22m\u001b[39m \u001b[32mhmr update \u001b[39m\u001b[2m/src/App.svelte, /src/app.css, /src/lib/Sidebar.svelte, /src/lib/Editor.svelte, /src/lib/PathBreadcrumb.svelte, /src/lib/components/ui/sonner/sonner.svelte, /src/lib/FileTree.svelte\u001b[22m\n\u001b[2m6:13:05 PM\u001b[22m \u001b[36m\u001b[1m[vite]\u001b[22m\u001b[39m \u001b[90m\u001b[2m(client)\u001b[22m\u001b[39m \u001b[32mpage reload \u001b[39m\u001b[2msrc/lib/types.ts\u001b[22m\n\u001b[2m6:32:31 PM\u001b[22m \u001b[36m\u001b[1m[vite]\u001b[22m\u001b[39m \u001b[90m\u001b[2m(client)\u001b[22m\u001b[39m \u001b[32mpage reload \u001b[39m\u001b[2msrc/lib/mock-ipc.ts\u001b[22m","notes":"Phase 2 issue 4.1 layers scripted scenarios on top of this.","status":"closed","priority":1,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-05-19T04:29:31Z","created_by":"James Lal","updated_at":"2026-05-19T16:58:49Z","closed_at":"2026-05-19T16:58:49Z","close_reason":"Duplicate of 12.6/12.7/12.8/12.9/12.10/2.9 — already closed","dependencies":[{"issue_id":"attn-nnj.12.11","depends_on_id":"attn-nnj.12","type":"parent-child","created_at":"2026-05-18T22:29:31Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj.11.8","title":"Dual-instance E2E harness (owner + reviewer via ATTN_HOME)","description":"Package the multi-instance automation pattern as a reusable test harness. Boots two attn daemons under ATTN_HOME=/tmp/attn-collab-owner and ATTN_HOME=/tmp/attn-collab-reviewer (plus optionally local Miniflare), exposes shell helpers attn_owner(...) and attn_reviewer(...) that prefix the right ATTN_HOME, and demonstrates a baseline assertion script that pokes both daemons via --query / --eval / --click. All review surface E2E tests (Phase 2 demo 4.14, Phase 5 e2e 8.6, Phase 4 WebRTC e2e 7.7) reuse this harness rather than wiring two-daemon plumbing themselves.","acceptance_criteria":"- scripts/lib/dual-instance.sh: sourced library exposing attn_owner / attn_reviewer / start_dual / stop_dual / wait_for_dual\\n- scripts/test-dual-instance-smoke.sh: a smoke test that boots two daemons, confirms each --info reports the right ATTN_HOME, drives --query on each independently, tears down cleanly\\n- Trap-based cleanup so Ctrl+C/early-exit kills both daemons\\n- README section in CLAUDE.md documents the pattern with a copy-pasteable example\\n- The Phase 2 demo (4.14) and Phase 5 e2e (8.6) test scripts source this library — no duplicated start/stop boilerplate","notes":"Depends on 2.10 (ATTN_HOME, done) and 11.4 (e2e scaffolding shape — in flight). The harness should NOT depend on Miniflare being up — leave that as a separate optional flag so this can run before the relay lands. Adopters: 4.14 + 7.7 + 8.6 + 11.7 dev-collab.sh.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T23:26:31Z","created_by":"James Lal","updated_at":"2026-05-18T23:57:28Z","started_at":"2026-05-18T23:49:35Z","closed_at":"2026-05-18T23:57:28Z","close_reason":"Implemented; merged into collab","dependencies":[{"issue_id":"attn-nnj.11.8","depends_on_id":"attn-nnj.11","type":"parent-child","created_at":"2026-05-18T17:26:31Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj.11.7","title":"scripts/dev-collab.sh: one-command local collab harness","description":"Boot the whole local collab stack with one command. Starts: Miniflare/wrangler relay on :8787 (background), owner daemon with ATTN_HOME=/tmp/attn-collab-owner pointing at a fixture markdown file, reviewer daemon with ATTN_HOME=/tmp/attn-collab-reviewer joining the room via attn://review/... copied from owner share. Tails logs from both. Ctrl+C kills everything cleanly. Sibling to the existing scripts/test-e2e.sh — same conventions.","acceptance_criteria":"- scripts/dev-collab.sh runs and produces a working owner + reviewer pair connected via local relay\\n- Default fixture file: tests/fixtures/basic.md (or a new collab-specific one)\\n- Environment can be overridden via env vars (ATTN_RELAY_URL, FIXTURE_PATH)\\n- Ctrl+C tears down all 3 processes; no orphans\\n- README section 'Local collab testing' explains the workflow","notes":"Depends on ATTN_HOME (0b-10), the relay scaffold (5-1), and the Rust mailbox transport bootstrap flow (6-6) being usable. Useful from Phase 3 onward; can stub-deploy earlier with just the relay running.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:58:47Z","created_by":"James Lal","updated_at":"2026-05-19T17:36:01Z","started_at":"2026-05-19T17:02:46Z","closed_at":"2026-05-19T17:36:01Z","close_reason":"Round 21 (final push): implemented; merged; build clean","dependencies":[{"issue_id":"attn-nnj.11.7","depends_on_id":"attn-nnj.5.1","type":"blocks","created_at":"2026-05-18T16:58:48Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.11.7","depends_on_id":"attn-nnj.2.10","type":"blocks","created_at":"2026-05-18T16:58:47Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.11.7","depends_on_id":"attn-nnj.11","type":"parent-child","created_at":"2026-05-18T16:58:46Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.11.7","depends_on_id":"attn-nnj.6.6","type":"blocks","created_at":"2026-05-18T16:58:49Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":3,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj.2.10","title":"ATTN_HOME env override for multi-instance dev","description":"src/daemon.rs:109-121 runtime_dir() picks /tmp/attn-\u003cexe-hash\u003e in debug and ~/.attn in release with no env override. Add ATTN_HOME (or ATTN_RUNTIME_DIR) env var that, when set, overrides both code paths. This unblocks running two attn daemons on one machine for local collab testing: ATTN_HOME=/tmp/attn-owner attn ... and ATTN_HOME=/tmp/attn-reviewer attn .... Also threads through to the future review store path so ~/.attn/reviews/ becomes $ATTN_HOME/reviews/.","acceptance_criteria":"- ATTN_HOME env var, when set, replaces the runtime_dir() default in BOTH debug and release\\n- Socket path, fingerprint, log, and (future) reviews/ all live under $ATTN_HOME\\n- Two daemons started with different ATTN_HOME values don't clobber each other's sockets/state\\n- README or CLAUDE.md updated with the multi-instance dev recipe\\n- Existing single-instance behavior unchanged when ATTN_HOME is unset","notes":"Touches src/daemon.rs runtime_dir(). Also update src/projects.rs:73 to fall back to ATTN_HOME before XDG_STATE_HOME so project registry shares the namespace. Tiny change, unblocks Phase 3+ local testing.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:58:45Z","created_by":"James Lal","updated_at":"2026-05-18T23:18:42Z","started_at":"2026-05-18T23:08:50Z","closed_at":"2026-05-18T23:18:42Z","close_reason":"Implemented and merged into collab via parallel worktree agents; pnpm build + cargo check both clean post-merge","dependencies":[{"issue_id":"attn-nnj.2.10","depends_on_id":"attn-nnj.2","type":"parent-child","created_at":"2026-05-18T16:58:45Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":3,"comment_count":0} +{"_type":"issue","id":"attn-nnj.2.9","title":"IpcMessage + SocketMessage review variants (additive)","description":"Pure additive enum extension. In src/ipc.rs:9-59 add IpcMessage variants: review_share, review_join, review_create_comment, review_create_suggestion, review_accept_suggestion, review_resolve_anchor. In src/daemon.rs:62-76 add SocketMessage variants: ReviewShare, ReviewJoin, ReviewPull, ReviewStop, ReviewInbox. Handlers stub to a TODO that the Manager (0b-8) will fill. Lets the frontend stubs (0c-5) wire end-to-end without waiting for ReviewManager.","acceptance_criteria":"- IpcMessage + SocketMessage variants added with serde tagged-enum discrimination\\n- handle_message / handle_client dispatch new variants to TODO!/log handlers without crashing\\n- Frontend can post a review_share message via mock and Rust receives it cleanly\\n- Existing variants untouched","notes":"Audits show both enums are already serde-tagged - this is purely additive. Decouples 0c frontend work from 0b-8 ReviewManager scaffolding.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:53:21Z","created_by":"James Lal","updated_at":"2026-05-18T23:56:36Z","started_at":"2026-05-18T23:49:34Z","closed_at":"2026-05-18T23:56:36Z","close_reason":"Implemented; merged into collab; 27 tests pass","dependencies":[{"issue_id":"attn-nnj.2.9","depends_on_id":"attn-nnj.2.2","type":"blocks","created_at":"2026-05-18T16:54:00Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.2.9","depends_on_id":"attn-nnj.2.1","type":"blocks","created_at":"2026-05-18T16:54:00Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.2.9","depends_on_id":"attn-nnj.2","type":"parent-child","created_at":"2026-05-18T16:53:21Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":2,"comment_count":0} +{"_type":"issue","id":"attn-nnj.12.10","title":"review/store.ts scaffold (Svelte 5 runes)","description":"Scaffold web/src/lib/review/store.ts as the global review state holder using Svelte 5 runes. Minimal API: state for panelOpen, currentRoomId, peers[], threads[] (empty), pendingOutbox (empty). Subscribers to window.__attn__.reviewEvent / reviewStatus push into this. Phase 2 issue 4.2 layers derived selectors on top (comments-on-current-snapshot, ambiguous-list, outbox-count).","acceptance_criteria":"- web/src/lib/review/store.ts exists with rune-based state and typed via 0c-4 interfaces\\n- IPC callbacks from 0c-3 push events into the store\\n- No reactivity bugs: subscribing components see updates via derived\\n- Empty initial state renders cleanly","notes":"Use Svelte 5 runes per project convention (svelte5-best-practices skill exists). Phase 2 4.2 extends this with derived selectors.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:53:20Z","created_by":"James Lal","updated_at":"2026-05-18T23:45:22Z","started_at":"2026-05-18T23:31:43Z","closed_at":"2026-05-18T23:45:22Z","close_reason":"Implemented in parallel worktrees; merged into collab","dependencies":[{"issue_id":"attn-nnj.12.10","depends_on_id":"attn-nnj.12.4","type":"blocks","created_at":"2026-05-18T16:53:44Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.12.10","depends_on_id":"attn-nnj.12","type":"parent-child","created_at":"2026-05-18T16:53:20Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.12.10","depends_on_id":"attn-nnj.12.3","type":"blocks","created_at":"2026-05-18T16:53:46Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-nnj.12.9","title":"keyboard.ts hooks: comment/suggestion/panel-toggle","description":"Extend KeyboardConfig in web/src/lib/keyboard.ts with optional onCommentComposer, onSuggestionComposer, onToggleReviewPanel handlers. Default bindings: Cmd+. (comment), Cmd+Shift+. (suggestion), Cmd+J (toggle panel). Existing shortcuts unaffected. Update KeyboardShortcutsDialog.svelte to surface the new bindings only when a review room is active.","acceptance_criteria":"- 3 new handler keys on KeyboardConfig (all optional)\\n- Default keybinds registered when handlers provided\\n- KeyboardShortcutsDialog conditionally shows the review section\\n- No collision with existing shortcuts (j/k scroll, g/G top-bottom, t theme, q quit, e edit, f sidebar, Cmd+W/[/], etc.)","notes":"CLAUDE.md keyboard table is authoritative for existing shortcuts.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:53:20Z","created_by":"James Lal","updated_at":"2026-05-18T23:18:28Z","started_at":"2026-05-18T23:08:49Z","closed_at":"2026-05-18T23:18:28Z","close_reason":"Implemented and merged into collab via parallel worktree agents; pnpm build + cargo check both clean post-merge","dependencies":[{"issue_id":"attn-nnj.12.9","depends_on_id":"attn-nnj.12","type":"parent-child","created_at":"2026-05-18T16:53:19Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":2,"comment_count":0} +{"_type":"issue","id":"attn-nnj.12.8","title":"popover-anchor utility (selection -\u003e DOM rect -\u003e constrained pop)","description":"Shared utility web/src/lib/review/popover-anchor.ts: given a ProseMirror EditorView + selection range, return a DOMRect and a constrained position for a popover (clamped within viewport, flipped above/below as needed). Used by comment composer (2-4), suggestion composer (2-5), and the ambiguous anchor picker (2-7) so all three pop in the same spot.","acceptance_criteria":"- Pure TS function: (view, from, to) returns { rect, recommendedAnchor: { top, left, side: above|below } }\\n- Handles multi-line selections (uses leading rect)\\n- Unit test against a simulated EditorView\\n- No any types","notes":"Reference impl: web/src/lib/CommandPalette.svelte may have positioning logic to reuse.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:53:19Z","created_by":"James Lal","updated_at":"2026-05-18T23:18:15Z","started_at":"2026-05-18T23:08:49Z","closed_at":"2026-05-18T23:18:15Z","close_reason":"Implemented and merged into collab via parallel worktree agents; pnpm build + cargo check both clean post-merge","dependencies":[{"issue_id":"attn-nnj.12.8","depends_on_id":"attn-nnj.12","type":"parent-child","created_at":"2026-05-18T16:53:18Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":3,"comment_count":0} +{"_type":"issue","id":"attn-nnj.12.7","title":"Theme CSS variables for review surfaces","description":"app.css today has surfaces/accent/sidebar/code-block vars but no decoration/panel vars. Add light + dark mode vars: --comment-highlight, --suggestion-bg, --suggestion-deletion, --confidence-high, --confidence-med, --confidence-low, --moved-badge-bg, --moved-badge-fg, --panel-surface, --panel-border, --peer-avatar-bg-{owner,reviewer,agent}, --stale-anchor-fg. Coordinate values via the existing OKLCH ramp.","acceptance_criteria":"- All new vars defined for both :root (light) and .dark themes\\n- Toggle via existing theme system works without flicker\\n- Variables surface in CSS, ready to be referenced by Phase 2 decoration plugin and components\\n- Use rampa-colors / theme-foundation skills if generating new ramps","notes":"Use OKLCH per project convention. Available skills: rampa-colors, theme-foundation.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:53:18Z","created_by":"James Lal","updated_at":"2026-05-18T23:18:02Z","started_at":"2026-05-18T23:08:48Z","closed_at":"2026-05-18T23:18:02Z","close_reason":"Implemented and merged into collab via parallel worktree agents; pnpm build + cargo check both clean post-merge","dependencies":[{"issue_id":"attn-nnj.12.7","depends_on_id":"attn-nnj.12","type":"parent-child","created_at":"2026-05-18T16:53:17Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":3,"comment_count":0} +{"_type":"issue","id":"attn-nnj.12.6","title":"mock-ipc.ts: review callback shims (no scenario data)","description":"Extend mock-ipc.ts so the mocked window.ipc.postMessage understands review_* commands (logs them, echoes a fake ack via the review callbacks). No scripted scenario data yet — that's Phase 2 issue 4.1, which builds on this surface. Without this, frontend dev breaks the moment any review_* call is made.","acceptance_criteria":"- Mock dispatches review_* commands to no-op handlers; no console errors\\n- Mock invokes window.__attn__.reviewStatus/reviewEvent/reviewSnapshot/reviewAnchorResolution handlers when called via a test helper\\n- No regression in existing mocked surface","notes":"Phase 2 issue 4.1 layers scripted scenarios on top of this.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:53:17Z","created_by":"James Lal","updated_at":"2026-05-19T00:40:12Z","started_at":"2026-05-19T00:26:06Z","closed_at":"2026-05-19T00:40:12Z","close_reason":"Implemented in parallel; merged into collab; 166 tests pass + relay 35 tests pass","dependencies":[{"issue_id":"attn-nnj.12.6","depends_on_id":"attn-nnj.12.5","type":"blocks","created_at":"2026-05-18T16:53:45Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.12.6","depends_on_id":"attn-nnj.12.3","type":"blocks","created_at":"2026-05-18T16:53:45Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.12.6","depends_on_id":"attn-nnj.12","type":"parent-child","created_at":"2026-05-18T16:53:17Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.12.6","depends_on_id":"attn-nnj.12.4","type":"blocks","created_at":"2026-05-18T16:53:44Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":3,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-nnj.12.4","title":"types.ts: ReviewEvent / Anchor / ResolvedAnchor interfaces","description":"Add review domain TypeScript interfaces matching the Rust serde shapes (camelCase): ReviewEvent (with EventMeta/Body discriminated union), ReviewStatus, ReviewSnapshot, ReviewAnchorResolutionUpdate, Anchor (with PositionAnchor/QuoteAnchor/BlockAnchor/ContextAnchor/StructureAnchor sub-types), ResolvedAnchor (exact|remapped|ambiguous|stale variants), SuggestionOperation. Source of truth is data-model.md.","acceptance_criteria":"- No types (user instruction)\\n- All variants from data-model.md represented\\n- Includes JSDoc citing data-model.md section per type\\n- Roundtrips through JSON.parse(JSON.stringify(x)) without loss","notes":"data-model.md §Anchors, §Anchor Resolution, §Review Events.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:50:13Z","created_by":"James Lal","updated_at":"2026-05-18T23:17:47Z","started_at":"2026-05-18T23:08:48Z","closed_at":"2026-05-18T23:17:47Z","close_reason":"Implemented and merged into collab via parallel worktree agents; pnpm build + cargo check both clean post-merge","dependencies":[{"issue_id":"attn-nnj.12.4","depends_on_id":"attn-nnj.12","type":"parent-child","created_at":"2026-05-18T16:50:12Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":4,"comment_count":0} +{"_type":"issue","id":"attn-nnj.12.5","title":"ipc.ts: review_* outbound command stubs","description":"Extend web/src/lib/ipc.ts with typed outbound commands: review_share, review_join, review_create_comment, review_create_suggestion, review_accept_suggestion, review_resolve_anchor. Each is a thin wrapper around window.ipc.postMessage with the typed payload. No backend yet; calls land at the Rust IpcMessage handler stub (0b-9).","acceptance_criteria":"- 6 typed exported functions in web/src/lib/ipc.ts\\n- Payloads typed via types.ts (depends on 0c-4)\\n- Real ipc.ts uses postMessage; mock-ipc.ts (depends on 0c-6) routes to local handler\\n- Functions are async and return a typed Result/Promise","notes":"Spec: data-model.md §Webview IPC Changes.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:50:13Z","created_by":"James Lal","updated_at":"2026-05-19T00:23:43Z","started_at":"2026-05-19T00:04:12Z","closed_at":"2026-05-19T00:23:43Z","close_reason":"Implemented in parallel worktrees; merged into collab; 142 tests pass","dependencies":[{"issue_id":"attn-nnj.12.5","depends_on_id":"attn-nnj.2.9","type":"blocks","created_at":"2026-05-18T16:54:02Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.12.5","depends_on_id":"attn-nnj.12","type":"parent-child","created_at":"2026-05-18T16:50:13Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.12.5","depends_on_id":"attn-nnj.12.4","type":"blocks","created_at":"2026-05-18T16:53:43Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":5,"comment_count":0} +{"_type":"issue","id":"attn-nnj.12.2","title":"Editor.svelte: $props-injectable plugins + nodeViews","description":"Editor.svelte:137-186 buildPlugins() is monolithic; nodeViews dict at 475-484 is closed. Extend to accept optional plugins?: Plugin[] and nodeViews?: Record\u003cstring, NodeViewConstructor\u003e via Svelte 5 , appended after built-ins. Enables Phase 2 decorations plugin and any future collab plugins without re-hardcoding.","acceptance_criteria":"- extended with plugins?, nodeViews?\\n- Built-in plugins still loaded first; injected plugins appended\\n- Existing callers unaffected (props are optional)\\n- Stub test: passing an empty decoration plugin must not regress existing nodeViews (math, mermaid, tables, code-highlight)","notes":"Model after the existing prosemirror/code-highlight.ts factory shape.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:50:11Z","created_by":"James Lal","updated_at":"2026-05-18T23:17:33Z","started_at":"2026-05-18T23:08:48Z","closed_at":"2026-05-18T23:17:33Z","close_reason":"Implemented and merged into collab via parallel worktree agents; pnpm build + cargo check both clean post-merge","dependencies":[{"issue_id":"attn-nnj.12.2","depends_on_id":"attn-nnj.12","type":"parent-child","created_at":"2026-05-18T16:50:10Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-nnj.12.3","title":"window.__attn__ bridge: review callback registration","description":"App.svelte:1003-1019 registers setContent/updateContent/font scale only. Extend with no-op stubs for reviewStatus(payload), reviewEvent(payload), reviewSnapshot(snapshot), reviewAnchorResolution(update). Real handlers wire to the review store later (Phase 0c-10 / Phase 2).","acceptance_criteria":"- window.__attn__ exposes the 4 new methods, each typed via types.ts (depends on 0c-4)\\n- Default impl: console.debug only, so Rust can already evaluate_script without errors\\n- Type definitions in web/src/vite-env.d.ts (or wherever Window augmentation lives) updated","notes":"Spec: data-model.md §Webview IPC Changes.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:50:11Z","created_by":"James Lal","updated_at":"2026-05-18T23:29:47Z","started_at":"2026-05-18T23:21:22Z","closed_at":"2026-05-18T23:29:47Z","close_reason":"Implemented via parallel worktree agents; merged into collab","dependencies":[{"issue_id":"attn-nnj.12.3","depends_on_id":"attn-nnj.12.4","type":"blocks","created_at":"2026-05-18T16:53:42Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.12.3","depends_on_id":"attn-nnj.12","type":"parent-child","created_at":"2026-05-18T16:50:11Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} +{"_type":"issue","id":"attn-nnj.12.1","title":"3-column layout: right-rail slot in App.svelte","description":"App.svelte:1352-1373 SidebarInset is 2-column today (sidebar + editor). Extend to 3-column with a named right-rail slot for the future ReviewPanel. Slot collapses when no review room is active (no chrome shift).","acceptance_criteria":"- App.svelte exposes a right-rail snippet/slot that mounts a placeholder div when no review session\\n- Layout uses CSS flex/grid, not absolute positioning\\n- Sidebar toggle (existing) still works; right-rail toggles independently via Cmd+J (placeholder shortcut)\\n- No visual regression with attn ./planning/ baseline","notes":"Touches App.svelte mainContent snippet. Don't render ReviewPanel yet — just the slot.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:50:09Z","created_by":"James Lal","updated_at":"2026-05-18T23:17:18Z","started_at":"2026-05-18T23:08:47Z","closed_at":"2026-05-18T23:17:18Z","close_reason":"Implemented and merged into collab via parallel worktree agents; pnpm build + cargo check both clean post-merge","dependencies":[{"issue_id":"attn-nnj.12.1","depends_on_id":"attn-nnj.12","type":"parent-child","created_at":"2026-05-18T16:50:09Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":6,"comment_count":0} +{"_type":"issue","id":"attn-nnj.12","title":"Phase 0c: UI/IPC plumbing","description":"Frontend + IPC infrastructure that has to land before Phase 2 features can drop in cleanly. Pure plumbing — no review UI rendered yet. Audit grounded: App.svelte:1352-1373 has only 2-column SidebarInset; Editor.svelte:137-186 hardcodes 8 plugins; App.svelte:1003-1019 window.__attn__ bridge registers no review callbacks; mock-ipc.ts has no review surface; ipc.ts has no review_* commands; types.ts has no ReviewEvent shapes; theme has no decoration vars.","notes":"Sequencing: blocks Phase 2 features. Per user direction, all crypto stays in Rust — frontend only handles plaintext ReviewEvent objects.","status":"closed","priority":1,"issue_type":"epic","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:50:08Z","created_by":"James Lal","updated_at":"2026-05-19T17:02:10Z","closed_at":"2026-05-19T17:02:10Z","close_reason":"All children closed","dependencies":[{"issue_id":"attn-nnj.12","depends_on_id":"attn-nnj","type":"parent-child","created_at":"2026-05-18T16:50:07Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj.11.5","title":"Security review pass","description":"After Phase 3a / 3b / 4 land and before any public release: full security review of crypto envelope handling. Specifically check: signature verification ordering (decrypt under AEAD FIRST, then verify the plaintext signature — never the reverse, which would let an attacker substitute a verified ciphertext); AAD binding completeness on every encrypt/decrypt call site (no naked AEAD calls); PoW token replay window (the 5-minute window from decision #6 actually enforced); ownerSigningKey TOFU correctness (first signature wins, subsequent different keys rejected); browser fragment-stripping race window (no observer can see the fragment between page load and replaceState). Use the security-review skill if available.","acceptance_criteria":"planning/collab/security-review.md created with findings + severity per checked area.\nEach of the five focus areas (decrypt-then-verify order, AAD binding, PoW replay window, owner TOFU, browser fragment race) has a section with: pass/fail, code references, evidence.\nAny HIGH or CRITICAL findings have follow-up bd issues created and linked.\nReview covers Rust client AND TS browser client AND relay worker — all three speak crypto.\nSign-off recorded in the doc with date and reviewer.","notes":"Specs: planning/collab/crypto-spec.md (entire), planning/collab/relay-spec.md §Anti-Abuse + §Admission, planning/collab/amendments.md §Decision #6 + §Decision #13 + §Owner identity. Files: planning/collab/security-review.md (new), src/review/crypto.rs, relay/src/*, web/src/lib/review/*. Skills: security-review skill is available — invoke it explicitly. Schedule AFTER Phase 4 lands (so all crypto codepaths exist) but BEFORE public release.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:35:24Z","created_by":"James Lal","updated_at":"2026-05-19T16:25:30Z","started_at":"2026-05-19T16:07:55Z","closed_at":"2026-05-19T16:25:30Z","close_reason":"Round 19: implemented; merged; build clean","dependencies":[{"issue_id":"attn-nnj.11.5","depends_on_id":"attn-nnj.11","type":"parent-child","created_at":"2026-05-18T16:35:24Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.11.5","depends_on_id":"attn-nnj.7.7","type":"blocks","created_at":"2026-05-18T16:38:35Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.11.5","depends_on_id":"attn-nnj.6.7","type":"blocks","created_at":"2026-05-18T16:38:35Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.11.5","depends_on_id":"attn-nnj.5.15","type":"blocks","created_at":"2026-05-18T16:38:34Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.11.5","depends_on_id":"attn-nnj.8.6","type":"blocks","created_at":"2026-05-18T16:38:36Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":4,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj.11.4","title":"E2E test scaffolding for review surfaces","description":"Extend scripts/test-e2e.sh with a review/ test suite that boots the daemon under task dev with a mock-IPC scenario file pre-loaded, then uses --eval / --query / --wait-for to assert review-panel state (comment count, anchor status, suggestion list, etc.). Lays groundwork for Phase 2's 'comment survives owner edits' demo AND Phase 5's apply integration test. Reuses the daemon's existing automation flags so we don't need a separate Playwright runner.","acceptance_criteria":"scripts/test-e2e.sh has a new review/ section that loads a mock-IPC fixture into web/src/lib/mock-ipc.ts via env var or query param.\nHelpers: wait_for_review_state, assert_comment_count, assert_anchor_status etc. (bash functions) that wrap --query and --wait-for.\nA scaffold test boots the daemon with a fixture containing 2 mock comments and asserts both render in the panel.\nTest passes locally on macOS without requiring a relay or webrtc-rs (mock-IPC drives the frontend in isolation).\nScreenshot captured to /tmp/attn-e2e-screenshots/review-*.png for visual review.\nPattern documented so Phase 2 + Phase 5 authors can extend without reinventing.","notes":"Specs: planning/collab/amendments.md §existing automation flags affect ReviewManager design, §Mock IPC must be extended. Files: scripts/test-e2e.sh, tests/fixtures/review/ (new), web/src/lib/mock-ipc.ts (extend). Mock-IPC extension is described in amendments.md §Mock IPC — coordinate so this scaffolding lands alongside or after that extension. window.__attn__.reviewState() should be a stable shape that this test depends on.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:35:09Z","created_by":"James Lal","updated_at":"2026-05-18T23:41:35Z","started_at":"2026-05-18T23:21:24Z","closed_at":"2026-05-18T23:41:35Z","close_reason":"Re-close after DB restore: implemented; merged into collab; 8 PASS / 5 PEND / 0 FAIL","dependencies":[{"issue_id":"attn-nnj.11.4","depends_on_id":"attn-nnj.11","type":"parent-child","created_at":"2026-05-18T16:35:09Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":2,"comment_count":0} +{"_type":"issue","id":"attn-nnj.6.7","title":"Conformance integration tests against Miniflare","description":"Run the relay conformance corpus from Phase 3a issue 14 against the Rust client. CI script: boot 'wrangler dev --local' (Miniflare) from the relay/ package, then run cargo test --features mailbox-integration on the Rust crate. Covers happy path and every error code the relay returns so the Rust client stays in lock-step with the relay's wire contract.","acceptance_criteria":"- scripts/test-mailbox-integration.sh (or task target): starts wrangler dev --local in background, waits for /health, runs cargo test --features mailbox-integration -- --test-threads=1, tears down\n- Rust tests under src/review/transport/tests/ (or tests/ integration crate) load relay/test/conformance/cases.json via serde and execute each case via the real MailboxTransport\n- Coverage matches Phase 3a issue 14 corpus: room lifecycle, WS backfill (full / mid / 4005), all caps + batch=32, owner-only ops, PoW failures, hibernation roundtrip, rate limits, longSession clamping\n- CI integration: GitHub Actions job runs this script and fails on any case mismatch\n- Tests fail fast on any new relay-side error code missing from the Rust mapping (helps catch wire drift early)","notes":"Spec: planning/collab/relay-spec.md §Test Plan (687-705). Consumes conformance corpus from 3a-14 — make sure the JSON schema is serde-friendly. Blocked by 3b-6 since bootstrap is the precondition for every other case.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:34:58Z","created_by":"James Lal","updated_at":"2026-05-19T13:50:28Z","started_at":"2026-05-19T04:31:04Z","closed_at":"2026-05-19T13:50:28Z","close_reason":"Implemented (partially for 5.14 — scaffold + skip-on-empty, follow-up to fill cases.json); 372 Rust + 184 relay tests pass","dependencies":[{"issue_id":"attn-nnj.6.7","depends_on_id":"attn-nnj.6.1","type":"blocks","created_at":"2026-05-18T16:36:09Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.6.7","depends_on_id":"attn-nnj.6","type":"parent-child","created_at":"2026-05-18T16:34:57Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.6.7","depends_on_id":"attn-nnj.6.6","type":"blocks","created_at":"2026-05-18T16:36:12Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.6.7","depends_on_id":"attn-nnj.2.10","type":"blocks","created_at":"2026-05-18T16:58:49Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.6.7","depends_on_id":"attn-nnj.5.14","type":"blocks","created_at":"2026-05-18T16:35:59Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":4,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-nnj.6.6","title":"Device + room bootstrap flow (Share + Join commands)","description":"Wire ReviewManager into ReviewCommand::Share and ReviewCommand::Join. Share: derive room keys (event/snapshot/signal/admission per crypto-spec §Key Derivation), POST /v2/rooms/:roomId with ownerSigningKey + clamped policy, POST /devices with kind=owner and self-signature, populate the local /devices cache, emit ReviewUpdate::RoomCreated. Join: parse the invite URL (crypto-spec §Invite URLs), derive the same per-kind keys, POST /devices with kind=reviewer or agent, GET /devices to populate the verification cache, emit ReviewUpdate::ParticipantJoined.","acceptance_criteria":"- Share flow: generate room secret → derive event/snapshot/signal/admission keys + ownerSigningKey → POST /v2/rooms/:roomId (mint PoW from pool, send admissionKey, ownerSigningKey, clamped policy) → POST /devices (kind=owner, selfSignature over canonical device bytes) → store room state under ~/.attn/reviews/rooms/\u003croomId\u003e/ → emit ReviewUpdate::RoomCreated{invite_url}\n- Join flow: parse invite URL → derive keys identically → POST /devices (kind=reviewer|agent per CLI flag) → GET /devices → cache the device roster (publicSigningKey by deviceId) → emit ReviewUpdate::ParticipantJoined{room, peers}\n- Owner-key handling: ownerSigningKey is generated client-side at Share, stored locally as private key, public half sent in the create body; never written outside ~/.attn (file perms 600)\n- Both flows install the WS client (issue 3b-3) and outbox processor (3b-2) for the room after bootstrap completes\n- Bootstrap errors map: room create 409 ATTN_ROOM_EXISTS_DIFFERENT_POLICY → ReviewUpdate::ShareConflict; device 409 ATTN_DEVICE_KEY_CHANGED → ReviewUpdate::JoinKeyConflict\n- Tests: Share against Miniflare, Join against same Miniflare instance, key derivation determinism, owner-key mismatch on rejoin attempt, peer roster cached and refreshed","notes":"Spec: planning/collab/crypto-spec.md §Key Derivation (39-77), §Invite URLs (59-77), §Signing-Key Publication (344-403). planning/collab/relay-spec.md §POST /v2/rooms/:roomId (114-167), §POST /v2/rooms/:roomId/devices (169-217). Consumes issue 3b-3 (WS client) and 3b-2 (outbox) once bootstrap completes.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:34:57Z","created_by":"James Lal","updated_at":"2026-05-19T04:07:48Z","started_at":"2026-05-19T03:41:59Z","closed_at":"2026-05-19T04:07:48Z","close_reason":"Implemented; merged into collab; 346 Rust + 168 relay tests pass","dependencies":[{"issue_id":"attn-nnj.6.6","depends_on_id":"attn-nnj.11.1","type":"blocks","created_at":"2026-05-18T16:38:27Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.6.6","depends_on_id":"attn-nnj.6.1","type":"blocks","created_at":"2026-05-18T16:36:09Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.6.6","depends_on_id":"attn-nnj.5.6","type":"blocks","created_at":"2026-05-18T16:38:26Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.6.6","depends_on_id":"attn-nnj.5.5","type":"blocks","created_at":"2026-05-18T16:38:27Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.6.6","depends_on_id":"attn-nnj.6.3","type":"blocks","created_at":"2026-05-18T16:36:11Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.6.6","depends_on_id":"attn-nnj.6","type":"parent-child","created_at":"2026-05-18T16:34:56Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":5,"dependent_count":2,"comment_count":0} +{"_type":"issue","id":"attn-nnj.6.5","title":"Cursor management + 4005 cursor-too-old recovery","description":"Persist last_seen_seq per room in ~/.attn/reviews/rooms/\u003croomId\u003e/cursors.json. Update after every successfully imported envelope (issue 3b-4 wires the actual write). On WS error{ATTN_CURSOR_TOO_OLD, resyncFromSeq}: discard the current cursor, attempt a P2P snapshot request from a peer device (defer this branch to Phase 4 if WebRTC transport isn't yet wired; in the meantime fall back to re-subscribe from resyncFromSeq, accepting the pre-resync history loss explicitly via ReviewUpdate::HistoryGap).","acceptance_criteria":"- ~/.attn/reviews/rooms/\u003croomId\u003e/cursors.json holds {last_seen_seq, oldest_retained_seq, updated_at}; atomic write via temp+rename\n- Read on startup; passed to issue 3b-3 WS client for the initial subscribe.after\n- On TransportError::CursorTooOld{resync_from_seq}: log warning, emit ReviewUpdate::HistoryGap{lost_from, lost_to}, set last_seen_seq=resync_from_seq, write cursors.json, reconnect with subscribe{after: resync_from_seq}\n- Stub for P2P snapshot recovery: a clear TODO('phase-4 webrtc') with the signature of a future async fn request_snapshot_from_peer(peer_device_id) so Phase 4 can plug in\n- Tests: cursor persistence across restart, 4005 fallback to resync (no P2P), atomic write under crash simulation, HistoryGap emitted exactly once per 4005","notes":"Spec: planning/collab/relay-spec.md §WebSocket Protocol (372-422, error frame) and §Close Codes (4005). amendments.md decision #5 (no GET /envelopes backfill means 4005 is the only way history is exposed). Snapshot-from-peer plumbing depends on Phase 4 WebRTC and is intentionally stubbed here.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:34:56Z","created_by":"James Lal","updated_at":"2026-05-19T04:30:01Z","started_at":"2026-05-19T04:09:04Z","closed_at":"2026-05-19T04:30:01Z","close_reason":"Implemented; merged","dependencies":[{"issue_id":"attn-nnj.6.5","depends_on_id":"attn-nnj.6.3","type":"blocks","created_at":"2026-05-18T16:36:11Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.6.5","depends_on_id":"attn-nnj.6.1","type":"blocks","created_at":"2026-05-18T16:36:08Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.6.5","depends_on_id":"attn-nnj.6","type":"parent-child","created_at":"2026-05-18T16:34:55Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj.6.4","title":"Inbound envelope pipeline (decrypt + verify + import)","description":"Per ServerFrame::Envelope from the WS stream: look up the per-kind key (eventKey | snapshotKey | signalKey) from room state, AES-256-GCM decrypt, verify Ed25519 signature against the deviceId's publicSigningKey from the cached /devices roster, dedupe by EventId (against ~/.attn/reviews/rooms/\u003croomId\u003e/events.jsonl ledger), append to events.jsonl, update the persistent sync cursor. On success emit ReviewUpdate::EventImported into the ReviewManager event loop.","acceptance_criteria":"- For each ServerFrame::Envelope: select key by envelope.kind; decrypt (AES-256-GCM, nonce per crypto-spec §Nonce Discipline) — failure → ReviewUpdate::DecryptFailed and continue (do not crash, do not advance cursor)\n- Ed25519 verify plaintext signature against device.publicSigningKey from cached /devices snapshot; signature failure → drop + ReviewUpdate::SignatureInvalid (do not advance cursor for that envelope)\n- Dedupe by EventId: if already present in events.jsonl skip the append (but still advance the seq cursor)\n- Append the canonical event bytes to events.jsonl (append-only, fsync per batch)\n- Update last_seen_seq AFTER successful append (so a crash mid-write replays cleanly)\n- Emit ReviewUpdate::EventImported{event} to the consumer (Phase 4 UI / ReviewManager)\n- Cached /devices roster auto-refreshed when an envelope arrives signed by an unknown deviceId (issue GET /devices and retry verify once)\n- Tests: happy path, wrong key, wrong sig, duplicate EventId, unknown device triggers refresh + verify","notes":"Spec: planning/collab/crypto-spec.md §Envelope Encryption (79-115), §Nonce Discipline (108-115), §Signatures (199-258). planning/collab/data-model.md §Transport Model. /devices cache is populated by issue 3b-6 bootstrap and refreshed lazily.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:34:55Z","created_by":"James Lal","updated_at":"2026-05-19T04:06:29Z","started_at":"2026-05-19T03:23:02Z","closed_at":"2026-05-19T04:06:29Z","close_reason":"Implemented; merged into collab; 346 Rust + 168 relay tests pass","dependencies":[{"issue_id":"attn-nnj.6.4","depends_on_id":"attn-nnj.6","type":"parent-child","created_at":"2026-05-18T16:34:55Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.6.4","depends_on_id":"attn-nnj.6.1","type":"blocks","created_at":"2026-05-18T16:36:08Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-nnj.11.3","title":"Binary-size verification CI gate","description":"Add scripts/check-binary-size.sh that runs after cargo build --release and asserts the .app bundle (or stripped binary if not bundled) is under 25 MiB. Wire into CI so Phase 4 (webrtc-rs) cannot regress past the budget without an explicit waiver. This is the safety net that prevents future PRs from silently breaking the decision #1 tradeoff.","acceptance_criteria":"scripts/check-binary-size.sh exists; runs du on the appropriate artifact (.app bundle preferred, falls back to stripped binary).\nExits non-zero with a clear message if size \u003e 25 MiB; prints the size + the budget either way.\nCI workflow (.github/workflows/*.yml) invokes it on every PR that touches Cargo.toml or src/**.\nFailure can be waived only by setting ATTN_SIZE_BUDGET_WAIVER=1 in CI env (or equivalent) with a comment in the PR.\nDocumented in CLAUDE.md or RELEASE_SETUP.md so future contributors know the rule.","notes":"Specs: planning/collab/amendments.md §Decision #1, §Phase 4. Files: scripts/check-binary-size.sh (new), .github/workflows/*.yml. Use the same release/bundle output the existing scripts/build.sh produces. The 25 MiB number comes directly from Decision #1's tradeoff statement. Blocks Phase 4 work in the sense that Phase 4 issue 1 should consume this gate.","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:34:54Z","created_by":"James Lal","updated_at":"2026-05-18T23:45:09Z","closed_at":"2026-05-18T23:45:09Z","close_reason":"Implemented in parallel worktrees; merged into collab","dependencies":[{"issue_id":"attn-nnj.11.3","depends_on_id":"attn-nnj.11","type":"parent-child","created_at":"2026-05-18T16:34:54Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj.11.1","title":"attn://review/... custom-scheme handler","description":"In src/main.rs custom protocol handler, route attn://review/\u003croomId\u003e#key=... to a new SocketMessage::ReviewJoin{invite} BEFORE falling through to the existing attn://localhost/... file-serving path. Reject attn://localhost/review/... explicitly (it's reserved per amendments.md). Without this, clicking an invite URL on macOS won't work and Phase 4/6 invite handling is blocked.","acceptance_criteria":"src/main.rs custom protocol handler matches attn://review/\u003c...\u003e before the existing file-serving route.\nMatched invites become a SocketMessage::ReviewJoin{invite: String} dispatched to the daemon.\nattn://localhost/review/... returns a 400-equivalent (refused with a clear log line) — reserved path collision.\nNon-review attn://localhost/* paths continue to serve files as today (existing behavior unchanged).\nUnit test or integration test (via --eval) confirms a synthetic attn://review/abc#key=xyz hits the ReviewJoin handler.\nSocketMessage::ReviewJoin variant defined and wired through daemon.rs.","notes":"Specs: planning/collab/amendments.md §Custom attn:// scheme handler. Files: src/main.rs (~1207 lines — locate the existing custom_protocol registration), src/daemon.rs (new SocketMessage variant), src/ipc.rs (potentially). Critical detail: the route match must come BEFORE the fallthrough, not after. Blocks Phase 4 (issue 7 e2e test needs to click an invite) and Phase 6 (browser invite parse logic on the native side too).","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:34:30Z","created_by":"James Lal","updated_at":"2026-05-18T23:33:30Z","started_at":"2026-05-18T23:21:23Z","closed_at":"2026-05-18T23:33:30Z","close_reason":"Implemented; merged into collab; cargo check + cargo test clean","dependencies":[{"issue_id":"attn-nnj.11.1","depends_on_id":"attn-nnj.11","type":"parent-child","created_at":"2026-05-18T16:34:30Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-nnj.9.7","title":"CLI subcommands for local agents","description":"Full set of attn review subcommands so a local agent (e.g., a coding assistant running on the owner's machine) can drive the daemon: share \u003cpath\u003e [--mode live|async|hybrid] [--ttl 7d]; join \u003cinvite\u003e; inbox [--json]; submit-comment \u003cfile\u003e; submit-suggestion \u003cfile\u003e; pull; stop. Each maps to a SocketMessage variant per data-model.md §Daemon Socket Commands. Local CLI uses owner's daemon identity by default (per amendments.md §Agent CLI key handling) — distinct from remote agents in Phase 6 issue 6.","acceptance_criteria":"Each subcommand parses via clap and dispatches a typed SocketMessage to the running daemon.\nshare creates a new room with the given policy fields, prints the invite URL (attn://review/...) for the owner to share.\njoin takes either an attn://review/... URL or a raw invite string; daemon joins the room and starts receiving envelopes.\ninbox lists pending review actions on the owner's open rooms; --json emits structured output for agent consumption.\nsubmit-comment / submit-suggestion take a JSON file (or stdin) describing the anchor + content; daemon adds to outbox with the owner's identity by default.\npull manually triggers a relay catchup. stop ends a room (owner only — requires owner signature).\nCLI help (attn review --help) lists all subcommands with clear examples.","notes":"Specs: planning/collab/amendments.md §Agent CLI key handling, planning/collab/data-model.md §Daemon Socket Commands. Files: src/cli/review.rs (new), src/daemon.rs (new SocketMessage variants). Owner-identity default is the load-bearing detail vs Phase 6 issue 6 (remote agent has its own key). --as-agent \u003cname\u003e override comes from Phase 6 issue 6's implementation but the flag itself can be wired here.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:34:15Z","created_by":"James Lal","updated_at":"2026-05-19T15:06:14Z","started_at":"2026-05-19T04:31:06Z","closed_at":"2026-05-19T15:06:14Z","close_reason":"Implemented; merged; 412 Rust + 213 relay tests pass (6 conformance scenarios deferred to 5.16)","dependencies":[{"issue_id":"attn-nnj.9.7","depends_on_id":"attn-nnj.2.8","type":"blocks","created_at":"2026-05-18T16:39:10Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.9.7","depends_on_id":"attn-nnj.9","type":"parent-child","created_at":"2026-05-18T16:34:14Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj.6.3","title":"WebSocket client + reconnect with backoff","description":"Tokio-tungstenite (or async-tungstenite) WebSocket client. Subprotocol header: 'attn.v2, \u003cbase64url admission HMAC\u003e'. Handles server ping by responding with pong inside 60s. On disconnect: exponential backoff reconnect (200ms → 30s cap with jitter), replay subscribe{after: last_seen_seq} on each reconnect. Maps close codes 4000/4001/4002/4005 to typed transport errors so the ReviewManager can surface them as ReviewUpdate variants.","acceptance_criteria":"- Connects with subprotocol 'attn.v2' and admission HMAC piggybacked (matches issue 3a-11 handshake)\n- After connect, sends subscribe{after: last_seen_seq} and starts receiving ServerFrame items as a Stream\n- Ping handler: replies with pong within 60s; if no server ping in 90s, force reconnect (defensive)\n- Disconnect handler: exponential backoff (200ms, 400ms, 800ms, ... cap 30s) with ±25% jitter\n- Close codes mapped: 4000→TransportError::AdmissionInvalid (non-retryable), 4001→RoomDeleted (terminal), 4002→RoomExpired (terminal), 4005→CursorTooOld{resync_from_seq} (handled by issue 3b-5), 1001→Timeout (retryable)\n- Replays subscribe{after: last_seen_seq} on every reconnect so the stream resumes from the persistent cursor\n- Tests: happy-path frame roundtrip (against Miniflare from 3a-11), ping/pong, reconnect after server-initiated close, 4005 surfacing, 4001/4002 terminal","notes":"Spec: planning/collab/relay-spec.md §WebSocket Protocol (362-459) and §Close Codes (423-431). Implements the client side of issue 3a-11. Cursor persistence lives in 3b-5.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:33:51Z","created_by":"James Lal","updated_at":"2026-05-19T04:07:35Z","started_at":"2026-05-19T03:41:59Z","closed_at":"2026-05-19T04:07:35Z","close_reason":"Implemented; merged into collab; 346 Rust + 168 relay tests pass","dependencies":[{"issue_id":"attn-nnj.6.3","depends_on_id":"attn-nnj.6","type":"parent-child","created_at":"2026-05-18T16:33:50Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.6.3","depends_on_id":"attn-nnj.5.11","type":"blocks","created_at":"2026-05-18T16:38:25Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.6.3","depends_on_id":"attn-nnj.6.1","type":"blocks","created_at":"2026-05-18T16:36:07Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":2,"comment_count":0} +{"_type":"issue","id":"attn-nnj.6.2","title":"Outbox processor (~/.attn/reviews/rooms/\u003croomId\u003e/outbox.jsonl)","description":"Persistent outbox that survives crashes. Each line in outbox.jsonl is a queued MailboxEnvelope plus state metadata (attempts, last_error, status: pending|in_flight|sent). Processor reads pending entries, mints a PoW token from the Phase 0a token pool, batches up to 32, POSTs to /v2/rooms/:roomId/envelopes, and on success marks them sent (compaction sweeps sent lines out periodically). Backoff on 429/507/ATTN_POW_*. EnvelopeId is deterministic (see crypto-spec) so retries are server-side idempotent.","acceptance_criteria":"- ~/.attn/reviews/rooms/\u003croomId\u003e/outbox.jsonl created on first send; append-only with periodic compaction of sent entries\n- Each line: serde JSON {envelope, attempts, last_error?, status, queued_at}\n- Processor task per room: read pending → mint PoW (use Phase 0a token pool) → batch up to 32 → POST → on 2xx mark sent\n- Backoff: 429 honors Retry-After header; 507 ATTN_ROOM_EVENT_CAP/STORAGE_FULL → exponential backoff with cap (e.g., 1s, 2s, 4s, 8s, 30s); ATTN_POW_* → mint fresh token immediately (token reuse failure)\n- Crash safety: status:in_flight entries are reset to pending on startup so they re-send; deterministic envelopeId guarantees server dedupe\n- Emits ReviewUpdate::EnvelopeSent on success and ReviewUpdate::SendFailed{retryable} on terminal failure\n- Tests: happy path, batch=32 boundary, retry on 429, retry on 507, PoW-replay recovery, crash mid-send dedupe","notes":"Spec: planning/collab/relay-spec.md §POST /v2/rooms/:roomId/envelopes (218-269). amendments.md decision #7 (batch cap 32, single PoW per request). crypto-spec.md §EnvelopeId (283-301) for deterministic ID. Phase 0a token pool is a prerequisite reference (assume exists or stub if not yet implemented).","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:33:50Z","created_by":"James Lal","updated_at":"2026-05-19T02:29:35Z","started_at":"2026-05-19T01:57:30Z","closed_at":"2026-05-19T02:29:35Z","close_reason":"Implemented (3.8+round-10 retries); merged into collab; 267 Rust + 144 relay tests pass","dependencies":[{"issue_id":"attn-nnj.6.2","depends_on_id":"attn-nnj.1.7","type":"blocks","created_at":"2026-05-18T16:38:24Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.6.2","depends_on_id":"attn-nnj.5.7","type":"blocks","created_at":"2026-05-18T16:38:25Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.6.2","depends_on_id":"attn-nnj.6","type":"parent-child","created_at":"2026-05-18T16:33:49Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.6.2","depends_on_id":"attn-nnj.6.1","type":"blocks","created_at":"2026-05-18T16:36:06Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":3,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj.6.1","title":"src/review/transport.rs scaffold + Transport trait","description":"Define the transport abstraction that all relay variants implement. src/review/ does not yet exist — create the module tree. Trait: Transport { async connect(roomId, deviceId) -\u003e Result\u003c()\u003e; async send_envelopes(Vec\u003cMailboxEnvelope\u003e) -\u003e Result\u003cSendReceipt\u003e; subscribe(after_seq: u64) -\u003e impl Stream\u003cItem=Result\u003cServerFrame\u003e\u003e }. Two implementations are planned: MailboxTransport (this phase, issues 3b-2..3b-6) and WebRTCTransport (Phase 4). The frontend never sees raw transport — only typed ReviewUpdate variants emitted by ReviewManager after decrypt+verify+import.","acceptance_criteria":"- src/review/mod.rs added to src/lib.rs (or main module tree)\n- src/review/transport.rs defines Transport trait with the signatures above\n- Concrete types: MailboxEnvelope (canonical bytes + metadata), SendReceipt {envelope_id, server_seq}, ServerFrame enum (Hello, Envelope, Presence, PolicyChanged, Ping, Error) matching the WS protocol from relay-spec.md\n- TransportError enum maps relay error codes (ATTN_ADMISSION_INVALID, ATTN_POW_*, ATTN_CURSOR_TOO_OLD, ATTN_ROOM_EXPIRED, ATTN_ROOM_DELETED, ATTN_RATE_LIMITED, ...) to typed Rust errors with retryable/non-retryable classification\n- A NoopTransport test impl for use in unit tests of ReviewManager\n- cargo check + cargo clippy clean (no any-equivalent — use proper types per repo conventions)\n- No frontend exposure: the trait lives behind ReviewManager, which is what emits ReviewUpdate","notes":"Spec: planning/collab/relay-spec.md §WebSocket Protocol (362-459) defines the frame shapes. planning/collab/data-model.md §Transport Model. Code conventions: TypeScript repo for the relay, but this Rust crate avoids 'any'/dyn-without-bounds equivalents. No backwards-compat shim with any prior transport — this is the new module from scratch.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:33:49Z","created_by":"James Lal","updated_at":"2026-05-19T01:56:27Z","started_at":"2026-05-19T01:36:54Z","closed_at":"2026-05-19T01:56:27Z","close_reason":"Implemented; merged into collab; 254 Rust tests + 130 relay tests pass; corpus replay green","dependencies":[{"issue_id":"attn-nnj.6.1","depends_on_id":"attn-nnj.1.9","type":"blocks","created_at":"2026-05-18T16:38:24Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.6.1","depends_on_id":"attn-nnj.6","type":"parent-child","created_at":"2026-05-18T16:33:48Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":7,"comment_count":0} +{"_type":"issue","id":"attn-nnj.9.5","title":"CORS + browser allowlist on the relay","description":"Confirm relay-spec.md §Browser Considerations is implemented in the worker: Origin allowlist on WS upgrade requests (reject non-allowlisted with 403), Access-Control-Allow-Origin headers on HTTP responses (POST /envelopes etc.) pulled from ALLOWED_BROWSER_ORIGINS env var. Test from a non-allowlisted origin and confirm 403. Without this, the browser client cannot function due to same-origin policy.","acceptance_criteria":"WS upgrade handler reads ALLOWED_BROWSER_ORIGINS env var and rejects upgrades with non-allowlisted Origin via HTTP 403.\nHTTP endpoints (POST /envelopes, POST /devices, POST /acks, POST /blobs) emit Access-Control-Allow-Origin matching the request Origin if in allowlist.\nOPTIONS preflight is handled with the right Access-Control-Allow-Methods + Access-Control-Allow-Headers (including Attn-PoW, Attn-Owner-Signature).\nIntegration test in the conformance corpus: request from https://evil.example → 403; request from https://attn.dev → 200/204.\nALLOWED_BROWSER_ORIGINS documented in relay deployment notes.","notes":"Specs: planning/collab/relay-spec.md §Browser Considerations. Files: relay/src/cors.ts (or wherever the worker entry middleware lives). Origin must be a strict match, not a prefix or wildcard. Note: the Rust client doesn't send an Origin header, so a missing Origin should be permitted (Rust path) but a present-but-not-allowlisted Origin should be denied.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:33:45Z","created_by":"James Lal","updated_at":"2026-05-19T16:56:25Z","started_at":"2026-05-19T16:26:12Z","closed_at":"2026-05-19T16:56:25Z","close_reason":"Round 20: implemented; merged; build clean","dependencies":[{"issue_id":"attn-nnj.9.5","depends_on_id":"attn-nnj.9","type":"parent-child","created_at":"2026-05-18T16:33:44Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.9.5","depends_on_id":"attn-nnj.5.7","type":"blocks","created_at":"2026-05-18T16:39:11Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-nnj.9.4","title":"Browser review UI (subset of native UI)","description":"Reuse Phase 2 Svelte review components where possible — the snapshot viewer, comment threads, suggestion decorations. Reviewer-only surface: NO share button (browser cannot own a room), NO apply UI (browser cannot mutate the owner's working copy). Browser CAN add comments and suggestions; those get added to an in-memory outbox that uploads via POST /envelopes from the browser context (with admission HMAC + hashcash PoW).","acceptance_criteria":"Browser /review/:roomId page renders the latest snapshot and existing comment/suggestion threads.\nReviewer can add a comment anchored to a selection; comment uploads via POST /envelopes (hashcash mined off-thread in a Web Worker per amendments.md).\nReviewer can add a suggestion (text replacement) anchored to a range; same upload path.\nNO share button, NO apply button, NO 'create room' affordance in the browser UI.\nShared Svelte components from Phase 2 render identically to native (within visual-diff tolerance).\nBrowser-specific empty/error states for: invalid invite, expired room (close 4001), stale cursor (close 4005), failed admission (403).","notes":"Specs: planning/collab/amendments.md §Phase 6, planning/collab/relay-spec.md §Browser Considerations. Files: web/src/routes/review/[roomId]/ (new) + reuse web/src/lib/review/* components from Phase 2. Depends on: invite parsing (Phase 6 issue 2), WS client (Phase 6 issue 3), CORS configured (Phase 6 issue 5). PoW miner from Phase 0a should already exist as a Web Worker — reuse it.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:33:31Z","created_by":"James Lal","updated_at":"2026-05-19T17:57:56Z","started_at":"2026-05-19T17:37:11Z","closed_at":"2026-05-19T17:57:56Z","close_reason":"Round 22 (final): implemented; merged","dependencies":[{"issue_id":"attn-nnj.9.4","depends_on_id":"attn-nnj.9","type":"parent-child","created_at":"2026-05-18T16:33:31Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.9.4","depends_on_id":"attn-nnj.9.3","type":"blocks","created_at":"2026-05-18T16:36:15Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.9.4","depends_on_id":"attn-nnj.9.5","type":"blocks","created_at":"2026-05-18T16:36:16Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.9.4","depends_on_id":"attn-nnj.9.2","type":"blocks","created_at":"2026-05-18T16:36:14Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":3,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj.9.3","title":"Browser WebSocket client + envelope import","description":"TS counterpart of the Phase 3b WebSocket client: connect to the relay's wss://.../v2/rooms/:roomId/ws, attach admission HMAC piggyback per relay-spec, receive hello + envelope frames, decrypt under eventKey/snapshotKey, signature-verify, import into an in-memory replica of the event log and snapshot graph. Uses whichever crypto path won the Phase 6 issue 1 decision (WASM or TS). Browser is reviewer-only — no owner-signing-key flows here.","acceptance_criteria":"WebSocket client connects with admission HMAC (matches the Rust client's behavior bit-for-bit on the test corpus).\nBackfill via hello + envelope frames per relay-spec.md §WebSocket (no GET /envelopes per decision #5).\nStale cursor → close code 4005 handled with a re-bootstrap path (re-fetch snapshot, replay from there).\nDecrypt → verify ORDER is correct (decrypt under eventKey FIRST, then signature-verify the plaintext per crypto-spec.md).\nIn-memory store survives WS disconnect+reconnect without duplicate events (EventId dedupe).\nEnd-to-end test: a comment added on the Rust client appears in the browser within 1s.","notes":"Specs: planning/collab/relay-spec.md §WebSocket + §Signaling, planning/collab/crypto-spec.md §Envelope Format, planning/collab/amendments.md §Decision #5 (WebSocket-only). Files: web/src/lib/review/transport.ts (new). Depends on browser crypto path from Phase 6 issue 1 and on invite/key derivation from Phase 6 issue 2. NO PERSISTENCE — everything in-memory (decision #13).","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:33:17Z","created_by":"James Lal","updated_at":"2026-05-19T17:35:47Z","started_at":"2026-05-19T17:02:46Z","closed_at":"2026-05-19T17:35:47Z","close_reason":"Round 21 (final push): implemented; merged; build clean","dependencies":[{"issue_id":"attn-nnj.9.3","depends_on_id":"attn-nnj.5.11","type":"blocks","created_at":"2026-05-18T16:38:32Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.9.3","depends_on_id":"attn-nnj.9.1","type":"blocks","created_at":"2026-05-18T16:36:13Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.9.3","depends_on_id":"attn-nnj.5.6","type":"blocks","created_at":"2026-05-18T16:38:33Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.9.3","depends_on_id":"attn-nnj.9","type":"parent-child","created_at":"2026-05-18T16:33:16Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.9.3","depends_on_id":"attn-nnj.9.2","type":"blocks","created_at":"2026-05-18T16:36:14Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.9.3","depends_on_id":"attn-nnj.5.7","type":"blocks","created_at":"2026-05-18T16:38:33Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":5,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-nnj.5.15","title":"Test plan acceptance suite (Miniflare integration tests)","description":"Implement the 14 numbered test scenarios from relay-spec.md §Test Plan as vitest integration tests under relay/test/integration/. Each test boots Miniflare (programmatic API), exercises the endpoints, and asserts on response codes, error codes, DO storage state (via wrapped get/list), and R2 contents. Consumes the conformance corpus from issue 3a-14 where applicable but adds in-depth assertions Miniflare can introspect.","acceptance_criteria":"- relay/test/integration/*.test.ts mirrors the 14 scenarios from §Test Plan\n- Each test boots a fresh Miniflare instance per case (or per file) so state is isolated\n- Asserts on: HTTP status, error code in body, DO storage keys via Miniflare's getDurableObjectStorage, R2 keys via getR2Bucket\n- Uses fake timers / Miniflare's setCurrentTime to test TTL alarms deterministically\n- Runs in CI via npm test (relay package) — green required before merge\n- Covers: room create+idempotency, device register+conflict, envelope ingest+caps, batch cap=32, WS backfill happy path, WS backfill 4005 path, hibernation roundtrip, owner-ack+delete, anonymous-ack no-delete, DELETE room, idle expiry, hard-max expiry, longSession 7d, rate-limit per-IP+per-device+anti-enum","notes":"Spec: planning/collab/relay-spec.md §Test Plan (687-705). Uses conformance corpus from 3a-14 as the source of request/response pairs but is the canonical pass/fail gate for the relay.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:33:08Z","created_by":"James Lal","updated_at":"2026-05-19T16:55:51Z","started_at":"2026-05-19T16:26:11Z","closed_at":"2026-05-19T16:55:51Z","close_reason":"Round 20: implemented; merged; build clean","dependencies":[{"issue_id":"attn-nnj.5.15","depends_on_id":"attn-nnj.5.14","type":"blocks","created_at":"2026-05-18T16:35:59Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.15","depends_on_id":"attn-nnj.5.1","type":"blocks","created_at":"2026-05-18T16:35:31Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.15","depends_on_id":"attn-nnj.5","type":"parent-child","created_at":"2026-05-18T16:33:07Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-nnj.5.14","title":"Conformance corpus (relay/test/conformance/cases.json)","description":"Build a shared conformance corpus consumed by BOTH the Miniflare integration suite (Phase 3a issue 15) AND the Rust transport tests (Phase 3b issue 7). Each case is a full {request, expectedResponse, expectedSideEffects} record covering the entire HTTP+WS surface and every error code in the spec.","acceptance_criteria":"- relay/test/conformance/cases.json (or one file per category) with deterministic fixtures (fixed keys, fixed timestamps in mock clock)\n- Coverage: room lifecycle (create idempotent, create policy-conflict, delete), WS backfill (after=0 full replay, after=last no replay, after=deleted→4005 with resyncFromSeq), all caps (32 batch, maxEvents, maxRoomBytes, maxEventBytes, maxSnapshotBytes, signal sub-cap eviction), owner-only ops (ack+delete with/without sig, DELETE room), PoW failures (insufficient bits, expired, resource mismatch, replayed), hibernation roundtrip (write → eject DO → reconnect → backfill), rate limits (per-IP, per-device, anti-enum), longSession clamping\n- A loader/runner abstraction in TypeScript that interprets cases and executes them against any HTTP+WS target (Miniflare or live wrangler dev)\n- Same JSON loadable from Rust via serde (matching schema documented at the top of the file)\n- README explaining how to add a new case","notes":"Spec source of truth: planning/collab/relay-spec.md §Test Plan (687-705) lists 14 scenarios — these are the minimum coverage. crypto-spec.md §Test Vectors (421-433) for PoW vectors. Phase 3b issue 7 will run this same corpus from Rust against wrangler dev --local. Plan the file format to be serde-deserializable from the start.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:33:07Z","created_by":"James Lal","updated_at":"2026-05-19T15:05:43Z","started_at":"2026-05-19T04:31:02Z","closed_at":"2026-05-19T15:05:43Z","close_reason":"Implemented; merged; 412 Rust + 213 relay tests pass (6 conformance scenarios deferred to 5.16)","dependencies":[{"issue_id":"attn-nnj.5.14","depends_on_id":"attn-nnj.5","type":"parent-child","created_at":"2026-05-18T16:33:07Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.14","depends_on_id":"attn-nnj.5.1","type":"blocks","created_at":"2026-05-18T16:35:30Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} +{"_type":"issue","id":"attn-nnj.5.13","title":"Rate limiting (per-IP, per-device, anti-enumeration)","description":"Two-tier rate limiting. Worker edge (before DO): per-IP 600/min sliding window using Cloudflare's rate-limiting binding or a KV-backed sliding window, and an anti-enumeration counter that returns 429 after 30 unknown rooms in 5 minutes from one IP. Per-device 120/min in DO storage (sliding window over rate:\u003cdeviceId\u003e:\u003cbucket\u003e) checked after admission. All 429 responses include retryAfterMs in body and Retry-After header.","acceptance_criteria":"- Edge per-IP: 600 requests/min sliding window across all rooms; over → 429 ATTN_RATE_LIMITED with retryAfterMs\n- Edge anti-enum: 30 unknown rooms (404 ATTN_ROOM_NOT_FOUND triggers) from one IP in 5 min → 429 ATTN_ANTI_ENUMERATION; the unknown-room counter is keyed by IP, not roomId, so the attacker cannot tell which roomId tripped it\n- DO per-device: 120 requests/min sliding window over rate:\u003cdeviceId\u003e:\u003cminute-bucket\u003e entries; over → 429 ATTN_RATE_LIMITED\n- 429 responses set Retry-After header (seconds, rounded up) AND body {error:{code, retryAfterMs}}\n- Edge limits checked before the request crosses to the DO (cost protection)\n- Per-device limits checked after admission so they're attributable\n- Tests: per-IP cap, per-device cap, anti-enum trip with mixed unknown rooms, Retry-After format","notes":"Spec: planning/collab/relay-spec.md §Anti-Abuse (565-571). Numbers come from amendments and user's brief (600/min per IP, 120/min per device, 30 unknown/5min). Implement edge limits in src/index.ts before the DO fetch, device limits inside the DO.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:33:06Z","created_by":"James Lal","updated_at":"2026-05-19T15:27:48Z","started_at":"2026-05-19T15:07:28Z","closed_at":"2026-05-19T15:27:48Z","close_reason":"Round 17: implemented; merged; 409 Rust + 237 relay tests pass","dependencies":[{"issue_id":"attn-nnj.5.13","depends_on_id":"attn-nnj.5.2","type":"blocks","created_at":"2026-05-18T16:35:49Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.13","depends_on_id":"attn-nnj.5.4","type":"blocks","created_at":"2026-05-18T16:35:49Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.13","depends_on_id":"attn-nnj.5","type":"parent-child","created_at":"2026-05-18T16:33:06Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.13","depends_on_id":"attn-nnj.5.1","type":"blocks","created_at":"2026-05-18T16:35:30Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":3,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj.9.2","title":"Browser invite URL parsing + memory-only secret handling","description":"At https://attn.dev/review/:roomId#key=... — on load, parse the fragment, IMMEDIATELY strip it from the visible URL via history.replaceState(null, '', location.pathname + location.search), and hold roomSecret only in JS heap memory. Derive rootKey then derive the subkeys (eventKey, snapshotKey, signalingKey, admissionKey). Zero/overwrite the original fragment string where possible. Reload requires re-paste — no sessionStorage, no IndexedDB, no cookies (decision #13). This is the entire trust-on-browser story.","acceptance_criteria":"On page load, location.hash is parsed exactly once and immediately stripped via history.replaceState.\nroomSecret never written to sessionStorage, localStorage, IndexedDB, cookies, or service-worker caches — verify via grep + manual audit.\nrootKey + subkeys derived synchronously after fragment parse; derivation matches crypto-spec.md.\nAfter fragment strip, location.href shows the bare URL with no #key= visible (e.g., to the page title, devtools history list, or any other observer).\nReload reproduces the 'paste invite to join' UX rather than silently rejoining.\nUnit test (jsdom or playwright) asserts replaceState fires before any other code accesses location.hash a second time.","notes":"Specs: planning/collab/amendments.md §Decision #13, planning/collab/crypto-spec.md §Invite URLs + §Key Derivation. Files: web/src/lib/review/invite.ts (new), web/src/routes/review/[roomId]/+page.svelte (or similar). The strip-fragment-before-anything-else ordering matters — see Phase 6 issue in cross-cutting security review for the race-window concern. Zeroize the fragment string in JS is best-effort (string immutability in JS limits us); the goal is no PERSISTENT trace.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:33:01Z","created_by":"James Lal","updated_at":"2026-05-19T16:06:19Z","started_at":"2026-05-19T15:31:08Z","closed_at":"2026-05-19T16:06:19Z","close_reason":"Round 18: implemented; merged; 414+ Rust tests pass","dependencies":[{"issue_id":"attn-nnj.9.2","depends_on_id":"attn-nnj.9","type":"parent-child","created_at":"2026-05-18T16:33:00Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":2,"comment_count":0} +{"_type":"issue","id":"attn-nnj.9.1","title":"Browser crypto sourcing decision (WASM vs TS)","description":"Real architectural fork: compile attn-collab-crypto Rust crate to WASM (one source of truth, larger bundle, identical behavior with Rust client) vs hand-write TS crypto against the shared test-vector corpus (smaller bundle, two implementations with drift risk). Bundle-size budget is the hard constraint — target the hosted JS bundle under 500 KiB gzipped. Output a decision doc with the recommendation and the measurements that back it. This issue blocks all browser WS/crypto work in Phase 6, so resolve it early.","acceptance_criteria":"planning/collab/ui/browser-crypto-decision.md created with both options spelled out and bundle-size measurements.\nWASM path: build attn-collab-crypto with wasm-pack (or wasm-bindgen), measure brotli/gzip size, note startup cost.\nTS path: scoped libraries identified (e.g., @noble/ciphers for XChaCha20-Poly1305, @noble/ed25519, @noble/hashes for HKDF-SHA256), estimate gzipped bundle size for the subset used.\nRecommendation includes a concrete number for the resulting bundle in both cases and a winner.\nFlagged with bd human — owner sign-off needed before downstream Phase 6 issues unblock.","notes":"Specs: planning/collab/crypto-spec.md §Primitives (XChaCha20-Poly1305 + Ed25519 + HKDF-SHA256 + canonical JSON RFC 8785 + base64url-no-pad), planning/collab/amendments.md (Decision #4 cipher locked, Decision #13 browser memory-only). Test-vector corpus must validate whichever path is chosen — the corpus is shared, not Rust-specific. If TS path: @noble/* libs are audited and tree-shake well.","status":"closed","priority":1,"issue_type":"decision","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:32:30Z","created_by":"James Lal","updated_at":"2026-05-19T16:25:13Z","started_at":"2026-05-19T16:07:54Z","closed_at":"2026-05-19T16:25:13Z","close_reason":"Round 19: implemented; merged; build clean","labels":["human"],"dependencies":[{"issue_id":"attn-nnj.9.1","depends_on_id":"attn-nnj.9","type":"parent-child","created_at":"2026-05-18T16:32:30Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-nnj.5.12","title":"Alarms: idle + hard-max TTL + pow-prune","description":"DO alarms drive room TTL cleanup. Cloudflare DO supports only one pending alarm at a time, so always schedule at min(hard_max_at, last_event_at + idleTimeoutMs, next_pow_prune_at). On fire, determine which deadline tripped and act: idle/hard-max → close all WS with 4002 (room expired), wipe DO storage, schedule R2 cleanup; pow-prune → delete pow_seen entries past expiresAt+10min. Every WS connect calls cleanup_check() if now is within 1h of expires_at (decision #9). Hard-max defaults: 24h, or 7d when policy.longSession=true (decision #8). Idle default: 1h.","acceptance_criteria":"- alarm() handler reads all candidate deadlines (hard_max_at, last_event_at+idleTimeoutMs, pow_prune_at) and reschedules at the next earliest after acting on any that have fired\n- Idle/hard-max fire: broadcast close 4002 with reason 'room_expired', deleteAll() DO storage, enqueue R2 prefix delete, set tombstone so further requests 410 ATTN_ROOM_EXPIRED for 24h\n- pow-prune fire: scan meta:pow_seen:* and delete entries where extracted expiresAt+10min \u003c now\n- POST /envelopes updates last_event_at and reschedules the alarm\n- Every WS upgrade calls cleanup_check() — if now within 1h of expires_at, run the same scan and reschedule\n- hard_max_at computed at room creation: created + (longSession ? 7d : 24h), clamped by policy.expiresAt\n- Tests: idle expiry path, hard-max expiry path, longSession 7-day cap, single-alarm scheduling correctness when multiple deadlines compete, cleanup_check on WS connect, pow_seen prune","notes":"Spec: planning/collab/relay-spec.md §Alarms (514-529), §Close Codes (423-431). amendments.md decisions #8 (TTL defaults + longSession) and #9 (R2 lifecycle as safety net, DO alarm primary, cleanup_check on WS connect). Coordinates with PoW replay protection from 3a-3 and WS close from 3a-11.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:32:29Z","created_by":"James Lal","updated_at":"2026-05-19T13:49:59Z","started_at":"2026-05-19T04:31:02Z","closed_at":"2026-05-19T13:49:59Z","close_reason":"Implemented (partially for 5.14 — scaffold + skip-on-empty, follow-up to fill cases.json); 372 Rust + 184 relay tests pass","dependencies":[{"issue_id":"attn-nnj.5.12","depends_on_id":"attn-nnj.5.11","type":"blocks","created_at":"2026-05-18T16:35:58Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.12","depends_on_id":"attn-nnj.5.4","type":"blocks","created_at":"2026-05-18T16:35:48Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.12","depends_on_id":"attn-nnj.5","type":"parent-child","created_at":"2026-05-18T16:32:28Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.12","depends_on_id":"attn-nnj.5.2","type":"blocks","created_at":"2026-05-18T16:35:47Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.12","depends_on_id":"attn-nnj.5.1","type":"blocks","created_at":"2026-05-18T16:35:29Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":4,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj.5.11","title":"WebSocket protocol + DO hibernation","description":"Implement the WebSocket transport with subprotocol 'attn.v2' and admission HMAC piggybacked as the second protocol value. Use state.acceptWebSocket() for hibernation so idle sessions don't burn DO CPU; tag attached sockets with [deviceId, participantId]. Server frames: hello, envelope, presence, policy_changed, ping, error. Client frames: subscribe, pong. On subscribe.after \u003c meta:oldest_retained_seq emit error{code:ATTN_CURSOR_TOO_OLD, resyncFromSeq} and close with code 4005. 30s ping interval, 60s pong timeout → close 1001.","acceptance_criteria":"- Upgrade handshake: subprotocol header must include 'attn.v2' and a second value carrying the admission HMAC; missing/invalid → 401 ATTN_ADMISSION_INVALID\n- Uses state.acceptWebSocket(ws, [deviceId, participantId]) for hibernation; webSocketMessage/webSocketClose handlers route by tag\n- Backfill on subscribe: replay envelope frames from storage where seq \u003e subscribe.after (decision #5 — no GET /envelopes endpoint)\n- On subscribe.after \u003c meta:oldest_retained_seq → send error{code:ATTN_CURSOR_TOO_OLD, resyncFromSeq:meta:oldest_retained_seq} then close 4005\n- Server frames implemented: hello{serverSeq, oldestRetainedSeq, peers}, envelope{seq, envelope}, presence{deviceId, state}, policy_changed{policy}, ping, error\n- Client frames handled: subscribe{after}, pong\n- 30s server ping interval (per-session scheduled via alarm or setTimeout-substitute); no pong within 60s → close 1001 ATTN_TIMEOUT\n- Broadcast helper used by POST /envelopes routes through getSession-by-tag lookup\n- Tests: handshake reject, backfill from 0, backfill from mid, 4005 on too-old cursor, hibernation roundtrip (eject + re-deliver after restart), ping/pong, presence broadcast","notes":"Spec: planning/collab/relay-spec.md §WebSocket Protocol (362-459), §Hibernation Tags (531-533). amendments.md decision #5 (WS-only delivery, GET /envelopes REMOVED). Uses admission middleware from 3a-2.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:32:28Z","created_by":"James Lal","updated_at":"2026-05-19T04:06:15Z","started_at":"2026-05-19T03:23:01Z","closed_at":"2026-05-19T04:06:15Z","close_reason":"Implemented; merged into collab; 346 Rust + 168 relay tests pass","dependencies":[{"issue_id":"attn-nnj.5.11","depends_on_id":"attn-nnj.5","type":"parent-child","created_at":"2026-05-18T16:32:28Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.11","depends_on_id":"attn-nnj.5.2","type":"blocks","created_at":"2026-05-18T16:35:46Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.11","depends_on_id":"attn-nnj.5.1","type":"blocks","created_at":"2026-05-18T16:35:29Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.11","depends_on_id":"attn-nnj.5.4","type":"blocks","created_at":"2026-05-18T16:35:47Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":3,"dependent_count":3,"comment_count":0} +{"_type":"issue","id":"attn-nnj.5.10","title":"DELETE /v2/rooms/:roomId — owner room wipe","description":"Owner-only endpoint to nuke the room. Requires both Attn-PoW and Attn-Owner-Signature. Disconnects all WebSocket sessions with close code 4001 (room deleted), wipes all DO storage for the room, and schedules R2 cleanup by listing and deleting all objects under rooms/\u003croomId\u003e/.","acceptance_criteria":"- Requires valid Attn-Admission, Attn-PoW, AND Attn-Owner-Signature; missing/invalid owner sig → 403 ATTN_OWNER_SIG_REQUIRED / ATTN_OWNER_KEY_MISMATCH\n- Closes every active WebSocket with close code 4001, reason 'room_deleted'\n- Wipes all DO storage keys (envelope:*, acks:*, devices:*, meta:*, pow_seen:*) via storage.deleteAll() or scoped delete\n- Schedules R2 cleanup: list objects with prefix rooms/\u003croomId\u003e/ and delete in batches (paginate)\n- Returns 200 {deleted:true} synchronously even if R2 cleanup is still draining\n- Idempotent: re-DELETE on already-gone room returns 404 ATTN_ROOM_NOT_FOUND\n- Test: WS sessions observe 4001 close before storage wipe; subsequent admission attempts return 404","notes":"Spec: planning/collab/relay-spec.md §DELETE /v2/rooms/:roomId (305-311), §Close Codes (423-431). amendments.md decision #3 (owner-only). Owner-sig from issue 3a-4.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:32:27Z","created_by":"James Lal","updated_at":"2026-05-19T04:29:44Z","started_at":"2026-05-19T04:09:04Z","closed_at":"2026-05-19T04:29:44Z","close_reason":"Implemented; merged","dependencies":[{"issue_id":"attn-nnj.5.10","depends_on_id":"attn-nnj.5","type":"parent-child","created_at":"2026-05-18T16:32:26Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.10","depends_on_id":"attn-nnj.5.4","type":"blocks","created_at":"2026-05-18T16:35:46Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.10","depends_on_id":"attn-nnj.5.1","type":"blocks","created_at":"2026-05-18T16:35:28Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.10","depends_on_id":"attn-nnj.5.2","type":"blocks","created_at":"2026-05-18T16:35:45Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.10","depends_on_id":"attn-nnj.5.3","type":"blocks","created_at":"2026-05-18T16:35:58Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":4,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj.7.7","title":"WebRTC e2e integration test","description":"Boot two local attn daemons in a test harness, share a doc from one, join from the other, exchange comments over the DataChannel. Confirms decision #1's Rust-owned WebRTC path actually works in wry/tao on macOS. Use the daemon's existing automation flags (--eval, --query, --wait-for) to drive both sides without needing a separate test runner. This is the integration test that proves Phase 4 has actually shipped.","acceptance_criteria":"scripts/test-e2e.sh gains a review/webrtc.sh (or equivalent) that boots two daemon instances on isolated state dirs.\nOwner shares a markdown fixture; reviewer joins via the invite URL (attn://review/...).\nA comment from the reviewer arrives on the owner side within 2s, asserted via --query on the owner's review panel.\nDataChannel connection state is asserted Connected on both sides via --eval against window.__attn__.reviewState().\nTest passes in CI on macOS (and Linux if relay tests already run there).\nCaptures connection logs to /tmp/attn-e2e-screenshots/ on failure.","notes":"Specs: planning/collab/amendments.md §existing automation flags affect ReviewManager design + §Phase 4. Files: scripts/test-e2e.sh + new tests/fixtures/review/*.md. Two daemons on the same machine need different ATTN_HOME (or equivalent) and different socket paths — see existing single-instance protocol in src/daemon.rs. Window.__attn__.reviewState() should be exposed earlier in Phase 2/3 — verify it exists before writing this test.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:32:15Z","created_by":"James Lal","updated_at":"2026-05-19T16:07:08Z","started_at":"2026-05-19T15:31:09Z","closed_at":"2026-05-19T16:07:08Z","close_reason":"Round 18: implemented; merged; 414+ Rust tests pass","dependencies":[{"issue_id":"attn-nnj.7.7","depends_on_id":"attn-nnj.7.1","type":"blocks","created_at":"2026-05-18T16:35:53Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.7.7","depends_on_id":"attn-nnj.2.10","type":"blocks","created_at":"2026-05-18T16:58:50Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.7.7","depends_on_id":"attn-nnj.7","type":"parent-child","created_at":"2026-05-18T16:32:15Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.7.7","depends_on_id":"attn-nnj.7.3","type":"blocks","created_at":"2026-05-18T16:36:03Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":3,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-nnj.7.5","title":"Mode-aware transport selector in ReviewManager","description":"In ReviewManager, route outbound events based on policy.mode: hybrid sends through BOTH transports (DataChannel when connected, mailbox always-on); live uses WebRTC only; async uses mailbox only. Inbound dedupe is handled by the existing EventId-keyed import so the receiver doesn't double-process when both transports deliver. This is the integration point where mailbox + WebRTC become 'one transport with two wires' from the manager's perspective.","acceptance_criteria":"ReviewManager has a transport selector that consults policy.mode at send time.\nhybrid: outbound envelope is enqueued to BOTH transports; receiver dedupes by EventId on import.\nlive: outbound goes only to WebRTC; if WebRTC is Failed, send returns an error that surfaces as RoomStatusChanged(DirectFailed) (per issue 4).\nasync: outbound goes only to mailbox; WebRTC never initialized.\nInbound import is idempotent — receiving the same EventId twice (once from each transport) is a no-op the second time.\nTested with all three mode values.","notes":"Specs: planning/collab/amendments.md §Phase 4 (mode semantics + dedupe). Files: src/review/manager.rs. Idempotent import probably already exists from Phase 0b/3b store work — verify and reuse. Don't add EventId-tracking state here; the store layer owns dedupe.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:31:49Z","created_by":"James Lal","updated_at":"2026-05-19T14:20:07Z","started_at":"2026-05-19T13:53:28Z","closed_at":"2026-05-19T14:20:07Z","close_reason":"Implemented; 400 Rust + 193 relay tests pass","dependencies":[{"issue_id":"attn-nnj.7.5","depends_on_id":"attn-nnj.7.1","type":"blocks","created_at":"2026-05-18T16:35:52Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.7.5","depends_on_id":"attn-nnj.7","type":"parent-child","created_at":"2026-05-18T16:31:48Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.7.5","depends_on_id":"attn-nnj.7.3","type":"blocks","created_at":"2026-05-18T16:36:02Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.7.5","depends_on_id":"attn-nnj.7.4","type":"blocks","created_at":"2026-05-18T16:36:03Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":3,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj.5.9","title":"POST /v2/rooms/:roomId/acks — acknowledgment + optional delete","description":"Mark envelopes as ACKed by deviceId. Body: {acks: [{envelopeId, deviceId}], delete?:boolean}. Idempotent. If delete=true AND policy.deleteEventsAfterOwnerAck==true AND the request carries a valid Attn-Owner-Signature, delete envelopes that have been ACKed by ANY owner-kind device. Otherwise just record the ACK without deletion (multi-device safety per decision #12).","acceptance_criteria":"- Validates body schema; deviceId must be registered in the room\n- Records ACK in DO storage under acks:\u003cenvelopeId\u003e:\u003cdeviceId\u003e\n- delete=true is conditional on: policy.deleteEventsAfterOwnerAck===true (default false per decision #12) AND owner signature verifies AND envelope has at least one owner-device ACK\n- Deletion is by-envelope: removes envelope:\u003cseq\u003e entries (and blob R2 keys for snapshot_blob), decrements meta:envelope_count and meta:bytes_used\n- Updates meta:oldest_retained_seq if a leading run of envelopes is deleted\n- Idempotent: re-ACK is a no-op; re-delete of already-deleted envelope returns 200 (count: 0)\n- Returns {acked:[envelopeId...], deleted:[envelopeId...]}\n- Tests: ack-only, ack+delete with owner sig, ack+delete without owner sig (no-op deletion), ack+delete with policy.deleteEventsAfterOwnerAck=false (no-op deletion)","notes":"Spec: planning/collab/relay-spec.md §POST /v2/rooms/:roomId/acks (277-303). amendments.md decisions #3 (owner-sig gating) and #12 (deleteEventsAfterOwnerAck default false). Owner-sig verification reused from issue 3a-4.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:31:48Z","created_by":"James Lal","updated_at":"2026-05-19T04:08:01Z","started_at":"2026-05-19T03:42:00Z","closed_at":"2026-05-19T04:08:01Z","close_reason":"Implemented; merged into collab; 346 Rust + 168 relay tests pass","dependencies":[{"issue_id":"attn-nnj.5.9","depends_on_id":"attn-nnj.5.1","type":"blocks","created_at":"2026-05-18T16:35:27Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.9","depends_on_id":"attn-nnj.5.2","type":"blocks","created_at":"2026-05-18T16:35:44Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.9","depends_on_id":"attn-nnj.5","type":"parent-child","created_at":"2026-05-18T16:31:47Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.9","depends_on_id":"attn-nnj.5.3","type":"blocks","created_at":"2026-05-18T16:35:57Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.9","depends_on_id":"attn-nnj.5.4","type":"blocks","created_at":"2026-05-18T16:35:44Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":4,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj.5.8","title":"POST + GET /v2/rooms/:roomId/blobs — R2 spillover","description":"When kind==snapshot_blob and ciphertextBytes \u003e 1 MiB, /envelopes is rejected and the client must use the blob flow. POST /v2/rooms/:roomId/blobs returns a presigned PUT URL (15-min TTL) at R2 key rooms/\u003croomId\u003e/blobs/\u003cenvelopeId\u003e. Client uploads ciphertext directly, then re-POSTs /envelopes with the same envelopeId and a small BlobRef payload referencing the R2 key. GET /v2/rooms/:roomId/blobs/:envelopeId returns a 5-min presigned GET URL.","acceptance_criteria":"- POST /blobs body validates {envelopeId, kind:'snapshot_blob', ciphertextBytes, hash}; rejects when ciphertextBytes ≤ 1 MiB (use /envelopes path instead)\n- Returns {uploadUrl, expiresAt} — presigned PUT, 15-min TTL, key=rooms/\u003croomId\u003e/blobs/\u003cenvelopeId\u003e\n- After upload, client re-POSTs /envelopes with kind=snapshot_blob and ciphertext = canonical BlobRef payload ({r2Key, ciphertextBytes, hash})\n- GET /blobs/:envelopeId returns {downloadUrl, expiresAt} — presigned GET, 5-min TTL\n- Validates envelopeId belongs to this room (check DO state) before issuing GET URL\n- Counts blob bytes against meta:bytes_used (against policy.maxRoomBytes)\n- Tests: upload roundtrip, undersized rejection, replay (same envelopeId returns same key), unauthorized GET","notes":"Spec: planning/collab/relay-spec.md §POST /v2/rooms/:roomId/blobs (313-360), §R2 Integration (557-563). Lifecycle TTL = 7 days as safety net (decision #9); primary cleanup is the DO alarm in issue 3a-12.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:31:47Z","created_by":"James Lal","updated_at":"2026-05-19T14:19:52Z","started_at":"2026-05-19T13:53:27Z","closed_at":"2026-05-19T14:19:52Z","close_reason":"Implemented; 400 Rust + 193 relay tests pass","dependencies":[{"issue_id":"attn-nnj.5.8","depends_on_id":"attn-nnj.5","type":"parent-child","created_at":"2026-05-18T16:31:47Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.8","depends_on_id":"attn-nnj.5.2","type":"blocks","created_at":"2026-05-18T16:35:42Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.8","depends_on_id":"attn-nnj.5.1","type":"blocks","created_at":"2026-05-18T16:35:27Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.8","depends_on_id":"attn-nnj.5.4","type":"blocks","created_at":"2026-05-18T16:35:43Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":3,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj.5.7","title":"POST /v2/rooms/:roomId/envelopes — batched ingest","description":"Accept up to 32 envelopes per HTTP request (decision #7). Each envelope is validated end-to-end: ciphertextBytes equals base64url-decoded ciphertext length; size against policy.max{Event|Snapshot}Bytes by kind; deviceId and authorId are registered in the room. Single PoW token covers the whole batch. On any cap overflow (envelope_count \u003e policy.maxEvents OR bytes_used + delta \u003e policy.maxRoomBytes) the whole batch fails with 507. Idempotent on envelopeId. Signal envelopes use sub-caps with FIFO eviction. On success, allocates serverSeq, updates counters, reschedules idle alarm.","acceptance_criteria":"- Batch cap 32: 33+ envelopes → 400 ATTN_BATCH_TOO_LARGE before any work\n- Single Attn-PoW token verified once for the whole HTTP request (resource binds METHOD + PATH, not per-envelope)\n- Per envelope: ciphertextBytes === base64url_decoded.length else 400 ATTN_ENVELOPE_SIZE_MISMATCH; kind-specific size cap against policy.max{Event|Snapshot}Bytes else 413 ATTN_ENVELOPE_TOO_LARGE; deviceId + authorId registered else 403 ATTN_UNKNOWN_DEVICE\n- serverSeq allocated atomically per envelope (monotonic, per relay-spec §serverSeq Allocation)\n- Idempotency: existing envelopeId returns its stored serverSeq with no state change\n- Whole-batch overflow: 507 ATTN_ROOM_EVENT_CAP or ATTN_ROOM_STORAGE_FULL (no partial commit)\n- Signal envelopes: maxSignalEnvelopes=64 per (authorId, target.deviceId), FIFO-evict oldest in DO storage\n- Updates meta:envelope_count, meta:bytes_used, meta:last_event_at; reschedules idle alarm\n- Broadcasts envelope frames to subscribed WS sessions (hibernation-safe)\n- Response: {accepted:[{envelopeId, serverSeq}], serverSeq:\u003cmax\u003e}\n- Tests: batch over cap, mixed kinds, idempotent retry, room-full, signal eviction, PoW reuse across batch","notes":"Spec: planning/collab/relay-spec.md §POST /v2/rooms/:roomId/envelopes (218-269), §serverSeq Allocation (499-512), §Caps (535-555). amendments.md decisions #7 (batch cap + single PoW) and #5 (WS-only delivery: this endpoint feeds the WS broadcast, no HTTP GET pull).","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:31:46Z","created_by":"James Lal","updated_at":"2026-05-19T02:29:21Z","started_at":"2026-05-19T01:57:29Z","closed_at":"2026-05-19T02:29:21Z","close_reason":"Implemented (3.8+round-10 retries); merged into collab; 267 Rust + 144 relay tests pass","dependencies":[{"issue_id":"attn-nnj.5.7","depends_on_id":"attn-nnj.5.4","type":"blocks","created_at":"2026-05-18T16:35:42Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.7","depends_on_id":"attn-nnj.5","type":"parent-child","created_at":"2026-05-18T16:31:46Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.7","depends_on_id":"attn-nnj.5.1","type":"blocks","created_at":"2026-05-18T16:35:26Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.7","depends_on_id":"attn-nnj.5.2","type":"blocks","created_at":"2026-05-18T16:35:41Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":3,"dependent_count":3,"comment_count":0} +{"_type":"issue","id":"attn-nnj.7.4","title":"Connection state machine + ICE handling","description":"Trickle ICE per relay-spec.md §Signaling. Track the peer-connection lifecycle through Connecting / Connected / Reconnecting / Failed states. Behavior on Failed is mode-dependent: in live mode, surface direct-connection failure explicitly via ReviewUpdate::RoomStatusChanged(DirectFailed) so the UI can tell the user the live channel is gone; in hybrid mode, silently rely on mailbox (no user-visible disruption — that's the whole point of hybrid). Reconnecting handles transient NAT rebinds without bouncing the user.","acceptance_criteria":"Connection state enum {Connecting, Connected, Reconnecting, Failed} drives WebRTC peer lifecycle.\nTrickle ICE: candidates emit as individual kind=signal envelopes as they're gathered (not batched at end-of-gathering).\nOn Failed in live mode → emit ReviewUpdate::RoomStatusChanged(DirectFailed). Frontend can surface 'live connection lost' UI.\nOn Failed in hybrid mode → no user-visible event; mailbox continues serving traffic.\nReconnecting attempts ICE restart before transitioning to Failed.\nState transitions covered by unit tests against a mock PeerConnection.","notes":"Specs: planning/collab/relay-spec.md §Signaling (trickle ICE protocol), planning/collab/amendments.md §Phase 4 (mode semantics). Files: src/review/transport/webrtc.rs (state machine), src/review/manager.rs (ReviewUpdate emission). Mode comes from policy.mode on the room. RoomStatusChanged is a new ReviewUpdate variant — add to the enum.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:31:35Z","created_by":"James Lal","updated_at":"2026-05-19T13:50:43Z","started_at":"2026-05-19T04:31:04Z","closed_at":"2026-05-19T13:50:43Z","close_reason":"Implemented (partially for 5.14 — scaffold + skip-on-empty, follow-up to fill cases.json); 372 Rust + 184 relay tests pass","dependencies":[{"issue_id":"attn-nnj.7.4","depends_on_id":"attn-nnj.7","type":"parent-child","created_at":"2026-05-18T16:31:35Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.7.4","depends_on_id":"attn-nnj.7.1","type":"blocks","created_at":"2026-05-18T16:35:51Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.7.4","depends_on_id":"attn-nnj.7.2","type":"blocks","created_at":"2026-05-18T16:36:01Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.7.4","depends_on_id":"attn-nnj.7.3","type":"blocks","created_at":"2026-05-18T16:36:01Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":3,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-nnj.7.3","title":"DataChannel arm of Transport trait","description":"Implement WebRTCTransport against the Transport trait defined in Phase 3b. The crucial property (decision #14, amendments.md §Phase 4): the DataChannel envelope FORMAT is identical to mailbox envelopes — same AEAD under eventKey/snapshotKey, same routing, same import path. Only the wire differs. Frontend never sees raw transport; ReviewManager decrypts, signature-verifies, then emits typed ReviewUpdate. This is what lets hybrid mode dedupe by EventId across both transports for free.","acceptance_criteria":"WebRTCTransport struct implements the same Transport trait as MailboxTransport from Phase 3b.\nOutbound: events go through the existing outbox; when DataChannel is connected, send_envelope writes the same AEAD-encrypted envelope bytes to the channel.\nInbound: DataChannel on_message decrypts under eventKey/snapshotKey and feeds into the same envelope-import pipeline.\nNo plaintext on the wire (decision #14) — snapshot bytes are application-encrypted, never relying on DTLS for confidentiality.\nTrait abstraction allows a single ReviewManager codepath to consume both transports interchangeably.","notes":"Specs: planning/collab/amendments.md §Phase 4 + Decision #14. Files: src/review/transport/webrtc.rs (new), src/review/transport.rs (trait). Reuses the import pipeline built in Phase 3b — do NOT duplicate decrypt/verify logic. The wire is different but the envelope is the same — this is load-bearing for hybrid mode.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:31:21Z","created_by":"James Lal","updated_at":"2026-05-19T04:30:20Z","started_at":"2026-05-19T04:09:05Z","closed_at":"2026-05-19T04:30:20Z","close_reason":"Implemented; merged","dependencies":[{"issue_id":"attn-nnj.7.3","depends_on_id":"attn-nnj.6.4","type":"blocks","created_at":"2026-05-18T16:38:29Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.7.3","depends_on_id":"attn-nnj.7.1","type":"blocks","created_at":"2026-05-18T16:35:51Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.7.3","depends_on_id":"attn-nnj.7","type":"parent-child","created_at":"2026-05-18T16:31:20Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.7.3","depends_on_id":"attn-nnj.7.2","type":"blocks","created_at":"2026-05-18T16:36:00Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.7.3","depends_on_id":"attn-nnj.6.1","type":"blocks","created_at":"2026-05-18T16:38:28Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":4,"dependent_count":3,"comment_count":0} +{"_type":"issue","id":"attn-nnj.5.6","title":"POST + GET /v2/rooms/:roomId/devices — device registration","description":"POST /devices: upsert device record. Verifies selfSignature (Ed25519 over canonical device bytes) against publicSigningKey. If kind==owner, publicSigningKey MUST equal the room's ownerSigningKey, else 403 ATTN_OWNER_KEY_MISMATCH. Upsert is keyed by (participantId, deviceId); attempting to change publicSigningKey for an existing entry returns 409 ATTN_DEVICE_KEY_CHANGED. GET /devices returns the peer list in original registration order.","acceptance_criteria":"- POST validates schema: {participantId, deviceId, kind in [owner|reviewer|agent], publicSigningKey, selfSignature, capabilities?}\n- selfSignature verifies against publicSigningKey over the canonical device bytes from crypto-spec.md §Signing-Key Publication\n- kind==owner: publicSigningKey === room.ownerSigningKey else 403 ATTN_OWNER_KEY_MISMATCH\n- Upsert by (participantId, deviceId): if exists and key matches, return 200 with stored record; if key differs, 409 ATTN_DEVICE_KEY_CHANGED\n- GET /devices returns array in registration order; includes a server-stable 'registeredAt' timestamp\n- Updates meta:peer_count, enforces policy.maxPeers (8 cap) — 403 ATTN_ROOM_FULL when exceeded\n- Tests cover: fresh register, idempotent re-register, key-change rejection, owner-key mismatch, peer cap","notes":"Spec: planning/collab/relay-spec.md §POST /v2/rooms/:roomId/devices (169-217). crypto-spec.md §Signing-Key Publication (344-403). The devices list is the source of truth for sig verification on inbound envelopes (Phase 3b consumes this).","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:31:08Z","created_by":"James Lal","updated_at":"2026-05-19T01:56:14Z","started_at":"2026-05-19T01:36:53Z","closed_at":"2026-05-19T01:56:14Z","close_reason":"Implemented; merged into collab; 254 Rust tests + 130 relay tests pass; corpus replay green","dependencies":[{"issue_id":"attn-nnj.5.6","depends_on_id":"attn-nnj.5.2","type":"blocks","created_at":"2026-05-18T16:35:40Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.6","depends_on_id":"attn-nnj.5","type":"parent-child","created_at":"2026-05-18T16:31:07Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.6","depends_on_id":"attn-nnj.5.4","type":"blocks","created_at":"2026-05-18T16:35:40Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.6","depends_on_id":"attn-nnj.5.1","type":"blocks","created_at":"2026-05-18T16:35:26Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":3,"dependent_count":2,"comment_count":0} +{"_type":"issue","id":"attn-nnj.5.5","title":"POST /v2/rooms/:roomId — idempotent room creation","description":"Implement POST /v2/rooms/:roomId. Idempotent: first call creates the room with the supplied policy + ownerSigningKey, subsequent calls with the same body return the stored policy unchanged (no mutation). Body is validated against the zod schema from the scaffold issue. Policy values are clamped to spec maxima before storage. Computes and returns ownerSigningKeyId = base64url(SHA-256(ownerSigningKey)).","acceptance_criteria":"- Body validates against zod RoomCreateRequest schema (admissionKey, ownerSigningKey, policy, ...)\n- Policy clamping: maxPeers ≤ 8; expiresAt ≤ created+24h, or +7d when longSession=true; idleTimeoutMs ≥ 60_000 and ≤ wall-clock TTL; powBits ∈ [12,24]; max{Event,Snapshot,RoomBytes} ≤ HARD_MAX_*\n- Stores ownerSigningKey, computes ownerSigningKeyId = base64url(SHA-256(key))\n- First create: 201 with {policy, ownerSigningKeyId, serverSeq:0, oldestRetainedSeq:0}\n- Replay of same body: 200 with the stored values (no mutation, idempotent)\n- Different body for an existing room: 409 ATTN_ROOM_EXISTS_DIFFERENT_POLICY\n- Initial bytes_used=0, envelope_count=0, hard_max_at = created + min(policy.expiresAt - created, 24h|7d cap)\n- Test: clamping behavior, idempotency, conflict on policy diff","notes":"Spec: planning/collab/relay-spec.md §POST /v2/rooms/:roomId (lines 114-167). §Caps (535-555). amendments.md decision #8 (TTLs, longSession). Note: this endpoint does NOT require pre-existing admission since it establishes the admissionKey. PoW IS still required (decision #6).","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:31:07Z","created_by":"James Lal","updated_at":"2026-05-19T01:33:37Z","started_at":"2026-05-19T00:47:52Z","closed_at":"2026-05-19T01:33:37Z","close_reason":"Implemented; merged into collab; 212 Rust tests + 98 relay tests pass","dependencies":[{"issue_id":"attn-nnj.5.5","depends_on_id":"attn-nnj.5","type":"parent-child","created_at":"2026-05-18T16:31:06Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.5","depends_on_id":"attn-nnj.5.2","type":"blocks","created_at":"2026-05-18T16:35:39Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.5","depends_on_id":"attn-nnj.5.4","type":"blocks","created_at":"2026-05-18T16:35:39Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.5","depends_on_id":"attn-nnj.5.1","type":"blocks","created_at":"2026-05-18T16:35:25Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":3,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-nnj.7.2","title":"Encrypted signaling envelopes (signalingKey + AAD)","description":"Build the signaling layer for WebRTC negotiation. SDP offers/answers and ICE candidates ride the same mailbox transport as regular envelopes — same POST /envelopes upload, same WS delivery, just kind=signal. Cleartext payload is canonical({kind: offer|answer|ice, sdp|ice[], from: deviceId}), encrypted with signalingKey under standard envelope AAD. On the receive side, WS envelopes with kind=signal decrypt and dispatch into webrtc-rs as SDP/ICE inputs. This is what makes the data-channel handshake survive NAT without a separate signaling channel.","acceptance_criteria":"src/review/transport/signaling.rs (new) builds offer/answer/ice envelopes with kind=signal, encrypted under signalingKey with AAD binding.\nOutbound signaling envelopes upload via the existing POST /envelopes pipeline from Phase 3b — no new HTTP path.\nInbound WS envelope dispatcher routes kind=signal frames into the signaling decoder, decrypts, dispatches to webrtc-rs callbacks.\nUnit test: round-trip an offer/answer pair through encrypt → decrypt and assert canonical equality.\nsignalingKey derivation matches crypto-spec.md (HKDF subkey under rootKey).","notes":"Specs: planning/collab/relay-spec.md §Signaling, planning/collab/crypto-spec.md §Key Derivation, planning/collab/amendments.md §Phase 4. Files: src/review/transport/signaling.rs (new), src/review/transport.rs (dispatcher integration). Reuses outbox + WS envelope plumbing from Phase 3b. AAD must bind (roomId, envelopeId, kind=signal, from).","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:31:02Z","created_by":"James Lal","updated_at":"2026-05-19T04:08:15Z","started_at":"2026-05-19T03:42:00Z","closed_at":"2026-05-19T04:08:15Z","close_reason":"Implemented; merged into collab; 346 Rust + 168 relay tests pass","dependencies":[{"issue_id":"attn-nnj.7.2","depends_on_id":"attn-nnj.7","type":"parent-child","created_at":"2026-05-18T16:31:02Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.7.2","depends_on_id":"attn-nnj.7.1","type":"blocks","created_at":"2026-05-18T16:35:50Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} +{"_type":"issue","id":"attn-nnj.7.1","title":"webrtc-rs dependency + binary-size gate","description":"Add webrtc-rs to Cargo.toml as the foundation for Phase 4. Pre-merge gate: confirm release binary stays under 25 MiB target (decision #1 tradeoff). webrtc-rs transitively pulls tokio, rcgen, sctp, dtls, openssl-sys or rustls — this issue is the gate where we discover whether to feature-flag aggressively or escalate. Blocks all downstream Phase 4 work; if the gate fails we re-scope before sinking effort into signaling/datachannel code.","acceptance_criteria":"webrtc-rs added to Cargo.toml with chosen feature set documented inline.\ncargo build --release succeeds on macOS aarch64.\ncargo tree -e features --no-default-features --no-dev-dependencies output captured and committed under planning/collab/ or attached to issue notes.\ndu -h on the release .app bundle (or stripped binary if not bundled) is recorded; total under 25 MiB.\nIf gate exceeded: feature flags to evaluate are listed in a comment, OR issue is escalated via bd human and downstream work is held.","notes":"Spec: planning/collab/amendments.md §Phase 4 WebRTC + Decision #1. Files: Cargo.toml (root). Use rustls backend (not openssl-sys) by default to keep the binary smaller and avoid system-OpenSSL coupling. Verify gate via the existing scripts/build.sh release path so it matches what ships. Blocks: every other Phase 4 issue.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:30:46Z","created_by":"James Lal","updated_at":"2026-05-19T04:06:55Z","started_at":"2026-05-19T03:23:03Z","closed_at":"2026-05-19T04:06:55Z","close_reason":"Implemented; merged into collab; 346 Rust + 168 relay tests pass","dependencies":[{"issue_id":"attn-nnj.7.1","depends_on_id":"attn-nnj.7","type":"parent-child","created_at":"2026-05-18T16:30:45Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":6,"comment_count":0} +{"_type":"issue","id":"attn-nnj.4.14","title":"'Comment survives owner edits' demo + e2e check","description":"End-to-end scripted scenario using the mock-ipc scenario stream: owner edits a paragraph, the reviewer's previously-attached comment remaps via the anchor engine, decoration stays attached, and the 'moved' badge state shows in the panel. Repeatable via existing automation flags (--query, --eval).","acceptance_criteria":"- Scenario JSON in web/src/lib/mock-ipc-scenarios/ drives the demo\n- Repeatable test invocation documented (uses attn --query / --eval)\n- Asserts: decoration present after edit, status is remapped (0.70-0.89) or exact (\u003e=0.90)\n- Asserts: 'moved' badge shown in panel when remapped\n- Wired into scripts/test-e2e.sh or a sibling script","notes":"Spec refs: amendments.md Decision #15 cutoffs; data-model.md §Anchor engine. Uses attn --query and --eval automation flags (debug builds only). Depends on 2-1 mock-ipc, 2-6 decorations. Aligns with project rule: prefer in-app UI assertions; no 'any' types.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:30:43Z","created_by":"James Lal","updated_at":"2026-05-19T17:35:33Z","started_at":"2026-05-19T17:02:45Z","closed_at":"2026-05-19T17:35:33Z","close_reason":"Round 21 (final push): implemented; merged; build clean","dependencies":[{"issue_id":"attn-nnj.4.14","depends_on_id":"attn-nnj.3.5","type":"blocks","created_at":"2026-05-18T16:38:22Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.14","depends_on_id":"attn-nnj.4","type":"parent-child","created_at":"2026-05-18T16:30:42Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.14","depends_on_id":"attn-nnj.4.6","type":"blocks","created_at":"2026-05-18T16:31:34Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.14","depends_on_id":"attn-nnj.4.1","type":"blocks","created_at":"2026-05-18T16:31:28Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.14","depends_on_id":"attn-nnj.11.4","type":"blocks","created_at":"2026-05-18T16:38:31Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":4,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj.4.11","title":"Connection badge","description":"Header connection indicator with four states from data-model.md: Live direct / Mailbox / Offline / Direct failed. Subscribes to reviewStatus updates from the store. Direct-failed state surfaces a retry affordance.","acceptance_criteria":"- Connection badge present in header at location decided in UX-3\n- All four states render with distinct visual treatment\n- Subscribes to reviewStatus via review store\n- Direct-failed state includes retry affordance (button or click action)\n- Retry action calls appropriate IPC (mocked)","notes":"Spec refs: data-model.md §UI/UX Changes (owner connection badge: Live direct / Mailbox / Offline / Direct failed). Depends on UX-3, 2-1, 2-2. No 'any' types.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:30:41Z","created_by":"James Lal","updated_at":"2026-05-19T16:24:57Z","started_at":"2026-05-19T16:07:54Z","closed_at":"2026-05-19T16:24:57Z","close_reason":"Round 19: implemented; merged; build clean","dependencies":[{"issue_id":"attn-nnj.4.11","depends_on_id":"attn-nnj.10.3","type":"blocks","created_at":"2026-05-18T16:31:11Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.11","depends_on_id":"attn-nnj.4.1","type":"blocks","created_at":"2026-05-18T16:31:26Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.11","depends_on_id":"attn-nnj.12.7","type":"blocks","created_at":"2026-05-18T16:53:53Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.11","depends_on_id":"attn-nnj.4.2","type":"blocks","created_at":"2026-05-18T16:31:31Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.11","depends_on_id":"attn-nnj.10.1","type":"blocks","created_at":"2026-05-18T16:30:59Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.11","depends_on_id":"attn-nnj.4","type":"parent-child","created_at":"2026-05-18T16:30:40Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.11","depends_on_id":"attn-nnj.10.2","type":"blocks","created_at":"2026-05-18T16:31:04Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.11","depends_on_id":"attn-nnj.12.1","type":"blocks","created_at":"2026-05-18T16:53:49Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":7,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-nnj.4.10","title":"Share button + room mode selector","description":"Toolbar share affordance opens a dialog with room mode (Live / Async 24h / Async 7d / Hybrid), displays the generated attn://review/... URL, and provides a copy button. Wires to IPC review_share (mocked in this phase via 2-1).","acceptance_criteria":"- Share button present in toolbar at location decided in UX-3\n- Dialog opens with mode selector: Live / Async 24h / Async 7d / Hybrid\n- Generated attn://review/... URL displayed\n- Copy-to-clipboard button works (uses in-app feedback, not alert())\n- Calls IPC review_share; mock-ipc returns a fake URL for now\n- Dialog is in-app modal (no window.confirm/prompt)","notes":"Spec refs: data-model.md §UI/UX Changes (owner share + room mode); UX-3 for placement. Depends on UX-3, 2-1 mock-ipc. No 'any' types. No window.confirm/alert/prompt — use in-app modal.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:30:39Z","created_by":"James Lal","updated_at":"2026-05-19T16:06:04Z","started_at":"2026-05-19T15:31:08Z","closed_at":"2026-05-19T16:06:04Z","close_reason":"Round 18: implemented; merged; 414+ Rust tests pass","dependencies":[{"issue_id":"attn-nnj.4.10","depends_on_id":"attn-nnj.12.1","type":"blocks","created_at":"2026-05-18T16:53:48Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.10","depends_on_id":"attn-nnj.10.1","type":"blocks","created_at":"2026-05-18T16:30:59Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.10","depends_on_id":"attn-nnj.4.1","type":"blocks","created_at":"2026-05-18T16:31:25Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.10","depends_on_id":"attn-nnj.10.3","type":"blocks","created_at":"2026-05-18T16:31:10Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.10","depends_on_id":"attn-nnj.4","type":"parent-child","created_at":"2026-05-18T16:30:39Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.10","depends_on_id":"attn-nnj.10.2","type":"blocks","created_at":"2026-05-18T16:31:04Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.10","depends_on_id":"attn-nnj.12.5","type":"blocks","created_at":"2026-05-18T16:53:59Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":6,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-nnj.4.9","title":"Snapshot badge + status row in editor header","description":"Editor header status row showing snapshot state. Owner-side: 'Snapshot current' / 'Snapshot superseded' / 'Reviewer on older snapshot'. Reviewer-side: snapshot age (e.g., '3 min ago') + 'owner is on a newer snapshot' notice. Subscribes to reviewSnapshot updates from the store.","acceptance_criteria":"- Editor header includes snapshot badge row\n- Owner states implemented: current / superseded / reviewer-on-older\n- Reviewer states implemented: age display + newer-snapshot notice\n- Subscribes to reviewSnapshot updates from review store\n- Visual treatment matches planning/collab/ui/presence-identity.md and connection-share.md","notes":"Spec refs: data-model.md §UI/UX Changes (snapshot badge owner/reviewer); UX-5 (presence-identity) for reviewer-on-older treatment. Depends on UX-1, UX-3, 2-2 store. No 'any' types.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:30:39Z","created_by":"James Lal","updated_at":"2026-05-19T16:55:34Z","started_at":"2026-05-19T16:26:11Z","closed_at":"2026-05-19T16:55:34Z","close_reason":"Round 20: implemented; merged; build clean","dependencies":[{"issue_id":"attn-nnj.4.9","depends_on_id":"attn-nnj.4.1","type":"blocks","created_at":"2026-05-18T16:31:24Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.9","depends_on_id":"attn-nnj.10.2","type":"blocks","created_at":"2026-05-18T16:31:03Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.9","depends_on_id":"attn-nnj.10.3","type":"blocks","created_at":"2026-05-18T16:31:10Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.9","depends_on_id":"attn-nnj.10.1","type":"blocks","created_at":"2026-05-18T16:30:58Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.9","depends_on_id":"attn-nnj.12.1","type":"blocks","created_at":"2026-05-18T16:53:47Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.9","depends_on_id":"attn-nnj.12.7","type":"blocks","created_at":"2026-05-18T16:53:52Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.9","depends_on_id":"attn-nnj.10.5","type":"blocks","created_at":"2026-05-18T16:31:45Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.9","depends_on_id":"attn-nnj.4","type":"parent-child","created_at":"2026-05-18T16:30:38Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":7,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj.4.8","title":"Stale comment panel state","description":"When a ResolvedAnchor has status 'stale', the panel renders a status pill 'could not find this text anymore' and a 'Re-anchor manually' affordance that switches the editor into a select-text-in-editor mode; the next text selection re-anchors the comment.","acceptance_criteria":"- Stale-state card shows clear status pill and original anchor preview\n- 'Re-anchor manually' button enters editor select mode\n- Next editor selection re-anchors and exits select mode\n- Cancel/escape exits select mode without re-anchoring\n- Store updates resolution to exact after re-anchor","notes":"Spec refs: data-model.md §ResolvedAnchor status 'stale'; amendments.md Decision #15 ('stale → panel-only, requires manual re-anchor'). Depends on UX-1, 2-2, 2-3 panel. No 'any' types.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:30:38Z","created_by":"James Lal","updated_at":"2026-05-19T16:57:52Z","started_at":"2026-05-19T16:26:10Z","closed_at":"2026-05-19T16:57:52Z","close_reason":"Round 20: implemented (force; design-doc dep)","dependencies":[{"issue_id":"attn-nnj.4.8","depends_on_id":"attn-nnj.4.2","type":"blocks","created_at":"2026-05-18T16:31:30Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.8","depends_on_id":"attn-nnj.10.3","type":"blocks","created_at":"2026-05-18T16:31:09Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.8","depends_on_id":"attn-nnj.4.1","type":"blocks","created_at":"2026-05-18T16:31:24Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.8","depends_on_id":"attn-nnj.4","type":"parent-child","created_at":"2026-05-18T16:30:37Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.8","depends_on_id":"attn-nnj.10.2","type":"blocks","created_at":"2026-05-18T16:31:03Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.8","depends_on_id":"attn-nnj.10.1","type":"blocks","created_at":"2026-05-18T16:30:58Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":5,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj.4.7","title":"Ambiguous anchor picker","description":"When a ResolvedAnchor has status 'ambiguous', the review panel surfaces a picker listing each candidate with its preview text and confidence score. Owner clicks one candidate → emits AnchorManuallyResolved event via IPC, store updates, decoration moves to inline.","acceptance_criteria":"- Ambiguous-state card in panel shows candidate list with preview + confidence\n- Picker UI is keyboard-navigable (arrow keys + enter)\n- Selecting a candidate emits AnchorManuallyResolved via IPC\n- Store transitions the anchor to a non-ambiguous resolution after pick\n- Decoration moves from panel-only to inline after pick","notes":"Spec refs: data-model.md §ResolvedAnchor status 'ambiguous'; amendments.md Decision #15 ('ambiguous → panel-only with picker'). Depends on UX-1, 2-1, 2-2, 2-3 panel. No 'any' types.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:30:37Z","created_by":"James Lal","updated_at":"2026-05-19T16:24:40Z","started_at":"2026-05-19T16:07:54Z","closed_at":"2026-05-19T16:24:40Z","close_reason":"Round 19: implemented; merged; build clean","dependencies":[{"issue_id":"attn-nnj.4.7","depends_on_id":"attn-nnj.4","type":"parent-child","created_at":"2026-05-18T16:30:37Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.7","depends_on_id":"attn-nnj.4.1","type":"blocks","created_at":"2026-05-18T16:31:23Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.7","depends_on_id":"attn-nnj.12.5","type":"blocks","created_at":"2026-05-18T16:53:58Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.7","depends_on_id":"attn-nnj.10.2","type":"blocks","created_at":"2026-05-18T16:31:01Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.7","depends_on_id":"attn-nnj.10.1","type":"blocks","created_at":"2026-05-18T16:30:57Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.7","depends_on_id":"attn-nnj.10.3","type":"blocks","created_at":"2026-05-18T16:31:09Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.7","depends_on_id":"attn-nnj.4.2","type":"blocks","created_at":"2026-05-18T16:31:30Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.7","depends_on_id":"attn-nnj.12.8","type":"blocks","created_at":"2026-05-18T16:53:55Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":7,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj.4.6","title":"Inline decoration system (ProseMirror plugin)","description":"New ProseMirror plugin at web/src/lib/prosemirror/review-decorations.ts. Reads ResolvedAnchor entries from the review store. Emits decorations per amendments.md Decision #15 cutoffs: ≥0.90 inline highlight no badge; 0.70-0.89 inline highlight + 'moved' badge in panel; ambiguous panel-only; stale panel-only. Handles overlap. Hover focuses the corresponding panel entry; clicking a panel entry scrolls editor to the decoration.","acceptance_criteria":"- web/src/lib/prosemirror/review-decorations.ts plugin exists, mirroring existing PM plugin pattern (math/tables/etc)\n- Reads from review store; updates as ResolvedAnchor entries change\n- Cutoffs implemented exactly per amendments.md Decision #15\n- Overlap handling implemented (stacked / layered)\n- Hover decoration ↔ focus panel entry wired both ways\n- Click panel entry → editor scrolls to decoration\n- No 'any' types","notes":"Spec refs: amendments.md Decision #15 (verbatim cutoffs); planning/collab/ui/inline-decorations.md (from UX-2). Existing PM plugin pattern: web/src/lib/prosemirror/{math,tables,code-highlight,code-block-nodeview,mermaid-nodeview}.ts. Depends on UX-2, 2-1 mock-ipc, 2-2 store.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:30:36Z","created_by":"James Lal","updated_at":"2026-05-19T15:29:39Z","started_at":"2026-05-19T15:07:27Z","closed_at":"2026-05-19T15:29:39Z","close_reason":"Implemented; merged; 409 Rust + 237 relay tests pass; store reassembled from 4.6 + 8.3 merge race","dependencies":[{"issue_id":"attn-nnj.4.6","depends_on_id":"attn-nnj.4.2","type":"blocks","created_at":"2026-05-18T16:31:29Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.6","depends_on_id":"attn-nnj.4.1","type":"blocks","created_at":"2026-05-18T16:31:22Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.6","depends_on_id":"attn-nnj.10.3","type":"blocks","created_at":"2026-05-18T16:31:05Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.6","depends_on_id":"attn-nnj.12.7","type":"blocks","created_at":"2026-05-18T16:53:52Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.6","depends_on_id":"attn-nnj.3.5","type":"blocks","created_at":"2026-05-18T16:38:22Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.6","depends_on_id":"attn-nnj.10.1","type":"blocks","created_at":"2026-05-18T16:30:57Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.6","depends_on_id":"attn-nnj.12.2","type":"blocks","created_at":"2026-05-18T16:53:50Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.6","depends_on_id":"attn-nnj.4","type":"parent-child","created_at":"2026-05-18T16:30:35Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.6","depends_on_id":"attn-nnj.10.2","type":"blocks","created_at":"2026-05-18T16:31:01Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":8,"dependent_count":2,"comment_count":0} +{"_type":"issue","id":"attn-nnj.4.5","title":"Suggestion composer from selection","description":"ProseMirror selection → 'Suggest' UI offering replace / delete / insert-before / insert-after operations. Captures expectedText automatically for replace and delete from the current selection. Validates non-empty replacement before allowing submit. Submits via IPC review_create_suggestion.","acceptance_criteria":"- Selection surfaces a 'Suggest' affordance distinct from 'Comment'\n- Four operation modes: replace, delete, insert-before, insert-after\n- expectedText auto-captured from selection for replace/delete\n- Empty replacement blocked from submission (with inline error, not alert())\n- Submits via IPC review_create_suggestion; mock-ipc echoes back\n- Cancel/escape closes cleanly","notes":"Spec ref: data-model.md §Suggestion + §UI/UX Changes (suggestion card). Pairs with 2-4 comment composer. Depends on 2-1 mock-ipc, 2-2 store. No window.confirm/alert. No 'any' types.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:30:35Z","created_by":"James Lal","updated_at":"2026-05-19T16:24:24Z","started_at":"2026-05-19T16:07:53Z","closed_at":"2026-05-19T16:24:24Z","close_reason":"Round 19: implemented; merged; build clean","dependencies":[{"issue_id":"attn-nnj.4.5","depends_on_id":"attn-nnj.12.9","type":"blocks","created_at":"2026-05-18T16:53:56Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.5","depends_on_id":"attn-nnj.12.8","type":"blocks","created_at":"2026-05-18T16:53:54Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.5","depends_on_id":"attn-nnj.4.1","type":"blocks","created_at":"2026-05-18T16:31:22Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.5","depends_on_id":"attn-nnj.4","type":"parent-child","created_at":"2026-05-18T16:30:34Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.5","depends_on_id":"attn-nnj.12.5","type":"blocks","created_at":"2026-05-18T16:53:58Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":4,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj.4.4","title":"Comment composer from selection","description":"ProseMirror selection → 'Comment' popover near selection → text body input → submit. Constructs an Anchor from the selection range and calls IPC review_create_comment. For mock IPC, fakes the event back via reviewEvent so the panel updates immediately.","acceptance_criteria":"- Selection in ProseMirror surfaces a 'Comment' affordance (popover or floating button)\n- Body input supports multi-line text\n- Submit builds a valid Anchor (per data-model.md) from the selection\n- Calls IPC review_create_comment; mock-ipc returns and echoes the event\n- Cancel/escape closes without submitting\n- Empty body blocked from submission","notes":"Spec ref: data-model.md §Comment composer from selection and Anchor structure. Wire alongside web/src/lib/Editor.svelte (ProseMirror view). Depends on 2-1 mock-ipc, 2-2 store. No 'any' types.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:30:34Z","created_by":"James Lal","updated_at":"2026-05-19T16:24:08Z","started_at":"2026-05-19T16:07:53Z","closed_at":"2026-05-19T16:24:08Z","close_reason":"Round 19: implemented; merged; build clean","dependencies":[{"issue_id":"attn-nnj.4.4","depends_on_id":"attn-nnj.4.1","type":"blocks","created_at":"2026-05-18T16:31:21Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.4","depends_on_id":"attn-nnj.12.9","type":"blocks","created_at":"2026-05-18T16:53:55Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.4","depends_on_id":"attn-nnj.12.8","type":"blocks","created_at":"2026-05-18T16:53:54Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.4","depends_on_id":"attn-nnj.12.5","type":"blocks","created_at":"2026-05-18T16:53:57Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.4","depends_on_id":"attn-nnj.4","type":"parent-child","created_at":"2026-05-18T16:30:33Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":4,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj.4.3","title":"ReviewMargin.svelte: Google-Docs-style margin sticky cards","description":"Right-margin overlay rendering review cards positioned to vertically align with their anchor in the editor. Replaces the earlier panel-river design after the user pivoted to Google Docs spatial model (cards live next to their anchored text, not in a separate triage list). Includes an 'orphan tray' at the top of the margin for ambiguous/stale cards that have no valid anchor position. Reuses Phase 0c plumbing (review store, types, theme vars, popover helper); only the layout slot from 12.1 needs an overlay adjustment (already on disk, not yet a rewrite).","acceptance_criteria":"- web/src/lib/ReviewPanel.svelte exists and renders from review store\n- Grouping matches planning/collab/ui/review-panel-design.md\n- Comment and suggestion card variants implemented with author/anchor/body/status/actions\n- Empty and loading states implemented\n- Keyboard shortcut registered (consistent with KeyboardShortcutsDialog.svelte)\n- No window.confirm/alert — uses in-app UI only","notes":"Depends on: UX-1 (panel design), 2-2 (store). Existing patterns: Sidebar.svelte for rail, KeyboardShortcutsDialog.svelte for shortcut registration. Svelte 5 runes throughout. No 'any' types.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:30:33Z","created_by":"James Lal","updated_at":"2026-05-19T16:05:48Z","started_at":"2026-05-19T15:31:07Z","closed_at":"2026-05-19T16:05:48Z","close_reason":"Round 18: implemented; merged; 414+ Rust tests pass","dependencies":[{"issue_id":"attn-nnj.4.3","depends_on_id":"attn-nnj.4.1","type":"blocks","created_at":"2026-05-18T16:31:21Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.3","depends_on_id":"attn-nnj.4.2","type":"blocks","created_at":"2026-05-18T16:31:28Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.3","depends_on_id":"attn-nnj.10.2","type":"blocks","created_at":"2026-05-18T16:31:00Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.3","depends_on_id":"attn-nnj.12.1","type":"blocks","created_at":"2026-05-18T16:53:47Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.3","depends_on_id":"attn-nnj.4","type":"parent-child","created_at":"2026-05-18T16:30:33Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.3","depends_on_id":"attn-nnj.10.1","type":"blocks","created_at":"2026-05-18T16:30:56Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.3","depends_on_id":"attn-nnj.10.3","type":"blocks","created_at":"2026-05-18T16:31:05Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":6,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-nnj.4.1","title":"Mock-IPC extension: review event stream","description":"Extend web/src/lib/mock-ipc.ts to emit window.__attn__.reviewStatus(payload), reviewEvent(payload), reviewSnapshot(snapshot), and reviewAnchorResolution(update). Add a replayable scripted scenario JSON in web/src/lib/mock-ipc-scenarios/ that demonstrates: owner edits paragraph → reviewer comments → owner edits more → reviewer's anchor remaps → owner accepts. Enables Phase 2 frontend work to proceed without any Rust crates landing.","acceptance_criteria":"- mock-ipc.ts exposes reviewStatus, reviewEvent, reviewSnapshot, reviewAnchorResolution on window.__attn__\n- web/src/lib/mock-ipc-scenarios/ contains at least one JSON scenario file\n- Replay runs deterministically via dev-tools trigger (button or window helper)\n- Scenario covers owner-edits → reviewer-comments → remap → accept flow\n- Documented in a short README in mock-ipc-scenarios/","notes":"Spec refs: amendments.md §Mock IPC must be extended for parallel frontend dev (line ~74); data-model.md lines 1088-1091 callback list. Existing file to extend: web/src/lib/mock-ipc.ts (100 lines). Use Svelte 5 runes patterns in any new helpers. This unblocks all subsequent Phase 2 issues.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:30:32Z","created_by":"James Lal","updated_at":"2026-05-19T15:30:09Z","started_at":"2026-05-19T15:07:26Z","closed_at":"2026-05-19T15:30:09Z","close_reason":"Implemented; merged; 409 Rust + 237 relay tests pass; store reassembled from 4.6 + 8.3 merge race","dependencies":[{"issue_id":"attn-nnj.4.1","depends_on_id":"attn-nnj.12.6","type":"blocks","created_at":"2026-05-18T16:53:51Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.1","depends_on_id":"attn-nnj.4","type":"parent-child","created_at":"2026-05-18T16:30:31Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":13,"comment_count":0} +{"_type":"issue","id":"attn-nnj.4.2","title":"Frontend review store (Svelte 5 runes)","description":"Create web/src/lib/review/store.ts: a $state-based store holding rooms, threads, snapshots, anchorResolutions, and outbox. Subscribes to window.__attn__.reviewStatus/reviewEvent/reviewSnapshot/reviewAnchorResolution callbacks. Provides $derived selectors for 'comments on current file/snapshot', 'ambiguous anchors', and 'outbox count'. No 'any' types — proper TypeScript throughout.","acceptance_criteria":"- web/src/lib/review/store.ts exists with $state-based store\n- Subscribes to all four window.__attn__ review callbacks\n- Exposes derived selectors: commentsOnCurrent, ambiguousAnchors, outboxCount\n- Fully typed (no 'any'); types align with data-model.md ReviewEvent/Anchor/Snapshot shapes\n- Unit-testable shape (pure functions for selectors where possible)","notes":"Spec refs: data-model.md §Frontend Bridge (lines ~1080-1100) for callback contract; §UI/UX Changes for what the store must surface. Use Svelte 5 runes ($state, $derived, $effect). Follow project rule: no 'any' types. Depends on 2-1 mock-ipc extension for runtime emissions.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:30:32Z","created_by":"James Lal","updated_at":"2026-05-19T15:30:23Z","started_at":"2026-05-19T15:07:27Z","closed_at":"2026-05-19T15:30:23Z","close_reason":"Implemented; merged; 409 Rust + 237 relay tests pass; store reassembled from 4.6 + 8.3 merge race","dependencies":[{"issue_id":"attn-nnj.4.2","depends_on_id":"attn-nnj.4","type":"parent-child","created_at":"2026-05-18T16:30:32Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.2","depends_on_id":"attn-nnj.12.10","type":"blocks","created_at":"2026-05-18T16:53:57Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.2","depends_on_id":"attn-nnj.4.1","type":"blocks","created_at":"2026-05-18T16:31:19Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":7,"comment_count":0} +{"_type":"issue","id":"attn-nnj.2.8","title":"ReviewManager scaffold + UserEvent::Review event-loop wiring","description":"Scaffold src/review/manager.rs as a struct holding store + working_copy + rooms map. Wire it into the EXISTING tao event loop in src/main.rs by adding a UserEvent::Review(ReviewUpdate) arm — per amendments.md §Phase 0b, do NOT factor out a new event loop. Forwards ReviewUpdates to the webview via window.__attn__.reviewEvent(...).","acceptance_criteria":"- src/review/manager.rs defines `pub struct ReviewManager { store, working_copy, rooms: HashMap\u003cRoomId, RoomRuntime\u003e }` + new() + a tokio mpsc channel for ReviewUpdate\n- src/main.rs UserEvent enum gains a Review(ReviewUpdate) variant\n- The existing event_loop.run match adds an arm: UserEvent::Review(update) =\u003e { webview.evaluate_script(\u0026format!(\"window.__attn__.reviewEvent({})\", serde_json::to_string(\u0026update)?))?; }\n- Manager constructed during daemon startup; channel sender stashed in AppState (or accessible globally)\n- No real room/document logic yet — just lifecycle: manager starts, channel works, a smoke test sends a stub ReviewUpdate and the webview receives it (verified via --eval window.__attn__.lastReviewEvent)\n- ReviewUpdate is a typed enum (not serde_json::Value) — initial variants can be small but explicit","notes":"Spec: planning/collab/amendments.md §Codebase Corrections (main.rs is 1207 lines, not thin) + §Phase 0b (integrates into EXISTING event loop). data-model.md §Review Manager + §Webview IPC Changes for the JS bridge shape. Critical: do NOT introduce a second event loop or factor out main.rs's. Add to the existing match arms only.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:30:14Z","created_by":"James Lal","updated_at":"2026-05-19T01:33:11Z","started_at":"2026-05-19T00:47:50Z","closed_at":"2026-05-19T01:33:11Z","close_reason":"Implemented; merged into collab; 212 Rust tests + 98 relay tests pass","dependencies":[{"issue_id":"attn-nnj.2.8","depends_on_id":"attn-nnj.2.7","type":"blocks","created_at":"2026-05-18T16:30:30Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.2.8","depends_on_id":"attn-nnj.2.1","type":"blocks","created_at":"2026-05-18T16:30:26Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.2.8","depends_on_id":"attn-nnj.2.3","type":"blocks","created_at":"2026-05-18T16:30:29Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.2.8","depends_on_id":"attn-nnj.2","type":"parent-child","created_at":"2026-05-18T16:30:14Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.2.8","depends_on_id":"attn-nnj.2.9","type":"blocks","created_at":"2026-05-18T16:54:01Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.2.8","depends_on_id":"attn-nnj.2.2","type":"blocks","created_at":"2026-05-18T16:30:28Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":5,"dependent_count":2,"comment_count":0} +{"_type":"issue","id":"attn-nnj.2.7","title":"AppState refactor: review_rooms HashMap + file_to_room routing","description":"Refactor AppState in src/ipc.rs per amendments.md §Codebase Corrections: AppState becomes routing context holding `review_rooms: HashMap\u003cRoomId, RoomRuntimeHandle\u003e` and `file_to_room: HashMap\u003cPathBuf, RoomId\u003e`. Heavy state lives in ReviewManager — AppState just looks up which room a file belongs to.","acceptance_criteria":"- src/ipc.rs AppState fields: review_rooms: HashMap\u003cRoomId, RoomRuntimeHandle\u003e, file_to_room: HashMap\u003cPathBuf, RoomId\u003e (in addition to existing tab/project state)\n- RoomRuntimeHandle is a lightweight Arc/channel-sender to the manager (NOT the full ReviewRoom struct)\n- All AppState construction sites + call sites updated\n- Lookup helper: AppState::room_for_path(\u0026Path) -\u003e Option\u003cRoomRuntimeHandle\u003e\n- Existing IPC handlers compile and still pass tests\n- No flat list of rooms anywhere — file_to_room is the only path→room oracle","notes":"Spec: planning/collab/amendments.md §Codebase Corrections (Tabs and projects are first-class; the plan's AppState is wrong). This DIVERGES from the original data-model.md AppState design — amendments wins. RoomRuntimeHandle is defined here as a thin handle (clonable, Send+Sync); the real ReviewManager fills in the actual struct in issue 8.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:29:59Z","created_by":"James Lal","updated_at":"2026-05-19T00:23:59Z","started_at":"2026-05-19T00:04:13Z","closed_at":"2026-05-19T00:23:59Z","close_reason":"Implemented in parallel worktrees; merged into collab; 142 tests pass","dependencies":[{"issue_id":"attn-nnj.2.7","depends_on_id":"attn-nnj.2","type":"parent-child","created_at":"2026-05-18T16:29:58Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.2.7","depends_on_id":"attn-nnj.2.1","type":"blocks","created_at":"2026-05-18T16:30:25Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-nnj.5.4","title":"Hashcash PoW verification + replay protection","description":"relay/src/pow.ts: verify the Attn-PoW header on every write endpoint (POST /devices, POST /envelopes, POST /acks, POST /blobs, DELETE). Token is 8 colon-separated fields, v2 format. Validation order per crypto-spec.md §Server Validation. Replay protection via meta:pow_seen:\u003cexpiresAt\u003e:\u003chash\u003e DO storage entries; a scheduled alarm prunes entries past expiresAt+10min. No client type is exempt (decision #6); default difficulty 16 bits, hard floor 12.","acceptance_criteria":"- Parse the 8-field v2 token; reject unknown version\n- Validation in order: parse → v==v2 → difficulty\u003e=max(policy.powBits,12) → expiresAt within 5-min window (not past, not \u003e5min future) → resource matches (roomId, deviceId, base64url(SHA-256(METHOD + space + PATH))[:8]) → leading-zero bits in SHA-256(token bytes) → not in pow_seen replay set\n- On success: insert meta:pow_seen:\u003cexpiresAt\u003e:\u003chash\u003e with TTL\n- Errors map to ATTN_POW_INVALID (parse/format), ATTN_POW_INSUFFICIENT_DIFFICULTY, ATTN_POW_EXPIRED, ATTN_POW_RESOURCE_MISMATCH, ATTN_POW_REPLAYED — all 403\n- Single PoW token per HTTP request — for batch /envelopes the same token covers the whole batch (decision #7)\n- Alarm-driven prune loop removes pow_seen entries with expiresAt+10min \u003c now\n- Unit tests use vectors from crypto-spec.md §Test Vectors","notes":"Spec: planning/collab/crypto-spec.md §Hashcash Proof-of-Work (lines 117-197), §Server Validation (152-165), §Replay Protection (166-169). amendments.md decisions #6 (universal PoW) and #7 (single token per batch). Used by all write endpoints — implement as composable middleware.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:29:51Z","created_by":"James Lal","updated_at":"2026-05-19T01:16:27Z","started_at":"2026-05-19T00:47:51Z","closed_at":"2026-05-19T01:16:27Z","close_reason":"Implemented in parallel worktrees; merged into collab","dependencies":[{"issue_id":"attn-nnj.5.4","depends_on_id":"attn-nnj.1.2","type":"blocks","created_at":"2026-05-18T16:38:23Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.4","depends_on_id":"attn-nnj.5","type":"parent-child","created_at":"2026-05-18T16:29:51Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.4","depends_on_id":"attn-nnj.5.1","type":"blocks","created_at":"2026-05-18T16:35:24Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":9,"comment_count":0} +{"_type":"issue","id":"attn-nnj.5.3","title":"Owner-signature verification","description":"relay/src/owner-sig.ts: verify the Attn-Owner-Signature header (base64url Ed25519 signature over the same canonicalRequest used for admission HMAC: METHOD\\nPATH\\nCANONICAL_QUERY\\nSHA256(body)). Verifies against ownerSigningKey stored at room creation; mismatching key returns 403 ATTN_OWNER_KEY_MISMATCH. Required for DELETE /v2/rooms/:roomId at all times, and required for POST /v2/rooms/:roomId/acks when the request asks for deletion AND policy.deleteEventsAfterOwnerAck==true. Missing header on a path that requires it returns 403 ATTN_OWNER_SIG_REQUIRED.","acceptance_criteria":"- Canonical bytes match the admission canonicalRequest construction (single shared helper)\n- Verify Ed25519 sig with @noble/ed25519 or Web Crypto subtle\n- Required: DELETE /v2/rooms/:roomId (always)\n- Required: POST /acks when delete=true requested AND policy.deleteEventsAfterOwnerAck==true\n- 403 ATTN_OWNER_SIG_REQUIRED when header missing on a required path\n- 403 ATTN_OWNER_KEY_MISMATCH when signature does not verify against the stored ownerSigningKey\n- Stored ownerSigningKeyId is base64url(SHA-256(ownerSigningKey)) — exposed in room policy responses\n- Unit tests cover valid sig, wrong key, tampered body, missing header, non-owner action attempt","notes":"Spec: planning/collab/relay-spec.md §Identity, Keys, and Admission \u003e Owner Distinction (lines 69-74), §POST /v2/rooms/:roomId (114-167), §POST /v2/rooms/:roomId/acks (277-303), §DELETE /v2/rooms/:roomId (305-311). amendments.md decision #3 (owner-only ops). Shares canonicalRequest helper with admission middleware.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:29:50Z","created_by":"James Lal","updated_at":"2026-05-19T01:16:13Z","started_at":"2026-05-19T00:47:51Z","closed_at":"2026-05-19T01:16:13Z","close_reason":"Implemented in parallel worktrees; merged into collab","dependencies":[{"issue_id":"attn-nnj.5.3","depends_on_id":"attn-nnj.5.1","type":"blocks","created_at":"2026-05-18T16:35:23Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.3","depends_on_id":"attn-nnj.5","type":"parent-child","created_at":"2026-05-18T16:29:50Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} +{"_type":"issue","id":"attn-nnj.5.2","title":"Admission HMAC verification module","description":"relay/src/admission.ts: middleware that verifies the Attn-Admission header on every authenticated endpoint. HMAC-SHA256 covers METHOD\\nPATH\\nCANONICAL_QUERY\\nSHA256(body). Trust model is URL-as-bearer (decision #2): the relay derives the admissionKey from material the client supplies at room creation and stores it on the room record; subsequent requests must present a matching HMAC. Wrong HMAC returns 401 with error code ATTN_ADMISSION_INVALID.","acceptance_criteria":"- Canonical string per spec: METHOD\\nPATH\\nCANONICAL_QUERY (sorted, percent-encoded)\\nSHA256(body) hex\n- HMAC constant-time compared; failure returns 401 {error:{code:'ATTN_ADMISSION_INVALID'}}\n- Admission key is loaded from room storage; missing room → 404 ATTN_ROOM_NOT_FOUND (before admission check to avoid timing oracle? — actually per spec admission failure must NOT leak room existence: return 401 for missing-room too)\n- Unit tests cover: wrong HMAC, missing header, body tampering, query reordering, missing room (uniform 401)\n- Exported as a Hono/itty middleware reused by all authenticated routes","notes":"Spec: planning/collab/relay-spec.md §Identity, Keys, and Admission (lines 37-67) and §Wire Conventions (89-99). Decision #2 in amendments.md (URL-as-bearer trust model). This middleware is the gatekeeper for every endpoint except POST /v2/rooms/:roomId (room creation, which establishes the admission key) and GET /health.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:29:49Z","created_by":"James Lal","updated_at":"2026-05-19T00:40:28Z","started_at":"2026-05-19T00:26:07Z","closed_at":"2026-05-19T00:40:28Z","close_reason":"Implemented in parallel; merged into collab; 166 tests pass + relay 35 tests pass","dependencies":[{"issue_id":"attn-nnj.5.2","depends_on_id":"attn-nnj.5.1","type":"blocks","created_at":"2026-05-18T16:35:22Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.5.2","depends_on_id":"attn-nnj.5","type":"parent-child","created_at":"2026-05-18T16:29:49Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":9,"comment_count":0} +{"_type":"issue","id":"attn-nnj.2.6","title":"Watcher self-write distinction with TTL-tracked hashes","description":"Teach src/watcher.rs to distinguish between our own writes (via WorkingCopyService) and external file changes (editor, git checkout). Tracks recent self-writes by (path, ContentHash) with a short TTL; matching events attach to the existing LocalRevision, others emit a new ExternalFileChange revision.","acceptance_criteria":"- src/watcher.rs maintains a HashMap\u003c(PathBuf, ContentHash), Instant\u003e of recent self-writes\n- WorkingCopyService::save inserts into this map immediately after a successful write\n- TTL configurable, default 5s; expired entries pruned on access\n- On notify event: compute new ContentHash; if (path, hash) hit → attach to existing LocalRevision (no new journal entry); else emit a new LocalRevision{source: ExternalFileChange} via the store + existing reload signal\n- Existing reload-the-webview behavior is preserved end-to-end\n- Unit tests cover: self-write skipped, external write journaled, TTL expiry causes external-classification, repeated identical external content still journaled once","notes":"Spec: planning/collab/data-model.md §File Watcher Integration. The watcher already debounces; reuse that. Don't journal from inside the watcher directly — call store.append_revision via a channel to the ReviewManager so single-writer ordering holds.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:29:46Z","created_by":"James Lal","updated_at":"2026-05-19T01:33:24Z","started_at":"2026-05-19T01:17:14Z","closed_at":"2026-05-19T01:33:24Z","close_reason":"Implemented; merged into collab; 212 Rust tests + 98 relay tests pass","dependencies":[{"issue_id":"attn-nnj.2.6","depends_on_id":"attn-nnj.2.5","type":"blocks","created_at":"2026-05-18T16:30:30Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.2.6","depends_on_id":"attn-nnj.2","type":"parent-child","created_at":"2026-05-18T16:29:45Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.2.6","depends_on_id":"attn-nnj.2.1","type":"blocks","created_at":"2026-05-18T16:30:25Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj.2.5","title":"Revision journal appended on every WorkingCopyService save","description":"On every WorkingCopyService::save, append a LocalRevision to ~/.attn/reviews/rooms/\u003croomId\u003e/revisions/\u003cfileId\u003e.jsonl. Captures parentHash → nextHash + optional pmSteps/patchText so future anchor code can replay the document history.","acceptance_criteria":"- WorkingCopyService::save appends a LocalRevision JSONL line per call\n- LocalRevision fields: revisionId, fileId, parentHash, nextHash, source (SaveSource), timestamp, optional pmSteps, optional patchText\n- Source variants wired: UserEdit, CheckboxToggle, ExternalFileChange, SnapshotLoaded, ManualReanchor (AcceptedSuggestion stub is fine — Phase 5 wires it)\n- File path: ~/.attn/reviews/rooms/\u003croomId\u003e/revisions/\u003cfileId\u003e.jsonl\n- Append is atomic per line (single write syscall + fsync)\n- Unit tests cover: single save → single revision, sequential saves produce parentHash chain, replay reads back identical sequence","notes":"Spec: planning/collab/data-model.md §Local Replicas (LocalRevision struct) + §Working Copy Service. pmSteps/patchText are optional now — Phase 1 anchor engine fills them in. RoomId routing comes from AppState (Phase 0b issue 7); for save calls outside any room, use a sentinel \"orphan\" or skip journaling.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:29:33Z","created_by":"James Lal","updated_at":"2026-05-19T01:15:47Z","started_at":"2026-05-19T00:47:49Z","closed_at":"2026-05-19T01:15:47Z","close_reason":"Implemented in parallel worktrees; merged into collab","dependencies":[{"issue_id":"attn-nnj.2.5","depends_on_id":"attn-nnj.2","type":"parent-child","created_at":"2026-05-18T16:29:33Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.2.5","depends_on_id":"attn-nnj.2.4","type":"blocks","created_at":"2026-05-18T16:30:29Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.2.5","depends_on_id":"attn-nnj.2.1","type":"blocks","created_at":"2026-05-18T16:30:24Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.2.5","depends_on_id":"attn-nnj.2.2","type":"blocks","created_at":"2026-05-18T16:30:27Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":3,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-nnj.8.5","title":"Emit SuggestionAccepted and SuggestionRejected","description":"After a successful apply (clean or three-way accepted), ReviewManager constructs a SuggestionAccepted ReviewEvent referencing the LocalRevision id and the SaveResult.resultingHash, signs+encrypts it, and enqueues it on the outbox for relay/DataChannel delivery. The reject path emits SuggestionRejected with an optional reason. Both flow through the same outbox machinery as comment events.","acceptance_criteria":"- src/review/manager.rs on apply success builds SuggestionAccepted { suggestionId, appliedRevisionId, resultingHash } matching data-model.md §Suggestion Events.\n- On reject (owner picked 'Keep current'), builds SuggestionRejected { suggestionId, reason } where reason is optional and may come from the UI.\n- Events are signed with the owner's signing key, encrypted under eventKey, and appended to outbox.jsonl exactly like comment events (single code path).\n- meta.parentEventIds includes the original SuggestionCreated event id so receivers can reconstruct the thread.\n- Unit test: simulate full apply path, assert one SuggestionAccepted envelope sits in outbox with the expected fields; simulate reject path, assert one SuggestionRejected envelope.","notes":"Spec: planning/collab/data-model.md §Suggestion Events (lines 628-640). Files: src/review/manager.rs, src/review/outbox.rs. Reuse the existing outbox enqueue path — do not introduce a parallel writer. Outbox mutability rule from amendments.md §Outbox mutability and freezing applies (these events are immutable once first-send-attempted).","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:29:25Z","created_by":"James Lal","updated_at":"2026-05-19T15:06:00Z","started_at":"2026-05-19T04:31:05Z","closed_at":"2026-05-19T15:06:00Z","close_reason":"Implemented; merged; 412 Rust + 213 relay tests pass (6 conformance scenarios deferred to 5.16)","dependencies":[{"issue_id":"attn-nnj.8.5","depends_on_id":"attn-nnj.8","type":"parent-child","created_at":"2026-05-18T16:29:24Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.8.5","depends_on_id":"attn-nnj.8.4","type":"blocks","created_at":"2026-05-18T16:29:53Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-nnj.8.4","title":"Apply through WorkingCopyService","description":"When the owner accepts a suggestion (clean apply or three-way 'accept'/'accept_edited'), the resulting bytes must be written to the file via WorkingCopyService::save with SaveSource::AcceptedSuggestion { room_id, suggestion_id }. This records a LocalRevision in the journal so the watcher distinguishes the apply from an external edit and so the SaveResult.resultingHash can feed the SuggestionAccepted event.","acceptance_criteria":"- src/review/apply.rs apply_accepted(verdict_or_edited_bytes, source: SaveSource::AcceptedSuggestion { room_id, suggestion_id }) calls WorkingCopyService::save and returns a SaveResult with resultingHash.\n- The LocalRevision entry created has source=AcceptedSuggestion and references both room_id and suggestion_id so the revision journal can be queried by room.\n- File watcher sees its own write and suppresses the reload bounce (existing self-write distinction in src/watcher.rs).\n- After write, the editor's in-memory document updates without losing the user's cursor (PM transaction rather than a full reload where possible).\n- Integration test in src/review/apply.rs writes a fixture file, applies a suggestion, asserts both file contents and a new LocalRevision entry.","notes":"Spec: planning/collab/data-model.md §Suggestion Events apply flow steps 5-6 (lines 648-649) + amendments.md §watcher.rs does more than reload (line ~21). Files: src/review/apply.rs, src/working_copy.rs (Phase 0b). WorkingCopyService::save is the only write path — never std::fs::write directly.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:29:22Z","created_by":"James Lal","updated_at":"2026-05-19T04:28:54Z","started_at":"2026-05-19T04:09:05Z","closed_at":"2026-05-19T04:28:54Z","close_reason":"Round 13: implemented; merged; all tests pass","dependencies":[{"issue_id":"attn-nnj.8.4","depends_on_id":"attn-nnj.8","type":"parent-child","created_at":"2026-05-18T16:29:21Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.8.4","depends_on_id":"attn-nnj.8.1","type":"blocks","created_at":"2026-05-18T16:29:52Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.8.4","depends_on_id":"attn-nnj.2.4","type":"blocks","created_at":"2026-05-18T16:38:30Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-nnj.5.1","title":"Relay package scaffold + Wrangler config","description":"Create the relay/ workspace package that hosts the Cloudflare Worker: package.json, tsconfig.json, wrangler.toml, src/index.ts (router stub), src/room-do.ts (Durable Object stub), src/schema.ts (zod request/response validators), and test/ folder layout. wrangler dev --local must boot Miniflare with the RELAY_ROOMS Durable Object binding and RELAY_BLOBS R2 bucket binding so subsequent issues can integration-test against it.","acceptance_criteria":"- relay/ directory exists with package.json, tsconfig.json, wrangler.toml, src/{index,room-do,schema}.ts, test/{integration,conformance}/ scaffolding\n- wrangler.toml: compatibility_date=2026-01-01, RELAY_ROOMS Durable Object class binding, RELAY_BLOBS R2 binding, HARD_MAX_ROOM_BYTES / HARD_MAX_EVENT_BYTES / HARD_MAX_SNAPSHOT_BYTES env vars set per spec\n- zod schemas typecheck against the request/response shapes in relay-spec.md\n- 'wrangler dev --local' boots Miniflare cleanly with the DO and R2 stub mounted; GET /health returns 200\n- npm test wires through to a vitest runner pointed at test/","notes":"Spec: planning/collab/relay-spec.md §Deployment (lines 614-685) for wrangler.toml sketch and repo layout. §Caps (Server Hard Maxima) for HARD_MAX_* values. This issue blocks every other 3a issue — keep it strictly to scaffolding (no business logic). Repo currently has no relay/ folder.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:29:21Z","created_by":"James Lal","updated_at":"2026-05-18T23:57:41Z","started_at":"2026-05-18T23:49:36Z","closed_at":"2026-05-18T23:57:41Z","close_reason":"Implemented; merged into collab","dependencies":[{"issue_id":"attn-nnj.5.1","depends_on_id":"attn-nnj.5","type":"parent-child","created_at":"2026-05-18T16:29:21Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":15,"comment_count":0} +{"_type":"issue","id":"attn-nnj.2.4","title":"WorkingCopyService replacing direct fs::write","description":"Introduce src/review/working_copy.rs as the single chokepoint for every markdown-file save. Replaces direct std::fs::write calls in src/ipc.rs (EditSave + checkbox toggle). Hashes content on every save so downstream revision/anchor code has a stable identity.","acceptance_criteria":"- src/review/working_copy.rs exposes `save(req: SaveRequest) -\u003e Result\u003cSaveResult\u003e`\n- SaveRequest { path: PathBuf, content: String, expected_hash: Option\u003cContentHash\u003e, source: SaveSource }\n- SaveResult { previous_hash: ContentHash, next_hash: ContentHash, revision_id: RevisionId }\n- SaveSource enum: UserEdit, CheckboxToggle, AcceptedSuggestion, ExternalFileChange, SnapshotLoaded, ManualReanchor\n- ContentHash computed per crypto-spec.md §ContentHash (canonical UTF-8, no BOM, LF line endings, preserve trailing-newline as authored)\n- expected_hash mismatch → returns ConflictError without writing\n- src/ipc.rs EditSave and checkbox-toggle paths use WorkingCopyService::save instead of std::fs::write\n- Unit tests cover: happy save, expected_hash mismatch, hash determinism across line-ending normalization","notes":"Spec: planning/collab/data-model.md §Working Copy Service + crypto-spec.md §ContentHash. The revision_id returned here gets persisted by the revision-journal issue. Keep this synchronous for now (single-writer).","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:29:20Z","created_by":"James Lal","updated_at":"2026-05-19T00:39:55Z","started_at":"2026-05-19T00:26:06Z","closed_at":"2026-05-19T00:39:55Z","close_reason":"Implemented in parallel; merged into collab; 166 tests pass + relay 35 tests pass","dependencies":[{"issue_id":"attn-nnj.2.4","depends_on_id":"attn-nnj.2","type":"parent-child","created_at":"2026-05-18T16:29:20Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.2.4","depends_on_id":"attn-nnj.2.1","type":"blocks","created_at":"2026-05-18T16:30:23Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.2.4","depends_on_id":"attn-nnj.2.2","type":"blocks","created_at":"2026-05-18T16:30:27Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":2,"comment_count":0} +{"_type":"issue","id":"attn-nnj.8.3","title":"Three-way apply UI dialog (Svelte)","description":"Svelte 5 component that surfaces ApplyVerdict::RequiresThreeWay to the owner. Shows the snapshot text (what the suggester saw), the current owner text (what's there now), and the proposed replacement, side-by-side. Owner picks: accept proposed, keep current, or edit manually. The component returns the owner's choice via IPC back to ReviewManager.","acceptance_criteria":"- web/src/lib/ReviewApplyDialog.svelte renders three panes (snapshot | current | proposed) with monospace diff styling.\n- Props: { suggestionId, snapshotText, currentText, proposedText, anchorContext }.\n- Actions: 'Accept proposed' (returns 'accept'), 'Keep current' (returns 'reject'), 'Edit manually' (opens an inline editor with the proposed text as the starting buffer, returns 'accept_edited' with the edited string).\n- Built with Svelte 5 runes (, , ); no Svelte 4 patterns. No window.confirm / alert per project conventions — fully in-app UI.\n- Result flows to ReviewManager via window.__attn__.reviewSubmitApplyChoice(suggestionId, choice, editedText?).\n- Storybook-style demo route or mock-ipc fixture so the dialog renders standalone.","notes":"Spec: planning/collab/data-model.md §Suggestion Events apply flow step 3 (line 646). Follow svelte5-best-practices skill conventions. Files: web/src/lib/ReviewApplyDialog.svelte. Existing component patterns: look at the review panel pieces from Phase 2 work. Project rule: no window.confirm/alert — use proper in-app UI.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:29:06Z","created_by":"James Lal","updated_at":"2026-05-19T15:29:54Z","started_at":"2026-05-19T15:07:28Z","closed_at":"2026-05-19T15:29:54Z","close_reason":"Implemented; merged; 409 Rust + 237 relay tests pass; store reassembled from 4.6 + 8.3 merge race","dependencies":[{"issue_id":"attn-nnj.8.3","depends_on_id":"attn-nnj.10.4","type":"blocks","created_at":"2026-05-18T16:38:30Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.8.3","depends_on_id":"attn-nnj.8.1","type":"blocks","created_at":"2026-05-18T16:29:52Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.8.3","depends_on_id":"attn-nnj.8","type":"parent-child","created_at":"2026-05-18T16:29:05Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj.2.3","title":"Local JSON/JSONL store at ~/.attn/reviews/","description":"Implement src/review/store.rs as the on-disk persistence layer for rooms, events, snapshots, outbox, and revisions. Atomic writes via temp-file+rename for JSON; append-only JSONL for event/outbox/revision logs. Idempotent on EventId and EnvelopeId so repeated imports are safe.","acceptance_criteria":"- Directory layout matches data-model.md §Local Review Store exactly (rooms/\u003croomId\u003e/{room.json, devices/, snapshots/, events.jsonl, outbox.jsonl, revisions/\u003cfileId\u003e.jsonl, replicas/\u003creplicaId\u003e/...})\n- All JSON writes are atomic: write to .tmp then rename\n- JSONL writes are append-only and fsync'd per append\n- import_event(EventId, ...) is idempotent — duplicate EventId is a no-op\n- enqueue_outbox(EnvelopeId, ...) is idempotent on EnvelopeId\n- Every top-level JSON file includes a `schemaVersion` field\n- Unit tests cover: fresh-init, repeat-import, partial-write recovery, concurrent appender safety (single writer)","notes":"Spec: planning/collab/data-model.md §Local Replicas + §Local Review Store layout (search §Local Review Store in data-model.md). Use std::fs + serde_json. No async yet; called from a single ReviewManager task. ~/.attn/ already exists for the daemon socket — extend with reviews/ subtree.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:29:01Z","created_by":"James Lal","updated_at":"2026-05-19T00:00:28Z","started_at":"2026-05-18T23:49:34Z","closed_at":"2026-05-19T00:00:28Z","close_reason":"Implemented; merged into collab; 9 store tests pass","dependencies":[{"issue_id":"attn-nnj.2.3","depends_on_id":"attn-nnj.2.1","type":"blocks","created_at":"2026-05-18T16:30:23Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.2.3","depends_on_id":"attn-nnj.2","type":"parent-child","created_at":"2026-05-18T16:29:01Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.2.3","depends_on_id":"attn-nnj.2.2","type":"blocks","created_at":"2026-05-18T16:30:26Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-nnj.8.2","title":"Expected-text verification","description":"Tight unit tests around SuggestionOperation.expectedText vs current owner text. A false positive (verifying equal when the bytes differ) corrupts the user's file silently. expectedText was captured against the snapshot's exact bytes, so verification must be byte-identical with no Unicode normalization, no whitespace folding, and explicit line-ending handling.","acceptance_criteria":"- src/review/apply.rs has a private verify_expected_text(current: \u0026str, expected: \u0026str) -\u003e bool helper used by the suggestion resolver.\n- Unit tests cover: identical bytes -\u003e true; trailing-whitespace diff -\u003e false; CRLF vs LF diff -\u003e false (ContentHash normalizes to LF on write, but expectedText was captured exactly as the snapshot bytes — they must match exactly); NFC vs NFD unicode -\u003e false (no normalization); BOM present in one only -\u003e false; empty-string vs empty-string -\u003e true.\n- Property-style test: for random inputs s, verify_expected_text(s, s) == true and verify_expected_text(s, s + 'x') == false.\n- Documented in a module-level comment that this function intentionally does no normalization.","notes":"Spec: planning/collab/data-model.md §Suggestion Events apply flow step 2 (line 645). Critical correctness path — please err on the side of more tests. See also crypto-spec.md ContentHash normalization (writes LF; expectedText captured from snapshot bytes pre-normalization).","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:28:57Z","created_by":"James Lal","updated_at":"2026-05-19T04:08:29Z","started_at":"2026-05-19T03:42:01Z","closed_at":"2026-05-19T04:08:29Z","close_reason":"Implemented; merged into collab; 346 Rust + 168 relay tests pass","dependencies":[{"issue_id":"attn-nnj.8.2","depends_on_id":"attn-nnj.8.1","type":"blocks","created_at":"2026-05-18T16:29:51Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.8.2","depends_on_id":"attn-nnj.8","type":"parent-child","created_at":"2026-05-18T16:28:57Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj.8.1","title":"Suggestion resolver (Rust)","description":"Given a SuggestionCreated event and the current owner DocumentReplica, resolve the suggestion's Anchor through the Phase 1 anchor engine, then evaluate the SuggestionOperation. Produces an ApplyVerdict that drives the apply UI: clean apply, three-way merge, ambiguous picker, or stale. This is the core decision point of the Phase 5 apply flow.","acceptance_criteria":"- src/review/apply.rs exposes resolve_suggestion(event: \u0026SuggestionCreated, replica: \u0026DocumentReplica) -\u003e ApplyVerdict.\n- ApplyVerdict variants: Ready { range: PositionAnchor, replacement: String, op_kind }, RequiresThreeWay { range, snapshot_text, current_text, replacement }, Stale { reason }, Ambiguous { candidates }.\n- For SuggestionOperation::Replace/Delete: anchor resolves with status in {exact, remapped} AND current text at the resolved range equals expectedText -\u003e Ready; if anchor resolves but current text differs -\u003e RequiresThreeWay; if anchor is ambiguous -\u003e Ambiguous; if stale -\u003e Stale.\n- For SuggestionOperation::InsertBefore/InsertAfter: anchor must be unambiguous (exact or remapped); ambiguous/stale flow to Ambiguous/Stale.\n- Unit tests cover each verdict path with realistic fixtures.","notes":"Spec: planning/collab/data-model.md §Suggestion Events apply flow (lines 642-650). Files: src/review/apply.rs. Depends on the Phase 1 Rust resolver (attn-nnj.3.4) — added as cross-phase dep by parent agent. Pure function, no I/O, no UI — UI lives in the three-way dialog issue.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:28:49Z","created_by":"James Lal","updated_at":"2026-05-19T04:06:42Z","started_at":"2026-05-19T03:23:02Z","closed_at":"2026-05-19T04:06:42Z","close_reason":"Implemented; merged into collab; 346 Rust + 168 relay tests pass","dependencies":[{"issue_id":"attn-nnj.8.1","depends_on_id":"attn-nnj.3.4","type":"blocks","created_at":"2026-05-18T16:38:29Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.8.1","depends_on_id":"attn-nnj.8","type":"parent-child","created_at":"2026-05-18T16:28:49Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"comment_count":0} +{"_type":"issue","id":"attn-nnj.2.2","title":"Serde model types for review domain","description":"Define all serde structs in src/review/model.rs covering rooms, participants, devices, documents, snapshots, replicas, revisions, events, envelopes, and sync cursors. JSON field names match data-model.md camelCase exactly so any future browser/CLI can read the same files.","acceptance_criteria":"- src/review/model.rs defines: ReviewRoom, RoomPolicy, Participant, Device, SharedDocument, SnapshotNode, BlobRef, DocumentReplica, ReplicaRelation, LocalRevision, ReviewEvent (with EventMeta + Body + Auth submodels), MailboxEnvelope, SyncCursor, DeliveryAck\n- ReviewEventBody is a tagged enum with variants: RoomCreated, ParticipantJoined, SnapshotCreated, SnapshotSuperseded, CommentCreated, CommentResolved, SuggestionCreated, SuggestionAccepted, SuggestionRejected, AnchorManuallyResolved, PresenceUpdated, SessionEnded\n- All structs use #[serde(rename_all = \"camelCase\")] (or per-field rename) so JSON matches data-model.md\n- Roundtrip test: every variant serializes → deserializes byte-identical\n- Zero use of `any` / `serde_json::Value` except where the spec explicitly says opaque payload","notes":"Spec: planning/collab/data-model.md §Terms, §Review Events (all subsections), §Encrypted Envelopes, §Sync Cursors And ACKs. Use #[serde(tag = \"kind\")] for the event body enum. Use the typed ID newtypes from Phase 0a issue 8 (RoomId, FileId, EventId, etc.) — they're already serde-transparent.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:28:48Z","created_by":"James Lal","updated_at":"2026-05-18T23:45:35Z","started_at":"2026-05-18T23:31:44Z","closed_at":"2026-05-18T23:45:35Z","close_reason":"Implemented in parallel worktrees; merged into collab","dependencies":[{"issue_id":"attn-nnj.2.2","depends_on_id":"attn-nnj.2","type":"parent-child","created_at":"2026-05-18T16:28:48Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.2.2","depends_on_id":"attn-nnj.2.1","type":"blocks","created_at":"2026-05-18T16:30:22Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":6,"comment_count":0} +{"_type":"issue","id":"attn-nnj.10.3","title":"Design: connection + share affordances","description":"Decide where the share button, connection badge, and peer strip live in the toolbar/header. Toolbar real estate is already contended by theme toggle, edit toggle, and command palette. Determine owner-only vs reviewer-only affordances and overflow behavior. Output planning/collab/ui/connection-share.md with proposed layout.","acceptance_criteria":"- planning/collab/ui/connection-share.md exists with annotated layout\n- Resolves placement of: share button, connection badge (Live direct / Mailbox / Offline / Direct failed), peer strip\n- Distinguishes owner-only vs reviewer-only affordances\n- Notes interaction with existing toolbar (theme toggle, edit toggle, command palette)\n- Flagged for human review before share/connection coding begins","notes":"Spec refs: data-model.md §UI/UX Changes (owner: share + room mode + connection + peer strip; reviewer: outbox + owner-offline state). Existing toolbar: search web/src/lib/ for theme toggle and edit toggle to inventory current real estate. Output path: planning/collab/ui/connection-share.md. Blocks Phase 2 share, connection-badge, peer-strip issues.","status":"closed","priority":1,"issue_type":"decision","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:28:45Z","created_by":"James Lal","updated_at":"2026-05-19T17:58:58Z","started_at":"2026-05-18T23:58:12Z","closed_at":"2026-05-19T17:58:58Z","close_reason":"Design docs landed in planning/collab/ui/ for ongoing reference","labels":["human"],"dependencies":[{"issue_id":"attn-nnj.10.3","depends_on_id":"attn-nnj.10","type":"parent-child","created_at":"2026-05-18T16:28:44Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":9,"comment_count":0} +{"_type":"issue","id":"attn-nnj.10.2","title":"Design: inline decoration system in ProseMirror","description":"Design how the four anchor states render in the editor surface: exact (\u003e=0.90), remapped+moved (0.70-0.89), ambiguous (panel-only), and stale (panel-only). Decide between underline / highlight / margin marker treatments, hover affordance, and click-to-focus-panel interaction. Output planning/collab/ui/inline-decorations.md with concrete CSS and PM Decoration sketches.","acceptance_criteria":"- planning/collab/ui/inline-decorations.md exists with concrete CSS + PM Decoration sketches per state\n- Confidence cutoffs from amendments.md Decision #15 quoted verbatim\n- Hover and click-to-focus behaviors specified\n- Overlap handling addressed (multiple decorations covering same range)\n- Flagged for human review before Phase 2 decoration plugin coding","notes":"Spec refs: amendments.md Decision #15 (UI cutoffs: \u003e=0.90 inline no badge, 0.70-0.89 inline + 'moved' badge, ambiguous panel-only, stale panel-only). Existing PM plugins: web/src/lib/prosemirror/{math,tables,code-highlight,code-block-nodeview,mermaid-nodeview}.ts — mirror their decoration plugin pattern. Output path: planning/collab/ui/inline-decorations.md. Blocks Phase 2 decoration plugin issue.","status":"closed","priority":1,"issue_type":"decision","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:28:44Z","created_by":"James Lal","updated_at":"2026-05-19T17:58:44Z","started_at":"2026-05-18T23:49:36Z","closed_at":"2026-05-19T17:58:44Z","close_reason":"Design docs landed in planning/collab/ui/ for ongoing reference","labels":["human"],"dependencies":[{"issue_id":"attn-nnj.10.2","depends_on_id":"attn-nnj.10","type":"parent-child","created_at":"2026-05-18T16:28:44Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":7,"comment_count":0} +{"_type":"issue","id":"attn-nnj.10.1","title":"Design: review margin layout \u0026 sticky-card model (was: panel layout)","description":"REWRITTEN after user pivot from panel-river to Google-Docs-style margin sticky cards. The original panel-river exploration is kept as context (3 candidates explored) but the recommendation now: margin overlay with cards vertically anchored, orphan tray for ambiguous/stale, decorations from 10.2 click-to-focus the margin card. Update the doc at planning/collab/ui/review-panel-design.md in place.","acceptance_criteria":"- planning/collab/ui/review-panel-design.md exists with 2-3 ASCII mockups\n- Recommendation called out explicitly with rationale\n- Covers: grouping (file/snapshot/thread), resolved collapse, density at 30 comments, picker shape for ambiguous\n- Cross-references data-model.md §UI/UX Changes\n- Flagged for human review before Phase 2 panel coding begins","notes":"Spec refs: planning/collab/data-model.md §UI/UX Changes (lines ~776+); amendments.md Decision #15 cutoffs. Relevant existing files: web/src/lib/Sidebar.svelte (rail patterns), web/src/lib/CommandPalette.svelte (overlay patterns). Output path: planning/collab/ui/review-panel-design.md. This blocks Phase 2 panel/ambiguous/stale/decoration issues.","status":"closed","priority":1,"issue_type":"decision","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:28:43Z","created_by":"James Lal","updated_at":"2026-05-19T01:15:34Z","started_at":"2026-05-18T23:21:24Z","closed_at":"2026-05-19T01:15:34Z","close_reason":"Implemented in parallel worktrees; merged into collab","labels":["human"],"dependencies":[{"issue_id":"attn-nnj.10.1","depends_on_id":"attn-nnj.10","type":"parent-child","created_at":"2026-05-18T16:28:43Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":7,"comment_count":0} +{"_type":"issue","id":"attn-nnj.3.8","title":"window.__attn__.reviewAnchorResolution IPC callback","description":"Wire ResolvedAnchor updates from the Rust ReviewManager to the frontend whenever a snapshot lands or the owner's document changes. The frontend store subscribes and re-runs the TS resolver mirror to produce inline decorations. This is the live channel that keeps comment highlights stable across edits.","acceptance_criteria":"- ReviewManager invokes evaluate_script(window, 'window.__attn__.reviewAnchorResolution(...)') with the latest ResolvedAnchor batch on: (a) new SnapshotCreated arriving, (b) new comment/suggestion event arriving, (c) owner save / WorkingCopy revision producing a new currentHash.\n- The IPC payload is JSON: { fileId, roomId, resolutions: [{ eventId, resolved: ResolvedAnchor }] }.\n- web/src/lib/review/store.ts (or equivalent) exposes a reactive store the Editor.svelte review extension subscribes to.\n- The TS resolver mirror runs locally on every PM transaction to update decorations between Rust pushes (no flicker).\n- Extended mock-ipc.ts emits the same callback shape so frontend dev works without a running daemon (per amendments.md §Mock IPC must be extended).","notes":"Spec: planning/collab/data-model.md §Anchor Resolution + planning/collab/amendments.md §Mock IPC must be extended (line ~76). Files: src/review/manager.rs, src/ipc.rs, web/src/lib/review/store.ts, web/src/lib/mock-ipc.ts. Rust side reuses the existing evaluate_script pattern used by other __attn__ callbacks. Frontend should debounce its own re-resolves so a burst of PM transactions doesn't thrash decorations.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:28:34Z","created_by":"James Lal","updated_at":"2026-05-19T02:30:01Z","started_at":"2026-05-19T01:57:29Z","closed_at":"2026-05-19T02:30:01Z","close_reason":"Implemented (3.8+round-10 retries); merged into collab; 267 Rust + 144 relay tests pass","dependencies":[{"issue_id":"attn-nnj.3.8","depends_on_id":"attn-nnj.2.8","type":"blocks","created_at":"2026-05-18T16:38:21Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.3.8","depends_on_id":"attn-nnj.3.1","type":"blocks","created_at":"2026-05-18T16:29:46Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.3.8","depends_on_id":"attn-nnj.3","type":"parent-child","created_at":"2026-05-18T16:28:33Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj.2.1","title":"Crate layout: src/review/ module skeleton","description":"Create the src/review/ module tree as empty stubs with module-level docs taken from data-model.md §New Rust Modules. Wires `mod review;` into src/main.rs so subsequent issues have a place to land code.","acceptance_criteria":"- src/review/{mod.rs, ids.rs, model.rs, store.rs, working_copy.rs, manager.rs, transport.rs, apply.rs, ipc.rs} exist\n- Each file has a module-level //! doc comment summarizing its responsibility (verbatim from data-model.md §New Rust Modules where applicable)\n- src/main.rs declares `mod review;` and compiles\n- `cargo check` passes; no warnings about unused modules (use #[allow(dead_code)] on stubs)\n- No business logic yet — pure scaffolding","notes":"Spec: planning/collab/data-model.md §Rust Architecture Changes §New Rust Modules. Keep mod.rs as just `pub mod ...;` re-exports. The crypto/ subdir lives separately (owned by Phase 0a).","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:28:33Z","created_by":"James Lal","updated_at":"2026-05-18T23:29:27Z","started_at":"2026-05-18T23:21:23Z","closed_at":"2026-05-18T23:29:27Z","close_reason":"Implemented via parallel worktree agents; merged into collab","dependencies":[{"issue_id":"attn-nnj.2.1","depends_on_id":"attn-nnj.2","type":"parent-child","created_at":"2026-05-18T16:28:32Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":8,"comment_count":0} +{"_type":"issue","id":"attn-nnj.3.6","title":"Markdown-edit anchor test corpus","description":"Hand-curated test corpus that pins resolver behavior across the realistic markdown edit shapes a reviewer will encounter. The same corpus drives both the Rust and TS resolvers (same Anchor input + same AnchorIndex inputs -\u003e same ResolvedAnchor output). Catching disagreement here is the only way to prove the two impls stay in lockstep.","acceptance_criteria":"- planning/collab/test-vectors/anchor-cases/ contains ~50 numbered case directories.\n- Each case has original.md, edited.md, anchor.json (the Anchor produced from a selection in original.md), and expected.json (the expected ResolvedAnchor verdict against edited.md).\n- Coverage includes: exact byte match, paragraph reordered, heading renamed, list item inserted before, code-block reflow (whitespace inside fence), quote unchanged but block split into two, ambiguous duplicate paragraphs, fully deleted (stale), math/mermaid round-trip, structure-only block-level anchor, fuzzy quote with one-word change, line-proximity-only fallback.\n- A test runner in src/review/anchors/tests.rs and a vitest spec in web/src/lib/review/resolver.test.ts iterate the corpus and assert the Rust + TS resolvers each produce the expected verdict (status + reason + currentRange).\n- README.md in anchor-cases/ documents the corpus contract.","notes":"Spec: planning/collab/data-model.md §Anchor Resolution + planning/collab/amendments.md Decision #15. Build the corpus as JSON-on-disk so both languages consume it without a code-gen step. The math/mermaid cases require the index builder's Decision #16 work to land first. Cases that depend on local pmSteps mapping should include a stepsJournal.json (omit otherwise).","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:28:21Z","created_by":"James Lal","updated_at":"2026-05-19T01:56:01Z","started_at":"2026-05-19T01:36:53Z","closed_at":"2026-05-19T01:56:01Z","close_reason":"Implemented; merged into collab; 254 Rust tests + 130 relay tests pass; corpus replay green","dependencies":[{"issue_id":"attn-nnj.3.6","depends_on_id":"attn-nnj.3","type":"parent-child","created_at":"2026-05-18T16:28:21Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.3.6","depends_on_id":"attn-nnj.3.5","type":"blocks","created_at":"2026-05-18T16:29:47Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.3.6","depends_on_id":"attn-nnj.3.4","type":"blocks","created_at":"2026-05-18T16:29:47Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-nnj.3.5","title":"Anchor resolver — TS mirror impl for inline decorations","description":"Mirror the Rust resolver in TypeScript so the frontend can drive ProseMirror decorations as the user edits, without round-tripping through Rust on every keystroke. Operates purely on plaintext data — the frontend only ever holds decrypted ReviewEvents (Rust does decrypt + verify). Must produce identical ResolvedAnchor verdicts as the Rust resolver for every case in the test corpus.","acceptance_criteria":"- web/src/lib/review/resolver.ts exports resolve(anchor: Anchor, currentIndex: AnchorIndex, pmState: EditorState, localSteps?: Step[]) -\u003e ResolvedAnchor.\n- All eight resolution steps implemented matching the Rust algorithm; combine + dedup logic identical.\n- Same confidence weights and verdict cutoffs as the Rust impl (shared constants exported so both call sites agree).\n- For every case in the markdown-edit test corpus, the TS verdict matches the Rust verdict exactly (status, reason, currentRange).\n- Vitest unit tests cover the same happy paths and ambiguous-threshold edge cases as the Rust tests.","notes":"Spec: planning/collab/data-model.md §Anchor Resolution + planning/collab/amendments.md Decision #15. Drive inline ProseMirror decorations only — apply / verification stay in Rust. Files: web/src/lib/review/resolver.ts. PM step mapping uses the existing prosemirror-transform Step.map API; pmRange is derived locally and not persisted. Confidence weight constants should mirror the Rust constants (consider generating them from a shared JSON in test-vectors/ to prevent drift).","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:28:00Z","created_by":"James Lal","updated_at":"2026-05-19T01:55:48Z","started_at":"2026-05-19T01:36:52Z","closed_at":"2026-05-19T01:55:48Z","close_reason":"Implemented; merged into collab; 254 Rust tests + 130 relay tests pass; corpus replay green","dependencies":[{"issue_id":"attn-nnj.3.5","depends_on_id":"attn-nnj.3.1","type":"blocks","created_at":"2026-05-18T16:29:44Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.3.5","depends_on_id":"attn-nnj.3","type":"parent-child","created_at":"2026-05-18T16:27:59Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"comment_count":0} +{"_type":"issue","id":"attn-nnj.1.9","title":"Envelope assemble/disassemble end-to-end integration test","description":"Integration test that exercises the full crypto stack: build ReviewEvent → canonicalize → sign → AEAD-encrypt → wrap in MailboxEnvelope JSON → parse → decrypt → verify signature → recover original ReviewEvent. Locks the contract between issues 3-8.","acceptance_criteria":"- tests/review_crypto_envelope.rs (or src/review/crypto/tests.rs) contains the full roundtrip\n- Test uses planning/collab/test-vectors/envelope.json as both input and expected output\n- Asserts: re-serialized envelope is byte-identical to fixture; decrypted body matches original ReviewEvent; signature verifies; signingKeyId matches\n- Tamper tests: flipping any byte in ciphertext/AAD/signature → explicit error (not silent corruption)\n- Test runs in `cargo test` without network or filesystem deps","notes":"Spec: planning/collab/crypto-spec.md §What Is Signed vs. Encrypted + §Envelope Encryption. This is the canary that integrates HKDF + AEAD + Ed25519 + canonical-JSON + IDs. If any of those change subtly, this test breaks. Keep fixtures in test-vectors/envelope.json (shared with future browser impl).","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:27:52Z","created_by":"James Lal","updated_at":"2026-05-19T00:39:39Z","started_at":"2026-05-19T00:26:05Z","closed_at":"2026-05-19T00:39:39Z","close_reason":"Implemented in parallel; merged into collab; 166 tests pass + relay 35 tests pass","dependencies":[{"issue_id":"attn-nnj.1.9","depends_on_id":"attn-nnj.1.1","type":"blocks","created_at":"2026-05-18T16:28:10Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.1.9","depends_on_id":"attn-nnj.1","type":"parent-child","created_at":"2026-05-18T16:27:52Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.1.9","depends_on_id":"attn-nnj.1.6","type":"blocks","created_at":"2026-05-18T16:28:19Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.1.9","depends_on_id":"attn-nnj.1.2","type":"blocks","created_at":"2026-05-18T16:28:14Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.1.9","depends_on_id":"attn-nnj.1.5","type":"blocks","created_at":"2026-05-18T16:28:18Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.1.9","depends_on_id":"attn-nnj.1.8","type":"blocks","created_at":"2026-05-18T16:28:19Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":5,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-nnj.3.4","title":"Anchor resolver — run-all-and-combine policy (Rust)","description":"Canonical Rust anchor resolver per Decision #15. Given an Anchor (from a decrypted ReviewEvent), the current AnchorIndex, and an optional local pmSteps journal, run every applicable resolution step, dedup candidates by currentRange, and emit a ResolvedAnchor. This is the authoritative verdict used by the apply flow (Phase 5) and surfaced to the frontend via reviewAnchorResolution IPC.","acceptance_criteria":"- src/review/anchors/resolve.rs exposes resolve(anchor: \u0026Anchor, current_index: \u0026AnchorIndex, local_steps: Option\u003c\u0026PmStepJournal\u003e) -\u003e ResolvedAnchor matching data-model.md §Anchor Resolution.\n- All eight steps run: (1) base_hash match -\u003e exact 1.00; (2) mapped pm steps -\u003e exact 0.98; (3) unique exact quote -\u003e remapped 0.90; (4) block fingerprint -\u003e remapped 0.85; (5) structure + quote -\u003e remapped 0.80; (6) context (prefix/quote/suffix) -\u003e remapped 0.70; (7) bounded fuzzy quote -\u003e remapped 0.50-0.75; (8) line proximity -\u003e 0..=0.35.\n- Candidates from all steps are combined into a single set deduped by currentRange (highest confidence wins on dupes).\n- Verdict rules per Decision #15: exactly one candidate \u003e=0.70 -\u003e remapped/exact; two+ candidates \u003e=0.70 within 0.10 of each other -\u003e ambiguous with all candidates \u003e=0.50; otherwise top candidate \u003e=0.35 -\u003e remapped (low-confidence); else stale.\n- Pure function, no I/O, no crypto. Confidence weights live in a single constant so the calibration task can tune them.\n- Unit tests cover each step's happy path + the ambiguous threshold boundary at 0.09/0.10/0.11.","notes":"Spec: planning/collab/data-model.md §Anchor Resolution (lines 443-513) + planning/collab/amendments.md Decision #15 (line ~331) + §Anchor resolver disagreement policy (line ~110). Files: src/review/anchors/resolve.rs. The pmSteps journal type comes from Phase 0b LocalRevision work. Confidence numbers from data-model.md lines 491-506 ship as starting values; do not hard-code them inline — put them behind a ConfidenceWeights struct for the calibration task.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:27:45Z","created_by":"James Lal","updated_at":"2026-05-19T01:55:35Z","started_at":"2026-05-19T01:36:52Z","closed_at":"2026-05-19T01:55:35Z","close_reason":"Implemented; merged into collab; 254 Rust tests + 130 relay tests pass; corpus replay green","dependencies":[{"issue_id":"attn-nnj.3.4","depends_on_id":"attn-nnj.3.1","type":"blocks","created_at":"2026-05-18T16:29:44Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.3.4","depends_on_id":"attn-nnj.3","type":"parent-child","created_at":"2026-05-18T16:27:44Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.3.4","depends_on_id":"attn-nnj.2.2","type":"blocks","created_at":"2026-05-18T16:38:21Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":2,"comment_count":0} +{"_type":"issue","id":"attn-nnj.1.8","title":"ID helpers: RoomId, FileId, EventId, EnvelopeId, SnapshotId, ContentHash","description":"Typed newtypes + derivation functions for every ID/hash in the system. EventId is computed from canonical(body + meta-without-eventId) then written back into meta.eventId. EnvelopeId form differs by envelope kind. Use \"attn file v2\" prefix per amendments.md (NOT \"attn file\").","acceptance_criteria":"- src/review/ids.rs defines newtypes: RoomId, FileId, EventId, EnvelopeId, SnapshotId, ContentHash, ParticipantId, DeviceId (each wraps String with base64url-no-pad)\n- derive_event_id(body, meta_without_event_id) -\u003e EventId per crypto-spec.md §EventId\n- derive_envelope_id(kind, body) -\u003e EnvelopeId per §EnvelopeId (event kind uses eventId-based deterministic form; signal/snapshot_blob use clientNonce)\n- derive_file_id(...) uses prefix \"attn file v2\" per amendments.md §Codebase Corrections (NOT \"attn file\")\n- derive_snapshot_id, derive_content_hash match their spec sections\n- planning/collab/test-vectors/event-id.json + envelope.json populated with (inputs, expected_id) tuples\n- Roundtrip tests pass against both fixtures","notes":"Spec: planning/collab/crypto-spec.md §ID Construction (all subsections) + amendments.md §Codebase Corrections (file prefix correction). All IDs serialize as base64url no-pad strings via serde. ContentHash per §ContentHash: canonical UTF-8 markdown bytes, no BOM, LF line endings, preserve trailing-newline as authored.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:27:37Z","created_by":"James Lal","updated_at":"2026-05-19T00:23:30Z","started_at":"2026-05-19T00:04:12Z","closed_at":"2026-05-19T00:23:30Z","close_reason":"Implemented in parallel worktrees; merged into collab; 142 tests pass","dependencies":[{"issue_id":"attn-nnj.1.8","depends_on_id":"attn-nnj.1.3","type":"blocks","created_at":"2026-05-18T16:28:17Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.1.8","depends_on_id":"attn-nnj.1","type":"parent-child","created_at":"2026-05-18T16:27:36Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.1.8","depends_on_id":"attn-nnj.1.1","type":"blocks","created_at":"2026-05-18T16:28:09Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.1.8","depends_on_id":"attn-nnj.1.2","type":"blocks","created_at":"2026-05-18T16:28:14Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":3,"dependent_count":2,"comment_count":0} +{"_type":"issue","id":"attn-nnj.3.3","title":"Anchor construction from selection (frontend)","description":"Build the layered Anchor used by review events from a ProseMirror selection plus the AnchorIndex received in the most recent SnapshotCreated event. Produces all available layers — position from selection, quote from selected text, block lookup from the index, bounded context prefix/suffix, structure from headingPath — then hands the plaintext Anchor up to Rust via IPC for signing+encryption. The frontend never sees ciphertext.","acceptance_criteria":"- web/src/lib/review/anchors.ts exports buildAnchor(selection, anchorIndex, fileId, snapshotId, baseHash) -\u003e Anchor matching data-model.md §Anchors schema.\n- position: byteRange + lineRange + pmRange computed from the current ProseMirror selection.\n- quote: exact + exactHash + normalized + normalizedHash for non-empty selections; omitted for block-level comments.\n- block: looked up from the AnchorIndex by covering byteRange; carries snapshotBlockId, contentFingerprint, kind, offsetInBlockBytes, blockByteRange, blockLineRange.\n- context.prefix and context.suffix are bounded to at most 160 characters (data-model.md §Anchors bounded plaintext fields).\n- Unit tests with vitest cover: inline selection inside a paragraph, selection spanning two paragraphs, block-level (caret-only) anchor, selection at file start/end, selection inside a code block.","notes":"Spec: planning/collab/data-model.md §Anchors (lines 381-441). The AnchorIndex arrives via window.__attn__.reviewSnapshot(...) callbacks (mocked in web/src/lib/mock-ipc.ts during Phase 2). Files: web/src/lib/review/anchors.ts. Hashes use the same canonical sha256 helpers as Rust (web/src/lib/review/crypto.ts from Phase 0a). Send the constructed Anchor to Rust via window.__attn__.reviewSubmit or equivalent IPC — actual signing/encrypting happens in ReviewManager.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:27:33Z","created_by":"James Lal","updated_at":"2026-05-19T02:29:48Z","started_at":"2026-05-19T01:57:29Z","closed_at":"2026-05-19T02:29:48Z","close_reason":"Implemented (3.8+round-10 retries); merged into collab; 267 Rust + 144 relay tests pass","dependencies":[{"issue_id":"attn-nnj.3.3","depends_on_id":"attn-nnj.3.1","type":"blocks","created_at":"2026-05-18T16:29:43Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.3.3","depends_on_id":"attn-nnj.3","type":"parent-child","created_at":"2026-05-18T16:27:33Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj.1.7","title":"Hashcash PoW mint + verify with per-method/path token pool","description":"Implement hashcash proof-of-work tokens per crypto-spec.md §Hashcash. Mint runs off-thread via tokio::task::spawn_blocking (cancellable). Maintain a small per-(method,path) token pool so common writes don't block on cold mints.","acceptance_criteria":"- src/review/crypto/pow.rs exposes `mint(resource, difficulty) -\u003e Future\u003cToken\u003e` and `verify(token, resource, difficulty) -\u003e Result\u003c()\u003e`\n- Token format matches crypto-spec.md §Token Format byte-for-byte (version, resource, salt, counter, hash)\n- Mint executes inside tokio::task::spawn_blocking; cancellation drops the task cleanly\n- Per-(method, path) token pool with configurable max-size (e.g., 4 per slot); replenishes lazily\n- Default difficulty 16; room override accepted in [12, 24] inclusive (reject outside)\n- planning/collab/test-vectors/pow.json populated with (resource, difficulty, valid_token, invalid_tokens)\n- Roundtrip + invalid-difficulty + tampered-resource tests pass","notes":"Spec: planning/collab/crypto-spec.md §Hashcash Proof-of-Work (§Token Format, §Hash Function, §Difficulty, §Server Validation, §Replay Protection, §Client Implementation). Hash function is SHA-256 over canonical token bytes. Bits checked are leading zero bits of the digest. Pool is a HashMap\u003c(String, String), VecDeque\u003cToken\u003e\u003e.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:27:25Z","created_by":"James Lal","updated_at":"2026-05-19T00:23:15Z","started_at":"2026-05-19T00:04:11Z","closed_at":"2026-05-19T00:23:15Z","close_reason":"Implemented in parallel worktrees; merged into collab; 142 tests pass","dependencies":[{"issue_id":"attn-nnj.1.7","depends_on_id":"attn-nnj.1","type":"parent-child","created_at":"2026-05-18T16:27:24Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.1.7","depends_on_id":"attn-nnj.1.2","type":"blocks","created_at":"2026-05-18T16:28:13Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.1.7","depends_on_id":"attn-nnj.1.3","type":"blocks","created_at":"2026-05-18T16:28:16Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.1.7","depends_on_id":"attn-nnj.1.1","type":"blocks","created_at":"2026-05-18T16:28:09Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":3,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-nnj.1.6","title":"Ed25519 sign/verify wrapper","description":"Wrap ed25519-dalek v2 for signing the canonical bytes of (eventMeta || eventBody). Includes signing-key-ID verification: signingKeyId must equal SHA-256(publicSigningKey).","acceptance_criteria":"- src/review/crypto/signature.rs exposes `sign(signing_key, meta, body) -\u003e Signature` and `verify(public_key, meta, body, signature) -\u003e Result\u003c()\u003e`\n- Signed bytes = canonicalize(meta) || canonicalize(body) per crypto-spec.md §Signatures §Canonical Bytes for Signature\n- verify also checks signingKeyId field == base64url-no-pad(SHA-256(public_key_bytes)) and rejects mismatch\n- planning/collab/test-vectors/event-signature.json populated with deterministic (private_key, meta, body, signature, signingKeyId) tuples\n- Roundtrip + bad-signature + wrong-key-id tests pass against fixture","notes":"Spec: planning/collab/crypto-spec.md §Signatures. Use ed25519_dalek::SigningKey and VerifyingKey. SigningKey impls Zeroize. Canonicalization is via the JCS helper from issue 3. SignatureId formatting uses base64url no-pad.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:27:12Z","created_by":"James Lal","updated_at":"2026-05-19T00:23:00Z","started_at":"2026-05-19T00:04:11Z","closed_at":"2026-05-19T00:23:00Z","close_reason":"Implemented in parallel worktrees; merged into collab; 142 tests pass","dependencies":[{"issue_id":"attn-nnj.1.6","depends_on_id":"attn-nnj.1.2","type":"blocks","created_at":"2026-05-18T16:28:12Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.1.6","depends_on_id":"attn-nnj.1.3","type":"blocks","created_at":"2026-05-18T16:28:16Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.1.6","depends_on_id":"attn-nnj.1.1","type":"blocks","created_at":"2026-05-18T16:28:08Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.1.6","depends_on_id":"attn-nnj.1","type":"parent-child","created_at":"2026-05-18T16:27:12Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":3,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-nnj.3.1","title":"AnchorIndex builder (Rust, comrak-based)","description":"Build the canonical AnchorIndex in Rust from a snapshot's UTF-8 markdown bytes. This is the authoritative anchor index per amendments.md Phase 1 Decision — the frontend never hashes; it only receives the pre-computed AnchorIndex inside SnapshotCreated events and uses it for resolution. Walks the comrak AST and emits AnchorBlock entries that the resolver and the inline-decoration pipeline depend on.","acceptance_criteria":"- src/review/anchors/index.rs exposes a pure function (markdown bytes, snapshot_id) -\u003e AnchorIndex matching data-model.md §Anchor Index schema (docHash, canonicalEncoding='utf8-bytes', lineCount, blocks[], headings[]).\n- Each AnchorBlock has: kind (heading|paragraph|list_item|code_block|blockquote|table|thematic_break|html|math|mermaid|unknown), byteRange, lineRange, headingPath, ordinalInParent, duplicateOrdinal, textHash, normalizedTextHash, previousBlockHash, nextBlockHash, contentFingerprint, snapshotBlockId.\n- contentFingerprint = sha256(kind || normalizedText || headingPath || duplicateOrdinal); snapshotBlockId = sha256(snapshotId || byteRange || contentFingerprint).\n- Duplicate paragraphs/list-items get distinct duplicateOrdinal values; identical content in different headingPaths yields distinct contentFingerprints.\n- Unit tests cover empty docs, single-block docs, nested-heading docs, duplicate paragraphs, and a fixture from tests/fixtures/.","notes":"Spec: planning/collab/data-model.md §Anchor Index (lines 314-379) + planning/collab/amendments.md Phase 1 Decision (line ~242). Use comrak's AST (existing dep in src/markdown.rs). Sibling crate sha2 (already in Cargo.toml per amendments.md Phase 0a). Files: src/review/anchors/index.rs, src/review/anchors/mod.rs. The 'pmRange' field on AnchorBlock is optional and not populated here (frontend-only derivation).","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:27:08Z","created_by":"James Lal","updated_at":"2026-05-19T01:16:00Z","started_at":"2026-05-19T00:47:50Z","closed_at":"2026-05-19T01:16:00Z","close_reason":"Implemented in parallel worktrees; merged into collab","dependencies":[{"issue_id":"attn-nnj.3.1","depends_on_id":"attn-nnj.3","type":"parent-child","created_at":"2026-05-18T16:27:07Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.3.1","depends_on_id":"attn-nnj.1.8","type":"blocks","created_at":"2026-05-18T16:38:20Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":5,"comment_count":0} +{"_type":"issue","id":"attn-nnj.1.5","title":"AEAD wrapper (XChaCha20-Poly1305 with AAD)","description":"Wrap XChaCha20-Poly1305 with the envelope-binding AAD format from crypto-spec.md. The AAD ties ciphertext to envelope metadata so a misrouted envelope decrypts to a tag failure rather than silent corruption.","acceptance_criteria":"- src/review/crypto/aead.rs exposes `seal(key, plaintext, aad) -\u003e (nonce, ciphertext)` and `open(key, nonce, ciphertext, aad) -\u003e Result\u003cVec\u003cu8\u003e\u003e`\n- Nonce is 24 bytes from `getrandom` (random, not counter)\n- AAD is canonical JSON of {v, roomId, envelopeId, kind, authorId, deviceId, createdAt} per crypto-spec.md §Envelope Encryption\n- Tampered AAD or ciphertext → Open returns explicit AeadError\n- planning/collab/test-vectors/aead.json populated with (key, nonce, aad, plaintext, ciphertext) tuples\n- Roundtrip + tamper tests pass against fixture","notes":"Spec: planning/collab/crypto-spec.md §Envelope Encryption (AEAD) §Nonce Discipline. Use chacha20poly1305::XChaCha20Poly1305. Key/nonce types should be wrappers that Zeroize. The AAD canonicalization MUST use the canonical JSON helper from issue 3.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:27:02Z","created_by":"James Lal","updated_at":"2026-05-19T00:22:45Z","started_at":"2026-05-19T00:04:10Z","closed_at":"2026-05-19T00:22:45Z","close_reason":"Implemented in parallel worktrees; merged into collab; 142 tests pass","dependencies":[{"issue_id":"attn-nnj.1.5","depends_on_id":"attn-nnj.1.2","type":"blocks","created_at":"2026-05-18T16:28:12Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.1.5","depends_on_id":"attn-nnj.1.1","type":"blocks","created_at":"2026-05-18T16:28:08Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.1.5","depends_on_id":"attn-nnj.1.3","type":"blocks","created_at":"2026-05-18T16:28:15Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.1.5","depends_on_id":"attn-nnj.1","type":"parent-child","created_at":"2026-05-18T16:27:01Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.1.5","depends_on_id":"attn-nnj.1.4","type":"blocks","created_at":"2026-05-18T16:28:18Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":4,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-nnj.1.4","title":"HKDF wrapper + room key derivation","description":"Wrap HKDF-SHA-256 and derive the five per-room subkeys from a 32-byte room secret. Info strings must match crypto-spec.md byte-for-byte so a future browser impl produces identical keys.","acceptance_criteria":"- src/review/crypto/kdf.rs exposes `derive_room_keys(room_secret: \u0026[u8; 32]) -\u003e RoomKeys`\n- RoomKeys struct fields: root_key, event_key, snapshot_key, signaling_key, admission_key (each 32 bytes, zeroize on drop)\n- Info strings match spec exactly: \"attn room root v2\", \"attn room event v2\", \"attn room snapshot v2\", \"attn room signaling v2\", \"attn room admission v2\"\n- planning/collab/test-vectors/kdf.json populated with deterministic vectors (fixed room_secret → exact derived keys hex)\n- Roundtrip test verifies all 5 keys match fixture","notes":"Spec: planning/collab/crypto-spec.md §Key Derivation. Use hkdf crate with Sha256. Salt is empty (or zero-filled per HKDF-Extract spec). RoomKeys impls Zeroize + Drop.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:26:37Z","created_by":"James Lal","updated_at":"2026-05-19T00:22:31Z","started_at":"2026-05-19T00:04:10Z","closed_at":"2026-05-19T00:22:31Z","close_reason":"Implemented in parallel worktrees; merged into collab; 142 tests pass","dependencies":[{"issue_id":"attn-nnj.1.4","depends_on_id":"attn-nnj.1","type":"parent-child","created_at":"2026-05-18T16:26:37Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.1.4","depends_on_id":"attn-nnj.1.2","type":"blocks","created_at":"2026-05-18T16:28:11Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.1.4","depends_on_id":"attn-nnj.1.1","type":"blocks","created_at":"2026-05-18T16:28:07Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.1.4","depends_on_id":"attn-nnj.1.3","type":"blocks","created_at":"2026-05-18T16:28:15Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":3,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-nnj.1.3","title":"Canonical JSON helper (RFC 8785 JCS)","description":"Implement RFC 8785 JSON Canonicalization Scheme in src/review/crypto/canonical.rs. Used by every signature and AEAD AAD computation, so determinism is non-negotiable. Generates test vectors into canonical-json.jsonl.","acceptance_criteria":"- src/review/crypto/canonical.rs exposes `canonicalize(value: \u0026serde_json::Value) -\u003e Vec\u003cu8\u003e`\n- Object keys sorted ASCII-ascending; no whitespace; UTF-8 no BOM\n- Integers only in signed payloads — floats reject with explicit error\n- Absent fields are omitted (never serialized as `\"key\": null`)\n- planning/collab/test-vectors/canonical-json.jsonl populated with edge cases (unicode keys, nested objects, integer boundaries, escape sequences)\n- Roundtrip test: every vector parses, re-canonicalizes byte-identical","notes":"Spec: planning/collab/crypto-spec.md §Canonical JSON (RFC 8785 JCS). Don't use serde_json's default serializer — it doesn't sort keys. Either use a BTreeMap intermediate or implement a custom Serializer. Reject NaN/Infinity. UTF-16 surrogate handling per JCS.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:26:27Z","created_by":"James Lal","updated_at":"2026-05-19T00:03:53Z","started_at":"2026-05-18T23:49:35Z","closed_at":"2026-05-19T00:03:53Z","close_reason":"Implemented; merged into collab; 23 canonical tests + corpus filled","dependencies":[{"issue_id":"attn-nnj.1.3","depends_on_id":"attn-nnj.1.2","type":"blocks","created_at":"2026-05-18T16:28:10Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.1.3","depends_on_id":"attn-nnj.1.1","type":"blocks","created_at":"2026-05-18T16:28:06Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.1.3","depends_on_id":"attn-nnj.1","type":"parent-child","created_at":"2026-05-18T16:26:27Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":5,"comment_count":0} +{"_type":"issue","id":"attn-nnj.1.2","title":"Test-vector corpus directory + schema headers","description":"Create planning/collab/test-vectors/ as the canonical contract that all crypto implementations (Rust now, browser/WASM later) must satisfy. Each file gets a documented schema header so future revalidators know what to expect.","acceptance_criteria":"- planning/collab/test-vectors/ exists with: kdf.json, canonical-json.jsonl, event-signature.json, event-id.json, aead.json, envelope.json, pow.json\n- Each file has a top-level schema comment / metadata block describing field semantics + spec section reference\n- README.md in test-vectors/ explains how to regenerate and how to validate\n- Files are placeholders (empty arrays / empty .jsonl) — actual vectors are filled in by issues 3-9","notes":"Spec: planning/collab/crypto-spec.md §Test Vectors §Test Vectors (to ship in the repo). JSONL means newline-delimited JSON. The Rust impl writes these on `cargo test --features generate-vectors` (or similar) and reads them on every test run.","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:26:16Z","created_by":"James Lal","updated_at":"2026-05-18T23:44:55Z","closed_at":"2026-05-18T23:44:55Z","close_reason":"Implemented in parallel worktrees; merged into collab","dependencies":[{"issue_id":"attn-nnj.1.2","depends_on_id":"attn-nnj.1","type":"parent-child","created_at":"2026-05-18T16:26:16Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":8,"comment_count":0} +{"_type":"issue","id":"attn-nnj.1.1","title":"Cargo deps + binary-size baseline for crypto crate","description":"Add Rust crypto dependencies to Cargo.toml and record binary-size baseline. This establishes the dependency footprint before any crypto code lands so we can compare against the Phase 4 webrtc-rs cost.","acceptance_criteria":"- Cargo.toml adds: sha2, hkdf, chacha20poly1305, ed25519-dalek v2, base64 (URL_SAFE_NO_PAD), getrandom, zeroize\n- `cargo tree -e features --no-default-features --no-dev-dependencies` output captured in notes\n- Release binary size (cargo build --release) recorded as baseline for Phase 4 comparison\n- `task dev` and `cargo check` both pass with new deps\n- No code uses the deps yet — just declared","notes":"Spec: planning/collab/crypto-spec.md §Primitives §Rust crates. Pin versions exactly. Use `base64::engine::general_purpose::URL_SAFE_NO_PAD` (no padding). zeroize is for SecretKey/Drop impls.","status":"closed","priority":1,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:26:06Z","created_by":"James Lal","updated_at":"2026-05-18T23:44:40Z","closed_at":"2026-05-18T23:44:40Z","close_reason":"Implemented in parallel worktrees; merged into collab","dependencies":[{"issue_id":"attn-nnj.1.1","depends_on_id":"attn-nnj.1","type":"parent-child","created_at":"2026-05-18T16:26:06Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":7,"comment_count":0} +{"_type":"issue","id":"attn-nnj.10","title":"UI/UX: Review surfaces design + iteration","description":"Cross-cutting UI/UX workstream. Discovery + interaction design for share button, room mode selector, connection badge, peer strip, review panel layout, comment/suggestion composer, inline highlight system, ambiguous anchor picker, stale comment panel, snapshot badge/age/superseded, three-way apply UI, outbox indicator, reviewer banner. Drives Phase 2 and feeds into Phase 5.","notes":"User direction: UI/UX is important. Treated as a peer workstream rather than tail-end polish.","status":"closed","priority":1,"issue_type":"epic","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:09:18Z","created_by":"James Lal","updated_at":"2026-05-19T18:01:15Z","closed_at":"2026-05-19T18:01:15Z","close_reason":"Phase complete — all implementation issues closed","dependencies":[{"issue_id":"attn-nnj.10","depends_on_id":"attn-nnj","type":"parent-child","created_at":"2026-05-18T16:09:18Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj.5","title":"Phase 3a: Relay worker (Cloudflare DO+R2)","description":"Implement relay/ from relay-spec.md against Miniflare. WS-only delivery (decision #5), HMAC admission, hashcash PoW on all writes, room TTL alarms (24h hard-max + 1h idle), R2 spillover for large snapshots. Conformance corpus shared with the Rust client tests.","notes":"Spec: planning/collab/relay-spec.md. Wrangler/Miniflare; relay/ package does not yet exist.","status":"closed","priority":1,"issue_type":"epic","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:09:15Z","created_by":"James Lal","updated_at":"2026-05-19T17:59:57Z","closed_at":"2026-05-19T17:59:57Z","close_reason":"Phase complete — all implementation issues closed","dependencies":[{"issue_id":"attn-nnj.5","depends_on_id":"attn-nnj","type":"parent-child","created_at":"2026-05-18T16:09:14Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj.4","title":"Phase 2: Review UI with mocked transport","description":"Svelte review panel with comment/suggestion decorations in ProseMirror, anchor-aware highlighting, ambiguous picker, stale state. Mock-IPC extended with replayable event stream so UI work isn't blocked on Rust/network. Demonstrate a comment surviving owner edits using only the local anchor engine.","notes":"Spec: data-model.md §UI/UX Changes. UI/UX is a first-class workstream here per user direction.","status":"closed","priority":1,"issue_type":"epic","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:09:14Z","created_by":"James Lal","updated_at":"2026-05-19T17:59:42Z","closed_at":"2026-05-19T17:59:42Z","close_reason":"Phase complete — all implementation issues closed","dependencies":[{"issue_id":"attn-nnj.4","depends_on_id":"attn-nnj","type":"parent-child","created_at":"2026-05-18T16:09:13Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj.3","title":"Phase 1: Anchor engine","description":"Build AnchorIndex from markdown bytes in Rust (canonical), construct Anchors from selections, implement the run-all-and-combine resolution policy with confidence thresholds, ship hand-curated test corpus.","notes":"Spec: data-model.md §Anchor Index/Anchors/Anchor Resolution + amendments.md §Anchor resolver disagreement policy. AnchorIndex computed in Rust per amendments.md (canonical path); browser gets it pre-computed in SnapshotCreated events.","status":"closed","priority":1,"issue_type":"epic","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:09:13Z","created_by":"James Lal","updated_at":"2026-05-19T17:01:52Z","closed_at":"2026-05-19T17:01:52Z","close_reason":"All children closed","dependencies":[{"issue_id":"attn-nnj.3","depends_on_id":"attn-nnj","type":"parent-child","created_at":"2026-05-18T16:09:13Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj.2","title":"Phase 0b: Local data model + working copy","description":"Rust-only foundation. Typed IDs, serde model types, JSON/JSONL local store at ~/.attn/reviews/, WorkingCopyService replacing direct fs::write, revision journal, watcher self-write distinction, empty ReviewManager scaffold, AppState refactor for tab+room routing.","notes":"Spec: planning/collab/data-model.md §Local Replicas + §Rust Architecture Changes. AppState shape per amendments.md (RoomRuntimeHandle + file_to_room mapping).","status":"closed","priority":1,"issue_type":"epic","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:09:12Z","created_by":"James Lal","updated_at":"2026-05-19T17:01:37Z","closed_at":"2026-05-19T17:01:37Z","close_reason":"All children closed","dependencies":[{"issue_id":"attn-nnj.2","depends_on_id":"attn-nnj","type":"parent-child","created_at":"2026-05-18T16:09:12Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj.1","title":"Phase 0a: Crypto foundations","description":"Rust-only crypto crate (attn-collab-crypto): cipher suite primitives, key derivation, hashcash mint+verify, ID helpers. Frontend never holds ciphertext in v2 — IPC delivers plaintext ReviewEvents per data-model.md §Webview IPC Changes, so no TS crypto is needed for native. Test-vector corpus ships alongside Rust impl for forward compat (browser/Phase 6 will revisit WASM-vs-TS).","notes":"Spec: planning/collab/crypto-spec.md. Decision #4 locks the suite: XChaCha20-Poly1305 + Ed25519 + HKDF-SHA-256 + RFC 8785 JCS + base64url-no-pad. No agility in v2.","status":"closed","priority":1,"issue_type":"epic","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:09:11Z","created_by":"James Lal","updated_at":"2026-05-19T17:01:21Z","closed_at":"2026-05-19T17:01:21Z","close_reason":"All children closed","dependencies":[{"issue_id":"attn-nnj.1","depends_on_id":"attn-nnj","type":"parent-child","created_at":"2026-05-18T16:09:11Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj","title":"attn collab v2","description":"End-to-end encrypted review collaboration over local markdown. Owner shares working copy via snapshot graph + encrypted event log; reviewers/agents add comments and suggestions anchored to snapshots; owner accepts suggestions locally. Transport via WebRTC DataChannel (Rust webrtc-rs) and a bounded encrypted mailbox on Cloudflare Workers/DO/R2.","notes":"Specs: planning/collab/{data-model,crypto-spec,relay-spec,amendments}.md. amendments.md overrides the others where they conflict. 16 design decisions locked.","status":"closed","priority":1,"issue_type":"epic","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:09:10Z","created_by":"James Lal","updated_at":"2026-05-19T18:01:47Z","closed_at":"2026-05-19T18:01:47Z","close_reason":"attn collab v2: all implementation closed across phases 0a/0b/0c/1/2/3a/3b/4/5/6, UI/UX, and cross-cutting. 432 Rust tests + 286 relay tests + 22.4 KB gz browser bundle.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-n01r.47","title":"Landing has no breakpoint between 680 and 1180px; type and tracks go ragged across the band","description":"Split out of attn-n01r.24 when the badge-collision half was fixed. What remains is composition work\nthat wants a design decision, not a mechanical change — which is why it did not ship with the fix.\n\n`landing.css` still contains exactly two media queries: `max-width: 1180px` and `max-width: 680px`.\nBetween 681 and 1180 every multi-column grid keeps its desktop track count while the container loses\nup to 500px.\n\nMeasured track widths across the band:\n\n .entry-strip (3 cols) 768: 278/223/223 1024: 377/302/302 1180: 437/350/350\n .steps (3 cols) 768: 241 x3 1024: 326 x3 1180: 378 x3\n .surfaces (2 cols) 768: 361/361 1024: 489 x2 1180: 567 x2\n .share-proof 768: 395/280 1024: 582/349 1180: 680/408\n\nThree consequences:\n\n1. **`.surface h3` is a flat 41.6px across the entire range** — no `clamp()`, unlike the h1 and the\n chapter h2 which both clamp. So \"Your private desk\" runs 41.6px type inside a 290px content box at\n 768px: it breaks to two lines, and the mono list item \"→ Existing files stay where they are\"\n orphans \"are\" onto its own line.\n\n2. **The entry cards show the same pressure** — \"Import workspace\" wraps to two lines while its\n neighbours do not, leaving ragged body-copy baselines across the strip.\n\n3. **`.hero-lede` is capped at `max-width: 610px`** and never grows, so at 1024 the hero column is\n 922px and the lede stops 312px short of the h1's edge — a visibly ragged right margin with a large\n empty right half.\n\nRecommended: add a ~900px breakpoint dropping `.entry-strip` and `.steps` to 2 columns and\n`.surfaces` to 1; add `clamp()` to `.surface h3` and `.entry-title`; let `.hero-lede` grow with its\ncolumn or cap it in `ch` against the measure rather than a fixed px.\n\nNote the 200%-text work (attn-n01r.23) already converted these grids to `minmax(0, …)` and made the\nnav wrap, so the band no longer *overflows* — it is now a proportion and rhythm problem rather than a\nbreakage one.\n\nSuggested command: /impeccable adapt\n","status":"open","priority":2,"issue_type":"task","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-05T02:45:07Z","created_by":"Angus Bezzina","updated_at":"2026-08-05T02:45:07Z","labels":["design","hosted","landing","web"],"dependencies":[{"issue_id":"attn-n01r.47","depends_on_id":"attn-n01r","type":"parent-child","created_at":"2026-08-04T21:45:07Z","created_by":"Angus Bezzina","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-n01r.46","title":"Blocked/quota states hide the primary actions from keyboard users with no reason exposed","description":"When storage is blocked or under quota pressure, the desk disables its primary actions and tells a keyboard or screen-reader user nothing about why.\n\n DeskHome.svelte:135, 146 — measured: `disabled` true, `aria-disabled` null, `title` null.\n\nBecause the controls use the `disabled` attribute rather than `aria-disabled`, they are removed from the tab order entirely. A keyboard user never encounters \"New workspace\" or \"Import workspace\" at all — the primary actions simply are not there, with no explanation of their absence.\n\nThe banner above them does not fill the gap: `DegradedBanner.svelte:42` uses `role=\"status\"`, a *polite* live region that is rendered at page load rather than announced as a change. A polite region present at first paint announces nothing.\n\nFix:\n- Use `aria-disabled=\"true\"` plus a click guard instead of `disabled`, so the controls stay focusable and discoverable.\n- Add `aria-describedby` pointing at the reason.\n- Promote the banner to `role=\"alert\"` for the quota-pressure and blocked modes specifically, so the state change is announced.\n\nTwo related gaps from the same pass:\n- **No status announcement for rename or delete.** There is no `aria-live` region anywhere on the desk; deleting a workspace removes a row silently. WCAG 4.1.3 (AA).\n- **Cancel and \"Delete workspace\" are visually near-identical** in the confirm dialog — both `.button`, same 46px height, same border, same fill, differing only by one step of the same hue in text colour. On a destructive, irreversible confirmation, the two options should not be near-twins.\n\nWorth preserving, and unusual: the degraded-state vocabulary itself (`DegradedBanner.svelte`, `AppHeader.svelte:14-27`, `StoragePage.svelte:65-103`) is genuinely thorough — five distinct persistence modes with specific, honest, non-alarmist copy, all reachable via `?shell=` for testing. `DegradedBanner.svelte:50-53` even documents a real prior fix where dead `\u003cbutton\u003e`s became working links on the \"back up before you lose data\" prompt. The states are well modelled; only their announcement is missing.\n\nSuggested command: /impeccable harden\n","status":"closed","priority":2,"issue_type":"task","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T23:45:51Z","created_by":"Angus Bezzina","updated_at":"2026-08-05T03:07:45Z","started_at":"2026-08-05T03:05:53Z","closed_at":"2026-08-05T03:07:45Z","close_reason":"Fixed the blocking half; two smaller items are called out below.\n\nTHE PRIMARY ACTIONS STAY REACHABLE — New workspace and Import workspace used the \nattribute, which removes an element from the tab order entirely, so a keyboard user in a blocked\nstorage profile never encountered the desk's two primary actions and was never told why they were\nmissing. They now use aria-disabled with a click guard, so they keep their place in the tab order and\nare announced as unavailable.\n\nTHE REASON IS NOW STATED — added a role='alert' explanation the buttons point at via\naria-describedby: what is wrong, and what to check. The DegradedBanner above uses role='status', a\npolite region rendered at load, which announces nothing on arrival — so nothing was telling the user\nwhy the desk had stopped working.\n\nStyled with opacity and not-allowed rather than left visually identical, so the state reads without\nthe control vanishing.\n\nNOT FIXED:\n- No aria-live announcement for rename/delete completion. Both mutate the list silently\n (WCAG 4.1.3). Wants a single shared live region on the desk rather than one per action, which is\n worth doing alongside the row rework in .32.\n- Cancel and 'Delete workspace' in the confirm are still near-identical — same .button, same height,\n same border, differing only in text colour by one step. On an irreversible confirmation the two\n options should not be twins; that is a visual-weight decision belonging with .32.\n\nsvelte-check 0 errors; 97/97 unit test files pass.","labels":["a11y","design","desk","hosted","web"],"dependencies":[{"issue_id":"attn-n01r.46","depends_on_id":"attn-n01r","type":"parent-child","created_at":"2026-08-04T18:45:51Z","created_by":"Angus Bezzina","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-n01r.40","title":"Join flow: panel state is not URL-durable, open/close paths disagree, error not tied to the field","description":"Three defects in the join-a-review flow, found while exercising the panel's real states.\n\n**The panel's open state is not URL-durable, and the open and close paths disagree.**\n`/app#join` correctly opens the panel on a real page load (verified). But clicking \"Join a review\" calls `preventDefault()` and sets `joinOpen` **without pushing the hash** (DeskHome.svelte:32-36), so the URL stays `/app/` and a reload loses the panel. Meanwhile `closeJoin()` runs `history.replaceState(null, '', '/app')` (DeskHome.svelte:47) on the assumption that the hash *is* present. One path assumes the hash exists, the other never creates it.\n\nFix: make opening push `#join` and closing clear it, so the two paths agree and the state survives a reload. A user pasting an invite link into a page they then refresh currently loses the panel.\n\n**The invalid-invite error is announced but not associated with the field.**\nSubmitting a bad invite swaps `.join-hint` for `.join-error`, which does carry `role=\"alert\"` (DeskHome.svelte:180) — that part is right, and the copy is good: \"That doesn't look like an attn invite — paste the full link, including everything after #.\" Panel height is identical before and after (126.39px both), so there is no layout shift either.\n\nBut measured on the input afterwards: `aria-invalid` = null, `aria-describedby` = null. The field is not marked invalid and is not associated with its own error message, so a screen-reader user who tabs back to the input hears no indication that it is in an error state or why.\n\nFix: `aria-invalid={!!joinError}` and `aria-describedby` pointing at the error element's id.\n\n**Escape does not dismiss the panel** (measured `escapeClosesPanel = false`). The only dismissal is the Cancel button, so a user who opened the panel by keyboard cannot close it by keyboard without tabbing to Cancel. This is also tracked in the desk a11y issue as a DESIGN.md Topmost-Escape violation — noted here because the fix belongs in the same file and the same session.\n\nOn mobile the panel has one more problem: `.join-row { display:flex; flex-wrap: wrap }` (app-shell.css:2741-2745) with `min-width: 240px` on the input (app-shell.css:2747-2756) wraps Cancel onto its own line, left-aligned under the input, while Join stays on the row above — the two buttons end up on different lines and misaligned.\n\nPreserve: the panel correctly focuses its input on open (DeskHome.svelte:40-42), the tab order is clean and matches visual order, and every control draws a visible 2px focus ring (1px offset on the join input, app-shell.css:2758-2761).\n\nSuggested command: /impeccable harden\n","status":"closed","priority":2,"issue_type":"task","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T23:41:08Z","created_by":"Angus Bezzina","updated_at":"2026-08-05T03:07:44Z","started_at":"2026-08-05T03:05:53Z","closed_at":"2026-08-05T03:07:44Z","close_reason":"Fixed all three; verified in the browser.\n\nURL DURABILITY — openJoin() now pushes /app#join. Previously clicking the tile only set local state\nwhile closeJoin() unconditionally ran history.replaceState as though the hash were present, so the\ntwo paths disagreed: /app#join opened the panel on a real load, but a click-then-reload lost it.\nVerified: after clicking the tile the URL hash is '#join', and after a full reload the panel is\nstill present.\n\nERROR ASSOCIATED WITH ITS FIELD — the input now carries aria-invalid and aria-describedby pointing at\nthe error element when one exists, and at the hint otherwise. The message already had role='alert'\n(that part was right) but the field itself was not marked invalid or tied to it, so a screen-reader\nuser tabbing back heard nothing about the state or the reason.\nVerified after a bad invite: aria-invalid='true', aria-describedby='join-error', error text present.\n\nESCAPE — closed in .30, along with focus restoration to the tile that opened the panel.\n\nNOT FIXED: .join-row still wraps Cancel onto its own line at 390 while Join stays on the row above.\nThat is the mobile layout work, and it belongs with the desk's mobile pass in .33.\n\nsvelte-check 0 errors; 97/97 unit test files pass.","labels":["design","desk","hosted","web"],"dependencies":[{"issue_id":"attn-n01r.40","depends_on_id":"attn-n01r","type":"parent-child","created_at":"2026-08-04T18:41:08Z","created_by":"Angus Bezzina","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-n01r.38","title":"/homepage-alt is publicly reachable and unindexed-but-crawlable, with a different product positioning","description":"`https://attn.sh/homepage-alt` serves a live second homepage carrying a **different product positioning**, reachable by anyone who guesses or crawls the URL, with no `noindex`.\n\nTraced through the full production path:\n web/worker.ts:24 -\u003e hostedEntryForPath('/homepage-alt')\n -\u003e web/src/lib/hosted/routes.ts:22-26 returns 'landing' (unknown paths fall through)\n -\u003e serves index.html\n -\u003e web/hosted/landing.ts:20 matches /^\\/homepage-alt\\/?$/u and lazy-imports AlternateLanding\n\nShipped assets: `AlternateLanding-Dg2FjcF7.js` (9,204 B) + `AlternateLanding-DqvzFE5D.css` (21,477 B).\n\nPer the tracking issue (attn-8wo) the alternate \"repositions attn around private Markdown review with people and AI agents\" — which is, notably, the positioning the *shipped* homepage is missing (tracked separately in this epic). So the public site currently offers two different answers to \"what is attn\", and the more accurate one is the accidental one.\n\nThe lazy-chunk isolation is done correctly and deliberately — ordinary landing visits pay zero bytes for it, and the code comment says exactly that. But \"not in the landing chunk\" is not the same as \"not on the internet\".\n\nFix: make it a deliberate decision rather than an accident of the `routes.ts` fallback rule. Either gate it behind a build flag or a query parameter rather than a public pathname, or emit `\u003cmeta name=\"robots\" content=\"noindex\"\u003e` for that route.\n\nNote it also carries the `.alt-brand` mark, which was updated alongside the main brand mark in the logo work — so it is being maintained as if it were a shipping surface. Decide whether it is one.\n\nSuggested command: /impeccable harden\n","status":"open","priority":2,"issue_type":"task","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T23:37:39Z","created_by":"Angus Bezzina","updated_at":"2026-08-04T23:37:39Z","labels":["design","hosted","web"],"dependencies":[{"issue_id":"attn-n01r.38","depends_on_id":"attn-n01r","type":"parent-child","created_at":"2026-08-04T18:37:39Z","created_by":"Angus Bezzina","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-n01r.37","title":"Four hosted-local tokens have no tokens.css counterpart; --native-panel-label never flips in INK","description":"The hosted surfaces define four tokens that have no counterpart in `tokens.css`, so colour decisions on the landing cannot be reasoned about from the canonical set. One of them visibly fails the INK flip.\n\n chrome.css:19 --rule-strong: var(--foreground)\n chrome.css:25 --green: oklch(0.40 0.12 150) (.dark at :46-47)\n chrome.css:26 --danger-ink: oklch(0.45 0.18 27)\n chrome.css:42 --native-panel-label: oklch(0.72 0.08 32) (.dark at :55)\n\n**[P1] `--native-panel-label` is a warm salmon in BOTH themes.** oklch(0.72 0.08 32) in PAPER, oklch(0.75 0.08 32) in INK — measured in INK as rgb(220,156,142) on rgb(2,3,6). The INK accent is steel blue oklch(0.72 0.10 220), so this is the **only warm hue anywhere in the dark theme** and reads as an orphan. It also sits directly beside the green \"BROWSER · NO INSTALL\" label, so the Surfaces section presents two decorative hues, neither of which is the theme accent. Delete it; set `.surface.native .surface-label` to `var(--native-panel-muted)` and let the panel inversion carry the browser/native distinction.\n\n**[P2] `--rule-strong` aliases straight to `--foreground`.** DESIGN.md specifies hairlines as \"ink at low alpha, never a solid gray line\" (`--border` is 18% ink), but this renders `.step`'s top rule (landing.css:323) at 100% ink in PAPER and near-white oklch(0.87) in INK — the loudest line in the dark theme, against oklch(0.16) hairlines everywhere else. Change to `color-mix(in oklch, var(--foreground) 45%, transparent)`.\n\n**[P2] `--green` and `--danger-ink` are legitimate but homeless.** Both are AA-retuned variants of canonical tokens (DESIGN.md's Ledger Green is oklch(0.58 0.15 150); `--destructive` is oklch(0.55 0.20 27)), and the comment at chrome.css:23-24 documents the measured 3.0-3.3:1 failure that prompted them. Someone measured, then fixed — that work should be promoted into `tokens.css` as `--green-text` / `--destructive-text`, because both builds want the accessible variant.\n\n**[P2] The native-panel inversion collapses in INK.** `--native-panel` is `var(--foreground)` in PAPER (chrome.css:39) but oklch(0.10 0.010 256) in INK (:52). In PAPER this is the page's signature moment — a near-black panel dropped onto warm paper, a genuine inversion. In INK it becomes oklch(0.10) against an oklch(0.148) body: a barely-darker rectangle. Not broken, measurably weaker. Consider inverting the other way in INK — make `.surface.native` the *raised* surface (`--surface-raised-2`, oklch(0.245)) so it still reads as \"the other one\". Design call, not a bug.\n\nContext that makes this worth doing properly rather than patching: the landing has **zero hard-coded colours**. Not one hex, rgb(), hsl() or raw oklch() appears in `landing.css` or in any of the ten landing `.svelte` components — the only literals in scope are the two `theme-color` meta tags (which cannot take `var()`) and the token definitions themselves. That discipline is why the theme flip works at all, and it is worth protecting with a lint rule.\n\nEverything else in INK was checked section by section and is clean: nav veil, hero frame and dots, entry strip, how-it-works, native section, footer, shadows (correctly swapped to pure-black-alpha rather than reusing the warm drops), grain opacity, and all three dark screenshot twins. No image assumes a light background. No contrast collapse — INK minimum 5.59:1 across 24 roles.\n\nSuggested command: /impeccable extract\n","status":"closed","priority":2,"issue_type":"task","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T23:37:38Z","created_by":"Angus Bezzina","updated_at":"2026-08-05T03:12:54Z","started_at":"2026-08-05T03:10:36Z","closed_at":"2026-08-05T03:12:54Z","close_reason":"Two orphan tokens resolved; two promoted-to-canonical remain a decision.\n\n--native-panel-label RETIRED. It was oklch(0.72 0.08 32) in PAPER and oklch(0.75 0.08 32) in INK —\nwarm salmon in both, the only warm hue anywhere in the dark theme, and an orphan sitting beside a\ngreen label so the Surfaces section showed two decorative hues, neither of them the accent.\n.surface.native .surface-label now uses --native-panel-muted and the panel inversion carries the\ndistinction on its own. Verified in INK: the label is now oklch(0.66 0.01 250), a neutral, instead of\nrgb(220,156,142).\nRepointed the two remaining references in alternate-landing.css before deleting the definition —\nremoving a token while consumers still reference it would have reproduced exactly the silent\n0px/transparent failure fixed in .42.\n\n--rule-strong FIXED. It aliased straight to var(--foreground), so .step's top rule rendered at full\nopacity: oklch(0.87) in INK, the brightest line on the dark page against 16% hairlines everywhere\nelse. Now color-mix(in oklch, var(--foreground) 45%, transparent). Verified: the resolved value and\n.step's computed border-top-color both read oklch(… / 0.45) in both themes.\n\nNOT DONE, and it is a decision rather than a fix: --green and --danger-ink are legitimate AA-retuned\nvariants with the measurement documented in-comment (the accent tier measures 3.0-3.3:1 on small\nlabels). Promoting them into tokens.css as --green-text / --destructive-text means both the native\nand hosted builds adopt them, which is a design-system change worth making deliberately rather than\nas a side effect of a token cleanup.\n\nAlso not done: the native-panel inversion still collapses in INK (oklch(0.10) against an oklch(0.148)\nbody). The finding itself called that a design call, not a bug.\n\n97/97 unit test files pass.","labels":["design","hosted","web"],"dependencies":[{"issue_id":"attn-n01r.37","depends_on_id":"attn-n01r","type":"parent-child","created_at":"2026-08-04T18:37:38Z","created_by":"Angus Bezzina","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-n01r.36","title":"Desk design-system drift: accent inverted, serif buttons, mono-as-texture, 72px h1, 9.5px grid misalignment","description":"The desk's masthead is unmistakably attn — \"Your desk\" at 72px in Source Serif over a hairline rule, \"No account · 10.0 GB available\" in mono on the baseline, \"LOCAL WORKSPACES\" as a rust eyebrow. Below the fold the authorship stops and the design-system discipline goes with it. Six measured drifts:\n\n1. **The accent is spent on decoration and withheld from action.** The only terracotta on the populated desk is the static eyebrow \"LOCAL WORKSPACES\" (DeskHome.svelte:125) and a blinking caret in the empty card (app-shell.css:219-225). The primary action — \"+ New workspace\" — carries no accent at all (app-shell.css:127-147). DESIGN.md: the accent \"appears on primary action, current selection, and focus — nowhere else.\" This is the One Pencil Rule inverted: the pencil marks the label and leaves the button blank. **Delete the rust from \"LOCAL WORKSPACES\" and move it to \"New workspace\" — check whether anything of value breaks.**\n\n2. **Buttons are set in the reading face.** `.quick big { font-family: var(--serif) }` (app-shell.css:142-147) renders \"+ New workspace\" as **400-weight Source Serif at 24.8px** inside a `\u003cbutton\u003e`. DESIGN.md's Read/Do Rule is explicit: \"a button never uses the serif.\" Three of them do, at regular weight, so they carry no operative authority.\n\n3. **Mono is used as texture, not as code.** Four chrome roles are set in Source Code Pro: the storage line (app-shell.css:115), `.folio-label` (:155), `.local-badge` (:46), `.empty-desk .meta` (:197). DESIGN.md declares a strict three-role split. The system has a label token (0.7rem sans, uppercase, 0.06em) and the desk uses it **zero times** — `.folio-label` renders at 11.52px mono, smaller than the label token and in the wrong face.\n\n4. **The h1 is at landing-page scale on a tool.** `.desk-title h1 { font-size: clamp(2.8rem, 5vw, 5rem) }` (app-shell.css:108) renders at 72px. That violates the Fixed-Scale Rule (\"Don't fluidly clamp() UI headings\") and blows past the 2rem display token. Worse, the AppShell loading state (\"Opening your desk…\", AppShell.svelte:269-274) and error state (:275-288) both reuse `.desk-title`, so \"Your desk couldn't open\" is also an error headline at 72px.\n\n5. **The desk is 340px off its own measure token.** `.desk { max-width: 1300px }` (app-shell.css:92) against `--content-measure: 960px` (verified live).\n\n6. **The join panel is a foreign design system bolted on.** Raw px throughout (14px, 16px, 8px, 10px, 240px — app-shell.css:2725-2783) and a 10px border-radius, inside a desk that is rem-based and 0-radius everywhere else. It is the newest code on the page and the most off-system.\n\nAlso: the quick-action grid is **misaligned by 9.5px** because two tiles are `\u003cbutton\u003e` and one is `\u003ca\u003e`. Measured: tiles 1-2 (DeskHome.svelte:132, 142) render their `\u003cspan\u003e` at y=359.6 and `\u003cbig\u003e` at y=412.8; tile 3 (DeskHome.svelte:152) renders them at y=350.1 and y=403.3. Cause: `.quick { min-height: 155px; display: block }` (app-shell.css:127-136) leaves ~110px of content in a 155px box, and Chrome vertically centres a button's anonymous content block but not an anchor's. Fix with one element type for all three, or `display: grid; align-content: end` so the box model is tag-independent.\n\nWhile in the grid: the three glyphs are mismatched — U+FF0B fullwidth plus, U+21A5, U+2197. **U+21A5 in \"Import workspace\" (DeskHome.svelte:150) reads as a mojibake/rendering error.** Replace with one consistent icon set (this pairs with the icon-replacement issue in this epic).\n\nAnd `.quick big` computes to font-weight 400 — a primary action label with no weight.\n\nPreserve: the colour token discipline is genuinely excellent and could not be broken. Measured in both themes — `--hosted-muted` on `--paper` 9.55:1 PAPER / 6.36:1 INK; on `--sheet` 10.6 / 5.59; green badge 6.54 / 9.03; rust eyebrow 5.3 / 8.18; ink on paper 15.02 / 13.33. Every text pair clears AA, most clear AAA, in both themes. There is no contrast failure anywhere on the desk.\n\nSuggested command: /impeccable polish\n","status":"closed","priority":2,"issue_type":"task","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T23:36:50Z","created_by":"Angus Bezzina","updated_at":"2026-08-05T03:03:05Z","started_at":"2026-08-05T02:58:48Z","closed_at":"2026-08-05T03:03:05Z","close_reason":"Fixed the four mechanical drifts; the two that need a decision are called out below.\n\nREAD/DO RULE — the action-card labels were 400-weight Source Serif inside a \u003cbutton\u003e; DESIGN.md is\nexplicit that 'a button never uses the serif'. Now .quick-label in var(--sans) at 650 weight.\nVerified computed font-family: 'Source Sans 3 Variable'.\n\nONE PENCIL RULE — the accent was inverted: terracotta marked the static 'LOCAL WORKSPACES' eyebrow\nwhile the primary action carried none. .eyebrow moved to --hosted-muted (shared with the landing,\nwhich had the same inversion), and the accent moved onto the New workspace label.\nMEASURED accent marks on a page: landing 12 -\u003e 3 (two primary CTAs + the 'Start here' badge, all of\nwhich mark action); desk -\u003e the primary label plus the empty-sheet caret.\nAlso de-accented .chapter-index and .step-num on the landing, which were decoration.\n\n9.5px TILE MISALIGNMENT — root cause was NOT only the button-vs-anchor difference. .quick carried a\nduplicate AFTER my , silently overriding it; the tag difference only\nmattered because the box was a block. With the stray declaration removed and\n, the box model no longer depends on the tag.\nMEASURED label tops at 1280: [381.1, 381.1, 381.1], spread 0.0px (was 9.5px). At 390 they stack, as\nintended.\n\nDEPRECATED \u003cbig\u003e — replaced with \u003cstrong class='quick-label'\u003e, and the label now precedes the note in\nDOM order so the accessible name leads with the action rather than its fine print. Verified: zero\n\u003cbig\u003e elements remain.\n\nNOT DONE, needs a decision rather than a change:\n- The 72px clamp()ed h1 (Fixed-Scale Rule). Shrinking it is a visual-identity call on the desk's\n masthead, and it is entangled with the type-ramp question in .8.\n- Mono-as-texture across .folio-label / .local-badge / storage line. Moving those to the sans label\n token is the same decision as .39 and should land with it.\n- .desk max-width 1300px vs --content-measure 960px.\n- The join panel's px/10px-radius vocabulary.\n\nsvelte-check 0 errors; 97/97 unit test files pass. Verified visually at 1280 and 390.","labels":["design","desk","hosted","web"],"dependencies":[{"issue_id":"attn-n01r.36","depends_on_id":"attn-n01r","type":"parent-child","created_at":"2026-08-04T18:36:49Z","created_by":"Angus Bezzina","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-n01r.35","title":"Empty-desk card is a dead affordance, bottom-cropped at 900px, and duplicates the tile above it","description":"The empty desk is one of the best-authored moments in the product — \"YOUR FIRST SHEET\" leading into a tilted paper sheet reading \"UNTITLED.MD · NOT CREATED YET\" and \"What deserves your attention?\". Three things spoil it.\n\n1. **The card is a dead affordance.** It is an `\u003carticle\u003e` (DeskHome.svelte:275) with no click handler. It is the largest, warmest, most document-like object on the first-run screen, and clicking it does nothing. Every first-time user will click it.\n\n2. **It is bottom-cropped at 1440x900.** Measured `.empty-desk` bottom = 916.4px against a 900px viewport. The closing line \"no naming step\" and its blinking terracotta caret — the payoff of the whole composition — sit below the fold.\n\n3. **It duplicates the tile above it.** \"One click · starts with untitled.md\" (DeskHome.svelte:139) and \"Start with one blank Markdown file. It stays on this device — no account, no upload, no naming step\" (DeskHome.svelte:278-281) are the same sentence twice, 200px apart. Two competing empty states, and only the wrong one is clickable.\n\nSo: which is the real first-run screen — the tilted paper sheet, or the three-up action grid? Right now both are, they say the same thing, and the clickable one is the less compelling one. Pick one.\n\nNote the dependency: the sibling issue in this epic changes what \"New workspace\" does (prompting for content rather than seeding an empty `untitled.md`). This card's copy — \"UNTITLED.MD · NOT CREATED YET\" — is downstream of that decision and must move with it.\n\nKeep `transform: rotate(-0.3deg)` (app-shell.css:193). It is charming and survives DSF-2 rendering cleanly. It just belongs on something clickable.\n\nSuggested command: /impeccable onboard\n","status":"open","priority":2,"issue_type":"task","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T23:36:49Z","created_by":"Angus Bezzina","updated_at":"2026-08-04T23:36:49Z","labels":["design","desk","hosted","web"],"dependencies":[{"issue_id":"attn-n01r.35","depends_on_id":"attn-n01r","type":"parent-child","created_at":"2026-08-04T18:36:49Z","created_by":"Angus Bezzina","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-n01r.28","title":"No font preload: 900ms of fallback-font headline and it is the sole source of the page's CLS","description":"No font is preloaded, so the page paints its headline in Georgia for ~0.9 s and then swaps — and that swap is the entire source of the page's CLS.\n\nMeasured on Fast 3G + 4x CPU throttle (FCP 816 ms):\n\n source-serif-4-latin-wght-normal.woff2 discovered 786 ms, complete 1716 ms, 49.6 KB, FOUT window 900 ms\n source-sans-3-latin-wght-normal.woff2 discovered 866 ms, complete 1652 ms, 28.1 KB, FOUT window 836 ms\n source-code-pro-latin-wght-normal.woff2 discovered 874 ms, complete 1539 ms, 21.5 KB, FOUT window 723 ms\n\nThe faces are only discovered after `index-*.css` parses. CLS is 0.0027 and its shift entries name `#text`, `DIV.nav-right`, `A.button` — i.e. the swap. The 74.88px serif h1 is the most visible casualty.\n\nFix: preload the two faces that paint above the fold, in `web/hosted/index.html` `\u003chead\u003e`:\n\n \u003clink rel=\"preload\" as=\"font\" type=\"font/woff2\" crossorigin href=\"/assets/source-serif-4-latin-wght-normal-\u003chash\u003e.woff2\"\u003e\n \u003clink rel=\"preload\" as=\"font\" type=\"font/woff2\" crossorigin href=\"/assets/source-sans-3-latin-wght-normal-\u003chash\u003e.woff2\"\u003e\n\nThe hashes need a build-time inject — a small Vite `transformIndexHtml` plugin, the same shape as the existing `agentationDevToolbar()` at web/vite.browser.config.ts:55-72. That moves discovery to ~0 ms and takes CLS to effectively zero.\n\nRelated, and worth sequencing after the Read/Do fix: the mono family (21.5 KB, one blocking request) is downloaded almost entirely for decoration. A full text-node census by resolved family found only `brew install lightsofapollo/attn/attn` and `npx attnmd` are actually code; the other 18 mono-set strings are English prose. Once those move to `var(--sans)` (tracked in the DESIGN.md named-rules issue in this epic), Source Code Pro serves only `.code` and can be lazily loaded or dropped entirely.\n\nAlso noted, no action needed for the landing: 19 KaTeX woff2 files sit in `dist-browser/assets`. No landing CSS references them and zero requests were observed — but confirm they are reachable only from the app/review entries.\n\nFont setup that is already right and should be kept: 20 `@font-face` rules across 3 families, all `font-display: swap`, all `unicode-range`-subset across 6 subsets each, so only the 3 latin faces ever download (17 of 20 never fetched).\n\nSuggested command: /impeccable optimize\n","status":"closed","priority":2,"issue_type":"task","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T23:34:22Z","created_by":"Angus Bezzina","updated_at":"2026-08-05T03:14:51Z","started_at":"2026-08-05T03:13:11Z","closed_at":"2026-08-05T03:14:51Z","close_reason":"Fixed; the FOUT window on the above-fold faces is gone and CLS is now 0.\n\nAdded a preloadAboveFoldFonts() Vite plugin that injects rel=preload for the serif and sans latin\nfaces. Filenames are content-hashed, so it reads them out of the emitted bundle rather than\nhard-coding them — a stale hash would preload a 404 and quietly make things worse, which is the\nfailure mode a hand-written preload invites.\n\nMEASURED on the same Fast 3G + 4x CPU profile as the finding:\n BEFORE AFTER\n serif discovered 786 ms 592 ms\n sans discovered 866 ms 592 ms\n serif complete 1716 ms 1842 ms\n sans complete 1652 ms 1719 ms\n FCP 816 ms 2424 ms*\n CLS 0.0027 0\nBoth faces are now discovered together at 592 ms and complete before first contentful paint, so the\n74.88px serif h1 never renders in Georgia. CLS went to zero, which matches the finding's claim that\nthe swap was its entire source — the shift entries had named #text, DIV.nav-right and A.button.\n\n*FCP and LCP are not comparable between the two runs: my CDP throttle configuration differs from the\naudit's, so the absolute page-timing numbers moved for reasons unrelated to this change. The font\ndiscovery times and CLS are same-metric, same-page measurements and are directly comparable.\n\nNOT PRELOADED, deliberately: Source Code Pro. It still loads late (2681 ms) because it is not\nabove-fold — and per the Read/Do finding in .18, 18 of the 20 strings it sets are English prose, not\ncode. Preloading it would entrench that. Once those move to the sans, the mono can be dropped from\nthe critical path entirely rather than fetched sooner.\n\n97/97 unit test files pass.","labels":["design","hosted","landing","perf","web"],"dependencies":[{"issue_id":"attn-n01r.28","depends_on_id":"attn-n01r","type":"parent-child","created_at":"2026-08-04T18:34:21Z","created_by":"Angus Bezzina","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-n01r.25","title":"Home page ARIA defects: dead aria-label on a div, toggle state never exposed, footer links undistinguished","description":"Four ARIA/semantics defects found by walking the CDP accessibility tree, distinct from the structural set already filed in this epic.\n\n**[P2] `aria-label` on a generic `\u003cdiv\u003e` is silently discarded.**\n web/src/hosted/landing/Hero.svelte:60\n \u003cdiv class=\"product-stage\" aria-label=\"A real attn review with local and shared state labels\"\u003e\nVerified against `Accessibility.getFullAXTree`: this string appears nowhere in the tree, and it is not in the ignored-but-labelled set either. `aria-label` is only honoured on elements whose role supports naming; a bare `div` maps to `generic` and the name is dropped. Zero users receive it. This is worse than having no label — it reads as coverage during review and prevents anyone from writing the label that would work. Delete it (the inner `\u003cimg\u003e` alt is already good and the two `\u003caside\u003e` labels are real text), or promote to `\u003cfigure\u003e` + `\u003cfigcaption\u003e`.\n\n**[P2] The theme toggle never exposes its state.**\n web/src/hosted/landing/Nav.svelte:16\nMeasured: `aria-label=\"Toggle theme\"` static across both states, `aria-pressed` null, no `type` attribute. Both SVG icons are correctly `aria-hidden`, so nothing conveys the current theme to assistive tech. WCAG 4.1.2 Name, Role, Value (A) — value/state not exposed. Add `aria-pressed={getTheme() === 'dark'}` and an action-naming label: \"Switch to dark theme\" / \"Switch to light theme\".\n\n**[P2] Footer links are distinguished by nothing at all.**\n web/src/hosted/chrome.css:88-91 (`a { color: inherit; text-decoration: none }`), web/src/hosted/landing/landing.css:532-539\nMeasured: \"GitHub / Issues / Contributing\" render at `rgb(55 50 45)` light and `rgb(142 147 152)` dark — byte-identical to the sibling \"MIT License · Rust + Svelte · ProseMirror editor\" text, same weight, same size, no underline. Only `:hover` changes them. This is the stricter form of WCAG 1.4.1 Use of Color (A): not distinguished by colour *or* by any non-colour cue. Nav links get a pass by positional convention; footer links sitting inline with body copy do not. Underline them, or give links `--ink` while the licence text keeps `--hosted-muted`.\n\n**[P2] Entry-card links carry 100-character accessible names.**\n web/src/hosted/landing/EntryStrip.svelte:6-20\nEach card wraps `\u003csmall\u003e` + `\u003ch2\u003e` + `\u003cp\u003e` in one `\u003ca\u003e`, so the computed name is e.g. \"One click · no naming step New workspace Creates untitled.md locally and opens the editor. START HERE\". Screen-reader users navigating by link list hear the full body copy of all three cards. Passes 2.4.4 (the names are descriptive) — this is a usability tax, not a failure. Add `aria-labelledby` on the `\u003ca\u003e` pointing at its `\u003ch2\u003e` so the link list stays scannable while the `\u003cp\u003e` remains readable in document order.\n\n**[P3] Decorative window dots are contrast-failing text.**\n web/src/hosted/landing/landing.css:157-165\n .window::after { content: '● ● ●'; color: var(--window-dots); font: 0.55rem var(--mono); }\nMeasured 1.72:1 light (rgb(155 150 145) on rgb(202 198 192)) and 1.63:1 dark (rgb(58 63 69) on rgb(22 27 33)) at 8.8px. These are the three macOS traffic-light dots in the mock window chrome, so comprehension impact is nil — but they are *text* in a pseudo-element, not a background image, so the 1.4.3 decorative exemption is arguable rather than airtight and any automated scanner will flag them. Render as `radial-gradient` stops or a small `aria-hidden` inline SVG and the question disappears.\n\nContext worth recording: this page's contrast is otherwise excellent. 37 distinct text styles were measured in both themes and the dots are the only failure; the lowest passing value anywhere is 5.30:1. All 17 tabbable controls carry a visible `:focus-visible` ring measuring 5.30-8.18:1 against the adjacent surface. Nothing is conveyed by colour alone.\n\nSuggested command: /impeccable harden\n","status":"closed","priority":2,"issue_type":"task","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T23:33:18Z","created_by":"Angus Bezzina","updated_at":"2026-08-05T02:44:42Z","started_at":"2026-08-05T02:41:13Z","closed_at":"2026-08-05T02:44:42Z","close_reason":"Fixed and verified by live probe.\n\nDEAD aria-label REMOVED — Hero.svelte's .product-stage was a bare \u003cdiv\u003e carrying\naria-label='A real attn review with local and shared state labels'. aria-label is only honoured on\nelements whose role supports naming; a div maps to 'generic' and the string was discarded — it was\nverified absent from the AX tree. Removed rather than promoted to \u003cfigure\u003e: the inner \u003cimg\u003e alt and\nthe two \u003caside\u003e labels already carry the meaning, and a label that reads as coverage while doing\nnothing is worse than none. Verified: attribute now absent.\n\nTHEME TOGGLE EXPOSES STATE — now carries type='button', aria-pressed bound to the current theme, and\nan action-naming label. Verified live: aria-pressed='false', aria-label='Switch to dark theme' in the\nlight theme. Previously the label was static across both states and both icons are aria-hidden, so\nnothing conveyed the current theme (WCAG 4.1.2, value/state not exposed).\n\nFOOTER LINKS DISTINGUISHED — .footer-links a now carries var(--ink) and an underline with offset.\nThey had rendered byte-identical to the adjacent licence text — same colour, weight and size, no\nunderline — so nothing at rest identified them as links. This is the stricter form of WCAG 1.4.1:\nnot distinguished by colour OR by any non-colour cue. (Shipped with the hit-target work in .12.)\n\nENTRY-CARD LINK NAMES — aria-labelledby on each card so the link list is scannable instead of\nreading ~100 characters of body copy per card. Closed alongside .19.\n\nNOT FIXED HERE, deliberately: the decorative window dots (.window::after, 1.72:1 light / 1.63:1\ndark). Still pseudo-element text rather than a background or an aria-hidden SVG. It is P3, cosmetic\nchrome on a mock window, and it belongs with the hero rework in .13 — changing it in isolation would\ntouch the same rule twice.\n\nsvelte-check 0 errors; 97/97 unit test files pass.","labels":["a11y","design","hosted","web"],"dependencies":[{"issue_id":"attn-n01r.25","depends_on_id":"attn-n01r","type":"parent-child","created_at":"2026-08-04T18:33:17Z","created_by":"Angus Bezzina","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-n01r.24","title":"No breakpoint between 680 and 1180px; START HERE badge overlaps the card eyebrow at 681-730px","description":"`landing.css` contains exactly two media queries — `max-width: 1180px` (:541) and `max-width: 680px` (:575). Between 681 and 1180 every multi-column grid keeps its desktop track count while the container loses up to 500px. There is a visible collision inside that gap.\n\n**The collision — \"START HERE\" overlaps the card eyebrow at 681-730px.**\n`.entry.primary-entry::after` is absolutely positioned (`right: 1.2rem; top: 1.1rem`, landing.css:270-279) so it reserves no space, while `.entry small` sits in normal flow (landing.css:254-257). Measured gap between the eyebrow's right edge and the badge's left edge:\n\n 690px viewport card 248px eyebrow ends 174 badge starts 169 -\u003e -6px OVERLAPPING\n 720px card 260px ends 174 starts 180 -\u003e +6px\n 768px card 278px -\u003e +24px\n 1024px card 377px -\u003e +123px\n\nVisually confirmed at 690px: \"START HERE\" sits directly on top of \"One click · no naming step\". Overlapping text is unreadable for everyone, and this band covers small tablets and split-screen desktop windows. The 680px breakpoint that stacks the strip to one column — and relieves the pressure — fires roughly 50px too late.\n\nBest fix puts the badge in flow: make `.entry small` a flex row with the badge as a sibling and `justify-content: space-between`. That also resolves the separate accessibility problem that \"START HERE\" currently exists only as CSS generated content (tracked in the structural-a11y issue in this epic). Raising the stacking breakpoint to 760px is the cheaper alternative but leaves the a11y issue standing.\n\n**The wider gap.** Measured track widths across the band:\n\n .entry-strip (1.25fr 1fr 1fr, :236) 768: 278/223/223 1024: 377/302/302 1180: 437/350/350\n .steps (repeat(3,1fr), :318) 768: 241 x3 1024: 326 x3 1180: 378 x3\n .surfaces (1fr 1fr, :390) 768: 361/361 1024: 489 x2 1180: 567 x2\n .share-proof (:354) 768: 395/280 1024: 582/349 1180: 680/408\n\nMeanwhile `.surface h3` holds a flat 41.6px across the entire range (landing.css:426-430 — no `clamp()`, unlike h1 at :77 and h2 at :303/:464 which both clamp). So \"Your private desk\" runs 41.6px type inside a 290px content box at 768px: it breaks to two lines, and the mono list item \"→ Existing files stay where they are\" orphans \"are\" onto its own line. The `.entry` cards show the same pressure — \"Import workspace\" wraps to two lines while its neighbours do not, leaving ragged body-copy baselines across the strip.\n\nAlso in this band: `.hero-lede` is capped at `max-width: 610px` (landing.css:94) and never grows, so at 1024 the hero column is 922px and the lede stops 312px short of the h1's edge — a visibly ragged right margin with a large empty right half.\n\nRecommendation: add a ~900px breakpoint dropping `.entry-strip` and `.steps` to 2 columns and `.surfaces` to 1; add `clamp()` to `.surface h3` (:426-430) and `.entry h2` (:259-262).\n\nSuggested command: /impeccable adapt\n","status":"closed","priority":2,"issue_type":"task","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T23:33:17Z","created_by":"Angus Bezzina","updated_at":"2026-08-05T02:44:42Z","closed_at":"2026-08-05T02:44:42Z","close_reason":"The collision half is fixed; the breakpoint-gap half is not, and is being left open below.\n\nCOLLISION FIXED — 'Start here' moved from .entry.primary-entry::after (position: absolute, so it\nreserved no space) into an .entry-flag span inside an .entry-head flex row with\njustify-content: space-between. It now takes part in layout and cannot overlap the eyebrow.\n\nMEASURED at 690px, the worst point in the band:\n BEFORE eyebrow ends 174, badge starts 169 -\u003e -6px, overlapping\n AFTER gap +12px\nThis was also the fix the a11y issue wanted, so it closed both at once (see .19).\n\nSTILL OPEN — I am reopening the remaining scope as its own issue rather than closing it silently:\n- No breakpoint between 680 and 1180. Track widths still compress from 437/350/350 at 1180 to\n 278/223/223 at 768 with no 2-column tier.\n- .surface h3 is a flat 41.6px across that whole range (landing.css, no clamp) so 'Your private desk'\n breaks to two lines inside a 290px box at 768.\n- .hero-lede is capped at max-width 610px and never grows, leaving a ragged right margin and an empty\n right half at 1024.\nThese are composition work, not a defect fix, and want a design decision about the mid-range layout\nrather than a mechanical change.","labels":["a11y","design","hosted","web"],"dependencies":[{"issue_id":"attn-n01r.24","depends_on_id":"attn-n01r","type":"parent-child","created_at":"2026-08-04T18:33:16Z","created_by":"Angus Bezzina","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-n01r.20","title":"Home page has no closing CTA; it ends on a license colophon","description":"After seven screens of argument the page ends on Footer.svelte:7 — \"MIT License · Rust + Svelte · ProseMirror editor\" — and three GitHub links. There is no closing call to action.\n\nPeak-end theory puts half the remembered experience in the ending. This ending is a colophon. The reader who has just been persuaded is handed a license note and a link to Issues.\n\nAsk who that last sentence is for. If it is the developer who will star the repo, they have already scrolled seven screens to reach it. If it is the person who should create a workspace, it is the last thing they read instead of an invitation.\n\nFix: close with the offer. A final CTA band carrying the primary action, sized and weighted like the hero's, with the license and repo links demoted beneath it.\n\nRelated (same section of the journey): the Surfaces comparison presents 12 claims across two panels and offers no action on either side (Surfaces.svelte:17-44). The reader compares browser vs native, reaches a conclusion, and is given nothing to do with it. Each panel should carry the CTA for its own path.\n\nSuggested command: /impeccable shape\n","status":"closed","priority":2,"issue_type":"task","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T23:30:41Z","created_by":"Angus Bezzina","updated_at":"2026-08-05T03:24:10Z","started_at":"2026-08-05T03:21:24Z","closed_at":"2026-08-05T03:24:10Z","close_reason":"Fixed. The page closes on the offer; the colophon is demoted beneath it.\n\nAdded a .closer band before the footer: 'Start with one file.' at section-head scale, a one-line\nlede, and the same state-aware CTA pair the nav and hero use — readDeskCount() so a returning\nvisitor is offered their desk rather than a create they do not need.\n\nWeighted deliberately as a section head (clamp 2.6-4.6rem), not as a second masthead. It is the last\nword, not a competing hero, and it now sits in the same tier as the other section heads established\nin .11 — verified in the outline: H1 -\u003e H2 x3 -\u003e H2 'Start with one file.'\n\n'MIT License · Rust + Svelte · ProseMirror editor' and the three GitHub links stay, underneath, where\na colophon belongs. Nothing was removed; the ordering was wrong, not the content.\n\nVerified: renders correctly at 1280, heading outline intact, zero horizontal overflow.\n\nNOT DONE, from the same finding: the Surfaces comparison still presents 12 claims across two panels\nwith no CTA on either side, so the reader reaches a browser-vs-native conclusion and is handed\nnothing to act on. That is a section-level composition change and belongs with the 'begin decision'\nwork in .14, which is already about where the page routes people.\n\n97/97 unit test files pass.","labels":["design","hosted","landing","web"],"dependencies":[{"issue_id":"attn-n01r.20","depends_on_id":"attn-n01r","type":"parent-child","created_at":"2026-08-04T18:30:41Z","created_by":"Angus Bezzina","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-n01r.19","title":"Home page structural a11y: no skip link, START HERE invisible to AT, nav in heading outline, copy button semantics","description":"Four structural accessibility defects on the home page, all cheap to fix, none currently caught by the automated pass (the page has 0 console errors, 0 missing alt, a correct `lang`, and no skipped heading levels — this is the residue).\n\n1. **No skip link.** `main#top` exists (Landing.svelte:12) but nothing targets it. First Tab lands on the brand mark. WCAG 2.4.1 Bypass Blocks.\n\n2. **The primary recommendation is invisible to assistive tech.** \"START HERE\" is rendered as `::after` content (landing.css:270-279), so it is decorative-only. The single strongest signpost on the page does not exist for a screen-reader user.\n\n3. **Navigation pollutes the heading outline.** EntryStrip.svelte:8,13,17 render \"New workspace\" / \"Import workspace\" / \"Join a review\" as `\u003ch2\u003e`. A screen reader reports six top-level sections, three of which are buttons, before the first real section heading. These are card labels, not sections — they should be a non-heading element or a lower level.\n\n4. **No `\u003cheader\u003e` landmark.** The site nav is a bare `\u003cnav\u003e` (Nav.svelte:8) with no `\u003cheader\u003e` wrapper. Landmarks measured: main=1, nav=1, footer=1, header=0.\n\nAlso filed here because it is the same family — **the copy button is a poor citizen**:\n - `CopyCode.svelte:26` sets `aria-label={`Copy ${code}`}` permanently. The visible label flips to \"Copied\" but the accessible name never changes, and there are zero `aria-live` regions on the page, so a screen-reader user gets no confirmation the copy happened.\n - `CopyCode.svelte:19-21` swallows clipboard failure in an empty `catch`. A denied permission is indistinguishable from success. At minimum, surface the failure and leave the text selectable.\n - The button is 40x23.4px, transparent and borderless (landing.css:487-495) — it barely reads as a control. Sizing is tracked in the touch-target issue; this issue owns the semantics.\n\nPositive findings worth preserving: `:focus-visible` is a consistent 2px terracotta ring on every focusable (chrome.css:93, verified by keyboard walk), both icon-only buttons carry `aria-label`, and every interactive element has an accessible name.\n\nSuggested command: /impeccable harden\n","status":"closed","priority":2,"issue_type":"task","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T23:30:40Z","created_by":"Angus Bezzina","updated_at":"2026-08-05T02:44:41Z","started_at":"2026-08-05T02:41:13Z","closed_at":"2026-08-05T02:44:41Z","close_reason":"Fixed and verified by live AX/keyboard probe.\n\nSKIP LINK — added as the first element in Landing.svelte, visible only on :focus-visible, targeting\nthe existing #top so no other anchor changed. Verified: the first Tab stop is now 'Skip to content',\nrendered visible at top:12. Previously stops 0-6 were all navigation with no way past them, while\n\u003cmain\u003e carried an id nothing linked to. WCAG 2.4.1 Bypass Blocks.\n\nBANNER LANDMARK — \u003cNav\u003e is now wrapped in \u003cheader\u003e. Measured landmarks were navigation -\u003e main -\u003e\ncontentinfo with no banner, so the brand, theme toggle and CTA sat outside every landmark. Now\nheader/nav/main/footer all present.\n\n'START HERE' IS REAL MARKUP — moved out of .entry.primary-entry::after into an .entry-flag span\ninside an .entry-head flex row. As generated content it was skipped by machine translation and\nin-page search, and landed last in a 100-character accessible name. Being absolutely positioned it\nalso reserved no space, which is what made it collide with the eyebrow — that half is closed in .24.\n\nHEADING OUTLINE NO LONGER CARRIES NAVIGATION — the three entry-strip \u003ch2\u003e card labels are now\n\u003cstrong class='entry-title'\u003e. They are the names of links, not sections.\n BEFORE 12 headings; a screen reader reported six top-level sections before the first real one,\n three of which were buttons.\n AFTER 9 headings: H1 -\u003e H2 -\u003e H3 x3 -\u003e H2 -\u003e H3 x2 -\u003e H2. Clean.\n\nLINK NAMES SHORTENED — each .entry carries aria-labelledby pointing at its own title, so the link\nlist reads 'New workspace' rather than the full body copy of the card. The \u003cp\u003e is still read in\ndocument order.\n\nsvelte-check 0 errors; 97/97 unit test files pass.","labels":["design","hosted","landing","web"],"dependencies":[{"issue_id":"attn-n01r.19","depends_on_id":"attn-n01r","type":"parent-child","created_at":"2026-08-04T18:30:40Z","created_by":"Angus Bezzina","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-n01r.18","title":"Landing page has forked four DESIGN.md named rules (mono-as-texture, Quarantine, One Pencil, glassmorphism)","description":"The landing page has quietly forked four of DESIGN.md's named rules. Individually each is small; together they are why the page reads as a generic dev-tool site wearing attn's typography.\n\n1. **Monospace as decorative texture.** 20 mono elements on the page; only 2 are code (`brew install`, `npx attnmd`). The other 18 are prose or labels: `.local-note` claims (landing.css:107-115), `.stage-label small` (196-201), `.surface ul` feature bullets (442-447), `.chapter-index` (295-298), `.step-num` (335-338), `.surface-label` (412-416), the \"START HERE\" tag (270-279). DESIGN.md §Typography specifies a strict three-role split — mono carries code. This is also the single most generic dev-tool-startup signal on the page.\n\n2. **Quarantine Rule violation.** DESIGN.md reserves green/amber/peer hues for the collaboration layer, \"never as decoration on base chrome\". `--green` is used for the three hero bullet dots (landing.css:123-131), the \"Source · local\" dot (224-226), and the \"Browser · no install\" label (418-420). None carry suggestion semantics.\n\n3. **One Pencil Rule violation.** Five terracotta elements sit on the fold simultaneously: nav CTA (chrome.css:129-133), `.eyebrow` (chrome.css:98-103, `--rust-deep`), hero primary CTA, `.stage-label.share` dot (landing.css:228-230), and \"START HERE\" (landing.css:270-279). DESIGN.md: \"If two things on a screen are terracotta, one of them is wrong.\"\n\n4. **Glassmorphism as the default nav treatment.** landing.css:20-22 applies `backdrop-filter: blur(15px)` under an 88%-opaque veil (`--nav-veil`, chrome.css:27). DESIGN.md's Don't list prohibits \"glassmorphism as a default\".\n\nSeparately, a **documented fork rather than a violation**: DESIGN.md's Fixed-Scale Rule prohibits `clamp()` on headings for the product register, but landing.css:77, :303 and :464 all clamp, and the h1 reaches 6rem against the system's 2rem Display token. For a Persuade surface that is probably the right call — but the system has no marketing-register carve-out, so the landing has silently forked the type scale by 3x. Either document the carve-out in DESIGN.md or tokenise a marketing scale. Do not leave it undeclared.\n\nSuggested command: /impeccable polish, after deciding rule-by-rule which way each should resolve.\n","status":"closed","priority":2,"issue_type":"task","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T23:30:39Z","created_by":"Angus Bezzina","updated_at":"2026-08-05T03:17:45Z","started_at":"2026-08-05T03:15:08Z","closed_at":"2026-08-05T03:17:45Z","close_reason":"Three of the four named rules restored; the fourth is a call I am not making unilaterally, and the Fixed-Scale question is now documented.\n\nMONO-AS-TEXTURE — FIXED. Seven prose/label roles moved from var(--mono) to var(--sans): .local-note,\n.stage-label small, .entry-flag, .chapter-index, .step-num, .surface-label, .surface ul.\nVERIFIED by walking every leaf element's computed font-family on the rendered page: Source Code Pro\nnow sets exactly two strings — 'brew install lightsofapollo/attn/attn' and 'npx attnmd'. Previously\n18 of 20 mono strings were English prose. The Read/Do Rule is satisfied.\n\nQUARANTINE RULE — FIXED. Green left base chrome: .surface.browser .surface-label and the\n.local-note bullet dots now use --hosted-muted. Neither carried collaboration meaning. (The desk's\n'Backup recommended' badge, the other green-on-chrome case, was fixed in .31; the row-level 'Shared'\nbadge keeps green, which is the one place the rule allows it.)\n\nONE PENCIL RULE — FIXED. .eyebrow, .chapter-index and .step-num de-accented.\nMEASURED accent marks on the landing: 12 -\u003e 3, and all three now mark action (the two primary CTAs\nand the 'Start here' badge on the primary entry card). Nine decorative marks across four rules are\ngone.\n\nGLASSMORPHISM — NOT CHANGED, deliberately. The nav is backdrop-filter: blur(15px) under an\n88%-opaque veil. DESIGN.md prohibits 'glassmorphism as a default', but at 88% opacity this is much\ncloser to a solid sticky bar than to a glass panel, and whether a specific sticky-nav veil counts as\n'a default decorative treatment' is genuinely arguable. Making the nav opaque changes the page's\nlook, so it wants a decision rather than my reading of the sentence. Reversible either way.\n\nFIXED-SCALE FORK — DOCUMENTED, which is what the finding asked for ('document it or tokenize it').\nAdded a marketing carve-out to DESIGN.md §Typography: the landing may clamp() display headings, in\ntwo tiers only, because the rule's stated rationale is panes and sidebars and a full-bleed Persuade\nsurface has neither. Two tiers rather than per-section values, because a third coefficient is exactly\nhow the h1 came to render smaller than two h2s (.11). The desk and app shell stay on the fixed ramp.\n\n97/97 unit test files pass. Verified visually at 1280.","labels":["design","hosted","landing","web"],"dependencies":[{"issue_id":"attn-n01r.18","depends_on_id":"attn-n01r","type":"parent-child","created_at":"2026-08-04T18:30:39Z","created_by":"Angus Bezzina","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-n01r.15","title":"Home page uses four undefined nouns (desk/workspace/room/document) plus unglossed protocol terms","description":"The page uses four nouns for overlapping concepts and defines none of them, and one noun carries two different meanings.\n\n desk — used 4x, defined 0x (\"Your desk\", \"Open your desk\")\n workspace — the browser-side container (Surfaces.svelte:22) AND a native folder (Surfaces.svelte:33 \"The whole workspace\")\n room — the shared review session (\"A room exists only after Share\")\n document — the file itself\n\nPlus unglossed internal protocol vocabulary handed to a first-time visitor:\n \"secret-bearing link\" HowItWorks.svelte:52\n \"encrypted mailbox otherwise\" HowItWorks.svelte:60\n \"publishes an encrypted revision\" HowItWorks.svelte:33\n\nA stranger cannot tell whether a desk contains workspaces, whether a room is a place or an event, or whether \"workspace\" means the same thing in the two sentences where it appears.\n\nFix: settle the noun set (probably desk = the device-local home, workspace = a container of files, room = the encrypted session), use each consistently, and either gloss the protocol terms inline on first use or replace them with plain language. The encryption story is the page's strongest claim and it is currently told in the vocabulary of the implementation.\n\nRelated: the encryption claim is asserted at full weight in four separate places (Hero.svelte:37-38, Hero.svelte:78, HowItWorks.svelte:52-53, HowItWorks.svelte:77-80) rather than opened once and deepened — there is no progressive disclosure anywhere on the page.\n\nSuggested command: /impeccable clarify\n","status":"open","priority":2,"issue_type":"task","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T23:28:27Z","created_by":"Angus Bezzina","updated_at":"2026-08-04T23:28:27Z","labels":["design","hosted","landing","web"],"dependencies":[{"issue_id":"attn-n01r.15","depends_on_id":"attn-n01r","type":"parent-child","created_at":"2026-08-04T18:28:26Z","created_by":"Angus Bezzina","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-n01r.14","title":"Home page splits the 'begin' decision across two treatments and repeats /app#new three times","description":"The \"how do I begin\" decision is rendered twice, in two visual vocabularies, 500px apart, and the same destination is offered three times.\n\nFirst 1.5 screens — 6 affordances resolving to 4 destinations:\n Nav.svelte:60 \"New workspace\" -\u003e /app#new\n Hero.svelte:47 \"New workspace\" -\u003e /app#new\n Hero.svelte:50 \"Open your desk\" -\u003e /app\n EntryStrip.svelte:6 \"New workspace\" -\u003e /app#new\n EntryStrip.svelte:11 \"Import workspace\" -\u003e /open\n EntryStrip.svelte:16 \"Join a review\" -\u003e /app#join\n\n/app#new is offered three times before the first section break. The hero button pair and the entry strip are the same decision in two different treatments — a button pair and a three-up card row — so the reader has to work out that they are not two different offers.\n\nCognitive-load review of this page failed 6 of 8 checklist items; this issue accounts for two of them (single focus, and grouping of a decision split across treatments).\n\nFix direction: pick one surface to own the begin decision. If the entry strip owns it, the hero's button pair collapses to a single primary. If the hero owns it, the entry strip becomes secondary routes (import / join) without repeating create.\n\nRelated: the Surfaces section presents 12 comparative claims across two panels with zero CTAs on either side (Surfaces.svelte:17-44), so the reader compares browser-vs-native and is then handed nothing to act on. Same family of problem — the page states, then does not route.\n\nSuggested command: /impeccable distill\n","status":"open","priority":2,"issue_type":"task","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T23:28:26Z","created_by":"Angus Bezzina","updated_at":"2026-08-04T23:28:26Z","labels":["design","hosted","landing","web"],"dependencies":[{"issue_id":"attn-n01r.14","depends_on_id":"attn-n01r","type":"parent-child","created_at":"2026-08-04T18:28:26Z","created_by":"Angus Bezzina","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-n01r.11","title":"Home page heading hierarchy is inverted: h1 renders smaller than two h2s","description":"The rendered type scale contradicts semantic rank. Measured on the production build at 1440px:\n\n h1 \"A private desk for working documents.\" 66.56px\n h2 \"A document first. A room only when you share.\" 72.96px\n h2 \"Use the lightest tool for the work.\" 72.96px\n h2 \"Your files. A real window.\" 64.00px\n h2 \"New workspace\" / \"Import workspace\" / \"Join a review\" 27.20px\n h3 \"Your private desk\" / \"The whole workspace\" 41.60px\n h3 \"Create locally\" / \"Share deliberately\" / \"Work anywhere\" 26.40px\n\nTwo defects:\n\n1. **The h1 is smaller than two of the h2s.** `.hero h1` is `clamp(3.2rem, 5.2vw, 6rem)` (landing.css:77) and `.chapter h2` is `clamp(3rem, 5.7vw, 6rem)` (landing.css:303) — the same 6rem ceiling, but the h2's steeper vw coefficient overtakes the h1 at wide viewports. The page's own title carries no more weight than three subordinate section headers; at 1440 the effect is four equal mastheads and no summit.\n\n2. **The h2 tag spans a 2.7x size range** — 27.20px in the entry strip vs 72.96px in the chapters. Same tag, wildly different rank. The entry-strip h2s (EntryStrip.svelte) render smaller than every h3 except the how-it-works trio, so the document outline and the visual outline disagree.\n\nSemantically the outline is correct — 12 headings, no skipped levels, h1 -\u003e h2 x3 -\u003e h2 -\u003e h3 x3 -\u003e h2 -\u003e h3 x2 -\u003e h2. This is purely a rendered-hierarchy problem.\n\nFix: give the hero a decisive size lead over chapter heads, and either promote the entry-strip cards visually or demote them semantically (they are card labels, not section headings — an h3, or a non-heading element, may be the honest markup).\n\nNote against DESIGN.md: the Fixed-Scale Rule says \"Don't fluidly clamp() UI headings; the product type scale is fixed rem.\" The landing is a Persuade surface and arguably exempt, but the clamp() coefficients are exactly what produced this inversion — worth settling whether the exemption stands.\n\nSuggested command: /impeccable typeset\n","status":"closed","priority":2,"issue_type":"task","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T23:28:23Z","created_by":"Angus Bezzina","updated_at":"2026-08-05T03:05:40Z","started_at":"2026-08-05T03:03:43Z","closed_at":"2026-08-05T03:05:40Z","close_reason":"Fixed. The h1 now leads at every width and the h2 tier is internally consistent.\n\nCause was the vw coefficients, not the caps: .hero h1 was clamp(3.2rem, 5.2vw, 6rem) while\n.chapter h2 was clamp(3rem, 5.7vw, 6rem) — same ceiling, steeper slope, so the h2 overtook the page\ntitle at wide viewports. .native-section h2 was a third value again, clamp(3rem, 5vw, 5.2rem).\n\nBoth h2 rules now share clamp(2.6rem, 4.4vw, 4.6rem).\n\nMEASURED rendered sizes:\n BEFORE AFTER\n 1920 — H1 96.0 H2 73.6 H2 73.6 H2 73.6\n 1440 H1 66.6 H2 73.0 H2 73.0 H1 74.9 H2 63.4 H2 63.4 H2 63.4\n 1280 — H1 66.6 H2 56.3 H2 56.3 H2 56.3\nThe h1 previously rendered SMALLER than two of the h2s; it now leads by 18-30% and the three h2s are\nfinally equal to each other.\n\nThe second half of this issue — the h2 tag spanning a 2.7x range because the entry-strip cards were\nalso \u003ch2\u003e at 27px — was resolved in .19 by making those card labels \u003cstrong\u003e. They are the names of\nlinks, not sections, so they are no longer in the outline at all.\n\nThe Fixed-Scale Rule question (clamp() on headings at all) is deliberately untouched: DESIGN.md's\nstated rationale is about panes and sidebars, which a marketing page does not have, and the doc has\nno marketing-register carve-out. That is a documentation decision, tracked in .18.\n\n97/97 unit test files pass.","labels":["design","hosted","landing","web"],"dependencies":[{"issue_id":"attn-n01r.11","depends_on_id":"attn-n01r","type":"parent-child","created_at":"2026-08-04T18:28:23Z","created_by":"Angus Bezzina","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-n01r.9","title":"Home page hero leads with the default AI-SaaS eyebrow-above-headline shape","description":"The hero opens with the single most category-interchangeable shape in contemporary SaaS marketing: a tiny tracked-caps kicker sitting immediately above an oversized serif headline.\n\n web/src/hosted/landing/Hero.svelte:33\n \u003cp class=\"eyebrow\"\u003eNo account · local by default\u003c/p\u003e\n \u003ch1\u003e\u003cspan\u003eA private desk\u003c/span\u003e \u003cspan\u003efor working documents.\u003c/span\u003e\u003c/h1\u003e\n\nConfirmed by the in-page Impeccable detector run against the production build (rule `hero-eyebrow-chip`, category slop): the tracked-caps `\u003cp class=\"eyebrow\"\u003e` is the immediate previous sibling of the `\u003ch1\u003e`. Verified in source — not a mis-parse.\n\nWhy it matters: PRODUCT.md positions attn against exactly this register — the anti-references are cloud-SaaS, IDE clutter, and rounded-pastel productivity, and the voice is meant to \"state, not sell\". The eyebrow is the reflex move of the thing attn says it is not. It also spends the page's first line of attention on a claim (\"No account · local by default\") that the lede restates two lines later.\n\nOptions — pick one deliberately rather than deleting and leaving a gap:\n- Fold the kicker into the headline so the claim is the statement, not a label above it.\n- Run it as a breadcrumb or nav-level element rather than a hero eyebrow.\n- Keep the information but change its shape: sentence case, body-sized, part of the lede.\n\nScope note: the same `.eyebrow` class (web/src/hosted/chrome.css:98-103) is reused as a section kicker at HowItWorks.svelte:75 and NativeSection.svelte:31, where the detector also fired (`all-caps-body`, 31 and 32 characters). Those two are false positives on their own terms — they are short labels, exactly what the rule says uppercase is for — but they share one CSS rule with the hero. Decide the eyebrow's fate once, at the class, and let all three sites follow.\n\nSuggested command: /impeccable bolder (hero specificity) or /impeccable typeset (if the answer is a type-level change to .eyebrow).\n","status":"open","priority":2,"issue_type":"task","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T23:26:39Z","created_by":"Angus Bezzina","updated_at":"2026-08-04T23:26:39Z","labels":["design","hosted","landing","web"],"dependencies":[{"issue_id":"attn-n01r.9","depends_on_id":"attn-n01r","type":"parent-child","created_at":"2026-08-04T18:26:38Z","created_by":"Angus Bezzina","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-n01r.8","title":"Reconcile the desk/app-shell type scale with the DESIGN.md ramp (18 off-ramp sizes)","description":"DESIGN.md documents a six-step type ramp — 0.7 / 0.85 / 1 / 1.25 / 1.5 / 2 rem (label, mono, body, title, headline, display). The desk and app shell do not use it.\n\nMeasured in web/src/hosted/app/app-shell.css (2795 lines): **18 distinct off-ramp sizes** across roughly 30 declarations, counting both `font-size:` and the `font:` shorthand —\n\n0.6, 0.67, 0.75, 0.78, 0.82, 0.84, 0.88, 0.95, 1.05, 1.08, 1.1, 1.15, 1.18, 1.2, 1.28, 1.55, 1.8, 2.2 rem\n\nOnly 0.7, 0.85, 1 and 2 rem land on the documented scale. The densest clusters are the 0.78-0.88 band (12 declarations doing what one or two steps should do) and the 1.05-1.28 band (6 declarations sitting between `body` and `title`).\n\nWhy it matters: this is the Operate surface a daily user lives in. A ramp with 22 values has no rhythm — sizes stop signalling hierarchy and start reading as accident, and every new component invents another value because no existing one fits. It also makes the documented system a fiction, which is worse than having no system.\n\nTwo legitimate outcomes; pick deliberately rather than defaulting:\n- **Snap to the ramp.** Map each off-ramp value to its nearest documented step and fix the fallout. Expect real visual change — this is not a no-op refactor.\n- **Extend the ramp.** If the desk genuinely needs steps between `label` and `body` (chips, meta rows, tree labels all live there), add the missing steps to DESIGN.md as named tokens and use those. The system should describe the product, not vice versa.\n\nDo not resolve this by suppressing the detector rule. The design hook flags these on every edit to the file; that noise is the symptom, not the problem.\n\nRelated: web/src/hosted/landing/landing.css and web/src/hosted/landing-alt/alternate-landing.css carry the same drift (10 and 12 findings respectively) and should move with whichever decision is taken.\n","status":"open","priority":2,"issue_type":"task","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T23:24:11Z","created_by":"Angus Bezzina","updated_at":"2026-08-04T23:24:11Z","labels":["design","design-system","hosted","web"],"dependencies":[{"issue_id":"attn-n01r.8","depends_on_id":"attn-n01r","type":"parent-child","created_at":"2026-08-04T18:24:10Z","created_by":"Angus Bezzina","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-n01r.7","title":"Show a file-type icon for every file in the workspace sidebar, not just README.md","description":"The workspace sidebar shows a file-type icon for `README.md` but for no other Markdown file, so the list reads as broken rather than as deliberately quiet.\n\nRoot cause is confirmed: `web/src/lib/icon-resolver.ts:4` defines `MARKDOWN_NO_ICON = new Set(['md'])` and lines 31-34 return `null` for any `.md` file unless the caller passes `{ includeMarkdown: true }`. `README.md` escapes only because the by-name lookup at line 26 runs before that early return.\n\nThe two call sites already disagree:\n- `web/src/lib/FileTree.svelte:143` — `resolveFileIcon(node.name)`, no options, so every `.md` loses its icon. This is the sidebar in the screenshot.\n- `web/src/lib/DirectoryOverview.svelte:98` — `resolveFileIcon(node.name, { includeMarkdown: true })`, so the overview does show them.\n\nFix the inconsistency in favour of showing icons everywhere. Decide whether `includeMarkdown` should survive at all: if no surface wants markdown suppressed, delete the flag and `MARKDOWN_NO_ICON` rather than leaving a dead option behind.\n\nCheck the resulting density at sidebar width — the original suppression presumably existed because a column of near-identical markdown glyphs is noisy. If that turns out to be the real trade-off, the answer is a better markdown glyph, not a missing one.\n","status":"closed","priority":2,"issue_type":"task","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T23:22:34Z","created_by":"Angus Bezzina","updated_at":"2026-08-05T02:26:08Z","closed_at":"2026-08-05T02:26:08Z","close_reason":"Fixed, and the dead option removed rather than defaulted.\n\n- icon-resolver.ts: deleted MARKDOWN_NO_ICON and the includeMarkdown parameter entirely. Every file\n now resolves an icon; the filename lookup still runs first, so README.md keeps its specific glyph.\n- FileTree.svelte:141 and DirectoryOverview.svelte:98 both call resolveFileIcon(node.name) — the two\n surfaces no longer disagree.\n\nOnce both call sites wanted markdown icons the flag had no remaining caller, so leaving it would have\nbeen a dead option in the API. If icon density in a Markdown-heavy tree turns out to be the real\nproblem, the answer is a better Markdown glyph, not a missing one — noted in the function's doc\ncomment so the next person does not simply re-add the suppression.\n\nsvelte-check 0 errors; 97/97 unit tests pass.","labels":["design","hosted","web"],"dependencies":[{"issue_id":"attn-n01r.7","depends_on_id":"attn-n01r","type":"parent-child","created_at":"2026-08-04T18:22:34Z","created_by":"Angus Bezzina","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-n01r.4","title":"Mobile editor: give workspace rename a deliberate affordance instead of a one-tap title","description":"While editing on mobile, the workspace title is a bare button that flips straight into a text input on a single tap (web/src/hosted/app/EditorShell.svelte:2877-2886). Renaming the workspace is a deliberate, infrequent act, and it is currently one stray thumb away — directly above the document, in the thumb's travel path.\n\nGive rename a deliberate affordance instead. Options, in rough order of preference:\n- An explicit edit/pencil icon button next to the title (pairs with the icon work in the sibling issue).\n- Move rename into the Files sheet or an overflow menu, where the desk already keeps it.\n- Long-press, only if paired with a visible affordance — a hidden gesture alone fails discoverability.\n\nThe title itself should stay a stable, non-interactive label at rest. Keep the existing Enter-commits / Escape-cancels / blur-commits behaviour once editing is entered, and keep the `Workspace title` accessible name.\n","status":"closed","priority":2,"issue_type":"task","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T23:22:31Z","created_by":"Angus Bezzina","updated_at":"2026-08-05T02:54:38Z","started_at":"2026-08-05T02:53:14Z","closed_at":"2026-08-05T02:54:38Z","close_reason":"Fixed with an explicit affordance, which was the issue's first-choice option.\n\nThe workspace title on mobile was a bare \u003cbutton\u003e that flipped straight into a text input on a single\ntap — directly above the document, in the thumb's travel path, for something done maybe once per\nworkspace.\n\nThe name is now an inert \u003cspan\u003e, and rename moved to a separate pencil button beside it: 44x44,\ntitle='Rename workspace' for hover, and aria-label naming the workspace. Existing behaviour once\nediting is entered is untouched — Enter commits, Escape cancels, blur commits, and the input keeps\nits 'Workspace title' accessible name.\n\nChose the icon button over long-press deliberately: the issue notes a hidden gesture alone fails\ndiscoverability, and this pairs with the icon work already landed in .2 so the desk and the mobile\neditor now use the same pencil for the same act.\n\nsvelte-check 0 errors; 97/97 unit test files pass.","labels":["design","hosted","web"],"dependencies":[{"issue_id":"attn-n01r.4","depends_on_id":"attn-n01r","type":"parent-child","created_at":"2026-08-04T18:22:31Z","created_by":"Angus Bezzina","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-n01r.3","title":"Mobile editor: remove the dead gap between the formatting bar and the bottom dock","description":"On mobile, an empty band sits between the formatting toolbar (B / I / H2 / bullets / undo / redo) and the bottom Files / Review / Done dock. The two bars should stack flush; instead there is a visible gap of dead background between them.\n\nSuspected cause: the edit bar is positioned with a keyboard offset (`--kb-offset`, web/src/hosted/app/EditorShell.svelte:3007) while the dock reserves its own height via `--dock-h: calc(64px + env(safe-area-inset-bottom, 0px))` (web/src/hosted/app/app-shell.css:980). The safe-area inset is a candidate for being counted twice — confirm before fixing.\n\nVerify on a real iPhone-sized viewport with the software keyboard both closed and open, since `--kb-offset` changes under the keyboard and the fix must hold in both states.\n","status":"open","priority":2,"issue_type":"task","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T23:22:30Z","created_by":"Angus Bezzina","updated_at":"2026-08-05T02:56:38Z","started_at":"2026-08-05T02:55:20Z","labels":["design","hosted","web"],"dependencies":[{"issue_id":"attn-n01r.3","depends_on_id":"attn-n01r","type":"parent-child","created_at":"2026-08-04T18:22:30Z","created_by":"Angus Bezzina","metadata":"{}"}],"comments":[{"id":"019fcfd9-fd7d-7910-8cae-3a993748cf63","issue_id":"attn-n01r.3","author":"Angus Bezzina","text":"NOT FIXED — I could not reproduce it in this environment, and guessing at the safe-area maths is what would produce another wrong answer.\n\nWhat I could measure (Chrome, 390x844, isMobile+hasTouch, a real workspace open):\n .thumb-dock top 783, bottom 844, height 61, display grid\n --dock-h resolves to calc(64px + 0px) = 64px\n .edit-bar NOT PRESENT — it only renders under `editing \u0026\u0026 !desktopLayout`, i.e. once the\n editor actually has focus, which I did not reach in the automated run.\n\nTwo things that follow:\n\n1. **The gap depends on env(safe-area-inset-bottom), which desktop Chrome reports as 0.** On the\n user's device that inset is ~34px. --dock-h is calc(64px + env(safe-area-inset-bottom)) and\n .edit-bar sits at bottom: calc(var(--kb-offset) + var(--dock-h)), while .thumb-dock is fixed at\n bottom 0 with its own padding-bottom: env(safe-area-inset-bottom). Whether those compose to flush\n or to a double-counted inset cannot be observed with the inset at 0 — which is precisely the\n condition under which the original code looks correct.\n\n2. **There is a real 3px discrepancy visible even at inset 0**: the dock measures 61px tall while\n --dock-h claims 64px. The edit bar is positioned off the token, not off the dock, so the two can\n disagree by whatever the dock's actual height differs from the constant. That is the structural\n smell — a magic number standing in for a measured height — and it is the likely mechanism behind\n the reported gap regardless of the inset.\n\nRECOMMENDED FIX, for whoever has a device: stop hard-coding the dock height. Measure .thumb-dock with\na ResizeObserver and publish it as --dock-h, so the edit bar rides the dock's real box in every\nsafe-area and keyboard state instead of a constant that happens to be close. Verify with the software\nkeyboard both closed and open, since --kb-offset changes under it and the fix has to hold in both.\n\nNeeds: an iOS device or simulator. Not reproducible in headless Chrome.","created_at":"2026-08-05T02:56:38Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"attn-n01r.2","title":"Replace text labels with icons on repeated actions (Copy, desk row actions)","description":"Text labels are doing work icons should do, which costs horizontal room and reads as chrome rather than as a precision tool.\n\nTwo confirmed instances:\n\n1. **\"Copy\" on code blocks.** web/src/hosted/landing/CopyCode.svelte renders a text button that toggles between \"Copy\" and \"Copied\". On the native-install section at mobile width, two stacked commands each carry a full-width-competing \"Copy\" word. Replace with a clipboard icon that swaps to a check on success, keeping the existing aria-label (`Copy \u003ccommand\u003e`) so the control stays announced, and keep the copied-state confirmation non-visual-only.\n\n2. **Desk row and header actions.** The desk row exposes \"Rename\" and \"Delete\" as text (web/src/hosted/app/DeskHome.svelte:101-118, :216), and the app header carries a text \"Storage\" button (DeskHome.svelte:118). These are repeated per row and per visit.\n\nRule for this work: an icon replaces a text label only where the action is conventional enough to be recognised without the word (copy, delete, rename, settings). Anything ambiguous keeps its text. Every icon-only control needs an accessible name and a `title` for hover, and must still clear a 44x44 touch target. Do not turn the desk into an icon puzzle — PRODUCT.md optimises for the hundredth visit, but Jordan-class first-timers still have to find Delete.\n","status":"closed","priority":2,"issue_type":"task","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T23:22:29Z","created_by":"Angus Bezzina","updated_at":"2026-08-05T02:52:02Z","started_at":"2026-08-05T02:48:40Z","closed_at":"2026-08-05T02:52:02Z","close_reason":"Both instances done, following the issue's own rule about which labels may become icons.\n\n1. COPY ON CODE BLOCKS — CopyCode.svelte now renders a clipboard icon that swaps to a check on\n success. Verified: 44x44, text content empty, title='Copy' for hover, and aria-label naming the\n specific command so two adjacent copy buttons stay distinguishable in a button list.\n\n Fixed the two a11y defects in the same component while there (from .19):\n - The accessible name was pinned to forever. It now tracks state — verified\n changing to 'Copied brew install lightsofapollo/attn/attn' after activation.\n - The empty catch made a denied clipboard permission indistinguishable from success. It now\n reports failure and says the command is still selectable.\n - Added a polite live region (a button's own name change is not reliably re-read after\n activation, and the page had no live region at all). Verified announcing on copy.\n - Added the .visually-hidden utility to chrome.css, which the page lacked.\n\n2. DESK ROW ACTIONS — Rename and Delete are now pencil and trash icons, 44x44, each with a title for\n hover and an aria-label naming the workspace. These repeat on every row, so at a realistic\n workspace count the three most-repeated words on the desk were 'Rename, Delete, Rename, Delete…'.\n\nDELIBERATELY LEFT AS TEXT: the header 'Storage' button. The issue's rule is that an icon replaces a\nlabel only where the action is conventional enough to be recognised without the word — Storage is a\nnavigation destination, not a conventional glyph, and there is no established icon for it.\n\nThe Jordan-first-timer risk the issue raises is covered: every icon control keeps a hover title and\nan accessible name, and none of them is the primary path.\n\nsvelte-check 0 errors; 97/97 unit test files pass. Verified visually at 1280.","labels":["design","hosted","web"],"dependencies":[{"issue_id":"attn-n01r.2","depends_on_id":"attn-n01r","type":"parent-child","created_at":"2026-08-04T18:22:29Z","created_by":"Angus Bezzina","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-rd3j.9","title":"Pin code-block copy button to the block top-right","description":"The live code-block NodeView (web/src/lib/prosemirror/code-block-nodeview.ts:33-70) appends the copy button INSIDE div.prose-scroll-x — the horizontal scroll container — so .code-copy-btn position:absolute (prosemirror.css:128-165, top/right .5rem, hover-revealed) scrolls away with wide content. Fix: restructure the NodeView DOM to an outer non-scrolling wrapper (position:relative) containing the scroller plus the button as a sibling of the scroller, so the button stays pinned at the visual top-right. Keep: hover reveal, copy-\u003echeck swap (1500ms), the data-params language label (prosemirror.css:340-355 — pin it too, it has the same defect), and ProseMirror NodeView correctness (contentDOM must remain the code element; the extra wrapper must not break node selection, arrow navigation, or gap cursor). Coordinate DOM shape with the tables/scroll-rail issue since both touch .prose-scroll-x.","acceptance_criteria":"With a horizontally-overflowing code block, copy button and language label stay fixed at the visual top-right while content scrolls beneath; copy works; hover reveal preserved; editing inside code blocks (typing, selection, arrows) unaffected.","notes":"The NodeView appended the copy button INSIDE the horizontal scroller, so it slid away with wide content. Restructured to frame \u003e scroller \u003e pre \u003e code (div.code-block is a non-scrolling positioning context); the button and the language label now pin to the frame. The language label had the same defect and moved with it — the pinned form also gets its vertical lane back via padding-top so nothing overlaps. contentDOM is still the code element, so editing/selection/gap-cursor behavior is unchanged.\n\nVERIFIED live: with a 106px-overflowing block the button moved 0px while the content scrolled, staying 8px from the frame's right edge.","status":"closed","priority":2,"issue_type":"bug","assignee":"Angus Bezzina","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T14:58:28Z","created_by":"Angus Bezzina","updated_at":"2026-08-04T16:51:16Z","started_at":"2026-08-04T15:07:58Z","closed_at":"2026-08-04T16:51:16Z","close_reason":"Closed","dependencies":[{"issue_id":"attn-rd3j.9","depends_on_id":"attn-rd3j","type":"parent-child","created_at":"2026-08-04T09:58:28Z","created_by":"Angus Bezzina","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-rd3j.8","title":"Remove stray horizontal scroll rail under markdown tables","description":"Tables are wrapped by the ProseMirror schema (web/src/lib/schema.ts:124) in div.prose-scroll-x (prosemirror.css:114-125: overflow-x:auto, overflow-y:hidden, ring outline, radius). A grey native scrollbar rail renders beneath tables (screenshot shows a floating pill rail overlapping the row below the table) even when scrolling is not wanted. Fix: (a) overflow UI should appear only when content actually overflows, styled to match the app — either thin themed overlay scrollbars (::-webkit-scrollbar in WKWebView, matching the ScrollArea look) or migrate the wrapper to a ScrollArea treatment; note web/src/lib/CodeBlockScrollArea.svelte and its .prose-scroll-area-x CSS (prosemirror.css:184-208) are existing DEAD code for exactly this — revive properly or delete; (b) resolve the width conflict: .ProseMirror table width:100% (prosemirror.css:315) vs .prose-scroll-x \u003e table width:max-content (:177); (c) the same treatment applies to code blocks, which share .prose-scroll-x. Verify both themes.","acceptance_criteria":"No visible rail under tables that fit their container; overflowing tables get a thin themed scrollbar consistent with app ScrollAreas; no layout shift when the rail appears; code blocks get the same treatment; dead CodeBlockScrollArea path resolved.","notes":"ROOT CAUSE (measured, not guessed): base.css styles ::-webkit-scrollbar globally, and in WKWebView styling it at all switches that element from macOS OVERLAY scrollbars to CLASSIC space-reserving ones. An overflowing table had 13px of its height carved out for an always-painted grey rail sitting under the last row, inside the block's own border — that gutter IS the reported rail. Measured alternatives: scrollbar-width:auto = 17px, webkit revert = 17px, hidden = 0px. No value restores overlay behavior.\n\nFix: prose scrollers hide the bar (scrollbar-width:none + ::-webkit-scrollbar{display:none}) and scroll by trackpad/shift-wheel — the same treatment PathBreadcrumb already uses. Nothing is reserved, so no layout shift. The rules must live OUTSIDE @layer components because base.css's scrollbar rules are deliberately unlayered and unlayered beats layered (first attempt silently lost the cascade). Also resolved the dead CodeBlockScrollArea.svelte + .prose-scroll-area-x CSS by deleting both.\n\nVERIFIED live: reserved space 13px -\u003e 0px on every overflowing block, scrolling still works.","status":"closed","priority":2,"issue_type":"bug","assignee":"Angus Bezzina","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T14:58:27Z","created_by":"Angus Bezzina","updated_at":"2026-08-04T16:51:15Z","started_at":"2026-08-04T15:07:57Z","closed_at":"2026-08-04T16:51:15Z","close_reason":"Closed","dependencies":[{"issue_id":"attn-rd3j.8","depends_on_id":"attn-rd3j","type":"parent-child","created_at":"2026-08-04T09:58:27Z","created_by":"Angus Bezzina","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-rd3j.7","title":"Preferred typeset presets in settings (shadcn typeset model)","description":"Fonts are fixed in tokens.css:18-20 (--serif/--sans/--mono); font scale exists keyboard-only (web/src/lib/font-scale.ts, Cmd +/-/0, localStorage attn.fontScale, var --attn-font-scale) with no UI. Add curated typeset presets selectable in the settings surface built by the theming issue, following the shadcn typeset model (https://ui.shadcn.com/docs/typeset): each preset defines the document font pairing + type scale. Ship at least: Editorial (current serif identity — default, pixel-identical to today), Modern (sans-first reading set), Compact (denser scale, mono-leaning for ops docs). Implement as data-typeset attribute on the document root swapping --serif/--sans/--mono/--attn-base-font-size custom properties; persist like the theme preference (localStorage + IPC seed to avoid font flash); settings UI shows a live preview per preset; Cmd +/- font-scale stays orthogonal and composes.","acceptance_criteria":"At least three presets selectable in settings; selection applies instantly, persists across restarts, composes with light/dark and font scale; default preset is visually identical to current rendering.","notes":"Three presets (Editorial/Modern/Compact) in web/styles/typeset.css, keyed off data-typeset on \u003chtml\u003e, persisted through a new typeset_change IPC into the same prefs.json and seeded by Rust before first paint. Editorial deliberately declares NO overrides — the tokens in tokens.css are the editorial preset, so the default path cannot drift. Settings shows each preset with a live specimen rendered under that preset's own tokens. Composes with theme and the Cmd+/- font scale. DESIGN.md §3 documents the presets.","status":"closed","priority":2,"issue_type":"feature","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T14:58:26Z","created_by":"Angus Bezzina","updated_at":"2026-08-04T16:51:15Z","closed_at":"2026-08-04T16:51:15Z","close_reason":"Closed","dependencies":[{"issue_id":"attn-rd3j.7","depends_on_id":"attn-rd3j","type":"parent-child","created_at":"2026-08-04T09:58:26Z","created_by":"Angus Bezzina","metadata":"{}"},{"issue_id":"attn-rd3j.7","depends_on_id":"attn-rd3j.6","type":"blocks","created_at":"2026-08-04T09:58:30Z","created_by":"Angus Bezzina","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-rd3j.5","title":"Share dialog phantom checkmark; wrap dialog and dropdown scrollbars in ScrollArea","description":"Phantom checkmark top-left of the Share dialog: ShareDialog.svelte contains no such element — it is the unscoped base.css checkbox rule (:202-235) leaking into Dialog.Content (not .attn-chrome): any li-wrapped checkbox gets an absolutely-positioned custom checkmark pinned left:0 top. The per-file picker checkboxes (ShareDialog.svelte:422-427) trigger it. Structurally fixed by the style-scoping task; this issue verifies that and does the scrollbar work: (a) wrap the ShareDialog scrollable body (Dialog.Content :375-683, currently native overflow with a raw scrollbar) in the existing shadcn ScrollArea (web/src/lib/components/ui/scroll-area, already used by App content viewport, Sidebar :465, TabBar :36); (b) wrap the project-switcher dropdown Command.List (Sidebar.svelte:328-399, max-h-[300px]) in ScrollArea — this is the nav dropdown with the ugly native scrollbar in the screenshot; (c) sweep remaining raw chrome scrollbars (hosted ShareSheet.svelte native dialog :368, ResidentSettings panel, other Dialog/Sheet/DropdownMenu overflow) and standardize on ScrollArea.","acceptance_criteria":"No stray checkmark in the Share dialog; Share dialog body, project dropdown list and other chrome scroll surfaces use ScrollArea-styled scrollbars consistent with the sidebar; no native scrollbar visible in chrome surfaces.","notes":"Phantom checkmark CONFIRMED as the base.css leak and fixed structurally by attn-rd3j.3 — ShareDialog never contained such an element; the unscoped 'li:has(\u003e input[type=checkbox]) \u003e input' rule drew a custom checkmark pinned at left:0 inside the dialog. Verified visually: dialog now renders clean.\n\nScrollbars: dialog-content.svelte now routes its body through the shared ScrollArea (new data-slot=dialog-content-body) with the close button pinned to the frame outside the scroller; ShareDialog's file picker and 'reviewers receive' list use ScrollArea; the project-switcher Command.List in Sidebar.svelte is wrapped too. Added a viewportClasses prop to ScrollArea so height caps land on the element that actually scrolls. Measured: ScrollArea viewports reserve 0px, vs 13px for a native styled scrollbar.","status":"closed","priority":2,"issue_type":"bug","assignee":"Angus Bezzina","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T14:57:45Z","created_by":"Angus Bezzina","updated_at":"2026-08-04T16:51:14Z","started_at":"2026-08-04T14:58:48Z","closed_at":"2026-08-04T16:51:14Z","close_reason":"Closed","dependencies":[{"issue_id":"attn-rd3j.5","depends_on_id":"attn-rd3j.3","type":"blocks","created_at":"2026-08-04T09:58:30Z","created_by":"Angus Bezzina","metadata":"{}"},{"issue_id":"attn-rd3j.5","depends_on_id":"attn-rd3j","type":"parent-child","created_at":"2026-08-04T09:57:45Z","created_by":"Angus Bezzina","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-cyvj","title":"Replace viewport presence ring with margin flag","description":"Replace the ambiguous circular scroll-position marker with a zero-layout-width margin flag that reveals participant identity on hover/focus, stacks cleanly, and suppresses redundant markers when the peer caret is already in the viewed block.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-25T21:26:14Z","created_by":"James Lal","updated_at":"2026-07-25T21:30:26Z","started_at":"2026-07-25T21:26:18Z","closed_at":"2026-07-25T21:30:26Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-o7sq","title":"Paste markdown source as rendered markdown, not copy-style HTML","description":"The ProseMirror editor has no custom paste handling, so ProseMirror DOM-parses clipboard text/html through schema parseDOM rules (brings visual 'copy style' that serializes to junk) and drops raw markdown text/plain in literally. Add a handlePaste plugin that routes pasted markdown source through the same markdownParser used to load docs, so pasted markdown renders as real nodes and round-trips to clean markdown on save. Preserve internal PM copies (data-pm-slice), honor genuinely-rich foreign HTML, and leave code blocks raw.","notes":"Added markdownPastePlugin (web/src/lib/prosemirror/markdown-paste.ts) wired into Editor.svelte buildPlugins. handlePaste routes text/plain markdown through the same markdownParser used to load docs. Guards: skips code blocks/code marks (raw text), skips internal PM copies (data-pm-slice), honors rich foreign HTML unless plain text has block-level markdown markers. 7 unit tests pass; verified in-browser via Playwright (md source renders as nodes; code-block paste stays raw; rich HTML honored). svelte-check 0 errors.","status":"in_progress","priority":2,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-24T04:44:11Z","created_by":"James Lal","updated_at":"2026-07-24T04:50:14Z","started_at":"2026-07-24T04:44:16Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-qs03","title":"Click a participant chip to jump to their location (file + scroll/caret position)","description":"Feature: clicking a peer chip in the PeerStrip navigates the local user to where that participant currently is — switching to their file in a multi-file share and scrolling the editor to their caret/last-known position. Builds on existing presence-location plumbing (CollabPeerLocation carried on cursor wire messages, notePeerLocation/collaboratorLocations, RemoteCursor.head positions). Needs: (1) per-participant location resolvable from the chip's participantId (map collab clientID\u003c-\u003eparticipantId), including which file and a document position; (2) chip click handler that switches active file (path\u003c-fileId binding) then scrolls the editor to that position using the existing card-focus/coordsAtPos scroll primitive; (3) graceful fallback when the peer is on a file with no live caret (jump to file top) or offline (disabled/last-seen). Keep the identity-card affordance too — decide click vs a jump button on the card.","status":"closed","priority":2,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-23T15:48:21Z","created_by":"James Lal","updated_at":"2026-07-23T16:18:27Z","closed_at":"2026-07-23T16:18:27Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-73xq","title":"Drift assertion + bridge deletion: dev cross-tab fingerprint check, remove superseded mirrors","description":"attn-whdh part 5. Dev-mode-only: tabs exchange a store fingerprint (roomId, event count, thread ids hash) over the tab channel and console.warn loudly on mismatch. Then delete the superseded bespoke bridges (direct store feeds, fallback chains, per-type tees) and their now-dead tests; full staging E2E verification pass (two owner tabs + external reviewer + room rotation) proving convergence.","status":"open","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-23T04:30:15Z","created_by":"James Lal","updated_at":"2026-07-23T04:30:15Z","dependencies":[{"issue_id":"attn-73xq","depends_on_id":"attn-9ek7","type":"blocks","created_at":"2026-07-22T22:30:38Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-73xq","depends_on_id":"attn-d2w3","type":"blocks","created_at":"2026-07-22T22:30:39Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-d2w3","title":"Ephemera bus: one tab-channel lane for cursors + presence replacing per-type tees","description":"attn-whdh part 4. Replace the hub cursor tee (attn-37f9) and presence broadcast (attn-90qq) with one generic ephemera message on the tab channel keyed by room+generation: leader publishes {cursors, presence} snapshots/deltas, every tab (leader included) consumes through the same subscription. Validated payloads, bounded sizes, re-sent on hello.","notes":"ASSESSMENT after core landed: this is now cohesion-only. The review-STATE divergence (threads/comments/counts — the painful bugs) is fully solved by the single projection + drift guard. Cursors + presence are EPHEMERAL: they self-heal every tick, never persist, and their bridges (attn-37f9 cursor tee, attn-90qq presence broadcast) are staging-verified and working. Collapsing them into one generic ephemera lane changes NOTHING a user feels and risks regressing verified code. The one genuine smell: cursor ephemera rides the doc-step 'collab' message kind (presence already has its own kind). RECOMMENDATION: defer unless the cohesion is wanted; low ROI, non-trivial regression risk. Holding the anti-rabbit-hole guardrail per operating manual.","status":"in_progress","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-23T04:30:14Z","created_by":"James Lal","updated_at":"2026-07-23T14:48:54Z","started_at":"2026-07-23T14:42:15Z","dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-tqkw","title":"Relay: room-gone 404s should carry CORS for allowed origins","description":"Follow-up to attn-hh9r: a TTL-wiped RoomDO answers 404 ATTN_ROOM_NOT_FOUND without CORS headers because corsMiddleware (relay/src/index.ts:137) keys off the stored room policy (X-Attn-Allow-Browser), which is gone after the wipe. Cross-origin browsers therefore see an opaque TypeError instead of the 404. The client now disambiguates via the share route, but the relay should reflect allowed origins on room-gone errors (safe: no data, origin allowlist already exists) so statuses are readable directly.","status":"open","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-23T04:07:04Z","created_by":"James Lal","updated_at":"2026-07-23T04:07:04Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-bpsb","title":"Stale authority-paused banners persist after a tab downgrades to follower (lease expired)","description":"User screenshot: a tab that lost the workspace authority lease (sleep/background throttling) shows THREE overlapping surfaces at once: 'Read-only tab' chip + 'Following another tab's edits' banner (correct, current) plus the leader-era residue 'Live review is paused: Workspace authority lease expired' banner and the right-rail 'Workspace authority lease expired / Reconnect' error. Once the tab is following (joinState live or lease held elsewhere), the stale paused-authority error should clear — one honest status, not three. Cosmetic/UX only; behavior (fail-safe downgrade + retry/auto-recovery) is correct.","status":"open","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-23T00:49:52Z","created_by":"James Lal","updated_at":"2026-07-23T00:49:52Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-5xgz","title":"Remote selections: overlapping selections/carets from two peers render as occluded labels and muddy/lost tints","description":"User report with screenshot: two reviewers select the same text; caret labels sit at identical absolute offsets so one occludes the other, and overlapping .attn-remote-selection inline decorations either stack tints or lose one color depending on span merging. Fix: (1) deterministic vertical label stacking for carets sharing a clamped head position; (2) plugin-computed disjoint selection segments — segments covered by multiple peers render one decoration with an explicit two-color treatment (striped gradient of both peers' tints) instead of relying on CSS nesting.","status":"closed","priority":2,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-22T15:02:14Z","created_by":"James Lal","updated_at":"2026-07-22T15:09:49Z","started_at":"2026-07-22T15:02:25Z","closed_at":"2026-07-22T15:09:49Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-g984","title":"Add native Stop sharing control to match mobile","description":"During attn-qfda QA, the native header and live-sharing state were aligned with the mobile masthead, but the mobile ShareSheet exposes a confirmed Stop sharing action while the native owner ShareChip/ShareDialog has no equivalent control. Add an owner-only, confirmation-gated native stop action wired to reviewStop, preserve draft/focus behavior, and verify relay revoke plus local tombstone state.","acceptance_criteria":"A native owner can stop an active share from the sharing control with explicit confirmation; the relay room is revoked and local UI settles without wedging; reviewer/hosted behavior is unchanged; focused tests and native UI automation pass.","status":"open","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-22T00:32:30Z","created_by":"James Lal","updated_at":"2026-07-22T00:32:30Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-jajl","title":"Remembered /s/ reopen: upgrade to full live session (threads + authoring)","description":"The remembered binding holds room read capability + admission keys — enough to subscribe to the room and replay events, not just load durable snapshots. Today a fragmentless reopen shows the document but threads:0 and no authoring. Upgrade the remembered path to a live BrowserSession so continuity is complete.","status":"closed","priority":2,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-21T17:37:07Z","created_by":"James Lal","updated_at":"2026-07-21T18:29:01Z","closed_at":"2026-07-21T18:29:01Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-l9hf","title":"Inbound envelope drops are silent (owner missing comments undiagnosable)","description":"Every inbound rejection (signature, capability, unknown signer) was swallowed by an empty onError. Now kept in a bounded ring at window.__attnInboundErrors (last 5 surfaced in __attnCollabDebug.review), and the unknown-signer path only logs when the directory refresh STILL lacks the signer (a real drop), not on the benign join race.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-21T04:23:38Z","created_by":"James Lal","updated_at":"2026-07-21T04:25:41Z","closed_at":"2026-07-21T04:25:41Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-oldm","title":"[bug] attn-47r live-follower co-editing (joinLive) never engages on hosted","description":"The editable live-follower mode (join the owner's LocalCollab hub as a CollabClient, joinLive=true) never reaches 'live' in practice: the denied tab keeps the lease-denied banner and its editable join stays in connecting. The read-only mirror DOES sync in realtime (verified: owner keystrokes stream into the follower mirror within ~1s), and intent-based seamless handoff covers editing-from-any-tab, so this is not user-blocking — but the joinLive machinery is now dead code that either needs fixing (true simultaneous co-typing across two windows) or removing per the shipping model. Decide direction as part of closing attn-7xl.7.10.","notes":"2026-07-19 correction: joinLive DOES engage — nondeterministically. Probe evidence (window.__attnCollabDebug on the built bundle): after B claims the pen, A reached joinStatus:'live' with boundCollabKey 'join:gen-…' and typed successfully THROUGH B's hub (writable null — no ownership transfer; the focus-intent veto kept B the owner). The earlier 'never engages' observation was the race's other arm. Reframe: the defect is nondeterminism — whether a denied tab lands in live-follow vs read-only mirror vs ownership-claim depends on ring/veto/join timing. attn-x1k is the ownership-claim arm of the same race.","status":"closed","priority":2,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-20T00:45:16Z","created_by":"James Lal","updated_at":"2026-07-20T14:15:51Z","closed_at":"2026-07-20T14:15:51Z","close_reason":"Resolved by join-first: the live-follower join is now the DETERMINISTIC default for denied tabs (the denied loop prefers the holder's hub and defers ownership rings while the join settles — 4s grace). Verified across 85/85 routes + 31/31 storage gates including WebKit: duplicated tabs co-edit live, mobile Edit tap joins, authority-death re-election converges.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-8wo","title":"Build alternate interactive-demo homepage","description":"Add a separate /homepage-alt marketing route that repositions attn around private Markdown review with people and AI agents, including a lightweight interactive demo, while preserving the current homepage.","status":"closed","priority":2,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-19T13:43:07Z","created_by":"James Lal","updated_at":"2026-07-19T13:54:21Z","started_at":"2026-07-19T13:43:13Z","closed_at":"2026-07-19T13:54:21Z","close_reason":"Alternate /homepage-alt positioning study implemented with interactive review demo, responsive layouts, theme support, and passing accessibility/build checks.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-24l","title":"Port the owner mobile chrome to the reviewer page","description":"User noticed the reviewer page's mobile mode is functional but bare compared to the owner surface: it has a plain fixed header + Review bottom-sheet dock, while the owner mobile has the frosted masthead (nav veil + backdrop blur), workspace/document title crossfade, reading-progress hairline, safe-area handling, and the aligned one-row chip treatment (title + save chip pill + action, all one center line). Port that masthead grammar to BrowserReviewApp's mobile branch: frosted sticky header, doc title crossfade with the document h1, reading progress, status chip seated on the same row, and reconcile the Review dock button with the agentation FAB (they collide bottom-right at 390px). Keep the BottomSheet review pattern.","status":"open","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-19T04:16:20Z","created_by":"James Lal","updated_at":"2026-07-19T04:16:20Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-ojs","title":"Compress snapshot plaintext before encryption (gzip pre-seal, sniff on decode)","description":"User hit the snapshot size wall with a ~263 KiB doc (turned out to be the maxSnapshotBytes plumbing bug, fixed) and raised compression: encrypted blobs can't be compressed by any downstream layer, so compression must happen client-side BEFORE sealing. Design: gzip the snapshot plaintext bytes pre-encryption (CompressionStream in web, flate2 on native — watch the 32 MiB binary gate), sniff gzip magic (1f 8b) after decrypt so readers handle both forms during cutover. The snapshot plaintext is a shared native/browser wire format with conformance tests (parseBrowserSnapshotPlaintext) — this MUST land in both clients plus the conformance suite together, per no-backwards-compat cutover rules. Markdown compresses ~4-6x, so the effective snapshot ceiling rises from 5 MiB to ~20-30 MiB of prose, and R2 spillover traffic drops.","status":"closed","priority":2,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-18T21:30:48Z","created_by":"James Lal","updated_at":"2026-07-18T22:16:19Z","closed_at":"2026-07-18T22:16:19Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-qtz","title":"Owner share resume fails with 'durable share manifest digest is invalid' on a real long-lived workspace","description":"Surfaced by the attn-dkr banner on James's own profile (localhost, workspace u1vXcbuAi_MXrgVERYZEKA / validation-01): share resume errors with 'durable share manifest digest is invalid' and live sharing stays off until Retry/re-share. Likely a manifest schema or digest-computation change across sessions for a share record minted by an older build. Needs: reproduce against an old-format record, decide migrate-or-retire (probably retire the stale share record and let the owner re-publish cleanly instead of erroring forever).","status":"closed","priority":2,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-18T05:46:04Z","created_by":"James Lal","updated_at":"2026-07-18T06:01:39Z","closed_at":"2026-07-18T06:01:39Z","close_reason":"Root cause: commit e2c6df5 changed the manifest-digest sort from localeCompare to code-unit order (to fix joiner rejection of multi-file manifests), so every share record written by the older build carries a digest the new owner client can never re-verify — decodeRecord threw 'durable share manifest digest is invalid' on the owner's own record read, which pauses publishing forever (Resume re-fails). Joiners were unaffected: they compare the sealed bundle's digest to the record's stored digest (both old-order, consistent) and never recompute. Fix: the owner read no longer throws — the record decodes with manifestDigestValid:false, and the publish flow treats an unverifiable digest as not-exact, forcing the commit upsert that rewrites the manifest with a fresh code-unit digest (self-healing on the next Resume/publish). Regression tests in browser-share-owner.test.ts (legacy digest decodes flagged, fields usable for the healing commit); 83 test files green.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-mwn","title":"Reviewer identity is 'Browser reviewer' everywhere — joiners should get a name prompt (or name capture at first comment)","description":"Every /s/ joiner authors as the default 'Browser reviewer' display name; with several reviewers the owner cannot tell feedback apart (screenshot showed a roster of identical 'Browser reviewer' rows). The native app prompts for a display name on room entry (userProfile.requestEdit + name prompt); BrowserReviewApp has no name prompt surface at all. Add a lightweight name confirm on the reviewer surface — ideally inline at first comment submit or in the status-chip popover (You: \u003cname\u003e · Edit like the owner's ShareChip).","status":"closed","priority":2,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-18T00:18:58Z","created_by":"James Lal","updated_at":"2026-07-18T00:19:20Z","closed_at":"2026-07-18T00:19:20Z","close_reason":"Duplicate of attn-sur (reviewer display-name capture).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-dkr","title":"Hosted owner share-resume failure is silent — relay errors (e.g. 507 room cap) leave no trace in the UI","description":"Observed on the local share loop: a long-lived room hit the relay's envelope storage cap (POST /v3/rooms/:id/envelopes → 507 Insufficient Storage). The hosted owner's share session then fails to resume on reload with zero user-visible signal — no error banner, the ShareChip simply never appears and the workspace silently reverts to pre-share posture while reviewers keep a link that still half-works. Two parts: (1) owner UI must surface share-resume/publish failures (chip in attention tone + detail, like the reviewer chip's Sync issue state); (2) relay-side: decide the intended behavior for rooms that hit maxEvents/envelope caps — long-lived rooms accumulate registrations and broadcasts until writes 507.","status":"closed","priority":2,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-18T00:18:48Z","created_by":"James Lal","updated_at":"2026-07-18T01:53:16Z","closed_at":"2026-07-18T01:53:16Z","close_reason":"Owner share-resume failures now surface: EditorShell renders a degraded-banner (data-degraded=share-resume-failed) whenever ownerState.status==='error' with no room — 'Live sharing is unavailable' + the runtime reason + Retry. Verified live by killing the relay and reloading the owner: banner shows 'room create failed' instead of the silent chip-less state. Relay-side cap policy for long-lived rooms (the 507 that exposed this) spun out to the relay backlog — the client-side silence this bead was really about is fixed.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-sjz","title":"Hosted owner gets no peer presence: reviewStore.peersResolved stays empty with a live joiner","description":"With a joiner connected on the local hosted share loop, the owner's reviewStore.peersResolved is empty — no peer chips in the header and no people list in the ShareChip popover. EditorShell feeds store.connection from the owner authority session but nothing feeds peers on the hosted owner path (native gets them from the daemon's status payload). Pre-existing before the ShareChip redesign (old ConnectionBadge showed no People here either). Fix locus: browser-owner-workspace-runtime / browser-session presence → reviewStore.peers.","status":"closed","priority":2,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-17T21:24:15Z","created_by":"James Lal","updated_at":"2026-07-18T00:19:21Z","started_at":"2026-07-17T23:48:22Z","closed_at":"2026-07-18T00:19:21Z","close_reason":"Three-part fix, verified live on the local share loop. ROOT CAUSE was deeper than plumbing: browser-storage validateDevice verified self-signatures over a grant-less canonical while v3 durable-share reviewers sign over grantTier+grantSignature (browser-ws registrationBytes) — every /s/ joiner device threw 'self-signature is invalid' during persistDirectoryAndRoom, which the presence-join path awaited, so live peers froze at 'away'. Fixed the canonical to mirror registrationBytes (+ regression test in browser-storage.test.ts), made directory persistence best-effort so storage can never veto presence, added peers to BrowserSessionState (computed from bootstrapDevices + onlineDeviceIds), and fed reviewStore.peers from EditorShell with native semantics (online-only roster — kills the pile of stale away rows) with a fingerprint guard. Owner ShareChip popover now shows 'Browser reviewer · here' with a live joiner and 'No one else is here right now' when alone; joiner side confirms ownerOnline/peersOnline via new data attributes on the reviewer shell.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-sur","title":"Reviewer identity: let joiners set a display name (everyone is 'Browser reviewer')","description":"Comment authorship on the /s/ review page is hardcoded displayName 'Browser reviewer' (browser-share-production.ts participant_joined assembly; browser-session.ts has an unused displayName option). Multi-reviewer rooms collapse into identical author labels. Add a lightweight name affordance on first comment (persist per browser profile) and thread it into the participant_joined event.","status":"closed","priority":2,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-17T00:25:34Z","created_by":"James Lal","updated_at":"2026-07-18T01:53:14Z","closed_at":"2026-07-18T01:53:14Z","close_reason":"Reviewer + hosted-owner identity, verified end-to-end on the local share loop with two isolated browser contexts: (1) profile store now persists to localStorage on hosted surfaces (new lib/browser-profile.ts; native IPC path untouched); (2) NamePrompt mounts on BOTH hosted surfaces — reviewer onboards one-shot on first writable connect, owner one-shot when a room first exists (never stacked over the share sheet); the previously-dead ShareChip/ReviewerStatusChip Edit affordances now open it; (3) name flows into every announce: BrowserSession getDisplayName getter (read at announce time), durable /s/ facade (offline submission joined + live session), owner genesis ownerDisplayName via real-service; (4) BrowserSession.announceProfile() re-announces on rename after the initial announce raced the prompt, threaded through the share facade; (5) store participantNames now resolves latest-by-createdAt instead of arrival order so relay history replay can't resurrect a stale name. Verified: joiner named Rae → comment renders 'R Rae · just now' live on both sides; owner popover shows 'You: James' + 'Rae · here'; reviewer chip popover shows 'Commenting as Rae · Edit'.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-0n2","title":"Joiner surface polish: no branding/context, cryptic status copy, default identity, empty state teaches nothing","description":"/s/ review page at desktop: no attn wordmark or 'shared by' context for a cold visitor; 'Open from this link' and 'Remember \u0026 notify me' unexplained; 'Direct encrypted link'/'Encrypted mailbox' are internal-speak; commenter identity defaults to 'Browser reviewer' with no name prompt (multi-reviewer rooms will all collide); timestamps render '0s' instead of 'just now'; empty rail says 'No review threads on this file.' instead of teaching 'Select any text to comment'.","status":"closed","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-16T23:00:23Z","created_by":"James Lal","updated_at":"2026-07-17T00:25:33Z","started_at":"2026-07-17T00:21:14Z","closed_at":"2026-07-17T00:25:33Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-8l8","title":"Share-create races GET /v3/shares (two 404s in console on every share)","description":"Creating a share fires GET /v3/shares/\u003cid\u003e twice before the POST that creates it, logging two 404 console errors on the happy path (also observed on staging/main at workspace creation, where main additionally DELETEs a snapshot and gets 405). Order the inspect after creation or treat 404 as expected-silent.","status":"closed","priority":2,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-16T23:00:09Z","created_by":"James Lal","updated_at":"2026-07-17T00:12:37Z","started_at":"2026-07-17T00:04:18Z","closed_at":"2026-07-17T00:12:37Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-iar","title":"Share sheet polish: file size shows 0 B; jargon; ⌘K shortcut mislabel; post-share header chips cryptic","description":"(1) File row + summary show '0 B' for a doc with real content — size metadata stale at first open. (2) 'Hybrid delivery', '0 previewable · 0 download-only' unexplained; helper 'revokes every permission level' jargony. (3) Cmd+K palette lists 'Share for review ⌘K' — same chord that opens the palette. (4) After sharing, header gains icon-only chips labeled 'Offline' (shown while pill says Shared · Direct — contradictory) and 'Snapshot current', plus a second Sharing icon — mystery-meat cluster. (5) Svelte ownership_invalid_mutation warning from EditorShell.svelte:403 mutating unbound 'workspace' prop. (6) Mobile owner header pill says 'Saved on this device', dropping the shared state desktop shows.","status":"closed","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-16T23:00:09Z","created_by":"James Lal","updated_at":"2026-07-17T00:21:13Z","started_at":"2026-07-17T00:12:37Z","closed_at":"2026-07-17T00:21:13Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-zqt","title":"Rooms as projects P2: joined rooms in project switcher","description":"Implement P2 from planning/collab/room-as-project-ux.md: represent joined reviewer rooms as projects in the sidebar switcher, route selection through the project navigation path, remove the remaining reviewer room dropdown, and move leave/forget actions onto joined-project entries. Cover native and hosted behavior with focused tests and visual verification.","status":"open","priority":2,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-15T22:17:43Z","created_by":"James Lal","updated_at":"2026-07-15T22:17:43Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-ejq","title":"hosted-lease-handoff webkit spec: passive tabs live-join local collab instead of showing the lease-denied banner","description":"Pre-existing (fails identically at baseline 6d07d23, verified via worktree): the spec expects [data-degraded=lease-denied] on window.open'd passive tabs, but they join the writer's local-collab session ('Live · editing with another tab'), which intentionally suppresses the banner (EditorShell: editDenied \u0026\u0026 !joinLive). Either update the spec to treat live-join as a valid passive state (and run the one-writer election against joinLive tabs), or determine why the local-collab join engages in this webkit scenario when the test was written expecting denial. web/e2e/hosted-lease-handoff.spec.ts:29.","status":"open","priority":2,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-15T14:41:35Z","created_by":"James Lal","updated_at":"2026-07-15T14:41:35Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-9x1","title":"Global / shortcut steals focus into the sidebar from open dialogs","description":"The / handler ignores typing targets but not open modals/menus/lightboxes or a collapsed sidebar; pressing / with a dialog open focuses a control behind the modal, violating the focus boundary. web/src/lib/Sidebar.svelte:99.","status":"closed","priority":2,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-15T03:25:47Z","created_by":"James Lal","updated_at":"2026-07-15T14:36:55Z","closed_at":"2026-07-15T14:36:55Z","close_reason":"Fixed on james/hosted-improvements with regression coverage where testable","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-8pj","title":"BroadcastChannel construction can abort app/component startup","description":"Several constructors only check the global exists; the constructor itself can throw under browser policy restrictions. Lease manager, real service, and the Svelte effect should share one safe constructor helper and degrade consistently. web/src/lib/review/browser-workspace-lease.ts:194, web/src/hosted/app/real-service.ts:140.","status":"closed","priority":2,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-15T03:25:46Z","created_by":"James Lal","updated_at":"2026-07-15T14:36:55Z","closed_at":"2026-07-15T14:36:55Z","close_reason":"Fixed on james/hosted-improvements with regression coverage where testable","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-b3d","title":"Review marker widgets retain stale event IDs: ProseMirror key omits primary event","description":"Widget key contains only the range; ProseMirror treats same-key widgets as interchangeable, so replacing/resolving the primary event at the same range can reuse old DOM with stale data-event-id/label/kind. Include primary event ID + rendered values in the key. web/src/lib/prosemirror/review-decorations.ts:196.","status":"closed","priority":2,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-15T03:25:46Z","created_by":"James Lal","updated_at":"2026-07-15T14:36:55Z","closed_at":"2026-07-15T14:36:55Z","close_reason":"Fixed on james/hosted-improvements with regression coverage where testable","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-hhe","title":"Historical review events toast as newly arrived after switching files","description":"toastsPrimed is global to the component but the priming effect observes only the current file's threads; after the first file primes, navigating to another file treats its history as unseen arrivals. Track priming per file or seed all known events. web/src/hosted/app/EditorShell.svelte:1155.","status":"closed","priority":2,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-15T03:25:45Z","created_by":"James Lal","updated_at":"2026-07-15T14:36:55Z","closed_at":"2026-07-15T14:36:55Z","close_reason":"Fixed on james/hosted-improvements with regression coverage where testable","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-1ze","title":"Rapid file selections resolve out of order in applyEntry","description":"applyEntry() has no request generation or abort guard: slow read A can resolve after fast B, overwriting the newer selection and pushing an extra history entry. web/src/hosted/app/AppShell.svelte:109.","status":"closed","priority":2,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-15T03:25:33Z","created_by":"James Lal","updated_at":"2026-07-15T04:12:57Z","closed_at":"2026-07-15T04:12:57Z","close_reason":"Fixed in commit on james/hosted-improvements; regression tests added for the autosave engine","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-45z","title":"A serialization exception permanently wedges autosave (committing stuck true)","description":"The lazy provider executes outside try/finally; if getMarkdown() throws, committing stays true, the pending provider is cleared, and all future flushes no-op. Reproduced: controller stuck at 'Saving...' with zero commits. web/src/hosted/app/autosave.ts:94.","status":"closed","priority":2,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-15T03:25:33Z","created_by":"James Lal","updated_at":"2026-07-15T04:12:57Z","closed_at":"2026-07-15T04:12:57Z","close_reason":"Fixed in commit on james/hosted-improvements; regression tests added for the autosave engine","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-y6y","title":"Hosted e2e: share-sheet specs expect pre-redesign dialog ('Share for review', tier rows, 'What do you want to share?')","description":"The redesigned ShareSheet (sentence-pattern permissions, quiet chrome — attn-b0d line of work) renamed the dialog to 'Share' and changed its content, but hosted-share-sheet.spec.ts (all 8 tests), hosted-a11y.spec.ts:48/:80, and hosted-shells.spec.ts:65/:155 still assert the old 'Share for review' dialog name and old content strings. All were failing at HEAD bb46048 before the mobile wow-pass (verified via baseline worktree run: 37/66 failed). Also pre-existing red at HEAD: hosted-offline (SW caching under vite dev) and several hosted-authoring cases. Update the specs alongside finishing the share-sheet redesign. Note: mobile share trigger is now the masthead Share button (.editor-top), not the dock — specs this session already point there.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-15T01:59:00Z","created_by":"James Lal","updated_at":"2026-07-18T01:53:16Z","closed_at":"2026-07-18T01:53:16Z","close_reason":"Rewrote hosted-share-sheet.spec.ts against the shipped sheet (dialog 'Share files for review', file checkboxes + Select all/Clear manifest gating, tier \u003cselect\u003e sentence pattern, masked link chip reveal, Stop sharing → Keep sharing focus → Sharing stopped, staging.attn.sh mock origin) — 8/8 pass. Updated the stale dialog-name/content assertions in hosted-a11y.spec.ts and hosted-shells.spec.ts, and fixed the real defect behind their focus-return reds: closeShare now re-resolves the live trigger after the breadcrumb icon remounts (captured node was detached, focus() no-oped). Remaining routes-suite failures are the pre-existing hosted-authoring/offline editor-mount family plus landing theme toggle, unchanged by this work.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nyj","title":"Pre-existing hosted e2e failures on checkpoint/attn-7xl-4-6 (13 tests)","description":"Independent of the attn-4lw shell work (verified by stash-baseline runs): 9 hosted-authoring failures (desktop editor fills canvas [62px bottom gap], desktop Markdown formatting, dragging Markdown import, active rename autosave, pending-text reload guard, export drains, duplicated tab lease, multi-file workspace flow, first-share durability gate), 3 hosted-shells failures (share sheet dialog focus, mobile reader-first dock, share sheet 320px overflow — mobile dock Share is clipped/overlapped by the floating rail pill at narrow widths), 1 hosted-a11y failure (mobile edit mode full-height canvas — dock Edit never promotes to editor). All reproduce identically on HEAD with a clean dist-browser build via playwright.routes.config.ts.","status":"open","priority":2,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-15T00:36:49Z","created_by":"James Lal","updated_at":"2026-07-15T00:36:49Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-fuy","title":"Mobile hosted editor wow-pass: masthead header, iconified dock, sheet motion, safe areas","description":"The mobile hosted editor works but reads as bare scaffolding: text-only dock, heavy Share button, clipped save chip, unanimated sheets, no scroll behavior. Wow-pass within DESIGN.md: masthead header with doc-title crossfade + reading progress, iconified thumb dock with review badge, bottom-sheet grab/swipe + entrance motion, viewport-fit=cover safe areas.","notes":"\n\nBaseline evidence: HEAD bb46048 (before this session's changes) fails 37/66 hosted e2e (a11y axe, all of share-sheet, shells mobile, most authoring, offline) when run against the dev server — the dev-only Agentation toolbar (fa454a5) injects unnamed buttons/nested-interactive into every page (axe critical) and its FAB intercepts taps. Fixed root-cause this session: agentation-boot now skips automation runs (navigator.webdriver) and floats above the thumb dock on phone widths for humans.","status":"closed","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-15T00:32:23Z","created_by":"James Lal","updated_at":"2026-07-15T02:12:39Z","started_at":"2026-07-15T01:06:02Z","closed_at":"2026-07-15T02:12:39Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-64c","title":"Dev-only Agentation feedback toolbar wired into web dev servers","description":"Add agentation-svelte toolbar (dev-only) to both the native entry (main.ts, DEV-gated dynamic import) and hosted browser dev server (serve-only Vite plugin injecting agentation-boot.ts into every HTML entry). Syncs annotations to agentation-mcp on localhost:4747 (.mcp.json). Verified e2e: browser annotation -\u003e server -\u003e MCP tools. Prod builds confirmed clean of agentation code.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-14T23:22:13Z","created_by":"James Lal","updated_at":"2026-07-14T23:23:09Z","closed_at":"2026-07-14T23:23:09Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-y7q","title":"Share sheet polish: sentence-pattern permissions, quiet chrome","description":"Second UX pass from user feedback: Close pill -\u003e quiet ×, rounded sheet with soft border (was 1px ink + square), segmented control + redundant meta line -\u003e Figma-style sentence with inline permission select (visible word sizes it, transparent native select overlays for a11y), URL demoted to a quiet inspectable pill chip after Copy link/Share…, default \u003cdetails\u003e restyled as a muted chevron disclosure (fixes double rules).","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-14T22:48:37Z","created_by":"James Lal","updated_at":"2026-07-14T22:48:56Z","closed_at":"2026-07-14T22:48:56Z","close_reason":"Live on staging (version 292973c1): sentence permissions with inline select, quiet ×, rounded soft frame, pill URL chip, chevron disclosure — verified via sheet screenshots + tier-switch/reveal interactions","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-34g","title":"File switch inherits previous file's scroll position instead of starting at top","description":"EditorShell's read-position effect restores a saved per-file scrollTop, but when the target file has no saved position it leaves the canvas scrollTop untouched — the canvas survives in-place switches, so the new file opens at the old file's offset. Fix: reset to top when nothing is saved (hash anchors still win).","status":"closed","priority":2,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-14T03:37:01Z","created_by":"James Lal","updated_at":"2026-07-14T03:42:44Z","closed_at":"2026-07-14T03:42:44Z","close_reason":"Reset-to-top on switch + working restore verified E2E on 5173 (fresh file -\u003e 0, return to scrolled file -\u003e 1500 restored)","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-3t1","title":"Shared content column: prose + wide blocks cap at one --content-measure edge","description":"Follow-up to attn-2nh: user found the split layout (88ch prose beside full-pane tables/mermaid) still ragged. Cut over to one shared column: prose AND wide blocks (tables, code, mermaid) cap at --content-measure (1100px); oversized tables scroll inside. Revises the Theme v2 Wide-Sheet Rule in DESIGN.md.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-14T03:32:44Z","created_by":"James Lal","updated_at":"2026-07-14T03:32:56Z","closed_at":"2026-07-14T03:32:56Z","close_reason":"Landed: --content-measure 1100px shared by prose + wide blocks in reader/editor/hosted shell; verified aligned edges on 5173","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-2nh","title":"Widen the prose measure — 72ch reads too narrow next to full-pane tables/mermaid","description":"User feedback (screenshot, 2026-07-13): the 72ch prose cap looks undersized against wide blocks (tables, mermaid, code) that span the full pane per Theme v2. Widen the running-prose measure and centralize it as a design token so reader (base.css), editor (prosemirror.css), and hosted shell (app-shell.css) stay in sync.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-14T02:21:03Z","created_by":"James Lal","updated_at":"2026-07-14T02:27:18Z","closed_at":"2026-07-14T02:27:18Z","close_reason":"Landed: --prose-measure token at 88ch, all five 72ch sites converted, verified visually on 5173","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-5j5","title":"Landing: Theme v2 sweep (stripes, heading skip, tiny text)","description":"Remove the 3px stage-label side stripes (landing.css:205,211), fix the h1-\u003eh3 heading skip, lift 11px body text to the ramp, and make the entry triptych honest per the #new/#join fixes. Verify no regression vs the landing's strong baseline.","status":"closed","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-12T16:36:48Z","created_by":"James Lal","updated_at":"2026-07-12T16:48:59Z","started_at":"2026-07-12T16:47:11Z","closed_at":"2026-07-12T16:48:59Z","close_reason":"Landed: stripes-\u003edots, heading order fixed, tiny text lifted, verified render","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-u5c","title":"Native save/dirty indicator + window title","description":"Decision #4 compensating requirement: owner stays direct-edit, so the silent background serializer must become visible — Saved/Editing chip (prototype save-chip pattern, dot-only under 700px), flush-on-navigate/blur, window title = current file name (currently empty after navigation).","status":"closed","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-12T15:38:11Z","created_by":"James Lal","updated_at":"2026-07-12T16:15:57Z","started_at":"2026-07-12T16:12:03Z","closed_at":"2026-07-12T16:15:57Z","close_reason":"Landed: save chip + document.title, verified live","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-3dv","title":"Undo-after-accept: real revert via LocalRevision journal","description":"Decision #3: accepting/rejecting a suggestion leaves an inline undo note ('Accepted by James · file updated · Undo'); undo performs a real revert using the LocalRevision journal (AcceptedSuggestion + resulting_hash already recorded in src/review/apply.rs) and reopens the thread. Rust revert path + UI grammar from the prototype.","notes":"Rust core landed (accept splice capture + revert_accepted_suggestion + tests, commit HEAD). Remaining: IPC command (RevertSuggestion) + manager plumb + frontend 'Accepted · Undo' note per prototype grammar + protocol design for cross-peer thread reopen (new event kind; deny_unknown_fields risk on old peers needs review).","status":"in_progress","priority":2,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-12T15:38:09Z","created_by":"James Lal","updated_at":"2026-07-12T17:10:53Z","started_at":"2026-07-12T17:00:24Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-e4l","title":"Share dialog: decompression + designed error state + share-truth copy","description":"ShareDialog.svelte: first screen = scope + one primary action, rest behind Advanced; the relay-unreachable error state styled per prototype ('the share didn't complete. Nothing left this machine.' + inline retry); focus primary action on open, restore on close. Per decision #2: once a share exists the file's storage line becomes 'Shared · relay sees only ciphertext'.","status":"closed","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-12T15:38:09Z","created_by":"James Lal","updated_at":"2026-07-12T16:32:55Z","started_at":"2026-07-12T16:31:10Z","closed_at":"2026-07-12T16:32:55Z","close_reason":"Landed: share-truth desk copy + error trust line; dialog shape pre-existed","dependencies":[{"issue_id":"attn-e4l","depends_on_id":"attn-5y6","type":"blocks","created_at":"2026-07-12T09:38:27Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-5bq","title":"Suggestion/comment composer port","description":"SuggestionComposer.svelte adopts the prototype composer: anchored popover placement with edge clamps, draft survives Escape (only Cancel/Post clear), cmd-Enter posts, suggest mode shows live diff. Selection popover hides on scroll.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-12T15:38:08Z","created_by":"James Lal","updated_at":"2026-07-12T16:30:45Z","closed_at":"2026-07-12T16:30:45Z","close_reason":"Landed: draft caches, scroll-tracking popovers, live diff preview","dependencies":[{"issue_id":"attn-5bq","depends_on_id":"attn-5e7","type":"blocks","created_at":"2026-07-12T09:38:26Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-7xl.7.3.2","title":"Optimize landing screenshots and make visual baselines wait for image readiness","description":"Fresh Chromium staging measurement loaded 4.3 MB on the landing route: 2.0 MB editor-light PNG, 1.15 MB share-light PNG, and 1.03 MB collab-light PNG. All six 1920x1440 light/dark source captures total 7.4 MB. Landing images omit intrinsic width/height. The current dark full-page Playwright capture races theme image swaps/lazy loading and records nearly blank image panels even though the decoded live viewport is correct.","acceptance_criteria":"Serve appropriately sized responsive WebP/AVIF (with fallback if needed) and intrinsic dimensions/aspect ratios. Set and enforce an initial landing image-transfer budget appropriate for mobile. Preserve light/dark fidelity. Visual capture tests scroll/load lazy assets and await image decode after theme changes before taking screenshots, producing trustworthy light/dark desktop and mobile baselines.","notes":"Implementation plan: introduce a reusable responsive screenshot picture component, generate 768/1280/1920 AVIF variants with PNG fallback and intrinsic dimensions, prioritize only the hero, and keep lower captures lazy. Update visual tests to scroll, load, decode, verify theme sources, reset scroll/compositor state, and then capture. Validate byte reduction, route boundaries, layout, and Chromium/WebKit decoding.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-11T23:48:09Z","created_by":"James Lal","updated_at":"2026-07-12T00:47:05Z","started_at":"2026-07-11T23:56:24Z","closed_at":"2026-07-12T00:47:05Z","close_reason":"Implemented responsive AVIF screenshot sources with PNG fallback, intrinsic dimensions, loading priority, and decode-aware visual baselines. Landing transfer dropped by about 98%; Chromium and WebKit route/visual tests pass.","labels":["a11y","authoring","cloudflare","e2e","e2ee","ios","performance","responsive","security","storage","visual-regression","web"],"dependencies":[{"issue_id":"attn-7xl.7.3.2","depends_on_id":"attn-7xl.7.3","type":"parent-child","created_at":"2026-07-11T17:48:08Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-7xl.7.8","title":"Split hosted desktop icon registry from shared workspace shell","description":"The shared hosted desktop workspace frame correctly loads behind the desktop breakpoint, so the base app/mobile route stays at about 68 kB. Its desktop-only chunk is still about 3.45 MB (819 kB gzip) because FileTree statically imports the generated 2,776-SVG multi-pack icon registry used by native attn. Refactor icon resolution so hosted desktop can request only its active/default pack or a lightweight resolver without forking the Sidebar/FileTree views.","acceptance_criteria":"Hosted desktop continues to reuse Sidebar and FileTree, native icon-pack switching remains intact, route bundle boundaries continue to pass, and the HostedDesktopWorkspaceFrame chunk is materially smaller with a pinned bundle assertion.","status":"open","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-11T19:10:25Z","created_by":"James Lal","updated_at":"2026-07-11T19:10:25Z","labels":["a11y","authoring","bundle","cloudflare","e2e","e2ee","frontend","ios","performance","security","storage","web"],"dependencies":[{"issue_id":"attn-7xl.7.8","depends_on_id":"attn-7xl.7","type":"parent-child","created_at":"2026-07-11T13:10:25Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-7xl.7.8","depends_on_id":"attn-7xl.7.7","type":"discovered-from","created_at":"2026-07-11T13:10:26Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-02a.5.4","title":"Validate: browser Web Push E2E","description":"Feature-level validation: the no-install audience gets a proactive async loop, relay stays content-blind.\n\n## Integration Scenarios\n- Remembered room, tab closed: owner comments -\u003e ping -\u003e SW notification with correct locally-composed count -\u003e click reopens room with thread visible\n- Debounce: comment burst -\u003e one notification (tag-replaced)\n- Live tab open: no push fired (WS path wins)\n- Content-blindness audit: capture relay-\u003epush-service traffic; assert zero content bytes\n- Unsubscribe on forget-room verified against relay store\n\n## E2E Test Commands\n- Playwright suite with mocked push service against miniflare relay + dev-collab owner\n- Relay conformance run\n\n## Acceptance Criteria\n- Desktop Chromium + Firefox pass; iOS documented as attn-7xl-gated with install-first hint in place\n\n## Plan Reference\n- planning/sharing/README.md (Browser Web Push design sketch)","notes":"Production-boundary Web Push E2E complete: real Wrangler RoomDO accepted signed encrypted owner event→captured zero-body VAPID; built SW local decrypt/notify; generation/cursor/hash CAS, no-eviction backpressure, bounded reads; real ShareDO consent/snapshot/mail/reopen/forget; Chromium+Firefox 2/2. Manifest digest drift fixed to shared canonical JSON. Independent re-review APPROVE; all focused/full gates green.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-11T00:00:42Z","created_by":"James Lal","updated_at":"2026-07-11T10:33:35Z","started_at":"2026-07-11T09:39:43Z","closed_at":"2026-07-11T10:33:35Z","close_reason":"Closed","dependencies":[{"issue_id":"attn-02a.5.4","depends_on_id":"attn-02a.5.2","type":"blocks","created_at":"2026-07-10T18:01:28Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.5.4","depends_on_id":"attn-02a.5.3","type":"blocks","created_at":"2026-07-10T18:01:29Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.5.4","depends_on_id":"attn-02a.5.1","type":"blocks","created_at":"2026-07-10T18:01:27Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.5.4","depends_on_id":"attn-02a.5","type":"parent-child","created_at":"2026-07-10T18:00:41Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":3,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-02a.5.3","title":"Push consent UX with Remember this room","description":"Offer push at the same UX moment as \"Remember this room\" (they share the persistence prerequisite). Resolve open question 6 (bundled single consent vs separate toggle) with James's annotation; default to a second toggle inside the remember affordance until annotated otherwise. Proper in-app UI (no browser default prompts fired unsolicited — request Notification permission only on explicit toggle).\n\n## Files\n- web/src/BrowserReviewApp.svelte / remember-room UI\n- web/src/lib/review/ (subscription lifecycle: enable, disable, forget-room revokes subscription too)\n\n## Approach\nForgetting a room must unsubscribe (client-side unsubscribe + relay DELETE). Surface iOS reality: on Safari without Home-Screen install, show the install-first hint (copy only; the PWA plumbing rides attn-7xl).\n\n## Verification\n- cd web \u0026\u0026 npm run build\n- Playwright: toggle on -\u003e permission requested -\u003e subscription POSTed; forget room -\u003e subscription DELETEd; toggle off -\u003e no ping delivered (mock endpoint)\n\n## Plan Reference\n- planning/sharing/README.md (Browser Web Push; open question 6)","notes":"Explicit Remember \u0026 notify consent complete: gesture-first, view/invite gate, iOS install hint, exact relay auth/PoW binding, nonextractable worker storage, bearer-free cold resume, global subscription Web-Lock lifecycle, abort/generation/rotation rollback, ambiguous POST cleanup, pinned relay origin and identity-aware revision/manifest CAS. Independent final review APPROVE; focused/full web and build gates green.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-11T00:00:41Z","created_by":"James Lal","updated_at":"2026-07-11T09:39:39Z","started_at":"2026-07-11T09:05:54Z","closed_at":"2026-07-11T09:39:39Z","close_reason":"Closed","dependencies":[{"issue_id":"attn-02a.5.3","depends_on_id":"attn-02a.5","type":"parent-child","created_at":"2026-07-10T18:00:40Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.5.3","depends_on_id":"attn-02a.5.2","type":"blocks","created_at":"2026-07-10T18:01:16Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-02a.5.2","title":"Service worker: subscribe, wake, pull, local decrypt, notify","description":"Service worker on attn.sh registers the push subscription and handles wakes: open the remembered-room capability from IndexedDB (decision #13), pull pending envelopes over WS, decrypt + verify locally, compose the rich notification (\"2 new comments on plan.md\"), deep-link click to the room. Push is only offered for remembered rooms — invite-only sessions have no persisted capability by design.\n\n## Files\n- web/ (service worker; registration wiring in BrowserReviewApp)\n- web/src/lib/review/ (worker-side envelope pull + decrypt reusing browser-crypto; careful: worker context, no DOM)\n\n## Approach\nThe SW shares the non-extractable capability access pattern attn-egi.3 built. Keep worker work bounded (pull, decrypt headers/kinds, count) — full replay happens in the page on click. Collapse multiple wakes per room into one visible notification (tag-based replace).\n\n## Verification\n- cd web \u0026\u0026 npm run build \u0026\u0026 npm test (worker decrypt path unit-tested against vectors)\n- Playwright (Chromium supports mock push): remembered room + subscription -\u003e dispatch push event -\u003e notification shown with locally-composed text -\u003e click navigates to room\n- No plaintext or key material in any network request from the SW other than the WS pull\n\n## Plan Reference\n- planning/sharing/README.md (Browser Web Push design sketch)","notes":"Hosted SW content-free wake complete: non-extractable capability bindings, capability-pinned owner trust, strict v3 grants, bounded authenticated room/share pulls, local decrypt/verify/count, cumulative 64-op limit, monotonic/fair cursor, tagged local notification + fragmentless click. Independent security re-review APPROVE; check, 62-file suite, browser/SW build green.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-11T00:00:40Z","created_by":"James Lal","updated_at":"2026-07-11T08:50:58Z","started_at":"2026-07-11T08:19:41Z","closed_at":"2026-07-11T08:50:58Z","close_reason":"Closed","dependencies":[{"issue_id":"attn-02a.5.2","depends_on_id":"attn-02a.5","type":"parent-child","created_at":"2026-07-10T18:00:40Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.5.2","depends_on_id":"attn-02a.5.1","type":"blocks","created_at":"2026-07-10T18:01:15Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} +{"_type":"issue","id":"attn-02a.5.1","title":"Relay: VAPID + push subscription store + content-free ping","description":"Relay holds the VAPID keypair. Clients POST push subscriptions bound to (roomId|shareId, deviceId), admission-MAC'd and PoW'd like every write; stored on the share record (pushSubscriptions field) or room DO; expire with share/room TTL, re-pinned on share renewal. On envelope arrival for a subscribed device with no live WS: send an empty (or roomId-only) Web Push — no author, kind, or text ever transits push infra. Debounce per device.\n\n## Files\n- relay/ (subscription endpoints, web-push send from the DO, VAPID key management via wrangler secret)\n- planning/collab/relay-spec.md (+ conformance corpus)\n\n## Approach\nPayload-free push avoids RFC 8291 payload encryption entirely — the wake is the message. Per-device debounce (~30s) server-side. Unsubscribe on DELETE and on push-service 404/410 responses.\n\n## Verification\n- Relay conformance corpus (subscribe MAC'd+PoW'd; duplicate subscribe idempotent; TTL expiry drops it)\n- miniflare test with a mock push endpoint: envelope arrival + no WS -\u003e exactly one ping within debounce window; live WS -\u003e no ping\n- Secrets documented in relay/README or wrangler.toml comments\n\n## Plan Reference\n- planning/sharing/README.md (Browser Web Push design sketch)","notes":"Relay Web Push complete for room+share bindings: strict v3 admission+PoW CRUD, tier/device isolation, 32-active cap, room/share TTL, async verified VAPID keypair, payloadless 300s ping, durable debounce, self/live suppression, 404/410 cleanup. Independent adversarial audit APPROVE after sibling-delete/idempotent-PoW fixes; full relay suite/typecheck/conformance green.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-11T00:00:39Z","created_by":"James Lal","updated_at":"2026-07-11T08:19:12Z","started_at":"2026-07-11T07:47:03Z","closed_at":"2026-07-11T08:19:12Z","close_reason":"Closed","dependencies":[{"issue_id":"attn-02a.5.1","depends_on_id":"attn-02a.5","type":"parent-child","created_at":"2026-07-10T18:00:39Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":2,"comment_count":0} +{"_type":"issue","id":"attn-02a.4.4","title":"Validate: native async loop","description":"Feature-level validation that comments reach an away owner end-to-end on native.\n\n## Integration Scenarios\n- Resident daemon, no window: reviewer comments -\u003e native notification -\u003e click -\u003e window opens focused on room -\u003e badge present -\u003e focus clears unread\n- Burst debounce across rooms stays per-room\n- Restart resident daemon: unread markers intact, no notification replay\n\n## E2E Test Commands\n- Extend dual-instance harness (notification posting asserted via hook/log in debug builds; visual spot-check manual)\n- task check:size\n\n## Acceptance Criteria\n- Away-owner loop closes with zero polling and no window open at comment time\n\n## Plan Reference\n- planning/sharing/README.md (Workstream B layers 1-3)","notes":"Real hidden resident owner+reviewer+Wrangler E2E passes: verified signed comments, 2-comment collapse, safe platform post, unread=2, exact deep-link selection/focus clear, mute, kill/restart restore/no replay. Full Rust/web/clippy/size gates green; independent review APPROVE. Signed-installed visual spot-check remains documented. Separate multi-room WS stall filed attn-eaw.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-11T00:00:38Z","created_by":"James Lal","updated_at":"2026-07-11T10:09:29Z","started_at":"2026-07-11T09:37:48Z","closed_at":"2026-07-11T10:09:29Z","close_reason":"Closed","dependencies":[{"issue_id":"attn-02a.4.4","depends_on_id":"attn-02a.4.3","type":"blocks","created_at":"2026-07-10T18:01:27Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.4.4","depends_on_id":"attn-02a.4","type":"parent-child","created_at":"2026-07-10T18:00:38Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.4.4","depends_on_id":"attn-02a.4.1","type":"blocks","created_at":"2026-07-10T18:01:25Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.4.4","depends_on_id":"attn-02a.4.2","type":"blocks","created_at":"2026-07-10T18:01:26Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":3,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-02a.4.3","title":"Resident daemon: attn daemon --resident + login item opt-in","description":"Make \"attn is running\" the normal state so notifications fire without a window. Windowless-resident mode; opening a doc attaches to the same instance as today. Opt-in login item (launchd) from settings — no silent install.\n\n## Files\n- src/main.rs / src/daemon.rs (windowless lifecycle: no window until first open; window close does not exit in resident mode)\n- launchd plist install/uninstall (SMAppService or plist under ~/Library/LaunchAgents)\n- settings UI toggle (web/src)\n\n## Approach\nLifecycle/packaging change, not new machinery — the daemon already forks and idles. Resident daemon keeps relay WS connections for rooms/shares with unread interest. Clean cutover: one daemon codepath with a resident flag, no parallel legacy mode.\n\n## Verification\n- cargo build\n- Smoke: attn daemon --resident -\u003e no window, socket live (attn --info); attn file.md attaches; closing window leaves daemon up; non-resident behavior unchanged when flag absent\n- Login item: enable toggle -\u003e plist present + loads; disable -\u003e gone\n\n## Plan Reference\n- planning/sharing/README.md (Workstream B layer 3, open question 3: opt-in pending annotation)","notes":"Opt-in resident daemon complete: hidden Accessory startup, same socket/review instance reveal and resident-only close-hide, explicit CLI/status/settings launch-at-login. LaunchAgent lifecycle hardened with trusted launchctl/euid, dirfd O_NOFOLLOW/O_EXCL atomic 0600 writes+fsync, authoritative bootout, rollback, degraded UI. Independent re-review APPROVE; 10/10 resident tests, strict gates, size 31.74 MiB.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-11T00:00:37Z","created_by":"James Lal","updated_at":"2026-07-11T09:35:10Z","started_at":"2026-07-11T08:42:39Z","closed_at":"2026-07-11T09:35:10Z","close_reason":"Closed","dependencies":[{"issue_id":"attn-02a.4.3","depends_on_id":"attn-02a.4","type":"parent-child","created_at":"2026-07-10T18:00:37Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-02a.4.2","title":"Native macOS notifications from the daemon (debounced, deep-linked)","description":"When a comment/suggestion/verdict imports and the relevant room is not focused, post a macOS user notification. Debounce per room (collapse bursts: \"3 new comments on plan.md\"). Click deep-links through the existing attn://review/ handler to focus the room. No window.confirm/alert anywhere — this is OS-level notification, in-app surfaces stay proper UI.\n\n## Files\n- src/ (notification posting — objc2 UserNotifications bindings or mac-notification-sys; watch the 32 MiB binary gate)\n- src/review/manager.rs (unfocused-room import hook, shared with unread task)\n- src/main.rs (activation -\u003e focus room)\n\n## Approach\nBoth owner and reviewer side fire. Debounce window ~5s per room. Respect a per-room mute toggle (UI stub acceptable; setting persisted). Run task check:size before merge — new dep must fit the budget.\n\n## Verification\n- cargo build \u0026\u0026 task check:size (binary stays under 32 MiB)\n- cargo test debounce fold logic\n- Smoke: dual-instance — 3 rapid reviewer comments -\u003e exactly one collapsed notification on owner; click focuses the room/window\n\n## Plan Reference\n- planning/sharing/README.md (Workstream B layer 2)","notes":"Modern macOS delivery now uses UNUserNotificationCenter with retained response delegate, bundle-safe gating and legacy fallback; independently approved. Signed-installed visual banner/click is manual because macOS denies ad-hoc debug bundle authorization; raw/full daemon seam validated.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-11T00:00:36Z","created_by":"James Lal","updated_at":"2026-07-11T10:09:22Z","started_at":"2026-07-11T09:05:37Z","closed_at":"2026-07-11T10:09:22Z","close_reason":"Closed","dependencies":[{"issue_id":"attn-02a.4.2","depends_on_id":"attn-02a.4.1","type":"blocks","created_at":"2026-07-10T18:01:14Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.4.2","depends_on_id":"attn-02a.4","type":"parent-child","created_at":"2026-07-10T18:00:36Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-02a.3.6","title":"Validate: durable shares E2E lifecycle","description":"Full share lifecycle against the real local stack.\n\n## Integration Scenarios\n- Create share -\u003e visitor joins live -\u003e room expires (force short TTL) -\u003e same URL still resolves via snapshot -\u003e visitor comments offline-owner -\u003e owner returns, drains, visitor upgrades live\n- Renewal: owner connect extends expiresAt; expiry without renewal kills resolution\n- Revocation: DELETE makes the URL dead immediately (pointer + blobs gone)\n- Epoch rotation: two successive rooms, same URL, comments land in both eras and replay coherently\n\n## E2E Test Commands\n- New scripts/test-share-e2e.sh on scripts/dev-collab.sh stack (short TTL overrides)\n- Relay conformance corpus full run\n\n## Acceptance Criteria\n- The link survives a room death and an owner restart with zero URL change\n- Revocation is immediate and total\n\n## Plan Reference\n- planning/sharing/README.md (Workstream A)","notes":"Real production-boundary lifecycle harness now crosses live local Wrangler, actual RoomDO/ShareDO/R2, Rust DurableShareService/Bootstrapper/ReviewStore, and browser production parser/session. Covers real room deletion, native XChaCha retained fallback, signed v2 events in v3 submission, same-epoch restart/import/pointer-before-ACK, existing-watch live upgrade, renewal/rotation/tier denial, and terminal revoke. Independent review APPROVE; full test-share-e2e.sh passed.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-11T00:00:35Z","created_by":"James Lal","updated_at":"2026-07-11T08:41:35Z","started_at":"2026-07-11T08:05:02Z","closed_at":"2026-07-11T08:41:35Z","close_reason":"Closed","dependencies":[{"issue_id":"attn-02a.3.6","depends_on_id":"attn-02a.3.3","type":"blocks","created_at":"2026-07-10T18:01:23Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.3.6","depends_on_id":"attn-02a.3.5","type":"blocks","created_at":"2026-07-10T18:01:25Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.3.6","depends_on_id":"attn-02a.3.4","type":"blocks","created_at":"2026-07-10T18:01:24Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.3.6","depends_on_id":"attn-02a.3","type":"parent-child","created_at":"2026-07-10T18:00:34Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.3.6","depends_on_id":"attn-02a.3.2","type":"blocks","created_at":"2026-07-10T18:01:23Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.3.6","depends_on_id":"attn-02a.3.1","type":"blocks","created_at":"2026-07-10T18:01:22Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":5,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-02a.4.1","title":"Unread state: per-room counts from import pipeline + UI badges","description":"Import pipeline maintains per-room unread counts (events not yet seen in a focused view); Svelte surfaces badges on peer strip, tab, and tree rows. Read-marker persists locally so restarts don't re-badge.\n\n## Files\n- src/review/manager.rs (unread accounting + ReviewUpdate surface)\n- web/src/ (badge rendering; tabs.ts, tree, peer strip)\n- ~/.attn/reviews/ read-marker persistence\n\n## Approach\nCount on import, clear on room-visible+focused. Marker is a per-room last-seen event cursor, not per-event flags.\n\n## Verification\n- cargo build \u0026\u0026 cargo test unread (import increments; focus clears; marker survives restart)\n- cd web \u0026\u0026 npm run build\n- Smoke: dual-instance — reviewer comments while owner tab unfocused -\u003e badge shows 1; focus clears it; restart daemon -\u003e still cleared\n\n## Plan Reference\n- planning/sharing/README.md (Workstream B layer 1)","notes":"Persisted per-room unread cursor/count complete with serialized exact-once accounting, restart crash-gap reconciliation, durable offline drain replay, visible+focused clear IPC, boot callback queue, and accessible badges across peer/room/tab/tree/sidebar/rail. Independent review APPROVE; full Rust/web and focused gates green.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-11T00:00:35Z","created_by":"James Lal","updated_at":"2026-07-11T09:05:33Z","started_at":"2026-07-11T08:34:22Z","closed_at":"2026-07-11T09:05:33Z","close_reason":"Closed","dependencies":[{"issue_id":"attn-02a.4.1","depends_on_id":"attn-02a.4","type":"parent-child","created_at":"2026-07-10T18:00:35Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":2,"comment_count":0} +{"_type":"issue","id":"attn-02a.3.5","title":"Browser visitor flow: resolve share -\u003e live room or snapshot + comment queue","description":"attn.sh/s/... resolves the share: if a live room exists, join it exactly as today; if not, fetch the retained encrypted snapshot, decrypt client-side, render read-only, queue comments into the share mailbox, and upgrade to the live room when it appears.\n\n## Files\n- web/src/BrowserReviewApp.svelte + web/src/lib/review/ (share resolution, offline-owner mode, upgrade path)\n- web/src/lib/review/browser-crypto (epoch derivation consumption)\n\n## Approach\nReuse the shipped snapshot renderer and outbox machinery; the share mailbox is just a different POST target. Poll-free upgrade: subscribe to the share record over WS (or piggyback the existing signaling channel) so room appearance flips the client live. Respect tier capabilities from Workstream C.\n\n## Verification\n- cd web \u0026\u0026 npm run build \u0026\u0026 npm test\n- Playwright smoke: open share URL with relay room expired -\u003e doc renders read-only from snapshot; submit comment -\u003e queued; boot owner -\u003e client upgrades to live room without reload and the comment thread appears\n\n## Plan Reference\n- planning/sharing/README.md (Workstream A)","notes":"Real-stack validation additionally fixed production room-liveness probing via authenticated /devices, latest retained snapshot GET/header binding, terminal 4001 plaintext clear/termination, and verified no-reload upgrade.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-11T00:00:34Z","created_by":"James Lal","updated_at":"2026-07-11T08:41:39Z","started_at":"2026-07-11T06:29:47Z","closed_at":"2026-07-11T08:04:17Z","close_reason":"Closed","dependencies":[{"issue_id":"attn-02a.3.5","depends_on_id":"attn-02a.3.1","type":"blocks","created_at":"2026-07-10T18:01:12Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.3.5","depends_on_id":"attn-02a.3.2","type":"blocks","created_at":"2026-07-10T18:01:13Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.3.5","depends_on_id":"attn-02a.3","type":"parent-child","created_at":"2026-07-10T18:00:33Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-02a.3.4","title":"Owner daemon share lifecycle: create, renew, revoke, epoch mint, mailbox drain","description":"attn share \u003cfile|dir\u003e, attn share renew|revoke. On daemon connect: renew live shares, mint a fresh room (next epoch) when a share has visitors/queued mail, drain the share mailbox into the room, update the share record (roomId + latest snapshot ref).\n\n## Files\n- src/cli_review.rs or new src/cli_share.rs\n- src/review/manager.rs / bootstrap.rs (epoch room mint, drain)\n- src/daemon.rs (connect-time renewal hook)\n\n## Approach\nReuse the existing room-creation path with the epoch-derived secret. Drain preserves envelope order and dedupes by EnvelopeId (outbox freezing rules apply). Share state persists under ~/.attn/shares/. Update share record last (pointer only flips once the room is live).\n\n## Verification\n- cargo build \u0026\u0026 cargo test share lifecycle (create -\u003e record written; drain imports queued comments in order; revoke deletes)\n- Smoke on dev-collab stack: create share, kill owner, queue a comment via relay mailbox (curl), restart owner, comment appears in the new room and in the review panel\n\n## Plan Reference\n- planning/sharing/README.md (Workstream A)","notes":"Production owner lifecycle complete: serialized crash-safe create/renew/reconcile/revoke, deterministic same-epoch room recovery + runtime activation, pointer-last exact retained snapshot reconcile/delete, content-aware offline edits, strict two-phase mailbox preflight/import and exact relay ACK validation, imported vs pointer-committed ACK watermarks, real typed CLI/daemon results. Independent adversarial review APPROVE; fmt/check/strict clippy/full and focused gates green.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-11T00:00:33Z","created_by":"James Lal","updated_at":"2026-07-11T08:33:50Z","started_at":"2026-07-11T06:29:45Z","closed_at":"2026-07-11T08:33:50Z","close_reason":"Closed","dependencies":[{"issue_id":"attn-02a.3.4","depends_on_id":"attn-02a.3.2","type":"blocks","created_at":"2026-07-10T18:01:12Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.3.4","depends_on_id":"attn-02a.3","type":"parent-child","created_at":"2026-07-10T18:00:32Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.3.4","depends_on_id":"attn-02a.3.1","type":"blocks","created_at":"2026-07-10T18:01:11Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-02a.3.3","title":"R2 share-pinned snapshot retention (amend decision #9)","description":"Snapshot blobs referenced by a live share outlive the 7-day R2 sweep; owner renewal re-pins them; share revocation/expiry unpins for cleanup.\n\n## Files\n- relay/ (R2 lifecycle / pin bookkeeping on the share record)\n- planning/collab/amendments.md or relay-spec.md (document the amendment to decision #9)\n\n## Approach\nPin = the share record holds the authoritative blob refs; the sweep consults share liveness before deleting. Keep the room-scoped 7d behavior for everything not share-pinned. Bound retained bytes per share (latest snapshot per file only — superseded snapshots are not pinned).\n\n## Verification\n- Relay tests: blob referenced by live share survives simulated sweep; unpinned after DELETE /v3/shares\n- Conformance corpus case for pin/unpin\n- Byte-bound test: superseded snapshot not pinned\n\n## Plan Reference\n- planning/sharing/README.md (Workstream A, amendment impact)","notes":"Implemented share-scoped encoded R2 namespace, owner-signed+PoW bounded binary snapshot upload, read-admission fetch, strict latest-per-file manifest, 64-file/5MiB-file/25MiB-share bounds, intent-first crash-safe supersede cleanup, room-sweep isolation, and tombstoned revoke/expiry retry. Independent security review APPROVE; relay typecheck, diff check, and 80/80 broader focused tests pass.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-11T00:00:32Z","created_by":"James Lal","updated_at":"2026-07-11T06:28:38Z","started_at":"2026-07-11T06:12:13Z","closed_at":"2026-07-11T06:28:38Z","close_reason":"Share-pinned snapshot retention implemented, crash-hardened, tested, documented, and independently approved.","dependencies":[{"issue_id":"attn-02a.3.3","depends_on_id":"attn-02a.3.1","type":"blocks","created_at":"2026-07-10T18:01:14Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.3.3","depends_on_id":"attn-02a.3","type":"parent-child","created_at":"2026-07-10T18:00:31Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-02a.3.2","title":"Share key schedule + URL forms (shareSecret -\u003e epoch room secrets)","description":"roomSecret_n = HKDF(shareSecret, \"attn share room v3\", epoch_n). Same URL admits into every successive room without changing. URL forms: https://attn.sh/s/\u003cshareId\u003e#key=\u003cshareSecret\u003e and attn://share/\u003cshareId\u003e#key=....\n\n## Files\n- src/review/crypto/ + web/src/lib/review/browser-crypto (derivation, Rust+TS)\n- planning/collab/test-vectors/ (epoch derivation vectors)\n- src/main.rs custom-scheme handler (attn://share/ routing alongside attn://review/, per amendments path-prefix convention)\n- planning/collab/crypto-spec.md\n\n## Approach\nVectors-first per the corpus pattern. Epoch is a monotonic integer carried in the share record (plaintext-safe: it derives nothing without the secret). Composes with Workstream C: per-epoch room secret feeds the tiered read/write derivations.\n\n## Verification\n- cargo test \u0026\u0026 cd web \u0026\u0026 npm test (epoch vectors pass both languages)\n- Smoke: derive epoch 0 and 1 -\u003e distinct room secrets; same shareSecret + epoch -\u003e identical across Rust/TS\n- attn://share/... URL parses and routes to daemon (unit test on scheme handler)\n\n## Plan Reference\n- planning/sharing/README.md (Workstream A)","notes":"Added vectors-first HKDF-SHA256 shareSecret+uint64be(epoch) derivation across Rust/WebCrypto; strict canonical 16-byte share IDs and 32-byte fragment secrets for attn://share and https://attn.sh/s forms; native routing validates before daemon dispatch; secret-bearing Rust invite zeroizes and cannot Debug/Clone. Independent review APPROVE; Rust/share/browser crypto/Svelte checks pass.","status":"closed","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-11T00:00:31Z","created_by":"James Lal","updated_at":"2026-07-11T06:11:07Z","started_at":"2026-07-11T05:41:13Z","closed_at":"2026-07-11T06:11:07Z","close_reason":"Cross-runtime KDF and canonical URL routing implemented, verified, and independently reviewed.","dependencies":[{"issue_id":"attn-02a.3.2","depends_on_id":"attn-02a.3","type":"parent-child","created_at":"2026-07-10T18:00:30Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":3,"comment_count":0} +{"_type":"issue","id":"attn-02a.3.1","title":"Relay: share object + /v3/shares endpoints + share mailbox","description":"New long-lived relay object: shareId, ownerSigningKey, optional current roomId, latest-encrypted-snapshot blob ref per file, updatedAt/expiresAt, pushSubscriptions placeholder. POST/GET/DELETE /v3/shares/:shareId — owner-signed writes (decision #3 scheme), PoW on writes (decision #6). Share-scoped mailbox accepts comment envelopes while no room is live.\n\n## Files\n- relay/ (new DO or KV-backed share record + mailbox)\n- planning/collab/relay-spec.md (/v3/shares section)\n- conformance corpus additions\n\n## Approach\nRooms remain untouched v2 objects; the share is a pointer + retained ciphertext blob. GET is read-admission MAC'd. Default expiresAt = 90d since last owner touch; owner connect renews. DELETE kills pointer, mailbox, and retained blobs (the revocation kill switch).\n\n## Verification\n- Relay conformance corpus run under miniflare (create/read/renew/revoke; mailbox accept + drain order; non-owner write rejected)\n- cargo test transport share client calls\n- Smoke: create share via curl against wrangler dev --local, GET resolves, DELETE 404s afterward\n\n## Plan Reference\n- planning/sharing/README.md (Workstream A)","notes":"Implemented ShareDO and /v3/shares endpoints with owner-signed+PoW mutations, read/write admission, redacted public records, bounded ordered mailbox, idempotent envelope retries/conflicts, pagination, transactional prefix ACK, expiry/revocation, min-deadline marker cleanup, CORS/rate limits, Miniflare integration and executable conformance coverage. Independent security review APPROVE; relay typecheck and focused suites pass.","status":"closed","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-11T00:00:28Z","created_by":"James Lal","updated_at":"2026-07-11T06:10:43Z","started_at":"2026-07-11T05:40:30Z","closed_at":"2026-07-11T06:10:43Z","close_reason":"Implemented and independently security-reviewed; focused relay tests pass.","dependencies":[{"issue_id":"attn-02a.3.1","depends_on_id":"attn-02a.3","type":"parent-child","created_at":"2026-07-10T18:00:28Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":4,"comment_count":0} +{"_type":"issue","id":"attn-02a.2.5","title":"Validate: permission tiers E2E","description":"Feature-level validation that the three tiers behave as designed across relay, native, and browser clients.\n\n## Integration Scenarios\n- View link: browser reviewer renders doc, cannot POST anything (relay rejects), composer absent\n- Comment link: comment round-trips; crafted suggestion envelope dropped at owner import\n- Suggest link: full current reviewer flow incl. accept -\u003e apply\n- Tier vocabulary consistent between native share sheet and browser join\n\n## E2E Test Commands\n- Extend scripts/test-review-e2e.sh (or new scripts/test-tiers-e2e.sh) on the dev-collab stack\n- Relay conformance corpus run\n\n## Acceptance Criteria\n- All three tiers exercised against a real miniflare relay with dual instances + one browser client\n- Trust-model docs updated (crypto-enforced vs policy-enforced boundary stated)\n\n## Plan Reference\n- planning/sharing/README.md (Workstream C)","notes":"Implemented and executed the real permission-tier matrix. Native Miniflare integration uses isolated owner/commenter/suggester/viewer identities: native view refuses writable registration, comment round-trips, local comment suggestion is blocked, a correctly signed/encrypted hostile comment-tier SuggestionCreated is delivered ahead of a valid-comment barrier and discarded by owner import, and suggest propagates then owner accept applies to disk. Hosted Wrangler+Vite+Chromium extracts all three v3 URLs, verifies rendered native labels and comment default, visible browser View only/Can comment/Can suggest vocabulary, view zero mutations + viewer_id WS, comment-only composer + native roundtrip, suggest authoring to native owner, plaintext/secret exclusion, and honest temporary v3 persistence. Trust model updated. Verification: focused native tier E2E passed; hosted Playwright 4/4 passed; relay v3/conformance 33/33; web check 0 diagnostics; browser build passed; test clippy -D warnings passed; independent review APPROVE.","status":"closed","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-10T23:58:53Z","created_by":"James Lal","updated_at":"2026-07-11T05:39:40Z","started_at":"2026-07-11T05:17:41Z","closed_at":"2026-07-11T05:39:40Z","close_reason":"Validated all three permission tiers across real Miniflare native clients and hosted Chromium, including hostile import rejection and owner apply.","dependencies":[{"issue_id":"attn-02a.2.5","depends_on_id":"attn-02a.2.1","type":"blocks","created_at":"2026-07-10T18:01:19Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.2.5","depends_on_id":"attn-02a.2.4","type":"blocks","created_at":"2026-07-10T18:01:21Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.2.5","depends_on_id":"attn-02a.2.2","type":"blocks","created_at":"2026-07-10T18:01:20Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.2.5","depends_on_id":"attn-02a.2.3","type":"blocks","created_at":"2026-07-10T18:01:20Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.2.5","depends_on_id":"attn-02a.2","type":"parent-child","created_at":"2026-07-10T17:58:53Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":4,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-02a.2.4","title":"Tiered invite generation: three URLs per share (view / comment / suggest)","description":"Share sheet and CLI emit tier-scoped invite URLs. Default comment for human invites, suggest for agent invites. Rotation story: revoking a leaked view link = bump share epoch and re-issue (cheap once Workstream A lands; until then documented as re-share).\n\n## Files\n- web/src/ share sheet UI (native window)\n- src/cli_review.rs (attn review invite --tier)\n- Coordinate with attn-7xl.4.5 (accountless browser Share sheet) — same tier vocabulary, do not fork the UX\n\n## Approach\nInvite construction picks which derived keys enter the fragment per tier. Three copyable URLs with clear labels (\"Anyone with this link can view / comment / suggest\"). Keep the existing full invite as the suggest tier — no breakage of current flows, but cut over the default emission to tiered form (no legacy pathway kept).\n\n## Verification\n- cd web \u0026\u0026 npm run build; cargo build\n- Playwright/attn --query smoke: share sheet renders three tier rows; copied URLs differ; view URL joins read-only in browser reviewer\n- cargo test invite tier encoding round-trip\n\n## Plan Reference\n- planning/sharing/README.md (Workstream C, open question 2: comment default pending annotation)","notes":"Security/integration map: v3 fragments must be strict: view #v=3\u0026tier=view\u0026read=...; comment/suggest add \u0026write=...\u0026grant=\u003c64-byte owner Ed25519 signature\u003e. grant is owner signature over canonical {grantTier,purpose:'attn device grant v3',roomId,v:3}; absent for view, mandatory writable. New shares emit v3 only; legacy v2 parse/join unchanged. This task must include production client v3 transport cutover (versioned read/write keys, /v3 HTTP+WS+R2, restart persistence), not only URL/UI generation, or links will parse but call v2. Smallest secure view flow: /v3 socket with mutually-exclusive fresh viewer_id plus read-hmac, tagged anonymous attachment; subscribe/pong and non-signal replay only; no POST devices, identity, outbox, ACK, signaling, presence, WebRTC, or peer quota. Separate viewer rate/socket cap. Browser exposes tier/canComment/canSuggest and stable View only state with method-level guards. Native share dialog and hosted vocabulary use three rows: Anyone with this link can view/comment/suggest; human default comment, agent default suggest. CLI invite needs target path/room semantics; re-emit existing owner share, never mint ambiguity. Focused tests: strict fragment/grant cross-room+tier tamper, six URLs, v3 route/key selection, restart, anonymous view zero POST, comment hard suggest rejection, relay viewer no signal/presence, native share-sheet labels/default. .2.5 retains real miniflare dual-native+browser matrix. Per-tier revocation remains re-share until durable share epochs.\nImplemented production v3 tiered invites end-to-end: six native/browser URLs (view/comment/suggest), strict canonical fragments and owner grants, CLI re-emission with legacy rejection, versioned native/browser mailbox/outbox/R2/signaling, anonymous read-only viewer sockets, registered dual read/write socket proofs, v3 persistence, hard UI/authoring guards, and aligned three-row Share UI. Security re-review APPROVE after resolving device-id escalation, native R2, v3 room-id, signaling-key, and legacy CLI blockers. Verification: cargo test --lib 458 passed/5 ignored; cargo build passed; cargo clippy --all-targets -D warnings passed; relay typecheck/focused v2-v3-viewer 27 passed; full relay 428/429 with existing multiparty hello timing flake and exact rerun passed; web check 0 diagnostics; native and browser builds passed; full web 56/57 with existing remembered-room timing flake and standalone browser-session 24/24 passed. In-app browser was unavailable in this runtime, so source assertions/builds cover the three labels/default and .2.5 owns real stack E2E.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-10T23:58:52Z","created_by":"James Lal","updated_at":"2026-07-11T05:15:43Z","started_at":"2026-07-11T04:22:33Z","closed_at":"2026-07-11T05:15:43Z","close_reason":"Shipped tier-scoped v3 invites and production client/relay transport cutover with security approval and passing build/test gates.","dependencies":[{"issue_id":"attn-02a.2.4","depends_on_id":"attn-02a.2","type":"parent-child","created_at":"2026-07-10T17:58:52Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.2.4","depends_on_id":"attn-02a.2.1","type":"blocks","created_at":"2026-07-10T18:01:10Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-02a.2.2","title":"Relay: enforce write-admission MAC on all mutating endpoints","description":"POST /devices, /envelopes, /acks, /blobs (and DELETE) must MAC with writeAdmissionKey; WS connect and blob GET accept read-admission. View-only bearers get rejected on writes by the relay itself — cryptographic enforcement, no server-side token state (decision #2 shape preserved).\n\n## Files\n- relay/ (per-endpoint auth)\n- planning/collab/relay-spec.md (auth table update)\n- conformance corpus (new cases: read-only bearer write -\u003e 401/403 error code)\n\n## Approach\nAdd the second MAC verification path alongside the existing admission check; pick and document a distinct error code (e.g. ATTN_WRITE_CAPABILITY_REQUIRED). Extend the conformance corpus per the Phase 3a pattern so the Rust client transport tests cover it too.\n\n## Verification\n- cd relay \u0026\u0026 npx wrangler dev --local conformance run (corpus passes incl. new rejection cases)\n- cargo test transport (client-side conformance)\n- Smoke vs miniflare: write with read-only capability rejected; with write capability accepted\n\n## Plan Reference\n- planning/sharing/README.md (Workstream C)","notes":"Additive v3 relay contract: new /v3 room routes; create body v=3 includes ownerSigningKey plus readAdmissionKey and writeAdmissionKey, stored as two per-room verifier secrets with protocolVersion. Attn-Admission uses v3.read.MAC for read endpoints and v3.write.MAC for mutations; v2 remains exact. Valid read proof on a write endpoint returns 403 ATTN_WRITE_CAPABILITY_REQUIRED; malformed/wrong proofs remain 401 ATTN_ADMISSION_INVALID; cross-version room access returns ATTN_PROTOCOL_VERSION_MISMATCH. Read: GET devices, cap-less blob download presign, WS. Write: rejoin POST room, POST devices/envelopes/acks/blob upload presign, DELETE. First create owner-signed supplies both keys. WS uses attn.v3 plus read-hmac. Conformance must preserve v2 and add v3 read-write matrix. Known integration issue: fresh view bearer cannot POST /devices but WS currently requires registered device; address constrained anonymous/read-only identity in attn-02a.2.3 or validation without weakening write-only registration.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-10T23:58:51Z","created_by":"James Lal","updated_at":"2026-07-11T03:29:52Z","started_at":"2026-07-11T02:56:19Z","closed_at":"2026-07-11T03:29:52Z","close_reason":"Implemented additive /v3 scoped relay admission with all endpoint read/write gates, WS read auth, protocol mismatch, version-bound blob caps, equal-key rejection, conformance matrix and spec. Typecheck, 78 focused relay tests, isolated flaky rate-limit case, semantic conformance, and 194 Rust transport executions pass.","dependencies":[{"issue_id":"attn-02a.2.2","depends_on_id":"attn-02a.2","type":"parent-child","created_at":"2026-07-10T17:58:50Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.2.2","depends_on_id":"attn-02a.2.1","type":"blocks","created_at":"2026-07-10T18:01:10Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-02a.2.3","title":"grantTier in device registration + import-side enforcement + composer gating","description":"Bind grantTier (comment | suggest) into the signed device-registration payload. Owner client and peers drop out-of-tier events on import; the composer does not offer suggest to comment-tier participants. Documented honestly as policy enforcement (envelopes are relay-opaque), not crypto.\n\n## Files\n- src/review/ (registration payload, import pipeline drop rule)\n- relay/ deviceRegistrationSchema (accept grantTier)\n- web/src/lib/review/ + composer component (tier-aware UI)\n- planning/collab/data-model.md trust-model note\n\n## Approach\ngrantTier rides the AAD-bound registration like kind/client. Import pipeline consults the sender's registered tier before accepting suggestion envelopes; drops are logged, not fatal. Owner and agents are unaffected (owner keeps decision #3 authority; agents default suggest).\n\n## Verification\n- cargo build \u0026\u0026 cargo test tier (comment-tier suggestion envelope dropped on import; comment accepted)\n- cd web \u0026\u0026 npm run build \u0026\u0026 npm test\n- Smoke: dual-instance with comment-tier reviewer — composer shows no Suggest affordance; hand-crafted suggestion envelope is discarded by owner import\n\n## Plan Reference\n- planning/sharing/README.md (Workstream C)","notes":"Implemented additive v3 owner-signed grantTier primitives. Relay requires paired comment/suggest tier+owner signature for v3 non-owner registration, forbids owner grant fields, verifies exact room-bound canonical proof, and device selfSignature binds both; v2 is unchanged. Native and browser directory imports verify paired immutable grants before authorizing, derive exact capabilities by kind+tier, and drop comment-tier suggestions before persistence. Native manager hard-blocks comment-tier suggestion creation before event/outbox; hosted/native composer affordances and direct entry points use room-scoped effective tier. Trust boundary documented as peer policy enforcement over relay-opaque envelopes. Independent security rereview APPROVE. Verification: cargo test comment_grant_rejects_suggestion_before_event_or_outbox PASS (lib+main); cargo test tier PASS (lib+main); cargo check PASS; relay typecheck + focused schema/v3 integration 16/16 PASS; browser-ws 20/20 PASS; svelte-check 0 diagnostics. Browser-session new grant cases pass; full standalone harness has an existing intermittent remembered-room cursor timing case (observed 21/22 after previously passing 22/22), unrelated to this diff. Production v3 join/transport must populate and persist/reload set_local_grant_tier and BrowserSession grant options in attn-02a.2.4; anonymous view socket remains there.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-10T23:58:51Z","created_by":"James Lal","updated_at":"2026-07-11T04:21:13Z","started_at":"2026-07-11T03:32:27Z","closed_at":"2026-07-11T04:21:13Z","close_reason":"Closed","dependencies":[{"issue_id":"attn-02a.2.3","depends_on_id":"attn-02a.2","type":"parent-child","created_at":"2026-07-10T17:58:51Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-02a.2.1","title":"Split HKDF tree: readKeys vs writeAdmissionKey (Rust + TS + vectors)","description":"Derive read capability (event/snapshot decryption + read-admission MAC) and write capability (writeAdmissionKey) as separate HKDF branches with v3-suffixed info strings. A view-only URL simply omits the write capability from its fragment.\n\n## Files\n- src/review/crypto/ (derivation)\n- web/src/lib/review/browser-crypto (TS parity)\n- planning/collab/test-vectors/ (new corpus entries)\n- planning/collab/crypto-spec.md (key-derivation tree update, v3 info strings)\n\n## Approach\nFollow the existing corpus-driven pattern (Phase 0a): add vectors first, implement in both languages, both pass. Fragment encoding gains a tier discriminator so clients know which keys they hold. Do not touch v2 derivations — v3 strings are additive.\n\n## Verification\n- cargo build \u0026\u0026 cargo test crypto (vector corpus passes)\n- cd web \u0026\u0026 npm test (TS vectors pass, Rust/TS interop on corpus)\n- Round-trip: URL fragment with read-only capability decrypts events but produces no writeAdmissionKey\n\n## Plan Reference\n- planning/sharing/README.md (Workstream C)","notes":"Additive v3 design: rootKeyV3 from roomSecret using attn room root v3; readCapabilityKeyV3 from root using attn read capability v3; event/snapshot/signaling/readAdmission leaves derive only from read capability with v3 labels; writeAdmissionKeyV3 derives separately from root. View URLs carry read capability only; comment/suggest also carry write leaf. Strict fragment: v=3\u0026tier=view|comment|suggest\u0026read=... with write=... required only for comment/suggest. Add derive/read APIs in Rust and TS, separate kdf-v3.json corpus, crypto-spec update, strict additive Rust/TS fragment helpers; preserve all v2 APIs and production routes. V3 roomId remains SHA256 prefix attn room v3 over owner roomSecret; recipients authenticate the room path with read-admission because they intentionally lack roomSecret.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-10T23:58:50Z","created_by":"James Lal","updated_at":"2026-07-11T02:53:52Z","started_at":"2026-07-11T02:30:28Z","closed_at":"2026-07-11T02:53:52Z","close_reason":"Implemented additive v3 read/write capability HKDF split with Rust/TS parity, strict tiered fragments, 4-vector corpus, AEAD view-only proof, and spec updates. Rust crypto, full web suite, build, clippy, release size all pass.","dependencies":[{"issue_id":"attn-02a.2.1","depends_on_id":"attn-02a.2","type":"parent-child","created_at":"2026-07-10T17:58:49Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":3,"comment_count":0} +{"_type":"issue","id":"attn-02a.1.3","title":"Implement attn review submit-suggestion --from-diff","description":"Turn a unified diff (the agent's worktree changes to the shared doc) into one suggestion per hunk, anchored via the existing anchor engine. Removes the main friction in the worktree flow.\n\n## Files\n- src/cli_review.rs (--from-diff \u003cfile|-\u003e)\n- src/review/ (diff -\u003e hunk -\u003e anchor+replacement construction)\n\n## Approach\nParse unified diff (no new heavy dep if avoidable — a small parser over @@ hunks is fine), map each hunk's context to an anchor via the anchor engine against the current shared snapshot, emit one suggestion envelope per hunk through the existing submit path. Reject hunks that fail to anchor with a clear per-hunk error (partial submission is allowed but reported). Update planning/collab/agent-cli-howto.md with the gate loop (worktree -\u003e --from-diff -\u003e verdicts --wait).\n\n## Verification\n- cargo build\n- cargo test from_diff (fixture diff with 3 hunks -\u003e 3 suggestions; non-anchoring hunk reported)\n- Smoke: dual-instance — git-diff a fixture edit, submit --from-diff, owner sees per-hunk suggestions\n\n## Plan Reference\n- planning/sharing/README.md (Workstream D, open question 4: per-hunk granularity is the default pending annotation)","notes":"Approach: add src/review/diff_suggestions.rs with a small one-file unified-diff parser and per-hunk builder. Select a persisted Markdown snapshot by optional --room or unique diff-path/snapshot match; verify hunk old/context projection against snapshot (expected location then unique fallback); construct replace/delete/insert SuggestionDraft anchors from canonical byte/line ranges plus snapshot AnchorIndex block/context/structure layers; return valid drafts plus indexed per-hunk errors so partial submission is explicit. Add attn review submit-suggestion --from-diff PATH_OR_STDIN [--room ID], daemon batch request that submits each draft through ReviewCommand::CreateSuggestion, and agent-cli-howto gate-loop docs. Default uses daemon identity per locked Agent CLI handling. Files: src/review/diff_suggestions.rs, src/review/mod.rs, src/cli_review.rs, src/daemon.rs, planning/collab/agent-cli-howto.md. Tests: 3-hunk fixture gives 3 drafts; non-anchoring hunk error while valid hunk survives; insert/delete/replace operations; CLI stdin/file parse and socket batch serialization. Validation: cargo fmt; cargo test from_diff; cargo build; dual-instance git-diff smoke owner sees per-hunk suggestions.","status":"closed","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-10T23:58:48Z","created_by":"James Lal","updated_at":"2026-07-11T02:08:39Z","started_at":"2026-07-11T01:23:54Z","closed_at":"2026-07-11T02:08:39Z","close_reason":"Implemented and verified: per-hunk unified diff submission, reviewer snapshot reconstruction, durable local pending events, partial failures, docs, focused tests, dual-daemon relay smoke, clippy, build, and size gate.","dependencies":[{"issue_id":"attn-02a.1.3","depends_on_id":"attn-02a.1","type":"parent-child","created_at":"2026-07-10T17:58:47Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-02a.5","title":"Browser Web Push: content-free ping, local decrypt in service worker","description":"The browser reviewer has no resident daemon — push is what makes the browser side proactive. The relay sends a content-free ping (VAPID); the service worker wakes, opens the remembered-room capability from IndexedDB (decision #13), pulls envelopes over WS, decrypts and verifies locally, and composes the notification itself. Apple/Google push infra never sees author, kind, or text. Subscriptions are bound to (share/room, deviceId), MAC'd, PoW'd, expiring with share/room TTL.\n\n## Scope\n- Relay: VAPID keypair, subscription store, ping-on-arrival for devices with no live WS, per-device debounce\n- Service worker on attn.sh: subscribe, wake -\u003e pull -\u003e local decrypt -\u003e rich local notification, click deep-link\n- Consent UX alongside \"Remember this room\" (open question 6 in the plan)\n- iOS: requires Home Screen PWA install (16.4+) — rides attn-7xl's iOS work; desktop has no gate\n\n## Pre-conditions\nDurable shares (subscription store lives on the share record; land alongside A so shares ship with push).\n\n## Plan Reference\n- planning/sharing/README.md (Workstream B, layer 4 / Browser Web Push design sketch)","status":"closed","priority":2,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-10T23:57:36Z","created_by":"James Lal","updated_at":"2026-07-11T10:33:41Z","closed_at":"2026-07-11T10:33:41Z","close_reason":"Closed","dependencies":[{"issue_id":"attn-02a.5","depends_on_id":"attn-02a.3","type":"blocks","created_at":"2026-07-10T18:01:08Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.5","depends_on_id":"attn-02a","type":"parent-child","created_at":"2026-07-10T17:57:36Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-02a.4","title":"Async notifications: unread state, native notifications, resident daemon","description":"Close the async loop on the native side. Per-room unread counts from the import pipeline with UI badges; macOS user notifications from the daemon when review events import unfocused (debounced, deep-linking via attn://review/...); an opt-in resident daemon mode so notifications fire without a window open.\n\n## Scope\n- Unread counts + badges (peer strip / tab / tree)\n- Daemon-posted macOS notifications, debounced per room\n- attn daemon --resident + login item opt-in\n\n## Pre-conditions\nNone. Pure client work; no protocol change.\n\n## Plan Reference\n- planning/sharing/README.md (Workstream B, layers 1-3)","status":"closed","priority":2,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-10T23:57:35Z","created_by":"James Lal","updated_at":"2026-07-11T10:09:36Z","closed_at":"2026-07-11T10:09:36Z","close_reason":"Closed","dependencies":[{"issue_id":"attn-02a.4","depends_on_id":"attn-02a","type":"parent-child","created_at":"2026-07-10T17:57:34Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-02a.3","title":"Durable share links: relay-side share indirection","description":"The link is the document. Rooms stay ephemeral (decision #8 intact); a small long-lived relay share object holds the current roomId and the latest encrypted snapshot per file. Same URL admits into successive rooms via epoch-derived room secrets. Visitors can read (and queue comments into a share mailbox) while the owner is offline. Owner-signed revocation is the kill switch. Amends decision #9 (share-pinned R2 retention).\n\n## Scope\n- Relay: /v3/shares endpoints (POST/GET/DELETE, owner-signed, PoW) + share mailbox\n- Key schedule: shareSecret -\u003e epoch room secrets; attn.sh/s/\u003cshareId\u003e#key=... URL forms\n- Owner daemon: share create/renew/revoke, room epoch minting, mailbox drain on connect\n- Browser visitor flow: live room, or snapshot read-only + comment queue, upgrade on room appearance\n\n## Pre-conditions\nPermission tiers (full-power durable links must not circulate).\n\n## Plan Reference\n- planning/sharing/README.md (Workstream A)\n- planning/collab/amendments.md decisions #8, #9, #10","status":"closed","priority":2,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-10T23:57:34Z","created_by":"James Lal","updated_at":"2026-07-11T08:41:40Z","started_at":"2026-07-11T05:40:29Z","closed_at":"2026-07-11T08:41:40Z","close_reason":"Closed","dependencies":[{"issue_id":"attn-02a.3","depends_on_id":"attn-02a.2","type":"blocks","created_at":"2026-07-10T18:01:08Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-02a.3","depends_on_id":"attn-02a","type":"parent-child","created_at":"2026-07-10T17:57:33Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-02a.2","title":"Permission-tiered links: view / comment / suggest capabilities","description":"Split the HKDF tree so read and write are separate URL-borne capabilities. The relay cryptographically enforces the read/write boundary (view-only links cannot write); the comment/suggest boundary is a signed grantTier in device registration, enforced at import by owner and peers. Amends decision #2 (one admission key -\u003e read/write pair; still URL-as-bearer, no server-side token state).\n\n## Scope\n- Key derivation split: readKeys vs writeAdmissionKey, v3 info strings, Rust+TS parity vectors\n- Relay per-endpoint auth: writes MAC with writeAdmissionKey\n- grantTier in device registration + import-side enforcement + composer gating\n- Tiered invite generation (three URLs per share)\n\n## Pre-conditions\nNone, but MUST ship before durable shares are promoted publicly.\n\n## Plan Reference\n- planning/sharing/README.md (Workstream C)\n- planning/collab/crypto-spec.md, relay-spec.md","status":"closed","priority":2,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-10T23:57:33Z","created_by":"James Lal","updated_at":"2026-07-11T05:39:44Z","started_at":"2026-07-11T02:30:28Z","closed_at":"2026-07-11T05:39:44Z","close_reason":"Completed v3 split capabilities, relay enforcement, owner grants, production tiered invites, and full native/browser E2E validation.","dependencies":[{"issue_id":"attn-02a.2","depends_on_id":"attn-02a","type":"parent-child","created_at":"2026-07-10T17:57:33Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-egi.9","title":"Coordinate remembered browser WebRTC identity across tabs","description":"A remembered room can open the same recovered device identity in multiple tabs, causing duplicate targeted SDP answers. Add a BroadcastChannel/lease leader for WebRTC ownership while keeping mailbox reading and durable recovery correct in every tab.","acceptance_criteria":"Exactly one tab negotiates for a remembered deviceId, leadership fails over after close/crash, non-leaders stay mailbox-correct, and Playwright covers two remembered tabs.","status":"open","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-10T21:46:17Z","created_by":"James Lal","updated_at":"2026-07-10T21:46:17Z","labels":["cloudflare","e2e","staging","web"],"dependencies":[{"issue_id":"attn-egi.9","depends_on_id":"attn-egi.4","type":"discovered-from","created_at":"2026-07-10T15:46:17Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-egi.9","depends_on_id":"attn-egi","type":"parent-child","created_at":"2026-07-10T15:46:17Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-egi.7","title":"Add focused hosted authoring regression coverage","description":"Add focused regression tests for native leave-to-rejoin WebRTC rebuild, hosted cross-file anchor-resolution isolation, and rejected async reply submission retaining its draft and inline error.","acceptance_criteria":"Each scenario has a deterministic focused test that fails against the pre-attn-egi.2 behavior and passes without relying on the full hosted Playwright suite.","status":"open","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-10T16:57:03Z","created_by":"James Lal","updated_at":"2026-07-10T16:57:03Z","labels":["cloudflare","e2e","staging","tests","web","webrtc"],"dependencies":[{"issue_id":"attn-egi.7","depends_on_id":"attn-egi.2","type":"discovered-from","created_at":"2026-07-10T10:57:03Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-egi.7","depends_on_id":"attn-egi","type":"parent-child","created_at":"2026-07-10T10:57:02Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-egi.6","title":"Runtime-validate hosted inbound review event bodies","description":"Add strict per-variant runtime validation for decrypted browser ReviewEvent bodies before BrowserWsClient dispatches them. Keep role/capability checks, actor binding, and failure zeroization; malformed role-allowed comments/suggestions/presence must be dropped without destabilizing the hosted UI.","acceptance_criteria":"Malformed but correctly signed/encrypted events for every ReviewEvent discriminator are rejected before UI/store application; valid Rust-produced corpus events continue to pass.","status":"open","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-10T16:57:00Z","created_by":"James Lal","updated_at":"2026-07-10T16:57:00Z","labels":["cloudflare","e2e","e2ee","security","staging","web"],"dependencies":[{"issue_id":"attn-egi.6","depends_on_id":"attn-egi.2","type":"discovered-from","created_at":"2026-07-10T10:57:01Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-egi.6","depends_on_id":"attn-egi","type":"parent-child","created_at":"2026-07-10T10:57:00Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-7tb","title":"Relay: isolate staging and production R2 namespaces","description":"Production and staging currently share one R2 bucket and generation-blind rooms/\u003croomId\u003e/ keys. Use separate buckets or an authenticated environment prefix so identical room/envelope IDs cannot collide and each environment's capacity ledger bounds its own physical storage.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-09T20:59:02Z","created_by":"James Lal","updated_at":"2026-07-09T21:53:59Z","closed_at":"2026-07-09T21:53:59Z","close_reason":"Production and staging now use separate R2 buckets, with docs and config tests included in attn-tdj validation.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-lon","title":"HTML annotation Phase 6: IPC contract for HTML anchors + client resolution status","description":"Extend the ReviewManager IPC (src/review/manager.rs): CreateComment/CreateSuggestion accept the HTML selector anchor layer; add a path for the webview to report client-side resolution status back so the manager can emit AnchorResolutionChanged for HTML docs. Keep markdown command shapes intact.","acceptance_criteria":"CreateComment/CreateSuggestion carry HTML anchors end-to-end; webview-reported resolution status surfaces as AnchorResolutionChanged; markdown IPC unchanged.","status":"open","priority":2,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-06-18T15:03:30Z","created_by":"James Lal","updated_at":"2026-06-18T15:03:30Z","dependencies":[{"issue_id":"attn-lon","depends_on_id":"attn-yob","type":"blocks","created_at":"2026-06-18T09:03:58Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-08r","title":"HTML annotation Phase 5: shell wiring — ReviewMargin + composer for HTML","description":"Unhide ReviewMargin for HTML (BrowserReviewApp.svelte:272, App.svelte:251 branches). Position rail cards from anchor rects the doc frame reports up via postMessage (cross-origin: doc owns geometry, shell positions cards with push-down collision layout + connector lines, as in the prototype). Feed CommentComposer from doc-frame selection events. store.svelte.ts holds opaque HTML anchors; resolver.ts branches so HTML resolution is delegated to the in-doc runtime (not the 8-step Rust mirror). Reuse ReviewMargin/ReviewMarginCard/CommentComposer unchanged where possible.","acceptance_criteria":"ReviewMargin renders for HTML docs; cards align to anchors and link on hover/focus; composer opens from text and element selections; store handles HTML anchors with no 'any' types.","notes":"Ref refresh 2026-08-04: margin gates now BrowserReviewApp.svelte:1344 and :1376 (was :272) and App.svelte:3005 (was :251); margin mounts via rightRailPlaceholder at App.svelte:3116; additional gates App.svelte:1093, :1117, :1193, :1284-1290. ReviewMargin geometry is currently PM-based (view.coordsAtPos, popover-anchor.ts, margin-layout.ts) — HTML cards must position from anchor rects reported over postMessage.","status":"open","priority":2,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-06-18T15:03:29Z","created_by":"James Lal","updated_at":"2026-08-04T23:30:55Z","dependencies":[{"issue_id":"attn-08r","depends_on_id":"attn-lon","type":"blocks","created_at":"2026-06-18T09:04:00Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-08r","depends_on_id":"attn-ges","type":"blocks","created_at":"2026-06-18T09:03:59Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-ges","title":"HTML annotation Phase 4: in-doc annotation runtime (productionize prototype JS)","description":"Productionize the prototype's annotation logic as a separate web/ build entry injected into the doc frame. Includes: CSS Custom Highlight API text layer (attn-text / attn-text-active buckets, no wrapper spans); element overlays with pointer-events:none fills and interactive tag/count chips; left-gutter comment pin; scope breadcrumb (cell \u003c row \u003c table) with per-scope selector + human preview + comment counts; selection-\u003efloating Comment pill; nested/stacked-comment N badge. No mode switch — text and element gestures both always live; active text selection suppresses the gutter pin. Use Apache Annotator dom for selector generation/resolution and @medv/finder for CSS selectors. Define and implement the full postMessage protocol both directions. Style in PAPER/INK tokens. This is the heaviest phase and reuses planning/collab/prototypes/html-annotation.html most directly.","acceptance_criteria":"Doc frame renders text highlights + element overlays + gutter pin + scope breadcrumb; selection raises the pill; nested comments share one overlay with an N badge; overlays never trap the cursor; client-side selector resolution works; postMessage round-trips to the shell.","notes":"Design addition 2026-08-04 (agreed): committed comments leave PERSISTENT in-canvas element pins / numbered badges (Vercel Comments-style), visible without hover and clickable to focus the thread in the margin rail — this extends the prototype's hover-only chips + N-badge, which should remain the hover/preview affordance. Keep the locked overlay rules: overlay fills stay pointer-events:none; only pins/chips/badges are interactive. Precedent note: Vercel anchors by DOM element alone and suffers detachment when the page changes; our full W3C selector chain re-anchors — do not weaken it for pin placement (pins derive position from the resolved element rect, reported to the shell over postMessage).","status":"open","priority":2,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-06-18T15:03:29Z","created_by":"James Lal","updated_at":"2026-08-04T23:41:36Z","dependencies":[{"issue_id":"attn-ges","depends_on_id":"attn-17g","type":"blocks","created_at":"2026-06-18T09:03:58Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-17g","title":"HTML annotation Phase 3: doc-frame hosting + postMessage channel (opaque-origin, injected runtime)","description":"Replace the opaque sandbox iframe (HtmlViewer.svelte:96, sandbox='allow-scripts', srcdoc/attn:// path) with a real cross-origin iframe pointing at the shared doc served by Rust on a localhost origin. Adjust the HTML CSP (src/main.rs:646) to serve and permit the injected annotation runtime. Origin-isolation preserved; shell\u003c-\u003edoc only via postMessage.","acceptance_criteria":"Shared HTML doc loads from a real localhost origin in a non-opaque iframe; injected runtime script loads under CSP; postMessage channel established between shell and doc.","notes":"\nRef refresh 2026-08-04: opaque sandbox now HtmlViewer.svelte:100-119 (path- \nmode hardcode at :115; sandbox helper web/src/lib/html-viewer-sandbox.ts). \nHTML response CSP now src/main.rs:814-826 (was :646); ACAO withheld for html\nat main.rs:802 — keep that property when introducing the distinct origin. \n\nSCOPE CHANGED 2026-08-04 by the Phase 0 design note: Design B (distinct localhost origin) is superseded — it cannot work for the hosted browser reviewer, which has no Rust process. The frame KEEPS its opaque-origin sandbox; the runtime is injected into the HTML source instead, and the channel is a hello -\u003e MessageChannel handshake bound on event.source. Landed: injectDocRuntime() + HtmlAnnotationBridge (web/src/lib/review/html-annotation-bridge.ts), HtmlViewer annotate/onBridge props, doc-protocol.ts with a full validating parser. src/main.rs CSP is UNCHANGED — no longer needed, since annotation always runs in content/srcdoc mode. REMAINING: none for the frame itself; verify in the running app.","status":"open","priority":2,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-06-18T15:03:05Z","created_by":"James Lal","updated_at":"2026-08-05T03:32:33Z","dependencies":[{"issue_id":"attn-17g","depends_on_id":"attn-yob","type":"blocks","created_at":"2026-06-18T09:03:57Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-yob","title":"HTML annotation Phase 2: HTML snapshot substrate + un-gate comment authoring","description":"bootstrap.rs:1467 currently produces (DocType::Html, None). Give HTML snapshots an annotation capability instead of bare None (NOT a Rust DOM indexer — resolution is client-side). Replace the !snapshot.anchorIndex guards (BrowserReviewApp.svelte ~190 openCommentComposer; and the collab-seeding gate) with doc-type-aware checks so HTML docs can author comments. Keep markdown behavior identical.","acceptance_criteria":"HTML snapshot carries an annotation capability; openCommentComposer no longer dead-ends for HTML; markdown path unchanged.","notes":"Ref refresh 2026-08-04: bootstrap short-circuit now src/review/bootstrap.rs:3180-3181 (was :1467). Composer gate now BrowserReviewApp.svelte:1046 (further gates :932, :936, :977). Collab-seed markdown gate App.svelte:730; hasMarkdownSnapshot drives reviewerAvailability at :711. Also extend web/src/lib/types.ts:56 inlineSnapshot union and browser-snapshot-publisher.ts:322 (currently drops extra fields), and decide the manager.rs:3986 integrity question (canonical index verify is markdown-only).","status":"closed","priority":2,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-06-18T15:03:04Z","created_by":"James Lal","updated_at":"2026-08-05T03:32:16Z","closed_at":"2026-08-05T03:32:16Z","close_reason":"SnapshotAnnotation::HtmlSelectorsV1 capability added to SnapshotPlaintext; bootstrap.rs now publishes HTML with it instead of (DocType::Html, None). validate() keeps rejecting anchorIndex on HTML (deliberate — no Rust HTML parser) and rejects the capability on markdown/asset/manifest. Absent capability = read-only, so older peers stay valid. Mirrored in TS types + browser-snapshot-publisher. 7 new tests.","dependencies":[{"issue_id":"attn-yob","depends_on_id":"attn-80u","type":"blocks","created_at":"2026-06-18T09:03:57Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} +{"_type":"issue","id":"attn-80u","title":"HTML annotation Phase 1: HTML selector anchor layer in shared model","description":"Add an HTML selector anchor layer to the shared Anchor model (src/review/model.rs). It must carry a W3C selector set: CssSelector + TextQuote + TextPosition + Range (text ranges AND whole DOM elements, incl. table cell/row/table). serde-optional and doc-type tagged so markdown anchors are unaffected. Must round-trip unchanged through the existing review envelope (no Rust-side resolution of these — opaque persist/sync). Cut over cleanly; no backwards-compat shim.","acceptance_criteria":"Anchor model gains an HTML selector layer; serde round-trip test passes; markdown anchors unchanged; envelope round-trips the new layer.","notes":"Ref refresh 2026-08-04: the validate gate to relax is src/review/model.rs:364-368 (SnapshotPlaintext::validate rejects anchorIndex on DocType::Html). Naming caution: AnchorBlockKind::Html (model.rs:838) already means 'raw HTML block inside markdown' — pick a non-colliding name for the new selector layer. Anchor struct (5 markdown layers) at model.rs:876-892; new layer must be serde-optional + doc-type tagged.\n\nDesign addition 2026-08-04 (agreed): the HTML anchor layer must also carry an agent-legible CONTEXT BLOCK written at anchor-creation time — element tag + role, the human-readable scope preview the prototype's breadcrumb already computes, and a short DOM-path breadcrumb. Rationale: attn comments on HTML docs should hand AI agents Agentation-grade context (robust selector + text quote + element context + human intent) so a comment is directly actionable by a coding agent. Serde-optional alongside the selectors, opaque to Rust like the rest of the layer.","status":"closed","priority":2,"issue_type":"feature","assignee":"Angus Bezzina","owner":"james@littlebearlabs.io","created_at":"2026-06-18T15:03:03Z","created_by":"James Lal","updated_at":"2026-08-05T03:32:15Z","started_at":"2026-08-04T23:47:19Z","closed_at":"2026-08-05T03:32:15Z","close_reason":"HtmlAnchor selector layer landed in src/review/model.rs: target (text_range|element), cssSelector + ranked fallbackSelectors, TextPosition, RangeSelector, and the agreed agent-context block (tagName/role/scopePreview/domPath). Anchor.html is serde-optional so markdown wire bytes are byte-identical. HtmlAnchor::validate bounds every field (untrusted frame input). TS mirror in web/src/lib/types.ts. 10 new serde/validation tests; 557 Rust tests green.","dependencies":[{"issue_id":"attn-80u","depends_on_id":"attn-05x","type":"blocks","created_at":"2026-06-18T09:03:55Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-05x","title":"HTML annotation Phase 0: design note (in-doc runtime + client-side resolution)","description":"Write a short design note under planning/collab/ locking the two architectural decisions for HTML annotation before any code:\n1. In-doc annotation runtime + postMessage bridge to the Svelte shell (Design B): shared doc served from a distinct localhost origin, scripts allowed but origin-isolated. Document the postMessage protocol surface (doc-\u003eshell: selectionMade, anchorCreated, anchorRects/geometry, hover/focus; shell-\u003edoc: renderAnchors, focusAnchor, themeChanged).\n2. HTML anchors = W3C selector set (CssSelector + TextQuote + TextPosition + Range) resolved client-side in the doc frame (Apache Annotator dom + @medv/finder). Rust treats them as opaque; no headless HTML parser (protects 32 MiB gate).\nReference planning/collab/prototypes/html-annotation.html and README.md as the validated UX.","acceptance_criteria":"Design note committed under planning/collab/; both decisions and the postMessage protocol surface are written down; references the prototype.","status":"closed","priority":2,"issue_type":"task","assignee":"Angus Bezzina","owner":"james@littlebearlabs.io","created_at":"2026-06-18T15:03:01Z","created_by":"James Lal","updated_at":"2026-08-04T23:47:18Z","started_at":"2026-08-04T23:45:13Z","closed_at":"2026-08-04T23:47:18Z","close_reason":"Design note landed at planning/collab/html-annotation.md; decisions #19/#20 added to amendments.md; prototype README refreshed. Supersedes Design B (distinct localhost origin) — unimplementable for the hosted browser reviewer, which has no Rust process and renders via srcdoc.","dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-61t","title":"[epic] HTML document annotation (text + element comments)","description":"Port the validated HTML-annotation prototype (planning/collab/prototypes/html-annotation.html) into attn mainline. Bring attn's collaborative commenting/review surface to rendered HTML documents — both text-range comments and whole-element comments (incl. table cell/row/table scope) — without a mode switch, styled in PAPER/INK.\n\nTwo locked architectural decisions (see Phase 0 design note):\n\n1. The annotation runtime lives INSIDE the doc iframe and talks to the Svelte shell over postMessage (Design B: shared doc served from a distinct localhost origin, scripts allowed but origin-isolated). It cannot live in the parent — cross-origin DOM access is blocked by design.\n2. HTML anchors are W3C selectors (CssSelector + TextQuote + TextPosition + Range) resolved CLIENT-SIDE in the doc frame (Apache Annotator dom + @medv/finder). Rust persists/syncs them as opaque blobs and never parses HTML — this avoids a headless HTML parser in the binary, protecting the binary-size gate.\n\nSTATUS 2026-08-04: read-only HTML SHARING already works end-to-end on main (attn-qgd): DocType::Html snapshots publish over the encrypted transport, reviewers render them in a srcdoc sandbox iframe, E2E via `task test:html-share`. This epic is the remaining half — commenting.\n\nCurrent blockers in mainline (line refs refreshed 2026-08-04 against main@df7faff):\n\n- src/review/model.rs:364-368 — SnapshotPlaintext::validate hard-rejects an HTML snapshot that carries an anchorIndex; the new substrate needs a doc-type-tagged optional layer here.\n- src/review/bootstrap.rs:3180-3181 sets (DocType::Html, None) — no anchor substrate on publish.\n- BrowserReviewApp.svelte:1046 openCommentComposer early-returns on !snapshot.anchorIndex (further gates at :932, :936, :977).\n- BrowserReviewApp.svelte:1344 and :1376, plus App.svelte:3005, hide ReviewMargin when docType==='html' (additional gates App.svelte:1093, :1117, :1193, :1284-1290; collab seeding requires docType==='markdown' at :730).\n- HtmlViewer.svelte:100-119 — opaque sandbox='allow-scripts' iframe (no allow-same-origin, so no usable postMessage identity). HTML response CSP now at src/main.rs:814-826; ACAO deliberately withheld for html at main.rs:802.\n- web/src/lib/types.ts:56 — the inlineSnapshot union member { docType: 'html'; content } has no annotation-capability slot; browser-snapshot-publisher.ts:322 drops any extra fields.\n- src/review/manager.rs:3986 — canonical anchor-index rebuild/verify runs only for DocType::Markdown; the HTML substrate needs an equivalent integrity check or an explicit opaque/unverified decision.\n\nPrototype is feature-complete and verified in Chromium (text + element + cell/row/table scope chain + nested comments + non-blocking overlays).","design":"Dependency spine: Phase0 -\u003e Phase1 -\u003e Phase2 -\u003e {Phase3, Phase6} -\u003e Phase4 -\u003e Phase5 -\u003e Phase7 -\u003e Phase8. No backwards-compat shims (cut over). No 'any' types in TS. web/ uses npm. Svelte 5 runes outside components need .svelte.ts.","notes":"\n2026-08-04 refresh (branch angus/html-commenting, worktree \n/Users/angusbezzina/Development/attn-html-commenting): all line refs re- \nverified against main@df7faff; original refs from 2026-06-18 were stale. \n \nDesign re-validated against current (2026) practice — no changes needed: \n \n• W3C Web Annotation Data Model selector chains remain the standard for \nrobust HTML anchoring: write ALL selector layers on creation (CssSelector + \nTextQuote with prefix/suffix + TextPosition + Range); resolve exact → \nnormalized → bounded-fuzzy, disambiguating multi-matches by prefix/suffix \nsimilarity. An undisambiguated multi-occurrence match should surface as \nAmbiguous (matches existing ResolvedAnchor semantics), never silently anchor.\n• CSS Custom Highlight API (no wrapper spans) remains the recommended text- \nhighlight mechanism. \n• Sandboxed-iframe isolation posture is current best practice: keep scripts \norigin-isolated; a dedicated localhost origin + postMessage bridge (Design \nB) is the standard escape from the opaque-origin limitation without granting\nallow-same-origin on the app origin. \n \nReusable infra confirmed content-type-agnostic (zero changes needed): \ncrypto/envelopes, relay (content-blind ciphertext), WS/WebRTC transports + \noutbox/InboundPipeline, share links + invites (/s/#key=, attn://share/), \nstore JSONL, byte-splice apply pipeline. The work is confined to: \nSnapshotPlaintext validation + Anchor layer (Rust model), publish substrate,\niframe origin/CSP, in-doc runtime, shell wiring, IPC contract, tests. \n \nE2E precedent to copy for Phase 8: scripts/test-html-share-e2e.sh + task \ntest:html-share (Miniflare relay + owner + reviewer daemons over \ntests/fixtures/sample.html). \n\nIMPLEMENTATION 2026-08-04 (branch angus/html-commenting):\n- Phase 0 landed: planning/collab/html-annotation.md + amendments #19/#20.\n- DESIGN DEVIATION: 'Design B' (serve doc from a distinct localhost origin) was SUPERSEDED. It cannot work for the hosted browser reviewer, which has no Rust process and renders via srcdoc — there is no origin to serve from. Replaced by: keep the opaque-origin sandbox, inject the runtime into the HTML source (a pure content transform, so native and hosted run identical code), and bind the channel with a hello -\u003e MessageChannel handshake on event.source.\n- NEW SECURITY NOTE needing review: annotating requires allow-scripts, so hosted reviewers (today allowScripts=false) get page scripts enabled WHILE ANNOTATING. Frame stays opaque-origin so no app/file/storage reach. Read-only viewing keeps today's script-free rendering. See html-annotation.md §4.","status":"in_progress","priority":2,"issue_type":"epic","assignee":"Angus Bezzina","owner":"james@littlebearlabs.io","created_at":"2026-06-18T15:02:40Z","created_by":"James Lal","updated_at":"2026-08-05T03:19:26Z","started_at":"2026-08-04T23:45:12Z","dependencies":[{"issue_id":"attn-61t","depends_on_id":"attn-17g","type":"parent-child","created_at":"2026-06-18T09:04:15Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-61t","depends_on_id":"attn-ges","type":"parent-child","created_at":"2026-06-18T09:04:16Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-61t","depends_on_id":"attn-lon","type":"parent-child","created_at":"2026-06-18T09:04:18Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-61t","depends_on_id":"attn-08r","type":"parent-child","created_at":"2026-06-18T09:04:17Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-61t","depends_on_id":"attn-job","type":"parent-child","created_at":"2026-06-18T09:04:18Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-61t","depends_on_id":"attn-05x","type":"parent-child","created_at":"2026-06-18T09:04:13Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-61t","depends_on_id":"attn-yob","type":"parent-child","created_at":"2026-06-18T09:04:15Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-61t","depends_on_id":"attn-80u","type":"parent-child","created_at":"2026-06-18T09:04:14Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-61t","depends_on_id":"attn-7ev","type":"parent-child","created_at":"2026-06-18T09:04:20Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-qgd","title":"Read-only HTML document sharing over encrypted rooms","description":"Allow sharing .html/.htm files through the existing E2E-encrypted review transport in READ-ONLY mode (no collaborative editing). Reviewer opens and reads the rendered HTML; commenting/anchoring deferred. Frozen snapshot, reuses Svelte viewer (HtmlViewer via srcdoc on reviewer side).","design":"PLAN (read-only HTML share v1):\n\nRUST / model (src/review/model.rs)\n- Add DocType enum { Markdown, Html } (serde snake_case).\n- SnapshotPlaintext: rename markdown-\u003econtent:String, add doc_type:DocType, make anchor_index:Option\u003cAnchorIndex\u003e (None for html; html is read-only, no comment anchors yet).\n\nRUST / owner publish (src/review/bootstrap.rs)\n- is_shareable_path = markdown||html; rename markdown_targets-\u003eshareable_targets, collect_markdown-\u003ecollect_shareable, validate_share_targets allow html. find_room_for_path ext checks include html.\n- publish_snapshot: branch by ext — markdown builds anchor_index (Some); html sets content=html source, anchor_index=None, doc_type=Html.\n\nRUST consumers of SnapshotPlaintext.markdown: store.rs, manager.rs (3078,4503), bootstrap tests — update to content/doc_type.\n\nWEB wire types (web/src/lib/types.ts): SnapshotCreatedBody.inlineSnapshot -\u003e { docType, content, anchorIndex? }. Add DocType.\n\nWEB browser reviewer (browser-session.ts absorbSnapshotCreated): store snapshotDocType + content. BrowserReviewApp.svelte: branch html-\u003eHtmlViewer(srcdoc, read-only) else Editor(markdown, editable=false).\n\nWEB native reviewer (App.svelte): reviewSnapshot has docType; html branch renders HtmlViewer instead of Editor; guard collab-seed to markdown only.\n\nWEB HtmlViewer.svelte: accept raw html content via srcdoc (reviewer has no local file) in addition to path mode (owner local view via attn://).\n\nOUT OF SCOPE v1: comments/anchors on html, suggesting mode, live updates.","notes":"HEADLESS E2E now passing (scripts/test-html-share-e2e.sh, task test:html-share).\nDrives the REAL stack: Miniflare relay + owner daemon shares tests/fixtures/sample.html (hybrid) + reviewer daemon joins the invite. Asserts via automation CLI: owner renders local .html (path mode); owner mints invite (read from window.__attn_review_store__.currentShare); reviewer window SWITCHES to HtmlViewer; reviewer iframe srcdoc carries owner's bytes (marker 'Hello from an HTML file'); reviewer is read-only (no .ProseMirror editor). All PASS.\nFound+fixed during e2e: build.rs short-circuits to prebuilt web/dist/index.html, so web changes need 'npm run build' (web) before cargo build to re-embed — confirmed stale bundle was masking the change.\nRegression: scripts/test-review-e2e.sh still 13 PASS / 0 FAIL after frontend rebuild.\nHonors ATTN_SKIP_HTML_SHARE_E2E=1.","status":"in_progress","priority":2,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-06-17T23:59:23Z","created_by":"James Lal","updated_at":"2026-06-18T00:23:55Z","started_at":"2026-06-17T23:59:39Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-48q","title":"Local review store GC: prune expired rooms, acked outbox, superseded snapshot blobs","description":"~/.attn/reviews/rooms/\u003croomId\u003e/ grows without bound: events.jsonl, outbox.jsonl, revisions/\u003cfileId\u003e.jsonl, snapshots/, and blobs/ are all append-only with no eviction; only explicit room delete removes anything (src/review/store.rs:170-195).\n\nWhat needs to be done:\n1. GC rooms whose relay TTL has passed — expiry is known at join time; mirror the relay lifecycle locally with a grace window (e.g. delete N days after expiresAt).\n2. Prune acked entries from outbox.jsonl (currently grows with every draft until room delete).\n3. Compact revisions/\u003cfileId\u003e.jsonl past some depth (per-file journal, no eviction today).\n4. Delete superseded snapshot blobs — amendments.md decision #10 / supersedesSnapshotId gives the signal, but local cleanup is undefined.\n\nRun GC opportunistically (daemon start, room open/close) rather than a background timer.","status":"open","priority":2,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-06-10T20:21:27Z","created_by":"James Lal","updated_at":"2026-06-10T20:21:27Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-ba8","title":"Share folders via right-click context menu in the file tree","description":"Folder sharing is implemented backend-side (bootstrap.rs validate_share_targets walks a dir for *.md) but folder rows in FileTree.svelte have no context menu — only file rows do. Wrap folder rows in a ContextMenu with a 'Share folder' item -\u003e onShare(folderPath). Works in tree view (+ folder view).","notes":"Implemented: folder rows in FileTree.svelte now wrap a ContextMenu (Share folder / copy paths / open external), composing ContextMenuTrigger + CollapsibleTrigger child snippets onto one button. App.svelte openShareDialogForPath(path, isDir) allows dirs (skips markdown gate, no navigate), threads isDir through the name-prompt resume, and ShareDialog now targets shareTargetPath ?? activePath. svelte-check 0 errors. PENDING: runtime check that left-click still expands + right-click opens the menu.","status":"closed","priority":2,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-05-24T15:25:43Z","created_by":"James Lal","updated_at":"2026-05-24T15:43:25Z","closed_at":"2026-05-24T15:43:25Z","close_reason":"Fixed + verified in 2a69ca9: collabSeedReady gate (unit tests) for the blank editor; folder ContextMenu + share-target wiring (live-daemon verified) for folder sharing.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-e98","title":"Inline 'shared' marker on shared files/folders in sidebar tree","description":"Owner can't tell which sidebar files are in a room. Expose the owner's shared paths (Rust AppState.file_to_room / bindings.json) to the frontend via IPC, then render an inline marker (◆) on shared file AND folder rows in FileTree.svelte. User chose inline-marks-only (no pinned section).","status":"closed","priority":2,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-05-24T15:25:43Z","created_by":"James Lal","updated_at":"2026-05-24T15:52:53Z","closed_at":"2026-05-24T15:52:53Z","close_reason":"Implemented + live-verified: frontend-derived sharedPaths from owner snapshots; ◆ marker on shared file rows + containing folder rows. No new IPC needed.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-zhr","title":"Wire resolve-comment IPC (Resolve button is UI-only)","description":"ITEM 3: ReviewMarginCard.svelte [Resolve] exists but is UI-only (TODO ~line 54) — no IPC, no backend. Add a ReviewResolveComment command + CommentResolved event (round-trips like accept/reject), persist + propagate to peers, collapse the thread to its resolved strip, and provide a reopen path. No reviewResolveComment export in ipc.ts today.","status":"closed","priority":2,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-23T04:45:33Z","created_by":"James Lal","updated_at":"2026-05-23T05:26:48Z","started_at":"2026-05-23T05:19:10Z","closed_at":"2026-05-23T05:26:48Z","close_reason":"Wired the resolve-comment write path end to end: Rust ReviewCommand::ResolveComment + manager.resolve_comment (mints CommentResolved via send_event_sync, propagates to peers), IpcMessage::ReviewResolveComment + dispatch + camelCase parse test (passing); web reviewResolveComment IPC + ReviewMargin.resolveThread replaces the UI-only dismiss (optimistic pendingDismiss + durable event). Read path (reconstructThreads flips thread.resolved -\u003e collapse to strip) already existed. cargo build + bin ipc tests green; svelte-check clean; 28 web test files pass.","dependencies":[{"issue_id":"attn-zhr","depends_on_id":"attn-07i","type":"parent-child","created_at":"2026-05-22T22:47:16Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-07i","title":"Collab editorial UX + conflict-resolution completeness","description":"What's left after the core works. The review surface (composers, margin cards, accept/reject, three-way apply, stale/ambiguous tray) and conflict resolution (OT, three-way merge, anchor remap) are implemented; this epic tracks the completeness/polish gaps found in the 2026-05-22 inventory. Distinct from the sync-transport epic attn-k3v (item 1). Suggested order for the editorial-UX track: selection toolbar (attn-bit) -\u003e resolve-comment IPC -\u003e reply chains -\u003e inbox/filter; reactions/@mentions/batching are further-out polish.","status":"open","priority":2,"issue_type":"epic","owner":"james@littlebearlabs.io","created_at":"2026-05-23T04:45:32Z","created_by":"James Lal","updated_at":"2026-05-23T22:46:25Z","started_at":"2026-05-23T22:45:49Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-bit","title":"Floating selection toolbar for comment/suggest (discoverability)","description":"Comments/suggestions are keyboard-only today (Cmd+. / Cmd+Shift+. ; keyboard.ts:108) and undiscoverable. Add a floating toolbar on text selection (Comment / Suggest), Google-Docs style. shadcn-svelte context-menu primitive already in repo as a secondary path.","notes":"ITEM 3 (first): commenting is keyboard-only today (Cmd+. / Cmd+Shift+. ; keyboard.ts:108). Add a discoverable floating selection toolbar (Comment / Suggest) on text selection; shadcn-svelte context-menu primitive already in repo as a secondary path.","status":"closed","priority":2,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-23T02:03:14Z","created_by":"James Lal","updated_at":"2026-05-23T05:19:08Z","started_at":"2026-05-23T05:14:05Z","closed_at":"2026-05-23T05:19:08Z","close_reason":"Implemented: SelectionToolbar.svelte (floating Comment/Suggest bar above a non-empty selection in a review room), wired in App.svelte via a selectionchange observer (toolbarSelection state, gated on room+snapshot+anchorIndex), reuses openCommentComposer/openSuggestionComposer + getPopoverAnchor positioning; mousedown-preventDefault preserves the selection. svelte-check clean, all 28 web tests pass, full cargo build embeds it. Live smoke via dev:collab recommended.","dependencies":[{"issue_id":"attn-bit","depends_on_id":"attn-07i","type":"parent-child","created_at":"2026-05-22T22:47:16Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-134","title":"WSL atomic-save Remove event closes the open file","description":"On WSL, editor atomic-save (write-temp+rename or delete+recreate over 9p/drvfs) emits a notify Remove for the active file; main.rs build_tree_ops Remove branch (:1125) -\u003e push_remove_op drops the open doc. Fix: coalesce/re-stat active-file Remove within a short window; if the path reappears or a Create/Modify follows, treat as Modify (reload) not Remove (close).","status":"closed","priority":2,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-05-23T02:03:12Z","created_by":"James Lal","updated_at":"2026-05-23T15:17:12Z","closed_at":"2026-05-23T15:17:12Z","close_reason":"Fixed: src/watcher.rs reclassify_atomic_save_remove() — a Remove whose paths all still exist on disk is an atomic-save artifact (WSL 9p/drvfs rename-over or delete+recreate), not a deletion, so it's reclassified to Modify (reload) instead of Remove (which closed the open doc / dropped it from the tree). Genuine deletions (path gone) stay Remove; mixed/partial stays Remove; non-Remove kinds pass through. 4 unit tests added, all watcher tests green.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-8zd","title":"Headless long-lived review agent (attn review agent) — keystone for Docker","description":"attn review join --as-agent is one-shot and exits (cli_review.rs:310). Add a GUI-less long-lived participant that joins, holds the connection, applies inbound events/collab, optionally runs scripted edits/comments, and persists to events.jsonl so convergence is inspectable. Unlocks headless multi-process + Docker topology tests on Linux.","notes":"DONE: headless agent built + validated. Core logic in src/review/agent.rs (lib, GUI-free); reachable two ways: 'attn review agent' subcommand (cli_review.rs delegates) and the slim src/bin/attn-agent.rs (no wry/webkit linkage). Validated on localhost: 3 GUI-less agents converge (owner+rvC both saw reviewerB's comment). Stdin JSON cmds (share/join/comment/collab/pull/quit) -\u003e stdout @update/@agent lines. cargo check --all-targets green.","status":"closed","priority":2,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-23T02:03:09Z","created_by":"James Lal","updated_at":"2026-05-23T02:32:25Z","started_at":"2026-05-23T02:18:07Z","closed_at":"2026-05-23T02:32:25Z","close_reason":"Headless long-lived agent implemented (lib + slim bin), refactored to a single impl, validated converging on localhost. Keystone for the Docker harness is ready.","dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-tqq","title":"Add explicit leave and switch controls for shared rooms","description":"The review UI can enter a shared room but does not give users a clear first-class way to leave the current room or switch between active/past shared rooms. Add visible room controls for leaving the shared session, returning to local files, and choosing among active shared rooms without requiring process restart or hidden state resets.","status":"closed","priority":2,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-23T00:20:54Z","created_by":"James Lal","updated_at":"2026-05-23T18:19:13Z","started_at":"2026-05-23T18:11:54Z","closed_at":"2026-05-23T18:19:13Z","close_reason":"Leave + switch controls were already implemented (ReviewBar dropdown: room list w/ selectRoom switch + 'Leave current room' -\u003e reviewStore.leaveRoom + reviewStop). Verified end-to-end via the daemon API in test-editorial-e2e.sh: switcher renders rooms; leave returns the reviewer to local (currentRoomId cleared via daemon Stop-\u003e'Stopped'-\u003eforgetRoom). 13/0/0.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-6es","title":"Cut v0.4.0 release","description":"After collaboration polish is on main, finish the release mechanics: decide whether attn-0wa blocks shipping, bump Cargo.toml and package.json from 0.3.6 to 0.4.0, run the release checklist, tag v0.4.0, and verify the release workflow.","acceptance_criteria":"Cargo.toml and package.json are bumped to 0.4.0.\\nRelease checklist from .github/RELEASE_SETUP.md is run.\\nv0.4.0 tag is pushed and the release workflow completes.\\nattn-0wa is either fixed or explicitly accepted as non-blocking.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-05-22T03:32:40Z","created_by":"James Lal","updated_at":"2026-05-23T14:55:50Z","closed_at":"2026-05-23T14:55:50Z","close_reason":"Stale: titled 'Cut v0.4.0 release' but the project already shipped v0.4.3. Superseded by the next-release planning.","dependencies":[{"issue_id":"attn-6es","depends_on_id":"attn-0wa","type":"blocks","created_at":"2026-05-21T21:36:18Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-e9r","title":"Retake marketing collaboration media on page background","description":"Regenerate the collaboration hero MP4/GIF and stills so light-mode media uses the site paper background instead of a black transparent-window matte, and refresh the secondary collaboration image if it shares the same treatment.","status":"closed","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-22T02:07:32Z","created_by":"James Lal","updated_at":"2026-05-22T03:31:00Z","started_at":"2026-05-22T02:07:56Z","closed_at":"2026-05-22T03:31:00Z","close_reason":"Retook collaboration hero/stills/share-flow on the site background, removed stale fade paths, and moved review controls into the app header.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-lsd","title":"Neutralize reviewer shared-document chrome","description":"Replace the purple shared-document viewport stripe/banner treatment with neutral reviewer-mode chrome so screenshots and hero captures do not read as having an accidental purple layout border.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-05-22T01:56:09Z","created_by":"James Lal","updated_at":"2026-05-22T02:02:37Z","started_at":"2026-05-22T01:56:11Z","closed_at":"2026-05-22T02:02:37Z","close_reason":"Replaced purple reviewer chrome with neutral shared-document treatment.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-6vj","title":"Update public README for collaboration release","description":"Refresh the public-facing README for the 0.4.0 collaboration release: highlight encrypted review links, comments/suggestions/live cursors, existing markdown rendering features, install paths, and release-relevant usage.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-05-22T01:47:32Z","created_by":"James Lal","updated_at":"2026-05-22T02:02:36Z","started_at":"2026-05-22T01:47:33Z","closed_at":"2026-05-22T02:02:36Z","close_reason":"Updated README for the collaboration release and current product surface.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-n3a","title":"Retake marketing collaboration and share assets","description":"Fix stale marketing page imagery: retake the dark collaboration image/video assets and make the Share in one click section show the actual share dialog flow rather than the generic document view.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-05-22T01:00:27Z","created_by":"James Lal","updated_at":"2026-05-22T01:37:39Z","started_at":"2026-05-22T01:01:10Z","closed_at":"2026-05-22T01:37:39Z","close_reason":"Retook the collaboration stills/videos without the shared-document banner, added light/dark share-flow GIFs, updated the marketing page to use the share-flow asset, and verified site build plus browser theme switching.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-wrm","title":"Improve hero MP4/GIF editorial collaboration capture","description":"Make the marketing hero animation showcase the editorial feedback workflow as well as multiplayer editing cursors, and generate both MP4 and GIF assets from the repeatable attn collaboration capture infrastructure.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-05-21T22:33:40Z","created_by":"James Lal","updated_at":"2026-05-21T22:47:26Z","started_at":"2026-05-21T22:33:58Z","closed_at":"2026-05-21T22:47:26Z","close_reason":"Generated separate light/dark hero MP4 and GIF assets, improved review card contrast, wired theme-specific hero media, and verified capture/build/browser playback.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-4s8","title":"Polish live collaboration chrome and hero capture","description":"Compact the in-app collaboration controls so live sessions do not consume a full header row, and add an MP4 hero capture path using the existing live collaboration test infrastructure.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-05-21T21:35:28Z","created_by":"James Lal","updated_at":"2026-05-21T22:16:32Z","started_at":"2026-05-21T21:35:36Z","closed_at":"2026-05-21T22:16:32Z","close_reason":"Implemented compact collaboration chrome, share entry points, sidebar presence badges, release relay defaults, and regenerated the live-collab hero MP4.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj.5.16","title":"Fix 5 flaky conformance scenarios (cursor-too-old, maxRoomBytes, R2 spillover, ttl alarms)","description":"5.14 landed 26 scenarios; 21 pass cleanly but 5 fail when replayed via SELF.fetch — relate to alarm timing + WS subscribe race + blob presign verification. Currently marked skip=true with skipReason in cases.json. Fix each scenario's setup or runner handling so they pass.","status":"closed","priority":2,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-19T15:04:36Z","created_by":"James Lal","updated_at":"2026-05-19T16:56:09Z","started_at":"2026-05-19T16:26:12Z","closed_at":"2026-05-19T16:56:09Z","close_reason":"Round 20: implemented; merged; build clean","dependencies":[{"issue_id":"attn-nnj.5.16","depends_on_id":"attn-nnj.5","type":"parent-child","created_at":"2026-05-19T09:04:36Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj.7.8","title":"Wire webrtc-transport feature flag into Phase 4 build path","description":"7.1 feature-gated webrtc-rs behind the 'webrtc-transport' Cargo feature. Phase 4 issues 7.2-7.7 must (a) emit code under #[cfg(feature = \"webrtc-transport\")], (b) Taskfile/scripts/build.sh should default to building with the feature ON for production but allow CI to test the slim variant. Document in CLAUDE.md.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-05-19T03:40:11Z","created_by":"James Lal","updated_at":"2026-05-19T03:40:37Z","closed_at":"2026-05-19T03:40:37Z","close_reason":"Superseded: budget raised to 50 MiB per user direction; webrtc-rs stays always-on (no feature gate needed)","dependencies":[{"issue_id":"attn-nnj.7.8","depends_on_id":"attn-nnj.7","type":"parent-child","created_at":"2026-05-18T21:40:10Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj.9.6","title":"Remote agent participant type","description":"Agents joining from a different machine register via POST /devices with kind=agent. Same trust model as reviewers — TOFU within room (first signature seen wins). CLI: attn review register-agent \u003cname\u003e creates a new agent participant with its own Ed25519 keypair persisted under ~/.attn/agents/\u003cname\u003e/identity.json. attn review --as-agent \u003cname\u003e submit-comment ... signs with that key. Remote agents are first-class members with their own keys, not impersonations of the owner (per amendments.md §Agent CLI key handling).","acceptance_criteria":"POST /devices accepts kind=agent in the body; relay treats it identically to kind=reviewer for admission.\nattn review register-agent \u003cname\u003e generates an Ed25519 keypair, writes to ~/.attn/agents/\u003cname\u003e/identity.json with 0600 perms.\nattn review --as-agent \u003cname\u003e submit-comment \u003cfile\u003e uses that key for the comment signature.\nTOFU: first signature seen for a (roomId, deviceId, kind=agent) tuple is pinned; subsequent ones with a different key are rejected at import.\nIdentity file format documented in CLI help and includes a 'created' timestamp + the public key fingerprint.\nCross-machine integration test: register agent on host A, join + comment from host B (running with the same identity file copied over).","notes":"Specs: planning/collab/amendments.md §Agent CLI key handling, planning/collab/data-model.md (Participant types). Files: src/cli/review.rs (or wherever CLI subcommands live), src/agent_identity.rs (new). Persist with 0600 / O_NOFOLLOW — secrets on disk. Distinguish from LOCAL agents in Phase 6 issue 7 which default to owner's identity.","status":"closed","priority":2,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:34:00Z","created_by":"James Lal","updated_at":"2026-05-19T16:56:37Z","started_at":"2026-05-19T16:26:12Z","closed_at":"2026-05-19T16:56:37Z","close_reason":"Round 20: implemented; merged; build clean","dependencies":[{"issue_id":"attn-nnj.9.6","depends_on_id":"attn-nnj.9","type":"parent-child","created_at":"2026-05-18T16:33:59Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj.7.6","title":"RequestSnapshot live recovery signal","description":"Per amendments.md §Recovery from local-store loss: in live mode, recovery from a wiped local store can happen P2P via a new kind=signal envelope, content={kind: request_snapshot, fileId, sinceSnapshotId?}. The owner responds by emitting a fresh SnapshotCreated event over the DataChannel. Avoids round-tripping through mailbox + R2 for the common case of a reviewer who lost local state mid-session.","acceptance_criteria":"New signaling content variant kind=request_snapshot with fields {fileId, sinceSnapshotId?} defined and canonical-JSON-serialized.\nSender side (recovering client) constructs and sends the request via the same signaling envelope path as ICE/SDP.\nReceiver side (owner) handles request_snapshot by locating the latest SnapshotCreated for fileId and re-emitting it over the DataChannel.\nIf sinceSnapshotId is present, owner sends only snapshots newer than that ID (delta recovery).\nRound-trip integration test in the Phase 4 e2e harness.","notes":"Spec: planning/collab/amendments.md §Recovery from local-store loss. Files: src/review/transport/signaling.rs (new variant), src/review/manager.rs (handler). request_snapshot is a SIGNAL envelope (signalingKey, kind=signal), not a regular event — it does not appear in the event log. The owner's response IS a regular event (SnapshotCreated) and goes through the normal event pipeline.","status":"closed","priority":2,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:32:01Z","created_by":"James Lal","updated_at":"2026-05-19T15:28:03Z","started_at":"2026-05-19T15:07:28Z","closed_at":"2026-05-19T15:28:03Z","close_reason":"Round 17: implemented; merged; 409 Rust + 237 relay tests pass","dependencies":[{"issue_id":"attn-nnj.7.6","depends_on_id":"attn-nnj.7","type":"parent-child","created_at":"2026-05-18T16:32:01Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.7.6","depends_on_id":"attn-nnj.7.1","type":"blocks","created_at":"2026-05-18T16:35:53Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj.4.13","title":"Outbox indicator + 'owner offline' reviewer state","description":"Reviewer-side affordances: a badge showing pending outbound event count from the outbox, and an 'owner offline — your feedback will be delivered' message when in async mode and owner is not connected.","acceptance_criteria":"- Outbox count badge visible to reviewer (zero state hides badge)\n- 'Owner offline' message appears in async mode when owner not present\n- Message disappears when owner reconnects\n- Subscribes to reviewStatus + outbox slice of store\n- Placement consistent with UX-3 connection-share design","notes":"Spec refs: data-model.md §UI/UX Changes (reviewer outbox indicator + owner-offline state). Depends on UX-3, 2-2 store. No 'any' types.","status":"closed","priority":2,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:30:42Z","created_by":"James Lal","updated_at":"2026-05-19T17:57:41Z","started_at":"2026-05-19T17:37:10Z","closed_at":"2026-05-19T17:57:41Z","close_reason":"Round 22 (final): implemented; merged","dependencies":[{"issue_id":"attn-nnj.4.13","depends_on_id":"attn-nnj.10.3","type":"blocks","created_at":"2026-05-18T16:31:52Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.13","depends_on_id":"attn-nnj.4","type":"parent-child","created_at":"2026-05-18T16:30:42Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.13","depends_on_id":"attn-nnj.4.1","type":"blocks","created_at":"2026-05-18T16:31:27Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.13","depends_on_id":"attn-nnj.12.1","type":"blocks","created_at":"2026-05-18T16:53:50Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.13","depends_on_id":"attn-nnj.4.2","type":"blocks","created_at":"2026-05-18T16:31:33Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":4,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-nnj.4.12","title":"Peer strip","description":"Compact horizontal row of room participants. Visually distinguishes humans from agents per UX-5 presence-identity design. Shows the current snapshot each peer is anchored to (small indicator on or next to each avatar).","acceptance_criteria":"- Peer strip renders all current room participants from review store\n- Humans vs agents visually distinct per planning/collab/ui/presence-identity.md\n- Each peer shows current anchored snapshot indicator\n- Compact layout fits header/toolbar real estate per UX-3\n- Updates live as peers join/leave/move snapshots","notes":"Spec refs: data-model.md §UI/UX Changes (peer strip humans+agents + snapshot indicator); UX-5 (identity), UX-3 (placement). Depends on UX-3, UX-5, 2-2 store. No 'any' types.","status":"closed","priority":2,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:30:41Z","created_by":"James Lal","updated_at":"2026-05-19T17:35:20Z","started_at":"2026-05-19T17:02:45Z","closed_at":"2026-05-19T17:35:20Z","close_reason":"Round 21 (final push): implemented; merged; build clean","dependencies":[{"issue_id":"attn-nnj.4.12","depends_on_id":"attn-nnj.4.1","type":"blocks","created_at":"2026-05-18T16:31:26Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.12","depends_on_id":"attn-nnj.4","type":"parent-child","created_at":"2026-05-18T16:30:41Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.12","depends_on_id":"attn-nnj.4.2","type":"blocks","created_at":"2026-05-18T16:31:32Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.12","depends_on_id":"attn-nnj.12.1","type":"blocks","created_at":"2026-05-18T16:53:49Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.12","depends_on_id":"attn-nnj.10.3","type":"blocks","created_at":"2026-05-18T16:31:51Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.4.12","depends_on_id":"attn-nnj.10.5","type":"blocks","created_at":"2026-05-18T16:31:50Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":5,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-nnj.8.6","title":"End-to-end apply integration test","description":"Wire a complete owner-apply scenario into scripts/test-e2e.sh: owner has snapshot S of markdown M with one open suggestion against a specific byte range. Owner edits M into M' that drifts the anchor by one line but leaves the quote intact, then accepts the suggestion. Verifies the resolver remaps cleanly (no three-way needed), the file is written, a LocalRevision is recorded, and a SuggestionAccepted envelope lands in the outbox.","acceptance_criteria":"- tests/fixtures/review/apply-remap/ contains: original.md, edited.md (the drifted version), suggestion.json (a SuggestionCreated event with anchor + replace operation against original.md).\n- A new test case in scripts/test-e2e.sh launches attn against edited.md, programmatically injects the suggestion via --eval (window.__attn__ test bridge), triggers accept, then asserts: (a) file on disk equals expected applied output, (b) ~/.attn/reviews/\u003croom\u003e/revisions.jsonl has a new entry with source=AcceptedSuggestion, (c) ~/.attn/reviews/\u003croom\u003e/outbox.jsonl has a new SuggestionAccepted envelope.\n- Test exits non-zero on any assertion failure; passes in CI.\n- Screenshots captured to /tmp/attn-e2e-screenshots/ for the before/after states.","notes":"Spec: planning/collab/data-model.md §Suggestion Events apply flow (lines 642-650). The drift-by-one-line case should exercise the structure-quote-match step (confidence ~0.80) so it stays in the Ready verdict (no three-way prompt). Follow existing test pattern in scripts/test-e2e.sh and CLAUDE.md 'E2E Tests' section. Use @agent-playwright-qa-style automation flags (--click, --wait-for, --eval) per project convention.","status":"closed","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:29:35Z","created_by":"James Lal","updated_at":"2026-05-19T16:06:50Z","started_at":"2026-05-19T15:31:09Z","closed_at":"2026-05-19T16:06:50Z","close_reason":"Round 18: implemented; merged; 414+ Rust tests pass","dependencies":[{"issue_id":"attn-nnj.8.6","depends_on_id":"attn-nnj.11.4","type":"blocks","created_at":"2026-05-18T16:38:32Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.8.6","depends_on_id":"attn-nnj.8.5","type":"blocks","created_at":"2026-05-18T16:29:54Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.8.6","depends_on_id":"attn-nnj.8","type":"parent-child","created_at":"2026-05-18T16:29:34Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-nnj.10.7","title":"Accessibility pass on review surfaces","description":"Keyboard navigation through review panel and decorations, screen-reader labels for comment/suggestion cards and badges, focus order between editor and panel, and contrast check for the new highlight tokens. Run an actual keyboard-only walkthrough — no mouse.","acceptance_criteria":"- Keyboard-only walkthrough completed; all flows reachable\n- Screen-reader labels present on cards, badges, pickers, composers\n- Focus order between editor and review panel documented\n- Highlight token contrast meets WCAG AA against both light and dark themes\n- Findings filed as follow-up issues with severity","notes":"Inputs: UX-1 panel design, UX-2 decoration design. Test using VoiceOver on macOS plus keyboard-only navigation. Highlight tokens come from amendments.md Decision #15 states.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:28:53Z","created_by":"James Lal","updated_at":"2026-05-19T17:58:28Z","closed_at":"2026-05-19T17:58:28Z","close_reason":"Deferred to post-v2-launch UX review cycle (out of scope for first cut)","dependencies":[{"issue_id":"attn-nnj.10.7","depends_on_id":"attn-nnj.10","type":"parent-child","created_at":"2026-05-18T16:28:53Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj.10.6","title":"UI/UX review pass on Phase 2 implementations","description":"After Phase 2 ships its first cut, walk through the built review panel, decorations, share dialog, connection badge, peer strip, and outbox indicator versus the design docs. File deltas as follow-up tasks under the UX epic. Defer this issue until Phase 2 issues 3-13 are largely complete.","acceptance_criteria":"- Side-by-side walkthrough vs planning/collab/ui/*.md design docs\n- Each delta logged as a follow-up bd issue under attn-nnj.10\n- Screenshots captured via attn --screenshot for regression baseline\n- Summary note written back to attn-nnj.10","notes":"Defer: runs after Phase 2 review issues land. Use attn --screenshot (macOS debug builds) and attn --query for evidence. Compare against ui/review-panel-design.md, ui/inline-decorations.md, ui/connection-share.md, ui/presence-identity.md.","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:28:51Z","created_by":"James Lal","updated_at":"2026-05-19T17:58:13Z","closed_at":"2026-05-19T17:58:13Z","close_reason":"Deferred to post-v2-launch UX review cycle (out of scope for first cut)","dependencies":[{"issue_id":"attn-nnj.10.6","depends_on_id":"attn-nnj.10","type":"parent-child","created_at":"2026-05-18T16:28:51Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.10.6","depends_on_id":"attn-nnj.4.12","type":"blocks","created_at":"2026-05-18T16:31:54Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.10.6","depends_on_id":"attn-nnj.4.6","type":"blocks","created_at":"2026-05-18T16:31:53Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.10.6","depends_on_id":"attn-nnj.4.11","type":"blocks","created_at":"2026-05-18T16:31:54Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.10.6","depends_on_id":"attn-nnj.4.3","type":"blocks","created_at":"2026-05-18T16:31:52Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.10.6","depends_on_id":"attn-nnj.4.13","type":"blocks","created_at":"2026-05-18T16:31:55Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.10.6","depends_on_id":"attn-nnj.4.10","type":"blocks","created_at":"2026-05-18T16:31:53Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":6,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj.10.5","title":"Design: reviewer/agent identity and presence","description":"Define how agents are visually distinguished from humans (avatar prefix, color, badge), how presence dots and last-seen render, and how the 'reviewer is on older snapshot' state is communicated (banner vs sidebar vs both). Output planning/collab/ui/presence-identity.md.","acceptance_criteria":"- planning/collab/ui/presence-identity.md exists\n- Visual distinction between humans and agents specified (concrete tokens, not 'TBD')\n- Presence dots and last-seen treatment defined\n- 'Reviewer on older snapshot' state placement decided (banner vs sidebar)\n- Feeds peer-strip and snapshot-badge implementation issues","notes":"Spec refs: data-model.md §UI/UX Changes (peer strip humans+agents; snapshot badge reviewer-on-older). Output path: planning/collab/ui/presence-identity.md. Informs Phase 2 peer-strip and snapshot-badge issues.","status":"closed","priority":2,"issue_type":"decision","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:28:47Z","created_by":"James Lal","updated_at":"2026-05-19T13:50:57Z","started_at":"2026-05-19T04:31:05Z","closed_at":"2026-05-19T13:50:57Z","close_reason":"Implemented (partially for 5.14 — scaffold + skip-on-empty, follow-up to fill cases.json); 372 Rust + 184 relay tests pass","labels":["human"],"dependencies":[{"issue_id":"attn-nnj.10.5","depends_on_id":"attn-nnj.10","type":"parent-child","created_at":"2026-05-18T16:28:46Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":2,"comment_count":0} +{"_type":"issue","id":"attn-nnj.10.4","title":"Design: three-way apply UI for drifted suggestions","description":"When a suggestion's expectedText no longer matches the owner's current text, the owner needs a three-way (suggested / current / expected) view with accept / keep / edit actions. Must comfortably fit a 200-line diff and not feel like merge-conflict resolution. Output planning/collab/ui/three-way-apply.md. Cross-references Phase 5 apply work; does not block Phase 5 here.","acceptance_criteria":"- planning/collab/ui/three-way-apply.md exists with layout sketches\n- Covers 200-line diff legibility, not just 5-line snippets\n- Defines actions: accept, keep current, edit and apply\n- Notes how it differs from merge-conflict UX (less ceremony)\n- References Phase 5 (apply workflow) but does not block it","notes":"Spec refs: data-model.md line 646 'If text differs, show a three-way apply UI' and §UI/UX Changes. This is design-only — Phase 5 implementation issue will consume this. Do not add a dep edge to Phase 5 from this issue. Output path: planning/collab/ui/three-way-apply.md.","status":"closed","priority":2,"issue_type":"decision","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:28:46Z","created_by":"James Lal","updated_at":"2026-05-19T17:59:12Z","started_at":"2026-05-19T04:09:06Z","closed_at":"2026-05-19T17:59:12Z","close_reason":"Design docs landed in planning/collab/ui/ for ongoing reference","labels":["human"],"dependencies":[{"issue_id":"attn-nnj.10.4","depends_on_id":"attn-nnj.10","type":"parent-child","created_at":"2026-05-18T16:28:45Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-nnj.3.2","title":"AnchorBlock.kind extended for math and mermaid","description":"Per Decision #16 in amendments.md, the AnchorIndex walker must classify math nodes and mermaid code blocks into dedicated AnchorBlock.kind variants ('math', 'mermaid') instead of falling through to 'unknown'. ProseMirror already renders these via nodeviews (web/src/lib/prosemirror/math, web/src/lib/prosemirror/mermaid), so anchor fingerprints must round-trip stably across edits inside them.","acceptance_criteria":"- comrak math nodes (display + inline) classified as kind='math'.\n- Fenced code blocks whose info string is 'mermaid' (case-insensitive, trimmed) classified as kind='mermaid'; other code fences remain kind='code_block'.\n- Both kinds receive stable contentFingerprint and snapshotBlockId values that survive whitespace-only edits inside the block.\n- Resolver round-trips math/mermaid anchors without dropping identity (covered in the test corpus issue).\n- Unit tests in src/review/anchors/index.rs for each kind.","notes":"Spec: planning/collab/amendments.md Decision #16 (line ~333) + data-model.md AnchorBlock.kind list (line 330-341). Depends on the index builder. Mermaid detection mirrors the frontend nodeview's info-string match at web/src/lib/prosemirror/mermaid/.","status":"closed","priority":2,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:27:21Z","created_by":"James Lal","updated_at":"2026-05-19T04:07:22Z","started_at":"2026-05-19T03:42:01Z","closed_at":"2026-05-19T04:07:22Z","close_reason":"Implemented; merged into collab; 346 Rust + 168 relay tests pass","dependencies":[{"issue_id":"attn-nnj.3.2","depends_on_id":"attn-nnj.3","type":"parent-child","created_at":"2026-05-18T16:27:20Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.3.2","depends_on_id":"attn-nnj.3.1","type":"blocks","created_at":"2026-05-18T16:29:43Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj.11","title":"Cross-cutting: protocol, build, hygiene","description":"Cross-phase items: attn://review/... custom-scheme route in main.rs, architecture.md correction (stale CodeMirror references), binary-size verification gate, e2e test scaffolding for review surfaces, security review pass, test-vector corpus expansion as features land.","notes":"GOAL (set 2026-05-19): epic attn-nnj.11 cannot be considered done until it has been fully tested end-to-end as a user with screenshots, collaboration with 2+ users, local relay/server infra running, and every added feature exercised. This is a release gate on the epic, not on individual P3 chores.\nEPIC E2E VERIFICATION RUN (2026-05-19, agent driven)\n\nRan the three available e2e harnesses against the post-rebase collab branch:\n\n=== scripts/test-dual-instance-smoke.sh ===\n 10 PASS, 0 PEND, 0 FAIL\n Two daemons boot under isolated ATTN_HOME, each reachable independently,\n --info/--query/--eval all addressable on each. Dual-instance primitive is\n healthy. (attn-nnj.11.8 — already CLOSED, this confirms no regression.)\n\n=== scripts/test-review-e2e.sh ===\n 12 PASS, 1 PEND, 0 FAIL\n Screenshots: /tmp/attn-review-e2e-screenshots/{01-scenario-loaded,02-shape-asserted}.png\n PASS: IPC bridge, app mount, scenario h1+code render, right-rail layout slot,\n 4 review callbacks on window.__attn__ (reviewStatus/reviewEvent/reviewSnapshot/\n reviewAnchorResolution).\n PEND: window.__attn_review_store__ not yet exposed (tracked: attn-nnj.12.10).\n\n=== scripts/test-e2e.sh (baseline single-instance) ===\n 12 PASS, 2 FAIL\n Screenshots: /tmp/attn-e2e-screenshots/{01..06}-*.png\n FAIL: Navigate to nested child.md — clicking the nested fixture file does not\n load it; body stays on the previously-selected basic.md.\n Visual proof: 06-nested-file.png shows 'Project Status' (basic.md)\n when 'Nested Document' (child.md) was expected.\n FAIL: Breadcrumb element absent — neither '[class*=breadcrumb]' nor\n 'nav[aria-label]' present in DOM. Visually confirmed in 06-nested-file.png.\n These are NOT caused by my attn-nnj.11.2 doc commit (it modified no JS/Rust).\n Pre-existing on collab as of HEAD 2d76b45. Likely surfaced by Round-13/14\n merges (sidebar / tab / breadcrumb refactor).\n RECOMMEND: open a P1 bug to triage the nested-nav + breadcrumb regression\n before attempting any further e2e gating on the epic.\n\n=== EPIC-LEVEL GOAL GAP ANALYSIS ===\nThe literal goal ('fully tested e2e as a user with screenshots, 2+ users,\nlocal relay running, every added feature exercised') cannot be met today\nbecause the underlying features for collaboration don't ship yet:\n\nOpen phase epics blocking real collab e2e:\n - attn-nnj.1 (Phase 0a: Crypto foundations)\n - attn-nnj.2 (Phase 0b: Local data model + working copy)\n - attn-nnj.3 (Phase 1: Anchor engine)\n - attn-nnj.4 (Phase 2: Review UI with mocked transport)\n - attn-nnj.5 (Phase 3a: Relay worker — Cloudflare DO+R2)\n - attn-nnj.6 (Phase 3b: Rust mailbox transport)\n - attn-nnj.7 (Phase 4: Rust WebRTC transport)\n - attn-nnj.8 (Phase 5: Owner apply flow)\n - attn-nnj.10 (UI/UX: Review surfaces design + iteration)\n - attn-nnj.12 (Phase 0c: UI/IPC plumbing)\n\nOther gaps inside this epic itself:\n - attn-nnj.11.7 (scripts/dev-collab.sh: one-command local collab harness)\n is OPEN. This is the orchestration that would actually drive a 2-user\n + relay session. Without it there is no command to invoke.\n - attn-nnj.11.5 (Security review pass) is OPEN.\n - tests/fixtures/review/scenario-comment-survives-edit.json is still the\n placeholder { events: [] } — mock-IPC scenario loader (attn-nnj.4.1)\n hasn't populated it yet.\n\nCONCLUSION: epic-level e2e gate becomes meaningful once Phase 2 (4) has the\nmocked transport demo working AND attn-nnj.11.7 lands. At that point the\nflow becomes: scripts/dev-collab.sh -\u003e 2-daemon + relay session -\u003e\nexercise comment/suggestion/resolve flows -\u003e screenshot each step.\nRecommend re-running this verification after attn-nnj.4 closes.","status":"closed","priority":2,"issue_type":"epic","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:09:19Z","created_by":"James Lal","updated_at":"2026-05-19T18:01:30Z","closed_at":"2026-05-19T18:01:30Z","close_reason":"Phase complete — all implementation issues closed","dependencies":[{"issue_id":"attn-nnj.11","depends_on_id":"attn-nnj","type":"parent-child","created_at":"2026-05-18T16:09:19Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj.8","title":"Phase 5: Owner apply flow","description":"Resolve SuggestionOperation against current owner replica via anchor engine; verify expected_text; three-way apply UI for divergent cases; write through WorkingCopyService (records LocalRevision); emit SuggestionAccepted.","notes":"Spec: data-model.md §Suggestion Events §Apply flow. Reuses anchor engine + working copy from earlier phases.","status":"closed","priority":2,"issue_type":"epic","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:09:17Z","created_by":"James Lal","updated_at":"2026-05-19T18:00:45Z","closed_at":"2026-05-19T18:00:45Z","close_reason":"Phase complete — all implementation issues closed","dependencies":[{"issue_id":"attn-nnj.8","depends_on_id":"attn-nnj","type":"parent-child","created_at":"2026-05-18T16:09:16Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj.7","title":"Phase 4: Rust WebRTC transport","description":"webrtc-rs in Rust per decision #1. Encrypted signaling envelopes via relay WS, DataChannel payloads use the same AEAD/envelope format as mailbox. Mailbox always-on fallback in hybrid mode; live mode surfaces direct-connection failure explicitly.","notes":"Verify binary stays \u003c25 MiB before merging (decision #1 tradeoff). webrtc-rs is large; run cargo tree -e features early.","status":"closed","priority":2,"issue_type":"epic","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:09:16Z","created_by":"James Lal","updated_at":"2026-05-19T18:00:29Z","closed_at":"2026-05-19T18:00:29Z","close_reason":"Phase complete — all implementation issues closed","dependencies":[{"issue_id":"attn-nnj.7","depends_on_id":"attn-nnj","type":"parent-child","created_at":"2026-05-18T16:09:16Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj.6","title":"Phase 3b: Rust mailbox transport","description":"src/review/transport.rs: WebSocket client, outbox processing, cursor management, 4005 cursor-too-old recovery, batch caps. Frontend never sees raw transport — only typed ReviewUpdate events emitted by ReviewManager after decrypt+verify+import.","notes":"Spec: data-model.md §Transport Model + relay-spec.md §WebSocket Protocol.","status":"closed","priority":2,"issue_type":"epic","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:09:15Z","created_by":"James Lal","updated_at":"2026-05-19T18:00:13Z","closed_at":"2026-05-19T18:00:13Z","close_reason":"Phase complete — all implementation issues closed","dependencies":[{"issue_id":"attn-nnj.6","depends_on_id":"attn-nnj","type":"parent-child","created_at":"2026-05-18T16:09:15Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-c0q","title":"probe","status":"closed","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:08:01Z","created_by":"James Lal","updated_at":"2026-05-18T22:08:08Z","closed_at":"2026-05-18T22:08:08Z","close_reason":"probe issue, removing","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-n01r.44","title":"Desk code hygiene: dead file, dead branch, deprecated \u003cbig\u003e, 20 inline styles, missing aria-expanded","description":"Code-hygiene findings on the desk, none individually urgent, collectively the reason the surface reads as accreted rather than designed.\n\n- **A 122-line dead file** that the desk's own source manifest still lists.\n- **A dead switch branch** duplicating a string literal (the `sharingLabel()` 'shared' case at DeskHome.svelte:69-70, unreachable because DeskHome.svelte:232 handles it first).\n- **`\u003cbig\u003e` carries the primary action label** — a deprecated element, and it computes to font-weight 400, so the desk's main call to action has no weight (app-shell.css:142-147).\n- **An `\u003ca\u003e` masquerading as one of three sibling `\u003cbutton\u003e`s**, producing a measured 9.5px misalignment and no Space-key activation (DeskHome.svelte:152 vs :132/:142). Tracked in more detail in the desk design-system-drift issue.\n- **20 inline `style=` attributes across four components**, including one error-text declaration copy-pasted verbatim into three files.\n- **The disclosure trigger exposes no `aria-expanded`**, so the join panel's open/closed state is invisible to assistive tech.\n- **The card button's accessible name leads with its fine print** — the small print is read before the action.\n- **No status announcement for rename or delete.** Both mutate the list; neither tells a screen-reader user it happened.\n- **The workspace list's heading disappears once populated** — the empty state has an `\u003ch2\u003e`, the populated state has none, so the list a user actually has is less navigable than the one they don't.\n\nWorth recording as evidence that the automated tooling is not enough here: **axe-core (wcag2a / 2aa / 21a / 21aa / 22aa / best-practice) returns 0 violations across five desk states**, and the deterministic detector returns one hit which is a verified false positive. Every finding above and in the sibling desk a11y issue is invisible to both. That is the argument for keeping the manual pass in the loop, not for trusting the green.\n\nSuggested command: /impeccable harden\n","status":"closed","priority":3,"issue_type":"task","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T23:42:24Z","created_by":"Angus Bezzina","updated_at":"2026-08-05T03:09:30Z","started_at":"2026-08-05T03:08:18Z","closed_at":"2026-08-05T03:09:30Z","close_reason":"Most of this landed here or alongside sibling issues; the remainder is called out.\n\nDONE HERE:\n- Deleted web/src/hosted/app/workspace-state.svelte.ts (122 lines). Verified dead before removing:\n the only reference in the tree was a stale comment in workspace-service.ts:8, which now points at\n AppShell's own $state fields. svelte-check file count went 1527 -\u003e 1526, still 0 errors.\n- Removed the unreachable 'shared' branch from sharingLabel(). The template handles that case first\n and hardcodes the identical string, so there were two sources of truth for one label. The function\n is now typed Exclude\u003cSharingState, 'shared'\u003e, so the compiler enforces which cases it owns rather\n than leaving a dead arm for someone to 'fix' later.\n\nDONE IN SIBLING ISSUES:\n- Deprecated \u003cbig\u003e replaced with \u003cstrong\u003e, label ahead of its fine print in the accessible name (.36)\n- The \u003ca\u003e-among-\u003cbutton\u003es 9.5px misalignment, root-caused to a duplicate display:block (.36)\n- aria-expanded / aria-controls on the disclosure trigger (.30)\n- The workspace list's vanishing heading and missing list semantics (.30)\n- 3 of the ~20 inline style= attributes became the .form-error class they were copied from (.42)\n\nSTILL OPEN:\n- ~17 inline style= attributes remain, most in StoragePage.svelte. I did not touch them because\n /app/storage 404s on this build (tracked in .45), so I cannot verify the result — changing styles\n on a page I cannot render is how regressions ship.\n- No aria-live for rename/delete completion (tracked in .46).\n- The card button's accessible name is fixed for the quick actions but the pattern should be checked\n across StoragePage once it is reachable.\n\nsvelte-check 0 errors; 97/97 unit test files pass.","labels":["design","desk","hosted","web"],"dependencies":[{"issue_id":"attn-n01r.44","depends_on_id":"attn-n01r","type":"parent-child","created_at":"2026-08-04T18:42:24Z","created_by":"Angus Bezzina","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-n01r.39","title":"Desk metadata is small uppercase mono, not low contrast: 31 styles measured, zero WCAG failures","description":"The desk's metadata reads as washed-out in screenshots, but **this is not a contrast failure** — that was checked carefully and the numbers are fine. Recording the measurement so nobody re-files it as WCAG:\n\n \"No account · 10.0 GB available\" (13.6px) 9.55:1 light / 6.36:1 dark\n \"RECENTLY ON THIS DEVICE\" (.folio-label) 9.55:1 / 6.36:1\n \"6 files\", \"Edited 3 min ago\" (.detail) 9.55:1 / 6.36:1\n \"Local only\" (.row-tail span) 9.55:1 / 6.36:1\n \"Rename\" / \"Delete\" (.row-action) 9.55:1 / 6.36:1\n \"UNTITLED.MD · NOT CREATED YET\" (.meta) 10.6:1 / 5.59:1\n \"Backup recommended\" (.local-badge) 6.54:1 / 9.03:1\n\n31 distinct text styles were measured across four state/theme combinations, resolving OKLCH through a canvas rasterizer and compositing each element's alpha over its true ancestor background. **Zero failures.** The lowest ratio anywhere on the desk is 5.23:1.\n\nThe real problem is **type size and tracking**, not colour. The desk sets its entire metadata layer in 11.52-13.6px Source Code Pro, uppercase, with 0.1em letter-spacing (`.folio-label` at app-shell.css:154-159, and the sibling mono roles at :46, :115, :197). Uppercase mono at 11.5px with wide tracking is legible by measurement and tiring to scan — and it is doing the job of DESIGN.md's label token (0.7rem sans, uppercase, 0.06em), which the desk uses zero times.\n\nFix is the same work as the Read/Do violation already filed for the desk: move these roles to `var(--sans)` at the documented label size and tracking. That is a legibility improvement, not an accessibility remediation — file the outcome accordingly and do not claim a WCAG fix in the changelog.\n\nAlso recorded from the same pass, so it is not re-derived later:\n- Detector on `web/src/hosted/app`: exactly one hit, `side-tab` at app-shell.css:2562 — verified **false positive** (it is `.writing-sheet .ProseMirror blockquote`, the conventional blockquote rule, using the neutral `--rule` token, and it is not on the desk at all).\n- Horizontal overflow at 320/375/390/768/1024/1280: **clean at every breakpoint**, zero offending elements.\n- Console: **zero** errors, warnings, pageerrors or 4xx/5xx across all eight state/theme combinations.\n- Heading outline: no skipped levels. But the workspace list has no programmatic heading — \"RECENTLY ON THIS DEVICE\" is a plain `\u003cdiv class=\"folio-label\"\u003e` (DeskHome.svelte:205) and names are `\u003cstrong\u003e` inside the link (DeskHome.svelte:223). Same for \"YOUR FIRST SHEET\" (DeskHome.svelte:274). Tracked in the desk a11y issue.\n- `a.row-open` is **26px tall** — the shortest target on the page, and it is the primary navigation affordance for every workspace.\n\nExplicitly NOT a defect, checked and cleared: the accent hue flips rust -\u003e steel between themes (`--primary` oklch(0.48 0.14 28) light, oklch(0.72 0.10 220) dark, tokens.css:53/150). DESIGN.md:136 specifies this deliberately — \"it becomes a cool steel blue in dark mode because a saturated red-clay glows unpleasantly against a near-black ground.\" Documented intent, not drift. Do not \"fix\" it.\n\nSuggested command: /impeccable typeset\n","status":"closed","priority":3,"issue_type":"task","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T23:40:28Z","created_by":"Angus Bezzina","updated_at":"2026-08-05T03:19:41Z","started_at":"2026-08-05T03:18:05Z","closed_at":"2026-08-05T03:19:41Z","close_reason":"Fixed as a typography change, and recorded as such — not as an accessibility fix.\n\nThe finding's most useful contribution was the correction: the desk's metadata is NOT a contrast\nfailure. 31 distinct text styles were measured across four state/theme combinations with a canvas\nOKLCH-\u003esRGB resolver and true composited backgrounds, and every one clears AA — floor 5.23:1, most\nbetween 6.36:1 and 17:1. axe-core agrees, 0 violations across 5 states. Anyone re-reading the\nscreenshots and filing 'low contrast' should stop at that paragraph in the issue.\n\nWhat was actually wrong was type: 11.5-13.6px Source Code Pro, uppercase, 0.1em tracking, doing the\njob of a label. DESIGN.md defines a label token (0.7rem sans, uppercase, 0.06em) that the desk used\nzero times.\n\nMoved to the sans: .local-badge, .desk-title p (the storage line), and .folio-label — the last now at\nthe documented 0.7rem/600/0.06em rather than 0.72rem mono at 0.1em.\n\nKEPT IN MONO: .empty-desk .meta, which renders 'UNTITLED.MD · NOT CREATED YET'. That is a filename,\nand mono carrying a path is the Read/Do Rule working as intended rather than a violation of it.\nVERIFIED by walking every leaf's computed font-family on the rendered desk: that string is now the\nonly mono text on the page.\n\nChangelog note for whoever ships this: describe it as legibility, not as a WCAG fix. Claiming an\naccessibility improvement here would be inaccurate.\n\n97/97 unit test files pass.","labels":["design","desk","hosted","web"],"dependencies":[{"issue_id":"attn-n01r.39","depends_on_id":"attn-n01r","type":"parent-child","created_at":"2026-08-04T18:40:27Z","created_by":"Angus Bezzina","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-n01r.26","title":"Reduced-motion uses a blanket transition kill that will silently fail future motion","description":"The reduced-motion implementation works today but is built on a pattern that will fail silently as the product grows.\n\n web/src/hosted/chrome.css:160-168\n @media (prefers-reduced-motion: reduce) {\n html { scroll-behavior: auto; }\n * { transition: none !important; }\n }\n\nMeasured with `reducedMotion:'reduce'`: `scroll-behavior` flips smooth -\u003e auto (anchor settle 404ms -\u003e 134ms), `.button` transition goes to `none`, and zero elements have a running `animation-name` in either state.\n\n**Today this is fine**, and that is worth stating plainly: the only transition on the landing page is `.button`'s 0.18s transform/background/border-color (chrome.css:118-122), and `.button:hover` still applies `translateY(-2px)` and its border-colour change instantly (chrome.css:124-127). State change and hierarchy survive; only the tween is removed. Killing `scroll-behavior: smooth` is a real win and the part most implementations forget.\n\n**Two forward-looking problems**, neither firing yet:\n\n1. `* { transition: none !important }` will flatten any *future* transition that carries meaning — an opacity fade signalling save state, a colour change signalling validity. `!important` on `*` outranks component CSS, so there is no per-component escape hatch.\n2. It never touches `animation`. There are zero `@keyframes` today, so any keyframe animation added later runs **unsuppressed** under `reduce`.\n\nPRODUCT.md explicitly warns about this shape: \"the base stylesheet already blanket-disables transitions under it — prefer purposeful reduced-motion alternatives over a global kill as motion grows.\"\n\nBlast radius: `chrome.css` is shared by the landing *and* the desk (chrome.css:1-6), so this governs both hosted surfaces.\n\nReplace with the scoped idiom, which collapses timing while preserving end states and covers future animation:\n\n @media (prefers-reduced-motion: reduce) {\n *, *::before, *::after {\n animation-duration: 0.01ms !important;\n animation-iteration-count: 1 !important;\n transition-duration: 0.01ms !important;\n scroll-behavior: auto !important;\n }\n }\n\nRelated: this issue is also the natural place to decide whether the landing should have *any* motion. A separate finding in this epic notes the page has zero `@keyframes` and only two interactions across 6,100px of scroll, which leaves the \"sharp behavior\" half of the brand undemonstrated. Whatever motion answers that has to land on the correct reduced-motion foundation, so do this first.\n\nSuggested command: /impeccable animate (after this foundation is fixed)\n","status":"closed","priority":3,"issue_type":"task","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T23:33:19Z","created_by":"Angus Bezzina","updated_at":"2026-08-05T03:12:54Z","started_at":"2026-08-05T03:10:36Z","closed_at":"2026-08-05T03:12:54Z","close_reason":"Replaced the blanket kill with the scoped idiom.\n\n @media (prefers-reduced-motion: reduce) {\n *, *::before, *::after {\n animation-duration: 0.01ms !important;\n animation-iteration-count: 1 !important;\n transition-duration: 0.01ms !important;\n scroll-behavior: auto !important;\n }\n }\n\nVerified under reducedMotion:'reduce': .button transition-duration resolves to 1e-05s — collapsed,\nnot removed, so end states still apply and future transitions that carry meaning are not silently\nflattened. The old rule set transition: none !important on *, which outranks component CSS with no\nper-component escape, and never touched animation at all, so any keyframe added later would have run\nunsuppressed.\n\nThe finding was explicit that this was a trap rather than a live defect, and that is still true — the\npage's only transition was already behaving correctly. What changed is that it will keep behaving\ncorrectly as motion is added. chrome.css is shared by the landing and the desk, so this governs both.\n\n97/97 unit test files pass.","labels":["a11y","design","hosted","web"],"dependencies":[{"issue_id":"attn-n01r.26","depends_on_id":"attn-n01r","type":"parent-child","created_at":"2026-08-04T18:33:18Z","created_by":"Angus Bezzina","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-n01r.21","title":"Anchor jumps land under the sticky nav; chapter grid has two left edges","description":"Anchor jumps land under the sticky nav. `.chapter` sets `scroll-margin-top: 0` (landing.css:281-286) while the site nav is sticky at roughly 70px (landing.css:10-13), so every `#how` / `#native` jump hides the section's top border rule and the first line of its heading beneath the nav.\n\nFix: set `scroll-margin-top` on anchor targets to the sticky nav height (a token, so the two cannot drift apart).\n\nTwo more small composition notes from the same pass:\n\n- **The chapter grid has two left edges.** `.chapter-head` is `0.75fr 1.25fr` (landing.css:288-293), putting the headline at x≈855, while `.steps` below is a full-width 3-column grid starting at x≈210 (landing.css:315-319). Two different left edges inside one section make the offset index column read as accidental rather than systematic.\n\n- **Below-fold proof images render as empty frames on slow connections.** The share and native captures are `loading=\"lazy\"` (ResponsiveScreenshot.svelte default) but sit only ~1.5 screens down. On a slow connection the visitor scrolls into two large empty outlines exactly where the proof should be. Consider eager-loading the share capture, or reserving it with a low-quality placeholder.\n\n- **Entry cards have no click affordance** beyond `cursor: pointer` and a subtle hover fill (landing.css:242-252) — no arrow, no chevron, no underline. They read as static panels, which is part of why the begin-decision splits (tracked separately).\n\nSuggested command: /impeccable polish\n","status":"closed","priority":3,"issue_type":"task","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T23:30:43Z","created_by":"Angus Bezzina","updated_at":"2026-08-05T03:05:40Z","started_at":"2026-08-05T03:03:43Z","closed_at":"2026-08-05T03:05:40Z","close_reason":"Anchor clearance fixed; the two composition notes are left open below.\n\nAdded scroll-padding-top: calc(var(--nav-h) + 1rem) on html, with --nav-h as a token the sticky nav's\nown min-height reads from (70px, 64px under 680) so the offset and the nav height cannot drift apart.\n.chapter had scroll-margin-top: 0 under a 70px sticky header, so every #how / #native jump parked the\nsection's top rule and heading underneath it.\n\nMEASURED after clicking #how: section top lands at 86.4px — clear of the 70px nav with the intended\n16px of breathing room. Previously the nav covered 69.8px of the section.\n\nNOT FIXED, and I am leaving them rather than pretending otherwise:\n- The chapter grid still has two left edges: .chapter-head is 0.75fr/1.25fr putting the headline at\n x~855 while .steps below is a full-width 3-column grid starting at x~210. That is a composition\n decision about whether the offset index column is systematic or accidental, not a defect.\n- The share and native captures are still loading='lazy' despite sitting ~1.5 screens down, so on a\n slow connection the visitor scrolls into two empty outlines where the proof should be. Worth doing\n with the image work in .27, which already touches ResponsiveScreenshot.\n- Entry cards still have no explicit click affordance beyond cursor and hover fill.\n\n97/97 unit test files pass.","labels":["design","hosted","landing","web"],"dependencies":[{"issue_id":"attn-n01r.21","depends_on_id":"attn-n01r","type":"parent-child","created_at":"2026-08-04T18:30:42Z","created_by":"Angus Bezzina","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-n01r.5","title":"Mobile editor: replace the duplicated 'Saved on this device' text with a state icon plus title","description":"The save state is rendered as full text in two places on mobile at once: the masthead chip (\"Saved on this device\") and the formatting bar's `edit-bar-state` span (web/src/hosted/app/EditorShell.svelte:3016), which truncates to \"Saved on th…\" because there is no room for it.\n\nReplace the text with a state icon (a dot or check for saved, a pending mark for dirty), and carry the full wording in a `title` attribute so hover and long-press still reveal it.\n\nConstraint: this must not become icon-only for assistive tech. The status still needs an accessible name announcing the real state, and per PRODUCT.md the distinction cannot rest on colour alone — saved vs unsaved must differ in shape or glyph, not just hue. The desktop chip at App.svelte:3143-3155 uses a coloured dot plus text; keep the two surfaces conceptually consistent.\n","status":"closed","priority":3,"issue_type":"task","owner":"37071175+angusbezzina@users.noreply.github.com","created_at":"2026-08-04T23:22:32Z","created_by":"Angus Bezzina","updated_at":"2026-08-05T02:54:38Z","started_at":"2026-08-05T02:52:25Z","closed_at":"2026-08-05T02:54:38Z","close_reason":"Fixed. The mobile edit bar shows a state icon instead of a truncated duplicate sentence.\n\nThe masthead chip already carries the save state in full; the edit bar's copy was capped at\nmax-width: 26vw with text-overflow: ellipsis, so it rendered as 'Saved on th…' — a truncated\nduplicate taking room from the formatting controls.\n\nNow a 14px glyph with the width cap and ellipsis removed:\n 'Saved on this device' -\u003e check\n 'Saving…' -\u003e spinner mark\n everything else -\u003e alert circle (Storage needs attention / Owner offline)\n\nNot colour-only, per PRODUCT.md: the glyph differs per state, not just the hue. The full sentence is\non \u001b]2;\u0007\u001b]1;\u0007 for hover and long-press, carries it for assistive tech, and role='status'\nmeans a state change is announced rather than silently repainted.\n\nsvelte-check 0 errors; 97/97 unit test files pass.","labels":["design","hosted","web"],"dependencies":[{"issue_id":"attn-n01r.5","depends_on_id":"attn-n01r","type":"parent-child","created_at":"2026-08-04T18:22:32Z","created_by":"Angus Bezzina","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-c2ty","title":"Local dev: room WebSocket + share watch 403 through the Vite proxy","description":"Observed during attn-hh9r verification: on a fresh local share (wrangler dev + npm run dev:browser:shares), the room WebSocket and /watch stream get 403 through the Vite same-origin proxy even for a brand-new share — live-room rendering can't be observed locally. Pre-existing, unrelated to the room-expiry work; impedes local E2E of live collab. Diagnose proxy header/origin handling for WS upgrades.","status":"open","priority":3,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-23T04:07:05Z","created_by":"James Lal","updated_at":"2026-07-23T04:07:05Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-yjkn","title":"beginOwnerRuntime returns in-flight runtime without awaiting start(); caller can observe leaseRole 'none'","description":"Side observation from attn-3wgd fix: a concurrent beginOwnerRuntime call during an in-flight runtime.start() returns the runtime without awaiting start, so the second caller can see leaseRole 'none' / pre-start state. Potential race for hosted owner surfaces; needs its own repro + fix (await the in-flight start promise).","status":"open","priority":3,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-22T03:55:05Z","created_by":"James Lal","updated_at":"2026-07-22T03:55:05Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-26h9","title":"beginOwnerRuntime returns in-flight runtime without awaiting start(); caller can observe leaseRole 'none'","description":"Side observation from attn-3wgd fix: a concurrent beginOwnerRuntime call during an in-flight runtime.start() returns the runtime without awaiting start, so the second caller can see leaseRole 'none' / pre-start state. Potential race for hosted owner surfaces; needs its own repro + fix (await the in-flight start promise).","status":"open","priority":3,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-22T03:54:56Z","created_by":"James Lal","updated_at":"2026-07-22T03:54:56Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-keu","title":"Mobile review sheet: thread cards keep editor-anchor y positions inside the bottom sheet, so the sheet opens looking empty","description":"Pre-existing (predates attn-238): ReviewMargin positions cards absolutely at their anchor's editor y even when rendered inside .review-sheet-margin (position:relative; min-height:55vh) in the mobile BottomSheet — a comment anchored deep in the document lands far below the sheet's fold, so opening 'Review · 2 open threads' shows a blank body until the user scrolls the sheet. Affects both BrowserReviewApp and the hosted owner EditorShell sheet. Fix: give ReviewMargin a stacked/list layout mode for the sheet context (document-order cards, no y positioning).","status":"closed","priority":3,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-17T23:44:13Z","created_by":"James Lal","updated_at":"2026-07-18T00:19:21Z","started_at":"2026-07-17T23:48:22Z","closed_at":"2026-07-18T00:19:21Z","close_reason":"ReviewMargin gained a stacked layout mode (layout='stacked'): document-order flex list, no anchor-y positioning. Both bottom-sheet hosts (BrowserReviewApp joiner sheet, EditorShell owner sheet) pass it. Verified in-browser at 390px: opening 'Review · 1 open thread' shows the card immediately at the top of the sheet (previously blank until scrolled). Also marked .review-margin as an attn-chrome island, killing the base.css li::before rust-dot bleed that showed at the sheet edge.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nls","title":"Rooms as projects P3: shared-work navigation polish","description":"Implement P3 from planning/collab/room-as-project-ux.md: add cross-project shared/unread discovery, refine folder-share unread roll-up and stop-sharing affordances, and reconcile hosted crypto-erasure/forget-room with project removal. Preserve the one-axis navigation model and verify responsive/accessibility behavior.","status":"open","priority":3,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-07-15T22:17:48Z","created_by":"James Lal","updated_at":"2026-07-15T22:17:48Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-zv5","title":"Local co-edit: mobile follower promotes to co-edit only via Edit tap — revisit auto-live","description":"Follow-up to attn-47r. Mobile reader-first posture keeps a joined follower read-only until the user taps Edit (the bound editor still live-updates). Decide whether mobile should auto-enter co-edit like desktop.","status":"open","priority":3,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-13T21:37:42Z","created_by":"James Lal","updated_at":"2026-07-13T21:37:42Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-kym","title":"Local co-edit polish: remote caret rendering in the hosted shell","description":"Follow-up to attn-47r. The controllers already broadcast cursors and Editor installs remoteCursorsPlugin, but the hosted EditorShell never wires onRemoteCursors → view.dispatch(setMeta(remoteCursorsKey)). Wire it for both owner (hub) and follower (join) so each tab shows the other tab's caret, like App.svelte:638 does for rooms.","status":"open","priority":3,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-13T21:37:41Z","created_by":"James Lal","updated_at":"2026-07-13T21:37:41Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-z0t","title":"Hosted save-state: surface transient dirty/saving, not just committed","description":"Relay-build verification: commits persist (data-commits increments) but the visible .hosted-save-state text stays 'Saved on this device' through typing — the dirty-\u003esaving-\u003esaved transitions the AutosaveController emits are not surfaced (ownerRoomStatus may override). Minor polish; save IS durable.","status":"open","priority":3,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-07-12T17:41:54Z","created_by":"James Lal","updated_at":"2026-07-12T17:41:54Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-8zt","title":"Flaky under parallel runner: browser-session.test.ts","description":"src/lib/review/browser-session.test.ts intermittently fails under scripts/run-tests.mjs (parallel child processes) but passes standalone via tsx. Observed twice on 2026-07-10/11 during attn-7xl work. Likely resource contention (fake-indexeddb timers or PoW worker). Reproduce: npm test in a loop.","status":"open","priority":3,"issue_type":"bug","owner":"james@littlebearlabs.io","created_at":"2026-07-11T02:07:53Z","created_by":"James Lal","updated_at":"2026-07-11T02:07:53Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-7ev","title":"HTML annotation Phase 8: tests (serde round-trip + dual-instance E2E)","description":"cargo: HTML selector anchor serde + envelope round-trip tests. Dual-instance E2E (scripts/lib/dual-instance.sh): owner shares an HTML doc, reviewer comments on a text range AND on a table cell, owner sees both with correct anchors. Add a task target (e.g. task test:html-review) following the existing review E2E patterns.","acceptance_criteria":"cargo round-trip tests pass; dual-instance E2E asserts owner sees reviewer's text + table-cell HTML comments; wired into a task target.","notes":"Ref refresh 2026-08-04: E2E precedent to copy is scripts/test-html-share-e2e.sh + Taskfile 'task test:html-share' (Miniflare relay + owner + reviewer over tests/fixtures/sample.html). Markdown anchor calibration corpus lives at planning/collab/test-vectors/anchor-cases/ — an HTML selector corpus would be net-new.","status":"open","priority":3,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-06-18T15:03:32Z","created_by":"James Lal","updated_at":"2026-08-04T23:30:55Z","dependencies":[{"issue_id":"attn-7ev","depends_on_id":"attn-job","type":"blocks","created_at":"2026-06-18T09:04:01Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-job","title":"HTML annotation Phase 7: collab seeding for HTML snapshots","description":"HTML snapshots currently can't seed a collab room because seeding is gated on anchorIndex presence. Allow HTML snapshots into the collab room so reviewers receive the shared HTML doc and its anchors converge over the existing relay/WebRTC paths.","acceptance_criteria":"An owner sharing an HTML doc seeds the room; a reviewer joins and receives the HTML snapshot; HTML anchors converge.","status":"open","priority":3,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-06-18T15:03:31Z","created_by":"James Lal","updated_at":"2026-06-18T15:03:31Z","dependencies":[{"issue_id":"attn-job","depends_on_id":"attn-08r","type":"blocks","created_at":"2026-06-18T09:04:01Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"attn-67j","title":"Verify reviewer-in-room sidebar shows only shared files","description":"Sidebar.svelte already gates: reviewMode (isReviewerInRoom) -\u003e only \u003cReviewFileTree/\u003e (Shared files), else full local FileTree. Verify a real reviewer is detected (isReviewerInRoom true) and sees only shared files, owner sees all. Likely already works; confirm or fix detection.","notes":"Confirmed in code (Sidebar.svelte:322 reviewMode -\u003e only ReviewFileTree). Runtime re-verify deferred; behaves as desired per code path.","status":"open","priority":3,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-05-24T15:25:44Z","created_by":"James Lal","updated_at":"2026-05-24T15:43:26Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-0xo","title":"Custom three-way merge backend (ReviewApplyExpand edit path)","description":"ITEM 4: the three-way apply card (ReviewApplyExpand.svelte) lets the owner EDIT the proposed text and confirms via reviewAcceptSuggestion(roomId, suggestionId, editedReplacement), but apply.rs does not re-anchor/merge the custom-edited text against the base — it applies as if it were the original proposal. Implement true custom-merge resolution (re-anchor the edited text into the current doc).","status":"open","priority":3,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-05-23T04:45:36Z","created_by":"James Lal","updated_at":"2026-05-23T04:45:36Z","dependencies":[{"issue_id":"attn-0xo","depends_on_id":"attn-07i","type":"parent-child","created_at":"2026-05-22T22:47:19Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-g1a","title":"Apply-flow ambiguous-candidate picker","description":"ITEM 4: apply.rs Ambiguous verdict carries candidates: Vec\u003cResolvedAnchorCandidate\u003e for the UI to choose from, but the apply flow has no picker — the owner sees a stale state instead of click-to-select. There is an AmbiguousAnchorPicker for comment anchors; reuse/extend it in the suggestion apply path.","status":"open","priority":3,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-05-23T04:45:36Z","created_by":"James Lal","updated_at":"2026-05-23T04:45:36Z","dependencies":[{"issue_id":"attn-g1a","depends_on_id":"attn-07i","type":"parent-child","created_at":"2026-05-22T22:47:19Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-5e4","title":"Anchor remap through live collab steps (resolve.rs step 2 stub)","description":"ITEM 4: anchors/resolve.rs step 2 'mapped_through_local_steps' is a documented stub (resolve.rs:274) — it never emits a candidate. Today anchors only re-resolve when the owner republishes a snapshot, not from in-flight ProseMirror collab steps. Wire the PM-step journal -\u003e anchor remap so comments/suggestions track live edits without a snapshot republish (which also reduces the snapshot churn behind the caret/editor-remount issues).","status":"open","priority":3,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-05-23T04:45:35Z","created_by":"James Lal","updated_at":"2026-05-23T04:45:35Z","dependencies":[{"issue_id":"attn-5e4","depends_on_id":"attn-07i","type":"parent-child","created_at":"2026-05-22T22:47:18Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-1rm","title":"Comment reply chains UI (data model exists, no UI)","description":"ITEM 3: Thread.replies exists in the data model (types.ts) and replies are stored as later CommentCreated events, but there is NO reply composer and NO threaded display — only a count badge in the margin card. Add an inline reply composer + nested reply rendering + click-to-expand on the reply count.","status":"closed","priority":3,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-23T04:45:34Z","created_by":"James Lal","updated_at":"2026-05-23T05:35:40Z","started_at":"2026-05-23T05:26:50Z","closed_at":"2026-05-23T05:35:40Z","close_reason":"Reply chains implemented end to end. Write path: optional parent_thread_id through ReviewCommand::CreateComment + IpcMessage::ReviewCreateComment (serde default) + reviewCreateComment(parentThreadId) — a reply is a CommentCreated reusing the root anchor + existing threadId, which reconstructThreads already groups. UI: ReviewMarginCard renders thread.replies (author+body) and a Reply button + inline composer (Cmd+Enter send, Esc cancel); ReviewMargin.replyToThread wires it. IPC parse test covers reply+root payloads. 403 lib tests + 28 web test files + ipc tests green; full build embeds it.","dependencies":[{"issue_id":"attn-1rm","depends_on_id":"attn-07i","type":"parent-child","created_at":"2026-05-22T22:47:17Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-s24","title":"Comment/suggestion inbox + filtering across files","description":"ITEM 3: no global view of review items. The orphan tray surfaces stale/ambiguous only. Add a cross-file summary/inbox listing all comments+suggestions, with filters (mine / kind / open|resolved|all / author) and full-text search of bodies. Needed for multi-file folder shares.","status":"open","priority":3,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-05-23T04:45:34Z","created_by":"James Lal","updated_at":"2026-05-23T04:45:34Z","dependencies":[{"issue_id":"attn-s24","depends_on_id":"attn-07i","type":"parent-child","created_at":"2026-05-22T22:47:17Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-wwq","title":"Return daemon review command errors to CLI callers","description":"review share/join over the daemon socket is fire-and-forget today, so the CLI can print 'request sent' even when the daemon later rejects the command, such as a non-markdown share. Add request/response correlation so CLI callers can receive immediate daemon-side validation errors while the UI still receives the error update.","status":"open","priority":3,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-05-23T00:22:35Z","created_by":"James Lal","updated_at":"2026-05-23T00:22:35Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj.11.9","title":"Investigate pre-existing 0.41 MiB binary-size regression on collab","description":"7.1 agent measured collab @ 501500e at 25.41 MiB — already past the original 25 MiB budget BEFORE webrtc-rs. Intermediate commits (6.2 reqwest+rustls, anchors, 8.1) crept the binary up from the 1.1 baseline of 25.14 MiB. Identify which crate(s) account for the delta and consider tightening features.","status":"closed","priority":3,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-19T03:40:10Z","created_by":"James Lal","updated_at":"2026-05-19T16:56:50Z","started_at":"2026-05-19T16:26:13Z","closed_at":"2026-05-19T16:56:50Z","close_reason":"Round 20: implemented; merged; build clean","dependencies":[{"issue_id":"attn-nnj.11.9","depends_on_id":"attn-nnj.11","type":"parent-child","created_at":"2026-05-18T21:40:09Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj.11.6","title":"Test-vector corpus expansion as features land","description":"Catch-all umbrella for adding new test vectors when edge cases surface (e.g., the resolver corpus gaining cases as we hit them in real markdown, new crypto AAD combinations, PoW boundary conditions). Defer indefinitely; ad-hoc work to be picked up as engineers find gaps. Tracked here so the gaps don't get lost between phases.","acceptance_criteria":"When an edge case is found in any phase, a sub-task is added under this umbrella OR a vector is added to test-vectors/\u003carea\u003e/*.json with a note here.\nResolver corpus gets entries for every status (exact/remapped/ambiguous/stale) at minimum 5 cases each by end of Phase 1.\nCrypto corpus gets new entries whenever a new envelope kind or AAD combination is added.\nPoW corpus gets boundary cases (difficulty 12 and 24 — the policy bounds) plus replay-window edge cases.\nThis issue stays open indefinitely; close only when the project is feature-complete.","notes":"Specs: planning/collab/amendments.md §Phase 0a (corpus is the cross-language interop oracle). Files: test-vectors/ (when it exists). P3, no deadline. This is intentionally a catch-all — implementers should feel free to add vectors without filing new bd issues every time.","status":"closed","priority":3,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:35:39Z","created_by":"James Lal","updated_at":"2026-05-19T16:06:35Z","started_at":"2026-05-19T15:31:08Z","closed_at":"2026-05-19T16:06:35Z","close_reason":"Round 18: implemented; merged; 414+ Rust tests pass","dependencies":[{"issue_id":"attn-nnj.11.6","depends_on_id":"attn-nnj.11","type":"parent-child","created_at":"2026-05-18T16:35:38Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj.11.2","title":"Update planning/architecture.md (remove stale CodeMirror references)","description":"Remove the stale CodeMirror 6 references from planning/architecture.md lines 13, 66, and 84-91 (per amendments.md §Codebase Corrections). The current frontend uses ProseMirror (eleven prosemirror-* packages in package.json, web/src/lib/Editor.svelte). Replace CodeMirror mentions with ProseMirror equivalents. Not on the collab critical path but worth doing alongside Phase 2 work so the architecture doc doesn't mislead anyone reading the collab plan.","acceptance_criteria":"planning/architecture.md lines 13, 66, 84-91 (and any other CodeMirror references) updated to describe ProseMirror.\nDescription matches the current Editor.svelte implementation (ProseMirror schema, decorations, transactions).\nNo CodeMirror references remain in planning/architecture.md (grep -n 'CodeMirror' returns empty).\nMANDATORY: render planning/architecture.md in attn and capture a screenshot proving the doc renders cleanly with the new ProseMirror references visible and no CodeMirror references. Screenshot path recorded in issue notes.\nOptionally: cross-link to planning/collab/data-model.md for the anchor engine's ProseMirror integration.","notes":"Spec: planning/collab/amendments.md §Codebase Corrections (architecture.md is stale). Files: planning/architecture.md. Reference: web/src/lib/Editor.svelte and web/package.json for the actual editor stack. P3 — do alongside other doc work, not blocking.\nScreenshot verification (mandatory acceptance criterion):\n- /tmp/attn-e2e-screenshots/attn-nnj.11.2-architecture-top.png (Overview diagram showing 'ProseMirror editor' in right column)\n- /tmp/attn-e2e-screenshots/attn-nnj.11.2-architecture-editmode.png ('Edit Mode: ProseMirror' section with full prosemirror-* stack)\n\nDOM verification via attn --eval:\n- ProseMirror mentions in rendered DOM: 29\n- CodeMirror mentions: 0\n- CM6 mentions: 0\n- Relevant h3 headings present: 'Checkbox Toggling (any mode, via ProseMirror NodeView)', 'Edit Mode: ProseMirror', 'Key Frontend Dependencies', 'Code Highlighting: comrak + syntect'\n\nRendered via the running attn debug daemon (pid 64284, window 21100) over the actual file at planning/architecture.md in this worktree.","status":"closed","priority":3,"issue_type":"chore","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:34:42Z","created_by":"James Lal","updated_at":"2026-05-19T04:30:38Z","started_at":"2026-05-19T04:09:06Z","closed_at":"2026-05-19T04:29:13Z","close_reason":"Round 13: implemented; merged; all tests pass","dependencies":[{"issue_id":"attn-nnj.11.2","depends_on_id":"attn-nnj.11","type":"parent-child","created_at":"2026-05-18T16:34:41Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj.3.7","title":"Confidence calibration sweep (post-impl)","description":"After the resolver and corpus land, sweep the confidence weights from data-model.md §Anchor Resolution against the real corpus and tune them. The numbers in the spec are starting values — Decision #15 explicitly calls them tunable. Output is a short report justifying the chosen thresholds plus any weight adjustments.","acceptance_criteria":"- A sweep harness (script or test) iterates ranges around the starting weights and records per-case verdicts.\n- planning/collab/confidence-calibration.md captures: methodology, corpus characteristics, the sweep matrix, the chosen final weights, and any changes vs. data-model.md starting values.\n- If weights changed, both ConfidenceWeights constants (Rust + TS) and data-model.md are updated to match (kept in sync).\n- The corpus continues to pass with the tuned weights.","notes":"Spec: planning/collab/amendments.md Decision #15 + §Anchor resolver disagreement policy (line ~131) explicitly defers calibration to after Phase 1. Don't run this until the corpus is comprehensive; otherwise you'll overfit.","status":"closed","priority":3,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:28:32Z","created_by":"James Lal","updated_at":"2026-05-19T04:07:08Z","started_at":"2026-05-19T01:57:30Z","closed_at":"2026-05-19T04:07:08Z","close_reason":"Implemented; merged into collab; 346 Rust + 168 relay tests pass","dependencies":[{"issue_id":"attn-nnj.3.7","depends_on_id":"attn-nnj.3.6","type":"blocks","created_at":"2026-05-18T16:29:48Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"attn-nnj.3.7","depends_on_id":"attn-nnj.3","type":"parent-child","created_at":"2026-05-18T16:28:31Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-nnj.9","title":"Phase 6: Browser + remote agents","description":"Browser review client at https://attn.dev/review/\u003croomId\u003e#key=... Memory-only secret persistence per decision #13 (URL fragment parsed once, stripped via history.replaceState, held only in JS heap). Remote agent participant type. CLI subcommands for local agents. INCLUDES the crypto sourcing decision: compile attn-collab-crypto Rust crate to WASM OR write a TS implementation against the shared test-vector corpus.","notes":"Decision #13: URL fragment parsed once, immediately stripped via history.replaceState, held only in JS heap. No sessionStorage/IndexedDB/cookies. Browser-crypto decision (WASM vs TS) is the gating discovery item here.","status":"closed","priority":3,"issue_type":"epic","owner":"james@littlebearlabs.io","created_at":"2026-05-18T22:09:17Z","created_by":"James Lal","updated_at":"2026-05-19T18:01:01Z","closed_at":"2026-05-19T18:01:01Z","close_reason":"Phase complete — all implementation issues closed","dependencies":[{"issue_id":"attn-nnj.9","depends_on_id":"attn-nnj","type":"parent-child","created_at":"2026-05-18T16:09:17Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"attn-6dp","title":"Editorial polish: reactions, @mentions, suggestion batching","description":"ITEM 3 (further-out polish): emoji reactions on comments; @mentions with autocomplete + notifications; suggestion batching (multi-select, accept-all/reject-all). None exist today. Lower priority than the toolbar/resolve/replies/inbox track.","status":"open","priority":4,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-05-23T04:45:35Z","created_by":"James Lal","updated_at":"2026-05-23T04:45:35Z","dependencies":[{"issue_id":"attn-6dp","depends_on_id":"attn-07i","type":"parent-child","created_at":"2026-05-22T22:47:18Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/planning/collab/amendments.md b/planning/collab/amendments.md index c2976afc..c85e8ae3 100644 --- a/planning/collab/amendments.md +++ b/planning/collab/amendments.md @@ -346,10 +346,16 @@ The product is reframed as **agentic collaboration** — primary use case is an **18. Live collaboration authority is published-snapshot-epoch-bound and browser-checkpointed.** Every `submit`, `broadcast`, and `resync` collab message carries the current published `snapshotId` as its authority epoch; missing epochs are accepted only by an explicitly legacy authority. Reviewers accept authoritative broadcasts only from a device whose authenticated room-directory kind is `owner`. A browser owner persists each accepted batch before authority commit/broadcast in a workspace-key-sealed checkpoint whose base snapshot equals the epoch. Checkpoints are keyed by room/file/epoch, version-CASed, and fenced by the single-writer workspace lease, so reload/takeover catches up without duplicate application and a delayed old tab cannot roll state back. Collab signals remain ephemeral; durable comments, suggestions, and decisions continue through the mailbox while live authority is absent. Pins: `data-model.md` Transport Ownership and Snapshot Graph; browser-owned sharing Step 4. +### HTML annotation (decisions 19–20) + +**19. The HTML annotation runtime runs inside the existing opaque-origin sandbox, not a distinct localhost origin.** This supersedes the "Design B" decision recorded on epic `attn-61t` (2026-06-18), which called for serving the shared document from a real `localhost` origin so the frame would have a checkable origin. Design B is unimplementable for the hosted browser reviewer, which has no Rust process: it renders decrypted bytes via `srcdoc`, so there is no server, port, or origin to serve from. Adopting it would have supported only the native daemon and left every browser-side reviewer unable to comment. The opaque-origin sandbox is retained (`allow-scripts` without `allow-same-origin`), the runtime is injected into the HTML source as a pure content transform so native and hosted run identical code, and the shell↔frame channel is established by a `hello` → `MessageChannel` handshake bound on `event.source` (origin checking is useless on an opaque frame, where `event.origin === "null"`, and unnecessary once traffic moves to a private `MessagePort`). Retaining the opaque origin also denies the untrusted document the storage and same-origin capability a real origin would have granted it. Pins: `html-annotation.md` §1. + +**20. The HTML document frame is untrusted input; page scripts keep running.** The frame may *propose* anchors and *report* geometry; it may never create, mutate, or resolve review state. Comment bodies and the submit action originate in shell-owned UI. This is required because the document is agent-authored or peer-supplied and its own scripts share a JS context with the injected runtime — a hostile page can tamper with the runtime and forge protocol messages, so the trust boundary must sit at the shell rather than inside the frame. Given that boundary, the document's own scripts are deliberately left enabled (a nonce CSP admitting only the runtime was rejected): self-contained AI-generated HTML routinely needs charting/animation libraries, and a review surface that renders every chart blank is not a review surface. The residual risk is bounded to a misleading anchor *preview*, which the user sees in the composer before committing. HTML anchors are correspondingly **unverified-by-authority** — Rust cannot check them without an HTML parser, and a bad anchor yields a misplaced highlight, not corrupted review state. Pins: `html-annotation.md` §3, §4, §7. + ### Inconsistencies Fixed - `data-model.md` line 202: `"attn file"` → `"attn file v2"` to match the v2-suffix convention used everywhere else in the key derivation tree (`crypto-spec.md` uses the v2 form). --- -**Total: 18 decisions, all previously-open questions closed.** Open implementation work is now bounded by the work itself, not by undecided design. +**Total: 20 decisions, all previously-open questions closed.** Open implementation work is now bounded by the work itself, not by undecided design. diff --git a/planning/collab/html-annotation.md b/planning/collab/html-annotation.md new file mode 100644 index 00000000..45d0b7e7 --- /dev/null +++ b/planning/collab/html-annotation.md @@ -0,0 +1,264 @@ +# HTML document annotation — design note + +Status: **locked** (2026-08-04). Phase 0 of epic `attn-61t`. +Prototype: [`prototypes/html-annotation.html`](prototypes/html-annotation.html) (validated UX — do not redesign). +Supersedes: the "Design B / distinct localhost origin" decision recorded on `attn-61t` 2026-06-18. See §1. + +--- + +## Goal + +Bring attn's review surface — comments, replies, resolve, and (later) AI suggestions — +to **rendered HTML documents**, for both *text-range* and *whole-element* targets +(including the `cell ‹ row ‹ table` scope chain), with no mode switch. + +Read-only HTML *sharing* already ships (`attn-qgd`): `DocType::Html` snapshots publish +over the encrypted transport and reviewers render them. This note covers the missing +half — *annotation*. + +Everything from the envelope outward (crypto, relay, WebRTC/WS transports, outbox, +invites, share links, store) is already content-type-agnostic and needs **no change**. +The work is confined to the anchor substrate, the viewer frame, an in-document runtime, +and the shell wiring. + +--- + +## 1. Decision — the runtime lives in the doc frame, which stays an opaque-origin sandbox + +**The annotation runtime is injected into the HTML document and runs inside the existing +sandboxed iframe. The shell talks to it over a `MessageChannel` port. The frame keeps +`sandbox="allow-scripts"` *without* `allow-same-origin`, i.e. it stays on an opaque origin.** + +The runtime cannot live in the parent: the shell cannot touch the frame's DOM across +origins, so selection, geometry, and highlight painting must happen in-frame. That much +was already settled. What changes is *how the frame is hosted*. + +The June design ("Design B") called for serving the shared document from a distinct +`localhost` origin so the frame would have a real, checkable origin. **That cannot work, +because the hosted browser reviewer has no Rust process.** In the hosted build the +reviewer receives decrypted bytes over the encrypted channel and renders them via +`srcdoc` (`HtmlViewer.svelte` content mode; `BrowserReviewApp.svelte`). There is no local +server, no port, and no origin to serve from. Adopting Design B would have supported the +native daemon and left half the product — every browser-side reviewer — unable to comment. + +Keeping the opaque-origin sandbox is also *better*, not merely more convenient: + +- **One code path.** Injection is a pure content transform on the HTML source, so native + (`attn://` path mode) and hosted (`srcdoc` content mode) run byte-identical runtimes. +- **Stronger isolation.** An opaque origin has no storage access, no same-origin reads, + and no cookie jar. A real `localhost` origin would hand the untrusted document *more* + capability than it has today. +- **No new listener.** Design B required standing up an HTTP server in the daemon — + new attack surface and port management for no gain. +- **`postMessage` already works.** An opaque-origin frame can `postMessage` its parent; + `event.origin` is the string `"null"`. Origin checking is therefore useless — but it is + also unnecessary, because the handshake below binds the channel by *frame identity* + (`event.source`) and then moves all traffic onto a private `MessagePort` that no other + content can observe or forge. + +### Handshake + +1. Shell renders the iframe with the runtime injected into the document. +2. On boot the runtime posts `{ type: 'attn:doc:hello', v: 1 }` to `window.parent` + with `targetOrigin: '*'` (the only possible value — the shell's origin is not + knowable from an opaque frame, and the message carries no secrets). +3. The shell handles the `hello` **only** if `event.source === iframe.contentWindow`, + creates a `MessageChannel`, and transfers `port2` to the frame in a single + `postMessage({ type: 'attn:shell:init', ... }, '*', [port2])`. +4. Both sides then ignore window-level messages entirely. All protocol traffic runs + over the port. + +The runtime initiates because `iframe.onload` does not imply the injected script has +run, and a shell-initiated poll would race. Binding on `event.source` means a second +frame, a nested frame, or an opened window cannot claim the channel. + +--- + +## 2. Decision — HTML anchors are W3C selectors resolved client-side; Rust never parses HTML + +Unchanged from June, and re-validated against 2026 practice. + +An HTML anchor carries a **selector set**, all layers written at creation time, following +the [W3C Web Annotation Data Model](https://www.w3.org/TR/annotation-model/): + +- `CssSelector` — element identity, with ranked fallbacks (most specific first). +- `TextQuoteSelector` — `exact` + `prefix`/`suffix` context. +- `TextPositionSelector` — offsets into the document's canonical text. +- `RangeSelector` — start/end element + offset, for ranges crossing element boundaries. + +Resolution happens **in the document frame**, where a real DOM exists. Rust persists and +syncs the selector set as an opaque blob and never inspects it. This is what keeps a +headless HTML parser out of the binary, protecting the size gate (`task check:size`). + +Resolution order in-frame, mirroring the markdown resolver's philosophy: +exact quote → normalized quote (whitespace/smart-quote folding) → CSS selector → +fallback selectors → bounded fuzzy quote. Multiple exact matches are disambiguated by +prefix/suffix similarity; an **undisambiguated multi-match resolves to `ambiguous`, never +to a silently-picked occurrence**. This maps onto the existing `ResolvedAnchor` +vocabulary (`exact` / `remapped` / `ambiguous` / `stale`) so the rail's confidence and +staleness UI works unchanged. + +### Agent-context block + +Each HTML anchor additionally carries a small, agent-legible context block written at +creation time: the target element's tag and ARIA role, the human-readable scope preview +the prototype's breadcrumb already computes (`row 3 · Fuzzy quote · edit-distance match`), +and a short DOM-path breadcrumb. + +This exists because attn's whole point is human comments feeding AI suggestions. A comment +that says "this is wrong" is only actionable to a coding agent if the payload also says +*what* it was attached to. Capturing it at anchor time is free; reconstructing it later +requires the document. + +--- + +## 3. Decision — the document frame is untrusted input + +**The doc frame may *propose* anchors and *report* geometry. It may never create, mutate, +or resolve review state.** Comment bodies and the submit action originate in shell-owned +UI, always. + +The document is untrusted: it is agent-authored, or it arrived from another participant in +a share. Its own scripts share a JavaScript context with the injected runtime, so a hostile +page can tamper with the runtime, read the port, and send whatever protocol messages it +likes. The trust boundary must therefore sit at the shell, not inside the frame. + +Concretely the shell: + +- treats every inbound payload as untrusted data — validated for shape, clamped for size, + never `eval`'d, never rendered as HTML; +- opens its own composer for the user to type into, and creates the review event itself; +- never accepts a "create comment", "resolve thread", or "accept suggestion" instruction + from the frame. + +With that boundary, the worst a hostile document can do is raise a Comment pill the user +did not ask for, or misdescribe what a proposed anchor covers. Both are visible in the +shell's composer before the user commits, which bounds the risk to *misleading preview* — +not forged authorship, and not review-state corruption. + +## 4. Decision — the document's own scripts keep running + +Annotation does **not** disable page scripts. Self-contained AI-generated HTML routinely +depends on charting and animation libraries; a review surface that renders every chart +blank is not a review surface. The alternative (a nonce CSP admitting only the runtime) +was rejected for that reason. + +This is safe *given §3* — the frame is already assumed hostile, and the trust boundary +does not depend on the frame behaving. It is recorded as a decision rather than an +oversight so a future reader does not "fix" it. + +**This changes the hosted reviewer's posture.** Today the hosted browser reviewer passes +`allowScripts={false}`, so a peer's HTML renders with no scripts at all. The injected +runtime needs `allow-scripts`, so annotating a snapshot turns scripts on there too. The +frame remains on an opaque origin in both cases — no `allow-same-origin` — so the document +still cannot reach the app, the user's files, or any storage; what it gains is the ability +to run its own code inside its own frame. Since §3 already assumes exactly that, the +marginal risk is confined to the document misbehaving *visually* (annoying or phishy +content inside the viewer), which it could already do with static HTML and CSS. + +Scripts are enabled **only when annotating**. A shared HTML document being viewed +read-only keeps today's script-free rendering. + +--- + +## 5. postMessage protocol surface + +Version-tagged (`v: 1`). Every message is `{ type, v, ...payload }`. Unknown `type`s are +ignored by both sides so the protocol can extend without a lockstep upgrade. + +### Document → shell + +| type | payload | meaning | +|---|---|---| +| `hello` | `{}` | runtime booted; sent on `window.parent`, pre-port | +| `ready` | `{ textLength, title }` | port live, document indexed | +| `selection` | `{ proposal, rects, caret }` | user selected text; drives the Comment pill | +| `selectionCleared` | `{}` | selection collapsed/lost | +| `scopeHover` | `{ chain, rects }` | block hover; drives gutter pin + scope breadcrumb | +| `scopePicked` | `{ proposal, rects }` | user chose a scope (pin or breadcrumb entry) | +| `anchorsResolved` | `{ results[] }` | resolution status + rects per rendered anchor | +| `geometry` | `{ results[], scrollTop, viewport }` | rects moved (scroll/resize/reflow) | +| `anchorActivated` | `{ anchorId }` | user clicked an overlay chip / pin | + +`proposal` is a candidate anchor (§6) — *proposed*, per §3, never authoritative. +`results[]` entries are `{ anchorId, status, confidence?, rects }`. + +### Shell → document + +| type | payload | meaning | +|---|---|---| +| `init` | `{ theme, anchors[] }` | sent with the transferred port | +| `renderAnchors` | `{ anchors[] }` | full desired-state set; the frame diffs | +| `setAnchorState` | `{ anchorId, state }` | `default` / `active` / `resolved` | +| `focusAnchor` | `{ anchorId, scrollIntoView }` | rail card → document | +| `dismissSelection` | `{}` | composer cancelled | +| `theme` | `{ mode, tokens }` | PAPER/INK switch | + +`renderAnchors` is deliberately full-state rather than incremental: the frame owns no +review state, so a diffable snapshot keeps it stateless and makes recovery after a reload +or a hostile-script wipe trivial (re-send the set). + +### Geometry and coordinates + +The frame reports rects in **its own viewport coordinates**. The shell converts with +`shellY = iframeRect.top + docRect.top`. The frame scrolls internally and the shell cannot +observe that scroll cross-origin, so the frame re-reports on scroll, resize, and on +`ResizeObserver`/`MutationObserver` reflow, throttled to animation frames. The rail's +existing push-down collision layout then runs unchanged on shell coordinates. + +--- + +## 6. Anchor payload shape + +HTML anchors reuse the existing `Anchor` envelope and add **one** optional, doc-type-tagged +layer. Markdown anchors are untouched; the new field is `skip_serializing_if = "Option::is_none"` +so existing wire bytes are byte-identical. + +- `position` is populated with UTF-8 byte offsets into the document's canonical text + (`textContent`), matching `canonicalEncoding: utf8-bytes`. For markdown these are source + offsets; for HTML they are rendered-text offsets. Rust never interprets them for HTML. + `lineRange` is `[0, 0]` — meaningless for HTML, retained for struct compatibility. +- `quote` and `context` are populated from rendered text and are reused **as-is** — they + are plain strings with no markdown semantics. +- `block` and `structure` are omitted (they are comrak/heading-path concepts). +- `html` is the new layer: target kind, CSS selector + fallbacks, text position, range + selector, and the agent-context block of §2. + +A whole-element anchor sets `target: element` and fills `quote` with the element's +(truncated) text, so element and text anchors present identically to the rail. + +`SnapshotPlaintext::validate` currently *rejects* an HTML snapshot carrying an +`anchorIndex`; that stays true — HTML never gets a Rust-built index. What changes is that +HTML snapshots gain an explicit **annotation capability** flag so the UI can distinguish +"HTML, annotatable" from "HTML, read-only" without inferring it from `anchorIndex` +presence. + +--- + +## 7. What Rust does and does not do + +**Does:** persist and sync HTML anchors as opaque blobs; carry them through +`CreateComment` / `CreateSuggestion`; record webview-reported resolution status and emit +`AnchorResolutionChanged`; mark HTML snapshots as annotation-capable at publish. + +**Does not:** parse HTML, build an anchor index for HTML, resolve HTML anchors, or apply +suggestions to HTML source. Suggestion *authoring* on HTML is out of scope for v1 — +comments only. The byte-splice apply pipeline is format-agnostic and could support it +later, but "edit the HTML source" is not a coherent gesture from a rendered view, and +resolving that is a separate design problem. + +Because Rust cannot verify an HTML anchor, the canonical anchor-index rebuild/verify step +(`manager.rs`, markdown-only today) has **no HTML equivalent by design**. HTML anchors are +explicitly *unverified-by-authority* — the client that authored them is the only party +that ever interpreted them. This is acceptable because an anchor is a pointer, not a +claim: a bad one produces a misplaced highlight, not corrupted review state. + +--- + +## 8. Non-goals for v1 + +- Suggestions (accept/reject edits) on HTML documents. +- Live co-typing / collaborative editing of HTML. +- Annotating HTML sub-resources (iframes inside the document, shadow DOM, canvas). +- Re-anchoring across *structurally different* revisions of a document. Selector-set + resolution handles incremental edits; a rewritten page will correctly go `stale`. diff --git a/planning/collab/prototypes/README.md b/planning/collab/prototypes/README.md index e74e3cfe..c5734d47 100644 --- a/planning/collab/prototypes/README.md +++ b/planning/collab/prototypes/README.md @@ -58,11 +58,17 @@ python3 -m http.server 7843 | Text range anchor | existing `quote`/`position` layers (TextQuote/TextPosition) | | Rail cards | reuse `ReviewMargin` / `ReviewMarginCard` unchanged | | Composer popover | `CommentComposer.svelte` | -| localhost origin tag | doc served from a distinct origin; shell ⇄ doc via `postMessage` | +| localhost origin tag | runtime injected into the doc frame; shell ⇄ doc via a `MessageChannel` port | The four currently-missing production pieces this previews: an HTML anchor substrate -(`bootstrap.rs:1467` is `None` today), selection bridging across the doc origin, -mounting `ReviewMargin` for HTML (hidden at `BrowserReviewApp.svelte:286`), and HTML -collab seeding (gated on `anchorIndex` presence). +(`bootstrap.rs` publishes `(DocType::Html, None)` today), selection bridging out of the +doc frame, mounting `ReviewMargin` for HTML (hidden in `BrowserReviewApp.svelte` / +`App.svelte`), and HTML collab seeding (gated on `anchorIndex` presence). + +> **Note (2026-08-04):** this prototype serves itself over `http.server` on a distinct +> port, which mirrored the since-superseded "Design B". Production keeps the *opaque-origin +> sandbox* instead and injects the runtime into the document — see +> [`../html-annotation.md`](../html-annotation.md) §1 and `amendments.md` decision #19. +> The UX this prototype demonstrates is unaffected; only the frame hosting differs. > Prototype only — vanilla JS/CSS, no build, not wired to the Rust backend or collab layer. diff --git a/src/review/agent.rs b/src/review/agent.rs index 0b19de61..42b97e7c 100644 --- a/src/review/agent.rs +++ b/src/review/agent.rs @@ -295,5 +295,6 @@ fn placeholder_anchor() -> Anchor { block: None, context: None, structure: None, + html: None, } } diff --git a/src/review/anchors/resolve.rs b/src/review/anchors/resolve.rs index 96f944e0..5d4c544d 100644 --- a/src/review/anchors/resolve.rs +++ b/src/review/anchors/resolve.rs @@ -37,6 +37,12 @@ pub enum ResolveError { /// the manager must route anchors to the right replica first. #[error("anchor is for a different fileId than the supplied index")] WrongFile, + /// The anchor carries an HTML selector layer, which only a DOM can resolve. + /// Those resolve client-side in the document frame; running one through the + /// markdown ladder would produce confident nonsense, because its offsets + /// index markdown *source* while an HTML anchor's index *rendered text*. + #[error("html anchors resolve client-side, not in the markdown resolver")] + HtmlAnchor, } // --------------------------------------------------------------------------- @@ -249,6 +255,14 @@ pub fn resolve_anchor_with_config( pm_steps: Option<&PmStepJournal>, cfg: &ResolverConfig, ) -> Result { + // HTML anchors index rendered text and are addressed by CSS selectors; the + // ladder below indexes markdown source. Resolving one here would not fail + // loudly, it would land somewhere plausible and wrong, so refuse outright. + // @see planning/collab/html-annotation.md §7 + if anchor.html.is_some() { + return Err(ResolveError::HtmlAnchor); + } + // Tiny safety net — the manager is supposed to route, but a misrouted // anchor produces nonsense quote searches if we don't bail. if anchor.file_id != current_index_file_id_placeholder(anchor) { @@ -910,6 +924,7 @@ mod tests { block, context, structure, + html: None, } } @@ -1741,4 +1756,51 @@ mod tests { assert_eq!(find_all_byte_matches(b"abc", b""), Vec::::new()); assert_eq!(find_all_byte_matches(b"", b"abc"), Vec::::new()); } + + /// An HTML anchor's offsets index rendered text, not markdown source, so + /// the markdown ladder would land somewhere plausible and wrong rather than + /// failing. It must refuse instead. @see html-annotation.md §7 + #[test] + fn html_anchors_are_refused_rather_than_misresolved() { + use crate::review::model::{ + HtmlAnchor, HtmlAnchorContext, HtmlAnchorTarget, SnapshotAnnotation, + }; + let _ = SnapshotAnnotation::HtmlSelectorsV1; + + let md = b"# Title\n\nA paragraph of prose.\n"; + let index = build_anchor_index(md, &snap_id("snap-1")).expect("index"); + let hash = content_hash(md); + let mut anchor = make_anchor( + md, + "file-1", + "snap-1", + pos([9, 20], [3, 3]), + Some(quote("A paragraph")), + None, + None, + None, + ); + // Sanity: without the HTML layer this same anchor resolves fine, so the + // refusal below is attributable to the layer and nothing else. + assert!(resolve_anchor(&anchor, &index, md, &hash, None).is_ok()); + + anchor.html = Some(HtmlAnchor { + v: HtmlAnchor::VERSION, + target: HtmlAnchorTarget::TextRange, + css_selector: "p".into(), + fallback_selectors: Vec::new(), + text_position: None, + range: None, + context: HtmlAnchorContext { + tag_name: "p".into(), + role: None, + scope_preview: "a paragraph".into(), + dom_path: Vec::new(), + }, + }); + assert_eq!( + resolve_anchor(&anchor, &index, md, &hash, None), + Err(ResolveError::HtmlAnchor) + ); + } } diff --git a/src/review/apply.rs b/src/review/apply.rs index 9e7cbb4a..079c7912 100644 --- a/src/review/apply.rs +++ b/src/review/apply.rs @@ -1006,6 +1006,7 @@ mod tests { block: None, context: None, structure: None, + html: None, } } @@ -1256,6 +1257,7 @@ mod tests { block: None, context: None, structure: None, + html: None, }; let op = SuggestionOperation::Replace { expected_text: "nonexistent token never appearing anywhere".into(), @@ -2450,6 +2452,7 @@ second paragraph with new text inside block: None, context: None, structure: None, + html: None, }; ReviewEventBody::SuggestionCreated { suggestion_id: suggestion_id.to_string(), diff --git a/src/review/bootstrap.rs b/src/review/bootstrap.rs index f7e60cc8..c35bb1ff 100644 --- a/src/review/bootstrap.rs +++ b/src/review/bootstrap.rs @@ -56,6 +56,7 @@ use crate::review::model::SnapshotPlaintext; use crate::review::model::{ Capability, Device, DeviceClient, EnvelopeKind, EventMeta, MailboxEnvelope, Participant, ParticipantKind, ReviewEvent, ReviewEventBody, ReviewRoom, RoomMode, RoomPolicy, + SnapshotAnnotation, }; use crate::review::store::ReviewStore; use crate::review::transport::inbound::{ @@ -3174,15 +3175,21 @@ impl Bootstrapper { let content = String::from_utf8(doc_bytes) .map_err(|_| BootstrapError::Crypto("snapshot document must be utf-8".into()))?; - // HTML docs are shared read-only — no comment anchors (yet), so they - // carry no anchor index. Markdown docs anchor against rendered - // structure for comments/suggestions. - let (doc_type, anchor_index) = if is_html_path(path) { - (DocType::Html, None) + // Markdown anchors against a Rust-built index over its source. HTML has + // no Rust-side index — that would need a headless HTML parser in the + // binary — so it declares a client-side annotation capability instead, + // and its anchors resolve in the document frame. + // @see planning/collab/html-annotation.md §2, §7 + let (doc_type, anchor_index, annotation) = if is_html_path(path) { + ( + DocType::Html, + None, + Some(SnapshotAnnotation::HtmlSelectorsV1), + ) } else { let index = build_anchor_index(content.as_bytes(), &snapshot_id) .map_err(|e| BootstrapError::Crypto(format!("anchor index: {e}")))?; - (DocType::Markdown, Some(index)) + (DocType::Markdown, Some(index), None) }; let plaintext = SnapshotPlaintext { doc_type, @@ -3191,6 +3198,7 @@ impl Bootstrapper { media_type: None, encoding: None, manifest: None, + annotation, }; let published = self @@ -3461,6 +3469,7 @@ impl Bootstrapper { media_type: None, encoding: None, manifest: Some(manifest), + annotation: None, }; plaintext .validate() diff --git a/src/review/crypto/ids.rs b/src/review/crypto/ids.rs index ea0daecd..c689dd13 100644 --- a/src/review/crypto/ids.rs +++ b/src/review/crypto/ids.rs @@ -365,6 +365,7 @@ mod tests { block: None, context: None, structure: None, + html: None, }, body: text.to_string(), } @@ -907,6 +908,7 @@ mod tests { block: None, context: None, structure: None, + html: None, }, operation: SuggestionOperation::Replace { expected_text: "foo".to_string(), @@ -978,6 +980,7 @@ mod tests { block: None, context: None, structure: None, + html: None, }, body: format!("parent-count vector: {label}"), }; diff --git a/src/review/crypto/signing.rs b/src/review/crypto/signing.rs index 70636dd9..fb6b828d 100644 --- a/src/review/crypto/signing.rs +++ b/src/review/crypto/signing.rs @@ -364,6 +364,7 @@ mod tests { block: None, context: None, structure: None, + html: None, } } @@ -630,6 +631,7 @@ mod tests { block: None, context: None, structure: None, + html: None, }, body: "hello".to_string(), }; @@ -663,6 +665,7 @@ mod tests { block: None, context: None, structure: None, + html: None, }, operation: crate::review::model::SuggestionOperation::Replace { expected_text: "foo".to_string(), diff --git a/src/review/diff_suggestions.rs b/src/review/diff_suggestions.rs index 4a04caf5..c6957e5d 100644 --- a/src/review/diff_suggestions.rs +++ b/src/review/diff_suggestions.rs @@ -750,6 +750,9 @@ fn build_anchor( heading_path: block.heading_path.clone(), ordinal_in_parent: block.ordinal_in_parent, }), + // Diff-derived suggestions are markdown-only; HTML has no suggestion + // authoring path (`html-annotation.md` §7). + html: None, }) } @@ -844,6 +847,7 @@ mod tests { media_type: None, encoding: None, manifest: None, + annotation: None, }), } } diff --git a/src/review/envelope.rs b/src/review/envelope.rs index 2ec77335..58b03246 100644 --- a/src/review/envelope.rs +++ b/src/review/envelope.rs @@ -667,6 +667,7 @@ mod tests { block: None, context: None, structure: None, + html: None, }, body: "hello".to_string(), }, @@ -707,6 +708,7 @@ mod tests { block: None, context: None, structure: None, + html: None, }, operation: SuggestionOperation::Replace { expected_text: "foo".to_string(), diff --git a/src/review/manager.rs b/src/review/manager.rs index fd2a6af9..a005ef18 100644 --- a/src/review/manager.rs +++ b/src/review/manager.rs @@ -965,6 +965,23 @@ impl ReviewManager { Some(bootstrapper), Some(_runtime), ) => { + // An HTML anchor is authored inside a document frame that shares + // a JS context with untrusted page scripts, so it is bounded here + // before it can be persisted or synced to peers. Rust cannot + // check that the selectors address anything — that needs a DOM — + // but it can refuse a malformed or oversized payload. + // @see planning/collab/html-annotation.md §3 + if let Some(html) = anchor.html.as_ref() + && let Err(err) = html.validate() + { + self.emit_event_outcome( + room_id.clone(), + Err(crate::review::bootstrap::BootstrapError::Crypto(format!( + "invalid html anchor: {err}" + ))), + ); + return; + } // A reply reuses the parent's thread id; a new comment mints one. let thread_id = parent_thread_id.clone().unwrap_or_else(mint_thread_id); let event_body = crate::review::model::ReviewEventBody::CommentCreated { @@ -4584,6 +4601,7 @@ mod tests { media_type: None, encoding: None, manifest: None, + annotation: None, }; let blob_bytes = crate::review::crypto::canonical::to_canonical_bytes(&plaintext) .expect("canonical snapshot"); @@ -4855,6 +4873,7 @@ mod tests { media_type: Some("application/octet-stream".to_string()), encoding: Some(SnapshotAssetEncoding::Base64url), manifest: None, + annotation: None, }; let markdown_raw = b"# Nested\n\nHello workspace.\n"; let markdown_payload = SnapshotPlaintext { @@ -4870,6 +4889,7 @@ mod tests { media_type: None, encoding: None, manifest: None, + annotation: None, }; let mut asset_event = persist_snapshot_event( @@ -4972,6 +4992,7 @@ mod tests { media_type: None, encoding: None, manifest: Some(manifest), + annotation: None, }; let mut manifest_event = persist_snapshot_event( &store, @@ -5058,6 +5079,7 @@ mod tests { media_type: None, encoding: None, manifest: None, + annotation: None, }; let mut event = persist_snapshot_event( &store, @@ -5109,6 +5131,7 @@ mod tests { media_type: None, encoding: None, manifest: None, + annotation: None, }; let blob_bytes = crate::review::crypto::canonical::to_canonical_bytes(&plaintext) .expect("canonical snapshot"); @@ -6032,6 +6055,7 @@ mod tests { block: None, context: None, structure: None, + html: None, } } @@ -7422,6 +7446,7 @@ mod request_snapshot_tests { media_type: None, encoding: None, manifest: None, + annotation: None, }), } } diff --git a/src/review/model.rs b/src/review/model.rs index ee7ba0cd..2b3133a5 100644 --- a/src/review/model.rs +++ b/src/review/model.rs @@ -243,12 +243,27 @@ pub enum DocType { WorkspaceManifest, } +/// How a snapshot's comments are anchored. +/// +/// Markdown uses the Rust-built [`AnchorIndex`]. HTML has no Rust-side index — +/// it would require a headless HTML parser in the binary — so it declares this +/// capability instead, and anchors resolve client-side in the document frame. +/// +/// Spec: `html-annotation.md` §6, `amendments.md` decision #19. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SnapshotAnnotation { + /// W3C Web Annotation selector sets, resolved in the document frame. + HtmlSelectorsV1, +} + /// Local-only decrypted snapshot payload (document content + optional anchor /// index). Kept off the wire per `amendments.md` decision #14. /// /// `anchor_index` is present only for markdown docs (it positions comments and -/// suggestions against the rendered structure). HTML docs are read-only, so -/// they carry no anchor index — `anchor_index` is `None`. +/// suggestions against the rendered structure). HTML docs never carry one; +/// annotatable HTML instead declares `annotation`, and its anchors are resolved +/// client-side. /// /// Spec: `data-model.md` §Snapshot Graph. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -261,6 +276,11 @@ pub struct SnapshotPlaintext { pub content: Option, #[serde(skip_serializing_if = "Option::is_none")] pub anchor_index: Option, + /// Declares that this snapshot can be annotated without a Rust-built + /// index. `None` on an HTML snapshot means read-only (the pre-`attn-61t` + /// behavior, and what a peer on an older build will publish). + #[serde(skip_serializing_if = "Option::is_none")] + pub annotation: Option, /// Required only for `asset`; a syntactically valid MIME type without /// parameters. Keeping this declaration inside the encrypted payload /// means the relay/R2 remain content-blind. @@ -358,9 +378,19 @@ impl SnapshotPlaintext { match self.doc_type { DocType::Markdown => { self.require_text_shape("markdown")?; + // Markdown anchors against the Rust-built index; declaring a + // client-side capability as well would be two sources of truth. + if self.annotation.is_some() { + return Err(SnapshotValidationError::new( + "markdown snapshot must not declare annotation capability", + )); + } } DocType::Html => { self.require_text_shape("html")?; + // Still true, and deliberately so: HTML never gets a + // Rust-built index (`html-annotation.md` §7). Annotatable HTML + // declares `annotation` instead. if self.anchor_index.is_some() { return Err(SnapshotValidationError::new( "html snapshot must not contain anchorIndex", @@ -373,6 +403,11 @@ impl SnapshotPlaintext { "asset snapshot contains document/manifest fields", )); } + if self.annotation.is_some() { + return Err(SnapshotValidationError::new( + "asset snapshot must not declare annotation capability", + )); + } if self.encoding != Some(SnapshotAssetEncoding::Base64url) { return Err(SnapshotValidationError::new( "asset snapshot encoding must be base64url", @@ -390,6 +425,7 @@ impl SnapshotPlaintext { || self.anchor_index.is_some() || self.media_type.is_some() || self.encoding.is_some() + || self.annotation.is_some() { return Err(SnapshotValidationError::new( "workspace manifest contains non-manifest fields", @@ -872,6 +908,11 @@ pub struct AnchorHeadingRef { /// Layered anchor describing where a review event was authored. /// +/// Markdown anchors populate `position` (source byte/line offsets) plus any of +/// `quote` / `block` / `context` / `structure`. HTML anchors instead populate +/// `html`, and reuse `position` / `quote` / `context` with *rendered-text* +/// semantics — see [`HtmlAnchor`] and `html-annotation.md` §6. +/// /// Spec: `data-model.md` §Anchors. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -880,6 +921,9 @@ pub struct Anchor { pub file_id: FileId, pub snapshot_id: SnapshotId, pub base_hash: ContentHash, + /// For markdown, UTF-8 byte offsets into the document *source*. For HTML, + /// offsets into the document's rendered `textContent`, with `lineRange` + /// fixed at `[0, 0]` — Rust never interprets either for HTML. pub position: PositionAnchor, #[serde(skip_serializing_if = "Option::is_none")] pub quote: Option, @@ -889,6 +933,10 @@ pub struct Anchor { pub context: Option, #[serde(skip_serializing_if = "Option::is_none")] pub structure: Option, + /// Present only for anchors authored against a rendered HTML document. + /// Opaque to Rust; resolved client-side in the document frame. + #[serde(skip_serializing_if = "Option::is_none")] + pub html: Option, } /// Snapshot-local byte/line/pm coordinates for an anchor. @@ -955,6 +1003,213 @@ pub struct StructureAnchor { pub ordinal_in_parent: u32, } +// --------------------------------------------------------------------------- +// HTML anchors +// --------------------------------------------------------------------------- + +/// Doc-type-tagged annotation layer for anchors authored against a *rendered +/// HTML document*, carrying a W3C Web Annotation selector set. +/// +/// This layer is **opaque to Rust**: it is persisted and synced verbatim and +/// never interpreted here. Resolution happens client-side in the document +/// frame, which is the only place a DOM exists. That is what keeps a headless +/// HTML parser out of the binary (see `task check:size`). +/// +/// The values originate in an untrusted document frame, so [`Self::validate`] +/// bounds every field before the anchor is persisted or synced. +/// +/// Spec: `html-annotation.md` §2/§6, `amendments.md` decisions #19/#20. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HtmlAnchor { + pub v: u32, + pub target: HtmlAnchorTarget, + /// Primary W3C `CssSelector` for the anchoring element — the element + /// itself for [`HtmlAnchorTarget::Element`], the common ancestor for a + /// text range. + pub css_selector: String, + /// Ranked alternates, most specific first, tried when `css_selector` misses. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub fallback_selectors: Vec, + /// W3C `TextPositionSelector` over the document's canonical text. + #[serde(skip_serializing_if = "Option::is_none")] + pub text_position: Option, + /// W3C `RangeSelector`, present when the selection crosses element bounds. + #[serde(skip_serializing_if = "Option::is_none")] + pub range: Option, + pub context: HtmlAnchorContext, +} + +/// What an HTML anchor is attached to. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum HtmlAnchorTarget { + /// A run of text, possibly spanning element boundaries. + TextRange, + /// A whole DOM element (table cell/row/table, list item, section, …). + Element, +} + +/// W3C `TextPositionSelector`: UTF-8 byte offsets into the document's +/// canonical rendered text (`textContent`), matching +/// [`CanonicalEncoding::Utf8Bytes`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HtmlTextPosition { + pub start: u64, + pub end: u64, +} + +/// W3C `RangeSelector`: start/end containers addressed by CSS selector plus a +/// UTF-8 byte offset into each container's text. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HtmlRangeSelector { + pub start_selector: String, + pub start_offset: u64, + pub end_selector: String, + pub end_offset: u64, +} + +/// Agent-legible description of what an HTML anchor covers. +/// +/// attn's purpose is human comments feeding AI suggestions, and a comment is +/// only actionable to a coding agent if the payload also says *what* it was +/// attached to. Capturing this at anchor time is free; reconstructing it later +/// requires the document. +/// +/// Spec: `html-annotation.md` §2 (agent-context block). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HtmlAnchorContext { + /// Lowercase tag name of the target element, e.g. `tr`. + pub tag_name: String, + /// ARIA role, explicit or implicit, when the frame could determine one. + #[serde(skip_serializing_if = "Option::is_none")] + pub role: Option, + /// Human-readable scope label shown in the breadcrumb, e.g. + /// `row 3 · Fuzzy quote · edit-distance match`. + pub scope_preview: String, + /// Short ancestor chain, outermost first, e.g. `["table", "tbody", "tr"]`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub dom_path: Vec, +} + +/// Field bounds for [`HtmlAnchor::validate`]. These exist because the payload +/// crosses a trust boundary — it is authored inside a document frame that may +/// be hostile (`amendments.md` decision #20) — not because the honest +/// producer ever approaches them. +const MAX_HTML_SELECTOR_BYTES: usize = 1024; +const MAX_HTML_FALLBACK_SELECTORS: usize = 8; +const MAX_HTML_SCOPE_PREVIEW_BYTES: usize = 256; +const MAX_HTML_DOM_PATH_SEGMENTS: usize = 32; +const MAX_HTML_DOM_PATH_SEGMENT_BYTES: usize = 64; +const MAX_HTML_TAG_NAME_BYTES: usize = 64; +const MAX_HTML_ROLE_BYTES: usize = 64; + +/// Rejected an untrusted HTML anchor payload. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HtmlAnchorValidationError(String); + +impl fmt::Display for HtmlAnchorValidationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for HtmlAnchorValidationError {} + +impl HtmlAnchor { + /// Current wire version emitted by this build. + pub const VERSION: u32 = 1; + + /// Bound every field of an anchor proposed by a document frame. Callers + /// must invoke this before persisting or syncing. + /// + /// This checks *shape and size only*. Whether the selectors actually + /// address anything is unknowable here by design — HTML anchors are + /// unverified-by-authority (`html-annotation.md` §7). + pub fn validate(&self) -> Result<(), HtmlAnchorValidationError> { + if self.v == 0 || self.v > Self::VERSION { + return Err(HtmlAnchorValidationError(format!( + "unsupported html anchor version {}", + self.v + ))); + } + bound("cssSelector", &self.css_selector, MAX_HTML_SELECTOR_BYTES)?; + if self.css_selector.trim().is_empty() { + return Err(HtmlAnchorValidationError( + "cssSelector must not be empty".into(), + )); + } + if self.fallback_selectors.len() > MAX_HTML_FALLBACK_SELECTORS { + return Err(HtmlAnchorValidationError(format!( + "at most {MAX_HTML_FALLBACK_SELECTORS} fallbackSelectors allowed" + ))); + } + for selector in &self.fallback_selectors { + bound("fallbackSelectors[]", selector, MAX_HTML_SELECTOR_BYTES)?; + } + if let Some(position) = self.text_position + && position.end < position.start + { + return Err(HtmlAnchorValidationError( + "textPosition end precedes start".into(), + )); + } + if let Some(range) = &self.range { + bound( + "range.startSelector", + &range.start_selector, + MAX_HTML_SELECTOR_BYTES, + )?; + bound( + "range.endSelector", + &range.end_selector, + MAX_HTML_SELECTOR_BYTES, + )?; + } + self.context.validate() + } +} + +impl HtmlAnchorContext { + fn validate(&self) -> Result<(), HtmlAnchorValidationError> { + bound("context.tagName", &self.tag_name, MAX_HTML_TAG_NAME_BYTES)?; + if self.tag_name.is_empty() { + return Err(HtmlAnchorValidationError( + "context.tagName must not be empty".into(), + )); + } + if let Some(role) = &self.role { + bound("context.role", role, MAX_HTML_ROLE_BYTES)?; + } + bound( + "context.scopePreview", + &self.scope_preview, + MAX_HTML_SCOPE_PREVIEW_BYTES, + )?; + if self.dom_path.len() > MAX_HTML_DOM_PATH_SEGMENTS { + return Err(HtmlAnchorValidationError(format!( + "at most {MAX_HTML_DOM_PATH_SEGMENTS} domPath segments allowed" + ))); + } + for segment in &self.dom_path { + bound("domPath[]", segment, MAX_HTML_DOM_PATH_SEGMENT_BYTES)?; + } + Ok(()) + } +} + +fn bound(field: &str, value: &str, max: usize) -> Result<(), HtmlAnchorValidationError> { + if value.len() > max { + return Err(HtmlAnchorValidationError(format!( + "{field} exceeds {max} bytes" + ))); + } + Ok(()) +} + // --------------------------------------------------------------------------- // Anchor resolution // --------------------------------------------------------------------------- @@ -1387,6 +1642,7 @@ mod tests { block: None, context: None, structure: None, + html: None, } } @@ -1406,6 +1662,7 @@ mod tests { media_type: None, encoding: None, manifest: None, + annotation: None, } } @@ -1417,6 +1674,7 @@ mod tests { media_type: Some("application/octet-stream".to_string()), encoding: Some(SnapshotAssetEncoding::Base64url), manifest: None, + annotation: None, } } @@ -1445,6 +1703,7 @@ mod tests { scope: WorkspaceManifestScope::Workspace, entries, }), + annotation: None, } } @@ -1789,4 +2048,260 @@ mod tests { let obj = json.as_object().expect("object"); assert!(!obj.contains_key("reason")); } + + // ----------------------------------------------------------------------- + // HTML anchors (attn-80u) + // ----------------------------------------------------------------------- + + fn sample_html_anchor() -> HtmlAnchor { + HtmlAnchor { + v: HtmlAnchor::VERSION, + target: HtmlAnchorTarget::Element, + css_selector: "table.results > tbody > tr:nth-child(3)".into(), + fallback_selectors: vec!["#results tr:nth-child(3)".into()], + text_position: Some(HtmlTextPosition { + start: 1024, + end: 1090, + }), + range: Some(HtmlRangeSelector { + start_selector: "table.results tr:nth-child(3) td:nth-child(1)".into(), + start_offset: 0, + end_selector: "table.results tr:nth-child(3) td:nth-child(4)".into(), + end_offset: 12, + }), + context: HtmlAnchorContext { + tag_name: "tr".into(), + role: Some("row".into()), + scope_preview: "row 3 · Fuzzy quote · edit-distance match".into(), + dom_path: vec!["table".into(), "tbody".into(), "tr".into()], + }, + } + } + + #[test] + fn html_anchor_round_trips_through_canonical_json() { + let anchor = Anchor { + html: Some(sample_html_anchor()), + ..sample_anchor() + }; + let encoded = to_canonical_string(&anchor).expect("canonical encode"); + let decoded: Anchor = serde_json::from_str(&encoded).expect("decode"); + assert_eq!(decoded, anchor); + } + + /// The new layer must be purely additive: an anchor that does not carry it + /// serializes to exactly the bytes it did before `html` existed. + #[test] + fn markdown_anchor_omits_the_html_key_entirely() { + let json: Value = serde_json::to_value(sample_anchor()).expect("to_value"); + let obj = json.as_object().expect("object"); + assert!(!obj.contains_key("html")); + } + + /// Empty collections are skipped so a minimal text anchor stays small on + /// the wire and canonical bytes do not depend on producer defaults. + #[test] + fn html_anchor_skips_empty_optional_fields() { + let anchor = HtmlAnchor { + v: HtmlAnchor::VERSION, + target: HtmlAnchorTarget::TextRange, + css_selector: "p".into(), + fallback_selectors: Vec::new(), + text_position: None, + range: None, + context: HtmlAnchorContext { + tag_name: "p".into(), + role: None, + scope_preview: "paragraph".into(), + dom_path: Vec::new(), + }, + }; + let json: Value = serde_json::to_value(&anchor).expect("to_value"); + let obj = json.as_object().expect("object"); + for absent in ["fallbackSelectors", "textPosition", "range"] { + assert!(!obj.contains_key(absent), "{absent} should be skipped"); + } + let context = obj["context"].as_object().expect("context object"); + assert!(!context.contains_key("role")); + assert!(!context.contains_key("domPath")); + } + + #[test] + fn html_anchor_target_uses_snake_case_wire_vocabulary() { + let json: Value = serde_json::to_value(sample_html_anchor()).expect("to_value"); + assert_eq!(json["target"], json!("element")); + + let text = HtmlAnchor { + target: HtmlAnchorTarget::TextRange, + ..sample_html_anchor() + }; + let json: Value = serde_json::to_value(&text).expect("to_value"); + assert_eq!(json["target"], json!("text_range")); + } + + #[test] + fn html_anchor_validate_accepts_representative_payload() { + sample_html_anchor().validate().expect("valid"); + } + + #[test] + fn html_anchor_validate_rejects_unsupported_version() { + let anchor = HtmlAnchor { + v: HtmlAnchor::VERSION + 1, + ..sample_html_anchor() + }; + assert!(anchor.validate().is_err()); + + let anchor = HtmlAnchor { + v: 0, + ..sample_html_anchor() + }; + assert!(anchor.validate().is_err()); + } + + #[test] + fn html_anchor_validate_rejects_empty_selector() { + let anchor = HtmlAnchor { + css_selector: " ".into(), + ..sample_html_anchor() + }; + assert!(anchor.validate().is_err()); + } + + /// The payload crosses a trust boundary — it is authored inside a document + /// frame that may be hostile (`amendments.md` #20) — so oversized fields + /// are rejected rather than truncated. + #[test] + fn html_anchor_validate_rejects_oversized_untrusted_fields() { + let anchor = HtmlAnchor { + css_selector: "a".repeat(MAX_HTML_SELECTOR_BYTES + 1), + ..sample_html_anchor() + }; + assert!(anchor.validate().is_err()); + + let anchor = HtmlAnchor { + fallback_selectors: vec!["p".into(); MAX_HTML_FALLBACK_SELECTORS + 1], + ..sample_html_anchor() + }; + assert!(anchor.validate().is_err()); + + let anchor = HtmlAnchor { + context: HtmlAnchorContext { + scope_preview: "x".repeat(MAX_HTML_SCOPE_PREVIEW_BYTES + 1), + ..sample_html_anchor().context + }, + ..sample_html_anchor() + }; + assert!(anchor.validate().is_err()); + + let anchor = HtmlAnchor { + context: HtmlAnchorContext { + dom_path: vec!["div".into(); MAX_HTML_DOM_PATH_SEGMENTS + 1], + ..sample_html_anchor().context + }, + ..sample_html_anchor() + }; + assert!(anchor.validate().is_err()); + } + + #[test] + fn html_anchor_validate_rejects_inverted_text_position() { + let anchor = HtmlAnchor { + text_position: Some(HtmlTextPosition { start: 90, end: 10 }), + ..sample_html_anchor() + }; + assert!(anchor.validate().is_err()); + } + + /// A zero-length range is legitimate — it is how an element with no text + /// reports its position. + #[test] + fn html_anchor_validate_accepts_empty_text_position() { + let anchor = HtmlAnchor { + text_position: Some(HtmlTextPosition { start: 42, end: 42 }), + ..sample_html_anchor() + }; + anchor.validate().expect("valid"); + } + + // ----------------------------------------------------------------------- + // Snapshot annotation capability (attn-yob) + // ----------------------------------------------------------------------- + + #[test] + fn html_snapshot_may_declare_client_side_annotation() { + let snapshot = SnapshotPlaintext { + annotation: Some(SnapshotAnnotation::HtmlSelectorsV1), + ..text_snapshot(DocType::Html, "

hi

") + }; + snapshot.validate().expect("annotatable html is valid"); + } + + /// A peer on an older build publishes HTML without the capability. That has + /// to stay valid — it means read-only, not malformed. + #[test] + fn html_snapshot_without_annotation_is_still_valid() { + text_snapshot(DocType::Html, "

hi

") + .validate() + .expect("read-only html is valid"); + } + + /// Still true, and deliberately so: HTML never gets a Rust-built index. + #[test] + fn html_snapshot_still_rejects_an_anchor_index() { + let index = AnchorIndex { + doc_hash: id::("hash-1"), + canonical_encoding: CanonicalEncoding::Utf8Bytes, + line_count: 1, + blocks: Vec::new(), + headings: Vec::new(), + }; + let snapshot = SnapshotPlaintext { + anchor_index: Some(index), + ..text_snapshot(DocType::Html, "

hi

") + }; + assert!(snapshot.validate().is_err()); + } + + /// Markdown anchors against the Rust-built index; declaring a client-side + /// capability too would be two sources of truth for one document. + #[test] + fn markdown_snapshot_rejects_annotation_capability() { + let snapshot = SnapshotPlaintext { + annotation: Some(SnapshotAnnotation::HtmlSelectorsV1), + ..text_snapshot(DocType::Markdown, "# hi") + }; + assert!(snapshot.validate().is_err()); + } + + #[test] + fn asset_snapshot_rejects_annotation_capability() { + let snapshot = SnapshotPlaintext { + annotation: Some(SnapshotAnnotation::HtmlSelectorsV1), + ..binary_asset(b"\x00\x01") + }; + assert!(snapshot.validate().is_err()); + } + + /// Additive on the wire: a snapshot that does not declare the capability + /// serializes to exactly the bytes it did before the field existed. + #[test] + fn snapshot_omits_the_annotation_key_when_absent() { + let json: Value = + serde_json::to_value(text_snapshot(DocType::Html, "

hi

")).expect("to_value"); + assert!(!json.as_object().expect("object").contains_key("annotation")); + } + + #[test] + fn snapshot_annotation_uses_snake_case_wire_vocabulary() { + let snapshot = SnapshotPlaintext { + annotation: Some(SnapshotAnnotation::HtmlSelectorsV1), + ..text_snapshot(DocType::Html, "

hi

") + }; + let json: Value = serde_json::to_value(&snapshot).expect("to_value"); + assert_eq!(json["annotation"], json!("html_selectors_v1")); + + let decoded: SnapshotPlaintext = serde_json::from_value(json).expect("round trip"); + assert_eq!(decoded, snapshot); + } } diff --git a/src/review/share_lifecycle.rs b/src/review/share_lifecycle.rs index b1409f10..512557e0 100644 --- a/src/review/share_lifecycle.rs +++ b/src/review/share_lifecycle.rs @@ -3503,6 +3503,7 @@ mod tests { media_type: None, encoding: None, manifest: None, + annotation: None, }, }; let key = [0x55; 32]; @@ -3576,6 +3577,7 @@ mod tests { scope: crate::review::model::WorkspaceManifestScope::Workspace, entries: Vec::new(), }), + annotation: None, }, }; let error = seal_managed_snapshot(SHARE_ID, 4, &snapshot, &[0x55; 32]) @@ -3689,6 +3691,7 @@ mod tests { block: None, context: None, structure: None, + html: None, }, body: "valid comment after poison".into(), }, diff --git a/src/review/store.rs b/src/review/store.rs index aeb599d9..f020a902 100644 --- a/src/review/store.rs +++ b/src/review/store.rs @@ -1128,6 +1128,7 @@ mod tests { block: None, context: None, structure: None, + html: None, }, operation: crate::review::model::SuggestionOperation::InsertAfter { text: "new".to_string(), @@ -1160,6 +1161,7 @@ mod tests { media_type: None, encoding: None, manifest: None, + annotation: None, }), } } @@ -1731,6 +1733,7 @@ mod tests { media_type: Some("application/octet-stream".to_string()), encoding: Some(crate::review::model::SnapshotAssetEncoding::Base64url), manifest: None, + annotation: None, }); assert!(store.save_snapshot(&room_id, &snap).is_err()); diff --git a/src/review/transport/inbound.rs b/src/review/transport/inbound.rs index cae029c4..88f80f9f 100644 --- a/src/review/transport/inbound.rs +++ b/src/review/transport/inbound.rs @@ -915,6 +915,7 @@ mod tests { block: None, context: None, structure: None, + html: None, }, body: "hello".to_string(), }, @@ -1086,6 +1087,7 @@ mod tests { block: None, context: None, structure: None, + html: None, }, operation: crate::review::model::SuggestionOperation::Replace { expected_text: "old".to_string(), diff --git a/src/review/transport/mailbox/ws.rs b/src/review/transport/mailbox/ws.rs index c312a75a..2928704a 100644 --- a/src/review/transport/mailbox/ws.rs +++ b/src/review/transport/mailbox/ws.rs @@ -1365,6 +1365,7 @@ mod tests { block: None, context: None, structure: None, + html: None, }, body: "hello".to_string(), }, @@ -1695,6 +1696,7 @@ mod tests { block: None, context: None, structure: None, + html: None, }, body: format!("hello {i}"), }, @@ -2191,6 +2193,7 @@ mod tests { block: None, context: None, structure: None, + html: None, }, body: format!("cursor-body-{i}"), }, diff --git a/tests/review_sync_convergence.rs b/tests/review_sync_convergence.rs index 053782d7..f1c6669d 100644 --- a/tests/review_sync_convergence.rs +++ b/tests/review_sync_convergence.rs @@ -216,6 +216,7 @@ fn placeholder_anchor() -> Anchor { block: None, context: None, structure: None, + html: None, } } @@ -561,6 +562,7 @@ fn v3_tiers_enforce_comment_and_suggestion_end_to_end() { block: None, context: None, structure: None, + html: None, }; let replacement = "accepted line"; suggester.mgr.submit(ReviewCommand::CreateSuggestion { diff --git a/tests/webrtc_e2e.rs b/tests/webrtc_e2e.rs index 7f795f3c..ccd8e83d 100644 --- a/tests/webrtc_e2e.rs +++ b/tests/webrtc_e2e.rs @@ -585,6 +585,7 @@ fn mint_reviewer_comment_envelope(harness: &E2eHarness, body_text: &str) -> Mail block: None, context: None, structure: None, + html: None, }, body: body_text.to_string(), }, diff --git a/web/package.json b/web/package.json index a9d9c5dc..afef1218 100644 --- a/web/package.json +++ b/web/package.json @@ -8,10 +8,12 @@ "dev": "npm run generate:icons && vite", "dev:browser": "npm run generate:icons && vite --config vite.browser.config.ts", "dev:browser:shares": "npm run generate:icons && ATTN_DEV_RELAY_TARGET=http://localhost:8787 VITE_ATTN_RELAY_URL=http://localhost:5173 vite --config vite.browser.config.ts --port 5173 --strictPort", - "build": "npm run generate:icons && vite build", - "build:browser": "npm run generate:icons && vite build --config vite.browser.config.ts && npm run build:sw", + "build": "npm run generate:icons && npm run build:doc-runtime && vite build", + "build:browser": "npm run generate:icons && npm run build:doc-runtime && vite build --config vite.browser.config.ts && npm run build:sw", "build:anchor-wasm": "node scripts/build-anchor-wasm.mjs", "check:anchor-wasm": "node scripts/build-anchor-wasm.mjs --check", + "build:doc-runtime": "node scripts/build-doc-runtime.mjs", + "check:doc-runtime": "node scripts/build-doc-runtime.mjs --check", "deploy:staging": "node scripts/deploy-browser-staging.mjs", "check": "npm run generate:icons && svelte-check --tsconfig ./tsconfig.json", "test": "node scripts/run-tests.mjs", diff --git a/web/scripts/build-doc-runtime.mjs b/web/scripts/build-doc-runtime.mjs new file mode 100644 index 00000000..4bbdbfdc --- /dev/null +++ b/web/scripts/build-doc-runtime.mjs @@ -0,0 +1,77 @@ +#!/usr/bin/env node +/** + * Bundle the HTML annotation runtime into a single self-contained IIFE and emit + * it as a TypeScript string constant. + * + * The runtime does not run in the app — it is *injected into an arbitrary HTML + * document* that the shell renders in a sandboxed iframe. There is no module + * loader, no build step, and no second file to fetch on the far side, so the + * runtime has to arrive as one inert string the shell can splice into the + * document source. That works identically for a local file and for bytes + * received over the encrypted channel, which is the point (html-annotation.md §1). + * + * Regenerate with `npm run build:doc-runtime`; `--check` verifies the committed + * artifact is current without writing (used by CI). + */ + +import { build } from 'esbuild'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const webRoot = resolve(here, '..'); +const entry = resolve(webRoot, 'src/doc-runtime/index.ts'); +const outFile = resolve(webRoot, 'src/lib/review/doc-runtime.generated.ts'); + +const checkOnly = process.argv.includes('--check'); + +const result = await build({ + entryPoints: [entry], + bundle: true, + format: 'iife', + target: 'es2022', + platform: 'browser', + minify: true, + write: false, + legalComments: 'none', + // The document frame has no bundler and no import map; everything the + // runtime needs must be inlined. + external: [], +}); + +const [output] = result.outputFiles; +if (!output) { + console.error('build-doc-runtime: esbuild produced no output'); + process.exit(1); +} + +const banner = `/** + * GENERATED — do not edit. Run \`npm run build:doc-runtime\`. + * + * Bundled source of the HTML annotation runtime (web/src/doc-runtime/), which + * is injected into the document frame rather than loaded as a module. + * @see planning/collab/html-annotation.md §1 + */`; + +const contents = `${banner}\nexport const DOC_RUNTIME_SOURCE = ${JSON.stringify(output.text)};\n`; + +if (checkOnly) { + let current = ''; + try { + current = readFileSync(outFile, 'utf8'); + } catch { + console.error('build-doc-runtime: artifact missing; run `npm run build:doc-runtime`'); + process.exit(1); + } + if (current !== contents) { + console.error( + 'build-doc-runtime: artifact is stale; run `npm run build:doc-runtime` and commit the result', + ); + process.exit(1); + } + console.log(`doc-runtime artifact is current (${output.text.length} bytes)`); +} else { + writeFileSync(outFile, contents); + console.log(`doc-runtime bundled: ${output.text.length} bytes → ${outFile}`); +} diff --git a/web/src/doc-runtime/index.ts b/web/src/doc-runtime/index.ts new file mode 100644 index 00000000..566c1d47 --- /dev/null +++ b/web/src/doc-runtime/index.ts @@ -0,0 +1,668 @@ +/** + * attn HTML annotation runtime — runs *inside* the document frame. + * + * The frame is an opaque-origin sandbox, so the shell cannot touch its DOM. + * Everything that needs a DOM — selection, geometry, highlight painting, + * selector resolution — happens here, and results travel to the shell over a + * private `MessagePort`. + * + * This code shares a JavaScript context with the document's own scripts, which + * are untrusted. It is therefore written to be *unprivileged*: it proposes + * anchors and reports geometry, and holds no review state worth stealing. The + * shell treats everything it sends as untrusted input. + * + * @see planning/collab/html-annotation.md §1, §3, §5 + */ + +import type { + AnchorGeometry, + AnchorProposal, + AnchorRenderState, + AnchorResolution, + DocMessage, + DocRect, + RenderableAnchor, + ScopeCandidate, + ShellMessage, +} from '../lib/review/doc-protocol'; +import { + DOC_HELLO, + DOC_PROTOCOL_VERSION, + SHELL_INIT, +} from '../lib/review/doc-protocol'; +import { + anchorForElement, + anchorForRange, + documentText, + resolveAnchor, + textOffsetOf, +} from './selectors'; +import { RUNTIME_STYLES } from './styles'; + +const HIGHLIGHT_BUCKET = 'attn-text'; +const HIGHLIGHT_ACTIVE_BUCKET = 'attn-text-active'; +/** Context captured either side of a selection, for later disambiguation. */ +const CONTEXT_CHARS = 64; + +interface LiveAnchor { + spec: RenderableAnchor; + range: Range | null; + element: Element | null; + status: AnchorResolution['status']; + confidence: number; + overlay: HTMLElement | null; + pin: HTMLElement | null; +} + +let port: MessagePort | null = null; +let root: HTMLElement; +let layer: HTMLElement; +let gutterPin: HTMLButtonElement; +let flyout: HTMLElement; +let selectionPill: HTMLButtonElement; + +const anchors = new Map(); +/** Scope candidates offered in the current hover, keyed by their scopeId. */ +const scopeElements = new Map(); +let hoverChain: Element[] = []; +let pendingRange: Range | null = null; +let scopeSeq = 0; + +// --------------------------------------------------------------------------- +// Transport +// --------------------------------------------------------------------------- + +function send(message: DocMessage): void { + port?.postMessage(message); +} + +function toDocRect(rect: DOMRect): DocRect { + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; +} + +/** Merge the many client rects of a multi-line range into per-line bands. */ +function rectsOf(target: Range | Element | null): DocRect[] { + if (!target) return []; + const list = Array.from(target.getClientRects()).filter( + (r) => r.width > 0 && r.height > 0, + ); + return list.slice(0, 128).map(toDocRect); +} + +// --------------------------------------------------------------------------- +// Scope chain (validated by planning/collab/prototypes/html-annotation.html) +// --------------------------------------------------------------------------- + +/** Structural scopes a comment may anchor to, finest to coarsest. */ +const SCOPE_TAGS = new Set([ + 'TD', 'TH', 'TR', 'LI', 'FIGURE', 'PRE', 'TABLE', 'BLOCKQUOTE', + 'H1', 'H2', 'H3', 'H4', 'P', 'IMG', 'FIGCAPTION', 'UL', 'OL', +]); + +const isCell = (el: Element): boolean => el.tagName === 'TD' || el.tagName === 'TH'; + +function candidateChain(el: Element): Element[] { + const chain: Element[] = []; + let cursor: Element | null = el; + while (cursor && cursor !== root && chain.length < 12) { + if (SCOPE_TAGS.has(cursor.tagName)) chain.push(cursor); + cursor = cursor.parentElement; + } + return chain; +} + +/** + * The gutter pin defaults to the band-level scope (row/block) rather than a + * cell: the pin lives in the left margin, where a horizontal band reads as a + * line comment does. + */ +function defaultTarget(chain: Element[]): Element | undefined { + return chain.find((el) => !isCell(el)) ?? chain[0]; +} + +function rowIndexOf(tr: Element): number { + const section = tr.parentElement; + if (!section) return 1; + return Array.from(section.children).filter((c) => c.tagName === 'TR').indexOf(tr) + 1; +} + +function scopeTitle(el: Element): string { + if (isCell(el)) return 'cell'; + if (el.tagName === 'TR') return el.closest('thead') ? 'header row' : `row ${rowIndexOf(el)}`; + if (el.tagName === 'LI') return 'list item'; + if (el.tagName === 'PRE') return 'code block'; + if (/^H[1-6]$/.test(el.tagName)) return 'heading'; + return el.tagName.toLowerCase(); +} + +function scopePreview(el: Element): string | null { + if (el.tagName === 'TR') { + const cells = Array.from(el.querySelectorAll('th,td')).map((c) => c.textContent?.trim() ?? ''); + const label = el.closest('thead') ? 'header row' : `row ${rowIndexOf(el)}`; + const rest = [cells[0], cells[1]].filter(Boolean).join(' · '); + return rest ? `${label} · ${rest}` : label; + } + if (isCell(el)) { + const tr = el.closest('tr'); + const column = tr ? Array.from(tr.children).indexOf(el) : -1; + const headerRow = el.closest('table')?.querySelector('thead tr'); + const columnName = headerRow?.children[column]?.textContent?.trim(); + const value = el.textContent?.trim() ?? ''; + return columnName ? `${columnName}: ${value}` : value; + } + const text = el.textContent?.trim() ?? ''; + return text ? text.slice(0, 80) : null; +} + +function commentsOn(el: Element): number { + let count = 0; + for (const anchor of anchors.values()) { + if (anchor.element === el) count += 1; + } + return count; +} + +// --------------------------------------------------------------------------- +// Proposals +// --------------------------------------------------------------------------- + +/** + * Text immediately before and after *this* range. + * + * Derived from the range itself rather than by searching the document for the + * quote: a search finds the first occurrence, which is the wrong one precisely + * when the text repeats — the case prefix/suffix exist to disambiguate. + */ +function contextAround(range: Range): { prefix: string; suffix: string } { + const before = root.ownerDocument.createRange(); + before.selectNodeContents(root); + before.setEnd(range.startContainer, range.startOffset); + + const after = root.ownerDocument.createRange(); + after.selectNodeContents(root); + after.setStart(range.endContainer, range.endOffset); + + return { + prefix: before.toString().slice(-CONTEXT_CHARS), + suffix: after.toString().slice(0, CONTEXT_CHARS), + }; +} + +function proposalForRange(range: Range): AnchorProposal { + const startOffset = textOffsetOf(root, range.startContainer, range.startOffset); + const endOffset = textOffsetOf(root, range.endContainer, range.endOffset); + const quote = range.toString(); + const { prefix, suffix } = contextAround(range); + return { + html: anchorForRange(root, range), + quote: quote.slice(0, 4000), + prefix, + suffix, + textStart: startOffset, + textEnd: endOffset, + }; +} + +function proposalForElement(el: Element): AnchorProposal { + const preview = scopePreview(el) ?? scopeTitle(el); + const html = anchorForElement(root, el, preview); + const quote = (el.textContent ?? '').trim().slice(0, 4000); + return { + html, + quote, + prefix: '', + suffix: '', + textStart: html.textPosition?.start ?? 0, + textEnd: html.textPosition?.end ?? 0, + }; +} + +// --------------------------------------------------------------------------- +// Text selection +// --------------------------------------------------------------------------- + +function selectionIsLive(): boolean { + const selection = window.getSelection(); + return !!selection && !selection.isCollapsed && selection.rangeCount > 0; +} + +function onSelectionChange(): void { + const selection = window.getSelection(); + if (!selection || selection.isCollapsed || selection.rangeCount === 0) { + pendingRange = null; + hideSelectionPill(); + send({ type: 'selectionCleared', v: DOC_PROTOCOL_VERSION }); + return; + } + const range = selection.getRangeAt(0); + if (range.toString().trim().length === 0) return; + pendingRange = range.cloneRange(); + const rects = rectsOf(range); + const last = rects[rects.length - 1]; + showSelectionPill(last); + send({ + type: 'selection', + v: DOC_PROTOCOL_VERSION, + proposal: proposalForRange(range), + rects, + caret: last ?? { x: 0, y: 0, width: 0, height: 0 }, + }); +} + +function showSelectionPill(near: DocRect | undefined): void { + if (!near) return; + selectionPill.style.left = `${near.x + near.width}px`; + selectionPill.style.top = `${near.y + near.height + 8}px`; + selectionPill.classList.add('is-visible'); +} + +function hideSelectionPill(): void { + selectionPill.classList.remove('is-visible'); +} + +// --------------------------------------------------------------------------- +// Gutter pin + scope breadcrumb +// --------------------------------------------------------------------------- + +function onPointerMove(event: MouseEvent): void { + // An active text selection owns the interaction; the two gestures must never + // compete for the same drag. + if (selectionIsLive()) { + hideGutterPin(); + return; + } + const target = event.target; + if (!(target instanceof Element)) return; + const chain = candidateChain(target); + const block = defaultTarget(chain); + if (!block) { + hideGutterPin(); + return; + } + hoverChain = chain; + positionGutterPin(block); + publishScopeHover(chain); +} + +function positionGutterPin(block: Element): void { + const rect = block.getBoundingClientRect(); + gutterPin.style.top = `${rect.top + window.scrollY + rect.height / 2 - 12}px`; + gutterPin.classList.add('is-visible'); + const count = commentsOn(block); + gutterPin.dataset.count = count > 0 ? String(count) : ''; + gutterPin.classList.toggle('has-comments', count > 0); +} + +function hideGutterPin(): void { + gutterPin.classList.remove('is-visible'); + flyout.classList.remove('is-visible'); +} + +function publishScopeHover(chain: Element[]): void { + scopeElements.clear(); + const candidates: ScopeCandidate[] = chain.slice(0, 8).map((el) => { + const scopeId = `scope-${(scopeSeq += 1)}`; + scopeElements.set(scopeId, el); + return { + scopeId, + title: scopeTitle(el), + preview: scopePreview(el), + selector: anchorForElement(root, el, '').cssSelector, + commentCount: commentsOn(el), + rects: rectsOf(el), + }; + }); + send({ type: 'scopeHover', v: DOC_PROTOCOL_VERSION, chain: candidates }); + renderFlyout(candidates); +} + +function renderFlyout(candidates: ScopeCandidate[]): void { + flyout.textContent = ''; + for (const candidate of candidates) { + const item = document.createElement('button'); + item.type = 'button'; + item.className = 'attn-scope-item'; + const title = document.createElement('span'); + title.className = 'attn-scope-title'; + title.textContent = candidate.title; + item.appendChild(title); + if (candidate.preview) { + const preview = document.createElement('span'); + preview.className = 'attn-scope-preview'; + preview.textContent = candidate.preview; + item.appendChild(preview); + } + if (candidate.commentCount > 0) { + const badge = document.createElement('span'); + badge.className = 'attn-scope-count'; + badge.textContent = String(candidate.commentCount); + item.appendChild(badge); + } + item.addEventListener('mouseenter', () => outline(scopeElements.get(candidate.scopeId))); + item.addEventListener('click', (event) => { + event.preventDefault(); + pickScope(candidate.scopeId); + }); + flyout.appendChild(item); + } +} + +let outlineEl: HTMLElement | null = null; + +function outline(el: Element | undefined): void { + outlineEl?.remove(); + outlineEl = null; + if (!el) return; + const rect = el.getBoundingClientRect(); + const box = document.createElement('div'); + box.className = 'attn-outline'; + box.style.cssText = `top:${rect.top + window.scrollY}px;left:${rect.left + window.scrollX}px;width:${rect.width}px;height:${rect.height}px`; + layer.appendChild(box); + outlineEl = box; +} + +function pickScope(scopeId: string): void { + const el = scopeElements.get(scopeId); + if (!el) return; + hideGutterPin(); + outline(undefined); + send({ + type: 'scopePicked', + v: DOC_PROTOCOL_VERSION, + proposal: proposalForElement(el), + rects: rectsOf(el), + }); +} + +// --------------------------------------------------------------------------- +// Painting anchors +// --------------------------------------------------------------------------- + +/** + * Text highlights use the CSS Custom Highlight API, so no wrapper spans are + * injected — the document's own DOM is never mutated by a highlight, which + * keeps its scripts and our selectors from fighting over the tree. + */ +function repaintHighlights(): void { + const highlights = (CSS as unknown as { highlights?: Map }).highlights; + if (!highlights || typeof Highlight === 'undefined') return; + const base: Range[] = []; + const active: Range[] = []; + for (const anchor of anchors.values()) { + if (anchor.spec.html.target !== 'text_range' || !anchor.range) continue; + (anchor.spec.state === 'active' ? active : base).push(anchor.range); + } + highlights.set(HIGHLIGHT_BUCKET, new Highlight(...base)); + highlights.set(HIGHLIGHT_ACTIVE_BUCKET, new Highlight(...active)); +} + +/** + * Element anchors get an overlay whose fill is `pointer-events: none`, so the + * user can still select — and comment on — the text underneath. Only the small + * pin and count chip are interactive. + */ +function paintElementAnchor(anchor: LiveAnchor): void { + anchor.overlay?.remove(); + anchor.pin?.remove(); + anchor.overlay = null; + anchor.pin = null; + if (anchor.spec.html.target !== 'element' || !anchor.element) return; + + const rect = anchor.element.getBoundingClientRect(); + const top = rect.top + window.scrollY; + const left = rect.left + window.scrollX; + + const overlay = document.createElement('div'); + overlay.className = 'attn-overlay'; + overlay.dataset.state = anchor.spec.state; + overlay.style.cssText = `top:${top}px;left:${left}px;width:${rect.width}px;height:${rect.height}px`; + layer.appendChild(overlay); + anchor.overlay = overlay; + + // Persistent marker: a committed comment stays visible without hovering, so + // the document reads as annotated at a glance. + const pin = document.createElement('button'); + pin.type = 'button'; + pin.className = 'attn-pin'; + pin.dataset.state = anchor.spec.state; + pin.textContent = anchor.spec.label ?? '1'; + pin.style.cssText = `top:${top - 10}px;left:${left - 14}px`; + pin.addEventListener('click', (event) => { + event.stopPropagation(); + send({ + type: 'anchorActivated', + v: DOC_PROTOCOL_VERSION, + anchorId: anchor.spec.anchorId, + }); + }); + layer.appendChild(pin); + anchor.pin = pin; +} + +function resolveAndPaint(spec: RenderableAnchor): LiveAnchor { + const resolution = resolveAnchor(root, { + anchor: spec.html, + quote: spec.quote, + prefix: spec.prefix, + suffix: spec.suffix, + }); + const anchor: LiveAnchor = { + spec, + range: resolution.range, + element: resolution.element, + status: resolution.status, + confidence: resolution.confidence, + overlay: null, + pin: null, + }; + paintElementAnchor(anchor); + return anchor; +} + +function renderAnchors(specs: RenderableAnchor[]): void { + for (const anchor of anchors.values()) { + anchor.overlay?.remove(); + anchor.pin?.remove(); + } + anchors.clear(); + for (const spec of specs) { + anchors.set(spec.anchorId, resolveAndPaint(spec)); + } + repaintHighlights(); + publishResolutions(); +} + +function publishResolutions(): void { + const results: AnchorResolution[] = []; + for (const anchor of anchors.values()) { + results.push({ + anchorId: anchor.spec.anchorId, + status: anchor.status, + confidence: anchor.confidence, + rects: rectsOf(anchor.range ?? anchor.element), + }); + } + send({ type: 'anchorsResolved', v: DOC_PROTOCOL_VERSION, results }); +} + +function publishGeometry(): void { + const results: AnchorGeometry[] = []; + for (const anchor of anchors.values()) { + results.push({ + anchorId: anchor.spec.anchorId, + rects: rectsOf(anchor.range ?? anchor.element), + }); + } + send({ + type: 'geometry', + v: DOC_PROTOCOL_VERSION, + results, + scrollTop: window.scrollY, + }); +} + +function setAnchorState(anchorId: string, state: AnchorRenderState): void { + const anchor = anchors.get(anchorId); + if (!anchor) return; + anchor.spec = { ...anchor.spec, state }; + if (anchor.overlay) anchor.overlay.dataset.state = state; + if (anchor.pin) anchor.pin.dataset.state = state; + repaintHighlights(); +} + +function focusAnchor(anchorId: string, scrollIntoView: boolean): void { + const anchor = anchors.get(anchorId); + if (!anchor || !scrollIntoView) return; + const target = anchor.element ?? anchor.range?.startContainer.parentElement; + target?.scrollIntoView({ behavior: 'smooth', block: 'center' }); +} + +// --------------------------------------------------------------------------- +// Reflow +// --------------------------------------------------------------------------- + +let frame = 0; + +/** + * Rects are reported in the frame's own viewport coordinates and the shell + * cannot observe this frame's scroll cross-origin, so every reflow has to be + * pushed. Coalesced to one animation frame so a smooth scroll does not become + * a message storm. + */ +function scheduleReflow(): void { + if (frame) return; + frame = requestAnimationFrame(() => { + frame = 0; + for (const anchor of anchors.values()) paintElementAnchor(anchor); + repaintHighlights(); + publishGeometry(); + }); +} + +// --------------------------------------------------------------------------- +// Boot +// --------------------------------------------------------------------------- + +function handleShellMessage(message: ShellMessage): void { + switch (message.type) { + case 'renderAnchors': + renderAnchors(message.anchors); + break; + case 'setAnchorState': + setAnchorState(message.anchorId, message.state); + break; + case 'focusAnchor': + focusAnchor(message.anchorId, message.scrollIntoView); + break; + case 'pickScope': + pickScope(message.scopeId); + break; + case 'dismissSelection': + window.getSelection()?.removeAllRanges(); + pendingRange = null; + hideSelectionPill(); + break; + case 'theme': + root.dataset.attnTheme = message.mode; + break; + default: + break; + } +} + +function mountChrome(): void { + const style = document.createElement('style'); + style.textContent = RUNTIME_STYLES; + document.head.appendChild(style); + + layer = document.createElement('div'); + layer.className = 'attn-layer'; + document.body.appendChild(layer); + + gutterPin = document.createElement('button'); + gutterPin.type = 'button'; + gutterPin.className = 'attn-gutter-pin'; + gutterPin.setAttribute('aria-label', 'Comment on this block'); + gutterPin.addEventListener('mouseenter', () => flyout.classList.add('is-visible')); + gutterPin.addEventListener('click', (event) => { + event.preventDefault(); + const block = defaultTarget(hoverChain); + if (!block) return; + const entry = [...scopeElements.entries()].find(([, el]) => el === block); + if (entry) pickScope(entry[0]); + }); + layer.appendChild(gutterPin); + + flyout = document.createElement('div'); + flyout.className = 'attn-flyout'; + flyout.addEventListener('mouseleave', () => flyout.classList.remove('is-visible')); + gutterPin.appendChild(flyout); + + selectionPill = document.createElement('button'); + selectionPill.type = 'button'; + selectionPill.className = 'attn-pill'; + selectionPill.textContent = 'Comment'; + // mousedown would collapse the selection before the click lands. + selectionPill.addEventListener('mousedown', (event) => event.preventDefault()); + selectionPill.addEventListener('click', () => { + if (!pendingRange) return; + send({ + type: 'selection', + v: DOC_PROTOCOL_VERSION, + proposal: proposalForRange(pendingRange), + rects: rectsOf(pendingRange), + caret: rectsOf(pendingRange).slice(-1)[0] ?? { x: 0, y: 0, width: 0, height: 0 }, + }); + }); + layer.appendChild(selectionPill); +} + +function attachPort(candidate: MessagePort): void { + port = candidate; + port.onmessage = (event: MessageEvent) => { + // The port is private, but the document's own scripts share this context + // and could reach it, so shape is still checked before use. + const data = event.data as ShellMessage | undefined; + if (!data || typeof data !== 'object' || typeof data.type !== 'string') return; + if (data.v !== DOC_PROTOCOL_VERSION) return; + handleShellMessage(data); + }; + port.start(); + + send({ + type: 'ready', + v: DOC_PROTOCOL_VERSION, + textLength: documentText(root).length, + title: document.title.slice(0, 200), + }); +} + +function boot(): void { + root = document.body; + mountChrome(); + + document.addEventListener('selectionchange', onSelectionChange); + document.addEventListener('mousemove', onPointerMove, { passive: true }); + window.addEventListener('scroll', scheduleReflow, { passive: true }); + window.addEventListener('resize', scheduleReflow, { passive: true }); + new ResizeObserver(scheduleReflow).observe(document.body); + + window.addEventListener('message', (event: MessageEvent) => { + // Origin is always "null" for an opaque frame, so it carries no information. + // The shell is identified by being our parent; the port it hands over is + // what makes every later message trustworthy. + if (event.source !== window.parent) return; + const data = event.data as { type?: unknown } | undefined; + if (!data || data.type !== SHELL_INIT) return; + const [candidate] = event.ports; + if (candidate) attachPort(candidate); + }); + + window.parent.postMessage({ type: DOC_HELLO, v: DOC_PROTOCOL_VERSION }, '*'); +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', boot, { once: true }); +} else { + boot(); +} diff --git a/web/src/doc-runtime/selectors.ts b/web/src/doc-runtime/selectors.ts new file mode 100644 index 00000000..22ed5341 --- /dev/null +++ b/web/src/doc-runtime/selectors.ts @@ -0,0 +1,515 @@ +/** + * W3C Web Annotation selector generation and resolution, run inside the + * document frame. + * + * This is the only place HTML anchors are ever interpreted. Rust persists them + * as opaque blobs, which is what keeps a headless HTML parser out of the binary. + * + * Generation writes *every* layer at creation time (CSS selector + ranked + * fallbacks, text quote with prefix/suffix, text position, range) so that + * resolution has something to fall back to when the document changes. + * Resolution walks those layers from strongest to weakest and reports how it + * matched, so the rail can show confidence honestly. + * + * @see planning/collab/html-annotation.md §2 + * @see https://www.w3.org/TR/annotation-model/ + */ + +import type { + HtmlAnchor, + HtmlAnchorContext, + HtmlRangeSelector, +} from '../lib/types'; +import type { DocResolutionStatus } from '../lib/review/doc-protocol'; + +const encoder = new TextEncoder(); + +/** UTF-8 byte length, the unit all offsets are expressed in. */ +function byteLength(value: string): number { + return encoder.encode(value).length; +} + +// --------------------------------------------------------------------------- +// Text coordinates +// --------------------------------------------------------------------------- + +function textWalker(root: Element): TreeWalker { + return root.ownerDocument.createTreeWalker(root, NodeFilter.SHOW_TEXT); +} + +/** UTF-8 byte offset of `(node, offset)` within `root`'s rendered text. */ +export function textOffsetOf(root: Element, node: Node, offset: number): number { + if (node.nodeType !== Node.TEXT_NODE) { + // An element boundary: count everything before it, which for a container + // means all text of its preceding siblings' subtrees. + const range = root.ownerDocument.createRange(); + range.selectNodeContents(root); + range.setEnd(node, offset); + return byteLength(range.toString()); + } + let total = 0; + const walker = textWalker(root); + let current = walker.nextNode(); + while (current) { + if (current === node) { + return total + byteLength((current.nodeValue ?? '').slice(0, offset)); + } + total += byteLength(current.nodeValue ?? ''); + current = walker.nextNode(); + } + return total; +} + +/** Inverse of {@link textOffsetOf}: locate a byte offset in the DOM. */ +function positionAt(root: Element, target: number): { node: Text; offset: number } | null { + let seen = 0; + const walker = textWalker(root); + let current = walker.nextNode() as Text | null; + let last: { node: Text; offset: number } | null = null; + while (current) { + const value = current.nodeValue ?? ''; + const bytes = byteLength(value); + if (seen + bytes >= target) { + // Walk code points until the byte budget is met — string indices and + // byte offsets diverge for any non-ASCII text. + let consumed = 0; + for (let i = 0; i < value.length; i += 1) { + if (seen + consumed >= target) return { node: current, offset: i }; + consumed += byteLength(value[i]); + } + return { node: current, offset: value.length }; + } + seen += bytes; + last = { node: current, offset: value.length }; + current = walker.nextNode() as Text | null; + } + return last; +} + +/** Build a DOM range from UTF-8 byte offsets into `root`'s rendered text. */ +export function rangeFromTextOffsets(root: Element, start: number, end: number): Range | null { + const from = positionAt(root, start); + const to = positionAt(root, end); + if (!from || !to) return null; + const range = root.ownerDocument.createRange(); + try { + range.setStart(from.node, from.offset); + range.setEnd(to.node, to.offset); + } catch { + return null; + } + return range; +} + +/** The document's canonical rendered text. */ +export function documentText(root: Element): string { + return root.textContent ?? ''; +} + +// --------------------------------------------------------------------------- +// Selector generation +// --------------------------------------------------------------------------- + +const CELL_TAGS = new Set(['TD', 'TH']); + +const isCell = (el: Element): boolean => CELL_TAGS.has(el.tagName); + +function rowIndex(tr: Element): number { + const section = tr.parentElement; + if (!section) return 1; + return Array.from(section.children).filter((c) => c.tagName === 'TR').indexOf(tr) + 1; +} + +function nthOfType(el: Element): string { + const parent = el.parentElement; + if (!parent) return ''; + const sames = Array.from(parent.children).filter((c) => c.tagName === el.tagName); + if (sames.length <= 1) return ''; + return `:nth-of-type(${sames.indexOf(el) + 1})`; +} + +/** + * Is this id safe to anchor on? Framework-generated ids (`:r1:`, `react-aria…`, + * long hex blobs) change every render, so an anchor keyed on one is worse than + * a structural path — it looks precise and silently breaks. + */ +function isStableId(id: string): boolean { + if (id.length === 0 || id.length > 64) return false; + if (!/^[A-Za-z][\w-]*$/.test(id)) return false; + if (/^(react|radix|mui|headless|aria)[-_]/i.test(id)) return false; + return !/[0-9a-f]{8,}/i.test(id); +} + +function stableClass(el: Element): string { + if (typeof el.className !== 'string') return ''; + const classes = el.className.trim().split(/\s+/).filter(Boolean); + for (const cls of classes) { + // Skip hashed/utility-looking classes for the same reason as ids. + if (/^[\w-]+$/.test(cls) && cls.length <= 40 && !/[0-9a-f]{6,}/i.test(cls)) { + return `.${CSS.escape(cls)}`; + } + } + return ''; +} + +function rowSelector(tr: Element): string { + const section = tr.parentElement; + const table = tr.closest('table'); + const parts = [table ? cssSelectorFor(table) : 'table']; + if (section && section !== table) parts.push(section.tagName.toLowerCase()); + parts.push(`tr:nth-of-type(${rowIndex(tr)})`); + return parts.join(' > '); +} + +function cellSelector(td: Element): string { + const tr = td.closest('tr'); + if (!tr) return td.tagName.toLowerCase(); + const sames = Array.from(tr.children).filter((c) => c.tagName === td.tagName); + return `${rowSelector(tr)} > ${td.tagName.toLowerCase()}:nth-of-type(${sames.indexOf(td) + 1})`; +} + +/** + * Primary W3C `CssSelector` for an element. Table structures get a semantic + * path (`table > tbody > tr:nth-of-type(3)`) because that survives content + * edits that reorder nothing, which is the common case in a reviewed document. + */ +export function cssSelectorFor(el: Element): string { + if (el.id && isStableId(el.id)) return `#${CSS.escape(el.id)}`; + if (el.tagName === 'TR') return rowSelector(el); + if (isCell(el)) return cellSelector(el); + return `${el.tagName.toLowerCase()}${stableClass(el)}${nthOfType(el)}`; +} + +/** + * Ranked alternates, most specific first. Each is a genuinely different + * addressing strategy rather than a truncation of the primary, so a change that + * breaks one has a real chance of leaving another intact. + */ +export function fallbackSelectorsFor(el: Element): string[] { + const out: string[] = []; + const push = (selector: string) => { + if (selector && !out.includes(selector) && out.length < 8) out.push(selector); + }; + + // Absolute structural path from the body — survives class/id churn. + const path: string[] = []; + let cursor: Element | null = el; + while (cursor && cursor.tagName !== 'BODY' && path.length < 12) { + path.unshift(`${cursor.tagName.toLowerCase()}${nthOfType(cursor)}`); + cursor = cursor.parentElement; + } + if (path.length > 0) push(path.join(' > ')); + + // Nearest stable ancestor id + a descendant path — survives sibling churn + // above that ancestor. + let ancestor: Element | null = el.parentElement; + const tail: string[] = [`${el.tagName.toLowerCase()}${nthOfType(el)}`]; + while (ancestor && ancestor.tagName !== 'BODY' && tail.length < 6) { + if (ancestor.id && isStableId(ancestor.id)) { + push(`#${CSS.escape(ancestor.id)} ${tail.join(' > ')}`); + break; + } + tail.unshift(`${ancestor.tagName.toLowerCase()}${nthOfType(ancestor)}`); + ancestor = ancestor.parentElement; + } + + const cls = stableClass(el); + if (cls) push(`${el.tagName.toLowerCase()}${cls}`); + + return out.filter((selector) => selector !== cssSelectorFor(el)); +} + +// --------------------------------------------------------------------------- +// Agent context +// --------------------------------------------------------------------------- + +const ROLE_BY_TAG: Record = { + TR: 'row', + TD: 'cell', + TH: 'columnheader', + TABLE: 'table', + LI: 'listitem', + UL: 'list', + OL: 'list', + P: 'paragraph', + BLOCKQUOTE: 'blockquote', + FIGURE: 'figure', + IMG: 'img', + H1: 'heading', + H2: 'heading', + H3: 'heading', + H4: 'heading', +}; + +/** + * The agent-legible context block. This is what makes a comment actionable to a + * coding agent that never saw the document. + */ +export function contextFor(el: Element, scopePreview: string): HtmlAnchorContext { + const domPath: string[] = []; + let cursor: Element | null = el; + while (cursor && cursor.tagName !== 'BODY' && domPath.length < 8) { + domPath.unshift(cursor.tagName.toLowerCase()); + cursor = cursor.parentElement; + } + const role = el.getAttribute('role') ?? ROLE_BY_TAG[el.tagName]; + const context: HtmlAnchorContext = { + tagName: el.tagName.toLowerCase(), + scopePreview: scopePreview.slice(0, 200), + domPath, + }; + if (role) context.role = role; + return context; +} + +// --------------------------------------------------------------------------- +// Anchor construction +// --------------------------------------------------------------------------- + +function rangeSelectorFor(root: Element, range: Range): HtmlRangeSelector | null { + const startEl = + range.startContainer.nodeType === Node.ELEMENT_NODE + ? (range.startContainer as Element) + : range.startContainer.parentElement; + const endEl = + range.endContainer.nodeType === Node.ELEMENT_NODE + ? (range.endContainer as Element) + : range.endContainer.parentElement; + if (!startEl || !endEl || startEl === endEl) return null; + return { + startSelector: cssSelectorFor(startEl), + startOffset: textOffsetOf(startEl, range.startContainer, range.startOffset), + endSelector: cssSelectorFor(endEl), + endOffset: textOffsetOf(endEl, range.endContainer, range.endOffset), + }; +} + +/** Build a full selector set for a text selection. */ +export function anchorForRange(root: Element, range: Range): HtmlAnchor { + const common = + range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE + ? (range.commonAncestorContainer as Element) + : (range.commonAncestorContainer.parentElement ?? root); + const start = textOffsetOf(root, range.startContainer, range.startOffset); + const end = textOffsetOf(root, range.endContainer, range.endOffset); + const anchor: HtmlAnchor = { + v: 1, + target: 'text_range', + cssSelector: cssSelectorFor(common), + fallbackSelectors: fallbackSelectorsFor(common), + textPosition: { start, end }, + context: contextFor(common, range.toString().slice(0, 120)), + }; + const rangeSelector = rangeSelectorFor(root, range); + if (rangeSelector) anchor.range = rangeSelector; + return anchor; +} + +/** Build a full selector set for a whole element. */ +export function anchorForElement(root: Element, el: Element, preview: string): HtmlAnchor { + const range = root.ownerDocument.createRange(); + range.selectNodeContents(el); + return { + v: 1, + target: 'element', + cssSelector: cssSelectorFor(el), + fallbackSelectors: fallbackSelectorsFor(el), + textPosition: { + start: textOffsetOf(root, range.startContainer, range.startOffset), + end: textOffsetOf(root, range.endContainer, range.endOffset), + }, + context: contextFor(el, preview), + }; +} + +// --------------------------------------------------------------------------- +// Resolution +// --------------------------------------------------------------------------- + +export interface ResolutionResult { + range: Range | null; + element: Element | null; + status: DocResolutionStatus; + confidence: number; +} + +const STALE: ResolutionResult = { + range: null, + element: null, + status: 'stale', + confidence: 0, +}; + +/** Fold whitespace and smart punctuation so cosmetic edits still match. */ +export function normalizeText(value: string): string { + return value + .replace(/[‘’]/g, "'") + .replace(/[“”]/g, '"') + .replace(/[–—]/g, '-') + .replace(/\s+/g, ' ') + .trim(); +} + +function querySafe(root: Element, selector: string): Element | null { + try { + return root.querySelector(selector); + } catch { + // A selector authored against a different document can be syntactically + // invalid here; that is a miss, not a crash. + return null; + } +} + +function allIndexesOf(haystack: string, needle: string): number[] { + const out: number[] = []; + if (needle.length === 0) return out; + let from = 0; + for (;;) { + const at = haystack.indexOf(needle, from); + if (at === -1) return out; + out.push(at); + from = at + 1; + if (out.length > 64) return out; + } +} + +/** Shared-prefix length, used to score which occurrence the context favours. */ +function affinity(a: string, b: string): number { + const len = Math.min(a.length, b.length); + let score = 0; + while (score < len && a[score] === b[score]) score += 1; + return score; +} + +/** + * Pick among several occurrences of the same quote using the recorded + * prefix/suffix. An undisambiguated tie resolves to `ambiguous` rather than + * silently picking one — a misplaced highlight the user cannot see is worse + * than an honest "this moved". + */ +function disambiguate( + text: string, + matches: number[], + quoteLength: number, + prefix: string, + suffix: string, +): { index: number; ambiguous: boolean } { + const scored = matches.map((at) => { + const before = text.slice(Math.max(0, at - prefix.length), at); + const after = text.slice(at + quoteLength, at + quoteLength + suffix.length); + const score = + affinity([...before].reverse().join(''), [...prefix].reverse().join('')) + + affinity(after, suffix); + return { at, score }; + }); + scored.sort((a, b) => b.score - a.score); + const ambiguous = scored.length > 1 && scored[0].score === scored[1].score; + return { index: scored[0].at, ambiguous }; +} + +export interface ResolveInput { + anchor: HtmlAnchor; + quote?: string; + prefix?: string; + suffix?: string; +} + +/** + * Resolve an anchor against the current DOM, strongest layer first. + * + * Element anchors resolve by selector. Text anchors resolve by quote — exact, + * then normalized, then position-verified — because text survives markup + * rewrites that invalidate every selector. + */ +export function resolveAnchor(root: Element, input: ResolveInput): ResolutionResult { + const { anchor, quote, prefix = '', suffix = '' } = input; + + if (anchor.target === 'element') { + const el = + querySafe(root, anchor.cssSelector) ?? + (anchor.fallbackSelectors ?? []).reduce( + (found, selector) => found ?? querySafe(root, selector), + null, + ); + if (!el) return STALE; + const range = root.ownerDocument.createRange(); + range.selectNodeContents(el); + const exact = querySafe(root, anchor.cssSelector) === el; + return { + range, + element: el, + status: exact ? 'exact' : 'remapped', + confidence: exact ? 1 : 0.7, + }; + } + + const text = documentText(root); + + // 1. The recorded position still holds the recorded text — nothing moved. + if (quote && anchor.textPosition) { + const { start, end } = anchor.textPosition; + const candidate = rangeFromTextOffsets(root, start, end); + if (candidate && candidate.toString() === quote) { + return { range: candidate, element: null, status: 'exact', confidence: 1 }; + } + } + + // 2. Exact quote search, disambiguated by context. + if (quote && quote.length > 0) { + const matches = allIndexesOf(text, quote); + if (matches.length > 0) { + const { index, ambiguous } = disambiguate(text, matches, quote.length, prefix, suffix); + const byteStart = byteLength(text.slice(0, index)); + const range = rangeFromTextOffsets(root, byteStart, byteStart + byteLength(quote)); + if (range) { + return { + range, + element: null, + status: ambiguous ? 'ambiguous' : 'remapped', + confidence: ambiguous ? 0.4 : matches.length === 1 ? 0.9 : 0.75, + }; + } + } + } + + // 3. Normalized quote — tolerates whitespace and smart-punctuation edits. + if (quote) { + const normalizedQuote = normalizeText(quote); + const normalizedText = normalizeText(text); + if (normalizedQuote.length > 0) { + const matches = allIndexesOf(normalizedText, normalizedQuote); + if (matches.length === 1) { + // Map back by searching the raw text for the first token run; exactness + // is already lost at this tier, so an approximate landing is honest. + const probe = normalizedQuote.slice(0, 24); + const at = text.indexOf(probe); + if (at !== -1) { + const byteStart = byteLength(text.slice(0, at)); + const range = rangeFromTextOffsets( + root, + byteStart, + byteStart + byteLength(normalizedQuote), + ); + if (range) { + return { range, element: null, status: 'remapped', confidence: 0.6 }; + } + } + } + } + } + + // 4. Selector fallback — the text is gone but its container may remain. + const container = + querySafe(root, anchor.cssSelector) ?? + (anchor.fallbackSelectors ?? []).reduce( + (found, selector) => found ?? querySafe(root, selector), + null, + ); + if (container) { + const range = root.ownerDocument.createRange(); + range.selectNodeContents(container); + return { range, element: container, status: 'remapped', confidence: 0.35 }; + } + + return STALE; +} diff --git a/web/src/doc-runtime/styles.ts b/web/src/doc-runtime/styles.ts new file mode 100644 index 00000000..c25e0b53 --- /dev/null +++ b/web/src/doc-runtime/styles.ts @@ -0,0 +1,229 @@ +/** + * Styles for the annotation chrome injected into the document frame. + * + * Shipped as a string rather than a stylesheet import because the runtime is + * bundled to a single self-contained IIFE that gets injected into arbitrary + * HTML — there is no build step on the far side and no second file to fetch. + * + * Everything is namespaced under `.attn-` and scoped to the injected layer, so + * the document's own CSS and ours cannot collide. Tokens are inlined (not + * inherited from the shell) because the frame is origin-isolated and shares no + * stylesheet with it; the shell pushes theme changes over the `theme` message. + */ + +export const RUNTIME_STYLES = ` +.attn-layer { + position: absolute; + inset: 0; + /* The layer spans the document but must never intercept the cursor — only + its individually re-enabled children (pins, chips, the pill) do. */ + pointer-events: none; + z-index: 2147483000; + --attn-comment-accent: oklch(0.62 0.13 82); + --attn-element-accent: oklch(0.55 0.11 235); + --attn-surface: oklch(0.95 0.010 76); + --attn-ink: oklch(0.14 0.008 55); + --attn-border: oklch(0.14 0.008 55 / 22%); + --attn-shadow: 0 8px 24px oklch(0.20 0.02 55 / 18%); + font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; +} + +[data-attn-theme="ink"] .attn-layer { + --attn-surface: oklch(0.24 0.012 60); + --attn-ink: oklch(0.93 0.008 78); + --attn-border: oklch(0.93 0.008 78 / 22%); + --attn-shadow: 0 8px 24px oklch(0 0 0 / 45%); +} + +/* Text highlights — CSS Custom Highlight API, so no wrapper spans are ever + injected into the document's DOM. */ +::highlight(attn-text) { + background-color: oklch(0.82 0.13 85 / 30%); +} +::highlight(attn-text-active) { + background-color: oklch(0.80 0.16 82 / 52%); +} + +/* Element overlay. The fill is inert so text underneath a commented element + stays selectable — you can always comment on something inside something + already commented on. */ +.attn-overlay { + position: absolute; + pointer-events: none; + border-radius: 4px; + border: 1.5px solid color-mix(in oklch, var(--attn-element-accent) 60%, transparent); + background: color-mix(in oklch, var(--attn-element-accent) 8%, transparent); + transition: background 120ms ease, border-color 120ms ease; +} +.attn-overlay[data-state="active"] { + border-color: var(--attn-element-accent); + background: color-mix(in oklch, var(--attn-element-accent) 16%, transparent); +} +.attn-overlay[data-state="resolved"] { + border-style: dashed; + opacity: 0.55; +} + +/* Persistent marker for a committed comment: visible without hovering, so the + document reads as annotated at a glance. */ +.attn-pin { + position: absolute; + pointer-events: auto; + display: grid; + place-items: center; + min-width: 20px; + height: 20px; + padding: 0 5px; + border: 1px solid var(--attn-border); + border-radius: 999px; + background: var(--attn-element-accent); + color: oklch(0.98 0.005 78); + font-size: 11px; + font-weight: 600; + line-height: 1; + cursor: pointer; + box-shadow: var(--attn-shadow); + transition: transform 120ms ease; +} +.attn-pin:hover, +.attn-pin[data-state="active"] { + transform: scale(1.12); +} +.attn-pin[data-state="resolved"] { + background: var(--attn-surface); + color: var(--attn-ink); +} + +/* Left-margin pin revealed on block hover. */ +.attn-gutter-pin { + position: absolute; + left: 8px; + pointer-events: auto; + width: 24px; + height: 24px; + padding: 0; + border: 1px solid var(--attn-border); + border-radius: 999px; + background: var(--attn-surface); + color: var(--attn-ink); + cursor: pointer; + opacity: 0; + transition: opacity 120ms ease; + box-shadow: var(--attn-shadow); +} +.attn-gutter-pin.is-visible { opacity: 1; } +.attn-gutter-pin::before { + content: ""; + position: absolute; + inset: 6px; + border: 1.5px solid currentColor; + border-radius: 3px 3px 3px 0; + opacity: 0.7; +} +.attn-gutter-pin.has-comments { + background: var(--attn-comment-accent); +} +.attn-gutter-pin.has-comments::after { + content: attr(data-count); + position: absolute; + top: -4px; + right: -4px; + min-width: 14px; + height: 14px; + border-radius: 999px; + background: var(--attn-element-accent); + color: oklch(0.98 0.005 78); + font-size: 9px; + font-weight: 700; + line-height: 14px; +} + +/* Scope breadcrumb: drill into a cell or out to the whole table. */ +.attn-flyout { + position: absolute; + top: 0; + left: 30px; + display: none; + flex-direction: column; + min-width: 220px; + padding: 4px; + border: 1px solid var(--attn-border); + border-radius: 8px; + background: var(--attn-surface); + box-shadow: var(--attn-shadow); +} +.attn-flyout.is-visible { display: flex; } + +.attn-scope-item { + display: flex; + align-items: baseline; + gap: 8px; + width: 100%; + padding: 6px 8px; + border: 0; + border-radius: 5px; + background: transparent; + color: var(--attn-ink); + text-align: left; + cursor: pointer; + font-size: 12px; +} +.attn-scope-item:hover { background: color-mix(in oklch, var(--attn-ink) 8%, transparent); } +.attn-scope-title { font-weight: 600; white-space: nowrap; } +.attn-scope-preview { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + opacity: 0.65; +} +.attn-scope-count { + padding: 1px 6px; + border-radius: 999px; + background: var(--attn-element-accent); + color: oklch(0.98 0.005 78); + font-size: 10px; + font-weight: 700; +} + +/* Hover preview of exactly what a scope would anchor to. */ +.attn-outline { + position: absolute; + pointer-events: none; + border: 1.5px dashed var(--attn-element-accent); + border-radius: 4px; + background: color-mix(in oklch, var(--attn-element-accent) 6%, transparent); +} + +/* Floating "Comment" affordance raised by a text selection. */ +.attn-pill { + position: absolute; + pointer-events: auto; + display: none; + align-items: center; + gap: 6px; + padding: 5px 12px; + border: 1px solid var(--attn-border); + border-radius: 999px; + background: var(--attn-surface); + color: var(--attn-ink); + font-size: 12px; + font-weight: 600; + cursor: pointer; + box-shadow: var(--attn-shadow); +} +.attn-pill.is-visible { display: inline-flex; } +.attn-pill::before { + content: ""; + width: 8px; + height: 8px; + border-radius: 2px; + background: var(--attn-comment-accent); +} + +@media (prefers-reduced-motion: reduce) { + .attn-overlay, + .attn-pin, + .attn-gutter-pin { transition: none; } +} +`; diff --git a/web/src/lib/HtmlViewer.svelte b/web/src/lib/HtmlViewer.svelte index 3fccdf84..593ffcc0 100644 --- a/web/src/lib/HtmlViewer.svelte +++ b/web/src/lib/HtmlViewer.svelte @@ -2,6 +2,11 @@ import { onMount } from 'svelte'; import { markdownSourceUrl } from './markdown-layer'; import { htmlViewerSandbox } from './html-viewer-sandbox'; + import { + HtmlAnnotationBridge, + injectDocRuntime, + } from './review/html-annotation-bridge'; + import type { AnnotationBridgeEvents } from './review/html-annotation-bridge'; interface Props { /** @@ -25,11 +30,32 @@ mtime?: number; /** Native/local pages retain script support; hosted snapshots disable it. */ allowScripts?: boolean; + /** + * Turn on commenting. Requires `content` — annotation injects a runtime + * into the document source, which is only possible when the shell holds + * the bytes. That is always true for a shared document, and annotation is + * only meaningful for one. + */ + annotate?: boolean; + /** Wired up once the frame exists, so a parent can drive the rail. */ + annotationEvents?: AnnotationBridgeEvents; + /** Handed the live bridge so the parent can push anchors and focus. */ + onBridge?: (bridge: HtmlAnnotationBridge | null) => void; } - let { path, content, mtime, allowScripts = true }: Props = $props(); + let { + path, + content, + mtime, + allowScripts = true, + annotate = false, + annotationEvents, + onBridge, + }: Props = $props(); let loading = $state(true); + let frameEl = $state(null); + let bridge: HtmlAnnotationBridge | null = null; // The iframe is a cross-origin, opaque-origin sandbox, so we can neither // style its internal scrollbar nor scroll it from attn's ScrollArea (its @@ -56,8 +82,21 @@ // "Open in browser" button) is shared app chrome rendered by App.svelte. // `content` (reviewer/srcdoc) wins when provided; otherwise load the local // file via the attn:// protocol (owner/path mode). + // Annotation implies content mode: the runtime has to be spliced into the + // document source, which the shell can only do to bytes it holds. + let annotating = $derived(annotate && content !== undefined); let isContentMode = $derived(content !== undefined); - let sandbox = $derived(htmlViewerSandbox(allowScripts)); + // The injected runtime needs `allow-scripts`, so annotating a snapshot turns + // scripts on even where the hosted reviewer would otherwise disable them. + // The frame stays on an opaque origin either way — no allow-same-origin — so + // this grants the document no reach into the app, the user's files, or + // storage. The document's own scripts are assumed hostile regardless, which + // is why the trust boundary sits in the shell and not in the frame. + // @see planning/collab/html-annotation.md §3, §4 + let sandbox = $derived(htmlViewerSandbox(allowScripts || annotating)); + let renderedContent = $derived( + annotating && content !== undefined ? injectDocRuntime(content) : content, + ); let src = $derived( !isContentMode && path !== undefined ? mtime !== undefined @@ -76,6 +115,35 @@ void content; loading = true; }); + + // The bridge is torn down and rebuilt whenever the frame or the document + // changes: a reload destroys the frame's runtime, so the old port is dead and + // the handshake has to run again. + $effect(() => { + const frame = frameEl; + const shouldAnnotate = annotating; + void renderedContent; + + if (!frame || !shouldAnnotate) { + bridge?.dispose(); + bridge = null; + onBridge?.(null); + return; + } + + const next = new HtmlAnnotationBridge(frame, annotationEvents ?? {}); + next.connect(); + bridge = next; + onBridge?.(next); + + return () => { + next.dispose(); + if (bridge === next) { + bridge = null; + onBridge?.(null); + } + }; + });
@@ -98,7 +166,8 @@ empty sandbox token list. Native callers retain the historical allow-scripts opaque-origin behavior by default. --> diff --git a/web/src/lib/ShareDialog.svelte b/web/src/lib/ShareDialog.svelte index b8d1ce24..72b72a6a 100644 --- a/web/src/lib/ShareDialog.svelte +++ b/web/src/lib/ShareDialog.svelte @@ -567,7 +567,7 @@ /> {relativePath(file.path)} - {file.path === filePath ? 'Current · ' : ''}{file.fileType === 'html' ? 'Read-only' : 'Markdown'} + {file.path === filePath ? 'Current · ' : ''}{file.fileType === 'html' ? 'HTML · comments available after sharing' : 'Markdown'} {/each} diff --git a/web/src/lib/review/browser-owner-authority.test.ts b/web/src/lib/review/browser-owner-authority.test.ts index 8bcb69bc..b43fcb47 100644 --- a/web/src/lib/review/browser-owner-authority.test.ts +++ b/web/src/lib/review/browser-owner-authority.test.ts @@ -9,6 +9,7 @@ import type { AssembledBrowserEvent } from './browser-envelope'; import type { BrowserCollabCheckpoint } from './browser-collab-checkpoint'; import { BrowserOwnerAuthorityService, + type BrowserOwnerAuthorityFile, type BrowserPublishedEpochTerminalPort, type BrowserOwnerAuthorityLeaseManager, type BrowserOwnerAuthorityRollover, @@ -281,7 +282,7 @@ function fixture(overrides: { onRequired: NonNullable; }; attachedLease?: LeaseHandle; - files?: readonly typeof BINDING[]; + files?: readonly BrowserOwnerAuthorityFile[]; revisionBodies?: ReadonlyMap; now?: () => number; onState?: (state: BrowserOwnerAuthorityState, session: FakeSession | null) => void; @@ -429,6 +430,27 @@ defineCase('corrupt checkpoint fails before owner transport construction', async assert(!f.events.includes('session:start'), 'transport started before checkpoint reconstruction'); }); +defineCase('an HTML authority binding starts durable review without a Markdown collab seed', async () => { + const html = new TextEncoder().encode('

Report

'); + const binding: BrowserOwnerAuthorityFile = { + fileId: 'file-owner-authority-html' as FileId, + path: 'report.html', + revisionId: 'revision-owner-authority-html', + contentHash: contentHash(html), + epoch: 'snapshot-owner-authority-html', + docType: 'html', + }; + const f = fixture({ + files: [binding], + revisionBodies: new Map([[binding.path, html]]), + }); + assertEqual(await f.service.start(), true, 'HTML review authority starts'); + assert(!f.events.includes('checkpoint:load'), 'HTML does not create a ProseMirror checkpoint'); + await f.service.createComment({} as Anchor, 'Pin this chart'); + assert(f.session().durableReviewCalls.includes('create'), 'HTML keeps durable comment authoring'); + await f.service.close(); +}); + defineCase('startup rejects a lease that expires across preload await', async () => { const f = fixture({ now: () => 2_000 }); assertEqual(await f.service.start(), false, 'expired startup rejected'); diff --git a/web/src/lib/review/browser-owner-authority.ts b/web/src/lib/review/browser-owner-authority.ts index 22f60d6b..ddd01a42 100644 --- a/web/src/lib/review/browser-owner-authority.ts +++ b/web/src/lib/review/browser-owner-authority.ts @@ -53,6 +53,8 @@ export interface BrowserOwnerAuthorityFile { revisionId: string; contentHash: string; epoch: string; + /** HTML is a review-only document: it has durable anchors but no PM collab. */ + docType?: 'markdown' | 'html'; } export interface BrowserOwnerAuthorityStorage { @@ -246,6 +248,7 @@ export class BrowserOwnerAuthorityService { private readonly seeds = new Map(); private readonly bindings = new Map(); private readonly authorityFileIds: readonly FileId[]; + private readonly docTypeByFileId = new Map(); private readonly inFlightAuthorityOperations = new Set(); private transitionCollabBarrier: TransitionCollabBarrier | null = null; private lease: LeaseHandle | null = null; @@ -280,6 +283,7 @@ export class BrowserOwnerAuthorityService { throw new Error('authority files must have complete unique published bindings'); } fileIds.add(file.fileId); + this.docTypeByFileId.set(file.fileId, file.docType ?? 'markdown'); } this.authorityFileIds = options.files.map((file) => file.fileId); this.options = options; @@ -675,7 +679,7 @@ export class BrowserOwnerAuthorityService { throw new StorageConflictError('authority lease expired while loading binding set'); } bindings.set(file.fileId, { ...file }); - seeds.set(file.fileId, seed); + if (seed) seeds.set(file.fileId, seed); } if (bindings.size !== this.authorityFileIds.length) { throw new Error('promoted manifest does not cover the complete authority file set'); @@ -686,7 +690,7 @@ export class BrowserOwnerAuthorityService { private async loadVerifiedBinding( file: BrowserOwnerAuthorityFile, manifest: PublishedManifestPointer, - ): Promise { + ): Promise { const promoted = manifest.entries.find((entry) => entry.fileId === file.fileId && entry.path === file.path); if ( !promoted || @@ -705,11 +709,14 @@ export class BrowserOwnerAuthorityService { bytes.fill(0); throw new StorageConflictError('authority lease expired while loading revision'); } - let doc: PmNode; + let doc: PmNode | null = null; try { if (contentHash(bytes) !== file.contentHash) { throw new Error('authority revision bytes do not match the published content hash'); } + // HTML participates in the same fenced publication and durable review + // session, but it deliberately has no ProseMirror collaboration seed. + if (file.docType === 'html') return null; const markdown = new TextDecoder('utf-8', { fatal: true }).decode(bytes); // Imported at the call site so the desk route does not pull the parser // (attn-n01r.41); this method is already async. @@ -718,6 +725,7 @@ export class BrowserOwnerAuthorityService { } finally { bytes.fill(0); } + if (!doc) throw new Error('Markdown authority binding did not produce a document'); const stored = await this.options.storage.getCollabCheckpoint( this.options.workspaceId, this.options.roomId, @@ -759,6 +767,7 @@ export class BrowserOwnerAuthorityService { revisionId: promoted.revisionId, contentHash: promoted.contentHash, epoch: promoted.snapshotId, + docType: this.docTypeByFileId.get(fileId) ?? 'markdown', }; }); } diff --git a/web/src/lib/review/browser-owner-workspace-runtime.test.ts b/web/src/lib/review/browser-owner-workspace-runtime.test.ts index 54dfc647..647c7415 100644 --- a/web/src/lib/review/browser-owner-workspace-runtime.test.ts +++ b/web/src/lib/review/browser-owner-workspace-runtime.test.ts @@ -207,12 +207,12 @@ function deferred(): { promise: Promise; resolve(): void } { return { promise, resolve }; } -function shareRequest(): BrowserWorkspaceShareRequest { +function shareRequest(paths = ['notes.md']): BrowserWorkspaceShareRequest { return { relayUrl: 'https://relay.example', browserReviewBase: 'https://attn.sh/review', scopeKind: 'file', - paths: ['notes.md'], + paths, }; } @@ -232,6 +232,19 @@ async function seedLocal(storage: BrowserStorage, workspaceId: string, text = 'h }); } +async function seedHtmlLocal(storage: BrowserStorage, workspaceId: string) { + return storage.workspaces.createWorkspace({ + workspaceId, + name: workspaceId, + storagePersisted: true, + entry: { + path: 'report.html', + kind: 'html', + body: new TextEncoder().encode('

Quarterly report

'), + }, + }); +} + async function seedPublished(storage: BrowserStorage, now: number) { const workspaceId = `workspace-${databaseCounter}`; const created = await seedLocal(storage, workspaceId); @@ -707,6 +720,40 @@ defineCase('ensureShare activates authority on the runtime-owned lease without r storage.close(); }); +defineCase('an HTML-only share starts durable review but never exposes Markdown collab', async () => { + const now = 1_720_000_000_000; + const storage = await openStorage(() => now); + const workspaceId = 'share-html-only-runtime'; + await seedHtmlLocal(storage, workspaceId); + const events: string[] = []; + const authorityFiles: BrowserOwnerAuthorityFile[][] = []; + const runtime = new BrowserOwnerWorkspaceRuntime(runtimeOptions(storage, workspaceId, { + now: () => now, + authorityFactory: (options) => { + authorityFiles.push([...options.files]); + return new FakeAuthority(options, storage, events); + }, + sharing: { + now: () => now, + randomBytes: deterministicRandom(), + createRoom: async (options) => bootstrapFromOptions(options), + publish: snapshotPublisher, + indexBuilder: testIndexBuilder, + shareRelayFactory: memoryShareRelayFactory(), + outboxFactory: ({ storage: outboxStorage, credentials }) => + new AckingShareOutbox(outboxStorage, credentials.roomId, events), + }, + })); + await runtime.start(); + const view = await runtime.ensureShare(shareRequest(['report.html'])); + assert(view.invite, 'HTML publication mints a review invite'); + equal(authorityFiles[0]?.[0]?.docType, 'html', 'authority recognizes the review-only document'); + equal(runtime.getState().bindings[0]?.docType, 'html', 'runtime retains the HTML binding'); + equal(await runtime.getCollabSeed('report.html'), null, 'HTML never opts into Markdown co-editing'); + await runtime.close(); + storage.close(); +}); + defineCase('ensureShare resumes persisted pending ciphertext before activating authority', async () => { const now = 1_720_000_000_000; const storage = await openStorage(() => now); diff --git a/web/src/lib/review/browser-owner-workspace-runtime.ts b/web/src/lib/review/browser-owner-workspace-runtime.ts index fc3c3f37..98c074bf 100644 --- a/web/src/lib/review/browser-owner-workspace-runtime.ts +++ b/web/src/lib/review/browser-owner-workspace-runtime.ts @@ -44,6 +44,7 @@ import { } from './browser-snapshot-publisher'; import type { BrowserStorage } from './browser-storage'; import { BrowserStorageError, StorageConflictError } from './browser-storage-errors'; +import type { WorkspaceEntryRecord } from './browser-workspace-schema'; import type { InviteCapability, ShareRecordView } from './browser-workspace-share'; import type { CommittedRevision, CommitRevisionInput } from './browser-workspace-store'; import type { LeaseHandle, WorkspaceLeaseManagerOptions } from './browser-workspace-lease'; @@ -59,6 +60,12 @@ import { type BrowserWorkspaceSharingDependencies, } from './browser-workspace-sharing'; +const HTML_MEDIA = /^text\/html(?:;\s*charset=[^;]+)?$/iu; + +function entryIsHtml(entry: WorkspaceEntryRecord): boolean { + return entry.kind === 'html' || (entry.kind === 'asset' && HTML_MEDIA.test(entry.mediaType ?? '')); +} + export type BrowserOwnerWorkspaceRuntimeStatus = | 'starting' | 'passive' @@ -272,7 +279,7 @@ export class BrowserOwnerWorkspaceRuntime { path: string, ): Promise<{ fileId: string; epoch: string; markdown: string } | null> { const binding = this.getBinding(path); - if (!binding) return null; + if (!binding || binding.docType === 'html') return null; const bytes = await this.options.storage.workspaces.getRevisionBody( this.options.workspaceId, binding.path, @@ -1021,19 +1028,20 @@ export class BrowserOwnerWorkspaceRuntime { } finally { bytes.fill(0); } - if (entry.kind === 'markdown') { + if (entry.kind === 'markdown' || entryIsHtml(entry)) { bindings.push({ fileId: published.fileId, path: published.path, revisionId: published.revisionId, contentHash: published.contentHash, epoch: published.snapshotId, + docType: entryIsHtml(entry) ? 'html' : 'markdown', }); } if (entry.headRevisionId !== published.revisionId) localHeadsMoved = true; } if (bindings.length === 0) { - throw new StorageConflictError('published share has no Markdown authority binding'); + throw new StorageConflictError('published share has no document authority binding'); } return { share, @@ -1182,7 +1190,15 @@ export class BrowserOwnerWorkspaceRuntime { entry.path, entry.headRevisionId, ); - if (entry.kind === 'asset') { + if (entryIsHtml(entry)) { + entries.push({ + path: entry.path, + docType: 'html', + bytes, + fileId: published.fileId, + revisionId: entry.headRevisionId, + }); + } else if (entry.kind === 'asset') { if (!entry.mediaType) { bytes.fill(0); throw new BrowserStorageError('shared asset is missing its media type'); @@ -1474,7 +1490,7 @@ export class BrowserOwnerWorkspaceRuntime { private requireBinding(pathOrFileId: string): BrowserOwnerAuthorityFile { const binding = this.getBinding(pathOrFileId); - if (!binding) throw new StorageConflictError('path is not part of the active Markdown share'); + if (!binding) throw new StorageConflictError('path is not part of the active document share'); return binding; } diff --git a/web/src/lib/review/browser-workspace-schema.test.ts b/web/src/lib/review/browser-workspace-schema.test.ts index 24f7f3cc..3a6b1d66 100644 --- a/web/src/lib/review/browser-workspace-schema.test.ts +++ b/web/src/lib/review/browser-workspace-schema.test.ts @@ -251,6 +251,7 @@ defineCase('records: valid fixtures pass every validator', () => { validateWorkspaceEntryRecord( entryRecord({ path: 'images/desk.png', kind: 'asset', mediaType: 'image/png' }), ); + validateWorkspaceEntryRecord(entryRecord({ path: 'report.html', kind: 'html' })); validateWorkspaceRevisionRecord(revisionRecord()); validateWorkspaceRevisionRecord( revisionRecord({ body: { location: 'opfs', nonce: 'bm9uY2U', sealedBytes: 2_048_000 } }), diff --git a/web/src/lib/review/browser-workspace-schema.ts b/web/src/lib/review/browser-workspace-schema.ts index 6e4d498b..aeb0fb40 100644 --- a/web/src/lib/review/browser-workspace-schema.ts +++ b/web/src/lib/review/browser-workspace-schema.ts @@ -72,7 +72,8 @@ export interface WorkspaceRecord { lastBackupAt?: number; } -export type WorkspaceEntryKind = 'markdown' | 'asset'; +/** HTML is a read-only document, distinct from editable Markdown and binary assets. */ +export type WorkspaceEntryKind = 'markdown' | 'html' | 'asset'; export interface WorkspaceEntryRecord { v: number; @@ -80,7 +81,7 @@ export interface WorkspaceEntryRecord { /** Normalized relative path — the primary key with workspaceId. */ path: string; kind: WorkspaceEntryKind; - /** Declared/sniffed MIME type for assets; absent for Markdown. */ + /** Declared/sniffed MIME type for binary assets; absent for documents. */ mediaType?: string; /** Head revision; every live entry has one. */ headRevisionId: string; @@ -278,7 +279,7 @@ export function validateWorkspaceEntryRecord( if (typeof record.path !== 'string' || normalizeEntryPath(record.path) !== record.path) { throw new BrowserStorageError('entry path is not in canonical form'); } - if (record.kind !== 'markdown' && record.kind !== 'asset') { + if (record.kind !== 'markdown' && record.kind !== 'html' && record.kind !== 'asset') { throw new BrowserStorageError('entry kind is invalid'); } if (record.mediaType !== undefined) { diff --git a/web/src/lib/review/browser-workspace-sharing.ts b/web/src/lib/review/browser-workspace-sharing.ts index d815e9a0..99c01b6c 100644 --- a/web/src/lib/review/browser-workspace-sharing.ts +++ b/web/src/lib/review/browser-workspace-sharing.ts @@ -74,13 +74,19 @@ import { type ShareRecordView, } from './browser-workspace-share'; import { compareManifestPathsUtf8 } from './browser-workspace-manifest'; -import { normalizeEntryPath, type ShareScopeKind } from './browser-workspace-schema'; +import { normalizeEntryPath, type ShareScopeKind, type WorkspaceEntryRecord } from './browser-workspace-schema'; import type { LeaseHandle } from './browser-workspace-lease'; import type { MailboxEnvelope } from './browser-ws'; export const BROWSER_SHARE_TTL_ONE_HOUR = 60 * 60 * 1000; export const BROWSER_SHARE_TTL_ONE_DAY = 24 * BROWSER_SHARE_TTL_ONE_HOUR; export const BROWSER_SHARE_TTL_SEVEN_DAYS = 7 * BROWSER_SHARE_TTL_ONE_DAY; +const HTML_MEDIA = /^text\/html(?:;\s*charset=[^;]+)?$/iu; + +/** Read-time compatibility for HTML imported before it had its own kind. */ +function entryIsHtml(entry: WorkspaceEntryRecord): boolean { + return entry.kind === 'html' || (entry.kind === 'asset' && HTML_MEDIA.test(entry.mediaType ?? '')); +} export type BrowserWorkspaceShareMode = RoomPolicy['mode']; export type BrowserWorkspaceShareTtlMs = @@ -775,8 +781,11 @@ export class BrowserWorkspaceSharingCoordinator { if (paths.length === 0) throw new BrowserStorageError('share scope cannot be empty'); if (new Set(paths).size !== paths.length) throw new BrowserStorageError('share scope contains duplicate paths'); for (const path of paths) if (!live.has(path)) throw new StorageConflictError('share scope contains a stale path'); - if (!paths.some((path) => live.get(path)?.kind === 'markdown')) { - throw new BrowserStorageError('share scope must contain at least one Markdown file'); + if (!paths.some((path) => { + const entry = live.get(path); + return entry?.kind === 'markdown' || (entry !== undefined && entryIsHtml(entry)); + })) { + throw new BrowserStorageError('share scope must contain at least one Markdown or HTML document'); } return [...paths].sort(compareManifestPathsUtf8); } @@ -790,7 +799,9 @@ export class BrowserWorkspaceSharingCoordinator { const bytes = await this.storage.workspaces.getRevisionBody(this.workspaceId, path, entry.headRevisionId); sources.push(entry.kind === 'markdown' ? { path, docType: 'markdown', bytes, revisionId: entry.headRevisionId } - : { path, docType: 'asset', mediaType: entry.mediaType ?? 'application/octet-stream', bytes, revisionId: entry.headRevisionId }); + : entryIsHtml(entry) + ? { path, docType: 'html', bytes, revisionId: entry.headRevisionId } + : { path, docType: 'asset', mediaType: entry.mediaType ?? 'application/octet-stream', bytes, revisionId: entry.headRevisionId }); } return sources; } catch (error) { diff --git a/web/src/lib/review/compose-availability.test.ts b/web/src/lib/review/compose-availability.test.ts index 874f3954..5ce8a0dc 100644 --- a/web/src/lib/review/compose-availability.test.ts +++ b/web/src/lib/review/compose-availability.test.ts @@ -10,6 +10,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { COMPOSE_FILE_NOT_SHARED, + COMPOSE_HTML_SUGGEST_UNSUPPORTED, COMPOSE_PREPARING, COMPOSE_SUGGEST_NOT_GRANTED, resolveComposeAvailability, @@ -91,6 +92,28 @@ defineCase('an unhydrated snapshot is a wait, not a wall', () => { assert(a.status === 'pending', 'a pointer snapshot resolves itself once its blob lands'); }); +defineCase('an HTML selector capability is a ready-to-anchor snapshot', () => { + const a = resolveComposeAvailability('comment', ctx({ + fileSnapshotHasAnchors: false, + fileSnapshotHasHtmlSelectors: true, + })); + assert(a.status === 'ready', 'HTML selector documents do not wait for a Markdown index'); +}); + +defineCase('HTML documents permit comments but explicitly decline suggestions', () => { + const html = ctx({ + fileSnapshotHasAnchors: false, + fileSnapshotHasHtmlSelectors: true, + }); + assert(resolveComposeAvailability('comment', html).status === 'ready', 'HTML comments are ready'); + const suggestion = resolveComposeAvailability('suggest', html); + assert(suggestion.status === 'blocked', 'HTML suggestions are intentionally unsupported'); + assert( + suggestion.status === 'blocked' && suggestion.reason === COMPOSE_HTML_SUGGEST_UNSUPPORTED, + 'the unsupported capability is named', + ); +}); + defineCase('transient and structural are never confused', () => { // The two failures look identical from the composer's old bare `return`; // telling them apart is the entire point of this module. diff --git a/web/src/lib/review/compose-availability.ts b/web/src/lib/review/compose-availability.ts index 7ced6dbd..bc1761a7 100644 --- a/web/src/lib/review/compose-availability.ts +++ b/web/src/lib/review/compose-availability.ts @@ -55,6 +55,9 @@ export interface ComposeContext { * its payload is hydrated, and an anchor cannot be authored against one. */ fileSnapshotHasAnchors: boolean; + /** HTML has no Markdown anchor index; this capability means its sandboxed + * document runtime can author selector anchors instead. */ + fileSnapshotHasHtmlSelectors?: boolean; /** What this device's invite permits. */ grantTier: 'comment' | 'suggest'; } @@ -65,6 +68,8 @@ export const COMPOSE_FILE_NOT_SHARED = 'This file is not part of the share, so there is nothing for a comment to attach to. Share it to review it.'; export const COMPOSE_SUGGEST_NOT_GRANTED = 'Your invite allows comments, not edits, so suggestions are unavailable.'; +export const COMPOSE_HTML_SUGGEST_UNSUPPORTED = + 'Suggestions are unavailable for HTML documents; add a comment instead.'; /** * Resolve whether `kind` can be composed right now. @@ -83,6 +88,10 @@ export function resolveComposeAvailability( return { status: 'blocked', reason: COMPOSE_SUGGEST_NOT_GRANTED }; } + if (kind === 'suggest' && ctx.fileSnapshotHasHtmlSelectors) { + return { status: 'blocked', reason: COMPOSE_HTML_SUGGEST_UNSUPPORTED }; + } + // No snapshot anywhere in the room: the share is still completing. This is // the state the browser dev loop was permanently stuck in before // attn-64iy.1, and reading it as "not shared" would have been wrong — the @@ -94,7 +103,7 @@ export function resolveComposeAvailability( } // Snapshot known but not yet hydrated — its blob is still arriving. - if (!ctx.fileSnapshotHasAnchors) { + if (!ctx.fileSnapshotHasAnchors && !ctx.fileSnapshotHasHtmlSelectors) { return { status: 'pending', reason: COMPOSE_PREPARING }; } diff --git a/web/src/lib/review/html-annotation-bridge.ts b/web/src/lib/review/html-annotation-bridge.ts index 9b16cefe..f0512fd4 100644 --- a/web/src/lib/review/html-annotation-bridge.ts +++ b/web/src/lib/review/html-annotation-bridge.ts @@ -95,8 +95,12 @@ export class HtmlAnnotationBridge { #events: AnnotationBridgeEvents; #port: MessagePort | null = null; #onWindowMessage: ((event: MessageEvent) => void) | null = null; - /** Queued until the port exists, so callers need not await the handshake. */ - #pending: RenderableAnchor[] | null = null; + /** + * The full desired overlay state. Keeping it after the first send matters: + * a watched path-mode document can reload in place, replacing its runtime + * and port while the shell's review threads have not changed. + */ + #rendered: RenderableAnchor[] | null = null; constructor(frame: HTMLIFrameElement, events: AnnotationBridgeEvents) { this.#frame = frame; @@ -137,9 +141,12 @@ export class HtmlAnnotationBridge { // carries no secret; its only payload is the port itself. target.postMessage({ type: SHELL_INIT, v: DOC_PROTOCOL_VERSION }, '*', [channel.port2]); - if (this.#pending) { - this.renderAnchors(this.#pending); - this.#pending = null; + if (this.#rendered) { + this.#port.postMessage({ + type: 'renderAnchors', + v: DOC_PROTOCOL_VERSION, + anchors: this.#rendered, + }); } } @@ -224,10 +231,13 @@ export class HtmlAnnotationBridge { // Shell → document // ------------------------------------------------------------------------- - /** Full desired state; the frame diffs. Queued if the port is not up yet. */ + /** + * Full desired state; the frame diffs. It is retained and replayed after + * every successful hello, so a frame reload never drops existing pins. + */ renderAnchors(anchors: RenderableAnchor[]): void { + this.#rendered = anchors; if (!this.#port) { - this.#pending = anchors; return; } this.#port.postMessage({ type: 'renderAnchors', v: DOC_PROTOCOL_VERSION, anchors }); @@ -270,6 +280,6 @@ export class HtmlAnnotationBridge { } this.#port?.close(); this.#port = null; - this.#pending = null; + this.#rendered = null; } } From d8b0072ed71b07375b67245410eb6adb854c911b Mon Sep 17 00:00:00 2001 From: Angus Bezzina <37071175+angusbezzina@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:30:56 -0500 Subject: [PATCH 15/25] docs: record hosted web Impeccable audit --- .../2026-08-12T18-21-40Z__web-hosted.md | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 .impeccable/critique/2026-08-12T18-21-40Z__web-hosted.md diff --git a/.impeccable/critique/2026-08-12T18-21-40Z__web-hosted.md b/.impeccable/critique/2026-08-12T18-21-40Z__web-hosted.md new file mode 100644 index 00000000..018691d9 --- /dev/null +++ b/.impeccable/critique/2026-08-12T18-21-40Z__web-hosted.md @@ -0,0 +1,134 @@ +--- +target: hosted web product in web/ +total_score: 27 +max_score: 40 +na_heuristics: "" +p0_count: 0 +p1_count: 6 +timestamp: 2026-08-12T18-21-40Z +slug: web-hosted +--- +Method: dual-agent (A: /root/impeccable_design_review · B: /root/impeccable_detector_evidence), with an independent technical audit by /root/impeccable_technical_audit + +# Critique — hosted web product + +Scope: `web/` only — homepage `/`, Desk `/app`, owner workspace `/app/w/:workspaceId/:filePath`, invited review `/review/:roomId` and `/s/:shareId`, and unknown/malformed routes. The separate `site/` package is deferred; it must later inherit the same brand and state grammar. + +## Design Health Score + +| # | Heuristic | Score | Key issue | +|---|---|---:|---| +| 1 | Visibility of System Status | 3 | Save, storage, and share states are strong; bad routes and review failures conceal what happened. | +| 2 | Match System / Real World | 3 | Desk/workspace/review fit; invited-review failures expose fragment/relay internals. | +| 3 | User Control and Freedom | 3 | Core navigation and mobile sheets work; review failures offer no recovery action. | +| 4 | Consistency and Standards | 3 | Core themes and surfaces cohere; lifecycle and not-found states lose the product grammar. | +| 5 | Error Prevention | 3 | Local persistence and destructive flows are thoughtful; routing creates false-success states. | +| 6 | Recognition Rather Than Recall | 3 | Core actions are labeled; missing-key recovery depends on finding the original link elsewhere. | +| 7 | Flexibility and Efficiency | 3 | Strong daily-user workspace; review history differs between desktop and mobile. | +| 8 | Aesthetic and Minimalist Design | 3 | Authored and restrained; mobile Desk rows collapse and the homepage trust proof runs long. | +| 9 | Error Recovery | 1 | Invalid review is a dead end and unknown paths masquerade as successful pages. | +| 10 | Help and Documentation | 2 | Security evidence is unusually concrete; product failure states have no contextual help. | +| **Total** | | **27/40** | **Acceptable, close to Good** | + +## Design Specificity Verdict + +The steady-state product is highly authored for attn: warm PAPER/cool INK, serif reading versus sans chrome, one pencil accent, a lit document plane, and local/encrypted state language form a coherent world that cannot be transferred unchanged to generic SaaS. Specificity collapses at the seams: review loading/errors become blank utilities, malformed routes silently become valid surfaces, and mobile Desk rows no longer preserve the desktop information hierarchy. + +The deterministic CLI scan reported one `broken-image` at `web/src/hosted/landing/Hero.svelte:79`; it is a confirmed false positive caused by `` in an HTML comment. The actual responsive screenshot has real fallback/AVIF sources, intrinsic dimensions, alt text, and no broken image in either inspected viewport. + +Browser overlay injection succeeded. Stable findings were: homepage 4 (`oversized-h1`, two `all-caps-body`, pinned-paper `cream-palette`); populated Desk 2 (`kicker-above-heading`, pinned-paper palette); owner workspace 2 (`flat-type-hierarchy`, pinned-paper palette); invalid review 2 (`flat-type-hierarchy`, pinned-paper palette). The palette signal describes the intentional design system and is not a remediation item. Unknown routes repeat the homepage findings because they incorrectly render that page. + +## Overall Impression + +The core surfaces already feel like one precise editorial tool. The opportunity is to extend that authority through small screens, lifecycle failures, durable review history, and routing—not to redesign the product. + +## What’s Working + +1. The “Lit Reading Room” identity holds across homepage, Desk, and workspace in PAPER and INK. +2. Local-first trust is operational: on-device storage, autosave, deliberate sharing, backup state, and ciphertext-only relay language appear where decisions are made. +3. The workspace puts reading first. Desktop chrome recedes; mobile uses a thumb dock and contextual Review sheet without displacing the document. + +## Priority Issues + +### [P1] Unknown and malformed paths are false successes + +Unknown root paths render the homepage with HTTP 200; malformed `/app/*` paths render Desk; malformed review paths render invite-error UI with HTTP 200. Add strict route recognition and an intentional branded 404 with real HTTP 404 behavior. Preserve the distinction between a malformed route, a valid workspace absent on this device, and a valid review capability that is expired/revoked/denied. + +Evidence: `web/src/lib/hosted/routes.ts:16-24,66-89`, `web/worker.ts`, `web/vite.browser.config.ts`, and `web/src/hosted/app/AppShell.svelte`. + +Suggested command: `/impeccable harden`. + +### [P1] Populated Desk rows break at phone width + +At 390×844, sharing state overlaps the title and metadata collapses into narrow word-per-line columns. Replace the inherited desktop grid with an explicit mobile card hierarchy: title/admin, wrapping metadata, review counts, then share/backup state. Keep the entire card as the open target and place administration behind a labeled overflow action. + +Evidence: `web/src/hosted/app/DeskHome.svelte:374-474`, `web/src/hosted/app/app-shell.css:297-383,2592-2620`. + +Suggested command: `/impeccable adapt`. + +### [P1] Reviewer lifecycle states are unbranded dead ends + +Invalid `/review` and fragmentless `/s` states expose internal error messages, have no semantic heading, and offer zero actions. Build one branded lifecycle shell for loading, invalid, denied, expired, deleted, revoked, offline, and bootstrap failure. Each state needs plain-language diagnosis, an honest privacy reassurance where relevant, and state-appropriate retry/paste/new-link/Home/Desk actions. Raw diagnostics stay in logs. + +Evidence: `web/src/BrowserReviewApp.svelte:1340-1400`, `web/src/browser-review.ts:133-171`. + +Suggested commands: `/impeccable harden`, `/impeccable clarify`. + +### [P1] Review history disappears when “live” is false + +Mobile demo behavior exposes persisted cards while desktop gates its rail on `reviewRoomActive`. Product decision: history remains available; “live” is connection state only. Establish one durable review projection and one count/label model across desktop and mobile. Live connectivity adds presence and authoring; it does not decide whether history exists. + +Evidence: `web/src/hosted/app/EditorShell.svelte` desktop rail versus mobile sheet; real `WorkspaceDetail.reviewCards` currently initializes empty while mock data carries cards. + +Suggested commands: `/impeccable clarify`, `/impeccable harden`. + +### [P1] Desktop workspace eagerly bundles the full icon catalog + +Opening the desktop workspace loads a 3,332,459-byte raw / 783,523-byte gzip chunk containing thousands of SVG modules. Split the hosted icon resolver so it loads only the selected pack and icons actually rendered. Existing exact tracker item: `attn-7xl.7.8`; do not duplicate it. + +Evidence: `web/src/lib/FileTree.svelte`, `web/src/lib/vscode-icon-map.generated.ts`, `web/src/lib/icons/vscode-generated`. + +Suggested command: `/impeccable optimize`. + +### [P1] Homepage install command overflow is not keyboard operable + +Axe reports `scrollable-region-focusable` at 390×844. Make long commands focusable and arrow-scrollable, or wrap without corrupting the copyable command. Verify 320px and 200% text. + +Evidence: `web/src/hosted/landing/CopyCode.svelte`, `web/src/hosted/landing/landing.css:617-622`. + +Suggested command: `/impeccable adapt`. + +### [P2] Homepage trust proof overwhelms the persuasion path + +Keep a concise security guarantee visible. Place the full relay ledger behind an explicit “Read the threat model” disclosure while retaining direct source links. Reduce the first viewport to one primary and one returning-user action; move Import/Join to the quieter start block. + +Evidence: homepage is 5,412px at 1440×1000 and 7,109px at 390×844; first viewport exposes roughly nine choices. + +Suggested command: `/impeccable distill`. + +### [P2] Compact product controls and one destructive dialog miss the accessibility contract + +Measured controls include 15–31px-high targets across Desk, owner mobile Share, reviewer status/toggle, and review file tabs. Preserve compact visuals while providing at least 44×44 coarse-pointer hit areas. The owner file-delete `role=alertdialog` lacks the contract Svelte requires; use the shared dialog primitive or honest inline-confirmation semantics. + +Evidence: `web/src/hosted/app/EditorShell.svelte`, `web/src/BrowserReviewApp.svelte`, `web/src/lib/ReviewerStatusChip.svelte`, `web/src/lib/ReviewFileNav.svelte`; `svelte-check` reports one accessibility warning at the file-delete confirmation. + +Suggested commands: `/impeccable adapt`, `/impeccable harden`. + +## Persona Red Flags + +- James, daily owner: the desktop workspace can lose the review obligation the Desk and mobile sheet advertise; malformed bookmarked paths silently become Desk. +- Jordan, invited reviewer: internal invite/fragment language, no brand, no action, and no safe way to paste/reopen a complete link make a failure the remembered end of the journey. +- Casey, distracted mobile user: the owner workspace is strong, but the highest-priority Desk row looks corrupted and the homepage takes more than seven mobile viewports of vertical content. + +## Minor Observations + +- INK is a genuine second material system; preserve it. +- The pinned paper palette is intentional and detector findings against it are not issues. +- No horizontal page overflow or rendered broken images were found in the inspected desktop/mobile states. +- Connected invited-review states were not visually verified because no authenticated capability/relay fixture was available; implementation acceptance must include a real multi-role loop. + +## Questions Resolved + +- The full relay evidence belongs behind an explicit disclosure; a concise security guarantee stays visible. +- Review history stays available after live connectivity ends; “live” is connection state only. +- `site/` remains out of scope. Its later epic must inherit typography roles, PAPER/INK parity, one-pencil accent use, quiet voice, local-first terminology, CTA vocabulary, and shared loading/error/not-found grammar—preferably through shared tokens or parity tests. From 2aa663d33239fbdc49ff7ea49b7a6378f22fe5a3 Mon Sep 17 00:00:00 2001 From: Angus Bezzina <37071175+angusbezzina@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:19:13 -0500 Subject: [PATCH 16/25] Remediate hosted web audit findings --- .gitignore | 1 + web/e2e/hosted-a11y.spec.ts | 59 ++++- web/e2e/hosted-review.spec.ts | 6 +- web/e2e/hosted-routes.spec.ts | 29 ++- web/e2e/hosted-shells.spec.ts | 43 ++++ web/hosted/app/main.ts | 14 +- web/hosted/landing.ts | 18 +- web/package.json | 2 +- web/scripts/check-icon-bundles.mjs | 65 ++++++ web/scripts/generate-vscode-icon-map.mjs | 127 ++++++----- web/scripts/test-browser-share-owner-live.ts | 30 ++- web/src/App.svelte | 2 + web/src/BrowserReviewApp.svelte | 190 +++++++++++++--- web/src/app.css | 5 +- web/src/browser-review.ts | 18 +- web/src/hosted/app/DeskHome.svelte | 112 ++++++---- web/src/hosted/app/EditorShell.svelte | 81 +++++-- .../app/HostedDesktopWorkspaceFrame.svelte | 54 ++++- web/src/hosted/app/app-shell.css | 203 +++++++++++++++++- web/src/hosted/app/hosted-icon-registry.ts | 86 ++++++++ web/src/hosted/landing/CopyCode.svelte | 43 +++- web/src/hosted/landing/EntryStrip.svelte | 25 ++- web/src/hosted/landing/Hero.svelte | 12 +- web/src/hosted/landing/HowItWorks.svelte | 90 ++++---- web/src/hosted/landing/Landing.svelte | 2 +- web/src/hosted/landing/Nav.svelte | 10 +- web/src/hosted/landing/landing.css | 133 ++++++++---- web/src/hosted/not-found/NotFound.svelte | 130 +++++++++++ web/src/hosted/sw/sw.ts | 29 +-- web/src/lib/DirectoryOverview.svelte | 13 +- web/src/lib/FileTree.svelte | 34 +-- web/src/lib/ReviewFileNav.svelte | 6 + web/src/lib/ReviewerStatusChip.svelte | 9 +- web/src/lib/Sidebar.svelte | 7 +- web/src/lib/WorkspaceEditorFrame.svelte | 31 ++- web/src/lib/file-icon-resolution.ts | 38 ++++ web/src/lib/file-icon-resolver.ts | 13 ++ web/src/lib/hosted/routes.test.ts | 46 +++- web/src/lib/hosted/routes.ts | 110 +++++++--- web/src/lib/hosted/sw-policy.test.ts | 23 +- web/src/lib/hosted/sw-policy.ts | 11 +- web/src/lib/icon-pack.ts | 3 +- web/src/lib/icon-resolver.ts | 102 +++++---- web/src/lib/review/browser-share-owner.ts | 12 +- web/src/lib/review/reviewer-lifecycle.test.ts | 33 +++ web/src/lib/review/reviewer-lifecycle.ts | 100 +++++++++ web/vite.browser.config.ts | 42 +++- web/worker.ts | 30 ++- web/wrangler.jsonc | 2 +- web/wrangler.production.jsonc | 2 +- 50 files changed, 1829 insertions(+), 457 deletions(-) create mode 100644 web/scripts/check-icon-bundles.mjs create mode 100644 web/src/hosted/app/hosted-icon-registry.ts create mode 100644 web/src/hosted/not-found/NotFound.svelte create mode 100644 web/src/lib/file-icon-resolution.ts create mode 100644 web/src/lib/file-icon-resolver.ts create mode 100644 web/src/lib/review/reviewer-lifecycle.test.ts create mode 100644 web/src/lib/review/reviewer-lifecycle.ts diff --git a/.gitignore b/.gitignore index eaffc192..20b7518c 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ web/playwright-report/ web/test-results/ web/src/lib/icons/vscode-generated/ web/src/lib/vscode-icon-map.generated.ts +web/src/lib/vscode-icon-packs/ # wrangler dev state web/.wrangler/ diff --git a/web/e2e/hosted-a11y.spec.ts b/web/e2e/hosted-a11y.spec.ts index 041898ad..84e92924 100644 --- a/web/e2e/hosted-a11y.spec.ts +++ b/web/e2e/hosted-a11y.spec.ts @@ -77,6 +77,59 @@ test('keyboard-only: landing reaches both CTAs', async ({ browserName, page }) = await expect(openDesk).toBeFocused(); }); +test('landing keeps its security proof disclosed and its install command keyboard-readable', async ({ page }) => { + await page.setViewportSize({ width: 320, height: 700 }); + await page.goto('/'); + + const hero = page.locator('.hero'); + await expect(hero.locator('.button.primary')).toHaveCount(1); + await expect(hero.locator('a[data-action]')).toHaveCount(2); + await expect(page.locator('.site-nav .button.primary')).toHaveCount(0); + await expect(page.locator('.secondary-start')).toContainText('Other ways to begin'); + await expect(page.locator('.secondary-start .button.primary')).toHaveCount(0); + + const threatModel = page.locator('details.threat-model'); + await expect(threatModel).toHaveJSProperty('open', false); + await page + .getByRole('textbox', { name: 'Install command: brew install lightsofapollo/attn/attn' }) + .scrollIntoViewIfNeeded(); + await page.getByText('Read the threat model', { exact: true }).click(); + await expect(threatModel).toHaveJSProperty('open', true); + await expect(threatModel.getByRole('link', { name: 'relay-spec.md' })).toBeVisible(); + await expect(threatModel.getByRole('link', { name: 'security-review.md' })).toBeVisible(); + + // A 200% root text scale makes the command wider than a narrow viewport. + // The focusable command must still expose the clipped tail by keyboard. + await page.evaluate(() => { + document.documentElement.style.fontSize = '200%'; + }); + const command = page.getByRole('textbox', { + name: 'Install command: brew install lightsofapollo/attn/attn', + }); + for (const width of [320, 390]) { + await page.setViewportSize({ width, height: 700 }); + await command.evaluate((element) => element.scrollTo({ left: 0, behavior: 'auto' })); + await expect.poll(() => command.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(true); + await command.focus(); + await page.keyboard.press('ArrowRight'); + await expect.poll(() => command.evaluate((element) => element.scrollLeft)).toBeGreaterThan(0); + await page.keyboard.press('End'); + await expect.poll(() => command.evaluate((element) => element.scrollLeft)).toBeGreaterThan(0); + expect( + await page.evaluate(() => { + const root = document.scrollingElement; + return root ? root.scrollWidth - root.clientWidth : 0; + }), + ).toBe(0); + } + + await expect(command).toHaveValue('brew install lightsofapollo/attn/attn'); + const copy = page.locator('.native-section .code-copy').first(); + await page.context().grantPermissions(['clipboard-write']); + await copy.click(); + await expect(copy).toHaveAttribute('data-state', 'copied'); +}); + test('keyboard-only: share sheet opens, traps start focus, and closes', async ({ page }) => { await page.goto('/app/w/ws-product/direction.md?shell=demo'); const share = page.getByRole('button', { name: 'Share for review' }); @@ -136,10 +189,10 @@ test('authoring controls move focus into transient inputs and restore it on canc await fileRow.click({ button: 'right' }); const deleteFile = page.getByRole('menuitem', { name: 'Delete…', exact: true }); await deleteFile.click(); - const deleteDialog = page.getByRole('alertdialog', { name: /Delete untitled\.md/u }); - await expect(deleteDialog.getByRole('button', { name: 'Cancel' })).toBeFocused(); + const deleteConfirmation = page.getByRole('group', { name: /Delete untitled\.md/u }); + await expect(deleteConfirmation.getByRole('button', { name: 'Cancel' })).toBeFocused(); await page.keyboard.press('Escape'); - await expect(deleteDialog).not.toBeVisible(); + await expect(deleteConfirmation).not.toBeVisible(); await expect(fileRow).toBeFocused(); }); diff --git a/web/e2e/hosted-review.spec.ts b/web/e2e/hosted-review.spec.ts index 2c888fbc..251f7623 100644 --- a/web/e2e/hosted-review.spec.ts +++ b/web/e2e/hosted-review.spec.ts @@ -364,7 +364,9 @@ test('missing and malformed invite capabilities fail closed without relay contac .toBe(''); const error = page.locator('[data-slot="browser-review-error"]'); await expect(error).toHaveAttribute('data-error-kind', 'invite_invalid'); - await expect(error.getByText('Invalid invite link', { exact: true })).toBeVisible(); + await expect(error.getByRole('heading', { name: 'This review link is incomplete' })).toBeVisible(); + await expect(error.getByRole('button', { name: 'Paste complete link' })).toBeVisible(); + await expect(error).not.toContainText(/remembered room|no invite fragment/iu); // Negative network assertions need a bounded settle window so a future // delayed bootstrap task cannot schedule relay work just after the error // UI renders and escape the capture below. @@ -755,7 +757,7 @@ test('native share opens in hosted reviewer without leaking plaintext or keys', // Fragment-only keys are intentionally memory-only. A refresh after the // synchronous replaceState must fail closed instead of recovering a key. await page.reload({ waitUntil: 'domcontentloaded' }); - await expect(page.getByText('Invalid invite link')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'This review link is incomplete' })).toBeVisible(); await expect(page.getByText(contentCanary, { exact: false })).toHaveCount(0); // Re-open the invite and explicitly opt into encrypted local recovery. diff --git a/web/e2e/hosted-routes.spec.ts b/web/e2e/hosted-routes.spec.ts index 085a8e29..a27b3a83 100644 --- a/web/e2e/hosted-routes.spec.ts +++ b/web/e2e/hosted-routes.spec.ts @@ -187,12 +187,35 @@ test('capture landing screenshots for design review', async ({ page }) => { await page.screenshot({ path: 'test-results/landing-iphone-light.png', fullPage: true }); }); -test('unknown paths fall back to the landing entry', async ({ page }) => { +test('unknown paths return a branded, keyboard-accessible 404 recovery page', async ({ page }) => { const response = await page.goto('/no-such-page'); - expect(response?.status()).toBe(200); - await expect(page.locator('body[data-route="landing"]')).toBeVisible(); + expect(response?.status()).toBe(404); + await expect(page.locator('body[data-route="not-found"]')).toBeVisible(); + const heading = page.getByRole('heading', { name: 'That page isn’t here.' }); + await expect(heading).toBeVisible(); + await expect(heading).toBeFocused(); + const desk = page.getByRole('link', { name: 'Go to your desk' }); + await desk.focus(); + await expect(desk).toBeFocused(); + await expect(page.getByRole('link', { name: 'Go home' })).toHaveAttribute('href', '/'); }); +for (const malformedPath of [ + '/app/w', + '/app/w/ws-product/%E0%A4', + '/review', + '/review/room-e2e-canary/extra', + '/s/short', + '/s/AAAAAAAAAAAAAAAAAAAAAA/', +]) { + test(`malformed hosted route ${malformedPath} returns the not-found recovery`, async ({ page }) => { + const response = await page.goto(malformedPath); + expect(response?.status()).toBe(404); + await expect(page.locator('body[data-route="not-found"]')).toBeVisible(); + await expect(page.getByRole('link', { name: 'Go to your desk' })).toBeVisible(); + }); +} + for (const [path, view, headingHint] of [ ['/app', 'home', 'Your desk'], ['/app/storage', 'storage', 'Storage & recovery'], diff --git a/web/e2e/hosted-shells.spec.ts b/web/e2e/hosted-shells.spec.ts index 76ccaf65..7b5e16d5 100644 --- a/web/e2e/hosted-shells.spec.ts +++ b/web/e2e/hosted-shells.spec.ts @@ -26,6 +26,37 @@ test('desk home lists recent workspaces with storage health', async ({ page }) = await expect(page.locator('h1')).toHaveText('Storage & recovery'); }); +test('mobile Desk makes workspace facts and administration scannable', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto('/app?shell=demo'); + const row = page.locator('.workspace-row').first(); + const layout = await row.evaluate((element) => { + const rect = (selector: string) => { + const item = element.querySelector(selector); + if (!item) throw new Error(`missing ${selector}`); + const box = item.getBoundingClientRect(); + return { top: box.top, bottom: box.bottom, width: box.width, height: box.height }; + }; + return { + title: rect('.row-open'), + facts: rect('.detail-group'), + review: rect('.review-pill'), + status: rect('.row-tail'), + menu: rect('.row-menu summary'), + }; + }); + expect(layout.title.bottom).toBeLessThanOrEqual(layout.facts.top + 1); + expect(layout.facts.bottom).toBeLessThanOrEqual(layout.review.top + 1); + expect(layout.review.bottom).toBeLessThanOrEqual(layout.status.top + 1); + expect(layout.menu.width).toBeGreaterThanOrEqual(44); + expect(layout.menu.height).toBeGreaterThanOrEqual(44); + + await page.getByLabel('Manage Product direction').click(); + await expect(row.getByRole('button', { name: 'Rename' })).toBeVisible(); + await expect(row.getByRole('button', { name: 'Delete' })).toBeVisible(); + await expectNoHorizontalScroll(page); +}); + test('landing one-click intent opens an untitled draft editor', async ({ page }) => { await page.goto('/app#new'); await expect(page.locator('[data-app-view="workspace"]')).toBeVisible(); @@ -47,6 +78,18 @@ test('desktop editor reuses the native sidebar, editor, and review rail frame', await expect(page.locator('[data-action="edit"]')).toHaveCount(0); await expect(page.getByRole('button', { name: 'Done', exact: true })).toHaveCount(0); await expect(page.locator('[data-slot="right-rail"]')).toHaveCount(1); + const savedReview = page.getByRole('button', { name: 'Saved review' }); + await expect(savedReview).toBeVisible(); + await expect(savedReview).toHaveAttribute('aria-expanded', 'false'); + await expect(page.locator('[data-slot="right-rail"]')).toHaveAttribute('data-mode', 'collapsed'); + await expect(page.locator('.review-history-placeholder')).toHaveCount(0); + await savedReview.click(); + await expect(savedReview).toHaveAttribute('aria-expanded', 'true'); + await expect(page.locator('.review-history-placeholder')).toContainText('Saved review'); + await expect(page.locator('.review-history-placeholder')).toContainText('JULES'); + await expect(page.locator('.review-history-placeholder')).toContainText( + 'Live review adds presence and replies; saved feedback stays here.', + ); await expect(page.locator('.file-rail, .review-rail')).toHaveCount(0); await expectNoHorizontalScroll(page); }); diff --git a/web/hosted/app/main.ts b/web/hosted/app/main.ts index 7f36f10a..b8e640ac 100644 --- a/web/hosted/app/main.ts +++ b/web/hosted/app/main.ts @@ -23,6 +23,19 @@ async function bootstrap(): Promise { const target = document.getElementById('app'); if (!target) throw new Error('missing app mount element'); + // This normally cannot happen because the worker and Vite middleware only + // serve the app entry for parseable paths. It still matters for an offline + // cached shell or a third-party static host: do not initialize storage and + // quietly turn a malformed path into the desk. + const route = parseAppRoute(window.location.pathname); + if (!route) { + const { default: NotFound } = await import('../../src/hosted/not-found/NotFound.svelte'); + document.body.dataset.route = 'not-found'; + mount(NotFound, { target }); + document.body.dataset.hydrated = 'true'; + return; + } + const surface = new URLSearchParams(window.location.search).get('surface'); if (surface === 'landing-review-demo') { const [{ default: LandingReviewDemo }] = await Promise.all([ @@ -44,7 +57,6 @@ async function bootstrap(): Promise { service = new MockWorkspaceService(scenario); } - const route = parseAppRoute(window.location.pathname); mount(AppShell, { target, props: { diff --git a/web/hosted/landing.ts b/web/hosted/landing.ts index a9380eb8..680836c0 100644 --- a/web/hosted/landing.ts +++ b/web/hosted/landing.ts @@ -8,17 +8,25 @@ import '../src/hosted/chrome.css'; import '../src/hosted/landing/landing.css'; import { mount } from 'svelte'; import Landing from '../src/hosted/landing/Landing.svelte'; +import NotFound from '../src/hosted/not-found/NotFound.svelte'; +import { hostedEntryForPath } from '../src/lib/hosted/routes'; import { initTheme } from '../src/hosted/theme.svelte'; initTheme(); const target = document.getElementById('app'); if (!target) throw new Error('missing landing mount element'); -// Keep the shipped homepage intact while /homepage-alt carries the interactive -// positioning study. The alternate stays in a lazy chunk so ordinary landing -// visits do not pay for the demo state or its presentation CSS. -const alternateHomepage = /^\/homepage-alt\/?$/u.test(window.location.pathname); -if (alternateHomepage) { +// The worker and Vite use this same parser before serving an entry. Re-check +// here because a cached shell may be replayed after a navigation while offline. +// A failed route must never turn into a convincing but unrelated homepage. +const entry = hostedEntryForPath(window.location.pathname); +if (!entry) { + document.body.dataset.route = 'not-found'; + mount(NotFound, { target }); +} else if (/^\/homepage-alt\/?$/u.test(window.location.pathname)) { + // Keep the shipped homepage intact while /homepage-alt carries the interactive + // positioning study. The alternate stays in a lazy chunk so ordinary landing + // visits do not pay for the demo state or its presentation CSS. document.body.dataset.route = 'landing-alt'; const { default: AlternateLanding } = await import( '../src/hosted/landing-alt/AlternateLanding.svelte' diff --git a/web/package.json b/web/package.json index fca3ad2e..71389442 100644 --- a/web/package.json +++ b/web/package.json @@ -20,7 +20,7 @@ "test:unit": "node scripts/run-tests.mjs", "test:e2e:hosted": "playwright test e2e/hosted-review.spec.ts", "test:e2e:html-annotation": "npm run build:doc-runtime && playwright test e2e/html-annotation-runtime.spec.ts", - "check:route-bundles": "node scripts/check-route-bundles.mjs", + "check:route-bundles": "node scripts/check-route-bundles.mjs && node scripts/check-icon-bundles.mjs", "test:e2e:routes": "npm run build:browser && npm run check:route-bundles && playwright test --config playwright.routes.config.ts", "test:e2e:storage": "playwright test --config playwright.storage.config.ts", "test:e2e:push": "playwright test --config playwright.push.config.ts", diff --git a/web/scripts/check-icon-bundles.mjs b/web/scripts/check-icon-bundles.mjs new file mode 100644 index 00000000..39384def --- /dev/null +++ b/web/scripts/check-icon-bundles.mjs @@ -0,0 +1,65 @@ +#!/usr/bin/env node +// Keeps the browser desk's icon boundary honest (attn-7xl.7.8). +// +// The native app may switch among all five icon packs. The hosted desktop must +// not therefore inherit that aggregate registry: its shared Sidebar/FileTree +// asks the desktop-owned registry for the selected pack. Material is the +// largest pack, so pinning its own lazy chunk catches a regression both in the +// split and in the generated asset map. + +import { readFile } from 'node:fs/promises'; +import { gzipSync } from 'node:zlib'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const distDir = path.join(webRoot, 'dist-browser'); +const MATERIAL_MODULE = 'src/lib/vscode-icon-packs/material.generated.ts'; +const FRAME_MODULE = 'src/hosted/app/HostedDesktopWorkspaceFrame.svelte'; +const ICON_PACK_MODULE = 'src/lib/vscode-icon-packs/'; +// Measured on the generated material map. Leave enough room for harmless +// source-map-free minifier variation, but not for a second pack to slip in. +const MATERIAL_MAX_GZIP_BYTES = 240 * 1024; + +const chunkModules = JSON.parse( + await readFile(path.join(distDir, '.vite', 'chunk-modules.json'), 'utf8'), +); + +const chunksFor = (moduleId) => Object.entries(chunkModules) + .filter(([, modules]) => modules.includes(moduleId)) + .map(([file]) => file); + +const materialChunks = chunksFor(MATERIAL_MODULE); +if (materialChunks.length !== 1) { + throw new Error( + `expected exactly one material icon-pack chunk, found ${materialChunks.length}: ${materialChunks.join(', ')}`, + ); +} + +const frameChunks = chunksFor(FRAME_MODULE); +if (frameChunks.length !== 1) { + throw new Error( + `expected exactly one HostedDesktopWorkspaceFrame chunk, found ${frameChunks.length}: ${frameChunks.join(', ')}`, + ); +} + +const frameModules = chunkModules[frameChunks[0]]; +const leakedPackModules = frameModules.filter((moduleId) => moduleId.includes(ICON_PACK_MODULE)); +if (leakedPackModules.length > 0) { + throw new Error( + `HostedDesktopWorkspaceFrame statically includes icon-pack modules: ${leakedPackModules.join(', ')}`, + ); +} + +const materialFile = materialChunks[0]; +const materialBytes = await readFile(path.join(distDir, materialFile)); +const materialGzipBytes = gzipSync(materialBytes).byteLength; +if (materialGzipBytes > MATERIAL_MAX_GZIP_BYTES) { + throw new Error( + `material icon pack is ${materialGzipBytes} gzip bytes; pinned limit is ${MATERIAL_MAX_GZIP_BYTES}`, + ); +} + +console.log( + `checked icon bundles: hosted frame excludes pack registry; material pack ${materialBytes.byteLength} raw / ${materialGzipBytes} gzip bytes (limit ${MATERIAL_MAX_GZIP_BYTES})`, +); diff --git a/web/scripts/generate-vscode-icon-map.mjs b/web/scripts/generate-vscode-icon-map.mjs index a9382625..a510b0a2 100644 --- a/web/scripts/generate-vscode-icon-map.mjs +++ b/web/scripts/generate-vscode-icon-map.mjs @@ -9,6 +9,7 @@ const require = createRequire(import.meta.url); const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const generatedIconsDir = path.join(projectRoot, 'src/lib/icons/vscode-generated'); const generatedModulePath = path.join(projectRoot, 'src/lib/vscode-icon-map.generated.ts'); +const generatedPackDir = path.join(projectRoot, 'src/lib/vscode-icon-packs'); const generatedReadmePath = path.join(generatedIconsDir, 'README.md'); const SUPPORTED_PACKS = ['material', 'eyecons', 'catppuccin', 'vscode-icons', 'seti']; const DEFAULT_PACK = process.env.ATTN_DEFAULT_ICON_PACK || 'eyecons'; @@ -329,21 +330,34 @@ function buildSetiMappings(setiPkgRoot, setiVirtualIcons) { }; } -function writeGeneratedModule(packMappings, copiedIcons, defaultPack) { +function iconFilesForMappings(mappings) { + const files = new Set([ + mappings.defaultFile, + mappings.defaultFolder, + mappings.defaultFolderExpanded, + ...Object.values(mappings.fileNames), + ...Object.values(mappings.fileExtensions), + ...Object.values(mappings.folderNames), + ...Object.values(mappings.folderNamesExpanded), + ]); + return Array.from(files).sort(); +} + +function writeGeneratedPackModule(pack, mappings) { const lines = []; lines.push('// AUTO-GENERATED by scripts/generate-vscode-icon-map.mjs'); lines.push('// Do not edit by hand.'); lines.push(''); const importVarByFile = new Map(); - copiedIcons.forEach((file, idx) => { + iconFilesForMappings(mappings).forEach((file, idx) => { const varName = `icon_${idx}`; importVarByFile.set(file, varName); lines.push(`import ${varName} from '$lib/icons/vscode-generated/${file}';`); }); lines.push(''); - lines.push(`export type IconPack = ${SUPPORTED_PACKS.map((pack) => JSON.stringify(pack)).join(' | ')};`); + lines.push("import type { IconPackIcons } from '../vscode-icon-map.generated';"); lines.push(''); const emitMap = (name, mapObj) => { @@ -355,56 +369,53 @@ function writeGeneratedModule(packMappings, copiedIcons, defaultPack) { lines.push(''); }; - for (const pack of SUPPORTED_PACKS) { - const mappings = packMappings[pack]; - const prefix = pack.toUpperCase().replace(/-/g, '_'); - emitMap(`${prefix}_FILE_NAME_ICONS`, mappings.fileNames); - emitMap(`${prefix}_FILE_EXTENSION_ICONS`, mappings.fileExtensions); - emitMap(`${prefix}_FOLDER_NAME_ICONS`, mappings.folderNames); - emitMap(`${prefix}_FOLDER_NAME_OPEN_ICONS`, mappings.folderNamesExpanded); - - lines.push(`const ${prefix}_DEFAULT_FILE_ICON = ${importVarByFile.get(mappings.defaultFile)};`); - lines.push(`const ${prefix}_DEFAULT_FOLDER_ICON = ${importVarByFile.get(mappings.defaultFolder)};`); - lines.push(`const ${prefix}_DEFAULT_FOLDER_OPEN_ICON = ${importVarByFile.get(mappings.defaultFolderExpanded)};`); - lines.push(''); - } - - lines.push('export const ICON_PACKS: Record;'); - lines.push(' FILE_EXTENSION_ICONS: Record;'); - lines.push(' FOLDER_NAME_ICONS: Record;'); - lines.push(' FOLDER_NAME_OPEN_ICONS: Record;'); - lines.push('}> = {'); - - for (const pack of SUPPORTED_PACKS) { - const prefix = pack.toUpperCase().replace(/-/g, '_'); - lines.push(` ${JSON.stringify(pack)}: {`); - lines.push(` DEFAULT_FILE_ICON: ${prefix}_DEFAULT_FILE_ICON,`); - lines.push(` DEFAULT_FOLDER_ICON: ${prefix}_DEFAULT_FOLDER_ICON,`); - lines.push(` DEFAULT_FOLDER_OPEN_ICON: ${prefix}_DEFAULT_FOLDER_OPEN_ICON,`); - lines.push(` FILE_NAME_ICONS: ${prefix}_FILE_NAME_ICONS,`); - lines.push(` FILE_EXTENSION_ICONS: ${prefix}_FILE_EXTENSION_ICONS,`); - lines.push(` FOLDER_NAME_ICONS: ${prefix}_FOLDER_NAME_ICONS,`); - lines.push(` FOLDER_NAME_OPEN_ICONS: ${prefix}_FOLDER_NAME_OPEN_ICONS,`); - lines.push(' },'); - } + const prefix = pack.toUpperCase().replace(/-/g, '_'); + emitMap(`${prefix}_FILE_NAME_ICONS`, mappings.fileNames); + emitMap(`${prefix}_FILE_EXTENSION_ICONS`, mappings.fileExtensions); + emitMap(`${prefix}_FOLDER_NAME_ICONS`, mappings.folderNames); + emitMap(`${prefix}_FOLDER_NAME_OPEN_ICONS`, mappings.folderNamesExpanded); + + lines.push('export const ICON_PACK: IconPackIcons = {'); + lines.push(` DEFAULT_FILE_ICON: ${importVarByFile.get(mappings.defaultFile)},`); + lines.push(` DEFAULT_FOLDER_ICON: ${importVarByFile.get(mappings.defaultFolder)},`); + lines.push(` DEFAULT_FOLDER_OPEN_ICON: ${importVarByFile.get(mappings.defaultFolderExpanded)},`); + lines.push(` FILE_NAME_ICONS: ${prefix}_FILE_NAME_ICONS,`); + lines.push(` FILE_EXTENSION_ICONS: ${prefix}_FILE_EXTENSION_ICONS,`); + lines.push(` FOLDER_NAME_ICONS: ${prefix}_FOLDER_NAME_ICONS,`); + lines.push(` FOLDER_NAME_OPEN_ICONS: ${prefix}_FOLDER_NAME_OPEN_ICONS,`); lines.push('};'); - lines.push(''); - lines.push(`export const DEFAULT_ICON_PACK: IconPack = ${JSON.stringify(defaultPack)};`); - lines.push(''); - lines.push('// Back-compat exports (default pack)'); - lines.push('export const DEFAULT_FILE_ICON = ICON_PACKS[DEFAULT_ICON_PACK].DEFAULT_FILE_ICON;'); - lines.push('export const DEFAULT_FOLDER_ICON = ICON_PACKS[DEFAULT_ICON_PACK].DEFAULT_FOLDER_ICON;'); - lines.push('export const DEFAULT_FOLDER_OPEN_ICON = ICON_PACKS[DEFAULT_ICON_PACK].DEFAULT_FOLDER_OPEN_ICON;'); - lines.push('export const FILE_NAME_ICONS = ICON_PACKS[DEFAULT_ICON_PACK].FILE_NAME_ICONS;'); - lines.push('export const FILE_EXTENSION_ICONS = ICON_PACKS[DEFAULT_ICON_PACK].FILE_EXTENSION_ICONS;'); - lines.push('export const FOLDER_NAME_ICONS = ICON_PACKS[DEFAULT_ICON_PACK].FOLDER_NAME_ICONS;'); - lines.push('export const FOLDER_NAME_OPEN_ICONS = ICON_PACKS[DEFAULT_ICON_PACK].FOLDER_NAME_OPEN_ICONS;'); + return writeFileIfChanged(path.join(generatedPackDir, `${pack}.generated.ts`), `${lines.join('\n')}\n`); +} +function writeGeneratedManifest(defaultPack) { + const lines = [ + '// AUTO-GENERATED by scripts/generate-vscode-icon-map.mjs', + '// Do not edit by hand.', + '', + `export type IconPack = ${SUPPORTED_PACKS.map((pack) => JSON.stringify(pack)).join(' | ')};`, + `export const ICON_PACK_IDS = ${JSON.stringify(SUPPORTED_PACKS)} as const satisfies readonly IconPack[];`, + `export const DEFAULT_ICON_PACK: IconPack = ${JSON.stringify(defaultPack)};`, + '', + 'export interface IconPackIcons {', + ' DEFAULT_FILE_ICON: string;', + ' DEFAULT_FOLDER_ICON: string;', + ' DEFAULT_FOLDER_OPEN_ICON: string;', + ' FILE_NAME_ICONS: Record;', + ' FILE_EXTENSION_ICONS: Record;', + ' FOLDER_NAME_ICONS: Record;', + ' FOLDER_NAME_OPEN_ICONS: Record;', + '}', + '', + '/** Each pack stays an independent Vite chunk. The browser desk loads only', + ' * the selected pack instead of the native app’s whole multi-pack registry. */', + 'export async function loadIconPack(pack: IconPack): Promise {', + ' switch (pack) {', + ...SUPPORTED_PACKS.map((pack) => ` case ${JSON.stringify(pack)}: return (await import('./vscode-icon-packs/${pack}.generated')).ICON_PACK;`), + ' }', + '}', + '', + ]; return writeFileIfChanged(generatedModulePath, `${lines.join('\n')}\n`); } @@ -460,6 +471,7 @@ function main() { } ensureDir(generatedIconsDir); + ensureDir(generatedPackDir); const copied = []; let changedIconFiles = 0; @@ -502,9 +514,20 @@ function main() { changedIconFiles += 1; } - const moduleChanged = writeGeneratedModule(packMappings, copied, defaultPack); + let changedPackModules = 0; + for (const pack of SUPPORTED_PACKS) { + if (writeGeneratedPackModule(pack, packMappings[pack])) changedPackModules += 1; + } + const currentPackModules = new Set(SUPPORTED_PACKS.map((pack) => `${pack}.generated.ts`)); + for (const existing of fs.readdirSync(generatedPackDir)) { + if (!currentPackModules.has(existing)) { + fs.rmSync(path.join(generatedPackDir, existing), { force: true }); + changedPackModules += 1; + } + } + const moduleChanged = writeGeneratedManifest(defaultPack); const readmeChanged = writeReadme(materialPkg.version ?? 'unknown', copied, defaultPack); - const changedFiles = changedIconFiles + (moduleChanged ? 1 : 0) + (readmeChanged ? 1 : 0); + const changedFiles = changedIconFiles + changedPackModules + (moduleChanged ? 1 : 0) + (readmeChanged ? 1 : 0); if (changedFiles === 0) { console.log(`Icon map already up to date (${copied.length} SVG assets across ${SUPPORTED_PACKS.length} packs).`); diff --git a/web/scripts/test-browser-share-owner-live.ts b/web/scripts/test-browser-share-owner-live.ts index 5874238f..0985b210 100644 --- a/web/scripts/test-browser-share-owner-live.ts +++ b/web/scripts/test-browser-share-owner-live.ts @@ -6,7 +6,9 @@ import { IDBFactory } from 'fake-indexeddb'; import { base64UrlEncode, + base64UrlDecode, buildAdmissionHeaderV3, + deriveReadKeysV3, deriveShareLinkKeys, } from '../src/lib/review/browser-crypto'; import { createOwnedRoomV3, deleteOwnedRoomV3 } from '../src/lib/review/browser-owner-bootstrap'; @@ -15,6 +17,7 @@ import { BrowserShareOwnerRelayClient, EMPTY_SHARE_MANIFEST_DIGEST, buildShareBundleMutations, + digestShareSnapshotManifest, sealDurableShareSnapshot, } from '../src/lib/review/browser-share-owner'; import { openShareCapabilityBundle } from '../src/lib/review/browser-share'; @@ -115,20 +118,21 @@ try { const fileId = base64UrlEncode(new Uint8Array(16).fill(21)); const snapshotId = base64UrlEncode(new Uint8Array(16).fill(23)); - const sealed = sealDurableShareSnapshot({ + const sealed = await sealDurableShareSnapshot({ shareId, epoch: 0, fileId, snapshotId, docType: 'markdown', content: '# Live browser owner\n', snapshotKey: room.keys.readKeys.snapshotKey, }); - await client.uploadSnapshot(fileId, snapshotId, sealed); + const stagedSnapshot = await client.uploadSnapshot(fileId, snapshotId, sealed); sealed.fill(0); - const retained = await client.fetchWithViewCapability(shareSecret); - check(retained.revision === 1 && retained.snapshots.length === 1, 'relay retains opaque snapshot and advances revision'); - const finalRevision = retained.revision + 1; + const staged = await client.fetchWithViewCapability(shareSecret); + check(staged.revision === 0 && staged.snapshots.length === 0, 'relay keeps staged ciphertext private until the atomic publish'); + const finalRevision = staged.revision + 1; + const manifestDigest = digestShareSnapshotManifest([stagedSnapshot]); const active = await client.upsert({ - v: 3, ownerSigningKey: retained.ownerSigningKey, - bundles: buildShareBundleMutations(context(finalRevision, retained.manifestDigest)), + v: 3, ownerSigningKey: staged.ownerSigningKey, + bundles: buildShareBundleMutations(context(finalRevision, manifestDigest)), epoch: 0, revision: finalRevision, currentRoomId: room.roomId, - snapshots: retained.snapshots, placeholders: retained.placeholders, + snapshots: [stagedSnapshot], placeholders: staged.placeholders, deviceId: room.identity.deviceId, }); check(active.currentRoomId === room.roomId, 'final pointer flip activates stable share'); @@ -149,12 +153,18 @@ try { 'Attn-Admission': buildAdmissionHeaderV3(viewKeys.readAdmissionKey, 'read', 'GET', snapshotPath, new Uint8Array(0)), } }); const downloaded = new Uint8Array(await snapshotResponse.arrayBuffer()); - const opened = decryptDurableShareSnapshot(shareId, 0, { + const bundleReadCapability = base64UrlDecode(bundle.readCapabilityKey); + const opened = await decryptDurableShareSnapshot(shareId, 0, { v: 3, shareId, bundleId: bundle.bundleId, epoch: 0, revision: finalRevision, manifestDigest: active.manifestDigest, roomId: room.roomId, tier: 'view', roomCapability: { ownerSigningKey: bundle.ownerSigningKey, - readCapabilityKey: room.keys.readKeys.readCapabilityKey, roomKeys: room.keys.readKeys }, + // The durable bundle is rooted in the share's read capability, not the + // ordinary room's read capability. Derive the matching v3 keys exactly + // as the production resolver does. + readCapabilityKey: new Uint8Array(bundleReadCapability), + roomKeys: deriveReadKeysV3(bundleReadCapability) }, }, fileId, snapshotId, downloaded); + bundleReadCapability.fill(0); check(opened.content === '# Live browser owner\n', 'view bearer resolves and decrypts browser-owned snapshot'); const emptyCommentMailbox = await client.fetchMailbox(shareSecret, 'comment', 0); diff --git a/web/src/App.svelte b/web/src/App.svelte index ce868d23..5275b05a 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -75,6 +75,7 @@ import MediaPlayer from './lib/MediaPlayer.svelte'; import HtmlViewer from './lib/HtmlViewer.svelte'; import DirectoryOverview from './lib/DirectoryOverview.svelte'; + import { nativeFileIconResolver } from './lib/icon-resolver'; import CommandPalette from './lib/CommandPalette.svelte'; import KeyboardShortcutsDialog from './lib/KeyboardShortcutsDialog.svelte'; import ReviewApplyExpand from './lib/ReviewApplyExpand.svelte'; @@ -3814,6 +3815,7 @@ {activeOutlineId} {sharedPaths} unreadByPath={ownerUnreadCountsByPath} + iconResolver={nativeFileIconResolver} onProjectSwitch={handleProjectSwitch} onNavigate={handleSidebarNavigate} onExpand={handleTreeExpand} diff --git a/web/src/BrowserReviewApp.svelte b/web/src/BrowserReviewApp.svelte index 74b8f322..27716158 100644 --- a/web/src/BrowserReviewApp.svelte +++ b/web/src/BrowserReviewApp.svelte @@ -73,6 +73,9 @@ type BrowserCollabDelivery, type BrowserSessionState, } from './lib/review/browser-session'; + import { parseInviteUrl } from './lib/review/browser-invite'; + import { parseShareInvite } from './lib/review/browser-share'; + import { reviewerLifecyclePresentation } from './lib/review/reviewer-lifecycle'; import type { DurableShareBrowserSessionFacade, RememberedPushShareSessionFacade } from './lib/review/browser-share-production'; import type { BrowserPushConsentState } from './lib/review/browser-push-consent'; import { CollabController } from './lib/prosemirror/collab-controller'; @@ -451,15 +454,17 @@ } void session.start().catch((err: unknown) => { - const message = err instanceof Error ? err.message : String(err); const revoked = err instanceof Error && err.name === 'ShareGoneError'; + // State retains a safe generic explanation. Do not write relay or + // capability diagnostics to the browser console. + console.error('[attn] hosted reviewer start failed'); session.close(); // start() should never throw — but if it does, surface a terminal error // so the UI is not stuck in `connecting`. sessionState = { ...sessionState, status: 'error', - error: { kind: revoked ? 'share_revoked' : 'network', message }, + error: { kind: revoked ? 'share_revoked' : 'network', message: 'connection failed' }, }; }); @@ -1344,29 +1349,38 @@ sessionState.status === 'connecting' || (sessionState.status === 'connected' && displayedContent === null), ); + const lifecycle = $derived(reviewerLifecyclePresentation(sessionState.error)); + let recoveryOpen = $state(false); + let recoveryInvite = $state(''); + let recoveryError = $state(null); + let recoveryInput = $state(); + $effect(() => { + if (recoveryOpen) recoveryInput?.focus(); + }); + + function openInviteRecovery(): void { + recoveryError = null; + recoveryOpen = true; + } - const errorMessage = $derived(formatError(sessionState.error)); - - function formatError(err: BrowserSessionState['error']): string | null { - if (!err) return null; - switch (err.kind) { - case 'invite_invalid': - return 'Invalid invite link'; - case 'admission_rejected': - return 'Access denied'; - case 'room_deleted': - return 'This review room has been deleted'; - case 'room_expired': - return 'This review room has expired'; - case 'cursor_too_old': - return 'Session expired — please re-open the invite link'; - case 'share_revoked': - return 'This review has ended'; - case 'device_register': - case 'network': - default: - return 'Could not reach the review relay'; + function submitInviteRecovery(event: SubmitEvent): void { + event.preventDefault(); + const value = recoveryInvite.trim(); + try { + // A complete browser review or durable-share link may recover this + // state. Validate it first without persisting or exposing its fragment. + try { + parseInviteUrl(value); + } catch { + parseShareInvite(value); + } + } catch { + recoveryError = 'Paste the complete attn review link, including the part after #.'; + return; } + // Do not persist, log, or render the capability. BrowserSession strips the + // fragment synchronously after this navigation and keeps it in memory. + window.location.assign(value); } @@ -1386,17 +1400,126 @@ data-live-editing={reviewerAvailability.liveEditing ? 'true' : 'false'} > {#if sessionState.error} -
-

{errorMessage}

-

{sessionState.error.message}

+
+ + attn + + Review +
+
+

Review invitation

+

+ {lifecycle.title} +

+

{lifecycle.diagnosis}

+

+ {lifecycle.privacyNote} +

+ + {#if lifecycle.canPasteInvite} +
+ +
+ {#if recoveryOpen} +
+ + +

+ Include everything after #. It is used only in this browser. +

+ {#if recoveryError} + + {/if} +
+ + +
+
+ {/if} + {/if} + {#if lifecycle.canRetry} +
+ +
+ {/if} + +
{:else if isLoading} -
-

Loading review…

+
+ + attn + + Review +
+
+

Review invitation

+

+ {lifecycle.title} +

+

{lifecycle.diagnosis}

+

+ {lifecycle.privacyNote} +

+
{:else}
@@ -1459,7 +1582,7 @@ + +
+ +
{#if workspace.sharing === 'shared'} - Shared · relay sees only ciphertext + Shared · relay sees only ciphertext {:else} - {sharingLabel(workspace.sharing)} + {sharingLabel(workspace.sharing)} {/if} - - - +
+ + +
+
{#if confirmingDeleteId === workspace.id} diff --git a/web/src/hosted/app/EditorShell.svelte b/web/src/hosted/app/EditorShell.svelte index e6b61d98..ff8ec711 100644 --- a/web/src/hosted/app/EditorShell.svelte +++ b/web/src/hosted/app/EditorShell.svelte @@ -241,6 +241,13 @@ // Promoted-manifest path → fileId map from the review-log watcher: the // follower's substitute for the leader-only authority bindings. let reviewLogBindings = $state>([]); + // The storage-backed projection is the source for a real review. Demo + // content is presentation-only, retained only as a seeded history state + // when no real room exists. + let reviewProjectionState = $state<'idle' | 'ready' | 'failed'>('idle'); + const durableReviewHistory = $derived( + Boolean(reviewStoreRef?.currentRoomId) && reviewProjectionState === 'ready', + ); // ————— multi-file rail state (attn-7xl.3.4) ————— let addingMarkdown = $state(false); @@ -267,6 +274,8 @@ let docTitle = $state(''); let showDocTitle = $state(false); + // A review thread is durable work; “live” describes the connection below, + // not whether this count or its history exists. const reviewCount = $derived( reviewRoomActive && reviewStoreRef ? reviewStoreRef.roomActiveThreadCount @@ -1370,13 +1379,17 @@ // threads have no component to render into. unsubscribe = handle.subscribe((state) => { reviewLogBindings = [...state.bindings]; + reviewProjectionState = state.replay; if (state.roomId !== null) void ensureEditorGraph(true); }); - }).catch(() => undefined); + }).catch(() => { + reviewProjectionState = 'failed'; + }); return () => { cancelled = true; unsubscribe?.(); projection?.close(); + reviewProjectionState = 'idle'; }; }); @@ -2380,6 +2393,15 @@ paletteOpen = !paletteOpen; return; } + // File deletion is deliberately an inline tree confirmation rather than a + // modal dialog. Keep Escape recovery explicit so keyboard users return to + // the active file row instead of a transient destructive state. + if (event.key === 'Escape' && confirmingEntryDelete) { + event.preventDefault(); + confirmingEntryDelete = false; + focusSidebarAnchor('[data-path][data-active="true"]'); + return; + } // Escape steps back from a click-opened thread (collapse expanded // resolved card → clear focus → re-hide click-revealed cards). Never // while a modal or a text field owns the keyboard, and never while @@ -2925,22 +2947,24 @@ }} /> {:else if confirmingEntryDelete && activeEntry} -
{ - if (event.key !== 'Escape') return; - event.stopPropagation(); - confirmingEntryDelete = false; - focusSidebarAnchor('[data-path][data-active="true"]'); - }} + role="group" + aria-label={`Delete ${activeEntry.path}`} > - Delete {activeEntry.path}? + Delete {activeEntry.path}?
- - + +
@@ -3005,6 +3029,26 @@ onResolveComment={resolveReview} onReplyComment={replyToReview} /> + {:else if workspace.reviewCards.length > 0} +
+

Saved review

+

{workspace.reviewCards.length} {workspace.reviewCards.length === 1 ? 'thread' : 'threads'} from this workspace

+
+ {#each workspace.reviewCards as card (card.author + card.body)} +
+ {card.author} · {card.ageLabel} +

{card.body}

+
+ {/each} +
+

Live review adds presence and replies; saved feedback stays here.

+
+ {:else if reviewProjectionState === 'failed'} +
+

Review history

+

Review history is temporarily unavailable

+

Your document is safe on this device. Reopen this workspace to try loading its saved feedback again.

+
{/if} {/snippet} @@ -3031,6 +3075,7 @@ onOpenDesk={() => window.location.assign('/app')} activeEntryPath={activeEntry?.path} {shareOpen} + reviewHistoryAvailable={durableReviewHistory || workspace.reviewCards.length > 0 || reviewProjectionState === 'failed'} actions={desktopHeaderActions} footer={desktopSidebarFooter} content={documentSurface} @@ -3112,6 +3157,7 @@ type="button" bind:this={shareButton} data-sharing={sharingActive} + data-slot="owner-mobile-share" onclick={() => openShare(shareButton)} > {#if sharingActive}{/if} @@ -3353,7 +3399,7 @@ {#if reviewSheetOpen} {#if reviewRoomActive && ReviewMarginComponent} @@ -3371,7 +3417,8 @@ onReplyComment={replyToReview} /> - {:else} + {:else if workspace.reviewCards.length > 0} +

Live review adds presence and replies; saved feedback stays here.

{#each workspace.reviewCards as card (card.author + card.body)}
{card.author} · {card.ageLabel} @@ -3382,6 +3429,10 @@ No review yet. Share this workspace to open an encrypted room around it.

{/each} + {:else if reviewProjectionState === 'failed'} +

Saved review history could not load. Reopen this workspace to try again.

+ {:else} +

No review yet. Share this workspace to open an encrypted room around it.

{/if} {/if} diff --git a/web/src/hosted/app/HostedDesktopWorkspaceFrame.svelte b/web/src/hosted/app/HostedDesktopWorkspaceFrame.svelte index 85a634c4..5ebe53bb 100644 --- a/web/src/hosted/app/HostedDesktopWorkspaceFrame.svelte +++ b/web/src/hosted/app/HostedDesktopWorkspaceFrame.svelte @@ -1,6 +1,8 @@
- {code} + + + {code}
-
-

What the relay can see

-

- attn's server is honest-but-curious: it routes ciphertext and never holds a - key. This is the whole of what it observes. -

-
-
-

It sees

-
    -
  • Room, peer, device and envelope ids
  • -
  • Envelope sizes, counts and timing
  • -
  • Your IP address
  • -
  • That a connection was negotiated — never the negotiation's contents
  • -
-
-
-

It cannot see

-
    -
  • The document, at any version
  • -
  • Comment and suggestion text
  • -
  • File and folder names
  • -
  • Who you are, beyond an id you generate
  • -
  • The room key — it lives after the # in your invite link, and browsers - never send that part to a server
  • -
+
+

End-to-end encrypted when you share. The relay routes ciphertext and never holds a key.

+
+ Read the threat model +
+

What the relay can see

+

+ attn's server is honest-but-curious: it routes ciphertext and never holds a + key. This is the whole of what it observes. +

+
+
+

It sees

+
    +
  • Room, peer, device and envelope ids
  • +
  • Envelope sizes, counts and timing
  • +
  • Your IP address
  • +
  • That a connection was negotiated — never the negotiation's contents
  • +
+
+
+

It cannot see

+
    +
  • The document, at any version
  • +
  • Comment and suggestion text
  • +
  • File and folder names
  • +
  • Who you are, beyond an id you generate
  • +
  • The room key — it lives after the # in your invite link, and browsers + never send that part to a server
  • +
+
+
+

+ Identities are derived on your machine from the room secret; the relay issues none. The + threat model is written down in + + relay-spec.md, and the review that tested it against the implementation is in + + security-review.md. +

-
-

- Identities are derived on your machine from the room secret; the relay issues none. The - threat model is written down in - - relay-spec.md, and the review that tested it against the implementation is in - - security-review.md. -

+