Skip to content

DRAFT / INCOMPLETE: "Ask Claude about this" native reference UX - #263

Draft
itsdestin wants to merge 27 commits into
masterfrom
feat/ask-claude-reference-ux
Draft

DRAFT / INCOMPLETE: "Ask Claude about this" native reference UX#263
itsdestin wants to merge 27 commits into
masterfrom
feat/ask-claude-reference-ux

Conversation

@itsdestin

Copy link
Copy Markdown
Owner

⚠️ This PR is INCOMPLETE and is NOT ready to merge. It is opened as a draft to
preserve the work and the reasoning behind it. The visual layer in particular went
through repeated dev-review rounds and is still unsettled — a reviewer should expect
to either invest substantial further work here or rewrite the overlay layer from
scratch
. See "Honest status" below before spending time on it.

Replaces the v1 "Ask about this" behaviour (shipped #169), which pasted a raw prompt
scaffold into the composer, with a held reference kept as state.

  • Spec: youcoded-dev/docs/active/specs/2026-07-26-ask-claude-reference-ux-design.md
  • Plan: youcoded-dev/docs/active/plans/2026-07-26-ask-claude-reference-ux.md
  • Mockups: artifact bed5f7ea (design), d8a70e4e (inline-reply options)

What works

The composer holds only the user's own words; the scaffold is assembled at send time and
rendered back as a collapsible reply (a pill that expands into a labelled panel). The
window dims behind a scrim, the referenced chat message clones and FLIP-animates to the
viewport centre and back out on cancel, the source hides so it reads as one bubble moving,
and the selection is marked inside the clone. Artifact selections stay in place, clipped so
only the referenced lines stay bright.

3551 tests passing, tsc --noEmit clean. ~50 tests are new and specific to this work.

Honest status — why this is a draft

The state, builder, menu wiring, and composer plumbing (roughly the first half) are
well-tested and settled. The visual overlay layer is not. It was reworked in response
to five separate rounds of dev-instance feedback, and each round found a real defect that
static analysis and jsdom could not catch:

  • the traced outline was drawn at the source's original position while the card travelled
  • the clone inherited max-w-[85%] and left dead space beside a "centred" card
  • an ancestor's overflow clipped the card's ring into corner fragments
  • the cancel button sat under pointer-events: none and only appeared to work because
    clicks fell through to the scrim
  • a multi-rect line produced a self-intersecting path whose fill bled outside the highlight

That cadence is the actual signal here: jsdom cannot verify any of this. Every remaining
risk in this PR is a paint-order, layout, or animation question, and the test suite is
structurally incapable of covering it. The tests pin mechanisms (attributes set, styles
written, path strings emitted), not appearance.

A reviewer should treat the overlay as a working prototype whose architecture is sound but
whose implementation has absorbed a lot of point fixes. Rebuilding it against the now
well-understood constraints may be cheaper than continuing to patch it.

Known-open items

  • Corner radius (6px) and merge tolerance (2px) in the outline geometry are reasoned
    defaults, never visually tuned.
  • The DOM mechanism producing multiple client rects on one line is inferred from geometry,
    not directly observed — the fix is correct for the general case regardless.
  • outgoing-message.ts flattens newlines before dispatch, so Claude receives a single-line
    scaffold. Pre-existing, not introduced here; the parser tolerates both forms.
  • A reference sent with a completely empty follow-up loses its marker to an existing trim
    and falls back to plain rendering.
  • setText/setAttachments restores on the native-send-failure path share a cross-session
    race that setReference was explicitly guarded against.
  • measure() in the geometry hook is unthrottled — a fast scroll drives many re-renders.
  • Switching artifact→artifact directly can paint one frame of stale clip-path.
  • Android/touch and narrow viewport (<640px) are out of scope — the context menu has no
    touch path at all, a pre-existing limitation.

Not to be lost if this is rewritten

Three constraints were learned the expensive way and are documented in code comments:

  1. Never mutate React-managed DOM structure in the reference path. The original design
    used Range.surroundContents(), which splits text nodes; React's fiber keeps the stale
    reference and the next reconcile throws NotFoundError: removeChild, taking down the
    chat view. Reversible inline style is fine — structure is not.
  2. .bottom-float forms a stacking context (position: absolute; z-index: 20 plus a
    transform), so the composer cannot out-z-index a portalled scrim from inside it. It is
    raised via a CSS variable published from Overlay.tsx, which stays the single authority
    on layer numbers.
  3. Transcript dedup matches on message content (chat-reducer.ts:331), so the
    optimistic dispatch and what is actually sent must be the same string or the message
    echoes as two bubbles.

🤖 Generated with Claude Code

itsdestin and others added 27 commits July 26, 2026 13:49
Holds the 'Ask Claude about this' reference as state instead of composer
text. Parked per session like InputBar's draftsRef so a reference cannot
leak from one conversation into another's next message.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Inverts v1's askAboutThis()/scaffold() — same prompt strings, returned as
data rather than dispatched at the composer as text. Tags the source element
and (where possible) the selected runs with marker attributes so the overlay
can re-measure geometry later without storing stale DOMRects.

describeArtifactSelection moves to build-reference.ts as module-private (not
exported, to avoid a circular import once Task 3 has build-menu.ts import the
builders). build-menu.ts's artifactMenu keeps a temporary pure duplicate
(describeArtifactSelectionForAskMenu) rather than importing buildArtifactReference
back, because that builder also tags DOM markers for the future overlay —
calling it from every right-click, not just an actual "Ask" click, would give
the live menu a new DOM-mutation side effect before Task 3 is ready for it.
Task 3 deletes the duplicate when it rewires the call site.

Also fixes a bug in the buildChatReference draft: it fell back to only
bubble?.textContent when locating the quote, so a null bubble (target has no
bubble ancestor) always produced an empty quote even when target itself had
text — inconsistent with the `host = bubble ?? target` fallback used two
lines later for anchoring. Now uses the same bubble-or-target fallback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reviewer caught a design defect in the PendingReference anchor: tagHost()/
tagSelectionRuns() set a data-reference-host attribute and wrapped the
selection in a marker <span> via Range.surroundContents(). Chat bubbles
render their text as plain React-managed JSX, so surroundContents() splits
a text node out from under React's fiber, and the next reconcile throws
NotFoundError: Failed to execute 'removeChild', crashing the chat view.
Repeated right-clicks also nested marker spans with no cleanup.

Anchor now holds a live host Element + a cloned Range instead of CSS
selectors, so building a reference never mutates the DOM. Safe because this
state is renderer-local and never serialized/persisted/sent over IPC.

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

Code review flagged that only buildChatReference had a regression test proving
the old DOM-mutating implementation (setAttribute + Range.surroundContents)
wasn't reintroduced. The other two builders had no equivalent guard, so a
reintroduced mutation would pass every existing test for them.

Verified by temporarily reintroducing setAttribute('data-reference-host', ...)
into both builders: both new assertions failed as expected, then reverted.
…ng turns

buildContextMenu now takes an onReference callback instead of dispatching
youcoded:compose-insert. Ask about this is DISABLED (not hidden) on the
in-flight turn, with a title hint explaining why — the reference card is a
static clone and would freeze a streaming message mid-sentence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SESSION_PROCESS_EXITED and NATIVE_SESSION_ERROR call endTurn(session),
flipping isThinking false without replacing the turn object in
session.assistantTurns (unlike TRANSCRIPT_TURN_COMPLETE / TRANSCRIPT_INTERRUPT,
which both create a new turn reference). For a text-only turn the memo
comparator's group-ID loop never runs, so prev.turn === next.turn let a
streaming true->false change through undetected, leaving data-streaming
stuck at "true" and "Ask about this" permanently disabled after a crash
mid-response.

Add streaming to assistantTurnPropsAreEqual, and a real-React-render test
that keeps the same turn reference across renders to prove the memo
actually re-renders when only streaming flips.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The composer now holds ONLY the user's words: placeholderFor() announces the
held reference, composeOutgoing() prepends its promptText at send, and the
youcoded:compose-insert CustomEvent is retired (0 producers, 0 consumers).
Feature is functionally complete here; Tasks 5-9 add the visual layer.

Also updates InputBar.test.tsx: InputBar now calls useReference()
unconditionally, so every render site needs a ReferenceProvider ancestor to
avoid throwing (matches how App.tsx scopes the real provider by sessionId).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Gap 1: a failed async native send restored the draft but not the held
reference (clearReference() had already run synchronously before the ack
settled). Restore both together now, guarded against clobbering a newer
reference set during the round-trip.

Gap 2: minimal (terminal view) send paths write straight to the PTY and
never consume a held reference, so it stayed parked invisibly if the user
switched from chat to terminal view. placeholderFor no longer announces a
reference in minimal mode, and an effect clears it outright rather than
attempting to prepend the scaffold to a PTY write (which desktop/CLAUDE.md
documents as unsafe under ConPTY chunking).

Widened ReferenceApi.setReference to also accept a (prev) => next updater,
needed for the gap-1 guarded restore.

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

Critical: BuddyChatApp/BuddyOverlayApp mount InputBar under ThemeProvider >
ChatProvider with no ReferenceProvider ancestor, so useReference() threw on
every render and the Buddy companion window rendered blank. It now soft-fails
to an inert API, matching the useEscClose convention.

Important: a native-send failure restored a held reference via
setReference((cur) => cur ?? reference) unconditionally. If the user switched
sessions while the send was in flight, this wrote the old session's
reference into the new session's live slot on the single app-wide
ReferenceProvider. Guarded on session identity via a live activeSessionIdRef.

Adds the first tests under src/renderer/components/buddy/, plus a
session-switch regression test in InputBar.test.tsx. Each test verified to
fail against the unfixed code (see task-4-report.md).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
buildUnionPath walks down the right edges of every line box then back up the
left, giving a partial selection its real notched shape instead of a bounding
box. Pure so the trickiest logic in the feature is testable without a DOM.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reuses the L2 band rather than inventing an L5, with REFERENCE_COMPOSER_Z
exported from Overlay.tsx so design rule 11 holds. Opening any overlay on top
cancels the reference (new useEscStackDepth), which makes the two states
mutually exclusive and sidesteps the z-ordering question entirely.

Fixes an off-by-one in the depth-baseline capture: useEscStackDepth() reads
the stack BEFORE this component's own useEscClose registers, so comparing
later (post-registration) depth against that pre-registration baseline made
the overlay cancel itself the instant it opened. Verified with a throwaway
scratch test against the original formula before landing the +1 fix.

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

Finding 1 (Critical): InputBar.tsx's inline z-index on .input-bar-container
could never beat the reference scrim — it's a descendant of .bottom-float,
which already forms its own stacking context (position:absolute + z-index +
transform/will-change), so a descendant's z-index only orders it against
siblings inside that context, never against .bottom-float itself. Fix lifts
the actual stacking-context-forming ancestor instead: globals.css now raises
.bottom-float's z-index via var(--reference-composer-z) whenever
body[data-reference-held] is set, and ReferenceOverlay.tsx publishes that var
(Overlay.tsx stays the sole source of the layer number, design rule 11).

Finding 2: the data-reference-held attribute is now actually consumed by CSS
(was dead code before).

Finding 3: documented the same-commit useEscClose registration race as an
accepted, deliberate behavior (any contention for the L2 band yields — safer
than a rewrite of the shared, app-wide Esc stack) and pinned it with a test
that reproduces the exact race via React 18 batching.

Finding 4: added tests for the attribute/CSS-var contract and a source-text
guard on the consuming CSS rule. Real paint order still needs a dev-instance
visual check (not provable in jsdom, and out of scope per task constraints).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Traces the real selection when there is one and the whole host element when
there isn't (Destin's 9B call). Geometry is re-derived from the live DOM on
scroll/resize rather than snapshotted, because stored rects go stale the
instant anything moves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Clones the source and transforms it from its real rect to the viewport
centre. A clone rather than a scroll because the newest message — the
most likely right-click target — sits directly above the composer with
no scroll room and can never reach centre by scrolling.

