Skip to content

feat(ui): add the @lody/ui icon set - #701

Draft
wibus-wee wants to merge 12 commits into
feat/ui-v2from
feat/ui-icon-set
Draft

wibus-wee wants to merge 12 commits into
feat/ui-v2from
feat/ui-icon-set

Conversation

@wibus-wee

@wibus-wee wibus-wee commented Sep 14, 2026

Copy link
Copy Markdown
Member

Related issue

Same-repository branch; no intake Issue.

Problem / pressure

Three sources of icons and no shared grid: packages/ui/src/internal/glyphs.tsx draws nine glyphs on a 16 grid at a 1.6 stroke because the package may depend on nothing but React, Base UI and StyleX; packages/components uses react-icons at sixteen sites (almost all brand marks) and inlines an <svg> in roughly four hundred files, each to whatever grid its author had. A menu row chevron and the sidebar toggle are two weights of two strokes on two grids, and every new surface draws a third. The product wants a set of its own, with the intermediate treatments (duotone and bulk) and with state: a sidebar that shows what is in it and whether it is open, animated between the two.

Summary

  • packages/ui/src/icons/registry.ts: 75 original icons on one 24 grid (20 live area, 1.5 stroke, round caps and joins, 2px container corners, 2px git nodes). Follows Nucleo drawing conventions, copies none of its paths. Each icon is marks (outline) plus, where it has depth, layers (mass / mid / front / detail / outer / dots).
  • icon.tsx: <Icon name variant title> and createIcon. Four treatments from the same paths: outline, duotone (mass at 18%), glyph (filled, marks cut through an SVG mask so the cut is a hole and not a painted panel colour), bulk (no stroke; mass 35%, mid 55%, front 100%). An icon states no size and no colour: it fills its box and inherits currentColor, the contract the existing glyphs have. Decoration unless title is given.
  • stateful.tsx: nine two-state icons (sidebar, chevron, check, eye, play/pause, star, bell, refresh, folder) driven by one registered custom property --lody-icon-t that CSS transitions; only transform, opacity and dash offset move, no path morphing; prefers-reduced-motion zeroes the duration.
  • index.ts: 75 named components (SidebarIcon, BranchIcon, ...), exported as @lody/ui/icons.
  • Gallery section (families, sizes 16/20/24/32, variant matrix, two-state pairs), test/icons.test.tsx, README row, AGENTS.md rule (existing bullets trimmed in wording only to stay under the 8 KiB gate), specs/ui-primitives.md Icons section (draft), proposed Agent Note.

Visual explanation

packages/ui/src/icons/
  registry.ts   ICONS[name] = { marks, layers? }   <- the drawing, generated from the design board
  icon.tsx      Icon / createIcon                   <- picks layers per variant
  stateful.tsx  *ToggleIcon, IconFrame              <- --lody-icon-t, CSS transition
  index.ts      75 named exports + re-exports
flowchart LR
  R[registry: marks + layers] --> O[outline: stroke marks]
  R --> D[duotone: mass 18% + marks]
  R --> G[glyph: mask cuts detail + front seam; outer strokes kept]
  R --> B[bulk: mass 35% / mid 55% / front 100%, no outline]
  S[stateful: one svg] -->|--lody-icon-t 0..1| T[transform / opacity / dashoffset]
Loading

Before / after

Before After
Nine 16-grid glyphs in @lody/ui, react-icons and ~400 ad hoc inline SVGs in components One 75-icon set on a 24 grid in @lody/ui/icons, four treatments per drawing, nine animated two-state icons
A filled icon is a second drawing A filled icon is a layer treatment of the same paths; the glyph cut is a mask, correct on any background
Open / closed sidebar are two icons swapped One svg whose divider, dashes and chevron are functions of one number

