# Cairn Stack

> Build beautiful, self-contained Cairn stacks — single `.cairn.html` files that carry their own reader, editor, pictures, and reader progress, work offline from a folder, and stay readable for years. Use this skill whenever the user mentions Cairn, a `.cairn.html` file, or a `cairn/stack` document, AND whenever they ask for anything shaped like one even without naming it: an interactive card deck, a portable explainer or handbook, a field guide, a one-file course or onboarding walkthrough, an offline presentation, a self-contained interactive document, or "something I can email that just opens and works with no server". Also use it to edit, restyle, extend, or audit an existing `.cairn.html`. Prefer this over hand-rolling an HTML page whenever the deliverable should stay useful after its tooling is gone.

- Skill: `thomasrohde/cairn-stack` (Agent Skill, multi-file: 11 files)
- Install (CLI): `npx skillmds@latest add thomasrohde/cairn-stack`
- Raw SKILL.md: https://api.skillmd.com/api/skills/thomasrohde/cairn-stack/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: thomasrohde (https://skillmd.com/u/thomasrohde)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/thomasrohde/cairn-stack

---


# Cairn stacks

A Cairn stack is one `.cairn.html` file. Inside it: the reader software, the
optional visual editor, a card-based document stored as plain JSON, every
picture and sound it needs, and the reader's place in it. Open it from a folder
and it works — no server, no install, no network. Press **Edit** and the reader
becomes an author. Press **Save As** and the browser writes a new file that
carries all of that with it.

That constraint is the whole point, and it shapes authoring:

- **Content is data, never code.** No author CSS, no author JavaScript. Colour
  comes from eight theme tokens; behaviour comes from a small closed set of
  declarative actions. What you cannot express is deliberately out of reach.
- **Everything is inside the file.** Prefer `sealed`, which cannot reach the
  network at all. A stack that needs a CDN is a stack that dies with the CDN.
- **Layout is absolute.** A card is a fixed canvas — 1280 × 800 unless you choose
  otherwise — and every element is a positioned box. There is no flexbox to save
  you, which is why the grid below matters more here than in ordinary web work.

## How to build one

You write a `*.stack.json` spec — the document plus a list of asset sources —
and the bundled script produces the `.cairn.html`. It handles everything
mechanical: `<` escaping, marker splicing, the canonical CSP, sorted asset
blocks, exact decoded byte counts. Hand-editing the HTML is possible and
unnecessary; spend the effort on content and composition instead.

```bash
S=<skill-dir>/scripts/cairn.mjs          # no dependencies, no npm install

node $S new     field-guide.stack.json --title "A field guide to lichen"
node $S build   field-guide.stack.json -o field-guide.cairn.html
node $S check   field-guide.cairn.html
node $S preview field-guide.cairn.html --out shots   # render and measure
node $S extract someone-elses.cairn.html -o edit.stack.json   # to revise one
```

`new` copies a working six-card starter and mints a fresh UUID v4 `docId`. Read
that starter (`assets/starter.stack.json`) before writing your own — it is the
grid, the type scale, and the interaction patterns in usable form. Keep its
geometry; replace its words.

`build` refuses to write when the document has errors, and prints design
warnings without blocking. `check` re-runs everything against a built file and
also proves that every byte outside the four managed regions still matches the
shell it came from.

Then **look at it** — this step is not optional, and it is the one most easily
skipped. `preview` renders every flow card in headless Chromium and reports what
static validation cannot know:

- **Measured** text overflow, from the real DOM. A text element that is too small
  for its copy does not reflow or grow — it becomes a scroll box. The words are
  still reachable by dragging inside them, but they are invisible at a glance,
  the arrow keys that page the deck will not reveal them, and nobody scrolls a
  paragraph on a projector or a phone in a field. It raises no error anywhere,
  which is why this is the most common way a valid stack is a bad one.
- Console and page errors raised while rendering.
- A PNG per card in `--out`.

Then read the PNGs with your own eyes. `preview` proves nothing overflows and
nothing threw; it cannot tell you a card is ugly, that a figure is optically
off-centre, or that two paragraphs say the same thing.

