π Sourdough β GRAIN's default theme
Sourdough is the visual identity GRAIN ships by default; a consuming product re-skins by overriding token slots (CONVENTIONS Β§1), never by editing components. A monochrome, e-ink / Swiss-editorial aesthetic. The whole identity rests on three things: a warm paper surface, soft inky black, and Redaction's grainy serif. No color, no shadows, no gradients. Restraint is the brand.
Implementation status (2026-06-30): built and live across the app. What runs in the monorepo vs. what's still on the page: - β Tokens β the monochrome palette is the Layer-1 primitives; the existing semantic aliases were re-pointed at them, so every component re-themed with no per-component edits (grain/styles/variables.cssβ GRAIN's default theme; a product overrides these slots to re-skin). - β Redaction self-hosted β grades clean / 35 / 50 / 70 as@font-facefamilies ingrain/fonts/(the grain grade is GRAIN's signature). The working grain grade is 50 (bumped from 35 for visibility; see Β§3). - β Grade as signal β the inherited--type-fontatom; grain = AI / in-transit, clean = human / committed. AI speech stays grain (provenance persists). The MECHANISM lives in GRAIN (grain/styles/grain.css); drives text, fields, pending cards, and the catalog Human/AI toggle. - β Paper grain layer, masthead, hairlines, crisp radius, underlined links, reduced-motion β the skin ingrain/styles/global.css; buttons ink-bordered, no fill. - β Non-text grain β an AI/in-transit button wears a dashed "terminal" edge + block caret (the grain equivalent for a non-text atom; Β§3 extension). - β³ Not built yet β the dot matrix (life grid) signature element; a distinct heavier 8-bit/terminal texture beyond Redaction's own grain. SeeAI-INTERFACE.mdΒ§5 for how grade ties to the interaction layer.
1. The vibe, decoded
What makes the reference feel like e-ink, in order of impact:
- Paper background, not white. A warm desaturated grey. Pure
#FFFinstantly kills it. - Soft black, not pure black. Ink is
~#1Csomething, so it reads printed, not screen-lit. - Disciplined display type, tight hierarchy. The reference used a Helvetica-style grotesque; this system swaps in Redaction (see Typography) β a serif whose built-in grain doubles as a state signal.
- Masthead pattern. Small sentence-case eyebrow β big bold headline β hairline rule.
- The dot matrix. Week-trackers and the life grid. This is the signature element.
- Crisp edges + hairline rules. Near-zero border-radius, 1px borders.
- No effects. No box-shadow, no gradient fill, no blur. Flatness is intentional.
2. Color tokens (the system legend)
Monochrome only. Six values do everything.
:root {
--paper: #E2E0D8; /* page background β dimmed warm grey, NOT bright */
--paper-2: #E8E6DF; /* subtle hover surface */
--panel: #E8E6DF; /* grouped "sheet" β a hair brighter than the page */
--ink: #1C1B17; /* primary text, filled dots, rules */
--ink-muted: #6E6C64; /* secondary text, section labels */
--ink-faint: #ABA89F; /* fading insights, empty/future dots */
--hairline: #1C1B17; /* solid rules (under headlines) */
--line-soft: rgba(28,27,23,.14); /* translucent border for quiet grouping */
}Note: states are made with opacity + size, not new colors. A "today" dot is just a bigger --ink dot. An "off" tracker dot is --ink-faint. Keep the palette closed.
The one signature hue (accent doctrine)
The palette stays closed, with exactly one exception: a single accent hue. GRAIN ships a --color-accent slot (+ -hover/-contrast/-soft) that a theme or consuming product sets β the one brand knob.
- Full reach. The accent reaches everywhere brand colour belongs: **links, focus rings,
::selection, the primary button fill (--color-primary chains to it), and the workspace's identity marks β the rail brand icon and the presence star** (online). Set one token, colour the whole surface.
- One hue, still closed. There is only ever one accent. **Success and danger keep no hue of
their own** β they're signalled by weight/treatment/opulence, never a red or green (the "no red errors" rule holds).
- Hueless by default. The default theme (Sourdough) sets
--color-accent: var(--ink), so it
renders identically to the pure-monochrome system β only ::selection gains a subtle ink tint, not a colour. Accented flavors (Baguette's soft blue, Brioche's honey-gold) opt in by overriding just the four accent tokens. See styles/variables.css (the accent + flavor blocks).
The two theming axes
Theming is two orthogonal attribute axes on <html>, both pure token re-skins (styles/variables.css):
data-color-schemeβlight|dark; unset = follow the OS. One attribute flips the
paper/ink stack; components never know.
data-themeβ the flavor. The consumer declares its ordered list on
<html data-themes="sourdough baguette brioche">; the first entry is the default (rendered by the bare :root, so it carries no attribute). GRAIN hardcodes no theme names β add/rename/reorder a flavor purely in the markup + a [data-theme="β¦"] token block. Each accented flavor is a reference file under styles/themes/ (baguette.css, brioche.css), @imported from variables.css; copy the annotated styles/themes/_template.css to add one.
The controls are declarative (scripts/theme.js: data-toggle-scheme, data-cycle-theme, data-set-theme/data-set-scheme on any element) and persist to localStorage; theme-boot.js (render-blocking, injected by the composition root) pre-sets the saved attributes before first paint so navigation never flashes the default (the FOUC guard). The server's boot drift-guard scans variables.css + styles/themes/*.css and warns on any referenced flavor that has no token block β a typo can't silently no-op. Ships today as Sourdough (default, hueless, in variables.css), Baguette (clean, soft blue accent), and Brioche (warm, honey-gold accent) β the latter two as themes/ reference files.
3. Typography
Family β Redaction (MCKL). A serif drawn from US legal-document typography, with built-in degradation grades (clean β 10 β 20 β 35 β 50 β 70 β 100) that mimic faxed and photocopied print. Chosen deliberately over a neutral grotesque: it pulls the identity toward institutional / legal β an archival, records-room gravity β and, crucially, its grades let the type carry meaning (see Grade as signal, below).
- Weight: Regular (400) is the house weight, including for headlines β at this grade the
grain reads finer and more refined than bold. Bold (700) is reserved for rare extra punch.
- Grades in play:
Redaction(clean) andRedaction 50are the working pair.Redaction 70
is the display accent β never body text. (Redaction 100 exists in the type family but is not loaded in this bundle.)
- Body: clean
Redactionholds up at reading sizes;Redaction 50stays legible for
short passages but not long body copy.
:root {
--font-smooth: "Redaction", "Times New Roman", Georgia, serif; /* clean β human/default */
--font-grain: "Redaction 50", "Times New Roman", Georgia, serif; /* grain β AI/draft */
--font-accent: "Redaction 70", "Times New Roman", Georgia, serif; /* display accent only */
}Loading. Self-host via Fontsource β one package per grade used: npm i @fontsource/redaction @fontsource/redaction-35 (add -70 for accents), then import "@fontsource/redaction"; import "@fontsource/redaction-35";. Redaction is free for personal use β confirm MCKL's terms before any commercial release.
Type scale (mobile-first, rem @ 16px base):
| Role | Size | Weight | Tracking | Case | Notes |
|---|---|---|---|---|---|
| Eyebrow | 0.875rem (14px) | 400 | normal | Sentence | name underlined inline |
| Display (H1) | 2.25rem (36px) | 400 | -0.01em | Title | line-height 1.02, the headline |
| Section heading | 1.3rem (21px) | 400 | normal | Title | "Overview", "Insights" |
| Body | 0.9375rem (15px) | 400 | normal | Sentence | line-height 1.55, clean grade |
| List label | 0.875rem (14px) | 400 | normal | Title | tracker row labels |
| Caption / muted | 0.8125rem (13px) | 400 | normal | Sentence | insight lines, fades out |
| Button label | 0.8125rem (13px) | 500 | 0.08em | UPPERCASE | "START A SESSION" |
Grade as signal β giving the font a job
The grades aren't decoration; they encode state. This is the system's signature move: you can tell what kind of text you're looking at from its texture alone β clean means settled and present, grain means machine-made or in-transit.
A working grade legend:
| Grade | Means | Where it shows up |
|---|---|---|
| Clean | Human / committed / present | What you wrote; saved, settled content |
| Redaction 50 | Machine / in-transit / draft | AI output, streaming text, unsaved edits |
| 70 / 100 | Heavily ephemeral / decorative | Rare display accents only |
Two patterns this enables:
- Provenance (AI vs human). Render AI-generated text in
50, human text clean. The
grain reads as "transmitted, not-quite-settled" β a fitting metaphor for machine output. AI text stays grain after it finishes β grain = AI, so provenance persists; it must not resolve to clean (that would make machine output look human). Only a human's optimistic value settles to clean once committed. (See AI-INTERFACE.md Β§5 β an earlier "resolve to clean on completion" flourish was dropped for exactly this reason.)
- Focus / editing. A field or card sits at
35at rest and snaps to clean while the
person edits it (or the reverse). The smooth state says "this is yours, right now."
Implementation notes.
- Each grade is a separate font-family, so switching grade = swapping the family (a class
change). It's instant, not interpolated.
- For a smooth transition, stack two copies of the text (clean +
35) and cross-fade their
opacity β a font swap alone can't tween, but a cross-fade gives the "settling" effect.
- Trigger on
:focus/:focus-within, or toggle a.is-ai/.is-draftclass in JS.
Guardrails.
- Readability floor: never use grades above
35for anything the user must actually read. - Don't let grain be the only provenance cue where trust matters β pair it with a label
or icon; texture reinforces, it isn't a sole source of truth.
- Offer a way to dampen or disable the effect, and honor it.
As an atom (inherited grade)
The feature belongs in the typography atom so every component inherits it. Use one inherited custom property; state lives on ancestors, and CSS inheritance distributes it.
:root { --type-font: var(--font-smooth); } /* default = human/clean */
/* the atom: every type primitive reads the switch */
.t, h1,h2,h3,h4,h5,h6, p, li, label, button, input, textarea {
font-family: var(--type-font);
}
/* state lives on any ancestor β inheritance does the rest */
.is-ai, [data-grade="grain"] { --type-font: var(--font-grain); }
.is-human, [data-grade="smooth"] { --type-font: var(--font-smooth); }
[data-grade="accent"] { --type-font: var(--font-accent); }
.field { --type-font: var(--font-grain); } /* draft at rest */
.field:focus-within { --type-font: var(--font-smooth); } /* clean on edit */Atomic mapping: the atom reads --type-font (knows nothing about grades). A molecule (message bubble, input field) sets the grade via a class or data-grade. An organism (chat thread) just composes them. Add class="is-ai" to a bubble and its whole subtree flips to 35 β no per-component wiring.
Typing / streaming. A blinking caret is pure CSS; a single-line steps() typewriter is pure CSS but needs a monospace font and no wrapping. For real (multi-line, proportional, or AI-streamed) text, append characters in JS β for a chatbot the stream already does this, so you just style the live text with the grain grade and a CSS caret, optionally resolving to clean on completion via a two-layer opacity cross-fade.
4. Spacing, radius, rules
:root {
/* 4px base scale */
--s1: 0.25rem; --s2: 0.5rem; --s3: 0.75rem;
--s4: 1rem; --s6: 1.5rem; --s8: 2rem;
--s12: 3rem; --s16: 4rem;
--radius: 4px; /* buttons β crisp, almost square */
--rule: 1.5px solid var(--hairline); /* under-headline rule */
--border: 1px solid var(--hairline); /* buttons, dotted dividers */
--page-pad: var(--s6); /* consistent left/right page margin */
}Layout is left-aligned, single column, generous air. One consistent left margin that everything hangs off β the masthead, section labels, list rows, and insights all share it.
5. Components
Masthead
Eyebrow (with underlined name) β bold display headline β hairline rule.
Hello, Helly R. β eyebrow, name underlined
You're 54% through life β display, bold, tight
βββββββββββββββββββββββββ β 1.5px ruleSection header
Label left, underlined action link right, baseline-aligned.
Overview Adjust
Insights & Motivation See AllTracker row (week dots)
Label left, a row of 7 dots right. Filled --ink = done/active, --ink-faint = off.
Deep Focus β β β β β β¦ β¦
Movement Breaks β β β β¦ β¦ β¦ β¦Build: flex row; dots are width/height + border-radius:50%, color swap by state.
Dot matrix (life grid) β the signature
A full grid of dots. Lived days = --ink, today = larger --ink dot, future = --ink-faint. Build: CSS grid (repeat(N, 1fr)), each cell a circle. ~14 columns reads well on mobile. Caption sits beneath in muted text.
Dotted divider
A row of tiny faint dots separating list content from the + Add New affordance. Build: border-bottom: 1.5px dotted var(--ink-faint) (cheapest) or a repeated dot row.
Insight list
Primary line in --ink, then each subsequent line steps down toward --ink-faint (a literal fade). Inline actions are underlined links.
Buttons
- Primary: full-width,
--border,--radius, uppercase tracked label, transparent fill. - Icon square: bordered square holding a single glyph (timer, menu dots).
- Bottom bar: three bordered elements in a row β icon / primary / icon.
- Prompt button: full-width bordered, sentence-case ("Do you feel good about your time spent?").
No fills, no shadows. Hover = a quiet ink wash (color-mix(in srgb, var(--ink) 8%, transparent)) β never a full invert and never a new hue. (The old invert-to-ink hover read as a loud accent block and made ghost buttons β invisible at rest β pop in as a rounded box out of nowhere; retired 2026-07-04.)
Links
Always underlined, inline, same ink color. The underline is the affordance β no color change.
6. Nailing the e-ink feel (the details that sell it)
- No crisp UI shadows or gradients. The only shadow allowed is one very soft,
warm, large-blur shadow used to lift a grouped sheet off the page (see Grouping). Everything else stays flat β no drop shadows on buttons, no gradient fills.
- Group flat, not floating. Group content by whitespace + a hairline rule, by a faint
--panel tone, or by a thin translucent --line-soft border β the same quiet border the color swatches use. Avoid drop shadows for grouping; even a soft one reads as too 3D against e-ink's flatness. Reserve solid --hairline ink for headline rules, and hard borders for things you tap (buttons, inputs).
- Don't go too bright. Keep the paper a muted warm grey (
~#E2E0D8, not#E9E7E1).
The slight dimness plus grain is what reads as a screen-lit e-ink panel rather than white paper.
- Paper grain. One fixed, full-screen SVG
feTurbulencelayer at ~8% opacity with
mix-blend-mode: multiply β multiply makes it darken the surface like fibre instead of sitting on top as a grey film. Tune baseFrequency ~0.85 for fine grain, and let it cover everything including content so the whole surface feels like one sheet.
- Crisp rules. 1β1.5px solid hairlines. Avoid sub-pixel blur β round to whole px.
- Respect
prefers-reduced-motionand keep motion minimal anyway; e-ink implies
stillness. If anything animates, a slow fade fits; bouncy easing breaks the spell.
- One accent of boldness only: the display headline weight + the dot matrix. Keep
everything else quiet.
7. Responsiveness
Mobile-first. The layout never becomes multi-column β it just breathes more as the screen grows. The discipline that makes the aesthetic work is the same discipline that makes it responsive: one column, consistent margins, fluid type.
:root {
--content-max: 768px; /* cap the editorial column */
--page-pad: clamp(1.25rem, 5vw, 2.5rem); /* margins grow with viewport */
--display: clamp(2rem, 6vw, 2.75rem); /* headline scales, body fixed */
}Principles:
- Constrain the reading column. Cap content at ~720β768px and center it, so the
column keeps its proportions on desktop instead of stretching edge to edge.
- Fluid type with
clamp(). The display headline scales between mobile and desktop;
body stays fixed (~15px) for steady readability.
- Fluid page padding with
clamp()so side margins expand on larger screens. - Dot matrix scales, never reflows. Fix the column count (e.g. 14) and size dots
relatively (grid 1fr + aspect-ratio: 1); the whole grid shrinks proportionally. Pick a column count that stays legible down to 320px.
- Tracker rows: the label flexes, the 7-dot group stays fixed-width on one line at
every size. Dots don't wrap.
- Touch targets β₯ 44px for buttons and icon squares.
- Hairlines stay 1px β don't scale rules, or they go blurry.
- Respect
prefers-reduced-motion; keep motion minimal regardless. - Test floor: 320px up, no horizontal scroll.
8. Build feasibility
| Element | Difficulty | How |
|---|---|---|
| Paper + ink | Trivial | Two CSS vars |
| Masthead + rule | Trivial | Type scale + border-bottom |
| Tracker dots | Easy | Flex row of border-radius:50% circles |
| Life dot matrix | Easy | CSS grid of circles, state by class |
| Underlined links | Trivial | text-decoration: underline |
| Buttons / bar | Easy | Borders + spacing, no fills |
| The "right" font | Medium | Free stand-in (Inter) or license Helvetica-like |
Verdict: fully achievable with plain HTML/CSS, no JS or framework needed. The only real decision is the font budget. Everything else is spacing and discipline.