Een dating-app op basis van brieven, niet van gezichten!
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
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/<sleutel>.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 = "" }) => `<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
${robots ? `<meta name="robots" content="${esc(robots)}">\n` : ""}<title>${esc(title)}</title>
<style>
${fontCss(mode)}
${TOKENS_CSS}
${BASIS_CSS}${css}</style>
</head>
<body>
${body}
</body>
</html>`;
/* ---------- 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: `<main class="vel">
<hr class="airmail">
<div class="vel-in">
${body}
</div>
</main>`,
});