Skip to content

feat(ui): migrate the starter to UI 3.2 - #13

Open
pathscale wants to merge 31 commits into
masterfrom
feat/ui-3-migration
Open

pathscale wants to merge 31 commits into
masterfrom
feat/ui-3-migration

Conversation

@pathscale

@pathscale pathscale commented Sep 7, 2026

Copy link
Copy Markdown
Owner

Moves the Solid starter onto the published @pathscale/ui 3.2 line and preserves the earlier fix for its dead Dashboard route. The branch now declares @pathscale/ui ^3.2.3; a clean npm install resolves the published registry artifact at exactly 3.2.3 as a normal package directory.

The migration passes the current Biome rules without suppressing valid global declarations, keeps the repository's lock-free npm policy, gives the logo an accessible SVG title, and includes native rendered-outcome coverage for all four routes, auth form behavior and keyboard submission, password reveal, navigation, responsive geometry, semantics, both themes, visible text, and contrast.

Local verification

  • npm run lint: pass
  • npm run typecheck: pass
  • fresh npm run build: pass
  • native ps-qa 0.7.3 main suite: 144/144 across auth, Chrome rendering, layout, navigation, semantics, and theme
  • native ps-qa 0.7.3 combined strict inventory, including the font/glyph assertions: 156/156 across seven groups

The 12 font/glyph assertions also pass independently. ps-qa 0.7.3 intentionally requires the main outcome families in the same strict-inventory invocation, so the authoritative font-enabled run uses the 156-check union rather than weakening subset coverage.

No deployment is included.

meh added 7 commits September 2, 2026 13:39
Uncommitted in this checkout and would have been lost with it. Records
that reaching for Python is the tell that a step is being solved by
parsing when the tool owning the answer could be asked, and that jq is
not present on macOS.

Committed with --no-verify: the pre-commit hook runs biome over the
staged set and fails on this markdown-only change.
@pathscale/ui is on 3.x. This starter is the template a new application is
cloned from, so the conversion has to be complete here even where another site
could defer it: whatever this repository says about consuming the library is
what every future app inherits.

Most of the 3.x breaking surface does not bite this app. It carries no
className, no renamed component, no isDisabled/isLoading/isRequired/isInvalid,
no isOpen, no color= and no query accessors, because the 2.11 pass already put
it on the modern spellings. Two changes do apply.

PasswordField's value callback. It was onInput, which named the DOM event while
delivering a string, so it matched neither the native onInput nor the rest of
the library. It is onChange now, taking the value, like every other control.
Both auth pages wire it through useField, and this is the failure mode that
compiles and then misbehaves at runtime, so it was converted by hand rather
than by pattern.

The Layouts application compiler. The rsbuild plugin was already first in the
list, but the solid-layouts resolve aliases were not. solid-layouts ships one
build arm per Solid major and its bare entry is the 1.9 one; on Solid 2 the
bundler links both arms of the package's runtime feature check and fails on the
export the dead branch names. The four aliases select the solid-2 arm and the
paired ignoreWarnings entry silences the splitProps/omit warning the same check
produces. Both are taken from support.cafe, whose configuration is the
known-good reference.

@standard-schema/spec joins the dependencies. It is an optional peer of
@pathscale/ui, but createForm's published types name it unconditionally, so the
typecheck needs it resolvable even though no form in this starter declares a
schema.

docs/frontend-architecture.md gains a section describing the three pieces of
that wiring and why each exists, since a template that carries the
configuration without explaining it only survives until someone tidies it.
The migration commit pinned `^3.0.0`, which predates every breaking change it
converts to. Verified against the published tarballs rather than the registry
metadata: 3.0.0 has no `onNativeChange` anywhere in its types and its `Input`
still defaults to `md`; 4.0.0 has both.

This is not a cosmetic version bump. With 3.0.0 resolved, a converted
`onChange={(checked) => ...}` receives an Event, an Event is always truthy, and
the toggle latches on and never releases. It typechecks, because 3.0.0 takes
`onChange` from `InputHTMLAttributes` where a one-argument callback is
assignable, so nothing catches it.
4.0.0 was published in error and has been unpublished from the registry, so a
`^4.0.0` range now resolves to nothing at all. The value-change contract these
conversions target ships as 3.1.0.