Artifact references don't travel; the clone stays pinned over the
source and is clipped to the selection instead, so only the referenced
lines read at full --fg above the dim. Fixed a real coordinate bug
along the way: clip-path: path() resolves against the clipped
element's OWN border box, not the viewport, so the viewport-relative
outline path (`d`) must be shifted by the source's own rect before use
(new `shiftPath` helper in reference-geometry.ts) — using it unshifted,
as the task brief's literal snippet did, would have clipped the wrong
region whenever the source isn't pinned at the viewport origin.

Also deletes `rects` from useReferenceGeometry's return value: nothing
outside the hook's own tests ever consumed it once the artifact case
switched from redrawing selected runs to clipping the clone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The FLIP positioning effect depended on `d` (the traced-outline path,
recomputed on every scroll/resize), but travelling chat references
never read `d` at all -- only the artifact clip-path branch does.
Sharing one effect meant scrolling mid-travel reset `transform` back
to the source position and re-scheduled the RAF, visibly restarting
the 460ms lift.

Split into two effects: the travel FLIP now keys on
`[reference, travels]` only, so it runs exactly once per reference;
the artifact clip-path keeps `[reference, travels, d]` so it still
tracks the selection as the page scrolls.

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

The Cancel button's wrapper div is a sibling of .reference-lift-card inside
.reference-lift, which sets pointer-events: none. Only .reference-lift-card
had it restored, so the button was never a real hit-test target — clicks
appeared to work only because they fell through to the full-viewport scrim
behind it. Add pointer-events-auto to both the travelling and non-travelling
wrapper (same idiom as Toast.tsx's action slot).

Also corrects a WHY comment on the cloneNode(true) call that incorrectly
claimed canvas/scroll state survives cloning — it doesn't (cloneNode copies
DOM attributes only, not drawn bitmaps or scrollTop/scrollLeft).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Outline only — no trace animation, breathing pulse, glow, or travel easing.
Stamps data-reduced on .reference-trace/.reference-lift from useTheme()'s
reducedEffects (there's no data-reduced-effects attribute on <html> to key
CSS off directly). Corrected the plan brief's stale `.reference-lift[data-
reduced="true"] > *` selector (predates Task 8's clone-markup split) to
target .reference-lift-card, and scoped the lift-shadow override to the
travelling case only so it doesn't clobber the artifact clone's unconditional
box-shadow: none.

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

Issues A/B — a detached source positioned the card wrongly. Both the FLIP
travel effect and the artifact clip effect called getBoundingClientRect() on
anchor.host unconditionally. When the host is gone (session switched and back,
or the file tab closed) that returns an all-zero rect, so the chat card landed
near the top-left corner and the artifact clone was pinned at (0,0) fully
unclipped. Both now detect !host.isConnected and render the non-anchored
centred card the spec's section 7 already specified, with no animation from a
meaningless origin.

Issue C — "right-click again to replace the held reference" (spec section 7)
was dead for chat references. The window-wide scrim intercepts the click, so
buildContextMenu bailed at its .chat-scroll ancestry gate. Artifact references
only worked by accident, because cloneNode copies data-artifact-viewer onto the
clone and that branch is checked before the .chat-scroll gate. ContextMenuHost
now resolves the true element under the pointer via elementsFromPoint when the
raw target is the scrim, so replacement works uniformly. Left-click on the
scrim still cancels; the transcript stays dimmed.

Also: object-wrapper workaround for the TS 5.9.3 never-narrowing quirk in the
new test, and the elementsFromPoint stub is now restored after each test rather
than left on the shared document.

Both detached-source tests verified RED against the unfixed overlay.

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

Three defects from Destin's dev-instance review.

1. "Black box around where the message bubble was originally." The trace SVG
   draws `d`, measured from anchor.host at its ORIGINAL position — but a chat
   reference's card flies to the viewport centre, so the outline stayed behind
   and boxed the empty space the bubble vacated. On a dark-accent theme that
   reads as a hard black rectangle. The source-anchored trace is now rendered
   only for NON-travelling (artifact) references, where nothing moves and the
   outline is exactly right. The travelling card carries its own ring + glow
   in CSS, so the highlight hugs the card and travels with it by construction.

2. "Weird extra clear space to the right of the centered message bubble."
   Chat bubbles carry `max-w-[85%]`. The lift wrapper is sized to the bubble's
   own measured rect, so that percentage resolved a SECOND time against it,
   leaving the card 15% narrower than its wrapper — dead space on the right,
   with the cancel button pinned out in it. The clone now fills the wrapper.

3. The ring sits on the clone rather than the holder, so it follows the
   bubble's own border-radius instead of boxing a rounded card in a square.

Reduced-effects keeps a plain ring and drops the glow + amplified shadow.
Task 9's data-reduced tests now cover both kinds, since the trace only exists
for one of them. New regression test verified RED against the old behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
.bubble-timestamp renders INSIDE the bubble div (UserMessage.tsx:75,
AssistantTurnBubble.tsx:437), so reading textContent swept it into the
quote — Destin's dev review caught a scaffold ending
`...ready to use or delete as needed.12:55 AM"`.

