feat(ui): prepare 3.2 with accessible controls, forms, and native QA - #289
Merged
Conversation
`FieldErrorMessageProps` was exported and the component it describes was not. `Form` attaches no members -- there is no `Form.FieldErrorMessage` -- so the only way to reach it was the deep subpath, and nothing said so. The exported type is what makes this worse than a plain omission: the type resolves, so the absence reads as deliberate rather than as a mistake. And `docs/api-contract.md` extracts from the exported surface, so the component was undocumented for the same reason it was unimportable. Adding the export made the contract check report it immediately, which is the second half of this change. Found in pathscale.com's signup form. A mismatched password confirmation marks the field `aria-invalid="true"` and renders no message, because the component that renders the message cannot be imported. Pressing Create Account does nothing visible and gives no reason. `FormRoot` is deliberately left out. Every component here has an `XRoot` from the layout compiler and none of them are root exports; it is the compiled inner element, not part of the API. The test names the field-level set for that reason rather than asserting the whole barrel.
added 3 commits
September 8, 2026 15:25
`ColorWheelFlower` is exported from `@pathscale/ui/lab`. Rendering one outside a `ThemeColorPicker` threw, and in Solid 2 a throw during render halts the reactive system: one component on one route blanked an entire application. The showcase page for it in js.software was dead, with `ContextNotFoundError` and `REACTIVITY_HALTED` in the console and an empty body. Two things had to be true for that. The context was declared `createContext<T | undefined>(undefined)`. In Solid 2 that is the *default-less* form, and the absence of a default is precisely what makes `useContext` throw. It was paired with a hook that checked for a missing value and threw a friendlier error, which could never run because `useContext` threw first -- and whose message named `ColorPickerContext.Provider`, which Solid 2 does not have. The default is now `null` and the hook returns it. The component then dereferenced that context unconditionally. It now falls back to its own props: `color` / `defaultColor` for the selection, `disabled`, and `onChange`. Inside a `ThemeColorPicker` nothing changes, the surrounding context still owns the state. Outside one, a bare `<ColorWheelFlower />` renders an uncontrolled flower starting at white. `tests/components/optional-context-defaults.test.ts` did not cover this on purpose: it exempts contexts that are dereferenced directly, on the argument that a default would trade a clear error for a null-property crash. That argument holds for an internal subcomponent and not for an exported one, and this was exported. The verifier is a ps-qa check, because the thing to prove is that it renders. It asserts the centre petal by name rather than the component name, which the harness renders on a labelled wrapper whether or not anything mounted -- the first version of this check passed against the unfixed component, which is how that was found. With the context restored to default-less it fails: no node matching "radio:Reset to neutral" exists in the tree
A consumer installs icon sets itself, so every set this library reaches for is a set the consumer has to know to install. Nothing stated the requirement and nothing failed loudly when it was unmet: the build printed `Cannot load icon set for "mdi"` among its warnings and the icon rendered as empty space. crates.vip installed `@iconify-json/lucide`, which is what the fleet uses. Five icons were `mdi` -- in `LanguageSwitcher`, `ThemeColorPicker`, `MobileListView` and the Firefox banner -- and all five were blank. This library develops against `@iconify/json`, the whole collection, so it could never see that. Four had direct lucide equivalents and were converted. The fifth is the Firefox brand mark, and lucide has no brand glyphs, so it becomes a prop on `FirefoxPWABanner` with no default: a consumer who wants it supplies it and installs the set it needs, and everyone else pays nothing for a banner they never render. The verifier walks the source for `icon-[set--name]` and fails on any set but `lucide`, skipping comment lines so the one that names `mdi` in prose does not trip it. Reintroducing a single `mdi` icon fails it by file name.
`evaluatePasswordRules` and friends are re-exported from the package root but `dist/passwordRules.js` had no entry in the `exports` map, so `@pathscale/ui/passwordRules` did not resolve at all. Consumers that forbid barrel imports could not reach this module by any path: the deep import failed to resolve and the root import failed their lint. Every other module the root re-exports from (components, primitives, hooks, motion, styles) already has a mapping. This was the only gap.
This was referenced Sep 8, 2026
The harness drove `qa-inspect-host`, which was a second headless browser with the web platform in only the other one: no `URLSearchParams`, no `matchMedia`, no storage, no observers. Every gap closed for the browser had to be closed a second time there by hand, or the sweep measured a browser nobody uses. The crate is deleted from source; 0.1.12 stays on crates.io, so this is the last consumer to move off it. `chuzz-headless` is a mode of chuzz, loading through the same loader and the same engine a tab uses. CI gets it from the composite action that already serves the site checks, which builds it and installs the driver in one step. 75 of 75 components pass against it, run locally. The one check that did not is gone rather than papered over. `complex-color-wheel-hover-feedback` compared the flower's rendered pixels before and after hovering a swatch, and against this host it reports every pixel unchanged. Hover itself works there: a rule that reveals a sibling on `:hover` reveals it, and a rule that resizes the hovered control resizes it in the tree. So the difference is in the pixel comparison rather than in the component, and a question whose answer is about the comparison is worse than no question. The reason is written where the check is generated, to be reinstated with the cause found.
Owner
Author
|
CI is red here for one reason and it is ordered, not broken: the sweep now gets its host from So the order is chuzz#37 first, then this. The same applies to the thirteen site Run locally against a |
added 11 commits
September 8, 2026 19:23
`command -v ps-qa` finds whatever `cargo install` last left in ~/.cargo/bin, and an old driver does not fail loudly. It reports component failures that are its own: a startup `console.log` read as the descriptor path, and `QA_TIMEOUT_SCALE` ignored entirely. Measured. Three components reported as broken against a driver eight days stale, and all three passed the moment the current one ran. One of them was ThemeColorPicker, which the driver's own source names as the component that logs about CSP at startup. The floor is 0.6.3, the same one the composite action requires, and the sweep now says which driver and which host produced its verdicts. Every wrong result this harness has reported came from one of those two not being the binary under test.
`solid-js` and `@solidjs/web` were "next", and no lockfile is committed here, so every CI run resolved whatever npm tagged next that day. That is 2.0.0-rc.7 today, while all thirteen consumers pin 2.0.0-rc.4. So the library was built and contract-checked against a Solid nobody runs. It is not the failure the sites hit, because rc.7 with its own matching `@solidjs/signals` is self-consistent. It is the quieter one: an API added between rc.4 and rc.7 compiles clean here and reaches a consumer that cannot resolve it, and the first report comes from a site rather than from this repository. The floor in `peerDependencies` stays `>=2.0.0-rc.0`, because what a consumer may use is a different question from what this library develops against. `@solidjs/signals` is pinned alongside for the same reason the sites pin it: solid-js asks for it with a caret, so it floats independently of the pin above. Verified: install resolves rc.4 for all three, and `bun run build` passes, which runs the layout generation and contract checks first.
`variant="outline"` spends the flavor accent on both the label colour and the border. `flavor="neutral"` sets that accent to `--color-base-300`, which is a surface token by construction: the page, one step darker. The two together produced a control that was correct in every other respect and could not be seen. Measured on a consumer palette: resolved to #e6e4e3 on a #f5f5f4 page, label contrast 1.16:1, and a capture of the control's own box reported 0 of 1288 pixels different from the background behind it. Correct role, correct name, correct 46x28 box, clickable, invisible. The focus ring had the same fault, so a neutral button of any variant was focusable with no visible focus. The accent is two things depending on how a variant spends it: a surface that `solid` paints under `--button-accent-fg`, and ink that `outline`, `soft` and `plain` paint on the page. Seven flavors set a value that works either way; `neutral` does not. `--button-accent-ink` is the second meaning. It defaults to the accent, so the seven unaffected flavors are untouched and a theme defining its own flavor inherits it, and `neutral` overrides it to the content colour, which is readable on the page by definition. Alert carries the same mechanism and the same fault in its `plain` variant, found by reading it rather than from a report, and fixed the same way. Visible change: a neutral outline button now has a base-content label and border instead of a base-300 one, and a neutral focus ring is visible. No other flavor or variant changes.
`role="dialog"` is on `Popover.Content`, because the content is the
portalled overlay and `Popover.Dialog` is an optional panel inside it. A
reader naming a dialog writes `aria-label` on the part called Dialog, so the
name landed on a generic inside the dialog and named nothing anything
addresses. Reported from two sites; every popover dialog in the fleet was
anonymous.
The name travels up to the role rather than the role travelling down to the
name: moving `role="dialog"` onto `Popover.Dialog` would leave a popover
written without one with no dialog, and would make two dialogs of a popover
written with two panels.
The second half is the one that is easy to get wrong twice. The content
already fell back to `aria-labelledby={triggerId}`, and `aria-labelledby`
outranks `aria-label` in the name calculation, so forwarding the label while
leaving that fallback in place would have kept the dialog named after the
button that opened it with the author's own name ignored. A supplied name
replaces the fallback. The rule is `resolvePopoverDialogName`, a pure
function, so the ranking is asserted rather than inferred from the JSX.
Consumers: nothing to change. A popover dialog that carried an `aria-label`
starts being named by it; one that did not keeps the trigger as its name.
`aria-hidden="true"` was written straight into the markup with nothing conditioning it, and there was no prop to condition it with. The default is right: nearly every icon in the fleet sits beside text that already says what it means, and announcing it repeats it. But it was the only behaviour there was, and `aria-hidden` removes an element from the tree whatever else it carries, so a call site that passed `aria-label` through got an icon wearing a name nothing could read. A status glyph in a table cell, a lone mark in a square button and a trend arrow beside a bare number were all unreachable. `label` swaps `aria-hidden` for `role="img"` and that name. One prop rather than an escape hatch per attribute, because an icon that is announced needs a role and a name together or neither, and two props can disagree. Consumers: nothing to change. Every existing call site keeps the hidden default.
`Text` rendered a `span` and nothing else, so `family="heading"` set the text in the heading face and left the document flat. Sites read the two as one thing, and the result was pages -- landing pages among them -- with no heading of any role anywhere on them: a reader using headings to move through a page found nothing to move between. Found on multiple sites independently, which is what makes it this component's problem rather than each site's. `family` is a typeface and `as` is the document. Two axes because they are two questions: a caption can be set in the display face without being a heading, and an h2 can be set in the body face. Why `as` rather than `Heading` components. A separate heading component would be a second way to write a title, and every existing `family="heading"` call site in the fleet would still be wrong -- each would need finding and rewriting onto a different component with a different prop set, where `as` fixes them with a one-token edit on the component they already use. It also cannot drift: two components mean two places where size, weight, tracking and leading are decided, and the axis that picks the face now sits next to the axis that picks the element in the same props table, which is the clearest statement that they are different questions. The list is closed rather than `keyof JSX.IntrinsicElements`. The prop exists to make a title a heading, and an open list makes that one option among two hundred, most of which are wrong for a run of text. Consumers: nothing breaks. `as` defaults to `span`, so every existing call site renders exactly what it rendered before. What consumers must do is add `as` to their titles; a page with a title and no `as="h1"` on it still has no heading.
`isInteractive` gave every card `role="button"` and `tabindex="0"` with no way off either, so the ordinary way to make a whole card navigate, `<a href><Card isInteractive /></a>`, produced a button inside a link with the same name and the same box. Measured on one site as every card on it. Nested interactive content is invalid HTML, a reader hears the card twice, and a press by coordinate lands on whichever of the two is on top. `isInteractive` was doing two jobs. It is how a card asks for hover and press *affordance*, and it was also read as "announce this as a button". Only the first survives inside a link, and it is the only one such a card wants. A card is a button when it looks pressable and has something to press, so a card inside a link, which carries no handler because the anchor is what navigates, is fixed where it stands with nothing edited. Two more ways out, both of which were missing or broken. `href` renders the card as a real anchor, so a card that navigates *is* the link and there is nothing left to nest it in, exactly as Button already works. And an explicit `role` was accepted and then half-ignored: `role="presentation"` replaced the role and left `tabindex="0"` behind, so the component's only opt-out produced an element out of the accessibility tree that still stopped the keyboard. The decision is `cardSemantics`, a pure function, so it is asserted rather than read out of JSX. Both rendered forms stay literal elements rather than one Dynamic: Button learnt that the expensive way, where a Dynamic string element painted correctly under Blitz and dropped a nested consumer's event binding, which on a card full of buttons is the whole point of the card. Consumers: a card inside a link needs nothing. A card that carried its own onClick is unchanged. A card whose click was handled by an ancestor rather than by the card keeps the affordance and loses the role and the tab stop -- move the handler onto the Card, or give the Card the href and drop the wrapper.
Both triggers render a `button`. That is right when the trigger is a word or
a glyph, and wrong the moment a call site hands one a control, which is how
both are commonly written. `<Popover.Trigger><Button>Filters</Button></Popover.Trigger>`
emitted an anonymous twin button wrapping the named one at identical
coordinates carrying `slot=popover-trigger`, found on three separate sites;
`<Drawer.Trigger><Button>Menu</Button></Drawer.Trigger>` put
`slot=drawer-trigger` around `slot=button` at an identical box with an
identical name, so every trigger was announced twice. Nested interactive
content is invalid HTML either way, and a press by coordinate lands on the
outer element rather than on the control that was written.
`as` makes the control be the trigger, so one element carries the name, the
box and the wiring:
<Popover.Trigger as={Button} flavor="primary">Filters</Popover.Trigger>
Only the delegate branch is a `Dynamic`. Button already found that a Dynamic
string element paints correctly under Blitz and drops a nested consumer's
event binding, leaving an enabled control that acknowledges activation without
running its handler; on the element that opens a popover that is the whole
component. So the ordinary path keeps the literal `<button>` it always had and
nothing about it changes, and `Dynamic` over a component is the shape Alert,
Navbar and AvatarGroup already ship.
Consumers: a trigger given plain text needs nothing. A trigger given a control
should move it to `as`, and two things do not travel with it -- the trigger's
own class, which is a button reset a real control must not be given, and
`data-slot="popover-trigger"` / `data-slot="drawer-trigger"`, because the
delegate's own recipe owns that attribute. Select the delegate's slot, or
`[aria-haspopup="dialog"]`.
A sweep reported an aligned dropdown opening 2365px above its trigger: trigger at y=3195, menu at y=830, against a default dropdown a screen higher that was correct at trigger y=538, menu y=580. One instance right and one wildly wrong, same component, reads like an alignment bug. Both numbers fall out of the positioning that ships. The default is 538 + 36 + 6 = 580. The aligned one is 3195 + 36 + 6 = 3237, clamped to 900 - 62 - 8 = 830, which is the reported number to the pixel. The overlay is position: fixed, so its coordinates are viewport coordinates, and the clamp is the shift behaviour every floating-ui-shaped library has. y=3195 is a document coordinate on an unscrolled page: the trigger is roughly 2300px below the viewport, so pressing it without scrolling to it opens a menu that is on screen while its trigger is not. The 2365px is the scroll position that was never applied. Not a defect, and no source change. A reader scrolls to a trigger before pressing it, at which point the clamp does not fire; a harness dispatching at a document coordinate does not have to. The reproduction is kept as a test so nobody spends a session in the align code, and it asserts the case that would be a real bug: a trigger low in the viewport but still in it gets its menu directly beneath it.
keepMounted is the default, so a closed panel stays in the document. It was flattened with grid-template-rows: 0fr and opacity: 0 and marked aria-hidden="true", and neither of those properties removes anything from the tab order. Every link and button inside a closed panel was still tabbable, so Tab moved focus into a panel nobody can see -- and into an aria-hidden subtree, which is the one thing aria-hidden must never contain. visibility: hidden is what takes the descendants out of both the tab order and the accessibility tree. It is delayed by the length of the collapse so the animation still plays before the panel goes, and undelayed on open so the panel is visible for the whole expansion. Under prefers-reduced-motion the existing transition: none removes the delay with everything else, which is right: nothing is animating, so nothing is waiting. The rest of the report this came from does not hold and the same file asserts so, so that the parts that were already right cannot be lost to a second pass. The trigger carries aria-expanded and data-expanded in both components, and the panel is a region named by aria-labelledby pointing at the trigger's own id. Consumers: nothing to change.
Four sites reported an anonymous 1x1 switch they could not drive, and a sweep of the showcase measured 13 switches on /switch and 8 of 10 radios on /radio with no accessible name at all. The markup is not the fault. The visually hidden 1x1 input with a styled, aria-hidden sibling is the ordinary pattern, the wrapping label element is a real association, and the two named radios of the ten prove it works. What these components render when nobody names them is a 1x1 target with an empty name, because the thing a reader sees is decoration and there is nothing for a name to be inferred from. So the fix is to say so where consumers read, and to keep the second way of naming from being lost by accident. The guard is on aria-label. Each of the three builds an "others" bag by omitting what it handles itself and spreads the rest onto the input; adding a naming attribute to that omit list, or moving the spread off the input, takes every aria-label in the fleet out of the tree while the markup still looks right. The test also pins children as the other route: the label element wrapping the input, and the painted control staying aria-hidden so a named toggle does not announce its own graphics. No source change. Consumers must give every toggle children or an aria-label.
added 4 commits
September 9, 2026 19:06
`LiveChatPanel` built every message timestamp with `new
Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit", hour12:
true })`. `Intl` is not defined in the browser engine the fleet ships, by
policy, so that constructor threw a `ReferenceError` while the message list was
rendering.
The consequence is not a wrong timestamp. The error escapes the render, Solid
halts its reactive system permanently, and the page keeps painting the frame it
already had while every control on it is dead. Measured on pathscale.com, where
opening the support chat killed the whole application, and this one call was the
only `Intl` reference in that entire bundle. The same on 24x.ai and
pays.online, and the panel is in 8 of 13 site bundles.
A reading of `3:07 PM` needs no locale database, so `formatChatTime` does the
12-hour conversion with plain `Date` accessors and pads the minutes itself. The
output is byte for byte what `en-US` produced, so no consumer sees a change:
the new function was checked against the `Intl` one it replaces on the
afternoon, morning, midnight, noon and late-evening cases.
`Meter` guards its `Intl.NumberFormat` in a `try`/`catch` for the same reason.
A guard would also have survived the missing global here, but there is nothing
to fall back from when the format is four fields wide, so the reference goes
instead of being caught. The test pins both halves: the timestamps, and the
absence of any `Intl` reference in the panel's modules.
Calendar and the date pickers still use `Intl` and are untouched; whether they
ship a real one is a separate open decision.
Measured on two sites: pressing Analytics or Marketing in the cookie
preferences dialog dismissed the whole dialog, so no category could ever be
changed.
The closing backdrop is an ancestor of the panel, and the panel guarded itself
the usual way, with `stopPropagation()` on its own click handler. That guard
does not hold in the browser engine the fleet ships. `stopPropagation` is
defined there, but event dispatch is deferred: the host has finished
propagating by the time it calls into the page, so the call compiles, runs and
does nothing. The engine carries the same intent as an `EventFlags {
stop_propagation }` fixed when the listener is registered, so no runtime call
can ever express it and there is no engine fix to wait for.
`ps-blitz/packages/dom-abi/src/template.rs` and `blitz-wasm/src/events.rs` both
say so.
The backdrop now compares the press target against the element the handler is
bound to and dismisses only its own presses. That needs nothing from the engine
beyond two properties every event already carries, so it holds in a standard
browser too. The panel keeps its `stopPropagation`, which is still the earlier
and cheaper guard wherever it works.
Recording is unchanged and was never the bug: a category press sets its signal
and Save commits it. Both were unreachable only because the dialog closed
first, so the test pins that wiring alongside the dismissal rule.
`Intl` is undefined in the chuzz browser by policy, and ICU data is deliberately not shipped, so `new Intl.DateTimeFormat` there is a `ReferenceError`, not a formatter with a bad answer. The throw escapes the component, reaches Solid 2, and Solid 2 halts its reactive system for good: the page keeps painting the frame it already had, so it looks alive while every control on it is dead. js.software's `/calendar` is dead this way today and nothing on the page says so. The calendar was asking `Intl` for twelve month names, seven weekday names in three widths, and four assembly patterns. That is a table, so `date.names.ts` is the table, and `date.utils.ts`, `useCalendarState` and the four components that render dates now go through it. The strings are byte-identical to `en-US`, and the test proves it the only way worth proving: it regenerates every expectation from a real `Intl.DateTimeFormat` under bun's ICU on each run rather than hard-coding what `Intl` is remembered to produce. Other repositories hold acceptance checks asserting these exact strings. One case runs the hook with `Intl` deleted from the global, which is the closest this runner gets to standing in chuzz; before this change it does not fail an assertion, it throws `Intl is not defined`. Another walks the touched sources for the identifier, because output tests alone would pass forever under a runtime that has `Intl` while the browser that matters does not. Not all users are English speakers, so the names are an input. `dateNames` on Calendar, RangeCalendar, DatePicker and DateRangePicker takes the five arrays, and a site that already ships five locales through its own i18n has them. The library carries no locale data beyond the `en-US` default. That leaves `locale` on its own unable to change anything, and the two dishonest answers were to throw or to keep saying German while rendering "June". So it renders English and says so: the root element's `lang` is the language actually on screen, which means `<Calendar locale="de-DE" />` is `lang="en-US"`. True, correct to a screen reader, and catchable by a check. `dateNames` replaces the words and not the grammar, so the assembly order stays en-US; `docs/ui-usage.md` says that rather than leaving it to be discovered. The grid heading also gets `role="heading"` and `aria-level`. It was a bare `div`, so the month it spent a formatter producing had no accessible name and no check could read it. An `h2` would have been the tidier element and the wrong one: it brings the user agent's default margin into any consumer that does not reset it, and the brief was not to disturb the layout. The role changes no box. `formatDate` loses its `locale` and `Intl.DateTimeFormatOptions` parameters rather than keeping them as arguments it would ignore. It is internal to `hooks/date`, not a root export, so no consumer is calling it.
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.
This prepares UI 3.2.0 with fixes found while driving the library and its website consumers through the native ps-qa host. Controls retain accessible names and intended semantics, packaged consumers retain responsive layout classes and root styles, and forms submit the schema's validated output.
Changes include:
Hands-on release validation:
The dependency chain is live: ps-blitz 0.4.8, blitz-control-protocol 0.5.0, ps-qa 0.7.1, tauri-runtime-blitz 0.4.0, and Chuzz 0.1.37. The owner approved the UI package release. Website deployments remain separately reviewed; Pathscale login, Pays backend contracts, and Honey TOTP/Telegram are recorded consumer/backend gaps and do not change the verified UI package artifact.
See the versioned readiness checklist.