Skip to content
1 change: 1 addition & 0 deletions .fallowrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"packages/producer/we-render.mjs",
"packages/producer/scripts/validate-fast-video.ts",
"packages/cli/scripts/generate-font-data.ts",
"packages/studio/scripts/icon-specimen.tsx",
"packages/engine/scripts/test-fitTextFontSize-browser.ts",
"packages/aws-lambda/scripts/*.ts",
// Built as standalone IIFE for the browser-side sandbox runtime;
Expand Down
172 changes: 172 additions & 0 deletions packages/studio/scripts/icon-specimen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
// Renders the icon specimen sheet from the module itself, so the sheet cannot
// drift from the code. Usage: bun scripts/icon-specimen.tsx <out.html>
import { readFileSync, writeFileSync } from "node:fs";
import { renderToStaticMarkup } from "react-dom/server";
import * as Ph from "@phosphor-icons/react";
import { Icon, ICON_NAMES, type IconName } from "../src/icons";

// The 20 most-used Studio icons today, Phosphor glyph beside ours.
const TODAY: [IconName, Ph.Icon][] = [
["chevron-down", Ph.CaretDown],
["x", Ph.X],
["plus", Ph.Plus],
["check", Ph.Check],
["trash", Ph.Trash],
["eye", Ph.Eye],
["eye-off", Ph.EyeSlash],
["copy", Ph.Copy],
["film", Ph.FilmStrip],
["layers", Ph.Stack],
["music", Ph.MusicNote],
["type", Ph.TextT],
["settings", Ph.Gear],
["undo", Ph.ArrowCounterClockwise],
["redo", Ph.ArrowClockwise],
["camera", Ph.Camera],
["scissors", Ph.Scissors],
["magnet", Ph.Magnet],
["warning", Ph.Warning],
["pencil", Ph.PencilSimple],
];

// The specimen paints with Studio's own tokens, read off theme.css so the two cannot drift.
const THEME = readFileSync(new URL("../src/styles/theme.css", import.meta.url), "utf8");
const TOKENS = [...THEME.matchAll(/(--color-[\w-]+):\s*([^;]+);/g)]
.map(([, k, v]) => `${k}:${v}`)
.join(";");

const SIZES = [12, 14, 16, 20] as const;
const STATES = [
["enabled", "var(--color-text-2)"],
["hover", "var(--color-text-0)"],
["active", "var(--color-accent)"],
["disabled", "var(--color-text-4)"],
] as const;
const DISTINCT: IconName[][] = [
["scissors", "split", "razor"],
["keyframe", "keyframe-auto", "marker", "beat"],
["snap", "magnet"],
["eye", "eye-off"],
["link", "unlink"],
["undo", "redo", "rotate-cw", "rotate-ccw", "loop"],
["sidebar-show", "sidebar-hide", "inspector", "window"],
["copy", "clipboard"],
["layers", "bring-forward", "send-backward", "bring-to-front", "send-to-back"],
["download", "upload"],
["group", "ungroup"],
["file", "file-code", "file-image", "file-video", "file-audio", "file-text", "file-font"],
];

const svg = (name: IconName, size: number, filled = false) =>
renderToStaticMarkup(<Icon name={name} size={size} filled={filled} />);

const cell = (inner: string, label = "") =>
`<div class="cell">${inner}${label ? `<span>${label}</span>` : ""}</div>`;

const distinctRows = DISTINCT.map(
(group) => `<div class="row">${group.map((n) => cell(svg(n, 12), n)).join("")}</div>`,
).join("");

const sizeRows = ICON_NAMES.map(
(n) =>
`<div class="row"><b>${n}</b>${SIZES.map((s) => cell(svg(n, s))).join("")}` +
`<i></i>${STATES.map(([st, color]) => `<div class="cell" style="color:${color}">${svg(n, 16, st === "active")}<span>${st}</span></div>`).join("")}</div>`,
).join("");