Strips chrome from a CLONE, never the live node: the reference path must
not mutate React-managed DOM. Pinned by a test asserting outerHTML is
unchanged alongside the content assertion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Problem 1: InputBar dispatched the optimistic USER_PROMPT/QUEUED_MESSAGE_ADDED
with the user's raw draft while the actual sent text carried the reference
scaffold prepended. chat-reducer's TRANSCRIPT_USER_MESSAGE dedup matches on
exact content, so the mismatch meant no pending entry was ever found and the
transcript event appended a second bubble. Fix: dispatch outgoing.content (the
exact string sent) at all three call sites instead of the draft-only
bubbleMessage.

Problem 2: since the timeline entry now legitimately holds the scaffold,
UserMessage rendered it as raw boilerplate text. Added
context-menu/reference-prompt.ts as the single source of truth for the
scaffold format (lead-ins, follow-up marker, buildScaffold/
buildArtifactScaffold, and a whitespace-tolerant parseReferencePrompt — it
has to tolerate InputBar's newline-flattening sanitize, not just the builder's
own multi-line output). build-reference.ts now calls the shared builder
instead of holding its own copy. UserMessage renders a quoted reply strip
(collapsed past ~3 lines/~240 chars, via a real <Button> toggle) for
chat-text/chat-code references and a compact descriptor line for artifact
references, falling through to the existing plain-text path otherwise.

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

