Kurz gesagt
Ein quadratisches Faltblatt für ein Musikfestival in vier Farbschemata. Es zeigt, wie Sie jede Farbe im Dokument auf einmal ändern, indem Sie einen neuen Satz Farben wählen.
Was Sie setzen
Das Programm der Riverside Music Week, eines erfundenen Festivals in einer Flussstadt, gedruckt als quadratisches Faltblatt von 200 mm. Die Vorderseite hat ein Farbfeld über der oberen Hälfte, mit dem Titel in Syne 800 mit 46 pt und einer Wellenform aus blassen, abgerundeten Balken; darunter listen zwei Spalten im Flattersatz die Woche Tag für Tag auf, jede Veranstaltung mit einer fetten Uhrzeit und einem Preis-Chip. Die Rückseite zeigt die Woche als Raster farbiger Kacheln unter einem Legendenbalken, listet dann Dienstag bis Freitag auf und endet mit einem Ticketkasten unter einem Streifen und dem Impressum. Über den Seiten zeigt der Pen vier Andrucke, in Rot, Petrol, Violett und Sand. Ihre Farben kommen aus sieben nach ihrer Rolle benannten Paletteneinträgen, dazu main-color, auf die Bandfarbe gesetzt. Klicken Sie einen Andruck an, und das ganze Dokument wird in dieser Palette neu gebaut.
Dieses Rezept beantwortet
- Wie verhindere ich, dass Überschriften, fette Wörter und Aufzählungszeichen blau erscheinen?
- Wie färbe ich Schlüsselbegriffe (fett oder kursiv) im Text oder in Kästen ein?
- Wie füge ich Farbfelder als Farblegende in Text, Bildunterschriften oder Tabellenanmerkungen ein?
Die kurze Antwort
// col(id) links a colour to a palette entry. The link decides what is printed, in text, lists,
// boxes, chips, captions, table fills, swatches, design slots and references alike; the hex
// beside it (the house red's) only fills the field a colour must have.
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
// A colourway is a palette. retint() writes it into the one config; the next build sees the
// change and sets the whole programme in the new colours.
function retint(way) {
const palette = { ...NEUTRALS, ...COLOURWAYS[way] };
config.colorPalette = entries({ ...palette, 'main-color': palette.band }); // defaults: band
return config;
}
Zutaten
- Funktionen
- Semantische FarbpaletteFett, kursiv und ihre FarbenFarbfelderInline-ChipsZellfüllungenFormat der BildunterschriftGestaltete AuftakteKapitelband über die ganze BreiteText, Linien und Kästen in SeitengestaltungenAufzählungen und ChecklistenKästenKolumnentitel und SeitenzahlenKopfzeilen nach SeitenrolleÜberschriftenattributeAbbildungen und Tabellen als RessourcenSeiten auf einem Canvas
- Nutzt außerdem
- Verweise, die Abbildungen platzierenAbbildung und Tabelle in Ihrer SpracheAbsatzformateEigene RessourcentypenZeilenumbrüche in Titeln
- Schrift
- Syne, Plus Jakarta Sans (SIL OFL 1.1)
- Dateien
- Keine: Jedes Bild wird im Code gezeichnet
Zubereitung
#1 · Farben nach ihrer Aufgabe benennen
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 house edition: red
band füllt die Farbfelder, onBand färbt die darauf gesetzte Schrift, deep ist der Akzent für Schrift auf weißem Papier und tint die helle Füllung. Jede Farbvariante setzt diese vier und teilt die drei neutralen Farben. Gegen Weiß misst das Petrolband 3,3:1 und das Sandband 2,1:1, zu wenig für weiße Schrift auf dem Band oder für bandfarbene Schrift auf Papier. Diese beiden Paletten setzen onBand in Schwarz, und alle vier setzen Fettschrift und Überschriften in deep, einem dunkleren Ton, der auf Weiß zwischen 5,99:1 (Sand) und 8,40:1 (Violett) misst.
#2 · Alles verknüpfen, was die Engine blau drucken würde
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: [
// The programme opens on a recto.
{ 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) } } });
Das Blau der Engine ist ein Paletteneintrag, main-color (#295AA3), und ihre Voreinstellungen richten Überschriften, Fettes, Kursives, Verweise, Aufzählungszeichen und den Kolumnentitel darauf aus. Hier trägt jedes davon eine Paletten-ID: Uhrzeiten und Schlüsselbegriffe fett in deep, Kursives in Schwarz, Aufzählungszeichen in band, und auf Seite 2 ein Kolumnentitel in deep und die Pagina in onBand auf einer Pille in band. retint() richtet auch main-color auf das Band aus; da diese Konfiguration jede Farbe setzt, ändert das auf diesen Seiten nichts, aber ein später hinzugefügtes Format ohne Farbe nimmt dann das Band statt Blau. Der Ticketkasten setzt keine eigene Farbe für Fettes, also erbt er bodyText.boldColor mit seiner Paletten-ID, und sein Fettes ändert sich mit der Farbvariante.
#3 · Das Bildwerk mit Gestaltungskästen zeichnen
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'): one more link the palette decides
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 }),
text('standfirst', '{attr.standfirst}', INNER, 71, 92, { fontFamily: SANS, fontSize: pt(10),
lineHeight: 1.36 }),
] },
};
Der Auftakt ist ein Gestaltungsplatz mit einem Kasten, der über die obere Kante randabfallend läuft, 29 abgerundeten Balken für die Wellenform und drei Textelementen: dem eigenen Text der Überschrift als Titel sowie Dachzeile und Vorspann aus ihren Attributen. Die Balken sind Kästen statt eines SVG, also ist ihre Füllung eine weitere Verknüpfung mit tint, die der Palette folgt; ein SVG müsste für jede Farbvariante neu gezeichnet und neu registriert werden. Für sich allein ließe der Bandkasten den Text auf der Rasterlinie 2,4 mm unter dem Band beginnen; minHeight, 18 Zeilen oder 85,1 mm unter dem oberen Rand, reserviert mehr und rückt die erste Zeile auf 7 mm darunter.
#4 · Die Palette die Tabelle füllen lassen
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') }; // cell fills are palette links too
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] }))]),
] } } }];
Das Raster ist eine Tabellenressource außerhalb der Konfiguration. Ihre Zellfüllungen tragen dennoch Paletten-IDs, und die Engine löst sie aus der Palette des Dokuments auf, wenn sie die Tabelle setzt; :swatch{color="band"} und :swatch{color="tint"} der Legende in der Tabellennotiz werden genauso aufgelöst. Die Chips in den Auflistungen verwenden dieselben zwei IDs, sodass ein gefülltes Quadrat, der Chip neben einer Uhrzeit und das Farbfeld der Legende in jeder Farbvariante übereinstimmen.
#5 · Die Palette tauschen und neu bauen
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]);
// The same config with another palette on every click; 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);
Jede Schaltfläche ruft retint() auf, das die Einträge der Farbvariante dem colorPalette des einen Konfigurationsobjekts zuweist, und baut neu. Die Engine löst eine Konfiguration einmal auf und speichert das Ergebnis zu diesem Objekt im Cache; seit postext 1.25 prüft jeder Build zuerst, ob das Objekt noch so lautet wie damals, sodass eine an Ort und Stelle gemachte Änderung erneut aufgelöst wird. Jede Verknüpfung folgt: Text, Listen, Chips und Legendenbalken, und mit ihnen Band, Wellenform, Kolumnentitel, Paginapille und „Table 1“. Die vier Andrucke behalten ihre Farben, wenn sich die Palette erneut ändert, weil jedes Dokument die Farben enthält, mit denen es gesetzt wurde. Der Pen baut sie mit buildDocumentWithFonts; wenn eine Schaltfläche angeklickt wird, sind die Schriften geladen, und buildDocument genügt.
Das ganze Rezept
// ═══ 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.25.0 import { buildDocument, buildDocumentWithFonts, renderPageToCanvas, 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 house edition: red // #endregion // #region answer: every colour is a link to a palette entry; retint() swaps the palette // col(id) links a colour to a palette entry. The link decides what is printed, in text, lists, // boxes, chips, captions, table fills, swatches, design slots and references alike; the hex // beside it (the house red's) only fills the field a colour must have. 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 // A colourway is a palette. retint() writes it into the one config; the next build sees the // change and sets the whole programme in the new colours. function retint(way) { const palette = { ...NEUTRALS, ...COLOURWAYS[way] }; config.colorPalette = entries({ ...palette, 'main-color': palette.band }); // defaults: band return config; } // #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'): one more link the palette decides 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 }), text('standfirst', '{attr.standfirst}', INNER, 71, 92, { fontFamily: SANS, fontSize: pt(10), lineHeight: 1.36 }), ] }, }; // #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: [ // The programme opens on a recto. { 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 = { // one object for every edition: retint() changes its palette in place locale: t({ en: 'en-us', es: 'es' }), // the programme's language, and its bundle's // The built-in types in the edition's language, renumbered: "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`---Markdown-Beispiel · 51 Zeilen · content.en.md
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') }; // cell fills are palette links too 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 [axis, n, width, gap] = [84, 29, 1.6, 0.95]; // mm; axis: the waterline const x0 = TRIM - OUTER - (n - 1) * (width + gap) - width; // the last bar ends at the margin 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 ─────────────────────────────────────────────────────── const TITLE = t({ en: 'One programme, four palettes', es: 'Un programa, cuatro paletas' }); const build = (way) => buildDocumentWithFonts({ markdown, resources }, retint(way), kitFonts(FONTS)); 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 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]); // The same config with another palette on every click; 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); // #endregionKit · core, fonts, viewer: in jedem Rezept gleich · 224 Zeilen
// ─── 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 v3 ── the same in every recipe · postext.dev/cookbook // Postext loads the faces a config asks for before layout (prepareFonts, // buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the // files the PDF embeds too. /** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts: * the Fontsource resolver, and the faces of FONTS loaded with the ones the * config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */ function kitFonts(FONTS = {}) { const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }))); return { resolve: fontsourceResolver, faces }; } /** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek * when `text` needs them and the family ships them (kitSubsetsFor), each with * its unicode-range. Null for a weight or slant Fontsource does not ship. */ async function fontsourceResolver(family, weight, style, { text = '' } = {}) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null; kitStatus('Loading fonts…'); const at = await fontsourceAt(id); return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({ source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`, unicodeRange: kitRanges()[subset], })); } /** The unicode-range of each Fontsource file the kit loads (a function: the * kit is inlined after the pen's code, which runs before a const would be set). */ function kitRanges() { return { 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', }; } /** For the cjk, arabic and comics blocks, which add their own files: the * faces of `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. * The files are added once all have loaded, in the order asked (latin, then * latin-ext, then greek), whatever order they arrive in: the last one added * wins a character two files hold. Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = kitRanges(); const jobs = []; 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 at = fontsourceAt(id); 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 = `${await at}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().catch(() => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return ready.filter((face) => face && document.fonts.add(face)).length; } /** 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, '-'); } /** A package's folder at the one version @5 names now (#766). */ function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) { return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json()) .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`); } /** 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 v2 ── 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. Other options go to renderPageToCanvas. */ function showPages(docs, { title, width = 460, ...render } = {}) { 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, { ...render, 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 ───────────────────────────────────────────────────────────────────────
/* 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; }
}
Das zusammengesetzte script.js läuft unverändert: Fügen Sie es in das Modulskript einer beliebigen Seite ein, oder öffnen Sie das Rezept auf CodePen. Ordner des Rezepts auf GitHub ↗ (öffnet in neuem Tab)
Varianten
#Fettschrift in Schwarz halten
Überlassen Sie den Akzent den Überschriften, Aufzählungszeichen und Chips, und setzen Sie Uhrzeiten und Schlüsselbegriffe in der Textfarbe.
- boldColor: col('deep'), referenceColor: col('deep'), textAlign: 'left', firstLineIndent: pt(0) };
+ boldColor: col('ink'), referenceColor: col('deep'), textAlign: 'left', firstLineIndent: pt(0) };#Jedem Teil eines Buchs eine eigene Farbe geben
Für Farben, die sich innerhalb eines Dokuments von Teil zu Teil ändern, siehe Teile in Farbe aus einem einzigen Attribut.
Häufige Fehler
- Ein Bild behält die Farben, mit denen es gezeichnet wurde. Ein SVG oder ein Foto folgt der Palette nicht, deshalb besteht die Wellenform aus Gestaltungskästen.
- Eine ID, die die Palette nicht enthält, fällt auf den daneben geschriebenen Hexwert zurück. Vertippen Sie sich bei einer
paletteId, bleibt diese Farbe in jeder Farbvariante im Hausrot.
Nachweise
- Rezept
- Ignacio Ferro
- Text
- Eigener Text, CC BY 4.0
- Schriften
- Syne (SIL OFL 1.1) · Plus Jakarta Sans (SIL OFL 1.1)
- Code
- MIT, wie Postext
Diesen Text bearbeiten ↗ (öffnet in neuem Tab)Ordner des Rezepts auf GitHub ↗ (öffnet in neuem Tab)


