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..6ffca0b611 --- /dev/null +++ b/packages/studio/scripts/icon-specimen.tsx @@ -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 +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(); + +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 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} +`; +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..b3cf6432e9 --- /dev/null +++ b/packages/studio/src/icons/Icon.test.tsx @@ -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 = { 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(); + 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(); + 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("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); + expect(strokeWidthFor(14)).toBe(1.5); + expect(strokeWidthFor("20px")).toBe(1.5); + expect(renderToStaticMarkup()).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); + } + } + }); +}); diff --git a/packages/studio/src/icons/Icon.tsx b/packages/studio/src/icons/Icon.tsx new file mode 100644 index 0000000000..11ce9f8a32 --- /dev/null +++ b/packages/studio/src/icons/Icon.tsx @@ -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, "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 ; + } + if (kind === "c" || kind === "d") + return ; + return ; +} + +// Shape nodes depend only on the glyph and its size class, so build them once. +const nodes = new Map(); +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 ( + + {title ? {title} : null} + {shapeNodes(name, small)} + + ); +} diff --git a/packages/studio/src/icons/glyphs.ts b/packages/studio/src/icons/glyphs.ts new file mode 100644 index 0000000000..b96042e38c --- /dev/null +++ b/packages/studio/src/icons/glyphs.ts @@ -0,0 +1,271 @@ +// 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, "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. */ + 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 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"; +const DIAMOND = "M8 2.5 13.5 8 8 13.5 2.5 8Z"; +const PANEL = "r 2 2.5 12 11 2"; +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"] }, + 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", + ], + }, + // Studio's existing z-order glyphs (CanvasContextMenu.tsx), kept verbatim. + "bring-forward": { + shapes: ["M3 11 L8 8.5 L13 11 L8 13.5 Z", "M8 8.5 L8 2", "M5.5 4.5 L8 2 L10.5 4.5"], + }, + "send-backward": { + shapes: ["M3 5 L8 2.5 L13 5 L8 7.5 Z", "M8 7.5 L8 14", "M5.5 11.5 L8 14 L10.5 11.5"], + }, + "bring-to-front": { + shapes: [ + "M3 9.5 L8 7 L13 9.5 L8 12 Z", + "M3 12.5 L8 10 L13 12.5 L8 15 Z", + "M8 12.5 L8 2", + "M5.5 4.5 L8 2 L10.5 4.5", + ], + }, + "send-to-back": { + shapes: [ + "M3 4 L8 1.5 L13 4 L8 6.5 Z", + "M3 7 L8 4.5 L13 7 L8 9.5 Z", + "M8 3.5 L8 14", + "M5.5 11.5 L8 14 L10.5 11.5", + ], + }, + 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.5Z"], 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: [PANEL, "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], 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"], + 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 + 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", + ], + }, + window: { shapes: [PANEL, "M2 6.5h12", "M7 6.5v7"] }, + "sidebar-show": { shapes: [PANEL, "M6.5 2.5v11"] }, + "sidebar-hide": { shapes: [PANEL, "R 2 2.5 4.5 11 2", "M6.5 2.5v11"] }, + inspector: { shapes: [PANEL, "R 9.5 2.5 4.5 11 2", "M9.5 2.5v11"] }, + 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..b5ffa0d439 --- /dev/null +++ b/packages/studio/src/icons/index.tsx @@ -0,0 +1,111 @@ +import { Icon, type IconName, type IconProps } from "./Icon"; + +type GlyphProps = Omit; +const named = (name: IconName) => (props: GlyphProps) => ; + +export const CheckIcon = named("check"); +export const XIcon = named("x"); +export const PlusIcon = named("plus"); +export const MinusIcon = named("minus"); +export const ChevronDownIcon = named("chevron-down"); +export const ChevronUpIcon = named("chevron-up"); +export const ChevronRightIcon = named("chevron-right"); +export const ChevronLeftIcon = named("chevron-left"); +export const CaretDownIcon = named("caret-down"); +export const CaretUpIcon = named("caret-up"); +export const CaretRightIcon = named("caret-right"); +export const ArrowLeftIcon = named("arrow-left"); +export const ArrowRightIcon = named("arrow-right"); +export const ArrowUpIcon = named("arrow-up"); +export const ArrowDownIcon = named("arrow-down"); +export const GripIcon = named("grip"); +export const UndoIcon = named("undo"); +export const RedoIcon = named("redo"); +export const RotateCwIcon = named("rotate-cw"); +export const RotateCcwIcon = named("rotate-ccw"); +export const LoopIcon = named("loop"); +export const SelectIcon = named("select"); +export const MoveIcon = named("move"); +export const ScissorsIcon = named("scissors"); +export const RazorIcon = named("razor"); +export const SplitIcon = named("split"); +export const PencilIcon = named("pencil"); +export const EyedropperIcon = named("eyedropper"); +export const LinkIcon = named("link"); +export const UnlinkIcon = named("unlink"); +export const CopyIcon = named("copy"); +export const ClipboardIcon = named("clipboard"); +export const TrashIcon = named("trash"); +export const GroupIcon = named("group"); +export const UngroupIcon = named("ungroup"); +export const LayersIcon = named("layers"); +export const BringForwardIcon = named("bring-forward"); +export const SendBackwardIcon = named("send-backward"); +export const BringToFrontIcon = named("bring-to-front"); +export const SendToBackIcon = named("send-to-back"); +export const CrosshairIcon = named("crosshair"); +export const SparkleIcon = named("sparkle"); +export const KeyframeIcon = named("keyframe"); +export const KeyframeAutoIcon = named("keyframe-auto"); +export const MarkerIcon = named("marker"); +export const BeatIcon = named("beat"); +export const MagnetIcon = named("magnet"); +export const SnapIcon = named("snap"); +export const GridIcon = named("grid"); +export const PathIcon = named("path"); +export const RulerIcon = named("ruler"); +export const SafeFrameIcon = named("safe-frame"); +export const WavesIcon = named("waves"); +export const WaveformIcon = named("waveform"); +export const RecordIcon = named("record"); +export const ClockIcon = named("clock"); +export const PlayIcon = named("play"); +export const PauseIcon = named("pause"); +export const FullscreenIcon = named("fullscreen"); +export const FullscreenExitIcon = named("fullscreen-exit"); +export const VolumeHighIcon = named("volume-high"); +export const VolumeLowIcon = named("volume-low"); +export const VolumeMuteIcon = named("volume-mute"); +export const CameraIcon = named("camera"); +export const ImageIcon = named("image"); +export const FilmIcon = named("film"); +export const MusicIcon = named("music"); +export const TypeIcon = named("type"); +export const FontIcon = named("font"); +export const FolderIcon = named("folder"); +export const FolderPlusIcon = named("folder-plus"); +export const FileIcon = named("file"); +export const FilePlusIcon = named("file-plus"); +export const FileCodeIcon = named("file-code"); +export const FileImageIcon = named("file-image"); +export const FileVideoIcon = named("file-video"); +export const FileAudioIcon = named("file-audio"); +export const FileTextIcon = named("file-text"); +export const FileFontIcon = named("file-font"); +export const DownloadIcon = named("download"); +export const UploadIcon = named("upload"); +export const EyeIcon = named("eye"); +export const EyeOffIcon = named("eye-off"); +export const SettingsIcon = named("settings"); +export const WindowIcon = named("window"); +export const SidebarShowIcon = named("sidebar-show"); +export const SidebarHideIcon = named("sidebar-hide"); +export const InspectorIcon = named("inspector"); +export const CompareIcon = named("compare"); +export const SquareIcon = named("square"); +export const PaletteIcon = named("palette"); +export const ZapIcon = named("zap"); +export const SearchIcon = named("search"); +export const ZoomInIcon = named("zoom-in"); +export const ZoomOutIcon = named("zoom-out"); +export const KeyboardIcon = named("keyboard"); +export const SpinnerIcon = named("spinner"); +export const WarningIcon = named("warning"); +export const CheckCircleIcon = named("check-circle"); +export const CheckSquareIcon = named("check-square"); +export const AlertCircleIcon = named("alert-circle"); +export const HelpCircleIcon = named("help-circle"); + +export { Icon, strokeWidthFor } from "./Icon"; +export type { IconName, IconProps } from "./Icon"; +export { GLYPHS, ICON_NAMES } from "./glyphs";