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

Doing it now (active)
<action-badge verb="clicks" status="active"></action-badge>
Done (settled)
<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

Doing it now (active)
<action-badge verb="clicks" status="active"></action-badge>
Done (settled)
<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

Active
Active
<span class="badge" data-status="active">Active</span>
Archived
Archived
<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

Active
Active
<span class="badge" data-status="active">Active</span>
Archived
Archived
<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

Default
<button class="btn">Button</button>
Hover
<button class="btn" data-force="hover">Button</button>
Focus
<button class="btn" data-force="focus">Button</button>
Active
<button class="btn" data-force="active">Button</button>
Disabled
<button class="btn" disabled>Button</button>

Sizes

Small
<button class="btn" data-size="sm">Small</button>
Medium
<button class="btn">Medium</button>
Large
<button class="btn" data-size="lg">Large</button>

Variants

Solid
<button class="btn">Solid</button>
Soft
<button class="btn" data-variant="soft">Soft</button>
Outline
<button class="btn" data-variant="outline">Outline</button>

Status

Success
<button class="btn" data-status="success">Save</button>
Danger
<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

Working
<button class="btn" data-commit="pending">Working…</button>
Archiving (small)
<button class="btn" data-size="sm" data-commit="pending">Archiving…</button>
Full width
<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

Plain
bun run dev
<pre class="code-block"><code>bun run dev</code></pre>
With a language tag
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

Plain
bun run dev
<pre class="code-block"><code>bun run dev</code></pre>
With a language tag
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

Planned · only the plan exists so far
<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

Planned · only the plan exists so far
<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

Default
<label class="field">
  <span class="field__label">Name</span>
  <input class="field__input" name="name" placeholder="Jane">
</label>
Focus
<label class="field">
  <span class="field__label">Name</span>
  <input class="field__input" name="name" value="Jane" data-force="focus">
</label>

Variants

Default (stacked)
<label class="field">
  <span class="field__label">Name</span>
  <input class="field__input" name="name">
</label>
Inline
<label class="field" data-variant="inline">
  <span class="field__label">Name</span>
  <input class="field__input" name="name">
</label>

Sizes

Small
<label class="field" data-size="sm">
  <span class="field__label">Name</span>
  <input class="field__input" name="name">
</label>
Large
<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

Default
<label class="field">
  <span class="field__label">Name</span>
  <input class="field__input" name="name" placeholder="Jane">
</label>
Focus
<label class="field">
  <span class="field__label">Name</span>
  <input class="field__input" name="name" value="Jane" data-force="focus">
</label>

Variants

Default (stacked)
<label class="field">
  <span class="field__label">Name</span>
  <input class="field__input" name="name">
</label>
Inline
<label class="field" data-variant="inline">
  <span class="field__label">Name</span>
  <input class="field__input" name="name">
</label>

Sizes

Small
<label class="field" data-size="sm">
  <span class="field__label">Name</span>
  <input class="field__input" name="name">
</label>
Large
<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

Default
<label class="field">
  <span class="field__label">Environment</span>
  <select class="field__select" name="env">
    <option>staging</option><option>prod</option>
  </select>
</label>
Focus
<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

Default (stacked)
<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>
Inline
<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

Small
<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>
Large
<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

Default
<label class="field">
  <span class="field__label">Environment</span>
  <select class="field__select" name="env">
    <option>staging</option><option>prod</option>
  </select>
</label>
Focus
<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

Default (stacked)
<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>
Inline
<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

Small
<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>
Large
<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

Off
<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>
On
<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>
Disabled
<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

Off
<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>
On
<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>
Disabled
<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

Body

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>
Display heading

An AI that writes back.

<h1 class="t masthead">An AI that writes back.</h1>
Section heading

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

Body

On it — checking your week. You have room on Thursday.

<p class="t">On it — checking your week. You have room on Thursday.</p>
Streaming (caret)

Noted: deep-work moved to Thursday

<p class="t"><span>Noted: deep-work moved to Thursday</span><span class="caret"></span></p>
Accent — display only

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

You (human — clean)
YouPlan my Thursday.
<div class="chat-message" data-role="you"><span class="chat-message__who">You</span><span class="chat-message__body">Plan my Thursday.</span></div>
The AI (grain)
GRAINOn it — three deep-work blocks, review at 2.
<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)

GRAIN I read your three newest notes and wrote a digest.
<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

You (human — clean)
YouPlan my Thursday.
<div class="chat-message" data-role="you"><span class="chat-message__who">You</span><span class="chat-message__body">Plan my Thursday.</span></div>
The AI (grain)
GRAINOn it — three deep-work blocks, review at 2.
<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)

GRAIN I read your three newest notes and wrote a digest.
<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

  • 2026-07-04 · ~8 min

    The Browser Grew Up

    The honest ledger of what betting on the native platform bought me.

    native-first
