diff --git a/src/app/(app)/pdf/[id]/page.tsx b/src/app/(app)/pdf/[id]/page.tsx index 0d1b3b9..cd79988 100644 --- a/src/app/(app)/pdf/[id]/page.tsx +++ b/src/app/(app)/pdf/[id]/page.tsx @@ -19,6 +19,7 @@ import { RateLimitBanner } from '@/components/auth/RateLimitBanner'; import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; import { LoadingSpinner } from '@/components/Spinner'; +import { PdfLayoutScan } from '@/components/reader/PdfLayoutScan'; import { Button, ButtonLink } from '@/components/ui'; import { FORCE_REPARSE_CONFIRM_MESSAGE, @@ -279,21 +280,13 @@ export default function PDFViewerPage() { const isMerging = parseProgress?.phase === 'merge'; let statusText = 'Loading PDF...'; - let statusSubText = 'Initializing document renderer'; if (!isLoading) { if (parseUiState === 'pending') { statusText = 'Preparing PDF layout...'; - statusSubText = parseProgress?.phase === 'merge' - ? 'Finalizing stitched block structure' - : 'Queueing parser and preparing page extraction'; } else if (parseUiState === 'running') { statusText = 'Parsing PDF layout blocks...'; - statusSubText = parseProgress?.phase === 'merge' - ? 'Merging cross-page sections' - : 'Inferring reading order and text regions'; } else if (parseUiState === 'failed') { statusText = 'PDF parsing failed. Retry to continue.'; - statusSubText = 'The parser could not build a usable layout map'; } } @@ -305,34 +298,80 @@ export default function PDFViewerPage() { return (
-
+
{showDetailedParseLoader ? ( -
-
-
-
+
+ {/* prism top edge + corner glow for depth */} +
+
+ {/* dotted texture spanning the whole loader */} +
+ +
+ {/* header: status badge + model attribution */} +
PDF Layout Parse
-

{statusText}

+
+ + PP-DocLayout-V3 +
-
-
-

- {hasMeasuredProgress ? `Page ${pagesParsed} / ${totalPages}` : 'Awaiting first page'} +

+ {/* animated layout scanner */} +
+ +
+ + {/* live status + progress */} +
+ {parseUiState === 'failed' ? ( +

{statusText}

+ ) : null} + +
+

+ {hasMeasuredProgress ? `Page ${pagesParsed} / ${totalPages}` : 'Awaiting first page'} +

+

{stageLabel}

+
+
+
+
+

+ {hasMeasuredProgress ? `${Math.round(progressPercent)}% complete` : 'Calibrating layout pass'}

-

{stageLabel}

-
-
-
-

- {hasMeasuredProgress ? `${Math.round(progressPercent)}% complete` : statusSubText} +

+ + {/* attribution footer */} +
+ + + +

+ Classifying titles, text, tables, figures, formulas, seals & more with PP-DocLayout-V3.

diff --git a/src/app/globals.css b/src/app/globals.css index b168ce2..1d69c28 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -425,115 +425,30 @@ h1, h2, h3, h4, h5, h6 { border-radius: 999px; /* subtle rounding to reinforce taper */ } -/* Range slider — "precision gauge": a hairline rail with ruler notches per step - and a slim accent needle instead of a ball. Per-instance values - (--range-progress, --range-tick-size, --range-tick-color) come from inline styles. */ -.range-input { - --range-track-h: 2px; - --range-needle-h: 1rem; - --range-needle-w: 3px; - --range-tick-size: 25%; /* segment width; only used when ticks are enabled */ - --range-tick-color: transparent; /* set to a real color inline for discrete sliders */ - --range-fill: var(--muted); - --range-empty: color-mix(in srgb, var(--offbase) 82%, var(--background)); - -webkit-appearance: none; - appearance: none; - width: 100%; - height: 1.25rem; - background: transparent; - cursor: pointer; +/* Generic determinate progress fill with a moving sheen. Apply to the colored + fill element (the one whose width tracks progress); its track should clip. */ +.progress-fill { + position: relative; + overflow: hidden; } -.range-input:disabled { - cursor: not-allowed; - opacity: 0.6; +.progress-fill::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient( + 90deg, + transparent, + color-mix(in srgb, var(--background) 55%, transparent), + transparent + ); + transform: translateX(-100%); + animation: progress-sheen 2.2s linear infinite; } -.range-input:focus-visible { - outline: none; +@keyframes progress-sheen { + 0% { transform: translateX(-100%); } + /* sweep across at constant speed, then rest off-screen so the loop reset + (from the right edge back to the left) is never visible */ + 65% { transform: translateX(220%); } + 100% { transform: translateX(220%); } } -/* Ticks ride above the muted fill / empty rail (first listed background = top). */ -.range-input::-webkit-slider-runnable-track { - height: var(--range-track-h); - border-radius: 999px; - background: - repeating-linear-gradient( - to right, - var(--range-tick-color) 0, - var(--range-tick-color) 1.5px, - transparent 1.5px, - transparent var(--range-tick-size) - ), - linear-gradient( - to right, - var(--range-fill) 0, - var(--range-fill) var(--range-progress), - var(--range-empty) var(--range-progress), - var(--range-empty) 100% - ); -} -.range-input::-webkit-slider-thumb { - -webkit-appearance: none; - appearance: none; - width: var(--range-needle-w); - height: var(--range-needle-h); - margin-top: calc((var(--range-track-h) - var(--range-needle-h)) / 2); - border-radius: 999px; - background: var(--accent); - box-shadow: - 0 0 0 1px color-mix(in srgb, var(--background) 70%, transparent), - 0 1px 6px color-mix(in srgb, var(--accent) 55%, transparent); - transition: transform 150ms ease, box-shadow 150ms ease; -} -.range-input:hover::-webkit-slider-thumb { - transform: scaleY(1.3); -} -.range-input:active::-webkit-slider-thumb { - transform: scaleY(1.55); -} -.range-input:focus-visible::-webkit-slider-thumb { - box-shadow: - 0 0 0 4px color-mix(in srgb, var(--accent) 28%, transparent), - 0 1px 6px color-mix(in srgb, var(--accent) 55%, transparent); -} - -/* Firefox: track + native progress fill + matching needle. */ -.range-input::-moz-range-track { - height: var(--range-track-h); - border-radius: 999px; - background: - repeating-linear-gradient( - to right, - var(--range-tick-color) 0, - var(--range-tick-color) 1.5px, - transparent 1.5px, - transparent var(--range-tick-size) - ), - var(--range-empty); -} -.range-input::-moz-range-progress { - height: var(--range-track-h); - border-radius: 999px; - background: var(--range-fill); -} -.range-input::-moz-range-thumb { - width: var(--range-needle-w); - height: var(--range-needle-h); - border: 0; - border-radius: 999px; - background: var(--accent); - box-shadow: - 0 0 0 1px color-mix(in srgb, var(--background) 70%, transparent), - 0 1px 6px color-mix(in srgb, var(--accent) 55%, transparent); - transition: transform 150ms ease, box-shadow 150ms ease; -} -.range-input:hover::-moz-range-thumb { - transform: scaleY(1.3); -} -.range-input:active::-moz-range-thumb { - transform: scaleY(1.55); -} -.range-input:focus-visible::-moz-range-thumb { - box-shadow: - 0 0 0 4px color-mix(in srgb, var(--accent) 28%, transparent), - 0 1px 6px color-mix(in srgb, var(--accent) 55%, transparent); -} diff --git a/src/components/ProgressCard.tsx b/src/components/ProgressCard.tsx index f8341d0..2933c9d 100644 --- a/src/components/ProgressCard.tsx +++ b/src/components/ProgressCard.tsx @@ -66,7 +66,7 @@ export function ProgressCard({ {/* Progress bar */}
diff --git a/src/components/documents/DexieMigrationModal.tsx b/src/components/documents/DexieMigrationModal.tsx index df469d1..2efa278 100644 --- a/src/components/documents/DexieMigrationModal.tsx +++ b/src/components/documents/DexieMigrationModal.tsx @@ -155,7 +155,7 @@ export function DexieMigrationModal({

{status}

-
+
)} diff --git a/src/components/reader/PdfLayoutScan.module.css b/src/components/reader/PdfLayoutScan.module.css new file mode 100644 index 0000000..fc1df01 --- /dev/null +++ b/src/components/reader/PdfLayoutScan.module.css @@ -0,0 +1,202 @@ +/* Scanner visualization for the PDF layout-parse loader. Scoped via CSS Module + so it stays out of the global stylesheet and works across the helper that + renders region content. Used by PdfLayoutScan.tsx. */ + +.stage { + position: relative; + width: 100%; + aspect-ratio: 1 / 1; + display: grid; + place-items: center; + background: transparent; +} + +.page { + position: relative; + width: 68%; + aspect-ratio: 3 / 4; + border-radius: 6px; + background: linear-gradient(180deg, color-mix(in srgb, var(--background) 92%, var(--foreground)), var(--background)); + border: 1px solid var(--line); + box-shadow: + var(--elev-2), + 0 0 0 1px color-mix(in srgb, var(--background) 60%, transparent), + inset 0 1px 0 color-mix(in srgb, var(--foreground) 6%, transparent); + overflow: hidden; +} + +.grid { + position: absolute; + inset: 0; + background-image: linear-gradient( + to bottom, + transparent 0, + transparent calc(100% / 22 - 1px), + color-mix(in srgb, var(--foreground) 6%, transparent) calc(100% / 22 - 1px), + color-mix(in srgb, var(--foreground) 6%, transparent) calc(100% / 22) + ); + background-size: 100% calc(100% / 22 * 4); +} + +/* ── readout chip (in the stage band, above the page) ─────────────────── */ +.tagRow { + position: absolute; + top: 4%; + left: 0; + right: 0; + display: flex; + justify-content: center; + z-index: 5; + padding: 0 4%; +} +.tag { + display: inline-flex; + align-items: center; + max-width: 100%; + padding: 3px 9px; + border-radius: 6px; + font-family: var(--font-display), system-ui, sans-serif; + font-size: 10px; + font-weight: 700; + line-height: 1.25; + letter-spacing: 0.01em; + white-space: nowrap; + color: var(--background); + background: var(--accent); + box-shadow: 0 2px 10px color-mix(in srgb, var(--accent) 50%, transparent); + animation: tagIn 0.34s var(--ease); +} +@keyframes tagIn { + from { opacity: 0; transform: translateY(-4px) scale(0.92); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +/* ── region boxes (one shown at a time, centered) ─────────────────────── */ +.solos { + position: absolute; + inset: 8%; + display: grid; + place-items: center; +} +.solo { + grid-area: 1 / 1; /* stack so regions cross-fade in place */ + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; + border: 1.5px solid var(--accent); + background: color-mix(in srgb, var(--foreground) 8%, transparent); + box-shadow: + 0 0 0 1px var(--accent-line), + 0 0 22px color-mix(in srgb, var(--accent) 30%, transparent); + padding: 4px 7px; + opacity: 0; + transform: scale(0.9); + transition: opacity 0.32s var(--ease), transform 0.32s var(--ease); + will-change: opacity, transform; +} +.solo.active { + opacity: 1; + transform: scale(1); +} + +/* inner content so each region reads as real document material */ +.lines { + display: flex; + flex-direction: column; + justify-content: center; + gap: 11%; + width: 100%; + height: 100%; +} +.linesHeading { gap: 22%; } +.lines > span { + display: block; + height: 3px; + width: 100%; + border-radius: 999px; + background: color-mix(in srgb, var(--foreground) 26%, transparent); +} +.linesHeading > span { + height: 6px; + background: color-mix(in srgb, var(--foreground) 46%, transparent); +} + +.glyph { + width: 100%; + height: 100%; + stroke: color-mix(in srgb, var(--foreground) 36%, transparent); + stroke-width: 2; + stroke-linejoin: round; + fill: none; +} +.glyph circle { + fill: color-mix(in srgb, var(--foreground) 30%, transparent); + stroke: none; +} + +.table { + display: grid; + grid-template-columns: repeat(4, 1fr); + grid-template-rows: repeat(4, 1fr); + gap: 2px; + width: 100%; + height: 100%; + background: color-mix(in srgb, var(--foreground) 16%, transparent); + border: 1px solid color-mix(in srgb, var(--foreground) 16%, transparent); + border-radius: 3px; + overflow: hidden; +} +.table > span { + background: color-mix(in srgb, var(--background) 82%, var(--foreground)); +} +.table > span:nth-child(-n + 4) { + background: color-mix(in srgb, var(--foreground) 22%, transparent); +} + +/* ── scan beam ────────────────────────────────────────────────────────── */ +.beam { + position: absolute; + left: -4%; + right: -4%; + height: 2px; + border-radius: 999px; + background: linear-gradient(90deg, transparent, var(--accent), transparent); + box-shadow: 0 0 14px 2px color-mix(in srgb, var(--accent) 55%, transparent); + animation: beam 1.05s var(--ease) infinite; + will-change: top, opacity; +} +.beam::after { + content: ''; + position: absolute; + left: 0; + right: 0; + bottom: 1px; + height: 30px; + background: linear-gradient(to top, color-mix(in srgb, var(--accent) 18%, transparent), transparent); +} +@keyframes beam { + 0% { top: -4%; opacity: 0; } + 8% { opacity: 1; } + 90% { opacity: 1; } + 100% { top: 104%; opacity: 0; } +} + +/* camera-style corner reticles */ +.corner { + position: absolute; + width: 10px; + height: 10px; + border: 1.5px solid var(--accent-line); + pointer-events: none; +} +.cornerTl { top: 5px; left: 5px; border-right: 0; border-bottom: 0; border-top-left-radius: 3px; } +.cornerTr { top: 5px; right: 5px; border-left: 0; border-bottom: 0; border-top-right-radius: 3px; } +.cornerBl { bottom: 5px; left: 5px; border-right: 0; border-top: 0; border-bottom-left-radius: 3px; } +.cornerBr { bottom: 5px; right: 5px; border-left: 0; border-top: 0; border-bottom-right-radius: 3px; } + +@media (prefers-reduced-motion: reduce) { + .beam { animation: none; opacity: 0; } + .tag { animation: none; } + .solo { transition: none; } +} diff --git a/src/components/reader/PdfLayoutScan.tsx b/src/components/reader/PdfLayoutScan.tsx new file mode 100644 index 0000000..2bf6e03 --- /dev/null +++ b/src/components/reader/PdfLayoutScan.tsx @@ -0,0 +1,144 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import styles from './PdfLayoutScan.module.css'; + +/** + * PdfLayoutScan — ambient visualization of PP-DocLayout-V3 at work. + * + * A miniature document page sits directly on the loader's dotted background + * while a scan beam sweeps across it. One layout region is "detected" at a time + * — a box rendered with one + * of a few simple shapes — while a readout chip above the page names its class. + * It cycles through the complete set of region classes PP-DocLayout-V3 emits + * (see PARSED_PDF_BLOCK_KINDS in types/parsed-pdf). Purely decorative; honours + * prefers-reduced-motion by freezing on a single region. Styles live in the + * adjacent CSS module so they stay out of the global stylesheet. + */ + +// Only a handful of real shapes — most regions are just text lines. +type BlockShape = 'heading' | 'text' | 'small' | 'image' | 'table'; + +interface ScanBlock { + label: string; + shape: BlockShape; +} + +// One consistent box size per shape, so every region renders the same way and +// nothing gets clipped — only the label changes. +const SHAPE_SIZE: Record = { + heading: { width: 82, height: 22 }, + text: { width: 88, height: 66 }, + small: { width: 72, height: 24 }, + image: { width: 80, height: 68 }, + table: { width: 88, height: 64 }, +}; + +// Every PP-DocLayout-V3 class, in document-flow order, mapped to a simple shape. +const SCAN_BLOCKS: ScanBlock[] = [ + { label: 'Header', shape: 'small' }, + { label: 'Doc title', shape: 'heading' }, + { label: 'Abstract', shape: 'text' }, + { label: 'Paragraph title', shape: 'heading' }, + { label: 'Text', shape: 'text' }, + { label: 'Formula', shape: 'text' }, + { label: 'Formula number', shape: 'small' }, + { label: 'Figure title', shape: 'small' }, + { label: 'Image', shape: 'image' }, + { label: 'Chart', shape: 'image' }, + { label: 'Table', shape: 'table' }, + { label: 'Algorithm', shape: 'text' }, + { label: 'Content', shape: 'text' }, + { label: 'Aside text', shape: 'text' }, + { label: 'Number', shape: 'small' }, + { label: 'Reference', shape: 'small' }, + { label: 'Reference content', shape: 'text' }, + { label: 'Footnote', shape: 'small' }, + { label: 'Vision footnote', shape: 'small' }, + { label: 'Seal', shape: 'image' }, + { label: 'Footer', shape: 'small' }, +]; + +const STEP_MS = 1050; + +const cx = (...parts: Array) => parts.filter(Boolean).join(' '); + +function BlockContent({ shape }: { shape: BlockShape }) { + if (shape === 'image') { + return ( + + + + + ); + } + if (shape === 'table') { + return ( +
+ {Array.from({ length: 16 }).map((_, i) => ( + + ))} +
+ ); + } + const isHeading = shape === 'heading'; + const lines = isHeading ? 2 : shape === 'small' ? 2 : 5; + return ( +
+ {Array.from({ length: lines }).map((_, i) => ( + + ))} +
+ ); +} + +export function PdfLayoutScan() { + const [active, setActive] = useState(0); + + useEffect(() => { + if (typeof window === 'undefined') return; + const reduced = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; + if (reduced) return; // freeze on the first region for reduced-motion users + const id = window.setInterval(() => { + setActive((i) => (i + 1) % SCAN_BLOCKS.length); + }, STEP_MS); + return () => window.clearInterval(id); + }, []); + + return ( +
+ {/* readout chip: floats in the stage band above the page so long class + names are never clipped by the page's rounded overflow */} +
+ + {SCAN_BLOCKS[active].label} + +
+ +
+
+ +
+ {SCAN_BLOCKS.map((block, i) => { + const size = SHAPE_SIZE[block.shape]; + return ( +
+ +
+ ); + })} +
+ +
+ + + + +
+
+ ); +} diff --git a/src/components/ui/range.module.css b/src/components/ui/range.module.css new file mode 100644 index 0000000..4191c45 --- /dev/null +++ b/src/components/ui/range.module.css @@ -0,0 +1,115 @@ +/* Range slider — "precision gauge": a hairline rail with ruler notches per step + and a slim accent needle instead of a ball. Scoped via CSS Module so it stays + out of the global stylesheet. Per-instance values (--range-progress, + --range-tick-size, --range-tick-color) come from inline styles set in + range.tsx. Used by RangeInput. */ + +.range { + --range-track-h: 2px; + --range-needle-h: 1rem; + --range-needle-w: 3px; + --range-tick-size: 25%; /* segment width; only used when ticks are enabled */ + --range-tick-color: transparent; /* set to a real color inline for discrete sliders */ + --range-fill: var(--muted); + --range-empty: color-mix(in srgb, var(--offbase) 82%, var(--background)); + -webkit-appearance: none; + appearance: none; + width: 100%; + height: 1.25rem; + background: transparent; + cursor: pointer; +} +.range:disabled { + cursor: not-allowed; + opacity: 0.6; +} +.range:focus-visible { + outline: none; +} + +/* Ticks ride above the muted fill / empty rail (first listed background = top). */ +.range::-webkit-slider-runnable-track { + height: var(--range-track-h); + border-radius: 999px; + background: + repeating-linear-gradient( + to right, + var(--range-tick-color) 0, + var(--range-tick-color) 1.5px, + transparent 1.5px, + transparent var(--range-tick-size) + ), + linear-gradient( + to right, + var(--range-fill) 0, + var(--range-fill) var(--range-progress), + var(--range-empty) var(--range-progress), + var(--range-empty) 100% + ); +} +.range::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: var(--range-needle-w); + height: var(--range-needle-h); + margin-top: calc((var(--range-track-h) - var(--range-needle-h)) / 2); + border-radius: 999px; + background: var(--accent); + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--background) 70%, transparent), + 0 1px 6px color-mix(in srgb, var(--accent) 55%, transparent); + transition: transform 150ms ease, box-shadow 150ms ease; +} +.range:hover::-webkit-slider-thumb { + transform: scaleY(1.3); +} +.range:active::-webkit-slider-thumb { + transform: scaleY(1.55); +} +.range:focus-visible::-webkit-slider-thumb { + box-shadow: + 0 0 0 4px color-mix(in srgb, var(--accent) 28%, transparent), + 0 1px 6px color-mix(in srgb, var(--accent) 55%, transparent); +} + +/* Firefox: track + native progress fill + matching needle. */ +.range::-moz-range-track { + height: var(--range-track-h); + border-radius: 999px; + background: + repeating-linear-gradient( + to right, + var(--range-tick-color) 0, + var(--range-tick-color) 1.5px, + transparent 1.5px, + transparent var(--range-tick-size) + ), + var(--range-empty); +} +.range::-moz-range-progress { + height: var(--range-track-h); + border-radius: 999px; + background: var(--range-fill); +} +.range::-moz-range-thumb { + width: var(--range-needle-w); + height: var(--range-needle-h); + border: 0; + border-radius: 999px; + background: var(--accent); + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--background) 70%, transparent), + 0 1px 6px color-mix(in srgb, var(--accent) 55%, transparent); + transition: transform 150ms ease, box-shadow 150ms ease; +} +.range:hover::-moz-range-thumb { + transform: scaleY(1.3); +} +.range:active::-moz-range-thumb { + transform: scaleY(1.55); +} +.range:focus-visible::-moz-range-thumb { + box-shadow: + 0 0 0 4px color-mix(in srgb, var(--accent) 28%, transparent), + 0 1px 6px color-mix(in srgb, var(--accent) 55%, transparent); +} diff --git a/src/components/ui/range.tsx b/src/components/ui/range.tsx index 3597943..e0f1ae0 100644 --- a/src/components/ui/range.tsx +++ b/src/components/ui/range.tsx @@ -1,5 +1,6 @@ import type { CSSProperties, InputHTMLAttributes } from 'react'; import { cn } from './cn'; +import styles from './range.module.css'; type RangeStyle = CSSProperties & { '--range-progress'?: string; @@ -55,5 +56,5 @@ export function RangeInput({ : {}), }; - return ; + return ; }