-
- {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({
)}
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 ;
}