spec milestones 22 and 23: typescript 7, stylex and react aria
This commit is contained in:
@@ -0,0 +1,108 @@
|
|||||||
|
# Milestone 22: TypeScript 7
|
||||||
|
|
||||||
|
Goal: move the web toolchain from TypeScript 6.0.3 to TypeScript 7.0.x (the
|
||||||
|
native compiler, GA 2026-07-08). One devDependency bump; every web gate and
|
||||||
|
the release pipeline stay green.
|
||||||
|
|
||||||
|
Design written 2026-08-12 against HEAD `91a0aa9`. TypeScript 7.0.2 is
|
||||||
|
`typescript@latest` on npm at spec time; the milestone pins whatever 7.0.x is
|
||||||
|
current when the session runs.
|
||||||
|
|
||||||
|
## Implementation contract (read first)
|
||||||
|
|
||||||
|
- Read `AGENTS.md`, then this spec whole, before session work starts.
|
||||||
|
- `web/src/lib/contractSamples.gen.ts` is generated; it is type-checked by
|
||||||
|
`tsc -b` but never hand-edited. If TS7 rejects it, the fix goes in the
|
||||||
|
generator (`AGENTS.md` regeneration procedure), not the file.
|
||||||
|
- No license work: `typescript` is a devDependency, absent from both the
|
||||||
|
runtime closure and the bundled set in `licenses/dependency-identity.txt`.
|
||||||
|
The Zig drift guard (`src/licenses_drift_test.zig`) must still pass —
|
||||||
|
it recomputes from `web/package-lock.json` and dev-only changes are
|
||||||
|
invisible to it.
|
||||||
|
|
||||||
|
## Rulings (binding)
|
||||||
|
|
||||||
|
### 1. The bump is one line plus the lockfile
|
||||||
|
|
||||||
|
`web/package.json:45` `"typescript": "6.0.3"` becomes the current 7.0.x,
|
||||||
|
exact pin (repo convention: no ranges, milestone-14 ruling 12 pins by exact
|
||||||
|
version everywhere). `npm install` updates `web/package-lock.json`. Nothing
|
||||||
|
else in `dependencies` or `devDependencies` moves in this milestone.
|
||||||
|
|
||||||
|
### 2. The tsconfigs are already TS7-clean; verify, do not churn
|
||||||
|
|
||||||
|
Checked against TS7's removed options (recorded here so the session does not
|
||||||
|
re-derive it): `tsconfig.app.json` and `tsconfig.node.json` use
|
||||||
|
`moduleResolution: "bundler"`, `module: "esnext"`, targets `es2022`/`es2023`,
|
||||||
|
`paths` without `baseUrl`, `verbatimModuleSyntax`, `isolatedModules`,
|
||||||
|
`noEmit`. None of TS7's removals (`target: es5`, `downlevelIteration`,
|
||||||
|
`moduleResolution: node10`/`classic`, `module: amd/umd/system/none`,
|
||||||
|
`baseUrl`-as-alias) are present. `tsconfig.json` is a two-entry project
|
||||||
|
reference; TS7 supports `tsc -b` and project references. The session changes
|
||||||
|
a tsconfig only if `tsc -b` errors demand it, and records any such change in
|
||||||
|
`## Recorded (implementation)`.
|
||||||
|
|
||||||
|
### 3. Known TS7 edge: `skipLibCheck` no longer hides parse-level .d.ts errors
|
||||||
|
|
||||||
|
Both tsconfigs set `skipLibCheck: true`. Under TS7 that still skips type
|
||||||
|
checking of `.d.ts` files but not parse-level errors in them. If a dependency
|
||||||
|
`.d.ts` fails to parse, the fix is a dependency patch bump or an upstream
|
||||||
|
issue reference recorded in the spec — never a copied-and-edited local
|
||||||
|
`.d.ts`.
|
||||||
|
|
||||||
|
### 4. Nothing else in the toolchain consumes the TS compiler
|
||||||
|
|
||||||
|
Verified at spec time: `oxlint` (own parser), `vite`/`vitest`
|
||||||
|
(esbuild transpile, no type checking), `prettier`, and
|
||||||
|
`@vitejs/plugin-react` run no `tsc` and load no `typescript` API. The only
|
||||||
|
consumer is the `typecheck` script (`web/package.json:12`, `tsc -b`), run in
|
||||||
|
CI at `.gitea/workflows/gates.yml:113-114`. TS7 has no stable programmatic
|
||||||
|
API until 7.1; nothing in this repo needs one.
|
||||||
|
|
||||||
|
## Sessions
|
||||||
|
|
||||||
|
One session.
|
||||||
|
|
||||||
|
### Session S1: the bump
|
||||||
|
|
||||||
|
Owns: `web/package.json`, `web/package-lock.json`, and — only if ruling 2
|
||||||
|
forces it — `web/tsconfig.app.json`, `web/tsconfig.node.json`,
|
||||||
|
`web/tsconfig.json`.
|
||||||
|
|
||||||
|
- S1.1 bump per ruling 1; `npm install`.
|
||||||
|
- S1.2 run, in order: `npm run format:check`, `npm run lint`,
|
||||||
|
`npm run typecheck`, `npm test`, `npm run build`, `npm run assert-bundled`
|
||||||
|
(all from `web/`).
|
||||||
|
- S1.3 `zig build test` (the licenses drift guard and the contract-samples
|
||||||
|
guard run inside it).
|
||||||
|
|
||||||
|
Acceptance (S1):
|
||||||
|
- [ ] `web/node_modules/.bin/tsc --version` reports 7.0.x.
|
||||||
|
- [ ] `npm run typecheck` exits 0 with no tsconfig change, or the change is
|
||||||
|
recorded with the TS7 error text that forced it.
|
||||||
|
- [ ] All six web gate commands exit 0.
|
||||||
|
- [ ] `zig build test` exits 0.
|
||||||
|
- [ ] `git diff` touches only the files S1 owns.
|
||||||
|
|
||||||
|
## Module layout
|
||||||
|
|
||||||
|
New files: none. Deleted surface: none.
|
||||||
|
|
||||||
|
## Acceptance (milestone complete)
|
||||||
|
|
||||||
|
- [ ] All S1 boxes.
|
||||||
|
- [ ] `licenses/dependency-identity.txt` is byte-identical to HEAD.
|
||||||
|
|
||||||
|
## Anti-requirements
|
||||||
|
|
||||||
|
- No other dependency bumps ride along — not oxlint, not vite, not vitest,
|
||||||
|
not `@types/*`.
|
||||||
|
- No tsconfig flag additions or "modernization" beyond what a TS7 error
|
||||||
|
forces.
|
||||||
|
- No typescript-eslint, ts-jest, or any tool needing the TS programmatic API
|
||||||
|
(unavailable until 7.1, and unwanted regardless — oxlint is the linter).
|
||||||
|
- No `tsgo` binary references anywhere: the binary is `tsc`, scripts stay as
|
||||||
|
they are.
|
||||||
|
- No edits to `web/src/**` — a source change to satisfy the new compiler is a
|
||||||
|
finding to record and fix, not silently absorb, unless `tsc -b` fails
|
||||||
|
without it; then it is recorded in `## Recorded (implementation)`.
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
# 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).
|
||||||
Reference in New Issue
Block a user