Two dev-review defects.

1. Weird corner artifacts around the lifted card. box-shadow paints OUTSIDE
   the border box and is clipped by an ANCESTOR's overflow — .reference-lift-card
   carried max-height/overflow-y, which sliced the clone's ring off everywhere
   except where it bled past the corners. The scroll clamp moves onto the clone
   itself; an element's own overflow never clips its own shadow.

2. The cancel X was the default ghost Button — transparent, so it disappeared
   against a wallpaper theme. Now a solid circular chip on bg-panel/border-edge
   (theme tokens, no literals).

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

Destin picked options B+D from the dev-review mockup. They compose into one
control rather than two: D's pill IS the collapsed state, B's labelled panel
IS the expanded one.

Collapsed by default and unconditionally — not length-gated — so the bubble
stays roughly the size of what the user actually typed and the reference never
competes with their own words. Clicking the pill reveals the full quote in a
tinted panel headed 'Claude said', with a Hide control to collapse again.

An artifact reference renders as the same pill shape but static: it is already
a short descriptor ('lines 12-14 of chat-reducer.ts'), not a quoted body, so
there is nothing to expand.

Removes the now-dead JS clamp helpers (isLongQuote/clampQuote/COLLAPSE_*) —
the pill ellipsises in CSS, so nothing truncates in JS any more.

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

Dev-instance review flagged four defects in the held reference feature:
source and travelling clone both visible at once, no indication of which
part of a message was selected, a "weird black box" around artifact
selections, and an "uneven and janky" traced outline.

- Hide the source (visibility:hidden, not display:none) for the duration a
  CHAT reference is held, so the clone reads as the bubble having moved
  rather than a second copy. Restored to its exact prior inline state
  (including dropping a leftover empty style attribute) on clear.
- Capture the selection's character offsets at build time
  (computeSelectionOffsets) and re-apply them as a <mark class="reference-
  mark"> inside the detached clone (applyHighlightMark), for both chat and
  artifact references.
- Delete the traced SVG outline entirely for both kinds. The artifact
  clip-path (and its underlying geometry hook) stays — it never rendered
  through the SVG, just shared its path data — along with the dead CSS
  (keyframes, --ref-wash, reduced-motion branch) that only the SVG used.

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

Dev-review follow-up on the held-reference feature:

- Cancelling (Esc, scrim click, x button) now flies the clone back to the
  source's current rect before actually clearing the reference, mirroring
  the entry FLIP's timing/easing. The source stays hidden until the flight
  lands, so the "two copies visible" bug this feature already fixed once
  can't come back on the way out. Sending is untouched — InputBar's send()
  still calls clearReference() directly, so it clears immediately with no
  animation; the call SITE is what distinguishes cancel from send, not a
  flag. Detached sources, artifact references (which never travelled),
  reducedEffects, and prefers-reduced-motion all skip straight to an
  immediate clear.
