# 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.ts` and `web/src/lib/types.ts` are 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` (from `web/`), and `zig build test`. The release gate (`zig build dist` + `verify-dist`, `.gitea/workflows/gates.yml`) keeps its byte budgets; they are not tight — `web/dist` is 440,924 bytes raw against the 15 MiB binary budget — but `verify-dist` remains the arbiter. - `assert-bundled` fails whenever the bundled npm package set changes. That failure is the license workflow trigger, not an obstacle: review each new package into `licenses/inventory.zon`, then record the new set in `licenses/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 ``, so `getByLabelText(...)` + `fireEvent.change` patterns break in the tests for the 7 Select files (LookupPage, QueryLogPage, LocalDnsPage/RecordsTab, RulesPage, SettingsPage, ClientsPage). They move to `userEvent`-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; wire `stylex.vite()` per ruling 1. Tailwind plugin stays. - S1.2 tokens per ruling 2; shared styles per ruling 3. `ui/classes.ts` is not deleted yet — both vocabularies coexist until S3. - S1.3 `styles.test.tsx`: render a probe component using `stylex.props(styles.button)` under vitest; assert a non-empty `className` lands on the element (proves the transform runs in the test pipeline, since `runtimeInjection` is off). - S1.4 license entries per ruling 8. Acceptance (S1): - [ ] `npm run dev` serves a probe page where a StyleX rule visibly applies and edits hot-reload (checked by hand, result recorded). - [ ] `npm test` passes including `styles.test.tsx`. - [ ] `npm run build` emits StyleX CSS into the `web/dist` assets; the built `index.html` page renders with the probe styled. - [ ] `npm run assert-bundled` passes with `@stylexjs/stylex` in the recorded set; `zig build test` passes (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 and `ui/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 `className` literal survives in an owned file. - S2.3 `window.confirm` removal and test rewrites per rulings 5 and 11. - S2.4 license review per ruling 8 — every package `assert-bundled` names. 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-dns` expose `role="tablist"`; keyboard arrow keys move between tabs (asserted in `LocalDnsPage.test.tsx`). - [ ] No `className=` literal in S2-owned files (`grep -c 'className="' ` = 0; `stylex.props` spreads excepted). - [ ] `assert-bundled` passes with the RAC closure recorded; `zig build test` passes. - [ ] 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.ts` when its import count reaches zero. - S3.3 remove Tailwind: drop `tailwindcss` and `@tailwindcss/vite` from `package.json`, the plugin from `vite.config.ts:3,8`, and the `@import "tailwindcss"` line from `styles.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/src` returns zero matches; `grep -rn 'tailwind' web` matches nothing outside `package-lock.json` history (i.e. no source, config or dependency reference). - [ ] `ui/classes.ts` is 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:138` and `:235` name StyleX + React Aria; `git diff PLAN.md` touches nothing else. - [ ] All gates green, including `zig build dist` + `zig build verify-dist` with `-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 utility `className` literal in `web/src`. - [ ] RAC appears in exactly four wrapper files plus their consumers; no other `react-aria-components` import (`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.zon` and the bundled reality agree (`assert-bundled` and `zig build test` both green). - [ ] `web/src/lib/types.ts` and `web/src/lib/contractSamples.gen.ts` are 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: false` stays), no CSS-in-JS at runtime, no styled-components-like patterns. - No dynamic class-name assembly, no `clsx`/`classnames` dependency, 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 under `src/` (the Zig tree). - No `vite-plugin-stylex` (unmaintained community plugin).