Een dating-app op basis van brieven, niet van gezichten!

main

Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.

15.8 kB Ruw bestand
/*
 * 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");