Atoms
Action badge
One step of the AI's work, shown as its action verb — the closed verb vocabulary (grain/ai/contract.ts) made visible. The AI narrates a run as a stream of these (reads → types → revises → clicks → commits). It's AI by nature, so it always wears the grain "terminal" edge; status carries the step's state. verb sets the label; status="active" marks the step the AI is doing right now.
States
<action-badge verb="clicks" status="active"></action-badge>
<action-badge verb="reads"></action-badge>
<action-badge verb="types"></action-badge>
<action-badge verb="revises"></action-badge>
<action-badge verb="commits"></action-badge>
One step of the AI's work, shown as its action verb — the closed verb vocabulary (grain/ai/contract.ts) made visible. The AI narrates a run as a stream of these (reads → types → revises → clicks → commits). It's AI by nature, so it always wears the grain "terminal" edge; status carries the step's state. verb sets the label; status="active" marks the step the AI is doing right now.
States
<action-badge verb="clicks" status="active"></action-badge>
<action-badge verb="reads"></action-badge>
<action-badge verb="types"></action-badge>
<action-badge verb="revises"></action-badge>
<action-badge verb="commits"></action-badge>
Atoms
Badge
One class — .badge. Tone is an attribute (data-status), not a second class. Monochrome: state reads as ink vs faint, not hue (DESIGN-SYSTEM §2).
Tones
<span class="badge" data-status="active">Active</span>
<span class="badge" data-status="archived">Archived</span>
One class — .badge. Tone is an attribute (data-status), not a second class. Monochrome: state reads as ink vs faint, not hue (DESIGN-SYSTEM §2).
Tones
<span class="badge" data-status="active">Active</span>
<span class="badge" data-status="archived">Archived</span>
Atoms
Button
One class — .btn. Variant, size, and status are attributes, not extra classes. Pseudo-states are forced for display with data-force.
States
<button class="btn">Button</button>
<button class="btn" data-force="hover">Button</button>
<button class="btn" data-force="focus">Button</button>
<button class="btn" data-force="active">Button</button>
<button class="btn" disabled>Button</button>
Sizes
<button class="btn" data-size="sm">Small</button>
<button class="btn">Medium</button>
<button class="btn" data-size="lg">Large</button>
Variants
<button class="btn">Solid</button>
<button class="btn" data-variant="soft">Soft</button>
<button class="btn" data-variant="outline">Outline</button>
Status
<button class="btn" data-status="success">Save</button>
<button class="btn" data-status="danger">Delete</button>
When the AI is acting through a button, it wears the non-text grain: a dashed "terminal" edge + a blinking block caret, with the label in the Redaction grain family — the same inherited grade state the text uses. It settles back to clean on commit.
In-transit
<button class="btn" data-commit="pending">Working…</button>
<button class="btn" data-size="sm" data-commit="pending">Archiving…</button>
<button class="btn" style="width:100%" data-commit="pending">Rescheduling your week…</button>
Atoms
Code block
A monospace block for fenced code — plus a .code-inline run for inline code. It reads the --font-mono token, so code stays mono in either grade; the AI signal here is the dashed edge (non-text grain), not a font swap. CSS-only: composed by hand (by MILL), nothing data-binds it, so there is no template. Long lines scroll rather than break the column.
Block
bun run dev<pre class="code-block"><code>bun run dev</code></pre>
export const answer = 42;<pre class="code-block" data-lang="ts"><code>export const answer = 42;</code></pre>
Inline
Run bun run check before you call it done.
<p>Run <code class="code-inline">bun run check</code> before you call it done.</p>
A monospace block for fenced code — plus a .code-inline run for inline code. It reads the --font-mono token, so code stays mono in either grade; the AI signal here is the dashed edge (non-text grain), not a font swap. CSS-only: composed by hand (by MILL), nothing data-binds it, so there is no template. Long lines scroll rather than break the column.
Block
bun run dev<pre class="code-block"><code>bun run dev</code></pre>
export const answer = 42;<pre class="code-block" data-lang="ts"><code>export const answer = 42;</code></pre>
Inline
Run bun run check before you call it done.
<p>Run <code class="code-inline">bun run check</code> before you call it done.</p>
Atoms
Flag (portfolio)
A small status flag for a work — "Planned", "Demo" — an uppercase whisper in a dashed pill. Portfolio-owned on purpose: inside GRAIN the dashed edge is the "AI / in-transit" signal, so a static flag that borrows the look stays out of the design system.
Flag
<span class="flag">Planned · only the plan exists so far</span>
A small status flag for a work — "Planned", "Demo" — an uppercase whisper in a dashed pill. Portfolio-owned on purpose: inside GRAIN the dashed edge is the "AI / in-transit" signal, so a static flag that borrows the look stays out of the design system.
Flag
<span class="flag">Planned · only the plan exists so far</span>
Atoms
Icon
Inline SVG glyphs from the shared sprite (/assets/sprite.svg). One class — .icon; size is an attribute. Monochrome and currentColor, so an icon flips ink ↔ grain with its surroundings. Icons are decorative (aria-hidden) — label the control that wraps them (see b-icon-button), not the glyph. Pass the whole sprite reference in sym (the binding vocabulary can't concatenate a name).
Glyphs
<b-icon sym="/assets/sprite.svg#loop"></b-icon>
<b-icon sym="/assets/sprite.svg#tasks"></b-icon>
<b-icon sym="/assets/sprite.svg#knowledge"></b-icon>
<b-icon sym="/assets/sprite.svg#rules"></b-icon>
<b-icon sym="/assets/sprite.svg#traces"></b-icon>
<b-icon sym="/assets/sprite.svg#settings"></b-icon>
<b-icon sym="/assets/sprite.svg#menu"></b-icon>
<b-icon sym="/assets/sprite.svg#close"></b-icon>
<b-icon sym="/assets/sprite.svg#chevron-left"></b-icon>
<b-icon sym="/assets/sprite.svg#chevron-right"></b-icon>
<b-icon sym="/assets/sprite.svg#send"></b-icon>
<b-icon sym="/assets/sprite.svg#search"></b-icon>
<b-icon sym="/assets/sprite.svg#spark"></b-icon>
<b-icon sym="/assets/sprite.svg#check"></b-icon>
<b-icon sym="/assets/sprite.svg#plus"></b-icon>
Sizes
<b-icon sym="/assets/sprite.svg#loop" size="sm"></b-icon>
<b-icon sym="/assets/sprite.svg#loop"></b-icon>
<b-icon sym="/assets/sprite.svg#loop" size="lg"></b-icon>
Inline SVG glyphs from the shared sprite (/assets/sprite.svg). One class — .icon; size is an attribute. Monochrome and currentColor, so an icon flips ink ↔ grain with its surroundings. Icons are decorative (aria-hidden) — label the control that wraps them (see b-icon-button), not the glyph. Pass the whole sprite reference in sym (the binding vocabulary can't concatenate a name).
Glyphs
<b-icon sym="/assets/sprite.svg#loop"></b-icon>
<b-icon sym="/assets/sprite.svg#tasks"></b-icon>
<b-icon sym="/assets/sprite.svg#knowledge"></b-icon>
<b-icon sym="/assets/sprite.svg#rules"></b-icon>
<b-icon sym="/assets/sprite.svg#traces"></b-icon>
<b-icon sym="/assets/sprite.svg#settings"></b-icon>
<b-icon sym="/assets/sprite.svg#menu"></b-icon>
<b-icon sym="/assets/sprite.svg#close"></b-icon>
<b-icon sym="/assets/sprite.svg#chevron-left"></b-icon>
<b-icon sym="/assets/sprite.svg#chevron-right"></b-icon>
<b-icon sym="/assets/sprite.svg#send"></b-icon>
<b-icon sym="/assets/sprite.svg#search"></b-icon>
<b-icon sym="/assets/sprite.svg#spark"></b-icon>
<b-icon sym="/assets/sprite.svg#check"></b-icon>
<b-icon sym="/assets/sprite.svg#plus"></b-icon>
Sizes
<b-icon sym="/assets/sprite.svg#loop" size="sm"></b-icon>
<b-icon sym="/assets/sprite.svg#loop"></b-icon>
<b-icon sym="/assets/sprite.svg#loop" size="lg"></b-icon>
Atoms
Icon button
The "icon square" (DESIGN-SYSTEM §5): a bordered control holding one glyph. One class — .icon-btn; variant and size are attributes. Icon-only, so label is required (it becomes the aria-label). Same ink-hairline / invert-on-hover treatment as b-button.
Default
<b-icon-button sym="/assets/sprite.svg#send" label="Send"></b-icon-button>
Ghost (rail / toolbar)
<b-icon-button sym="/assets/sprite.svg#menu" label="Menu" variant="ghost"></b-icon-button>
Sizes
<b-icon-button sym="/assets/sprite.svg#search" label="Search" size="sm"></b-icon-button>
<b-icon-button sym="/assets/sprite.svg#search" label="Search"></b-icon-button>
<b-icon-button sym="/assets/sprite.svg#search" label="Search" size="lg"></b-icon-button>
States
<b-icon-button sym="/assets/sprite.svg#send" label="Send" data-force="hover"></b-icon-button>
<b-icon-button sym="/assets/sprite.svg#send" label="Send" data-force="focus"></b-icon-button>
<b-icon-button sym="/assets/sprite.svg#send" label="Send" disabled></b-icon-button>
When the AI is acting through an icon button it wears the non-text grain — a dashed "terminal" edge — the same inherited grade state b-button uses. It settles back to a solid ink hairline on commit.
In-transit
<b-icon-button sym="/assets/sprite.svg#send" label="Sending…" data-commit="pending"></b-icon-button>
Atoms
Input
A labelled text field. Compose several inside a <form> to build any form. As a component: <b-input name="email" label="Email" required /> — required is a bare boolean, and the tag may self-close.
States
<label class="field">
<span class="field__label">Name</span>
<input class="field__input" name="name" placeholder="Jane">
</label>
<label class="field">
<span class="field__label">Name</span>
<input class="field__input" name="name" value="Jane" data-force="focus">
</label>
Variants
<label class="field">
<span class="field__label">Name</span>
<input class="field__input" name="name">
</label>
<label class="field" data-variant="inline">
<span class="field__label">Name</span>
<input class="field__input" name="name">
</label>
Sizes
<label class="field" data-size="sm">
<span class="field__label">Name</span>
<input class="field__input" name="name">
</label>
<label class="field" data-size="lg">
<span class="field__label">Name</span>
<input class="field__input" name="name">
</label>
A labelled text field. Compose several inside a <form> to build any form. As a component: <b-input name="email" label="Email" required /> — required is a bare boolean, and the tag may self-close.
States
<label class="field">
<span class="field__label">Name</span>
<input class="field__input" name="name" placeholder="Jane">
</label>
<label class="field">
<span class="field__label">Name</span>
<input class="field__input" name="name" value="Jane" data-force="focus">
</label>
Variants
<label class="field">
<span class="field__label">Name</span>
<input class="field__input" name="name">
</label>
<label class="field" data-variant="inline">
<span class="field__label">Name</span>
<input class="field__input" name="name">
</label>
Sizes
<label class="field" data-size="sm">
<span class="field__label">Name</span>
<input class="field__input" name="name">
</label>
<label class="field" data-size="lg">
<span class="field__label">Name</span>
<input class="field__input" name="name">
</label>
Atoms
Keyboard hint
A quiet hairline chip that names a keyboard shortcut. One class — .kbd. Presentational only: the real handler lives elsewhere (the ⌘K palette, a shell toggle). keys sets the label.
Examples
<b-kbd keys="⌘K"></b-kbd>
<b-kbd keys="⌘B"></b-kbd>
<b-kbd keys="Esc"></b-kbd>
A quiet hairline chip that names a keyboard shortcut. One class — .kbd. Presentational only: the real handler lives elsewhere (the ⌘K palette, a shell toggle). keys sets the label.
Examples
<b-kbd keys="⌘K"></b-kbd>
<b-kbd keys="⌘B"></b-kbd>
<b-kbd keys="Esc"></b-kbd>
Atoms
List
One class — .list with .list__item children. Bullet by default (an editorial em-dash); data-variant="ordered" switches to a counter. Markers are muted ink, not a heavy disc (DESIGN-SYSTEM §4). Give each item a data-surface so the AI can write — or revise — one item at a time.
Bullet (default)
- Deep-work block — 09:00–11:00
- Clear the inbox
- Review architecture doc
<ul class="list">
<li class="list__item">Deep-work block — 09:00–11:00</li>
<li class="list__item">Clear the inbox</li>
<li class="list__item">Review architecture doc</li>
</ul>
Ordered
- First
- Second
- Third
<ul class="list" data-variant="ordered">
<li class="list__item">First</li>
<li class="list__item">Second</li>
<li class="list__item">Third</li>
</ul>
One class — .list with .list__item children. Bullet by default (an editorial em-dash); data-variant="ordered" switches to a counter. Markers are muted ink, not a heavy disc (DESIGN-SYSTEM §4). Give each item a data-surface so the AI can write — or revise — one item at a time.
Bullet (default)
- Deep-work block — 09:00–11:00
- Clear the inbox
- Review architecture doc
<ul class="list">
<li class="list__item">Deep-work block — 09:00–11:00</li>
<li class="list__item">Clear the inbox</li>
<li class="list__item">Review architecture doc</li>
</ul>
Ordered
- First
- Second
- Third
<ul class="list" data-variant="ordered">
<li class="list__item">First</li>
<li class="list__item">Second</li>
<li class="list__item">Third</li>
</ul>
Atoms
Meter
A proportional horizontal bar split into segments — a health bar (passed / failed / pending share of a run), a storage bar, a progress bar, a poll result. Each segment's width is a single custom property (--seg) so it's bindable server-side with zero JS. Monochrome: segments differ by texture and ink-level — solid ink, hatched, faint — never by hue. Segments read left-to-right and should sum to 100% (any remainder shows the faint track). Give the meter an aria-label; for a single-value progress meter add aria-valuenow/min/max.
Health bar (three tones)
<div class="meter" role="meter" aria-label="Run health">
<span class="meter__seg" data-tone="ok" style="--seg: 70%"></span>
<span class="meter__seg" data-tone="bad" style="--seg: 20%"></span>
<span class="meter__seg" data-tone="pending" style="--seg: 10%"></span>
</div>
Progress (one segment)
<div class="meter" role="meter" aria-label="Upload" aria-valuenow="60" aria-valuemin="0" aria-valuemax="100">
<span class="meter__seg" data-tone="ok" style="--seg: 60%"></span>
</div>
Indeterminate (running, totals unknown)
<div class="meter" data-state="busy" role="meter" aria-label="Running…">
<span class="meter__seg"></span>
</div>
In transit (AI updating the meter)
<div class="meter" data-commit="pending" role="meter" aria-label="Run health">
<span class="meter__seg" data-tone="ok" style="--seg: 55%"></span>
<span class="meter__seg" data-tone="pending" style="--seg: 45%"></span>
</div>
A proportional horizontal bar split into segments — a health bar (passed / failed / pending share of a run), a storage bar, a progress bar, a poll result. Each segment's width is a single custom property (--seg) so it's bindable server-side with zero JS. Monochrome: segments differ by texture and ink-level — solid ink, hatched, faint — never by hue. Segments read left-to-right and should sum to 100% (any remainder shows the faint track). Give the meter an aria-label; for a single-value progress meter add aria-valuenow/min/max.
Health bar (three tones)
<div class="meter" role="meter" aria-label="Run health">
<span class="meter__seg" data-tone="ok" style="--seg: 70%"></span>
<span class="meter__seg" data-tone="bad" style="--seg: 20%"></span>
<span class="meter__seg" data-tone="pending" style="--seg: 10%"></span>
</div>
Progress (one segment)
<div class="meter" role="meter" aria-label="Upload" aria-valuenow="60" aria-valuemin="0" aria-valuemax="100">
<span class="meter__seg" data-tone="ok" style="--seg: 60%"></span>
</div>
Indeterminate (running, totals unknown)
<div class="meter" data-state="busy" role="meter" aria-label="Running…">
<span class="meter__seg"></span>
</div>
In transit (AI updating the meter)
<div class="meter" data-commit="pending" role="meter" aria-label="Run health">
<span class="meter__seg" data-tone="ok" style="--seg: 55%"></span>
<span class="meter__seg" data-tone="pending" style="--seg: 45%"></span>
</div>
Atoms
Select
A labelled <select> in the .field idiom — the dropdown sibling of the text input. It reuses the shared .field/.field__label frame from b-input (parent-context note: b-select contributes only .field__select, and must be composed inside a .field label exactly like b-input, or it has no frame/spacing/AI treatment). The native dropdown arrow is kept — no custom caret, so nothing here needs a hardcoded color.
States
<label class="field">
<span class="field__label">Environment</span>
<select class="field__select" name="env">
<option>staging</option><option>prod</option>
</select>
</label>
<label class="field">
<span class="field__label">Environment</span>
<select class="field__select" name="env" data-force="focus">
<option>staging</option><option>prod</option>
</select>
</label>
Variants
<label class="field">
<span class="field__label">Priority</span>
<select class="field__select" name="priority">
<option>Low</option><option>Medium</option><option>High</option>
</select>
</label>
<label class="field" data-variant="inline">
<span class="field__label">Priority</span>
<select class="field__select" name="priority">
<option>Low</option><option>Medium</option><option>High</option>
</select>
</label>
Sizes
<label class="field" data-size="sm">
<span class="field__label">Priority</span>
<select class="field__select" name="priority">
<option>Low</option><option>Medium</option><option>High</option>
</select>
</label>
<label class="field" data-size="lg">
<span class="field__label">Priority</span>
<select class="field__select" name="priority">
<option>Low</option><option>Medium</option><option>High</option>
</select>
</label>
A labelled <select> in the .field idiom — the dropdown sibling of the text input. It reuses the shared .field/.field__label frame from b-input (parent-context note: b-select contributes only .field__select, and must be composed inside a .field label exactly like b-input, or it has no frame/spacing/AI treatment). The native dropdown arrow is kept — no custom caret, so nothing here needs a hardcoded color.
States
<label class="field">
<span class="field__label">Environment</span>
<select class="field__select" name="env">
<option>staging</option><option>prod</option>
</select>
</label>
<label class="field">
<span class="field__label">Environment</span>
<select class="field__select" name="env" data-force="focus">
<option>staging</option><option>prod</option>
</select>
</label>
Variants
<label class="field">
<span class="field__label">Priority</span>
<select class="field__select" name="priority">
<option>Low</option><option>Medium</option><option>High</option>
</select>
</label>
<label class="field" data-variant="inline">
<span class="field__label">Priority</span>
<select class="field__select" name="priority">
<option>Low</option><option>Medium</option><option>High</option>
</select>
</label>
Sizes
<label class="field" data-size="sm">
<span class="field__label">Priority</span>
<select class="field__select" name="priority">
<option>Low</option><option>Medium</option><option>High</option>
</select>
</label>
<label class="field" data-size="lg">
<span class="field__label">Priority</span>
<select class="field__select" name="priority">
<option>Low</option><option>Medium</option><option>High</option>
</select>
</label>
Atoms
Switch
A labelled on/off toggle built on a real <input type="checkbox">, so it keeps native focus, Space to toggle, and form semantics. The checkbox is visually hidden but still focusable; the track and thumb are the drawn control. Use it where an action is a state the user sets (delivered / held, locked / open), not a one-shot verb - a button reading "PUBLISHING" that un-publishes on click is the anti-pattern this replaces. Monochrome by default; ON reaches the accent hue (the one place a toggle earns color, matching nav-item[aria-current] and tabs).
States
<label class="switch">
<input type="checkbox" class="switch__input">
<span class="switch__track"><span class="switch__thumb"></span></span>
<span class="switch__label switch__label--off">Not delivered</span>
</label>
<label class="switch">
<input type="checkbox" class="switch__input" checked>
<span class="switch__track"><span class="switch__thumb"></span></span>
<span class="switch__label">Delivered</span>
</label>
<label class="switch">
<input type="checkbox" class="switch__input" checked disabled>
<span class="switch__track"><span class="switch__thumb"></span></span>
<span class="switch__label">Delivered</span>
</label>
A labelled on/off toggle built on a real <input type="checkbox">, so it keeps native focus, Space to toggle, and form semantics. The checkbox is visually hidden but still focusable; the track and thumb are the drawn control. Use it where an action is a state the user sets (delivered / held, locked / open), not a one-shot verb - a button reading "PUBLISHING" that un-publishes on click is the anti-pattern this replaces. Monochrome by default; ON reaches the accent hue (the one place a toggle earns color, matching nav-item[aria-current] and tabs).
States
<label class="switch">
<input type="checkbox" class="switch__input">
<span class="switch__track"><span class="switch__thumb"></span></span>
<span class="switch__label switch__label--off">Not delivered</span>
</label>
<label class="switch">
<input type="checkbox" class="switch__input" checked>
<span class="switch__track"><span class="switch__thumb"></span></span>
<span class="switch__label">Delivered</span>
</label>
<label class="switch">
<input type="checkbox" class="switch__input" checked disabled>
<span class="switch__track"><span class="switch__thumb"></span></span>
<span class="switch__label">Delivered</span>
</label>
Atoms
Typography
One polymorphic type atom — <b-text as="…"> renders text in any element. Every type primitive reads the inherited --type-font switch and knows nothing about grades, so a single ancestor state flips a whole subtree between clean (human / committed) and grain (AI / in-transit). Toggle to AI above to see this component in grain.
Clean — human / committed
The AI records what you decided, in your own clean hand.
<p class="t">The AI records what you decided, in your own clean hand.</p>
An AI that writes back.
<h1 class="t masthead">An AI that writes back.</h1>
Overview
<h2 class="t">Overview</h2>
Machine-made text reads in the grain grade (Redaction 50) — the texture says "machine-made". The grain is built into the font (a family swap), not faked. Streaming AI output types in at grain with a caret; when it finishes the caret goes but the text stays grain — grain = AI, so provenance persists (it doesn't masquerade as human).
Grain — AI / in-transit
On it — checking your week. You have room on Thursday.
<p class="t">On it — checking your week. You have room on Thursday.</p>
Noted: deep-work moved to Thursday
<p class="t"><span>Noted: deep-work moved to Thursday</span><span class="caret"></span></p>
Sourdough
<p class="t" data-grade="accent">Sourdough</p>
Molecules
Callout
A blockquote / margin-note aside — a quiet ruled block, not a coloured box (state reads as ink vs muted, not hue). MILL maps Markdown blockquotes to this. Tone is an attribute (data-status), not a second class.
Note
Provenance persists: only the AI grains.
<blockquote class="callout">Provenance persists: only the AI grains.</blockquote>
Strong
One human click and one AI decision become the same Intent.
<blockquote class="callout" data-status="strong">One human click and one AI decision become the same Intent.</blockquote>
A blockquote / margin-note aside — a quiet ruled block, not a coloured box (state reads as ink vs muted, not hue). MILL maps Markdown blockquotes to this. Tone is an attribute (data-status), not a second class.
Note
Provenance persists: only the AI grains.
<blockquote class="callout">Provenance persists: only the AI grains.</blockquote>
Strong
One human click and one AI decision become the same Intent.
<blockquote class="callout" data-status="strong">One human click and one AI decision become the same Intent.</blockquote>
Molecules
Card
A bordered content tile — a hairline box, no fill (state is ink, not hue) — plus its companion .card-grid (auto-fit columns; tune the minimum tile width with --card-min, default 15rem). Composed by hand: a <div> for a plain fact, an <a> for a navigable tile (the whole tile is the link). Padding is an attribute (data-pad="sm"), not a class.
Fact card
No build step
Nothing between source and server: no bundler, no transpiler.
<div class="card" data-pad="sm">
<h3 class="card__title">No build step</h3>
<p class="card__body">Nothing between source and server: no bundler, no transpiler.</p>
</div>
Card grid
One
First fact.
Two
Second fact.
<div class="card-grid">
<div class="card" data-pad="sm"><h3 class="card__title">One</h3><p class="card__body">First fact.</p></div>
<div class="card" data-pad="sm"><h3 class="card__title">Two</h3><p class="card__body">Second fact.</p></div>
</div>
Navigable card
<a class="card" href="/grain">
<h3 class="card__title">GRAIN</h3>
<p class="card__body">An AI-interaction design system. Built on BATCH.</p>
</a>
A bordered content tile — a hairline box, no fill (state is ink, not hue) — plus its companion .card-grid (auto-fit columns; tune the minimum tile width with --card-min, default 15rem). Composed by hand: a <div> for a plain fact, an <a> for a navigable tile (the whole tile is the link). Padding is an attribute (data-pad="sm"), not a class.
Fact card
No build step
Nothing between source and server: no bundler, no transpiler.
<div class="card" data-pad="sm">
<h3 class="card__title">No build step</h3>
<p class="card__body">Nothing between source and server: no bundler, no transpiler.</p>
</div>
Card grid
One
First fact.
Two
Second fact.
<div class="card-grid">
<div class="card" data-pad="sm"><h3 class="card__title">One</h3><p class="card__body">First fact.</p></div>
<div class="card" data-pad="sm"><h3 class="card__title">Two</h3><p class="card__body">Second fact.</p></div>
</div>
Navigable card
<a class="card" href="/grain">
<h3 class="card__title">GRAIN</h3>
<p class="card__body">An AI-interaction design system. Built on BATCH.</p>
</a>
Molecules
Chat message
One line of the assistant conversation. data-role="you" is clean and right-aligned (your words, committed); data-role="ai" carries data-grade="grain" — the AI's speech stays grain (grain = AI, AI-INTERFACE §5). The single writer emits matching markup over the door (chat.send), streaming the reply into the __body. Parent context (required): a message positions itself with align-self, which only works in a flex column — so compose messages inside a chat-log. In a bare div the alignment silently does nothing. Legibility floor. A bubble renders at --text-sm, under the >= --text-2xl floor where Redaction 50's grade texture reads as texture rather than roughness (CLAUDE.md lesson 4). So an AI message (data-grade="grain" or mid-stream data-commit="pending") renders in --font-grain-fine (Redaction 35 only) instead of the sitewide --font-grain (Redaction 50) — the grain signal still reads, just without the roughest strokes turning to mush at bubble size.
Roles
<div class="chat-message" data-role="you"><span class="chat-message__who">You</span><span class="chat-message__body">Plan my Thursday.</span></div>
<div class="chat-message" data-role="ai" data-grade="grain"><span class="chat-message__who">GRAIN</span><span class="chat-message__body">On it — three deep-work blocks, review at 2.</span></div>
Actionable chat dialog (the AI offers a follow-up)
<div class="chat-message" data-role="ai" data-grade="grain">
<span class="chat-message__who">GRAIN</span>
<span class="chat-message__body">I read your three newest notes and wrote a digest.</span>
<div class="chat-message__actions">
<button class="btn" data-variant="soft" data-ai-run data-action="note.append" data-target="notepad" type="button">Add to my notes</button>
<button class="btn" data-variant="outline" data-ai-run data-action="demo.run" data-target="screen" type="button">See what's new</button>
</div>
</div>
One line of the assistant conversation. data-role="you" is clean and right-aligned (your words, committed); data-role="ai" carries data-grade="grain" — the AI's speech stays grain (grain = AI, AI-INTERFACE §5). The single writer emits matching markup over the door (chat.send), streaming the reply into the __body. Parent context (required): a message positions itself with align-self, which only works in a flex column — so compose messages inside a chat-log. In a bare div the alignment silently does nothing. Legibility floor. A bubble renders at --text-sm, under the >= --text-2xl floor where Redaction 50's grade texture reads as texture rather than roughness (CLAUDE.md lesson 4). So an AI message (data-grade="grain" or mid-stream data-commit="pending") renders in --font-grain-fine (Redaction 35 only) instead of the sitewide --font-grain (Redaction 50) — the grain signal still reads, just without the roughest strokes turning to mush at bubble size.
Roles
<div class="chat-message" data-role="you"><span class="chat-message__who">You</span><span class="chat-message__body">Plan my Thursday.</span></div>
<div class="chat-message" data-role="ai" data-grade="grain"><span class="chat-message__who">GRAIN</span><span class="chat-message__body">On it — three deep-work blocks, review at 2.</span></div>
Actionable chat dialog (the AI offers a follow-up)
<div class="chat-message" data-role="ai" data-grade="grain">
<span class="chat-message__who">GRAIN</span>
<span class="chat-message__body">I read your three newest notes and wrote a digest.</span>
<div class="chat-message__actions">
<button class="btn" data-variant="soft" data-ai-run data-action="note.append" data-target="notepad" type="button">Add to my notes</button>
<button class="btn" data-variant="outline" data-ai-run data-action="demo.run" data-target="screen" type="button">See what's new</button>
</div>
</div>
Molecules
Chip group
A set of selectable pills acting as a form control (single- or multi-select) — a filter bar / tag picker / facet control. Native inputs → zero JS, form-postable, keyboard + AX for free. Monochrome selected state: ink vs muted, not hue. Parent-context note: a chip is a <label class="chips__chip"> child of a <fieldset class="chips"> — the checkbox/radio + a <span> for the visible text live inside the label; the fieldset owns the layout (display: flex; flex-wrap: wrap) and the data-select attribute. A chips__chip outside a .chips fieldset has no layout context. A chip's value may carry data-surface="…" so an AI can address one selection later — this is a plain attribute hook only; it does not add a surface kind or action to ai/contract.ts.
Multi-select
<fieldset class="chips" data-select="multi">
<label class="chips__chip"><input type="checkbox" name="env" value="staging" checked><span>staging</span></label>
<label class="chips__chip"><input type="checkbox" name="env" value="prod"><span>prod</span></label>
<label class="chips__chip"><input type="checkbox" name="env" value="local"><span>local</span></label>
</fieldset>
Single-select
<fieldset class="chips" data-select="single">
<label class="chips__chip"><input type="radio" name="priority" value="low"><span>Low</span></label>
<label class="chips__chip"><input type="radio" name="priority" value="medium" checked><span>Medium</span></label>
<label class="chips__chip"><input type="radio" name="priority" value="high"><span>High</span></label>
</fieldset>
Selected + focus
<fieldset class="chips" data-select="multi">
<label class="chips__chip"><input type="checkbox" name="tag" value="urgent" checked><span>urgent</span></label>
<label class="chips__chip"><input type="checkbox" name="tag" value="backlog"><span>backlog</span></label>
</fieldset>
A set of selectable pills acting as a form control (single- or multi-select) — a filter bar / tag picker / facet control. Native inputs → zero JS, form-postable, keyboard + AX for free. Monochrome selected state: ink vs muted, not hue. Parent-context note: a chip is a <label class="chips__chip"> child of a <fieldset class="chips"> — the checkbox/radio + a <span> for the visible text live inside the label; the fieldset owns the layout (display: flex; flex-wrap: wrap) and the data-select attribute. A chips__chip outside a .chips fieldset has no layout context. A chip's value may carry data-surface="…" so an AI can address one selection later — this is a plain attribute hook only; it does not add a surface kind or action to ai/contract.ts.
Multi-select
<fieldset class="chips" data-select="multi">
<label class="chips__chip"><input type="checkbox" name="env" value="staging" checked><span>staging</span></label>
<label class="chips__chip"><input type="checkbox" name="env" value="prod"><span>prod</span></label>
<label class="chips__chip"><input type="checkbox" name="env" value="local"><span>local</span></label>
</fieldset>
Single-select
<fieldset class="chips" data-select="single">
<label class="chips__chip"><input type="radio" name="priority" value="low"><span>Low</span></label>
<label class="chips__chip"><input type="radio" name="priority" value="medium" checked><span>Medium</span></label>
<label class="chips__chip"><input type="radio" name="priority" value="high"><span>High</span></label>
</fieldset>
Selected + focus
<fieldset class="chips" data-select="multi">
<label class="chips__chip"><input type="checkbox" name="tag" value="urgent" checked><span>urgent</span></label>
<label class="chips__chip"><input type="checkbox" name="tag" value="backlog"><span>backlog</span></label>
</fieldset>
Molecules
Content index
A collection's listing — what MILL's live content route emits at a collection's index (/notes, /grain/docs): a ruled list of entries, each with a meta line, a linked title, a summary, and tag badges. CSS-only: MILL composes it; nothing data-binds it.
Listing
-
The Browser Grew Up
The honest ledger of what betting on the native platform bought me.
<ul class="content-index">
<li class="content-index__item">
<p class="content-index__meta">2026-07-04 · ~8 min</p>
<h2 class="content-index__title"><a href="/notes/the-browser-grew-up">The Browser Grew Up</a></h2>
<p class="content-index__summary">The honest ledger of what betting on the native platform bought me.</p>
<div class="note__tags"><span class="badge" data-status="active">native-first</span></div>
</li>
</ul>
Variant: log
- …
<ul class="content-index" data-variant="log">…</ul>
A collection's listing — what MILL's live content route emits at a collection's index (/notes, /grain/docs): a ruled list of entries, each with a meta line, a linked title, a summary, and tag badges. CSS-only: MILL composes it; nothing data-binds it.
Listing
-
The Browser Grew Up
The honest ledger of what betting on the native platform bought me.
<ul class="content-index">
<li class="content-index__item">
<p class="content-index__meta">2026-07-04 · ~8 min</p>
<h2 class="content-index__title"><a href="/notes/the-browser-grew-up">The Browser Grew Up</a></h2>
<p class="content-index__summary">The honest ledger of what betting on the native platform bought me.</p>
<div class="note__tags"><span class="badge" data-status="active">native-first</span></div>
</li>
</ul>
Variant: log
- …
<ul class="content-index" data-variant="log">…</ul>
Molecules
Content source
The Rendered/Source toggle on a MILL entry page: "the site is its own source tree" made clickable. Composed from the tab atom (reuse, not a new control) — Rendered carries aria-current="page", Source links straight to the entry's raw .md route (MILL's honest-source route, ${prefix}/${slug}.md).
<nav class="content-source" aria-label="View">
<a class="tab" aria-current="page" href="/notes/the-browser-grew-up">Rendered</a>
<a class="tab" href="/notes/the-browser-grew-up.md">Source</a>
</nav>
The Rendered/Source toggle on a MILL entry page: "the site is its own source tree" made clickable. Composed from the tab atom (reuse, not a new control) — Rendered carries aria-current="page", Source links straight to the entry's raw .md route (MILL's honest-source route, ${prefix}/${slug}.md).
<nav class="content-source" aria-label="View">
<a class="tab" aria-current="page" href="/notes/the-browser-grew-up">Rendered</a>
<a class="tab" href="/notes/the-browser-grew-up.md">Source</a>
</nav>
Molecules
cv-bullet (portfolio)
One plain-text line: a résumé bullet, an education note, or a certification. Nested each="bullets" inside [cv-entry](../cv-entry/cv-entry.md), and reused at page level for the certifications list (each="cvCerts"). Kept as flat text on purpose so an ATS / résumé parser reads it without trouble. Styling lives in cv-entry.css (.cv-bullet).
<li class="cv-bullet">Manage and mentor a team of engineers, working AI-first to ship faster.</li>
One plain-text line: a résumé bullet, an education note, or a certification. Nested each="bullets" inside [cv-entry](../cv-entry/cv-entry.md), and reused at page level for the certifications list (each="cvCerts"). Kept as flat text on purpose so an ATS / résumé parser reads it without trouble. Styling lives in cv-entry.css (.cv-bullet).
<li class="cv-bullet">Manage and mentor a team of engineers, working AI-first to ship faster.</li>
Molecules
cv-chip (portfolio)
One headline "primary" skill pill, data-bound each="cvPrimary" (server.ts maps data/cv.json primarySkills to { text, href }). Shown as a .cv-core row above the full Skills grid on /resume and About's CV tab, so the eye lands on the headline skills before the full keyword list. The label is a nested <a>: a skill with an href links to its evidence (a note, /bread, /grain, a Lessons role) and reads as a solid pill; a skill with an empty href has the attribute omitted by the renderer, so :not([href]) keeps it a plain, dashed, unclickable pill. Unlike grain's chips form control it carries no input. In @media print it flattens to a plain comma list, decoration stripped, keeping the printed résumé ATS-plain.
<ul class="cv-core">
<li class="cv-chip"><a class="cv-chip__link" href="/notes/ten-times-zero">AI-first development</a></li>
<li class="cv-chip"><a class="cv-chip__link">Next</a></li>
</ul>
One headline "primary" skill pill, data-bound each="cvPrimary" (server.ts maps data/cv.json primarySkills to { text, href }). Shown as a .cv-core row above the full Skills grid on /resume and About's CV tab, so the eye lands on the headline skills before the full keyword list. The label is a nested <a>: a skill with an href links to its evidence (a note, /bread, /grain, a Lessons role) and reads as a solid pill; a skill with an empty href has the attribute omitted by the renderer, so :not([href]) keeps it a plain, dashed, unclickable pill. Unlike grain's chips form control it carries no input. In @media print it flattens to a plain comma list, decoration stripped, keeping the printed résumé ATS-plain.
<ul class="cv-core">
<li class="cv-chip"><a class="cv-chip__link" href="/notes/ten-times-zero">AI-first development</a></li>
<li class="cv-chip"><a class="cv-chip__link">Next</a></li>
</ul>
Molecules
cv-entry (portfolio)
One entry in the résumé timeline: a job or an education line. Data-bound via each="cvRoles" and reused for each="cvEducation" (server.ts builds both from data/cv.json into the same shape). Used on /resume, /cv, and /about's CV tab, so the CV content lives in exactly one place. Parent-context requirement: a direct child of <ol class="cv-list">. Accordion is a screen-only enhancement. The bullets and related links are always in the DOM. - No JS / static export: the detail renders open (flat, linear, ATS-readable); the toggle stays hidden. - Screen with JS: the cv-accordion island adds .is-collapsed and reveals the toggle, so each entry collapses to its summary line and expands on click. - Print (@media print in components/pages/resume/resume.css): detail forced open, toggle + links stripped, single column on white — a standard résumé an ATS parses cleanly. The related-posts row (each="links" -> cv-link) hides when the entry has no links; a role may gain several /calendar feed posts over time with no code change. The summary and location hide when empty.
-
Technical Team Lead
United States, remote
Both people-manager and technical lead for the team.
<ol class="cv-list">
<li class="cv-entry" id="xp-0" data-role-tag="ai">
<div class="cv-entry__head">
<h3 class="cv-entry__title">Technical Team Lead</h3>
<p class="cv-entry__meta"><span class="cv-entry__company">Career Team</span> <span class="cv-entry__dates">Sep 2025 to Present</span></p>
<p class="cv-entry__where">United States, remote</p>
<p class="cv-entry__summary">Both people-manager and technical lead for the team.</p>
<button class="cv-entry__toggle" type="button" hidden>Hide details</button>
</div>
<div class="cv-entry__detail">
<ul class="cv-entry__bullets"><!-- cv-bullet per bullet --></ul>
<ul class="cv-entry__links"><!-- cv-link per related post, or empty (hidden) --></ul>
</div>
</li>
</ol>
One entry in the résumé timeline: a job or an education line. Data-bound via each="cvRoles" and reused for each="cvEducation" (server.ts builds both from data/cv.json into the same shape). Used on /resume, /cv, and /about's CV tab, so the CV content lives in exactly one place. Parent-context requirement: a direct child of <ol class="cv-list">. Accordion is a screen-only enhancement. The bullets and related links are always in the DOM. - No JS / static export: the detail renders open (flat, linear, ATS-readable); the toggle stays hidden. - Screen with JS: the cv-accordion island adds .is-collapsed and reveals the toggle, so each entry collapses to its summary line and expands on click. - Print (@media print in components/pages/resume/resume.css): detail forced open, toggle + links stripped, single column on white — a standard résumé an ATS parses cleanly. The related-posts row (each="links" -> cv-link) hides when the entry has no links; a role may gain several /calendar feed posts over time with no code change. The summary and location hide when empty.
-
Technical Team Lead
United States, remote
Both people-manager and technical lead for the team.
<ol class="cv-list">
<li class="cv-entry" id="xp-0" data-role-tag="ai">
<div class="cv-entry__head">
<h3 class="cv-entry__title">Technical Team Lead</h3>
<p class="cv-entry__meta"><span class="cv-entry__company">Career Team</span> <span class="cv-entry__dates">Sep 2025 to Present</span></p>
<p class="cv-entry__where">United States, remote</p>
<p class="cv-entry__summary">Both people-manager and technical lead for the team.</p>
<button class="cv-entry__toggle" type="button" hidden>Hide details</button>
</div>
<div class="cv-entry__detail">
<ul class="cv-entry__bullets"><!-- cv-bullet per bullet --></ul>
<ul class="cv-entry__links"><!-- cv-link per related post, or empty (hidden) --></ul>
</div>
</li>
</ol>
Molecules
cv-link (portfolio)
One related post under a résumé entry, linking out to its /calendar social-feed page. Nested each="links" inside [cv-entry](../cv-entry/cv-entry.md), bound to { href, label }. An experience may carry several posts; the row hides when it has none, and it is stripped from the printed résumé. Styling lives in cv-entry.css (.cv-link).
<li class="cv-link-item"><a class="cv-link" href="/calendar/hackathon-coaching">Coached a hackathon team</a></li>
One related post under a résumé entry, linking out to its /calendar social-feed page. Nested each="links" inside [cv-entry](../cv-entry/cv-entry.md), bound to { href, label }. An experience may carry several posts; the row hides when it has none, and it is stripped from the printed résumé. Styling lives in cv-entry.css (.cv-link).
<li class="cv-link-item"><a class="cv-link" href="/calendar/hackathon-coaching">Coached a hackathon team</a></li>
Molecules
cv-photo (portfolio)
One optional experience photo under a résumé entry, nested each="photos" inside [cv-entry](../cv-entry/cv-entry.md), bound to { src, alt }. server.ts turns a role's optional photo path in data/cv.json into a 0..1 photos array: a role with a path renders one image, a role without renders nothing (the same gate as [cv-link](../cv-link/cv-link.md)'s links[]). Every role ships photoless today; add a photo (and optional photoAlt) to a role in cv.json later to fill the slot with no code change. It is a screen-only enhancement: @media print strips the whole media row so the printed/ATS résumé stays flat text. Styling lives in cv-entry.css (.cv-entry__media, .cv-photo).
<div class="cv-entry__media">
<img class="cv-photo" src="/media/feed/talk.svg" alt="Technical Team Lead, Career Team">
</div>
One optional experience photo under a résumé entry, nested each="photos" inside [cv-entry](../cv-entry/cv-entry.md), bound to { src, alt }. server.ts turns a role's optional photo path in data/cv.json into a 0..1 photos array: a role with a path renders one image, a role without renders nothing (the same gate as [cv-link](../cv-link/cv-link.md)'s links[]). Every role ships photoless today; add a photo (and optional photoAlt) to a role in cv.json later to fill the slot with no code change. It is a screen-only enhancement: @media print strips the whole media row so the printed/ATS résumé stays flat text. Styling lives in cv-entry.css (.cv-entry__media, .cv-photo).
<div class="cv-entry__media">
<img class="cv-photo" src="/media/feed/talk.svg" alt="Technical Team Lead, Career Team">
</div>
Molecules
cv-skill (portfolio)
One skill group in the résumé Skills grid, data-bound each="cvSkills". server.ts joins each group's items into a single itemsLabel string, so the printed and exported résumé shows the keywords as plain text an ATS can read. Parent-context requirement: a child of <div class="cv-skills"> (an auto-fill grid).
Frontend and Mobile
Next · React · Flutter · Dart
<div class="cv-skills">
<div class="cv-skill">
<h4 class="cv-skill__group">Frontend and Mobile</h4>
<p class="cv-skill__items">Next · React · Flutter · Dart</p>
</div>
</div>
One skill group in the résumé Skills grid, data-bound each="cvSkills". server.ts joins each group's items into a single itemsLabel string, so the printed and exported résumé shows the keywords as plain text an ATS can read. Parent-context requirement: a child of <div class="cv-skills"> (an auto-fill grid).
Frontend and Mobile
Next · React · Flutter · Dart
<div class="cv-skills">
<div class="cv-skill">
<h4 class="cv-skill__group">Frontend and Mobile</h4>
<p class="cv-skill__items">Next · React · Flutter · Dart</p>
</div>
</div>
Molecules
cv-stat (portfolio)
One KPI tile in the About highlights strip, data-bound each="cvStats" (server.ts maps data/cv.json stats). Reuses GRAIN's [stat-tile](../../../node_modules/@tjakoen/grain/components/molecules/stat-tile/stat-tile.md) class contract (.stat / .stat__value / .stat__label / .stat__sub) so the tiles read as the stack's own dashboard primitive. The parent .cv-stats owns the row layout (per stat-tile's composition note); the sub-line hides when empty.
<div class="cv-stats">
<div class="stat">
<span class="stat__value">150-300</span>
<span class="stat__label">students a term</span>
<span class="stat__sub">software engineering</span>
</div>
</div>
One KPI tile in the About highlights strip, data-bound each="cvStats" (server.ts maps data/cv.json stats). Reuses GRAIN's [stat-tile](../../../node_modules/@tjakoen/grain/components/molecules/stat-tile/stat-tile.md) class contract (.stat / .stat__value / .stat__label / .stat__sub) so the tiles read as the stack's own dashboard primitive. The parent .cv-stats owns the row layout (per stat-tile's composition note); the sub-line hides when empty.
<div class="cv-stats">
<div class="stat">
<span class="stat__value">150-300</span>
<span class="stat__label">students a term</span>
<span class="stat__sub">software engineering</span>
</div>
</div>
Molecules
Docs list (portfolio)
A ruled index of canonical docs — name + what it is, hairline rules between rows. Used on the layer landing pages (/batch) to point at the docs that live beside the code.
Docs list
<div class="docs-list">
<div class="docs-list__item"><span class="docs-list__name">ARCHITECTURE</span><span class="docs-list__what">the substrate's reasoning.</span></div>
<div class="docs-list__item"><span class="docs-list__name">CONVENTIONS</span><span class="docs-list__what">the build standard.</span></div>
</div>
A ruled index of canonical docs — name + what it is, hairline rules between rows. Used on the layer landing pages (/batch) to point at the docs that live beside the code.
Docs list
<div class="docs-list">
<div class="docs-list__item"><span class="docs-list__name">ARCHITECTURE</span><span class="docs-list__what">the substrate's reasoning.</span></div>
<div class="docs-list__item"><span class="docs-list__name">CONVENTIONS</span><span class="docs-list__what">the build standard.</span></div>
</div>
Molecules
feed-card (portfolio)
One post in the /calendar social feed. Data-bound via each="calendarEvents" (built in server.ts buildCalendarEvents, which merges three real sources: note publish dates, the hand-authored data/desk-feed.json "shipped" posts, and the MILL-authored events collection events/*.md). Every row is a real dated thing, never an invented event. Parent-context requirement: a direct child of <ol class="feed__list">. Images first, then text, the same shape as an event's own page (/calendar/<slug>), so a card and its page read identically. The root carries the fields the Month + Week islands read straight off this DOM (one source of truth, no fetch): data-date, data-event-kind, data-tags, and the #evt-… id a chip scrolls to. The date relativizes client-side to "N days ago" (absolute stays in title); with no JS it shows the absolute date, and the feed is the whole no-JS page. The photo strip, links row, location and tags each hide when their data is empty.
<ol class="feed__list">
<li class="feed-card" id="evt-event-hackathon-coaching" data-date="2026-07-08"
data-event-kind="hackathon" data-tags="hackathon coaching placeholder">
<div class="feed-photos"><!-- feed-photo per photo, or empty (hidden) --></div>
<div class="feed-card__body">
<p class="feed-card__meta">🗓️ Hackathon <time datetime="2026-07-08">2026-07-08</time> Placeholder venue</p>
<h3 class="feed-card__title"><a href="/calendar/hackathon-coaching">Coached a hackathon team (placeholder)</a></h3>
<p class="feed-card__summary">…</p>
<ul class="feed-card__links"><!-- feed-link per link, or empty (hidden) --></ul>
<p class="feed-card__tags">hackathon, coaching, placeholder</p>
</div>
</li>
</ol>
One post in the /calendar social feed. Data-bound via each="calendarEvents" (built in server.ts buildCalendarEvents, which merges three real sources: note publish dates, the hand-authored data/desk-feed.json "shipped" posts, and the MILL-authored events collection events/*.md). Every row is a real dated thing, never an invented event. Parent-context requirement: a direct child of <ol class="feed__list">. Images first, then text, the same shape as an event's own page (/calendar/<slug>), so a card and its page read identically. The root carries the fields the Month + Week islands read straight off this DOM (one source of truth, no fetch): data-date, data-event-kind, data-tags, and the #evt-… id a chip scrolls to. The date relativizes client-side to "N days ago" (absolute stays in title); with no JS it shows the absolute date, and the feed is the whole no-JS page. The photo strip, links row, location and tags each hide when their data is empty.
<ol class="feed__list">
<li class="feed-card" id="evt-event-hackathon-coaching" data-date="2026-07-08"
data-event-kind="hackathon" data-tags="hackathon coaching placeholder">
<div class="feed-photos"><!-- feed-photo per photo, or empty (hidden) --></div>
<div class="feed-card__body">
<p class="feed-card__meta">🗓️ Hackathon <time datetime="2026-07-08">2026-07-08</time> Placeholder venue</p>
<h3 class="feed-card__title"><a href="/calendar/hackathon-coaching">Coached a hackathon team (placeholder)</a></h3>
<p class="feed-card__summary">…</p>
<ul class="feed-card__links"><!-- feed-link per link, or empty (hidden) --></ul>
<p class="feed-card__tags">hackathon, coaching, placeholder</p>
</div>
</li>
</ol>
Molecules
feed-link (portfolio)
One related link under a feed card, nested each="links" inside feed-card (bound to an event's links: { href, label }). Parent-context requirement: a direct child of ul.feed-card__links. The row hides itself when an event has no links (.feed-card__links:empty { display: none }), so a card without links shows no empty affordance. Desk-feed "shipped" posts carry a link to what shipped; note events and placeholder events currently carry none.
<ul class="feed-card__links">
<li class="feed-card__link-item"><a class="feed-card__link" href="/proof">Open PROOF</a></li>
</ul>
One related link under a feed card, nested each="links" inside feed-card (bound to an event's links: { href, label }). Parent-context requirement: a direct child of ul.feed-card__links. The row hides itself when an event has no links (.feed-card__links:empty { display: none }), so a card without links shows no empty affordance. Desk-feed "shipped" posts carry a link to what shipped; note events and placeholder events currently carry none.
<ul class="feed-card__links">
<li class="feed-card__link-item"><a class="feed-card__link" href="/proof">Open PROOF</a></li>
</ul>
Molecules
feed-photo (portfolio)
One image in a feed card's photo strip, nested each="photos" inside feed-card (bound to an event's photos: { src, width, height, alt }). Parent-context requirement: a direct child of div.feed-photos. data-lightbox wires it to GRAIN's image viewer (scripts/lightbox.js): a click opens the full image in a <dialog> and walks every photo in the strip's data-lightbox-group (feed-card.html), the tiles hidden past the five-tile cap included. The href stays the full image, so with no JS (or a modified click) it degrades to a plain navigation — the no-JS-safe fallback. The bound width/height plus the CSS aspect-ratio reserve the box so a lazily-loaded photo can't shift the layout while you scroll. The .feed-photos strip is shared: the event page renders the same markup from its frontmatter (content.ts renderPhotoGrid, composed in shellChrome for /calendar entries), so a card and its own page read identically. Photos come from a flat "src | 1200x675 | alt" frontmatter string (parsePhotos), because MILL's frontmatter parser is flat; a grain proposal tracks first-class nested frontmatter to retire that encoding.
One image in a feed card's photo strip, nested each="photos" inside feed-card (bound to an event's photos: { src, width, height, alt }). Parent-context requirement: a direct child of div.feed-photos. data-lightbox wires it to GRAIN's image viewer (scripts/lightbox.js): a click opens the full image in a <dialog> and walks every photo in the strip's data-lightbox-group (feed-card.html), the tiles hidden past the five-tile cap included. The href stays the full image, so with no JS (or a modified click) it degrades to a plain navigation — the no-JS-safe fallback. The bound width/height plus the CSS aspect-ratio reserve the box so a lazily-loaded photo can't shift the layout while you scroll. The .feed-photos strip is shared: the event page renders the same markup from its frontmatter (content.ts renderPhotoGrid, composed in shellChrome for /calendar entries), so a card and its own page read identically. Photos come from a flat "src | 1200x675 | alt" frontmatter string (parsePhotos), because MILL's frontmatter parser is flat; a grain proposal tracks first-class nested frontmatter to retire that encoding.
Molecules
Figure
An image with an optional caption. The clipped variant gives the editorial clipped-photo edge (a hard corner clip, no border). MILL maps a standalone Markdown image to this; composed by hand, so nothing data-binds it. Images never overflow the column.
Caption
<figure class="figure">
<img src="/assets/figure-sample.svg" alt="An abstract field of grain strokes">
<figcaption class="figure__caption">A caption sits under the image, set small.</figcaption>
</figure>
Clipped photo
<figure class="figure" data-variant="clipped">
<img src="/assets/figure-sample.svg" alt="The same image, clipped">
</figure>
An image with an optional caption. The clipped variant gives the editorial clipped-photo edge (a hard corner clip, no border). MILL maps a standalone Markdown image to this; composed by hand, so nothing data-binds it. Images never overflow the column.
Caption
<figure class="figure">
<img src="/assets/figure-sample.svg" alt="An abstract field of grain strokes">
<figcaption class="figure__caption">A caption sits under the image, set small.</figcaption>
</figure>
Clipped photo
<figure class="figure" data-variant="clipped">
<img src="/assets/figure-sample.svg" alt="The same image, clipped">
</figure>
Molecules
flow (portfolio)
A small left-to-right diagram of boxed nodes joined by arrows — a dependency chain or a pipeline. CSS-only layout pattern (no .html): compose the markup inline. Wraps on narrow widths; tokens only. Variants: flow__node--door (heavier border, for the emphasised step), flow__node--code (grain face). A <small> inside a node is muted sub-text.
<div class="flow" aria-hidden="true">
<div class="flow__node">BATCH<br><small>substrate</small></div>
<div class="flow__arrow">→</div>
<div class="flow__node">GRAIN<br><small>design system</small></div>
<div class="flow__arrow">→</div>
<div class="flow__node">MILL<br><small>content engine</small></div>
</div>
A small left-to-right diagram of boxed nodes joined by arrows — a dependency chain or a pipeline. CSS-only layout pattern (no .html): compose the markup inline. Wraps on narrow widths; tokens only. Variants: flow__node--door (heavier border, for the emphasised step), flow__node--code (grain face). A <small> inside a node is muted sub-text.
<div class="flow" aria-hidden="true">
<div class="flow__node">BATCH<br><small>substrate</small></div>
<div class="flow__arrow">→</div>
<div class="flow__node">GRAIN<br><small>design system</small></div>
<div class="flow__arrow">→</div>
<div class="flow__node">MILL<br><small>content engine</small></div>
</div>
Molecules
Lede
The opening paragraph of a page or section — larger, muted, held to a readable measure (56ch). One class, composed by hand under a masthead; emphasis inside it reads as an underline, not italic (state is ink, not hue).
Lede
A no-build, server-rendered hypermedia substrate. The browser gets near-zero framework JavaScript; the runtime reads the source directly.
<p class="lede">A <em>no-build, server-rendered</em> hypermedia substrate. The browser gets
near-zero framework JavaScript; the runtime reads the source directly.</p>
Small (a quiet aside)
An in-browser demo is on the way — a tiny model running client-side, no backend, no key.
<p class="lede" data-size="sm">An in-browser demo is on the way — a tiny model running
client-side, no backend, no key.</p>
The opening paragraph of a page or section — larger, muted, held to a readable measure (56ch). One class, composed by hand under a masthead; emphasis inside it reads as an underline, not italic (state is ink, not hue).
Lede
A no-build, server-rendered hypermedia substrate. The browser gets near-zero framework JavaScript; the runtime reads the source directly.
<p class="lede">A <em>no-build, server-rendered</em> hypermedia substrate. The browser gets
near-zero framework JavaScript; the runtime reads the source directly.</p>
Small (a quiet aside)
An in-browser demo is on the way — a tiny model running client-side, no backend, no key.
<p class="lede" data-size="sm">An in-browser demo is on the way — a tiny model running
client-side, no backend, no key.</p>
Molecules
made-with
The system byline: made with GRAIN by tjakoen. One quiet mono line, mounted at the bottom of every GRAIN app's shell, so provenance reads identically across the fleet. Links: GRAIN → the design-system docs home, tjakoen → the portfolio. CSS-only (no .html) — but the content is centralized too: server-side template-literal shells import the string helper so the line can never drift per app: ``ts import { madeWith } from "@tjakoen/grain/scripts/made-with.js"; // block form — a standalone page footer body += madeWith(); // inline form — inside a status-bar row <footer class="app-shell__status status-bar">…<span class="status-bar__spacer"></span>${madeWith({ inline: true })}</footer> `` Canonical markup (what the helper emits):
<footer class="made-with">
made with <a href="https://tjakoen.github.io/grain">GRAIN</a>
by <a href="https://tjakoen.github.io">tjakoen</a>
</footer>
The system byline: made with GRAIN by tjakoen. One quiet mono line, mounted at the bottom of every GRAIN app's shell, so provenance reads identically across the fleet. Links: GRAIN → the design-system docs home, tjakoen → the portfolio. CSS-only (no .html) — but the content is centralized too: server-side template-literal shells import the string helper so the line can never drift per app: ``ts import { madeWith } from "@tjakoen/grain/scripts/made-with.js"; // block form — a standalone page footer body += madeWith(); // inline form — inside a status-bar row <footer class="app-shell__status status-bar">…<span class="status-bar__spacer"></span>${madeWith({ inline: true })}</footer> `` Canonical markup (what the helper emits):
<footer class="made-with">
made with <a href="https://tjakoen.github.io/grain">GRAIN</a>
by <a href="https://tjakoen.github.io">tjakoen</a>
</footer>
Molecules
mail-folder (portfolio)
One folder in the /mail rail. Data-bound via each="mailFolders" (built in server.ts from data/mailbox.json): { id, label, count }, where count is computed server-side as the number of messages in that folder, so the badge can never drift from the list it describes. Parent-context requirement: a direct child of nav.mailbox__folders. The rail box owns the border and background; this molecule is just the row. href="#mailbox-list" is a harmless in-page jump kept for the no-JS path (the list is already on the page). With JS, the mailbox island owns aria-current="page" (it moves to whichever folder you click and filters the list to it); the server renders no current folder, so with no JS every folder's messages stay visible — nothing real is gated. The count itself can also move: archiving a letter (mail-reader's Archive button) recomputes every folder's .mailbox__folder-count from the live DOM, so Inbox's count drops and Archive's rises the moment a letter changes folder, still never drifting from the list it describes.
<nav class="mailbox__folders" data-mailbox-folders aria-label="Folders">
<a class="mailbox__folder" href="#mailbox-list" data-folder="inbox">
<span class="mailbox__folder-label">Inbox</span>
<span class="mailbox__folder-count">3</span>
</a>
<!-- … one per folder … -->
<a class="btn mailbox__compose" href="#compose" data-open-compose>Compose</a>
</nav>
One folder in the /mail rail. Data-bound via each="mailFolders" (built in server.ts from data/mailbox.json): { id, label, count }, where count is computed server-side as the number of messages in that folder, so the badge can never drift from the list it describes. Parent-context requirement: a direct child of nav.mailbox__folders. The rail box owns the border and background; this molecule is just the row. href="#mailbox-list" is a harmless in-page jump kept for the no-JS path (the list is already on the page). With JS, the mailbox island owns aria-current="page" (it moves to whichever folder you click and filters the list to it); the server renders no current folder, so with no JS every folder's messages stay visible — nothing real is gated. The count itself can also move: archiving a letter (mail-reader's Archive button) recomputes every folder's .mailbox__folder-count from the live DOM, so Inbox's count drops and Archive's rises the moment a letter changes folder, still never drifting from the list it describes.
<nav class="mailbox__folders" data-mailbox-folders aria-label="Folders">
<a class="mailbox__folder" href="#mailbox-list" data-folder="inbox">
<span class="mailbox__folder-label">Inbox</span>
<span class="mailbox__folder-count">3</span>
</a>
<!-- … one per folder … -->
<a class="btn mailbox__compose" href="#compose" data-open-compose>Compose</a>
</nav>
Molecules
mail-reader (portfolio)
One open message below the /mail list. Data-bound via each="mailMessages" (built in server.ts from data/mailbox.json). Parent-context requirement: a direct child of .mailbox__readers. The body is plain text by design: batch's bindings escape HTML, so a message body can't carry inline anchors. It renders in a single block with white-space: pre-line, so the \n\n paragraph breaks in the JSON become the visible spacing. Any link a message wants surfaces in a Related row below the body (mail-related, nested each="links"), which hides itself when a message has no links (.mailbox__reader-links:empty { display: none }). Reply / Forward stay honest set dressing everywhere: disabled, with a title that says so. Archive is server-rendered disabled too (so a no-JS reader makes no archive claim), but carries data-mail-archive. The mailbox island enables it — removing disabled and swapping the title to say what it now does — on any reader whose data-folder is "inbox"; sent/drafts/archive readers keep it disabled. Clicking it moves that letter to the Archive folder (row and reader data-folder become "archive", sessionStorage remembers it for the rest of the tab, rail counts and the current folder filter re-settle, and the button itself falls back to disabled dressing) — see mail.html's island script for the full move. With JS, the mailbox island also shows one reader at a time (keyed to the selected row) and marks the row read. With no JS, every reader is visible as a stacked letters page and nothing is gated except Archive, which needs the island to mean anything.
<article class="mailbox__reader" data-message id="msg-welcome" data-folder="inbox">
<div class="mailbox__reader-head"><h2 class="mailbox__reader-subject">Welcome to the mail panel</h2></div>
<p class="mailbox__reader-meta">The Desk → You · Jul 14, 2026</p>
<div class="mailbox__reader-tools"><!-- Reply / Forward disabled; Archive disabled here, live once JS enables it on inbox readers --></div>
<div class="mailbox__reader-body">This whole panel is dressed up like a real inbox…</div>
<ul class="mailbox__reader-links"><!-- mail-related per link, or empty (hidden) --></ul>
</article>
One open message below the /mail list. Data-bound via each="mailMessages" (built in server.ts from data/mailbox.json). Parent-context requirement: a direct child of .mailbox__readers. The body is plain text by design: batch's bindings escape HTML, so a message body can't carry inline anchors. It renders in a single block with white-space: pre-line, so the \n\n paragraph breaks in the JSON become the visible spacing. Any link a message wants surfaces in a Related row below the body (mail-related, nested each="links"), which hides itself when a message has no links (.mailbox__reader-links:empty { display: none }). Reply / Forward stay honest set dressing everywhere: disabled, with a title that says so. Archive is server-rendered disabled too (so a no-JS reader makes no archive claim), but carries data-mail-archive. The mailbox island enables it — removing disabled and swapping the title to say what it now does — on any reader whose data-folder is "inbox"; sent/drafts/archive readers keep it disabled. Clicking it moves that letter to the Archive folder (row and reader data-folder become "archive", sessionStorage remembers it for the rest of the tab, rail counts and the current folder filter re-settle, and the button itself falls back to disabled dressing) — see mail.html's island script for the full move. With JS, the mailbox island also shows one reader at a time (keyed to the selected row) and marks the row read. With no JS, every reader is visible as a stacked letters page and nothing is gated except Archive, which needs the island to mean anything.
Welcome to the mail panel
<article class="mailbox__reader" data-message id="msg-welcome" data-folder="inbox">
<div class="mailbox__reader-head"><h2 class="mailbox__reader-subject">Welcome to the mail panel</h2></div>
<p class="mailbox__reader-meta">The Desk → You · Jul 14, 2026</p>
<div class="mailbox__reader-tools"><!-- Reply / Forward disabled; Archive disabled here, live once JS enables it on inbox readers --></div>
<div class="mailbox__reader-body">This whole panel is dressed up like a real inbox…</div>
<ul class="mailbox__reader-links"><!-- mail-related per link, or empty (hidden) --></ul>
</article>
Molecules
mail-row (portfolio)
One message row in the /mail list. Data-bound via each="mailMessages" (built in server.ts from data/mailbox.json). Every message is set dressing (FROM "The Desk" TO the visitor, hand-authored ahead of time), so a row is a link to its own reader below (href="#msg-<id>"), never a fetch. Parent-context requirement: a direct child of .mailbox__list (the dense grid + separators are keyed to that container). Columns: status dot, from, subject, when; the snippet spans the width below. Two client-only touches, both degrading cleanly to nothing: - Unread dot. .mailbox__item-dot is transparent until the island adds .is-unread. Read state lives only in localStorage (tj.mail.read), so the server renders zero dots and a no-JS page makes no read-tracking claim at all. - Relative date. .mailbox__item-when is server-rendered absolute (Jul 14) with the full date in title and a machine date in data-date; the island rewrites [data-relativize] spans to "N days ago". Undated rows (Sent, Drafts) carry a literal label (Not sent, While you type) and no data-date, so they're left alone. A third one isn't cosmetic: the row also carries an AI target address, data-surface="item:mail-<id>" (computed server-side alongside href/domId), plus data-kind="item" and data-accepts="item.archive" so it's harvested as operable markup the same way other AI-addressable rows are (AI-INTERFACE §4). Archiving a letter through that surface and through the reader's own Archive button (mail-reader) are the same move: both flip data-folder to "archive" for the rest of the visit.
<a class="mailbox__item" href="#msg-welcome" data-folder="inbox"
data-kind="item" data-accepts="item.archive" data-surface="item:mail-welcome">
<span class="mailbox__item-dot" aria-hidden="true"></span>
<span class="mailbox__item-from">The Desk</span>
<span class="mailbox__item-subject">Welcome to the mail panel</span>
<span class="mailbox__item-when" data-relativize data-date="2026-07-14" title="Jul 14, 2026">Jul 14</span>
<span class="mailbox__item-snippet">What's real here, what isn't…</span>
</a>
One message row in the /mail list. Data-bound via each="mailMessages" (built in server.ts from data/mailbox.json). Every message is set dressing (FROM "The Desk" TO the visitor, hand-authored ahead of time), so a row is a link to its own reader below (href="#msg-<id>"), never a fetch. Parent-context requirement: a direct child of .mailbox__list (the dense grid + separators are keyed to that container). Columns: status dot, from, subject, when; the snippet spans the width below. Two client-only touches, both degrading cleanly to nothing: - Unread dot. .mailbox__item-dot is transparent until the island adds .is-unread. Read state lives only in localStorage (tj.mail.read), so the server renders zero dots and a no-JS page makes no read-tracking claim at all. - Relative date. .mailbox__item-when is server-rendered absolute (Jul 14) with the full date in title and a machine date in data-date; the island rewrites [data-relativize] spans to "N days ago". Undated rows (Sent, Drafts) carry a literal label (Not sent, While you type) and no data-date, so they're left alone. A third one isn't cosmetic: the row also carries an AI target address, data-surface="item:mail-<id>" (computed server-side alongside href/domId), plus data-kind="item" and data-accepts="item.archive" so it's harvested as operable markup the same way other AI-addressable rows are (AI-INTERFACE §4). Archiving a letter through that surface and through the reader's own Archive button (mail-reader) are the same move: both flip data-folder to "archive" for the rest of the visit.
<a class="mailbox__item" href="#msg-welcome" data-folder="inbox"
data-kind="item" data-accepts="item.archive" data-surface="item:mail-welcome">
<span class="mailbox__item-dot" aria-hidden="true"></span>
<span class="mailbox__item-from">The Desk</span>
<span class="mailbox__item-subject">Welcome to the mail panel</span>
<span class="mailbox__item-when" data-relativize data-date="2026-07-14" title="Jul 14, 2026">Jul 14</span>
<span class="mailbox__item-snippet">What's real here, what isn't…</span>
</a>
Molecules
note-card (portfolio)
The /notes feed's card: a Reddit-ish read of the notes collection (vote glyph, title, summary, tags, a "sections" link standing in for a comment count). CSS-only (grain lesson 3: a CSS-only component must state its parent-context requirement here) — there is no note-card.html because the markup is server-composed directly in content.ts's renderNotesFeedPage, not a batch template. note-card.css is auto-bundled through config.styleRoots (this repo's components/ root) with no manual registration; nothing data-binds it. Parent-context requirement: every .note-card must be a direct child of a <ul class="note-feed"> (the vote-glyph gutter + separators are keyed to that flex-list layout, and the feed's inline island reorders/hides .note-feed > .note-card nodes by that exact shape). The vote-glyph number is real reading minutes, parsed server-side from the note's own readingTime frontmatter — it is never a vote count, and the element's own title="" attribute says so for anyone hovering it.
-
I Built a Whole Stack Because No To-Do App Would Have Me
How trying to organize my own life turned into a no-build stack…
<ul class="note-feed">
<li class="note-card" data-surface="note:origin-story" data-date="2026-07-02" data-score="9" data-tags="origin-story batch grain">
<div class="note-card__vote" aria-hidden="true" title="Reading minutes, from the note's own frontmatter. Not votes.">
▲<span class="note-card__score">9</span>
</div>
<div class="note-card__main">
<p class="note-card__byline">Tjakoen Stolk · the desk · <time datetime="2026-07-02">2026-07-02</time></p>
<h2 class="note-card__title"><a href="/notes/origin-story">I Built a Whole Stack Because No To-Do App Would Have Me</a></h2>
<p class="note-card__summary">How trying to organize my own life turned into a no-build stack…</p>
<p class="note-card__foot">
<span class="note__tags"><span class="badge" data-status="active">origin-story</span></span>
<a class="note-card__sections" href="/notes/origin-story">4 sections</a>
</p>
</div>
</li>
</ul>
The /notes feed's card: a Reddit-ish read of the notes collection (vote glyph, title, summary, tags, a "sections" link standing in for a comment count). CSS-only (grain lesson 3: a CSS-only component must state its parent-context requirement here) — there is no note-card.html because the markup is server-composed directly in content.ts's renderNotesFeedPage, not a batch template. note-card.css is auto-bundled through config.styleRoots (this repo's components/ root) with no manual registration; nothing data-binds it. Parent-context requirement: every .note-card must be a direct child of a <ul class="note-feed"> (the vote-glyph gutter + separators are keyed to that flex-list layout, and the feed's inline island reorders/hides .note-feed > .note-card nodes by that exact shape). The vote-glyph number is real reading minutes, parsed server-side from the note's own readingTime frontmatter — it is never a vote count, and the element's own title="" attribute says so for anyone hovering it.
-
I Built a Whole Stack Because No To-Do App Would Have Me
How trying to organize my own life turned into a no-build stack…
<ul class="note-feed">
<li class="note-card" data-surface="note:origin-story" data-date="2026-07-02" data-score="9" data-tags="origin-story batch grain">
<div class="note-card__vote" aria-hidden="true" title="Reading minutes, from the note's own frontmatter. Not votes.">
▲<span class="note-card__score">9</span>
</div>
<div class="note-card__main">
<p class="note-card__byline">Tjakoen Stolk · the desk · <time datetime="2026-07-02">2026-07-02</time></p>
<h2 class="note-card__title"><a href="/notes/origin-story">I Built a Whole Stack Because No To-Do App Would Have Me</a></h2>
<p class="note-card__summary">How trying to organize my own life turned into a no-build stack…</p>
<p class="note-card__foot">
<span class="note__tags"><span class="badge" data-status="active">origin-story</span></span>
<a class="note-card__sections" href="/notes/origin-story">4 sections</a>
</p>
</div>
</li>
</ul>
Molecules
Page foot (portfolio)
The byline footer that closes every landing page — a ruled, muted sign-off with the "built with" line. Links keep full ink so they read against the muted prose.
Page foot
🤖 Built with Claude. I don't prompt and pray, I prompt and prove.
<div class="page-foot">
Part of <a href="/">tjakoen.github.io</a>, built with itself.
<p>🤖 Built with Claude. I don't prompt and pray, I prompt and prove.</p>
</div>
The byline footer that closes every landing page — a ruled, muted sign-off with the "built with" line. Links keep full ink so they read against the muted prose.
Page foot
🤖 Built with Claude. I don't prompt and pray, I prompt and prove.
<div class="page-foot">
Part of <a href="/">tjakoen.github.io</a>, built with itself.
<p>🤖 Built with Claude. I don't prompt and pray, I prompt and prove.</p>
</div>
Molecules
profile-card
A person header: an avatar tile, an id block (eyebrow · masthead name · role · mono tagline), and an actions group. Shared by /about and /resume. CSS-only (no .html) — author the markup per page:
About
Name
Role line.
Tagline.
<header class="profile-card">
<div class="profile-card__avatar" aria-hidden="true">🧑💻</div>
<div class="profile-card__id">
<p class="eyebrow">About</p>
<h1 class="masthead profile-card__name">Name</h1>
<p class="profile-card__role">Role line.</p>
<p class="profile-card__tagline">Tagline.</p>
</div>
<div class="profile-card__actions"><a class="btn" href="…">Action</a></div>
</header>
A person header: an avatar tile, an id block (eyebrow · masthead name · role · mono tagline), and an actions group. Shared by /about and /resume. CSS-only (no .html) — author the markup per page:
About
Name
Role line.
Tagline.
<header class="profile-card">
<div class="profile-card__avatar" aria-hidden="true">🧑💻</div>
<div class="profile-card__id">
<p class="eyebrow">About</p>
<h1 class="masthead profile-card__name">Name</h1>
<p class="profile-card__role">Role line.</p>
<p class="profile-card__tagline">Tagline.</p>
</div>
<div class="profile-card__actions"><a class="btn" href="…">Action</a></div>
</header>
Molecules
Shot (portfolio)
A framed screenshot of a real running interface, with a caption underneath. Used on project pages where the argument is "here is the thing actually working", so the capture has to stay unretouched. Distinct from the figures in FIGURES: those are tokenized inline SVG and must survive the static export with no external assets. A shot is a raster capture of a live app, served from /media. Always set loading="lazy" plus width and height on the image, so the page reserves the space and a screenshot never blocks first paint.
Shot
<figure class="shot">
<img class="shot__img" src="/media/greenroom/run-matrix.jpg" width="1440" height="900"
loading="lazy" alt="A run dashboard showing six passed and two failed checks across two environments.">
<figcaption class="shot__cap">One run, two environments, per-environment health.</figcaption>
</figure>
A framed screenshot of a real running interface, with a caption underneath. Used on project pages where the argument is "here is the thing actually working", so the capture has to stay unretouched. Distinct from the figures in FIGURES: those are tokenized inline SVG and must survive the static export with no external assets. A shot is a raster capture of a live app, served from /media. Always set loading="lazy" plus width and height on the image, so the page reserves the space and a screenshot never blocks first paint.
Shot
<figure class="shot">
<img class="shot__img" src="/media/greenroom/run-matrix.jpg" width="1440" height="900"
loading="lazy" alt="A run dashboard showing six passed and two failed checks across two environments.">
<figcaption class="shot__cap">One run, two environments, per-environment health.</figcaption>
</figure>
Molecules
Stack Diagram
The shared "four layers, one direction" figure that opens every BREAD layer page. One authored SVG, used once per page with a current prop; the matching layer is highlighted so a reader lands already knowing where they are in the stack. Built to the FIGURES geometry (the 0 0 620 H canvas, width="100%", role="img" + a spoken aria-label, serif, the 15/14/12.5 type scale) but deliberately re-colored from the live theme tokens instead of the fixed FIGURES palette, so it flips with the site theme (sourdough / baguette / brioche, light ↔ dark). No chromatic accent; the only emphasis is ink-fill vs outline. No client JS.
The model it draws
The `current` prop (the highlight)
<stack-diagram current="grain"></stack-diagram> <!-- GRAIN ink-filled, the rest outlined -->
<stack-diagram current="pantry"></stack-diagram> <!-- the PANTRY frame emphasized -->
<stack-diagram></stack-diagram> <!-- /bread overview: nothing singled out -->
Placement
Notes
The shared "four layers, one direction" figure that opens every BREAD layer page. One authored SVG, used once per page with a current prop; the matching layer is highlighted so a reader lands already knowing where they are in the stack. Built to the FIGURES geometry (the 0 0 620 H canvas, width="100%", role="img" + a spoken aria-label, serif, the 15/14/12.5 type scale) but deliberately re-colored from the live theme tokens instead of the fixed FIGURES palette, so it flips with the site theme (sourdough / baguette / brioche, light ↔ dark). No chromatic accent; the only emphasis is ink-fill vs outline. No client JS.
The model it draws
The `current` prop (the highlight)
<stack-diagram current="grain"></stack-diagram> <!-- GRAIN ink-filled, the rest outlined -->
<stack-diagram current="pantry"></stack-diagram> <!-- the PANTRY frame emphasized -->
<stack-diagram></stack-diagram> <!-- /bread overview: nothing singled out -->
Placement
Notes
Molecules
Stat tile
A KPI tile — one big number, a small label, an optional sub-line. The standard dashboard primitive (a stats strip, a run summary, a cockpit home). Monochrome: tone is ink-level + border weight, never hue. Composition note: tiles are laid out by their parent, not by a wrapper component — drop them in a plain flex or grid row (display: flex; gap: var(--space-3), or a grid-template-columns: repeat(auto-fit, minmax(…, 1fr))). Keep the .stat itself layout-free so it fits any strip. The value sits at display scale so, when an AI writes it, the grain texture reads — put data-grade="grain" on the .stat__value while the AI is composing it; a tile whose number is mid-update wears data-commit="pending" (the shared in-transit idiom), not a bespoke "loading" state.
Default
<div class="stat">
<span class="stat__value">128</span>
<span class="stat__label">total</span>
</div>
With a sub-line
<div class="stat">
<span class="stat__value">128</span>
<span class="stat__label">total</span>
<span class="stat__sub">across 4 environments</span>
</div>
Tones
<div class="stat" data-tone="ok"><span class="stat__value">14</span><span class="stat__label">passed</span></div>
<div class="stat" data-tone="bad"><span class="stat__value">2</span><span class="stat__label">failed</span></div>
<div class="stat" data-tone="muted"><span class="stat__value">3</span><span class="stat__label">skipped</span></div>
In transit (AI writing the number)
<div class="stat" data-commit="pending">
<span class="stat__value" data-grade="grain">…</span>
<span class="stat__label">passed</span>
</div>
A KPI tile — one big number, a small label, an optional sub-line. The standard dashboard primitive (a stats strip, a run summary, a cockpit home). Monochrome: tone is ink-level + border weight, never hue. Composition note: tiles are laid out by their parent, not by a wrapper component — drop them in a plain flex or grid row (display: flex; gap: var(--space-3), or a grid-template-columns: repeat(auto-fit, minmax(…, 1fr))). Keep the .stat itself layout-free so it fits any strip. The value sits at display scale so, when an AI writes it, the grain texture reads — put data-grade="grain" on the .stat__value while the AI is composing it; a tile whose number is mid-update wears data-commit="pending" (the shared in-transit idiom), not a bespoke "loading" state.
Default
<div class="stat">
<span class="stat__value">128</span>
<span class="stat__label">total</span>
</div>
With a sub-line
<div class="stat">
<span class="stat__value">128</span>
<span class="stat__label">total</span>
<span class="stat__sub">across 4 environments</span>
</div>
Tones
<div class="stat" data-tone="ok"><span class="stat__value">14</span><span class="stat__label">passed</span></div>
<div class="stat" data-tone="bad"><span class="stat__value">2</span><span class="stat__label">failed</span></div>
<div class="stat" data-tone="muted"><span class="stat__value">3</span><span class="stat__label">skipped</span></div>
In transit (AI writing the number)
<div class="stat" data-commit="pending">
<span class="stat__value" data-grade="grain">…</span>
<span class="stat__label">passed</span>
</div>
Molecules
Status list
A list where each row carries a leading status mark, a title, and trailing meta — a live pass/fail checklist, a job queue, a deploy pipeline, a task run-down. b-list is a prose-marker list; this is a three-slot structural row. Composition note: an item is a <li class="status-list__item"> inside a <ul class="status-list">, with three children — .status-list__mark (a glyph, a b-badge, or a b-icon), .status-list__title, and .status-list__meta. The mark carries the signal (monochrome, currentColor) — the component bakes in no domain status colours. The load-bearing idea: an in-flight row wears data-commit="pending" — a dashed leading edge + a blinking caret trailing the title — and settles on result. That's the grain control lifecycle (the row a runner/AI is acting on), not a bespoke "running" state. A not-yet-reached row reads faint via data-state="waiting". Rows take data-surface="…" so an AI can address one row at a time (a plain attribute hook — it adds no surface kind to ai/contract.ts).
Settled rows
- ✓ login works
- ✕ checkout completes
<ul class="status-list">
<li class="status-list__item" data-surface="row:login">
<span class="status-list__mark">✓</span>
<span class="status-list__title">login works</span>
<span class="status-list__meta">2.1s · staging</span>
</li>
<li class="status-list__item" data-surface="row:checkout">
<span class="status-list__mark">✕</span>
<span class="status-list__title">checkout completes</span>
<span class="status-list__meta">4.8s · staging</span>
</li>
</ul>
Waiting + in-flight
- ○ search returns results
- ◐ profile loads
<ul class="status-list">
<li class="status-list__item" data-state="waiting">
<span class="status-list__mark">○</span>
<span class="status-list__title">search returns results</span>
<span class="status-list__meta"></span>
</li>
<li class="status-list__item" data-commit="pending">
<span class="status-list__mark">◐</span>
<span class="status-list__title">profile loads</span>
<span class="status-list__meta">staging</span>
</li>
</ul>
With a badge mark
- ok deploy · api
<ul class="status-list">
<li class="status-list__item">
<span class="status-list__mark"><span class="badge" data-status="active">ok</span></span>
<span class="status-list__title">deploy · api</span>
<span class="status-list__meta">1m 12s</span>
</li>
</ul>
A list where each row carries a leading status mark, a title, and trailing meta — a live pass/fail checklist, a job queue, a deploy pipeline, a task run-down. b-list is a prose-marker list; this is a three-slot structural row. Composition note: an item is a <li class="status-list__item"> inside a <ul class="status-list">, with three children — .status-list__mark (a glyph, a b-badge, or a b-icon), .status-list__title, and .status-list__meta. The mark carries the signal (monochrome, currentColor) — the component bakes in no domain status colours. The load-bearing idea: an in-flight row wears data-commit="pending" — a dashed leading edge + a blinking caret trailing the title — and settles on result. That's the grain control lifecycle (the row a runner/AI is acting on), not a bespoke "running" state. A not-yet-reached row reads faint via data-state="waiting". Rows take data-surface="…" so an AI can address one row at a time (a plain attribute hook — it adds no surface kind to ai/contract.ts).
Settled rows
- ✓ login works
- ✕ checkout completes
<ul class="status-list">
<li class="status-list__item" data-surface="row:login">
<span class="status-list__mark">✓</span>
<span class="status-list__title">login works</span>
<span class="status-list__meta">2.1s · staging</span>
</li>
<li class="status-list__item" data-surface="row:checkout">
<span class="status-list__mark">✕</span>
<span class="status-list__title">checkout completes</span>
<span class="status-list__meta">4.8s · staging</span>
</li>
</ul>
Waiting + in-flight
- ○ search returns results
- ◐ profile loads
<ul class="status-list">
<li class="status-list__item" data-state="waiting">
<span class="status-list__mark">○</span>
<span class="status-list__title">search returns results</span>
<span class="status-list__meta"></span>
</li>
<li class="status-list__item" data-commit="pending">
<span class="status-list__mark">◐</span>
<span class="status-list__title">profile loads</span>
<span class="status-list__meta">staging</span>
</li>
</ul>
With a badge mark
- ok deploy · api
<ul class="status-list">
<li class="status-list__item">
<span class="status-list__mark"><span class="badge" data-status="active">ok</span></span>
<span class="status-list__title">deploy · api</span>
<span class="status-list__meta">1m 12s</span>
</li>
</ul>
Molecules
Tab
One open view in the tab-bar, editor-style: a boxed tab with an optional icon (.tab__icon), a mono label, and an optional close affordance (.tab__close). The active tab carries a top accent line and merges into its pane. The close is presentational by default (pointer-events off; a click lands on the tab's link) — wire it as a real button only when views are genuinely closable.
States
<a class="tab" aria-current="page"><span class="tab__icon"><b-icon sym="/assets/sprite.svg#tasks" size="sm"></b-icon></span>Overview<span class="tab__close" aria-hidden="true"><b-icon sym="/assets/sprite.svg#close" size="sm"></b-icon></span></a>
<a class="tab"><span class="tab__icon"><b-icon sym="/assets/sprite.svg#loop" size="sm"></b-icon></span>Tasks<span class="tab__close" aria-hidden="true"><b-icon sym="/assets/sprite.svg#close" size="sm"></b-icon></span></a>
<a class="tab">Library · notes</a>
<a class="tab" data-pinned aria-current="page"><span class="tab__icon"><b-icon sym="/assets/sprite.svg#spark" size="sm"></b-icon></span>Welcome<span class="tab__pin" aria-hidden="true"><b-icon sym="/assets/sprite.svg#pin" size="sm"></b-icon></span></a>
One open view in the tab-bar, editor-style: a boxed tab with an optional icon (.tab__icon), a mono label, and an optional close affordance (.tab__close). The active tab carries a top accent line and merges into its pane. The close is presentational by default (pointer-events off; a click lands on the tab's link) — wire it as a real button only when views are genuinely closable.
States
<a class="tab" aria-current="page"><span class="tab__icon"><b-icon sym="/assets/sprite.svg#tasks" size="sm"></b-icon></span>Overview<span class="tab__close" aria-hidden="true"><b-icon sym="/assets/sprite.svg#close" size="sm"></b-icon></span></a>
<a class="tab"><span class="tab__icon"><b-icon sym="/assets/sprite.svg#loop" size="sm"></b-icon></span>Tasks<span class="tab__close" aria-hidden="true"><b-icon sym="/assets/sprite.svg#close" size="sm"></b-icon></span></a>
<a class="tab">Library · notes</a>
<a class="tab" data-pinned aria-current="page"><span class="tab__icon"><b-icon sym="/assets/sprite.svg#spark" size="sm"></b-icon></span>Welcome<span class="tab__pin" aria-hidden="true"><b-icon sym="/assets/sprite.svg#pin" size="sm"></b-icon></span></a>
Molecules
Table
A content data table — MILL maps Markdown pipe tables (| a | b | + |---|---|) to it. Ruled and monochrome, not zebra-striped; the .table-scroll wrapper keeps wide tables scrolling inside their own box. CSS-only: composed by MILL or written by hand.
Table
| You change… | …also update |
|---|---|
| An action verb | contract.ts → reasoner → tests → docs |
| A design token | grain/styles/variables.css only |
<div class="table-scroll">
<table class="table">
<thead><tr><th>You change…</th><th>…also update</th></tr></thead>
<tbody>
<tr><td>An action verb</td><td>contract.ts → reasoner → tests → docs</td></tr>
<tr><td>A design token</td><td>grain/styles/variables.css only</td></tr>
</tbody>
</table>
</div>
A content data table — MILL maps Markdown pipe tables (| a | b | + |---|---|) to it. Ruled and monochrome, not zebra-striped; the .table-scroll wrapper keeps wide tables scrolling inside their own box. CSS-only: composed by MILL or written by hand.
Table
| You change… | …also update |
|---|---|
| An action verb | contract.ts → reasoner → tests → docs |
| A design token | grain/styles/variables.css only |
<div class="table-scroll">
<table class="table">
<thead><tr><th>You change…</th><th>…also update</th></tr></thead>
<tbody>
<tr><td>An action verb</td><td>contract.ts → reasoner → tests → docs</td></tr>
<tr><td>A design token</td><td>grain/styles/variables.css only</td></tr>
</tbody>
</table>
</div>
Molecules
Task Card
A row composing the type atom, a badge, and an action button. Bindings (data-field, data-bind-*) are filled from a task's data at render; the panels below show the expanded markup the component produces.
States
Read the architecture
Active<article class="task-card">
<h3 class="card-title">Read the architecture</h3>
<span class="badge" data-status="active">Active</span>
<button class="btn" data-size="sm" data-variant="outline">Archive</button>
</article>
Ship the POC
Archived<article class="task-card">
<h3 class="card-title">Ship the POC</h3>
<span class="badge" data-status="archived">Archived</span>
<button class="btn" data-size="sm" data-variant="outline">Archived</button>
</article>
A row composing the type atom, a badge, and an action button. Bindings (data-field, data-bind-*) are filled from a task's data at render; the panels below show the expanded markup the component produces.
States
Read the architecture
Active<article class="task-card">
<h3 class="card-title">Read the architecture</h3>
<span class="badge" data-status="active">Active</span>
<button class="btn" data-size="sm" data-variant="outline">Archive</button>
</article>
Ship the POC
Archived<article class="task-card">
<h3 class="card-title">Ship the POC</h3>
<span class="badge" data-status="archived">Archived</span>
<button class="btn" data-size="sm" data-variant="outline">Archived</button>
</article>
Molecules
walkthrough-card (portfolio)
A Welcome page Walkthroughs card — a bordered link card with an icon head, an optional mono badge, and a muted body. The PoC's progress meters were dropped on purpose: a meter that measures nothing is decoration pretending to be data (honest-status doctrine); they return if/when they can mean something real.
<div class="walks">
<a class="walk" href="/grain">
<span class="walk__head"><b-icon sym="/assets/sprite.svg#spark" size="sm"></b-icon>Watch the AI act
<span class="walk__badge">live</span></span>
<p class="walk__body">One vocabulary, two operators — the AI's hand stays visible as grain.</p>
</a>
</div>
A Welcome page Walkthroughs card — a bordered link card with an icon head, an optional mono badge, and a muted body. The PoC's progress meters were dropped on purpose: a meter that measures nothing is decoration pretending to be data (honest-status doctrine); they return if/when they can mean something real.
<div class="walks">
<a class="walk" href="/grain">
<span class="walk__head"><b-icon sym="/assets/sprite.svg#spark" size="sm"></b-icon>Watch the AI act
<span class="walk__badge">live</span></span>
<p class="walk__body">One vocabulary, two operators — the AI's hand stays visible as grain.</p>
</a>
</div>
Molecules
welcome-recent (portfolio)
One row of the Welcome page's Recent feed — note title + muted mono file path, the notes as the editor's "recent files". Data-bound (href / title / path); the page composes it with each= over the live notes list (recentNotes, provided by the composition root from MILL frontmatter — newest first). The static export freezes whatever was newest at freeze time.
<div class="recent">
<welcome-recent each="recentNotes"></welcome-recent>
<a class="recent__item" href="/notes"><span class="recent__name">More…</span></a>
</div>
One row of the Welcome page's Recent feed — note title + muted mono file path, the notes as the editor's "recent files". Data-bound (href / title / path); the page composes it with each= over the live notes list (recentNotes, provided by the composition root from MILL frontmatter — newest first). The static export freezes whatever was newest at freeze time.
<div class="recent">
<welcome-recent each="recentNotes"></welcome-recent>
<a class="recent__item" href="/notes"><span class="recent__name">More…</span></a>
</div>
Molecules
welcome-start (portfolio)
The Welcome page's Start list — icon + link rows (the VS Code Start list). Every row is a real <a> (zero-JS navigable); a row that should hand focus to the assistant instead carries data-shell="focus-chat" (shell.js) with its href as the no-JS fallback.
<nav class="start" aria-label="Start">
<a class="start__item" href="/grain" data-shell="focus-chat"><b-icon sym="/assets/sprite.svg#spark" size="sm"></b-icon>Ask the desk…</a>
<a class="start__item" href="/notes"><b-icon sym="/assets/sprite.svg#knowledge" size="sm"></b-icon>Read the notes…</a>
</nav>
The Welcome page's Start list — icon + link rows (the VS Code Start list). Every row is a real <a> (zero-JS navigable); a row that should hand focus to the assistant instead carries data-shell="focus-chat" (shell.js) with its href as the no-JS fallback.
<nav class="start" aria-label="Start">
<a class="start__item" href="/grain" data-shell="focus-chat"><b-icon sym="/assets/sprite.svg#spark" size="sm"></b-icon>Ask the desk…</a>
<a class="start__item" href="/notes"><b-icon sym="/assets/sprite.svg#knowledge" size="sm"></b-icon>Read the notes…</a>
</nav>
Molecules
Work card (portfolio)
A navigable tile on the home "Work" grid: name + kind up top, a description, a "go" line pinned to the bottom. Composes GRAIN's card — the root must carry both classes (class="card work-card"); the border, hover lift, disabled state, and AI-mode dashed edge all come from card, this component owns only the anatomy.
Work card
<a class="card work-card" href="/grain">
<div class="work-card__head">
<span class="work-card__name name">GRAIN</span>
<span class="work-card__kind">Design system</span>
</div>
<p class="card__body">An AI-interaction design system: one shared vocabulary for humans
and AI. Built on BATCH.</p>
<span class="work-card__go">See the system →</span>
</a>
A navigable tile on the home "Work" grid: name + kind up top, a description, a "go" line pinned to the bottom. Composes GRAIN's card — the root must carry both classes (class="card work-card"); the border, hover lift, disabled state, and AI-mode dashed edge all come from card, this component owns only the anatomy.
Work card
<a class="card work-card" href="/grain">
<div class="work-card__head">
<span class="work-card__name name">GRAIN</span>
<span class="work-card__kind">Design system</span>
</div>
<p class="card__body">An AI-interaction design system: one shared vocabulary for humans
and AI. Built on BATCH.</p>
<span class="work-card__go">See the system →</span>
</a>
Organisms
activity-bar
The VS Code activity bar: a slim vertical icon column at the far edge of the app-shell rail. Icon-only entries — an explorer toggle at the top, app links (with a .activity-bar__spacer to push some to the bottom). Persona-neutral and CSS-only: the consumer supplies the links, hrefs, and glyphs. A layout class composed of native <a>/<button> + b-icon, not a data-bound tag. Entries are plain links (.activity-bar__item) — no panel-switching JS. Each MUST carry an aria-label + title (icon-only, so the text label is the accessible name). Mark the current section's item aria-current="true" (or "page") for the accent left-edge.
Parent context (state it here — lesson 3)
<aside class="app-shell__rail" data-tab-source>
<nav class="activity-bar" aria-label="Activity">
<button class="activity-bar__item" data-shell="rail-toggle" aria-current="true"
title="Explorer" aria-label="Explorer"><b-icon sym="/assets/sprite.svg#files"></b-icon></button>
<span class="activity-bar__spacer"></span>
<a class="activity-bar__item" href="/calendar" data-section="calendar" data-tab-label="Calendar"
title="Calendar" aria-label="Calendar"><b-icon sym="/assets/sprite.svg#traces"></b-icon></a>
</nav>
<div class="side-rail" data-variant="explorer"> …brand + file-tree… </div>
</aside>
Rejected alternative (why not a 4th grid column)
The VS Code activity bar: a slim vertical icon column at the far edge of the app-shell rail. Icon-only entries — an explorer toggle at the top, app links (with a .activity-bar__spacer to push some to the bottom). Persona-neutral and CSS-only: the consumer supplies the links, hrefs, and glyphs. A layout class composed of native <a>/<button> + b-icon, not a data-bound tag. Entries are plain links (.activity-bar__item) — no panel-switching JS. Each MUST carry an aria-label + title (icon-only, so the text label is the accessible name). Mark the current section's item aria-current="true" (or "page") for the accent left-edge.
Parent context (state it here — lesson 3)
<aside class="app-shell__rail" data-tab-source>
<nav class="activity-bar" aria-label="Activity">
<button class="activity-bar__item" data-shell="rail-toggle" aria-current="true"
title="Explorer" aria-label="Explorer"><b-icon sym="/assets/sprite.svg#files"></b-icon></button>
<span class="activity-bar__spacer"></span>
<a class="activity-bar__item" href="/calendar" data-section="calendar" data-tab-label="Calendar"
title="Calendar" aria-label="Calendar"><b-icon sym="/assets/sprite.svg#traces"></b-icon></a>
</nav>
<div class="side-rail" data-variant="explorer"> …brand + file-tree… </div>
</aside>
Rejected alternative (why not a 4th grid column)
Organisms
App shell
The workspace layout — the "work-y" archetype that coexists with the single-column editorial .container. A full-viewport CSS grid of five regions: a left rail, a top bar, the main pane, a right aside (the assistant), and a bottom console. It hosts arbitrary content, so it's a layout you apply with region classes (not a data-bound tag): put class="app-shell" on the frame and drop content into app-shell__rail / __topbar / __main / __aside / __console. data-rail-collapsed narrows the rail to icons; on mobile the rail becomes a drawer (data-rail-open) over a app-shell__scrim. See the live composition on any page of the reference app (it's the EDITOR chrome — tjakoen.github.io's portfolio-frame). Parent context (implicit, self-installing). .app-shell must be a direct-or-nested child of <body> — every real composition already is. app-shell.css sets container-type: inline-size on body:has(.app-shell) (named shell-frame) because the shell's own mobile/tablet layout breakpoints restyle .app-shell's OWN grid properties, and an element can never @container-query a condition on itself — only a descendant can query an ancestor container. Using body (rather than a purpose-built wrapper) means the real narrow-viewport case works with zero JS; the viewport-toggle (data-shell="viewport-toggle") clamps body[data-viewport]'s max-width so its preview shrinks the same container a real narrow window does. sidebar-panel and status-bar share this container for their own mobile rules. One documented gap: app-window's backdrop padding lives on body itself (.app-window-backdrop), so it can't follow suit — it stays a real @media and only responds to a genuinely narrow window, not the toggle preview. Two optional full-width rows bracket the workspace: __window (top — the app-window title bar) and __status (bottom — the status-bar). They're auto rows, so a shell that places nothing in them renders exactly as before; fill them to dress the shell as an editor window (see app-window + status-bar). Activity bar (opt-in). Put an activity-bar (the VS Code icon column) as the FIRST child of __rail (or of .rail-body, see below), with a nested .side-rail beside it: the container becomes a flex row (rule lives in this sheet, alongside the mobile-drawer rules, so bundle order can't break the drawer). A rail without one is untouched. Collapsing the rail then leaves the icon strip only. See activity-bar.md. Rail head (opt-in). Put a full-width .rail-head (a brand/title row) as the FIRST child of __rail, followed by a .rail-body wrapping everything else (the activity-bar + side-rail row, or a bare side-rail): the rail stacks vertically, the head sitting flush with the topbar row beside it, everything else starting on its own line underneath. Skip both and the rail is untouched.
Structure (capped height for the catalog; real one is `100dvh`)
<div class="app-shell" data-rail-collapsed="false" style="height:280px">
<aside class="app-shell__rail side-rail">
<div class="side-rail__brand"><b-icon sym="/assets/sprite.svg#spark"></b-icon><span>Grain</span></div>
<a class="nav-item" aria-current="page"><b-icon sym="/assets/sprite.svg#loop"></b-icon><span class="nav-item__label">Overview</span></a>
<a class="nav-item"><b-icon sym="/assets/sprite.svg#tasks"></b-icon><span class="nav-item__label">Tasks</span></a>
</aside>
<div class="app-shell__topbar">
<nav class="tab-bar"><a class="tab" aria-current="page">Overview</a><a class="tab">Tasks</a></nav>
</div>
<main class="app-shell__main" style="padding:1rem">Main content.</main>
<aside class="app-shell__aside" style="padding:1rem">Assistant.</aside>
<section class="app-shell__console" style="padding:.5rem 1rem">✶ the AI — idle</section>
</div>
The workspace layout — the "work-y" archetype that coexists with the single-column editorial .container. A full-viewport CSS grid of five regions: a left rail, a top bar, the main pane, a right aside (the assistant), and a bottom console. It hosts arbitrary content, so it's a layout you apply with region classes (not a data-bound tag): put class="app-shell" on the frame and drop content into app-shell__rail / __topbar / __main / __aside / __console. data-rail-collapsed narrows the rail to icons; on mobile the rail becomes a drawer (data-rail-open) over a app-shell__scrim. See the live composition on any page of the reference app (it's the EDITOR chrome — tjakoen.github.io's portfolio-frame). Parent context (implicit, self-installing). .app-shell must be a direct-or-nested child of <body> — every real composition already is. app-shell.css sets container-type: inline-size on body:has(.app-shell) (named shell-frame) because the shell's own mobile/tablet layout breakpoints restyle .app-shell's OWN grid properties, and an element can never @container-query a condition on itself — only a descendant can query an ancestor container. Using body (rather than a purpose-built wrapper) means the real narrow-viewport case works with zero JS; the viewport-toggle (data-shell="viewport-toggle") clamps body[data-viewport]'s max-width so its preview shrinks the same container a real narrow window does. sidebar-panel and status-bar share this container for their own mobile rules. One documented gap: app-window's backdrop padding lives on body itself (.app-window-backdrop), so it can't follow suit — it stays a real @media and only responds to a genuinely narrow window, not the toggle preview. Two optional full-width rows bracket the workspace: __window (top — the app-window title bar) and __status (bottom — the status-bar). They're auto rows, so a shell that places nothing in them renders exactly as before; fill them to dress the shell as an editor window (see app-window + status-bar). Activity bar (opt-in). Put an activity-bar (the VS Code icon column) as the FIRST child of __rail (or of .rail-body, see below), with a nested .side-rail beside it: the container becomes a flex row (rule lives in this sheet, alongside the mobile-drawer rules, so bundle order can't break the drawer). A rail without one is untouched. Collapsing the rail then leaves the icon strip only. See activity-bar.md. Rail head (opt-in). Put a full-width .rail-head (a brand/title row) as the FIRST child of __rail, followed by a .rail-body wrapping everything else (the activity-bar + side-rail row, or a bare side-rail): the rail stacks vertically, the head sitting flush with the topbar row beside it, everything else starting on its own line underneath. Skip both and the rail is untouched.
Structure (capped height for the catalog; real one is `100dvh`)
<div class="app-shell" data-rail-collapsed="false" style="height:280px">
<aside class="app-shell__rail side-rail">
<div class="side-rail__brand"><b-icon sym="/assets/sprite.svg#spark"></b-icon><span>Grain</span></div>
<a class="nav-item" aria-current="page"><b-icon sym="/assets/sprite.svg#loop"></b-icon><span class="nav-item__label">Overview</span></a>
<a class="nav-item"><b-icon sym="/assets/sprite.svg#tasks"></b-icon><span class="nav-item__label">Tasks</span></a>
</aside>
<div class="app-shell__topbar">
<nav class="tab-bar"><a class="tab" aria-current="page">Overview</a><a class="tab">Tasks</a></nav>
</div>
<main class="app-shell__main" style="padding:1rem">Main content.</main>
<aside class="app-shell__aside" style="padding:1rem">Assistant.</aside>
<section class="app-shell__console" style="padding:.5rem 1rem">✶ the AI — idle</section>
</div>
Organisms
app-window
The editor-window frame: boxes the whole app in a bordered, radiused window on a darker backdrop, with a title bar up top. Pairs with status-bar for the bottom row. CSS-only (no .html): it dresses an app-shell, it doesn't render content. Parent contract (required): apply .app-window on the .app-shell element itself and .app-window-backdrop on <body>; the title bar is a .window-bar placed in the shell's .app-shell__window grid row (app-shell.css owns the rows). Without an app-shell grid around it, .window-bar is just a flex strip — the frame and rows come from the shell. The title bar hosts, left to right: - __dots — the window dots. Plain <i><i><i> for a decorative cluster (one filled, two hollow ink circles), or functional <button>s marked data-window-close / data-window-clear / data-window-back. At rest every dot reads as quiet ink; hovering the cluster reveals each dot's muted accent + glyph (--color-dot- tokens — the one sanctioned traffic-light exception to the closed palette). The behavior is the consumer's (wire the clicks in your app script); give each a title tooltip. - __ctl (optional) — a small control cluster (theme cycle, scheme toggle, pane toggles): unstyled text buttons, status-weight. - __title or __search — the centered element. __title is plain mono text; __search is a button drawn as an input* that opens the app's command palette (⌘K) — its text is the consumer's placeholder (e.g. the current page's breadcrumb). - __end — a symmetry spacer; give it a width matching the leading cluster so the center stays centered.
<body class="app-window-backdrop">
<div class="app-shell app-window" data-surface="screen">
<header class="app-shell__window window-bar">
<span class="window-bar__dots">
<button type="button" data-window-close title="Close">×</button>
<button type="button" data-window-clear title="Clear cached data">⌫</button>
<button type="button" data-window-back title="Back">‹</button>
</span>
<span class="window-bar__ctl">
<button type="button" data-cycle-theme title="Cycle theme">◆</button>
<button type="button" data-toggle-scheme title="Light / dark">◐</button>
</span>
<button class="window-bar__search" type="button"><b-kbd keys="⌘K"></b-kbd> my-app › page</button>
<span class="window-bar__end" style="width: 7rem"></span>
</header>
<!-- rail / topbar / main / aside / console … -->
<footer class="app-shell__status status-bar">…</footer>
</div>
</body>
The editor-window frame: boxes the whole app in a bordered, radiused window on a darker backdrop, with a title bar up top. Pairs with status-bar for the bottom row. CSS-only (no .html): it dresses an app-shell, it doesn't render content. Parent contract (required): apply .app-window on the .app-shell element itself and .app-window-backdrop on <body>; the title bar is a .window-bar placed in the shell's .app-shell__window grid row (app-shell.css owns the rows). Without an app-shell grid around it, .window-bar is just a flex strip — the frame and rows come from the shell. The title bar hosts, left to right: - __dots — the window dots. Plain <i><i><i> for a decorative cluster (one filled, two hollow ink circles), or functional <button>s marked data-window-close / data-window-clear / data-window-back. At rest every dot reads as quiet ink; hovering the cluster reveals each dot's muted accent + glyph (--color-dot- tokens — the one sanctioned traffic-light exception to the closed palette). The behavior is the consumer's (wire the clicks in your app script); give each a title tooltip. - __ctl (optional) — a small control cluster (theme cycle, scheme toggle, pane toggles): unstyled text buttons, status-weight. - __title or __search — the centered element. __title is plain mono text; __search is a button drawn as an input* that opens the app's command palette (⌘K) — its text is the consumer's placeholder (e.g. the current page's breadcrumb). - __end — a symmetry spacer; give it a width matching the leading cluster so the center stays centered.
<body class="app-window-backdrop">
<div class="app-shell app-window" data-surface="screen">
<header class="app-shell__window window-bar">
<span class="window-bar__dots">
<button type="button" data-window-close title="Close">×</button>
<button type="button" data-window-clear title="Clear cached data">⌫</button>
<button type="button" data-window-back title="Back">‹</button>
</span>
<span class="window-bar__ctl">
<button type="button" data-cycle-theme title="Cycle theme">◆</button>
<button type="button" data-toggle-scheme title="Light / dark">◐</button>
</span>
<button class="window-bar__search" type="button"><b-kbd keys="⌘K"></b-kbd> my-app › page</button>
<span class="window-bar__end" style="width: 7rem"></span>
</header>
<!-- rail / topbar / main / aside / console … -->
<footer class="app-shell__status status-bar">…</footer>
</div>
</body>
Organisms
Chat log
A vertical thread that lays out chat-messages. Each chat-message positions itself — you to the right, the AI to the left — via align-self, which only works inside a flex column. The chat-log provides that column, so messages can't silently misalign: compose them here, not in a bare div.
Thread
<div class="chat-log">
<div class="chat-message" data-role="you"><span class="chat-message__who">You</span><span class="chat-message__body">Plan my Thursday.</span></div>
<div class="chat-message" data-role="ai" data-grade="grain"><span class="chat-message__who">GRAIN</span><span class="chat-message__body">On it — three deep-work blocks, review at 2.</span></div>
</div>
A vertical thread that lays out chat-messages. Each chat-message positions itself — you to the right, the AI to the left — via align-self, which only works inside a flex column. The chat-log provides that column, so messages can't silently misalign: compose them here, not in a bare div.
Thread
<div class="chat-log">
<div class="chat-message" data-role="you"><span class="chat-message__who">You</span><span class="chat-message__body">Plan my Thursday.</span></div>
<div class="chat-message" data-role="ai" data-grade="grain"><span class="chat-message__who">GRAIN</span><span class="chat-message__body">On it — three deep-work blocks, review at 2.</span></div>
</div>
Organisms
Code editor
The interactive tier above [code-block](../../atoms/code-block/code-block.md): a framed edit surface with a toolbar, a mount slot a real editor is injected into, a validation status strip, a diagnostics list, and a version-history drawer. CSS-only like every grain component — it bundles no editor. The consumer lazy-loads its own (Monaco, CodeMirror, a plain <textarea>) into .code-editor__mount and toggles the data-state / data-history / data-dirty attributes; grain owns the chrome and the monochrome grammar, the app owns the engine. Shares code-block's mono/surface tokens so code in a blog and code in the editor read as one system. Give the panel a height (grid/flex parent) — the mount owns the scroll.
Frame
import { test, expect } from '@playwright/test';
<div class="code-editor" style="height:20rem">
<div class="code-editor__bar">
<span class="code-editor__title">tests/checkout.spec.ts</span>
<span class="code-editor__dirty">• unsaved</span>
<span class="code-editor__actions">
<button class="btn" data-size="sm" data-variant="soft">Validate</button>
<button class="btn" data-size="sm">Save</button>
</span>
</div>
<div class="code-editor__mount">
<pre class="code-block"><code>import { test, expect } from '@playwright/test';</code></pre>
</div>
<div class="code-editor__status">Ready</div>
</div>
// edited…<div class="code-editor" data-dirty="true" style="height:12rem">
<div class="code-editor__bar">
<span class="code-editor__title">tests/checkout.spec.ts</span>
<span class="code-editor__dirty">• unsaved</span>
<span class="code-editor__actions">
<button class="btn" data-size="sm">Save</button>
</span>
</div>
<div class="code-editor__mount"><pre class="code-block"><code>// edited…</code></pre></div>
<div class="code-editor__status">Ready</div>
</div>
Validation
<div class="code-editor" data-state="validating" style="height:8rem">
<div class="code-editor__bar"><span class="code-editor__title">spec.ts</span></div>
<div class="code-editor__mount"></div>
<div class="code-editor__status">Validating…</div>
</div>
<div class="code-editor" data-state="valid" style="height:8rem">
<div class="code-editor__bar"><span class="code-editor__title">spec.ts</span></div>
<div class="code-editor__mount"></div>
<div class="code-editor__status">Compiles — 0 problems</div>
</div>
<div class="code-editor" data-state="invalid" style="height:12rem">
<div class="code-editor__bar"><span class="code-editor__title">spec.ts</span></div>
<div class="code-editor__mount"></div>
<div class="code-editor__status">1 problem</div>
<div class="code-editor__diagnostics">
<div class="code-editor__diag" data-clickable>
<span class="code-editor__diag-loc">12:4</span>
<span>Cannot find name 'expcet'. Did you mean 'expect'?</span>
</div>
</div>
</div>
<div class="code-editor" data-state="saved" style="height:8rem">
<div class="code-editor__bar"><span class="code-editor__title">spec.ts</span></div>
<div class="code-editor__mount"></div>
<div class="code-editor__status">Saved as version 4</div>
</div>
History
<div class="code-editor" data-history="open" style="height:16rem">
<div class="code-editor__bar">
<span class="code-editor__title">spec.ts</span>
<span class="code-editor__actions"><button class="btn" data-size="sm" data-variant="soft">History</button></span>
</div>
<div class="code-editor__mount"></div>
<div class="code-editor__status">Ready</div>
<aside class="code-editor__history">
<div class="code-editor__history-head">History</div>
<div class="code-editor__version" data-current>
<div class="code-editor__version-msg">tighten the checkout assertion</div>
<div class="code-editor__version-meta"><span>maria</span><span>2m ago</span></div>
</div>
<div class="code-editor__version">
<div class="code-editor__version-msg">initial spec</div>
<div class="code-editor__version-meta"><span>maria</span><span>yesterday</span></div>
</div>
</aside>
</div>
When the AI is writing into the editor, the whole panel wears the non-text grain: a dashed "terminal" edge (the same signal code-block uses when the AI authors a block). It settles back to a solid frame on commit — the moment the human owns the buffer again. Inherited grade works too: any [data-grade="grain"] ancestor puts the panel in-transit without touching the root.
In-transit
await page.goto('/checkout');<div class="code-editor" data-commit="pending" style="height:12rem">
<div class="code-editor__bar">
<span class="code-editor__title">tests/checkout.spec.ts</span>
<span class="code-editor__dirty">• writing…</span>
</div>
<div class="code-editor__mount"><pre class="code-block"><code>await page.goto('/checkout');</code></pre></div>
<div class="code-editor__status">Drafting…</div>
</div>
<div data-grade="grain">
<div class="code-editor" style="height:8rem">
<div class="code-editor__bar"><span class="code-editor__title">spec.ts</span></div>
<div class="code-editor__mount"></div>
<div class="code-editor__status">Ready</div>
</div>
</div>
Organisms
console
The AI's narration terminal — a docked bottom panel (VS Code style), not a floating box. At rest it's a slim clickable bar (Terminal ▸); clicking console-toggle opens the feed in place (data-console-open — the shell's console grid row grows to a fixed ~16rem band, so the main pane shrinks and scrolls). A second control, console-grow-toggle, expands that band further to fill the whole shell (data-console-expanded, main collapses to 0) — for reading a long run back. Every state change glides: the row is a minmax(<floor>, <fr>) grid track whose endpoints are all lengths/fr, so grid-template-rows interpolates (bar → band → full-screen) instead of snapping; the feed is flex: 1 1 0 and just fills the row (no max-height reveal). During a run (data-acting) the terminal stays collapsed — the sidebar-panel (chat) shows a live preview and the person clicks "open in terminal" (→ data-console-open) to see the full feed. It narrates the AI's steps as action-badge lines, each a plain row (not individually boxed — a run's lines read as one flowing entry). The terminal shows the AI's thinking; the chat is for communication — the two coexist, so the chat no longer collapses during a run. CSS-only (no .html). Parent context (required): lives inside the shell's .app-shell__console region (which docks under main). Its height is driven by --shell-console-min / --shell-console-fr on the shell (data-console-open raises the floor to the band; data-console-expanded grows the fr to fill). data-acting (set by grain/scripts/ai-dispatch.js on a spotlight op) marks a run but does NOT open the feed. The AI narrates by pushing append ops at the console surface. Persona-neutral.
<section class="app-shell__console">
<div class="console__box">
<div class="console__bar">
<button class="console__expand" data-shell="console-toggle">
<b-icon sym="…#spark" size="sm"></b-icon><span class="console__label">the AI</span>
<span class="console__acting">is acting…</span><span class="console__chev">▾</span>
</button>
<button class="console__grow" data-shell="console-grow-toggle"></button>
</div>
<div class="console__feed" data-surface="console"></div>
</div>
</section>
Interactive mode (opt-in) — the terminal takes a command line
<div class="console__box" data-terminal="interactive"> … </div>
<script type="module" src="/scripts/terminal.js"></script>
The AI's narration terminal — a docked bottom panel (VS Code style), not a floating box. At rest it's a slim clickable bar (Terminal ▸); clicking console-toggle opens the feed in place (data-console-open — the shell's console grid row grows to a fixed ~16rem band, so the main pane shrinks and scrolls). A second control, console-grow-toggle, expands that band further to fill the whole shell (data-console-expanded, main collapses to 0) — for reading a long run back. Every state change glides: the row is a minmax(<floor>, <fr>) grid track whose endpoints are all lengths/fr, so grid-template-rows interpolates (bar → band → full-screen) instead of snapping; the feed is flex: 1 1 0 and just fills the row (no max-height reveal). During a run (data-acting) the terminal stays collapsed — the sidebar-panel (chat) shows a live preview and the person clicks "open in terminal" (→ data-console-open) to see the full feed. It narrates the AI's steps as action-badge lines, each a plain row (not individually boxed — a run's lines read as one flowing entry). The terminal shows the AI's thinking; the chat is for communication — the two coexist, so the chat no longer collapses during a run. CSS-only (no .html). Parent context (required): lives inside the shell's .app-shell__console region (which docks under main). Its height is driven by --shell-console-min / --shell-console-fr on the shell (data-console-open raises the floor to the band; data-console-expanded grows the fr to fill). data-acting (set by grain/scripts/ai-dispatch.js on a spotlight op) marks a run but does NOT open the feed. The AI narrates by pushing append ops at the console surface. Persona-neutral.
<section class="app-shell__console">
<div class="console__box">
<div class="console__bar">
<button class="console__expand" data-shell="console-toggle">
<b-icon sym="…#spark" size="sm"></b-icon><span class="console__label">the AI</span>
<span class="console__acting">is acting…</span><span class="console__chev">▾</span>
</button>
<button class="console__grow" data-shell="console-grow-toggle"></button>
</div>
<div class="console__feed" data-surface="console"></div>
</div>
</section>
Interactive mode (opt-in) — the terminal takes a command line
<div class="console__box" data-terminal="interactive"> … </div>
<script type="module" src="/scripts/terminal.js"></script>
Organisms
Empty State
Shown when a list has nothing in it — muted, centred, quiet.
Example
<div class="empty-state">No items yet. Capture one above.</div>
Shown when a list has nothing in it — muted, centred, quiet.
Example
<div class="empty-state">No items yet. Capture one above.</div>
Organisms
file-tree
An EXPLORER file tree for the editor-workspace metaphor: folders are native <details class="file-tree__dir"> / <summary> (zero-JS collapse — ship them collapsed, no open attribute; an island may open the current page's ancestors), files are real <a class="file-tree__file"> links, so the whole tree navigates as plain hypermedia. Mono type, hairline indent guides, a rotating chevron. A layout class, not a data-bound tag. Honesty contract (consumer's job): every entry should name a REAL source file and link to the page that file produces — an invented filename in the tree is a lie in the UI. What maps to what is the consumer's knowledge; this component only draws it.
<nav class="file-tree" aria-label="Explorer">
<details class="file-tree__dir">
<summary>tjakoen.github.io/</summary>
<div class="file-tree__children">
<details class="file-tree__dir">
<summary><a href="/notes">notes/</a></summary> <!-- a collection folder may link its index -->
<div class="file-tree__children">
<a class="file-tree__file" href="/notes/ten-times-zero">ten-times-zero.md</a>
</div>
</details>
<a class="file-tree__file" href="/" aria-current="page">pages/index.html</a>
</div>
</details>
</nav>
<a class="file-tree__file" href="/grain" data-variant="index" data-tab-label="GRAIN">index.html</a>
An EXPLORER file tree for the editor-workspace metaphor: folders are native <details class="file-tree__dir"> / <summary> (zero-JS collapse — ship them collapsed, no open attribute; an island may open the current page's ancestors), files are real <a class="file-tree__file"> links, so the whole tree navigates as plain hypermedia. Mono type, hairline indent guides, a rotating chevron. A layout class, not a data-bound tag. Honesty contract (consumer's job): every entry should name a REAL source file and link to the page that file produces — an invented filename in the tree is a lie in the UI. What maps to what is the consumer's knowledge; this component only draws it.
<nav class="file-tree" aria-label="Explorer">
<details class="file-tree__dir">
<summary>tjakoen.github.io/</summary>
<div class="file-tree__children">
<details class="file-tree__dir">
<summary><a href="/notes">notes/</a></summary> <!-- a collection folder may link its index -->
<div class="file-tree__children">
<a class="file-tree__file" href="/notes/ten-times-zero">ten-times-zero.md</a>
</div>
</details>
<a class="file-tree__file" href="/" aria-current="page">pages/index.html</a>
</div>
</details>
</nav>
<a class="file-tree__file" href="/grain" data-variant="index" data-tab-label="GRAIN">index.html</a>
Organisms
Note
The editorial content article — the layout MILL's default GRAIN adapter emits for a rendered Markdown document (eyebrow meta → masthead → lede → tag badges → hairline rule → prose). CSS-only: MILL composes the markup; nothing data-binds it. Human-authored content → always clean ink (data-grade="smooth"; only the AI grains).
Note article
2026-07-03 · ~13 min
Ten Times Zero Is Still Zero
How I actually work with AI, and why it holds up.
AI is a multiplier, not an addend…
<article class="note" data-grade="smooth">
<header class="note__head">
<p class="eyebrow">2026-07-03 · ~13 min</p>
<h1 class="masthead">Ten Times Zero Is Still Zero</h1>
<p class="note__lede">How I actually work with AI, and why it holds up.</p>
<div class="note__tags"><span class="badge" data-status="active">ai</span> <span class="badge" data-status="active">workflow</span></div>
<hr class="rule">
</header>
<p>AI is a multiplier, not an addend…</p>
</article>
The editorial content article — the layout MILL's default GRAIN adapter emits for a rendered Markdown document (eyebrow meta → masthead → lede → tag badges → hairline rule → prose). CSS-only: MILL composes the markup; nothing data-binds it. Human-authored content → always clean ink (data-grade="smooth"; only the AI grains).
Note article
2026-07-03 · ~13 min
Ten Times Zero Is Still Zero
How I actually work with AI, and why it holds up.
AI is a multiplier, not an addend…
<article class="note" data-grade="smooth">
<header class="note__head">
<p class="eyebrow">2026-07-03 · ~13 min</p>
<h1 class="masthead">Ten Times Zero Is Still Zero</h1>
<p class="note__lede">How I actually work with AI, and why it holds up.</p>
<div class="note__tags"><span class="badge" data-status="active">ai</span> <span class="badge" data-status="active">workflow</span></div>
<hr class="rule">
</header>
<p>AI is a multiplier, not an addend…</p>
</article>
Organisms
Notepad
The AI's memory as a visible, editable surface — a rail pane both operators write through the one door. Two modes flip on data-mode: rendered (the notes, where note.append / note.replace ops land on the inner notepad-body surface) and source (a textarea the human edits, then commits). The canonical state is the markdown source, not this rendered HTML: each .notepad__entry carries its own source in data-md, so a client island can rebuild the whole pad's markdown (join the entries) and mirror it to localStorage — the DOM is a projection, the source is the truth. Build entries with notepadEntry / an note.append op (ai/reasoner-kit.ts) so the grade and the data-md round-trip are never hand-rolled. An AI entry grades grain (provenance persists, DESIGN-SYSTEM §3); a human commit settles clean. The note.* verbs accept the notepad kind (the wrapper — also the surface the AI-acting spotlight lights); the ops write to the inner notepad-body push surface. Behaviour (the source⇄rendered toggle, localStorage mirror, and the human's Commit → note.replace through the door) is the consumer's island; grain/scripts/notepad.js ships a persona-neutral one.
Pane
New this week: an app-like feel.
<section class="notepad" data-mode="rendered" data-surface="notepad">
<header class="notepad__head">
<span class="notepad__title">Notepad</span>
<button type="button" class="notepad__toggle" aria-label="Toggle source / rendered"></button>
<button type="button" class="notepad__commit"
data-action="note.replace" data-target="notepad" data-from="notepad-src">Commit</button>
</header>
<div class="notepad__body" data-surface="notepad-body">
<div class="notepad__entry" data-grade="grain" data-md="**New this week:** an app-like feel."><p><strong>New this week:</strong> an app-like feel.</p></div>
</div>
<textarea class="notepad__source" data-surface="notepad-src" aria-label="Notepad source (markdown)"
placeholder="Write markdown…"></textarea>
</section>
The AI's memory as a visible, editable surface — a rail pane both operators write through the one door. Two modes flip on data-mode: rendered (the notes, where note.append / note.replace ops land on the inner notepad-body surface) and source (a textarea the human edits, then commits). The canonical state is the markdown source, not this rendered HTML: each .notepad__entry carries its own source in data-md, so a client island can rebuild the whole pad's markdown (join the entries) and mirror it to localStorage — the DOM is a projection, the source is the truth. Build entries with notepadEntry / an note.append op (ai/reasoner-kit.ts) so the grade and the data-md round-trip are never hand-rolled. An AI entry grades grain (provenance persists, DESIGN-SYSTEM §3); a human commit settles clean. The note.* verbs accept the notepad kind (the wrapper — also the surface the AI-acting spotlight lights); the ops write to the inner notepad-body push surface. Behaviour (the source⇄rendered toggle, localStorage mirror, and the human's Commit → note.replace through the door) is the consumer's island; grain/scripts/notepad.js ships a persona-neutral one.
Pane
New this week: an app-like feel.
<section class="notepad" data-mode="rendered" data-surface="notepad">
<header class="notepad__head">
<span class="notepad__title">Notepad</span>
<button type="button" class="notepad__toggle" aria-label="Toggle source / rendered"></button>
<button type="button" class="notepad__commit"
data-action="note.replace" data-target="notepad" data-from="notepad-src">Commit</button>
</header>
<div class="notepad__body" data-surface="notepad-body">
<div class="notepad__entry" data-grade="grain" data-md="**New this week:** an app-like feel."><p><strong>New this week:</strong> an app-like feel.</p></div>
</div>
<textarea class="notepad__source" data-surface="notepad-src" aria-label="Notepad source (markdown)"
placeholder="Write markdown…"></textarea>
</section>
Organisms
Portfolio frame — THE EDITOR chrome
The portfolio's workspace chrome: the whole site presents as one editor window. It's a domain organism (it lives in the app, not grain) that composes the grain shell primitives (app-window / side-rail / file-tree / tab-bar / sidebar-panel / console / status-bar) into the site's persistent frame — the same window wraps every page, <main> swaps beneath it. It's static markup wired by the app's client islands, not a data-bound tag: - theme.js drives theming (the ◆ cycle + ◐ light/dark), - site.js wires the window nav (back/refresh/forward), the linked breadcrumb, and the explorer tree (fills notes/, opens the current file's ancestors, marks the open file), - tabs.js projects navigation into the open-pages strip (a localStorage projection, pinned Welcome first), - ai-dispatch.js stamps AI presence. Nav model (THE EDITOR v3): the RAIL is the EXPLORER — a file tree of the real sources behind each page (the site is its own source tree), with an activity-bar icon column and fixed APP links (Feed / Mail / Catalog / Profile) at the bottom. The TABS are the OPEN PAGES. Compose it as a sibling of <main>, inside .app-shell.app-window (the <body> carries .app-window-backdrop). It's the app's own chrome — it isn't meant to be re-instantiated in the catalog; see it live on any page of the running site.
The portfolio's workspace chrome: the whole site presents as one editor window. It's a domain organism (it lives in the app, not grain) that composes the grain shell primitives (app-window / side-rail / file-tree / tab-bar / sidebar-panel / console / status-bar) into the site's persistent frame — the same window wraps every page, <main> swaps beneath it. It's static markup wired by the app's client islands, not a data-bound tag: - theme.js drives theming (the ◆ cycle + ◐ light/dark), - site.js wires the window nav (back/refresh/forward), the linked breadcrumb, and the explorer tree (fills notes/, opens the current file's ancestors, marks the open file), - tabs.js projects navigation into the open-pages strip (a localStorage projection, pinned Welcome first), - ai-dispatch.js stamps AI presence. Nav model (THE EDITOR v3): the RAIL is the EXPLORER — a file tree of the real sources behind each page (the site is its own source tree), with an activity-bar icon column and fixed APP links (Feed / Mail / Catalog / Profile) at the bottom. The TABS are the OPEN PAGES. Compose it as a sibling of <main>, inside .app-shell.app-window (the <body> carries .app-window-backdrop). It's the app's own chrome — it isn't meant to be re-instantiated in the catalog; see it live on any page of the running site.
Organisms
Side rail
The vertical navigation rail inside the app-shell: brand → nav-items → spacer → footer items. A layout class composed with b-icon and nav-item. When the shell carries data-rail-collapsed="true" the labels drop and glyphs center (icons-only). On mobile the shell turns the whole rail into a drawer. The brand mark (.side-rail__brand > b-icon) carries var(--color-accent) — hueless under the default theme, a hue under an accent theme (the accent reaches the brand + the presence star; DESIGN-SYSTEM §2). Pairs with the activity-bar organism when the shell wants a VS Code-style icon column (see activity-bar.md).
Example
<aside class="side-rail" style="height:240px;border:1px solid var(--color-line)">
<div class="side-rail__brand"><b-icon sym="/assets/sprite.svg#spark"></b-icon><span>Grain</span></div>
<a class="nav-item" aria-current="page"><b-icon sym="/assets/sprite.svg#loop"></b-icon><span class="nav-item__label">Overview</span></a>
<a class="nav-item"><b-icon sym="/assets/sprite.svg#tasks"></b-icon><span class="nav-item__label">Tasks</span></a>
<a class="nav-item"><b-icon sym="/assets/sprite.svg#knowledge"></b-icon><span class="nav-item__label">Library</span></a>
<div class="side-rail__spacer"></div>
<a class="nav-item"><b-icon sym="/assets/sprite.svg#settings"></b-icon><span class="nav-item__label">Settings</span></a>
</aside>
The vertical navigation rail inside the app-shell: brand → nav-items → spacer → footer items. A layout class composed with b-icon and nav-item. When the shell carries data-rail-collapsed="true" the labels drop and glyphs center (icons-only). On mobile the shell turns the whole rail into a drawer. The brand mark (.side-rail__brand > b-icon) carries var(--color-accent) — hueless under the default theme, a hue under an accent theme (the accent reaches the brand + the presence star; DESIGN-SYSTEM §2). Pairs with the activity-bar organism when the shell wants a VS Code-style icon column (see activity-bar.md).
Example
<aside class="side-rail" style="height:240px;border:1px solid var(--color-line)">
<div class="side-rail__brand"><b-icon sym="/assets/sprite.svg#spark"></b-icon><span>Grain</span></div>
<a class="nav-item" aria-current="page"><b-icon sym="/assets/sprite.svg#loop"></b-icon><span class="nav-item__label">Overview</span></a>
<a class="nav-item"><b-icon sym="/assets/sprite.svg#tasks"></b-icon><span class="nav-item__label">Tasks</span></a>
<a class="nav-item"><b-icon sym="/assets/sprite.svg#knowledge"></b-icon><span class="nav-item__label">Library</span></a>
<div class="side-rail__spacer"></div>
<a class="nav-item"><b-icon sym="/assets/sprite.svg#settings"></b-icon><span class="nav-item__label">Settings</span></a>
</aside>
Organisms
sidebar-panel
The workspace's right-hand panel — a header / scrolling body / footer column that fills the shell's .app-shell__aside region. Its default use is the assistant (chat). CSS-only (no .html): compose the markup in your frame. Parent context (required): lives inside .app-shell__aside (grain app-shell). The takeover behavior keys off the shell's data-acting / data-chat-open / data-aside-open attributes, and on mobile it becomes a bottom sheet — all driven by the shell + grain/scripts/ai-dispatch.js, not this CSS. Persona-neutral: any label ("Assistant", a product name) is the consumer's.
<aside class="app-shell__aside">
<div class="assistant">
<div class="assistant__head"><span>Assistant</span></div>
<div class="assistant__log" data-surface="chat-log">…messages…</div>
<div class="assistant__composer">
<input data-action="chat.send" data-target="chat-log" data-surface="chat-input">
<button class="icon-btn" data-action="chat.send" data-target="chat-log" data-from="chat-input">…</button>
</div>
</div>
</aside>
<div class="assistant" data-mode="chat">
<div class="assistant__head"><span>Assistant</span>
<nav class="assistant__modes" aria-label="Panel mode">
<button type="button" data-shell-mode="chat" aria-selected="true">Chat</button>
<button type="button" data-shell-mode="catalog" aria-selected="false">Catalog</button>
</nav>
</div>
<div class="assistant__pane" data-pane="chat">…log + composer…</div>
<div class="assistant__pane" data-pane="catalog" hidden>…anything…</div>
</div>
The workspace's right-hand panel — a header / scrolling body / footer column that fills the shell's .app-shell__aside region. Its default use is the assistant (chat). CSS-only (no .html): compose the markup in your frame. Parent context (required): lives inside .app-shell__aside (grain app-shell). The takeover behavior keys off the shell's data-acting / data-chat-open / data-aside-open attributes, and on mobile it becomes a bottom sheet — all driven by the shell + grain/scripts/ai-dispatch.js, not this CSS. Persona-neutral: any label ("Assistant", a product name) is the consumer's.
<aside class="app-shell__aside">
<div class="assistant">
<div class="assistant__head"><span>Assistant</span></div>
<div class="assistant__log" data-surface="chat-log">…messages…</div>
<div class="assistant__composer">
<input data-action="chat.send" data-target="chat-log" data-surface="chat-input">
<button class="icon-btn" data-action="chat.send" data-target="chat-log" data-from="chat-input">…</button>
</div>
</div>
</aside>
<div class="assistant" data-mode="chat">
<div class="assistant__head"><span>Assistant</span>
<nav class="assistant__modes" aria-label="Panel mode">
<button type="button" data-shell-mode="chat" aria-selected="true">Chat</button>
<button type="button" data-shell-mode="catalog" aria-selected="false">Catalog</button>
</nav>
</div>
<div class="assistant__pane" data-pane="chat">…log + composer…</div>
<div class="assistant__pane" data-pane="catalog" hidden>…anything…</div>
</div>
Organisms
status-bar
The editor-style status row at the bottom of the app window: quiet mono meta-text (presence, build info) on the left, small controls (theme cycle, scheme toggle, ⌘K hint) on the right of a __spacer. CSS-only (no .html) — the consumer supplies the content; controls here are status, not content, so buttons unstyle to plain text and sharpen on hover/focus. Parent contract (required): place a .status-bar in the app-shell's .app-shell__status grid row (app-shell.css owns the row). Pairs with app-window for the full editor-window frame.
<footer class="app-shell__status status-bar">
<span class="presence">✶ online</span>
<span data-status-optional>0 ⊘ · 0 ⚠</span>
<span data-status-optional>main</span>
<span class="status-bar__spacer"></span>
<button type="button" data-cycle-theme>◆ <span data-theme-name>sourdough</span></button>
<button type="button" data-toggle-scheme>◐ scheme</button>
<b-kbd keys="⌘K"></b-kbd>
</footer>
The editor-style status row at the bottom of the app window: quiet mono meta-text (presence, build info) on the left, small controls (theme cycle, scheme toggle, ⌘K hint) on the right of a __spacer. CSS-only (no .html) — the consumer supplies the content; controls here are status, not content, so buttons unstyle to plain text and sharpen on hover/focus. Parent contract (required): place a .status-bar in the app-shell's .app-shell__status grid row (app-shell.css owns the row). Pairs with app-window for the full editor-window frame.
<footer class="app-shell__status status-bar">
<span class="presence">✶ online</span>
<span data-status-optional>0 ⊘ · 0 ⚠</span>
<span data-status-optional>main</span>
<span class="status-bar__spacer"></span>
<button type="button" data-cycle-theme>◆ <span data-theme-name>sourdough</span></button>
<button type="button" data-toggle-scheme>◐ scheme</button>
<b-kbd keys="⌘K"></b-kbd>
</footer>
Organisms
Tab bar
The top strip of open views, inside the app-shell topbar. A scrollable row of tabs; place controls (a b-kbd for ⌘K, an AI presence marker) after it in the topbar. Tabs are navigation (links) — active is per-page, not client state. Close all (opt-in): a control marked data-shell="tabs-close-all" placed as a SIBLING of the strip (not inside it, so it survives the strip's own horizontal scroll) is wired live by scripts/tabs.js: it clears every non-pinned tab and, if the current page was one of them, navigates to the first pinned tab. tabs.js toggles its hidden attribute — shown only once there's something closable open.
Example
<div style="display:flex;align-items:stretch;border:1px solid var(--color-line)">
<nav class="tab-bar">
<a class="tab" aria-current="page">Overview</a>
<a class="tab">Tasks</a>
<a class="tab">Library · notes</a>
</nav>
<span style="margin-left:auto;display:flex;align-items:center;gap:.5rem;padding:0 .75rem">
<b-kbd keys="⌘K"></b-kbd>
</span>
</div>
The top strip of open views, inside the app-shell topbar. A scrollable row of tabs; place controls (a b-kbd for ⌘K, an AI presence marker) after it in the topbar. Tabs are navigation (links) — active is per-page, not client state. Close all (opt-in): a control marked data-shell="tabs-close-all" placed as a SIBLING of the strip (not inside it, so it survives the strip's own horizontal scroll) is wired live by scripts/tabs.js: it clears every non-pinned tab and, if the current page was one of them, navigates to the first pinned tab. tabs.js toggles its hidden attribute — shown only once there's something closable open.
Example
<div style="display:flex;align-items:stretch;border:1px solid var(--color-line)">
<nav class="tab-bar">
<a class="tab" aria-current="page">Overview</a>
<a class="tab">Tasks</a>
<a class="tab">Library · notes</a>
</nav>
<span style="margin-left:auto;display:flex;align-items:center;gap:.5rem;padding:0 .75rem">
<b-kbd keys="⌘K"></b-kbd>
</span>
</div>
Organisms
Task List
A simple grid of task rows. Renders task-cards from a collection (each="items"); the panel shows the expanded result.
Example
Read the architecture
ActiveShip the POC
Archived<div class="task-list">
<article class="task-card">
<h3 class="card-title">Read the architecture</h3>
<span class="badge" data-status="active">Active</span>
<button class="btn" data-size="sm" data-variant="outline">Archive</button>
</article>
<article class="task-card">
<h3 class="card-title">Ship the POC</h3>
<span class="badge" data-status="archived">Archived</span>
<button class="btn" data-size="sm" data-variant="outline">Archived</button>
</article>
</div>
A simple grid of task rows. Renders task-cards from a collection (each="items"); the panel shows the expanded result.
Example
Read the architecture
ActiveShip the POC
Archived<div class="task-list">
<article class="task-card">
<h3 class="card-title">Read the architecture</h3>
<span class="badge" data-status="active">Active</span>
<button class="btn" data-size="sm" data-variant="outline">Archive</button>
</article>
<article class="task-card">
<h3 class="card-title">Ship the POC</h3>
<span class="badge" data-status="archived">Archived</span>
<button class="btn" data-size="sm" data-variant="outline">Archived</button>
</article>
</div>
Organisms
timeline
The interaction timeline — a read-only feed of every crossing of the one door, human and AI, recorded identically at the single writer (AI-INTERFACE §5g). It is the unified log the whole stack implies: because both a human click and an AI decision enter through the same handleIntent, the door records them in one place, one format, source-tagged — so the history reads the same for either operator (uniform auditability a pixel-click imitation can't give). Push-only, never a SurfaceKind. The AI only ever writes to it — log render ops at the timeline surface. Nothing acts on it, so like console it is addressed by its bare slug and is not a verb target. Entries are produced by a LogSink (grain/ai/timeline-log.ts createStreamLogSink) wired at the composition root; the door calls logSink.record(...) for each crossing. Provenance is shown by GRADE, not a hue (the palette is hueless by design): an AI crossing renders in the grain font behind a dashed terminal edge (grain = AI); a human crossing in the smooth font behind a solid edge; a system rejection stays faint. A failed crossing (rejected request, rolled-back write) reads struck-through. So the timeline itself demonstrates grade-as-signal. Parent context: none required — it's self-contained. Give the feed a bounded height (the component caps it at 18rem) so a long run scrolls inside it; the dispatcher pins it to newest and caps the DOM at 80 rows. CSS-only (no .html).
<section class="timeline" aria-label="Interaction timeline">
<div class="timeline__head">
<span>Interaction timeline</span>
<span class="timeline__hint">every crossing of the one door — human and AI, recorded identically</span>
</div>
<ol class="timeline__feed" data-surface="timeline"></ol>
</section>
<li class="timeline__entry" data-provenance="ai" data-kind="response" data-seq="4">
<span class="timeline__who">ai</span>
<span class="timeline__mark">✓</span>
<span class="timeline__verb">chat.send</span>
<span class="timeline__detail">2 ops</span>
</li>
The interaction timeline — a read-only feed of every crossing of the one door, human and AI, recorded identically at the single writer (AI-INTERFACE §5g). It is the unified log the whole stack implies: because both a human click and an AI decision enter through the same handleIntent, the door records them in one place, one format, source-tagged — so the history reads the same for either operator (uniform auditability a pixel-click imitation can't give). Push-only, never a SurfaceKind. The AI only ever writes to it — log render ops at the timeline surface. Nothing acts on it, so like console it is addressed by its bare slug and is not a verb target. Entries are produced by a LogSink (grain/ai/timeline-log.ts createStreamLogSink) wired at the composition root; the door calls logSink.record(...) for each crossing. Provenance is shown by GRADE, not a hue (the palette is hueless by design): an AI crossing renders in the grain font behind a dashed terminal edge (grain = AI); a human crossing in the smooth font behind a solid edge; a system rejection stays faint. A failed crossing (rejected request, rolled-back write) reads struck-through. So the timeline itself demonstrates grade-as-signal. Parent context: none required — it's self-contained. Give the feed a bounded height (the component caps it at 18rem) so a long run scrolls inside it; the dispatcher pins it to newest and caps the DOM at 80 rows. CSS-only (no .html).
<section class="timeline" aria-label="Interaction timeline">
<div class="timeline__head">
<span>Interaction timeline</span>
<span class="timeline__hint">every crossing of the one door — human and AI, recorded identically</span>
</div>
<ol class="timeline__feed" data-surface="timeline"></ol>
</section>
<li class="timeline__entry" data-provenance="ai" data-kind="response" data-seq="4">
<span class="timeline__who">ai</span>
<span class="timeline__mark">✓</span>
<span class="timeline__verb">chat.send</span>
<span class="timeline__detail">2 ops</span>
</li>
Organisms
topbar
The app-shell top bar's content — a right-aligned control cluster. The shell (app-shell) owns the bar's grid slot + border; this primitive is the cluster you place inside .app-shell__topbar. CSS-only (no .html): compose the markup in your frame. Parent context: lives inside .app-shell__topbar. .topbar-ctl pushes itself to the right (margin-left:auto). The theming toggles are grain .icon-btns carrying the declarative theming controls from grain/scripts/theme.js — they need no per-page JS:
<header class="app-shell__topbar">
<!-- …brand / nav… -->
<div class="topbar-ctl">
<button class="icon-btn" data-variant="ghost" data-toggle-scheme aria-label="Toggle light / dark">…</button>
<button class="icon-btn" data-variant="ghost" data-cycle-theme aria-label="Cycle theme">…</button>
<b-kbd keys="⌘K"></b-kbd>
<span class="presence"><b-icon sym="…#spark" size="sm"></b-icon> online</span>
</div>
</header>
The app-shell top bar's content — a right-aligned control cluster. The shell (app-shell) owns the bar's grid slot + border; this primitive is the cluster you place inside .app-shell__topbar. CSS-only (no .html): compose the markup in your frame. Parent context: lives inside .app-shell__topbar. .topbar-ctl pushes itself to the right (margin-left:auto). The theming toggles are grain .icon-btns carrying the declarative theming controls from grain/scripts/theme.js — they need no per-page JS:
<header class="app-shell__topbar">
<!-- …brand / nav… -->
<div class="topbar-ctl">
<button class="icon-btn" data-variant="ghost" data-toggle-scheme aria-label="Toggle light / dark">…</button>
<button class="icon-btn" data-variant="ghost" data-cycle-theme aria-label="Cycle theme">…</button>
<b-kbd keys="⌘K"></b-kbd>
<span class="presence"><b-icon sym="…#spark" size="sm"></b-icon> online</span>
</div>
</header>
Organisms
welcome (portfolio)
The editor's Welcome page layout — the VS Code start screen, re-spoken in GRAIN: a display title, a one-liner, a two-column Start/Recent + Walkthroughs grid, and a footed pill CTA with the (functional) "Show welcome page on startup" checkbox. CSS-only scaffold; the lists inside are welcome-start, welcome-recent, and walkthrough-card. Parent contract: lives in the app-shell's main pane (the editor window frame around it comes from portfolio-frame). The checkbox is wired by site.js ([data-startup-checkbox], localStorage): unchecked, / reopens on the page you last had open (workspace fallback).
<div class="welcome">
<h1 class="welcome__title">TJ's Desk</h1>
<p class="welcome__sub">Software engineering, taught and shipped.</p>
<div class="welcome__cols">
<div><h2>Start</h2> … <h2>Recent</h2> …</div>
<div><h2>Walkthroughs</h2> …</div>
</div>
<div class="welcome__foot">
<a class="welcome__cta" href="/grain">✶ Try the desk</a>
<label class="welcome__startup"><input type="checkbox" data-startup-checkbox checked> Show welcome page on startup</label>
</div>
</div>
The editor's Welcome page layout — the VS Code start screen, re-spoken in GRAIN: a display title, a one-liner, a two-column Start/Recent + Walkthroughs grid, and a footed pill CTA with the (functional) "Show welcome page on startup" checkbox. CSS-only scaffold; the lists inside are welcome-start, welcome-recent, and walkthrough-card. Parent contract: lives in the app-shell's main pane (the editor window frame around it comes from portfolio-frame). The checkbox is wired by site.js ([data-startup-checkbox], localStorage): unchecked, / reopens on the page you last had open (workspace fallback).
<div class="welcome">
<h1 class="welcome__title">TJ's Desk</h1>
<p class="welcome__sub">Software engineering, taught and shipped.</p>
<div class="welcome__cols">
<div><h2>Start</h2> … <h2>Recent</h2> …</div>
<div><h2>Walkthroughs</h2> …</div>
</div>
<div class="welcome__foot">
<a class="welcome__cta" href="/grain">✶ Try the desk</a>
<label class="welcome__startup"><input type="checkbox" data-startup-checkbox checked> Show welcome page on startup</label>
</div>
</div>