Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/ci-paths.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
"turbo.json",
"knip.jsonc",
"scripts/*.mjs",
"scripts/brand/*.mjs",
"scripts/check-ui-wildcard-exports.test.sh",
".github/workflows/mobile-verify.yml",
".github/workflows/ci.yml",
Expand Down Expand Up @@ -107,6 +108,14 @@
"packages/views/editor/utils/text-preview-whitelist.generated.ts",
"packages/core/paths/reserved-slugs.ts",
"server/cmd/server/router.go",
"scripts/brand/**",
"packages/ui/lib/multica-brand.ts",
"apps/mobile/components/brand/multica-logo.tsx",
"server/cmd/multica/cmd_auth.go",
"apps/web/public/favicon.svg",
"apps/web/public/icons/icon.svg",
"apps/docs/app/icon.svg",
"docs/assets/logo-*.svg",
".github/workflows/ci.yml",
".github/ci-paths.json",
"scripts/ci-scope.mjs",
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -924,6 +924,13 @@ jobs:
node scripts/generate-attachment-text-preview.mjs
git diff --exit-code -- packages/views/editor/utils/text-preview-whitelist.generated.ts

- name: Verify brand copies match multica-brand.ts
# The mobile wordmark, the CLI login page and the logo SVGs are text
# copies of packages/ui/lib/multica-brand.ts; bitmaps are not rendered here.
run: |
node scripts/brand/sync.mjs --check
node --test scripts/brand/sync.test.mjs

- name: Setup Helm
uses: azure/setup-helm@v4

Expand Down
8 changes: 6 additions & 2 deletions apps/mobile/components/brand/multica-logo.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/**
* Multica wordmark. Vector copy of WORDMARK in packages/ui/lib/multica-brand.ts
* (mobile does not depend on @multica/ui) — keep the two in sync.
* (mobile does not depend on @multica/ui), written by `node scripts/brand/sync.mjs`;
* CI fails when the two drift.
*
* react-native-svg does not resolve CSS `currentColor`, so callers must pass
* `color` explicitly. For theme-aware usage, pair with `useColorScheme` +
Expand All @@ -10,7 +11,9 @@ import Svg, { G, Path } from "react-native-svg";
import { THEME } from "@/lib/theme";
import { useColorScheme } from "@/lib/use-color-scheme";

// BEGIN GENERATED by scripts/brand/sync.mjs from WORDMARK in packages/ui/lib/multica-brand.ts
const VIEW_BOX = { x: 0, y: -39.5, w: 421.36, h: 115.5 };
const STROKE = 16;
const LETTERS = [
"M8 76V31A23 23 0 0 1 54 31V76M54 31A23 23 0 0 1 100 31V76",
"M128 0V45A23 23 0 0 0 174 45M174 0V76",
Expand All @@ -23,6 +26,7 @@ const LETTERS = [
/** The star on the i, filled. */
const STAR =
"M274.92 -23L274.92 -38.739A16 16 0 0 1 280.68 -38.739L280.68 -23ZM275.764 -25.036L286.892 -36.165A16 16 0 0 1 290.965 -32.092L279.836 -20.964ZM277.8 -25.88L293.539 -25.88A16 16 0 0 1 293.539 -20.12L277.8 -20.12ZM279.836 -25.036L290.965 -13.908A16 16 0 0 1 286.892 -9.835L275.764 -20.964ZM280.68 -23L280.68 -7.261A16 16 0 0 1 274.92 -7.261L274.92 -23ZM279.836 -20.964L268.708 -9.835A16 16 0 0 1 264.635 -13.908L275.764 -25.036ZM277.8 -20.12L262.061 -20.12A16 16 0 0 1 262.061 -25.88L277.8 -25.88ZM275.764 -20.964L264.635 -32.092A16 16 0 0 1 268.708 -36.165L279.836 -25.036Z";
// END GENERATED

