Skip to content

feat(ui): prepare 3.2 with accessible controls, forms, and native QA - #289

Merged
pathscale merged 24 commits into
masterfrom
fix/export-field-error-message
Sep 11, 2026
Merged

feat(ui): prepare 3.2 with accessible controls, forms, and native QA#289
pathscale merged 24 commits into
masterfrom
fix/export-field-error-message

Conversation

@pathscale

@pathscale pathscale commented Sep 7, 2026

Copy link
Copy Markdown
Owner

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:

  • Package exports for FieldErrorMessage and passwordRules, plus ConnectionSettings in the layout manifest and an isolated consumer gate.
  • Standard Schema output and asynchronous validation preserved through form submission, with native checks for validation and transformed values.
  • Calendar cells react to selection changes without remounting; six native checks cover selection and controlled callbacks in both directions.
  • Responsive Grid/Flex classes retained in the purge manifest; solid-layouts uses published 0.2.4 root-style forwarding.
  • Accessible heading/icon APIs, named popovers, delegated popover/drawer triggers, corrected interactive-card semantics, and closed disclosure content removed from keyboard reach.
  • Neutral button/alert ink, standalone colour-wheel rendering, the icon set, and CookieConsent interaction corrected.
  • Full paint checks use a font-enabled Chuzz host. The local sweep rejects bundles older than the library source or compiled package.

Hands-on release validation:

  • The release script computes 3.2.0 from the published 3.1.0 baseline.
  • Fresh package and QA application builds pass.
  • 273 native outcomes across 75 component fixtures pass through ps-qa 0.7.1 and a freshly built Chuzz 0.1.37 host using published ps-blitz 0.4.8 and control protocol 0.5.0.
  • 320 code-level tests pass across 50 files.
  • All 93 component contracts pass.
  • The API contract covers 187 components; package verification covers 1,002 files, exports, and documented imports.
  • A fresh consumer installs the tarball, typechecks, and bundles it for the browser.
  • Worktables passes 112/112, JS Software 316/316, Web3 103/103, 24x 141/141 plus 20/20 mobile, crates 71/71, kard 223/223, nofilter 129/129, Prompt Syntax 129/129, support.cafe 104/104, and the starter 144/144 against the packed candidate.
  • Honey passes 193/196 across Platform Admin, App Admin, and Guest. The three failures identify the backend's empty API-key regeneration response; the truthful error path passes 18/18, and recovery-code coverage passes 33/33.

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.

`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.
meh 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.
@pathscale pathscale changed the title fix(form): export FieldErrorMessage from the package root fix: four library fixes for one release 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.
@pathscale pathscale changed the title fix: four library fixes for one release fix: four library fixes, and the component sweep on the browser that ships Sep 8, 2026
@pathscale

Copy link
Copy Markdown
Owner Author

CI is red here for one reason and it is ordered, not broken: the sweep now gets its host from pathscale/chuzz/.github/actions/headless-host@master, and that action does not exist on chuzz master yet. It is on feat/headless-serve (pathscale/chuzz#37).

So the order is chuzz#37 first, then this. The same applies to the thirteen site qa.yml workflows, which use the same action.

Run locally against a chuzz-headless built from that branch: 75 of 75 components pass.

meh 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.
@pathscale pathscale changed the title fix: four library fixes, and the component sweep on the browser that ships fix: thirteen library fixes, and the component sweep on the browser that ships Sep 9, 2026
meh 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.
@pathscale pathscale changed the title fix: thirteen library fixes, and the component sweep on the browser that ships feat(ui): prepare 3.2 with accessible controls, forms, and native QA Sep 11, 2026
@pathscale
pathscale merged commit dcfe3e4 into master Sep 11, 2026
1 check passed
@pathscale
pathscale deleted the fix/export-field-error-message branch September 11, 2026 20:03
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