Files
nxdns/specs/milestone-23.md
T

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.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 <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.props ships in the bundle) to the runtime closure and the bundled set. @stylexjs/unplugin is a devDependency — runtime closure and bundled set untouched by it.
  • S2 adds react-aria-components and its transitive closure (@react-aria/*, @react-stately/*, @react-types/*, @internationalized/*, @swc/helpers — the exact bundled subset is whatever npm run assert-bundled reports after S2's conversions; every reported name gets a reviewed licenses/inventory.zon entry 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 to within(screen.getByRole("dialog"))).
  • Tabs: LocalDnsPage.test.tsx (tab switcher becomes role="tablist"/ role="tab"; getByRole("button", { name: ... }) queries move to getByRole("tab", ...)).
  • AlertDialog: UpstreamsPage.test.tsx, useCrudForm.test.tsx per ruling 5.
  • Select: RAC Select renders a button + listbox, not <select>, 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="' <file> = 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).