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
| // it prunes directories, and unignoring files inside them does not bring them back. | ||
| globalIgnores(["legacy/**", "dist/**", ".astro/**", "node_modules/**", "public/**"]), | ||
| astroConfigs.recommended, | ||
| astroConfigs["jsx-a11y-strict"], |
There was a problem hiding this comment.
This config contributes zero rules — there is currently no accessibility linting at all.
eslint-plugin-jsx-a11y is an optional peer of eslint-plugin-astro@3.1.0 (peerDependenciesMeta.eslint-plugin-jsx-a11y.optional: true) and is not in devDependencies, so the plugin's jsx-a11y config block degrades to { rules: {} } — silently, with no install warning.
Verified on a fresh pnpm install of this branch:
$ eslint --print-config src/pages/index.astro | grep -c jsx-a11y
0
and this file passes pnpm lint with exit 0:
---
---
``<img src="/x.png" />``
<div onclick="void 0">click</div>(Typed linting itself works — no-floating-promises and no-unnecessary-condition both fire in .astro frontmatter. It is only the a11y half that is dead.)
That contradicts AGENTS.md, docs/tooling.md, docs/adr/0001 ("Do not drop accessibility coverage to finish the migration") and Phase 09's accessibility = 1.0 Lighthouse gate — the ruleset meant to catch these before Lighthouse does is inert from Phase 01 onward.
Fix: add eslint-plugin-jsx-a11y to devDependencies. Knip will then report it unused (it is only reached through the astro config), so it needs a knip.jsonc ignoreDependencies entry alongside tailwindcss.
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
| } | ||
| }, | ||
| "noEmit": true, | ||
| "strict": true |
There was a problem hiding this comment.
functions/ ends up typechecked much more loosely than src/ — the opposite of what you want, since this is the only code in the repo handling untrusted request input.
The root tsconfig.json extends astro/tsconfigs/strictest and adds noUncheckedIndexedAccess, exactOptionalPropertyTypes, noImplicitOverride, noFallthroughCasesInSwitch, verbatimModuleSyntax. This one enables strict alone, so none of those apply to the Pages Functions.
Verified with the same three lines in both trees:
const parts: string[] = "a,b".split(",");
export const first: string = parts[5];
export const len = first.length;tsc -p functions → exit 0. astro check → ts(2322): Type 'string' is not assignable to type 'number'-class error on the identical code. So an out-of-range index or a missing optional field off request.json<GenericFormRequest>() typechecks clean here and throws at runtime in the Worker.
Fix: extend the same base and keep only the genuinely Worker-specific bits:
| "strict": true | |
| "noEmit": true, | |
| "strict": true, | |
| "exactOptionalPropertyTypes": true, | |
| "noFallthroughCasesInSwitch": true, | |
| "noImplicitOverride": true, | |
| "noUncheckedIndexedAccess": true, | |
| "verbatimModuleSyntax": true |
Generated by Claude Code
| run() { | ||
| local tool="$1"; shift | ||
| local bin="$repo_root/node_modules/.bin/$tool" | ||
| [[ -x "$bin" ]] || { command -v pnpm >/dev/null && pnpm exec "$tool" "$@" 2>&1; return; } |
There was a problem hiding this comment.
A missing tool is indistinguishable from a lint failure here, so the hook blocks the edit with nothing useful to say.
Bare return propagates the status of the && chain. With no pnpm on PATH the chain short-circuits to 1, so run oxlint … returns 1 with empty output; the ts branch then takes the failure path, prints a blank line to stderr and exits 2. Concretely: fresh clone, before pnpm install, agent edits any .ts file → the edit is rejected with no message. With pnpm present but no node_modules, the same path surfaces pnpm's "Command not found: oxlint" as if it were lint findings.
Worth separating the two cases — e.g. have run return a sentinel status (or set a flag) when neither the local bin nor pnpm exists, and treat that as "tools not installed, exit 0" rather than as findings.
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
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