Skip to content

Livewire → Inertia v3 + React: the console, ported whole - #5

Merged
sylvesterdamgaard merged 10 commits into
mainfrom
feat/inertia-react
Aug 27, 2026
Merged

Livewire → Inertia v3 + React: the console, ported whole#5
sylvesterdamgaard merged 10 commits into
mainfrom
feat/inertia-react

Conversation

@sylvesterdamgaard

Copy link
Copy Markdown
Contributor

Livewire and Volt are gone. The console is Inertia v3 + React 19 + TypeScript, on the
same URLs, the same route names, the same authorization and the same two planes — 101
pages, ported rather than redesigned. resources/css/app.css is still the source of
truth for the design language, so runtime white-labelling works unchanged.

Why the seam mattered

Every console mutation used to be a component method POSTed to one /livewire/update
endpoint, so route middleware never saw the individual action. That single fact is why
ImpersonationCallGuard hung off Livewire's call event, why authorization lived in
boot() rather than mount(), and why the persistent-middleware list existed at all —
a guard missing from it ran on the page load and then stopped enforcing on everything the
page did. With env.admin missing, the whole environment control plane answered
unauthenticated action requests, and the snapshot checksum was keyed on APP_KEY.

Every mutation is now its own request, with its own verb and its own stack.
'unsafe-eval' left the CSP with the last Volt page — it was there because Alpine
evaluates x- expressions with new Function.

What replaced the guards, and how it is held

ReadOnlyWhileImpersonating answers "is this a write?" with the HTTP method, globally in
the web group: deny-by-default, no allowlist of method names to keep current.

PersistentMiddlewareTest had been erroring since composer remove — it reflected on
a class that no longer exists. WriteRouteStackTest replaces it, derived from the router
rather than a hand-kept list:

  • every write is guarded at least as tightly as the pages it sits beside
  • the read-only rule resolves onto every stateful route

One exception is named with its reason (sso.saml.acs, which is the relying-party half of
federation and must not carry plane:issuer), and the ten writes with no page to compare
against are listed rather than counted. Both mutation-checked.

Three defects found on the way

  • ConfirmDelete lost autocapitalize/autocorrect/spellcheck. iOS capitalises
    the first letter, so the typed name could never match and no destructive action could be
    confirmed on a phone. The disabled confirm button now says why it is disabled.
  • InvitationController granted roles past GrantAccessRole — the same SoD rule
    through a second signature, which is the drift its own comment worried about.
  • legacy_login_declarations.client_id is four characters too narrow for a cid_ id.
    PostgreSQL refuses the insert (22001), MySQL truncates it to an id matching no client,
    sqlite ignores widths. Repaired by migration here and fixed upstream in laravel-id
    (25b3328) — including the fixture that let the engine matrix miss it, which declared
    with 'client-a'.

Sweeps that had quietly lost their subject

Four were passing over deleted directories: the SoD grant sweep, the brand-icon layouts,
the copy sweep's coverage floor, and the environment-plane port check. Each follows the
code now instead of being retired, and none had its floor lowered — the copy sweep reaches
the module React pages it always claimed to cover.

Two tests that could not see what they claimed

Found by opening the console on a phone by hand. Under ->on()->mobile() the harness
emulates touch, and two things fail silently: a click never completes (no touchend, so
no click is synthesised), and ->script() evaluates against a document React has not
rendered into. So the horizontal-scroll check was measuring an empty page, and nothing
had ever opened the mobile drawer
— which at that width is the only navigation there is.
Both run at a plain narrow viewport now, with a rendered-page guard. AccessibilityTest
keeps the device preset, correctly: assertScript and axe do wait for the render, checked
against a deliberately overflowing page.

Verification

All green locally before this was pushed:

composer qa 1894 tests / 10,124 assertions; phpstan level max, no baseline; licences; SBOM byte-identical; audit
PostgreSQL 17 1801 passed, from an empty schema
MySQL 8.4 1801 passed, from an empty schema
Browser + axe 92 passed — both planes, dark, phone widths
npm ci && build && typecheck && lint && test clean; Vitest 85 passed
composer setup from a bare database installs and serves, single-tenant and multi-tenant
grep -ri "livewire|wire:" no live code; composer show lists nothing

Deliberately not in this PR

SSR. cbox-id is self-hostable and ships one runtime; a Node SSR process makes a
stranger's install harder. Per-page code splitting, instant visits and prefetch instead,
with branding and theme server-rendered in the root view so a branded login paints before
hydration. ssr.tsx can be added later without touching a page.

The foundation only — no page is served through it yet, and every Volt page still
renders exactly as it did. Both stacks build from one Vite config until the last
page is ported, which is what keeps the suite green through the middle of the move
instead of only at the ends.