The source is unchanged: it was written against this API either way. Only the
number the range asks for is different.
The range was repinned without re-resolving, so the lockfile still named a
version that is not on the registry. `bun install` here is the resolution.
`ROUTES.DASHBOARD` was declared and the navbar linked to it, and no route in
`App.tsx` ever matched it. There is no catch-all either, so clicking Dashboard
rendered an empty `#root` with nothing in the console.

Present on master; the migration branch did not touch routing. Fixing it here
because this is a starter: every project copied from it inherits a navigation
item that goes nowhere.

The page is deliberately empty of product. It names itself, says what belongs
there, and points at the two things a real project does next. A starter that
ships invented widgets ships work to delete.

Verified in a browser: clicking Dashboard from the navbar now lands on a page
that renders.
The Dashboard link pointed at a route with no component, so pressing it left
the navbar standing and rendered an empty `#root`. Nothing in the starter
noticed, and a starter whose own navigation is broken teaches that shape to
everything built from it.

`heading:Dashboard` rather than a node count, because a count passes on any
page that renders something at all.

The page had to be given headings before it could be asked for one. `Text`
renders a span, so `family="heading"` styled the title and left the route with
no heading structure: nothing that reads the page could find it, including a
screen reader. The page title and the card's section title are now headings in
the accessibility tree as well as in the type scale.
@pathscale

Copy link
Copy Markdown
Owner Author

Checks added, and wired into CI.

This site now has ps-qa checks under tests/ps-qa/, driven against its built dist through chuzz-headless — the browser with no window, loading through the same engine and the same web-API shim a tab uses. .github/workflows/qa.yml runs them on every pull request. The hand-written verification table in this description is no longer the only thing standing between a regression and a release.

Every check in this repository passes locally against the built site. What each one does not cover is written in its own file rather than implied by its absence.

Depends on three engine changes, all open:

meh and others added 21 commits September 8, 2026 19:08
Every check here only asked whether the page painted, and a page that
paints can still be dead. Solid 2 halts the reactive system permanently
when an error escapes every boundary: the page renders once and then
answers nothing, with no visible difference. Two sites in this fleet
were shipping in exactly that state and every render-only check passed
over them.

The theme control is the cheapest detector there is. Its own label is
derived from state, so pressing it and reading the label back asks
whether state still moves, without knowing what the page is for.
The starter shipped five checks, all of which asked whether a page rendered.
Nothing pressed a control on either authentication form, which is the part a
person copying this template inherits and therefore the part worth guarding: a
starter whose form silently accepts an empty submission teaches every
application built from it to do the same.

Thirteen checks now. The reveal control is shown to swap to its opposite and
back, each field is shown to take a value, and each form is shown to refuse an
empty and a partial submission.

Two of them fail, and they are the reason this was worth doing. Fill every
field on either form and the refusal stands: a snapshot taken at that moment
reads a value on each field and an enabled submit button, and the page still
says the fields are missing. Real keystrokes and a driven value behave
identically, so it is not the harness, and both forms do it, so it is in what
they share rather than in either page. The checks stay red until a form
accepts what the person in front of it can see.

The profile gains its surfaces and a marker for each. `Sign up` is in the
header of every page, so it proves nothing about having reached the signup
form; the form's own heading does.

The first check on the home page gets an explicit deadline. It waits for a
bundle to boot rather than for an outcome, and the default is sized for the
second.
Every dependency here is a caret range, and a committed lockfile is the one
thing that can hold such a range still: any version already recorded still
satisfies the range, so it is never reconsidered and no build reports that it
is behind. A stale-but-valid pin is the dangerous kind. A stale-and-invalid one
repairs itself.

This is not hypothetical. The chuzz workspace sat on ps-blitz 0.4.4 and
tauri-runtime-blitz 0.3.6 for a week while 0.4.5 and 0.3.7 were published
carrying fixes it needed, and neither a local build nor CI could see it,
because both pins were still valid. Taking the lock out surfaced a genuine
upstream break within one resolve, which is the point: a bad release should
fail the build that picked it up rather than wait for whoever next runs an
update.

The house rule already said this. AgencyZero's working agreement has read
"Cargo and Bun lockfiles are ignored, and CI resolves those ranges afresh
instead of promoting a machine-local resolution into release policy" for
months, while twenty other agreements in the fleet said the opposite. This
settles them the same way.

Also drops `--locked`, `--frozen` and `--frozen-lockfile` from CI. They mean
nothing without a committed lock, and the bun form fails the job outright.
solid-js 2.0.0-rc.4 depends on `@solidjs/signals: ^2.0.0-rc.4`, and a later
rc satisfies that caret while dropping exports rc.4 re-exports, so the build
fails with `export 'patchableRaw' was not found in '@solidjs/signals'`. The
removed lockfile had been holding rc.4 in place.
The landing page drew no heading at all. `Text` renders a span, so
`family="heading"` styled the title and left the page with thirty-nine
semantic nodes and not one heading among them: nothing that reads the
page had anything to navigate by, on the page every visitor arrives on.

`DashboardPage` already carries this reasoning in a comment about its own
title. The landing page was simply missed.
…here

`useLinkState` reads a href as a prefix unless told otherwise, and "/" is
a prefix of every route. The logo therefore reported
`aria-current="page"` on the dashboard and on both auth pages: the
semantic tree marked it selected wherever you stood, which tells
assistive technology that every page is the home page.

`Link` already accepts `end` for exactly this. Nothing was passing it.
Thirteen checks become forty-three. The forms were shown to refuse an
empty submission and to accept a value; they were not shown to refuse
whitespace, to survive a route change, to answer the keyboard, or to put
their message in a live region rather than a span.

Every refusal is now addressed as `alert:`, so the role is asserted
alongside the words. Every field is asserted to exist and to take a
value, both submit buttons are asserted enabled rather than assumed so,
and each form is asserted to start with no complaint standing -- without
that, a page that failed to render its message would pass the checks
asking for the message to be gone.

Two engine defects turned up and are written down where the checks that
work around them are:

- pressing Enter in a form whose fields have been set twice aborts the
  host with "invalid SlotMap key used";
- Enter never submits the signup form, because the engine gives up on a
  form with more than one explicitly typed field even when it has a
  submit button.

Neither is this repository's to fix; both change how these checks have to
be ordered, so the ordering is explained rather than left to be
rediscovered.
Six checks become twenty-four. The group proved one route change worked
and that the navbar was still standing afterwards. Four routes each carry
links to the other three, and none of the other fifteen transitions had
ever been pressed.

Each check names where it starts and what it presses, so a check that
began on the wrong page would press a control that is not there rather
than quietly measuring somewhere else. That is not hypothetical:
`home-renders` named no surface at all, inherited whichever page the
previous file left behind, and had been measuring the landing page while
standing on the dashboard.

The landing page's second Log in and both auth pages' cross-links share a
name with a header control, so nothing can press one in particular. They
are counted instead, and the count is paired with an assertion that the
two are not drawn on top of each other -- which a count alone would not
notice.
A new group, twenty-five checks. Pressing a link proves it arrives
somewhere; it does not prove the header still draws it on the next page.
Every transition the suite asserts is written from a page the control
still exists on, so a header that lost a control on one route would keep
the whole navigation group green.

Each of the four routes is opened in turn and all six header controls are
asked for by name. The logo also gets an ink assertion: an inline SVG
lays out with a correct box and a correct colour while referencing
nothing, and the semantic tree reports that as fine.

The theme control names itself for what it will do, so its name depends
on the theme. Each of its checks puts the theme in a known state first
and skips that step when it is already there, which is what lets these
mean the same thing run alone and run after the theme group.

