#!/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) => `${WORDMARK.letters.map((l, i) => `${l.star ? `` : ""}`).join("")}`; 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");