# Retint a whole document from one palette

> Each colour in the config carries a palette id, so one function returns a fresh config that sets a festival programme in red, teal, violet or sand.

- HTML version: https://postext.dev/en/cookbook/live-palette-retint
- Recipe Nº 054 · Output & integration · Level 2 (Intermediate) · Outputs: Canvas, Live controls
- Genres: Single sheets & ephemera
- Requires postext ≥ 1.4.1 · tested with 1.4.1 on 2026-09-26
- Pages: [1](https://postext.dev/cookbook/live-palette-retint/en/p01.webp?v=cfb3bc74), [2](https://postext.dev/cookbook/live-palette-retint/en/p02.webp?v=cfb3bc74)
- Last updated: 2026-09-27
- Other languages: [es](https://postext.dev/es/cookbook/live-palette-retint.md)

## What you'll build

The programme of *Riverside Music Week*, a fictional festival in a river town, printed as a 200 mm square leaflet. The front page has a colour field across its top half, with the title in Syne 800 at 46 pt and a waveform of pale rounded bars; below it, two ragged columns list the week day by day, each event with a bold time and a price chip. The back maps the week as a grid of coloured tiles under a caption bar, then lists Tuesday to Friday and ends with a tickets box under a stripe and the colophon. Above the pages the pen shows four proofs, in red, teal, violet and sand. Their colours come from seven palette entries named by role, plus `main-color` set to the band colour. Click a proof and the whole document is built again in that palette.

**This recipe answers:**

- How do I replace the default blue of headings, bold text and bullets with colours from my own palette?
- How do I colour key terms (bold or italic) in the body or inside boxes?
- How do I add colour-key swatches to text, captions or table notes?

## The short answer

A colourway is a palette; retint() builds a fresh config linked to it.

```js
// script.js, lines 27–39
function retint(way) {
  const palette = { ...NEUTRALS, ...COLOURWAYS[way] };
  // 1.4.1 applies colorPalette to text, lists, boxes, chips, captions and tables, and resolves
  // swatches and cell fills against it; design elements and referenceColor print the hex
  // written beside their id, so relink() rewrites that hex (gotcha: palette-skips-designs).
  const relink = (v) => (Array.isArray(v) ? v.map(relink) : !v || typeof v !== 'object' ? v
    : Object.hasOwn(palette, v.paletteId ?? '') ? { ...v, hex: palette[v.paletteId] }
      : Object.fromEntries(Object.entries(v).map(([k, x]) => [k, relink(x)])));
  return { // a new object on every call: resolved configs are cached per object
    ...relink(config()), // (gotcha: config-cache-identity)
    colorPalette: entries({ ...palette, 'main-color': palette.band }), // the defaults take the band
  };
}
```

## Ingredients

**Teaches**

- [Semantic colour palette](https://postext.dev/en/docs/configuration.md#color-palette): Named colours that every setting links to by id, so a whole document retints by changing one swatch; the default colours derive from main-color.
- [Bold, italic and their colours](https://postext.dev/en/docs/configuration.md#body-text): Bold and italic runs and the colour they print in; by default they take the accent blue, so a monochrome book sets them back to the text colour.

**Also uses**

- [Colour swatches](https://postext.dev/en/docs/document-format.md#inline-formatting)
- [Inline chips](https://postext.dev/en/docs/configuration.md#chip-styles)
- [Cell fills](https://postext.dev/en/docs/document-format.md#block-embed-optional-explicit-inline-placement)
- [Caption style](https://postext.dev/en/docs/configuration.md#caption-style)
- [Designed openers](https://postext.dev/en/docs/configuration.md#span-and-advanced-design)
- [Full-width chapter band](https://postext.dev/en/docs/configuration.md#span-and-advanced-design)
- [Text, rules and boxes in page designs](https://postext.dev/en/docs/configuration.md#headers--footers)
- [Bullet lists and checklists](https://postext.dev/en/docs/configuration.md#unordered-lists)
- [Callout boxes](https://postext.dev/en/docs/configuration.md#callout-styles)
- [Running heads and folios](https://postext.dev/en/docs/configuration.md#headers--footers)
- [Heads by page role](https://postext.dev/en/docs/configuration.md#text-elements)
- [Heading attributes](https://postext.dev/en/docs/document-format.md#heading-attributes)
- [Figures and tables as resources](https://postext.dev/en/docs/document-format.md#resources)
- [Pages on a canvas](https://postext.dev/en/docs/configuration.md#rendering-a-page-to-a-bitmap)
- [Citations that place figures](https://postext.dev/en/docs/document-format.md#inline-reference-the-primary-form)
- [Figure and Table in your language](https://postext.dev/en/docs/configuration.md#resource-types)
- [Paragraph styles](https://postext.dev/en/docs/configuration.md#paragraph-styles)
- [Custom resource types](https://postext.dev/en/docs/configuration.md#resource-types)
- [Line breaks in titles](https://postext.dev/en/docs/document-format.md#line-breaks-in-titles)

**Config at a glance**

- [`bodyText`](https://postext.dev/en/docs/configuration.md#body-text), [`calloutStyles`](https://postext.dev/en/docs/configuration.md#callout-styles), [`captionStyle`](https://postext.dev/en/docs/configuration.md#caption-style), [`chipStyles`](https://postext.dev/en/docs/configuration.md#chip-styles), [`colorPalette`](https://postext.dev/en/docs/configuration.md#color-palette), [`footer`](https://postext.dev/en/docs/configuration.md#headers--footers), [`header`](https://postext.dev/en/docs/configuration.md#headers--footers), [`headings`](https://postext.dev/en/docs/configuration.md#headings), [`layout`](https://postext.dev/en/docs/configuration.md#layout), [`page`](https://postext.dev/en/docs/configuration.md#page), [`paragraphStyles`](https://postext.dev/en/docs/configuration.md#paragraph-styles), [`resourceTypes`](https://postext.dev/en/docs/configuration.md#resource-types), [`tableStyle`](https://postext.dev/en/docs/configuration.md#table-style), [`unorderedLists`](https://postext.dev/en/docs/configuration.md#unordered-lists)

**APIs**

- [`buildDocument`](https://postext.dev/en/docs/configuration.md#building-a-document), [`clearMeasurementCache`](https://postext.dev/en/docs/configuration.md#measurement-cache), [`defaultResourceTypes`](https://postext.dev/en/docs/configuration.md#resource-types), [`renderPageToCanvas`](https://postext.dev/en/docs/configuration.md#rendering-a-page-to-a-bitmap)

**Typefaces**

- Syne (OFL-1.1), Plus Jakarta Sans (OFL-1.1)

## Method

### 1 · Name colours by the job they do

```js
// script.js, lines 13–23
const NEUTRALS = { ink: '#1d1d1f', muted: '#5f5f66', paper: '#ffffff' };
const COLOURWAYS = { // band: colour fields · onBand: type on them · deep: accent type on paper
  red: { band: '#d7263d', onBand: '#ffffff', deep: '#b3122a', tint: '#fcdfe3' },
  teal: { band: '#2a9d8f', onBand: '#1d1d1f', deep: '#17695f', tint: '#d8eeeb' },
  violet: { band: '#6a4c93', onBand: '#ffffff', deep: '#5b3f86', tint: '#e7dff0' },
  sand: { band: '#f4a261', onBand: '#1d1d1f', deep: '#a14a16', tint: '#fde4cf' },
}; // white on teal is 3.3:1 and on sand 2.1:1, so those two set their band type in ink
const HOUSE = { ...NEUTRALS, ...COLOURWAYS.red }; // the hex config() writes beside each id
const col = (id) => ({ hex: HOUSE[id], model: 'hex', paletteId: id });
const entries = (hexes) => Object.entries(hexes).map(([id, hex]) => ({ id, name: id,
  value: { hex, model: 'hex' } })); // the shape of config.colorPalette
```

`band` fills the colour fields, `onBand` colours the type set on them, `deep` is the accent for type on white paper and `tint` the light fill. Each colourway sets those four and shares the three neutrals. Against white, the teal band measures 3.3:1 and the sand band 2.1:1, too low for white type on the band or for band-coloured type on paper. Those two palettes set `onBand` in ink, and all four set bold text and headings in `deep`, a darker shade that measures between 5.99:1 (sand) and 8.40:1 (violet) on white.

### 2 · Link everything the engine would print blue

```js
// script.js, lines 70–96
const bodyText = { fontFamily: SANS, fontSize: pt(BODY), lineHeight: pt(LEAD),
  color: col('ink'), italicColor: col('ink'), // bold: the times, key terms, boxes (inherited)
  boldColor: col('deep'), referenceColor: col('deep'), textAlign: 'left', firstLineIndent: pt(0) };
const headings = { fontFamily: DISPLAY, color: col('deep'), levels: [
  // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
  { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
    marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener },
  { level: 2, fontSize: pt(12.5), lineHeight: lines(1), marginTop: lines(1), marginBottom: pt(0) },
] };
const unorderedLists = { color: col('band'), indent: mm(3.6), gap: mm(1.4),
  marginTop: pt(0), marginBottom: pt(0) };
const chip = { fontSize: pt(7.5), bold: true, borderWidth: pt(0), borderRadius: mm(1.6),
  paddingX: mm(1.3), paddingY: pt(0.9), gap: mm(1) };
const chipStyles = [ // band = a ticket, tint = free: the same code as the grid overleaf
  { id: 'ticket', background: col('band'), color: col('onBand'), ...chip },
  { id: 'free', background: col('tint'), color: col('deep'), ...chip,
    borderWidth: pt(0.6), borderColor: col('band') }, // the outline keeps it off the white
];
const corner = (edge, y) => ({ anchor: { to: 'page', edge }, offset: { x: mm(OUTER), y: mm(y) } });
const furniture = (id, content, edge, y, style) => ({ elements: [{ kind: 'text', id, content,
  parity: 'even', pages: 'body', overflow: 'wrap', placement: corner(edge, y), ...style }] });
const header = furniture('head', '{title} · {subtitle}', 'top-left', 12, // page 2's head
  { ...caps(7.5), color: col('deep') });
const footer = furniture('folio', '{pageNumber}', 'bottom-left', -12, { fontFamily: SANS,
  fontSize: pt(8), fontWeight: 700, color: col('onBand'), box: { backgroundColor: col('band'),
    borderRadius: mm(2.4), padding: { top: mm(0.9), right: mm(2.4), bottom: mm(0.9),
      left: mm(2.4) } } });
```

The engine's blue is a palette entry, `main-color` (#295AA3), and [its defaults](/en/docs/configuration#the-default-palette) point headings, bold, italic, references, bullets and the running head at it. Here each of those carries a palette id: times and key terms in bold `deep`, italics in ink, bullets in `band`, and on page 2 a running head in `deep` and the folio in `onBand` on a `band` pill. `retint()` also points `main-color` at the band; since this config sets every colour, that changes nothing on these pages, but a style added later without a colour takes the band instead of blue. The tickets box sets no bold colour of its own, so it inherits `bodyText.boldColor` with its palette id, and its bold changes with the colourway.

### 3 · Draw the artwork with design boxes

```js
// script.js, lines 50–66
const text = (id, content, x, y, width, style) => ({ kind: 'text', id, content, align: 'left',
  overflow: 'wrap', color: col('onBand'), placement: { anchor: { to: 'page', edge: 'top-left' },
    offset: { x: mm(x), y: mm(y) }, size: { width: mm(width) } }, ...style });
const opener = { enabled: true,
  minHeight: lines(18), // 85.1 mm: text 7 mm under the band (2.4 mm with the band box alone)
  slot: { elements: [
    { kind: 'box', id: 'band', style: { backgroundColor: col('band') },
      placement: { anchor: { to: 'bleed', edge: 'top-left' },
        size: { width: 'fill', height: mm(BAND) } } },
    ...waveform(), // boxes filled with col('tint'), which relink() rewrites like the rest
    text('kicker', '{attr.kicker}', INNER, 14, 150, caps(8)),
    text('title', '{titleText}', INNER, 20, TRIM - INNER - OUTER, { fontFamily: DISPLAY,
      fontSize: pt(46), fontWeight: 800, lineHeight: 0.92 }), // a multiple
    text('standfirst', '{attr.standfirst}', INNER, 71, 92, { fontFamily: SANS, fontSize: pt(10),
      lineHeight: 1.36 }), // (gotcha: design-lineheight-multiple)
  ] },
};
```

The opener is a [design slot](/en/docs/configuration#box-elements) holding a box bled off the top edge, 29 rounded bars for the waveform and three text elements: the heading's own text as the title, and the kicker and standfirst from its attributes. The bars are boxes rather than an SVG, so their fill is one more `tint` link that `relink()` rewrites; an SVG would have to be redrawn and registered again for every colourway. On its own, the band box would start the text on the grid line 2.4 mm under the band; `minHeight`, 18 lines or 85.1 mm below the top margin, reserves more and moves the first line to 7 mm under it.

### 4 · Let the palette fill the table

```js
// script.js, lines 177–200
const DAYS = t({ en: ['Sat 12', 'Sun 13', 'Mon 14', 'Tue 15', 'Wed 16', 'Thu 17', 'Fri 18'],
  es: ['Sáb 12', 'Dom 13', 'Lun 14', 'Mar 15', 'Mié 16', 'Jue 17', 'Vie 18'] });
const WEEK = [ // t: a ticketed concert, f: a free event, one mark a day from Saturday
  [t({ en: 'Quay Stage', es: 'Escenario del Muelle' }), 't.f.fft'],
  [t({ en: 'Iron Bridge steps', es: 'Escalinata del Puente' }), 'f.....f'],
  [t({ en: 'Market Hall', es: 'Mercado de Abastos' }), '.f...f.'],
  [t({ en: 'St Clare’s Cloister', es: 'Claustro de Santa Clara' }), '.t.....'],
  [t({ en: 'Tannery Yard', es: 'Patio de la Curtiduría' }), '...t...'],
  [t({ en: 'Boathouse', es: 'Casa de las Barcas' }), '.ft..t.'],
];
const fill = { t: col('band'), f: col('tint') }; // resources are not in the config: no relink
const resources = [{ id: 'week', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0,
  placement: { position: 'top', span: 'page' }, // cited on page 1, it heads page 2
  caption: t({ en: 'The week at a glance', es: 'La semana de un vistazo' }),
  note: t({ en: ':swatch{color="band"} ticketed concert   :swatch{color="tint"} free event',
    es: ':swatch{color="band"} concierto con entrada   :swatch{color="tint"} acto gratuito' }),
  altText: t({ en: 'A grid of six venues by seven days; filled squares mark the events.',
    es: 'Una cuadrícula de seis escenarios por siete días; los cuadros rellenos son los actos.' }),
  table: { model: { headerRowCount: 1, columnWidths: [2.6, 1, 1, 1, 1, 1, 1, 1], rows: [
    [{ content: '', isHeader: true, background: col('paper') }, ...DAYS.map((day) => ({
      content: day, isHeader: true, align: 'center' }))],
    ...WEEK.map(([venue, marks]) => [{ content: venue, align: 'right' }, ...[...marks].map((m) =>
      (m === '.' ? { content: '' } : { content: '', background: fill[m] }))]),
  ] } } }];
```

The grid is a table resource, outside the config, so `relink()` never touches it. Its [cell fills](/en/docs/document-format#block-embed-optional-explicit-inline-placement) carry palette ids all the same, and the engine resolves them from the document palette when it lays out the table; the `:swatch{color="band"}` and `:swatch{color="tint"}` of the key in the table note resolve the same way. The chips in the listings use the same two ids, so a filled square, the chip beside a time and the key's swatch agree in every colourway.

### 5 · Build a fresh config on every click

```js
// script.js, lines 237–266
const NAMES = t({ en: { red: 'Red', teal: 'Teal', violet: 'Violet', sand: 'Sand' },
  es: { red: 'Rojo', teal: 'Verde azulado', violet: 'Violeta', sand: 'Arena' } });
document.getElementById('pages').insertAdjacentHTML('beforebegin', `<section id="editions">
  <div class="desk"><header><p class="kicker">${t({ en: 'Riverside Music Week · proofs',
    es: 'Música en la Ribera · pruebas' })}</p><h2>${TITLE}</h2></header><canvas id="live"
  role="img"></canvas><div class="buttons" role="group"></div></div></section>`);
const paint = (canvas, doc) => renderPageToCanvas(doc.pages[0], doc, canvas,
  { scale: (canvas.clientWidth * Math.min(devicePixelRatio, 2)) / doc.pages[0].width });
const buttons = Object.keys(COLOURWAYS).map((way) => {
  const button = document.querySelector('#editions .buttons')
    .appendChild(Object.assign(document.createElement('button'), { type: 'button' }));
  button.innerHTML = `<canvas></canvas><span>${NAMES[way]}<i>${['band', 'deep', 'tint']
    .map((id) => `<b style="background:${COLOURWAYS[way][id]}"></b>`).join('')}</i></span>`;
  paint(button.firstChild, docs[way]);
  // A fresh config on every click (retint() calls config()); the fonts are loaded by now.
  button.onclick = () => show(way, buildDocument({ markdown, resources }, retint(way)));
  return [way, button];
});
const live = document.getElementById('live');
let shown; // the document on the live page
function show(way, doc) {
  paint(live, (shown = doc));
  live.ariaLabel = `${NAMES[way]}, ${t({ en: 'page 1', es: 'página 1' })}`;
  for (const [id, button] of buttons) button.ariaPressed = String(id === way);
  showPages(doc, { title: `${TITLE} · ${NAMES[way]}` });
}
show('red', docs.red);
new ResizeObserver(() => { // canvases are bitmaps: repaint them at the desk's new size
  paint(live, shown); buttons.forEach(([way, button]) => paint(button.firstChild, docs[way]));
}).observe(live);
```

Each button calls `retint()`, which calls `config()`, so every build gets a new object with no cached resolution behind it. The engine resolves a config once and [caches the result against that object](/en/docs/configuration#global-state-shared-in-one-page). Keep one config and change its palette entries in place, and the next build reuses the old resolution: in a test on 1.4.1, the headings, bullets, chips and caption bar kept their first colour, while the swatches and cell fills, which read the palette during layout, took the new one. Assigning a new `colorPalette` array to the same object changed nothing. A `structuredClone()` of the edited config does get a fresh resolution, but its design slots and reference colour still carry the old hex, so the band, the waveform, the furniture and “Table 1” stayed red; `retint()` rewrites those hexes and returns a new object.

## The whole recipe

The files below are composed from the recipe's folder, with the sample text and the Cookbook's shared kit inlined. To run them as one page, put the HTML in `<body>`, the CSS in a `<style>` element and the script in a `<script type="module">`; or paste each into the matching panel of a new CodePen (JS as a module). The script imports postext from esm.sh, so there is nothing to install or build.

- Source folder: https://github.com/drnachio/postext/tree/main/cookbook/live-palette-retint

### style.css

```css
/* The proof desk above the pages: the live page, and the four colourways as buttons.
   Sizes are in container units, so the desk keeps its 4:3 shape at any width up to 1120 px. */
#editions {
  container-type: inline-size; max-width: 1120px; margin: 0 auto; background: #16181d;
  color: #b9bcc4; font: 400 13px/1.4 "Plus Jakarta Sans", system-ui, sans-serif;
}
#editions .desk {
  box-sizing: border-box; display: grid; aspect-ratio: 4 / 3; padding: 4cqw;
  grid-template: 'page head' auto 'page ways' minmax(0, 1fr) / 67cqw minmax(0, 1fr);
  gap: 2.6cqw 3.5cqw;
}
#editions header { grid-area: head; }
#editions .kicker {
  margin: 0 0 0.8cqw; font-size: max(10px, 1cqw); font-weight: 700; letter-spacing: .18em;
  text-transform: uppercase; color: #8d9098;
}
#editions h2 { margin: 0; font: 800 2.2cqw/1.1 Syne, sans-serif; color: #f4f1ea; }
#live {
  grid-area: page; display: block; width: 67cqw; height: 67cqw; background: #fff;
  box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 2.4cqw 4.8cqw -1.8cqw rgb(0 0 0 / .85);
}
#editions .buttons {
  grid-area: ways; display: grid; grid-template-rows: repeat(4, minmax(0, 1fr)); gap: 1.3cqw;
}
#editions button {
  display: flex; align-items: center; gap: 1.6cqw; padding: 0; border: 0; background: none;
  color: #f4f1ea; font: 700 1.7cqw/1.15 Syne, sans-serif; text-align: left; cursor: pointer;
}
#editions button canvas {
  flex: none; width: 11cqw; height: 11cqw; background: #fff;
  outline: 2px solid transparent; outline-offset: 0.45cqw; transition: outline-color .15s;
}
#editions button[aria-pressed="true"] canvas { outline-color: #f4f1ea; }
#editions button:hover canvas, #editions button:focus-visible canvas { outline-color: #8d9098; }
#editions button:focus-visible { outline: none; }
#editions button i { display: flex; gap: 0.5cqw; margin-top: 0.8cqw; }
#editions button b {
  width: 1.4cqw; height: 1.4cqw; border-radius: 50%;
  box-shadow: inset 0 0 0 1px rgb(255 255 255 / .14);
}
@container (max-width: 640px) {
  #editions .desk {
    aspect-ratio: auto; padding: 24px 16px; gap: 18px;
    grid-template: 'head' auto 'page' auto 'ways' auto / minmax(0, 1fr);
  }
  #editions h2 { font-size: 24px; }
  #live { width: 100%; height: auto; aspect-ratio: 1; }
  #editions .buttons { grid-template: auto / repeat(4, minmax(0, 1fr)); gap: 10px; }
  #editions button { flex-direction: column; align-items: stretch; gap: 6px; font-size: 13px; }
  #editions button canvas { width: 100%; height: auto; aspect-ratio: 1; }
  #editions button i { gap: 4px; margin-top: 4px; }
  #editions button b { width: 10px; height: 10px; }
}
```

### script.js

```js
// ═══ Postext Cookbook · Nº 054 · Retint a whole document from one palette ═══════════
// https://postext.dev/en/cookbook/live-palette-retint
// Code: MIT · Text: original (CC BY 4.0) · Artwork: design boxes generated in code (CC BY 4.0)
// Fonts: Syne, Plus Jakarta Sans (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import { buildDocument, renderPageToCanvas, clearMeasurementCache, defaultResourceTypes }
  from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'live-palette-retint';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: three neutrals every edition shares, four entries each colourway sets
const NEUTRALS = { ink: '#1d1d1f', muted: '#5f5f66', paper: '#ffffff' };
const COLOURWAYS = { // band: colour fields · onBand: type on them · deep: accent type on paper
  red: { band: '#d7263d', onBand: '#ffffff', deep: '#b3122a', tint: '#fcdfe3' },
  teal: { band: '#2a9d8f', onBand: '#1d1d1f', deep: '#17695f', tint: '#d8eeeb' },
  violet: { band: '#6a4c93', onBand: '#ffffff', deep: '#5b3f86', tint: '#e7dff0' },
  sand: { band: '#f4a261', onBand: '#1d1d1f', deep: '#a14a16', tint: '#fde4cf' },
}; // white on teal is 3.3:1 and on sand 2.1:1, so those two set their band type in ink
const HOUSE = { ...NEUTRALS, ...COLOURWAYS.red }; // the hex config() writes beside each id
const col = (id) => ({ hex: HOUSE[id], model: 'hex', paletteId: id });
const entries = (hexes) => Object.entries(hexes).map(([id, hex]) => ({ id, name: id,
  value: { hex, model: 'hex' } })); // the shape of config.colorPalette
// #endregion

// #region answer: a colourway is a palette; retint() builds a fresh config linked to it
function retint(way) {
  const palette = { ...NEUTRALS, ...COLOURWAYS[way] };
  // 1.4.1 applies colorPalette to text, lists, boxes, chips, captions and tables, and resolves
  // swatches and cell fills against it; design elements and referenceColor print the hex
  // written beside their id, so relink() rewrites that hex (gotcha: palette-skips-designs).
  const relink = (v) => (Array.isArray(v) ? v.map(relink) : !v || typeof v !== 'object' ? v
    : Object.hasOwn(palette, v.paletteId ?? '') ? { ...v, hex: palette[v.paletteId] }
      : Object.fromEntries(Object.entries(v).map(([k, x]) => [k, relink(x)])));
  return { // a new object on every call: resolved configs are cached per object
    ...relink(config()), // (gotcha: config-cache-identity)
    colorPalette: entries({ ...palette, 'main-color': palette.band }), // the defaults take the band
  };
}
// #endregion

const [DISPLAY, SANS, BODY, LEAD] = ['Syne', 'Plus Jakarta Sans', 9.4, 13.4]; // pt: 33 lines
const [TRIM, TOP, BOTTOM, INNER, OUTER, GUTTER] = [200, 22, 22, 17, 15, 6]; // mm: square, mirrored
const BAND = 100; // mm, trim top to the colour field's foot ('bleed' is the trim: no cut lines)
const lines = (n) => pt(n * LEAD);
const caps = (size) => ({ fontFamily: SANS, fontSize: pt(size), fontWeight: 700,
  letterSpacing: pt(size * 0.16), textTransform: 'uppercase' });

// #region opener: the colour field, a waveform of palette-linked boxes, the title on top
const text = (id, content, x, y, width, style) => ({ kind: 'text', id, content, align: 'left',
  overflow: 'wrap', color: col('onBand'), placement: { anchor: { to: 'page', edge: 'top-left' },
    offset: { x: mm(x), y: mm(y) }, size: { width: mm(width) } }, ...style });
const opener = { enabled: true,
  minHeight: lines(18), // 85.1 mm: text 7 mm under the band (2.4 mm with the band box alone)
  slot: { elements: [
    { kind: 'box', id: 'band', style: { backgroundColor: col('band') },
      placement: { anchor: { to: 'bleed', edge: 'top-left' },
        size: { width: 'fill', height: mm(BAND) } } },
    ...waveform(), // boxes filled with col('tint'), which relink() rewrites like the rest
    text('kicker', '{attr.kicker}', INNER, 14, 150, caps(8)),
    text('title', '{titleText}', INNER, 20, TRIM - INNER - OUTER, { fontFamily: DISPLAY,
      fontSize: pt(46), fontWeight: 800, lineHeight: 0.92 }), // a multiple
    text('standfirst', '{attr.standfirst}', INNER, 71, 92, { fontFamily: SANS, fontSize: pt(10),
      lineHeight: 1.36 }), // (gotcha: design-lineheight-multiple)
  ] },
};
// #endregion

// #region links: every colour the engine would print in its blue, linked to a palette id
const bodyText = { fontFamily: SANS, fontSize: pt(BODY), lineHeight: pt(LEAD),
  color: col('ink'), italicColor: col('ink'), // bold: the times, key terms, boxes (inherited)
  boldColor: col('deep'), referenceColor: col('deep'), textAlign: 'left', firstLineIndent: pt(0) };
const headings = { fontFamily: DISPLAY, color: col('deep'), levels: [
  // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
  { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
    marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener },
  { level: 2, fontSize: pt(12.5), lineHeight: lines(1), marginTop: lines(1), marginBottom: pt(0) },
] };
const unorderedLists = { color: col('band'), indent: mm(3.6), gap: mm(1.4),
  marginTop: pt(0), marginBottom: pt(0) };
const chip = { fontSize: pt(7.5), bold: true, borderWidth: pt(0), borderRadius: mm(1.6),
  paddingX: mm(1.3), paddingY: pt(0.9), gap: mm(1) };
const chipStyles = [ // band = a ticket, tint = free: the same code as the grid overleaf
  { id: 'ticket', background: col('band'), color: col('onBand'), ...chip },
  { id: 'free', background: col('tint'), color: col('deep'), ...chip,
    borderWidth: pt(0.6), borderColor: col('band') }, // the outline keeps it off the white
];
const corner = (edge, y) => ({ anchor: { to: 'page', edge }, offset: { x: mm(OUTER), y: mm(y) } });
const furniture = (id, content, edge, y, style) => ({ elements: [{ kind: 'text', id, content,
  parity: 'even', pages: 'body', overflow: 'wrap', placement: corner(edge, y), ...style }] });
const header = furniture('head', '{title} · {subtitle}', 'top-left', 12, // page 2's head
  { ...caps(7.5), color: col('deep') });
const footer = furniture('folio', '{pageNumber}', 'bottom-left', -12, { fontFamily: SANS,
  fontSize: pt(8), fontWeight: 700, color: col('onBand'), box: { backgroundColor: col('band'),
    borderRadius: mm(2.4), padding: { top: mm(0.9), right: mm(2.4), bottom: mm(0.9),
      left: mm(2.4) } } });
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  // "Tabla" in Spanish (gotcha: resource-types-locale); one table: "Table 1", not "1.1"
  resourceTypes: defaultResourceTypes(LANG).map((r) => ({ ...r, numberingTemplate: '{n}' })),
  colorPalette: entries(HOUSE), // the red edition; retint() replaces it
  page: { width: mm(TRIM), height: mm(TRIM), dpi: 150, margins: { top: mm(TOP),
    bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER), mirror: true } },
  layout: { layoutType: 'double', gutterWidth: mm(GUTTER) },
  bodyText, headings, unorderedLists, chipStyles,
  calloutStyles: [{ id: 'tickets', backgroundEnabled: false, // no fill, a stripe on top
    stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('band') },
    padding: { top: mm(2.6), right: pt(0), bottom: pt(0), left: pt(0) },
    titleStyle: { fontFamily: DISPLAY, fontSize: pt(11), color: col('deep'), gap: mm(1.2) } }],
  tableStyle: { borderColor: col('paper'), borderWidth: pt(1.6), // white rules cut the tiles
    headerBackground: col('ink'), headerColor: col('paper'), headerFontSize: pt(7.5),
    bodyFontSize: pt(8.2), bodyColor: col('ink'), cellPadding: mm(1) },
  captionStyle: { fontSize: pt(8), color: col('onBand'), labelColor: col('onBand'),
    position: 'above', backgroundEnabled: true, background: col('band'), padding: mm(1.2),
    gap: mm(1.2), note: { fontSize: pt(7.5), color: col('muted') } },
  paragraphStyles: [{ id: 'colophon', fontSize: pt(7), lineHeight: pt(9.6), color: col('muted') }],
  header, footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Riverside Music Week"
subtitle: "Programme"
author: "Almarén Music Society"
---

# Riverside \\ Music Week {kicker="Almarén · 12–18 June 2027" standfirst="Fourteen events on both banks of the Almar, from the Quay Stage to the Boathouse. Eight of them are free; the rest cost €9 to €14."}

The ninth Riverside Music Week keeps close to the water. Every venue is within ten minutes’ walk of the **Quay Stage**, and the **Iron Bridge** joins the two banks. :ref{id="week" style="full"} overleaf maps the week by venue and day. Free events need no ticket, but the Market Hall seats only 300, so come early. Concerts on the Quay go ahead in light rain.

## Saturday 12 June

- **18.00** :chip[Free]{style="free"} The Almar Brass Band leads the opening parade from the Iron Bridge steps to the Quay Stage.
- **21.30** :chip[€12]{style="ticket"} The Almar Chamber Orchestra plays Handel’s *Water Music* from two barges moored off the Quay.

## Sunday 13 June

- **11.00** :chip[Free]{style="free"} Children’s percussion in the Market Hall. Bring a saucepan and a wooden spoon from home.
- **17.00** :chip[Free]{style="free"} The Rowing Club choir sings shanties on the Boathouse slipway.
- **20.00** :chip[€14]{style="ticket"} In St Clare’s Cloister, Cuarteto Ribera plays Haydn’s *Lark* quartet and Ravel’s Quartet in F.

## Monday 14 June

- **19.00** :chip[Free]{style="free"} Fado on the Quay: Marta Loureiro sings with Portuguese and classical guitar.
- **22.00** :chip[€9]{style="ticket"} The Almar Jazz Trio plays standards and its own tunes at the Boathouse.

## Tuesday 15 June

- **21.30** :chip[€10]{style="ticket"} Murnau’s silent film *Sunrise* (1927) on a screen in the Tannery Yard, with a new score played live by the Tannery Ensemble. The yard opens at 21.00.

## Wednesday 16 June

- **18.30** :chip[Free]{style="free"} Folk dance on the Quay. A caller walks everyone through each dance before the band starts it.

## Thursday 17 June

- **18.00** :chip[Free]{style="free"} Open rehearsal: the Almar Chamber Orchestra works through *Vltava* on the Quay Stage. Sit on the steps and listen.
- **20.00** :chip[Free]{style="free"} Six Almarén composers, one river song each, sung by the Community Choir in the Market Hall.
- **22.30** :chip[€9]{style="ticket"} Cumbia night at the Boathouse with Los Remeros.

## Friday 18 June

- **21.00** :chip[€12]{style="ticket"} Closing concert on the Quay Stage: the Almar Chamber Orchestra plays Smetana’s *Vltava*, which follows a river from its two springs to Prague.
- **23.45** :chip[Free]{style="free"} The Community Choir sings at midnight on the Iron Bridge steps. Candles are handed out from 23.15.

:::callout{type="tickets" title="Tickets"}
A €48 **week pass** covers all six ticketed concerts. Single tickets are sold at the Market Hall box office from Monday 7 June, 10.00 to 14.00, and at the door 45 minutes before the start. **Under-16s** go free with a paying adult.
:::

:::paragraphs{style="colophon"}
Riverside Music Week is run by the Almarén Music Society. Programme correct on 3 May 2027; changes are posted at the box office. Set in Syne and Plus Jakarta Sans (SIL OFL) · Text CC BY 4.0.
:::
`; // content.<lang>.md, inlined by the Cookbook

// #region grid: the week at a glance, filled from palette ids the engine resolves itself
const DAYS = t({ en: ['Sat 12', 'Sun 13', 'Mon 14', 'Tue 15', 'Wed 16', 'Thu 17', 'Fri 18'],
  es: ['Sáb 12', 'Dom 13', 'Lun 14', 'Mar 15', 'Mié 16', 'Jue 17', 'Vie 18'] });
const WEEK = [ // t: a ticketed concert, f: a free event, one mark a day from Saturday
  [t({ en: 'Quay Stage', es: 'Escenario del Muelle' }), 't.f.fft'],
  [t({ en: 'Iron Bridge steps', es: 'Escalinata del Puente' }), 'f.....f'],
  [t({ en: 'Market Hall', es: 'Mercado de Abastos' }), '.f...f.'],
  [t({ en: 'St Clare’s Cloister', es: 'Claustro de Santa Clara' }), '.t.....'],
  [t({ en: 'Tannery Yard', es: 'Patio de la Curtiduría' }), '...t...'],
  [t({ en: 'Boathouse', es: 'Casa de las Barcas' }), '.ft..t.'],
];
const fill = { t: col('band'), f: col('tint') }; // resources are not in the config: no relink
const resources = [{ id: 'week', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0,
  placement: { position: 'top', span: 'page' }, // cited on page 1, it heads page 2
  caption: t({ en: 'The week at a glance', es: 'La semana de un vistazo' }),
  note: t({ en: ':swatch{color="band"} ticketed concert   :swatch{color="tint"} free event',
    es: ':swatch{color="band"} concierto con entrada   :swatch{color="tint"} acto gratuito' }),
  altText: t({ en: 'A grid of six venues by seven days; filled squares mark the events.',
    es: 'Una cuadrícula de seis escenarios por siete días; los cuadros rellenos son los actos.' }),
  table: { model: { headerRowCount: 1, columnWidths: [2.6, 1, 1, 1, 1, 1, 1, 1], rows: [
    [{ content: '', isHeader: true, background: col('paper') }, ...DAYS.map((day) => ({
      content: day, isHeader: true, align: 'center' }))],
    ...WEEK.map(([venue, marks]) => [{ content: venue, align: 'right' }, ...[...marks].map((m) =>
      (m === '.' ? { content: '' } : { content: '', background: fill[m] }))]),
  ] } } }];
// #endregion

// #region art: a waveform over the river, 29 rounded bars from a seeded generator
function waveform() {
  let seed = 0x5eed; // Mulberry32: the same bars on every run
  const random = () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let x = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    x = (x + Math.imul(x ^ (x >>> 7), 61 | x)) ^ x;
    return ((x ^ (x >>> 14)) >>> 0) / 4294967296;
  };
  const [x0, axis, n, width, gap] = [114, 84, 29, 2.1, 1.35]; // mm; axis: the waterline
  return Array.from({ length: n }, (_, i) => {
    const envelope = Math.sin(((i + 0.5) / n) * Math.PI) ** 0.8;
    const up = 3 + 26 * envelope * (0.35 + 0.65 * random()); // mm above the waterline
    const down = up * 0.42; // and its reflection below it
    return { kind: 'box', id: `bar-${i}`, style: { backgroundColor: col('tint'),
      borderRadius: mm(width / 2) }, placement: { anchor: { to: 'page', edge: 'top-left' },
      offset: { x: mm(x0 + i * (width + gap)), y: mm(axis - up) },
      size: { width: mm(width), height: mm(up + down) } } };
  });
}
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { 'Plus Jakarta Sans': ['400', '400i', '700'], Syne: ['700', '800'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
const TITLE = t({ en: 'One programme, four palettes', es: 'Un programa, cuatro paletas' });
const build = (way) => buildWithFonts(() => buildDocument({ markdown, resources }, retint(way)),
  markdown);
const docs = {}; // red is built last: the capture shows the last build on its pages
for (const way of ['sand', 'violet', 'teal', 'red']) docs[way] = await build(way);

// #region live: four buttons, each a fresh build of the whole programme in one colourway
const NAMES = t({ en: { red: 'Red', teal: 'Teal', violet: 'Violet', sand: 'Sand' },
  es: { red: 'Rojo', teal: 'Verde azulado', violet: 'Violeta', sand: 'Arena' } });
document.getElementById('pages').insertAdjacentHTML('beforebegin', `<section id="editions">
  <div class="desk"><header><p class="kicker">${t({ en: 'Riverside Music Week · proofs',
    es: 'Música en la Ribera · pruebas' })}</p><h2>${TITLE}</h2></header><canvas id="live"
  role="img"></canvas><div class="buttons" role="group"></div></div></section>`);
const paint = (canvas, doc) => renderPageToCanvas(doc.pages[0], doc, canvas,
  { scale: (canvas.clientWidth * Math.min(devicePixelRatio, 2)) / doc.pages[0].width });
const buttons = Object.keys(COLOURWAYS).map((way) => {
  const button = document.querySelector('#editions .buttons')
    .appendChild(Object.assign(document.createElement('button'), { type: 'button' }));
  button.innerHTML = `<canvas></canvas><span>${NAMES[way]}<i>${['band', 'deep', 'tint']
    .map((id) => `<b style="background:${COLOURWAYS[way][id]}"></b>`).join('')}</i></span>`;
  paint(button.firstChild, docs[way]);
  // A fresh config on every click (retint() calls config()); the fonts are loaded by now.
  button.onclick = () => show(way, buildDocument({ markdown, resources }, retint(way)));
  return [way, button];
});
const live = document.getElementById('live');
let shown; // the document on the live page
function show(way, doc) {
  paint(live, (shown = doc));
  live.ariaLabel = `${NAMES[way]}, ${t({ en: 'page 1', es: 'página 1' })}`;
  for (const [id, button] of buttons) button.ariaPressed = String(id === way);
  showPages(doc, { title: `${TITLE} · ${NAMES[way]}` });
}
show('red', docs.red);
new ResizeObserver(() => { // canvases are bitmaps: repaint them at the desk's new size
  paint(live, shown); buttons.forEach(([way, button]) => paint(button.firstChild, docs[way]));
}).observe(live);
// #endregion

// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ─────

// ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook ─────────
function mm(value) { return { value, unit: 'mm' }; }
function pt(value) { return { value, unit: 'pt' }; }
function em(value) { return { value, unit: 'em' }; }
/** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */
function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; }
/** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */
function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; }

// ─── Kit · fonts v1 ── the same in every recipe · postext.dev/cookbook ────────
// Postext measures text with the faces the browser has loaded, and caches the
// widths, so every face must be ready before the first build. Faces come from
// Fontsource: the same static files the PDF embeds, so screen and PDF agree.

/** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With
 *  `optional`, a face Fontsource does not ship is skipped instead of failing.
 *  Resolves to the number of faces added. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = {
    latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,'
      + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD',
    'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,'
      + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF',
  };
  const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin'];
  const jobs = [];
  let added = 0;
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const meta = optional ? await fontsourceMeta(family) : null;
    for (const spec of new Set(specs)) {
      const weight = parseInt(spec, 10);
      const style = spec.endsWith('i') ? 'italic' : 'normal';
      if (hasFace(family, weight, style)) continue;
      if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue;
      for (const subset of subsets) {
        const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`;
        const face = new FontFace(family, `url(${url}) format('woff2')`,
          { weight: String(weight), style, unicodeRange: ranges[subset] });
        jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return added;
}

/** Runs `build` (a buildDocument or buildBundle call) and checks the faces
 *  the pages use. A regular face missing from FONTS is loaded with a warning;
 *  bold and italic variants are loaded when the family ships them. Then the
 *  measurement caches are cleared and the build runs again. */
async function buildWithFonts(build, text = '') {
  const tried = new Set();
  for (let round = 0; round < 3; round++) {
    kitStatus('Laying out…');
    await new Promise(requestAnimationFrame);          // let the status paint first
    const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; });
    const wanted = { base: {}, variants: {} };
    for (const { font, base } of [result].flat().flatMap(fontStringsOf)) {
      const { family, weight, style } = parseFont(font);
      const key = `${family}|${weight}|${style}`;
      if (tried.has(key) || hasFace(family, weight, style)) continue;
      tried.add(key);
      (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`);
    }
    if (Object.keys(wanted.base).length) {
      console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`);
    }
    const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true });
    if (added === 0) return result;
    clearMeasurementCache();
  }
  throw new Error('The fonts did not settle after three builds.');
}

/** Every font string of the layout. `base` marks a block's own face; its
 *  bold, italic and bold-italic variants are listed whether or not used. */
function fontStringsOf(doc) {
  const found = new Map();
  const walk = (node) => {
    if (!node || typeof node !== 'object') return;
    if (Array.isArray(node)) { node.forEach(walk); return; }
    for (const [key, value] of Object.entries(node)) {
      if (typeof value === 'string' && /fontString$/i.test(key)) {
        found.set(value, found.get(value) || key === 'fontString');
      } else if (value && typeof value === 'object') walk(value);
    }
  };
  walk(doc.pages);
  walk(doc.blocks);
  return [...found].map(([font, base]) => ({ font, base }));
}

/** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }.
 *  A string with no weight ('95.8px Young Serif', from a design text) is 400. */
function parseFont(font) {
  const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim());
  if (!m) throw new Error(`Unexpected font string: ${font}`);
  const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]);
  return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' };
}

/** True when a loaded FontFace covers exactly this family, weight and style
 *  (document.fonts.check() is also true for families nobody declared). */
function hasFace(family, weight, style) {
  for (const face of document.fonts) {
    if (face.status !== 'loaded' || face.style !== style) continue;
    if (face.family.replace(/^["']|["']$/g, '') !== family) continue;
    const [low, high = low] = face.weight.split(' ').map(Number);
    if (weight >= low && weight <= high) return true;
  }
  return false;
}

/** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */
function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); }

/** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */
function fontsourceMeta(family) {
  fontsourceMeta.cache ??= new Map();
  const id = fontsourceId(family);
  if (!fontsourceMeta.cache.has(id)) {
    fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`)
      .then((res) => (res.ok ? res.json() : null), () => null));
  }
  return fontsourceMeta.cache.get(id);
}

// ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ───────
/** Shows the pages as facing spreads on a dark desk: the first page is a
 *  recto on its own, then verso | recto pairs, as in a bound book. Pages
 *  are painted when they scroll near the screen. */
function showPages(docs, { title, width = 460 } = {}) {
  const root = viewer(title);
  const pages = [docs].flat().flatMap((doc) =>
    doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index })));
  const spreads = [];
  let verso = null;
  for (const p of pages) {
    if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; }
    else { spreads.push([verso, p]); verso = null; }
  }
  if (verso) spreads.push([verso, null]);
  const density = Math.min(window.devicePixelRatio || 1, 2);
  showPages.painter?.disconnect();
  const painter = new IntersectionObserver((entries) => {
    for (const { isIntersecting, target } of entries) {
      if (!isIntersecting) continue;
      painter.unobserve(target);
      const { doc, page } = target.postext;
      renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width });
    }
  }, { rootMargin: '800px' });
  showPages.painter = painter;
  root.replaceChildren(...spreads.map((pair) => {
    const spread = document.createElement('div');
    spread.className = 'pt-spread';
    for (const p of pair) {
      const figure = document.createElement('figure');
      if (p) {
        const label = p.page.pageLabel || String(p.n + 1);
        const canvas = document.createElement('canvas');
        canvas.postext = p;
        canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`;
        canvas.setAttribute('role', 'img');
        canvas.setAttribute('aria-label', `Page ${label}`);
        const folio = document.createElement('figcaption');
        folio.textContent = label;
        figure.append(canvas, folio);
        painter.observe(canvas);
      } else figure.className = 'pt-blank';
      spread.append(figure);
    }
    return spread;
  }));
  kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`);
  document.documentElement.dataset.postext = 'ready';
  return pages.length;
}

/** The desk, the bar and the error reporting, created once. */
function viewer(title) {
  if (!document.getElementById('pt-kit')) {
    document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit">
      :root { color-scheme: dark; }
      body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; }
      #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center;
        gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px);
        border-bottom: 1px solid #23262d; }
      #pt-bar strong { color: #f4f1ea; font-weight: 600; }
      #pt-actions { display: flex; gap: 12px; margin-left: auto; }
      #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
      #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; }
      .pt-spread { display: flex; }
      .pt-spread figure { margin: 0; width: min(460px, 44vw); }
      .pt-spread canvas { display: block; width: 100%; background: #fff;
        box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); }
      .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); }
      .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif;
        letter-spacing: .18em; text-transform: uppercase; color: #6c7079; }
      .pt-blank { visibility: hidden; }
      @media (max-width: 760px) {
        .pt-spread { flex-direction: column; gap: 32px; }
        .pt-spread figure { width: min(460px, 92vw); }
        .pt-blank { display: none; }
      }
    </style>`);
    document.body.insertAdjacentHTML('afterbegin',
      '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>');
    document.getElementById('pt-title').textContent = document.title || 'Postext';
    addEventListener('error', (event) => kitFail(event.error ?? event.message));
    addEventListener('unhandledrejection', (event) => kitFail(event.reason));
  }
  if (title) document.getElementById('pt-title').textContent = title;
  return document.getElementById('pages')
    ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' }));
}

function kitStatus(text) {
  viewer();
  document.getElementById('pt-status').textContent = text;
}

function kitFail(error) {
  document.documentElement.dataset.postext = 'error';
  kitStatus(`Error: ${error?.message ?? error}`);
}

// ─── /Kit ───────────────────────────────────────────────────────────────────────
```

## Variations

### Keep bold text in ink

Leave the accent to the headings, bullets and chips, and set the times and key terms in the text colour.

```diff
-  boldColor: col('deep'), referenceColor: col('deep'), textAlign: 'left', firstLineIndent: pt(0) };
+  boldColor: col('ink'), referenceColor: col('deep'), textAlign: 'left', firstLineIndent: pt(0) };
```

### Give each part of a book its own colour

For colours that change inside one document, part by part, see [Parts in colour from one attribute](https://postext.dev/en/cookbook/parts-in-colour.md).

## Pitfalls

- **A config is cached by identity: build a fresh object.** The engine caches resolved configs by object identity, so changing a config in place and building again reuses the old result. Build a fresh object for every build, which is why a recipe's config is a factory: config().
- **A swapped palette misses design elements and the reference colour.** postext 1.4.1 reads colorPalette into the text styles (body, headings, lists, captions, tables, boxes) but not into the elements of headers, footers, openers and part pages, nor into bodyText.referenceColor: they keep the hex written beside their paletteId. When you swap the palette, for a dark screen edition or a retint, rewrite every linked colour from colorPalette before the build.
- **Ragged text can strand punctuation next to bold or a :ref.** In postext 1.4.1 text that is not justified (box bodies, ragged paragraphs) can break a line between a bold or italic run, or a :ref, and the punctuation touching it: a full stop can open the next line, and the '(' before a reference can end the line above. Justified text never breaks there. Read the boxes of every edition and reword any sentence where it happens, so the run sits mid-line.
- **Ragged text is never checked for runts.** optimalLineBreaking, avoidRunts, runtPenalty and runtMinCharacters act on the Knuth–Plass line breaker, which postext 1.4.1 runs for justified text only. A ragged paragraph is broken line by line and can end on one short word whatever those settings say. Read the last lines of ragged text and reword a paragraph that ends on a runt.
- **Any headings object switches off the H1 page break.** By default an H1 breaks to a recto (always-odd), but passing any headings object resets that default, so chapters run on and span: 'page' does nothing. Restate headings.levels[0].breakBefore: { enabled: true, parity } in every config.
- **Localise Figure/Table with defaultResourceTypes(locale).** The config's locale sets hyphenation, not captions: without resourceTypes the built-in types say Figure and Table in English. Pass resourceTypes: defaultResourceTypes('es') for Spanish; for any other language, write the names yourself in resourceTypes.
- **A design text's lineHeight is a multiple, never a dimension.** In a design slot, a text element's lineHeight multiplies its font size (lineHeight: 1.05). In postext 1.4.1 a dimension such as pt(15) is not rejected: the opener's height measures as NaN, the room it reserves, minHeight included, is dropped without a warning and the text runs under the title.
- **Load every face before layout.** Layout measures text with the faces the browser has loaded and caches the widths, so a face that arrives after the first build leaves wrong line breaks and a PDF that no longer matches the screen. Load every weight and style first, and call clearMeasurementCache() before rebuilding when one arrives late.

- Delete `relink()` and build the teal edition to see what postext 1.4.1 leaves behind: the band, the waveform, the white title, the running head, the folio pill and the reference “Table 1” keep the red edition's colours, while the text, lists, chips, caption bar, table and tickets box follow the palette.
- Keep `bodyText.boldColor` set when a box holds bold text. The box copies it, palette id included; leave it unset and 1.4.1 copies the default instead, so in the teal edition the bold outside the box takes the band while “week pass” in the box prints in #295AA3.

## Credits

- Recipe: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Type: Syne (OFL-1.1), Plus Jakarta Sans (OFL-1.1)
- Code: MIT · Sample content: CC-BY-4.0

## Related

- [Nº 011 · One source, print and screen editions](https://postext.dev/en/cookbook/print-and-screen-editions.md): Both editions come from one config: htmlViewer.overrides holds the dark screen design, and applyHtmlViewerOverrides merges it in before each HTML build. · Level 3 (Advanced) · Magazines & zines
- [Nº 019 · Parts in colour from one attribute](https://postext.dev/en/cookbook/parts-in-colour.md): A pocket field guide where each :::part sets one palette entry, and its divider, painted verso, thumb tabs, field marks and contents row print in that colour. · Level 3 (Advanced) · Manuals, guides & reference
- [Nº 051 · Two-ink riso zine: art in one spot colour](https://postext.dev/en/cookbook/riso-zine-single-ink.md): A four-page zine for a two-drum risograph: full-colour drawings print as tints of the pink, recoloured by the pen for the canvas and by renderToPdf in the PDF. · Level 3 (Advanced) · Magazines & zines
