The surface without variables.
Outlook renders email with Word. Paper renders nothing at all. No custom properties, no flexbox, no max(), no media queries you can trust, no JavaScript, and in print no viewport, no dark mode and no hover. This is the surface that decides whether a token model is a real abstraction or just a CSS convention.
The problem
Every other surface consumes tokens at runtime. These two cannot.
Gone in email
- Custom properties. Word's engine ignores
var()entirely — the declaration is dropped, not fallen back. - Flexbox and grid. Layout is tables, as it was in 2003.
max()andcalc(). The 44px floor cannot be expressed as a function.- External stylesheets. Everything is inline, or it is nothing.
- SVG. The generator's native output does not render at all.
- JavaScript. Obviously.
Gone in print
- The viewport. Breakpoints are meaningless; the page is a fixed physical object.
- Dark mode. There is one ground and it is paper.
- Elevation. Nothing floats. Shadow is ink spent on a lie.
- Target size. Nothing is touched, so the 44px floor has no referent.
- Motion. Naturally.
- Cheap colour. Ink has a cost and light backgrounds have a coverage limit.
tokens.json — which existed as documentation until now — turns out to be the load-bearing artefact: it is the only representation of the system a build script can read. That is the argument for shipping a machine-readable token export even when nothing appears to consume it.
This is the actual shipped file rendered in a frame, not a copy — open assets/email-template.html directly if your browser blocks local frames. Note that it stays light-on-white while this page is in dark mode: that is correct behaviour, not a bug, and the reason is below.
Three things worth looking at
The bulletproof button
44px of target without max(): 13px vertical padding plus 18px line-height resolves to exactly 44. Outlook gets a VML roundrect at a literal height:44px. The floor is honoured on both paths — it was just computed by a human once instead of by CSS every time.
The PNG
Generated from seed 1229 — the same seed as the SVG in the website hero. Because the generator is deterministic, the raster and the vector are provably the same pattern rather than two assets someone hopes match.
The declared theme
meta color-scheme: light and supported-color-schemes: light. Email dark mode is client-side colour inversion, not a theme swap. Declaring light-only is more honest than shipping a dark variant that four clients will re-invert into something nobody designed.
Margazhi 12
Construction
Generated on a seven by seven pulli lattice under D4 symmetry. The line is continuous by construction: every path endpoint is shared by exactly two paths, and the boundary closes into twelve loops around the outer dots.
| Property | Value |
|---|---|
| Grid | 7 × 7 pulli |
| Seed | 4471 |
| Symmetry | D4 — both mirrors and transpose |
| Path segments | 84 |
| Boundary loops | 12 |
| Open endpoints | 0 |
A4 at 60% — the sheet is authored at real physical size in millimetres and points, then scaled for this column. This page also links print.css live: press Cmd/Ctrl+P to see the documentation itself re-render onto paper with the chrome removed, the lattice re-seeded and every link's URL made visible.
Token resolution
The same semantic role, resolved three ways. Note that print does not scale the screen values — it re-seeds the lattice in its own unit.
| Semantic role | Screen · P = 8 | Email · literal | Print · P = 2mm |
|---|---|---|---|
| --space-inset-sm | var(--idai-2) | 16px | 4mm |
| --space-inset-md | var(--idai-3) | 24px | 6mm |
| --space-inset-lg | var(--idai-4) | 32px | 8mm |
| --space-section | var(--idai-12) | 96px | 24mm |
| --radius-control | var(--valai-3) | 8px | 2mm |
| --radius-panel | var(--valai-4) | 16px | 4mm |
| --border-hairline | 1px | 1px | 0.5pt ↑ |
| --border-edge | 2px | 2px | 1pt |
| --alavu-md | max(5.5P, 44px) | 44px fixed | n/a |
| --ezhuthu-body | 16px | 16px | 10pt re-seeded |
| --ezhuthu-h1 | 45px | 32px ↓ | 28pt |
| --color-border | #E6DCC9 | #E6DCC9 | #998A75 ↑↑ |
| --color-text-tertiary | #7C6B5C | #7C6B5C | #5C4E42 merged |
√2 and r = P/2, not the numbers they produce. A system that could only scale its existing values would have failed here.What breaks
Five, this time. More than the other four surfaces combined — which is the point of building it.
| Where | Broke | Fix |
|---|---|---|
The 44px floor could not be expressed. max() is the mechanism that makes the target guarantee automatic, and it does not exist here. |
Resolved by hand into padding plus line-height that sums to exactly 44, and a VML roundrect at literal height:44px for Outlook. The guarantee moved from the token to the build step — which means it now needs a test, because nothing enforces it at runtime. |
|
| SVG does not render. The generator's entire output format is unavailable. | Rasterise from the same seed. Because generation is deterministic, the PNG is not an approximation of the SVG — it is the same pattern in another format, and a test can assert they came from identical parameters. | |
| Dark mode is not a theme. Several clients invert colours algorithmically, ignoring any dark palette you supply, and produce results nobody designed. | Declare light-only via color-scheme and supported-color-schemes, and choose values that survive inversion legibly. This is the one surface where the dual-theme model simply does not apply, and saying so beats shipping a variant that gets overridden. |
|
--color-border disappeared — twice. #E6DCC9 measures 1.36:1 on paper white and vanishes at 0.5pt. The first fix, #C9B99E, only reached 1.92:1 and was still too faint to read as a table rule. |
Lifted again to #998A75 — 3.36:1. The deeper point is a role reclassification: on screen this token is documented as decorative and exempt from the 3:1 floor, but on paper a table rule carries structure, so it inherits the boundary requirement. Print collapses --color-border and --color-border-strong into one value rather than inventing a third. A role's classification can be surface-dependent, which the two-theme model did not anticipate. |
|
| Tertiary text was unreadable. Three text weights are useful on a backlit screen and merely muddy in ink at 7–8pt. | Tertiary merges into secondary. A role can collapse on a surface without the role model being wrong — what matters is that the merge happens in the print token block where it can be audited, not scattered through the components. |
tokens.json rather than trusted.The build step
Roughly forty lines. The point is not the script — it is that the script is possible, because the token model has a machine-readable form.
// resolve.js — semantic roles to literals, for surfaces without var() const tokens = require('./assets/tokens.json'); const TARGETS = { email: { p: 8, unit: 'px', theme: 'light' }, print: { p: 2, unit: 'mm', theme: 'light', typeBase: '10pt' } }; // {seed.p} * 3 -> 24 (px) or 6 (mm) function resolve(expr, p) { if (typeof expr === 'number') return expr; const m = String(expr).match(/\{seed\.([pr])\}\s*\*\s*([\d.]+)/); if (!m) return expr; const seed = m[1] === 'p' ? p : p / 2; return seed * parseFloat(m[2]); } function build(target) { const t = TARGETS[target], out = {}; for (const [role, ref] of Object.entries(tokens.semantic.space)) { const prim = deref(ref.$value); // {primitive.idai.3} out[role] = resolve(prim.$value, t.p) + t.unit; } for (const [role, modes] of Object.entries(tokens.semantic.color)) { out[role] = modes[t.theme]; // print overrides: contrast floors differ on paper if (target === 'print' && PRINT_OVERRIDES[role]) out[role] = PRINT_OVERRIDES[role]; } return out; }
What CI must assert
- Resolved values match the source. Re-resolve from
tokens.jsonand diff against the literals in the shipped email; fail on drift. - The 44px floor survived. Parse the email's button padding and line-height and assert the sum. This is the check that replaces
max(). - The PNG matches its seed. Regenerate from the recorded parameters and compare hashes.
- Print contrast at the print palette. Every pair recomputed against paper white, not screen ground — including the roles that are exempt on screen but load-bearing on paper.
What this proves about the model
- The three-layer split earned its keep. Only the semantic layer had to be resolved. Palette and primitives came through unchanged because nothing consumed them directly.
- The seed relationship travelled.
r = P/2and√2are meaningful in millimetres and points, which is what makes re-seeding possible rather than merely rescaling. tokens.jsonstopped being documentation. It is the only reason a build script can exist at all — and it was written before there was anything to consume it.