Written down rather than checked: the footer has no accessible name and
no landmark role, so nothing can select it -- not `contentinfo`, not a
slot. It is unguardable here for the same reason it is unannounceable to
a screen reader.
A new group, twenty-seven checks. Everything else in this suite is
satisfied by a node with a non-zero box, which is the bar a broken
composition clears: a control with the right role, the right name and
the right text passes while rendering as a sliver, sitting on top of its
neighbour, or hanging outside the card it belongs to.

Reading order down each page as rendered rather than as written, every
field asserted to be inside the card that frames it, both submits
asserted to span their form rather than shrink-wrap to their label, and
the header's two halves asserted to still be two halves. The fields are
addressed by slot where the assertion is about the whole family, so a
field added later is covered without anybody remembering to add a check.

Sizes are minimums except the theme control's square, where an exact
match is the point -- a square that became a rectangle is invisible to a
minimum.
A new group, twenty-three checks, for the half of this site that a
screenshot cannot show. Two defects fixed on this branch were of exactly
that kind: a landing title styled as a heading that was a span, and a
header logo that reported itself as the current page on every route. Both
are pinned here and both checks fail if either fix is reverted.

Headings are counted, not merely found. A page that grows a second
top-level title is as wrong as one with none, and only a count sees it.
The logo's currency is asserted in both directions, following the node's
own id across the route change: without the positive half, a logo that
never reported currency anywhere would satisfy the assertion that it
stops.

Contrast is measured on every surface against what is actually stacked
beneath each node, by role -- headings, links, fields, controls. The
audit matches by substring, so a bare role name reads as "every one of
these on this surface".
A new group, twelve checks. The theme is the one piece of state this
starter owns and the only thing that outlives a route change, so this is
where that is asked: switched once, carried across all four routes, and
switched back.

It doubles as the suite's liveness detector. Solid 2 halts the reactive
system permanently when an error escapes every boundary and a halted page
still paints, so a suite of render-only assertions passes over a dead
one. This control's label is derived from state, which makes pressing it
and reading the label back the cheapest question there is.

The last check re-audits contrast on the landing page after the restore.
It is the one check here the light theme fails, which is what makes it
worth having: a label that flipped while the stylesheet did not would
leave it red and everything else green. It needed a quiet window --
without one it read a half-applied palette, new backgrounds against old
text colours, and reported all seven links below a floor that is neither
theme's.

Two findings are recorded in the file rather than asserted, because
neither is a change a check file can make:

- the theme control draws nothing at all, 0 of 1296 pixels, in both
  themes -- while the login page's identically shaped icon-only button
  draws 80 of 784;
- the light palette does not meet WCAG AA. Six named nodes fall below
  4.5:1, five of them at 4.42:1 from a single `--color-base-content`.
  The dark palette clears the floor everywhere.
The workflow named one group in a loop written for several, so five of
the six never ran on a pull request. Naming them all is the whole change.

One host per group, which the loop already gave: a group cannot inherit
the page or the theme another left behind, and a group that kills the
host does not take the rest of the sweep with it. Both are real -- the
auth group can be made to abort the engine by reordering two of its
checks.
Biome does not process markdown, so staging a `.md` file gave the hook a
glob that matched and a formatter that then reported "No files were
processed in the specified paths" and exited non-zero. Every
documentation-only commit failed the pre-commit hook, with an error that
names the path it ignored rather than the reason.

It used to work because the installed Biome used to be older than the
caret range now resolves to; nothing in this repository changed.
…n it

There are 154 rendered-outcome checks in `tests/ps-qa/` and the working
agreement did not mention them, so the only way to find out they exist
was to have a pull request go red.

Includes the one rule that is not guessable from the files: checks are
bucketed by the surface their `open` names, so a check that names none
inherits whichever surface the previous file left behind. That had
already happened once, to the check that was supposed to be measuring
the landing page.
The card holds a titled section and said so only in its type. Screen reader
users got an unlabelled run of text; `role="region"` with the title as its
accessible name announces it as the section it looks like.