Test plan

  • pnpm --filter @lody/ui typecheck: clean.
  • pnpm --filter @lody/ui test: 21 files, 261 tests pass (17 new in test/icons.test.tsx: registry shape and grid bounds, a11y contract, the four treatments, mask uniqueness and name-in-id, outer strokes surviving a glyph, bulk without outline, two states as one markup with one number changed).
  • oxlint on the new and changed files: 0 errors.
  • pnpm run docs check: only the four pre-existing acp-extension-dsh broken links remain.
  • Rendered the React output of every icon in every variant in headless Chromium, light and dark: glyph cuts are transparent, bulk faces read as depth.
  • Static Storybook board opened in Chromium (light and dark): the section renders as intended, glyph cuts are transparent, and the --lody-icon-t transition interpolates live (see the PR comment for the computed values). jsdom applies no CSS, so tests cover only the two ends. check:public-boundary failed on uninitialised submodules in this worktree, unrelated to this diff.

Context handoff

Instructions for reviewing agents

  • Review focus: icon.tsx glyph mask (id from useId plus the icon name; strokes outside the mass drawn outside the mask), and stateful.tsx (CSS.registerProperty guard, StyleX values using var(--lody-icon-t), reduced-motion media query).
  • Decisions to challenge: an icon states no size or colour of its own; variants as layer treatments rather than separate drawings; a bell that rings by a sin of the same number rather than a keyframe; the AGENTS.md trims must not have changed any rule.
  • Plausible failures / evidence gaps: the transition is verified on the board but not by tests (jsdom has no CSS); sin() in calc() and registered-property transitions need Chromium, Safari 16.4 or Firefox 128; the 16px rendering is the 24 grid scaled, not an optically corrected small size.

Authoring context

  • User goal / directives: an original icon set for Lody in the manner of Nucleo, with outline, duotone, glyph and bulk treatments, more detail (sidebar with rows and an open/closed state) and animation, landed in the repository now without migrating callers.
  • Constraints / non-goals: @lody/ui depends only on React, Base UI and StyleX; no Nucleo paths (paid library, no files in hand, redistribution understood to be forbidden); no product caller migrates; glyphs.tsx untouched.
  • Risk-bearing decisions: a new public export path @lody/ui/icons; a module-level CSS.registerProperty call guarded for SSR and repeat registration.
  • Destructive or irreversible behavior: none; additive.
  • Deliberately not done or tested: no caller migration, no optically corrected 16px family, no Spinner from refresh.
  • Unknowns / confidence: high for the static set and the four treatments (rendered and tested); high for the transitions after driving one on the board in Chromium; other engines unverified.

🤖 Generated with Claude Code

Copy link
Copy Markdown
Member Author

Board check (static Storybook, Chromium via Playwright), light and dark palettes:

  • The Icons section renders as intended: every icon in a 24px box, the four sizes, the four treatments, accent colour arriving through currentColor.
  • Glyph masks are unique and carry the icon name (lody-icon-copy-_r_jm_), so the cut-outs are transparent on both palettes.
  • --lody-icon-t is registered (CSS.registerProperty reports it already registered), the two sidebar toggles resolve it to 0 / 1, and the divider computes to matrix(1,0,0,1,0,0) vs matrix(1,0,0,1,-3,0); the chevron toggle computes to matrix(-1,0,0,-1,0,0) when open.
  • transition-property: --lody-icon-t / 0.32s is on the svg, and flipping the property live interpolates the divider: -1.56 → -2.45 → -2.82 → -2.97 → -3 over ~330 ms on the standard ease. So the transition works in Chromium, not only the two ends.

One finding about the harness rather than this PR: the static Storybook build does not link the StyleX stylesheet (assets/600-*.css) on the gallery story, so every section of the board renders unstyled until that file is injected; the check above injects it. The first build also died on the default Node heap (NODE_OPTIONS=--max-old-space-size=12288 fixes it).

🤖 Generated with Claude Code

Copy link
Copy Markdown
Member Author

Sidebar toggle reworked after review: the collapsed state no longer pastes a chevron in the content area. The open icon has a wider panel with three rows; collapsing slides the divider 10 → 6.5 and the rows shrink into the rail as dots (transform only), so nothing is added for a state and the static sidebar / sidebar-collapsed icons are the two ends of the same drawing.

🤖 Generated with Claude Code

wibus-wee and others added 11 commits September 16, 2026 01:10
An original 75-icon set drawn in the package on one 24 grid at a 1.5
stroke, following Nucleo's drawing conventions and none of its paths.
`src/icons/registry.ts` holds each drawing as outline marks plus, where
it has depth, a layer model (mass, mid, front, detail, outer, dots);
`Icon` renders it in four treatments — outline, duotone, glyph and bulk —
from those same paths, with a glyph's cuts made through a mask so they
are holes rather than a painted panel colour. Nine stateful icons move
between two states by one registered custom property, `--lody-icon-t`,
that CSS transitions: transform, opacity and dash offset only, no path
morphing, so at rest each state is the static drawing.

Board section, tests, README row, AGENTS.md rule, spec section and a
proposed Agent Note. No product caller migrates yet.

Model: claude-fable-5-1

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The draft-02 design board (`index.html`) the icon registry was generated
from, and a proposed note from a parallel exploration of a wider icon
family. Committed as found in the worktree so the exploration travels
with the PR; neither is consumed by the package.

Model: claude-fable-5-1

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Model: claude-fable-5-1

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`index.html` was not the board the registry was generated from, as the
previous commit claimed: it was a 42-symbol snapshot of the draft in
progress (33 icons plus the model candidates), copied to the worktree
root by a parallel session. The registry was generated from the finished
75-icon board, which `test/icons.test.tsx` and the Agent Note describe;
the board itself is a design artifact and stays out of the tree.

Model: claude-fable-5-1

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…hevron

The collapsed state of the sidebar toggle added a chevron in the content
area, which is a symbol about the sidebar rather than the sidebar. Now
nothing is added for a state: the open icon has a wider panel with three
rows, and collapsing slides the divider from 10 to 6.5 while the rows
shrink into the rail as dots — a narrow sidebar showing only its marks.
The static `sidebar` and `sidebar-collapsed` icons are the two ends of
the same drawing.

Model: claude-fable-5-1

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The set landed as four more rows on the token board, which is reached
through `packages/components`' Storybook. That was wrong twice. The
board's question is "what is this token's value", answered once per token
off the rendered node; an icon has no token, and its question is asked 75
times — find the drawing, put it at the size the surface uses, in that
surface's colour, on the rung it will sit on, and take it away as markup.
Those are controls, not samples. And the set is `@lody/ui`'s, while
Storybook belongs to a package that only consumes it: looking at an icon
should not require building the product's component library.

So `packages/ui/playground` is a Vite page of its own, `pnpm --filter
@lody/ui playground`, with no Storybook under it: search, a size slider
from 12 to 64, the four treatments, the semantic tones, the four rungs —
the accent rung is where a glyph's mask shows, since the cut is a hole
and the rung shows through — a per-icon panel with the drawing enlarged
on the 24 grid and `copy svg`, and the stateful icons under a slow-motion
switch that lets `--lody-icon-t` be watched rather than inferred from its
two ends. `src/gallery/gallery.tsx` goes back to what it was.

Nothing in `playground/` is exported and the package still depends on
React, Base UI and StyleX alone at runtime; `vite` and
`@vitejs/plugin-react` join its devDependencies. `playground.css` carries
the page's own reset and the slow-motion override, and also gives the
StyleX injection target a chunk to exist in: with no CSS of its own the
bundle emits no `index-<hash>.css` and the built page comes out unstyled.

Typecheck, the production build and the dev server were run. The page
itself has not been opened in a browser; the note says so.

Model: claude-opus-5[1m]
…arks

The collapsed state put its divider at 6.5, which leaves a 3-unit rail.
The two strokes bounding that rail take 1.5 of it, so the marks inside
had 1.5 units to live in and were drawn 2 wide: they overlapped the frame
on one side and the divider on the other, and the icon read as three
lumps. The playground is what showed it.

The animated state was wrong a second way. It shrank the rows with
`scaleX`, and a horizontal scale leaves a stroke's thickness alone while
squashing its round caps, so at t = 1 the marks were 0.8 wide against the
static drawing's 2. The two ends of the transition were two different
drawings, which is the one thing a stateful icon here may not be.

