import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import path from "node:path"; import { INK, RED, BLUE, PAPER, SKY, MUTE } from "../src/theme.js"; /* * De huisstijl van alles wat deze server zelf opmaakt. * * Er stonden drie stijlen naast elkaar. De publieke pagina's hadden het papier met de luchtpostrand, * maar systeemletters en een vast licht thema. Het rapport had de letters van de app en een donkere * modus in drie standen, maar zijn eigen papier. En de post had weer een eigen vel. Dit bestand is * de gemene deler — palet, letters, luchtpostrand, omslag — zodat er nog één plek is waar de * huisstijl staat en een kleur veranderen niet betekent dat je vier bestanden nagaat. * * Wat hier níet staat is de indeling. Een rapport is breed en vol tabellen, een verklaring is één * kolom om te lezen, een beheerpagina is een formulier: die verschillen horen bij die pagina's zelf. * En de post staat apart (server/mail-html.js), want een mailclient kent geen stylesheet en geen * variabelen — daar wordt ditzelfde palet in stijl-attributen uitgeschreven. * * Het palet komt uit src/theme.js, dezelfde kleuren als de app, met de donkere tegenhangers erbij. * De donkere modus staat in drie standen, en dat is met opzet: * * :root het lichte palet, altijd compleet. Nooit een kleur die alleen in een * media-blok bestaat: dan is er een stand waarin hij ontbreekt. * @media (prefers-color-scheme:dark) :root:not([data-theme="light"]) * het donkere palet voor wie zijn systeem donker heeft staan — tenzij * de pagina uitdrukkelijk licht is gezet. * :root[data-theme="dark"] dezelfde omslag, maar gezet in plaats van geraden. * * Kleur en achtergrond horen daarbij altijd als paar gezet te worden. Staat er alleen een * tekstkleur, dan komt er in de andere stand donkerblauw op bijna-zwart te staan, en dan leest * niemand het meer. */ export const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[ch]); /* ---------- Het palet ---------- */ /* * Zes namen komen rechtstreeks uit src/theme.js; de rest zijn de tussenstappen die je op een * pagina nodig hebt en op een telefoonscherm niet: een tweede inkt voor bijzin-tekst, een lijnkleur, * en drie blauwen voor staafjes die naast elkaar te onderscheiden moeten blijven — ook op een * scherm dat kleuren anders toont en voor wie kleurenblind is. */ export const LICHT = { paper: PAPER, panel: "#FFFFFF", "panel-2": SKY, ink: INK, "ink-2": "#46527A", mute: MUTE, rule: "#D3DAE8", accent: BLUE, alert: RED, b300: "#9DB2E8", b500: BLUE, b700: "#2B4693", track: "#E4E9F3", schaduw: "rgba(31,44,85,.08)", knop: BLUE, "knop-alarm": RED, "knop-ink": "#FFFFFF", }; export const DONKER = { paper: "#141B33", panel: "#1E2745", "panel-2": "#232D4E", ink: "#E4E9F4", "ink-2": "#B9C3DC", mute: "#8E99B5", rule: "#33406A", accent: "#6B8CE0", alert: "#DE6552", b300: "#40548F", b500: "#6B8CE0", b700: "#93AEEC", track: "#263052", schaduw: "rgba(0,0,0,.45)", /* * Deze drie draaien niet mee, en dat is geen vergetelheid. `accent` en `alert` worden in het * donker lichter omdat ze dán tekst zijn op een donkere ondergrond. Een knop is het omgekeerde: * een vlak met witte tekst erop. Het lichtere blauw en rood dragen die witte tekst niet — daar * zakt het contrast naar 2,6:1. Een gevulde knop houdt dus in beide standen dezelfde kleur; op * donker papier springt hij er nog steeds uit. */ knop: BLUE, "knop-alarm": RED, "knop-ink": "#FFFFFF", }; const vars = (t) => Object.entries(t).map(([k, v]) => `--${k}:${v}`).join(";"); /* * `color-scheme` hoort erbij: dat is wat de browser vertelt welke kant het op gaat voor de dingen * die wij niet opmaken — de schuifbalk, de rand van een invoerveld, het venster van een datumkiezer. */ export const TOKENS_CSS = `:root{color-scheme:light dark;${vars(LICHT)}} @media (prefers-color-scheme:dark){:root:not([data-theme="light"]){${vars(DONKER)}}} :root[data-theme="dark"]{color-scheme:dark;${vars(DONKER)}} :root[data-theme="light"]{color-scheme:light}`; /* ---------- De letters van de app, en hoe ze op een pagina komen ---------- */ const here = path.dirname(fileURLToPath(import.meta.url)); /* * De sleutel is de naam waaronder de webserver hem uitserveert (/lettertype/.woff2), het * pad wijst in node_modules. Twee manieren om ze aan een pagina te hangen: * * "insluiten" het losse bestand van `npm run health -- --html`: alles in één bestand, want dat * moet over een jaar nog openen zonder iets van buiten nodig te hebben. * "verwijzen" alles wat de server zelf uitserveert: dan is de pagina 15 KB in plaats van 170, * en haalt de browser de letters één keer op en bewaart ze een jaar. * * In de post gaat geen van beide mee. Een mailclient die een lettertype van deze server ophaalt, * vertelt daarmee wanneer en vanaf welk netwerk je de brief opende — dat is een leesbevestiging, en * die hoort er niet stilletjes in te zitten. De post gebruikt daarom systeemletters. */ export const FONT_FILES = { "spectral-400": "@fontsource/spectral/files/spectral-latin-400-normal.woff2", "spectral-600": "@fontsource/spectral/files/spectral-latin-600-normal.woff2", "plex-mono-400": "@fontsource/ibm-plex-mono/files/ibm-plex-mono-latin-400-normal.woff2", "bricolage": "@fontsource-variable/bricolage-grotesque/files/bricolage-grotesque-latin-wght-normal.woff2", }; export const fontPath = (key) => FONT_FILES[key] ? path.join(here, "..", "node_modules", FONT_FILES[key]) : null; const FACES = [ ["Spectral", "400", "spectral-400"], ["Spectral", "600", "spectral-600"], ["IBM Plex Mono", "400", "plex-mono-400"], ["Bricolage Grotesque", "200 800", "bricolage"], ]; /* Ontbreekt een bestand, dan slaan we het over: liever systeemletters dan een kapotte pagina */ export function fontCss(mode) { const src = (key) => { if (mode === "verwijzen") return `url(/lettertype/${key}.woff2) format('woff2')`; try { return `url(data:font/woff2;base64,${readFileSync(fontPath(key)).toString("base64")}) format('woff2')`; } catch { return null; } }; return FACES.map(([family, weight, key]) => { const s = src(key); return s && `@font-face{font-family:'${family}';font-weight:${weight};font-style:normal;font-display:swap;src:${s}}`; }).filter(Boolean).join("\n"); } export const SERIF = "'Spectral',Georgia,serif"; export const SANS = "'Bricolage Grotesque',system-ui,sans-serif"; export const MONO = "'IBM Plex Mono',ui-monospace,monospace"; /* ---------- Wat elke pagina deelt ---------- */ /* * De luchtpostrand. `--edge-gap` is de kleur van de tussenruimte: op volvlaks papier is dat het * papier, op een wit vel het vel. Zonder die knop staat er op het vel een rand met vier strepen * papierkleur erin, en dat valt op als een fout. */ export const BASIS_CSS = ` *{box-sizing:border-box} body{margin:0;background:var(--paper);color:var(--ink);font-family:${SERIF};font-size:17px; line-height:1.62;-webkit-font-smoothing:antialiased} h1,h2,h3{font-weight:600;text-wrap:balance;margin:0} a{color:var(--accent)} strong,b{font-weight:600} .airmail{height:6px;border:0;background:repeating-linear-gradient(-45deg, var(--alert) 0 10px,var(--edge-gap,var(--paper)) 10px 20px, var(--accent) 20px 30px,var(--edge-gap,var(--paper)) 30px 40px)} .mute{color:var(--mute)} .mono{font-family:${MONO};font-size:.86em} .eyebrow{font-family:${SANS};font-weight:600;font-size:.70rem;letter-spacing:.14em; text-transform:uppercase;color:var(--mute)} .num{font-family:${MONO};font-variant-numeric:tabular-nums} code{font-family:${MONO};font-size:.86em;background:var(--panel-2);padding:1px 5px;border-radius:2px;color:var(--ink)} :focus-visible{outline:2px solid var(--accent);outline-offset:2px} @media print{.airmail{-webkit-print-color-adjust:exact;print-color-adjust:exact}} `; /* * Het omhulsel dat elke pagina deelt: dezelfde letters, hetzelfde palet, dezelfde donkere modus. * Wat een pagina daarbovenop nodig heeft geeft ze mee in `css`. */ export const omslag = ({ title, mode = "verwijzen", css = "", body, robots = "" }) => ` ${robots ? `\n` : ""}${esc(title)} ${body} `; /* ---------- Het vel: het papier van de leespagina's ---------- */ /* * Eén kolom op een wit vel met de rand erboven — de privacyverklaring, de voorpagina, de * kinderveiligheidsnormen, /wachtwoord en de beheerpagina van het promotieteam delen dit. Breedte * op 680px: dat is bij deze lettergrootte ruim zestig tekens, en daarboven raak je aan het eind van * een regel het begin van de volgende kwijt. */ export const VEL_CSS = ` body{padding:32px 18px 72px} .vel{--edge-gap:var(--panel);max-width:680px;margin:0 auto;background:var(--panel); box-shadow:0 0 40px var(--schaduw)} .vel-in{padding:30px 26px 42px} h1{font-size:clamp(1.7rem,4.4vw,2.3rem);line-height:1.12;letter-spacing:-.02em;margin:0 0 10px} h2{font-size:1.22rem;margin:32px 0 8px;letter-spacing:-.01em} p,li{margin:0 0 .8em} ul,ol{padding-left:1.2em;margin:0 0 1em} li:last-child,p:last-child{margin-bottom:0} `; export const page = (title, body, { css = "", robots = "" } = {}) => omslag({ title, robots, css: VEL_CSS + css, body: `

${body}
`, });