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 cover](https://deepcitation.com/og-images/deepcitation-og-1200x630.png) +![DeepCitation cover](https://deepcitation.com/og-images/deepcitation-og-1200x630.png?v=2)
DeepCitation
@@ -13,8 +13,8 @@ Show proof for every AI citation. [![CI](https://img.shields.io/github/actions/workflow/status/DeepCitation/deepcitation/ci.yml?style=flat-square&label=CI)](https://github.com/DeepCitation/deepcitation/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-005595?style=flat-square)](https://opensource.org/licenses/MIT) -[![Zero Dependencies](https://img.shields.io/badge/Zero%20Dependencies-trusted-10b981?style=flat-square)](https://www.npmjs.com/package/deepcitation) -[![~17KB](https://img.shields.io/badge/gzip-~17KB-10b981?style=flat-square)](https://bundlephobia.com/package/deepcitation) +[![Zero Dependencies](https://img.shields.io/badge/Zero%20Dependencies-trusted-005595?style=flat-square)](https://www.npmjs.com/package/deepcitation) +[![~17KB](https://img.shields.io/badge/gzip-~17KB-005595?style=flat-square)](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); + }); +});