const btn = (n: IconName, active = false, label = "") =>
`<button class="ib${active ? " on" : ""}" title="${n}">${svg(n, 14, active)}${label ? `<em>${label}</em>` : ""}</button>`;
const chrome = `
<section class="chrome">
<div class="header">
<span class="logo">HyperFrames Studio</span>
<div class="seg">${btn("camera", false, "Capture")}${btn("window", false, "Window")}${btn("inspector", true, "Inspector")}</div>
${btn("download", false, "Export")}
</div>
<div class="body">
<div class="tree">
<div class="tabs">${btn("file", true)}${btn("layers")}${btn("image")}${btn("music")}${btn("sparkle")}</div>
<div class="li">${svg("chevron-down", 12)}${svg("folder", 14)}compositions</div>
<div class="li in">${svg("file-code", 14)}intro.html</div>
<div class="li in">${svg("file-code", 14)}outro.html</div>
<div class="li">${svg("chevron-right", 12)}${svg("folder", 14)}assets</div>
<div class="li in">${svg("file-video", 14)}clip.mp4</div>
<div class="li in">${svg("file-audio", 14)}music.mp3</div>
<div class="li in">${svg("file-image", 14)}logo.png</div>
<div class="li in">${svg("file-font", 14)}Inter.woff2</div>
<div class="li">${svg("file-text", 14)}README.md</div>
</div>
<div class="stage"><div class="frame"></div>
<div class="pc">${btn("play")}${btn("pause")}<span class="t">00:00:00</span>${btn("loop", true)}${btn("volume-high")}${btn("fullscreen")}</div>
</div>
<div class="insp">
<div class="ih">${svg("square", 13)}<b>hero</b>${btn("eye")}${btn("copy")}${btn("x")}</div>
<div class="ir"><span>Position</span>${btn("keyframe")}${btn("link", true)}</div>
<div class="ir"><span>Opacity</span>${btn("keyframe", true)}${btn("caret-down")}</div>
<div class="ir"><span>Fill</span>${btn("eyedropper")}${btn("palette")}</div>
<div class="ir"><span>Order</span>${btn("bring-to-front")}${btn("bring-forward")}${btn("send-backward")}${btn("send-to-back")}</div>
<div class="ir"><span>Text</span>${btn("type")}${btn("font")}</div>
</div>
</div>
<div class="tl">
<div class="tb">${btn("select", true)}${btn("undo")}${btn("redo")}<i></i>${btn("razor")}${btn("split")}${btn("scissors")}<i></i>${btn("magnet", true)}${btn("snap")}${btn("beat")}${btn("marker")}${btn("keyframe-auto")}<i></i>${btn("zoom-out")}${btn("zoom-in")}${btn("waves")}${btn("record")}<span class="grow"></span>${btn("keyboard")}${btn("settings")}</div>
<div class="lane">${svg("eye", 12)}${svg("film", 12)}<span>Video</span><div class="clip v"></div></div>
<div class="lane">${svg("eye", 12)}${svg("type", 12)}<span>Title</span><div class="clip t"></div></div>
<div class="lane">${svg("eye-off", 12)}${svg("music", 12)}<span>Music</span><div class="clip a"></div></div>
</div>
</section>`;

const todayRows = [16, 12]
.map(
(size) =>
`<div class="row"><b>${size} px</b>${TODAY.map(
([n, P]) =>
`<div class="pair"><span>${renderToStaticMarkup(<P size={size} />)}${svg(n, size)}</span><span>${n}</span></div>`,
).join("")}</div>`,
)
.join("");