What is here:

  - inertiajs/inertia-laravel ^3.3 and laravel/wayfinder ^0.1. Wayfinder generates
    TypeScript route helpers and form actions from routes/*.php on every dev start
    and every build, so nothing in resources/js spells a URL and a renamed route is
    a compile error rather than a 404 somebody finds later. The generated trees are
    gitignored: they are a second spelling of the route table, not source.

  - resources/views/app.blade.php — the one root document. It carries the two things
    that cannot wait for React: the theme attribute (a cookie, because the server
    cannot read localStorage) and the brand's token override as a <style> block, so
    a branded sign-in never paints Cbox blue first. Both were already true; what
    changed is that they are resolved once, in App\Platform\Appearance\BrandContext,
    instead of by whichever layout a page happened to declare.

  - Shared props as typed objects under app/Http/Props/, not associative arrays.
    Inertia resolves Arrayable recursively, so a readonly class with a constructor
    can be handed straight to a render — and a prop cannot reach the browser
    half-built with a key the page assumed.

  - A local-only Vite origin in the CSP. @Vite in development points the page at a
    second origin, and script-src 'self' refused all of it — so running the dev
    server meant switching the policy off, which is how a machine ends up testing a
    policy it does not run. Guarded on APP_ENV=local AND the presence of the hot
    file Vite writes, so a built image cannot reach that branch.

Two renames the generator forced, both of which were latent ambiguities:

  - PasskeySignInController::options() is now ::challenge(). The URI is unchanged
    (it is published in the Frontend API and the SDKs), but the route is registered
    for ['post', 'options'] and an action sharing a name with an HTTP verb on its
    own route cannot be generated into a typed client unambiguously.

  - The route named compliance.exports is now compliance.data-exports. `exports` is
    not a usable identifier in an ES module — TypeScript treats assignments to it as
    CommonJS exports, so the generated helper had no methods. The URI and the Volt
    component name are unchanged.

Toolchain: TypeScript 5.9 (typescript-eslint has no TS 7 support yet, and a linter
that cannot run is worse than an older compiler), oxlint in place of ESLint (the
react and jsx-a11y plugins do not support ESLint 10, and ESLint 9 is end-of-life),
Vitest with an axe matcher so accessibility is asserted per primitive.
…eady had

Twenty-six primitives, built against `resources/css/app.css` rather than beside it:
the shapes stay where they are, and these decide which of them applies. Nothing
visual moves — a `<Pill tone="success">` emits the same `.cbx-pill--success` the
Volt pages do, so a white-label token override reaches both. Verified in the
browser under a customer palette and under the default one, light and dark.

WHAT A COMPONENT BUYS THAT A CONVENTION DID NOT. The Volt console wrote label,
hint and error markup by hand at several hundred call sites, and the wiring
between them — `for`/`id`, `aria-describedby`, `aria-invalid`, a live region —
was a matter of remembering. `<Field>` generates the ids and the relationships
are not optional: a field cannot be built without a label, and an error cannot be
shown without being announced. Same story for the overlays. The Alpine dialog had
no focus trap at all, because the Alpine Focus plugin is not loaded in this app —
Tab walked out of the dialog and into the page behind it. Radix owns that now,
and the test walks a full Tab cycle to prove it.

Three defects the tests found while being written:

  - The dialog did not return focus on close. Radix restores to its own Trigger,
    and this dialog deliberately has none (it is opened by a button, a menu item
    that has since unmounted, a redirect) — so the default was to focus nothing,
    dropping a keyboard user at the top of the document. `onOpenAutoFocus` is the
    only moment the opener is still knowable; it is caught there.

  - `<Button>` had no default `type`, which inside a form is `submit` — the way a
    Cancel beside a Save submits the form.

  - The type-to-confirm dialog held its typed name in the parent. Cleared in an
    effect, a second opening could render ALREADY CONFIRMED for one frame, one
    Enter away from destroying something nobody re-read. It mounts on open now,
    so the field starts empty by construction.

Also: `CopyButton` waits for the clipboard promise. The version it replaces set
"Copied ✓" synchronously and unconditionally, so on an insecure origin or a denied
permission it claimed success over an empty clipboard — data loss on a secret that
is shown exactly once.

The checkbox and radio hit areas are grown to 24px with a pseudo-element while the
drawing stays 16px (WCAG 2.2 SC 2.5.8), and to 40px under a coarse pointer.

/dev/design-system draws all of it on one page, registered only when
`app()->environment('local')`. It exists because the suite cannot see whether a
control is DRAWN: a switch whose thumb is invisible against its track in dark mode
passes every test in this repository. DesignSystemRouteTest holds the guard —
removing it turns both cases red.

Toolchain notes: oxlint's react-perf object/array/JSX-prop rules are off. They
describe a real hazard only for a React.memo'd component, and nothing here is
memoized — left on, they fire on `options={[…]}` and `actions={<Button/>}`, which
is the design system's own API, and a linter people learn to ignore is worse than
no linter.
There were two — 385 lines of blade for the organization plane and 193 for the
environment one — each recomputing the same answers from the same sources in its
own idiom. The drift between them is written down all over both files: one carried
the impersonation banner and the other did not, so an operator who started an
impersonation from a page on the wrong layout got a banner-less console and no way
out except POSTing the exit endpoint by hand; one used the shared mobile navigation
and the other hand-rolled a drawer, so the same page behaved differently on a phone
depending on which plane served it.

None of that was a rendering problem. The chrome was described twice.

App\Platform\Console\ShellPayload is now the one description, and it is a typed
object rather than a nested array in a template — so the eyebrow, a breadcrumb and
a test can all read the same answer the rail does. Both planes go in and one shape
comes out. The organization plane's navigation still comes from the console-kit
registry, so a module that composer-requires in appears in the rail with no edit to
any of this.

The React side is arrangement only:

  - Rail, with the three states the app-shell guideline specifies. The focus
    handlers are not decoration: without them the rail expanded for a pointer and
    never for a keyboard, so tabbing through the primary navigation moved between
    unlabelled icons whose only text was a `title` attribute.

  - Subnav, whose collapsed strip is a button. In the version it replaces it was a
    div with a click handler, so a keyboard user who collapsed it lost tier-2
    navigation for good — no tabindex, no role, no key handler, and the only way
    back was a shortcut documented in the title of the control that had just
    vanished.

  - MobileNav, on Radix rather than the hand-rolled focus trap it replaces — which
    existed because the Alpine Focus plugin is not loaded in this app, and had been
    reimplemented, differently, in three places.

  - A ⌘K palette that is navigation only. The Volt console's ⌘K box was REMOVED
    rather than left in place because it searched nothing, and a dead affordance
    teaches people the shortcut does not work. Everything here comes from the same
    nav registry the rail draws from, so it offers exactly the pages this person can
    reach on this plane with these features on — never one that would 404 them.
    Searching entities needs an endpoint that scopes and authorizes the search;
    that is a feature, not a widget, and shipping the widget first is how the last
    one came to be deleted.

Three things the linter was right about, and one it was not:

  - The sandbox banner's `role="status"` did nothing. A live region present at page
    load is not announced — only updates to one are — and the text never changes.
    What reaches a screen reader is the copy, read in document order. Role removed.

  - The impersonation countdown resynced in an effect, rendering one frame of the
    stale figure on every navigation. On a control whose whole job is to say how
    long you have left, a visible jump backwards reads as a broken countdown. It
    adopts the server's figure during render instead.

  - The sub-nav's collapsed state was read from localStorage after mount, so a
    176px column appeared and vanished on every navigation. Lazy initialiser.

  - It objected to the rail's hover handlers and to `role="img"` on the inline
    brand mark. Both are suppressed with the reason in place: hovering is not how
    the rail is used, and the mark is filled from `var(--primary)` so it cannot be
    an <img> — a data URI resolves none of this page's custom properties and would
    paint one fixed colour on every theme and for every tenant.

The root view now paints the document background and carries the nav-pinned class
from its cookie, for the same reason it already carried the theme: a class applied
by JavaScript arrives after the first paint, and the console animated 52 → 210px on
every hard refresh for anybody who had pinned the rail.
…ooks

The seam first. ConsoleRoutes::page() registers a capability on both planes from
one declaration, and it now takes a CONTROLLER as readily as a Volt component —
so the six modules move by changing one argument, and a page can be ported without
touching the middleware stacks that guard it. The Volt branch is transitional and
goes with the last Volt page.

Then webhooks, end to end, as the shape everything else follows: a controller per
capability, a FormRequest per write, typed prop objects, and one React page per
route. Verified in a browser — created an endpoint, read the one-time secret,
rotated it, paused, resumed, deleted.

WHAT MOVING THE GUARDS OUT OF THE PAGE ACTUALLY CHANGED.

Under Volt every one of those six writes was an action POSTed to /livewire/update,
so no route middleware could see them and each page had to ask for itself — in
boot() rather than mount(), because only boot() runs per action. Now each write is
its own route:

  - `console.admin` replaces sixty-odd hand-written boot() guards. It is also in
    the persistent-middleware list: the exemption to justify is "a gate that need
    not hold", and this one does.

  - ReadOnlyWhileImpersonating replaces ImpersonationCallGuard's allowlist of
    method names with the HTTP verb. That is strictly stronger — a new endpoint is
    refused because it is a write, not because somebody remembered to think about
    it — and it is registered globally, because a guard on a route group is a guard
    the next route group will not carry. Livewire's endpoint stays exempt while the
    old guard still covers it.

  - The step-up now runs where it always should have: the create PAGE asks at the
    door and the POST asks again, and the test posts DIRECTLY to prove the second
    one is load-bearing. A gate that only lived on the form is satisfied by never
    opening the form.

THREE TESTS THAT WERE ABOUT TO GO GREEN OVER NOTHING.

  - EnvironmentAdminActionsTest drove the webhook mutations through Volt::test(),
    which invokes a component directly and never routes — so it kept passing after
    the routes stopped pointing at that component at all. Rewritten over HTTP, and
    one of its assertions turns out to have been comparing an enum to a backing
    string, which is never identical: it would have held with the pause deleted.

  - ConsoleNavHonestyTest demanded a rail entry for every `environment.*` route.
    That was free while every mutation was a Livewire action with no route name.
    It asks about GET routes now — a page is something you navigate to; a POST is
    something you do to one.

  - ConsoleParityHealthCheck read routes/web.php with a regex that only knew
    Volt::route, so it reported the ported capability as "registration not readable"
    — and the install command failed its own health check, which is how this was
    found. It asks the ROUTER for controller routes now, which is both simpler and
    truer: it compares what will run rather than what a file says should.

The tab title moves to the server. ConsoleAreasTest holds that a nav entry, the
<title> and the page's heading all agree, and a client-rendered title would have
left the first paint of every console page saying nothing but the product name.
The controller states it once: the root view renders it, the layout re-renders it
through <Head> across client-side navigations, and <PageHeader> takes its h1 from
the same prop — so the three cannot disagree by construction, which is a stronger
invariant than the one the test used to check.

The CSP note in SecurityHeaders was wrong and is corrected: Inertia v3 puts page
props in a `<script type="application/json">`, not a data attribute. That is
compatible with `script-src 'self'` rather than an exception to it — a script
element with a non-JavaScript type is never prepared for execution, so the policy
is never consulted. Checked in a browser under the real policy.
Five pages, and the pattern for the rest of them: a controller per surface, a
FormRequest per write, and the page's name stated once by the server.

WHAT MOVED, AND WHAT THAT FIXED.

The reset-from-a-link flow keeps the property the whole page is designed around —
the confirmation is identical whether or not the address has an account here, and
the throttle is keyed on the address so it cannot be used to probe. What it gains
is a `<PasswordField>`: `autocomplete="new-password"` beside a hidden username
field, so a password manager UPDATES the credential it already holds rather than
saving a second one, and a `passwordrules` hint so Safari's generator produces a
password this tenant's policy will actually accept rather than one refused after
the person has already saved it.

The forced change keeps its guard — only somebody the middleware is actually
HOLDING may set a password here, on either of the two reasons it holds them, and
the check sits immediately before the write against the same id the write uses.
The exemption that stops the redirect looping is not a restriction, and without
that guard this is an authenticated password-SET endpoint that asks for nothing.

The account switcher is a POST now. It was a Livewire action, which is a POST by
accident of the transport; it is one on purpose here, because a GET that changes
who you are is a GET any image tag on any page can make.

The social-link confirmation reads the pending link from the SESSION on submit
rather than from what it displayed — the same reasoning the Volt version had, and
now enforced by there being no submitted copy at all.

ACCESSIBILITY MOVES TO A REAL BROWSER, page by page.

The jsdom sweep audits the server's HTML, and a client-rendered page has nothing
in its response but a mount point — so it would audit an empty document and report
no violations, which is exactly the shape of green that guard's own docblock warns
about. Its `strlen > 2000` check is what caught this rather than a silent pass.

The replacement is Pest's browser plugin against the running app, and it is
strictly better coverage: jsdom has no layout engine and no cascade, so the old
bridge had to disable `color-contrast` — the one rule this design system's tokens
are most carefully tuned for, and the one that has actually regressed here before.
Each page moves as it ports; the jsdom file shrinks and goes with the last Volt
page.

Two `assertSee` tests migrate to `assertInertia` — the props, not the prose. The
words are the page's to choose and it renders them in the browser; scanning the
response body for them would fail the day somebody improves the copy, which is not
a regression.
PHPStan was right and the code was working by luck: RouteCollectionInterface is
not itself iterable, so `foreach (Route::getRoutes())` walked a value the analyser
could only see as mixed. `getRoutes()` on it returns the array — which is what the
nav-honesty test next door was already doing.
… needed

Login, sign-up, the second factor, the emailed step-up, invitation acceptance
and the step-up prompt. Driven in a real browser: signed out, signed in, walked
the identifier-first flow, watched the toast.

THE ONE THAT COULD ONLY BE FOUND BY LOOKING.

An Inertia form that redirects to a page Volt still serves breaks. The client
follows a redirect with an XHR and expects a page object back; ordinary HTML comes
back instead and it cannot render it, so it opens its error modal over the page.
That is what somebody signing in saw — the session was established, the redirect
was correct, and the screen filled with a black rectangle. No test could see it:
every assertion in the suite stops at the 302.

The protocol already had the answer (a 409 carrying `X-Inertia-Location`); what was
missing was knowing when. RedirectOutsideInertia decides it from the destination —
another origin, or a route this app serves with a closure, which on this codebase
means Volt — rather than leaving each controller to remember. The Volt half goes
with the last Volt page; the cross-origin half stays, because an XHR can never
follow a redirect to an identity provider.

A SECOND ONE THE TESTS DID CATCH. BrandContext was resolved fresh at each of its
three call sites, so the controller pinned an organization onto one instance while
the root view and the Inertia middleware read two empty ones — a branded sign-in
painting Cbox blue. It is `scoped` now: written once per request, read twice.

TWO CHANNELS, WITH A RULE.

Props are state and are written into the browser's history entry, which is what
makes Back instant. Inertia's flash is a one-shot and is not. Everything that is a
step in a flow — the identifier step passed, a link was sent, a CAPTCHA was asked
for, a mandate refused THIS attempt — moved to the flash, and so did the one-time
signing secret: a credential in a history entry is a credential at rest, readable
by pressing Back long after the page that showed it has gone.

Laravel's own session flash stays as the `flash` shared prop. It is the bridge
while the port runs — `->with('status', …)` works from a Volt page to a React one
and back — and it goes when Volt does.

WHAT THE PAGES GAINED.

  - A RouteAnnouncer in every layout. A client-side navigation announces nothing
    and moves no focus; `wire:navigate` did this for free and Inertia does not, so
    a screen-reader user got a silently swapped page. Focus is deliberately NOT
    moved: it would fight the filter-while-typing navigations this console is full of.

  - `<PasswordField>` everywhere a password is set or proved — a reveal toggle with
    a spoken state, `passwordrules` so Safari's generator produces something this
    tenant's policy will accept, and a hidden username field so a manager updates
    the credential it holds instead of saving a second one.

  - `<Field labelAction>` for "Forgot password?" and "Use a different email". The
    markup it replaces had a visible heading beside the link and a second,
    screen-reader-only label for the input — two names for one field, one of them
    invisible.

  - WebAuthn as a typed module rather than a delegated click handler on `document`,
    and a Turnstile component using `render=explicit`, which needs no global
    callback name and therefore no shim to publish one.

Test migration continues: five mandate screens now assert the flash the door wrote
rather than the sentence the page renders from it, and the exact `RefusedFactor`
sentence rather than a fragment of it. The jsdom a11y sweep has no public auth page
left in it — they are all audited in a real browser now, where colour contrast is
actually computed.
…with it

The last of the Volt console is ported: the platform plane, the account and device
surfaces, the OAuth consent screen and the admin portal. `livewire/livewire` and
`livewire/volt` are out of composer, `resources/views/livewire` and the whole Blade
component tree are deleted, and `'unsafe-eval'` — which existed only because Alpine
evaluates its expressions with `new Function` — is out of the CSP.

THE GUARD THAT ONLY EXISTED BECAUSE OF THE SEAM

Every console mutation used to be a component method POSTed to one `/livewire/update`
endpoint, so route middleware never saw the individual action. That is why
`ImpersonationCallGuard` hung off Livewire's `call` event and why `PersistentMiddleware`
had a list at all: a guard not on that list ran on the page load and stopped enforcing
on every action behind it. Both are gone with the endpoint.

What replaces them is not nothing. `ReadOnlyWhileImpersonating` answers "is this a
write?" with the HTTP method, globally in the `web` group — deny-by-default without an
allowlist of method names. And `PersistentMiddlewareTest`, which had been ERRORING since
`composer remove` (it reflected on a class that no longer exists), is replaced by
`WriteRouteStackTest`: derived from the router, it holds that every write is guarded at
least as tightly as the pages it sits beside, and that the read-only rule reaches every
stateful route. Both mutation-checked. One documented exception, `sso.saml.acs`, is
named with its reason rather than the route being exempted wholesale.

THREE REAL DEFECTS, none of which the sqlite suite could see

- `ConfirmDelete` lost `autocapitalize`/`autocorrect`/`spellcheck` in the port. iOS
  capitalises the first letter, so the typed name could never match the resource name and
  no destructive action could be confirmed on a phone at all. The disabled confirm button
  now describes why it is disabled, which was the other half of the same bug.
- `InvitationController` granted roles with a raw `Roles::assign()` past
  `GrantAccessRole`, applying the same SoD rule through a second signature — the drift
  its own comment worried about. Routed through the gate.
- `legacy_login_declarations.client_id` is four characters too narrow for a `cid_` id:
  PostgreSQL refuses the insert, MySQL truncates it to one that matches no client, and
  sqlite ignores widths. Repaired here; fixed upstream too.

SWEEPS THAT HAD LOST THEIR POPULATION

Four were passing over nothing — the SoD grant sweep, the brand-icon layouts, the copy
sweep's coverage floor and the environment-plane port check all globbed directories that
no longer exist. Each follows the code rather than being retired, and none had its floor
lowered: the copy sweep now reaches the module React pages it always claimed to cover.
`docs/security/livewire-action-sweep.md` is `console-action-sweep.md`, its recipe
rewritten for controller routes and its old findings kept as the evidence they are.

AND TWO TESTS THAT COULD NOT SEE WHAT THEY CLAIMED

Found by opening the console on a phone by hand. Under `->on()->mobile()` this harness
emulates touch, and two things stop working silently: a click never completes (no
`touchend`, so no `click`), and `->script()` evaluates against a document React has not
rendered into. So the horizontal-scroll check was measuring an empty page, and nothing
had ever opened the mobile drawer — which at that width is the only navigation there is.
Both now run at a plain narrow viewport, with a rendered-page guard, and both are
mutation-checked.

Gate, all green locally before this was pushed: composer qa (1894 tests, phpstan at max
with no baseline, licences, SBOM, audit); the full suite on PostgreSQL 17 and MySQL 8.4
from an empty schema; npm ci/build/typecheck/lint/test; the browser suite including axe
on both planes, in the dark and at a phone width; and `composer setup` from a bare
database on both deployment shapes.
…olt classes

THE LOCKFILE HELD ONE NATIVE BINARY: darwin-arm64.

`npm ci` on the Linux runner installed no rollup binary at all and the build died on
"Cannot find module @rollup/rollup-linux-x64-gnu" — before a single test ran. The same
hole was in esbuild, lightningcss, oxlint and tailwind-oxide: every native optional
dependency had exactly one platform recorded, this machine's.

`main`'s lockfile carries all 25 rollup platforms, so this was introduced during the
port — an `npm install` that pruned the others and was never exercised anywhere but an
ARM Mac. `npm install --package-lock-only` does NOT restore them; the lockfile has to be
regenerated from an empty `node_modules`. Now 25 rollup / 26 esbuild / 19 oxlint / 12
oxide entries, and the 18 in-range version bumps that came with the fresh resolve are
verified: build, typecheck, lint, and Vitest's 85.

ELEVEN CLASSES THE STYLESHEET STILL DEFINED AND NOTHING WORE

`.cbx-sidebar`, `.cbx-toast`, `.cbx-flow*`, `.cbx-help-panel` and the rest — Alpine-era
primitives whose React replacements style themselves (the toast is `sonner`, the help
panel is a Radix popper). One said so in its own comment: "that class stays until the
last Volt page that uses it is ported, and goes with it." The page was ported and the
class was not, because nothing was watching.

Dead CSS is not just weight — it leaves a reader two ways to build the same control and
no way to tell which is current, which is how `.cbx-help-panel` and `.cbx-help-content`
came to coexist positioned by two incompatible mechanisms.

`StylesheetClassTest` now fails on any `cbx-` class nothing wears, with the modifier case
(`cbx-dialog--${size}`, built rather than written) handled by its stem. Mutation-checked
— and it caught itself first: the comment explaining `.cbx-help-panel`'s removal names
the class, so the sweep read its own epitaph as a live selector until it learned to strip
comments.

Suite re-run against the refreshed dependencies: 1802 passed.
…g at it (#6)

* chore(test): Pest 5 and PHPUnit 13

pestphp/pest 4.7.5 → 5.1.3, pest-plugin-browser 4.3.1 → 5.0.1, phpunit 12.5.30 → 13.3.1,
and the arch/mutate/profanity plugins with them.

NO TEST CHANGED. 14 Unit, 1788 Feature and 93 Browser pass unmodified, with pint and
phpstan at level max clean — which is worth stating rather than assuming, because a
PHPUnit major usually is not free. `composer.json` already required `php ^8.4`, which is
Pest 5's floor, and `ext-sockets` is present for the browser plugin's new transport.

THE BROWSER PLUGIN IS THE REASON TO DO THIS NOW. v5 replaces the npm `playwright
run-server` child process with an amphp websocket client and HTTP server. The 4.x
arrangement was fragile in a way that cost most of a day: a killed run leaves
`.temp/playwright-server.json` pointing at a dead port and never runs `markAsStopped()`,
and `fromPersisted()` dials that pointer without checking anything is listening. That
path is only reached under `Parallel::isWorker()`, so it was not what hung this suite —
but it is a hang rather than a failure, waiting for whoever turns on parallel testing.

The suite that hung for eight hours yesterday runs in 67 seconds here.

* test(browser): drive the destructive dialogs and the whole sign-in door

Six tests over the two things a request-level suite structurally cannot reach.

DESTRUCTIVE ACTIONS. Every existing test of these posts straight to the route, which
proves the server handles a well-formed request and says nothing about the dialog between
the person and that request. The dialog is where the safety lives: a name typed exactly, a
button dead until it matches, the consequence in words. So the dialog is opened and the
confirm button is asserted DISABLED with the field empty, with a wrong case, and with a
prefix — three near misses, because the mobile-keyboard bug that shipped in this component
was exactly a near miss. Then the same dialog is driven to completion, because a
confirmation nobody can satisfy is its own outage.

Asserted as a STATE, not by clicking: a click on a disabled control times out waiting for
actionability, which is a five-second way of learning nothing and reads as a broken test
rather than a working guard.

CREDENTIALS SHOWN ONCE. "Once" is a claim about the SECOND page load, so it cannot be
tested anywhere but a browser: rotate, read the secret off the screen, ask for the same URL
again, require it to be gone and the page to say where it went. Falsified by removing the
reload — the assertion fails, so it is not passing vacuously.

THE SIGN-IN DOOR, END TO END. Every other test of this starts from a session built in PHP,
so the two-step form, the redirect that lands you where you were going, and the sign-out
that must work from anywhere were exercised by nothing that renders them. Three journeys:
in and out again, a refusal that is visible and keeps the email, and an interrupted visit
that resumes at the page it was asked for.

TWO TRAPS WORTH KNOWING, both cost me a wrong diagnosis first:

  - `assertPathIs` does NOT wait for a navigation — it reads the URL as it stands. After a
    submit it therefore describes the page being left. `assertSee` waits for the render, so
    text goes first and the path second. Written the other way round these fail on a
    working sign-in, which is exactly how I first wrote them.
  - `press()` matches VISIBLE TEXT, and this page's h1 also says "Sign in". The submit
    button is clicked by selector instead. Same family as the mobile drawer's trigger,
    whose accessible name and visible text disagree.

Both new files mutation-checked: always-enabled confirm, and a redirect that forgets the
intended URL.

* test(browser): the states a form spends most of its life in

A validation failure is not an edge case — it is what a form does whenever somebody is in
a hurry, and it is the moment the page most needs to be clear. tests/Feature proves the
SERVER refuses: a 422 with a message under the right key. Three separate ways that correct
422 can still be a broken page, none of them visible to a request test:

  - the message is drawn nowhere, so the form appears to do nothing when pressed
  - the message is drawn, but not on the field it is about
  - the form clears itself, punishing the mistake it just reported

All three are asserted, plus the empty state — the one screen every new customer is
guaranteed to see, and one a props test reads as `items: []` while saying nothing about
whether the page then explains itself or draws a table head over nothing.

FINDING A SERVER-ONLY RULE TOOK TWO TRIES, and the reason is worth writing down. The URL
field is `type="url"`, so Chrome refuses a malformed value with its own bubble and never
submits — a first draft used `not-a-url` and was testing the browser, not this
application. `max:500` is a rule only the server knows, which is the shape of every
server-only rule (uniqueness, ownership, entitlement) and therefore the shape worth
proving the page can display at all.

The fieldset error is asserted VISIBLE rather than present: it is a `role="alert"`
paragraph that is `hidden` until there is something to say, and `hidden` is precisely the
attribute that goes wrong — an element that never un-hides reads as "no error" to
everybody and passes any test that only checks the DOM contains the text.

Mutation-checked both ways: unwiring `error={form.errors.url}` from the Field, and pinning
the fieldset's alert `hidden`. Each turns its own test red.

* test(browser): hold the console's layout as a picture, locally

Every other test in tests/Browser asserts something somebody named in advance: this text,
that control, no axe violation. A layout regression is the opposite shape — nobody
predicted it, and the page stays perfectly accessible and full of the right words while
being visibly broken. A card grid that collapses to one column, a rail that doubles in
width, a panel whose padding goes, a dark surface that loses its contrast: every one of
those passes the entire suite today.

Seven baselines, chosen as the console's distinct LAYOUTS rather than seven pages that
happen to exist — the public door, the shell with its checklist, a long form, an empty
state, each in the dark where it matters, and the shell at a phone width. Small on
purpose: a baseline per page per theme per width is a cost paid on every intentional
design change, and a suite that cries wolf gets regenerated without being read, which is
worse than not having one.

WHAT COULD NOT BE MADE STABLE IS ABSENT, and that is the trade rather than an oversight.
The audit trail and the activity log are the densest tables in the console and the obvious
things to pin; they render dates, so their pictures rot on a calendar. `/dashboard` went
the same way — its audit-export card renders a pending count that differed between two
consecutive runs. `travelTo()` is not the answer: it moves the clock in the test process,
and the page is rendered by a separate server process that never hears about it.

EXCLUDED FROM CI, deliberately and with the reason in the workflow. A screenshot baseline
is a picture of ONE renderer: these are Chromium on macOS/arm64, and the CI runner is
Linux/x64, where text rasterises differently and the pinned font is substituted. Every
baseline would fail there for reasons nobody can act on. So this is a local gate today —
`vendor/bin/pest --group=visual` before a design change, and READ THE DIFF rather than
regenerating it. Making it a CI gate means generating the baselines inside the same
container the job runs in, which is worth doing and is not this change.

Mutation-checked twice, and the discrimination is the point: a `.cbx-panel` padding change
fails the form alone, and a rail width change fails all four console pictures while
leaving the two public ones and the phone — where the rail is not drawn — passing.

Browser suite: 109 passed.

* fix(test): a green summary and a red exit code, and a null array key on 8.5

TWO LINES FAILED EVERY CI LEG WHILE EVERY TEST PASSED.

    Tests:  1904 passed (10177 assertions)
    ##[error]Process completed with exit code 1

`EveryPageComponentExistsOnceTest` opened with `use RecursiveDirectoryIterator;` and
`use RecursiveIteratorIterator;`. A Pest test file is in the global namespace, so importing
a global class does nothing, and PHP 8.5 raises a warning saying so. `phpunit.xml` sets
`failOnWarning="true"` — so a no-op import is a failing run.

WHY IT TOOK SO LONG TO SEE. The warning is raised while the suite's files are being LOADED,
before any test starts, so it belongs to no test: absent from the summary, absent from
`--log-junit` (0 errors, 0 warnings, 4 tests), and not printed by `--display-all-issues`.
`wasSuccessful()` returns TRUE; the exit code comes from a separate `failOn*` branch that
nothing narrates. Finding it meant instrumenting Pest's kernel to enumerate the result
object's warning events by hand.

It also explains the shape of the reproduction, which had me looking in the wrong place: a
single file path exits 0 and `--testsuite=Feature` exits 1, because a path loads one file
and a suite loads all of them.

`TestFilesRaiseNoWarningsTest` guards it, and names the cause in its failure message so the
next person spends a second rather than an afternoon. It sweeps only the global-namespace
test files — inside `app/` these imports are correct and necessary. Mutation-checked.

AND A REAL BUG PHPSTAN ONLY SEES ON 8.5. `DashboardController::recentProps()` indexed
`$labels[$targetId]` where the id may be null. PHP coerces a null key to `''`, so an audit
entry with no target — a sign-in, a policy change — read whatever sits under the empty
string rather than missing. Nothing puts a value there, which is the only reason it has
been harmless. The null check comes first now.

Worth recording that my "phpstan clean" was PHP 8.4 and production runs 8.5; the 8.5 leg
had been red on the port branch too, so this was not new to Pest 5.

Full suite: 1905 passed, exit 0. Pint and phpstan clean on 8.5.

* chore(sbom): four Symfony components moved with the Pest 5 resolve

`composer require --dev pestphp/pest:^5.0 -W` widened the resolve beyond the dev tree:
symfony/console, finder and process went v8.1.x → v8.1.5 and polyfill-intl-normalizer
v1.38.0 → v1.42.0. No dependency was added or removed — the SET is identical — but the
committed SBOM no longer described the versions a build resolves, and the drift check
says so on every leg.

Same class as the laravel-id SBOM this week: a provenance record is only worth the last
time somebody regenerated it.
@sylvesterdamgaard
sylvesterdamgaard merged commit 8406616 into main Aug 27, 2026
7 checks passed
@sylvesterdamgaard
sylvesterdamgaard deleted the feat/inertia-react branch August 27, 2026 09:59
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