saturn·

design system · v1.0

Saturn Datasheet Annual

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.

01 · Install

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.

Markup
<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.

02 · Tokens

CSS variables live on :root. The @media (prefers-color-scheme: dark) block re-binds the same names. Ten tokens cover the whole system.

--paper
warm bone
HEX
#F4F1EA
--paper-raise
raised paper
HEX
#EFEBE1
--ink
near black
HEX
#161616
--ink-soft
body text
HEX
#3F3F3F
--muted
captions, labels
HEX
#6B6863
--hot
signal red
HEX
#CE3526
--cool
secondary accent
HEX
#2F5D8F
--warn
warning
HEX
#B26A00
--good
success
HEX
#2F6C3F
--rule
hairlines
HEX
rgba 14%
CSS
: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);
}

03 · Typography

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

04 · Chips & alerts

Three chip families: kind (neutral monochrome), role (cool blue), alert (info / warn / error with a colored dot). Use sparingly: one or two per surface.

numeric text categorical timestamp
identifier label feature free_text foreign_key
multilingual duplicates outliers null_rate
profile reading compare notes agree partial disagree
Markup
<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>

05 · Buttons

Two visual weights. Ink-on-paper for primary actions; ghost (transparent + thin border) for secondary. Hover swaps the fill to the hot accent.

Generate summary
Markup
<button class="btn">Analyze</button>
<button class="btn btn-ghost">Open</button>
<a class="btn btn-ghost btn-small" href="#">Generate summary</a>

06 · Form fields

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".

API key (used once, not stored)
Markup
<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>

07 · Notebook cells

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.

VizWiz validation

4,319 rows · five columns linking image filename to question and crowd answer.

[1]:
import json; findings = json.load(open('vizwiz.json'))
Out[1]:

findings.meta.row_count

rows4,319
cols5
Fig 1.
question · top values reveal heavy repetition of generic prompts.
Markup
<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>

08 · Summary card

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.

dataset summary · high confidence anthropic:claude-opus-4-7

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

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

09 · Tables

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.

Sample schema table
ColumnKindNull %UniqueAlerts
imagetext0.0%4,319multilingual
questiontext0.0%2,798duplicates
answer_typecategorical0.0%5
Markup
<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>

10 · Drop zone

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.

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

12 · Notes sidecar

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.

Notes

edit demo.notes.md beside the findings JSON

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).

Markup
{% 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 %}

Principles

  • Warm paper, dark ink, one accent. Bone white background instead of pure white; near-black ink instead of black; one signal red used sparingly. The hot color is the only thing that asks for attention.
  • Variable serif for prose, mono for data. Fraunces gets exposed across opsz/wght/SOFT/WONK so the same family handles 4rem display headlines and 1rem reading prose. Tabular-nums on every number that lives in a column.
  • Hairlines, never zebra. 1px rules at 14% opacity beat alternating row shading. A grid you can read straight through.
  • Eyebrow labels everywhere. Tiny mono labels in 0.14em letter-spacing-uppercase do the work of headings without taking the column's air.
  • Cells > cards. The notebook gutter ([1]: / Out[1]: / Fig 1.) is information dense and reads like real research output.
  • Progressive enhancement first. JS adds drag-and-drop and table sorting, nothing more. The page works without it.
  • WCAG 2.2 AA throughout. Skip-link, single h1, table captions, focus-visible outlines, prefers-reduced-motion, dark mode via prefers-color-scheme.