# Recipe card: ingredients beside the method

> Two cookbook pages with a white recipe card under a SERVES 4 tab: ingredients, tags and a checklist on the left, steps with 26 pt red numbers on the right.

- HTML version: https://postext.dev/en/cookbook/recipe-card
- Recipe Nº 033 · Boxes & notes · Level 2 (Intermediate) · Outputs: Canvas
- Genres: Manuals, guides & reference
- Requires postext ≥ 1.4.1 · tested with 1.4.1 on 2026-09-26
- Pages: [58](https://postext.dev/cookbook/recipe-card/en/p01.webp?v=6707f2d4), [59](https://postext.dev/cookbook/recipe-card/en/p02.webp?v=6707f2d4)
- Last updated: 2026-09-26
- Other languages: [es](https://postext.dev/es/cookbook/recipe-card.md)

## What you'll build

Two facing pages from *Salt & Olive Oil*, an invented Spanish home cookbook, on a 190 × 250 mm trim, one recipe to a page. A drawing made in code bleeds across the top 104 mm: a tortilla with a slice pulled out on a saffron table, a bowl of gazpacho on a sage one. Over it sit a kicker, the title in 42 pt Young Serif, pills for time, difficulty and season, and a note in Caveat handwriting that points at the dish. Below the italic headnote, a white card carries a tomato-red servings tab on its top-right corner. The ingredients run down its left column with olive dashes and tag chips; under the tortilla's, line drawings of a bowl, a pan and a plate head a checklist. The method fills the right column, each step led by a 26 pt old-style figure.

**This recipe answers:**

- How do I set the ingredients beside the method, in two columns inside a box with a servings tab?
- How do I add a numbered tab ("BOX 1-1"), a corner icon, or a margin icon with a rule?
- How do I customise lists: bullets per level, (a)/(i) numbering, task checkboxes, spacing that stays on the grid?
- How do I make inline chips: keyboard keys, tags, word banks for exercises?
- How do I add an author line, a standfirst or a lead with a drop cap to an opener?

## The short answer

A white card with a servings tab, two columns inside it.

```js
// script.js, lines 25–47
// In the Markdown, :::callout{type="card" label="SERVES 4"} holds a :::columns{count=2} group
// of two nested boxes, :::callout{type="column" title="Ingredients"} and one for the method.
// The group levels its columns by cutting between blocks or lines, so loose lists would run
// the method on under the ingredients. A nested box is one block that never splits, so the
// only cut left is between the two (gotcha: callout-columns).
const TAB = 5.6; // mm: the tab's height, and how far it rises above the card
const card = { id: 'card', background: col('card'),
  padding: { top: mm(4.5), right: mm(6), bottom: mm(5), left: mm(6) },
  // No space of its own above: the tab starts on the first grid line under the opener.
  columnGap: mm(7), marginTop: mm(0),
  // label="…" on the fence prints here: a tab on the top-right corner, a cutlery pictogram
  // beside it and a rule from the far corner that makes the tab part of the card.
  label: { fontFamily: TEXT, fontSize: pt(8), color: col('card'), // bold by default
    background: col('tomato'), height: mm(TAB), offset: mm(TAB), paddingX: mm(2.6),
    icon: { resourceId: 'cutlery', width: mm(TAB * 6 / 8), gap: mm(1.6) }, // as tall as the tab
    rule: { enabled: true, color: col('tomato'), width: pt(1.2) } } };
// The two columns: frameless boxes whose only device is a tracked title.
const NONE = { top: mm(0), right: mm(0), bottom: mm(0), left: mm(0) };
const column = { id: 'column', backgroundEnabled: false, padding: NONE,
  lists: { gap: mm(2.2), itemSpacing: pt(3.5) }, // for the steps as well as the dashes
  titleStyle: { fontFamily: TEXT, fontSize: pt(8), color: col('tomato'), // bold by default
    textTransform: 'uppercase', letterSpacing: pt(1.5), gap: mm(2.4) } };
// config() plugs them in: calloutStyles: [card, column, prep].
```

## Ingredients

**Teaches**

- [Columns inside a box](https://postext.dev/en/docs/document-format.md#columns): Two or more balanced columns within a callout, such as a text column beside a figure or a three-up panel.
- [Numbered box tabs](https://postext.dev/en/docs/configuration.md#callout-styles): A label tab on the box edge ("BOX 1-1") set from the fence's label attribute.
- [Numbered lists](https://postext.dev/en/docs/configuration.md#ordered-lists): Arabic, roman or letter numbering per level, styled separators and hanging step numbers.

**Also uses**

- [Nested boxes](https://postext.dev/en/docs/configuration.md#the-callout-container)
- [Box icons and corner badges](https://postext.dev/en/docs/configuration.md#callout-styles)
- [Inline chips](https://postext.dev/en/docs/configuration.md#chip-styles)
- [Bullet lists and checklists](https://postext.dev/en/docs/configuration.md#unordered-lists)
- [Callout boxes](https://postext.dev/en/docs/configuration.md#callout-styles)
- [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)
- [Heading styles](https://postext.dev/en/docs/configuration.md#heading-styles)
- [Heading attributes](https://postext.dev/en/docs/document-format.md#heading-attributes)
- [Text, rules and boxes in page designs](https://postext.dev/en/docs/configuration.md#headers--footers)
- [Pictures in page designs](https://postext.dev/en/docs/configuration.md#image-elements)
- [Line breaks in titles](https://postext.dev/en/docs/document-format.md#line-breaks-in-titles)
- [Paper colour](https://postext.dev/en/docs/configuration.md#page)
- [Running heads and folios](https://postext.dev/en/docs/configuration.md#headers--footers)
- [Semantic colour palette](https://postext.dev/en/docs/configuration.md#color-palette)
- [Paragraph styles](https://postext.dev/en/docs/configuration.md#paragraph-styles)
- [Figures and tables as resources](https://postext.dev/en/docs/document-format.md#resources)

**Config at a glance**

- [`bodyText`](https://postext.dev/en/docs/configuration.md#body-text), [`calloutStyles`](https://postext.dev/en/docs/configuration.md#callout-styles), [`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), [`headingStyles`](https://postext.dev/en/docs/configuration.md#heading-styles), [`headings`](https://postext.dev/en/docs/configuration.md#headings), [`layout`](https://postext.dev/en/docs/configuration.md#layout), [`orderedLists`](https://postext.dev/en/docs/configuration.md#ordered-lists), [`page`](https://postext.dev/en/docs/configuration.md#page), [`paragraphStyles`](https://postext.dev/en/docs/configuration.md#paragraph-styles), [`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), [`registerResourceImage`](https://postext.dev/en/docs/architecture.md#api-surface), [`renderPageToCanvas`](https://postext.dev/en/docs/configuration.md#rendering-a-page-to-a-bitmap)

**Typefaces**

- Young Serif (OFL-1.1), Figtree (OFL-1.1), Caveat (OFL-1.1)

## Method

### 1 · Give each column a box of its own

With loose lists, a [columns group in a box](/en/docs/document-format#columns) cuts where its columns level best, between blocks or between the lines of one, so the gazpacho's first step splits across the gutter: its first line closes the ingredient column and the rest opens the method column. A nested box, as in [the short answer](#the-short-answer) above, is one block, and the group never cuts inside it. A picture set in one needs a `::resource` with placement `'here'`, since any other placement makes it a float, which leaves the box and is placed like any cited figure. The card style's `label` turns the fence's `label="SERVES 4"` into the tab: an `offset` equal to its `height` stands it on the card's top edge, and the `rule` runs from the far corner to the fork and knife ([callout styles](/en/docs/configuration#callout-styles)).

### 2 · Centre the step numbers on two lines

```js
// script.js, lines 65–75
// Young Serif has old-style figures: 1 and 2 stand 0.56 em, a little above its 0.50 em
// x-height, so 5.1 mm at STEP. A list number is centred 0.3 em (of the text) above the item's
// first baseline, with the canvas 'middle' baseline, which Chrome puts 0.24 em above Young
// Serif's own (gotcha: list-number-centred). DROP centres the figures on the step's first two
// lines, from the cap height of the first to the baseline of the second.
const [STEP, FIGURE, MIDDLE, CAP] = [26, 0.56, 0.24, 0.7]; // pt; em of each face
const DROP = (LEAD - CAP * BODY) / 2 + 0.3 * BODY + (FIGURE / 2 - MIDDLE) * STEP; // pt
const orderedLists = { fontFamily: DISPLAY, fontWeight: 400, // Young Serif ships 400 only
  numberFontSize: pt(STEP), color: col('tomato'),
  separatorColor: col('olive'), separatorGap: pt(0.6), // the default '.' as its own run
  numberVerticalOffset: pt(DROP) };
```

A list number is painted centred a little above the item's first baseline, so `numberFontSize` alone would set the 26 pt figures level with the first line; `numberVerticalOffset` lowers them 2.5 mm, to the middle of the band from the first line's capitals to the second line's baseline. Young Serif has old-style figures: 3, 4 and 5 descend below the baseline that 1 and 2 sit on. With `separatorColor` set, the separator is painted as a run of its own, and the default full stop prints olive beside a red figure ([ordered lists](/en/docs/configuration#ordered-lists)).

### 3 · Head the checklist with a strip of drawings

```js
// script.js, lines 51–61
// :::callout{type="prep" title="Before you start"}, closed at once, is a header: a box that
// holds only its title and icon. The icon is one picture of three drawings; a width KIT times
// its size makes its box a strip, and the picture is fitted into width × size, left of the
// title. Tasks set inside the box would start after that column, 21 mm in, so the '- [ ]'
// items follow the box, where they print the default task box, '☐'.
const [STRIP, KIT] = [5, 30 / 8]; // mm: the strip's height; the drawing's width over height
const prep = { id: 'prep', backgroundEnabled: false, padding: NONE,
  marginTop: pt(LEAD), marginBottom: column.titleStyle.gap, // the tasks follow at this gap
  icon: { kind: 'resource', resourceId: 'kit', size: mm(STRIP), width: mm(STRIP * KIT),
    align: 'center' }, // the title centred on the strip
  titleStyle: { ...column.titleStyle, color: col('olive') } };
```

The `prep` box holds only its title and its icon, so it works as a header: an icon `width` 3.75 times its `size` stretches the icon's square into an 18.75 × 5 mm strip, and the three drawings fill it left of the title ([callout styles](/en/docs/configuration#callout-styles)). An icon takes a column of its own beside everything in its box, so the tasks go after the box, where they line up with the ingredients' dashes instead of starting 21 mm in. `- [ ]` items print the default task box, and `unorderedLists.marginTop: 0` lets them follow the header at its `marginBottom`, the 2.4 mm gap the column titles keep ([unordered lists](/en/docs/configuration#unordered-lists)).

### 4 · Make the tags chips

```js
// script.js, lines 79–81
const chipStyles = [{ id: 'tag', fontSize: em(0.86), bold: true, background: col('tint'),
  borderWidth: pt(0), borderRadius: em(1), // no outline; a radius past half the height: a pill
  paddingX: em(0.7), paddingY: em(0.18), gap: em(0.3) }];
```

`:chip[Vegetarian]{style="tag"}` puts a word in a box that never breaks across lines. A `borderRadius` larger than half the box's height is clamped to that half, which turns both ends into semicircles; with `borderWidth: 0` the pill is drawn in the tint alone. The box is 11.4 pt tall, under the card's 12.8 pt leading, so a second line of tags would not touch the first ([chip styles](/en/docs/configuration#chip-styles)).

### 5 · One design, one drawing per recipe

```js
// script.js, lines 85–116
const BAND = 104; // mm: the drawing's foot; the pills sit 13 mm above it, the lead 6 mm below
const NOTE = { x: 18, y: 24, w: 70 }; // mm on the page: the box ends 2 mm before the arrow
const at = (x, y, size) => ({ anchor: { to: 'container', edge: 'top-left' },
  offset: { x: mm(x), y: mm(y - PAGE.top) }, size }); // y in mm from the top of the page
const text = (id, content, family, size, placement, extra) => ({ kind: 'text', id, content,
  fontFamily: family, fontSize: pt(size), color: col('ink'), align: 'left',
  overflow: 'wrap', placement, ...extra }); // gotcha: overflow-ellipsis-default
const tracked = { fontWeight: 700, textTransform: 'uppercase', letterSpacing: pt(1.6) };
// Pills are text boxes chained right-of each other; each prints one heading attribute.
const pill = (id, after) => text(id, `{attr.${id}}`, TEXT, 8.4, after ? { anchor:
  { to: `#${after}`, edge: 'right-of' }, offset: { x: mm(1.8) } } : at(0, BAND - 13), {
  fontWeight: 600, box: { backgroundColor: col('card'), borderRadius: mm(3),
    padding: { top: mm(1.1), right: mm(2.8), bottom: mm(1.1), left: mm(2.8) } } });
// One heading style per recipe: the same design, each with its own drawing.
const opener = (art) => ({ id: art, advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'art', resourceId: art,
      placement: { anchor: { to: 'page', edge: 'top-left' },
        size: { width: mm(PAGE.w), height: mm(BAND) } } },
    text('note', '{attr.note}', HAND, 19, { anchor: { to: 'page', edge: 'top-left' },
      offset: { x: mm(NOTE.x), y: mm(NOTE.y) }, size: { width: mm(NOTE.w) } },
    { fontWeight: 600, align: 'right' }), // on the page, like the arrow drawn in the picture
    pill('time'), pill('level', 'time'), pill('season', 'level'),
    text('title', '{titleText}', DISPLAY, 42, { anchor: { to: '#time', edge: 'above' },
      offset: { y: mm(-3.2) }, size: { width: mm(96) } }, // two lines: the \\ in the heading
    { lineHeight: 1 }), // a multiple, never pt() (gotcha: design-lineheight-multiple)
    text('kicker', '{attr.kicker}', TEXT, 8.2, { anchor: { to: '#title', edge: 'above' },
      offset: { y: mm(-2.4) }, size: { width: mm(96) } }, tracked),
    // The drawing reserves no height (gotcha: opener-image-no-reserve); the lead under it does,
    // so the card starts below the lead without a minHeight.
    text('lead', '{attr.lead}', TEXT, 10.5, at(0, BAND + 6, { width: mm(122) }),
      { italic: true, lineHeight: 1.45 }),
  ] } } });
```

`# Tortilla \\ de patatas {style="tortilla" kicker="…" lead="…"}` picks a heading style and fills its slots from the attributes, the italic headnote among them ([heading attributes](/en/docs/document-format#heading-attributes)). An image element names a fixed resource id, which is why `opener(art)` builds one style per drawing. Each pill is anchored `right-of` the one before it, the title `above` the first pill and the kicker above the title, so a longer title pushes the kicker up while the pills keep their line. The drawing reserves no height in 1.4.1, but the headnote under it does, so the card starts below the headnote without a `minHeight`. The note is anchored to the page, like the drawing, and ends 2 mm short of the drawn arrow on both pages, although the mirrored margins move the text block 2 mm to the right on the recto.

### 6 · Register the drawings as resources

```js
// script.js, lines 403–419
// The opener's image element and both icons name a resource id; the resource names the file
// the canvas paints (loadSvg registers it). No :ref cites them, so none is numbered.
const ART = { // markup, width and height in mm, and the alt text
  tortilla: [tortillaArt(), PAGE.w, BAND, t({ en: 'A potato omelette with a slice pulled out, '
    + 'on a blue-rimmed plate and a red-checked napkin', es: 'Una tortilla de patatas con una '
    + 'porción separada, en un plato de borde azul sobre una servilleta de cuadros rojos' })],
  gazpacho: [gazpachoArt(), PAGE.w, BAND, t({ en: 'A bowl of gazpacho with diced vegetables and '
    + 'a spoon, on a blue-checked napkin beside two tomatoes', es: 'Un cuenco de gazpacho con '
    + 'dados de verdura y una cuchara, sobre una servilleta de cuadros azules junto a dos '
    + 'tomates' })],
  cutlery: [cutlery, 6, 8, t({ en: 'Fork and knife', es: 'Tenedor y cuchillo' })],
  kit: [kit, 8 * KIT, 8, t({ en: 'A bowl with two eggs, a frying pan and a plate',
    es: 'Un bol con dos huevos, una sartén y un plato' })] };
const resources = Object.entries(ART).map(([id, [, w, h, altText]]) => ({ id, typeId: 'figure',
  kind: 'svg', createdAt: 0, updatedAt: 0, altText, svg: { fileId: `${id}.svg`, width: w * 10,
  height: h * 10 } })); // 10 px a millimetre: the sizes only set the aspect ratio here
await Promise.all(Object.entries(ART).map(([id, [svg]]) => loadSvg(`${id}.svg`, svg)));
```

The opener's image element, the tab's icon and the checklist header's icon each name a resource, and the canvas paints the file `loadSvg` registered for it. No `:ref` cites these resources, so none becomes a numbered figure. An SVG drawn as an image cannot use the page's web fonts, so the drawings carry no words: the handwritten notes are design text set over them.

## The whole recipe

One file, composed from the recipe's folder with the sample text and the Cookbook's shared kit inlined; it builds its own page. To run it, put it in a `<script type="module">` on an empty page, or paste it into the JS panel of a new CodePen (as a module). It imports postext from esm.sh, so there is nothing to install or build.

- Source folder: https://github.com/drnachio/postext/tree/main/cookbook/recipe-card

### script.js

```js
// ═══ Postext Cookbook · Nº 033 · Recipe card: ingredients beside the method ═══════
// https://postext.dev/en/cookbook/recipe-card
// Code: MIT · Text: original (CC BY 4.0) · Drawings: generated in code (CC BY 4.0)
// Fonts: Young Serif, Figtree, Caveat (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage }
  from 'https://esm.sh/postext';

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

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { ink: '#2b2118', muted: '#76634e', // text; folios and the colophon
  paper: '#f7eddb', card: '#fffdf8', // the cream page; the white recipe card on it
  tomato: '#bf3d29', olive: '#6b7a3a', tint: '#f6e3c1' }; // numbers and tab; dashes; tags
// The hex rides along: design elements read it, not the palette (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// The engine's defaults link to 'main-color': point it at the tomato, so nothing prints blue.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.tomato })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const [TEXT, DISPLAY, HAND] = ['Figtree', 'Young Serif', 'Caveat'];
const PAGE = { w: 190, h: 250, top: 22, inner: 18, outer: 16 }; // mm, mirrored margins
const [BODY, LEAD] = [9.4, 12.8]; // pt: the text of the cards and the notes under them

// #region answer: a white card with a servings tab, two columns inside it
// In the Markdown, :::callout{type="card" label="SERVES 4"} holds a :::columns{count=2} group
// of two nested boxes, :::callout{type="column" title="Ingredients"} and one for the method.
// The group levels its columns by cutting between blocks or lines, so loose lists would run
// the method on under the ingredients. A nested box is one block that never splits, so the
// only cut left is between the two (gotcha: callout-columns).
const TAB = 5.6; // mm: the tab's height, and how far it rises above the card
const card = { id: 'card', background: col('card'),
  padding: { top: mm(4.5), right: mm(6), bottom: mm(5), left: mm(6) },
  // No space of its own above: the tab starts on the first grid line under the opener.
  columnGap: mm(7), marginTop: mm(0),
  // label="…" on the fence prints here: a tab on the top-right corner, a cutlery pictogram
  // beside it and a rule from the far corner that makes the tab part of the card.
  label: { fontFamily: TEXT, fontSize: pt(8), color: col('card'), // bold by default
    background: col('tomato'), height: mm(TAB), offset: mm(TAB), paddingX: mm(2.6),
    icon: { resourceId: 'cutlery', width: mm(TAB * 6 / 8), gap: mm(1.6) }, // as tall as the tab
    rule: { enabled: true, color: col('tomato'), width: pt(1.2) } } };
// The two columns: frameless boxes whose only device is a tracked title.
const NONE = { top: mm(0), right: mm(0), bottom: mm(0), left: mm(0) };
const column = { id: 'column', backgroundEnabled: false, padding: NONE,
  lists: { gap: mm(2.2), itemSpacing: pt(3.5) }, // for the steps as well as the dashes
  titleStyle: { fontFamily: TEXT, fontSize: pt(8), color: col('tomato'), // bold by default
    textTransform: 'uppercase', letterSpacing: pt(1.5), gap: mm(2.4) } };
// config() plugs them in: calloutStyles: [card, column, prep].
// #endregion

// #region prep: a checklist under a header with a strip of three pictograms
// :::callout{type="prep" title="Before you start"}, closed at once, is a header: a box that
// holds only its title and icon. The icon is one picture of three drawings; a width KIT times
// its size makes its box a strip, and the picture is fitted into width × size, left of the
// title. Tasks set inside the box would start after that column, 21 mm in, so the '- [ ]'
// items follow the box, where they print the default task box, '☐'.
const [STRIP, KIT] = [5, 30 / 8]; // mm: the strip's height; the drawing's width over height
const prep = { id: 'prep', backgroundEnabled: false, padding: NONE,
  marginTop: pt(LEAD), marginBottom: column.titleStyle.gap, // the tasks follow at this gap
  icon: { kind: 'resource', resourceId: 'kit', size: mm(STRIP), width: mm(STRIP * KIT),
    align: 'center' }, // the title centred on the strip
  titleStyle: { ...column.titleStyle, color: col('olive') } };
// #endregion

// #region steps: big step numbers in the display face, an olive full stop after each
// Young Serif has old-style figures: 1 and 2 stand 0.56 em, a little above its 0.50 em
// x-height, so 5.1 mm at STEP. A list number is centred 0.3 em (of the text) above the item's
// first baseline, with the canvas 'middle' baseline, which Chrome puts 0.24 em above Young
// Serif's own (gotcha: list-number-centred). DROP centres the figures on the step's first two
// lines, from the cap height of the first to the baseline of the second.
const [STEP, FIGURE, MIDDLE, CAP] = [26, 0.56, 0.24, 0.7]; // pt; em of each face
const DROP = (LEAD - CAP * BODY) / 2 + 0.3 * BODY + (FIGURE / 2 - MIDDLE) * STEP; // pt
const orderedLists = { fontFamily: DISPLAY, fontWeight: 400, // Young Serif ships 400 only
  numberFontSize: pt(STEP), color: col('tomato'),
  separatorColor: col('olive'), separatorGap: pt(0.6), // the default '.' as its own run
  numberVerticalOffset: pt(DROP) };
// #endregion

// #region chips: tags for diet and occasion, as pills in the text
const chipStyles = [{ id: 'tag', fontSize: em(0.86), bold: true, background: col('tint'),
  borderWidth: pt(0), borderRadius: em(1), // no outline; a radius past half the height: a pill
  paddingX: em(0.7), paddingY: em(0.18), gap: em(0.3) }];
// #endregion

// #region opener: the drawing bled across the head, the title and pills set on it
const BAND = 104; // mm: the drawing's foot; the pills sit 13 mm above it, the lead 6 mm below
const NOTE = { x: 18, y: 24, w: 70 }; // mm on the page: the box ends 2 mm before the arrow
const at = (x, y, size) => ({ anchor: { to: 'container', edge: 'top-left' },
  offset: { x: mm(x), y: mm(y - PAGE.top) }, size }); // y in mm from the top of the page
const text = (id, content, family, size, placement, extra) => ({ kind: 'text', id, content,
  fontFamily: family, fontSize: pt(size), color: col('ink'), align: 'left',
  overflow: 'wrap', placement, ...extra }); // gotcha: overflow-ellipsis-default
const tracked = { fontWeight: 700, textTransform: 'uppercase', letterSpacing: pt(1.6) };
// Pills are text boxes chained right-of each other; each prints one heading attribute.
const pill = (id, after) => text(id, `{attr.${id}}`, TEXT, 8.4, after ? { anchor:
  { to: `#${after}`, edge: 'right-of' }, offset: { x: mm(1.8) } } : at(0, BAND - 13), {
  fontWeight: 600, box: { backgroundColor: col('card'), borderRadius: mm(3),
    padding: { top: mm(1.1), right: mm(2.8), bottom: mm(1.1), left: mm(2.8) } } });
// One heading style per recipe: the same design, each with its own drawing.
const opener = (art) => ({ id: art, advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'art', resourceId: art,
      placement: { anchor: { to: 'page', edge: 'top-left' },
        size: { width: mm(PAGE.w), height: mm(BAND) } } },
    text('note', '{attr.note}', HAND, 19, { anchor: { to: 'page', edge: 'top-left' },
      offset: { x: mm(NOTE.x), y: mm(NOTE.y) }, size: { width: mm(NOTE.w) } },
    { fontWeight: 600, align: 'right' }), // on the page, like the arrow drawn in the picture
    pill('time'), pill('level', 'time'), pill('season', 'level'),
    text('title', '{titleText}', DISPLAY, 42, { anchor: { to: '#time', edge: 'above' },
      offset: { y: mm(-3.2) }, size: { width: mm(96) } }, // two lines: the \\ in the heading
    { lineHeight: 1 }), // a multiple, never pt() (gotcha: design-lineheight-multiple)
    text('kicker', '{attr.kicker}', TEXT, 8.2, { anchor: { to: '#title', edge: 'above' },
      offset: { y: mm(-2.4) }, size: { width: mm(96) } }, tracked),
    // The drawing reserves no height (gotcha: opener-image-no-reserve); the lead under it does,
    // so the card starts below the lead without a minHeight.
    text('lead', '{attr.lead}', TEXT, 10.5, at(0, BAND + 6, { width: mm(122) }),
      { italic: true, lineHeight: 1.45 }),
  ] } } });
// #endregion

// Folios at the foot of the outer corner: the book on versos, the recipe on rectos.
const foot = (id, content, parity, edge, x, look) => text(id, content, TEXT, 7.6,
  { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(-12) } },
  { color: col('muted'), parity, align: edge.endsWith('left') ? 'left' : 'right', ...look });
const folio = { fontFamily: DISPLAY, fontSize: pt(10), color: col('tomato') };
const footer = { elements: [
  foot('verso-folio', '{pageNumber}', 'even', 'bottom-left', PAGE.outer, folio),
  foot('verso-book', '{title}', 'even', 'bottom-left', PAGE.outer + 9, tracked),
  foot('recto-dish', '{chapterTitle}', 'odd', 'bottom-right', -(PAGE.outer + 9), tracked),
  foot('recto-folio', '{pageNumber}', 'odd', 'bottom-right', -PAGE.outer, folio),
] };

const config = () => ({ // a factory, never a shared object (gotcha: config-cache-identity)
  colorPalette, chipStyles, orderedLists, footer, header: { elements: [] },
  page: { width: mm(PAGE.w), height: mm(PAGE.h), dpi: 150, backgroundColor: col('paper'),
    margins: { top: mm(PAGE.top), bottom: mm(20), left: mm(PAGE.inner),
      right: mm(PAGE.outer), mirror: true } }, // left is the inner margin
  layout: { layoutType: 'single' },
  bodyText: { fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'left', firstLineIndent: mm(0) }, // ragged and flush: the notes under the cards
  // A designed heading's own text is hidden but still measured: in Young Serif 400, the only
  // weight it ships, not in the default Open Sans 700 that FONTS does not load.
  headings: { fontFamily: DISPLAY, fontWeight: 400, levels: [
    // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
    // 'any': each recipe opens the next page, whichever side it is on. span: 'page' paints the
    // opener outside the column's clip: kept in the column, the drawing is cut at the top margin.
    { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' } },
  ] },
  headingStyles: [opener('tortilla'), opener('gazpacho')],
  // Olive dashes for the whole document: an olive lists.color on the column style would turn
  // the step numbers olive too (gotcha: box-list-colour-numbers).
  unorderedLists: { bulletChar: '–', color: col('olive'),
    marginTop: mm(0), // under the checklist's header, the header's marginBottom alone
    marginBottom: pt(LEAD / 2) }, // half a line above the tags
  calloutStyles: [card, column, prep],
  paragraphStyles: [{ id: 'colophon', fontSize: pt(7.4), lineHeight: pt(10),
    color: col('muted'), marginTop: pt(LEAD) }],
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Salt & Olive Oil"
---

# Tortilla \\ de patatas {style="tortilla" kicker="Spanish potato omelette" time="45 min" level="Intermediate" season="All year" note="onion, always!" lead="Our house version: potatoes poached slowly in plenty of oil, eggs barely set and ten minutes’ rest so the potato soaks up the egg. With onion, as my grandmother made it; without, if your family argues about it."}

:::callout{type="card" label="SERVES 4"}
:::columns{count=2}
:::callout{type="column" title="Ingredients"}
- **6** large free-range eggs
- **800 g** frying potatoes
- **1** medium onion
- **300 ml** extra virgin olive oil
- Fine salt

:chip[Vegetarian]{style="tag"} :chip[Gluten-free]{style="tag"} :chip[Picnic]{style="tag"}

:::callout{type="prep" title="Before you start"}
:::

- [ ] Eggs out of the fridge
- [ ] A bowl for eggs and potato
- [ ] A plate wider than the pan
:::
:::callout{type="column" title="Method"}
1. Peel and thinly slice the potatoes, cut the onion into fine strips and salt them.
2. Poach them in the oil over a medium heat, in a 24 cm pan, until tender but pale: about 20 minutes.
3. Drain, keeping the oil, and stir the hot potato into the eggs, beaten with salt. Leave for 10 minutes.
4. Heat a spoonful of the oil over a high heat, pour in the mixture and let it set for 2 minutes, loosening the edges.
5. Turn it over with a plate, let it set a minute more and serve it warm.
:::
:::
:::

**Keep the oil.** Strained into a jar, it will fry the next tortilla.

# Gazpacho \\ andaluz {style="gazpacho" kicker="Andalusian cold soup" time="25 min + 2 h chilling" level="Easy" season="June to September" note="serve it ice cold!" lead="In many Andalusian homes it is drunk from a glass, straight from the fridge. It needs truly ripe tomatoes, and oil poured in a thin stream while the blender runs, which leaves it creamy and orange."}

:::callout{type="card" label="SERVES 6"}
:::columns{count=2}
:::callout{type="column" title="Ingredients"}
- **1.5 kg** ripe plum tomatoes
- **1** long green pepper
- **1** cucumber
- **1** garlic clove
- **100 g** day-old bread, soaked in water
- **120 ml** extra virgin olive oil
- **3 tbsp** sherry vinegar
- Salt and cold water

:chip[Vegan]{style="tag"} :chip[No-cook]{style="tag"} :chip[Make ahead]{style="tag"}
:::
:::callout{type="column" title="Method"}
1. Chop the tomatoes, seeded pepper, peeled cucumber and garlic.
2. Put it all in the blender with the soaked bread, the vinegar and a teaspoon of salt; leave for 15 minutes.
3. Blend on full power for 2 minutes, then, with the motor running, add the oil in a thin stream until it emulsifies.
4. Press it through a fine sieve and thin it with cold water to taste.
5. Season with salt and vinegar and chill for at least 2 hours before serving.
:::
:::
:::

**Make it ahead.** It keeps two days in a covered jug in the fridge; stir it before you pour.

:::paragraphs{style="colophon"}
*Salt & Olive Oil* · Set in Young Serif, Figtree and Caveat (SIL OFL) · Recipes and drawings: original, CC BY 4.0
:::
`; // content.<lang>.md, inlined by the Cookbook

// #region art: two table-top drawings and the pictograms, in the palette's colours
// No words in them: an SVG drawn as an image cannot use web fonts (gotcha: svg-no-webfonts);
// the handwritten note is a design element set in Caveat where the drawn arrow starts.
const TABLE = { saffron: '#e9a23b', sage: '#b9c08a', blue: '#2f5d8a', china: '#fffaf0',
  gold: '#e9b659', crust: '#d4923a', brown: '#b8702a', soup: '#d6552f', oil: '#e8c547' };
const n = (v) => +v.toFixed(2);
function mulberry32(seed) { // a seeded PRNG: the same drawing in every capture
  return () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let r = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
    return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
  };
}
const svgDoc = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" `
  + `height="${h * 10}" viewBox="0 0 ${w} ${h}">${body}</svg>`;
const circle = (x, y, r, fill, extra = '') => `<circle cx="${n(x)}" cy="${n(y)}" r="${n(r)}" `
  + `fill="${fill}"${extra}/>`;
const blob = (x, y, rx, ry, turn, fill, opacity) => `<ellipse cx="${n(x)}" cy="${n(y)}" `
  + `rx="${n(rx)}" ry="${n(ry)}" transform="rotate(${n(turn)} ${n(x)} ${n(y)})" fill="${fill}" `
  + `fill-opacity="${n(opacity)}"/>`;
const path = (d, fill, extra = '') => `<path d="${d}" fill="${fill}"${extra}/>`;
const stroke = (d, color, width, extra = '') => path(d, 'none', ` stroke="${color}" `
  + `stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round"${extra}`);
const group = (x, y, turn, body) => `<g transform="translate(${n(x)} ${n(y)}) `
  + `rotate(${n(turn)})">${body}</g>`;
const polar = (cx, cy, r, deg) => [cx + r * Math.cos(deg * Math.PI / 180),
  cy + r * Math.sin(deg * Math.PI / 180)];

// A napkin in checks: two sets of translucent stripes, darker where they cross.
function gingham(size, check, color, opacity) {
  let out = `<rect x="${-size / 2}" y="${-size / 2}" width="${size}" height="${size}" `
    + `fill="${TABLE.china}"/>`;
  for (let i = 0; i < size / check; i++) {
    const at = -size / 2 + i * check;
    out += `<rect x="${n(at)}" y="${-size / 2}" width="${check / 2}" height="${size}" `
      + `fill="${color}" fill-opacity="${opacity}"/>`
      + `<rect x="${-size / 2}" y="${n(at)}" width="${size}" height="${check / 2}" `
      + `fill="${color}" fill-opacity="${opacity}"/>`;
  }
  return out;
}
// A shadow, white china with a blue rim, a ring of cream dots and a fine inner line.
function plate(r, rim) {
  let out = circle(1.6, 2.4, r, palette.ink, ' fill-opacity=".16"')
    + circle(0, 0, r, TABLE.china)
    + circle(0, 0, r - rim / 2, 'none', ` stroke="${TABLE.blue}" stroke-width="${rim}"`);
  for (let a = 0; a < 360; a += 10) {
    out += circle(...polar(0, 0, r - rim / 2, a), 0.55, TABLE.china);
  }
  return out + circle(0, 0, r - rim - 1.4, 'none', ` stroke="${TABLE.blue}" stroke-width=".45"`);
}
// A sector path: the omelette with a slice taken out, or the slice itself.
const sector = (r, a0, a1) => {
  const [x0, y0] = polar(0, 0, r, a0);
  const [x1, y1] = polar(0, 0, r, a1);
  return `M0 0L${n(x0)} ${n(y0)}A${r} ${r} 0 ${a1 - a0 > 180 ? 1 : 0} 1 ${n(x1)} ${n(y1)}Z`;
};
function omelette(r, a0, a1, rand) { // a browned rim, a golden top mottled where it caught
  let out = path(sector(r, a0, a1), TABLE.crust) + path(sector(r - 1.8, a0, a1), TABLE.gold);
  // Spots in proportion to the sector, each kept clear of the rim and of both cut edges.
  const clear = (d, da) => da > 0 && d * Math.sin(Math.min(da, 90) * Math.PI / 180); // mm
  const inside = (d, a, rx) => d + rx < r - 2.4
    && [a - a0, a1 - a].every((da) => clear(d, da) > rx + 0.4);
  const spot = (count, size, fill, opacity) => {
    for (let i = 0; i < Math.ceil(count * (a1 - a0) / 360); i++) {
      const rx = size * (0.5 + rand());
      const [d, a] = [Math.sqrt(rand()) * r, a0 + rand() * (a1 - a0)];
      const [ry, turn, alpha] = [rx * (0.4 + rand() * 0.4), rand() * 180, 0.6 + rand() * 0.6];
      if (inside(d, a, rx)) out += blob(...polar(0, 0, d, a), rx, ry, turn, fill, opacity * alpha);
    }
  };
  spot(24, 3.4, TABLE.crust, 0.4); // browned patches
  spot(12, 2.4, '#f5d98a', 0.35); // pale patches
  spot(48, 0.45, TABLE.brown, 0.5); // specks
  for (const a of [a0, a1]) { // the cut edges catch the light: a pale line along each
    const [x, y] = polar(0, 0, r - 1, a);
    out += stroke(`M0 0L${n(x)} ${n(y)}`, '#f5dc93', 0.9);
  }
  return out;
}
function arrow(d, tip, turn) { // a hand-drawn line and its head, as paths, never a <marker>
  return stroke(d, palette.ink, 0.55) + group(...tip, turn,
    stroke('M-2.4-1.3L0 0-2.4 1.5', palette.ink, 0.55));
}
function sprig(x, y, turn, rand) { // an olive twig: paired grey-green leaves and two olives
  let out = stroke('M0 0C10-2 22-3 34-9', palette.olive, 0.7);
  for (let i = 0; i < 7; i++) {
    const t = 3 + i * 4.4;
    for (const side of [-1, 1]) {
      const leaf = 'M0 0C2-1.3 7-1.5 10 0C7 1.5 2 1.3 0 0Z';
      out += group(t, -t * 0.18, side * (32 + rand() * 12) - 8, path(leaf,
        side > 0 ? palette.olive : '#8d9a5c'));
    }
  }
  out += circle(12, 4.6, 2.3, '#4a3a38') + circle(20, 3.4, 2.1, '#7d8a3a')
    + circle(11.3, 3.8, 0.6, TABLE.china, ' fill-opacity=".5"');
  return group(x, y, turn, out);
}
const [PLATE_X, PLATE_Y] = [138, 45]; // mm: the plate or bowl, right of the title
function tortillaArt() {
  const rand = mulberry32(23);
  let body = `<rect width="${PAGE.w}" height="${BAND}" fill="${TABLE.saffron}"/>`;
  body += group(152, 32, -11, gingham(108, 11, palette.tomato, 0.2));
  const [CUT0, CUT1, PULL] = [-6, 30, 9]; // the slice in degrees; mm it is pulled out
  const shadow = (a0, a1) => group(0.9, 1.5, 0, path(sector(31, a0, a1), palette.ink,
    ' fill-opacity=".14"'));
  const slice = polar(0, 0, PULL, (CUT0 + CUT1) / 2);
  body += group(PLATE_X, PLATE_Y, 0, plate(43, 6.4) + shadow(CUT1, CUT0 + 360)
    + omelette(31, CUT1, CUT0 + 360, rand) + group(...slice, 0, shadow(CUT0, CUT1)
      + omelette(31, CUT0, CUT1, rand)));
  body += sprig(150, 95, -24, rand);
  body += arrow('M90 30C96 27 100 29 103 34', [103, 34], 62); // from the note towards the omelette
  return svgDoc(PAGE.w, BAND, body);
}
function tomato(x, y, r, turn) { // seen from above: a red disc, a green star, a highlight
  const star = [0, 72, 144, 216, 288].map((a) => group(0, 0, a,
    path('M0 0C1-1 3.2-1 4.2 0C3.2 1 1 1 0 0Z', palette.olive))).join('');
  return group(x, y, turn, circle(0, 0, r, palette.tomato) + circle(-r * 0.35, -r * 0.35,
    r * 0.3, TABLE.china, ' fill-opacity=".25"') + star + circle(0, 0, 0.9, '#4f5c27'));
}
function gazpachoArt() {
  const rand = mulberry32(7);
  let body = `<rect width="${PAGE.w}" height="${BAND}" fill="${TABLE.sage}"/>`;
  body += group(156, 32, 9, gingham(108, 11, TABLE.blue, 0.22));
  // A bowl from above: rim, pale inner wall, soup, drops of oil and a heap of diced vegetables.
  let bowl = plate(42, 5.8) + circle(0, 0, 32.5, '#efe5d2') + circle(0, 0, 29.5, TABLE.soup)
    + circle(0, 0, 29.5, 'none', ' stroke="#b8401f" stroke-width="1.2"')
    + path('M-24-12A26 26 0 0 1 4-26A28 28 0 0 0-24-12Z', '#e2703f'); // light on the surface
  for (const [x, y, rr] of [[-14, 4, 2.4], [-10, 12, 1.4], [-17, -6, 1.6], [-6, 17, 1.8],
    [-19, 3, 0.9], [2, 20, 1.1], [-12, -12, 1]]) {
    bowl += blob(x, y, rr, rr * 0.8, 20, TABLE.oil, 0.9);
  }
  const dice = ['#8fae4a', '#4f7a2a', '#f1d49a', '#b8321e', '#e3bf72'];
  for (let i = 0; i < 24; i++) {
    const [x, y] = polar(7, -5, Math.sqrt(rand()) * 11, rand() * 360);
    const size = 2 + rand() * 1.3;
    bowl += group(x, y, rand() * 90, `<rect x="${n(-size / 2)}" y="${n(-size / 2)}" `
      + `width="${n(size)}" height="${n(size)}" rx=".5" fill="${dice[i % 5]}"/>`);
  }
  // A spoon resting in the soup, its handle over the rim towards the napkin.
  bowl += group(14, -12, -38, stroke('M6 0L40 0', palette.ink, 3.2,
    ' stroke-opacity=".14" transform="translate(1 1.5)"') + stroke('M6 0L40 0', '#cfcabf', 3)
    + blob(0, 0, 7, 4.6, 0, '#dedad0', 1) + blob(-0.8, -0.8, 4.6, 2.6, 0, TABLE.china, 0.55));
  body += group(PLATE_X, PLATE_Y, 0, bowl);
  // Two tomatoes at the foot, shadows included, clear of the band's lower edge.
  const shade = (x, y, r) => circle(x + 1, y + 1.6, r, palette.ink, ' fill-opacity=".15"');
  body += shade(180, 91, 9) + tomato(180, 91, 9, 12)
    + shade(164, 95, 6.5) + tomato(164, 95, 6.5, -30);
  body += arrow('M90 30C96 27 100 29 104 33', [104, 33], 55); // from the note to the bowl
  return svgDoc(PAGE.w, BAND, body);
}
const cutlery = svgDoc(6, 8, stroke('M1.6 .6V7.4M.6 .6V2.6C.6 3.4 2.6 3.4 2.6 2.6V.6',
  palette.tomato, 0.55) + stroke('M4.6 7.4V.6C5.8 1.4 5.8 3.6 4.6 4.4', palette.tomato, 0.55));
// The kit in line drawings: a bowl with two eggs, the frying pan and the plate that turns the
// tortilla over, in a box KIT times as wide as it is tall.
const egg = (x, turn) => `<ellipse cx="${x}" cy="2.5" rx="1.05" ry="1.35" `
  + `transform="rotate(${turn} ${x} 2.5)" fill="none" stroke="${palette.olive}" `
  + 'stroke-width=".6"/>';
const kit = svgDoc(8 * KIT, 8, egg(3, -12) + egg(5, 14)
  + stroke('M.6 3.9H7.4M1 3.9C1 6.4 2.4 7.4 4 7.4S7 6.4 7 3.9', palette.olive, 0.7)
  + stroke('M9.8 4.4H16.8M10.2 4.4L10.8 6.7C10.9 7.1 11.2 7.3 11.6 7.3H15C15.4 7.3 15.7 7.1 '
    + '15.8 6.7L16.4 4.4', palette.olive, 0.7) + stroke('M16.8 5L20.2 4.1', palette.olive, 1.1)
  + circle(26.2, 4.2, 3.3, 'none', ` stroke="${palette.olive}" stroke-width=".7"`)
  + circle(26.2, 4.2, 2, 'none', ` stroke="${palette.olive}" stroke-width=".5"`));
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Every face the design uses. Layout measures with the browser's fonts, so the
// kit loads them from Fontsource before the first build (gotcha: fonts-first).
const FONTS = { Figtree: ['400', '400i', '600', '700'], 'Young Serif': ['400'], Caveat: ['600'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
// #region pictures: the drawings and the pictograms are resources, cited by id, never by :ref
// The opener's image element and both icons name a resource id; the resource names the file
// the canvas paints (loadSvg registers it). No :ref cites them, so none is numbered.
const ART = { // markup, width and height in mm, and the alt text
  tortilla: [tortillaArt(), PAGE.w, BAND, t({ en: 'A potato omelette with a slice pulled out, '
    + 'on a blue-rimmed plate and a red-checked napkin', es: 'Una tortilla de patatas con una '
    + 'porción separada, en un plato de borde azul sobre una servilleta de cuadros rojos' })],
  gazpacho: [gazpachoArt(), PAGE.w, BAND, t({ en: 'A bowl of gazpacho with diced vegetables and '
    + 'a spoon, on a blue-checked napkin beside two tomatoes', es: 'Un cuenco de gazpacho con '
    + 'dados de verdura y una cuchara, sobre una servilleta de cuadros azules junto a dos '
    + 'tomates' })],
  cutlery: [cutlery, 6, 8, t({ en: 'Fork and knife', es: 'Tenedor y cuchillo' })],
  kit: [kit, 8 * KIT, 8, t({ en: 'A bowl with two eggs, a frying pan and a plate',
    es: 'Un bol con dos huevos, una sartén y un plato' })] };
const resources = Object.entries(ART).map(([id, [, w, h, altText]]) => ({ id, typeId: 'figure',
  kind: 'svg', createdAt: 0, updatedAt: 0, altText, svg: { fileId: `${id}.svg`, width: w * 10,
  height: h * 10 } })); // 10 px a millimetre: the sizes only set the aspect ratio here
await Promise.all(Object.entries(ART).map(([id, [svg]]) => loadSvg(`${id}.svg`, svg)));
// #endregion
await loadFonts(FONTS, markdown);
// The excerpt is pages 58 and 59 of the book: 57 pages come before it, so the tortilla opens
// on a verso and the two recipes face each other.
const doc = await buildWithFonts(() => buildDocument({ markdown, resources,
  continuation: { pageIndexOffset: 57, pageNumbering: { startAt: 58 } } }, config()), markdown);
showPages(doc, { title: t({ en: 'Recipe card', es: 'Tarjeta de receta' }) });

// ─── 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 · images v1 ── recipes with pictures · postext.dev/cookbook ──────────
/** Registers a photo or PNG for the canvas and keeps its bytes for the PDF.
 *  fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */
async function loadImage(fileId, url) {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`);
  const bytes = new Uint8Array(await res.arrayBuffer());
  registerResourceImage(fileId, await createImageBitmap(new Blob([bytes])));
  (loadImage.bytes ??= new Map()).set(fileId, bytes);
}

/** Registers SVG markup (drawn in code, or fetched) as a vector image. */
async function loadSvg(fileId, svg) {
  const img = new Image();
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
  await img.decode();
  registerResourceImage(fileId, img);
  (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg));
}

/** renderToPdf({ resourceBytes: imageBytes }) */
function imageBytes(fileId) { return loadImage.bytes?.get(fileId); }

/** renderToHtml({ resourceImageUrl: imageUrl }) */
function imageUrl(fileId) {
  const bytes = imageBytes(fileId);
  if (!bytes) return undefined;
  imageUrl.urls ??= new Map();
  if (!imageUrl.urls.has(fileId)) {
    const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg';
    imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type })));
  }
  return imageUrl.urls.get(fileId);
}

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

## Variations

### Count blocks instead of boxing the columns

Without the column boxes, `breaks` names the child that opens the right column, and each list item, the tags line and the checklist's header count as one: the gazpacho needs `breaks="10"` (eight ingredients and the tags come first), the tortilla `breaks="11"`. The column titles and the column style's space between steps disappear with them.

```diff
 :::callout{type="card" label="SERVES 6"}
-:::columns{count=2}
-:::callout{type="column" title="Ingredients"}
+:::columns{count=2 breaks="10"}
 - **1.5 kg** ripe plum tomatoes
@@ seven more ingredients and a blank line @@
 :chip[Vegan]{style="tag"} :chip[No-cook]{style="tag"} :chip[Make ahead]{style="tag"}
-:::
-:::callout{type="column" title="Method"}
+
 1. Chop the tomatoes, seeded pepper, peeled cucumber and garlic.
@@ steps 2 to 5 @@
 :::
-:::
 :::
```

### Stand the tab on the left corner

`'top-left'` mirrors the label: the tab moves to the left corner, the fork and knife to its right, and the rule runs in from the right.

```diff
     background: col('tomato'), height: mm(TAB), offset: mm(TAB), paddingX: mm(2.6),
+    position: 'top-left', // 'top-right' by default
```

## Pitfalls

- **:::columns works only inside a box and never splits.** :::columns is ignored outside a callout, and a box that splits never cuts inside a columns group. A breaks attribute counts child blocks, with a nested box as one.
- **A list number is centred on its first line, not set on the baseline.** In postext 1.4.1 an ordered list's number is painted with the canvas 'middle' baseline, 0.3 em of the text above the item's first baseline, so a number in another face or at a larger numberFontSize grows up and down from there instead of standing on the line: a display face rides high, and a big step number hangs across the first line. Place it with orderedLists.numberVerticalOffset, and check the result at full size.
- **A box's lists.color also recolours its list numbers.** In postext 1.4.1 a callout style's lists.color reaches the numbers of its ordered lists whenever it differs from unorderedLists.color, even when orderedLists sets a colour of its own: give a box olive bullets that way and its step numbers turn olive too. To colour a box's bullets alone, set unorderedLists.color for the document and leave lists.color out of the box style.
- **Chips taller than the line pitch touch the next line.** A chip whose box is taller than the line pitch touches the chips on the next line (the chipOverlap warning). Reduce its vertical padding, border or size, or open the leading.
- **Lists say 'arabic', resources 'roman-upper', pages 'upper-roman'.** Each numbering setting spells its formats differently: lists take numberFormat 'arabic' ('decimal' prints "undefined"), resource types take counterFormat 'roman-upper', pages and :::numbering take 'upper-roman'.
- **An opener's images never count towards the height it reserves.** In postext 1.4.1 an advanced-design heading measures the height it reserves without its images: its texts, rules and boxes count, even when anchored to the page, but an image, such as a picture bled across the head of the page, reserves nothing, so the text can start on top of it. Set minHeight to where the text should begin.
- **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.
- **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.
- **Design text overflow defaults to 'ellipsis-end'.** A design text element that does not fit its width ends in an ellipsis by default. Set overflow: 'wrap' for titles that should break onto more lines.
- **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.
- **Text inside an SVG <img> cannot use web fonts.** An SVG is drawn as an image, and an image has no access to the page's web fonts, so its labels fall back to a system face. Outline the text, embed an @font-face subset in the SVG, or move the labels to the caption.
- **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().
- **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.

- A label has no `textTransform` or `letterSpacing` in 1.4.1, so SERVES 4 and PARA 4 are typed in capitals in the fences.
- The task box ☐ is in none of the three faces, so the browser draws it from a system font and its shape changes from one system to another.
- Keep a wide icon in the box's own column: at `position: 'corner'`, 1.4.1 offsets it by half its height, not half its width, so the 18.75 mm strip runs 16 mm past the box's right edge.

## Credits

- Recipe: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Type: Young Serif (OFL-1.1), Figtree (OFL-1.1), Caveat (OFL-1.1)
- Code: MIT · Sample content: CC-BY-4.0

## Related

- [Nº 052 · Bistro menu: prices aligned without tab stops](https://postext.dev/en/cookbook/bistro-menu.md): A bistro menu printed on both sides, with the prices set in borderless tables and a brass rule anchored to either side of each course head. · Level 2 (Intermediate) · Single sheets & ephemera
- [Nº 008 · A colour-coded family of textbook boxes](https://postext.dev/en/cookbook/textbook-box-family.md): Six kinds of textbook box in three colours, told apart by a stripe with an icon, a badge, a numbered tab, a strip of pictograms or a marker outside the frame. · Level 2 (Intermediate) · Textbooks
- [Nº 027 · Newspaper front page](https://postext.dev/en/cookbook/newspaper-front-page.md): A local weekly's front page and page 2 in two ruled columns, with page-wide headlines set as boxes and a four-column strip of briefs floated to the foot. · Level 3 (Advanced) · Newspapers & newsletters
