feat(ui): add parse failure state to PDF layout scan animation
Display a distinct "parse halted" visual state in the PDF layout scan component and PDF viewer page when parsing fails. The loader animation is replaced by a static, dimmed page with an alert glyph and updated styling, ensuring users are not misled by an active animation after a failure. CSS and component logic updated to support the new state.
This commit is contained in:
parent
21a8a71b03
commit
8913e5b592
3 changed files with 86 additions and 25 deletions
|
|
@ -326,7 +326,13 @@ export default function PDFViewerPage() {
|
|||
{/* header: status badge + model attribution */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="inline-flex items-center gap-2 rounded-md border border-line bg-surface-solid px-2.5 py-1">
|
||||
<LoadingSpinner className="h-3.5 w-3.5 text-accent" />
|
||||
{parseUiState === 'failed' ? (
|
||||
<svg className="h-3.5 w-3.5 text-accent" fill="none" stroke="currentColor" strokeWidth="1.8" viewBox="0 0 24 24" aria-hidden>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v4m0 4h.01M10.3 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.7 3.86a2 2 0 0 0-3.42 0Z" />
|
||||
</svg>
|
||||
) : (
|
||||
<LoadingSpinner className="h-3.5 w-3.5 text-accent" />
|
||||
)}
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.08em] text-soft">PDF Layout Parse</span>
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-1.5 rounded-md border border-accent-line bg-accent-wash px-2 py-1">
|
||||
|
|
@ -336,33 +342,36 @@ export default function PDFViewerPage() {
|
|||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-col gap-3">
|
||||
{/* animated layout scanner */}
|
||||
{/* animated layout scanner — static "halted" view when failed */}
|
||||
<div className="mx-auto w-full max-w-[15rem]">
|
||||
<PdfLayoutScan />
|
||||
<PdfLayoutScan failed={parseUiState === 'failed'} />
|
||||
</div>
|
||||
|
||||
{/* live status + progress */}
|
||||
<div className="min-w-0">
|
||||
{parseUiState === 'failed' ? (
|
||||
<p className="mb-3 text-sm font-semibold text-foreground">{statusText}</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-end justify-between gap-2">
|
||||
<p className="text-[11px] font-semibold text-foreground tabular-nums">
|
||||
{hasMeasuredProgress ? `Page ${pagesParsed} / ${totalPages}` : 'Awaiting first page'}
|
||||
{parseUiState === 'failed' ? (
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-foreground">{statusText}</p>
|
||||
<p className="mt-1 text-[11px] font-medium uppercase tracking-[0.06em] text-soft">{stageLabel}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-end justify-between gap-2">
|
||||
<p className="text-[11px] font-semibold text-foreground tabular-nums">
|
||||
{hasMeasuredProgress ? `Page ${pagesParsed} / ${totalPages}` : 'Awaiting first page'}
|
||||
</p>
|
||||
<p className="text-[10px] font-medium uppercase tracking-[0.06em] text-soft">{stageLabel}</p>
|
||||
</div>
|
||||
<div className="mt-1.5 h-2 w-full overflow-hidden rounded-full bg-surface-solid ring-1 ring-line">
|
||||
<div
|
||||
className="progress-fill h-full rounded-full bg-accent transition-[width] duration-slow ease-standard"
|
||||
style={{ width: `${hasMeasuredProgress ? progressPercent : 6}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1.5 text-[10px] tabular-nums text-soft">
|
||||
{hasMeasuredProgress ? `${Math.round(progressPercent)}% complete` : 'Calibrating layout pass'}
|
||||
</p>
|
||||
<p className="text-[10px] font-medium uppercase tracking-[0.06em] text-soft">{stageLabel}</p>
|
||||
</div>
|
||||
<div className="mt-1.5 h-2 w-full overflow-hidden rounded-full bg-surface-solid ring-1 ring-line">
|
||||
<div
|
||||
className="progress-fill h-full rounded-full bg-accent transition-[width] duration-slow ease-standard"
|
||||
style={{ width: `${hasMeasuredProgress ? progressPercent : 6}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1.5 text-[10px] tabular-nums text-soft">
|
||||
{hasMeasuredProgress ? `${Math.round(progressPercent)}% complete` : 'Calibrating layout pass'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* attribution footer */}
|
||||
|
|
|
|||
|
|
@ -58,6 +58,28 @@
|
|||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
/* ── failed / halted state ────────────────────────────────────────────── */
|
||||
.tagFailed {
|
||||
color: var(--foreground);
|
||||
background: color-mix(in srgb, var(--foreground) 12%, transparent);
|
||||
box-shadow: none;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.pageFailed {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
opacity: 0.55;
|
||||
filter: grayscale(0.4);
|
||||
}
|
||||
.alert {
|
||||
width: 38%;
|
||||
height: 38%;
|
||||
stroke: var(--accent);
|
||||
stroke-width: 1.6;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── region boxes (one shown at a time, centered) ─────────────────────── */
|
||||
.solos {
|
||||
position: absolute;
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ import styles from './PdfLayoutScan.module.css';
|
|||
* (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.
|
||||
*
|
||||
* When `failed` is set the animation is replaced by a static "halted" view —
|
||||
* the beam stops, the page dims, and an alert glyph sits on it — so the loader
|
||||
* never implies active work after a parse failure.
|
||||
*/
|
||||
|
||||
// Only a handful of real shapes — most regions are just text lines.
|
||||
|
|
@ -92,18 +96,44 @@ function BlockContent({ shape }: { shape: BlockShape }) {
|
|||
);
|
||||
}
|
||||
|
||||
export function PdfLayoutScan() {
|
||||
export function PdfLayoutScan({ failed = false }: { failed?: boolean }) {
|
||||
const [active, setActive] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (typeof window === 'undefined' || failed) return; // no cycling once halted
|
||||
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);
|
||||
}, []);
|
||||
}, [failed]);
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<div className={styles.stage} aria-hidden>
|
||||
<div className={styles.tagRow}>
|
||||
<span className={cx(styles.tag, styles.tagFailed)}>Parse halted</span>
|
||||
</div>
|
||||
|
||||
<div className={cx(styles.page, styles.pageFailed)}>
|
||||
<svg className={styles.alert} viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path
|
||||
d="M12 3.5 21 19H3L12 3.5Z"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path d="M12 10v4" strokeLinecap="round" />
|
||||
<circle cx="12" cy="16.5" r="0.6" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
|
||||
<span className={cx(styles.corner, styles.cornerTl)} />
|
||||
<span className={cx(styles.corner, styles.cornerTr)} />
|
||||
<span className={cx(styles.corner, styles.cornerBl)} />
|
||||
<span className={cx(styles.corner, styles.cornerBr)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.stage} aria-hidden>
|
||||
|
|
|
|||
Loading…
Reference in a new issue