The generator reads assets/*.svg and emits one module. Icons arrive from several sets with different viewboxes, stroke widths and fill conventions; normalising at build time means the overlay renders them all from a single code path.
33 KiB
budget_kb, icons
| budget_kb | icons | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 340 |
|
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 <g>. 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
-
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 reportsbox: 24and oneicon(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
rotationat 7.0 next todoc-plusat 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/Vtake a single operand. 297 of the 346 glyphs useH/V; a pair-wise scan drops those operands, andplus'sM6.5 11.5H17.5measured 0×0 — which under a general refit would have divided by zero and erased the glyph. <defs>,<clipPath>and<mask>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_REFso 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.
- Curves contribute their extrema, not their control points, and
-
Paint becomes
currentColor.#1B1F24(481 fills + 36 strokes),#007BE5andblackare rewritten.whiteis left alone — those 18 are<clipPath>/<mask>rects that must stay opaque — as is the single#F24822, which is a semantic red. -
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<g>then scales along with the geometry. Left alone that makes stroke weight a function of how large the source happened to be drawn:chev-downcame out at 3.02 units against its neighbours' 1.0. The generator writesstroke-width="STROKE_REF / scale"on the group, which cancels the transform exactly, so every stroked glyph paints the same weight. -
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.3and the meaningful part at0.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<g>, because inherited opacity multiplies with the child's own — a group at0.9would drag every0.3context path to0.27and silently re-tune the two-tone.All 356 icons now carry an explicit tone; 44 keep a
0.3context layer. -
Element ids are namespaced to
ap-<slug>-<id>. Only 19 files carry ids, but twosearchicons on one page would otherwise share — and break — a clip path. -
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: anAcut 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
Vof 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,historyandrotate-ccwin 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 defaultnonzerorule: 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.svgandshare/lock-open.svgare stroke-expansion exports: a<mask>holding the padlock silhouette, and a visible path that is only the ~1px ring around it. The layers tree draws its glyphs atxs(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.