# أعد تلوين مستند كامل من لوحة ألوان واحدة

> كل لون في الإعدادات يحمل معرّفًا من لوحة الألوان، فتعيد دالة واحدة إعدادات جديدة تنضّد برنامج مهرجان بالأحمر أو الفيروزي أو البنفسجي أو الرملي.

- نسخة HTML: https://postext.dev/ar/cookbook/live-palette-retint
- وصفة رقم 054 · الإخراج والدمج · المستوى 2 (متوسط) · المخرجات: Canvas, عناصر تحكم حية
- الأنواع: الأوراق المفردة والمطبوعات العابرة
- تتطلب postext ≥ 1.4.1 · اختُبرت مع 1.4.1 بتاريخ 2026-09-26
- الصفحات: [1](https://postext.dev/cookbook/live-palette-retint/en/p01.webp?v=cfb3bc74), [2](https://postext.dev/cookbook/live-palette-retint/en/p02.webp?v=cfb3bc74)
- افتح في Sandbox: https://postext.dev/ar/sandbox#recipe=live-palette-retint&lang=en (.postext: https://postext.dev/cookbook/live-palette-retint/en/live-palette-retint.postext)
- آخر تحديث: 2026-09-27
- لغات أخرى: [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)

## باختصار

مطوية مربعة لمهرجان موسيقي معروضة بأربع مجموعات ألوان. تبيّن كيف تغيّر كل ألوان المستند دفعة واحدة باختيار مجموعة ألوان جديدة.

## ما الذي ستنضده

برنامج *Riverside Music Week*، مهرجان متخيَّل في بلدة نهرية، مطبوعًا مطويةً مربعة قياسها 200 mm. للصفحة الأمامية حقل لوني يمتد على نصفها العلوي، فيه العنوان بخط Syne 800 بحجم 46 pt وموجة صوتية من أعمدة شاحبة مستديرة الأطراف؛ وتحته عمودان بحافة حرّة يسردان الأسبوع يومًا بيوم، ولكل فعالية وقت بالخط العريض وشارة للسعر. يرسم الظهر الأسبوع شبكةً من مربعات ملوّنة تحت شريط تعليق، ثم يسرد الأيام من الثلاثاء إلى الجمعة وينتهي بإطار للتذاكر تحت شريط ملوّن وبيانات الطبع. فوق الصفحات يعرض المثال أربع بروفات: بالأحمر والفيروزي والبنفسجي والرملي. تأتي ألوانها من سبعة مداخل في لوحة الألوان مسمّاة بحسب وظيفتها، إضافةً إلى `main-color` المضبوط على لون الحقل. انقر على بروفة فيُبنى المستند كله من جديد بتلك اللوحة.

**تجيب هذه الوصفة عن:**

- كيف أستبدل الأزرق الافتراضي في العناوين والخط العريض ونقاط القوائم بألوان من لوحتي الخاصة؟
- كيف ألوّن المصطلحات الرئيسية (العريضة أو المائلة) في المتن أو داخل الإطارات؟
- كيف أضيف عيّنات لونية دليلية إلى النص أو التعليقات أو ملاحظات الجداول؟

## الجواب المختصر

```js
// script.js, سطرًا 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
  };
}
```

## المكونات

**تعلّم**

- [لوحة ألوان دلالية](https://postext.dev/ar/docs/configuration.md#لوحة-الألوان): ألوان مسمّاة يرتبط بها كل إعداد بمعرّفها، فيُعاد تلوين المستند كله بتغيير عيّنة واحدة؛ والألوان الافتراضية مشتقة من main-color.
- [الغامق والمائل وألوانهما](https://postext.dev/ar/docs/configuration.md#نص-المتن): مقاطع غامقة ومائلة واللون الذي تُطبع به؛ تأخذ افتراضيًا الأزرق المميِّز، لذا يعيدها الكتاب أحادي اللون إلى لون النص.

**تستخدم أيضًا**

- [عيّنات الألوان](https://postext.dev/ar/docs/document-format.md#التنسيق-المضمّن)
- [الشارات داخل السطر](https://postext.dev/ar/docs/configuration.md#أنماط-الشارات)
- [تعبئة الخلايا](https://postext.dev/ar/docs/document-format.md#التضمين-في-كتلة-اختياري-لوضع-صريح-داخل-التدفق)
- [نمط التعليق](https://postext.dev/ar/docs/configuration.md#نمط-التعليقات)
- [صفحات افتتاح مصمَّمة](https://postext.dev/ar/docs/configuration.md#الامتداد-والتصميم-المتقدم)
- [شريط فصل بعرض الصفحة](https://postext.dev/ar/docs/configuration.md#الامتداد-والتصميم-المتقدم)
- [النصوص والخطوط والمربعات في تصاميم الصفحة](https://postext.dev/ar/docs/configuration.md#رؤوس-الصفحات-وتذييلاتها)
- [القوائم النقطية وقوائم التحقق](https://postext.dev/ar/docs/configuration.md#القوائم-غير-المرقّمة)
- [إطارات التنبيه](https://postext.dev/ar/docs/configuration.md#أنماط-الإطارات)
- [الترويسات وأرقام الصفحات](https://postext.dev/ar/docs/configuration.md#رؤوس-الصفحات-وتذييلاتها)
- [الترويسات بحسب دور الصفحة](https://postext.dev/ar/docs/configuration.md#عناصر-النص)
- [سمات العنوان](https://postext.dev/ar/docs/document-format.md#سمات-العناوين)
- [الأشكال والجداول بوصفها موارد](https://postext.dev/ar/docs/document-format.md#الموارد)
- [صفحات على اللوحة (Canvas)](https://postext.dev/ar/docs/configuration.md#رسم-صفحة-في-صورة-نقطية)
- citations
- [«شكل» و«جدول» بلغتك](https://postext.dev/ar/docs/configuration.md#أنواع-الموارد)
- [أنماط الفقرات](https://postext.dev/ar/docs/configuration.md#أنماط-الفقرات)
- [أنواع موارد مخصّصة](https://postext.dev/ar/docs/configuration.md#أنواع-الموارد)
- [فواصل الأسطر في العناوين](https://postext.dev/ar/docs/document-format.md#فواصل-الأسطر-في-العناوين)

**الإعدادات في لمحة**

- [`bodyText`](https://postext.dev/ar/docs/configuration.md#نص-المتن), [`calloutStyles`](https://postext.dev/ar/docs/configuration.md#أنماط-الإطارات), [`captionStyle`](https://postext.dev/ar/docs/configuration.md#نمط-التعليقات), [`chipStyles`](https://postext.dev/ar/docs/configuration.md#أنماط-الشارات), [`colorPalette`](https://postext.dev/ar/docs/configuration.md#لوحة-الألوان), [`footer`](https://postext.dev/ar/docs/configuration.md#رؤوس-الصفحات-وتذييلاتها), [`header`](https://postext.dev/ar/docs/configuration.md#رؤوس-الصفحات-وتذييلاتها), [`headings`](https://postext.dev/ar/docs/configuration.md#العناوين), [`layout`](https://postext.dev/ar/docs/configuration.md#التخطيط), [`page`](https://postext.dev/ar/docs/configuration.md#الصفحة), [`paragraphStyles`](https://postext.dev/ar/docs/configuration.md#أنماط-الفقرات), [`resourceTypes`](https://postext.dev/ar/docs/configuration.md#أنواع-الموارد), [`tableStyle`](https://postext.dev/ar/docs/configuration.md#نمط-الجداول), [`unorderedLists`](https://postext.dev/ar/docs/configuration.md#القوائم-غير-المرقّمة)

**واجهات API**

- [`buildDocument`](https://postext.dev/ar/docs/configuration.md#بناء-مستند), [`clearMeasurementCache`](https://postext.dev/ar/docs/configuration.md#ذاكرة-القياس-المؤقتة), [`defaultResourceTypes`](https://postext.dev/ar/docs/configuration.md#أنواع-الموارد), [`renderPageToCanvas`](https://postext.dev/ar/docs/configuration.md#رسم-صفحة-في-صورة-نقطية)

**الخطوط**

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

## طريقة التحضير

### 1 · سمِّ الألوان بحسب وظيفتها

```js
// script.js, سطرًا 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` الحقول اللونية، ويلوّن `onBand` الحروف المنضَّدة عليها، و`deep` هو لون الإبراز للحروف على الورق الأبيض، و`tint` هو التعبئة الفاتحة. تضبط كل مجموعة ألوان هذه الأربعة وتتشارك الألوان المحايدة الثلاثة. على الأبيض يبلغ تباين الحقل الفيروزي 3.3:1 والحقل الرملي 2.1:1، وهي نسبة أدنى مما يلزم لحروف بيضاء على الحقل أو لحروف بلون الحقل على الورق. تضبط هاتان اللوحتان `onBand` على لون الحبر، وتنضّد اللوحات الأربع الخط العريض والعناوين بلون `deep`، وهو درجة أغمق يتراوح تباينها على الأبيض بين 5.99:1 (الرملي) و8.40:1 (البنفسجي).

### 2 · اربط كل ما كان المحرّك سيطبعه بالأزرق

```js
// script.js, سطرًا 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) } } });
```

أزرق المحرّك مدخل في لوحة الألوان، `main-color` (#295AA3)، و[قيمه الافتراضية](/ar/docs/configuration#لوحة-الألوان-الافتراضية) توجّه إليه العناوين والخط العريض والمائل والإحالات ونقاط القوائم والترويسة. هنا يحمل كل واحد من هذه معرّفًا من لوحة الألوان: الأوقات والمصطلحات الأساسية بالعريض بلون `deep`، والمائل بلون الحبر، ونقاط القوائم بلون `band`، وفي الصفحة 2 ترويسة بلون `deep` ورقم الصفحة بلون `onBand` على كبسولة بلون `band`. توجّه `retint()` أيضًا `main-color` إلى لون الحقل؛ ولأن هذه الإعدادات تضبط كل لون، لا يتغير شيء في هذه الصفحات، لكن أي نمط يُضاف لاحقًا من غير لون يأخذ لون الحقل بدل الأزرق. لا يضبط إطار التذاكر لونًا خاصًا للخط العريض، فيرث `bodyText.boldColor` مع معرّفه في لوحة الألوان، ويتغير خطه العريض مع مجموعة الألوان.

### 3 · ارسم العمل الفني بصناديق التصميم

```js
// script.js, سطرًا 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)
  ] },
};
```

صفحة الافتتاح [خانة تصميم](/ar/docs/configuration#عناصر-الصندوق) تحوي صندوقًا ينزف خارج الحافة العليا، و29 عمودًا مستدير الأطراف للموجة الصوتية، وثلاثة عناصر نصية: نص العنوان نفسه عنوانًا، والسطر التمهيدي والمقدمة من سماته. الأعمدة صناديق لا SVG، فتعبئتها رابط آخر إلى `tint` تعيد `relink()` كتابته؛ أما SVG فكان سيلزم رسمه وتسجيله من جديد لكل مجموعة ألوان. لو تُرك صندوق الحقل وحده لبدأ النص على سطر الشبكة الواقع 2.4 mm تحت الحقل؛ أما `minHeight`، أي 18 سطرًا أو 85.1 mm تحت الهامش العلوي، فيحجز مساحة أكبر وينقل السطر الأول إلى 7 mm تحته.

### 4 · دع لوحة الألوان تملأ الجدول

```js
// script.js, سطرًا 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] }))]),
  ] } } }];
```

الشبكة مورد من نوع جدول، خارج الإعدادات، فلا تمسّها `relink()` أبدًا. ومع ذلك تحمل [تعبئات خلاياها](/ar/docs/document-format#التضمين-في-كتلة-اختياري-لوضع-صريح-داخل-التدفق) معرّفات من لوحة الألوان، ويحلّها المحرّك من لوحة ألوان المستند حين يُخرج الجدول؛ وكذلك تُحلّ `:swatch{color="band"}` و`:swatch{color="tint"}` في مفتاح ملحوظة الجدول. تستعمل الشارات في القوائم المعرّفين نفسيهما، فيتطابق المربع المملوء والشارة بجانب الوقت وعيّنة المفتاح في كل مجموعة ألوان.

### 5 · ابنِ إعدادات جديدة مع كل نقرة

```js
// script.js, سطرًا 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);
```

يستدعي كل زر `retint()`، التي تستدعي `config()`، فيحصل كل بناء على كائن جديد لا يقف خلفه أي حلّ مخزَّن. يحلّ المحرّك الإعدادات مرة واحدة و[يخزّن النتيجة مقترنةً بذلك الكائن](/ar/docs/configuration#الحالة-العامة-المشتركة-في-صفحة-واحدة). إن أبقيت كائن إعدادات واحدًا وغيّرت مداخل لوحة ألوانه في مكانها، أعاد البناء التالي استعمال الحلّ القديم: في اختبار على 1.4.1 احتفظت العناوين ونقاط القوائم والشارات وشريط التعليق بلونها الأول، بينما أخذت عيّنات الألوان وتعبئات الخلايا، التي تقرأ لوحة الألوان أثناء الإخراج، اللون الجديد. وإسناد مصفوفة `colorPalette` جديدة إلى الكائن نفسه لم يغيّر شيئًا. أما `structuredClone()` للإعدادات المعدَّلة فيحصل فعلًا على حلّ جديد، لكن خانات التصميم ولون الإحالات فيه ما زالت تحمل قيم hex القديمة، فبقي الحقل والموجة الصوتية وعناصر الصفحة الثابتة و«Table 1» بالأحمر؛ أما `retint()` فتعيد كتابة قيم hex تلك وتُرجع كائنًا جديدًا.

## الوصفة كاملة

تُركَّب الملفات أدناه من مجلد الوصفة، ومعها نص المثال وأدوات دليل الوصفات المشتركة مضمّنة. لتشغيلها صفحةً واحدة، ضع HTML داخل `<body>`، وCSS داخل عنصر `<style>`، والسكربت داخل `<script type="module">`؛ أو الصق كلًّا منها في اللوحة المقابلة من مثال CodePen جديد (مع ضبط JS وحدةً). يستورد السكربت postext من esm.sh، فلا حاجة إلى تثبيت أو بناء.

- مجلد الوصفة: https://github.com/drnachio/postext/tree/main/cookbook/live-palette-retint

### style.css

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

### script.js

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

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

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

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

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

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

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

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

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

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

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

## Saturday 12 June

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

## Sunday 13 June

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

## Monday 14 June

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

## Tuesday 15 June

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

## Wednesday 16 June

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

## Thursday 17 June

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

## Friday 18 June

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## تنويعات

### أبقِ الخط العريض بلون الحبر

اترك لون الإبراز للعناوين ونقاط القوائم والشارات، ونضّد الأوقات والمصطلحات الأساسية بلون النص.

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

### أعطِ كل جزء من الكتاب لونه الخاص

للألوان التي تتغير داخل مستند واحد جزءًا بعد جزء، انظر [أجزاء ملوّنة من سمة واحدة](https://postext.dev/ar/cookbook/parts-in-colour.md).

## أخطاء شائعة

- **يُخزَّن الإعداد مؤقتًا بحسب هويته: ابنِ كائنًا جديدًا.** يخزّن المحرّك الإعدادات المحسوبة مؤقتًا بحسب هوية الكائن، فتعديل الإعداد في مكانه ثم البناء مجددًا يعيد استخدام النتيجة القديمة. ابنِ كائنًا جديدًا في كل بناء، ولهذا يكون إعداد الوصفة دالة مصنِّعة: config().
- **لوحة الألوان المستبدلة لا تصل إلى عناصر التصميم ولا إلى لون الإحالة.** يقرأ postext 1.4.1 الإعداد colorPalette في أنماط النص (المتن والعناوين والقوائم والتعليقات والجداول والإطارات) لكن لا في عناصر الترويسات والتذييلات والافتتاحيات وصفحات الأجزاء، ولا في bodyText.referenceColor: تحتفظ بالقيمة الست عشرية المكتوبة بجانب paletteId الخاص بها. حين تستبدل لوحة الألوان، لنسخة شاشة داكنة أو لإعادة تلوين، أعِد كتابة كل لون مرتبط من colorPalette قبل البناء.
- **النص غير المضبوط قد يترك علامة الترقيم منفصلة عن العريض أو عن :ref.** في postext 1.4.1 قد يكسر النص غير المضبوط (متون الإطارات، الفقرات ذات الحافة الحرّة) السطرَ بين مقطع عريض أو مائل، أو :ref، وعلامة الترقيم الملاصقة له: قد تفتتح النقطة السطر التالي، وقد ينتهي السطر السابق بـ '(' التي قبل الإحالة. النص المضبوط لا ينكسر هناك أبدًا. اقرأ إطارات كل نسخة وأعد صياغة كل جملة يحدث فيها ذلك، ليقع المقطع في وسط السطر.
- **النص غير المضبوط لا يُفحص أبدًا بحثًا عن الكلمات المعزولة.** تعمل optimalLineBreaking وavoidRunts وruntPenalty وruntMinCharacters على كاسر الأسطر Knuth–Plass، الذي لا يشغّله postext 1.4.1 إلا للنص المضبوط. الفقرة غير المضبوطة تُكسر سطرًا سطرًا وقد تنتهي بكلمة قصيرة واحدة أيًّا كانت تلك الإعدادات. اقرأ الأسطر الأخيرة من النص غير المضبوط وأعد صياغة الفقرة التي تنتهي بكلمة معزولة.
- **أي كائن headings يُلغي فاصل الصفحة قبل H1.** ينتقل H1 افتراضيًا إلى صفحة فردية (always-odd)، لكن تمرير أي كائن headings يعيد ضبط هذا الافتراض، فتتوالى الفصول دون فاصل ولا يفعل span: 'page' شيئًا. أعد كتابة headings.levels[0].breakBefore: { enabled: true, parity } في كل إعداد.
- **ترجم Figure/Table بـ defaultResourceTypes(locale).** يضبط locale في الإعداد تقسيمَ الكلمات بالواصلة، لا التعليقات: من دون resourceTypes تقول الأنواع المدمجة Figure وTable بالإنجليزية. مرّر resourceTypes: defaultResourceTypes('es') للإسبانية؛ ولأي لغة أخرى اكتب الأسماء بنفسك في resourceTypes.
- **lineHeight لنص التصميم مُضاعِف، لا بُعد أبدًا.** في خانة التصميم، يضاعف lineHeight لعنصر النص مقاس خطه (lineHeight: 1.05). في postext 1.4.1 لا يُرفض بُعدٌ مثل pt(15): يُقاس ارتفاع الافتتاحية NaN، ويسقط ما تحجزه من مساحة، بما فيه minHeight، دون تحذير، فيجري النص تحت العنوان.
- **حمّل كل أوجه الخط قبل الإخراج.** يقيس الإخراج النص بأوجه الخط التي حمّلها المتصفح ويخزّن العروض مؤقتًا، فالوجه الذي يصل بعد البناء الأول يترك فواصل أسطر خاطئة وملف PDF لم يعد يطابق الشاشة. حمّل كل وزن وكل نمط أولًا، واستدعِ clearMeasurementCache() قبل إعادة البناء إذا تأخر وصول أحدها.

- احذف `relink()` وابنِ النسخة الفيروزية لترى ما يتركه postext 1.4.1 خلفه: الحقل والموجة الصوتية والعنوان الأبيض والترويسة وكبسولة رقم الصفحة والإحالة «Table 1» تحتفظ بألوان النسخة الحمراء، بينما يتبع النصُّ والقوائم والشارات وشريط التعليق والجدول وإطار التذاكر لوحةَ الألوان.
- أبقِ `bodyText.boldColor` مضبوطًا حين يحوي صندوقٌ خطًا عريضًا. ينسخه الصندوق ومعه معرّف لوحة الألوان؛ وإن تركته غير مضبوط نسخ 1.4.1 القيمة الافتراضية بدلًا منه، فيأخذ الخط العريض خارج الصندوق في النسخة الفيروزية لون الحقل بينما تُطبع «week pass» داخل الصندوق بلون #295AA3.

## الحقوق

- الوصفة: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- الخطوط: Syne (OFL-1.1), Plus Jakarta Sans (OFL-1.1)
- الشيفرة: MIT · محتوى المثال: CC-BY-4.0

## وصفات ذات صلة

- [رقم 011 · مصدر واحد وطبعتان للورق والشاشة](https://postext.dev/ar/cookbook/print-and-screen-editions.md): الطبعتان من إعداد واحد: يحمل htmlViewer.overrides تصميم الشاشة الداكن، ويدمجه applyHtmlViewerOverrides قبل كل بناء HTML. · المستوى 3 (متقدم) · المجلات والمجلات المستقلة
- [رقم 019 · أجزاء ملوّنة من سمة واحدة](https://postext.dev/ar/cookbook/parts-in-colour.md): دليل ميداني للجيب يضبط فيه كل :::part مدخلًا من لوحة الألوان، فتُطبع بلونه صفحته الفاصلة وظهرها المطلي وألسنة الإبهام والعلامات الميدانية وسطر المحتويات. · المستوى 3 (متقدم) · الأدلة والمراجع
- [رقم 051 · مجلة ريزو بحبرين: الرسوم بلون خاص واحد](https://postext.dev/ar/cookbook/riso-zine-single-ink.md): مجلة من أربع صفحات لآلة ريزوغراف بأسطوانتين: الرسوم الملونة تُطبع درجاتٍ من الوردي، يعيد المثال تلوينها لـCanvas وrenderToPdf في PDF. · المستوى 3 (متقدم) · المجلات والمجلات المستقلة
