@@ -336,33 +342,36 @@ export default function PDFViewerPage() {
- {/* animated layout scanner */}
+ {/* animated layout scanner — static "halted" view when failed */}
{/* live status + progress */}
-
- {parseUiState === 'failed' ? (
-
{statusText}
- ) : null}
-
-
-
- {hasMeasuredProgress ? `Page ${pagesParsed} / ${totalPages}` : 'Awaiting first page'}
+ {parseUiState === 'failed' ? (
+
+
{statusText}
+
{stageLabel}
+
+ ) : (
+
+
+
+ {hasMeasuredProgress ? `Page ${pagesParsed} / ${totalPages}` : 'Awaiting first page'}
+
+
{stageLabel}
+
+
+
+ {hasMeasuredProgress ? `${Math.round(progressPercent)}% complete` : 'Calibrating layout pass'}
-
{stageLabel}
-
-
- {hasMeasuredProgress ? `${Math.round(progressPercent)}% complete` : 'Calibrating layout pass'}
-
-
+ )}
{/* attribution footer */}
diff --git a/src/components/reader/PdfLayoutScan.module.css b/src/components/reader/PdfLayoutScan.module.css
index 5b46a19..0c44b79 100644
--- a/src/components/reader/PdfLayoutScan.module.css
+++ b/src/components/reader/PdfLayoutScan.module.css
@@ -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;
diff --git a/src/components/reader/PdfLayoutScan.tsx b/src/components/reader/PdfLayoutScan.tsx
index 9314065..7f46d00 100644
--- a/src/components/reader/PdfLayoutScan.tsx
+++ b/src/components/reader/PdfLayoutScan.tsx
@@ -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 (
+
+ );
+ }
return (