The divider now collapses to 8.5: the rail is 5 units and the 3.5 between
its strokes holds one mark of 2 with 0.75 of air either side. The rows
retract by their dash instead of scaling — a dash shortened to zero length
is a round cap and nothing else, which is a dot — and the uniform scale
carrying it from 1.5 to 2 grows the stroke without touching its shape.
Two marks, not three: at 2 wide a third leaves a 1-unit gap, and a size
ladder shows three smearing into one vertical stroke by 20px while two
stay separate. The middle row fades; the outer two land on (6, 9.5) and
(6, 14.5), which is what `sidebar-collapsed` draws.

Checked in the playground in Chromium. The animated t = 1 and the static
drawing were screenshotted at 64px and compared pixel by pixel: 825 ink
pixels against 833, differing only by a one-pixel fringe in one direction
on every stroke, which is a sub-pixel crop offset and not a difference in
shape. The transition was rendered at t = 0, 0.25, 0.5, 0.75 and 1 — the
caps stay round throughout and no sliver appears. The note records the
correction and retires the `translateX(-3px)` measurement, which was read
off the broken drawing.

Model: claude-opus-5[1m]
Drawn with an 11-wide dome over 6 units of straight side the bell was 14
across and 13 down: a tube with a cap on it, not a bell. The dome is 12
now and the side sweeps out to a mouth 16.4 across a 12.7-tall body.

The mouth is the part worth reading. Widening it while it was still a
flange — a straight flare from the side down to the mouth line — made the
filled variants grow horns: the flare met the mouth at 39 degrees, and a
39-degree tip is a spike that `stroke-linejoin: round` hides in the
outline and a fill cannot. Rounding the spike is not available either,
since a 0.8 radius at that angle consumes 2.28 units of a 3.2-unit flare
and there is no flange left. So the flange is gone. The side arrives at
the mouth vertically through a curve, turns through a 0.7 corner, and the
mouth is a line under it — a 90-degree corner, which a fill can hold.
That is the general form of a rule the set already had for glyphs: what
the outline's stroke rounds, the mass has to round itself.

The clapper's arc is a segment rather than a half-circle, so it hangs a
unit clear of the mouth and stops at the live area instead of 1.25 past
it; `front` is that same silhouette filled, which is what gives bulk a
solid clapper under the 35% body.

A wider bell swings wider. The mouth is 16 units from the nub it pivots
on, and at the old 14 degrees the corner furthest from the pivot put its
stroke 0.03 from the canvas — inside, but not a margin. The swing is 12
degrees, which leaves 0.42 and reads the same.

`BellRingIcon` no longer restates the path: it reads `ICONS.bell`, and a
test holds it there. That is the guard the sidebar did not have, and both
of this branch's drawing bugs were a stateful icon and a static one
drifting apart.

Seen in the playground in all four treatments at 12 through 48, and the
swing rendered at -12, -6, 0, 6 and 12 degrees against the canvas edge.
`pnpm --filter @lody/ui test` passes, 262 tests.

Model: claude-opus-5[1m]
`packages/components` imports 239 distinct icons from `lucide-react` across
295 files, 2138 usages. Collapsed onto the drawings that would serve them,
the set covered 78% of that. These sixteen take it to 91%: monitor,
alert-circle, users, circle, shield-alert, folder-plus, arrow-up, mail,
wrench, undo, quote, save, image, pin-off, fork and pull-request-closed.
`monitor` alone answers 52 usages, which is what a product about machines
looks like from the icon layer.

Each takes its family's skeleton before it takes a better idea: `monitor`
and `image` the 2px container `sidebar` and `terminal` already share,
`alert-circle` the circle family's ring with `info-circle` turned over,
`shield-alert` the shield, `users` `user` twice, `fork` and
`pull-request-closed` the git family's two node columns and 2px nodes.
`send` and `arrow-up`, and `pin` and `pin-off`, are one drawing under two
names: the path is a module const rather than two copies, which is the
lesson of the two drift bugs earlier on this branch.

