overhaul: 01 foundation - #15
Conversation
…ain, CI Park the Next.js site in legacy/ and stand up the Astro project it will be replaced by, with the full final toolchain in place before any UI exists. - mise pins node 26.7.0 + pnpm 11.22.0 with committed checksums; tasks forward to package.json scripts, which stay the single source of truth. - pnpm-workspace.yaml enforces a 7-day release cooldown and denies install scripts except sharp and esbuild. Dependency pins are therefore the newest version at least a week old, not the newest version. - Astro 7 static scaffold: sitemap integration, Tailwind v4 via the vite plugin, strictest tsconfig, cn re-exported from a single sanctioned site. - oxlint (type-aware, with anti-slop vendored from dmmulroy/anti-slop) and oxfmt own .ts/.js/.json/.css; ESLint (typed + jsx-a11y-strict) and Prettier own .astro and .md. Ownership table and invocation quirks in docs/tooling.md, rationale and the oxc migration seam in docs/adr/0001. - knip, .editorconfig, VS Code settings, AGENTS.md (CLAUDE.md symlink), and a PostToolUse hook that routes each edited file to its owning toolchain and feeds lint failures back. - CI gates every PR on check + build + an offline link check over dist/. Typechecking functions/ surfaced a real bug: an absent CF-Connecting-IP header was being sent to Turnstile as the string "null". The header is optional, so it is now omitted when missing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YRfxMh7FLjQtDbb1BEsCbR
Astro emits root-relative hrefs (/_astro/index.*.css). In --offline mode lychee cannot resolve those against a local file tree without knowing what the root is, so it errored on every page. CI's check and build steps passed; this was the only failure. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YRfxMh7FLjQtDbb1BEsCbR
CS-5
left a comment
There was a problem hiding this comment.
Code review of the Phase 01 foundation (a268397). I installed the lockfile and ran the whole gate locally to check the toolchain actually does what the docs claim: astro check, tsc -p functions, oxlint --type-aware, eslint, oxfmt --check, prettier --check, knip, and astro build all pass, and seeded-violation probes confirm the oxlint side is live (the clsx ban, anti-slop chained-assertion rules, and type-aware TS diagnostics all fire, in src/ and in functions/), the hook formats + feeds findings back with exit 2, oxfmt does own .css, and prettier's Tailwind class sorting works in .astro.
Four findings, all verified by running the tools rather than by reading:
eslint.config.ts:17—jsx-a11y-strictregisters zero rules.eslint-plugin-jsx-a11yis an optional peer ofeslint-plugin-astroand is not installed, so accessibility linting is silently off repo-wide. Highest-impact item here, given ADR 0001's "do not drop accessibility coverage".eslint.config.ts:36— theclsx/tailwind-mergeandlegacy/*import bans don't apply to.astro. They exist only in oxlint, which ignores.astro.functions/tsconfig.json:12— the Pages Functions are typechecked more loosely thansrc/(nonoUncheckedIndexedAccessetc.), which is backwards for request-handling code..claude/hooks/format-lint.sh:37— a missing binary is reported as a lint failure, so a pre-install edit is blocked with an empty error.
Nothing wrong with the one behavioral code change in the diff: the remoteip-when-null Turnstile fix in functions/util.ts is correct, and the noEmit addition to the functions tsconfig is a good catch.
Generated by Claude Code
| plugins: { perfectionist }, | ||
| rules: { | ||
| // Mirrors oxfmt's sortImports so the whole repo is sorted the same way. | ||
| "perfectionist/sort-imports": "error", |
There was a problem hiding this comment.
The clsx / tailwind-merge and legacy/* import bans are unenforced in .astro files.
Both bans live only in .oxlintrc.json, which ignores **/*.astro. ESLint is the only linter that reads .astro, and it sets no no-restricted-imports — so the two rules AGENTS.md leads with are off in the file type the entire site is built from (Phase 03 onward).
Verified on this branch — 0 findings, exit 0:
---
import { parseColor } from "../../legacy/src/styles/theme.ts";
const c = parseColor("yellow");
---
<p>{c}</p>eslint --print-config on an .astro file shows no no-restricted-imports entry. Note the phase acceptance check git grep -l 'from "legacy' src also misses this, since the import is relative.
Fix: restate the same no-restricted-imports config in this files: ["**/*.astro"] block. It is a copy of the oxlint one, but unlike the anti-slop rules (whose omission ADR 0001 justifies) these two bans need no plugin to work.
Generated by Claude Code
There was a problem hiding this comment.
Half of this is fixed, half is now moot, and one loose end is left — leaving the thread open for that.
Fixed: eslint.config.ts restates no-restricted-imports for **/*.astro, so the legacy/* pattern ban is live there. Verified: an .astro file importing ../../legacy/src/styles/theme.ts now fails lint.
Moot: the clsx / classnames / tailwind-merge paths ban was removed from both .oxlintrc.json and eslint.config.ts in 235aa2f ("Manual cleanup"), alongside deleting src/lib/cn.ts. So there is nothing left to restate on that half.
Loose end: AGENTS.md still asserts the ban —
clsx,classnames,tailwind-mergeare banned imports.
— and so does plan/00-overview.md D7. Nothing enforces it now, in either linter, for any extension. That is the same shape as the original finding: a documented rule with no mechanism behind it.
Two ways to close it, and I did not want to pick for you:
- Re-add the paths ban to both configs, now pointing at
@/lib/cn(see the note on overhaul: 03 primitives #17 — the file is back from Phase 03 because it stops being a re-export and becomes a configured merge). The docs stay as written. - Drop the claim from
AGENTS.mdand D7, and let the single-source rule rest on convention plus review.
Worth noting for either choice: the packages are not direct dependencies, so an accidental import { clsx } from "clsx" would fail knip's unlisted: "error" rule anyway. The ban's real value is catching someone adding one deliberately.
Generated by Claude Code
|
Accessibility linting was inert. `eslint-plugin-jsx-a11y` is an optional peer of `eslint-plugin-astro`, so `astroConfigs["jsx-a11y-strict"]` registered zero rules and an alt-less `<img>` passed `pnpm lint`. It is now a direct devDependency, and `pnpm-workspace.yaml` allows eslint 10 against its stale `^9` peer range. The `clsx`/`tailwind-merge` and `legacy/*` import bans lived only in `.oxlintrc.json`, which ignores `**/*.astro` — the file type the site is built from. `eslint.config.ts` restates them. `functions/` enabled `strict` alone, so the only code handling untrusted request input typechecked more loosely than `src/`. It now carries the same `noUncheckedIndexedAccess`/`exactOptionalPropertyTypes` flags as the root. The format-lint hook's `run()` propagated its `&&` chain status, so a missing binary was indistinguishable from a lint failure: before `pnpm install`, any `.ts` edit was rejected with an empty message. Missing tools now return a sentinel and let the edit through. Tailwind's source detection scanned the checked-in `legacy/` tree, emitting utilities for the retired Next.js site into the stylesheet every page links (47,119 -> 9,910 bytes here, with identical HTML). `@source not` excludes it. CI reinstalled the whole dependency tree from the network each run; mise-action caches tool binaries, not pnpm's store. Added an `actions/cache` step keyed on the lockfile and `--prefer-offline`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BX5PrKuYNRLVxiEj3eejhs
Four primitives had behavior that was declared but never ran. Carousel's scoped `[data-carousel-track] > *` was rewritten by Astro to require this component's own scope attribute, which slotted slides never carry — so `scroll-snap-align`, `flex: 0 0 100%` and the `flex-basis` media rule matched nothing. Slides collapsed to content width, nothing snapped, and `itemBasis` was dead. `:global(*)` on the child escapes the scope while keeping `define:vars`. FieldError's `empty:hidden` could never match: `:empty` requires no child nodes and the `<Icon>` was unconditional, so a placeholder rendered a bare red alert triangle. The icon is now gated on slot content. Button spread `disabled` onto the `<a>` path, where the attribute is invalid and `:disabled` never matches — a disabled link rendered at full opacity, fully clickable, and typechecked cleanly. It maps to `aria-disabled` (which the variants already style) and the `href` is dropped. `Props` also extended only `HTMLAttributes<"button">`, so every external link button the site needs failed `astro check`; it now picks up `download`/`hreflang`/`rel`/`target`. Accordion's `name` became an unused `data-accordion-name` with no consumer, so the wrapper's documented exclusive-open was a no-op — the styleguide worked only because it repeated `name` on each item. Dropped the prop; the JSDoc and the `shadcn-astro` worked example now say where `name` belongs. Icon emitted `stroke`/`stroke-width` unconditionally, but Tabler's filled sources carry no stroke, so filled glyphs inflated ~1px on every edge and thickened narrow details. Carousel's explicit `behavior: "smooth"` bypassed the reduced-motion `scroll-behavior: auto !important` in global.css; the track's `scroll-smooth` class supplies it instead. The styleguide's section numerals ran 1–6, 8, 7. Quality: `Field.variants.ts` holds the recipe `Input` and `Textarea` were copy-pasting — five of six lines byte-identical, which is the reuse the sibling-variants convention exists for. `Button` gained a `pocket` variant so the Carousel arrows and the Dialog close compose it rather than hand-building icon buttons; the Dialog's was `p-1`, below the 44px minimum the shared recipe enforces. `tools/checks/cn-font-size-group.mjs` makes `cn`'s font-size group drifting from the `--text-*` tokens a `pnpm check` failure instead of a comment — that invariant fails invisibly, and it already cost this phase a 1.3:1 contrast bug. The brief's carousel keyboard criterion had been rewritten in place to describe what shipped. Restored, with the arrow decision recorded under "Deviations from this brief" alongside the rest. Newly-live jsx-a11y rules (see PR #15) caught two real violations: `href="#"` on the styleguide's demo links, and `tabindex="0"` on the carousel track. The track is a keyboard-reachable scroll container, so it is `role="region"` with a name, and that one role is added to `no-noninteractive-tabindex`'s allowlist — dropping the tabindex would make the slides keyboard-unreachable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BX5PrKuYNRLVxiEj3eejhs
`oxfmt --check` was failing CI on two files: `.vscode/settings.json` (a `.json` file, so no trailing commas) and `knip.jsonc` (a `.jsonc` file, where oxfmt keeps them). Applied the formatter's own output. Removing `src/lib/cn.ts` left `cnfast` with no importer, which knip's `dependencies: "error"` rule reports. The dependency now arrives in Phase 02 alongside the first component that merges classes; `plan/01` says so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BX5PrKuYNRLVxiEj3eejhs
Four primitives had behavior that was declared but never ran. Carousel's scoped `[data-carousel-track] > *` was rewritten by Astro to require this component's own scope attribute, which slotted slides never carry — so `scroll-snap-align`, `flex: 0 0 100%` and the `flex-basis` media rule matched nothing. Slides collapsed to content width, nothing snapped, and `itemBasis` was dead. `:global(*)` on the child escapes the scope while keeping `define:vars`. FieldError's `empty:hidden` could never match: `:empty` requires no child nodes and the `<Icon>` was unconditional, so a placeholder rendered a bare red alert triangle. The icon is now gated on slot content. Button spread `disabled` onto the `<a>` path, where the attribute is invalid and `:disabled` never matches — a disabled link rendered at full opacity, fully clickable, and typechecked cleanly. It maps to `aria-disabled` (which the variants already style) and the `href` is dropped. `Props` also extended only `HTMLAttributes<"button">`, so every external link button the site needs failed `astro check`; it now picks up `download`/`hreflang`/`rel`/`target`. Accordion's `name` became an unused `data-accordion-name` with no consumer, so the wrapper's documented exclusive-open was a no-op — the styleguide worked only because it repeated `name` on each item. Dropped the prop; the JSDoc and the `shadcn-astro` worked example now say where `name` belongs. Icon emitted `stroke`/`stroke-width` unconditionally, but Tabler's filled sources carry no stroke, so filled glyphs inflated ~1px on every edge and thickened narrow details. Carousel's explicit `behavior: "smooth"` bypassed the reduced-motion `scroll-behavior: auto !important` in global.css; the track's `scroll-smooth` class supplies it instead. The styleguide's section numerals ran 1–6, 8, 7. Quality: `Field.variants.ts` holds the recipe `Input` and `Textarea` were copy-pasting — five of six lines byte-identical, which is the reuse the sibling-variants convention exists for. `Button` gained a `pocket` variant so the Carousel arrows and the Dialog close compose it rather than hand-building icon buttons; the Dialog's was `p-1`, below the 44px minimum the shared recipe enforces. `tools/checks/cn-font-size-group.mjs` makes `cn`'s font-size group drifting from the `--text-*` tokens a `pnpm check` failure instead of a comment — that invariant fails invisibly, and it already cost this phase a 1.3:1 contrast bug. The brief's carousel keyboard criterion had been rewritten in place to describe what shipped. Restored, with the arrow decision recorded under "Deviations from this brief" alongside the rest. Newly-live jsx-a11y rules (see PR #15) caught two real violations: `href="#"` on the styleguide's demo links, and `tabindex="0"` on the carousel track. The track is a keyboard-reachable scroll container, so it is `role="region"` with a name, and that one role is added to `no-noninteractive-tabindex`'s allowlist — dropping the tabindex would make the slides keyboard-unreachable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BX5PrKuYNRLVxiEj3eejhs
Four primitives had behavior that was declared but never ran. Carousel's scoped `[data-carousel-track] > *` was rewritten by Astro to require this component's own scope attribute, which slotted slides never carry — so `scroll-snap-align`, `flex: 0 0 100%` and the `flex-basis` media rule matched nothing. Slides collapsed to content width, nothing snapped, and `itemBasis` was dead. `:global(*)` on the child escapes the scope while keeping `define:vars`. FieldError's `empty:hidden` could never match: `:empty` requires no child nodes and the `<Icon>` was unconditional, so a placeholder rendered a bare red alert triangle. The icon is now gated on slot content. Button spread `disabled` onto the `<a>` path, where the attribute is invalid and `:disabled` never matches — a disabled link rendered at full opacity, fully clickable, and typechecked cleanly. It maps to `aria-disabled` (which the variants already style) and the `href` is dropped. `Props` also extended only `HTMLAttributes<"button">`, so every external link button the site needs failed `astro check`; it now picks up `download`/`hreflang`/`rel`/`target`. Accordion's `name` became an unused `data-accordion-name` with no consumer, so the wrapper's documented exclusive-open was a no-op — the styleguide worked only because it repeated `name` on each item. Dropped the prop; the JSDoc and the `shadcn-astro` worked example now say where `name` belongs. Icon emitted `stroke`/`stroke-width` unconditionally, but Tabler's filled sources carry no stroke, so filled glyphs inflated ~1px on every edge and thickened narrow details. Carousel's explicit `behavior: "smooth"` bypassed the reduced-motion `scroll-behavior: auto !important` in global.css; the track's `scroll-smooth` class supplies it instead. The styleguide's section numerals ran 1–6, 8, 7. Quality: `Field.variants.ts` holds the recipe `Input` and `Textarea` were copy-pasting — five of six lines byte-identical, which is the reuse the sibling-variants convention exists for. `Button` gained a `pocket` variant so the Carousel arrows and the Dialog close compose it rather than hand-building icon buttons; the Dialog's was `p-1`, below the 44px minimum the shared recipe enforces. `tools/checks/cn-font-size-group.mjs` makes `cn`'s font-size group drifting from the `--text-*` tokens a `pnpm check` failure instead of a comment — that invariant fails invisibly, and it already cost this phase a 1.3:1 contrast bug. The brief's carousel keyboard criterion had been rewritten in place to describe what shipped. Restored, with the arrow decision recorded under "Deviations from this brief" alongside the rest. Newly-live jsx-a11y rules (see PR #15) caught two real violations: `href="#"` on the styleguide's demo links, and `tabindex="0"` on the carousel track. The track is a keyboard-reachable scroll container, so it is `role="region"` with a name, and that one role is added to `no-noninteractive-tabindex`'s allowlist — dropping the tabindex would make the slides keyboard-unreachable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BX5PrKuYNRLVxiEj3eejhs
`pnpm typecheck` runs `tsgo -p functions` instead of `tsc -p functions`, via `@typescript/native-preview`. Measured on this tree: 961 ms -> 182 ms. Verified it enforces the same config — the seeded `noUncheckedIndexedAccess` and assignability probes both fail as they did under tsc. `astro check` stays on the JavaScript compiler, so `typescript` remains a dependency. `@astrojs/check` peers `typescript: ^5.0.0 || ^6.0.0` and its language server is built against that compiler's API; the native preview exports only `version` and `versionMajorMinor`, shipping no `typescript.js` or `tsserver.js` that could stand in. `typescript-eslint` independently peers `>=4.8.4 <6.1.0`, so the JS side cannot move to 7 either. Both constraints are recorded in docs/tooling.md with the version to re-check. oxlint's type-aware pass was already on the Go toolchain via `oxlint-tsgolint`, which ships its own `tsgolint` binary — no overlap with the native preview. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BX5PrKuYNRLVxiEj3eejhs
Four primitives had behavior that was declared but never ran. Carousel's scoped `[data-carousel-track] > *` was rewritten by Astro to require this component's own scope attribute, which slotted slides never carry — so `scroll-snap-align`, `flex: 0 0 100%` and the `flex-basis` media rule matched nothing. Slides collapsed to content width, nothing snapped, and `itemBasis` was dead. `:global(*)` on the child escapes the scope while keeping `define:vars`. FieldError's `empty:hidden` could never match: `:empty` requires no child nodes and the `<Icon>` was unconditional, so a placeholder rendered a bare red alert triangle. The icon is now gated on slot content. Button spread `disabled` onto the `<a>` path, where the attribute is invalid and `:disabled` never matches — a disabled link rendered at full opacity, fully clickable, and typechecked cleanly. It maps to `aria-disabled` (which the variants already style) and the `href` is dropped. `Props` also extended only `HTMLAttributes<"button">`, so every external link button the site needs failed `astro check`; it now picks up `download`/`hreflang`/`rel`/`target`. Accordion's `name` became an unused `data-accordion-name` with no consumer, so the wrapper's documented exclusive-open was a no-op — the styleguide worked only because it repeated `name` on each item. Dropped the prop; the JSDoc and the `shadcn-astro` worked example now say where `name` belongs. Icon emitted `stroke`/`stroke-width` unconditionally, but Tabler's filled sources carry no stroke, so filled glyphs inflated ~1px on every edge and thickened narrow details. Carousel's explicit `behavior: "smooth"` bypassed the reduced-motion `scroll-behavior: auto !important` in global.css; the track's `scroll-smooth` class supplies it instead. The styleguide's section numerals ran 1–6, 8, 7. Quality: `Field.variants.ts` holds the recipe `Input` and `Textarea` were copy-pasting — five of six lines byte-identical, which is the reuse the sibling-variants convention exists for. `Button` gained a `pocket` variant so the Carousel arrows and the Dialog close compose it rather than hand-building icon buttons; the Dialog's was `p-1`, below the 44px minimum the shared recipe enforces. `tools/checks/cn-font-size-group.mjs` makes `cn`'s font-size group drifting from the `--text-*` tokens a `pnpm check` failure instead of a comment — that invariant fails invisibly, and it already cost this phase a 1.3:1 contrast bug. The brief's carousel keyboard criterion had been rewritten in place to describe what shipped. Restored, with the arrow decision recorded under "Deviations from this brief" alongside the rest. Newly-live jsx-a11y rules (see PR #15) caught two real violations: `href="#"` on the styleguide's demo links, and `tabindex="0"` on the carousel track. The track is a keyboard-reachable scroll container, so it is `role="region"` with a name, and that one role is added to `no-noninteractive-tabindex`'s allowlist — dropping the tabindex would make the slides keyboard-unreachable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BX5PrKuYNRLVxiEj3eejhs
Four primitives had behavior that was declared but never ran. Carousel's scoped `[data-carousel-track] > *` was rewritten by Astro to require this component's own scope attribute, which slotted slides never carry — so `scroll-snap-align`, `flex: 0 0 100%` and the `flex-basis` media rule matched nothing. Slides collapsed to content width, nothing snapped, and `itemBasis` was dead. `:global(*)` on the child escapes the scope while keeping `define:vars`. FieldError's `empty:hidden` could never match: `:empty` requires no child nodes and the `<Icon>` was unconditional, so a placeholder rendered a bare red alert triangle. The icon is now gated on slot content. Button spread `disabled` onto the `<a>` path, where the attribute is invalid and `:disabled` never matches — a disabled link rendered at full opacity, fully clickable, and typechecked cleanly. It maps to `aria-disabled` (which the variants already style) and the `href` is dropped. `Props` also extended only `HTMLAttributes<"button">`, so every external link button the site needs failed `astro check`; it now picks up `download`/`hreflang`/`rel`/`target`. Accordion's `name` became an unused `data-accordion-name` with no consumer, so the wrapper's documented exclusive-open was a no-op — the styleguide worked only because it repeated `name` on each item. Dropped the prop; the JSDoc and the `shadcn-astro` worked example now say where `name` belongs. Icon emitted `stroke`/`stroke-width` unconditionally, but Tabler's filled sources carry no stroke, so filled glyphs inflated ~1px on every edge and thickened narrow details. Carousel's explicit `behavior: "smooth"` bypassed the reduced-motion `scroll-behavior: auto !important` in global.css; the track's `scroll-smooth` class supplies it instead. The styleguide's section numerals ran 1–6, 8, 7. Quality: `Field.variants.ts` holds the recipe `Input` and `Textarea` were copy-pasting — five of six lines byte-identical, which is the reuse the sibling-variants convention exists for. `Button` gained a `pocket` variant so the Carousel arrows and the Dialog close compose it rather than hand-building icon buttons; the Dialog's was `p-1`, below the 44px minimum the shared recipe enforces. `tools/checks/cn-font-size-group.mjs` makes `cn`'s font-size group drifting from the `--text-*` tokens a `pnpm check` failure instead of a comment — that invariant fails invisibly, and it already cost this phase a 1.3:1 contrast bug. The brief's carousel keyboard criterion had been rewritten in place to describe what shipped. Restored, with the arrow decision recorded under "Deviations from this brief" alongside the rest. Newly-live jsx-a11y rules (see PR #15) caught two real violations: `href="#"` on the styleguide's demo links, and `tabindex="0"` on the carousel track. The track is a keyboard-reachable scroll container, so it is `role="region"` with a name, and that one role is added to `no-noninteractive-tabindex`'s allowlist — dropping the tabindex would make the slides keyboard-unreachable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BX5PrKuYNRLVxiEj3eejhs
Vendored @nkzw/oxlint-config (MIT) to tools/lint/nkzw/ and extended it by path from .oxlintrc.json. 146 of its 185 rules apply here; tools/lint/nkzw/VENDOR.md records what was dropped and why — 35 react rules (this site ships no client-side framework), 3 @nkzw rules and 1 no-only-tests rule needing packages we are not installing, and 2 overrides for upstream's own directory layout. Upstream's `.ts` override is kept: it disables the correctness rules TypeScript already covers. Every rule is now an error. `perf` moved from warn to error; `style` stays off. oxlint reported 34 violations across the existing tree; all are fixed. Three needed a decision rather than autofix: - `functions/types.ts` carried `result?: any` behind a `biome-ignore` comment for a linter this repo no longer uses. It is `unknown`, which is what the two call sites pass anyway. - `eslint.config.ts` used a namespace import for the Astro parser; the package has no default export, so it names `parseForESLint` — the interface ESLint actually consumes. - `no-console` is off under `functions/**`. A Worker's console is its log stream, which is not the unintended-logging case the rule guards. Also fixed a pre-existing hole the new config surfaced: the `legacy/` import ban used `legacy/*`, and a single star matches one path segment — so `legacy/data/config` and every deep relative path were allowed. Both linters now use `["legacy/**", "**/legacy/**"]`, verified at four depths. Trailing commas are now explicit: `all` for code, `none` for JSON/JSONC/JSON5 via an oxfmt override, matched in Prettier for `.astro` frontmatter. `.prettierrc` became `.prettierrc.json` so the JSON override actually matches it — otherwise oxfmt wanted a trailing comma in Prettier's own config. Format-on-edit had a gap: oxfmt's directory scan covers `.yaml`/`.yml`/`.toml`, so an unformatted one fails `pnpm check`, but the hook did not route those extensions. It formats them now (no lint pass — oxlint has no rules for them). VS Code gets an explicit formatter per owned language rather than relying on the global default, plus `eslint.validate` for `.astro`. Astro cannot move to TypeScript 7 yet, and the reason is upstream: TS 7 exposes no stable programmatic API, so every Volar-based language server is pinned to TS 6 (withastro/roadmap#1321). Recorded in docs/tooling.md with the maintainer quote and what to watch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BX5PrKuYNRLVxiEj3eejhs
fd95452 to
b5dc21a
Compare
Four primitives had behavior that was declared but never ran. Carousel's scoped `[data-carousel-track] > *` was rewritten by Astro to require this component's own scope attribute, which slotted slides never carry — so `scroll-snap-align`, `flex: 0 0 100%` and the `flex-basis` media rule matched nothing. Slides collapsed to content width, nothing snapped, and `itemBasis` was dead. `:global(*)` on the child escapes the scope while keeping `define:vars`. FieldError's `empty:hidden` could never match: `:empty` requires no child nodes and the `<Icon>` was unconditional, so a placeholder rendered a bare red alert triangle. The icon is now gated on slot content. Button spread `disabled` onto the `<a>` path, where the attribute is invalid and `:disabled` never matches — a disabled link rendered at full opacity, fully clickable, and typechecked cleanly. It maps to `aria-disabled` (which the variants already style) and the `href` is dropped. `Props` also extended only `HTMLAttributes<"button">`, so every external link button the site needs failed `astro check`; it now picks up `download`/`hreflang`/`rel`/`target`. Accordion's `name` became an unused `data-accordion-name` with no consumer, so the wrapper's documented exclusive-open was a no-op — the styleguide worked only because it repeated `name` on each item. Dropped the prop; the JSDoc and the `shadcn-astro` worked example now say where `name` belongs. Icon emitted `stroke`/`stroke-width` unconditionally, but Tabler's filled sources carry no stroke, so filled glyphs inflated ~1px on every edge and thickened narrow details. Carousel's explicit `behavior: "smooth"` bypassed the reduced-motion `scroll-behavior: auto !important` in global.css; the track's `scroll-smooth` class supplies it instead. The styleguide's section numerals ran 1–6, 8, 7. Quality: `Field.variants.ts` holds the recipe `Input` and `Textarea` were copy-pasting — five of six lines byte-identical, which is the reuse the sibling-variants convention exists for. `Button` gained a `pocket` variant so the Carousel arrows and the Dialog close compose it rather than hand-building icon buttons; the Dialog's was `p-1`, below the 44px minimum the shared recipe enforces. `tools/checks/cn-font-size-group.mjs` makes `cn`'s font-size group drifting from the `--text-*` tokens a `pnpm check` failure instead of a comment — that invariant fails invisibly, and it already cost this phase a 1.3:1 contrast bug. The brief's carousel keyboard criterion had been rewritten in place to describe what shipped. Restored, with the arrow decision recorded under "Deviations from this brief" alongside the rest. Newly-live jsx-a11y rules (see PR #15) caught two real violations: `href="#"` on the styleguide's demo links, and `tabindex="0"` on the carousel track. The track is a keyboard-reachable scroll container, so it is `role="region"` with a name, and that one role is added to `no-noninteractive-tabindex`'s allowlist — dropping the tabindex would make the slides keyboard-unreachable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BX5PrKuYNRLVxiEj3eejhs
Layer 1 of the overhaul stack, on top of the plan branch (#14).
plan/01-foundation.md.An empty-but-real Astro project with the complete final toolchain, and the old site parked for reference. No UI yet — that starts in Phase 02.
What's here
Legacy parked.
src/,data/, and every Next.js config file moved tolegacy/with a README saying reference-only.public/andfunctions/stay at root. Nothing in the new tree imports from it, and every tool ignores it.Runtime + package manager.
mise.tomlpins node26.7.0and pnpm11.22.0, with per-platform checksums committed tomise.lock. mise tasks are thin forwarders —package.jsonscripts are the single source of truth.pnpm-workspace.yamlsets a 7-day release cooldown and denies install scripts exceptsharpandesbuild.Astro scaffold. Astro 7 static, sitemap integration, Tailwind v4 through
@tailwindcss/vite(no config file), strictest tsconfig with the explicit strictness flags from the brief,@/*path alias, andsrc/lib/cn.tsas the only sanctionedcnimport site.Lint + format. oxlint (type-aware, with anti-slop vendored into
tools/lint/anti-slop/) and oxfmt own.ts/.js/.json/.jsonc/.css. ESLint (strictTypeChecked+eslint-plugin-astro'sjsx-a11y-strict) and Prettier own.astroand.md. Import sorting is on for both halves.clsx,classnames,tailwind-merge, and anylegacy/path are banned imports.Agent + editor config.
AGENTS.md(one screen) withCLAUDE.mdsymlinked to it; aPostToolUsehook that routes each edited file to its owning formatter and linter, exiting 2 with findings on failure; VS Code settings matching the same split.CI.
pnpm check→pnpm build→ offline lychee link check overdist/, blocking, on every PR. Deploys stay with Cloudflare Pages' git integration (D14).Verified
mise install && pnpm install && pnpm check && pnpm buildgreen.import clsx from "clsx"and a chained type assertion in a.tsfile failspnpm linton both counts — the import ban and anti-slop are live..tswith oxfmt and an.astrowith Prettier, and returns oxlint findings to the agent with exit 2.legacy/is read by oxlint, oxfmt, Prettier, tsc, or knip.CI blocking on a seeded lint error is the one criterion not verifiable locally — it gets checked on this PR.
Deviations from the phase brief
typescriptis 6.0.3, not 7.x.typescript-eslintpeers<6.1.0and@astrojs/checkpeers^5 || ^6.oxlint-tsgolintbundles its own checker and is unaffected.minimumReleaseAgedoing its job, and it applies to every dependency here.pnpm typecheckisastro check && tsc -p functions.functions/gainedstrictand moved tomoduleResolution: "bundler"(which is what Cloudflare's esbuild actually does) so its existing extensionless@/*imports resolve. That surfaced a real bug: an absentCF-Connecting-IPheader was being sent to Turnstile as the string"null". The field is optional, so it's now omitted when missing.docs/tooling.md: oxfmt needs--ignore-path .gitignore(it reads.prettierignoreby default, which would make it skip every file it owns); Prettier is invoked against an explicit**/*.{astro,md}glob (a blanket*ignore prunes directories irreversibly); ESLint scopes viafiles:for the same reason..astrofrontmatter. Rationale in ADR 0001 — the rules are type-aware, frontmatter is thin by convention here, and the logic worth checking lives in.ts.@tabler/icons(plain SVG source, inlined at build) rather thanastro-icon+@iconify-json/tabler.@tabler/icons-reactcan't be used at all — it ships React components, which breaks the zero-framework-runtime rule.Docs added
docs/tooling.md(setup, ownership table, cooldown behavior, the corepack footgun, env vars),docs/adr/0001-toolchain-split.md(D24 + the exact steps to collapse onto oxc later),docs/adr/0002-tabler-icons-direct.md.README.mdstill describes the Next.js workflow — Phase 11 rewrites it, per the plan.Generated by Claude Code