Skip to content

fix: popover position:fixed, miss/not_found ghost scale-up#417

Merged
bensonwong merged 9 commits intomainfrom
fix/miss-keyhole-page-expand-scale
Apr 12, 2026
Merged

fix: popover position:fixed, miss/not_found ghost scale-up#417
bensonwong merged 9 commits intomainfrom
fix/miss-keyhole-page-expand-scale

Conversation

@bensonwong
Copy link
Copy Markdown
Collaborator

@bensonwong bensonwong commented Apr 12, 2026

Summary

  • Popover scroll fix (Popover.tsx): Restore position:fixed on the popover wrapper so it stays anchored to the viewport instead of scrolling with the page. With position:fixed, the popover is always in viewport space — the old scroll-container-relative coordinate math (x - containerRect.left + container.scrollLeft) reduces to a direct passthrough. The portal detection logic is simplified from ~35 lines of Radix/scroll-ancestor-walking to a 2-line [data-dc-portal-root] ?? document.body lookup; scroll ancestry is now irrelevant.
  • Ghost scale-up fix (viewTransition.ts): buildGhostTargetFromViewport (the miss/not_found fallback) previously set ghostRect = visibleRect, causing the ghost to scale up to match the expanded page image dimensions. Now mirrors buildGhostTarget: keeps ghost at source keyhole dimensions (snapshot.viewportRect) and aligns the image center-of-mass over the visible page center — pure translate, no scale.
  • Test update (popoverScrollStability.spec.tsx): Flip scroll-stability assertions from "Y shifts by ~scrollAmount" to "Y delta ≤ 5px". Add a direct toHaveCSS("position", "fixed") assertion to prove the mechanism (not just the symptom — a popover portalled to document.body with position:absolute would also appear viewport-stable in the test's scroll-container setup).
  • Comment (Popover.tsx): Document the assumption that [data-dc-portal-root] must be present before mount for the z-index stacking guarantee to hold.

Test plan

  • Open a citation popover and scroll the page — popover should stay fixed at its viewport position, not drift with the scroll
  • Trigger a miss/not_found page-expand — ghost should slide from keyhole to the page at constant size (no scale-up flash)
  • npm run test:ctpopoverScrollStability spec passes with the new position:fixed assertion
  • npm run build passes
  • npm run lint passes

Benson and others added 4 commits April 11, 2026 15:39
For citations with no annotation (miss/not_found), the page-expand ghost
was sized to the full visible page rect, causing scaleX/scaleY >> 1 and
an aggressive "keyhole filling the screen" effect.

Fix: pass the GhostSnapshot into buildGhostTargetFromViewport and compute
ghostRect using source keyhole dimensions (pure translate, scale = 1.0),
centering the ghost's image anchor on the page's visible area center —
mirroring the buildGhostTarget logic used for the success path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The scroll-following regression was introduced in aa97aff/cd49ef1:
portaling into the Radix ScrollArea viewport (or nearest scrollable
ancestor) with position:absolute and container-relative coordinates
makes the popover part of the scrollable document — it scrolls with
the page instead of staying at its rendered viewport position.

Root cause: position:absolute anchors the element in document space;
as the scroll container scrolls, the popover's document-space position
becomes a different viewport-space position.

Fix: revert to position:fixed with direct viewport-relative coordinates.
With position:fixed the popover stays at the viewport position it was
rendered at, regardless of scroll. The wheel passthrough handler
(added in Popover.tsx for the scroll-eating problem) already handles
the only reason position:fixed was abandoned: Chrome scroll latching
trapping wheel events on the popover.

Portal detection is simplified: only check for [data-dc-portal-root]
(consumer-provided fixed overlay for guaranteed stacking above headers),
then fall back to document.body. Scroll-ancestor detection is removed —
position:fixed escapes overflow:hidden/scroll without needing to be
portaled inside the scroll container.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The test was written for position:absolute behavior (yDelta ≈ scrollAmount).
With position:fixed the popover stays at its viewport position regardless of
scroll, so the correct assertion is yDelta ≈ 0.

Updates test name and header comment to match the restored fixed behavior.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…lity test

Add inline assumption comment to Popover.tsx explaining that
[data-dc-portal-root] must be present before mount for the dedicated
overlay's z-index guarantee to hold.

Strengthen popoverScrollStability spec with a direct position:fixed
CSS assertion so the test proves the mechanism (position:fixed) and not
just the symptom (viewport-stability that could also arise from
position:absolute portalled outside the scroll container).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown

vercel bot commented Apr 12, 2026

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

4 Skipped Deployments
Project Deployment Actions Updated (UTC)
agui-chat-deepcitation Ignored Ignored Preview Apr 12, 2026 1:34am
deepcitation-langchain-rag-chat Ignored Ignored Preview Apr 12, 2026 1:34am
mastra-rag-deepcitation Ignored Ignored Preview Apr 12, 2026 1:34am
nextjs-ai-sdk-deepcitation Ignored Ignored Preview Apr 12, 2026 1:34am

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

Code Review

Overall this is a solid, well-reasoned simplification. Both fixes address real bugs (popover drift on scroll, ghost scale-up on miss/not_found), the comments document the tradeoffs clearly, and the test correctly pivots from testing the symptom to testing the mechanism. A few things worth looking at before merging:


Popover.tsx — Breaking change concern

The old behavior was explicitly documented as intentional ("popover scrolls with the content") and the scroll-stability spec was written to verify it. This PR inverts that contract.

Any consumer that relies on the popover tracking document scroll (not viewport) will regress silently — they won't see a compile error and the behavior looks fine until they actually scroll. The PR description says "Restore position:fixed" which implies there was a prior regression, but there's no reference to the commit or PR where position:absolute was introduced. Worth verifying that lineage so reviewers can confirm this is a regression fix and not a UX reversal.

Suggested: Add a brief note to the PR (or a code comment) linking to the commit where position:absolute was introduced, so the history is clear.


useLayoutEffect(…, []) — Empty dep array is correct but fragile

}, []); // run once on mount — portal target is stable for the session