const html = `<!doctype html><meta charset="utf-8"><title>Studio icons</title>
<style>
:root{${TOKENS}}
body{margin:0;background:var(--color-bg-0);color:var(--color-text-2);font:12px/1.4 -apple-system,Inter,system-ui,sans-serif;padding:24px}
h1{font-size:13px;color:var(--color-text-0);margin:24px 0 8px}
.row{display:flex;align-items:center;gap:6px;padding:4px 0;border-bottom:1px solid var(--color-border)}
.row b{width:120px;font-weight:500;font-size:11px}
.row i{width:16px}
.cell{display:flex;flex-direction:column;align-items:center;gap:2px;width:78px}
.pair{display:flex;flex-direction:column;align-items:center;gap:2px;width:44px}.pair span{display:flex;gap:6px;font-size:8px;color:var(--color-text-4)}.pair svg{display:block}
.cell span{font-size:9px;color:var(--color-text-4);white-space:nowrap}
.cell svg{display:block}
.chrome{border:1px solid var(--color-border);background:var(--color-bg-0);width:900px;font-size:11px;user-select:none}
.header{display:flex;align-items:center;gap:8px;padding:6px 10px;background:var(--color-surface);border-bottom:1px solid var(--color-border)}
.logo{color:var(--color-text-0);font-weight:600;flex:1}
.seg{display:flex;background:#0f0f11;border:1px solid var(--color-border);border-radius:6px;padding:2px;gap:2px}
.ib{all:unset;display:inline-flex;align-items:center;gap:5px;padding:4px;border-radius:5px;color:var(--color-text-2);cursor:default}
.ib em{font-style:normal}
.ib:hover{background:var(--color-hover);color:var(--color-text-0)}
.ib.on{color:var(--color-accent)}
.body{display:flex;height:260px}
.tree{width:180px;border-right:1px solid var(--color-border);padding:4px}
.tabs{display:flex;gap:2px;border-bottom:1px solid var(--color-border);margin-bottom:6px;padding-bottom:4px}
.li{display:flex;align-items:center;gap:5px;padding:2px 4px;color:var(--color-text-2)}
.li.in{padding-left:22px}
.stage{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px}
.frame{width:320px;height:180px;background:#000;border:1px solid var(--color-border)}
.pc{display:flex;align-items:center;gap:2px}.pc .t{color:var(--color-text-0);font-variant-numeric:tabular-nums;padding:0 8px}
.insp{width:220px;border-left:1px solid var(--color-border);padding:6px}
.ih,.ir{display:flex;align-items:center;gap:4px;padding:3px 2px;border-bottom:1px solid var(--color-border)}
.ih b{flex:1;color:var(--color-text-0);font-weight:500}.ir span{flex:1}
.tl{border-top:1px solid var(--color-border);background:var(--color-surface)}
.tb{display:flex;align-items:center;gap:2px;padding:4px 8px;border-bottom:1px solid var(--color-border)}
.tb i{width:1px;height:16px;background:var(--color-border);margin:0 4px}.grow{flex:1}
.lane{display:flex;align-items:center;gap:6px;padding:4px 8px;height:24px;border-bottom:1px solid var(--color-border)}
.lane span{width:60px}.clip{height:20px;border-radius:4px;width:240px}
.clip.v{background:#1f5fa5}.clip.t{background:#6b4fbb;width:140px;margin-left:40px}.clip.a{background:#1e7a5f;width:400px}
</style>
<h1>Today (Phosphor, left) and ours (right)</h1>${todayRows}
<h1>Must stay distinct at 12 px</h1>${distinctRows}
<h1>In Studio chrome (mock, 14 px)</h1>${chrome}
<h1>Every icon at 12 / 14 / 16 / 20 px, then enabled / hover / active / disabled at 16 px</h1>${sizeRows}
`;
writeFileSync(process.argv[2] ?? "icon-specimen.html", html);
console.log(`${ICON_NAMES.length} icons -> ${process.argv[2]}`);
129 changes: 129 additions & 0 deletions packages/studio/src/icons/Icon.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { Icon, strokeWidthFor } from "./Icon";
import { GLYPHS, ICON_NAMES } from "./glyphs";
import * as named from "./index";

const NUMBER = /-?\d*\.?\d+/g;
const ARGS: Record<string, number> = { m: 2, l: 2, t: 2, h: 1, v: 1, c: 6, s: 4, q: 4, a: 7, z: 0 };

type Pen = { x: number; y: number; sx: number; sy: number };

