diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 283a9882..a11b8dab 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -52,7 +52,7 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Node.js
uses: useblacksmith/setup-node@65c6ca86fdeb0ab3d85e78f57e4f6a7e4780b391 # v5.0.4
@@ -60,7 +60,7 @@ jobs:
node-version: '22'
- name: Setup Bun
- uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
+ uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
@@ -77,7 +77,7 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Node.js
uses: useblacksmith/setup-node@65c6ca86fdeb0ab3d85e78f57e4f6a7e4780b391 # v5.0.4
@@ -85,7 +85,7 @@ jobs:
node-version: '22'
- name: Setup Bun
- uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
+ uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
@@ -102,7 +102,7 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Node.js
uses: useblacksmith/setup-node@65c6ca86fdeb0ab3d85e78f57e4f6a7e4780b391 # v5.0.4
@@ -110,7 +110,7 @@ jobs:
node-version: '22'
- name: Setup Bun
- uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
+ uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml
index d8a1e57c..687e8e25 100644
--- a/.github/workflows/claude-code-review.yml
+++ b/.github/workflows/claude-code-review.yml
@@ -34,7 +34,7 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 1
diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml
index 10ae8a6c..816ff6f2 100644
--- a/.github/workflows/claude.yml
+++ b/.github/workflows/claude.yml
@@ -32,13 +32,13 @@ jobs:
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
- uses: anthropics/claude-code-action@ea36d6abdedc17fc2a671b36060770b208a6f8f1 # v1.0.51
+ uses: anthropics/claude-code-action@cd77b50d2b0808657f8e6774085c8bf54484351c # v1.0.72
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index a691688f..311e79d2 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -25,19 +25,19 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Initialize CodeQL
- uses: github/codeql-action/init@f5c2471be782132e47a6e6f9c725e56730d6e9a3 # v3.32.3
+ uses: github/codeql-action/init@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0
with:
languages: ${{ matrix.language }}
queries: security-extended
config-file: ./.github/codeql/codeql-config.yml
- name: Autobuild
- uses: github/codeql-action/autobuild@f5c2471be782132e47a6e6f9c725e56730d6e9a3 # v3.32.3
+ uses: github/codeql-action/autobuild@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@f5c2471be782132e47a6e6f9c725e56730d6e9a3 # v3.32.3
+ uses: github/codeql-action/analyze@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0
with:
category: "/language:${{ matrix.language }}"
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 7aaee81c..1c02bb82 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -30,10 +30,10 @@ jobs:
lychee-exit-code: ${{ steps.lychee.outputs.exit_code }}
steps:
- name: Checkout
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Ruby
- uses: ruby/setup-ruby@09a7688d3b55cf0e976497ff046b70949eeaccfd # v1.288.0
+ uses: ruby/setup-ruby@dffb23f65a78bba8db45d387d5ea1bbd6be3ef18 # v1.293.0
with:
ruby-version: '3.2'
bundler-cache: true
@@ -41,7 +41,7 @@ jobs:
- name: Setup Pages
id: pages
- uses: actions/configure-pages@1f0c5cde4bc74cd7e1254d0cb4de8d49e9068c7d # v4.0.0
+ uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5.0.0
- name: Build with Jekyll
working-directory: docs
@@ -51,7 +51,7 @@ jobs:
- name: Check links
id: lychee
- uses: lycheeverse/lychee-action@c053181aa0c3d17606addfe97a9075a32723548a # v1.9.3
+ uses: lycheeverse/lychee-action@8646ba30535128ac92d33dfc9133794bfdd9b411 # v2.8.0
with:
args: --verbose --no-progress './docs/_site/**/*.html' --exclude 'localhost' --exclude '127.0.0.1' --accept 200,204,206,301,302,307,308
output: lychee-report.md
@@ -59,7 +59,7 @@ jobs:
- name: Upload lychee report
if: always()
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+ uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: lychee-report
path: lychee-report.md
@@ -91,13 +91,13 @@ jobs:
needs: build
steps:
- name: Download lychee report
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: lychee-report
continue-on-error: true
- name: Comment on PR
- uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
const fs = require('fs');
diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml
index 97e2e726..f67410aa 100644
--- a/.github/workflows/playwright.yml
+++ b/.github/workflows/playwright.yml
@@ -27,7 +27,7 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# For PRs, checkout the head ref to allow pushing snapshot updates
ref: ${{ github.event_name == 'pull_request' && github.head_ref || github.ref }}
@@ -40,7 +40,7 @@ jobs:
node-version: '22'
- name: Setup Bun
- uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
+ uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
@@ -92,7 +92,7 @@ jobs:
fi
- name: Upload Playwright report artifact
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+ uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
if: ${{ !cancelled() }}
with:
name: playwright-report
@@ -100,7 +100,7 @@ jobs:
retention-days: 14
- name: Upload visual snapshots artifact
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+ uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
if: ${{ !cancelled() }}
with:
name: visual-snapshots
@@ -113,7 +113,7 @@ jobs:
# Comment on PR with report link
- name: Comment PR with report link
- uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
if: github.event_name == 'pull_request' && !cancelled()
with:
script: |
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 72909e4c..1acd48db 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -28,18 +28,18 @@ jobs:
if: github.event.release.prerelease == false
steps:
- - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.release.tag_name || inputs.tag_name }}
- name: Setup Node.js
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
+ uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: '20'
registry-url: 'https://registry.npmjs.org'
- name: Setup Bun
- uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
+ uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
@@ -56,7 +56,7 @@ jobs:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Update release description
- uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
RELEASE_ID: ${{ inputs.release_id }}
with:
diff --git a/BRANDING.md b/BRANDING.md
index d0277e03..10cb47fe 100644
--- a/BRANDING.md
+++ b/BRANDING.md
@@ -86,6 +86,22 @@ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
---
+## Animation Timing
+
+Shared 5-tier scale with the web app. Import constants from `src/react/constants.ts` — never inline values.
+
+| Constant | Duration | Easing | Usage |
+|----------|----------|--------|-------|
+| `ANIM_INSTANT_MS` | 80ms | `EASE_EXPAND` | Hover states ("The Spark") |
+| `ANIM_FAST_MS` | 120ms | `EASE_EXPAND` | Popover entry, list expand |
+| `ANIM_STANDARD_MS` | 180ms | `EASE_COLLAPSE` | Geometry changes, drawer |
+| `ANIM_MEASURED_MS` | 250ms | `EASE_COLLAPSE` | Cross-component morph |
+| `ANIM_SLOW_MS` | 350ms | `ease-out` | Staged sequences |
+
+**Asymmetric timing is required:** expand ≥ collapse. Example: popover enter = 120ms (`EASE_EXPAND`), popover exit = 80ms (`EASE_COLLAPSE`). Use `EASE_EXPAND = cubic-bezier(0.34, 1.02, 0.64, 1)` and `EASE_COLLAPSE = cubic-bezier(0.2, 0, 0, 1)` — never inline cubic-bezier strings.
+
+---
+
## Key Rules for Contributors
- Use `text-dc-*` / `bg-dc-*` / `border-dc-*` Tailwind classes — never hardcode `slate-N` for persistent colors
diff --git a/README.md b/README.md
index 86d3acc3..69c1b498 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-
+
DeepCitation
@@ -13,8 +13,8 @@ Show proof for every AI citation.
[](https://github.com/DeepCitation/deepcitation/actions/workflows/ci.yml)
[](https://opensource.org/licenses/MIT)
-[](https://www.npmjs.com/package/deepcitation)
-[](https://bundlephobia.com/package/deepcitation)
+[](https://www.npmjs.com/package/deepcitation)
+[](https://bundlephobia.com/package/deepcitation)
diff --git a/docs/_includes/footer_custom.html b/docs/_includes/footer_custom.html
new file mode 100644
index 00000000..0cc9c9f2
--- /dev/null
+++ b/docs/_includes/footer_custom.html
@@ -0,0 +1 @@
+© {{ 'now' | date: "%Y" }} DeepCitation — a product of FileLasso, Inc.
diff --git a/docs/_sass/custom/custom.scss b/docs/_sass/custom/custom.scss
index 0640f054..e84b6309 100644
--- a/docs/_sass/custom/custom.scss
+++ b/docs/_sass/custom/custom.scss
@@ -2,22 +2,22 @@
// ====================================================
// -----------------------------------------------------
-// Brand Colors - Slate Scale
-// -----------------------------------------------------
-$slate-50: #F8FAFC;
-$slate-100: #F1F5F9;
-$slate-200: #E2E8F0;
-$slate-300: #CBD5E1;
-$slate-400: #94A3B8;
-$slate-500: #64748B;
-$slate-600: #475569;
-$slate-700: #334155;
-$slate-800: #1E293B;
-$slate-900: #0F172A;
-$slate-950: #020617;
+// Brand Colors - Slate Scale (zinc/warm, matches web app BRANDING.md)
+// -----------------------------------------------------
+$slate-50: #FAFAFA;
+$slate-100: #F4F4F5;
+$slate-200: #E4E4E7;
+$slate-300: #D4D4D8;
+$slate-400: #A1A1AA;
+$slate-500: #71717A;
+$slate-600: #52525B;
+$slate-700: #3F3F46;
+$slate-800: #27272A;
+$slate-900: #18181B;
+$slate-950: #09090B;
// Brand display font — Playfair Display for wordmark and h1 headings
-$font-display: 'Playfair Display', Georgia, serif;
+$font-display: 'Playfair Display', serif;
// Bracket accent — reusable left-edge indicator (app sidebar, testimonials, callouts)
@mixin bracket-indicator {
@@ -51,10 +51,12 @@ $btn-primary-color: $brand-blue-light;
// Dark Mode Mixin (DRY)
// -----------------------------------------------------
@mixin dark-mode-colors {
+ color-scheme: dark;
--body-background-color: #{$slate-950};
+ --body-background-image: none; // suppress JTD v0.8 body background-image gradient
--sidebar-color: #{$slate-900};
--body-heading-color: #{$slate-200}; // per BRANDING.md External Documentation Theme
- --body-text-color: #{$slate-400}; // per BRANDING.md: #94A3B8
+ --body-text-color: #{$slate-400}; // per BRANDING.md: #A1A1AA (zinc-400)
--link-color: #{$brand-blue-dark-link}; // #77bff6 (blue-dark) — not blue-luminous
--link-hover-color: #{$brand-blue-dark-link-hover};
--code-background-color: #{$slate-900};
@@ -156,9 +158,10 @@ h1, h2, h3, h4, h5, h6 {
line-height: 1.7;
// Display headings use Playfair Display per BRANDING.md
+ // !important to beat JTD compiled h1 rule that injects Georgia fallback
h1 {
font-size: 2rem;
- font-family: $font-display;
+ font-family: $font-display !important;
font-weight: 700;
}
h2 { font-size: 1.5rem; margin-top: 2rem; }
@@ -223,28 +226,28 @@ code, pre, .highlight {
}
// -----------------------------------------------------
-// "The Spark" - 75ms ease-out transitions (specific properties)
+// "The Spark" - 80ms ease-out transitions (Instant tier per BRANDING.md)
// -----------------------------------------------------
a {
- transition: color 75ms ease-out,
- text-decoration-thickness 75ms ease-out;
+ transition: color 80ms ease-out,
+ text-decoration-thickness 80ms ease-out;
}
.btn,
.nav-list-link {
- transition: color 75ms ease-out,
- background-color 75ms ease-out,
- border-color 75ms ease-out;
+ transition: color 80ms ease-out,
+ background-color 80ms ease-out,
+ border-color 80ms ease-out;
}
.search-input {
- transition: border-color 75ms ease-out,
- box-shadow 75ms ease-out;
+ transition: border-color 80ms ease-out,
+ box-shadow 80ms ease-out;
}
code {
- transition: background-color 75ms ease-out,
- border-color 75ms ease-out;
+ transition: background-color 80ms ease-out,
+ border-color 80ms ease-out;
}
// -----------------------------------------------------
@@ -335,17 +338,29 @@ pre {
// so we must override wrapper elements at runtime.
// -----------------------------------------------------
@media (prefers-color-scheme: dark) {
+ // Suppress JTD v0.8 body background-image gradient (compiled from $body-background-image)
+ body {
+ background-image: none;
+ }
+
// .main-content-wrap and .main bg is set unconditionally above
.page-header {
background-color: var(--body-background-color);
+ background-image: none;
}
// -------------------------------------------------
- // Tables — JTD hardcodes background-color: #fff on td elements.
- // tr/table are transparent, but td gets white directly, so we override td.
+ // Tables — JTD hardcodes background-color: #fff on td AND th elements.
+ // Both must be overridden; CSS vars alone lose to JTD's compiled specificity.
// tr:nth-child(even) td gets a slightly lighter shade for stripe contrast.
// -------------------------------------------------
+ .main-content th {
+ background-color: var(--sidebar-color); // slate-900 — visually distinct header
+ border-color: var(--border-color);
+ color: var(--body-heading-color);
+ }
+
.main-content td {
background-color: var(--body-background-color);
border-color: var(--border-color);
@@ -394,18 +409,22 @@ pre {
svg { fill: var(--body-heading-color); }
}
- // Navigation overlay on mobile
- .search-overlay {
- background-color: rgba(0, 0, 0, 0.6);
+ // Active nav item — JTD compiles a light highlight color that wins in dark mode
+ .side-bar .nav-list .nav-list-link.active {
+ background-color: var(--nav-active-bg) !important;
+ color: var(--nav-active-text);
}
- // Table header borders (th background is set via CSS var outside media query,
- // but JTD compiles a hardcoded border color on th that needs overriding here)
- .main-content th {
- border-color: var(--border-color);
+ // Parent nav items in dark mode — heading color, not link color
+ .side-bar .nav-list > .nav-list-item > .nav-list-link {
color: var(--body-heading-color);
}
+ // Navigation overlay on mobile
+ .search-overlay {
+ background-color: rgba(0, 0, 0, 0.6);
+ }
+
// Note/warning/important callouts (Just the Docs compiled backgrounds)
.note, .warning, .important {
background-color: var(--sidebar-color);
@@ -439,6 +458,28 @@ pre {
border-radius: 4px;
}
+ // -------------------------------------------------
+ // Code blocks — override JTD compiled light-mode backgrounds
+ // JTD hardcodes background-color on pre, .highlight, figure.highlight
+ // at build time; CSS vars alone lose to that specificity.
+ // -------------------------------------------------
+ pre,
+ pre.highlight,
+ figure.highlight,
+ figure.highlight pre,
+ .main-content pre,
+ .main-content .highlight {
+ background-color: var(--code-background-color) !important;
+ border-color: var(--code-border-color);
+ color: #{$slate-300};
+ }
+
+ .main-content pre code,
+ .highlight code {
+ background-color: transparent;
+ color: #{$slate-300};
+ }
+
// -------------------------------------------------
// Rouge syntax highlighting tokens (VS Code dark+)
// -------------------------------------------------
@@ -561,15 +602,16 @@ pre {
overflow: visible !important;
}
-// Navigation links in sidebar - high specificity to override theme
+// Navigation links in sidebar - neutral text by default, blue on hover/active
+// (reduces visual weight of sidebar relative to content — per branding review)
.side-bar .nav-list .nav-list-link {
font-size: 1rem; // 16px - bigger nav items
padding: 0.625rem 0.75rem;
- color: var(--link-color);
+ color: var(--body-text-color);
&:hover {
background-color: var(--nav-active-bg);
- color: var(--link-hover-color);
+ color: var(--link-color);
}
&.active {
@@ -598,11 +640,15 @@ pre {
}
}
-// Parent nav items (sections)
-.nav-list-item.parent {
- > .nav-list-link {
- font-weight: 600;
- color: var(--body-heading-color);
+// Parent nav items (top-level sections like "Getting Started", "API Reference")
+// JTD does NOT add a .parent class — target top-level items that contain a child nav-list
+// These should be neutral heading color, not link blue
+.side-bar .nav-list > .nav-list-item > .nav-list-link {
+ font-weight: 600;
+ color: var(--body-heading-color);
+
+ &:hover {
+ color: var(--link-color);
}
}
@@ -699,6 +745,7 @@ blockquote {
background-image: none !important; // Override Just the Docs purple gradient
border-color: var(--btn-primary-bg) !important;
color: var(--btn-primary-text) !important;
+ border-radius: 0 !important; // Bass's Constant — override JTD compiled radius
&:hover {
background-color: var(--btn-primary-bg-hover) !important;
@@ -788,6 +835,23 @@ blockquote {
&::before { @include bracket-indicator; }
}
+// -----------------------------------------------------
+// Mobile hamburger icon — override JTD compiled purple in BOTH modes
+// JTD compiles $link-color into .site-button at build time; this beats it.
+// Dark mode override also exists in the @media block below.
+// -----------------------------------------------------
+.site-header .site-button,
+.site-nav .site-button,
+.site-button.btn-reset,
+.site-button {
+ color: var(--body-heading-color) !important;
+
+ svg {
+ fill: var(--body-heading-color) !important;
+ color: var(--body-heading-color) !important;
+ }
+}
+
// Active states
*:active {
outline-color: var(--link-color);
diff --git a/docs/agents/animation-transition-rules.md b/docs/agents/animation-transition-rules.md
index 7375aa28..ab59bdaf 100644
--- a/docs/agents/animation-transition-rules.md
+++ b/docs/agents/animation-transition-rules.md
@@ -32,7 +32,7 @@ Five tiers cover all UI interactions. Match the tier to the perceptual weight of
| Constant | Value | Use |
|---|---|---|
-| `ANIM_INSTANT_MS` | 75ms | Hover background, trigger color change |
+| `ANIM_INSTANT_MS` | 80ms | Hover background, trigger color change |
| `ANIM_FAST_MS` | 120ms | Micro-interactions, exits, chevron rotations |
| `ANIM_STANDARD_MS` | 180ms | Popover entry fade, grid row expand |
| `ANIM_MEASURED_MS` | 250ms | Drawer slide-in, content morph |
@@ -305,6 +305,69 @@ The early opacity dip acts as perceptual motion blur — the user tracks the sha
---
+## Page-Expand Ghost Animation
+
+The keyhole→expanded-page transition uses a dedicated ghost element (not the View Transitions API) because the popover shell must snap to expanded-page layout while the image region animates independently.
+
+### Architecture
+
+```
+startEvidencePageExpandTransition (viewTransition.ts)
+ 1. capturePageExpandSource — snapshot keyhole geometry + image
+ 2. Dim popover root — opacity: PAGE_EXPAND_CONTENT_OPACITY_START
+ 3. flushSync(update) — popover snaps to expanded-page layout (already dimmed)
+ 4. createPageExpandGhost — fixed-position clone of keyhole image
+ 5. waitForPageExpandTarget — rAF poll until target is stable (~50ms)
+ 6. runPageExpandGhostAnimation — ghost + popover content animate together
+ 7. Cleanup — ghost.remove(), popover opacity cleared
+```
+
+**Critical**: The popover root (not just `[data-dc-inline-expanded]`) is dimmed. This
+ensures the header (Zone 1), status section (Zone 2), and image (Zone 3) are ALL
+dimmed together. Previously only the image container was dimmed, leaving the header
+at full opacity — which created a "page popped in" flash.
+
+### Choreography (250ms total)
+
+Mirrors the collapse's "dip-then-reveal" temporal structure:
+- **Collapse**: old snapshot dominates, dims early; new snapshot hidden until 60%, reveals sharply.
+- **Expand**: ghost dominates the first 60%; page near-invisible, reveals sharply in the last 40%.
+
+Two coordinated `Element.animate()` calls run in parallel:
+
+| Phase | Ghost | Page content | Visual effect |
+|---|---|---|---|
+| Polling (~50ms) | Not yet started | Dim at 0.03 | Nearly invisible — ghost will lead |
+| 0–18% | 0.55→0.75 | 0.03 | Ghost clearly dominates, page hidden |
+| 18–45% | 0.75→0.88 | 0.03 | Ghost at peak, eye tracks its motion |
+| 45–58% | 0.88→0.92 | 0.03→0.08 | Ghost landing, page barely emerging |
+| 58–72% | 0.92 | 0.08→0.35 | Handoff zone — ghost holds, page ramps |
+| 72–92% | 0.92→0.88 | 0.35→0.80 | Page takes over, ghost preparing exit |
+| 92–100% | 0.88→0 | 0.80→1.0 | Ghost exits decisively, page revealed |
+
+### Ghost mechanics
+
+- **`transform-origin: 0 0`** — mandatory. The `translate(tx, ty) scale(sx, sy)` math assumes top-left origin. Center origin causes the ghost to fly off-screen.
+- **Motion blur** — `filter: blur()` peaks at 6px mid-flight to mask the non-uniform scale distortion (squashed text from different scaleX/scaleY). Blur ramps: 0→3→6→4→1.5→0px across the keyframe offsets. GPU-composited, no layout cost.
+- **Ghost target** — `buildGhostTargetRect` queries `[data-dc-spotlight]` (the annotation overlay's dimming cutout). This is the "light area" — annotation rect + `SPOTLIGHT_PADDING`.
+- **Source priming** — `primeEvidencePageExpandSource(containerRef)` is called from the click handler. The primed ref is consumed within 500ms.
+- **Easing** — Ghost uses `EASE_COLLAPSE` (> 200px travel, per overshoot pixel budget rule). Page content uses `BLINK_ENTER_EASING` (near-linear settle).
+
+### Stale target detection
+
+`InlineExpandedImage` uses a ResizeObserver with `prevContainerVisibleRef` to detect `display:none → visible` transitions. On each visibility change, `pageExpandReady` and `hasAutoScrolledToAnnotationRef` are reset, forcing re-settle.
+
+### What NOT to change
+
+- Do not remove `transform-origin: 0 0` from `createPageExpandGhost`.
+- Do not remove `filter: blur()` from ghost keyframes — without it, non-uniform scale produces visibly squashed text mid-flight.
+- Do not use `startViewTransition` for page-expand — it uses `flushSync` + ghost.
+- Do not use the page container rect or full image rect as ghost target (giant flash).
+- Do not use `width`/`height` animation on the ghost — layout-triggering. Use `transform: scale()` + blur.
+- Do not let the popover shell visually participate in the motion.
+
+---
+
## Anti-Patterns
- **No `EASE_EXPAND` on travel > 200px.** Absolute overshoot must stay ≤ 4px. VT morphs, page transitions, and height morphs exceed this — use `EASE_COLLAPSE` or `BLINK_ENTER_EASING`.
diff --git a/docs/assets/images/logo-dark.svg b/docs/assets/images/logo-dark.svg
index fcc4800d..37ecb90a 100644
--- a/docs/assets/images/logo-dark.svg
+++ b/docs/assets/images/logo-dark.svg
@@ -17,7 +17,7 @@
{
setKeyholeViewportSize(container, 500, 100);
fireKeyholeImageLoad(container, 800, 200);
+ // When the image fits completely, the keyhole redirects to page-expand
+ // (handlePageExpand is always passed internally). onImageClick is suppressed.
await waitFor(() => {
const strip = container.querySelector("[data-dc-keyhole]");
const button = strip?.closest("button");
- expect(button).toHaveAttribute("title", "Already full size" /* i18n default */);
+ expect(button).toHaveAttribute("aria-label", "Click to view full page");
});
clickKeyholeButton(container);
diff --git a/src/client/DeepCitation.ts b/src/client/DeepCitation.ts
index b6939645..10a8c7c0 100644
--- a/src/client/DeepCitation.ts
+++ b/src/client/DeepCitation.ts
@@ -28,6 +28,9 @@ import type {
const DEFAULT_API_URL = "https://api.deepcitation.com";
+/** Current SDK version — must be kept in sync with package.json. */
+export const SDK_VERSION = "0.2.1";
+
/**
* Default concurrency limit for parallel file uploads.
* Prevents overwhelming the network/server with too many simultaneous requests.
@@ -156,6 +159,7 @@ export class DeepCitation {
private readonly endUserId?: string;
private readonly endFileId?: string;
private readonly convertedPdfDownloadPolicy: ConvertedPdfDownloadPolicy;
+ private readonly onLatestVersion?: (latestVersion: string) => void;
/**
* Request deduplication cache for verify calls.
@@ -207,6 +211,7 @@ export class DeepCitation {
this.endUserId = config.endUserId;
this.endFileId = config.endFileId;
this.convertedPdfDownloadPolicy = config.convertedPdfDownloadPolicy ?? "url_only";
+ this.onLatestVersion = config.onLatestVersion;
}
/** Resolve endUserId: per-request override wins over instance default. */
@@ -224,6 +229,21 @@ export class DeepCitation {
return override ?? this.convertedPdfDownloadPolicy;
}
+ /** Common headers included in every API request. */
+ private baseHeaders(): Record {
+ return {
+ Authorization: `Bearer ${this.apiKey}`,
+ "X-SDK-Version": SDK_VERSION,
+ };
+ }
+
+ /** If the response contains a latest SDK version header, notify the callback. */
+ private checkLatestVersion(response: Response): void {
+ if (!this.onLatestVersion) return;
+ const latest = response.headers.get("X-Latest-SDK-Version");
+ if (latest) this.onLatestVersion(latest);
+ }
+
/**
* Clean expired entries from the verify cache.
* Only runs periodically to avoid performance overhead on every call.
@@ -304,9 +324,10 @@ export class DeepCitation {
const response = await fetch(`${this.apiUrl}/prepareAttachments`, {
method: "POST",
- headers: { Authorization: `Bearer ${this.apiKey}` },
+ headers: { ...this.baseHeaders() },
body: formData,
});
+ this.checkLatestVersion(response);
if (!response.ok) {
this.logger.error?.("Upload failed", { filename: name, status: response.status });
@@ -367,10 +388,7 @@ export class DeepCitation {
if (url) {
response = await fetch(`${this.apiUrl}/convertFile`, {
method: "POST",
- headers: {
- Authorization: `Bearer ${this.apiKey}`,
- "Content-Type": "application/json",
- },
+ headers: { ...this.baseHeaders(), "Content-Type": "application/json" },
body: JSON.stringify({
url,
filename,
@@ -393,11 +411,13 @@ export class DeepCitation {
response = await fetch(`${this.apiUrl}/convertFile`, {
method: "POST",
- headers: { Authorization: `Bearer ${this.apiKey}` },
+ headers: { ...this.baseHeaders() },
body: formData,
});
}
+ this.checkLatestVersion(response);
+
if (!response.ok) {
this.logger.error?.("Conversion failed", { url, filename, status: response.status });
throw await createApiError(response, "Conversion");
@@ -436,10 +456,7 @@ export class DeepCitation {
const response = await fetch(`${this.apiUrl}/prepareAttachments`, {
method: "POST",
- headers: {
- Authorization: `Bearer ${this.apiKey}`,
- "Content-Type": "application/json",
- },
+ headers: { ...this.baseHeaders(), "Content-Type": "application/json" },
body: JSON.stringify({
attachmentId: options.attachmentId,
endUserId: resolvedEndUserId,
@@ -447,6 +464,7 @@ export class DeepCitation {
convertedPdfDownloadPolicy,
}),
});
+ this.checkLatestVersion(response);
if (!response.ok) {
this.logger.error?.("Prepare converted file failed", {
@@ -503,10 +521,7 @@ export class DeepCitation {
const convertedPdfDownloadPolicy = this.resolveConvertedPdfDownloadPolicy(options.convertedPdfDownloadPolicy);
const response = await fetch(`${this.apiUrl}/prepareAttachments`, {
method: "POST",
- headers: {
- Authorization: `Bearer ${this.apiKey}`,
- "Content-Type": "application/json",
- },
+ headers: { ...this.baseHeaders(), "Content-Type": "application/json" },
body: JSON.stringify({
url: options.url,
attachmentId: options.attachmentId,
@@ -518,6 +533,7 @@ export class DeepCitation {
convertedPdfDownloadPolicy,
}),
});
+ this.checkLatestVersion(response);
if (!response.ok) {
this.logger.error?.("Prepare URL failed", { url: options.url, status: response.status });
@@ -707,10 +723,7 @@ export class DeepCitation {
const fetchPromise = (async (): Promise => {
const response = await fetch(`${this.apiUrl}/verifyCitations`, {
method: "POST",
- headers: {
- Authorization: `Bearer ${this.apiKey}`,
- "Content-Type": "application/json",
- },
+ headers: { ...this.baseHeaders(), "Content-Type": "application/json" },
body: JSON.stringify({
data: {
attachmentId,
@@ -720,6 +733,7 @@ export class DeepCitation {
},
}),
});
+ this.checkLatestVersion(response);
if (!response.ok) {
// Remove from cache on error so retry is possible
@@ -868,14 +882,12 @@ export class DeepCitation {
const response = await fetch(`${this.apiUrl}/attachments/${options.attachmentId}/extend`, {
method: "POST",
- headers: {
- Authorization: `Bearer ${this.apiKey}`,
- "Content-Type": "application/json",
- },
+ headers: { ...this.baseHeaders(), "Content-Type": "application/json" },
body: JSON.stringify({
duration: options.duration,
}),
});
+ this.checkLatestVersion(response);
if (!response.ok) {
this.logger.error?.("Extend expiration failed", { attachmentId: options.attachmentId, status: response.status });
@@ -909,10 +921,9 @@ export class DeepCitation {
const response = await fetch(`${this.apiUrl}/attachments/${attachmentId}`, {
method: "DELETE",
- headers: {
- Authorization: `Bearer ${this.apiKey}`,
- },
+ headers: { ...this.baseHeaders() },
});
+ this.checkLatestVersion(response);
if (!response.ok) {
this.logger.error?.("Delete attachment failed", { attachmentId, status: response.status });
@@ -962,12 +973,10 @@ export class DeepCitation {
const response = await fetch(`${this.apiUrl}/getAttachment`, {
method: "POST",
- headers: {
- Authorization: `Bearer ${this.apiKey}`,
- "Content-Type": "application/json",
- },
+ headers: { ...this.baseHeaders(), "Content-Type": "application/json" },
body: JSON.stringify({ attachmentId, endUserId: resolvedEndUserId }),
});
+ this.checkLatestVersion(response);
if (!response.ok) {
this.logger.error?.("Get attachment failed", { attachmentId, status: response.status });
diff --git a/src/client/index.ts b/src/client/index.ts
index 6893f5c6..388fcf6d 100644
--- a/src/client/index.ts
+++ b/src/client/index.ts
@@ -1,4 +1,4 @@
-export { DeepCitation } from "./DeepCitation.js";
+export { DeepCitation, SDK_VERSION } from "./DeepCitation.js";
export {
AuthenticationError,
DeepCitationError,
diff --git a/src/client/types.ts b/src/client/types.ts
index 06c56815..0ea9c2c9 100644
--- a/src/client/types.ts
+++ b/src/client/types.ts
@@ -97,6 +97,11 @@ export interface DeepCitationConfig {
* @default "url_only"
*/
convertedPdfDownloadPolicy?: ConvertedPdfDownloadPolicy;
+ /**
+ * Optional callback invoked when the API responds with a latest SDK version header.
+ * Useful for detecting when a newer SDK version is available.
+ */
+ onLatestVersion?: (latestVersion: string) => void;
}
// ==========================================================================
diff --git a/src/index.ts b/src/index.ts
index 10f2b7a3..4261518a 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -13,7 +13,7 @@
*/
// Client & Errors
-export { DeepCitation } from "./client/DeepCitation.js";
+export { DeepCitation, SDK_VERSION } from "./client/DeepCitation.js";
export {
AuthenticationError,
DeepCitationError,
diff --git a/src/react/Citation.tsx b/src/react/Citation.tsx
index caed3cdd..44476a2a 100644
--- a/src/react/Citation.tsx
+++ b/src/react/Citation.tsx
@@ -59,7 +59,11 @@ import type {
import { isBlockedStatus, isErrorStatus } from "./urlStatus.js";
import { getUrlPath, safeWindowOpen, truncateString } from "./urlUtils.js";
import { cn, generateCitationInstanceId } from "./utils.js";
-import { isViewTransitioning, startEvidenceViewTransition } from "./viewTransition.js";
+import {
+ isViewTransitioning,
+ startEvidencePageExpandTransition,
+ startEvidenceViewTransition,
+} from "./viewTransition.js";
// Re-export types for convenience
export type {
@@ -609,17 +613,22 @@ export const CitationComponent = forwardRef = { summary: 0, "expanded-keyhole": 1, "expanded-page": 2 };
const isCollapse = ORDER[newState] < ORDER[prev];
+ const commitViewState = () => {
+ if (newState === "summary") {
+ setExpandedNaturalWidthForPosition(null);
+ setExpandedWidthSourceForPosition(null);
+ }
+ setPopoverViewState(newState);
+ };
const isPageExpand = !isCollapse && newState === "expanded-page";
- startEvidenceViewTransition(
- () => {
- if (newState === "summary") {
- setExpandedNaturalWidthForPosition(null);
- setExpandedWidthSourceForPosition(null);
- }
- setPopoverViewState(newState);
- },
- { isCollapse, isPageExpand, skipAnimation: prefersReducedMotion },
- );
+ if (isPageExpand) {
+ startEvidencePageExpandTransition(commitViewState, {
+ root: popoverContentRef.current,
+ skipAnimation: prefersReducedMotion,
+ });
+ return;
+ }
+ startEvidenceViewTransition(commitViewState, { isCollapse, skipAnimation: prefersReducedMotion });
},
[experimentalHaptics, isMobile, prefersReducedMotion],
);
diff --git a/src/react/CitationContentDisplay.tsx b/src/react/CitationContentDisplay.tsx
index 383a9270..5d01eba9 100644
--- a/src/react/CitationContentDisplay.tsx
+++ b/src/react/CitationContentDisplay.tsx
@@ -12,7 +12,17 @@ import type { CitationStatus } from "../types/citation.js";
import { isUrlCitation } from "../types/citation.js";
import { getInteractionClasses } from "./CitationContentDisplay.utils.js";
import { CitationStatusIndicator, type CitationStatusIndicatorProps } from "./CitationStatusIndicator.js";
-import { MISS_WAVY_UNDERLINE_STYLE, SUPERSCRIPT_STYLE } from "./constants.js";
+import {
+ DOT_COLORS,
+ DOT_INDICATOR_SIZE_STYLE,
+ ERROR_COLOR_STYLE,
+ INDICATOR_SIZE_STYLE,
+ MISS_WAVY_UNDERLINE_STYLE,
+ PARTIAL_COLOR_STYLE,
+ SUPERSCRIPT_STYLE,
+ VERIFIED_COLOR_STYLE,
+} from "./constants.js";
+import { CheckIcon, XIcon } from "./icons.js";
import { handleImageError } from "./imageUtils.js";
import type { CitationContent, CitationRenderProps, CitationVariant } from "./types.js";
import { cn } from "./utils.js";
@@ -292,8 +302,35 @@ export const CitationContentDisplay = ({
getInteractionClasses(isOpen, variant),
)}
>
- {citationNumber}
- {indicator}
+ {(() => {
+ if (!(isVerified || isPartialMatch || isMiss) || indicatorProps.indicatorVariant === "none") {
+ return citationNumber;
+ }
+ const iv = indicatorProps.indicatorVariant;
+ if (iv === "dot") {
+ const dotColor = isMiss ? "red" : isPartialMatch ? "amber" : "green";
+ return ;
+ }
+ if (iv === "caret") {
+ const colorStyle = isMiss
+ ? ERROR_COLOR_STYLE
+ : isPartialMatch
+ ? PARTIAL_COLOR_STYLE
+ : VERIFIED_COLOR_STYLE;
+ return (
+
+ {isMiss ? : }
+
+ );
+ }
+ // icon (default)
+ const colorStyle = isMiss ? ERROR_COLOR_STYLE : isPartialMatch ? PARTIAL_COLOR_STYLE : VERIFIED_COLOR_STYLE;
+ return (
+
+ {isMiss ? : }
+
+ );
+ })()}
>
);
diff --git a/src/react/CitationVariants.tsx b/src/react/CitationVariants.tsx
index 93e65b93..57ad137b 100644
--- a/src/react/CitationVariants.tsx
+++ b/src/react/CitationVariants.tsx
@@ -4,6 +4,8 @@ import type { Citation, CitationStatus } from "../types/citation.js";
import type { Verification } from "../types/verification.js";
import { getCitationKey } from "../utils/citationKey.js";
import {
+ ERROR_COLOR_STYLE,
+ INDICATOR_SIZE_STYLE,
MISS_WAVY_UNDERLINE_STYLE,
PARTIAL_COLOR_STYLE,
SUPERSCRIPT_STYLE,
@@ -22,6 +24,18 @@ const TWO_DOTS_THINKING_CONTENT = "..";
const defaultRenderVerifiedIndicator = () => ;
const defaultRenderPartialIndicator = () => ;
+// Block-specific defaults — no left margin since the indicator replaces the number as sole content.
+const defaultBlockVerifiedIndicator = () => (
+
+ ✓
+
+);
+const defaultBlockPartialIndicator = () => (
+
+ *
+
+);
+
interface ChipVisualClasses {
background: string;
border: string;
@@ -522,8 +536,8 @@ export const BlockCitation = forwardRef(
eventHandlers,
preventTooltips = false,
pendingContent = TWO_DOTS_THINKING_CONTENT,
- renderVerifiedIndicator = defaultRenderVerifiedIndicator,
- renderPartialIndicator = defaultRenderPartialIndicator,
+ renderVerifiedIndicator = defaultBlockVerifiedIndicator,
+ renderPartialIndicator = defaultBlockPartialIndicator,
},
ref,
) => {
@@ -569,13 +583,23 @@ export const BlockCitation = forwardRef(
{...events}
aria-label={t("aria.citationNumber", { number: displayText })}
>
- {displayText}
-
+ {isPartialMatch ? (
+ renderPartialIndicator(status)
+ ) : isVerified ? (
+ renderVerifiedIndicator(status)
+ ) : isMiss ? (
+
+
+
+ ) : isPending ? (
+ {pendingContent}
+ ) : (
+ displayText
+ )}
>
);
diff --git a/src/react/DefaultPopoverContent.tsx b/src/react/DefaultPopoverContent.tsx
index 4dfd6dce..a8c993cc 100644
--- a/src/react/DefaultPopoverContent.tsx
+++ b/src/react/DefaultPopoverContent.tsx
@@ -298,7 +298,7 @@ function ClaimQuote({
return (
void;
onRequestCollapseFromPage?: () => void;
/** When provided, renders an expanded-keyhole footer CTA (for example, "View page" or "View image"). */
@@ -465,15 +469,7 @@ function EvidenceZone({
throughout the morph. Size change is small enough that the
geometry morph alone provides smooth continuity.
- 2. Page expand (data-dc-page-expand) — reverse-collapse cross-fade.
- Both old (keyhole strip) and new (full page scroll container)
- have visible content. Old fades out while new fades in, with
- the group morphing from keyhole bounds → scroll container bounds.
- The VT name is forced onto the scroll container (not the
- transparent annotation marker) during page expand so the NEW
- snapshot actually has image content.
-
- 3. Collapse (data-dc-collapse) — opacity cross-fade.
+ 2. Collapse (data-dc-collapse) — opacity cross-fade.
Quick exit where the opacity dip reinforces the "shrinking
away" feel. Uses EASE_COLLAPSE (decisive deceleration). */}
{/* Slot A: summary — EvidenceTray keyhole strip */}
{summaryContent}
@@ -585,6 +556,7 @@ function EvidenceZone({
onNaturalSize={handlePageImageLoad}
renderScale={expandedImage.renderScale}
expectedDimensions={expandedImage.dimensions}
+ initialScroll={keyholeInitialScroll ?? undefined}
/>
)}
@@ -1064,7 +1036,7 @@ export function DefaultPopoverContent({
: undefined
}
pageCtaLabel={expandCtaLabel}
- onScrollCapture={evidenceSrc ? handleKeyholeScrollCapture : undefined}
+ onScrollCapture={handleKeyholeScrollCapture}
pageImageSrc={expandedImage?.src}
onKeyholeWidth={setKeyholeDisplayedWidth}
escapeInterceptRef={escapeInterceptRef}
diff --git a/src/react/EvidenceTray.tsx b/src/react/EvidenceTray.tsx
index 9fda93f6..0b4d5321 100644
--- a/src/react/EvidenceTray.tsx
+++ b/src/react/EvidenceTray.tsx
@@ -61,7 +61,7 @@ import { groupSearchAttemptsForNotFound } from "./searchAttemptGrouping.js";
import { buildIntentSummary } from "./searchSummaryUtils.js";
import { cn } from "./utils.js";
import { VerificationLogTimeline } from "./VerificationLog.js";
-import { DC_EVIDENCE_VT_NAME } from "./viewTransition.js";
+import { DC_EVIDENCE_VT_NAME, primeEvidencePageExpandSource } from "./viewTransition.js";
import { ZoomToolbar } from "./ZoomToolbar.js";
// =============================================================================
@@ -307,6 +307,52 @@ export function resolveExpandedImageForPage(
return resolveExpandedImage(verification, pageImages);
}
+function normalizeEvidenceText(text: string | null | undefined): string {
+ return text?.toLowerCase().replace(/\s+/g, " ").trim() ?? "";
+}
+
+function resolveEvidenceSourceAnchorRatio(
+ verification: Verification | null | undefined,
+): { x: number; y: number } | null {
+ const evidence = verification?.evidence;
+ const dims = evidence?.dimensions;
+ const items = evidence?.textItems;
+ if (!dims || dims.width <= 0 || dims.height <= 0 || !items || items.length === 0) return null;
+
+ const targets = [
+ verification?.verifiedAnchorText,
+ verification?.document?.anchorTextMatchDeepItems?.[0]?.text,
+ verification?.verifiedFullPhrase,
+ verification?.document?.phraseMatchDeepItem?.text,
+ ]
+ .map(normalizeEvidenceText)
+ .filter(Boolean);
+
+ let bestItem: DeepTextItem | null = null;
+ let bestScore = 0;
+
+ for (const item of items) {
+ const itemText = normalizeEvidenceText(item.text);
+ if (!itemText) continue;
+ for (const target of targets) {
+ let score = 0;
+ if (itemText === target) score = 4000 + itemText.length;
+ else if (target.includes(itemText)) score = 3000 + itemText.length;
+ else if (itemText.includes(target)) score = 2000 + target.length;
+ if (score > bestScore) {
+ bestScore = score;
+ bestItem = item;
+ }
+ }
+ }
+
+ if (!bestItem) return null;
+
+ const x = Math.max(0, Math.min(1, (bestItem.x + bestItem.width / 2) / dims.width));
+ const y = Math.max(0, Math.min(1, (bestItem.y + bestItem.height / 2) / dims.height));
+ return { x, y };
+}
+
// =============================================================================
// ANCHOR TEXT FOCUSED IMAGE (Keyhole viewer)
// =============================================================================
@@ -330,6 +376,7 @@ export function AnchorTextFocusedImage({
onPageExpand,
onKeyholeWidth,
onScrollCapture,
+ pageExpandSourceRef,
}: {
src: string;
verification?: Verification | null;
@@ -339,6 +386,8 @@ export function AnchorTextFocusedImage({
onKeyholeWidth?: (width: number) => void;
/** Called with natural-pixel scroll coords just before onImageClick fires. */
onScrollCapture?: (left: number, top: number) => void;
+ /** Exposes the visible summary keyhole node for page-expand transitions. */
+ pageExpandSourceRef?: React.MutableRefObject;
}) {
const t = useTranslation();
// Anchor item and renderScale for scroll positioning.
@@ -588,8 +637,15 @@ export function AnchorTextFocusedImage({
aria-label={keyholeAriaLabel}
>
{
+ containerRef.current = el;
+ if (pageExpandSourceRef) {
+ pageExpandSourceRef.current = el;
+ }
+ }}
data-dc-keyhole=""
+ data-dc-page-expand-source=""
+ data-dc-page-expand-source-kind="summary-keyhole"
className={cn(
DOCUMENT_CANVAS_BG_CLASSES,
isWidthFit ? "overflow-auto" : "overflow-x-auto overflow-y-hidden",
@@ -925,8 +981,10 @@ export function EvidenceTray({
// must gate the action here.
const trayMouseDownPosRef = useRef<{ x: number; y: number } | null>(null);
const trayRootRef = useRef
(null);
+ const pageExpandSourceRef = useRef(null);
const handlePageExpand = useCallback(() => {
+ primeEvidencePageExpandSource(pageExpandSourceRef.current);
onExpand?.();
}, [onExpand]);
@@ -1090,18 +1148,20 @@ export function EvidenceTray({
src={resolvedEvidenceSrc}
verification={verification}
onImageClick={onImageClick}
- onPageExpand={onExpand}
+ onPageExpand={handlePageExpand}
onKeyholeWidth={onKeyholeWidth}
onScrollCapture={onScrollCapture}
+ pageExpandSourceRef={pageExpandSourceRef}
/>
) : (isMiss || isPartialMatch) && isValidProofImageSrc(pageImageSrc) ? (
) : null}
{/* Miss/partial: search analysis and collapsible search log (only when there are search attempts) */}
@@ -1349,9 +1409,16 @@ export function InlineExpandedImage({
(shouldHighlightAnchorText(vAnchor, vPhrase) ||
(isStrategyOverride(vAnchor, vPhrase) && shouldHighlightAnchorText(vAnchor, effectivePhraseItem?.text)));
const scrollTarget = anchorHighlightActive ? effectiveAnchorItem : effectivePhraseItem;
+ const sourceAnchorRatio = useMemo(
+ () => (!fill ? resolveEvidenceSourceAnchorRatio(verification) : null),
+ [fill, verification],
+ );
// Track container size via ResizeObserver (both width and height for fit-to-screen).
- // biome-ignore lint/correctness/useExhaustiveDependencies: containerRef is a stable ref object from useDragToPan — its identity never changes
+ // When the container transitions from display:none (zero) to visible (positive),
+ // reset the auto-scroll guard so annotation scroll + pageExpandReady re-settle.
+ const prevContainerVisibleRef = useRef(false);
+ // biome-ignore lint/correctness/useExhaustiveDependencies: containerRef and prevContainerVisibleRef are stable refs — identity never changes
useEffect(() => {
if (!fill) return;
const el = containerRef.current;
@@ -1359,7 +1426,18 @@ export function InlineExpandedImage({
const observer = new ResizeObserver(entries => {
const rect = entries[0]?.contentRect;
if (rect && rect.width > 0 && rect.height > 0) {
+ const wasVisible = prevContainerVisibleRef.current;
+ prevContainerVisibleRef.current = true;
+ if (!wasVisible) {
+ // Container just became visible (display:none → visible).
+ // Reset scroll guards so the auto-scroll effect re-runs and
+ // pageExpandReady reflects the freshly settled annotation position.
+ hasAutoScrolledToAnnotationRef.current = false;
+ setPageExpandReady(false);
+ }
setContainerSize({ width: rect.width, height: rect.height });
+ } else {
+ prevContainerVisibleRef.current = false;
}
});
observer.observe(el);
@@ -1386,6 +1464,7 @@ export function InlineExpandedImage({
// biome-ignore lint/correctness/useExhaustiveDependencies: ref identities are stable; fill/onNaturalSize are read but not reactive triggers — only src change should fire this
useLayoutEffect(() => {
hasAutoScrolledToAnnotationRef.current = false;
+ setPageExpandReady(false);
lastReportedSizeRef.current = null;
touchGestureZoomRef.current = null;
touchGestureAnchorRef.current = null;
@@ -1432,6 +1511,7 @@ export function InlineExpandedImage({
// ---------------------------------------------------------------------------
const [locateDirty, setLocateDirty] = useState(false);
const [locatePulseKey, setLocatePulseKey] = useState(0);
+ const [pageExpandReady, setPageExpandReady] = useState(false);
// Ref storing the expected scroll position after a programmatic scroll.
// Used by the scroll listener to detect user-initiated drift.
const annotationScrollTarget = useRef<{ left: number; top: number } | null>(null);
@@ -1465,7 +1545,9 @@ export function InlineExpandedImage({
if (!scrollItem || !renderScale) return;
hasAutoScrolledToAnnotationRef.current = true;
+ setPageExpandReady(false);
const effectiveZoom = zoom;
+ let settleRafId = 0;
const rafId = requestAnimationFrame(() => {
const container = containerRef.current;
if (!container) return;
@@ -1485,9 +1567,15 @@ export function InlineExpandedImage({
container.scrollTop = st;
annotationScrollTarget.current = { left: sl, top: st };
setLocateDirty(false);
+ settleRafId = requestAnimationFrame(() => {
+ setPageExpandReady(true);
+ });
}
});
- return () => cancelAnimationFrame(rafId);
+ return () => {
+ cancelAnimationFrame(rafId);
+ cancelAnimationFrame(settleRafId);
+ };
}, [
fill,
imageLoaded,
@@ -1502,6 +1590,43 @@ export function InlineExpandedImage({
containerRef,
]);
+ useEffect(() => {
+ if (!fill || !imageLoaded) return;
+ const scrollItem = scrollTarget ?? effectivePhraseItem;
+ if (manualZoom !== null || !scrollItem || !renderScale) {
+ // No annotation to auto-scroll to. If a keyhole viewport position is
+ // available (miss/not_found with page preview), scroll the expanded page
+ // to show the same region the user was viewing in the keyhole. This must
+ // happen before the ghost target is computed via rAF polling so the
+ // viewport-based fallback target reflects the correct scroll position.
+ //
+ // Guard on manualZoom === null (not a ref-equality one-shot) so the scroll
+ // re-applies when zoom settles from the initial fallback (1) to the real
+ // fittedZoom — the ResizeObserver that measures containerSize may not have
+ // fired yet on the first effect run after display:none → visible.
+ // Once the user sets manualZoom (pinch/wheel), we stop overriding.
+ if (initialScroll && manualZoom === null) {
+ const el = containerRef.current;
+ if (el) {
+ void el.scrollHeight; // Force reflow after display:none → visible
+ el.scrollLeft = initialScroll.left * zoom + CANVAS_PADDING_PX;
+ el.scrollTop = initialScroll.top * zoom + CANVAS_PADDING_PX;
+ }
+ }
+ setPageExpandReady(true);
+ }
+ }, [
+ fill,
+ imageLoaded,
+ manualZoom,
+ scrollTarget,
+ effectivePhraseItem,
+ renderScale,
+ initialScroll,
+ zoom,
+ containerRef,
+ ]);
+
// Clamp helper — shared by buttons, slider, pinch, and wheel.
// Uses zoomFloor (not EXPANDED_ZOOM_MIN) so the lower bound respects the
// fit-to-screen zoom on narrow viewports where it may be < 50%.
@@ -1785,18 +1910,40 @@ export function InlineExpandedImage({
annotationOriginItem && renderScale && naturalWidth && naturalHeight
? computeAnnotationOriginPercent(annotationOriginItem, renderScale, naturalWidth, naturalHeight)
: null;
+ // VT geometry target: always use the full phrase rect so the View Transition
+ // morph envelope matches the visible overlay size on both expand and collapse.
+ // (scrollTarget may be the smaller anchor text — fine for scroll centering,
+ // but the VT rect must cover the full phrase to avoid starting from a smaller box.)
+ const annotationTargetItem = fill && renderScale ? effectivePhraseItem : null;
+ const annotationTargetNaturalWidth =
+ annotationTargetItem && renderScale ? annotationTargetItem.width * renderScale.x : null;
+ const annotationTargetNaturalHeight =
+ annotationTargetItem && renderScale ? annotationTargetItem.height * renderScale.y : null;
// Annotation rect as CSS percentages — used as the View Transition anchor
// in fill mode so the VT geometry morph tracks the annotation region instead
// of the whole page container. When null, falls back to container-level VT.
+ const annotationBaseDimensions =
+ naturalWidth && naturalHeight
+ ? { width: naturalWidth, height: naturalHeight }
+ : expectedDimensions && expectedDimensions.width > 0 && expectedDimensions.height > 0
+ ? expectedDimensions
+ : null;
const annotationVtRect =
- fill && effectivePhraseItem && renderScale && naturalWidth && naturalHeight
- ? toPercentRect(effectivePhraseItem, renderScale, naturalWidth, naturalHeight)
+ fill && annotationTargetItem && renderScale && annotationBaseDimensions
+ ? toPercentRect(
+ annotationTargetItem,
+ renderScale,
+ annotationBaseDimensions.width,
+ annotationBaseDimensions.height,
+ )
: null;
+ const pageExpandTargetReady = !!fill && !!annotationVtRect && !!imageLoaded && pageExpandReady;
const handleExpandToPage = useCallback(() => {
+ primeEvidencePageExpandSource(containerRef.current);
onExpand?.();
- }, [onExpand]);
+ }, [onExpand, containerRef]);
const handleCollapse = useCallback(() => {
onCollapse();
@@ -1841,6 +1988,18 @@ export function InlineExpandedImage({
- // synchronously before the VT callback, so reading it during the
- // flushSync render is deterministic and safe.
- ...(!annotationVtRect ||
- (typeof document !== "undefined" && "dcPageExpand" in document.documentElement.dataset)
- ? { viewTransitionName: DC_EVIDENCE_VT_NAME }
- : {}),
+ ...(!annotationVtRect ? { viewTransitionName: DC_EVIDENCE_VT_NAME } : {}),
...(fill ? {} : { maxHeight: "min(600px, 80dvh)" }),
overscrollBehavior: "none",
cursor: isDragging ? "move" : "zoom-out",
@@ -2030,13 +2179,18 @@ export function InlineExpandedImage({
{annotationVtRect && (
diff --git a/src/react/constants.ts b/src/react/constants.ts
index bf76529d..78cf91f8 100644
--- a/src/react/constants.ts
+++ b/src/react/constants.ts
@@ -545,6 +545,8 @@ export const EXPANDED_POPOVER_HEIGHT = "calc(100dvh - 2rem)";
/** Duration (ms) for evidence image expand VT (keyhole → expanded). ANIM_STANDARD_MS tier. */
export const VT_EVIDENCE_EXPAND_MS = 180;
+/** Duration (ms) for the page-expand ghost animation (summary/preview → expanded page). ANIM_MEASURED_MS tier (250ms). */
+export const VT_EVIDENCE_PAGE_EXPAND_MS = 250;
/** Duration (ms) for evidence image collapse VT (expanded → keyhole). ANIM_FAST_MS tier. */
export const VT_EVIDENCE_COLLAPSE_MS = 120;
/**
@@ -554,6 +556,57 @@ export const VT_EVIDENCE_COLLAPSE_MS = 120;
*/
export const VT_EVIDENCE_DIP_OPACITY = 0.45;
+// Page-expand ghost animation keyframe tuning.
+//
+// Mirrors the collapse's "dip-then-reveal" structure:
+// Collapse: old 1.0 → 0.45 (30%) → 0 / new 0 → 0 (60%) → 1
+// Expand: ghost dominates first 60% / page near-invisible until ghost lands
+//
+// The ghost is the "old snapshot equivalent" — the thing the eye tracks.
+// It must be opaque enough to dominate over the dimmed page beneath.
+// The page is the "new snapshot equivalent" — stays hidden, then reveals sharply.
+/** Ghost initial opacity — clearly visible "card" from click origin. */
+export const GHOST_OPACITY_START = 0.55;
+/** Ghost opacity at early interpolation (18% progress) — building dominance. */
+export const GHOST_OPACITY_EARLY = 0.75;
+/** Ghost opacity at mid interpolation (42% progress) — peak visibility, mid-motion. */
+export const GHOST_OPACITY_MID = 0.88;
+/** Ghost opacity at late interpolation (68% progress) — near-peak, approaching target. */
+export const GHOST_OPACITY_LATE = 0.92;
+/** Ghost near-peak opacity before final fade-out (92% progress). */
+export const GHOST_OPACITY_PEAK = 0.88;
+
+// Page-expand ghost motion blur.
+// CSS `filter: blur()` masks the non-uniform scale distortion (squashed text)
+// mid-flight and reads as cinematic motion blur. GPU-composited, no layout cost.
+/** Ghost blur (px) at start — sharp at source position. */
+export const GHOST_BLUR_START_PX = 1;
+/** Ghost blur (px) at early interpolation — motion building. */
+export const GHOST_BLUR_EARLY_PX = 4;
+/** Ghost blur (px) at mid interpolation — peak distortion zone. */
+export const GHOST_BLUR_MID_PX = 8;
+/** Ghost blur (px) at late interpolation — clearing as ghost nears target. */
+export const GHOST_BLUR_LATE_PX = 5;
+/** Ghost blur (px) at near-peak — nearly clear before fade-out. */
+export const GHOST_BLUR_PEAK_PX = 2;
+
+/** Page content initial opacity during page-expand — nearly invisible.
+ * Must be very low so the ghost dominates the first 60% of the animation
+ * (mirroring how the collapse keeps new content at 0 until 60%). */
+export const PAGE_EXPAND_CONTENT_OPACITY_START = 0.03;
+/** Ghost keyframe offset: early interpolation. */
+export const GHOST_OFFSET_EARLY = 0.18;
+/** Ghost keyframe offset: mid interpolation. */
+export const GHOST_OFFSET_MID = 0.42;
+/** Ghost keyframe offset: late interpolation. */
+export const GHOST_OFFSET_LATE = 0.68;
+/** Ghost keyframe offset: near-peak before fade-out. */
+export const GHOST_OFFSET_PEAK = 0.92;
+/** Debug outline color for page-expand source phase. */
+export const DEBUG_PAGE_EXPAND_SOURCE_COLOR = "#ef4444";
+/** Debug outline color for page-expand target phase. */
+export const DEBUG_PAGE_EXPAND_TARGET_COLOR = "#22c55e";
+
// =============================================================================
// ANIMATION & TRANSITION TIMINGS
// =============================================================================
diff --git a/src/react/viewTransition.ts b/src/react/viewTransition.ts
index a58b13e2..ed3174b2 100644
--- a/src/react/viewTransition.ts
+++ b/src/react/viewTransition.ts
@@ -1,4 +1,28 @@
import { flushSync } from "react-dom";
+import { BOX_PADDING, SPOTLIGHT_PADDING } from "../drawing/citationDrawing.js";
+import {
+ BLINK_ENTER_EASING,
+ DEBUG_PAGE_EXPAND_SOURCE_COLOR,
+ DEBUG_PAGE_EXPAND_TARGET_COLOR,
+ EASE_COLLAPSE,
+ GHOST_BLUR_EARLY_PX,
+ GHOST_BLUR_LATE_PX,
+ GHOST_BLUR_MID_PX,
+ GHOST_BLUR_PEAK_PX,
+ GHOST_BLUR_START_PX,
+ GHOST_OFFSET_EARLY,
+ GHOST_OFFSET_LATE,
+ GHOST_OFFSET_MID,
+ GHOST_OFFSET_PEAK,
+ GHOST_OPACITY_EARLY,
+ GHOST_OPACITY_LATE,
+ GHOST_OPACITY_MID,
+ GHOST_OPACITY_PEAK,
+ GHOST_OPACITY_START,
+ isValidProofImageSrc,
+ PAGE_EXPAND_CONTENT_OPACITY_START,
+ VT_EVIDENCE_PAGE_EXPAND_MS,
+} from "./constants.js";
/**
* View-transition name applied to evidence image elements (keyhole strip,
@@ -18,10 +42,24 @@ export const DC_EVIDENCE_VT_NAME = "dc-evidence";
* prematurely unguarding the second.
*/
let _transitionDepth = 0;
+let _primedPageExpandSource: HTMLElement | null = null;
+let _primedPageExpandSourceTime = 0;
+/** Max age (ms) before the primed source is considered stale and discarded. */
+const _PRIMED_SOURCE_MAX_AGE_MS = 500;
export function isViewTransitioning(): boolean {
return _transitionDepth > 0;
}
+/**
+ * Primes the source element for the next page-expand transition.
+ * Callers must invoke `startEvidencePageExpandTransition` immediately after —
+ * the primed ref is cleared on read or after `_PRIMED_SOURCE_MAX_AGE_MS`.
+ */
+export function primeEvidencePageExpandSource(sourceEl: HTMLElement | null): void {
+ _primedPageExpandSource = sourceEl;
+ _primedPageExpandSourceTime = Date.now();
+}
+
/**
* Wraps a state update in a View Transition so the browser morphs the
* geometry + cross-fades between the old and new evidence image elements.
@@ -76,3 +114,682 @@ export function startEvidenceViewTransition(
};
transition.finished.then(cleanup).catch(cleanup);
}
+
+type GhostSnapshot = {
+ viewportRect: DOMRect;
+ imageSrc: string;
+ imageOffsetLeft: number;
+ imageOffsetTop: number;
+ imageWidth: number;
+ imageHeight: number;
+ imageNaturalWidth: number;
+ imageNaturalHeight: number;
+ sourceKind: "summary-keyhole" | "expanded-keyhole" | null;
+ sourceAnchorX: number;
+ sourceAnchorY: number;
+ borderRadius: string;
+};
+
+function isVisibleRect(rect: DOMRect): boolean {
+ return rect.width > 0.5 && rect.height > 0.5;
+}
+
+type DebugPhase = "source" | "target" | "both" | null;
+
+function getPageExpandDebugPhase(): DebugPhase {
+ if (typeof document === "undefined") return null;
+ const phase = document.documentElement.dataset.dcPageExpandDebugPhase;
+ if (phase === "source" || phase === "target" || phase === "both") return phase;
+ return null;
+}
+
+/** Remove all debug overlays from the DOM. */
+function clearDebugOverlays(): void {
+ if (typeof document === "undefined") return;
+ document.querySelectorAll("[data-dc-debug-overlay]").forEach(el => el.remove());
+}
+
+/** Shared font for debug labels. */
+const DEBUG_LABEL_FONT = "10px/1.2 ui-monospace, SFMono-Regular, monospace";
+
+/** Create a debug overlay box at the given rect with a colored outline and label. */
+function createDebugOverlay(rect: DOMRect, color: string, label: string, sublabel?: string): HTMLDivElement {
+ const el = document.createElement("div");
+ el.setAttribute("aria-hidden", "true");
+ el.dataset.dcDebugOverlay = "";
+ el.style.position = "fixed";
+ el.style.left = `${rect.left}px`;
+ el.style.top = `${rect.top}px`;
+ el.style.width = `${rect.width}px`;
+ el.style.height = `${rect.height}px`;
+ el.style.outline = `2px solid ${color}`;
+ el.style.outlineOffset = "-1px";
+ el.style.backgroundColor = `${color}22`;
+ el.style.pointerEvents = "none";
+ el.style.zIndex = "2147483647";
+ el.style.overflow = "visible";
+
+ // Label badge
+ const badge = document.createElement("div");
+ badge.style.position = "absolute";
+ badge.style.top = "-18px";
+ badge.style.left = "0";
+ badge.style.background = color;
+ badge.style.color = "#fff";
+ badge.style.font = DEBUG_LABEL_FONT;
+ badge.style.padding = "1px 5px";
+ badge.style.borderRadius = "3px 3px 0 0";
+ badge.style.whiteSpace = "nowrap";
+ badge.textContent = label;
+ el.appendChild(badge);
+
+ // Dimensions sub-label
+ const dims = document.createElement("div");
+ dims.style.position = "absolute";
+ dims.style.bottom = "-16px";
+ dims.style.left = "0";
+ dims.style.font = DEBUG_LABEL_FONT;
+ dims.style.color = color;
+ dims.style.whiteSpace = "nowrap";
+ dims.style.textShadow = "0 0 3px #000, 0 0 3px #000";
+ const dimText = `${Math.round(rect.width)}×${Math.round(rect.height)} @ (${Math.round(rect.left)}, ${Math.round(rect.top)})`;
+ dims.textContent = sublabel ? `${dimText} — ${sublabel}` : dimText;
+ el.appendChild(dims);
+
+ document.body.appendChild(el);
+ return el;
+}
+
+function takePrimedPageExpandSource(root: ParentNode): HTMLElement | null {
+ const sourceEl = _primedPageExpandSource;
+ _primedPageExpandSource = null;
+ if (!sourceEl) return null;
+ // Discard stale primed sources to prevent leaked refs from accumulating.
+ if (Date.now() - _primedPageExpandSourceTime > _PRIMED_SOURCE_MAX_AGE_MS) return null;
+ if ("contains" in root && typeof root.contains === "function" && !root.contains(sourceEl)) {
+ return null;
+ }
+ const rect = sourceEl.getBoundingClientRect();
+ return isVisibleRect(rect) ? sourceEl : null;
+}
+
+function capturePageExpandSource(root: ParentNode): GhostSnapshot | null {
+ const primedSource = takePrimedPageExpandSource(root);
+ const candidates = primedSource
+ ? [primedSource]
+ : Array.from(root.querySelectorAll
("[data-dc-page-expand-source]"));
+ for (const sourceEl of candidates) {
+ const rect = sourceEl.getBoundingClientRect();
+ if (!isVisibleRect(rect)) continue;
+ const img = sourceEl.querySelector("img");
+ const imageRect = img?.getBoundingClientRect();
+ const imageSrc = img?.currentSrc || img?.src;
+ if (!img || !imageRect || !imageSrc || !isVisibleRect(imageRect)) continue;
+ const sourceAnchorXRaw = Number.parseFloat(sourceEl.dataset.dcSourceAnchorX ?? "");
+ const sourceAnchorYRaw = Number.parseFloat(sourceEl.dataset.dcSourceAnchorY ?? "");
+ return {
+ viewportRect: rect,
+ imageSrc,
+ imageOffsetLeft: imageRect.left - rect.left,
+ imageOffsetTop: imageRect.top - rect.top,
+ imageWidth: imageRect.width,
+ imageHeight: imageRect.height,
+ imageNaturalWidth: img.naturalWidth,
+ imageNaturalHeight: img.naturalHeight,
+ sourceKind:
+ sourceEl.dataset.dcPageExpandSourceKind === "summary-keyhole" ||
+ sourceEl.dataset.dcPageExpandSourceKind === "expanded-keyhole"
+ ? sourceEl.dataset.dcPageExpandSourceKind
+ : null,
+ sourceAnchorX:
+ Number.isFinite(sourceAnchorXRaw) && sourceAnchorXRaw >= 0 && sourceAnchorXRaw <= 1 ? sourceAnchorXRaw : 0.5,
+ sourceAnchorY:
+ Number.isFinite(sourceAnchorYRaw) && sourceAnchorYRaw >= 0 && sourceAnchorYRaw <= 1 ? sourceAnchorYRaw : 0.5,
+ borderRadius: getComputedStyle(sourceEl).borderRadius || "0px",
+ };
+ }
+ return null;
+}
+
+type PageExpandTarget = {
+ markerRect: DOMRect;
+ ghostRect: DOMRect;
+};
+
+function buildGhostTargetRect(_snapshot: GhostSnapshot, targetEl: HTMLElement, markerRect: DOMRect): DOMRect {
+ // The ghost lands on the annotation spotlight — the "light area" cutout in
+ // the dimming overlay (annotation rect + SPOTLIGHT_PADDING). This is the
+ // visual focal point of the expanded page, sized to give surrounding context
+ // without covering the full page (which would create a giant flash).
+ const spotlight = targetEl.parentElement?.querySelector("[data-dc-spotlight]");
+ if (spotlight) {
+ const spotRect = spotlight.getBoundingClientRect();
+ if (isVisibleRect(spotRect)) return spotRect;
+ }
+ // Overlay dismissed or not yet rendered — synthesize the spotlight rect from
+ // the annotation marker + padding. The spotlight is the annotation bounding
+ // box expanded by (BOX_PADDING + SPOTLIGHT_PADDING) in natural image pixels,
+ // scaled to the rendered image size.
+ const img = targetEl.parentElement?.querySelector("img");
+ if (img && img.naturalWidth > 0 && targetEl.parentElement) {
+ const containerRect = targetEl.parentElement.getBoundingClientRect();
+ const scale = containerRect.width / img.naturalWidth;
+ const pad = (BOX_PADDING + SPOTLIGHT_PADDING) * scale;
+ return new DOMRect(
+ markerRect.left - pad,
+ markerRect.top - pad,
+ markerRect.width + 2 * pad,
+ markerRect.height + 2 * pad,
+ );
+ }
+ return markerRect;
+}
+
+/**
+ * Fallback ghost target for miss/not_found states where no annotation marker
+ * exists. Maps the keyhole's visible viewport onto the expanded page's visible
+ * image area — the ghost lands on whatever region the user was already viewing.
+ */
+function buildGhostTargetFromViewport(root: ParentNode): PageExpandTarget | null {
+ // Only match containers that have no annotation data (miss/not_found).
+ // data-dc-no-annotation is set by InlineExpandedImage when fill=true and
+ // scrollTarget is null — derived from props, not layout measurements, so
+ // it's available immediately after flushSync (no useEffect timing issues).
+ const containers = root.querySelectorAll("[data-dc-inline-expanded][data-dc-no-annotation]");
+ for (const container of containers) {
+ const containerRect = container.getBoundingClientRect();
+ if (!isVisibleRect(containerRect)) continue;
+ const img = container.querySelector("img");
+ if (!img) continue;
+ const imgRect = img.getBoundingClientRect();
+ if (!isVisibleRect(imgRect)) continue;
+ // Ghost target = intersection of the container viewport and the image rect
+ // (the visible portion of the page image on screen).
+ const left = Math.max(containerRect.left, imgRect.left);
+ const top = Math.max(containerRect.top, imgRect.top);
+ const right = Math.min(containerRect.right, imgRect.right);
+ const bottom = Math.min(containerRect.bottom, imgRect.bottom);
+ if (right <= left || bottom <= top) continue;
+ const visibleRect = new DOMRect(left, top, right - left, bottom - top);
+ return { markerRect: visibleRect, ghostRect: visibleRect };
+ }
+ return null;
+}
+
+function findPageExpandTarget(root: ParentNode, snapshot: GhostSnapshot): PageExpandTarget | null {
+ const candidates = Array.from(root.querySelectorAll("[data-dc-page-expand-target]"));
+ for (const targetEl of candidates) {
+ if (targetEl.dataset.dcPageExpandReady !== "true") continue;
+ const rect = targetEl.getBoundingClientRect();
+ if (!isVisibleRect(rect)) continue;
+ return { markerRect: rect, ghostRect: buildGhostTargetRect(snapshot, targetEl, rect) };
+ }
+ // Annotation target elements exist but aren't ready yet — keep polling.
+ if (candidates.length > 0) return null;
+ // No annotation target at all (miss/not_found without annotation data).
+ // Fall back to the visible viewport of the expanded page image.
+ // The ready-attribute selector in buildGhostTargetFromViewport ensures we
+ // don't fire prematurely — pageExpandReady is only set after the component's
+ // useEffect has run and (for success states) annotation targets are rendered.
+ return buildGhostTargetFromViewport(root);
+}
+
+function createPageExpandGhost(snapshot: GhostSnapshot): HTMLDivElement | null {
+ // Defensive re-validation: the source image was already validated before
+ // rendering, but a DOM mutation (e.g. browser extension) could have changed it.
+ if (!isValidProofImageSrc(snapshot.imageSrc)) return null;
+ const ghost = document.createElement("div");
+ ghost.setAttribute("aria-hidden", "true");
+ ghost.dataset.dcPageExpandGhost = "";
+ ghost.style.position = "fixed";
+ ghost.style.left = `${snapshot.viewportRect.left}px`;
+ ghost.style.top = `${snapshot.viewportRect.top}px`;
+ ghost.style.width = `${snapshot.viewportRect.width}px`;
+ ghost.style.height = `${snapshot.viewportRect.height}px`;
+ ghost.style.overflow = "hidden";
+ ghost.style.pointerEvents = "none";
+ ghost.style.zIndex = "2147483646";
+ ghost.style.borderRadius = snapshot.borderRadius;
+ ghost.style.transformOrigin = "0 0";
+ ghost.style.willChange = "transform, opacity";
+ const debugPhase = getPageExpandDebugPhase();
+ if (debugPhase && debugPhase !== "both") {
+ ghost.style.outline =
+ debugPhase === "source"
+ ? `2px solid ${DEBUG_PAGE_EXPAND_SOURCE_COLOR}`
+ : `2px solid ${DEBUG_PAGE_EXPAND_TARGET_COLOR}`;
+ ghost.style.outlineOffset = "0";
+ }
+
+ const img = document.createElement("img");
+ img.src = snapshot.imageSrc;
+ img.alt = "";
+ img.draggable = false;
+ img.style.position = "absolute";
+ img.style.left = `${snapshot.imageOffsetLeft}px`;
+ img.style.top = `${snapshot.imageOffsetTop}px`;
+ img.style.width = `${snapshot.imageWidth}px`;
+ img.style.height = `${snapshot.imageHeight}px`;
+ img.style.maxWidth = "none";
+ img.style.userSelect = "none";
+ img.style.pointerEvents = "none";
+ ghost.appendChild(img);
+ document.body.appendChild(ghost);
+ return ghost;
+}
+
+function applyGhostRect(ghost: HTMLDivElement, rect: DOMRect): void {
+ ghost.style.left = `${rect.left}px`;
+ ghost.style.top = `${rect.top}px`;
+ ghost.style.width = `${rect.width}px`;
+ ghost.style.height = `${rect.height}px`;
+}
+
+function runPageExpandGhostAnimation(
+ ghost: HTMLDivElement,
+ snapshot: GhostSnapshot,
+ target: PageExpandTarget,
+ popoverRoot: HTMLElement | null,
+): void {
+ const { ghostRect } = target;
+ const debugPhase = getPageExpandDebugPhase();
+ if (debugPhase === "source") {
+ return;
+ }
+ if (debugPhase === "target") {
+ applyGhostRect(ghost, ghostRect);
+ return;
+ }
+ if (debugPhase === "both") {
+ // "both" mode: hide the real ghost, draw persistent debug overlays for both rects
+ ghost.remove();
+ clearDebugOverlays();
+ const kindLabel =
+ snapshot.sourceKind === "summary-keyhole"
+ ? "summary keyhole"
+ : snapshot.sourceKind === "expanded-keyhole"
+ ? "expanded keyhole"
+ : "source";
+ createDebugOverlay(
+ snapshot.viewportRect,
+ DEBUG_PAGE_EXPAND_SOURCE_COLOR,
+ `SOURCE (${kindLabel})`,
+ `anchor: (${snapshot.sourceAnchorX.toFixed(2)}, ${snapshot.sourceAnchorY.toFixed(2)})`,
+ );
+ createDebugOverlay(ghostRect, DEBUG_PAGE_EXPAND_TARGET_COLOR, "TARGET (ghost destination)");
+ createDebugOverlay(target.markerRect, "#3b82f6", "MARKER (annotation VT rect)");
+ if (process.env.NODE_ENV !== "production") {
+ console.groupCollapsed("[DC debug] page-expand geometry");
+ console.table({
+ source: {
+ left: snapshot.viewportRect.left,
+ top: snapshot.viewportRect.top,
+ width: snapshot.viewportRect.width,
+ height: snapshot.viewportRect.height,
+ },
+ ghostTarget: {
+ left: ghostRect.left,
+ top: ghostRect.top,
+ width: ghostRect.width,
+ height: ghostRect.height,
+ },
+ marker: {
+ left: target.markerRect.left,
+ top: target.markerRect.top,
+ width: target.markerRect.width,
+ height: target.markerRect.height,
+ },
+ });
+ console.log("snapshot:", snapshot);
+ console.groupEnd();
+ }
+ return;
+ }
+
+ // Animate using transform (translate + scale) + opacity so the compositor
+ // handles the interpolation without triggering layout on every frame.
+ // The ghost is positioned at the source rect; we compute the transform needed
+ // to move and scale it to the target rect.
+ const src = snapshot.viewportRect;
+ const scaleX = ghostRect.width / src.width;
+ const scaleY = ghostRect.height / src.height;
+ const translateX = ghostRect.left - src.left;
+ const translateY = ghostRect.top - src.top;
+
+ // Helper: build a transform string at a given interpolation fraction t ∈ [0, 1].
+ const tfAt = (t: number) =>
+ `translate(${translateX * t}px, ${translateY * t}px) scale(${1 + (scaleX - 1) * t}, ${1 + (scaleY - 1) * t})`;
+
+ // Helper: build a blur filter string at a given blur radius.
+ const blurAt = (px: number) => (px > 0 ? `blur(${px}px)` : "none");
+
+ // Large-travel expand: EASE_COLLAPSE intentional (>200px travel, per BRANDING.md large-motion rule)
+ // Motion blur (filter: blur) masks the non-uniform scale distortion (squashed text)
+ // and reads as cinematic motion blur. Peaks mid-flight, clears near landing.
+ const keyframes: Keyframe[] = [
+ { transform: tfAt(0), opacity: GHOST_OPACITY_START, filter: blurAt(GHOST_BLUR_START_PX) },
+ {
+ transform: tfAt(GHOST_OFFSET_EARLY),
+ opacity: GHOST_OPACITY_EARLY,
+ filter: blurAt(GHOST_BLUR_EARLY_PX),
+ offset: GHOST_OFFSET_EARLY,
+ },
+ {
+ transform: tfAt(GHOST_OFFSET_MID),
+ opacity: GHOST_OPACITY_MID,
+ filter: blurAt(GHOST_BLUR_MID_PX),
+ offset: GHOST_OFFSET_MID,
+ },
+ {
+ transform: tfAt(GHOST_OFFSET_LATE),
+ opacity: GHOST_OPACITY_LATE,
+ filter: blurAt(GHOST_BLUR_LATE_PX),
+ offset: GHOST_OFFSET_LATE,
+ },
+ { transform: tfAt(1), opacity: GHOST_OPACITY_PEAK, filter: blurAt(GHOST_BLUR_PEAK_PX), offset: GHOST_OFFSET_PEAK },
+ { transform: tfAt(1), opacity: 0, filter: blurAt(0) },
+ ];
+
+ const animation = ghost.animate(keyframes, {
+ duration: VT_EVIDENCE_PAGE_EXPAND_MS,
+ easing: EASE_COLLAPSE,
+ fill: "both",
+ });
+
+ // Coordinated popover content fade-in — dims the ENTIRE popover (header,
+ // status section, image, toolbar — everything) so the ghost is the only
+ // visible element during the first 55% of the animation.
+ //
+ // Mirrors the collapse's "dip-then-reveal":
+ // Collapse: new content stays at 0 until 60%, then reveals sharply 0→1.
+ // Expand: popover stays near-invisible while ghost dominates,
+ // then reveals sharply in the last ~40%.
+ //
+ // Previously we only dimmed [data-dc-inline-expanded] (the image container),
+ // leaving Zone 1 (header) and Zone 2 (status/claim) at full opacity — which
+ // made the expand look like "the page popped in" despite the image being dimmed.
+ if (popoverRoot) {
+ const contentAnim = popoverRoot.animate(
+ [
+ { opacity: PAGE_EXPAND_CONTENT_OPACITY_START },
+ { opacity: 0.03, offset: 0.45 },
+ { opacity: 0.08, offset: 0.58 },
+ { opacity: 0.35, offset: 0.72 },
+ { opacity: 0.8, offset: 0.88 },
+ { opacity: 1 },
+ ],
+ { duration: VT_EVIDENCE_PAGE_EXPAND_MS, easing: BLINK_ENTER_EASING, fill: "forwards" },
+ );
+ contentAnim.finished
+ .catch(() => {})
+ .finally(() => {
+ // Cancel removes the WAAPI animation layer so its fill: "forwards"
+ // no longer overrides inline styles on subsequent transitions.
+ contentAnim.cancel();
+ popoverRoot.style.opacity = "";
+ popoverRoot.style.transition = "";
+ });
+ }
+
+ animation.finished
+ .catch(() => {})
+ .finally(() => {
+ ghost.remove();
+ });
+}
+
+function waitForPageExpandTarget(
+ root: ParentNode,
+ snapshot: GhostSnapshot,
+ callback: (target: PageExpandTarget | null) => void,
+ attemptsLeft = 12,
+ previousStableRect: DOMRect | null = null,
+ stableFrames = 0,
+): void {
+ requestAnimationFrame(() => {
+ const target = findPageExpandTarget(root, snapshot);
+ const targetRect = target?.markerRect ?? null;
+ const debugPhase = getPageExpandDebugPhase();
+ if (
+ targetRect &&
+ isVisibleRect(targetRect) &&
+ targetRect.bottom > 0 &&
+ targetRect.right > 0 &&
+ targetRect.top < window.innerHeight &&
+ targetRect.left < window.innerWidth
+ ) {
+ if (debugPhase === "target" || debugPhase === "both") {
+ callback(target);
+ return;
+ }
+ const isStable =
+ previousStableRect &&
+ Math.abs(targetRect.left - previousStableRect.left) <= 1 &&
+ Math.abs(targetRect.top - previousStableRect.top) <= 1 &&
+ Math.abs(targetRect.width - previousStableRect.width) <= 1 &&
+ Math.abs(targetRect.height - previousStableRect.height) <= 1;
+ if (isStable && stableFrames >= 0) {
+ callback(target);
+ return;
+ }
+ if (attemptsLeft <= 1) {
+ callback(target);
+ return;
+ }
+ waitForPageExpandTarget(root, snapshot, callback, attemptsLeft - 1, targetRect, isStable ? stableFrames + 1 : 0);
+ return;
+ }
+ if (attemptsLeft <= 1) {
+ callback(target);
+ return;
+ }
+ waitForPageExpandTarget(root, snapshot, callback, attemptsLeft - 1, null, 0);
+ });
+}
+
+export function startEvidencePageExpandTransition(
+ update: () => void,
+ options?: { root?: ParentNode | null; skipAnimation?: boolean },
+): void {
+ const root = options?.root ?? null;
+ if (options?.skipAnimation || typeof document === "undefined" || !root) {
+ // Guard the transition depth even in the synchronous fallback so dismiss
+ // handlers see a consistent in-flight state during the state update.
+ _transitionDepth++;
+ try {
+ update();
+ } finally {
+ _transitionDepth = Math.max(0, _transitionDepth - 1);
+ }
+ return;
+ }
+
+ const debugPhase = getPageExpandDebugPhase();
+ // Clear stale debug overlays from a previous transition / popover session
+ // so every page-expand attempt starts fresh.
+ if (debugPhase) clearDebugOverlays();
+
+ // Resolve root to an HTMLElement for style manipulation. The root is
+ // popoverContentRef.current — always an HTMLElement at runtime.
+ const rootEl = root instanceof HTMLElement ? root : null;
+
+ // Commit the state update and run the ghost transition in a microtask.
+ // This matches the timing of the View Transition API (which defers its
+ // callback), and avoids mutating the DOM via flushSync while the browser
+ // is still processing the click event that triggered the expand — React
+ // can lose track of the event target when it's replaced mid-handler.
+ //
+ // queueMicrotask typically fires before the next paint (within the same
+ // task frame during React event batching), so the pre-dim + flushSync
+ // + ghost creation all complete within the same visual frame.
+ const commitAndAnimate = () => {
+ _transitionDepth++;
+ const source = capturePageExpandSource(root);
+
+ // Pre-dim the ENTIRE popover content BEFORE flushSync. This ensures that
+ // when flushSync makes the expanded-page slot visible, the whole popover
+ // (header, status, image — everything) appears already dimmed.
+ //
+ // CRITICAL: Disable CSS transitions first. The PopoverContent element has
+ // `transition: opacity 60ms ...` from getBlinkContainerMotionStyle("steady").
+ // Without disabling transitions, the opacity change animates from 1 → 0.03
+ // over ~60ms — the first paint frame shows the expanded page at ~50% opacity,
+ // which reads as a full-page flash before the ghost animation begins.
+ if (rootEl) {
+ // Cancel any lingering WAAPI animations from a previous page-expand.
+ // `fill: "forwards"` on the content fade-in holds opacity: 1 indefinitely
+ // at a higher cascade priority than inline styles — without cancelling,
+ // the inline opacity: 0.03 below would be silently overridden.
+ for (const anim of rootEl.getAnimations()) anim.cancel();
+ rootEl.style.transition = "none";
+ rootEl.style.opacity = String(PAGE_EXPAND_CONTENT_OPACITY_START);
+ }
+
+ flushSync(update);
+
+ if (!source) {
+ if (rootEl) {
+ rootEl.style.opacity = "";
+ rootEl.style.transition = "";
+ }
+ if (debugPhase) {
+ clearDebugOverlays();
+ const primedEls = root.querySelectorAll?.("[data-dc-page-expand-source]");
+ console.warn(
+ "[DC debug] source capture FAILED — no visible source element found.",
+ `${primedEls?.length ?? 0} [data-dc-page-expand-source] elements in root.`,
+ );
+ }
+ _transitionDepth = Math.max(0, _transitionDepth - 1);
+ return;
+ }
+ const ghost = createPageExpandGhost(source);
+ if (!ghost) {
+ if (rootEl) {
+ rootEl.style.opacity = "";
+ rootEl.style.transition = "";
+ }
+ if (debugPhase) {
+ console.warn("[DC debug] ghost creation FAILED — image validation rejected:", source.imageSrc);
+ }
+ _transitionDepth = Math.max(0, _transitionDepth - 1);
+ return;
+ }
+ waitForPageExpandTarget(root, source, target => {
+ _transitionDepth = Math.max(0, _transitionDepth - 1);
+ if (!target) {
+ ghost.remove();
+ if (rootEl) {
+ rootEl.style.opacity = "";
+ rootEl.style.transition = "";
+ }
+ if (debugPhase) {
+ clearDebugOverlays();
+ const targetEls = root.querySelectorAll?.("[data-dc-page-expand-target]");
+ const readyEls = root.querySelectorAll?.('[data-dc-page-expand-ready="true"]');
+ const kindLabel =
+ source.sourceKind === "summary-keyhole"
+ ? "summary keyhole"
+ : source.sourceKind === "expanded-keyhole"
+ ? "expanded keyhole"
+ : "source";
+ createDebugOverlay(
+ source.viewportRect,
+ DEBUG_PAGE_EXPAND_SOURCE_COLOR,
+ `SOURCE (${kindLabel}) — TARGET NOT FOUND`,
+ `anchor: (${source.sourceAnchorX.toFixed(2)}, ${source.sourceAnchorY.toFixed(2)})`,
+ );
+ console.warn(
+ "[DC debug] target NOT FOUND after 12 rAF polls.",
+ `\n [data-dc-page-expand-target] elements: ${targetEls?.length ?? 0}`,
+ `\n [data-dc-page-expand-ready="true"] elements: ${readyEls?.length ?? 0}`,
+ "\n This usually means annotationVtRect is null (no text match on the page — not_found/miss state).",
+ "\n Source snapshot:",
+ source,
+ );
+ }
+ return;
+ }
+ runPageExpandGhostAnimation(ghost, source, target, rootEl);
+ });
+ };
+
+ queueMicrotask(commitAndAnimate);
+}
+
+// =============================================================================
+// CONSOLE DEBUG API
+// =============================================================================
+//
+// Usage from browser DevTools:
+// __dcDebugPageExpand("both") — show source + target + marker overlays
+// __dcDebugPageExpand("source") — freeze ghost at source rect
+// __dcDebugPageExpand("target") — freeze ghost at target rect
+// __dcDebugPageExpand(null) — disable debug mode
+// __dcDebugPageExpand() — toggle: off → "both" → "source" → "target" → off
+// __dcDebugPageExpand.clear() — remove debug overlays without changing mode
+
+if (typeof window !== "undefined") {
+ const CYCLE: DebugPhase[] = ["both", "source", "target", null];
+
+ const api = (phase?: DebugPhase | undefined) => {
+ if (phase === undefined) {
+ // Cycle through modes
+ const current = getPageExpandDebugPhase();
+ const idx = CYCLE.indexOf(current);
+ const next = CYCLE[(idx + 1) % CYCLE.length];
+ return api(next ?? null);
+ }
+ if (phase === null) {
+ delete document.documentElement.dataset.dcPageExpandDebugPhase;
+ clearDebugOverlays();
+ console.log("[DC debug] page-expand debug OFF");
+ } else {
+ document.documentElement.dataset.dcPageExpandDebugPhase = phase;
+ if (phase !== "both") clearDebugOverlays();
+ console.log(
+ `[DC debug] page-expand debug: %c${phase}`,
+ `color: ${phase === "source" ? DEBUG_PAGE_EXPAND_SOURCE_COLOR : phase === "target" ? DEBUG_PAGE_EXPAND_TARGET_COLOR : "#3b82f6"}; font-weight: bold`,
+ "— click a page pill / View Page to trigger",
+ );
+ }
+ return phase;
+ };
+ api.clear = () => {
+ clearDebugOverlays();
+ console.log("[DC debug] overlays cleared");
+ };
+
+ /** Scan the live DOM and outline all page-expand source/target elements without triggering a transition. */
+ api.scan = () => {
+ clearDebugOverlays();
+ const sources = document.querySelectorAll("[data-dc-page-expand-source]");
+ const targets = document.querySelectorAll("[data-dc-page-expand-target]");
+ let sourceCount = 0;
+ let targetCount = 0;
+ sources.forEach(el => {
+ const rect = el.getBoundingClientRect();
+ if (!isVisibleRect(rect)) return;
+ sourceCount++;
+ const kind = el.dataset.dcPageExpandSourceKind ?? "unknown";
+ const anchorX = el.dataset.dcSourceAnchorX ?? "—";
+ const anchorY = el.dataset.dcSourceAnchorY ?? "—";
+ createDebugOverlay(rect, DEBUG_PAGE_EXPAND_SOURCE_COLOR, `SOURCE (${kind})`, `anchor: (${anchorX}, ${anchorY})`);
+ });
+ targets.forEach(el => {
+ const rect = el.getBoundingClientRect();
+ if (!isVisibleRect(rect)) return;
+ targetCount++;
+ const ready = el.dataset.dcPageExpandReady;
+ const color = ready === "true" ? DEBUG_PAGE_EXPAND_TARGET_COLOR : "#f59e0b";
+ createDebugOverlay(rect, color, `TARGET (ready=${ready ?? "?"})`);
+ });
+ console.log(
+ `[DC debug] scan: ${sourceCount} visible source(s), ${targetCount} visible target(s)`,
+ `\n Total in DOM: ${sources.length} source(s), ${targets.length} target(s)`,
+ );
+ return { sources: sourceCount, targets: targetCount };
+ };
+
+ (window as unknown as Record).__dcDebugPageExpand = api;
+}
diff --git a/tests/playwright/specs/PageExpandGeometryCitation.tsx b/tests/playwright/specs/PageExpandGeometryCitation.tsx
new file mode 100644
index 00000000..08fa67d0
--- /dev/null
+++ b/tests/playwright/specs/PageExpandGeometryCitation.tsx
@@ -0,0 +1,105 @@
+import { useMemo } from "react";
+import { CitationComponent } from "../../../src/react/Citation";
+import type { Citation } from "../../../src/types/citation";
+import type { Verification } from "../../../src/types/verification";
+
+const baseCitation: Citation = {
+ type: "document",
+ attachmentId: "att-page-expand-geometry",
+ citationNumber: 1,
+ anchorText: "Collision installation",
+ fullPhrase:
+ 'At YC we use the term "Collision installation" for the technique they invented. More diffident founders ask "Will you try our beta?"',
+ pageNumber: 5,
+};
+
+function createCanvasDataUrl(width: number, height: number, label: string): string {
+ if (typeof document === "undefined") {
+ return "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mO8cuXKfwYGBgYGAAi7Av7W3NgAAAAASUVORK5CYII=";
+ }
+ const canvas = document.createElement("canvas");
+ canvas.width = width;
+ canvas.height = height;
+ const ctx = canvas.getContext("2d");
+ if (!ctx) {
+ return "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mO8cuXKfwYGBgYGAAi7Av7W3NgAAAAASUVORK5CYII=";
+ }
+ ctx.fillStyle = "#f3f4f6";
+ ctx.fillRect(0, 0, width, height);
+ ctx.fillStyle = "#111827";
+ ctx.font = `${Math.max(16, Math.floor(height * 0.18))}px sans-serif`;
+ ctx.fillText(label, 20, Math.max(30, Math.floor(height * 0.45)));
+ ctx.strokeStyle = "#2563eb";
+ ctx.lineWidth = 4;
+ ctx.strokeRect(12, 12, width - 24, height - 24);
+ return canvas.toDataURL("image/png");
+}
+
+export function PageExpandGeometryCitation() {
+ const pageSrc = useMemo(
+ () =>
+ createCanvasDataUrl(
+ 800,
+ 1600,
+ 'At YC we use the term "Collision installation" for the technique they invented.',
+ ),
+ [],
+ );
+ const evidenceSrc = useMemo(() => createCanvasDataUrl(560, 120, '"Collision installation"'), []);
+
+ const verification = useMemo(
+ () => ({
+ status: "found",
+ attachmentId: "att-page-expand-geometry",
+ verifiedMatchSnippet: 'At YC we use the term "Collision installation" for the technique they invented.',
+ verifiedAnchorText: "Collision installation",
+ verifiedFullPhrase:
+ 'At YC we use the term "Collision installation" for the technique they invented. More diffident founders ask "Will you try our beta?"',
+ document: {
+ verifiedPageNumber: 5,
+ phraseMatchDeepItem: {
+ x: 140,
+ y: 1280,
+ width: 460,
+ height: 34,
+ text: 'At YC we use the term "Collision installation" for the technique they invented.',
+ },
+ anchorTextMatchDeepItems: [
+ {
+ x: 330,
+ y: 1280,
+ width: 170,
+ height: 34,
+ text: "Collision installation",
+ },
+ ],
+ renderScale: { x: 1, y: 1 },
+ },
+ evidence: {
+ src: evidenceSrc,
+ dimensions: { width: 560, height: 120 },
+ },
+ }),
+ [evidenceSrc],
+ );
+
+ const pageImagesByAttachmentId = useMemo(
+ () => ({
+ "att-page-expand-geometry": [
+ {
+ pageNumber: 5,
+ dimensions: { width: 800, height: 1600 },
+ imageUrl: pageSrc,
+ isMatchPage: true,
+ },
+ ],
+ }),
+ [pageSrc],
+ );
+
+ return (
+
+
+
+ );
+}
diff --git a/tests/playwright/specs/citationPopoverInteractions.spec.tsx b/tests/playwright/specs/citationPopoverInteractions.spec.tsx
index 76d2a7ad..16d5c795 100644
--- a/tests/playwright/specs/citationPopoverInteractions.spec.tsx
+++ b/tests/playwright/specs/citationPopoverInteractions.spec.tsx
@@ -237,7 +237,7 @@ test.describe("Citation Popover - Click-to-Close Behavior", () => {
// Click the page pill to switch to expanded-page view (another internal transition)
const pagePill = popover.getByRole("button", { name: /full page/i }).first();
if (await pagePill.isVisible()) {
- await pagePill.dispatchEvent("click");
+ await pagePill.click();
await page.waitForTimeout(100);
}
@@ -445,7 +445,8 @@ test.describe("Citation Popover - Click-to-Close Behavior", () => {
await expect(toPageButton).toBeVisible();
// keyhole -> expanded-page
- await toPageButton.dispatchEvent("click");
+ // Use click() instead of dispatchEvent to ensure React handles the event
+ await toPageButton.click();
const pageExpandSamples = await page.evaluate(async () => {
const dialog = document.querySelector("[role='dialog']") as HTMLElement | null;
if (!dialog) return { inlineOpacity: [] as number[] };
@@ -477,7 +478,7 @@ test.describe("Citation Popover - Click-to-Close Behavior", () => {
// expanded-page -> keyhole
const backToKeyholeButton = popover.getByRole("button", { name: /Close (page|image)/i }).first();
await expect(backToKeyholeButton).toBeVisible();
- await backToKeyholeButton.dispatchEvent("click");
+ await backToKeyholeButton.click();
const pageCollapseSamples = await page.evaluate(async () => {
const dialog = document.querySelector("[role='dialog']") as HTMLElement | null;
if (!dialog) return { maxTransitionSeconds: 0 };
diff --git a/tests/playwright/specs/pageExpandGeometry.spec.tsx b/tests/playwright/specs/pageExpandGeometry.spec.tsx
new file mode 100644
index 00000000..344d8b3c
--- /dev/null
+++ b/tests/playwright/specs/pageExpandGeometry.spec.tsx
@@ -0,0 +1,166 @@
+import { expect, test } from "@playwright/experimental-ct-react";
+import { PageExpandGeometryCitation } from "./PageExpandGeometryCitation";
+
+async function openSummaryPopover(page: import("@playwright/test").Page) {
+ await page.locator("[data-citation-id]").click();
+ const popover = page.locator("[data-dc-popover-wrapper]");
+ await expect(popover).toBeVisible();
+ return popover;
+}
+
+async function freezeSummaryToPageTransition(
+ page: import("@playwright/test").Page,
+ phase: "source" | "target",
+ popover?: import("@playwright/test").Locator,
+) {
+ await page.evaluate(currentPhase => {
+ document.documentElement.dataset.dcPageExpandDebugPhase = currentPhase;
+ }, phase);
+ const activePopover = popover ?? (await openSummaryPopover(page));
+ const expandButton = activePopover.getByLabel(/Expand to full page/).first();
+ await expect(expandButton).toBeVisible();
+ await expandButton.click();
+ const ghost = page.locator("[data-dc-page-expand-ghost]");
+ await expect(ghost).toBeVisible();
+ return { popover: activePopover, ghost };
+}
+
+test.describe("Page Expand Geometry Debug", () => {
+ test.afterEach(async ({ page }) => {
+ await page.evaluate(() => {
+ delete document.documentElement.dataset.dcPageExpandDebugPhase;
+ });
+ });
+
+ test("source phase starts from the visible summary keyhole box", async ({ mount, page }) => {
+ await mount();
+ const popover = await openSummaryPopover(page);
+ const source = popover.locator("[data-dc-page-expand-source]").filter({ visible: true }).first();
+ const sourceBox = await source.boundingBox();
+ expect(sourceBox).toBeTruthy();
+ await page.evaluate(() => {
+ delete document.documentElement.dataset.dcPageExpandDebugPhase;
+ });
+
+ const { ghost } = await freezeSummaryToPageTransition(page, "source", popover);
+ const ghostBox = await ghost.boundingBox();
+ expect(ghostBox).toBeTruthy();
+
+ expect(Math.abs(ghostBox!.x - sourceBox!.x)).toBeLessThanOrEqual(2);
+ expect(Math.abs(ghostBox!.y - sourceBox!.y)).toBeLessThanOrEqual(2);
+ // Width tolerance 5px: ghost includes SPOTLIGHT_PADDING around the source keyhole
+ expect(Math.abs(ghostBox!.width - sourceBox!.width)).toBeLessThanOrEqual(5);
+ expect(Math.abs(ghostBox!.height - sourceBox!.height)).toBeLessThanOrEqual(2);
+ });
+
+ test("target phase lands near the settled expanded-page target and stays on-screen", async ({ mount, page }) => {
+ await mount();
+ const { ghost } = await freezeSummaryToPageTransition(page, "target");
+ // The ghost lands on the spotlight (annotation + SPOTLIGHT_PADDING), not the
+ // bare annotation marker. Use [data-dc-spotlight] as the reference rect.
+ // Fall back to [data-dc-page-expand-target] if no spotlight is rendered.
+ const spotlight = page.locator("[data-dc-spotlight]").first();
+ const target = page.locator("[data-dc-page-expand-target][data-dc-page-expand-ready='true']").first();
+ await expect(target).toBeVisible();
+
+ // Prefer spotlight if it exists; fall back to the annotation marker.
+ // Resolve the reference element inside the poll so we pick up the spotlight
+ // even if it paints after the initial count check.
+ await expect
+ .poll(
+ async () => {
+ const referenceEl = (await spotlight.count()) > 0 ? spotlight : target;
+ const referenceBox = await referenceEl.boundingBox();
+ const ghostBox = await ghost.boundingBox();
+ if (!ghostBox || !referenceBox) return Number.POSITIVE_INFINITY;
+ return Math.max(
+ Math.abs(ghostBox.x - referenceBox.x),
+ Math.abs(ghostBox.y - referenceBox.y),
+ Math.abs(ghostBox.width - referenceBox.width),
+ Math.abs(ghostBox.height - referenceBox.height),
+ );
+ },
+ { timeout: 1500 },
+ )
+ .toBeLessThanOrEqual(5);
+ // Re-sample after the poll has confirmed convergence.
+ const referenceEl = (await spotlight.count()) > 0 ? spotlight : target;
+ const referenceBox = await referenceEl.boundingBox();
+ const ghostBox = await ghost.boundingBox();
+ const viewport = page.viewportSize()!;
+ expect(ghostBox).toBeTruthy();
+ expect(referenceBox).toBeTruthy();
+
+ expect(Math.abs(ghostBox!.x - referenceBox!.x)).toBeLessThanOrEqual(5);
+ expect(Math.abs(ghostBox!.y - referenceBox!.y)).toBeLessThanOrEqual(5);
+ expect(Math.abs(ghostBox!.width - referenceBox!.width)).toBeLessThanOrEqual(5);
+ expect(Math.abs(ghostBox!.height - referenceBox!.height)).toBeLessThanOrEqual(5);
+ expect(ghostBox!.x).toBeGreaterThanOrEqual(-2);
+ expect(ghostBox!.y).toBeGreaterThanOrEqual(-2);
+ expect(ghostBox!.x + ghostBox!.width).toBeLessThanOrEqual(viewport.width + 2);
+ expect(ghostBox!.y + ghostBox!.height).toBeLessThanOrEqual(viewport.height + 2);
+ });
+
+ test("both phase renders source, target, and marker debug overlays", async ({ mount, page }) => {
+ await mount();
+ const popover = await openSummaryPopover(page);
+
+ // Set "both" debug phase — ghost is replaced by three persistent overlays
+ await page.evaluate(() => {
+ document.documentElement.dataset.dcPageExpandDebugPhase = "both";
+ });
+ const expandButton = popover.getByLabel(/Expand to full page/).first();
+ await expect(expandButton).toBeVisible();
+ await expandButton.click();
+
+ // Ghost should NOT be in the DOM (removed in "both" mode)
+ const ghost = page.locator("[data-dc-page-expand-ghost]");
+ await expect(ghost).toBeHidden({ timeout: 1500 });
+
+ // Three debug overlays should be visible
+ const overlays = page.locator("[data-dc-debug-overlay]");
+ await expect(overlays).toHaveCount(3, { timeout: 1500 });
+
+ // All overlays should be on-screen
+ const viewport = page.viewportSize()!;
+ for (let i = 0; i < 3; i++) {
+ const box = await overlays.nth(i).boundingBox();
+ expect(box).toBeTruthy();
+ expect(box!.width).toBeGreaterThan(1);
+ expect(box!.height).toBeGreaterThan(1);
+ // At least partially on-screen
+ expect(box!.x + box!.width).toBeGreaterThan(0);
+ expect(box!.y + box!.height).toBeGreaterThan(0);
+ expect(box!.x).toBeLessThan(viewport.width);
+ expect(box!.y).toBeLessThan(viewport.height);
+ }
+ });
+
+ test("scan() outlines live source/target elements without triggering a transition", async ({ mount, page }) => {
+ await mount();
+ await openSummaryPopover(page);
+
+ const result = await page.evaluate(() => {
+ const api = (window as unknown as Record { sources: number; targets: number } }>)
+ .__dcDebugPageExpand;
+ return api.scan();
+ });
+ // Summary popover should have at least one source element
+ expect(result.sources).toBeGreaterThanOrEqual(1);
+ // Overlays should be in the DOM
+ const overlays = page.locator("[data-dc-debug-overlay]");
+ expect(await overlays.count()).toBeGreaterThanOrEqual(1);
+ });
+
+ test("ghost is removed from the DOM after the animation completes", async ({ mount, page }) => {
+ await mount();
+ const popover = await openSummaryPopover(page);
+ const expandButton = popover.getByLabel(/Expand to full page/).first();
+ await expect(expandButton).toBeVisible();
+ await expandButton.click();
+ const ghost = page.locator("[data-dc-page-expand-ghost]");
+ // Ghost should appear then be removed after animation (250ms + buffer)
+ await expect(ghost).toBeHidden({ timeout: 2000 });
+ expect(await ghost.count()).toBe(0);
+ });
+});