This is correct given the stated assumption. The comment documents the tradeoff well. One edge case: if CitationComponent mounts during SSR hydration before [data-dc-portal-root] is in the DOM (e.g., streamed HTML where the portal root appears after the citation), the effect runs once, finds nothing, and falls back to document.body permanently for that instance. Since position:fixed works fine with document.body, this is functionally correct — but the z-index stacking guarantee is quietly lost and there's no warning.

Consider: A console.warn in dev mode when [data-dc-portal-root] is absent but the consumer appears to expect it (e.g., some detectable signal). Low priority, but would help catch misconfigurations early.


buildGhostTargetFromViewport — Ghost rect can extend off-screen

const ghostRect = new DOMRect(pageCX - anchorInGhostX, pageCY - anchorInGhostY, srcW, srcH);

If anchorInGhostX > pageCX (keyhole is wider than the visible page area and scrolled right), ghostRect.left goes negative — the ghost starts partially off-screen. This mirrors buildGhostTarget exactly, so the behavior is at least consistent with the hit-annotation path. But in the miss/not_found fallback, where pageCX is the center of the visible page area (potentially a narrow stripe), this is more likely to produce a large negative left.

The ghost is position:fixed so negative left is safe visually (clipped by viewport, not by a scroll container), and the scale/translate math in runPageExpandGhostAnimation handles this. Just worth verifying the animation still looks correct when the ghost origin starts off the left viewport edge.


Missing test for the ghost fix

The ghost scale-up fix has no dedicated test. The popover scroll stability spec was updated to reflect the new behavior, but there's no spec for buildGhostTargetFromViewport returning a ghostRect at keyhole dimensions rather than visibleRect dimensions.

Given that the animation math is non-trivial (and the bug was subtle — ghost silently scaled to page dimensions), a unit test for buildGhostTargetFromViewport would be a good safety net:

// expected: ghostRect.width === snapshot.viewportRect.width (not visibleRect.width)
// expected: ghostRect.height === snapshot.viewportRect.height

Not blocking, but this is the kind of regression that's easy to re-introduce.


Minor: Old triggerRef.current dep array comment is gone — good catch

