From f92c2c3dce994d512b517d96df4d6c5814b58e81 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 19 Sep 2026 15:34:01 -0400 Subject: [PATCH 1/7] feat(studio): custom icon set module with specimen sheet --- .fallowrc.jsonc | 1 + packages/studio/scripts/icon-specimen.tsx | 129 ++++++++++++ packages/studio/src/icons/Icon.test.tsx | 62 ++++++ packages/studio/src/icons/Icon.tsx | 64 ++++++ packages/studio/src/icons/glyphs.ts | 233 ++++++++++++++++++++++ packages/studio/src/icons/index.tsx | 111 +++++++++++ 6 files changed, 600 insertions(+) create mode 100644 packages/studio/scripts/icon-specimen.tsx create mode 100644 packages/studio/src/icons/Icon.test.tsx create mode 100644 packages/studio/src/icons/Icon.tsx create mode 100644 packages/studio/src/icons/glyphs.ts create mode 100644 packages/studio/src/icons/index.tsx diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 7d2dff4b73..c0df79c6f9 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -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; diff --git a/packages/studio/scripts/icon-specimen.tsx b/packages/studio/scripts/icon-specimen.tsx new file mode 100644 index 0000000000..c2d926a303 --- /dev/null +++ b/packages/studio/scripts/icon-specimen.tsx @@ -0,0 +1,129 @@ +// Renders the icon specimen sheet from the module itself, so the sheet cannot +// drift from the code. Usage: bun scripts/icon-specimen.tsx +import { writeFileSync } from "node:fs"; +import { renderToStaticMarkup } from "react-dom/server"; +import { Icon, ICON_NAMES, type IconName } from "../src/icons"; + +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(); + +const cell = (inner: string, label = "") => + `
${inner}${label ? `${label}` : ""}
`; + +const distinctRows = DISTINCT.map( + (group) => `
${group.map((n) => cell(svg(n, 12), n)).join("")}
`, +).join(""); + +const sizeRows = ICON_NAMES.map( + (n) => + `
${n}${SIZES.map((s) => cell(svg(n, s))).join("")}` + + `${STATES.map(([st, color]) => `
${svg(n, 16, st === "active")}${st}
`).join("")}
`, +).join(""); + +const btn = (n: IconName, active = false, label = "") => + ``; +const chrome = ` +
+
+ +
${btn("camera", false, "Capture")}${btn("window", false, "Window")}${btn("inspector", true, "Inspector")}
+ ${btn("download", false, "Export")} +
+
+
+
${btn("file", true)}${btn("layers")}${btn("image")}${btn("music")}${btn("sparkle")}
+
${svg("chevron-down", 12)}${svg("folder", 14)}compositions
+
${svg("file-code", 14)}intro.html
+
${svg("file-code", 14)}outro.html
+
${svg("chevron-right", 12)}${svg("folder", 14)}assets
+
${svg("file-video", 14)}clip.mp4
+
${svg("file-audio", 14)}music.mp3
+
${svg("file-image", 14)}logo.png
+
${svg("file-font", 14)}Inter.woff2
+
${svg("file-text", 14)}README.md
+
+
+
${btn("play")}${btn("pause")}00:00:00${btn("loop", true)}${btn("volume-high")}${btn("fullscreen")}
+
+
+
${svg("square", 13)}hero${btn("eye")}${btn("copy")}${btn("x")}
+
Position${btn("keyframe")}${btn("link", true)}
+
Opacity${btn("keyframe", true)}${btn("caret-down")}
+
Fill${btn("eyedropper")}${btn("palette")}
+
Order${btn("bring-to-front")}${btn("bring-forward")}${btn("send-backward")}${btn("send-to-back")}
+
Text${btn("type")}${btn("font")}
+
+
+
+
${btn("select", true)}${btn("undo")}${btn("redo")}${btn("razor")}${btn("split")}${btn("scissors")}${btn("magnet", true)}${btn("snap")}${btn("beat")}${btn("marker")}${btn("keyframe-auto")}${btn("zoom-out")}${btn("zoom-in")}${btn("waves")}${btn("record")}${btn("keyboard")}${btn("settings")}
+
${svg("eye", 12)}${svg("film", 12)}Video
+
${svg("eye", 12)}${svg("type", 12)}Title
+
${svg("eye-off", 12)}${svg("music", 12)}Music
+
+
`; + +const html = `Studio icons + +

Must stay distinct at 12 px

${distinctRows} +

In Studio chrome (mock, 14 px)

${chrome} +

Every icon at 12 / 14 / 16 / 20 px, then enabled / hover / active / disabled at 16 px