interface MulticaLogoProps {
/** Rendered height in points; width follows the wordmark's aspect ratio. */
Expand All @@ -42,7 +46,7 @@ export function MulticaLogo({ size = 24, color }: MulticaLogoProps) {
viewBox={`${VIEW_BOX.x} ${VIEW_BOX.y} ${VIEW_BOX.w} ${VIEW_BOX.h}`}
accessibilityLabel="Multica"
>
<G fill="none" stroke={resolvedColor} strokeWidth={16}>
<G fill="none" stroke={resolvedColor} strokeWidth={STROKE}>
{LETTERS.map((d) => (
<Path key={d} d={d} />
))}
Expand Down
4 changes: 2 additions & 2 deletions knip.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@

"workspaces": {
".": {
"entry": ["scripts/*.mjs"],
"project": ["scripts/*.mjs"]
"entry": ["scripts/*.mjs", "scripts/brand/*.mjs"],
"project": ["scripts/*.mjs", "scripts/brand/*.mjs"]
},

"apps/web": {
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"check:ui-radii": "node scripts/check-ui-radius-tokens.mjs",
"clean": "turbo clean && rm -rf node_modules",
"ui:add": "cd packages/ui && npx shadcn@latest add",
"generate:brand": "node scripts/brand/generate.mjs",
"generate:attachment-text-preview": "node scripts/generate-attachment-text-preview.mjs",
"generate:reserved-slugs": "node scripts/generate-reserved-slugs.mjs"
},
Expand Down
4 changes: 4 additions & 0 deletions packages/ui/lib/multica-brand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
* centre-lines with a constant stroke, and the star as the dot on its i. Lifted
* out on its own, that star is the icon. Units are the 100×100 star grid unless
* stated otherwise; do not tweak these numbers by eye, they are the spec.
*
* After changing anything here, run `pnpm generate:brand` (scripts/brand/README.md):
* the icons, favicons, OG image, README logos, the mobile wordmark and the CLI
* login page are all generated from this file.
*/

const r3 = (n: number) => Math.round(n * 1000) / 1000;
Expand Down
69 changes: 69 additions & 0 deletions scripts/brand/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Brand assets

Every logo file in the repo is generated from `packages/ui/lib/multica-brand.ts`.
After editing that file, regenerate and commit what changed:

```sh
pnpm generate:brand
```

On an unchanged `multica-brand.ts` the command normally leaves the working
tree clean, byte for byte. GPU rasterisation occasionally moves a single pixel
by one step in one channel. A bitmap showing up as modified when the geometry
didn't change is that noise. Discard it with `git checkout -- <file>`.

## Prerequisites

Run it on macOS. The committed bitmaps were rendered there. Other platforms
are untested, and they skip `icon.icns` because `iconutil` is macOS-only.

- Node 22.18 or later, for `.ts` type stripping, with `pnpm install` done.
- Playwright's Chromium: `pnpm exec playwright install chromium`. The script
uses the full Chrome for Testing build (`channel: "chromium"`), which
rasterises on the GPU. The default headless shell, or `--disable-gpu`,
rasterises in software and changes every bitmap.
- Python 3 with Pillow (`pip install pillow`), for `icon.ico` and the GIFs.
- `oxipng` (`brew install oxipng`).
- `iconutil` for `icon.icns`, which ships with macOS.

## What it writes

`sync.mjs` writes the text copies. It needs no dependencies, so CI runs it
with `--check` and fails when a copy drifts from `multica-brand.ts`.

- `apps/web/public/favicon.svg`, `apps/docs/app/icon.svg`: favicon, with a dark-mode variant
- `apps/web/public/icons/icon.svg`: full-bleed source for the home-screen icons
- `docs/assets/logo-{light,dark}.svg`: the README wordmark
- `apps/mobile/components/brand/multica-logo.tsx`: the block between the `GENERATED` markers. Mobile does not depend on `@multica/ui`.
- `server/cmd/multica/cmd_auth.go`: the `.brand` SVG on the `multica login` callback page

`generate.mjs` runs `sync.mjs`, then renders the bitmaps in Chromium:

| File | Size | Shape |
| --- | --- | --- |
| `apps/desktop/build/icons/*.png`, `build/icon.png` | 16–1024 | tile with transparent margin |
| `apps/desktop/build/icon.ico` | 16–256 | same tiles, packed by Pillow |
| `apps/desktop/resources/icon.png`, `build/icon.icns` | 16–1024 | Apple's 1024 grid (body 100–924) with a soft shadow |
| `apps/web/public/icons/icon-{192,512}.png` | 192, 512 | PWA tile with transparent margin |
| `apps/web/public/icons/apple-touch-icon.png`, `icon-maskable-512.png` | 180, 512 | full-bleed, opaque |
| `apps/mobile/assets/icon.png` | 1024 | full-bleed, opaque |
| `apps/web/app/opengraph-image.png` | 1200×630 | OG card, Inter from `@fontsource-variable/inter` |
| `docs/assets/logo-{light,dark}.gif` | 720×256 | README write-on, 30 fps, 64 colours |

At 24px and below, the star grows and thickens (`iconStar`). The margins in
`FLAT` follow the existing desktop sizes. Every PNG then goes through
`oxipng -o max --strip safe`.

## Recording the GIF

The write-on timings mirror `MulticaWordmark` (`multica-wordmark.tsx`) and its
keyframes in `packages/ui/styles/base.css`. Change all three together.

Frames are captured by pausing every animation and seeking it to each frame's
time. Do not use CDP virtual time instead: transform animations, like the star
spinning in, do not advance under it, so the star never appears.

After each seek, wait two animation frames before taking the screenshot. The
star's transform reaches the compositor late. Captured sooner, a few frames of
the spin keep pixels from the previous frame, and the GIF differs from run to
run.
120 changes: 120 additions & 0 deletions scripts/brand/generate.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
#!/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(`<html><head><style>${fonts}*{margin:0}html,body{width:${w}px;height:${h}px;overflow:hidden;background:transparent}svg{display:block}</style></head><body>${body}</body></html>`);
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) => `<div style="position:absolute;left:${off}px;top:${off}px;width:${px}px;height:${px}px">${svg}</div>`;

// 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 `<div style="position:absolute;left:${off}px;top:${off}px;width:${px}px;height:${px}px;filter:drop-shadow(0 ${(22 * k).toFixed(2)}px ${(30 * k).toFixed(2)}px rgba(0,0,0,.32))">${tile(px)}</div>`;
};
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));
}

