#!/usr/bin/env node
// Rebuilds every brand asset from packages/ui/lib/multica-brand.ts. Prerequisites
// and the list of outputs: scripts/brand/README.md.
//
// node scripts/brand/generate.mjs
import { chromium } from "@playwright/test";
import { spawnSync } from "node:child_process";
import { mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { INK, PAPER, loadBrand, svgs, sync } from "./sync.mjs";
const here = dirname(fileURLToPath(import.meta.url));
const root = resolve(here, "../..");
const brand = await loadBrand(root);
const { WORDMARK, WORDMARK_STROKE, STAR_GRAPHIC_STROKE, starPath } = brand;
const { tile, wordmark } = svgs(brand);
function run(cmd, args) {
const r = spawnSync(cmd, args, { stdio: "inherit" });
if (r.error?.code === "ENOENT") throw new Error(`${cmd} not found; see scripts/brand/README.md`);
if (r.status !== 0) throw new Error(`${cmd} ${args.join(" ")} exited ${r.status}`);
}
const out = mkdtempSync(join(tmpdir(), "multica-brand-"));
const repo = (rel) => join(root, rel);
const pngs = [];
const updated = await sync(root);
if (updated.length) console.log(`updated ${updated.join(", ")}`);
const inter = createRequire(repo("apps/desktop/package.json")).resolve("@fontsource-variable/inter/files/inter-latin-wght-normal.woff2");
const fonts = `@font-face{font-family:"Inter";font-weight:100 900;src:url(data:font/woff2;base64,${readFileSync(inter).toString("base64")}) format("woff2")}`;
// Full Chrome for Testing, which rasterises on the GPU like the committed bitmaps.
// The default headless shell (and --disable-gpu) rasterises in software and
// antialiases differently, so every bitmap would change.
const browser = await chromium.launch({ channel: "chromium" });
try {
const page = await browser.newPage({ deviceScaleFactor: 1 });
async function png(file, [w, h], body, { alpha = true } = {}) {
mkdirSync(dirname(file), { recursive: true });
await page.setViewportSize({ width: w, height: h });
await page.setContent(`
${body}`);
await page.evaluate(() => document.fonts.ready);
await page.screenshot({ path: file, omitBackground: alpha });
if (file.startsWith(root)) pngs.push(file);
}
const at = (svg, px, off) => `${svg}
`;
// Desktop and PWA tiles: [tile px, margin] per canvas size.
const FLAT = { 16: [16, 0], 24: [24, 0], 32: [30, 1], 48: [46, 1], 64: [52, 6], 128: [104, 12], 256: [208, 24], 512: [412, 50] };
for (const [s, [px, off]] of Object.entries(FLAT)) {
await png(repo(`apps/desktop/build/icons/${s}x${s}.png`), [+s, +s], at(tile(px), px, off));
if (+s <= 256) await png(join(out, `ico-${s}.png`), [+s, +s], at(tile(px), px, off));
}
await png(repo("apps/desktop/build/icon.png"), [1024, 1024], at(tile(824), 824, 100));
await png(repo("apps/web/public/icons/icon-192.png"), [192, 192], at(tile(156), 156, 18));
await png(repo("apps/web/public/icons/icon-512.png"), [512, 512], at(tile(412), 412, 50));
// iOS, Android and Expo mask these themselves, so they are full-bleed and opaque.
await png(repo("apps/web/public/icons/apple-touch-icon.png"), [180, 180], tile(180, { bleed: true }), { alpha: false });
await png(repo("apps/web/public/icons/icon-maskable-512.png"), [512, 512], tile(512, { bleed: true }), { alpha: false });
await png(repo("apps/mobile/assets/icon.png"), [1024, 1024], tile(1024, { bleed: true }), { alpha: false });
// macOS: the tile on Apple's 1024 grid (body 100..924) with the standard soft shadow.
const macOS = (S) => {
const k = S / 1024, px = Math.round(824 * k), off = Math.round(100 * k);
return `${tile(px)}
`;
};
await png(repo("apps/desktop/resources/icon.png"), [1024, 1024], macOS(1024));
const iconset = join(out, "icon.iconset");
for (const [name, S] of [["16x16", 16], ["16x16@2x", 32], ["32x32", 32], ["32x32@2x", 64], ["128x128", 128], ["128x128@2x", 256], ["256x256", 256], ["256x256@2x", 512], ["512x512", 512], ["512x512@2x", 1024]]) {
await png(join(iconset, `icon_${name}.png`), [S, S], macOS(S));
}
// OG card: the wordmark and headline beside a team of thin stars, one per
// teammate. The lit one is the newest to join.
const TEAM = { cols: 3, rows: 4, size: 96, gap: 36, right: 76, lit: 7 };
const pitch = TEAM.size + TEAM.gap;
const teamLeft = 1200 - TEAM.right - (TEAM.cols * pitch - TEAM.gap);
const teamTop = (630 - (TEAM.rows * pitch - TEAM.gap)) / 2;
const thinStar = starPath(STAR_GRAPHIC_STROKE);
const team = Array.from({ length: TEAM.cols * TEAM.rows }, (_, i) =>
``).join("");
await png(repo("apps/web/app/opengraph-image.png"), [1200, 630], `
${team}
${wordmark(PAPER, ` height="52"`)}
People and agents,
working as one team.
`, { alpha: false });
await page.close();
// README hero: the write-on from MulticaWordmark (timings in multica-wordmark.tsx,
// keyframes in packages/ui/styles/base.css). Transform animations do not advance
// under CDP virtual time, so every animation is paused and seeked per frame. The
// seeked transform reaches the compositor a frame or two later; capturing sooner
// leaves a few star pixels from the previous frame, differently on every run.
const W = 720, H = 256, FPS = 30, TOTAL_MS = 2500, START_MS = 100, LETTER_MS = 150, STAR_AFTER_MS = 6 * LETTER_MS + 420;
const css = `@keyframes write { from { stroke-dashoffset: 1; } }
@keyframes star-in { from { transform: scale(0) rotate(-135deg); } }
.wl { stroke-dasharray: 1 1; animation: write .5s cubic-bezier(.65,0,.35,1) both; }
.ws { transform-box: fill-box; transform-origin: 50% 50%; animation: star-in .9s cubic-bezier(.34,1.56,.64,1) backwards; }`;
const writeOn = (c) => ``;
for (const [name, bg, fg] of [["light", PAPER, INK], ["dark", "#0d1117", PAPER]]) {
const dir = join(out, `gif-${name}`);
mkdirSync(dir);
const gif = await browser.newPage({ viewport: { width: W, height: H }, deviceScaleFactor: 1 });
await gif.setContent(`${writeOn(fg)}`);
await gif.evaluate(() => document.getAnimations().forEach((a) => a.pause()));
for (let k = 0; k < Math.round((TOTAL_MS / 1000) * FPS); k++) {
await gif.evaluate(async (t) => {
document.getAnimations().forEach((a) => { a.currentTime = t; });
for (let i = 0; i < 2; i++) await new Promise(requestAnimationFrame);
}, (k * 1000) / FPS);
await gif.screenshot({ path: join(dir, `f${String(k).padStart(4, "0")}.png`) });
}
await gif.close();
}
} finally {
await browser.close();
}
run("python3", [join(here, "pack.py"), out, root]);
if (process.platform === "darwin") run("iconutil", ["-c", "icns", join(out, "icon.iconset"), "-o", repo("apps/desktop/build/icon.icns")]);
else console.warn("iconutil is macOS-only: apps/desktop/build/icon.icns was not rebuilt");
run("oxipng", ["-o", "max", "--strip", "safe", "-q", ...pngs]);
rmSync(out, { recursive: true, force: true });
console.log("done");