Pulliv0.1
8px
11.05 — Showcase · Email & print

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() and calc(). 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.
The answer is resolution, not reference. The semantic layer stops being a runtime lookup and becomes a build-time one. 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.

Email

Lattice
resolved at P = 8
Width
600px · 75 P
Theme
light only, declared
Pattern
PNG from seed 1229

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.

Print

Lattice
re-seeded · P = 2mm
Type base
10pt, ratio √2
Page
A4 · 18/16/20mm
Elevation
removed entirely
Margazhi Studio · Pattern record

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.

PropertyValue
Grid7 × 7 pulli
Seed4471
SymmetryD4 — both mirrors and transpose
Path segments84
Boundary loops12
Open endpoints0
Pulli v0.1 · derived from kolam practice1 / 1

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 roleScreen · P = 8Email · literalPrint · P = 2mm
--space-inset-smvar(--idai-2)16px4mm
--space-inset-mdvar(--idai-3)24px6mm
--space-inset-lgvar(--idai-4)32px8mm
--space-sectionvar(--idai-12)96px24mm
--radius-controlvar(--valai-3)8px2mm
--radius-panelvar(--valai-4)16px4mm
--border-hairline1px1px0.5pt ↑
--border-edge2px2px1pt
--alavu-mdmax(5.5P, 44px)44px fixedn/a
--ezhuthu-body16px16px10pt re-seeded
--ezhuthu-h145px32px ↓28pt
--color-border#E6DCC9#E6DCC9#998A75 ↑↑
--color-text-tertiary#7C6B5C#7C6B5C#5C4E42 merged
The ratio is structural; the base is surface-dependent. Print does not use 16px type scaled to millimetres — it re-seeds at 10pt, because 16px is a screen reading size and 10pt is a paper one. What travels between surfaces is √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.

WhereBrokeFix
email 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.
email 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.
email 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.
print --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.
print 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.
The honest conclusion. Four of these five are cases where a runtime guarantee became a build-time responsibility. That is a real loss of safety, and the correct response is not to claim the system covers email and print seamlessly — it is to move the corresponding checks into CI, where the resolved output can be asserted against 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.json and 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/2 and √2 are meaningful in millimetres and points, which is what makes re-seeding possible rather than merely rescaling.
  • tokens.json stopped 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.