18 KiB
Milestone 23: StyleX and React Aria replace Tailwind
Goal: every styled element in web/src renders through StyleX
(@stylexjs/unplugin at build time); Dialog, AlertDialog, Tabs and Select
come from react-aria-components; Tailwind is removed. TanStack Router and
TanStack Query stay (decided 2026-08-12 after review; the router swap is out
of scope permanently, not deferred).
Design written 2026-08-12 against HEAD 91a0aa9. Needs milestone 22 landed
(the StyleX and RAC .d.ts surfaces are checked by the TS7 tsc -b).
Inventory this spec rests on: 522 className= occurrences across 31
web/src files; 17 shared class constants in web/src/ui/classes.ts;
web/src/styles.css is the single line @import "tailwindcss" (so dark mode
is Tailwind 4's default prefers-color-scheme media strategy — no class
toggle exists); the Tailwind plugin sits at web/vite.config.ts:3,8;
tailwindcss and @tailwindcss/vite are devDependencies
(web/package.json:34,44) and appear in neither the runtime closure nor the
bundled set of licenses/dependency-identity.txt.
Implementation contract (read first)
- Read
AGENTS.md, then this spec whole, before session work starts. web/src/lib/contractSamples.gen.tsandweb/src/lib/types.tsare API contract surfaces; this milestone is styling-only and touches neither.- Every session leaves all gates green:
npm run format:check,npm run lint,npm run typecheck,npm test,npm run build,npm run assert-bundled(fromweb/), andzig build test. The release gate (zig build dist+verify-dist,.gitea/workflows/gates.yml) keeps its byte budgets; they are not tight —web/distis 440,924 bytes raw against the 15 MiB binary budget — butverify-distremains the arbiter. assert-bundledfails whenever the bundled npm package set changes. That failure is the license workflow trigger, not an obstacle: review each new package intolicenses/inventory.zon, then record the new set inlicenses/dependency-identity.txt(procedure in that file's header). Never update the recorded list without the inventory review.
Rulings (binding)
1. Build integration is @stylexjs/unplugin, proven before any page moves
stylex.vite() from @stylexjs/unplugin goes into web/vite.config.ts
alongside the existing plugins (Tailwind stays until S3). Configuration per
the official Vite guide (stylexjs.com/docs/learn/installation/vite):
useCSSLayers: true, runtimeInjection: false, dev keyed off the Vite
mode. The generated CSS injects into an existing CSS asset, so
web/src/styles.css (imported by main.tsx:8) remains the CSS entry for the
whole milestone; the dev/HMR virtual-module wiring from that guide is part of
S1. The unmaintained community vite-plugin-stylex is forbidden. S1's
acceptance proves dev server, vitest run and vite build all handle a
StyleX component before S2 or S3 convert anything — this is a gate, not an
assumption.
2. Tokens live in web/src/ui/tokens.stylex.ts
stylex.defineVars with role names, not raw shades: surface, surface-raised,
border, border-strong, text, text-muted (the zinc ramp today), primary and
primary-text (blue-600/white), danger, danger-surface, danger-border (the red
ramp), focus (blue-600). Each var carries its dark value under
@media (prefers-color-scheme: dark) inside defineVars — same media
strategy the app has now. No theme toggle, no data-theme attribute.
3. web/src/ui/styles.ts replaces ui/classes.ts
The 17 exports of web/src/ui/classes.ts (focusRing, insetFocusRing, input,
smallInput, button, smallButton, largeButton, primaryButton,
largePrimaryButton, rowButton, linkButton, dangerLinkButton, retryButton, th,
td, tableWrap, formCard) become stylex.create style objects in
web/src/ui/styles.ts, composed at call sites via
{...stylex.props(styles.button, extra)}. The milestone-9 accessibility
floor restated by milestone-18 ruling 10 carries over verbatim: every
interactive element renders the focus ring (:focus-visible outline, 2px,
offset 2, focus token; the inset variant for controls flush against panel
edges). ui/classes.ts is deleted in S3 when its last importer converts.
4. React Aria scope is exactly four components — end state, not a phase
From react-aria-components: Dialog + Modal (replacing the hand-rolled
role="dialog" overlay in web/src/features/clients/ClientEditDialog.tsx:27-28),
AlertDialog composition (ruling 5), Tabs (replacing the button-pair tab
switcher in web/src/features/local/LocalDnsPage.tsx), Select (replacing the
7 files carrying native <select>: LookupPage, QueryLogPage, RecordsTab,
RulesPage, SettingsPage, PrefixesEditor, ClientEditDialog). Everything else —
buttons, text inputs, tables, links, banners — stays native HTML styled with
StyleX. A native styled <button> is the finished design, not an interim
one. S2 wraps the four as shared components: web/src/ui/Dialog.tsx,
web/src/ui/ConfirmDialog.tsx, web/src/ui/Tabs.tsx,
web/src/ui/Select.tsx, each styled with StyleX through RAC's render-prop
state booleans (stylex.props(isSelected && styles.selected)) — never
data-attribute selectors, which StyleX cannot express.
5. window.confirm dies with the conversion
All call sites become ConfirmDialog (RAC AlertDialog: role="alertdialog",
focus trapped, danger-styled confirm button):
web/src/features/blocklists/BlocklistsPage.tsx:61,
web/src/features/upstreams/UpstreamsPage.tsx:43,
web/src/features/rules/RulesPage.tsx:34, and
web/src/ui/useCrudForm.ts:49 (which carries the confirm for ZonesTab and
RecordsTab). useCrudForm's CrudFormSpec.confirmDelete keeps its signature;
the hook returns the dialog state instead of calling window.confirm. The
tests that stub confirm today
(web/src/features/upstreams/UpstreamsPage.test.tsx:140,144,188,
web/src/ui/useCrudForm.test.tsx:102) are rewritten to drive the real
dialog: open, assert role="alertdialog" and the entity name in the message,
confirm and cancel paths both asserted. BlocklistsPage and RulesPage gain the
same coverage for their delete paths.
6. Styling mechanics
stylex.create/stylex.props only. Conditions are boolean-guarded style
objects; variants are separate named styles; responsive behavior (the
md: sidebar grid in web/src/shell/AppShell.tsx:90-98, the mobile drawer)
becomes @media conditions inside style values. No dynamic class-name
strings, no clsx-style helpers, no className literals left anywhere in
web/src after S3 — className/style attributes appear only as the spread
of stylex.props(...) (RAC render-prop className functions included).
TanStack Link activeProps/inactiveProps (AppShell.tsx:35-42) pass
stylex.props(...).className outputs.
7. Visual contract: drift accepted, structure pinned
No pixel parity requirement and no visual-regression harness. Pinned instead,
checkable by reading the converted file against the original: same layout
structure (grid/flex relationships, table columns, heading levels), token
roles per ruling 2 (zinc surfaces, blue primary actions, red danger), the
focus floor of ruling 3 on every interactive element, dark mode via media
query, overflow-x-auto table wrappers preserved.
8. License inventory, named per step
- S1 adds
@stylexjs/stylex(runtime dependency;stylex.propsships in the bundle) to the runtime closure and the bundled set.@stylexjs/unpluginis a devDependency — runtime closure and bundled set untouched by it. - S2 adds
react-aria-componentsand its transitive closure (@react-aria/*,@react-stately/*,@react-types/*,@internationalized/*,@swc/helpers— the exact bundled subset is whatevernpm run assert-bundledreports after S2's conversions; every reported name gets a reviewedlicenses/inventory.zonentry before the recorded list moves). This is the milestone's largest review surface. - S3 removes nothing from the license files: Tailwind was dev-only and never
recorded. The
@tanstack/*entries stay untouched.
9. PLAN.md wording moves in S3
PLAN.md:138 "Vite + React + TypeScript + Tailwind; TanStack Router +
TanStack Query" → "Vite + React + TypeScript + StyleX + React Aria; TanStack
Router + TanStack Query". PLAN.md:235 directory comment updates the same
way. Line 562 names loaders and Query only — untouched. No other PLAN
amendment.
10. Conversion order is ascending className count, one commit per file
So a regression bisects to one page. Counts at spec time:
| file | count |
|---|---|
| lib/InlineError.tsx | 2 |
| features/settings/ReadOnlyConfigBanner.tsx | 2 |
| features/dashboard/HealthBanners.tsx | 2 |
| features/local/LocalDnsPage.tsx | 3 |
| features/settings/RestartBanner.tsx | 3 |
| features/dashboard/StatCards.tsx | 5 |
| routes.tsx | 6 |
| auth/LoginPage.tsx | 9 |
| features/dashboard/DashboardPage.tsx | 9 |
| features/groups/GroupSourcesEditor.tsx | 9 |
| features/pause/PauseWidget.tsx | 9 |
| features/blocklists/BlocklistForm.tsx | 11 |
| features/clients/ClientEditDialog.tsx | 11 |
| features/dashboard/DiskCard.tsx | 13 |
| features/upstreams/UpstreamForm.tsx | 14 |
| features/clients/PrefixesEditor.tsx | 15 |
| shell/AppShell.tsx | 17 |
| features/live/LiveLogPage.tsx | 18 |
| features/dashboard/TimeseriesChart.tsx | 21 |
| features/dashboard/UpstreamHealthTable.tsx | 21 |
| features/upstreams/UpstreamsPage.tsx | 21 |
| features/groups/GroupsPage.tsx | 22 |
| features/lookup/LookupPage.tsx | 25 |
| features/local/ZonesTab.tsx | 26 |
| features/blocklists/SourceStatusSection.tsx | 27 |
| features/clients/ClientsPage.tsx | 28 |
| features/rules/RulesPage.tsx | 31 |
| features/blocklists/BlocklistsPage.tsx | 31 |
| features/settings/SettingsPage.tsx | 31 |
| features/local/RecordsTab.tsx | 34 |
| features/queries/QueryLogPage.tsx | 46 |
S2 takes its owned cluster out of this order (converted together because the RAC wrappers land there); S3 walks the remainder smallest-first.
11. Tests that must change, known in advance
RAC changes DOM and focus behavior; these edits are expected work, not regressions to paper over:
- Dialog:
ClientsPage.test.tsx(ClientEditDialog now portals into an RAC Modal; queries move towithin(screen.getByRole("dialog"))). - Tabs:
LocalDnsPage.test.tsx(tab switcher becomesrole="tablist"/role="tab";getByRole("button", { name: ... })queries move togetByRole("tab", ...)). - AlertDialog:
UpstreamsPage.test.tsx,useCrudForm.test.tsxper ruling 5. - Select: RAC Select renders a button + listbox, not
<select>, sogetByLabelText(...)+fireEvent.changepatterns break in the tests for the 7 Select files (LookupPage, QueryLogPage, LocalDnsPage/RecordsTab, RulesPage, SettingsPage, ClientsPage). They move touserEvent-style open-then-select interactions. All other tests query by role/text and are expected to survive; a test that asserted a Tailwind class is rewritten against role or accessible name.
Sessions
Three sessions, strictly sequential: S1 → S2 → S3. (S2 and S3 both write
web/package.json and the license files; the RAC wrappers S3 consumes are
built in S2.)
Session S1: StyleX foundation, proven end to end
Owns: web/package.json, web/package-lock.json, web/vite.config.ts,
web/src/styles.css, web/src/ui/tokens.stylex.ts (new),
web/src/ui/styles.ts (new), web/src/ui/styles.test.tsx (new),
licenses/inventory.zon, licenses/dependency-identity.txt.
- S1.1 add
@stylexjs/stylex(dependency) and@stylexjs/unplugin(devDependency), exact pins; wirestylex.vite()per ruling 1. Tailwind plugin stays. - S1.2 tokens per ruling 2; shared styles per ruling 3.
ui/classes.tsis not deleted yet — both vocabularies coexist until S3. - S1.3
styles.test.tsx: render a probe component usingstylex.props(styles.button)under vitest; assert a non-emptyclassNamelands on the element (proves the transform runs in the test pipeline, sinceruntimeInjectionis off). - S1.4 license entries per ruling 8.
Acceptance (S1):
npm run devserves a probe page where a StyleX rule visibly applies and edits hot-reload (checked by hand, result recorded).npm testpasses includingstyles.test.tsx.npm run buildemits StyleX CSS into theweb/distassets; the builtindex.htmlpage renders with the probe styled.npm run assert-bundledpasses with@stylexjs/stylexin the recorded set;zig build testpasses (drift guard agrees).- All remaining gates green.
Session S2: React Aria primitives and their cluster
Owns: web/package.json, web/package-lock.json, web/src/ui/Dialog.tsx
(new), web/src/ui/ConfirmDialog.tsx (new), web/src/ui/Tabs.tsx (new),
web/src/ui/Select.tsx (new), web/src/ui/useCrudForm.ts,
web/src/ui/useCrudForm.test.tsx, web/src/features/clients/*,
web/src/features/local/*, web/src/features/upstreams/*,
web/src/features/blocklists/BlocklistsPage.tsx and its test,
web/src/features/rules/RulesPage.tsx and its test,
licenses/inventory.zon, licenses/dependency-identity.txt.
- S2.1 add
react-aria-components, exact pin; build the four wrappers per ruling 4, styled with tokens andui/styles.ts. - S2.2 convert the owned cluster fully to StyleX while adopting the wrappers:
ClientEditDialog + PrefixesEditor + ClientsPage (Dialog, Select);
LocalDnsPage + ZonesTab + RecordsTab (Tabs, Select, ConfirmDialog via
useCrudForm); UpstreamsPage + UpstreamForm, BlocklistsPage, RulesPage
(ConfirmDialog, Select). No
classNameliteral survives in an owned file. - S2.3
window.confirmremoval and test rewrites per rulings 5 and 11. - S2.4 license review per ruling 8 — every package
assert-bundlednames.
Acceptance (S2):
grep -rn 'window.confirm' web/src→ only hits, if any, are in files S3 owns (expected: none; ruling 5 call sites all live in S2 files).- Delete flows on upstreams, blocklists, rules, zones and records drive a
role="alertdialog"element; confirm and cancel both covered by tests. - Tabs on
/local-dnsexposerole="tablist"; keyboard arrow keys move between tabs (asserted inLocalDnsPage.test.tsx). - No
className=literal in S2-owned files (grep -c 'className="' <file>= 0;stylex.propsspreads excepted). assert-bundledpasses with the RAC closure recorded;zig build testpasses.- All remaining gates green.
Session S3: the sweep and the removal
Owns: every web/src file not owned by S1/S2 that carries className=
(the ruling-10 remainder: InlineError, ReadOnlyConfigBanner, HealthBanners,
RestartBanner, StatCards, routes.tsx, LoginPage, DashboardPage,
GroupSourcesEditor, GroupsPage, PauseWidget, BlocklistForm,
SourceStatusSection, DiskCard, AppShell, LiveLogPage, TimeseriesChart,
UpstreamHealthTable, LookupPage, QueryLogPage, SettingsPage) plus their
tests, web/src/ui/classes.ts (delete), web/package.json,
web/package-lock.json, web/vite.config.ts, web/src/styles.css,
PLAN.md.
- S3.1 convert the remainder smallest-first per ruling 10, one commit per file, gates green at every commit.
- S3.2 delete
ui/classes.tswhen its import count reaches zero. - S3.3 remove Tailwind: drop
tailwindcssand@tailwindcss/vitefrompackage.json, the plugin fromvite.config.ts:3,8, and the@import "tailwindcss"line fromstyles.css(the file itself stays as the StyleX CSS entry per ruling 1). - S3.4 PLAN.md per ruling 9.
Acceptance (S3):
grep -rn 'className="' web/srcreturns zero matches;grep -rn 'tailwind' webmatches nothing outsidepackage-lock.jsonhistory (i.e. no source, config or dependency reference).ui/classes.tsis gone; nothing imports it.- Dark scheme still follows
prefers-color-scheme(vitest: token var resolves differently under a mocked dark media query, or a recorded manual check in both schemes). PLAN.md:138and:235name StyleX + React Aria;git diff PLAN.mdtouches nothing else.- All gates green, including
zig build dist+zig build verify-distwith-Dweb-dist=web/dist(run once at milestone end).
Orchestrator
Verify S1 acceptance (the ruling-1 gate) before S2 starts; S2 before S3.
After S3: full gate set, then a live smoke — zig build a server with the
fresh web/dist, click through every page in light and dark scheme, exercise
one delete confirm and the local-dns tabs. Record deviations in
## Recorded (implementation).
Module layout
New files:
web/src/ui/tokens.stylex.ts— the design tokens (ruling 2).web/src/ui/styles.ts— the shared style vocabulary (ruling 3).web/src/ui/styles.test.tsx— the transform-pipeline probe (S1.3).web/src/ui/Dialog.tsx,web/src/ui/ConfirmDialog.tsx,web/src/ui/Tabs.tsx,web/src/ui/Select.tsx— the RAC wrappers (ruling 4).
Deleted surface: web/src/ui/classes.ts; the window.confirm calls; the
Tailwind dependency pair and its vite plugin line and CSS import.
Acceptance (milestone complete)
- All session boxes above.
- Zero Tailwind: no dependency, no plugin, no
@import, no utilityclassNameliteral inweb/src. - RAC appears in exactly four wrapper files plus their consumers; no
other
react-aria-componentsimport (grep -rln 'react-aria-components' web/src⊆ the four wrappers). - Focus ring visible via keyboard on every interactive element of every page (spot-checked live, recorded).
licenses/dependency-identity.txt,licenses/inventory.zonand the bundled reality agree (assert-bundledandzig build testboth green).web/src/lib/types.tsandweb/src/lib/contractSamples.gen.tsare byte-identical to HEAD.
Anti-requirements
- No router or query changes: TanStack Router and TanStack Query stay, untouched. That decision is closed.
- No RAC beyond Dialog, AlertDialog, Tabs, Select — no RAC Button, TextField, Table, Menu, Popover, DatePicker. Native elements styled with StyleX are the end state.
- No runtime style injection (
runtimeInjection: falsestays), no CSS-in-JS at runtime, no styled-components-like patterns. - No dynamic class-name assembly, no
clsx/classnamesdependency, no Tailwind-compatibility shim. - No theme toggle, no
data-theme, no stored preference — media-query dark mode only, as today. - No redesign: layout structure is pinned by ruling 7. Drift in shades and spacing is accepted; new page structures are not.
- No visual-regression harness, no screenshot tooling.
- No edits to
web/src/lib/types.ts,web/src/lib/contractSamples.gen.ts,web/src/lib/api.ts, or anything undersrc/(the Zig tree). - No
vite-plugin-stylex(unmaintained community plugin).