Skip to content

overhaul: 01 foundation - #15

Open
CS-5 wants to merge 7 commits into
claude/website-overhaul-plan-38czl6from
overhaul/01-foundation
Open

overhaul: 01 foundation#15
CS-5 wants to merge 7 commits into
claude/website-overhaul-plan-38czl6from
overhaul/01-foundation

Conversation

@CS-5

@CS-5 CS-5 commented Aug 28, 2026

Copy link
Copy Markdown
Member

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 to legacy/ with a README saying reference-only. public/ and functions/ stay at root. Nothing in the new tree imports from it, and every tool ignores it.

Runtime + package manager. mise.toml pins node 26.7.0 and pnpm 11.22.0, with per-platform checksums committed to mise.lock. mise tasks are thin forwarders — package.json scripts are the single source of truth. pnpm-workspace.yaml sets a 7-day release cooldown and denies install scripts except sharp and esbuild.

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, and src/lib/cn.ts as the only sanctioned cn import 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's jsx-a11y-strict) and Prettier own .astro and .md. Import sorting is on for both halves. clsx, classnames, tailwind-merge, and any legacy/ path are banned imports.

Agent + editor config. AGENTS.md (one screen) with CLAUDE.md symlinked to it; a PostToolUse hook 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 checkpnpm build → offline lychee link check over dist/, blocking, on every PR. Deploys stay with Cloudflare Pages' git integration (D14).

Verified

  • mise install && pnpm install && pnpm check && pnpm build green.
  • Seeding import clsx from "clsx" and a chained type assertion in a .ts file fails pnpm lint on both counts — the import ban and anti-slop are live.
  • The hook formats a .ts with oxfmt and an .astro with Prettier, and returns oxlint findings to the agent with exit 2.
  • Nothing under 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

  • typescript is 6.0.3, not 7.x. typescript-eslint peers <6.1.0 and @astrojs/check peers ^5 || ^6. oxlint-tsgolint bundles its own checker and is unaffected.
  • Pins are the newest version at least a week old, not the newest version — that's minimumReleaseAge doing its job, and it applies to every dependency here.
  • pnpm typecheck is astro check && tsc -p functions. functions/ gained strict and moved to moduleResolution: "bundler" (which is what Cloudflare's esbuild actually does) so its existing extensionless @/* imports resolve. That surfaced a real bug: an absent CF-Connecting-IP header was being sent to Turnstile as the string "null". The field is optional, so it's now omitted when missing.
  • Three gitignore-semantics workarounds, all documented in docs/tooling.md: oxfmt needs --ignore-path .gitignore (it reads .prettierignore by 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 via files: for the same reason.
  • anti-slop is not re-registered as a local ESLint plugin for .astro frontmatter. Rationale in ADR 0001 — the rules are type-aware, frontmatter is thin by convention here, and the logic worth checking lives in .ts.
  • ADR 0002 supersedes Phase 03's icon choice: icons come from @tabler/icons (plain SVG source, inlined at build) rather than astro-icon + @iconify-json/tabler. @tabler/icons-react can'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.md still describes the Next.js workflow — Phase 11 rewrites it, per the plan.


Generated by Claude Code

CS-5 and others added 2 commits August 28, 2026 00:07
…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 CS-5 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. eslint.config.ts:17jsx-a11y-strict registers zero rules. eslint-plugin-jsx-a11y is an optional peer of eslint-plugin-astro and is not installed, so accessibility linting is silently off repo-wide. Highest-impact item here, given ADR 0001's "do not drop accessibility coverage".
  2. eslint.config.ts:36 — the clsx/tailwind-merge and legacy/* import bans don't apply to .astro. They exist only in oxlint, which ignores .astro.
  3. functions/tsconfig.json:12 — the Pages Functions are typechecked more loosely than src/ (no noUncheckedIndexedAccess etc.), which is backwards for request-handling code.
  4. .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

Comment thread eslint.config.ts
Comment thread eslint.config.ts
plugins: { perfectionist },
rules: {
// Mirrors oxfmt's sortImports so the whole repo is sorted the same way.
"perfectionist/sort-imports": "error",

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-merge are 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:

  1. 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.
  2. Drop the claim from AGENTS.md and 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

Comment thread functions/tsconfig.json Outdated
Comment thread .claude/hooks/format-lint.sh Outdated

CS-5 commented Aug 28, 2026

Copy link
Copy Markdown
Member Author

/simplify pass — quality review (reuse · simplification · efficiency · altitude)

Quality only; correctness findings are in the separate review on this PR.

Three of the four angles came back empty for this PR, and that's a real result rather than a shrug — I'll say what was checked and cleared at the bottom. Two efficiency findings:


1. Tailwind scans legacy/, so every page ships ~32 KB of CSS generated from the retired Next.js site

src/styles/global.css:1

@import "tailwindcss";

Tailwind v4's automatic source detection walks up from the CSS file and scans the whole project, minus .gitignored paths. legacy/ is checked in, so all of the old Next.js JSX is a scan target and its classes are emitted into the one shared stylesheet that every page links.

Verified in built output further up the stack — dist/_astro/BaseLayout.*.css contains classes that exist nowhere in src/:

bg-[url(/image/metal-shavings.webp)]   from-green-900/95   bg-red-600
hover:bg-yellow-500   lg:right-[-225]   data-[active=true]:after:h-[2px]
dark:text-white   h-450   max-h-37.5

Cost, measured. Adding @source not "../../legacy"; took the shared bundle from 68,567 → 36,666 bytes (12,870 → 8,206 gzipped) — a 47% cut, 4.7 KB gzipped off every page load — plus that much less scanning and generation on every build. dist/index.html and dist/styleguide/index.html came out byte-identical in size, and every house utility (pocket, eyebrow, spec-label, ambient-pool, draw-on, text-h1) survived.

This is worth fixing here rather than later: legacy/ is reference-only per AGENTS.md, so nothing it contains should ever reach the build, and the exclusion belongs next to the import that creates the problem.

Fix: @source not "../../legacy"; beside the @import, or an explicit @source scoped to ../.


2. CI reinstalls the whole dependency tree from the network on every run

.github/workflows/ci.yml:21-29

      - name: Install mise tools
        uses: jdx/mise-action@v3
        with:
          version: 2026.8.14
          install: true
          cache: true

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

mise-action's cache: true covers mise-managed tool binaries (node, pnpm), not pnpm's content-addressable store, and there's no actions/cache step for it. Every PR run re-downloads and re-links the full tree — which by PR #17/#19 includes @tabler/icons (~12k files) and sharp's platform binaries.

Fix: cache the pnpm store keyed on pnpm-lock.yaml, install with --prefer-offline.

Separately, package.json:16pnpm check chains typecheck && lint && fmt:check && knip serially, but the last three are mutually independent. Running them concurrently uses the runner's second core for free. Worth weighing against the fact that serial output is easier to read when something fails; if you keep it serial, that's a deliberate call rather than an oversight.


Checked and cleared

  • Reuse: ignoring the renames into legacy/, none of the new content (src/lib/cn.ts, tsconfig.json, .oxlintrc.json, eslint.config.ts, knip.jsonc, the vendored anti-slop rules) re-implements anything already in the tree. The ignore lists that repeat across the four configs are each required by their own tool and have no shared mechanism to reuse.
  • Simplification: the mise.toml task list duplicates package.json scripts, but that's the stated single-source-of-truth forwarding design, not accidental duplication. tools/lint/anti-slop/ is a vendored upstream plugin (MIT, documented as copy-don't-fork in its VENDOR.md) — out of scope for restructuring.
  • Altitude: the suppressions are all at the right depth and carry their justification. .oxlintrc.json's **/*.astro ignore and ESLint's files: ["**/*.astro"] scoping are the ownership split from ADR 0001; knip.jsonc's ignoreDependencies: ["tailwindcss"] names a real tool limitation. The plan/01-foundation.md edit adds an honest "Deviations from this brief" section rather than weakening a criterion — the one unmet box stays [ ] with a reason. That's the model the rest of the stack should follow.

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
CS-5 pushed a commit that referenced this pull request Aug 28, 2026
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
CS-5 and others added 2 commits August 28, 2026 09:57
`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
CS-5 pushed a commit that referenced this pull request Aug 28, 2026
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
CS-5 pushed a commit that referenced this pull request Aug 28, 2026
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
CS-5 pushed a commit that referenced this pull request Aug 28, 2026
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
CS-5 pushed a commit that referenced this pull request Aug 28, 2026
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
@CS-5
CS-5 force-pushed the overhaul/01-foundation branch from fd95452 to b5dc21a Compare August 28, 2026 16:12
CS-5 pushed a commit that referenced this pull request Aug 28, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants