diff --git a/packages/editor-icons/ICONS.md b/packages/editor-icons/ICONS.md new file mode 100644 index 0000000..57a2eea --- /dev/null +++ b/packages/editor-icons/ICONS.md @@ -0,0 +1,692 @@ +--- +budget_kb: 340 +icons: + # --- Disclosure, arrows, chrome ------------------------------------------- + chev-down: others/down-arrow-small.svg + chev-right: others/right-arrow-small.svg + chev-up: others/up-arrow.svg + caret-down: others/dropdown-arrow.svg + drag: others/drag.svg + grab: others/grab.svg + + # --- General verbs -------------------------------------------------------- + plus: general/add.svg + minus: general/remove.svg + close: general/x.svg + check: general/check.svg + check-bold: general/check-bold.svg + search: general/search.svg + more: general/more-actions.svg + settings: general/setting.svg + eye: appearance/show.svg + eye-off: appearance/hide.svg + list-view: general/show-as-list.svg + grid-view: general/show-as-grid.svg + question: others/question-mark.svg + clipboard: others/clipboard.svg + save: others/save.svg + play: others/play.svg + + # --- Alignment row (a design panel opens with exactly these) --------------- + align-left: position/align-vertical-left.svg + align-h-center: position/align-vertical-center.svg + align-right: position/align-vertical-right.svg + align-top: position/align-horizontal-top.svg + align-v-center: position/align-horizontal-center.svg + align-bottom: position/align-horizontal-bottom.svg + distribute-h: position/distribute-horizontal-spacing.svg + distribute-v: position/distribute-vertical-spacing.svg + tidy-up: position/tidy-up.svg + flip-h: alignment/flip-horizontal.svg + flip-v: alignment/flip-vertical.svg + rotation: alignment/rotation.svg + + # --- Auto layout ---------------------------------------------------------- + al-horizontal: auto-layout/horizontal-layout.svg + al-vertical: auto-layout/vertical-layout.svg + al-wrap: auto-layout/wrap.svg + al-add: auto-layout/use-auto-layout.svg + al-remove: auto-layout/remove-auto-layout.svg + al-baseline: auto-layout/icon-16-autolayout-alignment-baseline.svg + al-absolute: auto-layout/absolute.svg + gap-h: auto-layout/horizontal-gap-between-objects.svg + gap-v: auto-layout/vertical-gap-between-objects.svg + pad-h: auto-layout/horizontal-padding.svg + pad-v: auto-layout/vertical-padding.svg + pad-individual: auto-layout/individual-padding.svg + pad-top: auto-layout/top-padding.svg + pad-right: auto-layout/right-padding.svg + pad-bottom: auto-layout/bottom-padding.svg + pad-left: auto-layout/left-padding.svg + + # --- Sizing (Hug / Fill / Fixed, plus min-max) ---------------------------- + size-hug: auto-layout/hug-content.svg + size-fixed: auto-layout/fixed-width.svg + size-fill: auto-layout/resize-to-fit.svg + size-min-w: auto-layout/add-min-width.svg + size-max-w: auto-layout/add-max-width.svg + size-min-h: auto-layout/add-min-height.svg + size-max-h: auto-layout/add-max-height.svg + aspect-lock: auto-layout/lock-aspect-ratio.svg + aspect-unlock: auto-layout/unlock-aspect-ratio.svg + fit: auto-layout/resize-to-fit-1.svg + + # --- Text ----------------------------------------------------------------- + text-size: text/text-font-size.svg + text-line-height: text/text-line-height.svg + text-letter-spacing: text/text-letter-spacing.svg + text-paragraph-spacing: text/text-paragraph-spacing.svg + text-indent: text/text-paragraph-indent.svg + text-align-left: text/text-align-left.svg + text-align-center: text/text-align-center.svg + text-align-right: text/text-align-right.svg + text-align-justify: text/text-align-justified.svg + text-align-top: text/text-align-top.svg + text-align-middle: text/text-align-middle.svg + text-align-bottom: text/text-align-bottom.svg + text-bold: text/bold.svg + text-italic: text/italic.svg + text-underline: text/underline.svg + text-strike: text/strike-through-false.svg + text-uppercase: text/uppercase.svg + text-lowercase: text/lowercase.svg + text-titlecase: text/title-case.svg + text-truncate: text/truncate-text.svg + text-list: text/number-list.svg + text-resize-fixed: text/text-resize-fixed.svg + text-resize-width: text/text-resize-width.svg + text-resize-height: text/text-resize-height.svg + + # --- Appearance & corners ------------------------------------------------- + opacity: appearance/opacity.svg + blend-mode: appearance/apply-blend-mode.svg + # The "Clip content" toggle. From Frame/, which is otherwise excluded — + # the rest of that folder is boolean path operations a DOM editor cannot do. + overflow-clip: frame/overflow-clip.svg + overflow-visible: frame/overflow-display.svg + corners-independent: appearance/independent-corners.svg + corner-tl: appearance/top-left-corner-radius.svg + corner-tr: appearance/top-right-corner-radius.svg + corner-bl: appearance/bottom-left-corner-radius.svg + corner-br: appearance/bottom-right-corner-radius.svg + + # --- Fill ----------------------------------------------------------------- + fill-solid: fill/fill-solid.svg + fill-gradient: fill/fill-gradient-linear.svg + fill-image: fill/fill-image.svg + fill-mixed: fill/fill-mixed.svg + eyedropper: fill/eyedropper.svg + gradient-linear: fill/gradient-linear.svg + gradient-radial: fill/gradient-radial.svg + gradient-angular: fill/gradient-angular.svg + rotate-ccw: fill/rotate.svg + + # --- Stroke --------------------------------------------------------------- + stroke: stroke/border.svg + stroke-width: stroke/stroke-width.svg + stroke-custom: stroke/stroke-custom.svg + border-top: stroke/border-top.svg + border-right: stroke/border-right.svg + border-bottom: stroke/border-bottom.svg + border-left: stroke/border-left.svg + border-none: stroke/border-none.svg + stroke-solid: stroke/stroke-solid.svg + stroke-dash: stroke/stroke-dash.svg + + # --- Effects -------------------------------------------------------------- + effects: effects/effects.svg + effect-drop-shadow: effects/effect-setting-drop-shadow.svg + effect-inner-shadow: effects/effect-setting-inner-shadow.svg + effect-layer-blur: effects/effect-setting-layer-blur.svg + effect-bg-blur: effects/effect-setting-background-blur.svg + blur: effects/blur.svg + spread: effects/spread.svg + + # --- Layout grid ---------------------------------------------------------- + grid: grid/grid.svg + grid-columns: grid/layout-grid-columns.svg + grid-rows: grid/layout-grid-rows.svg + grid-uniform: grid/layout-grid-uniform.svg + + # --- Constraints ---------------------------------------------------------- + constraints-h: constraints/constraints-horizontal.svg + constraints-v: constraints/constraints-vertical.svg + + # --- Layer kinds (the tree's per-node glyph) ------------------------------ + layer-frame: layers/frame.svg + layer-group: layers/group.svg + layer-text: layers/text.svg + layer-component: layers/layer-component.svg + layer-instance: layers/layer-instance.svg + layer-vector: layers/layer-complex-vector.svg + layer-section: layers/section.svg + layer-slice: layers/slice.svg + + # --- Toolbar -------------------------------------------------------------- + tool-move: toolbar/move.svg + tool-hand: toolbar/hand-tool.svg + tool-frame: toolbar/frame.svg + tool-text: toolbar/edit-text.svg + tool-inspect: toolbar/inspect.svg + tool-measure: toolbar/measure.svg + tool-comment: toolbar/comment.svg + tool-scale: toolbar/scale.svg + dev-brackets: toolbar/dev-brackets.svg + keyboard: toolbar/keyboard-shortcuts.svg + history: toolbar/version-history.svg + crop: toolbar/crop.svg + annotate: toolbar/annotate.svg + actions: toolbar/actions.svg + component: toolbar/component.svg + image: toolbar/image.svg + insert: toolbar/insert.svg + + # --- Agent vocabulary ----------------------------------------------------- + # The chat timeline names one tool call per row. Every row used to lead with + # the same status dot, so a Read and a Bash were distinguishable only by + # reading their label. These are the glyphs that tell them apart — see + # `overlay/src/chat/tool-row.ts`. The set has no "run a shell command" mark; + # `command` (the ⌘ knot) is the nearest true thing in the set, and it reads + # as "a command" rather than as a terminal, which is the right abstraction + # for a row that might be `npm test` or `git status`. + # The thinking row's glyph. It used to share the brand mark with the dock + # head, which said "Airship" where it meant "the model is reflecting" — two + # different statements on one shape. The set's own idea mark says the second. + bulb-on: others/bulb-on.svg + pencil: toolbar/pencil.svg + doc-plus: toolbar/doc-plus.svg + command: toolbar/command.svg + # `Share/public.svg`, not `Others/language.svg`. The latter is the set's + # translate control — a 文 beside an A — which on a WebFetch row reads as + # "localise this", not "off the network". + globe: share/public.svg + # The Edit tab's own glyph, and the one exception to the `Icons/` exclusion + # below: the `Icons/design.svg` mark. It first borrowed `settings` — a gear, + # which in every editor means application preferences rather than "the visual + # properties of the thing you selected" — then `Toolbar/edit object.svg`, a + # bezier path with its handles out, which names *vector point editing*. That + # is a thing this editor cannot do and the tab does not offer. The product + # mark names the surface instead of a tool inside it, which is what a tab is. + design: icons/design.svg + + # --- Mode, files, versions ------------------------------------------------ + edit-mode: sidebar-left/edit.svg + preview: sidebar-left/preview.svg + present: sidebar-left/present.svg + folder: sidebar-left/folder.svg + libraries: sidebar-left/libraries.svg + code: others/code.svg + version-current: others/version-current.svg + version-branch: others/version-new-branch.svg + version-merged: others/version-merged.svg + device-mobile: others/mobile.svg + device-tablet: others/tablet.svg + + # --- Style swatches (section headers) ------------------------------------- + style-fill: style/style-fill.svg + style-text: style/style-text.svg + style-effect: style/style-effect.svg + style-grid: style/style-grid.svg + # --- Shadow direction + spread (Effects). The offset presets the drop/inner shadow + # rows offer, and the two blur ramps. + # ---------------------------------------------------------------------- + shadow-top: effects/drop-shadow-top.svg + shadow-bottom: effects/drop-shadow-bottom.svg + shadow-left: effects/drop-shadow-left.svg + shadow-right: effects/drop-shadow-right.svg + shadow-top-left: effects/drop-shadow-top-left.svg + shadow-top-right: effects/drop-shadow-top-right.svg + shadow-bottom-left: effects/drop-shadow-bottom-left.svg + shadow-bottom-right: effects/drop-shadow-bottom-right.svg + shadow-spread: effects/drop-shadow-spread.svg + shadow-min: effects/drop-shadow-min.svg + shadow-mid: effects/drop-shadow-mid.svg + shadow-max: effects/drop-shadow-max.svg + inner-shadow-top: effects/inner-shadow-top.svg + inner-shadow-bottom: effects/inner-shadow-bottom.svg + inner-shadow-left: effects/inner-shadow-left.svg + inner-shadow-right: effects/inner-shadow-right.svg + inner-shadow-top-left: effects/inner-shadow-top-left.svg + inner-shadow-top-right: effects/inner-shadow-top-right.svg + inner-shadow-bottom-left: effects/inner-shadow-bottom-left.svg + inner-shadow-bottom-right: effects/inner-shadow-bottom-right.svg + inner-shadow-spread: effects/inner-shadow-spread.svg + blur-small: effects/layer-blur-small.svg + + # --- Border side combinations. `border-width` takes four values, and these are the + # sixteen-way switch a design tool offers for picking which sides are on. + # ---------------------------------------------------------------------- + border-top-bottom: stroke/border-top-bottom.svg + border-left-right: stroke/border-left-right.svg + border-top-left: stroke/border-top-left.svg + border-top-right: stroke/border-top-right.svg + border-bottom-left: stroke/border-bottom-left.svg + border-bottom-right: stroke/border-bottom-right.svg + border-top-left-right: stroke/border-top-left-right.svg + border-bottom-left-right: stroke/border-bottom-left-right.svg + border-top-left-bottom: stroke/border-top-left-bottom.svg + border-top-right-bottom: stroke/border-top-right-bottom.svg + + # --- Stroke caps, joins and markers — the SVG section's vocabulary. + # ---------------------------------------------------------------------- + stroke-cap-none: stroke/stroke-cap-none.svg + stroke-cap-round: stroke/stroke-cap-round.svg + stroke-cap-square: stroke/stroke-cap-square.svg + stroke-join-round: stroke/stroke-round.svg + stroke-join-square: stroke/stroke-square.svg + stroke-arrow-line: stroke/stroke-line-arrow.svg + stroke-arrow-triangle: stroke/stroke-triangle-arrow.svg + stroke-arrow-reversed: stroke/stroke-reversed-triangle.svg + stroke-arrow-circle: stroke/stroke-circle-arrow.svg + stroke-arrow-diamond: stroke/stroke-diamond-arrow.svg + + # --- Text, second pass — the typographic controls the Text section grew. + # ---------------------------------------------------------------------- + text-h1: text/h1.svg + text-h2: text/h2.svg + text-small-caps: text/small-caps.svg + text-caps: text/text-caps.svg + text-subscript: text/subscript.svg + text-superscript: text/superscript.svg + text-underline-dashed: text/underline-dashed.svg + text-underline-dotted: text/underline-dotted.svg + text-underline-wave: text/underline-wave.svg + text-skip-ink: text/skip-ink-true.svg + text-skip-ink-off: text/skip-ink-false.svg + text-trim: text/leading-trim.svg + text-trim-none: text/no-trim.svg + text-code: text/code-snippet.svg + text-code-block: text/code-block.svg + text-num-normal: text/normal-number.svg + text-num-mono-lower: text/monospace-lowercase-number.svg + text-num-mono-upper: text/monospace-uppercase-number.svg + text-num-prop-lower: text/proportional-lowercase-number.svg + text-num-prop-upper: text/proportional-uppercase-number.svg + text-style-replace: text/replace-text-style.svg + text-style-update: text/update-text-style.svg + text-resize-auto: text/text-resize-height-1.svg + + # --- Design tokens / variables. The token badge and picker are the client. + # ---------------------------------------------------------------------- + var-add: variable/add-variable.svg + var-color: variable/variable-color.svg + var-string: variable/variable-string.svg + var-boolean: variable/variable-boolean.svg + var-text: variable/variable-text.svg + var-value: variable/variable-value.svg + var-settings: variable/variable-setting.svg + var-mode: prototype/set-variable-mode.svg + var-apply: general/apply-variable.svg + var-style-apply: general/apply-style-and-variable.svg + var-info: others/variable-info.svg + var-mapping: others/variable-mapping.svg + + # --- Interaction triggers and actions. The forced-state row (:hover, :active, + # :focus) and anything that later describes a transition reaches for these. + # ---------------------------------------------------------------------- + proto-click: prototype/on-click.svg + proto-drag: prototype/on-drag.svg + proto-mouse-down: prototype/mouse-down.svg + proto-mouse-up: prototype/mouse-up.svg + proto-mouse-enter: prototype/mouse-enter.svg + proto-mouse-leave: prototype/mouse-leave.svg + proto-hover: prototype/while-hovering.svg + proto-press: prototype/while-pressing.svg + proto-delay: prototype/after-delay.svg + proto-conditional: prototype/conditional.svg + proto-none: prototype/none.svg + proto-back: prototype/back.svg + proto-navigate: prototype/navigate-to.svg + proto-scroll-to: prototype/scroll-to.svg + proto-open-link: prototype/open-link.svg + proto-change-to: prototype/change-to.svg + proto-open-overlay: prototype/open-overlay.svg + proto-close-overlay: prototype/close-overlay.svg + proto-swap-overlay: prototype/swap-overlay.svg + proto-overlay-bottom: prototype/layout-overlay-bottom.svg + proto-overlay-left: prototype/layout-overlay-left.svg + proto-set-variable: prototype/set-variable.svg + proto-time-set: prototype/action-time-set.svg + proto-time-jump: prototype/action-time-jump.svg + proto-gamepad: prototype/gamepad.svg + + # --- Colour adjustments and blend modes (the picker's second row). + # ---------------------------------------------------------------------- + blend-off: color-picker/blendmode.svg + blend-on: color-picker/blendmode-active.svg + gradient-diamond: color-picker/gradient-diamond.svg + adjust-contrast: color-picker/contrast.svg + adjust-exposure: color-picker/exposure.svg + adjust-saturation: color-picker/saturation.svg + adjust-temperature: color-picker/temperature.svg + adjust-tint: color-picker/tint.svg + adjust-highlights: color-picker/highlights.svg + adjust-shadows: color-picker/shadows.svg + + # --- Vector and shape tools — the SVG section edits these primitives. + # ---------------------------------------------------------------------- + shape-rect: toolbar/rectangle.svg + shape-ellipse: toolbar/ellipse.svg + shape-line: toolbar/line.svg + shape-arrow: toolbar/arrow.svg + shape-polygon: toolbar/polygon.svg + shape-star: toolbar/star.svg + shape-pen: toolbar/pen.svg + shape-bend: toolbar/vector-bend.svg + shape-paint: toolbar/vector-paint.svg + shapes: toolbar/shapes.svg + count-polygon: frame/count-polygon.svg + count-star: frame/count-star.svg + angle: frame/angle.svg + mask: frame/mask.svg + flatten: frame/flatten-selection.svg + bool-union: frame/boolean-union.svg + bool-subtract: frame/boolean-subtract.svg + bool-intersect: frame/boolean-intersect.svg + bool-exclude: frame/boolean-exclude.svg + + # --- Cross-axis alignment per layout mode (the set splits these by direction). + # ---------------------------------------------------------------------- + layer-align-h-top: layers/horizontal-layout/align-top.svg + layer-align-h-center: layers/horizontal-layout/align-center.svg + layer-align-h-bottom: layers/horizontal-layout/align-bottom.svg + layer-align-v-left: layers/vertical-layout/align-left.svg + layer-align-v-center: layers/vertical-layout/align-center.svg + layer-align-v-right: layers/vertical-layout/align-right.svg + layer-align-wrap-left: layers/wrap-layout/align-left.svg + layer-align-wrap-right: layers/wrap-layout/align-right.svg + layer-wrap-center: layers/layer-wrap-align-center.svg + grid-column: grid/column.svg + grid-row: grid/row.svg + + # --- Chat, comments and review. + # ---------------------------------------------------------------------- + comment-message: comment/message.svg + comment-resolve: comment/make-as-resolved.svg + comment-mention: comment/mention.svg + comment-emoji: comment/add-emoji.svg + comment-image: comment/add-image.svg + comment-new: toolbar/comment-new.svg + thumb-up: others/thumb-up.svg + thumb-down: others/thumb-down.svg + heart: others/heart.svg + bulb: others/bulb-on.svg + bulb-off: others/bulb-off.svg + + # --- General chrome, second pass. + # ---------------------------------------------------------------------- + view: general/view.svg + spotlight: general/spotlight.svg + team-library: general/team-library.svg + indeterminate: general/intermediate.svg + select-using: general/select-item-using.svg + ready-for-dev: general/mark-as-ready-for-dev.svg + language: others/language.svg + square: others/square.svg + rectangle: others/rectangle.svg + portrait: others/portrait.svg + connection: others/creating-a-connection.svg + person: share/person.svg + people: share/people.svg + company: share/company.svg + key: share/key.svg + attention: share/attention.svg + public: share/public.svg + edit: sidebar-left/edit.svg + disconnect: sidebar-left/disconnect.svg + minimize: sidebar-left/minimize-ui.svg + missing-font: sidebar-left/missing-font.svg + syncing: sidebar-left/saving-syncing.svg + + # --- Local marks. Not from the imported set — see "Marks we draw ourselves". + # ---------------------------------------------------------------------- + logo: local/logo.svg + panel-left: local/panel-left.svg + panel-right: local/panel-right.svg + dot: local/dot.svg + gutter: local/gutter.svg + lock: local/lock.svg + unlock: local/unlock.svg + claude: local/claude.svg + codex: local/codex.svg + opencode: local/opencode.svg + +--- + +# Airship editor icons + +The overlay's icon set. This front-matter is the **canonical manifest**: it maps +a stable, semantic slug to a file under `assets/ui/`. `scripts/gen.mjs` reads +it and emits `src/generated/icons.ts`, exactly the way `@airship/editor-tokens` +turns `EDITOR.md` into `src/generated/editor.ts`. + +``` +edit ICONS.md → pnpm --filter @airship/editor-icons gen → src/generated/icons.ts +``` + +Never edit the generated file. + +## Naming + +Slugs are **kebab-case and semantic**, not source-verbatim — `text-size`, not +`text font size`. Where a bare noun would be ambiguous across panels, the slug +carries its domain as a prefix: + +| Prefix | Domain | Example | +| --- | --- | --- | +| `al-` | Auto layout | `al-horizontal`, `al-remove` | +| `pad-` / `gap-` | Spacing controls | `pad-individual`, `gap-v` | +| `size-` | Resizing modes | `size-hug`, `size-fill` | +| `text-` | The Text section | `text-line-height` | +| `corner-` / `border-` | Per-side/per-corner toggles | `corner-tl`, `border-none` | +| `layer-` | Layer-kind glyphs in the tree | `layer-component` | +| `tool-` | Toolbar tools | `tool-hand` | +| `effect-` | Effect types | `effect-inner-shadow` | + +Add an icon by adding a line here, not by importing an SVG at a call site. The +budget check below is the only thing keeping the bundle honest, and it can only +see what goes through the manifest. + +## Budget + +`budget_kb` caps the total normalised markup. The generator prints a per-category +size report and **fails the build** when the manifest exceeds it. Measured +figures for the full corpus, after normalisation: + +| Set | Icons | Raw | Gzipped | +| --- | --- | --- | --- | +| This manifest | 348 | ~312 KB | ~87 KB | +| Everything in `assets/` | 509 | ~468 KB | ~124 KB | + +The manifest went from 173 slugs to 348 in one pass, and `budget_kb` from 160 to +320 with it. That doubled cold-start icon cost by ~40 KB gzipped, and it was a +deliberate trade: the sections added alongside it (filters, media, vector, scope, +tokens, the gradient editor) were reaching for glyphs that did not exist, and the +failure mode when a slug is missing is not a fallback — `resolve()` throws, or, +worse, the name lands in a text slot and renders as literal characters. + +320 → 340 was the second raise, when normalisation stopped exempting the 24×24 +majority and every glyph gained a refit ``. That is +16.4 KB of markup but +only **+1.8 KB gzipped** — the wrappers are near-identical strings, which is the +case compression is best at, and compression is what these bytes actually ship +under. The alternative was baking the transform into the path coordinates, which +costs no bytes but makes the refit invisible and unauditable; the wrapper can be +read, diffed, and re-measured, and the generator does re-measure it. + +What is still excluded, and why: the application-specific glyphs with no meaning +in a DOM editor — component libraries and their variants, plugin and widget +marks, presentation and whiteboard tools, collaboration affordances, captioning, +engineering-handoff states. That is roughly 120 more unique glyphs and about +100 KB. They are one manifest line each if the product ever grows a use for them. + +Raising `budget_kb` is a decision, not a formality — the overlay bundle is +re-fetched on every page load (`no-store`, it changes with every rebuild), so +icon bytes are cold-start bytes. They are affordable only because +`serveAirshipAsset` compresses (`packages/server/src/proxy.ts`). + +A single object literal is retained wholesale by esbuild, so the registry cannot +be tree-shaken: `icon(name)` is called with computed names in the layers tree and +in the descriptor tables, so it has to be eager. Curation plus the budget check +is the control, not the bundler. + +## What the generator normalises + +1. **Every glyph is refit onto one 24×24 box at one optical size.** Each icon's + artwork is measured, then wrapped in a `translate(…) scale(…)` that centres + it at **12.8 units**. After this pass every icon reports `box: 24` and one + `icon(name, "md")` means one optical size everywhere. A file with no viewBox + is a hard failure, because there is nothing safe to assume. + + This used to exempt anything already 24×24, on the reasoning that those + glyphs are drawn on the pixel grid and rescaling would only blur them — + the set insets its artwork heavily, the span is about 12.8 units, and that + inset *is* the set's visual rhythm. + + That is true of the corpus and false of its members. Measured properly the + artwork spans **7 to 24 units**: median 13.0, p90 16.0. The exemption covered + 300 of 346 glyphs, so optical size was free-floating almost everywhere, and + icons from opposite ends of that spread ended up side by side — the bottom + bar had `rotation` at 7.0 next to `doc-plus` at 18.0, a 2.6× difference in + painted ink at the same nominal size. + + Two details the measurement has to get right, both of which the old + coordinate-pair scan did not: + + - **Curves contribute their extrema, not their control points**, and `H`/`V` + take a single operand. 297 of the 346 glyphs use `H`/`V`; a pair-wise scan + drops those operands, and `plus`'s `M6.5 11.5H17.5` measured 0×0 — which + under a general refit would have divided by zero and erased the glyph. + - **``, `` and `` are excluded.** Their geometry is + structural rather than painted: `toolbar/move.svg`'s mask is a full-bleed + rect spanning 17 of its 24 units, which is *not* the size of the cursor it + reveals. + + Stroked glyphs are fitted to 12.8 of **painted** extent, not geometry — their + centrelines land at `12.8 − STROKE_REF` so the stroke's outer edge finishes + where a filled glyph's silhouette would. See normalisation step 3. + + The generator re-measures its own output and fails the build if any glyph is + more than 0.01 units off the reference. That check is the thing that was + missing: the old exemption was never verified, so the drift was invisible. +2. **Paint becomes `currentColor`.** `#1B1F24` (481 fills + 36 strokes), + `#007BE5` and `black` are rewritten. `white` is left alone — those 18 are + ``/`` rects that must stay opaque — as is the single + `#F24822`, which is a semantic red. +3. **Stroke weight is pinned.** 26 of the 346 glyphs are stroked and *none* + declares a `stroke-width`, so they inherit 1 user unit — which the refit + `` then scales along with the geometry. Left alone that makes stroke + weight a function of how large the source happened to be drawn: `chev-down` + came out at 3.02 units against its neighbours' 1.0. The generator writes + `stroke-width="STROKE_REF / scale"` on the group, which cancels the transform + exactly, so every stroked glyph paints the same weight. +4. **Paint tone is completed, never overwritten.** The two-tone is the most + recognisable property of the set — the Constraints and Auto Layout glyphs + draw their frame at `0.3` and the meaningful part at `0.9` — and flattening + it collapses them into mush, so any opacity the source states is left exactly + alone. + + What the source *doesn't* state is filled in. The grammar was only applied + to part of the corpus: 146 icons had at least one element with no opacity at + all, painting at 100% beside neighbours at 90%. Those get `0.9`, the subject + tone. It is applied per element rather than on the refit ``, because + inherited opacity multiplies with the child's own — a group at `0.9` would + drag every `0.3` context path to `0.27` and silently re-tune the two-tone. + + All 356 icons now carry an explicit tone; 44 keep a `0.3` context layer. +5. **Element ids are namespaced** to `ap--`. Only 19 files carry ids, + but two `search` icons on one page would otherwise share — and break — a clip + path. +6. **Coordinates round to 2 dp**, which takes the corpus from ~600 KB of path + data to ~442 KB for no visible change. + +## Marks we draw ourselves + +Ten slugs under `assets/local/` are not from the imported set. They go through the same +manifest and the same normalisation as everything else — which is the point. +They previously lived as hand-authored strings in the overlay's `icons.ts`, +inset "roughly 4..20" (a **16-unit** span) to match the set by eye. The set's +actual reference is 12.8, so the brand mark, both panel toggles and all three +agent logos rendered about 25% oversized, and there were two competing +conventions with nothing reconciling them. + +- **`logo`** — Airship's mark: an `A` cut from two slabs. It has been a + four-point spark and then a flying saucer. The spark was the house style of + every AI feature shipped since 2023, so the one mark meant to say *Airship* + was the one saying the least; the saucer said the name, but a pictogram in a + dock head is a small illustration wherever you put it, and it kept reading as + a drawing rather than as an identity. A monogram is the thing that stays a + monogram at 16px. + + Two shapes. The main slab is the A's right leg carried up through a mitred + apex; the second is the left leg, detached, its flat top landing exactly on + the first slab's inner edge. The counter is therefore open at the foot — a + `V` of negative space rather than a closed triangle — which is what keeps the + mark from filling in at small sizes, where a closed counter of this + proportion would be the first thing to clog. + + Everything follows from two numbers: every edge that is not a foot or the + leg's top runs at dx/dy = 0.58, and the horizontal bar width is 4.53. That one + slope, mirrored about the centre, is what makes the apex symmetric and the two + legs read as the same slab. Two consequences of it are worth not "tidying" + away later: the apex sits at x=12, and the left leg's top-right corner also + lands at x=12, directly beneath it. Neither was placed — both fall out of the + slope, and they are why the mark feels centred despite being made of two + shapes that are not. + + Solid, where the imported set is outline, deliberately: it sits beside `plus`, + `history` and `rotate-ccw` in the dock head and should be the heaviest thing + in that row. It carries no two-tone. The set's 0.9/0.3 split marks subject + against context, and a logo is all subject — dimming a limb of it would draw a + distinction the mark does not have. One compound path under the default + `nonzero` rule: the two subpaths wind the same way and meet at exactly one + point, so nonzero unions them and the junction needs no hand-fitted geometry. +- **`panel-left` / `panel-right`** — a framed rect with a gutter on the side its + panel is on, so the glyph reads as the layout it toggles. The gutter is the + 0.9 tone, the frame the 0.3 — the same two-tone grammar the imported set uses. +- **`dot`** — a solid dot for neutral / in-progress status. +- **`gutter`** — the elbow rail for the chat timeline's result line, a drawn + `⎿`. The character itself (U+23BF) is absent from the latin font subsets we + self-host, so it would fall out to a system face with different metrics inside + a gutter whose entire job is alignment, and tofu where no fallback has it. +- **`lock` / `unlock`** — the layers tree's lock pair, filled. + `share/lock.svg` and `share/lock-open.svg` are stroke-expansion exports: a + `` holding the padlock silhouette, and a visible path that is only the + ~1px ring around it. The layers tree draws its glyphs at `xs` (16px), where + that ring scales to two thirds of a pixel — it antialiases to a grey smudge, + and sits next to the eye and the layer-kind glyphs, which are filled. These + are the mask's own silhouette painted directly, with the shackle as an + even-odd counter, so the pair matches the family it lives in. +- **`claude` / `codex` / `opencode`** — third-party product marks, for the agent + picker. Unlike the rest these are not ours to redraw; they name a backend in + the UI. Path data from `@lobehub/icons-static-svg`; the marks remain the + trademarks of Anthropic, OpenAI and the opencode project respectively. + +## Sizing + +Every glyph's artwork spans 12.8 of the 24 units, centred — so the visible mark +runs x/y 5.6..18.4. A 24-box glyph rendered at 16 px therefore yields a ~8.5 px +mark, which is why `icon()` defaults to **24**, not 16, and why the +`--ap-icon-size-*` scale starts at 16 rather than 12. Do not pass raw pixel +numbers at call sites; use the named sizes so the scale stays swappable. + +Normalising by bounding box makes every glyph occupy the same *box*, which is +not quite the same as the same *optical weight*: a solid disc filling 12.8 units +reads heavier than a thin chevron spanning the same 12.8. Where that matters the +fix belongs at the call site, as a deliberate smaller `size` — not as a +hand-tuned exception inside the set, which is how the drift started. + +## Deliberately excluded + +`prototype/` (26 — no prototyping surface), `comment/` (7 — no comment backend), +`variable/` (8), `frame/` boolean operations (a DOM editor cannot union paths), +`icons/` product marks (3 of 4 — `design` is kept as the Edit tab's glyph; the +other three name applications Airship is not), most of `share/` +(`lock`, `unlock` and `globe` are the exceptions) and most of `sidebar-left/`. + +`color-picker/` is excluded as a **duplicate**: its 21 files are byte-identical to +`fill/`. + +The 25 `constraints/constraints-N.svg` anchor-matrix variants are excluded on +purpose. That widget composes procedurally from a frame plus an anchor, which is +what Airship does — see the Constraints control. Inlining the matrix would cost +~20 KB to hard-code every state of something better expressed as two booleans. diff --git a/packages/editor-icons/package.json b/packages/editor-icons/package.json new file mode 100644 index 0000000..9c714f4 --- /dev/null +++ b/packages/editor-icons/package.json @@ -0,0 +1,27 @@ +{ + "name": "@airship/editor-icons", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "node scripts/gen.mjs && tsup src/index.ts --format esm --dts --sourcemap --clean", + "clean": "rm -rf dist .turbo", + "dev": "node scripts/gen.mjs && tsup src/index.ts --format esm --dts --sourcemap --watch", + "gen": "node scripts/gen.mjs", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "yaml": "^2.6.1" + } +} diff --git a/packages/editor-icons/scripts/gen.mjs b/packages/editor-icons/scripts/gen.mjs new file mode 100644 index 0000000..9499373 --- /dev/null +++ b/packages/editor-icons/scripts/gen.mjs @@ -0,0 +1,1048 @@ +// Generates src/generated/icons.ts from the canonical ICONS.md front-matter. +// ICONS.md (this package's root) is the single source of truth; this keeps the +// TypeScript icon registry in lock-step with it. Run automatically before every +// build, mirroring @airship/editor-tokens' scripts/gen.mjs. +// +// The corpus is an imported UI set: mostly 24x24, filled +// paths, two-tone via fill-opacity. This script normalises it into something an +// overlay can inline safely — see ICONS.md's "What the generator normalises". +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { gzipSync } from "node:zlib"; +import { parse } from "yaml"; + +const here = dirname(fileURLToPath(import.meta.url)); +const specPath = resolve(here, "../ICONS.md"); +const assetsRoot = resolve(here, "../assets"); +const outPath = resolve(here, "../src/generated/icons.ts"); + +/** + * Where one manifest entry's file lives. + * + * Paths are relative to `assets/ui`, the imported set, except for the + * handful under `local/` — the marks that set does not carry (the brand mark, + * the panel toggles, and the agent logos). They sit in the same manifest and go + * through the same normalisation deliberately: authored separately they drifted + * to a 16-unit inset against the set's 12.8, and nothing caught it. + */ +const assetPath = (rel) => + rel.startsWith("local/") + ? resolve(assetsRoot, rel) + : resolve(assetsRoot, "ui", rel); + +/** Every manifest path is lowercase kebab, directories included. */ +const ASSET_PATH = /^([a-z0-9]+(-[a-z0-9]+)*\/)*[a-z0-9]+(-[a-z0-9]+)*\.svg$/; + +if (!existsSync(specPath)) { + throw new Error(`gen: cannot find canonical manifest at ${specPath}`); +} + +const raw = readFileSync(specPath, "utf8"); +const match = raw.match(/^---\n([\s\S]*?)\n---/); +if (!match) { + throw new Error("gen: ICONS.md is missing its YAML front-matter block"); +} + +const front = parse(match[1]); +const manifest = front?.icons; +if (!manifest) { + throw new Error("gen: ICONS.md front-matter has no `icons:` section"); +} +const budgetKb = Number(front.budget_kb) || Number.POSITIVE_INFINITY; + +// --------------------------------------------------------------------------- +// Normalisation +// --------------------------------------------------------------------------- + +/** + * Paint values that mean "the icon's own colour" and become `currentColor`. + * `white` is NOT here: the 18 files that use it paint / rects, + * which must stay opaque or the icon disappears. Neither is `#F24822`, which is + * a semantic red on the one error glyph that carries it. + */ +const INK = new Set(["#1b1f24", "#007be5", "black"]); + +/** The `` root and the pieces of it this script reads. */ +const SVG_OPEN_TAG = /]*)>/i; +const SVG_TAG_NAME = / { + const r = Number.parseFloat(n); + // `toFixed` then strip the trailing zeros it adds: 6.20 -> 6.2, 6.00 -> 6. + const fixed = r.toFixed(2).replace(TRAILING_ZEROS, ""); + return fixed === "" || fixed === "-" ? "0" : fixed; + }); +} + +/** Attributes whose values are geometry and therefore worth rounding. */ +const GEOMETRY_ATTRS = new Set([ + "d", + "points", + "transform", + "x", + "y", + "x1", + "y1", + "x2", + "y2", + "cx", + "cy", + "r", + "rx", + "ry", + "width", + "height", + "offset", + "stroke-width", + "stroke-dasharray", +]); + +/** + * The tone a glyph's subject paints at. + * + * The set's two-tone grammar is 0.9 for the subject and 0.3 for the context it + * sits in — the Constraints and Auto Layout glyphs draw their frame at 0.3 and + * the meaningful part at 0.9. But it was only applied to some of the corpus: + * 146 icons had at least one element carrying no opacity at all, so they painted + * at 100% beside neighbours at 90%. That is the same "one of these looks heavier + * than the others" problem as the size drift, one property over. + * + * So an element that names `currentColor` and states no opacity gets the subject + * tone. Explicit values are never touched, which is what keeps the 0.3 context + * paths intact rather than flattening the two-tone into mush. + */ +const SUBJECT_TONE = "0.9"; + +/** + * Give an element the subject tone if it paints and has not chosen one. + * + * Applied per element rather than on the refit ``: opacity inherited from a + * group *multiplies* with the child's own, so a group at 0.9 would drag the + * 0.3 context paths down to 0.27 and quietly re-tune every two-tone glyph. + */ +function applySubjectTone(attrs) { + let out = attrs; + for (const channel of ["fill", "stroke"]) { + const paints = new RegExp(`\\b${channel}="currentColor"`).test(out); + const stated = new RegExp(`\\b${channel}-opacity=`).test(out); + if (paints && !stated) { + out += ` ${channel}-opacity="${SUBJECT_TONE}"`; + } + } + return out; +} + +/** + * Rewrite one element's attributes. + * + * `inDefs` is the whole reason this walks tags rather than running a global + * regex: inside the fills are structural (a mask's white rect is what + * makes the mask show anything), so paint there must be left exactly as-is. + */ +function rewriteAttrs(attrs, { slug, inDefs, ids }) { + return attrs.replace( + /([:\w-]+)\s*=\s*"([^"]*)"/g, + (_whole, name, valueRaw) => { + const key = name.toLowerCase(); + let value = valueRaw; + + if (key === "xmlns" || key === "xmlns:xlink") { + return ""; + } + if ( + (key === "fill" || key === "stroke") && + !inDefs && + INK.has(value.trim().toLowerCase()) + ) { + value = "currentColor"; + } + if (key === "id") { + ids.add(value); + value = `ap-${slug}-${value}`; + } + if (GEOMETRY_ATTRS.has(key)) { + value = roundNumbers(value); + } + // url(#x), href="#x", xlink:href="#x" — every way an id is referenced. + if (value.includes("#")) { + value = value.replace(/url\(#([^)]+)\)/g, (_m, id) => { + ids.add(id); + return `url(#ap-${slug}-${id})`; + }); + if ((key === "href" || key === "xlink:href") && value.startsWith("#")) { + const id = value.slice(1); + ids.add(id); + value = `#ap-${slug}-${id}`; + } + } + return `${name}="${value}"`; + } + ); +} + +/** An icon that paints with a stroke rather than a fill. */ +const STROKE_PAINT = /stroke="currentColor"/; + +/** A path command letter, as opposed to one of its numeric operands. */ +const PATH_COMMAND = /^[MmLlHhVvCcSsQqTtAaZz]$/; +/** Command letters and numbers, exponent notation included. */ +const PATH_TOKEN = /[MmLlHhVvCcSsQqTtAaZz]|-?(?:\d*\.\d+|\d+)(?:e[-+]?\d+)?/gi; + +/** Evaluate one axis of a cubic at `t`. */ +function cubicAt(a, b, c, d, t) { + const u = 1 - t; + return u * u * u * a + 3 * u * u * t * b + 3 * u * t * t * c + t * t * t * d; +} + +/** + * The `t` values in (0,1) where one axis of a cubic turns around. + * + * The derivative is the quadratic `3(At² + Bt + C)`; the leading term vanishes + * whenever the four coordinates are in arithmetic progression, which is common + * enough in machine-drawn artwork to be worth the explicit linear branch. + */ +function cubicRoots(a, b, c, d) { + const A = -a + 3 * b - 3 * c + d; + const B = 2 * (a - 2 * b + c); + const C = b - a; + const out = []; + if (Math.abs(A) < 1e-12) { + if (Math.abs(B) > 1e-12) { + out.push(-C / B); + } + } else { + const disc = B * B - 4 * A * C; + if (disc >= 0) { + const root = Math.sqrt(disc); + out.push((-B + root) / (2 * A), (-B - root) / (2 * A)); + } + } + return out.filter((t) => t > 0 && t < 1); +} + +/** Report a cubic segment's endpoints and its true extrema. */ +function seeCubic(x0, y0, x1, y1, x2, y2, x3, y3, see) { + see(x0, y0); + see(x3, y3); + const ts = [...cubicRoots(x0, x1, x2, x3), ...cubicRoots(y0, y1, y2, y3)]; + for (const t of ts) { + see(cubicAt(x0, x1, x2, x3, t), cubicAt(y0, y1, y2, y3, t)); + } +} + +/** Report a quadratic segment's endpoints and its true extrema. */ +function seeQuadratic(x0, y0, x1, y1, x2, y2, see) { + // Degree-elevate to a cubic rather than repeat the root-finding. + seeCubic( + x0, + y0, + x0 + (2 / 3) * (x1 - x0), + y0 + (2 / 3) * (y1 - y0), + x2 + (2 / 3) * (x1 - x2), + y2 + (2 / 3) * (y1 - y2), + x2, + y2, + see + ); +} + +/** Signed angle between two vectors, per SVG's arc-conversion appendix. */ +function vectorAngle(ux, uy, vx, vy) { + const dot = ux * vx + uy * vy; + const len = Math.hypot(ux, uy) * Math.hypot(vx, vy); + const sign = ux * vy - uy * vx < 0 ? -1 : 1; + return sign * Math.acos(Math.min(1, Math.max(-1, dot / len))); +} + +/** + * Report an elliptical arc's endpoints and any axis extreme it actually sweeps + * through. + * + * Endpoint-to-centre conversion is SVG 1.1 appendix F.6.5 verbatim, including + * the radius correction for arcs whose endpoints are further apart than the + * given radii can reach. The source exporter never emits arcs, but the marks drawn + * by hand for this set do, and a rounded corner clipped out of the box would + * scale the whole glyph wrong. + */ +function seeArc(x1, y1, rxIn, ryIn, rotDeg, largeArc, sweep, x2, y2, see) { + see(x1, y1); + see(x2, y2); + let rx = Math.abs(rxIn); + let ry = Math.abs(ryIn); + if (rx === 0 || ry === 0) { + return; + } + const phi = (rotDeg * Math.PI) / 180; + const cosP = Math.cos(phi); + const sinP = Math.sin(phi); + const dx = (x1 - x2) / 2; + const dy = (y1 - y2) / 2; + const xp = cosP * dx + sinP * dy; + const yp = -sinP * dx + cosP * dy; + const lambda = (xp * xp) / (rx * rx) + (yp * yp) / (ry * ry); + if (lambda > 1) { + const s = Math.sqrt(lambda); + rx *= s; + ry *= s; + } + const numerator = rx * rx * ry * ry - rx * rx * yp * yp - ry * ry * xp * xp; + const den = rx * rx * yp * yp + ry * ry * xp * xp; + const co = + (largeArc === sweep ? -1 : 1) * + Math.sqrt(Math.max(0, numerator / den || 0)); + const cxp = (co * (rx * yp)) / ry; + const cyp = (-co * (ry * xp)) / rx; + const cx = cosP * cxp - sinP * cyp + (x1 + x2) / 2; + const cy = sinP * cxp + cosP * cyp + (y1 + y2) / 2; + const theta = vectorAngle(1, 0, (xp - cxp) / rx, (yp - cyp) / ry); + let sweepAngle = vectorAngle( + (xp - cxp) / rx, + (yp - cyp) / ry, + (-xp - cxp) / rx, + (-yp - cyp) / ry + ); + if (!sweep && sweepAngle > 0) { + sweepAngle -= 2 * Math.PI; + } else if (sweep && sweepAngle < 0) { + sweepAngle += 2 * Math.PI; + } + const TWO_PI = 2 * Math.PI; + const sweeps = (t) => { + let delta = (t - theta) % TWO_PI; + if (sweepAngle >= 0) { + if (delta < 0) { + delta += TWO_PI; + } + return delta <= sweepAngle + 1e-9; + } + if (delta > 0) { + delta -= TWO_PI; + } + return delta >= sweepAngle - 1e-9; + }; + // Where dx/dθ and dy/dθ vanish, plus their antipodes. + const bases = [ + Math.atan2(-ry * sinP, rx * cosP), + Math.atan2(ry * cosP, rx * sinP), + ]; + for (const base of bases) { + for (let k = -2; k <= 2; k += 1) { + const t = base + k * Math.PI; + if (sweeps(t)) { + see( + cx + rx * Math.cos(t) * cosP - ry * Math.sin(t) * sinP, + cy + rx * Math.cos(t) * sinP + ry * Math.sin(t) * cosP + ); + } + } + } +} + +/** Coordinate pairs made absolute against the current point. */ +function absolutise(state, args, rel) { + return args.map((n, k) => + rel ? n + (k % 2 === 0 ? state.cx : state.cy) : n + ); +} + +/** + * One entry per path command: how many operands it takes, and what it draws. + * + * A table rather than a chain of branches because the walker's only real job is + * operand bookkeeping — arity, the relative-versus-absolute offset, and which + * coordinates become the new current point. Each handler is then just the + * geometry it owns, and adding a command is adding a row. + */ +const PATH_OPS = { + A: { + // rx ry rotation large-arc-flag sweep-flag x y — only the last pair is a + // coordinate, which is why this one does its own offsetting. + arity: 7, + run(state, args, rel) { + const [rx, ry, rotation, largeArc, sweep, rawX, rawY] = args; + const x = rel ? state.cx + rawX : rawX; + const y = rel ? state.cy + rawY : rawY; + seeArc( + state.cx, + state.cy, + rx, + ry, + rotation, + largeArc !== 0, + sweep !== 0, + x, + y, + state.see + ); + state.cx = x; + state.cy = y; + }, + }, + C: { + arity: 6, + run(state, args, rel) { + const [x1, y1, x2, y2, x, y] = absolutise(state, args, rel); + seeCubic(state.cx, state.cy, x1, y1, x2, y2, x, y, state.see); + state.ctrlX = x2; + state.ctrlY = y2; + state.cx = x; + state.cy = y; + }, + }, + H: { + arity: 1, + run(state, [x], rel) { + state.cx = rel ? state.cx + x : x; + state.see(state.cx, state.cy); + }, + }, + L: { + arity: 2, + run(state, [x, y], rel) { + state.cx = rel ? state.cx + x : x; + state.cy = rel ? state.cy + y : y; + state.see(state.cx, state.cy); + }, + }, + M: { + arity: 2, + run(state, [x, y], rel) { + state.cx = rel ? state.cx + x : x; + state.cy = rel ? state.cy + y : y; + state.sx = state.cx; + state.sy = state.cy; + state.see(state.cx, state.cy); + }, + }, + Q: { + arity: 4, + run(state, args, rel) { + const [x1, y1, x, y] = absolutise(state, args, rel); + seeQuadratic(state.cx, state.cy, x1, y1, x, y, state.see); + state.ctrlX = x1; + state.ctrlY = y1; + state.cx = x; + state.cy = y; + }, + }, + S: { + arity: 4, + run(state, args, rel) { + const [x2, y2, x, y] = absolutise(state, args, rel); + // The first control point is the previous curve's trailing one, mirrored + // through the current point — or the current point itself if the command + // before this was not a cubic. + const smooth = state.prev === "C" || state.prev === "S"; + const x1 = smooth ? 2 * state.cx - state.ctrlX : state.cx; + const y1 = smooth ? 2 * state.cy - state.ctrlY : state.cy; + seeCubic(state.cx, state.cy, x1, y1, x2, y2, x, y, state.see); + state.ctrlX = x2; + state.ctrlY = y2; + state.cx = x; + state.cy = y; + }, + }, + T: { + arity: 2, + run(state, args, rel) { + const [x, y] = absolutise(state, args, rel); + const smooth = state.prev === "Q" || state.prev === "T"; + const x1 = smooth ? 2 * state.cx - state.ctrlX : state.cx; + const y1 = smooth ? 2 * state.cy - state.ctrlY : state.cy; + seeQuadratic(state.cx, state.cy, x1, y1, x, y, state.see); + state.ctrlX = x1; + state.ctrlY = y1; + state.cx = x; + state.cy = y; + }, + }, + V: { + arity: 1, + run(state, [y], rel) { + state.cy = rel ? state.cy + y : y; + state.see(state.cx, state.cy); + }, + }, + Z: { + arity: 0, + run(state) { + state.cx = state.sx; + state.cy = state.sy; + }, + }, +}; + +/** + * Walk one `d` attribute, reporting every point the outline actually reaches. + * + * The whole command set, absolute and relative, because this now measures every + * glyph in the set rather than the ~12% of odd-sized exports it used to — and + * because the hand-drawn marks use arcs and relative commands that the source + * exporter does not. + */ +function walkPath(d, see) { + const toks = d.match(PATH_TOKEN) ?? []; + const state = { + ctrlX: 0, + ctrlY: 0, + cx: 0, + cy: 0, + prev: "", + see, + sx: 0, + sy: 0, + }; + let i = 0; + let cmd = ""; + while (i < toks.length) { + if (PATH_COMMAND.test(toks[i])) { + cmd = toks[i]; + i += 1; + } else if (!cmd) { + i += 1; + continue; + } + const upper = cmd.toUpperCase(); + const op = PATH_OPS[upper]; + if (!op) { + i += 1; + continue; + } + const args = toks.slice(i, i + op.arity).map(Number); + i += op.arity; + op.run(state, args, cmd !== upper); + state.prev = upper; + if (upper === "M") { + // Extra coordinate pairs after a moveto are implicit linetos. + cmd = cmd === "M" ? "L" : "l"; + } else if (upper === "Z") { + // Z takes no operands, so leaving it current would spin on a stray + // number. A closepath is always followed by a command in valid data. + cmd = ""; + } + } +} + +/** Every `name="value"` pair on one element. */ +const ATTR_PAIR = /([:\w-]+)\s*=\s*"([^"]*)"/g; +/** `translate(…)`/`scale(…)` and their operands, in source order. */ +const TRANSFORM_FN = /(translate|scale)\(([^)]*)\)/g; +/** Signed decimals, for a transform's operand list. */ +const TRANSFORM_ARG = /-?[\d.]+(?:e[-+]?\d+)?/gi; +/** One element, opening or closing, with its attributes. */ +const TAG = /<(\/?)([\w:-]+)((?:\s+[^>]*?)?)(\/?)>/g; + +/** + * An element's attributes as a plain object, lower-cased. + * + * Parsed once per element rather than probed with a `new RegExp` per lookup — + * this runs for every tag of every icon on every build. + */ +function attrsOf(text) { + const out = {}; + for (const m of text.matchAll(ATTR_PAIR)) { + out[m[1].toLowerCase()] = m[2]; + } + return out; +} + +/** One attribute as a number, or `fallback` when absent or unparseable. */ +function num(attrs, name, fallback = Number.NaN) { + const v = attrs[name]; + if (v === undefined) { + return fallback; + } + const n = Number(v); + return Number.isFinite(n) ? n : fallback; +} + +/** `outer ∘ inner` — the transform mapping a point through both. */ +function composeTransform(outer, inner) { + return { + ax: outer.ax * inner.ax, + ay: outer.ay * inner.ay, + tx: outer.ax * inner.tx + outer.tx, + ty: outer.ay * inner.ty + outer.ty, + }; +} + +/** + * One element's `transform` as `{ ax, ay, tx, ty }`. + * + * Functions compose right-to-left — `translate(a) scale(b)` scales first — so + * the list folds in source order with each new function on the inside. Only + * `translate` and `scale` ever appear, so a transform is four numbers and + * composition stays closed; no matrix type is needed. + */ +function parseTransform(attrs) { + let out = { ax: 1, ay: 1, tx: 0, ty: 0 }; + const spec = attrs.transform; + if (!spec) { + return out; + } + for (const m of spec.matchAll(TRANSFORM_FN)) { + const args = (m[2].match(TRANSFORM_ARG) ?? []).map(Number); + const fn = + m[1] === "translate" + ? { ax: 1, ay: 1, tx: args[0] ?? 0, ty: args[1] ?? 0 } + : { ax: args[0] ?? 1, ay: args[1] ?? args[0] ?? 1, tx: 0, ty: 0 }; + out = composeTransform(out, fn); + } + return out; +} + +/** Report one leaf shape's extreme points, in its own coordinate space. */ +function seeShape(tag, attrs, see) { + if (tag === "path") { + if (attrs.d) { + walkPath(attrs.d, see); + } + return; + } + if (tag === "rect") { + const w = num(attrs, "width"); + const h = num(attrs, "height"); + if (Number.isFinite(w) && Number.isFinite(h)) { + const x = num(attrs, "x", 0); + const y = num(attrs, "y", 0); + see(x, y); + see(x + w, y + h); + } + return; + } + if (tag === "circle" || tag === "ellipse") { + const rx = tag === "circle" ? num(attrs, "r") : num(attrs, "rx"); + const ry = tag === "circle" ? num(attrs, "r") : num(attrs, "ry"); + if (Number.isFinite(rx) && Number.isFinite(ry)) { + const cx = num(attrs, "cx", 0); + const cy = num(attrs, "cy", 0); + see(cx - rx, cy - ry); + see(cx + rx, cy + ry); + } + } +} + +/** Elements whose geometry defines something rather than painting it. */ +function isStructural(tag) { + return tag === "defs" || tag === "clippath" || tag === "mask"; +} + +/** Unwind one closing tag. */ +function closeTag(tag, ctx) { + if (isStructural(tag)) { + ctx.skipDepth = Math.max(0, ctx.skipDepth - 1); + } else if (tag === "g" && ctx.stack.length > 1) { + ctx.stack.pop(); + } +} + +/** Fold one tag into the measuring walk. */ +function applyTag(m, ctx) { + const [, slash, tagRaw, attrText, selfClose] = m; + const tag = tagRaw.toLowerCase(); + if (slash) { + closeTag(tag, ctx); + return; + } + if (isStructural(tag)) { + ctx.skipDepth += selfClose ? 0 : 1; + return; + } + if (tag === "g") { + // Pushed even inside a skipped subtree, so the pops stay balanced. + if (!selfClose) { + ctx.stack.push( + composeTransform(ctx.top(), parseTransform(attrsOf(attrText))) + ); + } + return; + } + if (ctx.skipDepth === 0) { + seeShape(tag, attrsOf(attrText), ctx.see); + } +} + +/** + * The artwork's exact bounding box, in the source file's own coordinates. + * + * Two things this gets right that a coordinate-pair scan does not, and both + * matter now that the box scales *every* glyph rather than the odd-sized few: + * + * - Curves contribute their extrema, not their control points. A control point + * can sit well outside the ink it shapes, and `H`/`V` operands are single + * numbers that a pair-wise scan drops entirely — 297 of the 346 glyphs use + * them, and a pure `M6.5 11.5H17.5` measured 0×0. + * - ``, `` and `` are excluded. Their geometry is + * structural: a mask's white rect is full-bleed by construction, and the + * `toolbar/move.svg` mask alone spans 17 of its 24 units. + * + * Stroke width is deliberately *not* added here; `centrelineTarget` accounts + * for it once, at the point where the scale is chosen. + * + * `` is honoured, which is what lets the same function measure an + * icon this script has already refit — the verification pass at the end reads + * the emitted markup rather than trusting the transform it just wrote. + */ +function artworkBox(body) { + let minX = Number.POSITIVE_INFINITY; + let minY = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + let maxY = Number.NEGATIVE_INFINITY; + + const stack = [{ ax: 1, ay: 1, tx: 0, ty: 0 }]; + const top = () => stack.at(-1); + const see = (x, y) => { + const t = top(); + const px = t.ax * x + t.tx; + const py = t.ay * y + t.ty; + if (Number.isFinite(px) && Number.isFinite(py)) { + minX = Math.min(minX, px); + maxX = Math.max(maxX, px); + minY = Math.min(minY, py); + maxY = Math.max(maxY, py); + } + }; + + const ctx = { see, skipDepth: 0, stack, top }; + TAG.lastIndex = 0; + let m = TAG.exec(body); + while (m) { + applyTag(m, ctx); + m = TAG.exec(body); + } + + if (!Number.isFinite(minX)) { + return null; + } + return { h: maxY - minY, w: maxX - minX, x: minX, y: minY }; +} + +/** + * The reference box, and the optical size the artwork occupies inside it. + * + * The set insets its glyphs heavily and that inset *is* its visual rhythm, + * but it is a rhythm the corpus only keeps on average. Measured properly the + * artwork spans anywhere from 7 to 24 of the 24 units — a median of 13.0 and a + * p90 of 16.0 — so "already 24×24" was never the same thing as "already at the + * reference size". Icons drawn from both ends of that spread landed side by side + * in the bottom bar at a 2.6× difference in painted ink. + * + * So every glyph is measured and mapped onto this one number, and + * `icon(name, "md")` now means one optical size everywhere. The cost is that + * artwork which happened to sit on the pixel grid no longer does; the set is + * filled silhouettes rather than hairlines, which is what makes that affordable. + */ +const REF_BOX = 24; +const REF_ARTWORK = 12.8; + +/** + * Stroke weight on the reference box, in user units. + * + * The 26 stroked glyphs declare no `stroke-width`, so they inherit 1 unit — and + * the refit `` then scales it along with the geometry. Left alone that makes + * stroke weight a function of how big the source artwork happened to be drawn: + * `chev-down` came out at 3.02 units against its neighbours' 1.0. Pinning the + * width on the group and dividing by the scale cancels the transform exactly, so + * every stroked glyph paints the same weight as every other. + */ +const STROKE_REF = 1.5; + +/** `translate(tx ty) scale(s)` centring a glyph's artwork at `target` units. */ +function fitTransform(art, target) { + const scale = target / Math.max(art.w, art.h); + const r3 = (n) => Number(n.toFixed(3)); + const tx = r3(REF_BOX / 2 - scale * (art.x + art.w / 2)); + const ty = r3(REF_BOX / 2 - scale * (art.y + art.h / 2)); + return { scale, transform: `translate(${tx} ${ty}) scale(${r3(scale)})` }; +} + +/** + * The span a glyph's *centrelines* must occupy for its painted extent to be + * `REF_ARTWORK`. + * + * A fill's edge is its path; a stroke's edge is half a stroke-width outside its + * path on either side. Measuring both as their geometry and scaling both to the + * same number would leave every stroked glyph a full stroke bigger than the + * filled ones beside it — which is the same class of mismatch this whole pass + * exists to remove, just one level down. + */ +function centrelineTarget(stroked) { + return stroked ? REF_ARTWORK - STROKE_REF : REF_ARTWORK; +} + +/** + * Normalise one SVG file into `{ box, body }`. + * + * Deliberately a tag-level rewrite rather than a real XML parse: the corpus is + * machine-generated by the source exporter and uniformly well-formed, and pulling + * in a DOM implementation for a build script that only ever touches attributes + * would be the more fragile choice. + */ +function normalise(slug, source, file) { + const rootMatch = source.match(SVG_OPEN_TAG); + if (!rootMatch) { + throw new Error(`gen: ${file} has no root`); + } + const viewBox = rootMatch[1].match(VIEWBOX_ATTR); + if (!viewBox) { + // A shared default would silently squash the ~40 non-24 icons in this set. + throw new Error( + `gen: ${file} has no viewBox — cannot infer a safe default` + ); + } + const nums = viewBox[1].trim().split(VIEWBOX_SEPARATOR).map(Number); + if (nums.length !== 4 || nums.some((n) => !Number.isFinite(n))) { + throw new Error(`gen: ${file} has an unparseable viewBox "${viewBox[1]}"`); + } + const [minX, minY] = nums; + if (minX !== 0 || minY !== 0) { + throw new Error(`gen: ${file} has a non-zero viewBox origin — unsupported`); + } + + const open = source.indexOf(">", source.search(SVG_TAG_NAME)) + 1; + const close = source.lastIndexOf(""); + let body = source.slice(open, close); + + const ids = new Set(); + let defsDepth = 0; + body = body.replace( + /<(\/?)([\w:-]+)((?:\s+[^>]*?)?)(\/?)>/g, + (_whole, slash, tag, attrs, selfClose) => { + const isDefs = DEFS_TAG.test(tag); + if (slash) { + if (isDefs) { + defsDepth = Math.max(0, defsDepth - 1); + } + return ``; + } + // A element's own attributes (its id) are rewritten as normal; + // only its *children* count as "in defs" for paint purposes. + const next = rewriteAttrs(attrs, { ids, inDefs: defsDepth > 0, slug }); + const toned = defsDepth > 0 ? next : applySubjectTone(next); + if (isDefs && !selfClose) { + defsDepth += 1; + } + const clean = toned.replace(/\s+/g, " ").trim(); + return `<${tag}${clean ? ` ${clean}` : ""}${selfClose ? "/" : ""}>`; + } + ); + + body = body.replace(/>\s+<").replace(/\s+/g, " ").trim(); + + // Warn on paint we chose not to touch, so a future set change is visible. + const exotic = [...body.matchAll(/(?:fill|stroke)="(#[0-9a-f]{3,8})"/gi)] + .map((m) => m[1].toLowerCase()) + .filter((c) => c !== "#fff" && c !== "#ffffff"); + if (exotic.length) { + warnings.push( + `${slug}: kept literal paint ${[...new Set(exotic)].join(", ")}` + ); + } + + // Everything lands on one box at one optical size — including the glyphs that + // were already 24×24, which is the whole point. `w`/`h` are not consulted: + // the source viewBox says where the artwork was drawn, never how big it is. + const art = artworkBox(body); + if (!art) { + throw new Error( + `gen: ${file} has no measurable artwork, ` + + "so it cannot be brought onto the 24 box" + ); + } + const span = Math.max(art.w, art.h); + if (!(span > 0)) { + // A single point, or geometry the walker could not read. Scaling by + // `REF_ARTWORK / 0` would emit an `Infinity` transform and erase the glyph. + throw new Error( + `gen: ${file} measures ${art.w}×${art.h} — degenerate artwork cannot be ` + + "scaled onto the reference" + ); + } + // Only stroked glyphs need the width pinned; adding it unconditionally would + // put a dead attribute on every filled icon in the set. + const stroked = STROKE_PAINT.test(body); + const { scale, transform } = fitTransform(art, centrelineTarget(stroked)); + const strokeAttr = stroked + ? ` stroke-width="${Number((STROKE_REF / scale).toFixed(3))}"` + : ""; + return { + body: `${body}`, + box: REF_BOX, + ids: ids.size, + refit: true, + span, + stroked, + }; +} + +// --------------------------------------------------------------------------- +// Build +// --------------------------------------------------------------------------- + +const warnings = []; +const icons = []; +const missing = []; + +for (const [slug, relPath] of Object.entries(manifest)) { + if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(slug)) { + throw new Error(`gen: slug "${slug}" is not kebab-case`); + } + // The asset paths are held to the same grammar as the slugs. This is not + // cosmetic: dev is a case-insensitive filesystem, so `existsSync` below + // happily resolves `General/X.svg` against `general/x.svg` and a stale + // manifest entry survives every check until CI checks out on Linux. This + // assert is the only thing that catches it here. + if (!ASSET_PATH.test(relPath)) { + throw new Error(`gen: asset path "${relPath}" is not lowercase kebab-case`); + } + const file = assetPath(relPath); + if (!existsSync(file)) { + missing.push(`${slug} -> ${relPath}`); + continue; + } + icons.push([slug, normalise(slug, readFileSync(file, "utf8"), relPath)]); +} + +if (missing.length) { + throw new Error( + `gen: ${missing.length} manifest entries point at files that do not exist:\n ${missing.join("\n ")}` + ); +} + +icons.sort(([a], [b]) => a.localeCompare(b)); + +const banner = + "// AUTO-GENERATED from /packages/editor-icons/ICONS.md + assets/ui by scripts/gen.mjs.\n" + + "// Do not edit by hand — edit ICONS.md and run `pnpm --filter @airship/editor-icons gen`.\n\n"; + +const entries = icons + .map(([slug, { box, body }]) => { + // Bodies contain double quotes and no backticks or ${, so a template + // literal is the one quoting style that needs no escaping at all. + return ` "${slug}": { box: ${box}, body: \`${body}\` },`; + }) + .join("\n"); + +const out = `${banner}export interface EditorIcon { + /** + * The reference viewBox, always ${REF_BOX}. Stored rather than assumed so the + * renderer has no constant of its own to keep in step, and so changing + * \`REF_BOX\` is a one-line change here rather than a hunt across packages. + * + * It used to be \`number | readonly [number, number]\`, back when odd-ratio + * exports kept their own aspect and the renderer derived a width from it. + * Nothing has a non-square box any more — every glyph is refit — so that + * union only ever widened the type. + */ + box: ${REF_BOX}; + /** + * Inner markup: paint normalised to \`currentColor\`, element ids namespaced + * per icon, coordinates rounded to 2dp. \`fill-opacity\` is preserved — the + * 0.9/0.3 two-tone is the set's most recognisable property. + */ + body: string; +} + +export const EDITOR_ICONS = { +${entries} +} as const satisfies Record; + +export type EditorIconName = keyof typeof EDITOR_ICONS; +`; + +mkdirSync(dirname(outPath), { recursive: true }); +writeFileSync(outPath, out); + +// --------------------------------------------------------------------------- +// Report +// --------------------------------------------------------------------------- + +const markup = icons.reduce((n, [, i]) => n + i.body.length, 0); +const gz = gzipSync(Buffer.from(icons.map(([, i]) => i.body).join(""))).length; +const kb = (n) => `${(n / 1024).toFixed(1)} KB`; + +const byPrefix = new Map(); +for (const [slug, icon] of icons) { + const key = slug.includes("-") ? `${slug.split("-")[0]}-*` : "(root)"; + byPrefix.set(key, (byPrefix.get(key) ?? 0) + icon.body.length); +} +const top = [...byPrefix.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8); + +console.log(`gen: wrote ${outPath}`); +console.log( + `gen: ${icons.length} icons, ${kb(markup)} markup (${kb(gz)} gzipped)` +); +console.log( + `gen: largest groups — ${top.map(([k, v]) => `${k} ${kb(v)}`).join(", ")}` +); +const withIds = icons.filter(([, i]) => i.ids > 0); +if (withIds.length) { + console.log( + `gen: namespaced ids in ${withIds.length} icons (${withIds.map(([s]) => s).join(", ")})` + ); +} +// How far the corpus had drifted, reported as the spread that was corrected — +// the interesting number now that every glyph is refit by construction. +const spans = icons.map(([, i]) => i.span).sort((a, b) => a - b); +const at = (p) => spans[Math.floor(p * (spans.length - 1))]; +console.log( + `gen: refit all ${icons.length} icons onto the ${REF_BOX} box at ` + + `${REF_ARTWORK} units — source spans ran ${spans[0].toFixed(1)}..` + + `${spans.at(-1).toFixed(1)} (median ${at(0.5).toFixed(1)})` +); +const strokedIcons = icons.filter(([, i]) => i.body.includes("stroke-width=")); +if (strokedIcons.length) { + console.log( + `gen: pinned stroke weight to ${STROKE_REF} units on ${strokedIcons.length} stroked icons` + ); +} +for (const w of warnings) { + console.warn(`gen: warning — ${w}`); +} + +/* + * Prove the refit actually landed, by measuring the emitted markup rather than + * trusting the transform we just wrote. This is the guard that would have caught + * the original drift: "already 24×24" was taken as "already at the reference + * size" for 300 glyphs, and nothing ever checked. + */ +const EPSILON = 0.01; +const offReference = []; +for (const [slug, icon] of icons) { + const art = artworkBox(icon.body); + // Painted extent, not geometry: a stroke reaches half its width past the path + // it follows, which is exactly what `centrelineTarget` set the scale to allow. + const painted = art + ? Math.max(art.w, art.h) + (icon.stroked ? STROKE_REF : 0) + : Number.NaN; + if (!(Math.abs(painted - REF_ARTWORK) <= EPSILON)) { + offReference.push( + `${slug} (${Number.isFinite(painted) ? painted.toFixed(2) : "unmeasurable"})` + ); + } +} +if (offReference.length) { + throw new Error( + `gen: ${offReference.length} icons did not land on the ${REF_ARTWORK}-unit ` + + `reference: ${offReference.join(", ")}` + ); +} + +if (markup / 1024 > budgetKb) { + throw new Error( + `gen: ${kb(markup)} of icon markup exceeds budget_kb: ${budgetKb}. ` + + "Either drop icons from ICONS.md or raise the budget deliberately — " + + "these are cold-start bytes on every page load." + ); +} diff --git a/packages/editor-icons/src/index.ts b/packages/editor-icons/src/index.ts new file mode 100644 index 0000000..d06b9ed --- /dev/null +++ b/packages/editor-icons/src/index.ts @@ -0,0 +1,5 @@ +export { + EDITOR_ICONS, + type EditorIcon, + type EditorIconName, +} from "./generated/icons"; diff --git a/packages/editor-icons/tsconfig.json b/packages/editor-icons/tsconfig.json new file mode 100644 index 0000000..a2744fc --- /dev/null +++ b/packages/editor-icons/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "types": [], + "strictNullChecks": true + }, + "include": ["src"] +}