feat(analysis): admin coverage overview + per-page result inspector UI

Reworks /admin/analysis from a blind enqueue form into a coverage browser:

- Loads a paginated overview of mangas with a CoverageBadge
  (Full/Partial/None, analyzed/total) per row; debounced search filters it.
- Drill manga → chapters (coverage badge each) → page grid (chips colored
  done/queued/failed/none) with a legend.
- Click a page chip → Modal showing the full result: status, model, time,
  NSFW + content-warning chips, scene description, tags, and OCR lines
  (labeled by kind). Unanalyzed/failed pages get a "Queue this page"
  action (force re-analyze).
- Keeps "Queue all" + per-manga/chapter enqueue and the include-analyzed
  toggle; enqueue refreshes the open chapter's grid so queued pages show.

API client: getAnalysisMangaCoverage / ChapterCoverage / ChapterPages /
PageDetail + types. New CoverageBadge component.

Tests: vitest for the four clients; Playwright for overview+badges+queue-all,
drill+inspect, and queue-from-detail. svelte-check + build clean; 266 vitest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-13 20:39:54 +02:00
parent 824f5acf22
commit 6bb72b8775
8 changed files with 902 additions and 269 deletions

View File

@@ -1,78 +1,174 @@
<script lang="ts">
import { onMount } from 'svelte';
import { ApiError } from '$lib/api/client';
import {
reenqueueAnalysis,
listAdminMangas,
listAdminChapters,
type AdminMangaRow,
type AdminChapterRow,
analyzePage,
getAnalysisMangaCoverage,
getAnalysisChapterCoverage,
getAnalysisChapterPages,
getAnalysisPageDetail,
type MangaCoverage,
type ChapterCoverage,
type PageStatusItem,
type PageAnalysisDetail,
type ReenqueueAnalysisOptions
} from '$lib/api/admin';
import { chapterLabel } from '$lib/api/chapters';
import CoverageBadge from '$lib/components/CoverageBadge.svelte';
import Modal from '$lib/components/Modal.svelte';
// One global toggle applied to whichever enqueue action runs.
const LIMIT = 25;
// Global enqueue toggle.
let includeAnalyzed = $state(false);
// Manga search.
// Coverage overview / search.
let query = $state('');
let mangas = $state<AdminMangaRow[]>([]);
let searching = $state(false);
let searched = $state(false);
let searchTimer: ReturnType<typeof setTimeout> | null = null;
let mangas = $state<MangaCoverage[]>([]);
let total = $state(0);
let offset = $state(0);
let loading = $state(true);
let loadingMore = $state(false);
// Per-manga chapter expansion: id → loading | rows.
let expandedId = $state<string | null>(null);
let chapters = $state<Record<string, 'loading' | AdminChapterRow[]>>({});
// Drill-down state.
let expandedManga = $state<string | null>(null);
let chapters = $state<Record<string, 'loading' | ChapterCoverage[]>>({});
let expandedChapter = $state<string | null>(null);
let pages = $state<Record<string, 'loading' | PageStatusItem[]>>({});
// In-flight action key ('library' | `manga:<id>` | `chapter:<id>`).
// Page-detail modal.
let detailOpen = $state(false);
let detail = $state<PageAnalysisDetail | null>(null);
let detailLoading = $state(false);
let detailError = $state<string | null>(null);
let detailQueueBusy = $state(false);
// Enqueue feedback.
let busyKey = $state<string | null>(null);
let notice = $state<string | null>(null);
let error = $state<string | null>(null);
onMount(() => {
void loadCoverage(true);
});
function onSearchInput() {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(runSearch, 300);
searchTimer = setTimeout(() => loadCoverage(true), 300);
}
async function runSearch() {
const q = query.trim();
if (!q) {
mangas = [];
searched = false;
return;
async function loadCoverage(reset: boolean) {
if (reset) {
offset = 0;
loading = true;
// Collapse open rows so stale chapter/page state doesn't linger.
expandedManga = null;
expandedChapter = null;
} else {
loadingMore = true;
}
searching = true;
error = null;
try {
const r = await listAdminMangas({ search: q, limit: 20 });
mangas = r.items;
searched = true;
const r = await getAnalysisMangaCoverage({
search: query.trim() || undefined,
limit: LIMIT,
offset
});
mangas = reset ? r.items : [...mangas, ...r.items];
total = r.page.total ?? mangas.length;
} catch (e) {
error = e instanceof ApiError ? e.message : 'Search failed.';
error = e instanceof ApiError ? e.message : 'Failed to load coverage.';
} finally {
searching = false;
loading = false;
loadingMore = false;
}
}
async function toggleChapters(mangaId: string) {
if (expandedId === mangaId) {
expandedId = null;
async function loadMore() {
offset += LIMIT;
await loadCoverage(false);
}
async function toggleManga(id: string) {
if (expandedManga === id) {
expandedManga = null;
return;
}
expandedId = mangaId;
if (!chapters[mangaId]) {
chapters[mangaId] = 'loading';
expandedManga = id;
expandedChapter = null;
if (!chapters[id]) {
chapters[id] = 'loading';
try {
const r = await listAdminChapters(mangaId, { limit: 500 });
chapters[mangaId] = r.items;
chapters[id] = await getAnalysisChapterCoverage(id);
} catch (e) {
delete chapters[mangaId];
expandedId = null;
delete chapters[id];
expandedManga = null;
error = e instanceof ApiError ? e.message : 'Failed to load chapters.';
}
}
}
async function toggleChapter(id: string) {
if (expandedChapter === id) {
expandedChapter = null;
return;
}
expandedChapter = id;
await loadPages(id);
}
async function loadPages(id: string) {
pages[id] = 'loading';
try {
pages[id] = await getAnalysisChapterPages(id);
} catch (e) {
delete pages[id];
error = e instanceof ApiError ? e.message : 'Failed to load pages.';
}
}
async function openDetail(pageId: string) {
detailOpen = true;
detailLoading = true;
detailError = null;
detail = null;
try {
detail = await getAnalysisPageDetail(pageId);
} catch (e) {
detailError = e instanceof ApiError ? e.message : 'Failed to load page.';
} finally {
detailLoading = false;
}
}
function closeDetail() {
detailOpen = false;
}
async function queueFromDetail() {
if (!detail || detailQueueBusy) return;
detailQueueBusy = true;
detailError = null;
try {
await analyzePage(detail.page_id);
// Reflect the new queued state in the grid + modal.
if (expandedChapter) await loadPages(expandedChapter);
notice = `Queued page ${detail.page_number} for analysis.`;
closeDetail();
} catch (e) {
detailError =
e instanceof ApiError && e.status === 503
? 'Analysis worker is disabled (ANALYSIS_ENABLED=false).'
: e instanceof ApiError
? e.message
: 'Failed to queue page.';
} finally {
detailQueueBusy = false;
}
}
async function enqueue(
key: string,
scope: Omit<ReenqueueAnalysisOptions, 'onlyUnanalyzed'>,
@@ -88,6 +184,8 @@
onlyUnanalyzed: !includeAnalyzed
});
notice = `Enqueued ${r.enqueued} page${r.enqueued === 1 ? '' : 's'} for analysis (${label}).`;
// Refresh the open chapter's page grid so queued pages show.
if (expandedChapter) await loadPages(expandedChapter);
} catch (e) {
if (e instanceof ApiError) {
error =
@@ -103,14 +201,19 @@
}
const enqueueLibrary = () => enqueue('library', {}, 'whole library');
const enqueueManga = (m: AdminMangaRow) =>
enqueue(`manga:${m.id}`, { mangaId: m.id }, m.title);
const enqueueChapter = (m: AdminMangaRow, c: AdminChapterRow) =>
const enqueueManga = (m: MangaCoverage) =>
enqueue(`manga:${m.manga_id}`, { mangaId: m.manga_id }, m.title);
const enqueueChapter = (m: MangaCoverage, c: ChapterCoverage) =>
enqueue(
`chapter:${c.id}`,
{ chapterId: c.id },
`chapter:${c.chapter_id}`,
{ chapterId: c.chapter_id },
`${m.title} · ${chapterLabel({ number: c.number, title: c.title })}`
);
function fmtDate(iso: string | null): string {
if (!iso) return '';
return new Date(iso).toLocaleString();
}
</script>
<svelte:head>
@@ -119,11 +222,12 @@
<h1 class="heading">Content analysis</h1>
<p class="lede">
Queue page images for the AI analysis worker (OCR, auto-tags, scene
description, NSFW moderation).
Coverage of the AI analysis worker (OCR, auto-tags, scene description,
NSFW moderation). Browse what's analyzed, queue more, and inspect any
page's result.
</p>
<label class="checkbox" data-testid="admin-analysis-include-analyzed-wrap">
<label class="checkbox">
<input
type="checkbox"
bind:checked={includeAnalyzed}
@@ -133,127 +237,173 @@
Include already-analyzed pages
<span class="muted">
— re-runs analysis on pages that already have results (default
off: only un-analyzed pages are queued). Applies to every action
below.
off). Applies to every "Queue" action.
</span>
</span>
</label>
<section class="panel" aria-label="Whole library">
<div class="row">
<div class="row-main">
<span class="row-title">Whole library</span>
<span class="row-sub">Every page of every chapter of every manga.</span>
</div>
<button
type="button"
disabled={busyKey !== null}
onclick={enqueueLibrary}
data-testid="admin-analysis-enqueue-library"
>
{busyKey === 'library' ? 'Queueing…' : 'Queue all'}
</button>
</div>
</section>
<div class="row library-row">
<span class="row-title">Whole library</span>
<button
type="button"
disabled={busyKey !== null}
onclick={enqueueLibrary}
data-testid="admin-analysis-enqueue-library"
>
{busyKey === 'library' ? 'Queueing…' : 'Queue all'}
</button>
</div>
<section class="panel" aria-label="Search a manga">
<label class="field">
<span>Search a manga to queue it or one of its chapters</span>
<input
type="text"
bind:value={query}
oninput={onSearchInput}
placeholder="Title or author…"
data-testid="admin-analysis-search"
/>
</label>
<label class="field">
<span>Search</span>
<input
type="text"
bind:value={query}
oninput={onSearchInput}
placeholder="Filter mangas by title…"
data-testid="admin-analysis-search"
/>
</label>
{#if searching}
<p class="muted" data-testid="admin-analysis-searching">Searching</p>
{:else if searched && mangas.length === 0}
<p class="muted" data-testid="admin-analysis-no-results">
No mangas match "{query.trim()}".
</p>
{/if}
{#if loading}
<p class="muted" data-testid="admin-analysis-loading">Loading coverage</p>
{:else if mangas.length === 0}
<p class="muted" data-testid="admin-analysis-empty">
No mangas with pages{query.trim() ? ` match "${query.trim()}"` : ''}.
</p>
{:else}
<ul class="results" data-testid="admin-analysis-results">
{#each mangas as m (m.manga_id)}
<li class="manga" data-testid={`admin-analysis-manga-${m.manga_id}`}>
<div class="row">
<button
type="button"
class="disclosure"
aria-expanded={expandedManga === m.manga_id}
onclick={() => toggleManga(m.manga_id)}
data-testid={`admin-analysis-expand-${m.manga_id}`}
>
<span class="caret" class:open={expandedManga === m.manga_id}></span>
<span class="row-main">
<span class="row-title">{m.title}</span>
</span>
</button>
<CoverageBadge
analyzed={m.analyzed_pages}
total={m.total_pages}
testid={`admin-analysis-coverage-manga-${m.manga_id}`}
/>
<button
type="button"
disabled={busyKey !== null}
onclick={() => enqueueManga(m)}
data-testid={`admin-analysis-enqueue-manga-${m.manga_id}`}
>
{busyKey === `manga:${m.manga_id}` ? 'Queueing…' : 'Queue manga'}
</button>
</div>
{#if mangas.length > 0}
<ul class="results" data-testid="admin-analysis-results">
{#each mangas as m (m.id)}
<li class="manga" data-testid={`admin-analysis-manga-${m.id}`}>
<div class="row">
<button
type="button"
class="disclosure"
aria-expanded={expandedId === m.id}
onclick={() => toggleChapters(m.id)}
data-testid={`admin-analysis-expand-${m.id}`}
>
<span class="caret" class:open={expandedId === m.id}>▸</span>
<span class="row-main">
<span class="row-title">{m.title}</span>
<span class="row-sub">
{m.chapter_count} chapter{m.chapter_count === 1
? ''
: 's'}
</span>
</span>
</button>
<button
type="button"
disabled={busyKey !== null}
onclick={() => enqueueManga(m)}
data-testid={`admin-analysis-enqueue-manga-${m.id}`}
>
{busyKey === `manga:${m.id}` ? 'Queueing…' : 'Queue manga'}
</button>
</div>
{#if expandedId === m.id}
{#if chapters[m.id] === 'loading'}
<p class="muted chapters-loading">Loading chapters…</p>
{:else if Array.isArray(chapters[m.id])}
{@const rows = chapters[m.id] as AdminChapterRow[]}
{#if rows.length === 0}
<p class="muted chapters-loading">No chapters.</p>
{:else}
<ul class="chapters">
{#each rows as c (c.id)}
<li
class="chapter"
data-testid={`admin-analysis-chapter-${c.id}`}
{#if expandedManga === m.manga_id}
{#if chapters[m.manga_id] === 'loading'}
<p class="muted indent">Loading chapters…</p>
{:else if Array.isArray(chapters[m.manga_id])}
{@const chs = chapters[m.manga_id] as ChapterCoverage[]}
<ul class="chapters">
{#each chs as c (c.chapter_id)}
<li
class="chapter"
data-testid={`admin-analysis-chapter-${c.chapter_id}`}
>
<div class="row">
<button
type="button"
class="disclosure"
aria-expanded={expandedChapter === c.chapter_id}
onclick={() => toggleChapter(c.chapter_id)}
data-testid={`admin-analysis-expand-chapter-${c.chapter_id}`}
>
<span class="chapter-label">
{chapterLabel({
number: c.number,
title: c.title
})}
<span class="row-sub">
{c.page_count} page{c.page_count === 1
? ''
: 's'}
<span
class="caret"
class:open={expandedChapter === c.chapter_id}
>▸</span
>
<span class="row-main">
<span class="chapter-label">
{chapterLabel({
number: c.number,
title: c.title
})}
</span>
</span>
<button
type="button"
disabled={busyKey !== null}
onclick={() => enqueueChapter(m, c)}
data-testid={`admin-analysis-enqueue-chapter-${c.id}`}
</button>
<CoverageBadge
analyzed={c.analyzed_pages}
total={c.total_pages}
testid={`admin-analysis-coverage-chapter-${c.chapter_id}`}
/>
<button
type="button"
disabled={busyKey !== null}
onclick={() => enqueueChapter(m, c)}
data-testid={`admin-analysis-enqueue-chapter-${c.chapter_id}`}
>
{busyKey === `chapter:${c.chapter_id}`
? 'Queueing…'
: 'Queue chapter'}
</button>
</div>
{#if expandedChapter === c.chapter_id}
{#if pages[c.chapter_id] === 'loading'}
<p class="muted indent">Loading pages…</p>
{:else if Array.isArray(pages[c.chapter_id])}
{@const ps = pages[c.chapter_id] as PageStatusItem[]}
<div
class="page-grid"
data-testid={`admin-analysis-pages-${c.chapter_id}`}
>
{busyKey === `chapter:${c.id}`
? 'Queueing…'
: 'Queue chapter'}
</button>
</li>
{/each}
</ul>
{/if}
{/if}
{#each ps as p (p.page_id)}
<button
type="button"
class="page-chip {p.status}"
title={`Page ${p.page_number} ${p.status}`}
onclick={() => openDetail(p.page_id)}
data-testid={`admin-analysis-page-${p.page_id}`}
data-status={p.status}
>
{p.page_number}
</button>
{/each}
</div>
<p class="legend">
<span class="dot done"></span> analyzed
<span class="dot queued"></span> queued
<span class="dot failed"></span> failed
<span class="dot none"></span> not analyzed
</p>
{/if}
{/if}
</li>
{/each}
</ul>
{/if}
</li>
{/each}
</ul>
{/if}
</li>
{/each}
</ul>
{#if mangas.length < total}
<button
type="button"
class="load-more"
disabled={loadingMore}
onclick={loadMore}
data-testid="admin-analysis-load-more"
>
{loadingMore ? 'Loading…' : `Load more (${mangas.length}/${total})`}
</button>
{/if}
</section>
{/if}
{#if notice}
<p class="notice" role="status" data-testid="admin-analysis-notice">{notice}</p>
@@ -262,6 +412,89 @@
<p class="error" role="alert" data-testid="admin-analysis-error">{error}</p>
{/if}
<Modal
open={detailOpen}
title={detail ? `Page ${detail.page_number}` : 'Page analysis'}
onClose={closeDetail}
size="lg"
closeOnBackdrop
testid="admin-analysis-detail"
>
{#if detailLoading}
<p class="muted">Loading</p>
{:else if detailError}
<p class="error" role="alert">{detailError}</p>
{:else if detail}
<div class="detail-meta">
<span class="status-pill {detail.status}" data-testid="admin-analysis-detail-status">
{detail.status === 'done'
? 'Analyzed'
: detail.status === 'failed'
? 'Failed'
: 'Not analyzed'}
</span>
{#if detail.model}<span class="muted">model {detail.model}</span>{/if}
{#if detail.analyzed_at}<span class="muted">{fmtDate(detail.analyzed_at)}</span>{/if}
</div>
{#if detail.status === 'none'}
<p class="muted">This page hasn't been analyzed yet.</p>
{:else if detail.status === 'failed'}
<p class="error">{detail.error ?? 'Analysis failed.'}</p>
{/if}
{#if detail.is_nsfw || detail.content_warnings.length > 0}
<div class="warnings" data-testid="admin-analysis-detail-warnings">
<span class="cw-label"> NSFW</span>
{#each detail.content_warnings as w (w)}
<span class="cw-chip">{w}</span>
{/each}
</div>
{/if}
{#if detail.scene_description}
<section class="block">
<h3>Scene</h3>
<p>{detail.scene_description}</p>
</section>
{/if}
{#if detail.tags.length > 0}
<section class="block">
<h3>Tags</h3>
<div class="tag-row">
{#each detail.tags as t (t)}<span class="tag">{t}</span>{/each}
</div>
</section>
{/if}
{#if detail.ocr.length > 0}
<section class="block">
<h3>OCR text</h3>
<ul class="ocr">
{#each detail.ocr as line, i (i)}
<li><span class="ocr-kind">{line.kind}</span> {line.text}</li>
{/each}
</ul>
</section>
{/if}
{/if}
{#snippet footer()}
{#if detail && detail.status !== 'done'}
<button
type="button"
disabled={detailQueueBusy}
onclick={queueFromDetail}
data-testid="admin-analysis-detail-queue"
>
{detailQueueBusy ? 'Queueing…' : 'Queue this page'}
</button>
{/if}
<button type="button" onclick={closeDetail}>Close</button>
{/snippet}
</Modal>
<style>
.heading {
margin-bottom: var(--space-2);
@@ -269,7 +502,7 @@
.lede {
color: var(--text-muted);
margin-bottom: var(--space-4);
max-width: 42rem;
max-width: 44rem;
}
.checkbox {
display: grid;
@@ -277,24 +510,28 @@
gap: var(--space-2);
align-items: start;
font-size: var(--font-sm);
margin-bottom: var(--space-4);
max-width: 42rem;
margin-bottom: var(--space-3);
max-width: 44rem;
}
.muted {
color: var(--text-muted);
}
.panel {
max-width: 46rem;
padding: var(--space-3) var(--space-4);
.indent {
margin-left: var(--space-5);
}
.library-row {
max-width: 44rem;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-lg, 12px);
background: var(--surface);
margin-bottom: var(--space-4);
border-radius: var(--radius-md);
margin-bottom: var(--space-3);
}
.field {
display: flex;
flex-direction: column;
gap: var(--space-1);
margin-bottom: var(--space-3);
max-width: 44rem;
}
.field > span {
color: var(--text-muted);
@@ -303,7 +540,7 @@
.row {
display: flex;
align-items: center;
gap: var(--space-3);
gap: var(--space-2);
}
.row-main {
display: flex;
@@ -312,22 +549,18 @@
flex: 1;
text-align: left;
}
.row-title {
.row-title,
.chapter-label {
font-weight: var(--weight-semibold);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-sub {
color: var(--text-muted);
font-size: var(--font-sm);
}
.results {
list-style: none;
margin: var(--space-3) 0 0;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
max-width: 44rem;
}
.manga {
border-top: 1px solid var(--border);
@@ -360,26 +593,160 @@
flex-direction: column;
gap: var(--space-1);
}
.chapter {
.page-grid {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin: var(--space-2) 0 0 var(--space-5);
}
.page-chip {
min-width: 28px;
height: 28px;
padding: 0 4px;
font-size: var(--font-xs);
border-radius: var(--radius-sm);
border: 1px solid var(--border);
cursor: pointer;
background: var(--surface-elevated);
color: var(--text-muted);
}
.page-chip.done {
background: color-mix(in srgb, #2e7d32 18%, transparent);
border-color: color-mix(in srgb, #2e7d32 45%, transparent);
color: #2e7d32;
}
.page-chip.queued {
background: color-mix(in srgb, #2563eb 18%, transparent);
border-color: color-mix(in srgb, #2563eb 45%, transparent);
color: #2563eb;
}
.page-chip.failed {
background: color-mix(in srgb, var(--danger) 18%, transparent);
border-color: color-mix(in srgb, var(--danger) 45%, transparent);
color: var(--danger);
}
.legend {
display: flex;
align-items: center;
gap: var(--space-3);
gap: var(--space-2);
margin: var(--space-2) 0 0 var(--space-5);
font-size: var(--font-xs);
color: var(--text-muted);
}
.chapter-label {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
.dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 2px;
margin-right: 2px;
vertical-align: middle;
}
.chapters-loading {
margin: var(--space-2) 0 0 var(--space-4);
.dot.done {
background: #2e7d32;
}
.dot.queued {
background: #2563eb;
}
.dot.failed {
background: var(--danger);
}
.dot.none {
background: var(--surface-elevated);
border: 1px solid var(--border);
}
.load-more {
margin-top: var(--space-3);
}
.notice {
color: var(--success, #2e7d32);
max-width: 46rem;
max-width: 44rem;
}
.error {
color: var(--danger);
max-width: 46rem;
max-width: 44rem;
}
/* Detail modal */
.detail-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
font-size: var(--font-sm);
margin-bottom: var(--space-3);
}
.status-pill {
font-weight: var(--weight-semibold);
font-size: var(--font-xs);
padding: 1px var(--space-2);
border-radius: var(--radius-pill);
background: var(--surface-elevated);
color: var(--text-muted);
}
.status-pill.done {
background: color-mix(in srgb, #2e7d32 16%, transparent);
color: #2e7d32;
}
.status-pill.failed {
background: color-mix(in srgb, var(--danger) 16%, transparent);
color: var(--danger);
}
.warnings {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
background: color-mix(in srgb, #d4762a 12%, transparent);
margin-bottom: var(--space-3);
}
.cw-label {
font-weight: 600;
color: #b85e1a;
font-size: var(--font-sm);
}
.cw-chip {
font-size: var(--font-sm);
padding: 1px var(--space-2);
border-radius: var(--radius-sm);
background: color-mix(in srgb, #d4762a 22%, transparent);
text-transform: capitalize;
}
.block {
margin-bottom: var(--space-3);
}
.block h3 {
font-size: var(--font-sm);
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
margin: 0 0 var(--space-1);
}
.tag-row {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
}
.tag {
font-size: var(--font-sm);
padding: 1px var(--space-2);
border-radius: var(--radius-pill);
background: var(--surface-elevated);
}
.ocr {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.ocr-kind {
display: inline-block;
min-width: 5.5rem;
font-size: var(--font-xs);
color: var(--text-muted);
text-transform: uppercase;
}
</style>