Skip to content

NextJS -> Astro - #30

Draft
CS-5 wants to merge 102 commits into
stagingfrom
astro
Draft

CS-5 wants to merge 102 commits into
stagingfrom
astro

Conversation

@CS-5

@CS-5 CS-5 commented Sep 26, 2026

Copy link
Copy Markdown
Member

No description provided.

claude and others added 30 commits August 27, 2026 14:55
Complete phased plan for the Next.js -> Astro rewrite: toolchain
(pnpm/mise/oxlint/oxfmt/ESLint/Prettier split), design system,
shadcn-convention Astro primitives, content collections, page ports,
event landing pattern, asset/performance budgets, SEO/AI-SEO,
analytics, and cutover. plan/00-overview.md records all locked
decisions from the planning interview.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DU6XwSGRUDZtz5DuXjzKnn
…ules

DESIGN.md is now authored with the plan (grounded in the extracted
legacy palette, fonts, and live-site audit) rather than deferred to
Phase 02, which becomes pure implementation. Phase 06's gate becomes a
conformance check. New decision D25: no private/internal content on
the main domain - _headers noindex reduced to preview/staging hosts
only, dropping /team/*, /image/*, /video/* and enabling media
indexing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DU6XwSGRUDZtz5DuXjzKnn
Settled in owner review against Brand Guidelines v1: brand-literal
recessed surfaces (#262626 page / #171717 machined-pocket cards, grid
floor on feature moments), AAA text ramp, fill-vs-text accent token
pairs for all brand colors, Science Blue as informational + reserved
light-theme primary, Source Code Pro as the data voice, engineering
grid motif replacing the circuit pattern, real logo lockups in chrome,
sticky header, /programs hub behind the nav dropdown, full-height
mobile menu, and brand naming rules. Recorded as D26; navbar/footer
specs and the new /programs page added to phases 02/05/07.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DU6XwSGRUDZtz5DuXjzKnn
Between-section devices for the dark ground: ambient pools, ghost
section numerals (from the brand guidelines' section pages), ruler
dividers, and tint bands - exactly one per boundary, with a restraint
rule. Closes the flat/sterile-background review feedback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DU6XwSGRUDZtz5DuXjzKnn
…on marks

The engineering grid now runs on the page ground itself (<=3%) so the
dark surface reads as the drawing sheet the pockets are cut into;
pools/numerals/ruler opacities raised per review; corner registration
marks added as a feature-pocket garnish.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DU6XwSGRUDZtz5DuXjzKnn
Review finding: a page-level grid competes with the V2+V3 feature
pocket floors and dulls the recess effect. Grid stays scoped to
heroes, section breaks, and feature-pocket interiors; ground
modulation comes from pools, numerals, ruler dividers, tint bands,
and registration marks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DU6XwSGRUDZtz5DuXjzKnn
New DESIGN.md motif family with the t-shirt art as normative
reference: highlight capsules (the Real Skills/Robots/Fun treatment),
the engineering-drawing title block, scribed wireframe lineart, and
labeled leader-line callouts - plus a register budget capping distinct
blueprint devices at 4 per viewport. Phase 02 gains the task of
sourcing the wireframe gear-bulb vector from the merch files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DU6XwSGRUDZtz5DuXjzKnn
Third metaphor layer: the engineer's hand markup over the machined
sheet - chalk ovals (replacing the rejected pill capsules),
highlighter swipes, chalk underlines, and sketch arrows, with a
draw-on entrance as the one sanctioned decorative animation. Machined
strokes stay perfectly straight; hand strokes stay imperfect - the
contrast is deliberate. Register budget covers all three layers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DU6XwSGRUDZtz5DuXjzKnn
…adii

Per design review: every hand-markup device ships >=3 SVG path
variants (adjacent instances never match); highlighter swipes vary
rotation/inset/alpha; chalk underlines work inline under short phrases
as the sanctioned in-prose emphasis; sketch arrows may only target
handwritten annotation text (new Architects Daughter annotation face,
markup-only); machined/scribed layers stay perfectly geometric. Radius
scale unified to the one-end-mill system: 4/8/12, 16px and pills
retired.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DU6XwSGRUDZtz5DuXjzKnn
The overhaul lands as one gh-stack: this plan/DESIGN branch is the
bottom layer targeting staging, each phase is a branch stacked on the
previous phase's branch, and the whole stack cascade-merges into
staging at cutover - no long-running astro-rewrite branch, no manual
merge sequencing. Restack rules, per-layer CI/preview notes, and
cutover mechanics updated; stale radius-xl references in phase 02
cleaned up and hand-markup primitives added to its task list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DU6XwSGRUDZtz5DuXjzKnn
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DU6XwSGRUDZtz5DuXjzKnn
…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
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
`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
`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
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
Turn DESIGN.md into working CSS: the full token set, fluid type scale,
self-hosted fonts, program themes, the motif components, and a /styleguide
page that proves all of it.

Contrast is verified at build time, not by eye. /styleguide computes every
pair required by DESIGN.md §9 and throws with the offending pairs listed if
one drops below its floor, so a token edit cannot quietly break accessibility.

That verification caught two defects in the spec, amended here per DESIGN.md
§11:

- Science Blue's fill used a white label, which measures 3.5:1 — below AA,
  on fills that carry chip-sized text. Changed to the brand near-black
  (4.9:1), which also makes blue consistent with every other fill.
- body's stated ratio was "≈11.5:1"; it measures 10.2:1. Still AAA.

Also added a destructive pair, which §8 required but the doc never valued,
and pinned the highlighter swipe at 25% alpha — the only value in the
documented range that keeps white text at AAA.

Fonts are declared face by face rather than through the @fontsource CSS, so
only latin subsets reach the build: five woff2 files, no cyrillic or greek.

Program themes remap exactly four tokens — primary, primary-bright,
primary-foreground, ring — diff-checked in the built CSS. The brief said
three; DESIGN.md §2 says four, and without primary-bright a program page's
links and stats would stay yellow on a green page.

Hand-markup strokes carry pathLength="100" so the draw-on animation's dash
math is in percent; a hard-coded length truncates any path longer than the
guess, which is what first left the chalk ovals as open arcs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YRfxMh7FLjQtDbb1BEsCbR
Three DESIGN.md §8 rules were declared but not in force. In-prose links set only
underline thickness and offset, and preflight's `text-decoration: inherit`
resolved the line to `none`, so links were colour-only; `spec-chip` used an
opaque `currentcolor` border where the doc asks for 40% alpha; and adding `svg`
to the base `max-width: 100%` rule clamped ChalkOval's deliberate 2rem overshoot,
since `max-width` cannot be overridden by a `width` utility.

`SketchArrow direction="right"` was a silent no-op: Tailwind v4 implements
`-rotate-45` with the individual `rotate` property, which the per-variant inline
`style` overrode. Direction is now summed into that one value; mirroring stays a
class because `scale` does not collide.

`--color-body` and `--text-body` both claimed `.text-body` and the colour won,
leaving the size token unreachable and the type-ramp row demonstrating a colour.
Added `@utility text-copy` and pointed the ramp at it.

`/styleguide`'s contrast gate compared a hand-typed copy of the palette against
itself, so a token edited only in the stylesheet left the build green. `@/lib/tokens`
now parses the `@theme` and `[data-theme]` blocks out of `global.css`, and the
program themes' `primary-bright` accents are gated too — with the theme list read
from the stylesheet, so a new program cannot ship an unchecked accent. Verified:
dimming `--color-muted`, renaming a token, breaking the FLL accent, and changing
the swipe alpha each now fail the build.

DESIGN.md's accent ratios disagreed with the repo's own verifier. Danger Orange
is 6.6:1, not 7.9:1 — the old number sat above the 7:1 AAA line while the real
value sits below it. Corrected alongside Safety Yellow (9.8), Science Blue (5.9),
destructive text (7.9) and the swipe (7.6), and the measured value now renders
per theme on the page.

`Callout` emitted `<figcaption>` with no `<figure>` anywhere on the page. It
renders `<p>` by default; `as="figcaption"` is opt-in and the caller owns the
figure. `ambient-pool-neutral` overrides only a `::before` background, so used
alone it emitted no pseudo-element — documented as the modifier it is.

Quality: the §2.3 engineering grid was defined twice, in CSS and as an inline
style with a raw `#FAFAFA`, already disagreeing on alpha; it is now one
`@utility engineering-grid` driven by custom properties off `--color-foreground`.
The §13 stroke contract was duplicated across three hand-markup devices, which
had already drifted on `vector-effect`; it is now `@utility hand-stroke` plus a
shared tone mapping. RegistrationMarks encoded one bracket as four hand-computed
paths. The four `public/image/svg/logo-*.svg` files were byte-identical to
`src/assets/brand/` and unreferenced, deploying 48 KB unhashed.

The page was `noindex` and in the sitemap — a "Submitted URL marked 'noindex'"
error. Added a sitemap filter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BX5PrKuYNRLVxiEj3eejhs
The zero-JS primitive layer: Button, Card and its sub-parts, Badge, Input,
Textarea, Label, FieldError, Separator, Accordion, Dialog, Carousel, Icon,
Skeleton — every one on /styleguide in all variants and all three themes.

Interactivity stays as high up the ladder as it can: Accordion is native
<details> with the name attribute for exclusive open, Dialog is native
<dialog> so the browser owns the focus trap and Esc, Carousel is a
scroll-snap track. Together the whole styleguide ships 835 bytes of inlined
script, and a page built from only the static primitives ships none.

Two silent bugs surfaced here, both of which would have spread across every
page:

cn() was dropping font sizes. Tailwind builds text-* utilities from both
--text-* and --color-*, and the merge step only knows Tailwind's stock scale,
so it treated every text-* class as one conflict group and kept the last.
cn("text-primary-foreground", "text-body") collapsed to text-body, which
rendered every primary button's label in body gray on Safety Yellow — 1.3:1,
measured in the browser. @/lib/cn now registers the DESIGN.md §3 type scale as
the font-size group; those buttons measure 11.7:1, and size-plus-color pairs
keep both classes.

Relatedly, text-body is a color and not a size, so Button, Input, and Textarea
were asking for a size and getting none. They use text-copy now.

Icons follow ADR 0002: @tabler/icons inlined at build, no astro-icon or
Iconify. The package's exports map rewrites every subpath including
package.json, so the icons directory is located through a known icon instead.

CVA recipes live in sibling *.variants.ts files, since Astro forbids exporting
values from a component. That also lets one component reuse another's recipe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YRfxMh7FLjQtDbb1BEsCbR
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
Everything that changes over time moves out of code and into zod-validated
markdown. Adding a sponsor is now a logo file plus five lines of frontmatter.

Migrated: 10 sponsors (7 active, 3 as active: false), 6 FAQ answers, both
seasonal events, 6 robots, 14 team photos. Copy comes across verbatim, typos
included — revision is Phase 12 (D8). Former sponsors are records with
active: false rather than commented-out code, so we can still say who
supported us in 2022.

src/data/site.ts replaces every hardcoded constant the legacy pages carried:
the four external URLs from data/config.ts, both Google Calendar IDs, the GA4
measurement ID, the workspace address and directions link, socials and contact.

Schemas stay flat on purpose (D2) — strings, enums, booleans, dates, numbers,
images — which is what keeps a git-backed CMS a later addition rather than a
restructuring. events therefore carries locationName/locationAddress instead
of a nested object.

Two things worth knowing:

Collection names avoid slashes (frcRobots, not frc/robots) while the content
still nests under frc/ and fll/ on disk as D18 requires. Astro writes each
collection's editor JSON schema to .astro/collections/<name>.schema.json
without creating intermediate directories, so a slashed name warned on every
build and silently dropped frontmatter autocomplete.

news/template.md is a real entry with draft: true rather than a glob-excluded
_TEMPLATE.md. An excluded template drifts from the schema unnoticed and leaves
the collection empty, which warns on every build; as an entry it is validated
and still never renders.

"Test Sponsor" was deliberately not migrated — it is the one commented-out
legacy entry that is a test fixture, not a former sponsor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YRfxMh7FLjQtDbb1BEsCbR
`image()` in the schemas commits every moved asset to astro:assets, but pnpm's
isolated layout keeps Astro's own `sharp` where the bundled image service cannot
resolve it — the first page to render a collection image gets one
`MissingSharp` warning per asset and an unoptimized passthrough. Reproduced, then
declared `sharp` in dependencies with ADR 0003.

`z.object` strips unknown keys, so a typo'd field name built clean and silently
dropped the value — the one thing `docs/content.md` promises it does not do. The
acceptance criterion had been verified with a bad *value*, which always failed
correctly. All seven schemas are `z.strictObject`; `sinceYear` for `since` now
exits 1 with `Unrecognized key`.

The `program` enum accepted `sc2` (the open house uses it) while `programs` had
only `frc` and `fll`, so `programs[entry.data.program]` was undefined for that
event. The enum derives from `PROGRAM_KEYS` and the map `satisfies` a total
record over it. `astro.config.ts` reads `site.url` rather than declaring the
canonical origin a second time. `z` from `astro:content` is deprecated with
removal slated for the major already pinned — switched to `astro/zod`, and
`z.string().url()` to Zod 4's `z.url()`, clearing 53 check hints.

Content that legacy had and no schema field could hold: the kickoff's two season
teaser URLs and two game-hint links (`teaserUrls`/`hintUrls`/`hintLabels`, flat
per D2), its hero subtitle, which had become an `##` with no section under it,
Robo Fett's "2020-2021" two-season label, and the Viper and TroubleClef wordmark
overlays. The kickoff `description` is legacy's own again rather than new prose.

Reference integrity is now enforced where the reference is declared:
`tools/checks/content-references.mjs` fails `pnpm check` on a dangling `faq`
slug, which Astro only logs before exiting 0.

Quality: the workspace address and directions URL were written into every event's
frontmatter next to the copies in `site.ts`; location is optional and defaults
from there, so only an off-site event carries one. `displayDate` restated
`start`/`end` as prose, and the open house stated its date twice more — in body
copy and in an FAQ answer; `src/lib/event-date.ts` formats the timestamps, and
Phase 08's acceptance criteria require closing its knip seam. Dropping the
blanket `src/data/site.ts` knip entry restored export-level checking there, with
`@public` on the two exports that have a known future consumer.

The moved masters were camera-resolution — up to 6000px and near-lossless — so
`<Image>` derived variants from them that came out larger than the source and
cost ~22s of sharp time per build. `tools/assets/optimize-sources.mjs`
(plan/09 §2) caps sources at 2560px: 17.8 MB saved across 12 files.
`openhouse-header.webp` went back to `public/image`, where Phase 09's
inventory-based prune can still see it — nothing references it.

Also: `src/data/site.ts` described a `functions/api/calendar/` that does not
exist, and `docs/content.md` linked to a route that does not exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BX5PrKuYNRLVxiEj3eejhs
The chrome every page shares, with SEO correctness built into the type
signature rather than left to each page: BaseLayout requires title and
description, so omitting either is a compile error instead of something a
reviewer has to catch.

Seo emits title, description, canonical, the full Open Graph set with image
dimensions, and a Twitter card. No keywords meta — legacy carried 22 terms
for a signal search engines dropped years ago.

jsonld.ts holds typed builders: NGO (the specific type for a nonprofit, not
bare Organization) on every page, WebSite on the homepage, and breadcrumbs
ready for the nested pages in Phases 07-08.

Navbar follows D26: the real lockup, full-width on desktop and the square mark
on mobile, never a rebuilt gear plus the name in Inter. Programs is a real link
to /programs with a dropdown in addition, so touch and no-JS both get a
destination — verified that the panel opens on keyboard focus and Tab moves
into it. The mobile sheet is full-height with 44px targets, locks body scroll,
and closes on Esc with focus returned. The only script is the ~25-line toggle.

Footer is a recessed card band carrying the lockup and the engineering title
block. It points Donate at the real /donate page rather than legacy's
/wiki/donations, and adds a Programs column — with the mobile sheet closed, the
footer is otherwise the only place a program link appears.

sharp becomes a direct dependency: the first real <Image> use made astro:assets
need it, which allowBuilds already anticipated. The icon set is rendered from
the square mark rather than committed by hand; the apple-touch icon is
flattened onto the brand ground because iOS ignores transparency.

exactOptionalPropertyTypes forced one signature choice worth knowing: a layout
that forwards optional props passes explicit undefined, which that flag treats
as distinct from an absent prop, so the receiving props are declared
`?: T | undefined` instead of filtering props at every call site.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YRfxMh7FLjQtDbb1BEsCbR
The app shell links to the full site's routes, but Phase 07 builds them, so
the offline link check failed on every page carrying the header and footer:
65 errors across ten routes. That is a correct finding about an unavoidable
ordering problem, not a tooling fault.

.lycheeignore excludes exactly those ten routes and nothing else, so the gate
stays live for assets, anchors, and any link outside the list — verified by
seeding a bogus href, which still fails the check.

An exclusion list like this is a trap once its pages land: the entry silently
keeps that page out of the link check forever. tools/checks/stale-link-ignores.mjs
fails CI if an excluded route now exists in dist, so the list can only shrink.
Phase 07's acceptance criteria now require the file to be gone.

Verified locally with lychee 0.24.2 using the workflow's exact flags: 0 errors,
and the guard correctly fails when a landed route is left excluded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YRfxMh7FLjQtDbb1BEsCbR
Four things in the mobile menu did not work. `Icon.astro` has no rest spread, so
`data-menu-icon-open`/`-close` never reached the DOM and the hamburger never
became an X — the `?.` swallowed the nulls and TypeScript does not check
hyphenated JSX attribute names. Backdrop close was unreachable: the sheet's
`<nav class="h-full">` is exactly the sheet's box, so `event.target === sheet`
could never be true. Crossing `md` with the sheet open left the page
scroll-locked and unscrollable with no visible control, since both the sheet and
its toggle are `md:hidden`. And nothing made the page behind the opaque sheet
inert, so Tab past the last item walked every invisible link in `main` and
`footer`.

The sheet now keeps one piece of state — `aria-expanded` — with CSS deriving the
icon swap and the scroll lock from it, a `matchMedia` listener closing it at
`md`, and `inert` on everything in the shell that is not the toggle. Verified in
a real browser: icon swap, Esc, scroll lock, inert (only the close button stays
focusable), and the breakpoint close all behave.

The Programs panel was a `:focus-within` disclosure with no dismissal path, which
could not meet this phase's own "Esc closes" criterion (§9) or WCAG 2.2 SC
1.4.13 — and that criterion had been rewritten to describe what shipped. It is a
native `popover` now: Esc, light-dismiss, top-layer and the `expanded` state come
from the platform with no script, and the criterion is restored.

The sticky header animated `height` on a scroll timeline, relayouting itself and
reflowing the page on every frame through the first 120px, against §6's "only
`opacity` and `transform` animate" — it animates `scale`. The header logo was
`loading="lazy"`, invisible to the preload scanner despite being an LCP
candidate; it is `priority`. `min-h-dvh` on a block `<body>` stretched nothing,
leaving a lighter strip below the footer band on tall viewports; the body is a
flex column and `main` absorbs the slack (measured: 0px gap at 1600px tall).

`stale-link-ignores.mjs` silently dropped any entry it could not parse, so a
malformed exclusion escaped the staleness guard forever while lychee still
applied it — exactly the permanent blind spot it exists to prevent. It fails loud
now. The `og:image:width`/`height` block was dead for every page, since the
default `ogImage` is a string; the default card's dimensions travel with its path
in `site.ts`. The manifest's maskable entry reused the unpadded 512, whose
artwork spans ~88% of its box, so a launcher mask clipped the gear teeth — a
padded render at 72% is a separate file.

Quality: the route inventory was declared four times across two components and
the 404 page, already diverged, with the Footer re-typing program names
`site.ts` already held; it is one `nav` structure there, and the
desktop/sheet split is a `surfaces` field rather than a discrepancy. DESIGN.md
§8's link contract had no implementation home — twelve call sites wrote
`no-underline` and no chrome link underlined on hover; `ui-link` and
`external-link` are utilities, as is §5's container rule, which was a class
string at five sites. The Footer declared its socials twice and re-implemented
`CardTitle`. `site.webmanifest` is a generated route reading `site.ts` instead of
restating the org name, a third description and both brand colours.
`organization`/`webSite` are constants, not zero-argument factories.

Dropping the `cn.ts`, `fonts.ts` and `site.ts` knip entries — all genuinely
referenced as of this PR — restored export-level checking in the two most central
modules; `jsonld.ts`'s entry became `@public` on the one unused export.

Also fixed a regression this PR nearly introduced: the first draft toggled the
menu icons from a scoped `<style>`, which Astro rewrites to demand this
component's scope attribute — the same failure the Carousel had, since
`Icon.astro` renders its own root. `:global()` on the icon side, verified in the
built CSS.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BX5PrKuYNRLVxiEj3eejhs
Reuse and altitude:
- nav data now actually drives the chrome: the Programs disclosure is a
  panel flag, the sheet's Donate button is a sheet-cta surface, the footer
  references nav.calendar instead of nav.programs[3], and the dead
  "footer" surface value is gone
- browser-chrome colors read the CSS tokens (Seo, manifest); theme-color
  follows the program theme; the icon inventory lives in site.icons and
  is projected into head links, the manifest, and JSON-LD
- ProgramLayout derives its props from BaseLayout, restoring ogType and
  noindex passthrough; og:image:alt travels with the image
- button/badge variant inventories are exported and the styleguide
  renders them, durations, and program themes from the same sources the
  build gates on (the missing pocket variant now shows)
- hand-markup register shares its variant type and base classes;
  --breakpoint-md is a declared token read by CSS and the sheet script

Efficiency:
- the navbar fetches one logo per viewport via <picture> instead of
  eagerly downloading both variants on every load
- tick-strip SVGs collapse to single paths (styleguide HTML -32 KB);
  programColor memoizes its stylesheet scan; the carousel reads the
  track gap instead of hardcoding 16; tool scripts do one read per
  collection and one sharp pipeline per image

Also deletes edit-history and restating comments per the comment policy,
and drops the unused cloudflareBeaconToken placeholder.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
claude and others added 30 commits September 24, 2026 21:13
- Eyebrows take primary-bright (fill-vs-text law); one carrying a
  numeral takes the spec-label voice.
- The closing tagline keeps each "Real X." pair together, stacks below
  sm, and the chalk ovals clear their neighbours.
- Section padding is never doubled: the footer drops mt-24 and adjacent
  sections on one ground share a padding.
- FIRST® stands upright in Orbitron headings, its ® drawn by Inter
  through the display stack.
- Only standalone dates take the mono voice; the donate form and its
  card title rules align.
- Meta-refresh stub pages (openhouse, kickoff) also emit a 301 in
  _redirects.
- DESIGN.md §1, §3, §4 record the rules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFeYttZCfuyKpR2hJqLfK4
…wipes

- FRC hero h1 is two lines; the rest of the sentence leads the subhead.
- Donate's hero carries the PayPal button; Sponsors' hero carries
  "Become a sponsor".
- SponsorTier picks its column count from the sponsor count, and a lone
  sponsor takes SponsorCard's full-width feature layout.
- Highlight swipes run 35% at display sizes and 25% at body size; the
  styleguide build check measures each against its floor.
- DESIGN.md §2.13 and §5 record the rules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFeYttZCfuyKpR2hJqLfK4
Depth drawn rather than rendered: cut into metal, laid on the table.

- band (was tint): #121212 drafting table with a primary top edge, the
  brand edge vignette and a feathered two-weight grid; pockets on it
  become raised plates. A build step fails any page where two bands
  touch, footer included.
- Buttons are keycaps: a hard 3px offset that lifts on hover and
  presses flat; the outline keycap's edge meets 3:1. Reduced motion
  keeps the face still.
- Pockets cut deeper with a visible hover; framed media takes a 6px
  drafted edge; photo heroes carry the grid on their copy side.
- Header (md+) and footer use the brand's Light lockup; mobile keeps
  the colour mark.
- The styleguide shows the surfaces and states, and its contrast check
  covers band, plate, grid crossings and control edges.
- DESIGN.md §2, §2.3, §2.4, §4–§8 and §10 record the rules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFeYttZCfuyKpR2hJqLfK4
The panel is a popover in the top layer, where `absolute top-full`
resolves against the viewport, so it opened just below the bottom of
the screen. CSS anchor positioning places it under the nav item, with
a toggle-time fallback where anchors are unsupported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFeYttZCfuyKpR2hJqLfK4
The grain tile's gamma goes 4 → 5 and its alpha 0.4 → 1: the spread
rises from ~4 to ~6 levels while the mean lift moves only ~5 → ~8, so
the sheet reads as textured without lightening. DESIGN.md §2 records
the new figures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFeYttZCfuyKpR2hJqLfK4
The staging rule matched one host by name, so any other preview
subdomain (astro.scstem.org) was indexable. https://*.scstem.org/*
covers them all; the apex, production, does not match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFeYttZCfuyKpR2hJqLfK4
Tighter heroes, intros and CTAs across home, programs, FLL, FRC, about,
sponsors, donate, get-involved, contact, calendar, 404, the events and
the FAQ. Each pitch now lives on one page instead of being copied
between several. Facts corrected: two programs, founded 2012, no drone
teams or maker spaces, kickoff from noon. Grammar and typo fixes along
the way ("premier", "partner with", "teamwork", "begins", en dashes).
The robots page is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFeYttZCfuyKpR2hJqLfK4
overhaul: cutover pre-flight, parity records, legacy removal
Moves every design token into src/styles/tokens.css: plain CSS with no Tailwind, so the team
wiki (Starlight) and web apps can import the same file. global.css maps it onto Tailwind with
`@theme inline`, so utilities read the tokens where they are used and a switch on any element
reaches everything inside it.

Three switches: data-mode (dark default, light), data-accent (yellow default, green, orange)
and data-register (blueprint). Colors are written once as light-dark(light, dark); the grain
and the link underline, which light-dark() cannot carry, switch in [data-mode] blocks. Program
pages now set data-accent from programs[key].accent in place of data-theme.

@/lib/tokens resolves a token for any finish, accent and register and throws on anything it
cannot parse. /styleguide gates every text, accent, swipe, tier and control-edge pair in all
twelve combinations. The light and blueprint values are in place but nothing switches to them
yet, so the dark site renders as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6mmyYzaUyrZPwBpPMpN6u
DESIGN.md takes every decision from the review: the three switches and the portable token file;
the clear-anodized light finish with ink links and a yellow marker; the tuned light scrim; dark
first with a remembered toggle; yellow, green and orange accents; the cyanotype and whiteprint
blueprint register with its drawing frame; the felt-marker stroke and drifting underline; six
call-out devices with their rules; grid bubbles, construction-line registration marks and lit
pocket floors; nine text roles on three line heights; nine spacing steps by convention; three
radii by kind of object; IBM Carbon icons.

plan/todo.md records the open question the review left: frame or soft margins for blueprint
pages outside the wiki. AGENTS.md points at tokens.css.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6mmyYzaUyrZPwBpPMpN6u
Type (DESIGN.md §3): nine roles on three line heights — tight 1.1, snug 1.3, relaxed 1.6.
body-lg becomes lead and the reading size becomes copy, so text-copy is a real token rather
than a hand-written utility beside the body color. Eyebrows are labels (Source Code Pro caps),
so eyebrow.ts and the Orbitron eyebrow utility go; stats are the data font at h2 size.

Spacing (§4): tokens.css names the nine steps by their Tailwind numbers (--sc2-space-1 … 24)
as the convention; other values stay allowed.

Icons (§8, docs/adr/0023-carbon-icons.md, superseding 0002): @carbon/icons 11.88.0 replaces
@tabler/icons. Icon.astro inlines the 32px masters filled in currentColor, and every call site
uses Carbon's names. Its telemetry postinstall is denied in pnpm-workspace.yaml.

Also fixes the token commit's transitions: twelve duration-(--duration-*) and ease-(--ease-*)
classes read variables that @theme inline no longer emits, and now read the --sc2-* tokens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6mmyYzaUyrZPwBpPMpN6u
Grid bubbles (DESIGN.md §2.6) replace ghost numerals: GridBubble draws the circled section
number and a dash-dot center line across the container, and Section's atmosphere="numeral"
becomes "bubble". GhostNumeral and the numeral type tokens go.

RegistrationMarks (§2.8) draws construction lines crossing just outside each corner of the
pocket, after the lockup on the t-shirt front, in place of inset brackets.

Pockets take the lit floor (§2): a shade-to-lit overlay on the fill, so hover still changes
only the fill. The feature grid is a layer above it through --pocket-grid, and plates on a band
stay flat. /styleguide gates text on both ends of the floor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6mmyYzaUyrZPwBpPMpN6u
Hand markup (DESIGN.md §2.12–2.20):

- The shared stroke is the felt marker: 4.5px at 90% opacity. Marks take the accent's marker
  color (text-mark), and handwriting takes its text color, since the light finish's yellow
  marker fails as text.
- Link underlines drift upward toward their end, in three variants rotated among sibling links
  with :nth-of-type. The eighteen strokes (three variants, three accents, two finishes) are
  written into tokens.css by `pnpm assets:underlines` from the paths and the colors the file
  declares. Links are ink on the light finish, where the yellow stroke alone marks them.
- RevisionCloud: CSS-drawn scallops that fit any content, with a delta tag.
- StrikeCorrect: a struck word with a handwritten correction; assistive tech reads the
  correction only.
- ChalkOval takes around="control" to ring a button; SketchArrow gains up-left and up-right so
  a leader note can point at a control.

Tick marks and margin brackets stay in DESIGN.md until a page uses them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6mmyYzaUyrZPwBpPMpN6u
Every page opens dark (DESIGN.md §5). ModeToggle, a neutral keycap in the header and a
labelled row in the mobile menu, switches to the clear-anodized light finish and remembers the
choice in localStorage; an inline, blocking script in BaseLayout applies it before first paint,
so a light-mode visitor never sees a dark flash. The light: variant styles the light finish.

The header and footer lockup is now one file drawn as a CSS mask and filled with the logo token
(brand White or Black), so both finishes share a single fetch; below md the square mark stays.
Photo headers take the tuned light scrim, solid further across the copy with the photo pulled
back slightly (§7). Sponsor and FIRST logos, all drawn for a dark ground, keep it on the light
finish on a print token that is transparent on the dark one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6mmyYzaUyrZPwBpPMpN6u
BaseLayout takes register="blueprint" (DESIGN.md §2): the page wears the cyanotype or
whiteprint tokens, and its main is a drawing sheet. From xl up the drafting grid fills the page
margins, and DrawingFrame lays plain ground inside one frame line at the container's edge, with
zone numbers along the top and letters down the left, so no grid line crosses text. Photos on a
blueprint page go grayscale under the print's scrim.

The 404 is the first page in the register: the sheet that is not in the set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6mmyYzaUyrZPwBpPMpN6u
The `light:` variant and the hero's light-finish photo filter now skip anything inside a
`data-mode="dark"` element, so a dark panel on a light page keeps its dark styling. Also brings
the primitives README token note and the tooling script table up to date with tokens.css and
assets:underlines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6mmyYzaUyrZPwBpPMpN6u
/design-language/ explains the design system for the people who build with it: the idea, the
three switches (with a live preview panel), which token to reach for, type, space and radius,
surfaces, components, the marker, the blueprint register, recipes and a ship checklist. It is
a draft on its way to the team wiki. Nothing links to it, it sets noindex (so the sitemap
leaves it out), and _headers adds X-Robots-Tag: noindex.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6mmyYzaUyrZPwBpPMpN6u
Astro does not unwrap `:global()` nested inside `:not()`, so the dark-island exclusion made
the selector invalid and the filter matched nothing. The rule is now global as a whole. Also
adds the todo to move /design-language/ to the wiki and take it off the site.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6mmyYzaUyrZPwBpPMpN6u
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