Skip to content

The unified 2.2 API - #230

Merged
pathscale merged 78 commits into
masterfrom
feat/unified-api
Aug 14, 2026
Merged

The unified 2.2 API#230
pathscale merged 78 commits into
masterfrom
feat/unified-api

Conversation

@pathscale

@pathscale pathscale commented Aug 14, 2026

Copy link
Copy Markdown
Owner

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

type Variant = "solid" | "soft" | "outline" | "ghost" | "plain";        // shape,  closed
type Flavor  = "neutral" | "primary" | "secondary" | "accent"
             | "destructive" | "success" | "warning" | "info"
             | (string & {});                                            // what it IS, OPEN
type State   = "default" | "loading" | "error"
             | "invalid" | "disabled" | "hidden";                        // what's happening, closed

flavor rather than color and deliberately open: a theme adds flavor="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.

state is closed and transient. invalid is derived from issues by default and settable when you need it, the same controlled/uncontrolled shape as value. error is not invalid: error means 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. isDisabled and isLoading became state, because two booleans can contradict each other and one enum cannot. isRequired and isReadOnly became the native HTML attributes. isOpen became the open/defaultOpen/onOpenChange triple. isIconOnly became width="square". The is* names that survive are capabilities, not conditions: isInteractive, isDismissable, keepMounted.

Validation is first class

interface Issue { code: string; params?: Record<string, unknown>; message?: JSX.Element; severity?: "error" | "warning" }
interface Constraint { code: string; satisfied: boolean; label?: JSX.Element }
type ValidateOn = "change" | "blur" | "touched" | "submit";   // default "touched"

Codes rather than strings, so a message is translatable and a test can assert on the fact rather than the copy. validateOn defaults to touched: silent while first typing, surfaces on blur, live after that. issues is 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, Address and the Status model, each measured across the fleet before being written rather than added because the shelf looked incomplete. Composer exists because of isComposing: 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.

Status reports 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

CalloutAlert, ToggleSwitch, ModalDialog, TextAreaTextarea, DisclosureCollapsible, EmptyStateEmpty, ProgressBarProgress, ScrollShadowScrollArea, BreadcrumbsBreadcrumb, FloatingDockDock, dangerdestructive.

One deliberate divergence: shadcn spells icon-only size="icon". This uses width="square", because size="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-layouts 0.1.3 and builds with solid-layouts-oxc 0.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 install cannot resolve solid-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:

Manifest components 241 → 186
Main-entry exports 241 → 151
Contract checks 90/90 pass
agencyzero, 561 frontend tests 557 passing against this branch

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.

meh 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.
meh 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.
meh 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.
@pathscale
pathscale merged commit 5e55827 into master Aug 14, 2026
1 check passed
@pathscale
pathscale deleted the feat/unified-api branch August 14, 2026 08:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant