From c5e6ab9180bbc75eae2798d6bc419e2e34ac4cf3 Mon Sep 17 00:00:00 2001 From: m5r Date: Wed, 12 Aug 2026 21:30:35 +0200 Subject: [PATCH] spec milestones 22 and 23: typescript 7, stylex and react aria --- specs/milestone-22.md | 108 +++++++++++++ specs/milestone-23.md | 368 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 476 insertions(+) create mode 100644 specs/milestone-22.md create mode 100644 specs/milestone-23.md diff --git a/specs/milestone-22.md b/specs/milestone-22.md new file mode 100644 index 0000000..9c6c803 --- /dev/null +++ b/specs/milestone-22.md @@ -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)`. diff --git a/specs/milestone-23.md b/specs/milestone-23.md new file mode 100644 index 0000000..1577c9d --- /dev/null +++ b/specs/milestone-23.md @@ -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 ``, 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).