// One command's points as x,y pairs before resolving relative offsets.
function segmentPairs(k: string, seg: number[], pen: Pen, rel: boolean): number[] {
if (k === "a") return [seg[5], seg[6]];
if (k === "h") return [seg[0], rel ? 0 : pen.y];
if (k === "v") return [rel ? 0 : pen.x, seg[0]];
return seg;
}

function resolvePairs(pairs: number[], pen: Pen, rel: boolean): number[] {
return rel ? pairs.map((v, i) => (i % 2 ? pen.y : pen.x) + v) : pairs;
}

function advance(k: string, pts: number[], pen: Pen): void {
pen.x = pts[pts.length - 2];
pen.y = pts[pts.length - 1];
if (k === "m") [pen.sx, pen.sy] = [pen.x, pen.y];
}

// Every point a path visits (anchors and control points), relative commands
// resolved against the current point; arcs contribute their end points.
function pathPoints(d: string): number[] {
const out: number[] = [];
const pen: Pen = { x: 0, y: 0, sx: 0, sy: 0 };
for (const [, cmd, body] of d.matchAll(/([A-Za-z])([^A-Za-z]*)/g)) {
const k = cmd.toLowerCase();
const n = ARGS[k];
const nums = (body.match(NUMBER) ?? []).map(Number);
if (k === "z") [pen.x, pen.y] = [pen.sx, pen.sy];
for (let i = 0; n > 0 && i + n <= nums.length; i += n) {
const pts = resolvePairs(
segmentPairs(k, nums.slice(i, i + n), pen, cmd === k),
pen,
cmd === k,
);
out.push(...pts);
advance(k, pts, pen);
}
}
return out;
}

function primitiveBox(shape: string): number[] {
const [x, y, a, b] = shape.split(" ").slice(1).map(Number);
return shape.startsWith("c") || shape.startsWith("d")
? [x - a, y - a, x + a, y + a]
: [x, y, x + a, y + b];
}

describe("studio icon set", () => {
it("has a named export for every glyph and nothing else", () => {
const pascal = (n: string) =>
n
.split("-")
.map((w) => w[0].toUpperCase() + w.slice(1))
.join("") + "Icon";
for (const name of ICON_NAMES) {
expect(typeof named[pascal(name) as keyof typeof named]).toBe("function");
}
const extra = Object.keys(named).filter((k) => !k.endsWith("Icon") || k === "Icon");
expect(extra.sort()).toEqual(["GLYPHS", "ICON_NAMES", "Icon", "strokeWidthFor"]);
});

it("resolves relative path commands when checking the margin", () => {
expect(pathPoints("M8 8h-8")).toEqual([8, 8, 0, 8]);
expect(pathPoints("M8 8v9")).toEqual([8, 8, 8, 17]);
expect(pathPoints("M2 2l1 1c1 1 2 2 3 3z")).toEqual([2, 2, 3, 3, 4, 4, 5, 5, 6, 6]);
expect(pathPoints("M2 2a1 1 0 0 1 3 3")).toEqual([2, 2, 5, 5]);
});

it.each(ICON_NAMES)("%s renders in currentColor on the 16 grid", (name) => {
const html = renderToStaticMarkup(<Icon name={name} />);
expect(html).toContain('viewBox="0 0 16 16"');
expect(html).toContain('stroke="currentColor"');
expect(html).not.toMatch(/#[0-9a-f]{3,8}|rgb\(/i);
expect(html).toContain('aria-hidden="true"');
});

it("announces itself only when given a title", () => {
const html = renderToStaticMarkup(<Icon name="check" title="Done" />);
expect(html).toContain("<title>Done</title>");
expect(html).toContain('role="img"');
expect(html).not.toContain("aria-hidden");
});

it("paints solid only for silhouettes", () => {
expect(renderToStaticMarkup(<Icon name="keyframe" filled />)).toContain('fill="currentColor"');
expect(renderToStaticMarkup(<Icon name="undo" filled />)).toContain('fill="none"');
expect(renderToStaticMarkup(<Icon name="caret-down" />)).toContain('fill="currentColor"');
});

it("draws the small variant below 14 px", () => {
expect(renderToStaticMarkup(<Icon name="file-code" size={12} />)).not.toContain(
"M9.5 2v3.5H13",
);
expect(renderToStaticMarkup(<Icon name="file-code" size={14} />)).toContain("M9.5 2v3.5H13");
});

it("picks the stroke by size class", () => {
expect(strokeWidthFor(12)).toBe(1.25);
expect(strokeWidthFor(13)).toBe(1.25);
expect(strokeWidthFor(14)).toBe(1.5);
expect(strokeWidthFor("20px")).toBe(1.5);
expect(renderToStaticMarkup(<Icon name="x" size={12} />)).toContain('stroke-width="1.25"');
});
});

describe("studio icon set safe margin", () => {
it.each(ICON_NAMES)("%s stays inside the 1 px safe margin", (name) => {
const glyph = GLYPHS[name] as { shapes: readonly string[]; small?: readonly string[] };
for (const shape of [...glyph.shapes, ...(glyph.small ?? [])]) {
const points = /^[Mm]/.test(shape) ? pathPoints(shape) : primitiveBox(shape);
for (const v of points) {
expect(v, `${name}: ${shape}`).toBeGreaterThanOrEqual(1);
expect(v, `${name}: ${shape}`).toBeLessThanOrEqual(15);
}
}
});
});
74 changes: 74 additions & 0 deletions packages/studio/src/icons/Icon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type { ReactNode, SVGProps } from "react";
import { GLYPHS, type Glyph, type IconName, type Shape } from "./glyphs";

export type { IconName };

export interface IconProps extends Omit<SVGProps<SVGSVGElement>, "name" | "fill" | "stroke"> {
name: IconName;
/** Rendered size in px; the stroke picks its weight from the size class. */
size?: number | string;
/** Standalone icon with no visible label: announced instead of hidden. */
title?: string;
/** Active state: the glyph is painted solid. */
filled?: boolean;
}

// Size classes, not a linear scale: below 14 px the stroke thins to 1.25.
function isSmall(size: number | string): boolean {
const px = typeof size === "number" ? size : Number.parseFloat(size);
return Number.isFinite(px) && px < 14;
}
export const strokeWidthFor = (size: number | string): number => (isSmall(size) ? 1.25 : 1.5);

const SOLID = { fill: "currentColor", stroke: "none" } as const;

function renderShape(shape: Shape, i: number) {
const kind = shape[0];
const n = shape.split(" ").slice(1).map(Number);
const solid = kind === "R" || kind === "d" ? SOLID : undefined;
if (kind === "r" || kind === "R") {
return <rect key={i} x={n[0]} y={n[1]} width={n[2]} height={n[3]} rx={n[4]} {...solid} />;
}
if (kind === "c" || kind === "d")
return <circle key={i} cx={n[0]} cy={n[1]} r={n[2]} {...solid} />;
return <path key={i} d={shape} />;
}

// Shape nodes depend only on the glyph and its size class, so build them once.
const nodes = new Map<string, ReactNode[]>();
function shapeNodes(name: IconName, small: boolean): ReactNode[] {
const key = `${name}:${small}`;
let built = nodes.get(key);
if (!built) {
const glyph: Glyph = GLYPHS[name];
built = ((small && glyph.small) || glyph.shapes).map(renderShape);
nodes.set(key, built);
}
return built;
}

export function Icon({ name, size = 16, title, filled, ...rest }: IconProps) {
const glyph: Glyph = GLYPHS[name];
const solid = glyph.solid || (filled && glyph.fillable);
const small = isSmall(size);
return (
<svg
viewBox="0 0 16 16"
width={size}
height={size}
fill={solid ? "currentColor" : "none"}
stroke="currentColor"
strokeWidth={strokeWidthFor(size)}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden={title ? undefined : true}
aria-label={title}
role={title ? "img" : undefined}
data-icon={name}
{...rest}
>
{title ? <title>{title}</title> : null}
{shapeNodes(name, small)}
</svg>
);
}
Loading
Loading