await png(repo("apps/web/app/opengraph-image.png"), [1200, 630], `<div style="position:relative;width:1200px;height:630px;background:${INK};overflow:hidden;font-family:Inter">
<svg viewBox="0 0 100 100" style="position:absolute;width:780px;height:780px;right:-250px;bottom:-90px"><path fill="#1f1f22" d="${starPath(STAR_GRAPHIC_STROKE)}"/></svg>
<div style="position:absolute;left:64px;top:98px;height:52px">${wordmark(PAPER, ` height="52"`)}</div>
<div style="position:absolute;left:64px;bottom:76px;color:#fff;font:600 66px/1.12 Inter;letter-spacing:-.02em">People and agents,<br>working as one team.</div></div>`, { 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) => `<svg viewBox="${WORDMARK.viewBox}" width="643" style="display:block"><g fill="none" stroke="${c}" stroke-width="${WORDMARK_STROKE}">${WORDMARK.letters.map((l, i) => `<path class="wl" pathLength="1" style="animation-delay:${START_MS + i * LETTER_MS}ms" d="${l.d}"/>${l.star ? `<path class="ws" style="animation-delay:${START_MS + STAR_AFTER_MS}ms" fill="${c}" stroke="none" d="${l.star}"/>` : ""}`).join("")}</g></svg>`;
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(`<html><head><style>*{margin:0}html,body{width:${W}px;height:${H}px;background:${bg};overflow:hidden}body{display:grid;place-items:center}${css}</style></head><body>${writeOn(fg)}</body></html>`);
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");
21 changes: 21 additions & 0 deletions scripts/brand/pack.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""Packs the frames scripts/brand/generate.mjs renders into icon.ico and the README GIFs.
Usage: python3 pack.py <render dir> <repo root>. Needs Pillow.
"""
import glob
import sys

from PIL import Image

out, root = sys.argv[1:3]

sizes = [16, 24, 32, 48, 64, 128, 256]
ico = [Image.open(f"{out}/ico-{s}.png").convert("RGBA") for s in sizes]
ico[-1].save(f"{root}/apps/desktop/build/icon.ico", format="ICO", sizes=[(s, s) for s in sizes], append_images=ico[:-1])

for name in ("light", "dark"):
frames = [Image.open(f).convert("RGB") for f in sorted(glob.glob(f"{out}/gif-{name}/f*.png"))]
# One palette for every frame, taken from the finished mark, so the strokes never shimmer.
palette = frames[-1].quantize(colors=64, method=Image.Quantize.MEDIANCUT)
gif = [f.quantize(palette=palette, dither=Image.Dither.NONE) for f in frames]
gif[0].save(f"{root}/docs/assets/logo-{name}.gif", save_all=True, append_images=gif[1:], duration=33, optimize=True, disposal=1)
Loading
Loading