`package` was dropped from the batch rather than drawn — a box with a lid
is `archive` and an isometric one is `model`.

Three were redrawn after the first pass, all failing the same way, reading
at 120px and not at 20. `quote` as a block with the tail notched out of a
corner is a pair of counters by 20px; it is a hook now. `pin-off` broken
into fragments is not a pin, so the pin stays whole and takes the line
across it. `pull-request-closed` had its cross floating clear of the branch
it closes.

`folder-plus` and `users` ended up with no layer model, which is a rule
worth having: **a drawing a fill would cost its meaning takes no layers.**
A cross on a 100% front panel is currentColor on currentColor and what is
left is `folder`; half a person as a stroke beside a filled one is two
fragments off a silhouette. `folder-open` and `user` were already
outline-only for the same reason. The playground's bulk column is where
both showed.

The icon rules move to `src/icons/AGENTS.md`: the parent was 33 bytes under
its gate and these are now a scope of their own, with the grid, the four
treatments, the two-state rules and what to check before calling a drawing
done. Families are reordered so members line up in a list, which is what
that file asks for.

Seen in the playground at 32 and 52 in outline, glyph and bulk.
`pnpm --filter @lody/ui test` passes, 262 tests.

Model: claude-opus-5[1m]
A second reading of the board sent six back, and the reasons divide into
three kinds.

**A shape that is not the thing.** `quote`, twice: a notched block reads as
a counter, and a ball with its tail swept too far round it reads as a
lowercase e. What a quote mark is is a ball with a short tail, so that is
what it is now.

**A proportion that is not the thing's.** `save` at 17 by 15 is a
letterbox; a disk is square, so it is 15 by 15, and its shutter and label
centre on 12. `code` had 4.5 of chevron travel and a slash 4 wide, which
bunched its mass in the middle and read narrow beside everything on its
row; the chevrons reach the live area now and the slash leans across it.

**A part that does not belong to the whole.** `folder-plus` carried the
front-panel line as well as the cross, and that line splits the face the
cross has to sit in — the line is gone and the cross centres on the face.
`pull-request-closed` drew its cross at an arrowhead's size with the branch
stopping two units short of it, so the cross and the branch read as two
drawings sharing a column; the cross is a node's size now and the branch
runs up to meet it. And `fork` put its heads on 6.5 and 17.5 while
`branch`, `merge` and `pull-request` put theirs on 7 and 17 — symmetrical
on its own, out of line the moment the family is read down a list, which is
the only way a family is ever read.

Four of the six read fine enlarged and failed at 20, which is the size they
will be used at. No test sees any of this.

Seen in the playground: all six at 112px on the grid, the git family side
by side against its 7 and 17 columns, and `save` in all four treatments at
16 through 32. `pnpm --filter @lody/ui test` passes, 262 tests.

Model: claude-opus-5[1m]
It was substituted into the batch when `package` turned out to be
`archive`, and it went through four drawings without arriving: a cross
capping the branch, which leaves two columns with nothing between them and
reads as two lollipops rather than a pull request; the branch run up into
the cross to close that gap, which came out a spear; `pull-request`'s arm
restored with a cross for its arrowhead, drawn at the size that fit the
room left above the arm rather than the size a cross is in this set; and
the same again at `x-circle`'s 5. The last was defensible and still not
good enough to keep.

The lesson is about the batch rather than the drawing. It answers four
usages; everything else in the fifteen answers seven or more, and none of
them needed more than one redraw. A drawing that has to say a relation did
not happen — not a thing, not a state — is a harder problem than four
usages justify, and the honest move when `package` fell out was to draw
fifteen, not to fill the slot from the ranked list because it was open.

`packages/components` keeps `lucide-react`'s `GitPullRequestClosed` at
those four sites, as it does for every icon this set has not taken over; no
caller changes. 90 icons.

Model: claude-opus-5[1m]
The gallery file was missing Toggle component definitions and rows during the rebase.
Restored from origin/feat/ui-v2 to include all necessary Toggle, ToggleGroup,
and Toolbar components and their gallery representations.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant