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:
Richard R 2026-06-03 02:36:54 -06:00
parent 21a8a71b03
commit 8913e5b592
3 changed files with 86 additions and 25 deletions

View file

@ -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 */}

View file

@ -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;

View file

@ -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>