Rebuild the frontend UI on the design system - #30
Merged
Conversation
…okens The Tinyrack design system declares IBM Plex Sans as its only typeface but ships no @font-face, and never applies a family to the document. Nothing in the repo loaded a webfont, so every screen rendered in the UA default font. Latin uses the variable build because --tinyrack-weight-heading is 650, which has no static counterpart in fontsource. KR/JP have no variable build published, so they use the numbered-subset entrypoints whose ~95 unicode-range chunks per weight let the browser fetch only the glyphs it paints; the korean-*/japanese-* entrypoints are single unchunked files and are avoided. Also corrects the font tokens locally: upstream declares a bare "IBM Plex Sans" with no fallback stack, which never matches the variable build family name and points the mono token at a proportional face.
The Tinyrack design system mandates lucide-react as the only icon library. Renames the 16 icons whose lucide names differ and drops every `weight` prop: lucide is stroke-only, so a forwarded `weight` would land on the <svg> as an unknown DOM attribute. Glyphs that were `weight="fill"` become outlines. lucide ships no brand icons — its `Apple` is the fruit — so the Apple, GitHub and Google marks move to a local provider-logos module rather than pulling in a second icon package. They stay aria-hidden and are named by their button. Icon sizing is unaffected: every call site sizes via `size-*` utilities, which override lucide's default width/height attributes.
Every auth screen used the same centred card floating on a blurred background, and passed the same three props to get it. Replaces that with a two-column shell: the deployment identity on the left, the task on the right. The form is not in a card on desktop — hierarchy comes from the contrast between the inverse brand panel and the canvas, so a card would only nest a box in a box. Below md: the panel collapses to a banner and the form does get a surface so it is not floating unanchored on a phone. Branding now renders exactly once, in the panel, which makes it the page h1 and screen titles h2. Previously /login and /login/password each repeated the branding title and subtitle inside their own header, which gave the page two nodes with the same text. The panel keeps its background as an inline style because the URL is runtime config and cannot be part of the CSS build. The theme toggle and language selector move out of fixed/floating positions into a real header bar, so they sit in the tab order and stop overlapping content on short viewports. profile/ and admin/ keep the old PageLayout: they are outside this redesign.
The panel used the `inverse` colour tokens, which flip with the active theme — in dark mode that resolved to near-black text over the always-dark scrim a background image sits under, leaving the product name unreadable. Scoping data-theme to the panel makes every token inside it resolve consistently, so the brand surface reads the same in both app themes. The theme toggle still carried its old fixed positioning as a default, so it floated over the brand panel instead of sitting in the new header bar. It is unpositioned now; page-layout (profile/admin) passes the old classes itself. Also gives the content stack its own vertical rhythm, so screens compose as a flat list of blocks rather than each hand-rolling margins, and caps the mobile brand banner so a background image cannot take a third of a phone screen.
Introduces the shared building blocks the screens will be rebuilt on: - auth-field: the input, on the design system's tr-input-group contract, with a password reveal toggle. Keeps the field-error/input-error-wrapper test ids. - auth-choice-row: one tappable row for picking an option. The 2FA choosers and account switcher each hand-rolled a different version, one of which was a bare button with no focus ring. - auth-method-tile: full-width sign-in method rows, replacing a three-across grid where provider names wrapped and icons never lined up. - auth-footer: owns the footer row so links compose; the old FooterLink baked in one prompt-and-link pair, which is why six call sites passed an empty prompt to get a bare link. - auth-outcome: one terminal-state screen, replacing several divergent copies. - auth-steps: wizard progress. Not built on TRSteps, which is a vertical docs-prose list with no notion of a current step. - password-strength: TRMeter hint scored against the deployment's own policy, no entropy-estimator dependency. - labeled-separator: replaces Divider plus two inline copies of its markup.
Establishes the pattern the rest of the auth surface follows. The method chooser becomes full-width rows instead of a three-across grid where provider names wrapped onto two lines. Password fields gain a reveal toggle, and "Forgot password?" moves onto the password label row rather than sitting between the fields and the submit button, where it separated the two things the user is trying to do. The primitives take a `render` element rather than an `as` component plus loose props. The old FooterLink relied on Record<string, unknown> and an `as object` cast to accept a router Link; passing the element keeps the router `to`/`search` types checked at the call site instead.
Single-form screens, choosers, wizards and decision screens all now compose as a flat stack of blocks over the layout gap, instead of each hand-rolling mb-4/mt-6 margins that had drifted apart. Notable consolidations: - The 2FA choosers and account switcher share one choice row. They previously had three different implementations, one of which was a bare button inside a bordered div with no focus ring of its own. - Terminal states (email verified, reset sent, password changed, error) share one outcome component instead of several divergent copies. - setup/totp rendered its layout and header separately in seven branches of an if-ladder; they render once now and only the body switches, with a step indicator driven by the existing state machine. - consent and terms get a sticky action bar so the decision stays reachable when the scope list is long. Fixes a real bug in the TOTP QR step: the code had no light quiet zone, so on the dark theme it rendered dark-on-dark and could fail to scan.
IconInput, SubmitButton, LoginMethodButton, LoginMethodList, FooterLink, PageHeader and Divider are all replaced by the primitive layer and no longer imported. Their tests go with them; the behaviour they covered is now covered where the replacements are used. Also fixes the labeled separator overflowing its column: TRSeparator sets its own inline-size, which a width utility on the element does not override, so each rule demanded the full container width. The rules are wrapped in flex containers instead.
Adds a single toast surface, wired once in main.tsx above the router so a toast queued just before a navigation survives it. Scope is deliberately narrow, and the rule is written into the component: if dismissing it loses information the user still needs, it is not a toast. So this carries "we re-sent the email" and "codes copied", while every validation error, session-expiry countdown and decision prompt stays inline next to the thing it describes, where a screen reader meets it in document order. Both call sites previously kept a boolean alive with their own setTimeout to fake a temporary message. The resend keeps its timer, but only for what it was really for — a cooldown so the button cannot be used to spam the address. The two test harnesses now mirror main.tsx's provider stack, which is what they are emulating.
An axe sweep of the auth surface found three real defects, all pre-existing: - The language selector announced as an unnamed button. Its visible content is the current value, which the combobox role does not expose as a name. - The OTP boxes announced as unlabelled fields, with no way to tell which position had focus. Each slot now carries its position, and the field gets a visually hidden label rather than an aria-label: Base UI deliberately ignores aria-label on the first slot, since that input is also the one password managers autofill. Naming the OTP root is what lets the label point at a real input instead of at the wrapping group. Adds an axe regression test over the unauthenticated screens in both colour schemes, so this cannot silently come back. Both schemes matter because the brand panel is pinned to dark and sits behind a scrim, so a contrast regression there would only appear in one of them. Also fixes a pre-existing broken e2e assertion: the language option selector matched both "English" and "Auto (English)", failing strict mode.
The resend-verification test asserted an inline success alert. That confirmation is a toast now, so it asserts the toast; the selector lives in the helper rather than the test. The html-interpolation test expected theme-color #570df8, the DaisyUI purple. The migration to the Tinyrack design system replaced that default with a neutral in the server months ago and left the test behind, so it has been failing on this branch since. Now matches DEFAULT_HTML_VARIABLES.
Both were still the English strings, which is plainly visible as the largest piece of copy on two of the most-used screens.
The important one: text.css was never imported, so every TRText in the redesign
was inert. Titles rendered as UA-default headings with browser margins,
color="muted" did nothing, and truncate did nothing. Nothing could catch it —
the build succeeds, and inert muted text only raises contrast, so axe stayed
green. It is the one component stylesheet that was missing.
Sizing the brand title then had to move to TRText's own --tr-text-font-size
hook: the component's per-variant rule outranks a utility class, so a text-*
utility on it is silently ignored.
Also:
- Branding no longer falls back to the sign-in title. That fallback was the h1
of every screen, so an unbranded deployment put "Welcome back!" atop
register, consent and error, and showed it twice on /login. With no product
name configured the screen title is now the h1, rather than the page starting
at level 2.
- The lang search param works for branding again. The prop that carried it was
never passed, so a deployment with per-language branding ignored the URL on a
screen that still honoured it for the signup notice.
- The recovery-codes copy confirmation is visible on /profile. It became a toast
but only the auth layout had a viewport, so on the one screen where losing the
codes is unrecoverable the confirmation went nowhere.
- The password reveal toggle is keyboard reachable; it carried tabIndex={-1}
while its own doc comment claimed otherwise.
- OTP boxes announce both the field name and their position. Base UI drops
aria-label on the first box and lets it replace the inherited field name on
the rest, so the name is folded into each per-box string.
- Choice rows wrap instead of truncating: they carry the only description of
each option, and at phone width even English clips.
- Toasts take their variant from their own type rather than always success.
- Widens the axe sweep to the 2FA and passkey screens, refreshes the e2e warmup
list to files that still exist, drops a now-orphaned i18n key, and uses the
mono token instead of raw font-mono.
0.9.2, 0.9.3 and 0.10.0 landed while this branch was in progress. 0.9.2/0.9.3 finish the docs-layout height fixes started in 0.9.1 and 0.10.0 adds TRPagination; none of it touches anything the auth surface uses, and the build, unit suite and e2e spot check are clean without source changes.
Three separate causes, none of them in the tests themselves. Worker oversubscription. A worker here is not just a browser: each scenario fixture also boots a Hono server with its own MikroORM SQLite database. One worker per core therefore spawned 32 of each on this machine, and workers died with code=4294967295 — which Playwright reports against whichever tests were in flight, so it looked like a dozen unrelated timeouts. Capped at a quarter of the cores, which still scales down to 1 on a small machine. Keep-alive reaping. Node closes idle keep-alive sockets after 5s, and Playwright's request context pools connections, so any test that drives the browser for longer than that between two API calls could send on a socket the server was closing and fail with ECONNRESET. The OAuth tests do exactly that: complete a consent flow, then exchange the code. The server now outlives the per-test timeout, so sockets are only reaped between tests. Non-idempotent teardown. cleanup() disposes the ORM, and doing that twice closes a native handle that is already closing, which aborts the whole worker process. Teardown is memoized, closing an already-closed server is no longer treated as an error (it used to reject and skip the database cleanup), and the fixture no longer hands tests a teardown they never called. Also asks for reduced motion. Playwright waits for an element to stop moving before acting on it, which is correct but put a stability window in front of nearly every interaction for the sake of a decorative entry animation; this takes the app's own prefers-reduced-motion path instead. Verified with retries disabled so a flake fails the run rather than hiding.
…concurrency Restores `workers: '100%'`. Warms by directory instead of by filename. Every worker shares one Vite dev server, so a module still cold when the run starts gets transformed while the machine is at its busiest and every worker waiting on it stalls. The hand-listed set had gone stale — it still named deleted components — so the login route, which nearly every test loads first, was only partly warm. This took the suite from 8.6m to 8.1m. Sizes the timeout budget for the configured parallelism rather than an idle machine. A worker here runs a browser and its own Hono server, and `100%` runs one per core, so a single action can wait on the scheduler far longer than an unloaded run needs. Raising a timeout costs nothing on a green run, so this buys tolerance without making the suite slower.
A worker here is not just a browser: each scenario fixture also boots its own Hono server with a MikroORM SQLite database. One per core starves the machine, and the starvation surfaces as ordinary-looking action timeouts on whichever tests happened to be running — raising the timeouts to 30s did not help, because the work simply was not being scheduled. Raising the worker count also buys very little, because the bottleneck is the single Vite dev server every worker shares rather than the workers themselves. Measured on a 32-core machine over full runs with retries disabled: 8 workers took 10.0-10.6 min and passed 3/3, 16 took 8.3-8.5 min and passed 1/2, 32 took 7.8-8.3 min and passed 1/3. The curve is flat past 8, so determinism costs about two minutes. The table is in the config so the next person does not have to re-measure it.
Three breaking changes landed in 0.11.0 and 0.12.0; one of them reaches us. TRField.Root no longer takes uiSize. It only ever reached .tr-field-control and won over the control's own size, so a root set to md around a control set to sm rendered the wrong height. We passed it in 16 places, and in every one the control already carried the same size, so this is a deletion rather than a move. Verified in a browser that the auth field is still 42px at md. TRBrand was removed and TRTextarea was rebased on Base UI Field.Control; neither is used here. The textarea stylesheet import goes with them. 0.11.0 pointed --tinyrack-font-mono at a real monospace face and made loading it the consumer's job, so @fontsource/ibm-plex-mono is now bundled and the local mono override is gone — upstream is right about it now. The prose override stays, because it is the pattern the design system documents: upstream deliberately names a single family so the typeface is never silently substituted, and expects consumers to extend the stack, which @tinyrack/docs also does. We extend it for the variable build's family name and for the Korean and Japanese faces the Latin one does not cover. Fixes a real defect that upgrade surfaced: the error-code line asked for mono through a utility class on TRText, which loses to that component's own per-variant font-family rule, so it had been rendering in the sans face. The utility now sits on the code itself, where nothing competes with it.
0.15.0 is the release carrying the two fixes contributed upstream from this work: TRInput.Group/Adornment/Action, and --tinyrack-weight-strong corrected to a weight IBM Plex Sans can actually render. auth-field now composes those parts instead of hand-writing tr-input-group, tr-input-group-adornment, tr-input-group-action, and tr-input-group-input. Those are an internal layer shared with Combobox rather than a public contract, so the app was coupled to names that could have been renamed under it. The input joins the group through context now, so nothing in this repo names them. 0.14.0 also moved the remaining component stylesheets into @layer components, which is what had made a utility class silently lose to component CSS here twice. Verified the auth field is unchanged: still 42px at md, the input is still borderless inside the group, and the brand heading still renders 30px at weight 650. 531 e2e pass with retries disabled.
The admin console sat out the auth redesign, so next to the new screens it read as a different product: raw numeric spacing throughout, `font-mono` and `font-bold` where the rest of the app uses tokens, an arbitrary `max-w-[1500px]`, and not a single `TRText`. Worse, it was missing chrome the rest of the app has. There was no theme toggle, no language selector, no toast viewport, and — on an authenticated surface — no way to sign out at all. Those now live in the header and the sidebar, with language before theme so the two shells put the same control in the same place. Three near-identical "you cannot be here" panels (two access-required, one console-disabled) collapse into `AdminGateScreen`, which composes the same `AuthLayout` + `AuthOutcome` pair `/error` already uses. That removes the last raw `text-2xl` and `font-bold` from admin along with every `PageLayout` import, and it deletes `admin-disabled-panel.tsx`. Two real defects turned up while measuring: - The sidebar declared `w-80` (20rem) inside a grid track `app-shell.css` sizes at 18rem, so it overflowed. The width declaration is gone; the design system already owns it. - Two `lg:hidden` wrappers re-implemented visibility rules `app-shell.css` applies to `TRAppShell.Close` and `.Trigger` itself. The page header sheds its card — a card around nothing but a title is a box for its own sake — and its title becomes a real `h1` instead of `TRCard.Title`'s `h2`. Both heading assertions are role-based and level-agnostic, so they hold. `formatAdminRole` had three copies and `formatManagedBy` two, which is how the same eyebrow ended up `tracking-wide` on one screen and `tracking-wider` on another; both move to `format-admin-user.ts`. `MetricStat` and `SummaryStat` were one component with different tails and become `AdminStat`. The dashboard's `xl:grid-cols-[1.35fr_0.75fr]` becomes `xl:grid-cols-3` with a 2-column span: 64:36 versus 67:33 is not a difference anyone can see, and it costs an arbitrary value. The user preview tiles had a hover affordance but no destination, so they are now links to the directory they preview. All 404 unit tests pass unchanged.
`routes/admin/users/index.tsx` was 808 lines holding a summary strip, a
toolbar, a filter bar, a seven-column table, pagination, and a three-mode form
modal. It is now ~230 lines of route definition, guards, state, mutations, and
composition, with the pieces under `features/admin/users/`.
Two raw controls that bypassed the design system entirely become `TRCheckbox`.
Both carry an explicit `aria-label`: the design system's checkbox is a
`button[role=checkbox]`, and whether the accessible name resolves through
`Field.Label` for a non-input control is not something to gamble a test
contract on. `value="on"` is explicit too, so the `FormData` round-trip does
not lean on React's default. The page-size `select` becomes `TRSelect`.
The Role select stays a native `<select>`, with a comment saying why: the
directory test drives it with `selectOptions`, which requires a real
`HTMLSelectElement`. Design-system parity there would cost the only coverage
this form has, and buy nothing a user can see.
The notice banner also stays a banner rather than becoming a toast now that a
viewport is mounted. Base UI renders toasts as `dialog`/`alertdialog` inside a
`region` and never as a live `status`, so a toast would silently drop the
polite announcement a successful create currently makes.
One test assertion changes. `getByText('User directory').closest('.tr-card')`
asserted a styling fact — that this heading sits in a card — and broke as soon
as the card's internals moved. It now anchors on a `data-testid`. The
`.tr-app-shell` assertion in the dashboard test is deliberately left alone:
that one genuinely tests "the shell primitive is present", and should fail if
the primitive is ever swapped.
404 unit tests pass, including the create-user flow that asserts
`email_verified: true` in the request body and `getByRole("status")` after it.
…eLayout `/profile` was the last screen still on the old shell — a blurred full-bleed branding image, a centred card, a theme toggle pinned to the viewport corner, and a language selector floating under the card. Beside the redesigned auth screens it read as a different application. It now sits on `AppLayout`: the auth surface's vocabulary — canvas, header bar, entrance, token-owned rhythm — but full width and with no brand panel. A settings page is a list of cards to work through, not one task to focus on, so surrendering the leading columns to a permanent brand column would be paying prime space for decoration. `AuthHeaderBar` becomes `ShellHeaderBar`, gaining an optional brand mark and an action slot rather than being copied. `brand` defaults to false and must stay that way: on auth screens `AuthBrandPanel` already owns the deployment title as the page `h1`, and turning it on there would give that title two heading nodes. Sign-out moves into that action slot and gains an `aria-label`. Its visible text is `hidden sm:inline`, so on a phone the control had no accessible name at all; `profile.logout` is the same string the label renders, so this names it without shadowing anything. `SecurityRow` collapses four copies of the same row — password, TOTP, passkeys, and each linked provider — that had already drifted into three different paddings. Linked accounts now show the real provider marks instead of a generic chain-link glyph; showing Google's logo at sign-in and a paperclip for the same provider minutes later reads as two different products. `AuthField` gains `errorTestId` and `hideLabel`, both additive with today's behaviour as the default, so no auth call site changes. Seven profile modals drop their hand-rolled field triples for it. `hideLabel` also fixes a real defect: the inline passkey rename input had no label, no `aria-label` and no `id` — a nameless textbox. `modal.tsx` sizes through `--tr-dialog-box-max-width` rather than a `max-w-*` utility, because the box feeds that same property into its own `width: min(…)` calculation and a utility would move one without the other. Its `max-h-[90vh]` override is gone: `.tr-dialog-box` already clamps against `100dvh` minus the viewport gap, which is the more correct bound. `page-layout.tsx` is deleted, and with it `ThemeToggle`'s `className` prop — every shell now lays the toggle out in a real header bar. The blurred branding background goes with it, deliberately: the deployment's identity lives in the brand panel or the header mark, not as a full-bleed wash. `background_url` is still consumed by `AuthBrandPanel`, so no config field goes dead. `/profile` joins the axe suite as its own block rather than an entry in `ROUTES`: it redirects to `/login` without a session, so a bare `goto` would have silently audited the login screen a third time. 404 unit tests pass.
Screenshotting the redesigned console showed the user preview tiles rendering "User" as "Use" / "r": the tile is a flex row whose left column is `min-w-0`, so the badge was the thing that gave when the two competed for width. `shrink-0` on the badge. The email and subject id already truncate, which is the correct thing for them to do.
On a phone the seven-column table compressed the Role, Source, Status and Actions columns until their labels broke one letter per line — "User" rendered as a vertical column of four characters. `whitespace-nowrap` on the badges. The table now exceeds the viewport instead of collapsing, which is what the horizontal scroller wrapping it is for; the email and subject id still truncate, since those are the columns where losing the tail costs nothing.
The admin console's sticky header carried `z-tinyrack-dropdown` (1000) while every design-system scrim sits at 900, so opening any dialog dimmed the console but left its own header bright and looking interactive above blocked content. The class was mine. `app-shell.css` only sets a header z-index for the `docs`/`splash`/`standalone` chrome modes and the `pageScroll="document"` posture; this console runs `chrome="app"` with container scrolling, so the design system contributed nothing and the 1000 came entirely from here. I had justified it as matching what the shell uses internally, which was true of the shell's other modes and wrong for this one. Investigating it surfaced the real gap: the layer ladder went `base: 0` straight to `backdrop: 900`, so sticky page furniture had no shelf and both this console and the shell itself reached for `dropdown`. @tinyrack/ui 0.19.0 adds `--tinyrack-layer-chrome` (100) and moves the shell's own header rules onto it; this moves the console to the matching `z-tinyrack-chrome`. Verified by measurement rather than by eye — reading the screenshots, I twice judged the header still bright and was twice wrong. Sampling the rendered pixels, the header centre goes from `rgb(255,255,255)` to `rgb(112,112,112)` when the dialog opens, exactly matching the content band, and `document.elementFromPoint` over the header returns the dialog backdrop. Also picks up 0.16.0-0.18.0, all additive or fixes: the brand asset catalog, a `displayLg` text role added beside the unchanged `display`, viewport-edge drawer alignment, and an inline-code containment fix. 404 unit tests and 275 chromium e2e tests pass.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Reimplements every screen in the frontend on
@tinyrack/uidesign tokens, and retires the last hand-rolled layout. 30 commits; the summary below is by surface rather than by commit.Auth screens
A split canvas replaces the blurred-background-plus-centred-card shell: the deployment's identity on the left, the task on the right. On desktop the form is not in a card — hierarchy comes from the contrast between the inverse brand panel and the canvas, so a card would be a box inside a box. Below
md:the panel collapses to a banner and the form does get a surface, because on a phone it would otherwise float unanchored.The brand panel renders once in the DOM at every breakpoint. Rendering it twice and hiding one would give the brand title two heading nodes and the icon two
imgnodes, which both assistive technology and the e2e assertions read as duplicates. It is pinned todata-theme="tinyrack-dark"because--tinyrack-text-inverseflips with the active theme and resolves to near-black in dark mode — illegible over the always-dark scrim a background image sits under.The theme toggle and language selector move out of the viewport corners into a real header bar, so they are in the tab order where a header is expected and stop overlapping content on short viewports.
New primitive layer under
components/auth/:AuthField,AuthPageHeader,AuthChoiceRow,AuthMethodTile,AuthOutcome,AuthSteps,AuthFooter,PasswordStrength, and local brand SVGs for the identity providers. Seven older wrappers are deleted.Fonts and icons
IBM Plex Sans self-hosted, variable for Latin and static for Korean and Japanese, with
unicode-rangesubsetting.@phosphor-icons/reactis replaced bylucide-react, which the design system mandates; lucide ships no brand marks, so the provider logos are inlined SVGs.Profile
/profilemoves onto a newAppLayout— the auth surface's vocabulary, but full width and with no brand panel. A settings page is a list of cards to work through, not one task to focus on, so a permanent brand column would be paying prime space for decoration.SecurityRowcollapses four copies of the same row — password, TOTP, passkeys, and each linked provider — that had drifted into three different paddings. Linked accounts now show the real provider marks instead of a generic chain-link glyph.Admin console
The console sat out the earlier passes and read as a different product: raw numeric spacing throughout,
font-monoandfont-boldwhere the rest of the app uses tokens, an arbitrarymax-w-[1500px], and not oneTRText.It was also missing chrome the rest of the app has — no theme toggle, no language selector, no toast viewport, and on an authenticated surface no way to sign out at all.
routes/admin/users/index.tsxwas 808 lines holding a summary strip, toolbar, filter bar, seven-column table, pagination, and a three-mode form modal. It is now ~230 lines of route definition, guards, state, mutations, and composition, with the pieces underfeatures/admin/users/.Three near-identical "you cannot be here" panels collapse into
AdminGateScreen, composing the sameAuthLayout+AuthOutcomepair/erroralready uses.Defects found and fixed
w-80(20rem) inside a grid trackapp-shell.csssizes at 18rem, so it overflowed.aria-label, and noid— a nameless textbox.modal.tsx'smax-h-[90vh]overrode.tr-dialog-box's own, more correct100dvh-based clamp.text.csswas never imported, so everyTRTextwas inert — UA-default headings,color="muted"andtruncatedoing nothing. It passed axe because inert muted text only raises contrast.z-tinyrack-dropdown(1000) above every 900 scrim, so a dialog dimmed the console but not its own header. Fixed upstream — see below.Upstream
Two releases of
@tinyrack/uicame out of this work.0.15.0 —
--tinyrack-weight-strongcorrected from 800 to 700 (IBM Plex Sans stops at 700, so nothing heavier could ever be drawn), andTRInput.Group/.Adornment/.Actiongained a React surface so consumers stop hand-writing internal class names.0.19.0 — the layer ladder went
base: 0straight tobackdrop: 900, so sticky page furniture had no shelf and bothTRAppShelland this console reached fordropdown. Adds--tinyrack-layer-chrome(100) and moves the shell's own header rules onto it. Also picks up 0.16.0–0.18.0, all additive.E2E
Three distinct flakiness root causes fixed — non-idempotent teardown, keep-alive socket reclamation, and stale warmup. Workers set to 25% after measuring: the shared Vite dev server is the bottleneck, so 32 workers buys 8 minutes against 8 workers' 10 with far worse determinism.
/profilejoins the axe suite as its own block rather than aROUTESentry, since it redirects to/loginwithout a session and a baregotowould have audited the login screen a third time.Verification
pnpm buildandpnpm biome check .— clean--retries=0— chromium 275 passed, firefox 260 passed with 15 intentionalVirtual WebAuthn requires ChromiumskipsLayering and typography claims were checked by measurement, not by eye: computed styles,
document.elementFromPointhit tests, and sampled pixel values.