`preview` needs Playwright — resolved from the project or a global install. It
never installs anything. If it reports `skipped`, either install Playwright
(`npm i -D playwright && npx playwright install chromium`) or open the file
yourself and page through with the arrow keys (Esc for the chrome, O for the
overview). If you genuinely cannot see it rendered, say so in your report
instead of implying you checked — an unseen stack is an unverified one.

### Editing an existing stack

`extract` recovers an editable spec and its asset files. **Keep the `docId`** —
it is the document's identity, and reader progress in copies out in the world is
keyed to it. Mint a new one only when you intend a genuine fork. Keep card,
element, and asset ids stable too: handlers, saved state, and navigation all
reference ids, never labels.

## The grid

Every good Cairn card in this system shares one geometry. Using it consistently
is most of what makes a stack look designed rather than assembled.

```
canvas    1280 × 800
margin    x = 76, content width = 1128      ← left edge of everything
header    kicker y 140 · heading y 174 · rule at y 116
body      content lives between y 268 and y 620
footer    nav buttons y 636 · progress y 700
columns   full 1128 · split 600 + 464 (gap 64) · sidebar 520 + 548 (gap 60)
```

Right edges land on 1204 (= 1280 − 76). A two-column card is `x: 76, w: 600`
beside `x: 740, w: 464`. Keep at least 32px of air on every side; the linter
says so, and crowded edges are the most common way a card reads as unfinished.

Put the furniture — rules, page marks, footer navigation, the progress readout —
on a **background**, not on each card. Backgrounds exist so that changing the
footer once changes it everywhere, and so a cover can look different by using a
different background rather than by special-casing a card.

### Choose the canvas for the device

The reader scales the whole card uniformly to fit the window — `min(w/W, h/H)`,
letterboxed, never cropped. So the canvas aspect ratio decides how big the type
actually is on the reader's screen, and this is the one early decision that is
expensive to reverse.

- **1280 × 800** (the default) suits laptops, desktops, and projectors. On a
  portrait phone it scales to about 30%, so 19px body copy renders near 6px.
  That is unreadable, not merely small.
- **420 × 860** suits a stack meant to be read on a phone in the field. It
  renders roughly 1:1 on a handset; on a desktop it appears as a narrow centred
  column, which is an honest trade rather than a bug.

If the user says "on their phones", "in the field", or "on my commute", ask
yourself which device wins and pick the canvas deliberately — then tell the user
which you chose and why. Do not split the difference: a canvas that fits neither
is worse than one that fits one. `references/layouts.md` carries the full grid
for both.

## Type

There is one gotcha worth internalising: **the runtime renders `h1` at 2.3× the
element's own font size.** So you author a display line by the size you want it
to *look*, divided by 2.3.

| Role | Looks like | Author as | Line height |
| --- | --- | --- | --- |
| Cover title | 62px | `<h1>` at `fontSize: 27` | 1.08 |
| Card heading | 42px | `<h1>` at `fontSize: 18` | 1.08 |
| Standfirst | 21px | `<p>` at `fontSize: 21` | 1.5 |
| Body copy | 19px | `<p>` at `fontSize: 19` | 1.55 |
| Small body | 17–18px | `<p>` | 1.5 |
| Caption, footnote | 14–15px, `color: "muted"` | `<p>` | 1.45 |
| Kicker / eyebrow | 13px, `fontWeight: 700`, `color: "accent"`, UPPER CASE | `<p>` | 1.3 |

A heading frame needs about `size × 1.08 × lines + size × 0.45` of height. Give
body text a frame with room to spare: the linter estimates whether copy fits,
but an estimate is not a measurement, and text that overflows its box turns into
a scroll box rather than reflowing — present but invisible.

Tables get no cell padding from the runtime, so key/value lists read better as
paired lines (`<p><strong>Term</strong><br>Definition</p>`) than as `<table>`.

## Colour

Element styles take **token names**, never hex: `color: "accent"`, not
`color: "#1C554C"`. Hex values appear once, in `doc.theme.colors`. That is what
makes a stack restylable — change the theme and every element follows.

The eight tokens are `bg`, `surface`, `text`, `muted`, `accent`, `accentText`,
`success`, `danger`. Define all eight and a `radius`; a partial palette silently
mixes with defaults and looks accidental.

