# Mudar as cores de um documento inteiro com uma paleta

> Cada cor da configuração leva um id de paleta, e uma só função compõe a programação de um festival em vermelho, verde-azulado, violeta ou areia.

- Versão HTML: https://postext.dev/pt/cookbook/live-palette-retint
- Receita Nº 054 · Saída e integração · Nível 2 (Intermediário) · Saídas: Canvas, Controles ao vivo
- Gêneros: Folhas avulsas e impressos efêmeros
- Requer postext ≥ 1.4.1 · testada com 1.19.1 em 2026-10-06
- Páginas: [1](https://postext.dev/cookbook/live-palette-retint/en/p01.webp?v=fba47188), [2](https://postext.dev/cookbook/live-palette-retint/en/p02.webp?v=fba47188)
- Abrir no Sandbox: https://postext.dev/pt/sandbox#recipe=live-palette-retint&lang=en (.postext: https://postext.dev/cookbook/live-palette-retint/en/live-palette-retint.postext)
- Última atualização: 2026-10-06
- Outros idiomas: [en](https://postext.dev/en/cookbook/live-palette-retint.md), [es](https://postext.dev/es/cookbook/live-palette-retint.md), [ca](https://postext.dev/ca/cookbook/live-palette-retint.md), [zh](https://postext.dev/zh/cookbook/live-palette-retint.md), [ja](https://postext.dev/ja/cookbook/live-palette-retint.md), [ar](https://postext.dev/ar/cookbook/live-palette-retint.md)

## Em poucas palavras

Um folheto quadrado para um festival de música, mostrado em quatro combinações de cores. Mostra como trocar todas as cores do documento de uma vez escolhendo outro conjunto de cores.

## O que você vai compor

A programação de *Riverside Music Week*, um festival fictício numa cidade à beira de um rio, impressa como um folheto quadrado de 200 mm. A página da frente tem um campo de cor na metade superior, com o título em Syne 800 de 46 pt e uma onda de barras arredondadas claras; abaixo, duas colunas alinhadas à esquerda percorrem a semana dia a dia, cada evento com o horário em negrito e um chip com o preço. O verso resume a semana numa grade de casas coloridas sob uma barra de legenda, depois lista de terça a sexta e termina com um boxe de ingressos sob uma tarja e o colofão. Acima das páginas, o pen mostra quatro provas, em vermelho, verde-azulado, violeta e areia. As cores delas vêm de sete entradas de paleta nomeadas pela função, mais `main-color` apontada para a cor da faixa. Clique numa prova e o documento inteiro é composto de novo naquela paleta.

**Esta receita responde a:**

- Como faço para trocar o azul padrão dos títulos, do negrito e dos marcadores pelas cores da minha paleta?
- Como dou cor aos termos-chave (em negrito ou itálico) no texto ou dentro dos boxes?
- Como acrescento amostras de cor, como chave de cores, ao texto, às legendas ou às notas de tabela?

## A resposta curta

```js
// script.js, linhas 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
  };
}
```

## Ingredientes

**Ensina**

- [Paleta de cores semântica](https://postext.dev/pt/docs/configuration.md#paleta-de-cores): Cores com nome às quais cada configuração se liga por id, de modo que o documento inteiro muda de tom ao alterar uma amostra de cor; as cores padrão derivam de main-color.
- [Negrito, itálico e suas cores](https://postext.dev/pt/docs/configuration.md#texto-do-corpo): Os trechos em negrito e itálico e a cor em que são impressos; por padrão usam o azul de destaque, por isso um livro a uma cor precisa devolvê-los à cor do texto.

**Também usa**

- [Amostras de cor](https://postext.dev/pt/docs/document-format.md#formatação-em-linha)
- [Chips no texto](https://postext.dev/pt/docs/configuration.md#estilos-de-chip)
- [Preenchimento de células](https://postext.dev/pt/docs/document-format.md#inserção-em-bloco-opcional-posicionamento-explícito-em-linha)
- [Estilo de legenda](https://postext.dev/pt/docs/configuration.md#estilo-de-legenda)
- [Aberturas desenhadas](https://postext.dev/pt/docs/configuration.md#largura-e-design-avançado)
- [Faixa de capítulo em largura total](https://postext.dev/pt/docs/configuration.md#largura-e-design-avançado)
- [Textos, fios e caixas nos designs de página](https://postext.dev/pt/docs/configuration.md#cabeços-e-rodapés)
- [Listas com marcadores e de verificação](https://postext.dev/pt/docs/configuration.md#listas-com-marcadores)
- [Boxes](https://postext.dev/pt/docs/configuration.md#estilos-de-boxe)
- [Cabeços e fólios](https://postext.dev/pt/docs/configuration.md#cabeços-e-rodapés)
- [Cabeços por tipo de página](https://postext.dev/pt/docs/configuration.md#elementos-de-texto)
- [Atributos de título](https://postext.dev/pt/docs/document-format.md#atributos-de-título)
- [Figuras e tabelas como recursos](https://postext.dev/pt/docs/document-format.md#recursos)
- [Páginas em um canvas](https://postext.dev/pt/docs/configuration.md#renderizar-uma-página-como-bitmap)
- [Citações que posicionam as figuras](https://postext.dev/pt/docs/document-format.md#referência-em-linha-a-forma-principal)
- [Figura e Tabela no seu idioma](https://postext.dev/pt/docs/configuration.md#tipos-de-recurso)
- [Estilos de parágrafo](https://postext.dev/pt/docs/configuration.md#estilos-de-parágrafo)
- [Tipos de recurso personalizados](https://postext.dev/pt/docs/configuration.md#tipos-de-recurso)
- [Quebras de linha nos títulos](https://postext.dev/pt/docs/document-format.md#quebras-de-linha-em-títulos)

**A configuração em resumo**

- [`bodyText`](https://postext.dev/pt/docs/configuration.md#texto-do-corpo), [`calloutStyles`](https://postext.dev/pt/docs/configuration.md#estilos-de-boxe), [`captionStyle`](https://postext.dev/pt/docs/configuration.md#estilo-de-legenda), [`chipStyles`](https://postext.dev/pt/docs/configuration.md#estilos-de-chip), [`colorPalette`](https://postext.dev/pt/docs/configuration.md#paleta-de-cores), [`footer`](https://postext.dev/pt/docs/configuration.md#cabeços-e-rodapés), [`header`](https://postext.dev/pt/docs/configuration.md#cabeços-e-rodapés), [`headings`](https://postext.dev/pt/docs/configuration.md#títulos), [`layout`](https://postext.dev/pt/docs/configuration.md#diagramação), [`page`](https://postext.dev/pt/docs/configuration.md#página), [`paragraphStyles`](https://postext.dev/pt/docs/configuration.md#estilos-de-parágrafo), [`resourceTypes`](https://postext.dev/pt/docs/configuration.md#tipos-de-recurso), [`tableStyle`](https://postext.dev/pt/docs/configuration.md#estilo-de-tabela), [`unorderedLists`](https://postext.dev/pt/docs/configuration.md#listas-com-marcadores)

**API**

- [`buildDocument`](https://postext.dev/pt/docs/configuration.md#compilar-um-documento), [`clearMeasurementCache`](https://postext.dev/pt/docs/configuration.md#cache-de-medidas), [`defaultResourceTypes`](https://postext.dev/pt/docs/configuration.md#tipos-de-recurso), [`renderPageToCanvas`](https://postext.dev/pt/docs/configuration.md#renderizar-uma-página-como-bitmap)

**Tipos**

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

## Preparo

### 1 · Dê nome às cores pela função que cumprem

```js
// script.js, linhas 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` preenche os campos de cor, `onBand` colore o texto composto sobre eles, `deep` é o destaque para texto sobre papel branco e `tint` o preenchimento claro. Cada combinação define essas quatro e divide os três neutros. Contra o branco, a faixa verde-azulada mede 3,3:1 e a faixa areia 2,1:1, pouco para texto branco sobre a faixa ou para texto na cor da faixa sobre o papel. Essas duas paletas põem `onBand` na cor da tinta, e as quatro compõem negritos e títulos em `deep`, um tom mais escuro que mede entre 5,99:1 (areia) e 8,40:1 (violeta) sobre o branco.

### 2 · Ligue tudo o que o motor imprimiria em azul

```js
// script.js, linhas 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) } } });
```

O azul do motor é uma entrada de paleta, `main-color` (#295AA3), e [os seus padrões](https://postext.dev/pt/docs/configuration.md#a-paleta-padrão) apontam para ela os títulos, o negrito, o itálico, as remissões, os marcadores e o cabeço. Aqui, cada um deles leva um id de paleta: horários e termos-chave em negrito `deep`, itálicos na cor da tinta, marcadores em `band` e, na página 2, o cabeço em `deep` e o fólio em `onBand` numa pílula `band`. `retint()` também aponta `main-color` para a faixa; como esta configuração define todas as cores, isso não muda nada nestas páginas, mas um estilo acrescentado depois sem cor pega a faixa em vez do azul. O boxe de ingressos não define uma cor de negrito própria, então herda `bodyText.boldColor` com o seu id de paleta, e o negrito dele muda com a combinação.

### 3 · Desenhe a arte com caixas de design

```js
// script.js, linhas 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)
  ] },
};
```

A abertura é um [encaixe de design](https://postext.dev/pt/docs/configuration.md#elementos-de-caixa) com uma caixa que sangra pela borda superior, 29 barras arredondadas para a onda e três elementos de texto: o próprio texto do título, e o antetítulo e a linha fina vindos dos atributos dele. As barras são caixas e não um SVG, então o preenchimento delas é mais uma ligação a `tint` que `relink()` reescreve; um SVG teria de ser redesenhado e registrado de novo para cada combinação. Sozinha, a caixa da faixa faria o texto começar na linha da grade 2,4 mm abaixo da faixa; `minHeight`, 18 linhas ou 85,1 mm abaixo da margem superior, reserva mais e leva a primeira linha para 7 mm abaixo dela.

### 4 · Deixe a paleta preencher a tabela

```js
// script.js, linhas 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] }))]),
  ] } } }];
```

A grade é um recurso de tabela, fora da configuração, então `relink()` nunca mexe nela. Mesmo assim, os [preenchimentos de célula](https://postext.dev/pt/docs/document-format.md#inserção-em-bloco-opcional-posicionamento-explícito-em-linha) levam ids de paleta, e o motor os resolve pela paleta do documento quando compõe a tabela; os `:swatch{color="band"}` e `:swatch{color="tint"}` da legenda, na nota da tabela, se resolvem do mesmo jeito. Os chips da programação usam os mesmos dois ids, e assim a casa preenchida, o chip ao lado de um horário e a amostra da legenda batem em todas as combinações.

### 5 · Monte uma configuração nova a cada clique

```js
// script.js, linhas 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);
```

Cada botão chama `retint()`, que chama `config()`, então cada composição recebe um objeto novo, sem nenhuma resolução em cache por trás. O motor resolve uma configuração uma vez e [guarda o resultado associado àquele objeto](https://postext.dev/pt/docs/configuration.md#estado-global-compartilhado-em-uma-página). Mantenha uma configuração e altere as entradas da paleta no próprio objeto, e a composição seguinte reaproveita a resolução antiga: num teste com a versão 1.4.1, os títulos, os marcadores, os chips e a barra de legenda ficaram com a primeira cor, enquanto as amostras e os preenchimentos de célula, que leem a paleta durante a composição, pegaram a nova. Atribuir um novo array `colorPalette` ao mesmo objeto não mudou nada. Um `structuredClone()` da configuração editada recebe, sim, uma resolução nova, mas os encaixes de design e a cor das remissões ainda trazem o hexadecimal antigo, então a faixa, a onda, os cabeços e fólios e “Table 1” continuaram vermelhos; `retint()` reescreve esses hexadecimais e devolve um objeto novo.

## A receita completa

Os arquivos abaixo são compostos a partir da pasta da receita, com o texto de exemplo e o kit comum das Receitas já incluídos. Para executá-los como uma única página, coloque o HTML em `<body>`, o CSS em um elemento `<style>` e o script em um `<script type="module">`; ou cole cada um no painel correspondente de um pen novo do CodePen (o JS como módulo). O script importa o postext do esm.sh, então não há nada para instalar nem compilar.

- Pasta da receita: 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 v2 ── the same in every recipe · postext.dev/cookbook
// Postext measures with the loaded faces and caches the widths: load every face
// before the first build, from Fontsource, the files the PDF embeds too.

/** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  č ł † α χ also load latin-ext and greek files (kitSubsetsFor). 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',
    greek: 'U+0370-03FF',
  };
  const jobs = [];
  let added = 0;
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal'])
      .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await
    const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null;
    const subsets = ['latin', ...kitSubsetsFor(text, meta)];
    for (const [weight, style] of todo) {
      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` and loads any face the pages use that FONTS missed (a regular
 *  one with a warning), then clears the measurement cache and builds 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. */
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' };
}

/** A loaded FontFace covers this family, weight and style (fonts.check() would
 *  also say yes 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;
}

/** The files beyond latin `text` needs that `meta`'s family ships. */
function kitSubsetsFor(text, meta) {
  return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']]
    .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x);
}

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

/** The family's Fontsource metadata (weights, styles, subsets), 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
/** The pages as spreads on a dark desk, page 1 alone, then verso | recto,
 *  each painted when it scrolls near. */
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 ───────────────────────────────────────────────────────────────────────
```

## Variações

### Mantenha o negrito na cor da tinta

Deixe o destaque para os títulos, os marcadores e os chips, e componha os horários e os termos-chave na cor do texto.

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

### Dê a cada parte de um livro uma cor própria

Para cores que mudam dentro de um mesmo documento, parte a parte, veja [Partes em cores a partir de um atributo](https://postext.dev/pt/cookbook/parts-in-colour.md).

## Erros comuns

- **A configuração fica em cache pela identidade: crie um objeto novo.** O motor guarda em cache as configurações resolvidas pela identidade do objeto, então alterar uma configuração no próprio objeto e compor de novo reaproveita o resultado antigo. Crie um objeto novo a cada composição; por isso a configuração de uma receita é uma função, config().
- **Uma paleta trocada não chega aos elementos de design nem à cor das referências.** postext 1.4.1 aplica colorPalette aos estilos de texto (corpo, títulos, listas, legendas, tabelas, boxes), mas não aos elementos de cabeçalhos, rodapés, aberturas e páginas de parte, nem a bodyText.referenceColor: eles mantêm o hex escrito ao lado do seu paletteId. Se você trocar a paleta, para uma edição de tela escura ou para mudar as cores, reescreva cada cor vinculada a partir de colorPalette antes de compor.
- **Texto alinhado à esquerda pode isolar a pontuação ao lado de um negrito ou de um :ref.** No postext 1.4.1, o texto que não é justificado (corpo de boxes, parágrafos em bandeira) pode quebrar a linha entre um trecho em negrito ou itálico, ou um :ref, e a pontuação colada nele: um ponto final pode abrir a linha seguinte, e o “(” antes de uma referência pode fechar a linha de cima. Texto justificado nunca quebra aí. Revise os boxes de cada edição e reescreva a frase onde isso acontecer, para que o trecho fique no meio da linha.
- **Texto em bandeira nunca é verificado contra linhas curtas.** optimalLineBreaking, avoidRunts, runtPenalty e runtMinCharacters agem sobre o algoritmo de quebra de linha de Knuth–Plass, que o postext 1.4.1 só aplica a texto justificado. Um parágrafo em bandeira é quebrado linha a linha e pode terminar numa única palavra curta, digam o que disserem essas configurações. Revise as últimas linhas do texto em bandeira e reescreva o parágrafo que terminar numa linha curta.
- **Qualquer objeto headings desativa a quebra de página do H1.** Por padrão, um H1 salta para uma página ímpar (always-odd), mas passar qualquer objeto headings redefine esse padrão, então os capítulos ficam emendados e span: 'page' não faz nada. Declare de novo headings.levels[0].breakBefore: { enabled: true, parity } em toda configuração.
- **Traduza Figura e Tabela com defaultResourceTypes(locale).** O locale da configuração define a hifenização, não as legendas: sem resourceTypes, os tipos embutidos dizem Figure e Table, em inglês. Passe resourceTypes: defaultResourceTypes('es') para o espanhol; para qualquer outro idioma, escreva você mesmo os nomes em resourceTypes.
- **O lineHeight de um texto de design é um múltiplo, nunca uma medida.** Num slot de design, o lineHeight de um elemento de texto multiplica o tamanho da fonte (lineHeight: 1.05). No postext 1.4.1, uma medida como pt(15) não é rejeitada: a altura da abertura dá NaN, o espaço que ela reserva, minHeight incluído, se perde sem aviso e o texto passa por baixo do título.
- **Carregue todas as fontes antes do layout.** O motor de layout mede o texto com as fontes que o navegador carregou e guarda as larguras em cache, então uma fonte que chega depois da primeira composição deixa quebras de linha erradas e um PDF que não corresponde mais à tela. Carregue antes todos os pesos e estilos e chame clearMeasurementCache() antes de recompor quando alguma chegar atrasada.

- Apague `relink()` e componha a edição verde-azulada para ver o que o postext 1.4.1 deixa para trás: a faixa, a onda, o título branco, o cabeço, a pílula do fólio e a remissão “Table 1” mantêm as cores da edição vermelha, enquanto o texto, as listas, os chips, a barra de legenda, a tabela e o boxe de ingressos acompanham a paleta.
- Mantenha `bodyText.boldColor` definido quando um boxe tiver texto em negrito. O boxe o copia, com o id de paleta; deixe-o sem definir e a versão 1.4.1 copia o padrão, e na edição verde-azulada o negrito fora do boxe pega a cor da faixa enquanto “week pass”, dentro do boxe, sai em #295AA3.

## Créditos

- Receita: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Tipos: Syne (OFL-1.1), Plus Jakarta Sans (OFL-1.1)
- Código: MIT · Conteúdo de exemplo: CC-BY-4.0

## Relacionadas

- [Nº 011 · Um só original, edições impressa e de tela](https://postext.dev/pt/cookbook/print-and-screen-editions.md): As duas edições saem de uma configuração: htmlViewer.overrides guarda o design escuro de tela e applyHtmlViewerOverrides o mescla antes de cada composição HTML. · Nível 3 (Avançado) · Revistas e fanzines
- [Nº 019 · Partes em cores com um único atributo](https://postext.dev/pt/cookbook/parts-in-colour.md): Num guia de campo de bolso, cada :::part redefine uma cor da paleta, que tinge a página divisória, o verso pintado, a aba lateral e a linha do sumário. · Nível 3 (Avançado) · Manuais, guias e obras de referência
- [Nº 051 · Zine riso em duas cores: a arte numa cor especial](https://postext.dev/pt/cookbook/riso-zine-single-ink.md): Um zine de quatro páginas para risografia de dois tambores: os desenhos coloridos saem em tons de rosa, recoloridos pelo pen no canvas e por renderToPdf no PDF. · Nível 3 (Avançado) · Revistas e fanzines
