From 52ec0ad9b46f37a63aca11cb23c396a65dd74e39 Mon Sep 17 00:00:00 2001 From: Richard R Date: Wed, 3 Jun 2026 00:15:25 -0600 Subject: [PATCH] refactor(ui): modularize PDF loader and range slider visuals Move PDF layout scan visualization and range slider styles into dedicated CSS modules, isolating their styles from the global scope. Integrate PdfLayoutScan component into the PDF viewer loader UI for animated parse progress. Refactor progress bars to use a reusable progress-fill class with animated sheen effect. Update range input to use CSS module for precision gauge styling. --- src/app/(app)/pdf/[id]/page.tsx | 93 +++++--- src/app/globals.css | 131 ++---------- src/components/ProgressCard.tsx | 2 +- .../documents/DexieMigrationModal.tsx | 2 +- .../reader/PdfLayoutScan.module.css | 202 ++++++++++++++++++ src/components/reader/PdfLayoutScan.tsx | 144 +++++++++++++ src/components/ui/range.module.css | 115 ++++++++++ src/components/ui/range.tsx | 3 +- 8 files changed, 554 insertions(+), 138 deletions(-) create mode 100644 src/components/reader/PdfLayoutScan.module.css create mode 100644 src/components/reader/PdfLayoutScan.tsx create mode 100644 src/components/ui/range.module.css 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 ; }