…iser
`toKebabIconName` split `lower-or-digit -> Upper` and `acronym-run -> Word`,
and never `letter -> digit`. `Building2` — the PascalCase component
`lucide-react` exports and the spelling lucide's own site shows an author —
tokenised to `building2` while the canonical key is `building-2`, so the name
matched nothing and `getLazyIcon` degraded it to the `Database` glyph with no
error, no warning and no log.
The new rule is deliberately not unconditional: a negative lookbehind holds the
split off when the letter is itself preceded by a digit, which is lucide's grid
spelling (`Grid2x2` is the Pascal form of `grid-2x2`, where the `x` sits inside
a segment rather than starting one). That is what lands the same single rule on
lucide's primary `grid-2x2` rather than its `grid-2-x-2` alias, and it is why
`Grid3x2` — for which lucide ships no `-3-x-2` alias — is reached too.
The change is a strict superset: every name the previous tokeniser accepted
still tokenises byte-identically, and every canonical kebab spelling is still
returned untouched. Both legs and the capability itself are re-derived from the
installed lucide on every run rather than written down.
Two existing pins move with the seam, both of which had recorded the defect as
intended behaviour:
- the CLI-seam contract test listed `Building2` among lucide's ALIAS spellings.
It never was one; it is canonical, and only the tokeniser could not reach it.
- `record-alert`'s severity-icon controls armed a tripwire for the day
`check-circle-2` became reachable. It fired — for the tokeniser, not for a
lucide release — so `CheckCircle2` moves from DARK to LIT and the DARK half
moves to `LucideCheckCircle2`, a prefix alias that must stay unreachable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011QreXiyMEqKLN4U5daMPVa
Fixes #9414
Session:
session_011QreXiyMEqKLN4U5daMPVa— in prose because a footer link does notsurvive an edit of this description.
What was wrong
packages/components/src/lib/lazy-icon.tsx'stoKebabIconNamesplit two boundaries —lower-or-digit -> Upperandacronym-run -> Word— and neverletter -> digit. SoBuilding2, the PascalCase componentlucide-reactexports and the spelling lucide's ownsite shows an author, tokenised to
building2while the canonical key isbuilding-2. Thename matched nothing and
getLazyIcondegraded it to theDatabaseglyph with no error,no warning and no log — the author saw an icon with no signal that it was not theirs.
Re-measured at this branch's own base — the card's numbers were NOT inherited
Base
63d9ca6,lucide-react@1.35.0as resolved frompackages/components, the card'sreproducer run from that directory so the specifiers resolve:
The
CONTROLline is alive in that same run, so this is a reading rather than a void one.PR #8941 (lucide-react 1.43.0) had not landed at this base, so the population is still
the one the card measured against.
Two differences from the card, both reported rather than smoothed over:
default, the namespace's own default-export key.It is not an icon and stays correctly rejected. The card's residual list of seven was
otherwise exact.
the 155, leaving
defaultanduseLucideContext— a default export and a hook. Neitheris an icon, and both are still rejected.
The stronger reading, which is what the pin actually asserts: every one of the 2,039
canonical icon names is now reachable from its own exported PascalCase spelling, up from
1,882. ⛔ That number is not written into any source file — the test re-derives it from the
installed lucide on every run (AGENTS.md #9).
The fix — one rule, and deliberately not an unconditional one
A third
.replace(...)on the same chain: match a letter immediately followed by adigit and insert a hyphen between them, under a negative lookbehind that refuses the match
when the letter is itself preceded by a digit.
⚠ Spelled in prose rather than pasted, and the reason is on-topic: the literal regex
opens with a question mark, a less-than sign and an exclamation mark, and GitHub's body
sanitizer eats that sequence as an HTML comment opener — including inside a code
fence. The first revision of this description was stored with the
!silently removed,turning the regex into a different one. The exact text lives in
packages/components/src/lib/lazy-icon.tsx.The transform is the inverse of lucide's own
toPascalCase, which builds each exportedcomponent name by dropping the hyphens of a canonical name and upper-casing what followed.
The job is to put a boundary back exactly where
toPascalCaseremoved one — and thenegative lookbehind is what stops it going further. When the letter is itself preceded by a
digit, it sits inside a segment rather than starting one:
Grid2x2is the Pascal form ofgrid-2x2, and an unconditional rule yieldsgrid-2x-2, which is not a name.Design call 1 — a tokeniser rule, not an alias table
The card framed this as smaller vs. auditable, and the measurement inverts that framing.
A table would need 153 hand-listed rows that state a fact derived once and never again —
exactly the shape AGENTS.md #9 rules against — and would rot at every lucide bump with
nothing to notice. The rule is re-derived from its input on every call, and its acceptance
test enumerates the whole canonical population from the installed lucide, so it is
auditable by re-derivation rather than by reading a list. A table would also be a second
de-facto vocabulary standing beside the rule, which is the "N dialects" outcome
commandment #0.1 exists to refuse.
Design call 2 — the
Grid2x2family is INThe card and triage both fenced this as the taker's call, so: in, and reached by the
same single rule rather than a second one.
The second rule the card anticipated would be a
digit -> lettersplit. Measured, itrecovers five of the six by landing on lucide's alias spelling
grid-2-x-2, and stillmisses
Grid3x2— for which lucide ships nogrid-3-x-2alias at all. That leaves anasymmetric hole that is more confusing than a clean stated fence. The lookbehind
refinement of rule 3 lands on lucide's primary
grid-2x2instead, and reaches all six.⇒ including them costs one lookbehind assertion, not a second rule, so leaving them out
would have been a fence with nothing behind it.
The no-loss leg — ⛔ a count that only goes up is not a reading
Triage asked for proof that the 1,882 keys which already survived still survive
byte-identically. Measured over the whole population and pinned as two cases that run on
every CI run:
lucide-reactexport key the pre-finding(components): the icon seam's tokeniser never splits the letter-to-digit boundary, so 147 canonical PascalCase lucide names resolve nowhere in the platform #9414 tokeniser resolved produces thebyte-identical output under the new one — the set of keys whose output moved is empty;
Clause-②: no
Contract-text:the seam's published.d.ts—packages/componentspublishesfiles: ["dist", "README.md", "CHANGELOG.md", "LICENSE"], andtscemits these doccomments verbatim into
dist/lib/lazy-icon.d.ts, where they have shipped since before thiscard:
and the published notifications guide, linked from that same published README:
⇒ the declared accept set is real Lucide icon names, spelled kebab-case or PascalCase.
Building2is precisely that: the PascalCase spellinglucide-reactexports for the livecanonical key
building-2. Not one of the names this change admits sits outside thedeclared set, and the reject rule — a name Lucide does not have still degrades rather than
throwing — is untouched. So the accept set does not move; the implementation stops
under-delivering against it. That is the case the rules exempt from Clause ② in as many
words, which is why the claim's conservative
yesis overturned here rather than carried.⛔ The mechanical half is neutral, not supporting, and is reported as such:
check-widening-tells.mjs --declaration noexits 0 on this diff while printing5 NOT MEASURED ... no declared surface covers it. An exit 0 that examined nothing is notevidence, so the declaration rests on the contract text above.
The pin, before and after
The gate is correctly green on the defect:
check:icon-record-namescensuses what ISauthored, and nothing in this tree authors a digit-suffixed name. So the test pins the
capability instead, and it was proved to fail before the fix — mutation proved on disk,
restore proved by state.
Mutation: rule 3 deleted from
lazy-icon.tsx, i.e. the pre-#9414 tokeniser body restored.Proved on disk by anchor count and by blob hash, ⛔ not by an editor's exit code:
With the fix reverted:
157 canonical names unreachable, which is exactly
2039 - 1882. The two no-loss cases passunder the mutation, which is the correct shape — they assert that nothing moved.
Restore proved by state, ⛔ not by re-running the test:
No build was involved in either leg and none was needed: the root
vitest.config.mtsmaps@object-ui/componentstopackages/components/src, so the mutated source is the subjectunder test directly rather than a stale
dist.Two existing pins recorded the defect as intended behaviour, and both move
lazy-icon-generated-app-contract-7472.test.tslistedBuilding2among lucide's aliasspellings that stopped resolving, beside
HouseIconandLucideHouse. It never was one.The two alias shapes are correctly described and are untouched here;
Building2iscanonical, and only this tokeniser could not reach it. The file now asserts the opposite,
with the correction stated in place.
record-alert.severityIcons.test.tsarmed a tripwire reading "Should lucide ever addcheck-circle2to the dynamic surface, this line goes red — deliberately, because thatwould be worth a look." It fired, for a cause the note did not anticipate: lucide added
nothing, and the seam learned the boundary.
CheckCircle2moves from DARK to LIT; theDARK half moves to
LucideCheckCircle2, a prefix alias that must stay unreachable, so thecontrol still stands on the same footing.
Interaction with PR #8941, stated rather than left to be discovered
The worked-example cases assert that each expected canonical name is a live member of
lucide's dynamic surface, so a lucide release that retires one reds here by design instead
of leaving the assertion restating a dead name. #8941 moves lucide to 1.43.0. If it lands
first, this branch should be merged with
origin/mainand the suite re-run; a red in thosecases is the pin doing its job and names the retired spelling directly. The universal leg
needs no such care — it is derived from whatever lucide is installed.
What ran
lazy-icon-digit-boundary-9414.test.tspackages/components/+packages/plugin-detail/full suitesturbo run type-checkfor both packagesturbo run lintfor both packagescheck:icon-record-namesOK ... 184 authored/declared names reaching 1 record-reading resolver are live— unchanged, and ⛔ the gate was NOT widenedcheck:control-bytes·check:changeset-presence·changeset:check·check:changeset-claims·check:changeset-overwrite·check:new-line-citations·check:test-path-roots·check:comment-mask-corpuscheck:readme-exportsthe population COLLAPSED, this run proves nothingotherwise. No export surface moved in this diff, so it is outside what this change owes; CI runs it.pnpm test,pnpm lint, fulltype-check)Acceptance notes
Building2" among lucide's alias forms that stopped resolving. The two alias shapes itnames are correct;
Building2never belonged with them. ⛔ That changeset was NOT edited —it belongs to another PR and
check-changeset-overwriteexists for exactly that reason.The correction is carried in this PR's own changeset, which lands in the same CHANGELOG
section, so a reader meets both together. Noted, not filed.
packages/components/src/renderers/action/__tests__/resolve-icon-seam.test.tspinsresolveIcon('building_2')against theiconsrecord — a different seam on the samesubject, touched by PR perf(components): take lucide's
iconsrecord off the eager path #9399 and not by this one. It is unaffected and passes here.Noted, not filed.
icons-record resolver (resolve-icon.ts) has its own tokeniser and is out of thiscard's fence. Whether the two seams should agree is a live question that this change does
not answer and does not make worse. Noted, not filed.
DECLARED_RECORD_READERSand the gate's discovery predicate were not widened, per thefence objectui#7472 set and this card inherited.
🤖 Generated with Claude Code
https://claude.ai/code/session_011QreXiyMEqKLN4U5daMPVa
Generated by Claude Code