- Artifact selections now get a visible ring (theme-token box-shadow), not
  just a translucent background tint layered on a clip-path region that was
  already the bright part of the screen. Investigated first: the offsets and
  the mark were already reaching the artifact clone correctly (the offset
  capture and the clip-path share the same captured Range) — the gap was
  purely that the mark had no edge of its own to read as "selected" once it
  sat inside an already-undimmed area.

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

Commit 2a89452 deleted the outline entirely after dev review flagged it as
"uneven and janky" with a "weird black box" around artifact selections. Two
different bugs were tangled together there and deletion papered over both:

- Geometry: the union path was raw, unsnapped, unmerged client rects joined
  with hard 90 degree steps. reference-geometry.ts now snaps every edge to a
  whole pixel, merges near-identical adjacent line-box edges (2px tolerance),
  drops near-zero boxes, and rounds every corner (quadratic, clamped per
  vertex to half the shorter adjacent edge) via a new buildRoundedOutlinePath.
- Anchor: the outline used to measure the SOURCE's position, so a travelling
  chat reference painted a box around the empty space the bubble left behind
  — worked around at the time by just never rendering it for that case.
  useReferenceGeometry now measures the `.reference-mark` elements inside the
  CLONE instead, which is wherever the highlight actually is for both a
  travelling and a pinned clone, by construction. A transitionrun/end-driven
  rAF loop keeps it tracking during the FLIP travel without ever touching
  transform/left/top itself, so it can't restart the travel the way sharing
  an effect with `d` used to.

.reference-mark's inset ring (added as a stand-in while there was no outline)
is now a literal duplicate of the restored outline and is removed; the
background tint stays as a distinct signal. The travelling card's own
ring+glow is kept — it answers a different question ("this is the referenced
message" vs. the outline's "this is the selected span within it").

Full reasoning, tolerances, and verification output in
.superpowers/sdd/outline-restore-report.md (gitignored, local only).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A selection that crosses a syntax-highlighting token boundary makes
apply-highlight.ts emit one <mark> per covered text node, so a single
visual line can hand back more than one client rect. toBoxes fed every
rect straight to buildRoundedOutlinePath's down-right/up-left traversal,
which only forms a valid simple polygon with at most one box per line --
feeding it 2 boxes on one line produced a self-intersecting path whose
fill bled into a bounding-box-shaped region behind the correctly-tight
mark highlights (Destin's 2026-07-28 report: outline enclosing text that
was never selected, alongside what looked like a second, larger box).

toBoxes now groups rects landing on the same line (by raw vertical
midpoint, pre-padding) and unions each group into one box first.

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