VizWiz validation
4,319 rows · five columns linking image filename to question and crowd answer.
design system · v1.0
A warm-paper, ink-and-signal-red type system for tools that read like a research notebook. Fraunces variable serif for the prose; IBM Plex Mono for the data; one accent color used sparingly. Every component lives next to its source markup so you can lift what you need.
Two files do all the work: the stylesheet and the Google Fonts link. Drop these into any Flask/Jinja app and the rest of this guide just works.
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="stylesheet" href="/static/saturn.css">
Source: saturn/viewer/static/saturn.css · ~1300 lines including notebook cells, chips, drop zone, dark mode, and prefers-reduced-motion. Vendor it.
CSS variables live on :root. The @media (prefers-color-scheme: dark) block re-binds the same names. Ten tokens cover the whole system.
:root {
--paper: #F4F1EA;
--paper-raise: #EFEBE1;
--ink: #161616;
--ink-soft: #3F3F3F;
--muted: #6B6863;
--hot: #CE3526;
--hot-wash: rgba(206, 53, 38, 0.08);
--cool: #2F5D8F;
--warn: #B26A00;
--good: #2F6C3F;
--rule: rgba(22, 22, 22, 0.14);
--rule-strong: rgba(22, 22, 22, 0.32);
}
Fraunces is a variable serif with optical-size, weight, soft, and wonky axes. We push opsz to 144 for headlines, drop to 36 for small headings; WONK is set to 1 only on hot/italic display moments. IBM Plex Mono with tabular-nums for every number that needs to align.
DISPLAY · opsz 144 · wght 400 · SOFT 100 · WONK 1
Saturn dissects.
SECTION HEADING · opsz 72 · wght 650 · SOFT 30
Most divergent columns
PROSE · 16px / 1.55 · oldstyle nums
The corpus is dominated by English alt-text from a curated set of 489 high-volume accounts; firehose entries (125,645 rows) are anonymised and lean longer (mean 281 chars vs 202 in curated). Worth a closer look at the +79 chars discrepancy.
DATA / MONO · IBM Plex Mono · tabular nums
404,841 rows · skew=+3.62 · null=12.4% · jaccard=0.35
EYEBROW LABEL · IBM Plex Mono · 0.72rem · 0.14em letter-spacing · uppercase
FEATURED CHARTS · OPT-IN
Three chip families: kind (neutral monochrome), role (cool blue), alert (info / warn / error with a colored dot). Use sparingly: one or two per surface.
<span class="chip">numeric</span> <span class="chip chip-role">identifier</span> <span class="alert alert-info">multilingual</span> <span class="alert alert-warn">duplicates</span> <span class="alert alert-error">outliers</span> <span class="stamp">profile</span> <span class="stamp stamp--insight">reading</span> <span class="stamp stamp--compare">compare</span> <span class="stamp stamp--notes">notes</span> <span class="verdict verdict-agree">agree</span> <span class="verdict verdict-partial">partial</span> <span class="verdict verdict-disagree">disagree</span>
Two visual weights. Ink-on-paper for primary actions; ghost (transparent + thin border) for secondary. Hover swaps the fill to the hot accent.
<button class="btn">Analyze</button> <button class="btn btn-ghost">Open</button> <a class="btn btn-ghost btn-small" href="#">Generate summary</a>
Mono-labelled, paper-on-paper inputs. Optional sections collapse into <details> with a + / − marker; useful for keys, advanced controls, anything that's "nice but most users skip".
<div class="field"> <label for="repo">Repo id</label> <input type="text" id="repo" placeholder="user/dataset"> </div> <details class="byok"> <summary>API key (used once, not stored)</summary> <label for="llm">Provider</label> <select id="llm">...</select> <label for="key">API key</label> <input type="password" id="key" autocomplete="off"> </details> <label class="checkbox"> <input type="checkbox"> <span>stats only, skip summary</span> </label>
Four cell types (markdown, code, output, figure) share a 70px gutter for execution counters. Cells are <article class="cell cell-..."> with .gutter + .cell-body. The Jupyter feeling without the Jupyter weight.
4,319 rows · five columns linking image filename to question and crowd answer.
import json; findings = json.load(open('vizwiz.json'))
findings.meta.row_count
| rows | 4,319 |
| cols | 5 |
<article class="cell cell-md">
<div class="gutter"></div>
<div class="cell-body">
<h3>VizWiz validation</h3>
<p>4,319 rows · five columns…</p>
</div>
</article>
<article class="cell cell-code">
<div class="gutter">[1]:</div>
<pre class="cell-body"><code>import json…</code></pre>
</article>
<article class="cell cell-output">
<div class="gutter">Out[1]:</div>
<div class="cell-body">…</div>
</article>
<article class="cell cell-figure">
<div class="gutter">Fig 1.</div>
<div class="cell-body">
<figure>…<figcaption>…</figcaption></figure>
</div>
</article>
The hero block on every finding. Hot-red left border anchors it; oldstyle-num prose flows up to the 62ch measure; mono cite line beneath. Drop a critiques sub-block when peer-reviewed, an evidence_keys line for citation discipline.
This is the VizWiz validation set. The question column is where character lives: only 2,798 unique values with a 35% duplicate rate, dominated by generic prompts like "What is this?" (523 occurrences). Worth a closer look first: the answer_type column is 62% "other", and answers stores serialized Python dicts.
citing: question.duplicate_rate · answer_type.top_rate · answers.top_words
<div class="summary-card">
<div class="summary-meta">
<span>dataset summary · high confidence</span>
<span class="cite">anthropic:claude-opus-4-7</span>
</div>
<p>…narrative…</p>
<p class="cite">citing: x · y · z</p>
</div>
Two table styles: .schema-table for sortable column listings (mono, hairlines between rows, no zebra), .df for output-cell dataframes (mono, sticky header, narrow variant for thin sidecars). Tabular-nums everywhere.
| Column | Kind | Null % | Unique | Alerts |
|---|---|---|---|---|
| image | text | 0.0% | 4,319 | multilingual |
| question | text | 0.0% | 2,798 | duplicates |
| answer_type | categorical | 0.0% | 5 |
<table class="schema-table">
<caption class="visually-hidden">…</caption>
<thead>
<tr><th scope="col">Column</th>…</tr>
</thead>
<tbody>
<tr>
<td class="col-name">image</td>
<td>text</td>
<td class="num">0.0%</td>
…
</tr>
</tbody>
</table>
A keyboard-accessible <label> wraps the hidden <input type="file">. Progressive enhancement adds drag-and-drop via a tiny vanilla-JS module. Never JavaScript-required.
<label class="dropzone" tabindex="0"> <span class="subcallout">drop · or · browse</span> <span class="callout">XLSX · CSV · …</span> <span class="filename" aria-live="polite"></span> <input type="file" name="file" accept=".csv,.parquet,…"> </label>
Drop a <id>.notes.md next to the findings JSON and the viewer renders it
above the LLM reading on both the report view (as .notes-card) and the
notebook view (as a .cell-notes cell). Markdown goes through
markdown + bleach with an allowlist tuned for research
notes (tables, fenced code, footnotes, links allowed; script/style/iframe/inline
handlers stripped. Bare URLs auto-linkify.
Reviewed 2026-04-23. Caveat: the firehose split is
anonymised: author_handle is null for every row.
Follow-up: rerun compare with the curated split alone for a clean baseline (see project notebook).
{% if notes_html %}
<section class="notes-card" aria-labelledby="notes-heading">
<div class="notes-meta">
<h2 id="notes-heading">Notes</h2>
<span class="muted">edit <code>{{ doc.id }}.notes.md</code></span>
</div>
<div class="notes-body">{{ notes_html | safe }}</div>
</section>
{% endif %}
[1]: / Out[1]: / Fig 1.) is information dense and reads like real research output.prefers-reduced-motion, dark mode via prefers-color-scheme.