diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl
index 1a796f3c..a6246816 100644
--- a/.beads/interactions.jsonl
+++ b/.beads/interactions.jsonl
@@ -815,3 +815,67 @@
{"id":"int-6c72086930397d90a7623af9b89fdea9","kind":"field_change","created_at":"2026-09-03T23:02:03.869571Z","actor":"Angus Bezzina","issue_id":"attn-6q7b.1","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
{"id":"int-e81c0c2ba1299115b3a95da02f9d8c36","kind":"field_change","created_at":"2026-09-03T23:02:04.497331Z","actor":"Angus Bezzina","issue_id":"attn-6q7b.2","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
{"id":"int-2e4b2f65fe6de7795ab8ec4d3bc18ea3","kind":"field_change","created_at":"2026-09-03T23:02:05.074798Z","actor":"Angus Bezzina","issue_id":"attn-6q7b.3","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}}
+{"id":"int-d24af1f19d639bdad617256430428c1e","kind":"field_change","created_at":"2026-09-03T23:54:30.939733Z","actor":"Angus Bezzina","issue_id":"attn-6q7b.6","extra":{"field":"priority","new_value":"2","old_value":"3"}}
+{"id":"int-380484b8b80ea635adcbe234fce66737","kind":"field_change","created_at":"2026-09-03T23:54:31.796752Z","actor":"Angus Bezzina","issue_id":"attn-6q7b.4","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"CI parity job green (run 33817511711, all 5 jobs); staging deploy green end to end without the npm pin (run 33818979937, 4/4 parity); fresh-clone sequence verified"}}
+{"id":"int-cd7a92fb6bedf80d4a6aa5ba6f265469","kind":"field_change","created_at":"2026-09-03T23:54:32.127855Z","actor":"Angus Bezzina","issue_id":"attn-6q7b.5","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"CI parity job green (run 33817511711, all 5 jobs); staging deploy green end to end without the npm pin (run 33818979937, 4/4 parity); fresh-clone sequence verified"}}
+{"id":"int-ea7d01bf4d7af3c2b2700f9d29fb2b83","kind":"field_change","created_at":"2026-09-07T16:08:05.628201Z","actor":"Angus Bezzina","issue_id":"attn-qiwt","extra":{"field":"status","new_value":"in_progress","old_value":"open"}}
+{"id":"int-b00192b8dbd059917c6f88aad973e33f","kind":"field_change","created_at":"2026-09-07T16:19:49.803341Z","actor":"Angus Bezzina","issue_id":"attn-qiwt","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Created and pushed planning/collab/agent-margin-feedback.md and agent-margin-feedback.html in commit 22507737. Verified individual/selected/workspace exports, format parity, clipboard fallback, simulated listener cancellation and replies, escaped input, desktop/mobile layout, and no axe violations, runtime errors, or external requests. Product implementation remains proposed for review."}}
+{"id":"int-f52f91104615ed0640945750ddb38930","kind":"field_change","created_at":"2026-09-07T16:32:45.828314Z","actor":"Angus Bezzina","issue_id":"attn-hau1","extra":{"field":"status","new_value":"in_progress","old_value":"open"}}
+{"id":"int-0958d21a10ff51b133fa1916d55c02d2","kind":"field_change","created_at":"2026-09-07T16:36:48.747987Z","actor":"Angus Bezzina","issue_id":"attn-hau1","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Published visual product mockup and still preview in b8dee252. Verified marking, individual/batch/workspace export, modal keyboard behavior, pause/resume, threaded simulated replies, accepting preview edits without resolving comments, escaped input and clipboard fallback. Six desktop/mobile states passed axe; no runtime errors or external requests."}}
+{"id":"int-70e7d559ce906aa3b28c092f5946790c","kind":"field_change","created_at":"2026-09-07T16:44:34.325512Z","actor":"Angus Bezzina","issue_id":"attn-bmom","extra":{"field":"status","new_value":"in_progress","old_value":"open"}}
+{"id":"int-ab536c33b4070530839be212e7b6c72d","kind":"field_change","created_at":"2026-09-07T16:46:58.05511Z","actor":"Angus Bezzina","issue_id":"attn-bmom","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Direct Copy, Copy selected and Copy all are implemented in the mockup, with inline selection and no review dialog or numbered walkthrough. Updated the one-pager and preview. Verified desktop/mobile clipboard paths, scope, resolved exclusion, fallback, and live replies with zero axe violations or runtime errors. Published in 3d5d2227."}}
+{"id":"int-3f954233f8d33c6c95a0d2c7a4206c27","kind":"field_change","created_at":"2026-09-07T17:00:17.498509Z","actor":"Angus Bezzina","issue_id":"attn-fh2u","extra":{"field":"status","new_value":"in_progress","old_value":"open"}}
+{"id":"int-ed93ccc06545ab45217d58c8e7febca8","kind":"field_change","created_at":"2026-09-07T17:13:25.756252Z","actor":"Angus Bezzina","issue_id":"attn-fh2u","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Updated and browser-verified the HTML/Markdown CLI proposal and preview. Created and validated open epic attn-aysu with six workstreams, 24 tasks and 49 acyclic dependencies. Artifact commit f2dac11d pushed to origin/angus/agent-margin-feedback-plan. Product implementation remains open under the epic."}}
+{"id":"int-847a3f1c3d54f7ccee26d295f252f1fa","kind":"field_change","created_at":"2026-09-07T17:17:22.911095Z","actor":"Angus Bezzina","issue_id":"attn-y87n","extra":{"field":"status","new_value":"in_progress","old_value":"open"}}
+{"id":"int-fe028168f4a6a3b5f5925cf90be21444","kind":"field_change","created_at":"2026-09-07T17:19:23.237092Z","actor":"Angus Bezzina","issue_id":"attn-y87n","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Created and pushed angus/agentic-enhancements at f2dac11d. Opened https://github.com/lightsofapollo/attn/pull/22 and verified OPEN, draft=true, base main, exact requested head and only the three planning artifact files. Existing unrelated Beads edit and stash preserved."}}
+{"id":"int-792c9d9f2b5471a38c39a48560e8c208","kind":"field_change","created_at":"2026-09-07T17:30:31.511949Z","actor":"Angus Bezzina","issue_id":"attn-aysu","extra":{"field":"status","new_value":"in_progress","old_value":"open"}}
+{"id":"int-a72851c1405b335f5bd73b3d91580e30","kind":"field_change","created_at":"2026-09-07T17:30:32.430845Z","actor":"Angus Bezzina","issue_id":"attn-aysu.1.1","extra":{"field":"status","new_value":"in_progress","old_value":"open"}}
+{"id":"int-1cee017f95642698054b3ac33f0f26a1","kind":"field_change","created_at":"2026-09-07T19:12:28.88846Z","actor":"Angus Bezzina","issue_id":"attn-aysu.1.1","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Versioned contract, scope semantics, revisions and boundaries are implemented and tested."}}
+{"id":"int-fb2d4dc37816269387dbe989fb6e286a","kind":"field_change","created_at":"2026-09-07T19:12:30.018657Z","actor":"Angus Bezzina","issue_id":"attn-aysu.1.2","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Native and browser-private routing persist with tested UI/store integration."}}
+{"id":"int-99b1537d0d06ea9da874e110e125cafb","kind":"field_change","created_at":"2026-09-07T19:12:30.419385Z","actor":"Angus Bezzina","issue_id":"attn-aysu.1.3","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Native and browser-private routing persist with tested UI/store integration."}}
+{"id":"int-54b025ffb83f37052944460b94bab152","kind":"field_change","created_at":"2026-09-07T19:12:31.231958Z","actor":"Angus Bezzina","issue_id":"attn-aysu.1.4","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Compatibility, cleanup and corrupt/future-state behavior are implemented and tested."}}
+{"id":"int-8aac2dfa8e8db0e529e5ab4ddc1b9c2f","kind":"field_change","created_at":"2026-09-07T19:12:32.075677Z","actor":"Angus Bezzina","issue_id":"attn-aysu.1","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Personal For-agent routing workstream is implemented."}}
+{"id":"int-731d6e88068d90305215ec0092b074f8","kind":"field_change","created_at":"2026-09-07T19:12:32.843297Z","actor":"Angus Bezzina","issue_id":"attn-aysu.2.1","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Shared deterministic feedback projection is implemented and tested."}}
+{"id":"int-689d4518e6078668373b7aded9a353f4","kind":"field_change","created_at":"2026-09-07T19:12:33.550162Z","actor":"Angus Bezzina","issue_id":"attn-aysu.2.2","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Project/file scope and binding safety are implemented and tested."}}
+{"id":"int-63c6b5c1fffc49dc97acd1c04c3d8139","kind":"field_change","created_at":"2026-09-07T19:12:34.439139Z","actor":"Angus Bezzina","issue_id":"attn-aysu.2.3","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Markdown and HTML anchor semantics are implemented and tested."}}
+{"id":"int-c578a71b7d96561a70deba396c192390","kind":"field_change","created_at":"2026-09-07T19:12:34.839227Z","actor":"Angus Bezzina","issue_id":"attn-aysu.2.4","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Markdown and HTML anchor semantics are implemented and tested."}}
+{"id":"int-96bca9457039c77ce24eebc699768128","kind":"field_change","created_at":"2026-09-07T19:12:35.737165Z","actor":"Angus Bezzina","issue_id":"attn-aysu.2","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Grounded source projection workstream is implemented."}}
+{"id":"int-b155135f220888890f85f9e9ef7a6106","kind":"field_change","created_at":"2026-09-07T19:12:36.532733Z","actor":"Angus Bezzina","issue_id":"attn-aysu.3.1","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"For-agent composer and margin controls are implemented."}}
+{"id":"int-429a1d4823699f771b5ef157ddb2dd73","kind":"field_change","created_at":"2026-09-07T19:12:37.36044Z","actor":"Angus Bezzina","issue_id":"attn-aysu.3.2","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Copy, Copy selected and Copy all are implemented with file/project scope."}}
+{"id":"int-14e1368ed535b8897b221ffd7f2a0eea","kind":"field_change","created_at":"2026-09-07T19:12:38.18393Z","actor":"Angus Bezzina","issue_id":"attn-aysu.3.3","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Context packets and clipboard fallback are implemented and tested."}}
+{"id":"int-25d28af42051e0a0004283a61086d07f","kind":"field_change","created_at":"2026-09-07T19:12:38.972437Z","actor":"Angus Bezzina","issue_id":"attn-aysu.3.4","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Native, hosted and reviewer surfaces pass web checks and builds."}}
+{"id":"int-462e4178b864734958b6b3ba43aeeafc","kind":"field_change","created_at":"2026-09-07T19:12:39.827364Z","actor":"Angus Bezzina","issue_id":"attn-aysu.3","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Direct copy and marking workstream is implemented."}}
+{"id":"int-46c00b5cbc385071b3b822aa4b37b601","kind":"field_change","created_at":"2026-09-07T19:12:40.608969Z","actor":"Angus Bezzina","issue_id":"attn-aysu.4.1","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"attn feedback grammar and output are implemented."}}
+{"id":"int-a033d5f4a6a1499a8f62a42498d85fbf","kind":"field_change","created_at":"2026-09-07T19:12:41.424463Z","actor":"Angus Bezzina","issue_id":"attn-aysu.4.2","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Offline coherent snapshot reads are implemented."}}
+{"id":"int-4d38574171bffc789568b26e8f10e313","kind":"field_change","created_at":"2026-09-07T19:12:42.232568Z","actor":"Angus Bezzina","issue_id":"attn-aysu.4.3","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"CLI subprocess and regression tests pass."}}
+{"id":"int-677961c90850739d521a1ed89dec89ae","kind":"field_change","created_at":"2026-09-07T19:12:43.095261Z","actor":"Angus Bezzina","issue_id":"attn-aysu.4","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Project-scoped snapshot command workstream is implemented."}}
+{"id":"int-eff2785f0072c901d943a248b64847f2","kind":"field_change","created_at":"2026-09-07T19:12:43.874699Z","actor":"Angus Bezzina","issue_id":"attn-aysu.5.1","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Persisted routing, comment and source changes feed coherent projections."}}
+{"id":"int-1a05f0c6864752fcfc0574b68291b9fb","kind":"field_change","created_at":"2026-09-07T19:12:44.698281Z","actor":"Angus Bezzina","issue_id":"attn-aysu.5.2","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Typed Unix-socket subscription and flushed NDJSON are implemented."}}
+{"id":"int-728d1f61e6dbd7d2508db7abcceafb36","kind":"field_change","created_at":"2026-09-07T19:12:45.538419Z","actor":"Angus Bezzina","issue_id":"attn-aysu.5.3","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Restart, disconnect, closed-pipe and bounded reset behavior are implemented."}}
+{"id":"int-14f8fccd74cbb5b71d345e1e45d65327","kind":"field_change","created_at":"2026-09-07T19:12:46.377422Z","actor":"Angus Bezzina","issue_id":"attn-aysu.5.4","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Stream ordering, revision separation and dedup consumer rules are tested."}}
+{"id":"int-827a64583c974afd2584cd5df209c30f","kind":"field_change","created_at":"2026-09-07T19:12:47.243638Z","actor":"Angus Bezzina","issue_id":"attn-aysu.5","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Continuous feedback watch workstream is implemented."}}
+{"id":"int-1792943e0ea0e9d2439d7e8abb305f1d","kind":"field_change","created_at":"2026-09-07T19:12:48.008726Z","actor":"Angus Bezzina","issue_id":"attn-aysu.6.1","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"External source revisions and anchor refresh preserve existing editor conflict behavior."}}
+{"id":"int-ba06212b6d9262f77400ea5205929e3b","kind":"field_change","created_at":"2026-09-07T19:12:48.845269Z","actor":"Angus Bezzina","issue_id":"attn-aysu.6.2","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Provider-neutral host recipe and watch adapter are documented."}}
+{"id":"int-58f312d318086bfc2bf2dccc515f03b3","kind":"field_change","created_at":"2026-09-07T19:12:49.692351Z","actor":"Angus Bezzina","issue_id":"attn-aysu.6.3","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Disposable end-to-end fixtures cover feedback, source edit, refresh and lifecycle behavior."}}
+{"id":"int-ffd4223721036aa15f50e6612b1a4ddf","kind":"field_change","created_at":"2026-09-07T19:12:50.516541Z","actor":"Angus Bezzina","issue_id":"attn-aysu.6.4","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Codex CLI 0.153.4 and Claude Code 2.1.263 completed live snapshot/watch edits; evidence records 209.4 ms stream delivery separately."}}
+{"id":"int-35cd9f33148641014544f196e93d0a0c","kind":"field_change","created_at":"2026-09-07T19:15:32.618344Z","actor":"Angus Bezzina","issue_id":"attn-aysu.6.5","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Implementation commit 0d5c754b is pushed; help, evidence, Rust/web gates and boundaries are published on draft PR #22."}}
+{"id":"int-1746ef8cafa19f1740ee1df3748781c0","kind":"field_change","created_at":"2026-09-07T19:15:33.470465Z","actor":"Angus Bezzina","issue_id":"attn-aysu.6","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Original-file workflow, provider-neutral docs, two-host live evidence and release gates are complete."}}
+{"id":"int-c75a02f565b3a0c4ab5e0ab0638b2d6f","kind":"field_change","created_at":"2026-09-07T19:15:34.318058Z","actor":"Angus Bezzina","issue_id":"attn-aysu","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Agent feedback routing, direct copy, snapshot/watch CLI, source grounding, tests and two-host evidence are implemented and pushed in 0d5c754b."}}
+{"id":"int-3a70ab4ae353cce1af38594db8b0f297","kind":"field_change","created_at":"2026-09-08T22:18:53.494447Z","actor":"Angus Bezzina","issue_id":"attn-wrf3","extra":{"field":"status","new_value":"in_progress","old_value":"open"}}
+{"id":"int-af26c5929b666fe89bdba7af16500891","kind":"field_change","created_at":"2026-09-08T22:18:54.018377Z","actor":"Angus Bezzina","issue_id":"attn-wrf3.1","extra":{"field":"status","new_value":"in_progress","old_value":"open"}}
+{"id":"int-6ea49c1307ebc34c5413200195a9c010","kind":"field_change","created_at":"2026-09-08T22:18:54.530503Z","actor":"Angus Bezzina","issue_id":"attn-wrf3.4","extra":{"field":"status","new_value":"in_progress","old_value":"open"}}
+{"id":"int-9c4287b485909c3320a83d5b464c0140","kind":"field_change","created_at":"2026-09-08T22:41:52.515136Z","actor":"Angus Bezzina","issue_id":"attn-wrf3.1","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented and validated. HtmlAnnotateToggle.svelte + HtmlViewer children slot; App.svelte htmlAnnotateMode state, setInspect(htmlAnnotatable && mode), reset keyed on displayed document. Tests: html-annotate-toggle.test.ts, html-annotation-bridge-inspect.test.ts. svelte-check 0 errors, unit 147 files pass, native E2E 31 PASS."}}
+{"id":"int-27d3e229af5aecbb57922fe4ac9a51eb","kind":"field_change","created_at":"2026-09-08T22:41:53.404222Z","actor":"Angus Bezzina","issue_id":"attn-wrf3.4","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented and validated. doc-runtime inspect:false now unconditionally tears down hover/scope state and pickScope is gated on inspectEnabled; bridge #establish always re-states inspect (explicit false). 8 new Playwright cases in html-annotation-runtime.spec.ts (35 pass) + bridge unit test."}}
+{"id":"int-4e8c27e9832325aaf051bf9d4207ca5c","kind":"field_change","created_at":"2026-09-08T22:41:54.095243Z","actor":"Angus Bezzina","issue_id":"attn-wrf3.2","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Implemented and validated. BrowserReviewApp (desktop + mobile, toggle shares the dock's 1rem baseline bottom-right) and EditorShell (html presentation only, clears thumb dock on mobile) gate setInspect on htmlAnnotatable && mode; shared component, copy and data-slot. Source-level parity test across all three shells. Deviation: hosted reviewer resets on file switch only, keeps mode across a republish (matches native)."}}
+{"id":"int-baca2b231a845fa6d5b9430cfcacfbe4","kind":"field_change","created_at":"2026-09-08T22:41:54.789476Z","actor":"Angus Bezzina","issue_id":"attn-wrf3.3","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Implemented and validated. ⌘⇧N / Ctrl+Shift+N (verified free) in keyboard.ts + all three shells; Esc leaves the mode beneath composers/dialogs; aria-pressed, focus ring, polite live region, reduced motion; rows in ShortcutsSheet and KeyboardShortcutsDialog. Test: html-annotate-hotkey.test.ts."}}
+{"id":"int-ca4966f23e7523ce00dbd6ef9e8c55c3","kind":"field_change","created_at":"2026-09-08T22:41:55.45714Z","actor":"Angus Bezzina","issue_id":"attn-wrf3.5","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Implemented and validated. tests/fixtures/interactive.html + annotate-mode walk on owner and reviewer in scripts/test-html-annotation-e2e.sh (exit 0, 31 PASS); hosted assertions in the Playwright runtime spec; html-annotation.md §Goal/§5 and amendments.md decision #21 updated. Hosted shell-level browser E2E not added (no harness) — follow-up filed."}}
+{"id":"int-75df4eac99e7d680d7728c6c6a5697ed","kind":"field_change","created_at":"2026-09-08T22:41:56.198886Z","actor":"Angus Bezzina","issue_id":"attn-wrf3","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"All five children complete. Annotate mode off by default on native, hosted reviewer and hosted owner; pinned shell-owned toggle (data-slot html-annotate-toggle, ⌘⇧N, Esc); runtime/bridge hardened; E2E + docs updated. Validation: svelte-check 0 errors, unit 147 files 0 failures, Playwright runtime spec 35 pass, native dual-daemon E2E 31 PASS. Uncommitted in working tree on angus/agentic-enhancements."}}
+{"id":"int-881ba525d187746101dabdf0a5baf913","kind":"field_change","created_at":"2026-09-10T19:15:18.970577Z","actor":"Angus Bezzina","issue_id":"attn-926b","extra":{"field":"status","new_value":"in_progress","old_value":"open"}}
+{"id":"int-411a70a343614a7dfd4147e5df1c621c","kind":"field_change","created_at":"2026-09-10T19:15:25.762413Z","actor":"Angus Bezzina","issue_id":"attn-926b.1","extra":{"field":"status","new_value":"in_progress","old_value":"open"}}
+{"id":"int-4b4844cfedfa134fd5930db6cc159904","kind":"field_change","created_at":"2026-09-10T19:15:26.612776Z","actor":"Angus Bezzina","issue_id":"attn-926b.2","extra":{"field":"status","new_value":"in_progress","old_value":"open"}}
+{"id":"int-7100f7fe63284175091902fb1c81770b","kind":"field_change","created_at":"2026-09-10T19:15:27.469447Z","actor":"Angus Bezzina","issue_id":"attn-926b.3","extra":{"field":"status","new_value":"in_progress","old_value":"open"}}
+{"id":"int-a9318d4dd2b3692ccfe4eb91feb2307c","kind":"field_change","created_at":"2026-09-10T19:46:26.582395Z","actor":"Angus Bezzina","issue_id":"attn-926b.1","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented by subagents; verified by parent: svelte-check 0 errors, 150 unit test files green, native two-daemon UI walk 40/40 feature assertions with screenshots."}}
+{"id":"int-aaa981b29764edead512def02e794d9c","kind":"field_change","created_at":"2026-09-10T19:46:27.135687Z","actor":"Angus Bezzina","issue_id":"attn-926b.2","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented by subagents; verified by parent: svelte-check 0 errors, 150 unit test files green, native two-daemon UI walk 40/40 feature assertions with screenshots."}}
+{"id":"int-73b22c7c8cc8d3c051e99c98ba231449","kind":"field_change","created_at":"2026-09-10T19:46:27.710706Z","actor":"Angus Bezzina","issue_id":"attn-926b.3","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented by subagents; verified by parent: svelte-check 0 errors, 150 unit test files green, native two-daemon UI walk 40/40 feature assertions with screenshots."}}
+{"id":"int-cf8622637bfa23121ec6176d013df9ca","kind":"field_change","created_at":"2026-09-10T19:46:31.216243Z","actor":"Angus Bezzina","issue_id":"attn-926b","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"All five UI items implemented and verified; see notes."}}
+{"id":"int-ca202224b4d8ba2a1d71b0e28803d095","kind":"field_change","created_at":"2026-09-10T20:38:58.95154Z","actor":"Angus Bezzina","issue_id":"attn-926b.4","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Done: 24px borderless ghost icons right-aligned; shared AssignToAgentCheckbox (appearance-none, primary fill, tick glyph, forced-colors fallback); new scope titles; WorkspaceEditorFrame rail body lost mb-2 and the 8px gap moved into ReviewMargin fitBottom clearance; label/badge updated. Verified: svelte-check 0 errors, 150 unit files green, native walk measured dock gap 0px, badge bg == --primary, footer icons 24px/0 border/right-aligned."}}
diff --git a/README.md b/README.md
index 7c2ff38e..c39067e6 100644
--- a/README.md
+++ b/README.md
@@ -127,8 +127,9 @@ attn daemon is stopped. Watch mode uses the local daemon socket and exits with
an actionable error if that daemon disconnects. Agents edit the original files
with their normal tools; attn's file watcher refreshes the open document.
-Margin actions also support **Copy**, **Copy selected**, and **Copy all** with
-file or project scope. These actions keep threads open. Native For-agent marks
+Each comment card has a **Copy** icon next to its time badge, and a bar pinned
+to the bottom of the rail copies **all** agent-assigned comments with file or
+project scope. These actions keep threads open. Native For-agent marks
are visible to the local CLI; hosted-browser marks remain private to that
browser and can be copied there.
diff --git a/planning/collab/agent-margin-feedback.md b/planning/collab/agent-margin-feedback.md
index 58d65b4e..8e3faffa 100644
--- a/planning/collab/agent-margin-feedback.md
+++ b/planning/collab/agent-margin-feedback.md
@@ -26,7 +26,7 @@ The agent checks the quote against the current file, makes changes under its exi
## Keep manual copying immediate
- **For agent** is a durable personal mark in attn's storage. The human remains the author; room visibility stays the same.
-- **Copy**, **Copy selected**, and **Copy all** write directly to the clipboard with the same contextual records. Inline checkboxes and document/workspace scope determine the batch. No review, preview or confirmation step.
+- **Copy** (per comment, next to the time badge) and **Copy all** (the bar pinned to the rail bottom) write directly to the clipboard with the same contextual records. Document/workspace scope determines the batch; there is no per-comment selection ("Copy selected" was dropped 2026-09-10). Success is shown by the icon turning into a check, never by text.
- Exclude resolved/unmarked threads. Show a selectable-text fallback only when clipboard access fails. Never silently truncate a batch.
## Implementation and boundaries
diff --git a/planning/collab/amendments.md b/planning/collab/amendments.md
index f5b2e49e..69fd88f3 100644
--- a/planning/collab/amendments.md
+++ b/planning/collab/amendments.md
@@ -352,10 +352,14 @@ The product is reframed as **agentic collaboration** — primary use case is an
**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. attn-z64t applies the same decision separately to the native owner, hosted author, and hosted reviewer: all are share-first, all retain `allow-scripts` without `allow-same-origin`, and the hosted author gets no additional confirmation beyond their explicit share action. Pins: `html-annotation.md` §3, §4, §7.
+### HTML annotation, continued (decision 21 — 2026-09-08)
+
+**21. Annotate mode on an HTML document is off by default and entered through a shell-owned toggle.** Until 2026-09-08 every shell derived "this HTML document is under review" and immediately sent the frame `inspect: true`, so the injected runtime swallowed every click on a hovered element and painted hover chrome for the whole session — the artefact's own links, buttons, tabs and inputs stopped working the moment it was shared. Interactive artefacts (dashboards, prototypes, demos such as `planning/collab/agent-loop-roadmap.html`) are precisely what this surface exists to review, and a review surface that breaks the thing under review is not one. The decision (Angus, 2026-09-08, epic `attn-wrf3`): annotate mode is **off** for every HTML document, a shared one included; a small note button pinned to the bottom-right of the document viewport turns it on (hover outline, breadcrumb chip, click-to-comment exactly as before) and off again (interactivity restored); the mode persists until the person turns it off, so submitting or cancelling a note does not exit it; `⌘⇧N` toggles it and `Esc` leaves it beneath any composer or dialog. The toggle is **shell-owned chrome rendered outside the untrusted frame**, consistent with #19/#20 — the document can neither draw nor press it, so it cannot put itself into a mode that swallows its own clicks. The shell sends `inspect: enabled` only as `annotatable && mode`, the frame boots with the surface off, the bridge re-states the mode (explicitly, `false` included) after every handshake, and the runtime refuses to emit a `scopePicked` proposal by any route once the mode is off. What does not change: existing pins render in both modes (`renderAnchors` is independent of `inspect`), the rail / replies / resolve are untouched, the text-selection Comment pill stays available in both modes, and the script posture is unchanged (the runtime is injected whenever the document is annotatable, not when the toggle is on). Pins: `html-annotation.md` §Goal, §5 `inspect`; supersedes the "with no mode switch" clause of the 2026-08-04 note.
+
### 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: 20 decisions, all previously-open questions closed.** Open implementation work is now bounded by the work itself, not by undecided design.
+**Total: 21 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
index 18ab5c51..4ccd22ca 100644
--- a/planning/collab/html-annotation.md
+++ b/planning/collab/html-annotation.md
@@ -1,6 +1,6 @@
# HTML document annotation — design note
-Status: **locked** (2026-08-04). Phase 0 of epic `attn-61t`.
+Status: **locked** (2026-08-04; §Goal and §5 `inspect` amended 2026-09-08 by `attn-wrf3`). 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.
@@ -10,7 +10,14 @@ Supersedes: the "Design B / distinct localhost origin" decision recorded on `att
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.
+(including the `cell ‹ row ‹ table` scope chain).
+
+Element annotation is entered through one explicit switch — a shell-owned note toggle
+pinned to the document viewport — and is **off by default**, a document under review
+included (`attn-wrf3`, 2026-09-08). Text-range commenting via the selection pill needs no
+switch and works in both modes. The original note read "with no mode switch"; that
+coupled *under review* with *every click is a comment*, which broke the interactive
+artefacts (dashboards, prototypes, demos) this surface most needs to review.
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
@@ -210,7 +217,7 @@ ignored by both sides so the protocol can extend without a lockstep upgrade.
| `setAnchorState` | `{ anchorId, state }` | `default` / `active` / `resolved` |
| `focusAnchor` | `{ anchorId, scrollIntoView }` | rail card → document |
| `dismissSelection` | `{}` | composer cancelled |
-| `inspect` | `{ enabled }` | this document is under review; annotate it (see below) |
+| `inspect` | `{ enabled }` | the person's annotate mode, gated by the shell (see below) |
| `theme` | `{ mode, tokens }` | PAPER/INK switch |
`explicit` separates *the person asked* (Comment pill, element click, breadcrumb entry)
@@ -219,17 +226,35 @@ from *the person is merely dragging a selection*. A shell must answer an explici
explicit proposal is the failure mode attn-yqun.3 exists to close.
`inspect` gates the whole element-annotation surface: hover outline, breadcrumb chip, and
-click-to-comment all appear together and only once the shell says the document can
-actually take a comment.
+click-to-comment all appear together. It is **gated by the person's annotate toggle,
+default off**: the shell sends `enabled: true` only when the document is annotatable
+(shared, capability declared) **and** the person has turned the mode on, and sends
+`false` the moment either half stops holding. The frame boots with the surface off, and
+the bridge re-states the current mode after every handshake, so a reloaded or
+republished frame lands in the mode the shell holds rather than whatever it last
+remembered. (Amended 2026-09-08, `attn-wrf3`; previously "under review" alone turned it
+on.)
They are one switch rather than two because the chip is opaque and painted *over* the
page. An always-visible chip occludes whatever sits above the hovered element and, being
clickable, swallows clicks there — so on a document that is merely being read it would
break the page's own links to offer an affordance that could answer nothing but "share
-this first". Under review, that same interception is the point: clicking a link means
-commenting on it, not following it. Before then the document behaves exactly like a
-document, and the text-selection pill remains the way to ask — it answers, with the
-reason.
+this first". And a document under review is, until someone asks to annotate it, still a
+document being read: a dashboard's tabs, a prototype's buttons and a demo's links have
+to keep working, or the artefact cannot be reviewed *as* the thing it is. Once the
+person asks, that same interception is the point: clicking a link means commenting on it,
+not following it. The text-selection pill is independent of the switch and remains the
+way to ask in either mode — it answers, with the reason.
+
+The switch itself is **shell-owned chrome**: a small note button pinned to the
+bottom-right of the document viewport (`HtmlAnnotateToggle.svelte`,
+`data-slot="html-annotate-toggle"`), rendered *outside* the frame, so the document can
+neither draw it nor press it (amendments.md #19/#20). It is shown only when the document
+is annotatable; `⌘⇧N` (`Ctrl+Shift+N`) toggles it and `Esc` leaves the mode when no
+composer or dialog sits above it. The mode stays on until the person turns it off —
+submitting or cancelling a note does not exit it. Turning it off mid-hover takes the chip
+and outline down; turning it off with a composer open keeps the composer (the person may
+be typing) and the frame refuses to propose again until the mode returns.
Two invariants hold inside the frame. A click commits only to the element the chip is
currently naming, so nothing is ever anchored to something the person could not see was
diff --git a/scripts/test-html-annotation-e2e.sh b/scripts/test-html-annotation-e2e.sh
index 0f6e4374..74e9220c 100755
--- a/scripts/test-html-annotation-e2e.sh
+++ b/scripts/test-html-annotation-e2e.sh
@@ -13,6 +13,9 @@
# 5. Assert the runtime booted inside the opaque-origin frame, that the
# comment margin mounts for an annotatable HTML doc, and that the
# capability survived the encrypted round-trip.
+# 6. Annotate mode (attn-wrf3): on both sides, the page's own button keeps
+# working under review; pressing the pinned note toggle turns the same
+# click into a comment composer; pressing it again hands the click back.
#
# Why the assertions look indirect: the document renders in a cross-origin
# sandboxed iframe, so the automation bridge (which evaluates in the SHELL's
@@ -21,15 +24,23 @@
# capability on the received snapshot, the shell's own annotation wiring, and
# the mounted margin — rather than pretending to inspect the frame's DOM.
#
-# THE IN-FRAME INTERACTION LAYER IS NOT TESTED HERE, and pretending otherwise is
-# how the hover/click layer once shipped green while being unusable by hand.
-# Hovering, the label chip, element clicks and the Comment pill are driven with
-# a real mouse against a real opaque-origin frame in
+# THE IN-FRAME INTERACTION LAYER IS MOSTLY NOT TESTED HERE, and pretending
+# otherwise is how the hover/click layer once shipped green while being unusable
+# by hand. Hovering, the label chip, element clicks and the Comment pill are
+# driven with a real mouse against a real opaque-origin frame in
# web/e2e/html-annotation-runtime.spec.ts (`npm run test:e2e:html-annotation`).
# What this script uniquely proves is the part that needs two daemons and a
# relay: that the capability, the bytes and the runtime survive the encrypted
# round trip, and that both sides' shells wire an annotatable frame up.
#
+# The one in-frame behaviour it does exercise is the annotate-mode switch, and
+# it does so through the FIXTURE: tests/fixtures/interactive.html answers a
+# postMessage from the shell by hovering and clicking its own button with the
+# event sequence the runtime listens to, then reports its DOM state back. That
+# is the only way to observe "the button's handler ran" from outside an opaque
+# frame; the shell-side halves (the toggle's aria-pressed, the composer) are
+# asserted directly.
+#
# Honors ATTN_SKIP_HTML_ANNOTATION_E2E=1 as a CI escape hatch (relay + webview
# need a display + loopback, flaky on some headless infra).
#
@@ -50,8 +61,8 @@ cd "$PROJECT_DIR"
: "${ATTN_RELAY_URL:=http://localhost:8787}"
: "${ATTN_BIN:=$PROJECT_DIR/target/debug/attn}"
-FIXTURE="$PROJECT_DIR/tests/fixtures/sample.html"
-MARKER="Hello from an HTML file"
+FIXTURE="$PROJECT_DIR/tests/fixtures/interactive.html"
+MARKER="Interactive HTML fixture"
# The injected runtime carries this attribute; it is the only reliable
# shell-visible proof that injection happened.
RUNTIME_MARKER="data-attn-runtime"
@@ -267,6 +278,125 @@ __attn_dual_wait_one "$ATTN_DUAL_REVIEWER" '[data-slot="review-margin"]' 15000 \
&& pass "comment margin mounts for the annotatable HTML doc" \
|| pend "review margin not observed (attn-7ev: needs the rail expanded)"
+# ---- annotate mode: off by default; the pinned toggle enters and leaves it ---
+#
+# Both shells are the same App.svelte, so the same walk runs on the owner
+# (path-mode frame, runtime spliced by the attn:// handler) and on the reviewer
+# (srcdoc frame, runtime spliced by the shell). The fixture reports its own
+# state over postMessage — see the header comment for why.
+
+TOGGLE='[data-slot="html-annotate-toggle"]'
+COMPOSER='[data-slot="html-comment-composer"]'
+
+# $1: home. Evaluate JS in that shell; print the JSON result (or 'err').
+shell_eval() {
+ ATTN_HOME="$1" "$ATTN_BIN" --eval "$2" 2>/dev/null | jq -r . 2>/dev/null || echo 'err'
+}
+
+# Install the shell-side listener that captures the fixture's state reports.
+install_fixture_listener() {
+ shell_eval "$1" "(() => { if (!window.__attn_fixture_listener) { window.__attn_fixture_listener = true; window.__attn_fixture_state = null; window.addEventListener('message', (e) => { if (e.data && e.data.type === 'attn-fixture:state') window.__attn_fixture_state = { pressed: e.data.pressed, hash: e.data.hash }; }); } return true; })()" >/dev/null
+}
+
+# $1: home, $2: CSS selector inside the fixture. Hover + click it, as the
+# runtime would see a real pointer do, and wait for the state report.
+fixture_press() {
+ shell_eval "$1" "(() => { window.__attn_fixture_state = null; document.querySelector('[data-slot=\"html-viewer\"] iframe')?.contentWindow?.postMessage({ type: 'attn-fixture:press', target: '$2' }, '*'); return true; })()" >/dev/null
+ local i=0
+ while [ $i -lt 30 ]; do
+ local got
+ got="$(shell_eval "$1" "window.__attn_fixture_state !== null")"
+ [ "$got" = "true" ] && return 0
+ sleep 0.1; i=$((i + 1))
+ done
+ return 1
+}
+
+fixture_pressed() { shell_eval "$1" "window.__attn_fixture_state?.pressed ?? -1"; }
+toggle_pressed() { shell_eval "$1" "document.querySelector('$TOGGLE')?.getAttribute('aria-pressed') ?? 'missing'"; }
+composer_open() { shell_eval "$1" "document.querySelector('$COMPOSER') !== null"; }
+
+# $1: home, $2: label
+assert_annotate_mode() {
+ local home="$1" who="$2"
+
+ __attn_dual_wait_one "$home" "$TOGGLE" 15000 \
+ && pass "$who: pinned annotate toggle shown for the annotatable doc" \
+ || { fail "$who: annotate toggle never rendered"; return; }
+ case "$(toggle_pressed "$home")" in
+ false) pass "$who: annotate mode is OFF by default" ;;
+ *) fail "$who: annotate mode not off by default (aria-pressed=$(toggle_pressed "$home"))" ;;
+ esac
+
+ install_fixture_listener "$home"
+
+ # (1) Off: the page's own handler runs and no composer appears.
+ if fixture_press "$home" '#action'; then
+ local pressed; pressed="$(fixture_pressed "$home")"
+ if [ "$pressed" = "1" ] && [ "$(composer_open "$home")" = "false" ]; then
+ pass "$who: (1) with the mode off, the page's button runs its handler and no composer opens"
+ else
+ fail "$who: (1) mode off — pressed=$pressed composer=$(composer_open "$home")"
+ fi
+ else
+ fail "$who: (1) fixture never reported after the press"
+ fi
+
+ # (2) On: the same click opens the composer and the handler does NOT run.
+ ATTN_HOME="$home" "$ATTN_BIN" --click "$TOGGLE" >/dev/null 2>&1 \
+ || fail "$who: could not click the annotate toggle"
+ sleep 0.4
+ case "$(toggle_pressed "$home")" in
+ true) pass "$who: toggle reports pressed after the click" ;;
+ *) fail "$who: toggle did not flip on (aria-pressed=$(toggle_pressed "$home"))" ;;
+ esac
+ local mode; mode="$(shell_eval "$home" "window.__attn_html_debug__?.annotateMode === true")"
+ [ "$mode" = "true" ] \
+ && pass "$who: shell mirrors annotate mode on" \
+ || fail "$who: shell debug mirror says annotateMode=$mode"
+ if fixture_press "$home" '#action'; then
+ local pressed; pressed="$(fixture_pressed "$home")"
+ if __attn_dual_wait_one "$home" "$COMPOSER" 5000 && [ "$pressed" = "1" ]; then
+ pass "$who: (2) with the mode on, the same click opens the composer and the handler does not run"
+ else
+ fail "$who: (2) mode on — pressed=$pressed composer=$(composer_open "$home")"
+ fi
+ else
+ fail "$who: (2) fixture never reported after the press"
+ fi
+
+ # Cancel the composer (by its label, not its position), then leave the mode.
+ shell_eval "$home" "(() => { const b = [...document.querySelectorAll('$COMPOSER button')].find((el) => el.textContent.trim() === 'Cancel'); b?.click(); return Boolean(b); })()" >/dev/null
+ sleep 0.2
+ [ "$(composer_open "$home")" = "false" ] \
+ && pass "$who: cancelling the composer closes it" \
+ || fail "$who: composer still open after Cancel"
+ ATTN_HOME="$home" "$ATTN_BIN" --click "$TOGGLE" >/dev/null 2>&1 \
+ || fail "$who: could not click the annotate toggle a second time"
+ sleep 0.4
+ case "$(toggle_pressed "$home")" in
+ false) pass "$who: toggle reports released after the second click" ;;
+ *) fail "$who: toggle did not flip off (aria-pressed=$(toggle_pressed "$home"))" ;;
+ esac
+
+ # (3) Off again: the handler runs and nothing proposes.
+ if fixture_press "$home" '#action'; then
+ local pressed; pressed="$(fixture_pressed "$home")"
+ if [ "$pressed" = "2" ] && [ "$(composer_open "$home")" = "false" ]; then
+ pass "$who: (3) with the mode off again, the page's button runs its handler"
+ else
+ fail "$who: (3) mode off again — pressed=$pressed composer=$(composer_open "$home")"
+ fi
+ else
+ fail "$who: (3) fixture never reported after the press"
+ fi
+}
+
+log "Annotate mode walk — owner"
+assert_annotate_mode "$ATTN_DUAL_OWNER" "owner"
+log "Annotate mode walk — reviewer"
+assert_annotate_mode "$ATTN_DUAL_REVIEWER" "reviewer"
+
echo
if [ "$FAILURES" -eq 0 ]; then
echo "RESULT: html-annotation e2e passed"
diff --git a/src/main.rs b/src/main.rs
index 42f3442b..c7d9691f 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -707,6 +707,11 @@ fn run_daemon(cli: Cli, path: PathBuf, resident_mode: bool) -> Result<()> {
})
.with_custom_protocol("attn".to_string(), move |_webview_id, request| {
let uri = request.uri().to_string();
+ let request_origin = request
+ .headers()
+ .get("origin")
+ .and_then(|value| value.to_str().ok())
+ .map(str::to_owned);
if uri == "attn://app"
|| uri == "attn://app/"
|| uri.starts_with("attn://app/index.html")
@@ -817,6 +822,20 @@ fn run_daemon(cli: Cli, path: PathBuf, resident_mode: bool) -> Result<()> {
// exfiltrate them. @see planning/complete-plan.md §5.
if matches!(ext.as_deref(), Some("md" | "markdown" | "txt")) {
builder = builder.header("Access-Control-Allow-Origin", "*");
+ } else if let Some(origin) = request_origin
+ .as_deref()
+ .filter(|origin| is_app_document_origin(origin))
+ {
+ // Every other type (HTML included) is readable ONLY by
+ // the app document itself — the header "Copy file
+ // contents" button (attn-926b) fetches the raw bytes
+ // back. The grant is scoped to the app origin, never
+ // `*`, so the sandboxed HtmlViewer iframe (opaque
+ // origin, sends `Origin: null`) still gets no
+ // CORS-clean read of local files.
+ builder = builder
+ .header("Access-Control-Allow-Origin", origin)
+ .header("Vary", "Origin");
}
// HTML is rendered inside a sandboxed iframe (HtmlViewer).
@@ -2052,6 +2071,17 @@ fn generate_ipc_token() -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
+/// True for the origin of attn's own UI document: the embedded
+/// `attn://app` page, or (debug builds only) the Vite dev server that
+/// `task dev` serves it from. Used to scope CORS grants on the `attn://`
+/// file handler so only our shell — never a sandboxed document frame —
+/// can read arbitrary local files back.
+fn is_app_document_origin(origin: &str) -> bool {
+ origin == "attn://app"
+ || (cfg!(debug_assertions)
+ && (origin.starts_with("http://localhost:") || origin.starts_with("http://127.0.0.1:")))
+}
+
fn mime_from_extension(path: &std::path::Path) -> &'static str {
// Lowercase the extension so `.HTML`, `.PNG`, etc. match (mirrors
// `files::detect_file_type`, which lowercases before matching).
@@ -2153,6 +2183,19 @@ fn build_page_html(init_payload_json: &str, theme: &str, typeset: &str) -> Strin
#[cfg(test)]
mod tests {
+ #[test]
+ fn app_document_origin_grants_only_the_shell() {
+ assert!(super::is_app_document_origin("attn://app"));
+ // The sandboxed HtmlViewer iframe (opaque origin) must never qualify.
+ assert!(!super::is_app_document_origin("null"));
+ assert!(!super::is_app_document_origin("attn://localhost"));
+ assert!(!super::is_app_document_origin("https://evil.example"));
+ assert_eq!(
+ super::is_app_document_origin("http://localhost:5173"),
+ cfg!(debug_assertions)
+ );
+ }
+
use super::*;
#[test]
diff --git a/tests/fixtures/interactive.html b/tests/fixtures/interactive.html
new file mode 100644
index 00000000..a75e2bbb
--- /dev/null
+++ b/tests/fixtures/interactive.html
@@ -0,0 +1,107 @@
+
+
+
+
+
+ attn interactive HTML fixture
+
+
+
+
+
Interactive HTML fixture
+
+ A page with its own working controls. Under review, annotate mode is off
+ by default, so these keep working until someone presses the note toggle.
+
Nothing to see here; the link above only moves the hash.
+
+
+
+
diff --git a/web/e2e/hosted-review.spec.ts b/web/e2e/hosted-review.spec.ts
index e1fee83e..191056ff 100644
--- a/web/e2e/hosted-review.spec.ts
+++ b/web/e2e/hosted-review.spec.ts
@@ -741,7 +741,11 @@ test('native share opens in hosted reviewer without leaking plaintext or keys',
});
}).toBe(true);
await expect.poll(async () => (await nativeEval('document.body.innerText')).includes(suggestionCanary)).toBe(true);
- await expect.poll(async () => (await nativeEval('document.body.innerText')).includes('Accept')).toBe(true);
+ // Card actions are icon buttons (attn-926b): the owner's Accept control is
+ // found by its data-action hook, not by visible text.
+ await expect.poll(async () =>
+ nativeEval("document.querySelectorAll('[data-testid=review-margin-card] [data-action=accept]').length"),
+ ).toBeGreaterThan(0);
const peerSuggestionCard = peerPage
.locator('[data-testid="review-margin-card"]')
.filter({ hasText: suggestionCanary });
diff --git a/web/e2e/html-annotation-runtime.spec.ts b/web/e2e/html-annotation-runtime.spec.ts
index 54665c68..49a9f7cf 100644
--- a/web/e2e/html-annotation-runtime.spec.ts
+++ b/web/e2e/html-annotation-runtime.spec.ts
@@ -108,8 +108,10 @@ async function boot(page: import('@playwright/test').Page, doc = DOC) {
/**
* Turn on inspect mode, as the shell does once a document is genuinely under
- * review. Hover chrome and element clicks are both gated on it: a document that
- * cannot take a comment is left strictly alone.
+ * review AND the person has pressed the annotate toggle (attn-wrf3). Hover
+ * chrome and element clicks are both gated on it: a document that cannot take
+ * a comment — or whose reader has not asked to annotate — is left strictly
+ * alone.
*/
async function inspect(page: import('@playwright/test').Page) {
await page.evaluate(() => {
@@ -585,10 +587,10 @@ test.describe('HTML annotation runtime', () => {
await frame.locator('p.intro').hover();
await page.waitForTimeout(200);
- // No chrome at all until the shell says the document is under review. The
- // chip is opaque and painted OVER the page, so raising one here would
- // occlude — and eat clicks on — a document that is only being read, to
- // offer an affordance that could answer nothing but "share this first".
+ // No chrome at all until the shell says so — and the shell says so only
+ // for a document under review whose reader has turned annotate mode on.
+ // The chip is opaque and painted OVER the page, so raising one here would
+ // occlude — and eat clicks on — a document that is only being read.
await expect(frame.locator('.attn-chip')).toBeHidden();
await expect(frame.locator('.attn-outline')).toHaveCount(0);
@@ -640,6 +642,192 @@ test.describe('HTML annotation runtime', () => {
).toBeNull();
});
+ /**
+ * The mode switch a person actually works (attn-wrf3). Annotate mode is OFF
+ * by default — under review included — so a dashboard's tabs and a
+ * prototype's buttons keep working until someone presses the pinned toggle;
+ * pressing it again hands the clicks back. Each leg is asserted from both
+ * sides: what the page saw, and what the shell was sent.
+ */
+ test('leaves the page interactive until asked, and again once done', async ({ page }) => {
+ await boot(page);
+ const frame = page.frameLocator('#doc');
+ const send = (enabled: boolean) =>
+ page.evaluate((flag) => {
+ (window as unknown as { __attn_send: (m: unknown) => void }).__attn_send({
+ type: 'inspect',
+ v: 1,
+ enabled: flag,
+ });
+ (window as unknown as { __attn_clear: () => void }).__attn_clear();
+ }, enabled);
+ const lastPick = () =>
+ page.evaluate(() =>
+ (window as unknown as { __attn_last: (t: string) => unknown }).__attn_last('scopePicked'),
+ );
+
+ // (1) On open: the button's own handler runs, nothing is proposed.
+ await frame.locator('#ping').click();
+ await page.waitForTimeout(200);
+ expect(await pageState(page)).toMatchObject({ clicks: 1 });
+ expect(await lastPick()).toBeNull();
+
+ // (2) Mode on: the same click becomes a proposal and the handler does NOT run.
+ await send(true);
+ await frame.locator('#ping').click();
+ await page.waitForFunction(() =>
+ (window as unknown as { __attn_last: (t: string) => unknown }).__attn_last('scopePicked'),
+ );
+ expect(await pageState(page)).toMatchObject({ clicks: 1 });
+
+ // (3) Mode off: the handler runs again and nothing more is proposed.
+ await send(false);
+ await frame.locator('#ping').click();
+ await page.waitForTimeout(200);
+ expect(await pageState(page)).toMatchObject({ clicks: 2 });
+ expect(await lastPick()).toBeNull();
+
+ // And the mode re-enters cleanly: turning it off forgot the offered
+ // scopes, so a fresh hover has to offer them again before a click commits.
+ await send(true);
+ await frame.locator('#ping').click();
+ await page.waitForFunction(() =>
+ (window as unknown as { __attn_last: (t: string) => unknown }).__attn_last('scopePicked'),
+ );
+ expect(await pageState(page)).toMatchObject({ clicks: 2 });
+ });
+
+ /**
+ * The chip and the breadcrumb handle their own clicks, bypassing the
+ * document-level gate, and the shell can replay a `pickScope` of its own —
+ * so a press that lands after "Done annotating" has two routes past
+ * `onDocumentClick`. Both must be shut: a composer opening for a document the
+ * person just handed back to its scripts is the bug this pins.
+ */
+ test('cannot propose after the mode is off, whichever route a click takes', async ({ page }) => {
+ await boot(page);
+ await inspect(page);
+ const frame = page.frameLocator('#doc');
+
+ await frame.locator('p.intro').hover();
+ await expect(frame.locator('.attn-chip')).toBeVisible();
+ const hover = await page.evaluate(
+ () =>
+ (window as unknown as { __attn_last: (t: string) => { chain: { scopeId: string }[] } })
+ .__attn_last('scopeHover'),
+ );
+ const scopeId = hover.chain[0].scopeId;
+
+ await page.evaluate(() => {
+ (window as unknown as { __attn_send: (m: unknown) => void }).__attn_send({
+ type: 'inspect',
+ v: 1,
+ enabled: false,
+ });
+ (window as unknown as { __attn_clear: () => void }).__attn_clear();
+ });
+ await expect(frame.locator('.attn-chip')).toBeHidden();
+
+ // Route 1: the chip's own click handler (the element still exists, hidden).
+ await frame.locator('.attn-chip').dispatchEvent('click');
+ // Route 2: the shell replaying the scope it was offered a moment ago.
+ await page.evaluate((id) => {
+ (window as unknown as { __attn_send: (m: unknown) => void }).__attn_send({
+ type: 'pickScope',
+ v: 1,
+ scopeId: id,
+ });
+ }, scopeId);
+ await page.waitForTimeout(250);
+ expect(
+ await page.evaluate(() =>
+ (window as unknown as { __attn_last: (t: string) => unknown }).__attn_last('scopePicked'),
+ ),
+ ).toBeNull();
+ });
+
+ /**
+ * Mid-hover: the switch flips while the chip is up and a hide may be armed.
+ * Nothing may be stranded — no chip, no outline, no hover reports — and the
+ * mode must come back clean afterwards.
+ */
+ test('takes the hover down mid-gesture and raises it again on re-entry', async ({ page }) => {
+ await boot(page);
+ await inspect(page);
+ const frame = page.frameLocator('#doc');
+ const send = (enabled: boolean) =>
+ page.evaluate((flag) => {
+ (window as unknown as { __attn_send: (m: unknown) => void }).__attn_send({
+ type: 'inspect',
+ v: 1,
+ enabled: flag,
+ });
+ (window as unknown as { __attn_clear: () => void }).__attn_clear();
+ }, enabled);
+
+ await frame.locator('p.intro').hover();
+ await expect(frame.locator('.attn-chip')).toBeVisible();
+ // Leave the paragraph for unscoped space so the grace-period hide is
+ // armed, then flip the switch while it is pending.
+ await page.mouse.move(790, 590);
+ await send(false);
+ await expect(frame.locator('.attn-chip')).toBeHidden();
+ await expect(frame.locator('.attn-outline')).toHaveCount(0);
+
+ // Moving over another block while off raises nothing and reports nothing.
+ await frame.locator('p', { hasText: 'second paragraph' }).hover();
+ await page.waitForTimeout(250);
+ await expect(frame.locator('.attn-chip')).toBeHidden();
+ expect(
+ await page.evaluate(() =>
+ (window as unknown as { __attn_last: (t: string) => unknown }).__attn_last('scopeHover'),
+ ),
+ ).toBeNull();
+
+ await send(true);
+ await frame.locator('p', { hasText: 'second paragraph' }).hover();
+ await expect(frame.locator('.attn-chip')).toBeVisible();
+ });
+
+ /**
+ * The text-selection pill is independent of the mode: it works with the mode
+ * off, and a selection made with the mode on keeps its pill when the mode is
+ * turned off underneath it.
+ */
+ test('keeps the selection pill available in both modes', async ({ page }) => {
+ await boot(page);
+ const frame = page.frameLocator('#doc');
+ const explicitSelection = () =>
+ page.waitForFunction(
+ () =>
+ (window as unknown as { __attn_last: (t: string) => { explicit: boolean } | null })
+ .__attn_last('selection')?.explicit === true,
+ );
+
+ // Mode off (the default): select, press, and the shell hears an explicit ask.
+ await selectText(page, 'quick brown fox');
+ await expect(frame.locator('.attn-pill')).toBeVisible();
+ await frame.locator('.attn-pill').click();
+ await explicitSelection();
+
+ // Mode on, then off mid-selection: the pill stays and still answers.
+ await inspect(page);
+ await page.evaluate(() => (window as unknown as { __attn_clear: () => void }).__attn_clear());
+ await selectText(page, 'lazy dog again');
+ await expect(frame.locator('.attn-pill')).toBeVisible();
+ await page.evaluate(() => {
+ (window as unknown as { __attn_send: (m: unknown) => void }).__attn_send({
+ type: 'inspect',
+ v: 1,
+ enabled: false,
+ });
+ });
+ await page.waitForTimeout(100);
+ await expect(frame.locator('.attn-pill')).toBeVisible();
+ await frame.locator('.attn-pill').click();
+ await explicitSelection();
+ });
+
/**
* The reachable version of "a click committed to something the chip never
* named": drag-select a sentence to read it, then click elsewhere to dismiss
@@ -1107,4 +1295,86 @@ test.describe('HtmlAnnotationBridge (shell side)', () => {
{ timeout: 15_000 },
);
});
+
+ /**
+ * The shipped bridge, the shipped injector, and the default the shells rely
+ * on (attn-wrf3): a bridge that has not been told otherwise leaves the page
+ * fully interactive. The same three legs as the runtime suite, driven
+ * through the real classes.
+ */
+ test('leaves the page interactive until setInspect(true), and again after false', async ({ page }) => {
+ await bootBridge(page);
+ const frame = page.frameLocator('#doc');
+ const clicks = () =>
+ frame.locator('body').evaluate(() => (window as unknown as { __ping?: number }).__ping ?? 0);
+ const explicitCount = () =>
+ page.evaluate(() => (window as unknown as { __explicit: unknown[] }).__explicit.length);
+ const setInspect = (enabled: boolean) =>
+ page.evaluate((flag) => {
+ (window as unknown as { __inspect: (e: boolean) => void }).__inspect(flag);
+ }, enabled);
+
+ await frame.locator('#ping').click();
+ await page.waitForTimeout(200);
+ expect(await clicks()).toBe(1);
+ expect(await explicitCount()).toBe(0);
+
+ await setInspect(true);
+ await frame.locator('#ping').click();
+ await page.waitForFunction(
+ () => (window as unknown as { __explicit: unknown[] }).__explicit.length === 1,
+ );
+ expect(await clicks()).toBe(1);
+
+ await setInspect(false);
+ await frame.locator('#ping').click();
+ await page.waitForTimeout(200);
+ expect(await clicks()).toBe(2);
+ expect(await explicitCount()).toBe(1);
+ });
+
+ /**
+ * A frame reload (a watched file saved, a snapshot republished) rebuilds the
+ * runtime, which boots with the surface off. The bridge re-states the mode
+ * after every handshake, so a person mid-annotation keeps their mode — and
+ * a person who had turned it off is not handed a swallowing frame.
+ */
+ test('a reloaded frame comes back in the mode the shell holds', async ({ page }) => {
+ await bootBridge(page);
+ const frame = page.frameLocator('#doc');
+ const reload = async () => {
+ await page.evaluate(() => {
+ const target = window as unknown as { __ready: boolean };
+ target.__ready = false;
+ const doc = document.getElementById('doc') as HTMLIFrameElement;
+ doc.srcdoc = doc.srcdoc;
+ });
+ await page.waitForFunction(() => (window as unknown as { __ready: boolean }).__ready, {
+ timeout: 15_000,
+ });
+ };
+ const clicks = () =>
+ frame.locator('body').evaluate(() => (window as unknown as { __ping?: number }).__ping ?? 0);
+
+ await page.evaluate(() => {
+ (window as unknown as { __inspect: (e: boolean) => void }).__inspect(true);
+ });
+ await reload();
+ await frame.locator('#ping').click();
+ await page.waitForFunction(
+ () => (window as unknown as { __explicit: unknown[] }).__explicit.length === 1,
+ );
+ expect(await clicks()).toBe(0);
+
+ await page.evaluate(() => {
+ (window as unknown as { __inspect: (e: boolean) => void }).__inspect(false);
+ });
+ await reload();
+ await frame.locator('#ping').click();
+ await page.waitForTimeout(200);
+ expect(await clicks()).toBe(1);
+ expect(
+ await page.evaluate(() => (window as unknown as { __explicit: unknown[] }).__explicit.length),
+ ).toBe(1);
+ });
});
diff --git a/web/src/App.svelte b/web/src/App.svelte
index 823afe2f..cd5914b9 100644
--- a/web/src/App.svelte
+++ b/web/src/App.svelte
@@ -22,7 +22,7 @@
SearchResultItem,
UpdatePayload,
} from './lib/types';
- import { initKeyboard } from './lib/keyboard';
+ import { initKeyboard, isHtmlAnnotateHotkey } from './lib/keyboard';
import { checkForUpdate, upgradeHint } from './lib/update-check';
import { netRemovedPaths } from './lib/tree-ops';
import {
@@ -64,6 +64,8 @@
import PenLineIcon from '@lucide/svelte/icons/pen-line';
import Share2Icon from '@lucide/svelte/icons/share-2';
import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
+ import CopyIcon from '@lucide/svelte/icons/copy';
+ import CheckIcon from '@lucide/svelte/icons/check';
import PanelRightIcon from '@lucide/svelte/icons/panel-right';
import SunMoonIcon from '@lucide/svelte/icons/sun-moon';
import SettingsIcon from '@lucide/svelte/icons/settings';
@@ -75,6 +77,7 @@
import ImageViewer from './lib/ImageViewer.svelte';
import MediaPlayer from './lib/MediaPlayer.svelte';
import HtmlViewer from './lib/HtmlViewer.svelte';
+ import HtmlAnnotateToggle from './lib/HtmlAnnotateToggle.svelte';
import DirectoryOverview from './lib/DirectoryOverview.svelte';
import { nativeFileIconResolver } from './lib/icon-resolver';
import CommandPalette from './lib/CommandPalette.svelte';
@@ -124,6 +127,7 @@
import {
detectFileType,
extractStructureFromMarkdown,
+ loadFileTextFromPath,
loadMarkdownFromPath,
markdownSourceUrl,
resolveImageSrc,
@@ -134,6 +138,11 @@
import ReviewMargin from './lib/ReviewMargin.svelte';
import BrandMark from './lib/BrandMark.svelte';
import SaveChip from './lib/SaveChip.svelte';
+ import {
+ copyFileTitle,
+ createCopyFileController,
+ type CopyFileState,
+ } from './lib/copy-file-contents';
import { brandPlacement, reservesWindowControls } from './lib/shell';
import {
SAVE_STATE_AUTOSAVED,
@@ -517,13 +526,75 @@
htmlAnnotationSnapshot?.annotation === 'html_selectors_v1',
);
- // Hover chrome is always live in an annotating frame; taking the CLICK is
- // what has to wait for a real review, or an unshared page would stop
- // following its own links the moment attn rendered it.
+ /**
+ * Annotate mode (attn-wrf3): OFF by default for every HTML document, a
+ * shared one included. The page stays fully interactive — its own links,
+ * buttons, tabs and inputs keep working — until the person presses the
+ * pinned note toggle (or ⌘⇧N). The mode then stays on until they turn it
+ * off; submitting or cancelling a note does not exit it.
+ */
+ let htmlAnnotateMode = $state(false);
+
+ /**
+ * Identity of the HTML document ON SCREEN, for resetting the mode.
+ *
+ * Keyed to the document, not the snapshot: a republish of the same file
+ * keeps the mode (the bridge re-sends it after the frame reloads), while
+ * switching to another file — or to a markdown one — starts the next
+ * document interactive, as every document starts.
+ */
+ let htmlAnnotateDocKey = $derived.by((): string | null => {
+ if (isReviewerViewingHtmlSnapshot) return `snapshot:${reviewSnapshot?.fileId ?? ''}`;
+ if (activeFileType === 'html') return `path:${activePath}`;
+ return null;
+ });
+ let lastHtmlAnnotateDocKey: string | null = null;
$effect(() => {
- htmlBridge?.setInspect(htmlAnnotatable);
+ const key = htmlAnnotateDocKey;
+ if (key === lastHtmlAnnotateDocKey) return;
+ lastHtmlAnnotateDocKey = key;
+ htmlAnnotateMode = false;
});
+ // The frame's element-annotation surface (hover outline, breadcrumb chip,
+ // click-to-comment) is live only while the document can take a comment AND
+ // the person has asked to annotate. Either half alone leaves the page's own
+ // clicks with the page.
+ $effect(() => {
+ htmlBridge?.setInspect(htmlAnnotatable && htmlAnnotateMode);
+ });
+
+ function toggleHtmlAnnotateMode(): void {
+ if (!htmlAnnotatable) return;
+ // Leaving the mode keeps an open composer — the person may be typing —
+ // and the frame takes its own chip down on `inspect: false`. Cancelling
+ // the composer still sends `dismissSelection`, exactly as before.
+ htmlAnnotateMode = !htmlAnnotateMode;
+ }
+
+ /** `⌘⇧N` toggles annotate mode; `Esc` leaves it when nothing sits above it. */
+ function handleGlobalHtmlAnnotateHotkeys(e: KeyboardEvent): void {
+ if (e.defaultPrevented) return;
+ if (isHtmlAnnotateHotkey(e)) {
+ // `initKeyboard` owns the chord; this only exists so a shell that runs
+ // without it (diagnostic modes) still answers. Guarded by
+ // `defaultPrevented` above so the two never double-fire.
+ if (!htmlAnnotatable) return;
+ e.preventDefault();
+ toggleHtmlAnnotateMode();
+ return;
+ }
+ if (e.key !== 'Escape' || !htmlAnnotateMode) return;
+ // Topmost-Escape: a composer or any dialog is the topmost layer and takes
+ // this press itself; the mode is the layer beneath them all.
+ if (htmlComposer || commandPaletteOpen || shortcutsOpen || settingsOpen
+ || shareDialogOpen || namePromptOpen) return;
+ if (document.querySelector('[role="dialog"]:not([data-state="closed"]), [role="alertdialog"]:not([data-state="closed"])')) return;
+ if (isEditableShortcutTarget(e.target)) return;
+ e.preventDefault();
+ htmlAnnotateMode = false;
+ }
+
/**
* Why this HTML document cannot take a comment right now.
*
@@ -628,6 +699,9 @@
window as Window & { __attn_html_debug__?: Record }
).__attn_html_debug__ = {
annotatable: htmlAnnotatable,
+ // The person's switch, separate from annotatability: E2E has to tell
+ // "the toggle never showed" from "the toggle showed and did nothing".
+ annotateMode: htmlAnnotateMode,
snapshotId: htmlAnnotationSnapshot?.snapshotId ?? null,
composerOpen: htmlComposer !== null,
// Geometry crosses a MessagePort from an origin the shell cannot inspect,
@@ -2611,6 +2685,46 @@
editorDirty = dirty;
}
+ // ————— "Copy file contents" (header) —————
+ // One glyph for every document the viewport can show. The source follows
+ // the viewport's own branch order (`isReviewerWaiting` → HTML snapshot →
+ // markdown snapshot → local file) so the clipboard always gets what is on
+ // screen, and it is read at click time — never cached — because the edit
+ // buffer moves under it.
+ let copyFileState = $state({ kind: 'idle' });
+ let copyFileTitleText = $derived(copyFileTitle(copyFileState));
+ // Text documents only: images, video and audio have no text to copy, so
+ // the button would only ever fail there.
+ const copyFileTextTypes: ReadonlySet = new Set(['markdown', 'html', 'unsupported']);
+ let copyFileAvailable = $derived(
+ isReviewerInRoom
+ ? reviewSnapshotContent !== null
+ : Boolean(activePath) && copyFileTextTypes.has(activeFileType),
+ );
+
+ async function readActiveFileText(): Promise {
+ if (isReviewerViewingHtmlSnapshot || isReviewerViewingSnapshot) {
+ return reviewSnapshotContent ?? '';
+ }
+ if (!activePath) throw new Error('No file is open');
+ if (activeFileType === 'markdown') {
+ // The live buffer while editing (unsaved keystrokes included); the
+ // file as loaded otherwise, so a view-mode copy is byte-faithful rather
+ // than a ProseMirror re-serialisation.
+ if (mode === 'edit' && editorRef) return editorRef.getMarkdown();
+ return rawMarkdown;
+ }
+ // HTML and every other local type: the bytes are not in memory (HtmlViewer
+ // streams them into a sandboxed iframe), so read them back from disk.
+ return loadFileTextFromPath(activePath);
+ }
+
+ const copyFileController = createCopyFileController({
+ source: readActiveFileText,
+ onChange: (state) => { copyFileState = state; },
+ });
+ $effect(() => () => copyFileController.dispose());
+
function deferExternalReload(path: string, contentMtimeMs?: number): void {
if (!path) return;
deferredReloadMtimeByPath.set(path, typeof contentMtimeMs === 'number' ? contentMtimeMs : null);
@@ -3602,6 +3716,9 @@
onSuggestionComposer: () => {
openSuggestionComposer();
},
+ onToggleHtmlAnnotate: () => {
+ toggleHtmlAnnotateMode();
+ },
// Three-way apply hooks (attn-nnj.8.3). Read the verdict from the
// store each time so we always operate on the currently-open card;
// mirror the same accept/keep/edit-trigger/cancel semantics the
@@ -3676,7 +3793,13 @@
annotate={htmlAnnotatable}
annotationEvents={htmlAnnotationEvents}
onBridge={(bridge) => (htmlBridge = bridge)}
- />
+ >
+
+
{:else if isReviewerViewingSnapshot}
+
+
{:else if activeFileType === 'directory'}
{headerDocumentName}
+ {#if copyFileAvailable}
+
+
+
+ {copyFileState.kind === 'copied' ? 'Copied' : ''}
+
+ {/if}
{#if !isReviewerViewingSnapshot && mode === 'edit' && activeFileType === 'markdown'}
+
(htmlBridge = bridge)}
- />
+ >
+
+
{:else}
{var J="attn:doc:hello",ee="attn:shell:init";var Et=new TextEncoder;var Oe=new TextEncoder;function x(e){return Oe.encode(e).length}function S(e,t,n){let o=0,s=0,r=0;for(let i of e){let a=x(i);if(s+1>t||o+a>n)break;o+=a,s+=1,r+=i.length}return r===e.length?e:e.slice(0,r)}function se(e){return e.ownerDocument.createTreeWalker(e,NodeFilter.SHOW_TEXT)}function v(e,t,n){if(t.nodeType!==Node.TEXT_NODE){let i=e.ownerDocument.createRange();return i.selectNodeContents(e),i.setEnd(t,n),x(i.toString())}let o=0,s=se(e),r=s.nextNode();for(;r;){if(r===t)return o+x((r.nodeValue??"").slice(0,n));o+=x(r.nodeValue??""),r=s.nextNode()}return o}function te(e,t){let n=0,o=se(e),s=o.nextNode(),r=null;for(;s;){let i=s.nodeValue??"",a=x(i);if(n+a>=t){let c=0,d=0;for(let u of i){if(n+c>=t)return{node:s,offset:d};c+=x(u),d+=u.length}return{node:s,offset:i.length}}n+=a,r={node:s,offset:i.length},s=o.nextNode()}return r}function V(e,t,n){let o=te(e,t),s=te(e,n);if(!o||!s)return null;let r=e.ownerDocument.createRange();try{r.setStart(o.node,o.offset),r.setEnd(s.node,s.offset)}catch{return null}return r}function G(e){return e.textContent??""}var _e=new Set(["TD","TH"]),Le=e=>_e.has(e.tagName);function ke(e){let t=e.parentElement;return t?Array.from(t.children).filter(n=>n.tagName==="TR").indexOf(e)+1:1}function P(e){let t=e.parentElement;if(!t)return"";let n=Array.from(t.children).filter(o=>o.tagName===e.tagName);return n.length<=1?"":`:nth-of-type(${n.indexOf(e)+1})`}function ie(e){return e.length===0||e.length>64||!/^[A-Za-z][\w-]*$/.test(e)||/^(react|radix|mui|headless|aria)[-_]/i.test(e)?!1:!/[0-9a-f]{8,}/i.test(e)}function ce(e){if(typeof e.className!="string")return"";let t=e.className.trim().split(/\s+/).filter(Boolean);for(let n of t)if(/^[\w-]+$/.test(n)&&n.length<=40&&!/[0-9a-f]{6,}/i.test(n))return`.${CSS.escape(n)}`;return""}function ae(e){let t=e.parentElement,n=e.closest("table"),o=[n?A(n):"table"];return t&&t!==n&&o.push(t.tagName.toLowerCase()),o.push(`tr:nth-of-type(${ke(e)})`),o.join(" > ")}function Ne(e){let t=e.closest("tr");if(!t)return e.tagName.toLowerCase();let n=Array.from(t.children).filter(o=>o.tagName===e.tagName);return`${ae(t)} > ${e.tagName.toLowerCase()}:nth-of-type(${n.indexOf(e)+1})`}function A(e){return e.id&&ie(e.id)?`#${CSS.escape(e.id)}`:e.tagName==="TR"?ae(e):Le(e)?Ne(e):`${e.tagName.toLowerCase()}${ce(e)}${P(e)}`}function le(e){let t=[],n=c=>{c&&!t.includes(c)&&t.length<8&&t.push(c)},o=[],s=e;for(;s&&s.tagName!=="BODY"&&o.length<12;)o.unshift(`${s.tagName.toLowerCase()}${P(s)}`),s=s.parentElement;o.length>0&&n(o.join(" > "));let r=e.parentElement,i=[`${e.tagName.toLowerCase()}${P(e)}`];for(;r&&r.tagName!=="BODY"&&i.length<6;){if(r.id&&ie(r.id)){n(`#${CSS.escape(r.id)} ${i.join(" > ")}`);break}i.unshift(`${r.tagName.toLowerCase()}${P(r)}`),r=r.parentElement}let a=ce(e);return a&&n(`${e.tagName.toLowerCase()}${a}`),t.filter(c=>c!==A(e))}var Ie={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"};function ue(e,t){let n=[],o=e;for(;o&&o.tagName!=="BODY"&&n.length<8;)n.unshift(o.tagName.toLowerCase()),o=o.parentElement;let s=e.getAttribute("role")??Ie[e.tagName],r={tagName:e.tagName.toLowerCase(),scopePreview:S(t,200,256),domPath:n};return s&&(r.role=s),r}function Me(e,t){let n=t.startContainer.nodeType===Node.ELEMENT_NODE?t.startContainer:t.startContainer.parentElement,o=t.endContainer.nodeType===Node.ELEMENT_NODE?t.endContainer:t.endContainer.parentElement;return!n||!o||n===o?null:{startSelector:A(n),startOffset:v(n,t.startContainer,t.startOffset),endSelector:A(o),endOffset:v(o,t.endContainer,t.endOffset)}}function de(e,t){let n=t.commonAncestorContainer.nodeType===Node.ELEMENT_NODE?t.commonAncestorContainer:t.commonAncestorContainer.parentElement??e,o=v(e,t.startContainer,t.startOffset),s=v(e,t.endContainer,t.endOffset),r={v:1,target:"text_range",cssSelector:A(n),fallbackSelectors:le(n),textPosition:{start:o,end:s},context:ue(n,S(t.toString(),120,256))},i=Me(e,t);return i&&(r.range=i),r}function U(e,t,n){let o=e.ownerDocument.createRange();return o.selectNodeContents(t),{v:1,target:"element",cssSelector:A(t),fallbackSelectors:le(t),textPosition:{start:v(e,o.startContainer,o.startOffset),end:v(e,o.endContainer,o.endOffset)},context:ue(t,n)}}var ne={range:null,element:null,status:"stale",confidence:0};function He(e){return e.replace(/[‘’]/g,"'").replace(/[“”]/g,'"').replace(/[–—]/g,"-").replace(/\s+/g," ").trim()}function De(e){let t=[],n=[],o=[],s=-1;for(let r=0;r0&&(t.push(" "),n.push(s),o.push(r)),s=-1,t.push(i),n.push(r),o.push(r+1)}return{normalized:t.join(""),starts:n,ends:o}}function L(e,t){try{return e.querySelector(t)}catch{return null}}function oe(e,t){let n=[];if(t.length===0)return n;let o=0;for(;;){let s=e.indexOf(t,o);if(s===-1||(n.push(s),o=s+1,n.length>64))return n}}function re(e,t){let n=Math.min(e.length,t.length),o=0;for(;o{let c=e.slice(Math.max(0,a-o.length),a),d=e.slice(a+n,a+n+s.length),u=re([...c].reverse().join(""),[...o].reverse().join(""))+re(d,s);return{at:a,score:u}});r.sort((a,c)=>c.score-a.score);let i=r.length>1&&r[0].score===r[1].score;return{index:r[0].at,ambiguous:i}}function fe(e,t){let{anchor:n,quote:o,prefix:s="",suffix:r=""}=t;if(n.target==="element"){let c=L(e,n.cssSelector)??(n.fallbackSelectors??[]).reduce((T,C)=>T??L(e,C),null);if(!c)return ne;let d=e.ownerDocument.createRange();d.selectNodeContents(c);let u=L(e,n.cssSelector)===c;return{range:d,element:c,status:u?"exact":"remapped",confidence:u?1:.7}}let i=G(e);if(o&&n.textPosition){let{start:c,end:d}=n.textPosition,u=V(e,c,d);if(u&&u.toString()===o)return{range:u,element:null,status:"exact",confidence:1}}if(o&&o.length>0){let c=oe(i,o);if(c.length>0){let{index:d,ambiguous:u}=Pe(i,c,o.length,s,r),T=x(i.slice(0,d)),C=V(e,T,T+x(o));if(C)return{range:C,element:null,status:u?"ambiguous":"remapped",confidence:u?.4:c.length===1?.9:.75}}}if(o){let c=He(o),{normalized:d,starts:u,ends:T}=De(i);if(c.length>0){let C=oe(d,c);if(C.length===1){let W=C[0],K=u[W],Q=T[W+c.length-1];if(K!==void 0&&Q!==void 0){let Ae=x(i.slice(0,K)),Re=x(i.slice(0,Q)),Z=V(e,Ae,Re);if(Z)return{range:Z,element:null,status:"remapped",confidence:.6}}}}}let a=L(e,n.cssSelector)??(n.fallbackSelectors??[]).reduce((c,d)=>c??L(e,d),null);if(a){let c=e.ownerDocument.createRange();return c.selectNodeContents(a),{range:c,element:a,status:"remapped",confidence:.35}}return ne}var pe=`
+"use strict";(()=>{var J="attn:doc:hello",ee="attn:shell:init";var Et=new TextEncoder;var Oe=new TextEncoder;function x(e){return Oe.encode(e).length}function S(e,t,n){let o=0,s=0,r=0;for(let i of e){let a=x(i);if(s+1>t||o+a>n)break;o+=a,s+=1,r+=i.length}return r===e.length?e:e.slice(0,r)}function se(e){return e.ownerDocument.createTreeWalker(e,NodeFilter.SHOW_TEXT)}function v(e,t,n){if(t.nodeType!==Node.TEXT_NODE){let i=e.ownerDocument.createRange();return i.selectNodeContents(e),i.setEnd(t,n),x(i.toString())}let o=0,s=se(e),r=s.nextNode();for(;r;){if(r===t)return o+x((r.nodeValue??"").slice(0,n));o+=x(r.nodeValue??""),r=s.nextNode()}return o}function te(e,t){let n=0,o=se(e),s=o.nextNode(),r=null;for(;s;){let i=s.nodeValue??"",a=x(i);if(n+a>=t){let c=0,d=0;for(let u of i){if(n+c>=t)return{node:s,offset:d};c+=x(u),d+=u.length}return{node:s,offset:i.length}}n+=a,r={node:s,offset:i.length},s=o.nextNode()}return r}function V(e,t,n){let o=te(e,t),s=te(e,n);if(!o||!s)return null;let r=e.ownerDocument.createRange();try{r.setStart(o.node,o.offset),r.setEnd(s.node,s.offset)}catch{return null}return r}function G(e){return e.textContent??""}var _e=new Set(["TD","TH"]),Le=e=>_e.has(e.tagName);function ke(e){let t=e.parentElement;return t?Array.from(t.children).filter(n=>n.tagName==="TR").indexOf(e)+1:1}function B(e){let t=e.parentElement;if(!t)return"";let n=Array.from(t.children).filter(o=>o.tagName===e.tagName);return n.length<=1?"":`:nth-of-type(${n.indexOf(e)+1})`}function ie(e){return e.length===0||e.length>64||!/^[A-Za-z][\w-]*$/.test(e)||/^(react|radix|mui|headless|aria)[-_]/i.test(e)?!1:!/[0-9a-f]{8,}/i.test(e)}function ce(e){if(typeof e.className!="string")return"";let t=e.className.trim().split(/\s+/).filter(Boolean);for(let n of t)if(/^[\w-]+$/.test(n)&&n.length<=40&&!/[0-9a-f]{6,}/i.test(n))return`.${CSS.escape(n)}`;return""}function ae(e){let t=e.parentElement,n=e.closest("table"),o=[n?A(n):"table"];return t&&t!==n&&o.push(t.tagName.toLowerCase()),o.push(`tr:nth-of-type(${ke(e)})`),o.join(" > ")}function Ne(e){let t=e.closest("tr");if(!t)return e.tagName.toLowerCase();let n=Array.from(t.children).filter(o=>o.tagName===e.tagName);return`${ae(t)} > ${e.tagName.toLowerCase()}:nth-of-type(${n.indexOf(e)+1})`}function A(e){return e.id&&ie(e.id)?`#${CSS.escape(e.id)}`:e.tagName==="TR"?ae(e):Le(e)?Ne(e):`${e.tagName.toLowerCase()}${ce(e)}${B(e)}`}function le(e){let t=[],n=c=>{c&&!t.includes(c)&&t.length<8&&t.push(c)},o=[],s=e;for(;s&&s.tagName!=="BODY"&&o.length<12;)o.unshift(`${s.tagName.toLowerCase()}${B(s)}`),s=s.parentElement;o.length>0&&n(o.join(" > "));let r=e.parentElement,i=[`${e.tagName.toLowerCase()}${B(e)}`];for(;r&&r.tagName!=="BODY"&&i.length<6;){if(r.id&&ie(r.id)){n(`#${CSS.escape(r.id)} ${i.join(" > ")}`);break}i.unshift(`${r.tagName.toLowerCase()}${B(r)}`),r=r.parentElement}let a=ce(e);return a&&n(`${e.tagName.toLowerCase()}${a}`),t.filter(c=>c!==A(e))}var Ie={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"};function ue(e,t){let n=[],o=e;for(;o&&o.tagName!=="BODY"&&n.length<8;)n.unshift(o.tagName.toLowerCase()),o=o.parentElement;let s=e.getAttribute("role")??Ie[e.tagName],r={tagName:e.tagName.toLowerCase(),scopePreview:S(t,200,256),domPath:n};return s&&(r.role=s),r}function Me(e,t){let n=t.startContainer.nodeType===Node.ELEMENT_NODE?t.startContainer:t.startContainer.parentElement,o=t.endContainer.nodeType===Node.ELEMENT_NODE?t.endContainer:t.endContainer.parentElement;return!n||!o||n===o?null:{startSelector:A(n),startOffset:v(n,t.startContainer,t.startOffset),endSelector:A(o),endOffset:v(o,t.endContainer,t.endOffset)}}function de(e,t){let n=t.commonAncestorContainer.nodeType===Node.ELEMENT_NODE?t.commonAncestorContainer:t.commonAncestorContainer.parentElement??e,o=v(e,t.startContainer,t.startOffset),s=v(e,t.endContainer,t.endOffset),r={v:1,target:"text_range",cssSelector:A(n),fallbackSelectors:le(n),textPosition:{start:o,end:s},context:ue(n,S(t.toString(),120,256))},i=Me(e,t);return i&&(r.range=i),r}function U(e,t,n){let o=e.ownerDocument.createRange();return o.selectNodeContents(t),{v:1,target:"element",cssSelector:A(t),fallbackSelectors:le(t),textPosition:{start:v(e,o.startContainer,o.startOffset),end:v(e,o.endContainer,o.endOffset)},context:ue(t,n)}}var ne={range:null,element:null,status:"stale",confidence:0};function He(e){return e.replace(/[‘’]/g,"'").replace(/[“”]/g,'"').replace(/[–—]/g,"-").replace(/\s+/g," ").trim()}function De(e){let t=[],n=[],o=[],s=-1;for(let r=0;r0&&(t.push(" "),n.push(s),o.push(r)),s=-1,t.push(i),n.push(r),o.push(r+1)}return{normalized:t.join(""),starts:n,ends:o}}function L(e,t){try{return e.querySelector(t)}catch{return null}}function oe(e,t){let n=[];if(t.length===0)return n;let o=0;for(;;){let s=e.indexOf(t,o);if(s===-1||(n.push(s),o=s+1,n.length>64))return n}}function re(e,t){let n=Math.min(e.length,t.length),o=0;for(;o{let c=e.slice(Math.max(0,a-o.length),a),d=e.slice(a+n,a+n+s.length),u=re([...c].reverse().join(""),[...o].reverse().join(""))+re(d,s);return{at:a,score:u}});r.sort((a,c)=>c.score-a.score);let i=r.length>1&&r[0].score===r[1].score;return{index:r[0].at,ambiguous:i}}function fe(e,t){let{anchor:n,quote:o,prefix:s="",suffix:r=""}=t;if(n.target==="element"){let c=L(e,n.cssSelector)??(n.fallbackSelectors??[]).reduce((T,C)=>T??L(e,C),null);if(!c)return ne;let d=e.ownerDocument.createRange();d.selectNodeContents(c);let u=L(e,n.cssSelector)===c;return{range:d,element:c,status:u?"exact":"remapped",confidence:u?1:.7}}let i=G(e);if(o&&n.textPosition){let{start:c,end:d}=n.textPosition,u=V(e,c,d);if(u&&u.toString()===o)return{range:u,element:null,status:"exact",confidence:1}}if(o&&o.length>0){let c=oe(i,o);if(c.length>0){let{index:d,ambiguous:u}=Pe(i,c,o.length,s,r),T=x(i.slice(0,d)),C=V(e,T,T+x(o));if(C)return{range:C,element:null,status:u?"ambiguous":"remapped",confidence:u?.4:c.length===1?.9:.75}}}if(o){let c=He(o),{normalized:d,starts:u,ends:T}=De(i);if(c.length>0){let C=oe(d,c);if(C.length===1){let W=C[0],K=u[W],Q=T[W+c.length-1];if(K!==void 0&&Q!==void 0){let Ae=x(i.slice(0,K)),Re=x(i.slice(0,Q)),Z=V(e,Ae,Re);if(Z)return{range:Z,element:null,status:"remapped",confidence:.6}}}}}let a=L(e,n.cssSelector)??(n.fallbackSelectors??[]).reduce((c,d)=>c??L(e,d),null);if(a){let c=e.ownerDocument.createRange();return c.selectNodeContents(a),{range:c,element:a,status:"remapped",confidence:.35}}return ne}var pe=`
.attn-layer {
position: absolute;
inset: 0;
@@ -206,4 +206,4 @@
.attn-overlay,
.attn-pin { transition: none; }
}
-`;var Be="attn-text",Xe="attn-text-active",$e="attn-text-hover",k=64,B=null,f,b,l,y,g,h=new Map,X=new Map,p=null,H=null,q=null,R=null,Ye=0,$=!1;function E(e){B?.postMessage(e)}function Ve(e){return{x:e.x,y:e.y,width:e.width,height:e.height}}function _(e){return e?Array.from(e.getClientRects()).filter(n=>n.width>0&&n.height>0).slice(0,128).map(Ve):[]}var Ge=new Set(["TD","TH","TR","LI","FIGURE","PRE","CODE","TABLE","BLOCKQUOTE","H1","H2","H3","H4","H5","H6","P","IMG","FIGCAPTION","UL","OL","DL","DT","DD","SECTION","ARTICLE","ASIDE","HEADER","FOOTER","MAIN","NAV","DETAILS","SUMMARY","FORM","VIDEO","AUDIO","CANVAS","SVG","A","BUTTON","LABEL","INPUT","TEXTAREA","SELECT","HR"]),ge=e=>e.tagName==="TD"||e.tagName==="TH";function Ue(e){let t=e;for(let n=0;t&&t!==f&&n<12;n+=1){let o=t.getBoundingClientRect();if(o.width>0&&o.height>0)return t;t=t.parentElement}return null}function Fe(e){let t=[],n=e;for(;n&&n!==f&&t.length<12;)Ge.has(n.tagName.toUpperCase())&&t.push(n),n=n.parentElement;if(t.length===0){let o=Ue(e);o&&t.push(o)}return t}function je(e){return e[0]}function Ee(e){let t=e.parentElement;return t?Array.from(t.children).filter(n=>n.tagName==="TR").indexOf(e)+1:1}function xe(e){return ge(e)?"cell":e.tagName==="TR"?e.closest("thead")?"header row":`row ${Ee(e)}`:e.tagName==="LI"?"list item":e.tagName==="PRE"?"code block":e.tagName==="UL"||e.tagName==="OL"?"list":e.tagName==="A"?"link":e.tagName==="IMG"?"image":e.tagName==="BLOCKQUOTE"?"quote":/^H[1-6]$/.test(e.tagName)?"heading":e.tagName.toLowerCase()}function ve(e){if(e.tagName==="TR"){let n=Array.from(e.querySelectorAll("th,td")).map(r=>r.textContent?.trim()??""),o=e.closest("thead")?"header row":`row ${Ee(e)}`,s=[n[0],n[1]].filter(Boolean).join(" \xB7 ");return s?`${o} \xB7 ${s}`:o}if(ge(e)){let n=e.closest("tr"),o=n?Array.from(n.children).indexOf(e):-1,r=e.closest("table")?.querySelector("thead tr")?.children[o]?.textContent?.trim(),i=e.textContent?.trim()??"";return r?`${r}: ${i}`:i}let t=e.textContent?.trim()??"";return t?t.slice(0,80):null}function qe(e){let t=0;for(let n of h.values())n.element===e&&(t+=1);return t}function ze(e){let t=f.ownerDocument.createRange();t.selectNodeContents(f),t.setEnd(e.startContainer,e.startOffset);let n=f.ownerDocument.createRange();n.selectNodeContents(f),n.setStart(e.endContainer,e.endOffset);let o=t.toString(),s=[...o.slice(-k*2)].slice(-k),r=[...n.toString().slice(0,k*2)].slice(0,k);if(s.length>0&&o.length>k*2){let i=s[0].charCodeAt(0);i>=56320&&i<=57343&&s.shift()}return{prefix:s.join(""),suffix:r.join("")}}function be(e){let t=v(f,e.startContainer,e.startOffset),n=v(f,e.endContainer,e.endOffset),o=e.toString(),{prefix:s,suffix:r}=ze(e);return{html:de(f,e),quote:S(o,4e3,4096),prefix:s,suffix:r,textStart:t,textEnd:n}}function We(e){let t=ve(e)??xe(e),n=U(f,e,t),o=S((e.textContent??"").trim(),4e3,4096);return{html:n,quote:o,prefix:"",suffix:"",textStart:n.textPosition?.start??0,textEnd:n.textPosition?.end??0}}function Ke(){let e=window.getSelection();return!!e&&!e.isCollapsed&&e.rangeCount>0}function Qe(){let e=window.getSelection();if(!e||e.isCollapsed||e.rangeCount===0){R=null,Se(),E({type:"selectionCleared",v:1});return}let t=e.getRangeAt(0);if(t.toString().trim().length===0)return;R=t.cloneRange(),w();let n=_(t),o=n[n.length-1];Ze(o),E({type:"selection",v:1,proposal:be(t),rects:n,caret:o??{x:0,y:0,width:0,height:0},explicit:!1})}function Ze(e){e&&(g.style.left=`${e.x+e.width}px`,g.style.top=`${e.y+e.height+8}px`,g.classList.add("is-visible"))}function Se(){g.classList.remove("is-visible")}var Je=160,he=4,et=2,O=0;function Ce(e){return e instanceof Node&&b.contains(e)}function I(){O&&(clearTimeout(O),O=0)}function ye(){O||(O=setTimeout(()=>{O=0,w()},Je))}var M=null;function tt(e,t){let n=t?.closest("[data-anchor-id]");if(n?.dataset.anchorId)return n.dataset.anchorId;let o=e.clientX,s=e.clientY;for(let r of h.values())if(!(r.spec.html.target!=="text_range"||!r.range)){for(let i of r.range.getClientRects())if(o>=i.left&&o<=i.right&&s>=i.top&&s<=i.bottom)return r.spec.anchorId}if(t){let r=null;for(let i of h.values()){if(i.spec.html.target!=="element"||!i.element||!i.element.contains(t))continue;let a=0;for(let c=i.element;c;c=c.parentElement)a+=1;(!r||a>r.depth)&&(r={id:i.spec.anchorId,depth:a})}if(r)return r.id}return null}function nt(e){if(h.size===0&&M===null)return;let t=e.target instanceof Element?e.target:null,n=tt(e,t);n!==M&&(M=n,E({type:"anchorHover",v:1,anchorId:n}))}function ot(){M!==null&&(M=null,E({type:"anchorHover",v:1,anchorId:null}))}function rt(e){if(nt(e),!$)return;if(l.contains(e.target)){I();return}if(Ce(e.target))return;if(Ke()){w();return}let t=e.target;if(!(t instanceof Element))return;if(t===q){p&&I();return}q=t;let n=Fe(t),o=je(n);if(!o){ye();return}I(),o!==p&&(p=o,D(o),ct(it(n)))}function w(){I(),p=null,q=null,l.classList.remove("is-visible"),D(void 0)}function st(e){if(Ce(e.target)||!$)return;let t=e.target;if(!(t instanceof Element))return;let n=H,o=p;!n||!o||o!==t&&!o.contains(t)||(e.preventDefault(),e.stopPropagation(),Y(n))}function it(e){X.clear();let t=e.slice(0,8).map(n=>{let o=`scope-${Ye+=1}`;X.set(o,n);let s=ve(n);return{scopeId:o,title:xe(n),preview:s===null?null:S(s,200,256),selector:U(f,n,"").cssSelector,commentCount:qe(n),rects:_(n)}});return E({type:"scopeHover",v:1,chain:t}),t}function ct(e){if(y.textContent="",H=e[0]?.scopeId??null,e.length===0){l.classList.remove("is-visible");return}let t=e.slice(0,he).reverse(),n=e.length>he;if(n){let o=document.createElement("span");o.className="attn-chip-sep",o.textContent="\u2026",y.appendChild(o)}t.forEach((o,s)=>{if(s>0||n){let c=document.createElement("span");c.className="attn-chip-sep",c.textContent="\u203A",c.setAttribute("aria-hidden","true"),y.appendChild(c)}let r=s===t.length-1,i=document.createElement("button");i.type="button",i.className="attn-chip-seg",r&&i.classList.add("is-current"),i.dataset.scope=o.scopeId,i.setAttribute("aria-label",`Comment on ${o.preview??o.title}`);let a=document.createElement("span");if(a.className="attn-chip-title",a.textContent=o.title,i.appendChild(a),r&&o.preview&&o.preview!==o.title){let c=document.createElement("span");c.className="attn-chip-preview",c.textContent=S(o.preview,48,192),i.appendChild(c)}if(o.commentCount>0){let c=document.createElement("span");c.className="attn-chip-count",c.textContent=String(o.commentCount),i.appendChild(c)}i.addEventListener("mouseenter",()=>D(X.get(o.scopeId))),i.addEventListener("mouseleave",()=>D(p??void 0)),i.addEventListener("mousedown",c=>c.preventDefault()),i.addEventListener("click",c=>{c.preventDefault(),c.stopPropagation(),Y(o.scopeId)}),y.appendChild(i)}),l.classList.add("is-visible"),p&&we(p)}function we(e){let t=e.getBoundingClientRect(),n=t.top+window.scrollY,o=t.left+window.scrollX,s=l.offsetHeight,r=n-s+et;l.style.top=`${r"u")return;let t=[],n=[],o=[];for(let s of h.values())s.spec.html.target!=="text_range"||!s.range||(s.spec.state==="active"?n.push(s.range):s.spec.state==="hovered"?o.push(s.range):t.push(s.range));e.set(Be,new Highlight(...t)),e.set(Xe,new Highlight(...n)),e.set($e,new Highlight(...o))}function Te(e){if(e.overlay?.remove(),e.pin?.remove(),e.overlay=null,e.pin=null,e.spec.html.target!=="element"||!e.element)return;let t=e.element.getBoundingClientRect(),n=t.top+window.scrollY,o=t.left+window.scrollX,s=document.createElement("div");s.className="attn-overlay",s.dataset.state=e.spec.state,s.dataset.anchorId=e.spec.anchorId,s.style.cssText=`top:${n}px;left:${o}px;width:${t.width}px;height:${t.height}px`,b.appendChild(s),e.overlay=s;let r=document.createElement("button");r.type="button",r.className="attn-pin",r.dataset.state=e.spec.state,r.dataset.anchorId=e.spec.anchorId,r.textContent=e.spec.label??"1",r.style.cssText=`top:${n-10}px;left:${o-14}px`,r.addEventListener("click",i=>{i.stopPropagation(),E({type:"anchorActivated",v:1,anchorId:e.spec.anchorId})}),b.appendChild(r),e.pin=r}function at(e){let t=fe(f,{anchor:e.html,quote:e.quote,prefix:e.prefix,suffix:e.suffix}),n={spec:e,range:t.range,element:t.element,status:t.status,confidence:t.confidence,overlay:null,pin:null};return Te(n),n}function lt(e){for(let t of h.values())t.overlay?.remove(),t.pin?.remove();h.clear();for(let t of e)h.set(t.anchorId,at(t));z(),ut()}function ut(){let e=[];for(let t of h.values())e.push({anchorId:t.spec.anchorId,status:t.status,confidence:t.confidence,rects:_(t.range??t.element)});E({type:"anchorsResolved",v:1,results:e})}function dt(){let e=[];for(let t of h.values())e.push({anchorId:t.spec.anchorId,rects:_(t.range??t.element)});E({type:"geometry",v:1,results:e,scrollTop:window.scrollY})}function ft(e,t){let n=h.get(e);n&&(n.spec={...n.spec,state:t},n.overlay&&(n.overlay.dataset.state=t),n.pin&&(n.pin.dataset.state=t),z())}function pt(e,t){let n=h.get(e);if(!n||!t)return;(n.element??n.range?.startContainer.parentElement)?.scrollIntoView({behavior:"smooth",block:"center"})}var F=0;function j(){F||(F=requestAnimationFrame(()=>{F=0;for(let e of h.values())Te(e);p&&!p.isConnected?w():p&&(D(p),we(p)),z(),dt()}))}function ht(e){switch(e.type){case"renderAnchors":lt(e.anchors);break;case"setAnchorState":ft(e.anchorId,e.state);break;case"focusAnchor":pt(e.anchorId,e.scrollIntoView);break;case"pickScope":Y(e.scopeId);break;case"dismissSelection":window.getSelection()?.removeAllRanges(),R=null,Se();break;case"inspect":{let t=e.enabled===!0;$&&!t&&(w(),H=null),$=t;break}case"theme":f.dataset.attnTheme=e.mode;break;default:break}}function mt(){let e=document.createElement("style");e.textContent=pe,document.head.appendChild(e),b=document.createElement("div"),b.className="attn-layer",document.body.appendChild(b),l=document.createElement("div"),l.className="attn-chip",l.setAttribute("role","toolbar"),l.setAttribute("aria-label","Comment on this element"),l.addEventListener("mouseenter",I),l.addEventListener("mouseleave",ye),l.addEventListener("mousedown",t=>t.preventDefault()),l.addEventListener("click",t=>{let n=t.target;n instanceof Element&&n.closest(".attn-chip-seg")||(t.preventDefault(),t.stopPropagation(),H&&Y(H))}),y=document.createElement("div"),y.className="attn-chip-body",l.appendChild(y),b.appendChild(l),g=document.createElement("button"),g.type="button",g.className="attn-pill",g.textContent="Comment",g.addEventListener("mousedown",t=>t.preventDefault()),g.addEventListener("click",t=>{if(t.preventDefault(),t.stopPropagation(),!R)return;let n=_(R);E({type:"selection",v:1,proposal:be(R),rects:n,caret:n[n.length-1]??{x:0,y:0,width:0,height:0},explicit:!0})}),b.appendChild(g)}function gt(e){B=e,B.onmessage=t=>{let n=t.data;!n||typeof n!="object"||typeof n.type!="string"||n.v===1&&ht(n)},B.start(),E({type:"ready",v:1,textLength:G(f).length,title:S(document.title,200,512)})}function me(){let e=window;e.__attnDocRuntime||(e.__attnDocRuntime=!0,f=document.body,mt(),document.addEventListener("selectionchange",Qe),document.addEventListener("mousemove",rt,{passive:!0}),document.addEventListener("click",st,!0),document.documentElement.addEventListener("mouseleave",w),document.documentElement.addEventListener("mouseleave",ot),window.addEventListener("scroll",j,{passive:!0}),window.addEventListener("resize",j,{passive:!0}),new ResizeObserver(j).observe(document.body),window.addEventListener("message",t=>{if(t.source!==window.parent)return;let n=t.data;if(!n||n.type!==ee)return;let[o]=t.ports;o&>(o)}),window.parent.postMessage({type:J,v:1},"*"))}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",me,{once:!0}):me();})();
+`;var Be="attn-text",Xe="attn-text-active",$e="attn-text-hover",k=64,X=null,f,b,l,y,g,h=new Map,H=new Map,p=null,D=null,q=null,R=null,Ye=0,$=!1;function E(e){X?.postMessage(e)}function Ve(e){return{x:e.x,y:e.y,width:e.width,height:e.height}}function _(e){return e?Array.from(e.getClientRects()).filter(n=>n.width>0&&n.height>0).slice(0,128).map(Ve):[]}var Ge=new Set(["TD","TH","TR","LI","FIGURE","PRE","CODE","TABLE","BLOCKQUOTE","H1","H2","H3","H4","H5","H6","P","IMG","FIGCAPTION","UL","OL","DL","DT","DD","SECTION","ARTICLE","ASIDE","HEADER","FOOTER","MAIN","NAV","DETAILS","SUMMARY","FORM","VIDEO","AUDIO","CANVAS","SVG","A","BUTTON","LABEL","INPUT","TEXTAREA","SELECT","HR"]),ge=e=>e.tagName==="TD"||e.tagName==="TH";function Ue(e){let t=e;for(let n=0;t&&t!==f&&n<12;n+=1){let o=t.getBoundingClientRect();if(o.width>0&&o.height>0)return t;t=t.parentElement}return null}function Fe(e){let t=[],n=e;for(;n&&n!==f&&t.length<12;)Ge.has(n.tagName.toUpperCase())&&t.push(n),n=n.parentElement;if(t.length===0){let o=Ue(e);o&&t.push(o)}return t}function je(e){return e[0]}function Ee(e){let t=e.parentElement;return t?Array.from(t.children).filter(n=>n.tagName==="TR").indexOf(e)+1:1}function xe(e){return ge(e)?"cell":e.tagName==="TR"?e.closest("thead")?"header row":`row ${Ee(e)}`:e.tagName==="LI"?"list item":e.tagName==="PRE"?"code block":e.tagName==="UL"||e.tagName==="OL"?"list":e.tagName==="A"?"link":e.tagName==="IMG"?"image":e.tagName==="BLOCKQUOTE"?"quote":/^H[1-6]$/.test(e.tagName)?"heading":e.tagName.toLowerCase()}function ve(e){if(e.tagName==="TR"){let n=Array.from(e.querySelectorAll("th,td")).map(r=>r.textContent?.trim()??""),o=e.closest("thead")?"header row":`row ${Ee(e)}`,s=[n[0],n[1]].filter(Boolean).join(" \xB7 ");return s?`${o} \xB7 ${s}`:o}if(ge(e)){let n=e.closest("tr"),o=n?Array.from(n.children).indexOf(e):-1,r=e.closest("table")?.querySelector("thead tr")?.children[o]?.textContent?.trim(),i=e.textContent?.trim()??"";return r?`${r}: ${i}`:i}let t=e.textContent?.trim()??"";return t?t.slice(0,80):null}function qe(e){let t=0;for(let n of h.values())n.element===e&&(t+=1);return t}function ze(e){let t=f.ownerDocument.createRange();t.selectNodeContents(f),t.setEnd(e.startContainer,e.startOffset);let n=f.ownerDocument.createRange();n.selectNodeContents(f),n.setStart(e.endContainer,e.endOffset);let o=t.toString(),s=[...o.slice(-k*2)].slice(-k),r=[...n.toString().slice(0,k*2)].slice(0,k);if(s.length>0&&o.length>k*2){let i=s[0].charCodeAt(0);i>=56320&&i<=57343&&s.shift()}return{prefix:s.join(""),suffix:r.join("")}}function be(e){let t=v(f,e.startContainer,e.startOffset),n=v(f,e.endContainer,e.endOffset),o=e.toString(),{prefix:s,suffix:r}=ze(e);return{html:de(f,e),quote:S(o,4e3,4096),prefix:s,suffix:r,textStart:t,textEnd:n}}function We(e){let t=ve(e)??xe(e),n=U(f,e,t),o=S((e.textContent??"").trim(),4e3,4096);return{html:n,quote:o,prefix:"",suffix:"",textStart:n.textPosition?.start??0,textEnd:n.textPosition?.end??0}}function Ke(){let e=window.getSelection();return!!e&&!e.isCollapsed&&e.rangeCount>0}function Qe(){let e=window.getSelection();if(!e||e.isCollapsed||e.rangeCount===0){R=null,Se(),E({type:"selectionCleared",v:1});return}let t=e.getRangeAt(0);if(t.toString().trim().length===0)return;R=t.cloneRange(),w();let n=_(t),o=n[n.length-1];Ze(o),E({type:"selection",v:1,proposal:be(t),rects:n,caret:o??{x:0,y:0,width:0,height:0},explicit:!1})}function Ze(e){e&&(g.style.left=`${e.x+e.width}px`,g.style.top=`${e.y+e.height+8}px`,g.classList.add("is-visible"))}function Se(){g.classList.remove("is-visible")}var Je=160,he=4,et=2,O=0;function Ce(e){return e instanceof Node&&b.contains(e)}function I(){O&&(clearTimeout(O),O=0)}function ye(){O||(O=setTimeout(()=>{O=0,w()},Je))}var M=null;function tt(e,t){let n=t?.closest("[data-anchor-id]");if(n?.dataset.anchorId)return n.dataset.anchorId;let o=e.clientX,s=e.clientY;for(let r of h.values())if(!(r.spec.html.target!=="text_range"||!r.range)){for(let i of r.range.getClientRects())if(o>=i.left&&o<=i.right&&s>=i.top&&s<=i.bottom)return r.spec.anchorId}if(t){let r=null;for(let i of h.values()){if(i.spec.html.target!=="element"||!i.element||!i.element.contains(t))continue;let a=0;for(let c=i.element;c;c=c.parentElement)a+=1;(!r||a>r.depth)&&(r={id:i.spec.anchorId,depth:a})}if(r)return r.id}return null}function nt(e){if(h.size===0&&M===null)return;let t=e.target instanceof Element?e.target:null,n=tt(e,t);n!==M&&(M=n,E({type:"anchorHover",v:1,anchorId:n}))}function ot(){M!==null&&(M=null,E({type:"anchorHover",v:1,anchorId:null}))}function rt(e){if(nt(e),!$)return;if(l.contains(e.target)){I();return}if(Ce(e.target))return;if(Ke()){w();return}let t=e.target;if(!(t instanceof Element))return;if(t===q){p&&I();return}q=t;let n=Fe(t),o=je(n);if(!o){ye();return}I(),o!==p&&(p=o,P(o),ct(it(n)))}function w(){I(),p=null,q=null,l.classList.remove("is-visible"),P(void 0)}function st(e){if(Ce(e.target)||!$)return;let t=e.target;if(!(t instanceof Element))return;let n=D,o=p;!n||!o||o!==t&&!o.contains(t)||(e.preventDefault(),e.stopPropagation(),Y(n))}function it(e){H.clear();let t=e.slice(0,8).map(n=>{let o=`scope-${Ye+=1}`;H.set(o,n);let s=ve(n);return{scopeId:o,title:xe(n),preview:s===null?null:S(s,200,256),selector:U(f,n,"").cssSelector,commentCount:qe(n),rects:_(n)}});return E({type:"scopeHover",v:1,chain:t}),t}function ct(e){if(y.textContent="",D=e[0]?.scopeId??null,e.length===0){l.classList.remove("is-visible");return}let t=e.slice(0,he).reverse(),n=e.length>he;if(n){let o=document.createElement("span");o.className="attn-chip-sep",o.textContent="\u2026",y.appendChild(o)}t.forEach((o,s)=>{if(s>0||n){let c=document.createElement("span");c.className="attn-chip-sep",c.textContent="\u203A",c.setAttribute("aria-hidden","true"),y.appendChild(c)}let r=s===t.length-1,i=document.createElement("button");i.type="button",i.className="attn-chip-seg",r&&i.classList.add("is-current"),i.dataset.scope=o.scopeId,i.setAttribute("aria-label",`Comment on ${o.preview??o.title}`);let a=document.createElement("span");if(a.className="attn-chip-title",a.textContent=o.title,i.appendChild(a),r&&o.preview&&o.preview!==o.title){let c=document.createElement("span");c.className="attn-chip-preview",c.textContent=S(o.preview,48,192),i.appendChild(c)}if(o.commentCount>0){let c=document.createElement("span");c.className="attn-chip-count",c.textContent=String(o.commentCount),i.appendChild(c)}i.addEventListener("mouseenter",()=>P(H.get(o.scopeId))),i.addEventListener("mouseleave",()=>P(p??void 0)),i.addEventListener("mousedown",c=>c.preventDefault()),i.addEventListener("click",c=>{c.preventDefault(),c.stopPropagation(),Y(o.scopeId)}),y.appendChild(i)}),l.classList.add("is-visible"),p&&we(p)}function we(e){let t=e.getBoundingClientRect(),n=t.top+window.scrollY,o=t.left+window.scrollX,s=l.offsetHeight,r=n-s+et;l.style.top=`${r"u")return;let t=[],n=[],o=[];for(let s of h.values())s.spec.html.target!=="text_range"||!s.range||(s.spec.state==="active"?n.push(s.range):s.spec.state==="hovered"?o.push(s.range):t.push(s.range));e.set(Be,new Highlight(...t)),e.set(Xe,new Highlight(...n)),e.set($e,new Highlight(...o))}function Te(e){if(e.overlay?.remove(),e.pin?.remove(),e.overlay=null,e.pin=null,e.spec.html.target!=="element"||!e.element)return;let t=e.element.getBoundingClientRect(),n=t.top+window.scrollY,o=t.left+window.scrollX,s=document.createElement("div");s.className="attn-overlay",s.dataset.state=e.spec.state,s.dataset.anchorId=e.spec.anchorId,s.style.cssText=`top:${n}px;left:${o}px;width:${t.width}px;height:${t.height}px`,b.appendChild(s),e.overlay=s;let r=document.createElement("button");r.type="button",r.className="attn-pin",r.dataset.state=e.spec.state,r.dataset.anchorId=e.spec.anchorId,r.textContent=e.spec.label??"1",r.style.cssText=`top:${n-10}px;left:${o-14}px`,r.addEventListener("click",i=>{i.stopPropagation(),E({type:"anchorActivated",v:1,anchorId:e.spec.anchorId})}),b.appendChild(r),e.pin=r}function at(e){let t=fe(f,{anchor:e.html,quote:e.quote,prefix:e.prefix,suffix:e.suffix}),n={spec:e,range:t.range,element:t.element,status:t.status,confidence:t.confidence,overlay:null,pin:null};return Te(n),n}function lt(e){for(let t of h.values())t.overlay?.remove(),t.pin?.remove();h.clear();for(let t of e)h.set(t.anchorId,at(t));z(),ut()}function ut(){let e=[];for(let t of h.values())e.push({anchorId:t.spec.anchorId,status:t.status,confidence:t.confidence,rects:_(t.range??t.element)});E({type:"anchorsResolved",v:1,results:e})}function dt(){let e=[];for(let t of h.values())e.push({anchorId:t.spec.anchorId,rects:_(t.range??t.element)});E({type:"geometry",v:1,results:e,scrollTop:window.scrollY})}function ft(e,t){let n=h.get(e);n&&(n.spec={...n.spec,state:t},n.overlay&&(n.overlay.dataset.state=t),n.pin&&(n.pin.dataset.state=t),z())}function pt(e,t){let n=h.get(e);if(!n||!t)return;(n.element??n.range?.startContainer.parentElement)?.scrollIntoView({behavior:"smooth",block:"center"})}var F=0;function j(){F||(F=requestAnimationFrame(()=>{F=0;for(let e of h.values())Te(e);p&&!p.isConnected?w():p&&(P(p),we(p)),z(),dt()}))}function ht(e){switch(e.type){case"renderAnchors":lt(e.anchors);break;case"setAnchorState":ft(e.anchorId,e.state);break;case"focusAnchor":pt(e.anchorId,e.scrollIntoView);break;case"pickScope":Y(e.scopeId);break;case"dismissSelection":window.getSelection()?.removeAllRanges(),R=null,Se();break;case"inspect":{let t=e.enabled===!0;t||(w(),D=null,H.clear()),$=t;break}case"theme":f.dataset.attnTheme=e.mode;break;default:break}}function mt(){let e=document.createElement("style");e.textContent=pe,document.head.appendChild(e),b=document.createElement("div"),b.className="attn-layer",document.body.appendChild(b),l=document.createElement("div"),l.className="attn-chip",l.setAttribute("role","toolbar"),l.setAttribute("aria-label","Comment on this element"),l.addEventListener("mouseenter",I),l.addEventListener("mouseleave",ye),l.addEventListener("mousedown",t=>t.preventDefault()),l.addEventListener("click",t=>{let n=t.target;n instanceof Element&&n.closest(".attn-chip-seg")||(t.preventDefault(),t.stopPropagation(),D&&Y(D))}),y=document.createElement("div"),y.className="attn-chip-body",l.appendChild(y),b.appendChild(l),g=document.createElement("button"),g.type="button",g.className="attn-pill",g.textContent="Comment",g.addEventListener("mousedown",t=>t.preventDefault()),g.addEventListener("click",t=>{if(t.preventDefault(),t.stopPropagation(),!R)return;let n=_(R);E({type:"selection",v:1,proposal:be(R),rects:n,caret:n[n.length-1]??{x:0,y:0,width:0,height:0},explicit:!0})}),b.appendChild(g)}function gt(e){X=e,X.onmessage=t=>{let n=t.data;!n||typeof n!="object"||typeof n.type!="string"||n.v===1&&ht(n)},X.start(),E({type:"ready",v:1,textLength:G(f).length,title:S(document.title,200,512)})}function me(){let e=window;e.__attnDocRuntime||(e.__attnDocRuntime=!0,f=document.body,mt(),document.addEventListener("selectionchange",Qe),document.addEventListener("mousemove",rt,{passive:!0}),document.addEventListener("click",st,!0),document.documentElement.addEventListener("mouseleave",w),document.documentElement.addEventListener("mouseleave",ot),window.addEventListener("scroll",j,{passive:!0}),window.addEventListener("resize",j,{passive:!0}),new ResizeObserver(j).observe(document.body),window.addEventListener("message",t=>{if(t.source!==window.parent)return;let n=t.data;if(!n||n.type!==ee)return;let[o]=t.ports;o&>(o)}),window.parent.postMessage({type:J,v:1},"*"))}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",me,{once:!0}):me();})();
diff --git a/web/src/hosted/app/EditorShell.svelte b/web/src/hosted/app/EditorShell.svelte
index be6813b4..1ed42200 100644
--- a/web/src/hosted/app/EditorShell.svelte
+++ b/web/src/hosted/app/EditorShell.svelte
@@ -8,6 +8,13 @@
} from '../../lib/save-state-copy';
import { appWorkspaceUrl } from '../../lib/hosted/routes';
import SaveChip from '../../lib/SaveChip.svelte';
+ import CopyIcon from '@lucide/svelte/icons/copy';
+ import CheckIcon from '@lucide/svelte/icons/check';
+ import {
+ copyFileTitle,
+ createCopyFileController,
+ type CopyFileState,
+ } from '../../lib/copy-file-contents';
import BrandMark from '../../lib/BrandMark.svelte';
import type { EditorView } from 'prosemirror-view';
import BottomSheet from './BottomSheet.svelte';
@@ -69,6 +76,8 @@
import LoadingLine from './LoadingLine.svelte';
import ImportChooser from './ImportChooser.svelte';
import HtmlViewer from '../../lib/HtmlViewer.svelte';
+ import HtmlAnnotateToggle from '../../lib/HtmlAnnotateToggle.svelte';
+ import { isHtmlAnnotateHotkey } from '../../lib/keyboard';
import HtmlCommentComposer from '../../lib/HtmlCommentComposer.svelte';
import type {
AnnotationBridgeEvents,
@@ -1352,11 +1361,34 @@
bridge.setHoveredAnchor(thread?.id ?? null);
});
- // Hover chrome is always live in an annotating frame; taking the CLICK — so
- // the page's own links stop firing — waits until the document is genuinely
- // reviewable.
+ /**
+ * Annotate mode (attn-wrf3): OFF by default for every HTML document, a
+ * shared one included. The page stays fully interactive until the person
+ * presses the pinned note toggle (or ⌘⇧N); the mode then stays on until
+ * they turn it off — submitting or cancelling a note does not exit it.
+ * Reset when the displayed entry changes; kept across a republish of the
+ * same file. Parity with native and the hosted reviewer.
+ */
+ let htmlAnnotateMode = $state(false);
+ let lastHtmlAnnotateEntryPath: string | null = null;
$effect(() => {
- htmlBridge?.setInspect(htmlAnnotatable);
+ const path = activeEntry?.presentation === 'html' ? activeEntry.path : null;
+ if (path === lastHtmlAnnotateEntryPath) return;
+ lastHtmlAnnotateEntryPath = path;
+ htmlAnnotateMode = false;
+ });
+
+ function toggleHtmlAnnotateMode(): void {
+ if (!htmlAnnotatable) return;
+ htmlAnnotateMode = !htmlAnnotateMode;
+ }
+
+ // The frame's element-annotation surface (hover outline, breadcrumb chip,
+ // click-to-comment) is live only while the document can take a comment AND
+ // the person has asked to annotate. Either half alone leaves the page's own
+ // clicks with the page.
+ $effect(() => {
+ htmlBridge?.setInspect(htmlAnnotatable && htmlAnnotateMode);
});
$effect(() => {
@@ -2653,6 +2685,33 @@
}
}
+ // ————— "Copy file contents" (both headers) —————
+ // Reads the entry the way the canvas shows it: the live editor buffer for
+ // markdown (unsaved keystrokes included; `displayText`/`bodyText` cover the
+ // reader-mode frames where no editor is mounted), the stored source for
+ // HTML. Read on click, never cached — the buffer moves under the button.
+ let copyFileState = $state({ kind: 'idle' });
+ const copyFileTitleText = $derived(copyFileTitle(copyFileState));
+ const copyFileAvailable = $derived(
+ activeEntry?.presentation === 'editable' || activeEntry?.presentation === 'html',
+ );
+
+ async function readActiveEntryText(): Promise {
+ const entry = activeEntry;
+ if (!entry) throw new Error('No file is open');
+ if (entry.presentation === 'editable') {
+ return editorRef?.getMarkdown() ?? displayText ?? bodyText ?? '';
+ }
+ if (entry.presentation === 'html') return bodyText ?? displayText ?? '';
+ throw new Error('This file has no text to copy');
+ }
+
+ const copyFileController = createCopyFileController({
+ source: readActiveEntryText,
+ onChange: (state) => { copyFileState = state; },
+ });
+ $effect(() => () => copyFileController.dispose());
+
async function exitEdit(): Promise {
if (!editing) return;
if (editorRef) displayText = editorRef.getMarkdown();
@@ -2937,6 +2996,14 @@
/* "?" opens the shortcut list — the convention every keyboard-first tool
this product is measured against uses (attn-08fa.11). Never while a text
field owns the keyboard: "?" is an ordinary character in a document. */
+ // ⌘⇧N enters / leaves annotate mode on an HTML document (attn-wrf3). Same
+ // chord as the native shell; a no-op on a document that cannot take a note.
+ if (isHtmlAnnotateHotkey(event)) {
+ if (!htmlAnnotatable) return;
+ event.preventDefault();
+ toggleHtmlAnnotateMode();
+ return;
+ }
if (event.key === '?' && !event.metaKey && !event.ctrlKey && !event.altKey) {
if (shareOpen || filesSheetOpen || reviewSheetOpen || lightboxOpen || paletteOpen) return;
const target = event.target;
@@ -2965,6 +3032,14 @@
) {
const target = event.target;
if (target instanceof HTMLElement && target.closest('input, textarea, [role="dialog"]')) return;
+ // Annotate mode is the layer beneath every composer and dialog: with
+ // nothing above it, Escape leaves the mode (Topmost-Escape) before it
+ // touches thread focus.
+ if (htmlAnnotateMode && !htmlComposer) {
+ event.preventDefault();
+ htmlAnnotateMode = false;
+ return;
+ }
if (reviewStoreRef?.dismissFocusStep()) event.preventDefault();
}
}
@@ -3414,7 +3489,13 @@
{/if}
{:else if activeEntry?.presentation === 'html'}
-