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.
/*
* Genereert app-icoon, splash-screen, web-favicon en BIMI-logo uit één bron:
* src/components/Envelope.jsx.
*
* node assets/build.mjs # SVG/HTML-bronnen → PNG's → Android-resources
* node assets/build.mjs --no-cap # alleen bronnen + PNG's (geen @capacitor/assets)
* node assets/build.mjs --only-sources # alleen assets/src/ (geen rasteren, niets buiten die map)
*
* Gebruikt de tools in ~/.claude/tools: imgconv (SVG → PNG via sharp) en webshot
* (splash met echte lettertypen via headless Chromium). Uitvoer:
* assets/src/*.svg|html bronnen (gecommit, handig om te bekijken/aan te passen), waaronder
* bimi.svg — het logo voor mailclients, zie hieronder
* assets/*.png invoer voor @capacitor/assets (icon-only, icon-foreground,
* icon-background, splash, splash-dark)
* public/favicon.svg, public/apple-touch-icon.png
* android/app/src/main/res/** via @capacitor/assets
*/
import { mkdir, writeFile } from "node:fs/promises";
import { spawnSync } from "node:child_process";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { createServer } from "vite";
import { renderToStaticMarkup } from "react-dom/server";
import { createElement as h } from "react";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const TOOLS = path.join(process.env.USERPROFILE ?? process.env.HOME, ".claude", "tools");
const withCap = !process.argv.includes("--no-cap");
const P = (...p) => path.join(root, ...p);
/* ---------- Envelope.jsx laden via Vite (JSX + theme-imports) ---------- */
const vite = await createServer({ root, server: { middlewareMode: true }, logLevel: "silent" });
const { default: Envelope } = await vite.ssrLoadModule("/src/components/Envelope.jsx");
const { INK, PAPER, MUTE, SKY } = await vite.ssrLoadModule("/src/theme.js");
await vite.close();
/* Envelop als losse <svg> string, gepositioneerd binnen een groter canvas */
const envelope = (x, y, width, id) =>
renderToStaticMarkup(h(Envelope, { width, id })).replace("<svg ", `<svg x="${x}" y="${y}" `);
const svg = (size, inner) =>
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}">\n${inner}\n</svg>\n`;
/* ---------- BIMI-logo (SVG Tiny 1.2, profiel "tiny-ps") ----------
* Het merkteken dat mailclients naast de afzender zetten als het domein een BIMI-record heeft.
* Tiny-ps is een smalle subset van SVG: vierkante viewBox, `version` en `baseProfile` op de
* wortel, een `<title>` als eerste kind — en géén geneste <svg>, <pattern>, <style>, script,
* animatie of externe verwijzing. Envelope.jsx gebruikt juist die eerste twee.
*
* Daarom wordt zijn markup hier omgebouwd in plaats van overgetypt; vorm en kleur blijven zo uit
* één bron komen. Wat er verandert:
* - de luchtpostrand wordt uitgerekend. Het pattern is een 45° gedraaide bandenbundel, dus een
* streep is `(x+y)/√2 ∈ [begin, begin+breedte]`: twee halfvlakken waarmee de omtrek van de
* envelop wordt afgeknipt tot één polygoon per streep. Het midden gaat daarna weer dicht met
* de binnenrand van diezelfde ring (de tweede deelroute van dat pad).
* - de halfdoorzichtige onderflappen krijgen de kleur die ze op het witte papier tóch al
* hadden, zodat er geen opacity meer in het bestand staat.
* Wijkt Envelope.jsx af van wat hier gezocht wordt, dan valt de build om in plaats van stilletjes
* een verkeerd logo te schrijven.
*/
const sub = (s, re, rep) => {
const out = s.replace(re, rep);
if (out === s) throw new Error(`BIMI: geen ${re} in de markup van Envelope.jsx`);
return out;
};
const grab = (s, re, wat) => {
const m = s.match(re);
if (!m) throw new Error(`BIMI: ${wat} niet gevonden in de markup van Envelope.jsx`);
return m;
};
/* kleur `hex` met dekking `t` op wit papier, als vlakke kleur */
const opWit = (hex, t) =>
"#" + [1, 3, 5].map((i) => Math.round(parseInt(hex.slice(i, i + 2), 16) * t + 255 * (1 - t)).toString(16).padStart(2, "0")).join("").toUpperCase();
const rond = (v) => String(Math.round(v * 10) / 10);
const bimiSvg = (size, width) => {
let body = renderToStaticMarkup(h(Envelope, { width: 640, id: "bimi" }));
/* streepmaten en -kleuren uit het pattern, vóór de <defs> sneuvelen */
const defs = grab(body, /<defs>[\s\S]*<\/defs>/, "<defs>")[0];
const tegel = Number(grab(defs, /<pattern[^>]*\bwidth="([\d.]+)"/, "de pattern-tegel")[1]);
const strepen = [...defs.matchAll(/<rect x="([\d.]+)"[^>]*\bwidth="([\d.]+)"[^>]*\bfill="([^"]+)"/g)];
if (!strepen.length) throw new Error("BIMI: geen strepen in het pattern van Envelope.jsx");
/* de naam die in de mailclient onder het logo hangt: dezelfde als het toegankelijkheidslabel */
const titel = grab(body, /^<svg[^>]*\baria-label="([^"]*)"/, "de aria-label van de envelop")[1];
body = sub(body, /^<svg[^>]*>/, "");
body = sub(body, /<\/svg>$/, "");
body = sub(body, /<defs>[\s\S]*<\/defs>/, "");
/* omtrek van de envelop als convexe veelhoek (hoeken in achtjes), uit de witte rect zelf */
const doek = grab(body, /<rect[^>]*\bwidth="([\d.]+)"[^>]*\bheight="([\d.]+)"[^>]*\brx="([\d.]+)"[^>]*\bfill="([^"]+)"/, "de envelop-rect");
const [W, H, R] = doek.slice(1, 4).map(Number);
const papier = doek[4];
const boog = (cx, cy, vanaf) => Array.from({ length: 9 }, (_, i) => {
const a = ((vanaf + (90 * i) / 8) * Math.PI) / 180;
return [cx + R * Math.cos(a), cy + R * Math.sin(a)];
});
const omtrek = [...boog(R, R, 180), ...boog(W - R, R, 270), ...boog(W - R, H - R, 0), ...boog(R, H - R, 90)];
/* afstand langs de normaal van de strepen (patternTransform rotate(45)) */
const u = ([px, py]) => (px + py) * Math.SQRT1_2;
/* Sutherland–Hodgman: houd wat aan f(p) <= 0 voldoet — de omtrek is convex, dus dat blijft één stuk */
const knip = (poly, f) => {
const uit = [];
poly.forEach((a, i) => {
const b = poly[(i + 1) % poly.length];
const [fa, fb] = [f(a), f(b)];
if (fa <= 0) uit.push(a);
if ((fa < 0) !== (fb < 0)) {
const t = fa / (fa - fb);
uit.push([a[0] + t * (b[0] - a[0]), a[1] + t * (b[1] - a[1])]);
}
});
return uit;
};
const band = (begin, breedte, fill) => {
const p = knip(knip(omtrek, (q) => begin - u(q)), (q) => u(q) - (begin + breedte));
return p.length < 3 ? "" : `<polygon fill="${fill}" points="${p.map(([px, py]) => `${rond(px)},${rond(py)}`).join(" ")}"/>`;
};
const rand = [];
for (let k = 0; tegel * k <= Math.max(...omtrek.map(u)); k++) {
for (const [, begin, breedte, fill] of strepen) rand.push(band(tegel * k + Number(begin), Number(breedte), fill));
}
/* ring met pattern → strepen + de binnenrand van diezelfde ring, in het papierwit dichtgelegd */
const ring = grab(body, /<path[^>]*fill="url\(#[^"]*\)"[^>]*><\/path>/, "de rand met het pattern")[0];
const binnen = grab(ring, /\bd="([^"]*)"/, "het pad van de rand")[1].split(/(?=M)/).slice(1).join(" ").trim();
if (!binnen) throw new Error("BIMI: de rand van Envelope.jsx heeft geen binnenrand meer");
body = body.replace(ring, rand.join("") + `<path d="${binnen}" fill="${papier}"/>`);
/* opacity → vlakke kleur (deze lijnen liggen volledig op het papier) */
body = sub(body, /<path[^>]*\bopacity="[\d.]+"[^>]*><\/path>/, (el) =>
el
.replace(/\bstroke="([^"]+)"/, (_, kleur) => `stroke="${opWit(kleur, Number(grab(el, /\bopacity="([\d.]+)"/, "opacity")[1]))}"`)
.replace(/ opacity="[\d.]+"/, ""));
/* gecentreerd; de halve diagonaal blijft ruim binnen de straal, dus de cirkeluitsnede van
Gmail en Apple Mail snijdt niets van de envelop af */
const s = width / W;
const [x, y] = [(size - width) / 2, (size - H * s) / 2];
return `<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="tiny-ps" viewBox="0 0 ${size} ${size}">
<title>${titel}</title>
<rect x="0" y="0" width="${size}" height="${size}" fill="${INK}"/>
<g transform="translate(${rond(x)} ${rond(y)}) scale(${s})">
${body.replace(/(<\/[a-z]+>|\/>)(?=<)/g, "$1\n")}
</g>
</svg>
`;
};
/* ---------- Bronnen ---------- */
const S = 1024;
/* plek van de envelop op het vierkante doek; ruim binnen de cirkel waarin mailclients bijsnijden */
const [EX, EY, EW] = [168, 286, 688];
const sources = {
// Volledig icoon (iOS / legacy Android / web): inkt-achtergrond, envelop gecentreerd
"icon-only.svg": svg(S, `<rect width="${S}" height="${S}" fill="${INK}"/>\n${envelope(EX, EY, EW, "icon")}`),
// Adaptive icon: voorgrond binnen de veilige zone (centrale 66%), achtergrond effen
"icon-foreground.svg": svg(S, envelope(240, 334, 544, "fg")),
"icon-background.svg": svg(S, `<rect width="${S}" height="${S}" fill="${INK}"/>`),
// Favicon: afgeronde hoeken, want browsers ronden zelf niet af
"favicon.svg": svg(S, `<rect width="${S}" height="${S}" rx="200" fill="${INK}"/>\n${envelope(EX, EY, EW, "fav")}`),
// BIMI: hetzelfde beeld als icon-only, maar binnen het tiny-ps-profiel en met een iets grotere
// envelop — dit logo wordt rond bijgesneden en soms op 24 px getoond
"bimi.svg": bimiSvg(S, 760),
};
/* relatief t.o.v. assets/src/, zodat de gegenereerde HTML machine-onafhankelijk is */
const font = (rel) => `../../node_modules/${rel}`;
const splashHtml = (bg, fg, sub) => `<!doctype html>
<meta charset="utf-8">
<style>
@font-face { font-family: Bricolage; src: url("${font("@fontsource-variable/bricolage-grotesque/files/bricolage-grotesque-latin-opsz-normal.woff2")}") format("woff2"); font-weight: 200 800; }
@font-face { font-family: Plex; src: url("${font("@fontsource/ibm-plex-mono/files/ibm-plex-mono-latin-400-normal.woff2")}") format("woff2"); }
html, body { margin: 0; }
#splash { width: 2732px; height: 2732px; background: ${bg}; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0; position: relative; }
h1 { margin: 110px 0 0; font: 700 210px/1 Bricolage, system-ui, sans-serif; letter-spacing: -0.02em; color: ${fg}; }
p { margin: 40px 0 0; font: 400 52px/1 Plex, monospace; letter-spacing: 0.08em; color: ${sub}; }
</style>
<div id="splash">
${envelope(0, 0, 900, "splash")}
<h1>Liefdesbrieven</h1>
<p>PAR AVION</p>
</div>
`;
await mkdir(P("assets", "src"), { recursive: true });
for (const [name, content] of Object.entries(sources)) await writeFile(P("assets", "src", name), content);
await writeFile(P("assets", "src", "splash.html"), splashHtml(PAPER, INK, MUTE));
await writeFile(P("assets", "src", "splash-dark.html"), splashHtml(INK, "#fff", SKY));
/* Previewblad: alles bij elkaar op één pagina (bekijken met webshot) */
await writeFile(P("assets", "src", "preview.html"), `<!doctype html><meta charset="utf-8">
<style>
body{margin:0;padding:40px;background:#ddd;font:14px system-ui;display:grid;grid-template-columns:auto auto;gap:40px;align-items:start}
.row{display:flex;gap:24px;align-items:flex-end}
img{display:block}
.mask{width:192px;height:192px;border-radius:50%;overflow:hidden;position:relative;background:${INK}}
.mask img{position:absolute;inset:0;width:100%;height:100%}
.sq{border-radius:22%;overflow:hidden}
figure{margin:0}figcaption{margin-top:6px;color:#333}
</style>
<figure><div class="row">
<img class="sq" src="icon-only.svg" width="192">
<img class="sq" src="icon-only.svg" width="96">
<img class="sq" src="icon-only.svg" width="48">
<img class="sq" src="icon-only.svg" width="24">
</div><figcaption>icon-only (afgerond zoals iOS/launchers dat doen)</figcaption></figure>
<figure><div class="row">
<div class="mask"><img src="icon-background.svg"><img src="icon-foreground.svg"></div>
<img src="../icon-only.png" width="96" style="border-radius:22%">
<img src="favicon.svg" width="32">
<img src="favicon.svg" width="16">
</div><figcaption>adaptive (cirkelmasker) · icon-only.png · favicon 32/16</figcaption></figure>
<figure><img src="../splash.png" width="420" style="border-radius:24px"><figcaption>splash.png</figcaption></figure>
<figure><img src="../splash-dark.png" width="420" style="border-radius:24px"><figcaption>splash-dark.png</figcaption></figure>
`);
if (process.argv.includes("--only-sources")) {
console.log("klaar (alleen assets/src/)");
process.exit(0);
}
/* ---------- Rasteren ---------- */
const run = (label, cmd, args, opts = {}) => {
const r = spawnSync(cmd, args, { stdio: "pipe", encoding: "utf8", shell: process.platform === "win32", ...opts });
if (r.status !== 0) { console.error(`✗ ${label}\n${r.stdout}\n${r.stderr}`); process.exit(1); }
console.log(`✓ ${label}`);
};
const imgconv = (input, out, size, extra = []) =>
run(`imgconv ${path.basename(out)} ${size}px`, "node", [path.join(TOOLS, "imgconv", "imgconv.js"), input, "--out", out, "--width", String(size), "--height", String(size), "--fit", "contain", "--bg", "transparent", ...extra]);
const webshot = (input, out) =>
run(`webshot ${path.basename(out)}`, "node", [path.join(TOOLS, "webshot", "webshot.js"), pathToFileURL(input).href, "--out", out, "--selector", "#splash", "--width", "2732", "--height", "2732", "--wait", "800"]);
imgconv(P("assets/src/icon-only.svg"), P("assets/icon-only.png"), 1024);
imgconv(P("assets/src/icon-foreground.svg"), P("assets/icon-foreground.png"), 1024);
imgconv(P("assets/src/icon-background.svg"), P("assets/icon-background.png"), 1024);
imgconv(P("assets/src/icon-only.svg"), P("public/apple-touch-icon.png"), 180);
await writeFile(P("public", "favicon.svg"), sources["favicon.svg"]);
webshot(P("assets/src/splash.html"), P("assets/splash.png"));
webshot(P("assets/src/splash-dark.html"), P("assets/splash-dark.png"));
/* ---------- Android-resources ---------- */
if (withCap) {
run("@capacitor/assets generate --android", "npx", ["@capacitor/assets", "generate", "--android", "--assetPath", "assets", "--iconBackgroundColor", INK, "--iconBackgroundColorDark", INK, "--splashBackgroundColor", PAPER, "--splashBackgroundColorDark", INK], { cwd: root });
/*
* @capacitor/assets zet de adaptive-icon-lagen op 48 dp (xxxhdpi = 192 px); een adaptive icon
* hoort 108 dp te zijn (xxxhdpi = 432 px). De launcher blaast ze anders op → wazig icoon.
* Daarom hier opnieuw op de juiste maat, uit dezelfde bronnen.
*/
const DP108 = { ldpi: 81, mdpi: 108, hdpi: 162, xhdpi: 216, xxhdpi: 324, xxxhdpi: 432 };
for (const [dpi, px] of Object.entries(DP108)) {
const dir = P("android", "app", "src", "main", "res", `mipmap-${dpi}`);
imgconv(P("assets/src/icon-foreground.svg"), path.join(dir, "ic_launcher_foreground.png"), px);
imgconv(P("assets/src/icon-background.svg"), path.join(dir, "ic_launcher_background.png"), px);
}
/*
* @capacitor/assets zet in de adaptive-icon-XML een inset van 16,7% op béide lagen: de
* achtergrond krijgt dan een transparante (zwart getoonde) rand en de envelop wordt dubbel
* klein (de SVG houdt de veilige zone al aan). Daarom de XML zelf schrijven: effen inkt-
* achtergrond over de hele laag, voorgrond zonder inset.
*/
const adaptive = `<?xml version="1.0" encoding="utf-8"?>
<!-- Adaptive icon: effen inkt-achtergrond over de hele laag, envelop-voorgrond zonder extra inset
(icon-foreground.svg houdt zelf al de veilige zone aan). Wordt door assets/build.mjs geschreven. -->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
`;
for (const name of ["ic_launcher.xml", "ic_launcher_round.xml"]) {
await writeFile(P("android", "app", "src", "main", "res", "mipmap-anydpi-v26", name), adaptive);
}
await writeFile(P("android", "app", "src", "main", "res", "values", "ic_launcher_background.xml"),
`<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Achtergrond van het adaptive icon: postinkt-blauw (INK in src/theme.js) -->
<color name="ic_launcher_background">${INK}</color>
</resources>
`);
console.log("✓ adaptive-icon XML (zonder inset)");
}
console.log("klaar");