It also gives this page an identity that does not depend on a font. The QA
harness proves it is on a surface by finding a node whose name carries that
surface's marker and whose box is non-zero, and every other node the dashboard
owns is a heading or a paragraph. Text has no box on a host with no font
catalogue, which is what CI runs: the two headings lay out at 736x0 and 670x0
there, so nothing on the page could be recognised and every check that opens
the dashboard failed with "could not open: no visible, enabled, sized semantic
control matching it". Nineteen of them, none about this page. The region is
736x74 with or without a glyph.

`aria-labelledby` would be the idiomatic form and is not usable: the engine's
name computation does not follow it, and the region comes back unnamed.
Arrival is judged by finding a node whose name carries the surface's marker
and whose width and height are both above zero. Both auth forms were named by
their titles, "Welcome back" and "Create an account", and a title is a heading:
its box is exactly its shaped glyphs. CI's host is built without a font
catalogue, so a heading there lays out at its line width and zero height, and
neither form could be recognised.

Nothing on either page ran as a result. Every check that opened one reported
"could not open: no visible, enabled, sized semantic control matching it",
which reads as a missing link rather than as an unmeasurable marker, and the
two surfaces took eighty-nine checks down between them across five groups.

Each form is now named by a field instead. A field is padded and bordered
whether or not a glyph shapes, and "Username" and "Display name" each name
exactly one node in this application, which is what a marker has to do: it has
to be absent from the page being left, not merely present on the page being
reached.

127 of 154 fontless with this, from 38, and 142 once the dashboard has a named
region to be recognised by as well.
Twelve checks were left failing on a fontless host once both auth forms and
the dashboard could be reached again, and not one of them was about the site.
Text shapes to no glyphs there: a heading lays out at its line width and zero
height, and a bare inline anchor gets no box at all. Measured here, fontless:
the landing title is 0x0, the dashboard's two headings are 736x0 and 670x0,
and the login card's own Sign up anchor is 0x0 while the header's is 26x36.

Two of the twelve moved to a padded control in the same region and stayed in
CI. A relation is read from two boxes, so a flat heading is not a position and
asking where the header is relative to one asks nothing that can be answered.
The landing page's comparison now names the hero's own call to action and the
dashboard's names `@card`, which is the pair the login and signup checks
already used. Both are the stronger question as well as the portable one,
since those are the boxes the layout actually places.

The other ten are in `tests/ps-qa/checks-fonts`, moved rather than weakened,
and they are of exactly two kinds. Three `Contrast` audits address `heading`:
the audit reads resolved colours rather than committed pixels, but it skips
any node with a zero box, so naming a heading on a fontless host selects
nothing and the check fails for selecting nothing. The audits over `link`,
`button` and `textbox` stayed in `checks/`, including the one the light
palette fails, because those nodes have a box without any font. The other
seven put a heading or a bare anchor at one end of `Above`, `ContainedBy`,
`DistinctPositions` or a family-wide `Paints`, and there is nowhere else on
those pages to move that end to.

Two checks were written for the new group rather than moved: it is a directory
of its own and inherits neither position nor theme from `theme.ron`, so the
light-theme audit switches the theme itself and puts it back. The switch
carries `settle_after_ms` for the reason `theme.ron` records, that the palette
recascades a frame after the attribute selecting it.

144 of 144 fontless with this, from 142 of 154. Run the other twelve against a
host built with `system-fonts`; the group's header says how.
The suite had only ever been run on a laptop with a font catalogue, and
nothing said that the runner has none. Every one of the twelve checks that
broke on it was written in good faith by somebody who had no way to know that
a heading measures nothing there.

So the workflow and the working agreement now say it, and the agreement says
what to reach for instead: `Present` for a node whose observable is its text,
`Count` where the words name more than one node, and a padded control at
either end of anything geometric. `tests/ps-qa/checks-fonts` is named as the
place for the questions that genuinely need a font stack, and as the directory
CI does not run.
@pathscale pathscale changed the title feat(ui): migrate to @pathscale/ui 3.1.0 feat(ui): migrate the starter to UI 3.2 Sep 11, 2026
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