The removed comment // triggerRef is a stable ref; open re-runs detection on each popover open was describing [triggerRef.current] as a dep, which is a React anti-pattern (.current mutations don't trigger effects). Removing it along with the complex detection logic is the right call.


Test update looks correct

The toHaveCSS("position", "fixed") assertion is the right defensive addition — it tests the mechanism, not just the symptom. Without it, a position:absolute portal to document.body (outside the test's scroll container) would also appear viewport-stable, giving a false pass. This is a good pattern.

The Y-delta tolerance of ≤ 5px for fixed positioning is reasonable — accounts for sub-pixel browser rendering differences.


Summary: The core logic of both fixes is correct and the implementation is clean. The main things I'd want addressed before merging are (1) confirming the position:absoluteposition:fixed revert has a paper trail, and (2) adding a ghost-rect dimension assertion to catch future regressions on the buildGhostTargetFromViewport fix.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 12, 2026

✅ Playwright Test Report

Status: Tests passed

📊 Download Report & Snapshots (see Artifacts section)

What's in the Visual Snapshots

The gallery includes visual snapshots for:

  • 🖥️ Desktop showcase (all variants × all states)
  • 📱 Mobile showcase (iPhone SE viewport)
  • 📟 Tablet showcase (iPad viewport)
  • 🔍 Popover states (verified, partial, not found)
  • 🔗 URL citation variants

Run ID: 24295968335

Benson and others added 5 commits April 11, 2026 18:51
…on:fixed lineage

Add buildGhostTargetFromViewport.test.ts (6 cases) to pin the regression fix:
- ghostRect.width/height must match snapshot.viewportRect (not visibleRect)
- image center-of-mass alignment formula is verified by coordinate assertions
- null/edge-case paths covered (zero-size img, no matching container)

Export buildGhostTargetFromViewport and GhostSnapshot from viewTransition.ts
with @internal JSDoc — not re-exported in the public index.

Add a history comment in Popover.tsx citing a91a344 (the commit that
switched to position:absolute) and explaining that its root cause
(wheel-scroll passthrough in overflow:hidden apps) is now handled by the
overflow:clip + JS wheel-passthrough approach, making position:fixed correct
again.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…t tests

Biome lint/style/noNonNullAssertion forbids the ! operator. Replace all
result! usages with result?. (optional chaining) in expect() calls, and
use a type-narrowing guard (if result == null return) for the one case
that needs destructuring.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… page

position:fixed makes the popover stay pinned to the viewport while the
user scrolls, which is the scroll-eating bug itself. The correct behavior
is for the popover to scroll away with the trigger element.

Restore position:absolute inside the scroll-ancestor portal container.
Restore the scroll-ancestor portal detection logic (Radix ScrollArea
viewport → nearest overflow:auto ancestor → document.body).
Restore container-offset coordinate math in recomputePosition.
Restore scroll-stability test assertions: Y shifts by ~scrollAmount.

The ghost scale-up fix (efa492d) and unit tests are unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…wport

When a consumer provides [data-dc-portal-root] (a position:fixed overlay),
the popover portals into it and is viewport-pinned regardless of its own
position:absolute CSS — the fixed overlay is the containing block, not the
scroll container. Repositioning in real-time is insufficient; the right fix
is to dismiss the popover when the page scroll container fires a scroll event
(matching Linear / Notion / GitHub behavior).

Updates the scroll-stability Playwright test to assert dismissal on scroll.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…oll ancestor

The [data-dc-portal-root] exception (added in 4e1e8de to escape overflow
clipping near sticky headers) made the popover viewport-pinned even with
position:absolute, because portaling into a position:fixed overlay means
that overlay — not the scroll container — is the containing block.

Restoring the pre-4e1e8de approach: portal into the Radix ScrollArea viewport
(or nearest scrollable ancestor, or document.body). With position:absolute
inside the scroll container the popover sits in document space and scrolls
away naturally when the page scrolls.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@bensonwong bensonwong merged commit 3ad31b0 into main Apr 12, 2026
14 checks passed
@bensonwong bensonwong deleted the fix/miss-keyhole-page-expand-scale branch April 12, 2026 01:37
@bensonwong bensonwong mentioned this pull request Apr 12, 2026
5 tasks
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