The unified 2.2 API - #230
Merged
Merged
Conversation
added 30 commits
August 14, 2026 00:20
AuthFieldGroup was exported from the package but absent from the Layout manifest, so the application compiler rejected every consumer that imported it. Presentation moves from inline Tailwind into the recipe, and the gap axis becomes a declared presentation prop rather than a local class record.
The gap and full-width sizing move out of inline utilities and into the recipe, so a consumer no longer restates the form's spacing to match it.
Delegates to Alert with status=danger as before. The type scale is the recipe's now rather than a call-site class.
Mirrors AuthErrorMessage: Alert with status=success, role=status and aria-live=polite so a screen reader announces it without stealing focus.
Drops the variant translation helper: the four variants it accepted were already named exactly as Button names them, so it forwards instead of mapping.
The align and variant axes become declared presentation parameters instead of two local class records, so the combination is resolved from one compiled table.
Per-item disabled state moves to a data-disabled attribute styled by the recipe rather than a class ternary at each branch. The selectors match =true explicitly: a recipe reports both directions, so a bare presence selector would also match the enabled case.
The header, headings, title, description and branding regions become named slots, so a consumer can target them without reaching for the Tailwind classes that used to be inlined here. bodyClass still merges over the body slot.
Met and unmet appearance moves onto data-passed and into the recipe, so the two states are described in one place instead of a class ternary per row.
The visibility helpers move to PasswordField.interactions.ts, following the split Slider already uses, and the tests import them from there. Deferring the post-toggle restore also moves into that module. A .layout.tsx template resolves free identifiers against props, so a bare queueMicrotask in the template compiles to props.queueMicrotask, which typechecks as unknown and fails.
The inventory still called these thin Tailwind-utility wrappers, which stopped being true when they gained recipes. Names the presentation parameters and AuthCard's slots so a consumer can find them without reading the source.
One name means one thing across the library. Tone and Variant are split because the old ComponentColor mixed meanings (primary, success) with a shape (ghost), which is why a second axis grew beside it: across 13 apps Button.variant carried ghost 275 times and primary 99, while Button.color carried primary 116 and ghost 34. One axis was answering two questions. Named Width exists because w-full is written by hand at 384 call sites in 9 of 13 apps. className is absent: 7 sites fleet-wide against 302 for class. Additive for now. Components move onto it one at a time, and the old ComponentColor/ComponentVariant come out once nothing references them.
Tone and variant become orthogonal. The old single variant axis mixed shapes (ghost, outline) with meanings (primary, danger, secondary), which is why a second axis called color grew beside it: across 13 apps Button.variant carried ghost 275 times and primary 99, while Button.color carried primary 116 and ghost 34. A tone now sets --button-accent and a variant decides how that accent is spent, so 8 tones and 5 variants cost 13 CSS rules rather than 40 combinations, and combinations the old axis could not express (a soft warning button) come for free. size defaults to sm rather than md: the fleet passes sm at 349 of 465 sites and md at 25, so the default was the least-used value on the scale. fullWidth becomes width=full, isPending becomes isLoading, disabled becomes isDisabled, className is gone. tertiary and danger-soft had zero fleet usage and are now variant=plain and variant=soft tone=danger. Button is a hub, so this also updates its internal callers: ButtonGroup and its context, FormSubmitButton, AuthSubmitButton, InlineConfirm, SizePicker, LiveChatPanel and the three ImmersiveLanding banners.
A danger button is a dangerous action and a danger callout is an error. It is the same fact in both cases, so it gets the same word and there is no second colour axis able to contradict it. The interaction booleans move from StateProps to FlagProps. They are conditions rather than meaning, and a button can be state=danger and isDisabled at the same time, so the two must not share a name.
Alert.status was passed at 29 of 29 call sites across the fleet. A prop set at every site is not configuration, it is the identity of the thing: there was no state the component could default to, because "alert" was one of the states. <Alert status="success"> said a green alert. Callout is the box. state picks the accent and variant decides how it is spent, the same shape Button now uses. There is deliberately no second colour axis, so a success callout cannot be made red. placement replaces the Banner component nobody has needed: all 29 sites are inline and in flow, so page-level becomes a value rather than a second component. The role is now derived from the state. The old component hardcoded role=alert, which is assertive and interrupts a screen reader mid sentence — right for an error, wrong for "codes copied", which is a real call site in honey.id and nofilter.io. Only danger and warning interrupt. AuthErrorMessage and AuthSuccessMessage collapse into AuthMessage for the same reason: identical markup with a state baked into the name. That undoes a mistake in the auth port earlier on this branch.
Thirty-six exports with zero call sites across all 13 apps. The compound root is already the default export, so the alias was only ever a second name for the same component — the defect this release is removing everywhere else, applied to the export list itself.
Each had zero or near-zero fleet usage and duplicated something nine to thirteen apps already use. TextField, SearchField, NumberField and InputGroup all had zero call sites and are thin wrappers over Input, which has 113 across 9 apps and already takes type. PasswordField is the exception and stays: its visibility toggle restores focus and selection, which is behaviour rather than a preset type. Textarea and TextArea shipped side by side, differing only in case. That is a trap. TextArea survives; the lowercase directory is gone and its 12 call sites rename. ErrorMessage, FieldError and Description had zero call sites while text-error text-sm was hand-written at 23 sites across 6 apps. The field contract carries errorMessage and description as props instead. Tag had 4 call sites in one app against Chip's 29 across three, and every one passed only size, variant and startIcon, which Chip already has. TagGroup follows it: a group of a component that no longer exists. DisclosureGroup had zero call sites. It was a third grouping mechanism independent of Accordion's own context, so removing it makes Disclosure standalone, which is all anything used it for. AuthForm had zero call sites across all 13 apps. Every app writes its own form element with AuthFieldGroup inside.
Loading was already export { Spinner as Loading }, so the two components
the fleet thought it had were always one. The alias is gone and its 38
call sites across 4 apps become Spinner.
What actually varied was the animation, not emphasis: Loading.variant was
bars at 23 of its sites. That becomes shape, so it stops borrowing the
word that means solid/soft/outline everywhere else. color becomes state.
The recipe also stops referencing CLASSES.*, which was one of the two
remaining recipe-static errors in the library.
340 call sites across 3 apps, every one styled by class, and not one declared parameter ever passed anywhere in the fleet. Its size and shape are the whole point of the component and it had neither, so reaching for class was not laziness, it was the only option. shape, size, width, height, radius and animation are now parameters, with width and height also accepting a raw number for the cases a scale cannot cover. lines renders a multi-line text placeholder, because a paragraph was the common case being hand-built from several bare skeletons and a wrapper. The last line is short, which is what makes a stack read as text. The recipe also stops referencing CLASSES.*, clearing the last recipe-static error in the library.
Twenty-eight component families with zero call sites across all 13 apps move to a /lab entry: ComboBox, Menu, ListBox, Meter, ProgressCircle, InputOTP, Kbd, Join, ButtonGroup, CheckboxGroup, CloseButton, ChatBubble, NoiseBackground, VideoPreview, SizePicker, Toolbar, the six colour components, and the five date and time components. Nothing is deleted or deprecated. They are importable from "@pathscale/ui/lab" and behave exactly as before. The point is that the main surface should say what is in use: it was 56% components nobody had adopted, which makes the library look like it offers more than it supports. ChatBubble is the cautionary one. It is exported, used by nobody, and hand-written in six apps, so it comes back only when a redesign is driven by those six call sites rather than by guesswork.
state="primary" had no honest call site, because nothing is ever in the primary state. It is a styling preference, and the two were sharing an axis. flavor holds the preferences: neutral, primary, secondary, accent. state holds the reported conditions: info, success, warning, danger. The value sets are deliberately disjoint, so the two can never disagree about a colour. Both write --*-accent, with state declared second so it wins on source order: a condition outranks a preference, and a destructive primary button should read as destructive. style and class were the natural names and both are taken — style is JSX.CSSProperties on every component and passed at 12 fleet call sites, class at 302 on Button alone. This also fixes a default I got wrong. flavor defaults to primary, so <Button> is the call to action again. Under the previous single axis it defaulted to neutral and rendered grey, which would have forced the fleet's 215 primary call sites to write two props for the commonest button there is.
Card was styled by class at 186 of its 202 call sites, because its one variant axis conflated three unrelated things. That is also why a separate shadow prop grew beside it: 17 sites of shadow=xl against 26 of variant=shadow, both saying the same thing. Split so each axis means one thing. variant is the fill treatment and is now the shared vocabulary's; elevation is how far off the page it sits; material is what it is made of. Fleet mapping: bordered=62 becomes variant outline, shadow=26 becomes elevation, flat=6 becomes variant plain. Surface folds in: 4 call sites, all variant=secondary, which is flavor=secondary on a plain card. GlassPanel folds in as material=glass, 50 sites. It looked like a bigger component than it was — it carried a collapsible header, chevron and content-inner machinery, and not one call site in 13 apps used any of it. Only size (49), tone (7), highlight (6) and interactive (1) were ever passed, which is a Card. The collapse machinery is dropped, not ported. isHoverable and isPressable become isInteractive: one call site each across 330. Interactive cards now take a button role, tab stop and keyboard activation, which the pressable flag never wired up. The compound parts carry their own recipes, since a Layout must render every slot its recipe declares. Card.Body is used at 92 sites across 7 apps so the statics are reattached in the barrel.
The lab list was hand-written rather than derived from the usage data, and three of its 28 entries are in use: InputOTP 2 apps, 12 sites (honey.id, nofilter.io) ListBox 1 app, 3 sites ColorSwatch 1 app, 1 site InputOTP is the one that mattered. nofilter.io imports it through a deep subpath in OtpCodeForm and builds its TOTP step on it, and AuthFlow's otp field type needs it too, so parking it would have broken a login flow and a component not yet written. ChatBubble comes back for a different reason: it has no call sites, but support.cafe chat is planned for every site in the fleet. Usage data is backward-looking and cannot see that.
flavor was going to be renamed to color, since color is the industry term in MUI and daisyUI and colorScheme in Chakra. That was the wrong call. color promises a literal. Someone will write color="#f00" and be annoyed it does not work, because the name says colour and the behaviour is a token lookup. flavor promises a name that resolves elsewhere, which is what this actually is. Which only means something if the set is open, so it is: the type keeps the four built-ins for autocomplete and accepts any string, and every component mirrors the raw value to data-flavor. A theme can define flavor="hip" and style [data-flavor="hip"] in its own stylesheet with no library change. state stays closed. It reports a condition, and which conditions exist is the library's to define, not a theme's.
is* booleans were a bag of independent flags pretending to be dimensions.
Nothing stopped isLoading && isHidden, which is not a thing a component
can be. A dialog is loading, then hidden, then it unmounts: one
lifecycle, so one prop.
Audited all 25 distinct is* props in the library. They split five ways:
-> state isDisabled (74 declarations), isInvalid (26),
isLoading, isPending, isSubmitting, isSending,
isEntering, isExiting, isActive, isVisible
-> native HTML isRequired (14), isReadOnly — required and readonly
already exist as attributes; inventing props for them
was the mistake
-> controlled isOpen (22), isExpanded, isSelected -> open/defaultOpen/
onOpenChange, taking shadcn's and Radix's spelling
-> another axis isIconOnly -> size=icon, isCenter -> align=center,
isIndeterminate -> value=indeterminate
-> deleted isFocused (9) is :focus and was never a prop
The co-occurrence problem forced a correction rather than an exception. A
destructive button that is also loading cannot be said with one state
value — because destructive was never a state. A destructive button is
destructive permanently. So destructive, success, warning and info move
from state to flavor, and both can now be true at once.
That also lands us back on shadcn's side: it writes variant=destructive,
putting the semantic colour on the look axis, which is what flavor is.
The hand-written interface doc covered 10 of 88 component directories. A catalogue that is written by hand drifts the moment a prop changes, and this one is also the js.software home page, so it has to be complete and stay complete. scripts/generate-component-reference.ts reads dist/**/*.d.ts after a build and emits every component with its real props type. It runs as part of postbuild, so the reference regenerates with the artifact it describes. Coverage is asserted rather than assumed: 197 public exports, 15 of them constants, 182 components, and all 182 appear either as their own entry or as a listed part of a compound. Nested components that live inside another component's folder — CookieConsent, PWAInstallPrompt, FirefoxPWABanner, FieldGroup, I18nProvider — are picked up too; they were the six the first pass missed.
The first reference generator read the built dist, so it documented what ships today rather than what is proposed. With only ten components ported that meant 78 of 88 entries showed legacy props — Toggle still carrying color and isDisabled. Wrong artifact for a review that has to happen before the port. This one takes each component's real current props and applies the 2.2 rules to them, so nothing is invented and nothing is missing, and it prints what changed per component. The rule table is the executable spec for the codemod: one list, used to produce the document and later to rewrite the fleet. Coverage asserted, not assumed: 182 components, all 182 documented.
Two gaps the audit found, both with the evidence already in the tree.
Validation produced a string. passwordRules.ts had the right idea and the
wrong shape: a structured key beside a message hardcoded in English inside
the library, so a non-English app either shows English or rebuilds the
result. And one message cannot say a password is both too short and
missing a digit.
Issue carries a code, params and an optional severity instead. "password
too short" is { code: "too_small", params: { minimum: 8 } }, resolved from
i18n context, and a field can show an error and a warning at once because
not every issue blocks submission. Standard Schema maps straight onto it,
which is what createForm already uses through TanStack.
Events lost information. Collapsing onClose into onOpenChange(boolean),
as D4 proposed, would have discarded a distinction the library already
draws — Drawer ships DrawerCloseReason with escape, backdrop, trigger and
api. Whether a dialog closed by Escape or by a backdrop click is exactly
what decides whether to warn about unsaved work.
Both onChange and onOpenChange now take an optional trailing reason, so
existing call sites read the same and nothing is thrown away.
Every component a user can type into, pick from or toggle now extends Validatable<T>. Nineteen of them carry it. The design turns on one distinction. A constraint and an issue are the same rule at different moments: "at least 8 characters" ticking off as you type is help, and the same rule flashed as "too short" mid-word is nagging. So they are separate props with separate timing — constraints live from the first keystroke and positive, issues only once earned. validateOn defaults to touched, which is the only timing that is not hostile: silent while they are first typing, surfaced on blur, then live on every keystroke so they can watch themselves fix it. issues is controlled, so a server response sets it directly and merges with whatever validate produced. A server issue renders in the same slot, through the same i18n path, as a client one — no second banner and no second rendering path. Both render into one field-message slot, so nothing shifts as validation state changes, and state=invalid is set only for error severity: a warning colours the message without blocking submit or reddening the field.
loading, disabled and hidden form a priority chain — each is set by the caller and supersedes the next — which is what lets them share one prop. invalid fails that test twice. It is orthogonal rather than superseding: a field can be disabled and carrying an error from the last submit, or revalidating and still showing the previous one. And it is derived rather than set: a component is invalid because it has error-severity issues, so asking the caller to assert it separately invites state=invalid and an empty issues array to disagree, leaving a red field with nothing to explain it. isInvalid(issues) computes it and it is mirrored to aria-invalid and data-invalid. Warnings do not count, so a weak password colours the message without reddening the field or blocking submit. The general rule: a value belongs on state only if the caller sets it and it supersedes the others. Anything derived is computed and mirrored.
added 3 commits
August 14, 2026 13:18
`mode: "source"` requires this list, and it had gone stale twice over. It still named the eleven components renamed in this branch, so the manifest advertised `Callout` and had never heard of `Alert`, and it had never been told about `Address` or `Composer` either. Regenerated from what `src/index.ts` and `src/lab.ts` actually export, values only: 184 names, every rename in place, and the manifest now agrees with the package. Nothing compares the two, which is why this rotted quietly. A check belongs here rather than in the compiler, and wants doing before the next rename.
`color?: ComponentColor` was declared and never read, so an icon asked for `color="danger"` typechecked and came out the colour of its parent's text. `flavor` replaces it and the recipe carries the axis: the flavour writes `--icon-accent`, the base rule spends it, same mechanism as Button. `inherit` is the default and resolves to `currentColor`, because most icons sit inside something that has already chosen a colour. Class composition moves out of the layout and into the recipe, which is what removes this file's `manual-classes` diagnostic from the lint baseline: 2,128 to 2,127, nothing added.
The layout rewrite dropped `export default`, so `index.ts` re-exported a member the generated file no longer had. `bun run check` passes on this and `tsc` does not, which is the whole reason both are in the gate.
added 24 commits
August 14, 2026 14:07
It was a directory of re-exports, not a component: no layout, no recipe, no CSS. The root barrel already exports the same symbols straight from src/passwordRules.ts, so nothing imported it. Its only effect was to make the reference generator emit a PasswordRules entry for a folder that renders nothing.
It read dist/, applied a rename table hardcoded inside itself, and printed the result as a proposal. Because it derived the target from what was already built, the output was identical before and after a component was ported, so it could never show what was left to do, and it could never disagree with the code it was generated from. The rationale it carried as string literals is in UI-2.2-DECISIONS.md with the call-site counts behind each call.
Neither generator was part of the 2.2 API change: they built a markdown catalogue out of dist/ and cost 756 lines of script plus 2895 lines of committed output in a release whose point is to remove surface area. The postbuild step no longer regenerates the catalogue.
The renames changed directories, files, identifiers and each recipe's component string, but not the child slots' base classes or the CSS that matches them, so nine components shipped a root class under the new name and children under the old one: .alert with .callout__title inside it, .dialog with .modal__backdrop, .switch with .toggle__thumb.
Icon took name, a raw class string the caller had to spell exactly, which meant a typo rendered an empty box and the library owned a convention it could not check. It now takes src, and which source it is, is the type: a string is a preload token that the consuming application's iconify build resolves, an element is inline SVG the caller owns. They cannot both apply, so they are one prop rather than two that can disagree. The recipe gains a glyph slot that paints nothing. It carries a stable class to style against, and the class that draws the mark is added at the call boundary, so no icon data is baked into this package. Both sources inherit colour through currentColor, so flavor stays one custom property and behaves the same either way. Tokens are accepted both wrapped and bare, since the fleet writes both.
The iconify plugin scans an application's own source and emits one rule per glyph it finds. Running it here scanned this library instead, wrote the result into src/styles/icons/generated-icons.css, committed it, and imported it from src/index.css, so every consumer downloaded whatever glyphs our own components happened to reference and got nothing for the ones they actually use. It also meant every build rewrote a tracked source file. The plugin belongs in the consuming app, which is where its README puts it and where the playground already runs it through @iconify/tailwind4. The generated directory is now ignored, and the plugin and its tailwind companion leave dependencies, since neither is needed at runtime by anything this package ships.
Three families the themes never had, which is why components invented their own. Hover and pressed were mixed toward literal #000 in ten places, so a button darkened on hover in the dark theme and moved toward the page instead of away from it; --shade resolves to base-content, which is near-black on light and near-white on dark, so one declaration moves the right way in both. Elevation was four hand-written shadow pairs per component, all tuned against white; --shadow-sm through --shadow-xl carry deeper alphas on dark, where a 10% black shadow against gray-900 is invisible. Focus was 2px and an outline colour chosen six different ways across the library. Card also stopped reinventing glass. The themes already define 31 glass tokens and it was computing its own from base-100, so a glass card ignored every glass setting the theme carried.
135 literals across the component CSS: 25 one-pixel borders, 53 pill radii, 25 focus outlines and 24 offsets, each written by hand in a file that had no token to point at. Every reference carries a fallback equal to the value it replaces, matching what radius-field and radius-box already did, because a theme the library does not ship (agencyzero sets its own data-theme) matches neither the light nor the dark block, and an undefined custom property does not fall back to the initial value: the whole declaration is dropped, so an untokened border would not have been thinner, it would have been absent.
62 shadow and backdrop colours were literal black, written as oklch(0% 0 0), rgb(0 0 0) or the keyword, so a theme could retune elevation only by overriding every component. They now spend --shadow-color, which the themes set and which falls back to the same black, so nothing moves today and a theme can change the colour of depth in one declaration. Geometry is untouched: the offsets and blurs are the values that were there.
92 layouts still took IComponentBaseProps, which is UIBaseProps plus className. Carrying both meant every component composed two class props and callers could set either, so a component's final class list depended on which one an app picked. 429 references removed across 194 type positions: the declarations, the splitProps entries, and the twMerge tails that spent them. Lint baseline 2127 to 2126, one diagnostic removed and none added.
…ecome eleven Badge had color as default|accent|success|warning|danger and variant as primary|secondary|soft, so primary was a variant while every other component in the library treats it as a colour, and .badge--primary was a variant class that would have collided the moment primary became a flavour. Flavour now writes --badge-accent and variant spends it, which is the mechanism Button and Card already use. That replaced fifteen compound rules, one per variant-and-colour pair, with eight flavour rules and three variant rules, and it is why adding primary, secondary and info cost nothing rather than nine more compounds. Variant reads solid|soft|outline, the vocabulary's names for the shapes it already had. State is new and was simply absent. Flavor is an open type, so an unrecognised name yields badge--flavor-<name> for an app to style instead of falling through to no flavour at all.
preloadClasses returned an empty string for a blank or whitespace src, and that string became a classList key. Solid splits each key on whitespace and calls DOMTokenList.toggle on the parts, and toggling an empty token throws InvalidCharacterError, so <Icon src="" /> crashed the render rather than drawing nothing. It now returns a bare iconify, which paints the same empty box an unrecognised token already gives. IComponentBaseProps had one holdout, immersive-landing's types, and was not exported from either barrel, so 2.2 was shipping a type nobody could reach and one component still carrying className through a side door.
tsc aborted on a deprecated baseUrl before checking a single file and exited with one error, which reads as a nearly clean playground. It is not: with the gate working the count is 477. The handover's 424 was measured through the same abort and should not be trusted.
Avatar, Chip, Meter, Progress, RadialProgress and Switch each declared their own five-or-six name colour list, all slightly different and none matching the vocabulary: default rather than neutral, danger rather than destructive, and no primary, secondary or info at all, so an app could not tint any of them the way it tints a Button. Each now takes flavor and carries all eight. The three that were missing are generated from the accent rule with fallbacks, so a flavour whose soft or foreground token a theme does not ship degrades to a mix of the base colour instead of dropping the declaration and rendering unpainted. Flavor stays open: an unrecognised name yields <component>--flavor-<name>. AvatarColor is deleted, including from the root barrel.
…variant Both already carried eight or nine names, so this is mostly the last rename: error becomes destructive, matching the vocabulary rather than the CSS token it happened to spend. Navbar's row listed ghost alongside neutral and primary as if transparency were a colour. It is a shape, so it moves to variant, where solid|ghost sits beside the same axis every other component uses. A row can now be ghost and primary at once, which the old single-axis list made unsayable. That is every color prop in the library gone: 9 components at the start of this pass, 0 now.
38 components carried isDisabled as a boolean, several of them beside an isLoading or an isInvalid, which let a caller say a control was disabled and loading and invalid at once and left the component to decide which of the three it rendered. State is one enum, so the question does not arise. 43 declarations, 58 reads, 42 splitProps keys and 10 forwarded props. Where a component already declared state the boolean was simply dropped rather than duplicated.
…the booleans isInvalid becomes issues. Validity is not something a caller asserts, it is what the constraints report, so the component derives it with resolveState and a caller that wants to force it says state="invalid". FormField, which owns the message, now hands down a real Issue rather than a flag, so the text and the invalid styling can no longer disagree. isRequired becomes required, the attribute the platform already has and that assistive technology already reads. isLoading becomes the loading state. Toast was the last holdout and its promise helper queued isLoading: true beside a variant, which is the shape that let a toast be loading and successful at once. 42 invalid references, 13 required, 6 loading.
All nine already shipped defaultOpen and onOpenChange, so the boolean was the one member of the triple still spelled the old way and the only reason a caller had to remember that this library disagreed with shadcn and Radix about the controlled prop. 50 references. Internal context accessors keep their own names: some declare open, some isOpen, and each read now matches its own declaration rather than a guess, because that is component-private and renaming it was not the change.
Every compiled Layout imports clsx, so dist did too, but the manifest declared it only under devDependencies. npm does not install those for a consumer, so installing @pathscale/ui and importing anything produced 'Cannot find package clsx'. The library built, typechecked and passed its contract checks the whole time, because nothing in this repo installs the package the way an app does.
It demoed every component against the pre-2.2 API and had become 477 typecheck errors of dead demos, most of them written against the sixteen components this release removed. Rewriting it would have meant maintaining a second copy of the API beside the API, which is the arrangement that let it drift this far in the first place. The PR preview workflow went with it, since deploying the playground to Surge was its only job. What replaces both is scripts/smoke-consumer.ts, which packs the tarball, installs it into a fresh consumer and builds against it, so the thing under test is the artifact people actually get. CONTRIBUTING, the README and docs/ui-usage no longer promise a playground, and ui-usage's Icon section no longer documents the name prop or the glyph baking, both of which this branch removed.
The fixture imported Modal, ComponentColor and ComponentSize and passed color and isDisabled to a Button, so it described the API this release replaced. It now uses Dialog, Flavor, Size and State, and renders an Icon through src, which means the smoke test fails if the three axes ever stop being exported. The runtime step executed the package in a bare Bun process, where it threw before any assertion ran: this is a browser build and every compiled Layout calls template() and delegateEvents() at module scope. Passing would have meant installing a DOM shim to prove a DOM shim works. Bundling for the browser is what a consumer's build actually does and it fails on exactly what this step exists to catch, an undeclared package or a missing barrel export. Verified by removing clsx and watching it fail, then restoring it.
Two recipes, aliased at src for hot reload and installed from the tarball for truth, with the trap that costs the most time written down: src/index.ts re-exports from gitignored .generated.tsx files, so a fresh clone aliased at src is a barrel pointing at nothing until bun run check has run. The tests were not wired to anything. There was no test script and CI ran contract, typecheck and build but never bun test, so a suite of 33 sat unrun, one of which read src/components/tag/Tag.css for a component this release deleted. That test is gone, the remaining 32 pass, and CI now runs them and the consumer smoke alongside the rest.
docs/api-contract.md lists all 179 exported components and their props, is committed, and is compared against the built types on every CI run. Drift fails in both directions: a prop the document promises and the build does not export, and a prop the build ships that the document never mentions. The direction matters. The 2.2 effort already had a document generated from dist on demand, and it was worthless, because a document derived from the code cannot disagree with the code: it read identically before and after a component was ported, which is how eleven renames stayed invisible for a day. This inverts that. The script only ever compares. --write exists so an intentional API change is one command, and CI never passes it. Getting the extraction right took four passes, each one a real defect in the first version: type-only exports counted as components, XProps aliased to XRootProps read as no props at all, interface declarations were invisible next to type ones, and a part like CardBody names its type on its own declaration rather than after itself. An empty prop list now means the component genuinely adds nothing beyond HTML attributes and UIBaseProps, and a type that cannot be found is marked rather than silently recorded as empty, because conflating those two is the same failure in miniature.
The TextArea to Textarea rename is case-only, and macOS is case-insensitive, so git kept TextArea.css, TextArea.layout.tsx and TextArea.recipe.ts while the working tree held Textarea.*. Every local check passed against the working tree and never noticed. On Linux they are different files. CI checked out TextArea.layout.tsx, the compiler emitted TextArea.generated.tsx, and index.ts imports ./Textarea.generated, which is not there. The visible symptom was the lint ratchet failing with one diagnostic fewer than the baseline, 1669 against 1670, because the file the baseline was recorded from did not exist under that name. This would have shipped: the tarball is built in CI.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The unified 2.2 API. 48 commits, every one green through
bun run check && tsc --noEmit && rslib build.2.0.0, 2.1.0 and 2.1.1 are already on npm and a published version can never be reused, so this ships as 2.2.0 and every earlier 2.x gets deprecated on release. Breakage is the point of the release rather than a cost of it.
The three axes
flavorrather thancolorand deliberately open: a theme addsflavor="hip"and styles[data-flavor="hip"]without a library change. A colour is a value you set; a flavour is a name you define elsewhere.stateis closed and transient.invalidis derived from issues by default and settable when you need it, the same controlled/uncontrolled shape asvalue.erroris notinvalid:errormeans something we do not understand broke, which is what async validation that cannot reach the server produces, and rendering that field red with "too short" is a lie.No
is*for conditions.isDisabledandisLoadingbecamestate, because two booleans can contradict each other and one enum cannot.isRequiredandisReadOnlybecame the native HTML attributes.isOpenbecame theopen/defaultOpen/onOpenChangetriple.isIconOnlybecamewidth="square". Theis*names that survive are capabilities, not conditions:isInteractive,isDismissable,keepMounted.Validation is first class
Codes rather than strings, so a message is translatable and a test can assert on the fact rather than the copy.
validateOndefaults totouched: silent while first typing, surfaces on blur, live after that.issuesis controlled, so a server error lands in the same slot as a local one and nothing has to render two kinds of error in two places.What is new
Composer,Addressand theStatusmodel, each measured across the fleet before being written rather than added because the shelf looked incomplete.Composerexists because ofisComposing: with an IME open, Enter commits the candidate and must not also send, and skipping that check sends half a word in Japanese, Chinese and Korean.Statusreports the root cause rather than the worst item. Seven independent conditions in a call, and "show the worst" tells someone their camera is off when the truth is the internet is down.Renames, taken from the industry
Callout→Alert,Toggle→Switch,Modal→Dialog,TextArea→Textarea,Disclosure→Collapsible,EmptyState→Empty,ProgressBar→Progress,ScrollShadow→ScrollArea,Breadcrumbs→Breadcrumb,FloatingDock→Dock,danger→destructive.One deliberate divergence: shadcn spells icon-only
size="icon". This useswidth="square", becausesize="icon"collapses two axes into one and most of the 465 button call sites in the fleet pass a size and icon-only. This stylesheet had already conceded the point, carrying a separate square width per size.Dependencies
Requires
solid-layouts0.1.3 and builds withsolid-layouts-oxc0.1.7. Those minimums are load-bearing. Below them a compiled component drops every HTML attribute its caller passed, and its children freeze at whatever they were on the first render, so a library published against the older compiler looks correct and is not. Fixes are in solid-layouts#2. CI is red here until those publish, and it is red for the right reason:bun installcannot resolvesolid-layouts@^0.1.3, because that version genuinely does not exist yet. Merge order is compiler, tag, then this.Where it stands
Ported locally and proven, not guessed at:
agencyzero is the interesting number: it passes 561/561 on 1.3.1, and going to 2.2 first produced 78 failures. None were in this library. Chasing them is what found both compiler defects.
Reviewable interface doc for all 97 components:
UI-2.2-PROPOSED-INTERFACES.md, generated from the real props. Decision record with evidence for each of 35 decisions:UI-2.2-DECISIONS.md.Closes #229, which ports the auth family and is now a strict subset of this branch. It also ports
AuthForm, deleted here for having zero call sites anywhere in the fleet.