<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

  • 2026-07-04 · ~8 min

    The Browser Grew Up

    The honest ledger of what betting on the native platform bought me.

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

  • Manage and mentor a team of engineers, working AI-first to ship faster.
  • <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).

  • Manage and mentor a team of engineers, working AI-first to ship faster.
  • <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.

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

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

    Technical Team Lead, Career Team
    <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).

    Technical Team Lead, Career Team
    <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.

    150-300 students a term software engineering
    <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.

    150-300 students a term software engineering
    <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

    ARCHITECTUREthe substrate's reasoning.
    CONVENTIONSthe build standard.
    <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

    ARCHITECTUREthe substrate's reasoning.
    CONVENTIONSthe build standard.
    <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.

    1. 🗓️ Hackathon Placeholder venue

      Coached a hackathon team (placeholder)

      hackathon, coaching, placeholder

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

    1. 🗓️ Hackathon Placeholder venue

      Coached a hackathon team (placeholder)

      hackathon, coaching, placeholder

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

    <div class="feed-photos">
      <a class="feed-photo" href="/media/feed/hackathon-1.svg">
        <img loading="lazy" decoding="async" src="/media/feed/hackathon-1.svg" width="1200" height="675"
             alt="Placeholder photo standing in for a hackathon team photo">
      </a>
    </div>

    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.

    <div class="feed-photos">
      <a class="feed-photo" href="/media/feed/hackathon-1.svg">
        <img loading="lazy" decoding="async" src="/media/feed/hackathon-1.svg" width="1200" height="675"
             alt="Placeholder photo standing in for a hackathon team photo">
      </a>
    </div>

    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

    An abstract field of grain strokes
    A caption sits under the image, set small.
    <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

    The same image, clipped
    <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

    An abstract field of grain strokes
    A caption sits under the image, set small.
    <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

    The same image, clipped
    <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.

    Welcome to the mail panel

    The Desk → You · Jul 14, 2026

    This whole panel is dressed up like a real inbox…
    <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

    The Desk → You · Jul 14, 2026

    This whole panel is dressed up like a real inbox…
    <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.

    <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.">
          &#9650;<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.

    <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.">
          &#9650;<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

    Part of tjakoen.github.io, built with itself.

    🤖 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

    Part of tjakoen.github.io, built with itself.

    🤖 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

    A run dashboard showing six passed and two failed checks across two environments.
    One run, two environments, per-environment health.
    <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

    A run dashboard showing six passed and two failed checks across two environments.
    One run, two environments, per-environment health.
    <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

    128 total
    <div class="stat">
      <span class="stat__value">128</span>
      <span class="stat__label">total</span>
    </div>

    With a sub-line

    128 total across 4 environments
    <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

    14passed
    2failed
    3skipped
    <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)

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

    128 total
    <div class="stat">
      <span class="stat__value">128</span>
      <span class="stat__label">total</span>
    </div>

    With a sub-line

    128 total across 4 environments
    <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

    14passed
    2failed
    3skipped
    <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)

    passed
    <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 2.1s · staging
    • checkout completes 4.8s · staging
    <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 staging
    <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 1m 12s
    <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 2.1s · staging
    • checkout completes 4.8s · staging
    <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 staging
    <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 1m 12s
    <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

    Active
    <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>
    Default + closable
    <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>
    Pinned (never closes)
    <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

    Active
    <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>
    Default + closable
    <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>
    Pinned (never closes)
    <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 verbcontract.ts → reasoner → tests → docs
    A design tokengrain/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 verbcontract.ts → reasoner → tests → docs
    A design tokengrain/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

    Active

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

    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

    Active

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

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

    Main content.
    ✶ the AI — idle
    <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`)

    Main content.
    ✶ the AI — idle
    <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

    YouPlan my Thursday.
    GRAINOn it — three deep-work blocks, review at 2.
    <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

    YouPlan my Thursday.
    GRAINOn it — three deep-work blocks, review at 2.
    <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

    Empty (pre-engine fallback)
    tests/checkout.spec.ts • unsaved
    import { test, expect } from '@playwright/test';
    Ready
    <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>
    Dirty
    tests/checkout.spec.ts • unsaved
    // edited…
    Ready
    <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

    Validating
    spec.ts
    Validating…
    <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>
    Valid
    spec.ts
    Compiles — 0 problems
    <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>
    Invalid (with diagnostics)
    spec.ts
    1 problem
    12:4 Cannot find name 'expcet'. Did you mean 'expect'?
    <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>
    Saved
    spec.ts
    Saved as version 4
    <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

    spec.ts
    Ready
    <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

    AI writing a spec
    tests/checkout.spec.ts • writing…
    await page.goto('/checkout');
    Drafting…
    <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>
    Inherited grade
    spec.ts
    Ready
    <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

    No items yet. Capture one above.
    <div class="empty-state">No items yet. Capture one above.</div>

    Shown when a list has nothing in it — muted, centred, quiet.

    Example

    No items yet. Capture one above.
    <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 workflow

    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 workflow

    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

    Notepad

    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

    Notepad

    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

    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.

    ✶ online 0 ⊘ · 0 ⚠ main
    <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.

    ✶ online 0 ⊘ · 0 ⚠ main
    <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

    Active

    Ship 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

    Active

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

    Interaction timeline every crossing of the one door — human and AI, recorded identically
      <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>
    1. ai chat.send 2 ops
    2. <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).

      Interaction timeline every crossing of the one door — human and AI, recorded identically
        <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>
      1. ai chat.send 2 ops
      2. <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:

        online
        <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:

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

        TJ's Desk

        Software engineering, taught and shipped.

        Start

        Recent

        Walkthroughs

        ✶ Try the desk
        <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).

        TJ's Desk

        Software engineering, taught and shipped.

        Start

        Recent

        Walkthroughs

        ✶ Try the desk
        <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>