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.
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
textandmuted. Stacks look cheap when every element wants attention. - Shapes are furniture, not decoration. A
surfacerect behind a block of text makes a panel. A 2pxmutedrect at 0.3 opacity is a rule. Three small accent rects stacked are a mark. Give them negativezso 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:
{ "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
alttext, or"decorative": truewhen 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.