Skip to content

Rebuild the frontend UI on the design system - #30

Merged
winetree94 merged 30 commits into
mainfrom
frontend-ui-modernization
Jul 27, 2026
Merged

Rebuild the frontend UI on the design system#30
winetree94 merged 30 commits into
mainfrom
frontend-ui-modernization

Conversation

@winetree94

Copy link
Copy Markdown
Contributor

Reimplements every screen in the frontend on @tinyrack/ui design 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 img nodes, which both assistive technology and the e2e assertions read as duplicates. It is pinned to data-theme="tinyrack-dark" because --tinyrack-text-inverse flips 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-range subsetting. @phosphor-icons/react is replaced by lucide-react, which the design system mandates; lucide ships no brand marks, so the provider logos are inlined SVGs.

Profile

/profile moves onto a new AppLayout — 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.

SecurityRow collapses 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-mono and font-bold where the rest of the app uses tokens, an arbitrary max-w-[1500px], and not one TRText.

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.tsx was 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 under features/admin/users/.

Three near-identical "you cannot be here" panels collapse into AdminGateScreen, composing the same AuthLayout + AuthOutcome pair /error already uses.

Defects found and fixed

  • The admin sidebar declared w-80 (20rem) inside a grid track app-shell.css sizes at 18rem, so it overflowed.
  • The inline passkey rename input had no label, no aria-label, and no id — a nameless textbox.
  • modal.tsx's max-h-[90vh] overrode .tr-dialog-box's own, more correct 100dvh-based clamp.
  • text.css was never imported, so every TRText was inert — UA-default headings, color="muted" and truncate doing nothing. It passed axe because inert muted text only raises contrast.
  • Role badges broke one letter per line on a phone, and the dashboard preview badge wrapped mid-word.
  • The admin header carried 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/ui came out of this work.

0.15.0--tinyrack-weight-strong corrected from 800 to 700 (IBM Plex Sans stops at 700, so nothing heavier could ever be drawn), and TRInput.Group / .Adornment / .Action gained a React surface so consumers stop hand-writing internal class names.

0.19.0 — the layer ladder went base: 0 straight to backdrop: 900, so sticky page furniture had no shelf and both TRAppShell and this console reached for dropdown. 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.

/profile joins the axe suite as its own block rather than a ROUTES entry, since it redirects to /login without a session and a bare goto would have audited the login screen a third time.

Verification

  • pnpm build and pnpm biome check . — clean
  • Backend — 1831 passed
  • Standalone — 130 passed, dist tests passed
  • Frontend unit — 404 passed
  • E2E with --retries=0 — chromium 275 passed, firefox 260 passed with 15 intentional Virtual WebAuthn requires Chromium skips
  • 100 screenshots across 25 screens × desktop/mobile × light/dark, reviewed

Layering and typography claims were checked by measurement, not by eye: computed styles, document.elementFromPoint hit tests, and sampled pixel values.

…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.
@winetree94
winetree94 merged commit cbb5498 into main Jul 27, 2026
34 of 37 checks passed
@winetree94
winetree94 deleted the frontend-ui-modernization branch July 27, 2026 07:05
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