Skip to content

Comment on shared HTML documents, by element (attn-61t, attn-yqun) - #14

Merged
angusbezzina merged 25 commits into
mainfrom
angus/html-commenting
Aug 14, 2026
Merged

Comment on shared HTML documents, by element (attn-61t, attn-yqun)#14
angusbezzina merged 25 commits into
mainfrom
angus/html-commenting

Conversation

@angusbezzina

@angusbezzina angusbezzina commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Comment on shared HTML documents by pointing at things. Hover any element to outline it and see it named on a breadcrumb chip (table › row 2 › cell), click to comment on it, or select text and use the Comment pill. Works on the hosted reviewer and the native window, and comments ride the same end-to-end-encrypted event path markdown already uses — so the rail, replies, and resolve work unchanged.

Closes the attn-61t epic (9/9) and the attn-yqun epic (4/4). Follow-ups live under attn-mz25 and attn-yqun.

Two decisions worth reviewing first

Design B is superseded (amendments.md #19). The June plan called for serving the shared document from a distinct localhost origin. That cannot work for the hosted browser reviewer, which has no Rust process — it renders decrypted bytes via srcdoc, so there is no server, port, or origin to serve from. Building it would have shipped commenting to the native daemon and left every browser-side reviewer out.

Instead the frame keeps its opaque-origin sandbox and the runtime is injected into the HTML source: one code path for both surfaces, and the untrusted document is denied the storage and same-origin reach a real origin would have granted it. The channel is a helloMessageChannel handshake bound on event.source, since origin checking is meaningless on an opaque frame (event.origin is the string "null").

The document frame is untrusted (amendments.md #20). It may propose anchors and report geometry; it never creates or mutates review state. Comment bodies and the submit action stay in shell-owned UI, so the worst a hostile page can do is misdescribe a proposal you see before committing.

Rust never parses HTML — anchors are opaque blobs, resolved client-side in the frame — which keeps a headless HTML parser out of the 40 MiB binary budget. HTML anchors are therefore "unverified-by-authority", and client resolution reports are local-only: they mint no durable event and reach no peer, because an HTML anchor resolves against this client's DOM and peers can legitimately disagree.

The interaction model (attn-yqun)

The first human smoke test found the in-frame surface both mis-designed and broken: no element-by-element inspection, the comment affordance vanished when the cursor reached for it, and pressing Comment did nothing. All of it had shipped green, because the automation bridge evaluates in the shell's DOM and cannot cross into the sandboxed frame — the entire interaction layer had zero coverage.

What changed:

  • Element-first. The left-margin gutter pin and its flyout are gone. Hovering outlines the element under the cursor and names it on a breadcrumb chip; clicking commits; the breadcrumb drills to any ancestor scope. Unscoped markup falls back to the nearest laid-out ancestor, so no part of a document is un-commentable.
  • One switch, not two. Hover chrome and click-to-comment are both gated on the document actually being under review. The chip is opaque and painted over the page — raising it on a document that is only being read would occlude, and swallow clicks on, that page's own links.
  • A click commits only to what the chip names. Re-deriving the scope from the click target let a click anchor to something never outlined: drag-select a sentence, click elsewhere to dismiss it, and a composer opened on whatever was under the pointer.
  • Nothing ends in silence. Every explicit refusal now says why — not shared, not in this share, or snapshot still landing — and a passive drag no longer ambushes you with a composer.

Two bugs that made a saved comment look lost

Both pre-existing, both only reachable once commenting worked well enough to make a comment. Found by reproducing the smoke test against a live daemon.

  • Anchors were unclonable. RenderableAnchor objects are assembled from the Svelte 5 review store, where every tracked object is a Proxy — and postMessage serialises with structured clone, which throws DataCloneError on a proxy. renderAnchors threw on every call, so the frame never received an anchor: no pin, no resolution, no geometry. Every prior bridge test passed plain object literals, which is exactly why none caught it.
  • The rail then hid the evidence. ReviewMargin's HTML branch skipped any thread with no reported geometry, so those comments rendered nowhere at all — saved, threaded, correctly scoped, and invisible. It now falls back to y=0, as the resolved-chip branch beside it already did: a card stacked at the top is a position problem, a missing card is a lost comment.

Also fixed: the path-mode iframe was never bound to the bridge, so an owner's local file never completed the handshake; annotatability and the composer gate keyed off the focused room, which an owner routinely lacks since they stay on their local file after sharing; the runtime's own chrome hid itself under the cursor; and snapshotPublishesPath could match a same-named file from an unrelated folder — which decides where a comment lands.

Hardening after adversarial review

Reviewers swept the full diff across two rounds. The architecture held; the defects were real:

  • Commenting was broken on non-ASCII documents. Producers sliced strings by characters against wire caps measured in bytes, so a CJK/emoji selection overran the parser caps and the whole message was dropped silently. Byte-offset mapping also counted an emoji as 6 bytes instead of 4, skewing every anchor to its right.
  • Trust-boundary gap. Inbound events never ran HtmlAnchor::validate() — a modified client with valid room keys could sync unbounded selector blobs into every peer's events.jsonl and doc frame. Now enforced at authorize_event. HTML-anchored suggestions (unresolvable by design) are refused at creation.
  • The hosted validator rejected the annotation capability outright, so a hosted reviewer joining a hosted-published HTML share could not hydrate the document at all; a second site dropped the capability even once parsed, leaving live joiners silently read-only until a refresh.
  • Injection robustness. The runtime spliced at the first </body> — including one inside a comment or script string — used String.replace (whose $' patterns could splice document text into the script), and had no double-injection guard.
  • Anchor-resolution reports were keyed to the focused room, not the displayed document, so a document's own scripts could emit resolution events into whatever room happened to be in focus.

Closing the coverage gap

The reason all of this shipped green was that nothing could see inside the frame. Playwright can — it drives a real mouse into an opaque-origin sandboxed iframe — so that is where the coverage went, rather than adding a debug channel to an untrusted runtime.

26 real-browser cases, including hover→outline→chip, the reach for the chip, click-to-comment, breadcrumb drilling, clicks left alone on a document that cannot take a comment, and anchors delivered as proxies. Six were verified to fail when their fix is reverted. The daemon-level E2E gained a shell-observable assertion that the owner's frame is both annotatable and connected — also verified to fail when its fix is reverted.

Verification

Full Rust suite, 125 web test files, svelte-check clean across 1592 files, clippy and cargo fmt clean, 26/26 Playwright in real Chromium, and the dual-instance E2E (scripts/test-html-annotation-e2e.sh) passing 13/13 over a real relay. The complete surface — numbered pin, overlay, and a rail card aligned to its element — confirmed visually against a running daemon.

Markdown review was regression-checked empirically rather than argued: the review-surface suite produces a failure profile identical to the merge-base, so nothing here changed it.

🤖 Generated with Claude Code

angusbezzina and others added 12 commits August 10, 2026 11:42
Phases 0-3 of the HTML document annotation epic: give shared HTML
documents a comment anchoring substrate and the in-frame runtime that
resolves it. Read-only HTML sharing already worked (attn-qgd); this is
the commenting half.

Design (planning/collab/html-annotation.md, amendments #19/#20):

The June-locked "Design B" — serve the shared document from a distinct
localhost origin so the frame has a checkable origin — is superseded. It
cannot work for the hosted browser reviewer, which has no Rust process:
it renders decrypted bytes via srcdoc, so there is no server, port, or
origin to serve from. Adopting it would have left every browser-side
reviewer unable to comment.

Instead the frame keeps its opaque-origin sandbox and the runtime is
injected into the HTML source — a pure content transform, so native and
hosted run byte-identical code. The channel is a hello -> MessageChannel
handshake bound on event.source; origin checking is meaningless on an
opaque frame and unnecessary once traffic moves to a private port.
Retaining the opaque origin also denies the untrusted document the
storage and same-origin capability a real origin would have granted it.

The document frame is untrusted: it may propose anchors and report
geometry, never create or mutate review state. Comment bodies and the
submit action stay in shell-owned UI, so the worst a hostile page can do
is misdescribe a proposal the user sees before committing.

Landed:

- Rust: HtmlAnchor W3C selector layer (CssSelector + ranked fallbacks,
  TextQuote, TextPosition, RangeSelector) plus an agent-context block, so
  a comment is actionable to a coding agent that never saw the document.
  Anchor.html is serde-optional; markdown wire bytes are unchanged.
  HtmlAnchor::validate bounds every field at the trust boundary.
- Rust: SnapshotAnnotation::HtmlSelectorsV1 capability; bootstrap now
  publishes HTML with it rather than (DocType::Html, None). HTML still
  never carries a Rust-built anchorIndex — that would need a headless
  HTML parser in the binary and the size gate forbids it.
- Rust: the markdown resolver refuses HTML anchors outright rather than
  landing them somewhere plausible and wrong; manager validates HTML
  anchors before persisting or syncing them.
- Web: doc-runtime/ (selector generation + resolution, CSS Custom
  Highlight API text layer, element overlays with inert fills, persistent
  comment pins, scope breadcrumb), bundled to an injectable IIFE.
- Web: doc-protocol.ts with a validating parser for every inbound frame
  message, and HtmlAnnotationBridge for the handshake and coordinates.
- Web: HtmlViewer gains annotate/onBridge. Annotating needs allow-scripts,
  so hosted reviewers get page scripts enabled while annotating; the frame
  stays opaque-origin, and read-only viewing is unchanged.

Tests: 557 Rust, 97 web test files, all green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n-ges)

The runtime cannot be tested without a browser engine: it depends on live
Range geometry, getClientRects, document.getSelection, the CSS Custom
Highlight API, and on postMessage/MessageChannel behaving as they do
across a genuinely opaque-origin sandboxed iframe — which is the whole
premise of the design.

The spec stands up a shell harness that injects the runtime into a srcdoc
frame with sandbox="allow-scripts" and no allow-same-origin, performs the
real hello -> MessageChannel handshake, and asserts against what the frame
reports back. Covers the handshake, a text proposal carrying every
selector layer, exact resolution, re-anchoring by quote after the document
shifts (the reason all layers are written at creation time), stale
detection when the content is gone, the cell/row/table scope chain,
element overlay + persistent pin with a pointer-events:none fill, and
resilience to malformed shell messages.

8 passing. Run with `npm run test:e2e:html-annotation`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…attn-08r)

Phases 5 and 6. The annotation runtime now reaches the user: a shared HTML
snapshot that declares the client-side annotation capability renders with
the runtime injected, mounts the comment margin beside it, and round-trips
comments through the same encrypted event path markdown uses.

Phase 6 — IPC contract:

ReportHtmlAnchorResolution carries the document frame's verdict to the
daemon, which finds the event's fileId and emits AnchorResolutionChanged so
the rail can show position and confidence. Deliberately local-only: unlike
ResolveAnchor it mints no durable event and reaches no peer, because an
HTML anchor resolves against *this* client's rendered DOM. Two peers can
legitimately disagree, and propagating one peer's view would overwrite the
other's correct one.

ExactReason and RemappedReason gain ClientResolved rather than reusing a
markdown reason. The existing variants each name a step this resolver ran,
and for HTML none of them did — labelling a frame-reported match as
base_hash_match would assert something the daemon cannot know.

Phase 5 — shell wiring:

ReviewMargin gains an optional anchorTops record, consulted only when there
is no ProseMirror view. An HTML document renders in a cross-origin frame
whose DOM the shell cannot touch, so the frame reports its own geometry and
the shell forwards it. The markdown path is untouched: with a view present,
every code path is exactly what it was.

HtmlCommentComposer is a sibling of CommentComposer rather than a variant.
That component is built around a ProseMirror view — quote via textBetween,
anchor tracking via coordsAtPos, anchor construction from the live
selection — none of which exists here. Keeping them apart leaves the
markdown authoring path, which everything else in review depends on,
completely unmodified.

Also carries `annotation` through the two snapshot assembly sites that were
silently dropping it; without that the reviewer always saw a read-only
document no matter what the owner published.

Tests: 557 Rust, 97 web test files, all green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The existing spec drove the protocol with an inline harness. That proved the
document side works but said nothing about the code the app ships, so a
regression in HtmlAnnotationBridge or injectDocRuntime would have gone
unnoticed. These four cases bundle the real modules into the page.

Covers the handshake through the shipped injector, viewport→shell coordinate
conversion (with the frame deliberately offset so the two spaces cannot
coincide by accident), rejection of a forged hello that did not come from
this exact frame — the event.source binding that stands in for an origin
check an opaque frame cannot provide — and the queue-before-handshake path,
since the shell renders threads well before the frame finishes booting.

12 passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The hosted reviewer got the full treatment first; this brings the native
window to parity so the feature exists on both surfaces rather than only in
the browser build.

Same shape as BrowserReviewApp: the bridge state mirrors what the document
frame reports, threads become renderable anchors keyed by thread id, frame
geometry becomes ReviewMargin card tops, and proposals open
HtmlCommentComposer. The native margin already mounted unconditionally via
rightRailPlaceholder, so it only needed the geometry source and a null view
for HTML — the markdown path keeps its ProseMirror view and every code path
it had.

Also adds an envelope round-trip test for an HTML-anchored comment. That is
the convergence-critical path: the anchor is opaque to Rust, so nothing
downstream would notice a dropped or reordered selector — the comment would
simply land somewhere else on the peer's screen, or nowhere.

Tests: 558 Rust, 12 Playwright, 0 svelte-check errors.

Note: web/src/lib/review/review-drift-check.test.ts is flaky (~1 in 3) on a
60ms timing race in the test itself. Reproduced independently of these
changes; untouched here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sibling to test-html-share-e2e.sh, which proves an HTML document reaches a
reviewer read-only. This proves the half that makes it a review surface: the
annotation capability survives publish → encrypt → relay → decrypt, the
runtime is spliced into the reviewer's frame, and the comment margin mounts.

Two assertions are worth calling out. The capability check exists because
losing it in transit is invisible — the reviewer silently falls back to the
read-only viewer, which looks identical until you try to comment. The
sandbox check asserts the frame gained allow-scripts but NOT
allow-same-origin, since the whole isolation argument in amendments #19
rests on the document staying on an opaque origin.

The assertions are deliberately shell-observable rather than reaching into
the frame: the automation bridge evaluates in the shell's context and cannot
cross into a sandboxed cross-origin document. The frame's own behavior is
covered in a real browser by web/e2e/html-annotation-runtime.spec.ts.

Adds task test:html-annotation and task test:html-annotation:runtime.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… calibration follow-ups

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Post-hoc review of attn-61t (3 independent passes over the full diff)
surfaced these; all verified by test before fixing.

Frame runtime and injection:
- positionAt counted astral chars as 6 bytes (per-code-unit byteLength on
  lone surrogates); every offset right of an emoji skewed. Walk code points.
- Producers sliced by CHARS against wire caps in BYTES, so selections in
  CJK/emoji documents blew the parser caps and the whole message was
  silently dropped (commenting dead on non-ASCII docs). New clampText
  enforces both bounds without splitting surrogate pairs; applied to
  quote/scopePreview/preview/title; prefix/suffix now cut by code points.
- Normalized-quote tier probed RAW text with 24 normalized chars, failing
  exactly when the cosmetic edit sat in the probe, and sized the range with
  the collapsed-whitespace length. normalizeTextWithMap maps the normalized
  match back to exact raw offsets.
- injectDocRuntime spliced at the FIRST </body> — including one inside a
  comment, script string, or attribute (runtime dead or document
  corrupted). Now splices before the LAST, skips documents already carrying
  the runtime, and concatenates instead of String.replace (whose $'
  patterns could splice document text into the script). Runtime also guards
  itself with a window global against double boot. build-doc-runtime
  escapes </script and <!-- in the bundle and syntax-checks the result.

Trust boundary (Rust):
- Inbound events never ran HtmlAnchor::validate() — a modified client with
  valid room keys could sync an unbounded selector blob to every peer's
  events.jsonl and doc frame. authorize_event now validates CommentCreated
  and SuggestionCreated anchors (InboundError::InvalidAnchor, test).
- CreateSuggestion accepted html-anchored drafts that the apply pipeline
  can never resolve (v1 non-goal, §8); now refused at creation.

Hosted shell:
- validateSnapshotPlaintext's html arm required EXACT keys, so a snapshot
  carrying the annotation capability failed validation outright — a hosted
  joiner could not hydrate a hosted-published HTML share at all. The arm
  now accepts and PRESERVES annotation (known value only, html only).
- hydrateSnapshot dropped annotation even once parsed — live-joining
  reviewers landed read-only until a refresh replayed the log. Now copied;
  regression-tested through the real validator + session.
- Hosted resolution reports called a native-only IPC that no-ops in the
  browser; verdicts now apply straight to the review store (local-only by
  design), with client_resolved added to the TS ResolvedAnchor unions to
  match the Rust wire.
- Both composers bound LIVE snapshot identity to an anchor whose offsets
  were measured at selection time; identity is now captured at open and the
  composer closes on republish/file switch.

Verified: cargo test green (incl. new inbound rejection test), fmt+check
clean; web 98/98 test files (10 new text-bounds + 4 new inject cases +
annotation validator cases), svelte-check 0 errors; Playwright 12/12; the
dual-instance E2E (task test:html-annotation) run for the first time and
passing 9/9 against the rebuilt binary. Markdown review regression-checked:
scripts/test-review-e2e.sh matches the merge-base failure profile exactly
(34 PASS / 9 pre-existing FAIL; the scroll-tracking delta reproduced as an
environmental rAF-throttling flake, tracks correctly in live probes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
sample.html is a centered card — no table, no scroll, one heading. It
proves the viewer renders, but it cannot exercise the parts of annotation
that only a human can judge.

This fixture is shaped for that pass: long enough to scroll (margin cards
tracking their anchors), a real table with a header row (the cell < row <
table scope breadcrumb), a list/code block/blockquote (each its own gutter
scope), and one sentence repeated verbatim in two places so prefix/suffix
disambiguation can be watched keeping two comments on their own paragraphs.
Self-contained — no remote fonts — so the smoke test never depends on the
network. Inline script retained deliberately: annotating runs with scripts
enabled (html-annotation.md §4).

Verified over the real relay: shares, reaches the reviewer with the
annotation capability, and the runtime injects with the table intact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-mz25.2)

The shared dialog body was `grid gap-4 p-6`. A bare grid has ONE implicit
column sized `auto` = `minmax(auto, max-content)`, and inside the
ScrollArea that resolves to the widest child's max-content width rather
than the dialog's own. One unbreakable string — the font-mono project
path in the share modal — therefore widened the whole column past the
frame, where `overflow-hidden` silently clipped it: 'Select visible' cut
mid-word, the file-type labels gone entirely, the create button sliced in
half. Measured in the running app: a 542px column inside a 446px content
box, 32 elements up to 70px past the right edge.

`grid-cols-[minmax(0,1fr)]` resolves the track against the dialog and
lets children shrink below min-content, which is precisely what gives the
`truncate`/`min-w-0` already on those children a definite width to work
against. Same measurement after: 447.5px column, zero overflow, the path
ellipsising as intended. The share dialog's project-root row also gains
`min-w-0`, since a flex item defaults to `min-width:auto` and refuses to
shrink below its nowrap min-content no matter what `truncate` says.

Fixes all four dialogs on the primitive (Settings, Share, NamePrompt,
ReviewExitConfirm). Vertical scrolling was already correct and stays so:
verified the body scrolls its full 301px when the frame is squashed to
240px, with no horizontal overflow at that size either.

The mirror-image VERTICAL bug was fixed and guarded in attn-11g4.1.1, but
that guard asserted nothing about the horizontal axis, which is how this
one reached a user. dialog-scroll.spec.ts now covers both: a width sweep
as a blast-radius check, plus an unbreakable-string case that reproduces
the real trigger. Verified honest — reverting the fix fails that test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
attn Ready Ready Preview Aug 14, 2026 3:15pm

Request Review

`wait_for_dual 'h1'` is a fine proxy for "the window rendered" only while
the fixture is markdown, which renders into the SHELL's own DOM. An HTML
document renders inside a sandboxed, opaque-origin iframe (src=attn://…
for a local file, srcdoc for a received snapshot), so its <h1> lives in
the frame's document and the automation bridge — which evaluates in the
shell's context — can never see it.

So `FIXTURE_PATH=…​.html scripts/dev-collab.sh` timed out after 20s on a
window that had rendered perfectly, and tore the whole harness down
before the user could click Share. Probed to confirm rather than assume:
with the smoke fixture open the shell reports 0 `h1` elements and 1
`[data-slot="html-viewer"]`.

The library gains `dual_ready_selector` (fixture path → the selector that
proves it rendered) and `wait_for_dual_fixtures`, which waits for each
window against ITS OWN fixture's selector — the owner may be on .html
while the reviewer is still on .md, so one selector for both cannot work.
`start_dual` stashes the reviewer fixture it actually booted so the two
calls cannot disagree. `wait_for_dual` is untouched for existing callers.

Verified: the failing command now reaches "Daemons running" and stays up;
the default markdown path still boots; test:dual passes 10/0/0; the
html-annotation E2E passes 9/9.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… document

subscribeIconPack deliberately emits its current value synchronously on
subscribe (icon-pack.ts:71). nativeFileIconResolver forwarded that first
emission straight to the subscriber, so FileTree's `iconRevision += 1`
ran while the very effect that registered it was still executing. The
render reads iconRevision (FileTree.svelte:145,150), so Svelte re-ran the
subscription effect until it tripped effect_update_depth_exceeded — which
kills the reactive graph. The editor then mounts a single empty paragraph
and NO document renders at all, markdown included.

Bisected to 2aa663d. The same commit had already found and fixed this
exact hazard in the HOSTED registry, whose comment describes the native
failure precisely — 'Notifying here would write every recursive
FileTree's revision from inside its own subscription effect'. The native
resolver was simply left behind, so this mirrors that fix: track the
selected pack, keep kicking off the lazy load on the initial emission,
and only notify on a genuine change. Later loads still notify through
the async path, so icons still repaint (verified: 13 icons across 8 tree
items after the change).

Not the HTML commenting epic: 5bb122b measures healthy, 2aa663d does not.

Verified against the pre-epic baseline exactly — ProseMirror back to 17
children, 1 h1, zero effect errors (was 1 child, 2 errors). The HTML
annotation E2E goes from 6 failures to 12/12 including the owner-side
assertions; svelte-check clean over 1592 files; 125 web test files pass.
The review-surface suite's 13 remaining failures are the known
rail/gutter cluster that predates this branch, untouched by icons.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@angusbezzina
angusbezzina marked this pull request as ready for review August 14, 2026 14:13
Smoke testing found the in-frame annotation surface both mis-designed and
broken: no element-by-element inspection, the comment affordance vanished
when the cursor reached for it, and pressing Comment did nothing. All of it
shipped green because the automation bridge evaluates in the shell's DOM and
cannot cross into the sandboxed frame, so the entire interaction layer had
zero coverage.

Two defects made a saved comment look lost, both pre-existing and only
reachable once commenting worked well enough to make one:

  - RenderableAnchor objects are built from the Svelte 5 review store, where
    every tracked object is a Proxy. postMessage serialises with structured
    clone, which throws DataCloneError on a proxy — so renderAnchors threw on
    every call, the frame never received an anchor, and no pin, resolution or
    geometry ever came back.
  - ReviewMargin's HTML branch skipped any thread with no reported geometry,
    so those comments rendered nowhere at all. It now falls back to y=0, as
    the resolved-chip branch beside it already did: a card stacked at the top
    is a position problem, a missing card is a lost comment.

The interaction model is now element-first, as agentation.dev does it: hover
outlines the element under the cursor and names it on a breadcrumb chip
(table > row 2 > cell), clicking it opens the composer, and the breadcrumb
drills to any ancestor scope. Hover chrome and click-to-comment are one
switch, gated on the document actually being under review — the chip is
opaque and painted over the page, so raising it on a document that is merely
being read would occlude and swallow clicks on that page's own links.

Also fixed: the path-mode iframe was never bound to the bridge, so an owner's
local file never completed the handshake; annotatability and the composer
gate keyed off the focused room, which an owner routinely lacks since they
stay on their local file; a click could commit to an element the chip never
named; the runtime's own chrome hid itself under the cursor; and
snapshotPublishesPath could match a same-named file from another folder,
which decides where a comment lands.

Closes the coverage gap with 26 real-browser cases driving a mouse inside an
opaque-origin frame, including one that renders PROXIED anchors — every
earlier bridge test passed plain literals, which is exactly why none caught
the clone failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
angusbezzina and others added 2 commits August 14, 2026 09:46
…ed inspect chrome

Two review findings on the HTML commenting surface.

A local HTML file published through overlapping owner rooms (a folder share
plus a separate share of the same file) had its annotation snapshot chosen by
recency alone, while the rail, margin, and threadsForCurrentFile stayed scoped
to the room the path resolves to. A comment could post into a review the person
was not looking at and never appear beside the text. The resolved room now
wins; recency only breaks ties within it.

Turning inspection off (room stopped or revoked, snapshot gone) only stopped
new hover chrome from being raised. A chip already on screen handles its own
clicks, bypassing the onDocumentClick guard, so it kept swallowing page clicks
and emitting proposals for a document that was no longer reviewable. The
inspect handler now hides the hover chrome and clears the current scope on the
true->false transition.

Covered by a new runtime E2E case; verified to fail without the teardown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…scription

Adds one-command HMR entry points for the marketing site and the hosted
browser app, documents both in AGENTS.md, and adds `prepare: svelte-kit sync`
so a fresh site/ checkout has its generated types before first run. Also
retitles the html-annotation E2E task to match what it now covers (both sides
receive an annotatable frame).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`cargo clippy --all-targets -- -D warnings` (the CI quality gate) failed on
collapsible_if in inject_html_annotation_runtime. Folded the `>` check into
the `if let` as a let-chain; edition 2024 already allows it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
At 50% the scrim barely separated a dialog from the INK theme, where the app
surface is already near-black — the modal read as one more panel rather than
the thing being asked about. 70% pushes the document and sidebar back without
reaching the shadcn default of 80%, which crushes the paper theme.

Sheets move with dialogs: they make the same promise about attention, and two
scrim weights would look like the app disagreed with itself about which one
mattered more.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@angusbezzina angusbezzina changed the title Comment on shared HTML documents (attn-61t) Comment on shared HTML documents, by element (attn-61t, attn-yqun) Aug 14, 2026
'Rust Quality (Linux)' failed on
resident_away_owner_loop_imports_notifies_focuses_and_survives_restart:
by_room[&first_room].body did not start with "2 new comments".

The debounce is real wall-clock in a worker thread, and the two first_room
events had a durable store append -- a file write plus an fsync -- between
them. When that append outlasted the 15ms window on a loaded runner, the
trailing-edge batch fired early and the room posted "1 new comment" twice
instead of "2 new comments" once. The count assertion above it still passed,
because two notifications from two distinct rooms is exactly what a split
burst produces; only the body showed it.

Every append now happens before the burst, so the window covers the forwarding
alone, and the window is 500ms rather than 15ms. The negative waits shrink from
4x to 1-2x the debounce -- a post that is due arrives immediately, so they were
only ever paying for the old window being tiny. Net suite time is unchanged
(669 tests, 1.9s -> 2.3s, the sleeps overlap).

Also names the burst-fold assertion, so a future split reports what it got
instead of failing bare.

Verified with 6 consecutive runs under 8 spinning CPU hogs. The original could
not be reproduced locally -- macOS fsync is too fast to close a 15ms window --
which is consistent with it only ever failing on the Ubuntu runner.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t a hang

The two anti-enumeration cases had been failing 'Relay Tests (Ubuntu)' on
almost every run since 2026-07-27. attn-i38g recorded them as hanging only
under GitHub Actions, on the grounds that they finish in ~350ms locally, and
told us not to touch the budgets until the hang was explained: if the limiter
can stall a request path, a longer timeout would hide a production bug.

There is no hang, and nothing is Actions-specific. The ~350ms figure came from
running the file on its own. Run the whole integration suite and the same two
cases take 19,660ms and 9,216ms on macOS.

The cost is the SELF.fetch round trip, not anything under test. An unknown-room
GET costs 1.6ms when this file runs alone and ~250ms when the suite shares the
workerd isolate. A GET /health -- no Durable Object, no quota, no rate limiter
on that path -- costs ~217ms under the same load, the same price. So it scales
with sequential calls times suite activity, and these four cases make the most
sequential calls in the repo: the cap (antiEnumPerFiveMin=30) can only be
exercised by 30+ distinct probes. They were simply first over the line, which
is also why they were intermittent rather than uniformly broken.

That clears the concern the bead raised. The limiter does not stall anything; a
no-op route is equally slow under the same conditions.

Ruled out by measurement: file parallelism (--no-file-parallelism changed
nothing), accumulated Durable Objects (500 preloaded DOs left the tests fast),
a single bad neighbour file (only the full integration set reproduces), and
concurrency as a fix (Promise.all over 20 probes measured 1.0x against the
sequential loop, loaded and idle -- the pool serializes them regardless).

So the only lever is fewer sequential calls. The 'existing rooms' known-room
loop drops from 50 hits to 5: it was ~11s of that case's ~19s, and the property
under test is a per-request branch that holds on the first hit or not at all,
so five guards against an accumulator as well as fifty did. Worst case under
full-integration load falls 19,660ms -> 8,967ms.

All four cases then get an explicit 30s budget. Every one of them is
structurally 30+ sequential calls and was marginal at the 15s default, so the
two that still passed were next. Raising the budget here is the measured
response to harness overhead, documented in the block comment, not a way to
hide a suspected stall.

Making antiEnumPerFiveMin configurable would be the real speed fix -- 30 probes
becomes ~6 -- but the limiter is a module-level singleton shared across the
isolate, and files other than this one still share the "unknown" IP bucket, so
a smaller global cap would make unrelated cases 429. Left alone deliberately.

Refs: attn-i38g

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@angusbezzina
angusbezzina merged commit 76f54c0 into main Aug 14, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant