Skip to content

Redesign review comment cards: author border, hover linking, unresolve - #15

Merged
angusbezzina merged 13 commits into
mainfrom
angus/comment-card-redesign
Aug 21, 2026
Merged

Redesign review comment cards: author border, hover linking, unresolve#15
angusbezzina merged 13 commits into
mainfrom
angus/comment-card-redesign

Conversation

@angusbezzina

Copy link
Copy Markdown
Collaborator

Implements the attn-bb6t epic (all five children) across the native app, the hosted owner shell, and the browser reviewer. Closes attn-bb6t.

What changed

Card border — the 3px left accent strip becomes a full 2px border in the comment author's identity colour, radius unchanged. The strip existed as a positioned pseudo-element because neither border-left nor an inset shadow survives the 6px corner radius: one mitres into the curve, the other tapers at both ends. A uniform border has neither failure mode, so the pseudo-element, its stacking context, and the -1px offsets that kept it as tall as the card are all gone with it. Active/selected no longer recolours the border — that would erase author identity — and is an outer ring instead.

Excerpts and hover linking — the quoted excerpt is removed from anchored cards and replaced by hover in both directions. Stale cards keep their quote: a stale anchor paints no inline mark, so the quote is the only surviving trace of what the comment was about.

Card→document hover toggles a class directly on the mark DOM rather than rebuilding the decoration set, which is what the existing perf note in App.svelte asks for (buildDecorations rebuilds every mark in the document; hovering a card shouldn't cost that). While wiring this I found the plugin has always emitted an is-focused class with no CSS anywhere — clicking a card gave zero document feedback. That's implemented too.

HTML annotationshovered becomes its own AnchorRenderState, deliberately distinct from active (which means "focused"), with a third CSS Custom Highlight bucket. Restoring an anchor's base state on un-hover is owned by the bridge rather than the three shells, because the bridge is the only thing retaining the rendered specs — a shell that got this wrong would leave a resolved anchor painted as unresolved.

The frame→shell direction needed a new additive anchorHover message (DOC_PROTOCOL_VERSION stays 1; both sides already ignore unknown types). Text ranges are CSS Custom Highlights, not DOM nodes, so they receive no events at all and are hit-tested geometrically against getClientRects().

Unresolve — resolve was one-way by design (there's a comment in manager.rs saying reopening would need a future CommentReopened event), so this adds that event: authorised exactly like resolve (non-agent, self-attributed), threaded through the full session facade chain, and pinned by a new shared signing-corpus vector.

The subtle part is the projection. Resolved state was an add-only Set of thread ids, and the event array is not in log order — replay and live delivery interleave peers. A naive add/delete pass would flip a thread open or closed depending on arrival order. reconstructThreads now folds resolve/reopen last-writer-wins by the same comparator that orders the log.

Resolved cards gain an Unresolve action (the resolved footer branch was deliberately empty under attn-42y, which was a statement about the protocol rather than a design choice), the ticks become Lucide check icons, and the resolved badge spans the rail like the cards it sits among.

Verification

Verified against an isolated checkout of this commit alone, not the tree it was developed in — the commit deliberately excludes unrelated in-flight work from a parallel session, so "it works locally" wouldn't have proven much.

  • 561 Rust tests, clippy clean
  • 125 web test files (new: 3 selector-ordering cases incl. an out-of-order log, 3 count cases, 4 protocol-validator cases, 2 Rust authz cases)
  • 29 html-annotation E2E, including a new Playwright case that hit-tests real range geometry — confirmed to fail with the reporting call disabled
  • svelte-check clean, doc-runtime artifact in sync, production web build OK

Notes for review

  • Forward compatibility: CommentReopened is a new externally-tagged event variant, so receivers on 0.9.0 and earlier reject it outright and will keep showing a reopened thread as resolved — silently. Same family as the snapshot-field problem in attn-mz25; recorded on attn-mz25.1 so the rollout answer covers new event variants too.
  • Overlaps with attn-is2m: the resolved icon-chips still render in the 48px collapsed gutter, which attn-is2m.3 retires. Those chips go with it.
  • Generated artifacts (runtime.generated.js, doc-runtime.generated.ts) are rebuilt and committed, as the build gate requires.

🤖 Generated with Claude Code

Implements the attn-bb6t epic across the native and hosted surfaces.

Card border (attn-bb6t.1): the 3px left accent strip becomes a full 2px
border in the comment author's identity colour. The strip was a positioned
pseudo-element because neither border-left nor an inset shadow survives the
6px corner radius — one mitres, the other tapers. A uniform border has
neither failure mode, so the pseudo-element, its stacking context and the
-1px offsets that kept it as tall as the card all go with it. Active no
longer recolours the border (that would erase author identity); it is an
outer ring instead.

Excerpts and hover linking (attn-bb6t.2): the quoted excerpt is gone from
anchored cards, replaced by hover in both directions. Stale cards keep their
quote — a stale anchor paints no inline mark, so the quote is the only thing
left that says what the comment was about. Card-to-document hover toggles a
class straight on the mark DOM rather than rebuilding the decoration set,
which is what the existing perf note in that file asks for. The is-focused
class, emitted by the plugin since it was written but never styled anywhere,
is now implemented too.

HTML annotations (attn-bb6t.3): hovered becomes its own AnchorRenderState,
distinct from active, with a third CSS Custom Highlight bucket. Restoring an
anchor's base state on un-hover is owned by the bridge, which retains the
rendered specs — a shell that got this wrong would leave a resolved anchor
painted as unresolved. The frame-to-shell direction needs a new additive
anchorHover message: text ranges are highlights, not DOM nodes, so they
receive no events and are hit-tested against getClientRects().

Unresolve (attn-bb6t.4/.5): resolve was one-way by design, so reopening
needs a new CommentReopened event — authorised exactly like resolve
(non-agent, self-attributed), threaded through the session facades, and
pinned by a new signing-corpus vector. The projection now folds resolve and
reopen last-writer-wins by event comparison: the log is not delivery-ordered,
so a Set of closed ids would flip a thread open or closed depending on
arrival. Resolved cards gain an Unresolve action, the ticks become Lucide
check icons, and the resolved badge spans the rail like the cards it sits
among.

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

vercel Bot commented Aug 17, 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 21, 2026 2:57am

Request Review

…n line

The landing promised reviewers who "aren't human" and never showed one
(critique 2026-08-18, P1). This makes the proof real end to end:

- Hero recapture from a live session: a human comment (Sam Porter) and an
  agent suggestion (Claude, violet/hex) in one margin, both themes, deeper
  document. New reproducible pipeline: capture-collab-screenshots.sh
  (modernized for the durable-share flow, name-prompt personas, agent
  participant, native snapshots, stills-only mode) + sharp-based
  build-landing-screenshots.mjs for the AVIF/WebP set.
- Agent runtime grew the honest mechanics the capture needed: suggest-diff
  (anchored diff suggestions under the agent identity) and kind:"agent"
  join (join_self_as_agent / ReviewCommand::JoinAsAgent) so the roster
  announce and event signatures stay one participant.
- "Watch a live review" link surfaces the existing guided demo
  (/app?surface=landing-review-demo); verified cold, desktop + mobile.
- Join line split by register: "Agents join by invite, as peers" on the
  Review-room stage label; verbatim `attn review join <invite> --as-agent`
  beside the install commands.
- Clarify: category sentence opens the lede; desk labels unified.
- Harden: scroll-behavior gated on prefers-reduced-motion; desk-count CTA
  refreshes on bfcache restore.
- Share-proof asset cropped to the dialog's trust core (kills the dimmed
  backdrop slab); review-card meta chips lifted to AA contrast.

Note: Hero.svelte and manager.rs also carry pre-existing uncommitted hunks
from this branch's in-progress work; they ride along here rather than being
hunk-split.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
angusbezzina and others added 5 commits August 18, 2026 22:54
Comment-only pass across the web, relay, and Rust sources: drop agent
commentary and restate what survives in the present tense.

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

Implements attn-a9f7, the three-tier plan from the /impeccable critique of the
hosted app routes (31/40).

Phase 1 — make existing promises true:
- Enter opens the arrow-selected workspace from anywhere on the desk; it used
  to require focus in the filter field, so ↓ then Enter did nothing.
- ConfirmPanel owns one destructive-confirm contract (focus in, Tab trapped,
  Escape out, focus restored). Storage's two alertdialogs had none of it.
- The desk error state says what is safe, offers Try again and Check storage,
  and keeps the raw exception behind a disclosure.
- Export all builds ONE archive and claims "backed up" only after it is
  delivered; the per-workspace download loop could be swallowed by the
  browser while every row still reported success.
- The delete confirm can export in place instead of sending the user away.
- Copy and type-ramp sweep across /open and /app/storage.
- The first-run desk spends the pencil once: on the caret, not also the tile.
- ⌘K is advertised in the editor header, and one .kbd-chip replaces three.

Phase 2 — one grammar:
- The desk header moves onto the accent plane DESIGN.md already claimed all
  headers shared, re-pointing the hosted aliases the way app.css re-points the
  canonical tokens. Its badge tiers keep three non-colour channels.
- chrome-dialect.test.ts pins the rule: tokens are canonical, the ramp is
  closed. It found three off-ramp sizes and two forked colour literals.

Phase 3 — the continuous desk:
- Desk↔workspace is an in-app transition; the route is state, not a mount-time
  prop, and one delegated link handler keeps anchors real anchors.
- One shell renders the header once for desk, storage and open.
- The populated desk leads with review work: a "Waiting on you" group, and the
  counts move from the fifth column to the second.
- ⌘K works on the desk, loaded on first use so the static graph is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Snapshot commit taken to unblock a fast-forward merge — this captures another
session's uncommitted working tree as-is and may be mid-change. Reversible
with `git reset --soft HEAD~1`.

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

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

# Conflicts:
#	web/src/hosted/app/AppShell.svelte
#	web/src/hosted/app/DeskHome.svelte
#	web/src/hosted/app/EditorShell.svelte
#	web/src/hosted/app/OpenPage.svelte
#	web/src/hosted/app/StoragePage.svelte
#	web/src/hosted/app/app-shell.css
The header reads mark | workspace | file. The mark is a link home; the
workspace opens the switcher and the file says where you are, both set in
one face, size, weight and colour — printed at two weights they read as a
mistake rather than as a hint. The chevron is gone from the trigger, so
hover and focus carry the affordance. No file segment at all while the
canvas is still asking which document to open: the route mints a
placeholder untitled.md and navigates to it, so a non-empty path was never
evidence anyone had chosen anything.

Renaming edits the word. Both fields drop their border, fill and fixed
width: the workspace name grows with what is typed (a sizer pseudo-element
in a one-cell grid, since an <input> measures from `size` and no CSS width
can shrink past it), and the file name takes its own tree row, which stops
being a <button> for exactly as long as it is a field. The file rename used
to render at the FOOT of the rail, hundreds of pixels from the row it
renamed — the rail's half of attn-rjuo.2.1, finally done.

Also: a "Back to desk" row at the top of the rail, which is where the way
out of a workspace belongs; the picker's action rows inset so their focus
ring stops being clipped by the popover's own overflow (and, with the same
inset, finally aligned to the project labels above them); the desk's two
empty-state routes set alike, with order rather than weight marking which
one leads; and the loading line moved from a 0.85rem caption to the desk
title's register with a spinner beside it, since a whole empty viewport
carrying one caption reads as a page that failed to load.

Contrast measured on the accent plane and the rail: every text pair now
clears 4.5:1, the focus underline and spinner clear 3:1, and both honour
prefers-reduced-motion.

Includes prior in-progress work from this branch (review-trouble dialog,
file-drop, workspace-origin, landing and DESIGN.md edits) that was already
in the working tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four findings from the Codex review of this branch (attn-1l2f).

Unresolve reopened decided suggestions (attn-1l2f.1). Accepted and
rejected suggestions read as `resolved` too, so their expanded card
carried the Unresolve button, and the `comment_reopened` it minted won
last-writer-wins in `reconstructThreads` — an already-applied edit came
back with its Accept/Reject actions intact. Only a comment thread
reopens now, stated once in the pure layer and enforced at three
depths: the affordance, the projection (threads and counts alike), and
`reopen_comment`, which asks the store whether the id names a
suggestion before minting anything.

A file read could land in the wrong workspace (attn-1l2f.2). `applyEntry`
guarded against a newer `applyEntry` and `navigate` against a newer
`navigate`, and neither could see the other, so a read issued in
workspace A resolving after B was open wrote A's body, path and URL into
B — and the next autosave saved A's text against B. One transition token
is now shared by every move, and reads carry the workspace they were
issued against, because generation alone cannot say "this body belongs
to a document we are no longer showing".

Entry paths were interpolated raw into URLs (attn-1l2f.3). `draft#1.md`
became a fragment and `plan?.md` a query string, so a reload or a copied
link reopened the wrong document or none. `parseAppRoute` already
decoded per segment; `appWorkspaceUrl` is the encoding half that was
missing, and every /app/w writer goes through it.

Enter on the desk pre-empted focused controls (attn-1l2f.4). The handler
is on the window, and once an arrow key had set a selection it claimed
Enter from every button and link on the page — Tab, Delete, Enter
silently opened a workspace. Enter is the desk's only when no control is
holding it; the filter field stays the exception.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Codex's review of this branch found five defects in the seam where the
desktop app switches workspaces WITHOUT unmounting the editor — same
EditorShell, new workspace prop — plus two folds that answered by arrival
order (attn-e9r2).

An import is several awaits long, and every step read the workspace id
live: dropping a folder on A and clicking B before it finished deleted B's
untitled.md, renamed B after A's import, and moved the user out of the
workspace they had just opened. The pipeline moves to
import-into-workspace.ts, where the id is a PARAMETER; the live reads (the
placeholder heuristic, the name to adopt, the navigation) are gated on that
workspace still being on screen. The files still land, because a half-read
import is the one outcome nobody can recover from.

Acquiring the pen is slow by design — a polite ask, a handoff doorbell, a
grace period — and the acquisition was tracked as a bare promise with no
workspace attached. B's request was answered with A's pending promise, and
when it resolved it installed A's runtime as B's session, so autosave
committed B's open file through A's fence. owner-session-gate.ts holds the
pair and discards a grant that arrives too late — including A -> B -> A,
where the id alone says the stale attempt is current again.

A discarded session is not merely dropped: nothing installed it, so nothing
else would ever close it. Leaving a workspace now hands its runtime back
(closeOwnerRuntime closes and evicts; close() flushes the final share
republish and says goodbye to followers first). Before this, every departed
workspace stayed held against the user's other tabs and repeated switches
accumulated live runtimes.

review-counts folded comment lifecycle in ARRIVAL order, so a newer reopen
read before a delayed older resolve left the thread closed on the desk and
open in the rail — the badge disagreeing with the surface it links to. It
now folds last-writer-wins through compareEventKeys, exported from
selectors.ts so both projections order by literally the same rule.

A dropped folder past the file/depth ceilings stopped the walk and returned
what it had, and the import reported success over a tree with files missing
that nobody could name. The walk throws DropLimitError instead, and
FileDropOptions.onError is required so every drop surface has to say where
the message goes.

Tests: 20 new/extended unit cases across the four suites, including the two
races driven deterministically; hosted-workspace-switch.spec.ts is the
end-to-end floor in the real app. npm --prefix web run check clean;
132 unit files green; build:browser + check:route-bundles clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four surfaces that answered a failure by carrying on as though nothing
had happened (attn-ze60, from a Codex review of this branch).

A dropped folder whose read failed partway was imported as a shorter
tree: both readers answered the entries API's error callback by
resolving empty, which is also how the walk signals end-of-directory.
They reject now, with a DropReadError the drop surfaces already had a
callback for — the same silent truncation the ceilings were written to
prevent, arriving through a different door.

The editor's chunk had no rejection handler, so a fetch that failed —
offline, or hashed chunks 404ing because the deployment the document
came from has been replaced — left the workspace under "Opening ..."
indefinitely. Retrying the import cannot help; the module map answers
with the same rejection without going back to the network. So: one
automatic reload, with the ceiling of one as the load-bearing part,
then a surface that says the workspace is safe and offers a way out.

The desk read #join once at boot and handed the answer down, but the
desk mounts more than once per page load. The snapshot was wrong in
both directions — it reopened the panel over an address bar that had
said plain /app for two screens, and left it shut on a Back to an entry
that genuinely asked for #join. It asks the URL now, every mount.

And a re-render under the pointer painted over its own hover: nothing
downstream would put it back, so the card stayed lit while the document
went dark until the pointer left and came back.

Each case is pinned by a test that fails without its fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The loading line ran a spinner in front of its sentence, and every
surface that mounts it centres its single child — so what was centred
was the group, and the words sat ~15px right of the middle by a distance
that changed with the sentence. Moving from one stage of a wait to the
next slid the line sideways.

The spinner is gone, with its keyframes and its reduced-motion
counterpart. The two loading surfaces also stop asking percentage
heights of every ancestor in turn, which only fills the screen while
each of them holds a definite height; the viewport is the floor now, and
a grid item still stretches into a taller parent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@angusbezzina
angusbezzina merged commit 312222a into main Aug 21, 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