${sizeRows} +`; +writeFileSync(process.argv[2] ?? "icon-specimen.html", html); +console.log(`${ICON_NAMES.length} icons -> ${process.argv[2]}`); diff --git a/packages/studio/src/icons/Icon.test.tsx b/packages/studio/src/icons/Icon.test.tsx new file mode 100644 index 0000000000..e1f7a53d89 --- /dev/null +++ b/packages/studio/src/icons/Icon.test.tsx @@ -0,0 +1,62 @@ +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; + +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(""); + for (const name of ICON_NAMES) + expect(typeof named[pascal(name) as keyof typeof named]).toBe("function"); + }); + + it.each(ICON_NAMES)("%s renders in currentColor on the 16 grid", (name) => { + const html = renderToStaticMarkup(); + 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.each(ICON_NAMES)("%s stays inside the 1 px safe margin", (name) => { + for (const shape of GLYPHS[name].shapes) { + if (!/^[Mm]/.test(shape)) continue; + // Absolute commands only carry coordinates we can check without a path + // parser; relative arcs and curves are bounded by their neighbours. + const abs = shape.replace(/[a-z][^A-Z]*/g, " "); + for (const n of abs.match(NUMBER) ?? []) { + const v = Number(n); + expect(v, `${name}: ${shape}`).toBeGreaterThanOrEqual(1); + expect(v, `${name}: ${shape}`).toBeLessThanOrEqual(15); + } + } + }); + + it("announces itself only when given a title", () => { + const html = renderToStaticMarkup(); + expect(html).toContain("Done"); + expect(html).toContain('role="img"'); + expect(html).not.toContain("aria-hidden"); + }); + + it("paints solid only for silhouettes", () => { + expect(renderToStaticMarkup()).toContain('fill="currentColor"'); + expect(renderToStaticMarkup()).toContain('fill="none"'); + expect(renderToStaticMarkup()).toContain('fill="currentColor"'); + }); + + 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()).toContain('stroke-width="1.25"'); + }); +}); diff --git a/packages/studio/src/icons/Icon.tsx b/packages/studio/src/icons/Icon.tsx new file mode 100644 index 0000000000..c0d4f96e02 --- /dev/null +++ b/packages/studio/src/icons/Icon.tsx @@ -0,0 +1,64 @@ +import type { CSSProperties, SVGProps } from "react"; +import { GLYPHS, type Glyph, type IconName, type Shape } from "./glyphs"; + +export type { IconName }; + +export interface IconProps extends Omit, "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; + style?: CSSProperties; +} + +// Size classes, not a linear scale: 12/14 px get the thinner stroke. +export function strokeWidthFor(size: number | string): number { + const px = typeof size === "number" ? size : Number.parseFloat(size); + return Number.isFinite(px) && px < 14 ? 1.25 : 1.5; +} + +const num = (s: string) => s.split(" ").slice(1).map(Number); + +function renderShape(shape: Shape, i: number) { + if (shape.startsWith("r ")) { + const [x, y, w, h, rx] = num(shape); + return ; + } + if (shape.startsWith("c ")) { + const [cx, cy, r] = num(shape); + return ; + } + if (shape.startsWith("d ")) { + const [cx, cy, r] = num(shape); + return ; + } + return ; +} + +export function Icon({ name, size = 16, title, filled, ...rest }: IconProps) { + const glyph: Glyph = GLYPHS[name]; + const solid = glyph.solid || (filled && glyph.fillable); + return ( + + {title ? {title} : null} + {glyph.shapes.map(renderShape)} + + ); +} diff --git a/packages/studio/src/icons/glyphs.ts b/packages/studio/src/icons/glyphs.ts new file mode 100644 index 0000000000..e11b6f897c --- /dev/null +++ b/packages/studio/src/icons/glyphs.ts @@ -0,0 +1,233 @@ +// Studio's icon set. One 16-unit grid, 1-unit safe margin, drawn in +// currentColor. A glyph is a list of shapes: "M…" path data, "r x y w h rx" +// rounded rect, "c cx cy r" circle, "d cx cy r" filled dot. +export type Shape = string; + +export interface Glyph { + shapes: readonly Shape[]; + /** Always painted solid (small carets). */ + solid?: true; + /** Closed silhouette that reads when painted solid; `filled` is ignored elsewhere. */ + fillable?: true; +} + +const FILE_BODY = + "M9.5 2H4.5A1.5 1.5 0 0 0 3 3.5v9A1.5 1.5 0 0 0 4.5 14h7a1.5 1.5 0 0 0 1.5-1.5v-7Z"; +const FILE_FOLD = "M9.5 2v3.5H13"; +const FOLDER = + "M2 4.5A1.5 1.5 0 0 1 3.5 3H6l1.5 1.5h5A1.5 1.5 0 0 1 14 6v6a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 2 12Z"; +const SPEAKER = "M2.5 6H5l3.5-3v10L5 10H2.5Z"; +const DIAMOND = "M8 2.5 13.5 8 8 13.5 2.5 8Z"; +const TRAY = "M2.5 11v1.5A1.5 1.5 0 0 0 4 14h8a1.5 1.5 0 0 0 1.5-1.5V11"; +const MAGNIFIER = ["c 7 7 4.5", "M10.5 10.5 14 14"]; + +export const GLYPHS = { + // marks + check: { shapes: ["M3.5 8.5 6.5 11.5 12.5 4.5"] }, + x: { shapes: ["M4 4l8 8M12 4l-8 8"] }, + plus: { shapes: ["M8 3v10M3 8h10"] }, + minus: { shapes: ["M3 8h10"] }, + "chevron-down": { shapes: ["M4 6l4 4 4-4"] }, + "chevron-up": { shapes: ["M4 10l4-4 4 4"] }, + "chevron-right": { shapes: ["M6 4l4 4-4 4"] }, + "chevron-left": { shapes: ["M10 4 6 8l4 4"] }, + "caret-down": { shapes: ["M4.5 6h7L8 10Z"], solid: true }, + "caret-up": { shapes: ["M4.5 10h7L8 6Z"], solid: true }, + "caret-right": { shapes: ["M6 4.5v7L10 8Z"], solid: true }, + "arrow-left": { shapes: ["M13 8H3", "M7 4 3 8l4 4"] }, + "arrow-right": { shapes: ["M3 8h10", "M9 4l4 4-4 4"] }, + "arrow-up": { shapes: ["M8 13V3", "M4 7l4-4 4 4"] }, + "arrow-down": { shapes: ["M8 3v10", "M4 9l4 4 4-4"] }, + grip: { shapes: ["M6 4h.01M10 4h.01M6 8h.01M10 8h.01M6 12h.01M10 12h.01"] }, + // history and rotation + undo: { shapes: ["M3 7h7a3.5 3.5 0 0 1 0 7H6", "M6 4 3 7l3 3"] }, + redo: { shapes: ["M13 7H6a3.5 3.5 0 0 0 0 7h4", "M10 4l3 3-3 3"] }, + "rotate-cw": { shapes: ["M13 8a5 5 0 1 1-1.5-3.5", "M9 4.5h2.5V2"] }, + "rotate-ccw": { shapes: ["M3 8a5 5 0 1 0 1.5-3.5", "M7 4.5H4.5V2"] }, + loop: { + shapes: [ + "M3 6v-.5a2 2 0 0 1 2-2h8", + "M11 1.5l2 2-2 2", + "M13 10v.5a2 2 0 0 1-2 2H3", + "M5 10.5l-2 2 2 2", + ], + }, + // editing + select: { shapes: ["M3.5 2.5 12.5 8l-4 .8 2.2 4.2-1.7.8-2.2-4.1-3.3 2.8Z"], fillable: true }, + move: { + shapes: [ + "M8 2v12M2 8h12", + "M5.5 4.5 8 2l2.5 2.5M5.5 11.5 8 14l2.5-2.5M4.5 5.5 2 8l2.5 2.5M11.5 5.5 14 8l-2.5 2.5", + ], + }, + scissors: { shapes: ["c 4.5 4.5 2", "c 4.5 11.5 2", "M6 5.8 13.5 12M6 10.2 13.5 4"] }, + razor: { shapes: ["M9.8 2.7 13.3 6.2 6.2 13.3 2.7 9.8Z", "M6.2 9.8l3.6-3.6"], fillable: true }, + split: { shapes: ["M5.5 3h-2v10h2", "M10.5 3h2v10h-2", "M8 2v12"] }, + pencil: { shapes: ["M2.5 13.5 3 10.5l8-8a1.4 1.4 0 0 1 2.5 2.5l-8 8Z", "M9.5 4 12 6.5"] }, + eyedropper: { + shapes: [ + "M2 14v-1.5l6-6L9.5 8l-6 6Z", + "M7.5 6 10 8.5", + "M8.5 5.5 11 3a1.4 1.4 0 0 1 2 2l-2.5 2.5Z", + ], + }, + link: { + shapes: ["M6 11.5H4.5a3.5 3.5 0 0 1 0-7H6", "M10 4.5h1.5a3.5 3.5 0 0 1 0 7H10", "M5.5 8h5"], + }, + unlink: { + shapes: [ + "M6 11.5H4.5a3.5 3.5 0 0 1 0-7H6", + "M10 4.5h1.5a3.5 3.5 0 0 1 0 7H10", + "M5.5 8h1M9.5 8h1", + ], + }, + copy: { + shapes: [ + "r 6 6 8 8 2", + "M4 10h-.5A1.5 1.5 0 0 1 2 8.5v-5A1.5 1.5 0 0 1 3.5 2h5A1.5 1.5 0 0 1 10 3.5V4", + ], + }, + clipboard: { + shapes: [ + "M5.5 3h-1A1.5 1.5 0 0 0 3 4.5V13a1.5 1.5 0 0 0 1.5 1.5h7A1.5 1.5 0 0 0 13 13V4.5A1.5 1.5 0 0 0 11.5 3h-1", + "r 5.5 1.5 5 3 1", + "M5.5 8h5M5.5 11h3", + ], + }, + trash: { + shapes: ["M3 4.5h10", "M6.5 4.5v-2h3v2", "M4.5 4.5l.7 9h5.6l.7-9", "M7 7.5v3.5M9 7.5v3.5"], + }, + group: { + shapes: [ + "r 2 2 8 8 1.5", + "M10 6h2.5A1.5 1.5 0 0 1 14 7.5v5a1.5 1.5 0 0 1-1.5 1.5h-5A1.5 1.5 0 0 1 6 12.5V10", + ], + }, + ungroup: { shapes: ["r 2 2 5.5 5.5 1.5", "r 8.5 8.5 5.5 5.5 1.5"] }, + "bring-forward": { shapes: ["M2.5 11 8 8.5l5.5 2.5", "M8 8.5v-6", "M5.5 5 8 2.5 10.5 5"] }, + "send-backward": { shapes: ["M2.5 5 8 7.5 13.5 5", "M8 7.5v6", "M5.5 11 8 13.5l2.5-2.5"] }, + "bring-to-front": { + shapes: ["M2.5 10.5 8 8l5.5 2.5M2.5 13.5 8 11l5.5 2.5", "M8 8V2.5", "M5.5 5 8 2.5 10.5 5"], + }, + "send-to-back": { + shapes: ["M2.5 2.5 8 5l5.5-2.5M2.5 5.5 8 8l5.5-2.5", "M8 8v5.5", "M5.5 11 8 13.5l2.5-2.5"], + }, + layers: { + shapes: [ + "M8 2.5 13.5 5.25 8 8 2.5 5.25Z", + "M2.5 8.5 8 11.25l5.5-2.75", + "M2.5 11.75 8 14.5l5.5-2.75", + ], + }, + crosshair: { shapes: ["c 8 8 5", "M8 1.5V4M8 12v2.5M1.5 8H4M12 8h2.5", "d 8 8 1"] }, + sparkle: { shapes: ["M8 2l1.5 4.5L14 8l-4.5 1.5L8 14l-1.5-4.5L2 8l4.5-1.5Z"], fillable: true }, + // timeline + keyframe: { shapes: [DIAMOND], fillable: true }, + "keyframe-auto": { shapes: [DIAMOND, "d 8 8 1.5"], fillable: true }, + marker: { shapes: ["M4 14V2.5h8L10 5.5 12 8.5H4"], fillable: true }, + beat: { shapes: ["M2 8h3l1.5-4 3 8L11 8h3"] }, + magnet: { + shapes: ["M3 2.5h3.5V8a1.5 1.5 0 0 0 3 0V2.5H13V8A5 5 0 0 1 3 8Z", "M3 5.5h3.5M9.5 5.5H13"], + fillable: true, + }, + snap: { shapes: ["M11.5 2v12", "r 2.5 5.5 5 5 1.5", "M8.5 6.5 10 8l-1.5 1.5"], fillable: true }, + grid: { shapes: ["r 2 2 12 12 2", "M2 6h12M2 10h12M6 2v12M10 2v12"] }, + path: { shapes: ["M3.5 11.5C4 6 6.5 4.5 8 8s4 3.5 4.5-3", "c 2.5 12.5 1.25", "c 13.5 3.5 1.25"] }, + ruler: { shapes: ["r 2 5.5 12 5 1.5", "M5 5.5V8M8 5.5v3.5M11 5.5V8"] }, + "safe-frame": { + shapes: [ + "M2 6V3.5A1.5 1.5 0 0 1 3.5 2H6", + "M10 2h2.5A1.5 1.5 0 0 1 14 3.5V6", + "M14 10v2.5a1.5 1.5 0 0 1-1.5 1.5H10", + "M6 14H3.5A1.5 1.5 0 0 1 2 12.5V10", + ], + }, + waves: { shapes: ["M2 8c1.5-3 2.5-3 4 0s2.5 3 4 0 2.5-3 4 0"] }, + waveform: { shapes: ["M3 6v4M5.5 3.5v9M8 5.5v5M10.5 2.5v11M13 7v2"] }, + record: { shapes: ["c 8 8 5.5", "d 8 8 2.5"], fillable: true }, + clock: { shapes: ["c 8 8 6", "M8 4.5V8l2.5 1.5"] }, + // player + play: { shapes: ["M4.5 2.5 13 8l-8.5 5.5Z"], fillable: true }, + pause: { shapes: ["r 3 2.5 3.5 11 1", "r 9.5 2.5 3.5 11 1"], fillable: true }, + fullscreen: { shapes: ["M2 6V2h4M10 2h4v4M14 10v4h-4M6 14H2v-4"] }, + "fullscreen-exit": { shapes: ["M6 2v4H2M14 6h-4V2M10 14v-4h4M2 10h4v4"] }, + "volume-high": { + shapes: [SPEAKER, "M10.5 5.5a3.5 3.5 0 0 1 0 5", "M12.5 3.5a6.5 6.5 0 0 1 0 9"], + }, + "volume-low": { shapes: [SPEAKER, "M10.5 5.5a3.5 3.5 0 0 1 0 5"] }, + "volume-mute": { shapes: [SPEAKER, "M10.5 6 14 9.5M14 6l-3.5 3.5"] }, + // media and files + camera: { + shapes: [ + "M2 5.5A1.5 1.5 0 0 1 3.5 4h2l1-1.5h3l1 1.5h2A1.5 1.5 0 0 1 14 5.5V12a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 2 12Z", + "c 8 8.5 2.5", + ], + }, + image: { shapes: ["r 2 2.5 12 11 2", "c 5.5 6 1.25", "M2 11.5 6 8l3 3 2-1.5 3 2.5"] }, + film: { shapes: ["r 2 3 12 10 2", "M5 3v10M11 3v10M2 6.5h3M2 9.5h3M11 6.5h3M11 9.5h3"] }, + music: { shapes: ["M6 12.5v-9l7-1.5V11", "c 4 12.5 2", "c 11 11 2"] }, + type: { shapes: ["M3 3.5h10", "M3 3.5v2M13 3.5v2", "M8 3.5V13", "M6 13h4"] }, + font: { shapes: ["M2 12l3-8 3 8", "M3.2 9h3.6", "c 11.5 9.5 2", "M13.5 7.5V12"] }, + folder: { shapes: [FOLDER], fillable: true }, + "folder-plus": { shapes: [FOLDER, "M8 7v4M6 9h4"] }, + file: { shapes: [FILE_BODY, FILE_FOLD] }, + "file-plus": { shapes: [FILE_BODY, FILE_FOLD, "M8 7.5v4M6 9.5h4"] }, + "file-code": { shapes: [FILE_BODY, FILE_FOLD, "M6 8.5 4.5 10 6 11.5M10 8.5l1.5 1.5-1.5 1.5"] }, + "file-image": { shapes: [FILE_BODY, FILE_FOLD, "d 6.5 8.5 .9", "M5 12l2.5-2.5 2 2 1.5-1.5"] }, + "file-video": { shapes: [FILE_BODY, FILE_FOLD, "M6.5 8 10 10l-3.5 2Z"] }, + "file-audio": { shapes: [FILE_BODY, FILE_FOLD, "M5.5 9.5V11M8 8v4.5M10.5 9v2.5"] }, + "file-text": { shapes: [FILE_BODY, FILE_FOLD, "M5.5 8.5h5M5.5 11h3"] }, + "file-font": { shapes: [FILE_BODY, FILE_FOLD, "M6 12l2-5 2 5", "M6.8 10.5h2.4"] }, + download: { shapes: ["M8 2.5v8", "M4.5 7 8 10.5 11.5 7", TRAY] }, + upload: { shapes: ["M8 10.5v-8", "M4.5 6 8 2.5 11.5 6", TRAY] }, + // chrome + eye: { + shapes: [ + "M1.5 8C3.5 4.5 5.5 3 8 3s4.5 1.5 6.5 5c-2 3.5-4 5-6.5 5S3.5 11.5 1.5 8Z", + "c 8 8 2.5", + ], + }, + "eye-off": { + shapes: [ + "M2.5 2.5l11 11", + "M6.2 6.3a2.5 2.5 0 0 0 3.5 3.5", + "M4.7 4.8C3.5 5.6 2.5 6.7 1.5 8c2 3.5 4 5 6.5 5 1.2 0 2.3-.3 3.3-.9", + "M7 3.1c.3-.1.7-.1 1-.1 2.5 0 4.5 1.5 6.5 5-.6 1.1-1.3 2-2 2.8", + ], + }, + settings: { + shapes: [ + "M12.9 7.01 14.47 7.35v1.3l-1.57.34-.73 1.77.86 1.35-.92.92-1.35-.86-1.77.73-.34 1.57h-1.3l-.34-1.57-1.77-.73-1.35.86-.92-.92.86-1.35-.73-1.77-1.57-.34v-1.3l1.57-.34.73-1.77-.86-1.35.92-.92 1.35.86 1.77-.73.34-1.57h1.3l.34 1.57 1.77.73 1.35-.86.92.92-.86 1.35Z", + "c 8 8 2", + ], + }, + "sidebar-show": { shapes: ["r 2 2.5 12 11 2", "M6 2.5v11", "M8.5 6l2 2-2 2"] }, + "sidebar-hide": { shapes: ["r 2 2.5 12 11 2", "M6 2.5v11", "M11 6l-2 2 2 2"] }, + inspector: { shapes: ["r 2 2.5 12 11 2", "M10 2.5v11"] }, + window: { shapes: ["r 2 2.5 12 11 2", "M2 6.5h12", "M7 6.5v7"] }, + compare: { shapes: ["r 2 2 12 12 2", "M2 8h12"] }, + square: { shapes: ["r 2.5 2.5 11 11 2"], fillable: true }, + palette: { + shapes: [ + "M8 2C4.4 2 1.5 4.7 1.5 8S4.4 14 8 14c1 0 1.5-.7 1.5-1.5 0-1-1-1.5-1-2.5 0-1 .8-1.5 1.8-1.5H12c1.4 0 2.5-1.1 2.5-2.5C14.5 3.8 11.6 2 8 2Z", + "d 5 7 1", + "d 8 5 1", + "d 11 6 1", + ], + }, + zap: { shapes: ["M9 1.5 3 9h5l-1 5.5L13 7H8Z"], fillable: true }, + search: { shapes: [...MAGNIFIER] }, + "zoom-in": { shapes: [...MAGNIFIER, "M5 7h4M7 5v4"] }, + "zoom-out": { shapes: [...MAGNIFIER, "M5 7h4"] }, + keyboard: { shapes: ["r 1.5 4 13 8.5 1.5", "M4 7h1M7 7h2M11 7h1M4.5 10h7"] }, + spinner: { shapes: ["M14 8A6 6 0 1 1 8 2"] }, + // status + warning: { shapes: ["M8 2.5 14.5 13.5h-13Z", "M8 6.5v3", "d 8 12 .75"] }, + "check-circle": { shapes: ["c 8 8 6", "M5 8l2.2 2.2L11 6"] }, + "check-square": { shapes: ["r 2 2 12 12 2", "M5 8l2.2 2.2L11 6"] }, + "alert-circle": { shapes: ["c 8 8 6", "M8 5v3.5", "d 8 11 .75"] }, + "help-circle": { shapes: ["c 8 8 6", "M6 6.5a2 2 0 1 1 2.5 2c-.5.3-.5.7-.5 1.3", "d 8 12 .75"] }, +} as const satisfies Record; + +export type IconName = keyof typeof GLYPHS; +export const ICON_NAMES = Object.keys(GLYPHS) as IconName[]; diff --git a/packages/studio/src/icons/index.tsx b/packages/studio/src/icons/index.tsx new file mode 100644 index 0000000000..f5ae6ac70d --- /dev/null +++ b/packages/studio/src/icons/index.tsx @@ -0,0 +1,111 @@ +import { Icon, type IconProps } from "./Icon"; + +type GlyphProps = Omit; +const named = (name: IconProps["name"]) => (props: GlyphProps) => ; + +export const Check = named("check"); +export const X = named("x"); +export const Plus = named("plus"); +export const Minus = named("minus"); +export const ChevronDown = named("chevron-down"); +export const ChevronUp = named("chevron-up"); +export const ChevronRight = named("chevron-right"); +export const ChevronLeft = named("chevron-left"); +export const CaretDown = named("caret-down"); +export const CaretUp = named("caret-up"); +export const CaretRight = named("caret-right"); +export const ArrowLeft = named("arrow-left"); +export const ArrowRight = named("arrow-right"); +export const ArrowUp = named("arrow-up"); +export const ArrowDown = named("arrow-down"); +export const Grip = named("grip"); +export const Undo = named("undo"); +export const Redo = named("redo"); +export const RotateCw = named("rotate-cw"); +export const RotateCcw = named("rotate-ccw"); +export const Loop = named("loop"); +export const Select = named("select"); +export const Move = named("move"); +export const Scissors = named("scissors"); +export const Razor = named("razor"); +export const Split = named("split"); +export const Pencil = named("pencil"); +export const Eyedropper = named("eyedropper"); +export const Link = named("link"); +export const Unlink = named("unlink"); +export const Copy = named("copy"); +export const Clipboard = named("clipboard"); +export const Trash = named("trash"); +export const Group = named("group"); +export const Ungroup = named("ungroup"); +export const BringForward = named("bring-forward"); +export const SendBackward = named("send-backward"); +export const BringToFront = named("bring-to-front"); +export const SendToBack = named("send-to-back"); +export const Layers = named("layers"); +export const Crosshair = named("crosshair"); +export const Sparkle = named("sparkle"); +export const Keyframe = named("keyframe"); +export const KeyframeAuto = named("keyframe-auto"); +export const Marker = named("marker"); +export const Beat = named("beat"); +export const Magnet = named("magnet"); +export const Snap = named("snap"); +export const Grid = named("grid"); +export const Path = named("path"); +export const Ruler = named("ruler"); +export const SafeFrame = named("safe-frame"); +export const Waves = named("waves"); +export const Waveform = named("waveform"); +export const Record = named("record"); +export const Clock = named("clock"); +export const Play = named("play"); +export const Pause = named("pause"); +export const Fullscreen = named("fullscreen"); +export const FullscreenExit = named("fullscreen-exit"); +export const VolumeHigh = named("volume-high"); +export const VolumeLow = named("volume-low"); +export const VolumeMute = named("volume-mute"); +export const Camera = named("camera"); +export const Image = named("image"); +export const Film = named("film"); +export const Music = named("music"); +export const Type = named("type"); +export const Font = named("font"); +export const Folder = named("folder"); +export const FolderPlus = named("folder-plus"); +export const File = named("file"); +export const FilePlus = named("file-plus"); +export const FileCode = named("file-code"); +export const FileImage = named("file-image"); +export const FileVideo = named("file-video"); +export const FileAudio = named("file-audio"); +export const FileText = named("file-text"); +export const FileFont = named("file-font"); +export const Download = named("download"); +export const Upload = named("upload"); +export const Eye = named("eye"); +export const EyeOff = named("eye-off"); +export const Settings = named("settings"); +export const SidebarShow = named("sidebar-show"); +export const SidebarHide = named("sidebar-hide"); +export const Inspector = named("inspector"); +export const Window = named("window"); +export const Compare = named("compare"); +export const Square = named("square"); +export const Palette = named("palette"); +export const Zap = named("zap"); +export const Search = named("search"); +export const ZoomIn = named("zoom-in"); +export const ZoomOut = named("zoom-out"); +export const Keyboard = named("keyboard"); +export const Spinner = named("spinner"); +export const Warning = named("warning"); +export const CheckCircle = named("check-circle"); +export const CheckSquare = named("check-square"); +export const AlertCircle = named("alert-circle"); +export const HelpCircle = named("help-circle"); + +export { Icon, strokeWidthFor } from "./Icon"; +export type { IconName, IconProps } from "./Icon"; +export { GLYPHS, ICON_NAMES } from "./glyphs"; From 5b5d33c75a8d80ba10b43030a97603efcbdd180b Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 19 Sep 2026 15:39:39 -0400 Subject: [PATCH 2/7] feat(studio): small-size icon variants and a today-vs-ours specimen row --- packages/studio/scripts/icon-specimen.tsx | 39 +++++++++++++- packages/studio/src/icons/Icon.test.tsx | 48 ++++++++++++++--- packages/studio/src/icons/Icon.tsx | 12 ++++- packages/studio/src/icons/glyphs.ts | 65 ++++++++++++++++++----- 4 files changed, 141 insertions(+), 23 deletions(-) diff --git a/packages/studio/scripts/icon-specimen.tsx b/packages/studio/scripts/icon-specimen.tsx index c2d926a303..90aaa6cc32 100644 --- a/packages/studio/scripts/icon-specimen.tsx +++ b/packages/studio/scripts/icon-specimen.tsx @@ -2,8 +2,33 @@ // drift from the code. Usage: bun scripts/icon-specimen.tsx import { 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], +]; + const SIZES = [12, 14, 16, 20] as const; const STATES = [ ["enabled", "var(--color-text-2)"], @@ -84,6 +109,16 @@ const chrome = ` `; +const todayRows = [16, 12] + .map( + (size) => + `
${size} px${TODAY.map( + ([n, P]) => + `
${renderToStaticMarkup(

)}${svg(n, size)}${n}

`, + ).join("")}
`, + ) + .join(""); + const html = `Studio icons +