Contrast is a real constraint, not a nicety — `text` on `bg` and `accentText` on
`accent` both need 4.5:1. `references/layouts.md` has five palettes with
verified ratios; the linter checks whatever you write.

Two habits carry most of the visual weight:

- **One accent, used sparingly.** Kickers, the primary button, a rule, a figure
  stroke. Everything else is `text` and `muted`. Stacks look cheap when every
  element wants attention.
- **Shapes are furniture, not decoration.** A `surface` rect behind a block of
  text makes a panel. A 2px `muted` rect at 0.3 opacity is a rule. Three small
  accent rects stacked are a mark. Give them negative `z` so content sits above.

## Composing a card

Each card should be one idea, held in a shape the reader recognises by the third
card. The reliable rhythm is: **kicker → heading → one column of prose → one
thing to look at or do.**

Resist filling the canvas. A card carrying one 300px paragraph and one figure
reads as confident; the same card with four boxes and a stat strip reads as a
dashboard nobody asked for.

`references/layouts.md` has the archetypes with real frame numbers — cover,
statement, prose-and-figure, two-up comparison, gate, reveal, gallery, closing —
plus the palettes and safe-SVG patterns. Read it when you start composing.

## Behaviour

State is declared and typed. Four types (`string`, `number`, `boolean`,
`stringList`), each with a matching `initial`, and no coercion anywhere.

**Action values and guards are expression *strings*, not JSON values.** This is
the single most common mistake:

```json
{ "act": "set", "var": "done", "to": "true" }        ✓ the expression true
{ "act": "set", "var": "done", "to": true }          ✗ a raw JSON boolean
{ "act": "set", "var": "who", "to": "\"Ada\"" }      ✓ a quoted string literal
{ "act": "set", "var": "who", "to": "Ada" }          ✗ reads Ada as a variable
{ "act": "set", "var": "label", "to": "concat(a, b)" }  ✓ join with concat, not +
```

Handlers live on elements, cards, backgrounds, or the stack, and resolve in that
order. Use the placements deliberately: element handlers for buttons, card
handlers for `enter`/`leave` bookkeeping, background handlers for shared
furniture, stack handlers for keyboard navigation.

Interpolate live values into display text with `{{ expression }}`:
`"{{ card }} of {{ cards }}"`, `"Ready? {{ round(confidence * 100) }}%"`.

`references/recipes.md` has working JSON for the patterns worth reusing —
guarded continue with a hint, reveal-on-demand, quiz scoring, keyboard
navigation, a menu card, checklists, and an external link. Copy from there
rather than deriving handler wiring from scratch.

## What "finished" means

Before you hand a stack over, `check` and `preview` should both be quiet, you
should have looked at the rendered cards, and these should be true:

- Every card says something. A card with a heading and eleven words is a slide,
  and the linter will tell you its character count.
- The reader always knows where they are and how to move — progress readout,
  visible navigation, and ideally a key handler.
- Interactive state is honest: a guard that blocks Continue also explains why,
  rather than making the button silently inert.
- Images have `alt` text, or `"decorative": true` when they genuinely carry
  nothing.
- It is `sealed`, unless it truly needs the network.

If the brief asks for something *comprehensive* or *fully featured*, there is a
measurable bar the shell itself defines — 20 cards, two backgrounds, all four
variable types, ten element kinds, eight action kinds, handlers at all four
placements, a guard, a key handler, interpolation, an embedded SVG, and full
reachability. Check it with `node $S check file.cairn.html --featured`. Do not
chase that bar for an ordinary stack; padding a six-card explainer to hit
feature counts makes it worse.

## Reference files

- `references/format.md` — every field of every element kind, variable,
  handler, action, expression built-in, and asset rule, plus the spec format
  the build script accepts. Read it when you need a field you have not used.
- `references/layouts.md` — card archetypes with frames, five verified
  palettes, figure and SVG patterns. Read it when composing.
- `references/recipes.md` — working handler JSON for common interactions.
- `assets/starter.stack.json` — the six-card starter, and the best single
  example of all of the above at once.
- `assets/cairn-shell.cairn.html` — the shell every stack is built into. It also
  carries its own complete field reference in the first 12 KB, inside
  `#cairn-agent-reference`, if you ever need to author without this skill.