Today (Phosphor, left) and ours (right)

${todayRows}

Must stay distinct at 12 px

${distinctRows}

In Studio chrome (mock, 14 px)

${chrome}

Every icon at 12 / 14 / 16 / 20 px, then enabled / hover / active / disabled at 16 px

${sizeRows} diff --git a/packages/studio/src/icons/Icon.test.tsx b/packages/studio/src/icons/Icon.test.tsx index e1f7a53d89..967dd1701d 100644 --- a/packages/studio/src/icons/Icon.test.tsx +++ b/packages/studio/src/icons/Icon.test.tsx @@ -5,6 +5,36 @@ import { GLYPHS, ICON_NAMES } from "./glyphs"; import * as named from "./index"; const NUMBER = /-?\d*\.?\d+/g; +// Coordinates of every absolute command; relative segments are bounded by +// their absolute neighbours and arc radii/flags are not positions. +const COORDS_PER: Record = { + M: [2, 2], + L: [2, 2], + T: [2, 2], + H: [1, 1], + V: [1, 1], + C: [6, 6], + S: [4, 4], + Q: [4, 4], + A: [7, 2], +}; +function absolutePoints(d: string): number[] { + const out: number[] = []; + for (const [, cmd, body] of d.matchAll(/([A-Za-z])([^A-Za-z]*)/g)) { + const spec = COORDS_PER[cmd]; + if (!spec) continue; + const nums = (body.match(NUMBER) ?? []).map(Number); + for (let i = 0; i + spec[0] <= nums.length; i += spec[0]) + out.push(...nums.slice(i + spec[0] - spec[1], i + spec[0])); + } + 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", () => { @@ -26,13 +56,10 @@ describe("studio icon set", () => { }); it.each(ICON_NAMES)("%s stays inside the 1 px safe margin", (name) => { - for (const shape of GLYPHS[name].shapes) { - if (!/^[Mm]/.test(shape)) continue; - // Absolute commands only carry coordinates we can check without a path - // parser; relative arcs and curves are bounded by their neighbours. - const abs = shape.replace(/[a-z][^A-Z]*/g, " "); - for (const n of abs.match(NUMBER) ?? []) { - const v = Number(n); + const glyph = GLYPHS[name] as { shapes: readonly string[]; small?: readonly string[] }; + for (const shape of [...glyph.shapes, ...(glyph.small ?? [])]) { + const points = /^[Mm]/.test(shape) ? absolutePoints(shape) : primitiveBox(shape); + for (const v of points) { expect(v, `${name}: ${shape}`).toBeGreaterThanOrEqual(1); expect(v, `${name}: ${shape}`).toBeLessThanOrEqual(15); } @@ -52,6 +79,13 @@ describe("studio icon set", () => { expect(renderToStaticMarkup()).toContain('fill="currentColor"'); }); + it("draws the small variant below 14 px", () => { + expect(renderToStaticMarkup()).not.toContain( + "M9.5 2v3.5H13", + ); + expect(renderToStaticMarkup()).toContain("M9.5 2v3.5H13"); + }); + it("picks the stroke by size class", () => { expect(strokeWidthFor(12)).toBe(1.25); expect(strokeWidthFor(13)).toBe(1.25); diff --git a/packages/studio/src/icons/Icon.tsx b/packages/studio/src/icons/Icon.tsx index c0d4f96e02..b28524282f 100644 --- a/packages/studio/src/icons/Icon.tsx +++ b/packages/studio/src/icons/Icon.tsx @@ -27,6 +27,12 @@ function renderShape(shape: Shape, i: number) { const [x, y, w, h, rx] = num(shape); return ; } + if (shape.startsWith("R ")) { + const [x, y, w, h, rx] = num(shape); + return ( + + ); + } if (shape.startsWith("c ")) { const [cx, cy, r] = num(shape); return ; @@ -41,6 +47,8 @@ function renderShape(shape: Shape, i: number) { export function Icon({ name, size = 16, title, filled, ...rest }: IconProps) { const glyph: Glyph = GLYPHS[name]; const solid = glyph.solid || (filled && glyph.fillable); + const strokeWidth = strokeWidthFor(size); + const shapes = strokeWidth < 1.5 && glyph.small ? glyph.small : glyph.shapes; return ( {title ? {title} : null} - {glyph.shapes.map(renderShape)} + {shapes.map(renderShape)} ); } diff --git a/packages/studio/src/icons/glyphs.ts b/packages/studio/src/icons/glyphs.ts index e11b6f897c..7954fde88a 100644 --- a/packages/studio/src/icons/glyphs.ts +++ b/packages/studio/src/icons/glyphs.ts @@ -1,10 +1,12 @@ // Studio's icon set. One 16-unit grid, 1-unit safe margin, drawn in // currentColor. A glyph is a list of shapes: "M…" path data, "r x y w h rx" -// rounded rect, "c cx cy r" circle, "d cx cy r" filled dot. +// rounded rect, "R x y w h rx" filled rect, "c cx cy r" circle, "d cx cy r" +// filled dot. `small` replaces `shapes` below 14 px: fewer strokes, one mark. export type Shape = string; export interface Glyph { shapes: readonly Shape[]; + small?: readonly Shape[]; /** Always painted solid (small carets). */ solid?: true; /** Closed silhouette that reads when painted solid; `filled` is ignored elsewhere. */ @@ -14,6 +16,8 @@ export interface Glyph { const FILE_BODY = "M9.5 2H4.5A1.5 1.5 0 0 0 3 3.5v9A1.5 1.5 0 0 0 4.5 14h7a1.5 1.5 0 0 0 1.5-1.5v-7Z"; const FILE_FOLD = "M9.5 2v3.5H13"; +const SMALL_FILE = + "M9.5 2H4.5A1.5 1.5 0 0 0 3 3.5v9A1.5 1.5 0 0 0 4.5 14h7a1.5 1.5 0 0 0 1.5-1.5V6Z"; const FOLDER = "M2 4.5A1.5 1.5 0 0 1 3.5 3H6l1.5 1.5h5A1.5 1.5 0 0 1 14 6v6a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 2 12Z"; const SPEAKER = "M2.5 6H5l3.5-3v10L5 10H2.5Z"; @@ -104,13 +108,21 @@ export const GLYPHS = { ], }, ungroup: { shapes: ["r 2 2 5.5 5.5 1.5", "r 8.5 8.5 5.5 5.5 1.5"] }, - "bring-forward": { shapes: ["M2.5 11 8 8.5l5.5 2.5", "M8 8.5v-6", "M5.5 5 8 2.5 10.5 5"] }, - "send-backward": { shapes: ["M2.5 5 8 7.5 13.5 5", "M8 7.5v6", "M5.5 11 8 13.5l2.5-2.5"] }, + "bring-forward": { + shapes: ["M2.5 11 8 8.5l5.5 2.5", "M8 8.5v-6", "M5.5 5 8 2.5 10.5 5"], + small: ["M3 12h10", "M4 8.5 8 4.5l4 4"], + }, + "send-backward": { + shapes: ["M2.5 5 8 7.5 13.5 5", "M8 7.5v6", "M5.5 11 8 13.5l2.5-2.5"], + small: ["M3 4h10", "M4 7.5 8 11.5l4-4"], + }, "bring-to-front": { shapes: ["M2.5 10.5 8 8l5.5 2.5M2.5 13.5 8 11l5.5 2.5", "M8 8V2.5", "M5.5 5 8 2.5 10.5 5"], + small: ["M3 13h10", "M4 10.5 8 6.5l4 4M4 6.5 8 2.5l4 4"], }, "send-to-back": { shapes: ["M2.5 2.5 8 5l5.5-2.5M2.5 5.5 8 8l5.5-2.5", "M8 8v5.5", "M5.5 11 8 13.5l2.5-2.5"], + small: ["M3 3h10", "M4 5.5 8 9.5l4-4M4 9.5 8 13.5l4-4"], }, layers: { shapes: [ @@ -170,14 +182,32 @@ export const GLYPHS = { font: { shapes: ["M2 12l3-8 3 8", "M3.2 9h3.6", "c 11.5 9.5 2", "M13.5 7.5V12"] }, folder: { shapes: [FOLDER], fillable: true }, "folder-plus": { shapes: [FOLDER, "M8 7v4M6 9h4"] }, - file: { shapes: [FILE_BODY, FILE_FOLD] }, + file: { shapes: [FILE_BODY, FILE_FOLD], small: [SMALL_FILE] }, "file-plus": { shapes: [FILE_BODY, FILE_FOLD, "M8 7.5v4M6 9.5h4"] }, - "file-code": { shapes: [FILE_BODY, FILE_FOLD, "M6 8.5 4.5 10 6 11.5M10 8.5l1.5 1.5-1.5 1.5"] }, - "file-image": { shapes: [FILE_BODY, FILE_FOLD, "d 6.5 8.5 .9", "M5 12l2.5-2.5 2 2 1.5-1.5"] }, - "file-video": { shapes: [FILE_BODY, FILE_FOLD, "M6.5 8 10 10l-3.5 2Z"] }, - "file-audio": { shapes: [FILE_BODY, FILE_FOLD, "M5.5 9.5V11M8 8v4.5M10.5 9v2.5"] }, - "file-text": { shapes: [FILE_BODY, FILE_FOLD, "M5.5 8.5h5M5.5 11h3"] }, - "file-font": { shapes: [FILE_BODY, FILE_FOLD, "M6 12l2-5 2 5", "M6.8 10.5h2.4"] }, + "file-code": { + shapes: [FILE_BODY, FILE_FOLD, "M6 8.5 4.5 10 6 11.5M10 8.5l1.5 1.5-1.5 1.5"], + small: [SMALL_FILE, "M6.5 7 4.5 9l2 2M9.5 7l2 2-2 2"], + }, + "file-image": { + shapes: [FILE_BODY, FILE_FOLD, "d 6.5 8.5 .9", "M5 12l2.5-2.5 2 2 1.5-1.5"], + small: [SMALL_FILE, "d 6 7.5 1.1", "M4.5 12l3-3 2 2 2.5-2.5"], + }, + "file-video": { + shapes: [FILE_BODY, FILE_FOLD, "M6.5 8 10 10l-3.5 2Z"], + small: [SMALL_FILE, "M6.5 6.5 10.5 9l-4 2.5Z"], + }, + "file-audio": { + shapes: [FILE_BODY, FILE_FOLD, "M5.5 9.5V11M8 8v4.5M10.5 9v2.5"], + small: [SMALL_FILE, "M5.5 8v3M8 6.5v6M10.5 7.5v4"], + }, + "file-text": { + shapes: [FILE_BODY, FILE_FOLD, "M5.5 8.5h5M5.5 11h3"], + small: [SMALL_FILE, "M5.5 7.5h5M5.5 10.5h3"], + }, + "file-font": { + shapes: [FILE_BODY, FILE_FOLD, "M6 12l2-5 2 5", "M6.8 10.5h2.4"], + small: [SMALL_FILE, "M5.5 12l2.5-6 2.5 6M6.4 10h3.2"], + }, download: { shapes: ["M8 2.5v8", "M4.5 7 8 10.5 11.5 7", TRAY] }, upload: { shapes: ["M8 10.5v-8", "M4.5 6 8 2.5 11.5 6", TRAY] }, // chrome @@ -201,9 +231,18 @@ export const GLYPHS = { "c 8 8 2", ], }, - "sidebar-show": { shapes: ["r 2 2.5 12 11 2", "M6 2.5v11", "M8.5 6l2 2-2 2"] }, - "sidebar-hide": { shapes: ["r 2 2.5 12 11 2", "M6 2.5v11", "M11 6l-2 2 2 2"] }, - inspector: { shapes: ["r 2 2.5 12 11 2", "M10 2.5v11"] }, + "sidebar-show": { + shapes: ["r 2 2.5 12 11 2", "M6 2.5v11", "M8.5 6l2 2-2 2"], + small: ["r 2 2.5 12 11 2", "R 2 2.5 4.5 11 2"], + }, + "sidebar-hide": { + shapes: ["r 2 2.5 12 11 2", "M6 2.5v11", "M11 6l-2 2 2 2"], + small: ["r 2 2.5 12 11 2", "M6.5 2.5v11"], + }, + inspector: { + shapes: ["r 2 2.5 12 11 2", "M10 2.5v11"], + small: ["r 2 2.5 12 11 2", "R 9.5 2.5 4.5 11 2"], + }, window: { shapes: ["r 2 2.5 12 11 2", "M2 6.5h12", "M7 6.5v7"] }, compare: { shapes: ["r 2 2 12 12 2", "M2 8h12"] }, square: { shapes: ["r 2.5 2.5 11 11 2"], fillable: true }, From 3a2565291dc2a14b1833f04138dfcfcacb425a42 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 19 Sep 2026 15:42:39 -0400 Subject: [PATCH 3/7] refactor(studio): icon shapes built once per glyph, specimen reads theme tokens --- packages/studio/scripts/icon-specimen.tsx | 10 ++++- packages/studio/src/icons/Icon.tsx | 54 ++++++++++++----------- packages/studio/src/icons/glyphs.ts | 17 +++---- packages/studio/src/icons/index.tsx | 4 +- 4 files changed, 47 insertions(+), 38 deletions(-) diff --git a/packages/studio/scripts/icon-specimen.tsx b/packages/studio/scripts/icon-specimen.tsx index 90aaa6cc32..6ffca0b611 100644 --- a/packages/studio/scripts/icon-specimen.tsx +++ b/packages/studio/scripts/icon-specimen.tsx @@ -1,6 +1,6 @@ // Renders the icon specimen sheet from the module itself, so the sheet cannot // drift from the code. Usage: bun scripts/icon-specimen.tsx -import { writeFileSync } from "node:fs"; +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"; @@ -29,6 +29,12 @@ const TODAY: [IconName, Ph.Icon][] = [ ["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)"], @@ -121,7 +127,7 @@ const todayRows = [16, 12] const html = `Studio icons