diff --git a/.claude/hooks/format-lint.sh b/.claude/hooks/format-lint.sh index c56b623..82fcdfc 100755 --- a/.claude/hooks/format-lint.sh +++ b/.claude/hooks/format-lint.sh @@ -1,89 +1,28 @@ #!/usr/bin/env bash -# Formats and lints one edited file with whichever toolchain owns its extension -# (docs/tooling.md). Lint failures exit 2 so the findings go back to the agent. +# Formats and lints the file Claude Code just edited. Lint failures exit 2 so the +# findings go back to the agent; a missing toolchain exits 0 so a fresh clone is not +# blocked before `pnpm install`. set -uo pipefail repo_root="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null)}" [[ -n "$repo_root" ]] || exit 0 -payload="$(cat)" -file="$(printf '%s' "$payload" | jq -r '.tool_response.filePath // .tool_input.file_path // empty')" +file="$(jq -r '.tool_response.filePath // .tool_input.file_path // empty')" [[ -n "$file" ]] || exit 0 -[[ -f "$file" ]] || exit 0 - -# Absolute, and inside this repo. -case "$file" in - /*) ;; - *) file="$repo_root/$file" ;; -esac -case "$file" in - "$repo_root"/*) ;; - *) exit 0 ;; -esac - -rel="${file#"$repo_root"/}" -case "$rel" in - legacy/* | dist/* | .astro/* | node_modules/* | plan/* | tools/lint/anti-slop/* | pnpm-lock.yaml) - exit 0 - ;; -esac +[[ "$file" = /* ]] || file="$repo_root/$file" +[[ -f "$file" && "$file" = "$repo_root"/* ]] || exit 0 cd "$repo_root" || exit 0 +bin="$repo_root/node_modules/.bin" +[[ -x "$bin/prettier" ]] || exit 0 -# Use the installed binaries directly so the hook works without pnpm on PATH. -# Status 127 means the tool itself is missing, which callers must not report as -# lint findings: on a fresh clone, before `pnpm install`, every edit would -# otherwise be rejected with an empty message. -readonly TOOL_MISSING=127 - -run() { - local tool="$1"; shift - local bin="$repo_root/node_modules/.bin/$tool" - if [[ -x "$bin" ]]; then - "$bin" "$@" 2>&1 - return - fi - command -v pnpm >/dev/null || return "$TOOL_MISSING" - [[ -d "$repo_root/node_modules" ]] || return "$TOOL_MISSING" - pnpm exec "$tool" "$@" 2>&1 -} +"$bin/prettier" --write --ignore-unknown --log-level warn "$file" -# Runs a linter and exits 2 with its findings. A missing toolchain exits 0 so the -# edit is allowed through un-linted rather than blocked with nothing to act on. -lint() { - local findings status - findings="$(run "$@")" - status=$? - case "$status" in - 0) return 0 ;; - "$TOOL_MISSING") exit 0 ;; - *) +case "${file##*.}" in + ts | mts | js | mjs | astro) + findings="$("$bin/eslint" --max-warnings 0 "$file" 2>&1)" || { printf '%s\n' "$findings" >&2 exit 2 - ;; - esac -} - -case "${file##*.}" in - ts | tsx | js | jsx | mjs | cjs | json | jsonc | json5) - # Without --ignore-path, oxfmt also reads .prettierignore, which excludes - # every extension oxfmt owns. - run oxfmt --ignore-path .gitignore "$file" >/dev/null - lint oxlint --type-aware "$file" - ;; - # oxfmt's directory scan formats these too, so `pnpm check` fails on an unformatted one — - # but oxlint has no rules for them, so they are formatted and not linted. Passing one to - # oxlint is not a no-op: it reports "No files found to lint" and exits non-zero. - css | yaml | yml | toml) - run oxfmt --ignore-path .gitignore "$file" >/dev/null - ;; - astro) - run prettier --write "$file" >/dev/null - lint eslint --max-warnings 0 "$file" - ;; - md) - run prettier --write "$file" >/dev/null + } ;; esac - -exit 0 diff --git a/.claude/skills/shadcn-astro/SKILL.md b/.claude/skills/shadcn-astro/SKILL.md index 05ac6cd..3d5fb57 100644 --- a/.claude/skills/shadcn-astro/SKILL.md +++ b/.claude/skills/shadcn-astro/SKILL.md @@ -5,7 +5,7 @@ description: Port a shadcn/ui component to an Astro primitive in src/components/ # Porting shadcn components to Astro primitives -This site ships **zero client framework runtime** (D3). shadcn is React + Radix, so a port is a +This site ships **zero client framework runtime**. shadcn is React + Radix, so a port is a re-implementation, not a copy — but the _design API_ comes across almost verbatim, and should. ## When to use this diff --git a/.editorconfig b/.editorconfig index 879a99a..f76ad3d 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,4 +1,4 @@ -# For what neither oxfmt nor Prettier covers (yaml, toml, shell, gitignore-likes). +# For what Prettier does not format (toml, shell, gitignore-likes). root = true [*] diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml new file mode 100644 index 0000000..ba5b003 --- /dev/null +++ b/.github/actions/setup/action.yml @@ -0,0 +1,24 @@ +name: Setup +description: Install the mise-pinned toolchain and the pnpm dependencies, with both cached. +runs: + using: composite + steps: + - uses: jdx/mise-action@v4 + with: + version: 2026.8.14 + cache: true + + # mise-action caches tool binaries, not pnpm's content-addressable store. + - name: Locate pnpm store + shell: bash + run: echo "PNPM_STORE=$(pnpm store path --silent)" >>"$GITHUB_ENV" + + - uses: actions/cache@v6 + with: + path: ${{ env.PNPM_STORE }} + key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: pnpm-store-${{ runner.os }}- + + - name: Install dependencies + shell: bash + run: pnpm install --frozen-lockfile --prefer-offline diff --git a/.github/eslint-matcher.json b/.github/eslint-matcher.json new file mode 100644 index 0000000..eba0bf6 --- /dev/null +++ b/.github/eslint-matcher.json @@ -0,0 +1,22 @@ +{ + "problemMatcher": [ + { + "owner": "eslint-stylish", + "pattern": [ + { + "regexp": "^([^\\s].*)$", + "file": 1 + }, + { + "regexp": "^\\s+(\\d+):(\\d+)\\s+(error|warning|info)\\s+(.*)\\s\\s+(.*)$", + "line": 1, + "column": 2, + "severity": 3, + "message": 4, + "code": 5, + "loop": true + } + ] + } + ] +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cef64b0..7a14ecc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,8 @@ name: CI on: pull_request: + push: + branches: [main, staging] permissions: contents: read @@ -12,50 +14,46 @@ concurrency: jobs: check: - name: Check + Build + name: Check runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 + - uses: ./.github/actions/setup - - name: Install mise tools - uses: jdx/mise-action@v4 - with: - version: 2026.8.14 - install: true - cache: true - - # mise-action's cache covers mise-managed tool binaries (node, pnpm), not - # pnpm's content-addressable store, so without this every run re-downloads - # the whole tree. - - name: Locate pnpm store - run: echo "PNPM_STORE=$(pnpm store path --silent)" >>"$GITHUB_ENV" - - - name: Cache pnpm store - uses: actions/cache@v6 - with: - path: ${{ env.PNPM_STORE }} - key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: pnpm-store-${{ runner.os }}- - - - name: Install dependencies - run: pnpm install --frozen-lockfile --prefer-offline + # Every step runs even after an earlier one fails, so one PR run reports every kind of + # problem. The ESLint matcher turns findings into inline annotations on the PR diff. + - name: Typecheck + run: pnpm typecheck + - name: Lint + if: ${{ !cancelled() }} + run: | + echo "::add-matcher::.github/eslint-matcher.json" + pnpm lint + - name: Format + if: ${{ !cancelled() }} + run: pnpm fmt:check + - name: Unused code and dependencies + if: ${{ !cancelled() }} + run: pnpm knip - - name: Check (typecheck + lint + format + knip) - run: pnpm check + build: + name: Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: ./.github/actions/setup - name: Build run: pnpm build - # Needs dist/, so it cannot live in `pnpm check`: uniqueness of titles and descriptions, - # and whether an og:image resolves, are only answerable across the whole built site. - - name: Verify page metadata - run: pnpm run check:meta + # Title and description uniqueness, and whether every og:image resolves, are only + # answerable across the whole built site. + - name: Page metadata + run: pnpm check:meta - # --root-dir is required for the root-relative hrefs Astro emits (/_astro/...); - # without it lychee cannot resolve them in local files and errors on every page. - # llms.txt is in scope because its links are hand-written paths in src/data/events.ts, - # which nothing else would catch drifting from the routes they name. - - name: Link check + # --root-dir resolves the root-relative hrefs Astro emits. llms.txt is in scope because its + # links are hand-written paths that nothing else checks against the routes they name. + - name: Links uses: lycheeverse/lychee-action@v2 with: args: >- @@ -63,3 +61,36 @@ jobs: --root-dir ${{ github.workspace }}/dist 'dist/**/*.html' 'dist/llms.txt' fail: true + + - uses: actions/upload-artifact@v5 + with: + name: dist + path: dist/ + retention-days: 1 + + lighthouse: + name: Lighthouse + runs-on: ubuntu-latest + needs: build + steps: + - uses: actions/checkout@v7 + - uses: ./.github/actions/setup + - uses: actions/download-artifact@v6 + with: + name: dist + path: dist/ + + - name: Lighthouse CI + run: pnpm dlx @lhci/cli@0.15.1 autorun --config=tools/ci/lighthouserc.json + + # Into the log as well as the summary, so the waterfall behind a failed LCP assertion is + # readable from the API without downloading the report artifact. + - name: Summarize + if: ${{ !cancelled() }} + run: node tools/ci/lighthouse-summary.ts | tee -a "$GITHUB_STEP_SUMMARY" + + - uses: actions/upload-artifact@v5 + if: ${{ !cancelled() }} + with: + name: lighthouse-reports + path: .lighthouseci/reports/ diff --git a/.github/workflows/lighthouse.yml b/.github/workflows/lighthouse.yml deleted file mode 100644 index 848efdc..0000000 --- a/.github/workflows/lighthouse.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: Lighthouse - -on: - pull_request: - -permissions: - contents: read - -concurrency: - group: lighthouse-${{ github.ref }} - cancel-in-progress: true - -jobs: - budgets: - name: Budgets - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - - name: Install mise tools - uses: jdx/mise-action@v4 - with: - version: 2026.8.14 - install: true - cache: true - - - name: Locate pnpm store - run: echo "PNPM_STORE=$(pnpm store path --silent)" >>"$GITHUB_ENV" - - - name: Cache pnpm store - uses: actions/cache@v6 - with: - path: ${{ env.PNPM_STORE }} - key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: pnpm-store-${{ runner.os }}- - - - name: Install dependencies - run: pnpm install --frozen-lockfile --prefer-offline - - - name: Build - run: pnpm build - - # lhci is a CI-only tool run through dlx rather than a devDependency: it pulls Lighthouse - # and Puppeteer, which no one needs to develop a page (docs/adr/0007-lighthouse-ci-gate.md). - - name: Lighthouse CI - run: pnpm dlx @lhci/cli@0.15.1 autorun - - - name: Upload reports - if: ${{ !cancelled() }} - uses: actions/upload-artifact@v5 - with: - name: lighthouse-reports - path: .lighthouseci/reports/ diff --git a/.oxfmtrc.json b/.oxfmtrc.json deleted file mode 100644 index 4ceca93..0000000 --- a/.oxfmtrc.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "$schema": "./node_modules/oxfmt/configuration_schema.json", - "sortImports": true, - "sortPackageJson": true, - "sortTailwindcss": true, - "trailingComma": "all", - "overrides": [ - { - "files": ["**/*.json", "**/*.jsonc", "**/*.json5"], - "options": { - "trailingComma": "none" - } - } - ], - "ignorePatterns": [ - "legacy/**", - "dist/**", - ".astro/**", - ".claude/**", - "tools/lint/anti-slop/**", - "**/*.md", - "**/*.astro" - ] -} diff --git a/.oxlintrc.json b/.oxlintrc.json deleted file mode 100644 index da07d38..0000000 --- a/.oxlintrc.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "$schema": "./node_modules/oxlint/configuration_schema.json", - "extends": ["./tools/lint/nkzw/oxlintrc.json"], - "env": { - "browser": true, - "builtin": true, - "es2024": true, - "node": true - }, - "plugins": ["typescript", "unicorn", "oxc", "import"], - "options": { - "typeAware": true, - "typeCheck": true - }, - "categories": { - "correctness": "error", - "suspicious": "error", - "perf": "error", - "style": "off" - }, - "jsPlugins": [ - { - "name": "anti-slop", - "specifier": "./tools/lint/anti-slop/index.ts" - } - ], - "rules": { - "anti-slop/no-chained-type-assertions": "error", - "anti-slop/no-conditional-empty-object-spread": "error", - "anti-slop/no-known-value-widening": "error", - "anti-slop/no-module-mocking": "error", - "anti-slop/no-object-parameters": "error", - "anti-slop/no-reflect-apply": "error", - "anti-slop/no-reflect-get": "error", - "anti-slop/no-runtime-typeof": "error", - "anti-slop/no-shape-in-symbol-names": "error", - "anti-slop/no-unknown-parameters": "error", - "anti-slop/no-unknown-returns": "error", - "anti-slop/no-unknown-type-aliases": "error", - "anti-slop/no-unsafe-dictionary-type": "error", - "anti-slop/no-widen-then-assert": "error", - "anti-slop/require-safety-comment-for-type-assertion": "error", - "no-restricted-imports": [ - "error", - { - "patterns": [ - { - "group": ["legacy/**", "**/legacy/**"], - "message": "legacy/ is reference only \u2014 never import from it (plan/00-overview.md)" - } - ] - } - ], - "perfectionist/sort-objects": "off" - }, - "overrides": [ - { - "files": ["functions/**", "tools/**"], - "rules": { - "no-console": "off" - } - } - ], - "ignorePatterns": [ - "**/*.astro", - "legacy/**", - "dist/**", - ".astro/**", - "tools/lint/anti-slop/**", - ".claude/**", - "plan/**", - "node_modules/**" - ] -} diff --git a/.prettierignore b/.prettierignore index 77c609c..98fc361 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,14 +1,7 @@ -# Prettier is invoked against an explicit `**/*.{astro,md}` glob (see package.json), -# because a blanket `*` ignore prunes directories and cannot be undone per-file. -# oxfmt owns every other extension — see docs/tooling.md. -legacy/** -dist/** -.astro/** -node_modules/** -tools/lint/anti-slop/** - -# Hand-formatted markdown: tables, checkboxes, and line breaks here are deliberate. -plan/** -docs/adr/** -DESIGN.md -.browser/** +legacy/ +dist/ +.astro/ +.browser/ +.lighthouseci/ +pnpm-lock.yaml +.claude/settings.local.json diff --git a/.prettierrc.json b/.prettierrc.json index dc82700..1bd475f 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -1,13 +1,6 @@ { "printWidth": 100, - "trailingComma": "all", "plugins": ["prettier-plugin-astro", "prettier-plugin-tailwindcss"], - "overrides": [ - { - "files": "*.astro", - "options": { - "parser": "astro" - } - } - ] + "tailwindStylesheet": "./src/styles/global.css", + "tailwindFunctions": ["cn", "cva"] } diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 1bba082..97c27d7 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,11 +1,9 @@ { "recommendations": [ "astro-build.astro-vscode", - "oxc.oxc-vscode", + "dbaeumer.vscode-eslint", "esbenp.prettier-vscode", "bradlc.vscode-tailwindcss", - "hverlin.mise-vscode", - "dbaeumer.vscode-eslint", - "typescriptteam.native-preview" + "hverlin.mise-vscode" ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 5af3ec6..c5dc755 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,57 +1,20 @@ { "editor.formatOnSave": true, - "editor.defaultFormatter": "oxc.oxc-vscode", + "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.codeActionsOnSave": { - "source.fixAll.oxc": "explicit", - "source.organizeImports.oxc": "explicit" + "source.fixAll.eslint": "explicit" }, - "[typescript]": { - "editor.defaultFormatter": "oxc.oxc-vscode" - }, - "[typescriptreact]": { - "editor.defaultFormatter": "oxc.oxc-vscode" - }, - "[javascript]": { - "editor.defaultFormatter": "oxc.oxc-vscode" - }, - "[javascriptreact]": { - "editor.defaultFormatter": "oxc.oxc-vscode" - }, - "[json]": { - "editor.defaultFormatter": "oxc.oxc-vscode" - }, - "[jsonc]": { - "editor.defaultFormatter": "oxc.oxc-vscode" - }, - "[css]": { - "editor.defaultFormatter": "oxc.oxc-vscode" - }, - "[yaml]": { - "editor.defaultFormatter": "oxc.oxc-vscode" - }, - "[toml]": { - "editor.defaultFormatter": "oxc.oxc-vscode" - }, - "[astro]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" - }, - "[markdown]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" - }, - "eslint.validate": ["astro"], - "eslint.useFlatConfig": true, + "eslint.validate": ["javascript", "typescript", "astro"], "files.associations": { "*.css": "tailwindcss" }, "tailwindCSS.classFunctions": ["cn", "cva"], - "tailwindCSS.experimental.classRegex": [["cn\\(([^)]*)\\)", "[\"'`]([^\"'`]*)[\"'`]"]], "search.exclude": { "legacy/**": true, "dist/**": true, - "pnpm-lock.yaml": true, - "node_modules/**": true + "pnpm-lock.yaml": true }, - "js/ts.experimental.useTsgo": true, "js/ts.tsdk.path": "node_modules/typescript/lib", - "js/ts.preferences.importModuleSpecifier": "non-relative" + "js/ts.preferences.importModuleSpecifier": "non-relative", + "js/ts.experimental.useTsgo": false } diff --git a/AGENTS.md b/AGENTS.md index 07e562e..18c955d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,27 +8,27 @@ Pages. **Zero client-side framework runtime** — `.astro` components and plain ## Commands -| Command | What it does | -| ------------------------------ | -------------------------------------------------------------------------- | -| `mise install && pnpm install` | Set up (mise pins node + pnpm; see `docs/tooling.md`) | -| `pnpm dev` | Dev server | -| `pnpm check` | Typecheck + lint + format check + knip. **Must pass before every commit.** | -| `pnpm build` | Static build to `dist/` | -| `pnpm fmt` / `pnpm lint:fix` | Write formatting / autofix lint | +| Command | What it does | +| ------------------------------ | ---------------------------------------------------------------------------------------- | +| `mise install && pnpm install` | Set up (mise pins node + pnpm; see `docs/tooling.md`) | +| `pnpm dev` | Dev server | +| `pnpm check` | Typecheck + lint + format check + knip + repo checks. **Must pass before every commit.** | +| `pnpm build` | Static build to `dist/` | +| `pnpm fmt` / `pnpm lint:fix` | Write formatting / autofix lint | -`mise run ` forwards to the same `package.json` scripts, which are the single source of truth. +## Toolchain -## Toolchain ownership +ESLint (typed `strictTypeChecked` + `stylisticTypeChecked`, `eslint-plugin-astro` with +`jsx-a11y-strict`, the vendored anti-slop rules in `tools/lint/`) lints every `.ts`, `.js`, and +`.astro` file. Prettier formats everything (`docs/adr/0012-single-toolchain.md`). +TypeScript 6 throughout: `astro check` covers `src/` and the config files, `tsc` covers +`functions/` and `tools/`. -| Extensions | Linter | Formatter | -| -------------------------------- | ---------------------------------------------- | --------- | -| `.ts .js .mjs .cjs .json .jsonc` | oxlint (type-aware, vendored nkzw + anti-slop) | oxfmt | -| `.css` | — (oxlint has no CSS rules) | oxfmt | -| `.astro` | ESLint (typed, jsx-a11y-strict) | Prettier | -| `.md` | — | Prettier | +The Claude Code hook in `.claude/hooks/format-lint.sh` formats and lints every file you edit and +feeds lint failures back to you. -The Claude Code hook in `.claude/hooks/format-lint.sh` runs the right pair on every file you -edit, and feeds lint failures back to you. Do not reach for the other toolchain by hand. +Repo-specific checks and asset pipelines are TypeScript scripts under `tools/`, run directly by +Node (`node tools/checks/verify-meta.ts`); every one has a `package.json` script. ## Architecture @@ -39,12 +39,17 @@ edit, and feeds lint failures back to you. Do not reach for the other toolchain - `src/content/` — markdown content collections (sponsors, events, faq, news, robots, photos). - `src/data/site.ts` — org facts, external URLs, calendar and analytics IDs. No hardcoded constants. - `functions/` — Cloudflare Pages Functions (form submit, calendar proxy). Own tsconfig. +- `tools/` — repo checks, asset pipelines, and CI helpers. Own tsconfig. - `legacy/` — the old Next.js site. **Reference only; never import from it.** ## Rules - `cn` comes from `@/lib/cn` only — a `cnfast` merge configured with the DESIGN.md §3 type scale (see the docstring). `clsx`, `classnames`, `tailwind-merge` are banned imports. +- The `font-size` group in `src/lib/cn.ts` mirrors the `--text-*` tokens in `src/styles/global.css` + by hand. Adding, renaming, or removing a size token means the same edit in both files, in the + same commit; nothing checks them, and a missing entry shows up only as a wrong size in the + browser. - No new dependencies without an ADR in `docs/adr/`. - No client-side frameworks, no framework islands. - Content changes go in `src/content/` — see `docs/content.md`. **Never inline a content array diff --git a/DESIGN.md b/DESIGN.md index bcda696..3c6ccf3 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -2,23 +2,24 @@ Source of truth for the visual design of scstem.org. Every UI decision an implementer makes should be answerable from this document; if it isn't, propose an addition here first (PR + owner review), then build. -The system is a **brand-faithful refresh** of the 2024–2026 site, grounded in the official **Brand Guidelines v1** (colors, fonts, logo, and naming rules below are normative from that document) and settled through an owner design review in 2026-08. The organizing metaphor is the **build document**, in three layers: the page is a machined metal sheet (pockets cut *into* it), blueprint linework and spec labels are scribed *onto* it, and an **engineer's hand markup** — highlighter swipes, chalk circles and underlines, sketched arrows — sits over the top. The machine provides structure; the hand provides warmth; data speaks in a monospaced spec-sheet voice. The official t-shirt art (wireframe gear-bulb with dimension callouts, the circled-word tagline, the PROJECT/ORGANIZATION/URL title block) is a normative reference. The balance rule: the sheet stays technical, not themed — the bar is *great, not just good*: fewer competing treatments, stronger hierarchy, deliberate everything. +The system is a **brand-faithful refresh** of the 2024–2026 site, grounded in the official **Brand Guidelines v1** (colors, fonts, logo, and naming rules below are normative from that document) and settled through an owner design review in 2026-08. The organizing metaphor is the **build document**, in three layers: the page is a machined metal sheet (pockets cut _into_ it), blueprint linework and spec labels are scribed _onto_ it, and an **engineer's hand markup** — highlighter swipes, chalk circles and underlines, sketched arrows — sits over the top. The machine provides structure; the hand provides warmth; data speaks in a monospaced spec-sheet voice. The official t-shirt art (wireframe gear-bulb with dimension callouts, the circled-word tagline, the PROJECT/ORGANIZATION/URL title block) is a normative reference. The balance rule: the sheet stays technical, not themed — the bar is _great, not just good_: fewer competing treatments, stronger hierarchy, deliberate everything. ## 1. Brand essence, voice, and naming South Central STEM Collective (SC2) is a 501(c)(3) making hands-on STEM — FIRST robotics first among it — accessible to students aged 9–18 in and around Franklin County, PA. The site speaks to three audiences at once: **students** (this looks fun and serious), **parents** (this is safe, organized, worth our time), **sponsors** (this is a credible investment in the community). Voice: **energetic, concrete, community-proud.** -- This, not that: *workshop*, not *startup*. *Confident*, not *hype*. *Specific* ("12+ years, 9 competition robots"), not *vague* ("empowering the future"). *Warm*, not *corporate*. + +- This, not that: _workshop_, not _startup_. _Confident_, not _hype_. _Specific_ ("12+ years, 9 competition robots"), not _vague_ ("empowering the future"). _Warm_, not _corporate_. - Sentence case everywhere — headings, buttons, nav. Title Case only for proper nouns. No ALL CAPS except eyebrow labels and SCP spec labels (§3). - First person plural ("we build", "join us"); address the reader as "you". - Numbers beat adjectives. Real names, real seasons, real awards (with permission). -**Name usage (from Brand Guidelines):** full name "South Central STEM Collective" with capitalized *STEM*; may break to two lines after "Central". Short name "SC2" (capitalized SC) — **never in headings**, and never on a page where the full name isn't present elsewhere. "SCSTEM" only for domains/handles, never in prose. +**Name usage (from Brand Guidelines):** full name "South Central STEM Collective" with capitalized _STEM_; may break to two lines after "Central". Short name "SC2" (capitalized SC) — **never in headings**, and never on a page where the full name isn't present elsewhere. "SCSTEM" only for domains/handles, never in prose. ## 2. Color -Dark-only for now (D15). All component color comes from **semantic tokens** — raw hex values and Tailwind palette classes are banned in components (the logo mark's fixed colors are brand-asset colors, exempt like any other image). Tokens live in `src/styles/global.css` `@theme`; a future light theme is a new token block, not a refactor. +Dark-only for now. All component color comes from **semantic tokens** — raw hex values and Tailwind palette classes are banned in components (the logo mark's fixed colors are brand-asset colors, exempt like any other image). Tokens live in `src/styles/global.css` `@theme`; a future light theme is a new token block, not a refactor. ### Brand palette (normative, Brand Guidelines v1) @@ -26,26 +27,28 @@ Safety Yellow `#FACC15` · Science Blue `#3B82F6` · Foundation Gray `#4B5563` ### Surfaces — the recessed system -The page is the raised material; cards are **pockets machined into it**. (This is the *opposite* of the default elevated-lighter-card dark UI — deliberately.) +The page is the raised material; cards are **pockets machined into it**. (This is the _opposite_ of the default elevated-lighter-card dark UI — deliberately.) + +| Token | Value | Use | +| -------------- | --------- | -------------------------------------------------------------- | +| `background` | `#262626` | Page ground (brand Background Black) | +| `card` | `#171717` | Pockets: cards, panels, form fields, footer band (brand Black) | +| `section-tint` | `#212121` | Full-width alternate section bands | +| `border` | `#3A3A3A` | Hairlines, card borders, dividers | -| Token | Value | Use | -|---|---|---| -| `background` | `#262626` | Page ground (brand Background Black) | -| `card` | `#171717` | Pockets: cards, panels, form fields, footer band (brand Black) | -| `section-tint` | `#212121` | Full-width alternate section bands | -| `border` | `#3A3A3A` | Hairlines, card borders, dividers | +**Ground grain**: the page ground and the `section-tint` bands carry a fine monochrome grain — even, per-pixel film grain from a 600px seamless SVG noise tile (`--texture-grain`), with a mean lift of ~5 levels over `#262626` and a spread of ~4. It must never show a cell structure or a visible repeat — if a pattern can be picked out, the frequency is too low or the tile too small. It is what makes the sheet read as a surface rather than a flat hex, and it is material, not illustration: no brushed streaks, no bevels, no lighting gradients. Pockets never take it — their floors stay smooth, so the recess reads against the grain around it. Every contrast pair below is measured on the plain hex and still holds on the grained ground. **Pocket anatomy (V2, "machined pocket")** — the standard card treatment: `card` fill, 1px `#383838` border, `radius-lg`, and inset edge physics: `box-shadow: inset 0 2px 8px rgb(0 0 0 / 0.55), inset 0 -1px 0 rgb(255 255 255 / 0.05)`. -**Feature pocket (V2+V3, "drawing pocket")**: the same, plus the engineering grid (§2 motifs) rendered *inside* the pocket at ~5% opacity — reserved for feature moments (program cards, CTA panels, stat bands) on ≥ md screens; dense card grids and mobile stay plain V2. +**Feature pocket (V2+V3, "drawing pocket")**: the same, plus the engineering grid (§2 motifs) rendered _inside_ the pocket at ~5% opacity — reserved for feature moments (program cards, CTA panels, stat bands) on ≥ md screens; dense card grids and mobile stay plain V2. **Hover (interactive pockets)**: pockets don't float — border warms to 40%-alpha `primary`, floor lifts `#171717 → #1A1A1A`, no translate, no glow, no shadow change. ### Text -| Token | Value | Contrast on `#262626` | Use | -|---|---|---|---| -| `foreground` | `#FAFAFA` | ≈14.5:1 (AAA) | Headings, nav, emphasis (brand White) | -| `body` | `#D4D4D4` | 10.2:1 (AAA) | **All reading copy** | -| `muted` | `#A3A3A3` | ≈6.0:1 (AA) | Captions, meta, labels only — never paragraphs | +| Token | Value | Contrast on `#262626` | Use | +| ------------ | --------- | --------------------- | ---------------------------------------------- | +| `foreground` | `#FAFAFA` | ≈14.5:1 (AAA) | Headings, nav, emphasis (brand White) | +| `body` | `#D4D4D4` | 10.2:1 (AAA) | **All reading copy** | +| `muted` | `#A3A3A3` | ≈6.0:1 (AA) | Captions, meta, labels only — never paragraphs | Rule: **AAA (≥7:1) for anything longer than a caption.** `muted` is the floor; nothing text-bearing goes dimmer. @@ -54,44 +57,45 @@ Rule: **AAA (≥7:1) for anything longer than a caption.** `muted` is the floor; All ratios in this section are measured against `background` `#262626` and verified at build time on `/styleguide`; the build fails if one drops below its floor. -Every brand accent is a **token pair**: the brand hex for *fills* (buttons, bands, chips, large graphics — with a near-black label), and a brightened variant for *text/icons/focus on dark* (the brand hexes other than yellow fail AA as dark-bg text). No exceptions, no third variants. +Every brand accent is a **token pair**: the brand hex for _fills_ (buttons, bands, chips, large graphics — with a near-black label), and a brightened variant for _text/icons/focus on dark_ (the brand hexes other than yellow fail AA as dark-bg text). No exceptions, no third variants. -| Accent | Fill (brand hex / label color) | Text-on-dark (≈ contrast) | Role | -|---|---|---|---| -| Safety Yellow | `#FACC15` / `#171717` | `#FACC15` (9.8:1 — bright enough to be both) | **Action**: CTAs, links, focus ring, key-word emphasis. The default `primary`. | -| Science Blue | `#3B82F6` / `#171717` | `#60A5FA` (5.9:1) | **Informational**: info callouts, calendar/event chips, data UI. Also the **designated primary of a future light theme** (yellow is illegible on white) — do not repurpose. | -| Hazard Green | `#16A34A` / `#08240F` | `#3ECF6E` (7.4:1) | FRC/Biohazard theme accent pair | -| Danger Orange | `#F97316` / `#241102` | `#FB923C` (6.6:1) | FLL theme accent pair | -| Destructive | `#DB262F` / `#FAFAFA` (4.6:1) | `#FCA5A5` (7.9:1) | **Errors only**: form validation, destructive confirmations. Not a brand accent and never decorative — it appears when something is wrong and nowhere else. | +| Accent | Fill (brand hex / label color) | Text-on-dark (≈ contrast) | Role | +| ------------- | ------------------------------ | -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Safety Yellow | `#FACC15` / `#171717` | `#FACC15` (9.8:1 — bright enough to be both) | **Action**: CTAs, links, focus ring, key-word emphasis. The default `primary`. | +| Science Blue | `#3B82F6` / `#171717` | `#60A5FA` (5.9:1) | **Informational**: info callouts, calendar/event chips, data UI. Also the **designated primary of a future light theme** (yellow is illegible on white) — do not repurpose. | +| Hazard Green | `#16A34A` / `#08240F` | `#3ECF6E` (7.4:1) | FRC/Biohazard theme accent pair | +| Danger Orange | `#F97316` / `#241102` | `#FB923C` (6.6:1) | FLL theme accent pair | +| Destructive | `#DB262F` / `#FAFAFA` (4.6:1) | `#FCA5A5` (7.9:1) | **Errors only**: form validation, destructive confirmations. Not a brand accent and never decorative — it appears when something is wrong and nowhere else. | -### Program themes (D16) +### Program themes `data-theme="frc"` / `data-theme="fll"` on the page root remap **only** `primary`, `primary-bright`, `primary-foreground`, and `ring` to the program's accent pair. Everything else is identical — program pages are the same site wearing team colors. Adding a program = one token block. Rules: -- **One action accent per view** (the theme's `primary`). Blue may appear alongside it only in its informational role. The legacy mix (yellow CTA + blue links + orange button on one page) stays dead. + +- **One action accent per view** (the theme's `primary`). Blue may appear alongside it only in its informational role. - Long body text is never accent-colored, never pure white — `body` token only. - Foundation Gray `#4B5563` lives in the logo and imagery; it is not a UI token. ### Signature motifs -1. **Key-word emphasis**: display headings may emphasize exactly one phrase — either `primary`-colored text or a highlighter swipe (hand markup §13), never both, never more than one phrase. Link-style underlines never appear in headings. +1. **Key-word emphasis**: display headings may emphasize exactly one phrase — either `primary`-colored text or a highlighter swipe (§2.13), never both, never more than one phrase. Link-style underlines never appear in headings. 2. **Accent hairline**: heroes end with a 2px `primary` rule, full-bleed. Card titles may carry a 32px × 2px `primary` rule beneath. -3. **Engineering grid** (replaces the legacy circuit-board texture): fine graph-paper grid (~24–28px cell, 1px `#FAFAFA` strokes) at 4–7% opacity, on heroes and section breaks and inside V2+V3 feature-pocket floors — never behind body copy, and **never on the page ground**: a page-level grid was tried and rejected in review (it competes with the pocket grid floors and dulls the recess effect). The ground stays smooth; its life comes from the atmosphere devices below, so the grid stays special where it appears. Optional **dimension-line ticks** (`primary` at ≤50%, SCP annotation) as rare garnish. +3. **Engineering grid** (replaces the legacy circuit-board texture): fine graph-paper grid (~24–28px cell, 1px `#FAFAFA` strokes) at 4–7% opacity, on heroes and section breaks and inside V2+V3 feature-pocket floors — never behind body copy, and **never on the page ground**, where it competes with the pocket grid floors and dulls the recess effect. The ground's life comes from its grain (surfaces, above) and the atmosphere devices below, not from linework. Optional **dimension-line ticks** (`primary` at ≤50%, SCP annotation) as rare garnish. 4. **Framed media**: photo collages/feature media get a 2px `primary` border + `radius-lg` — the "team picture frame". ### Atmosphere layer Large unmodulated `background` fields read sterile. Between the hero and the footer, every major section boundary carries **exactly one** of these devices (never stacked, never behind photos): -5. **Ambient pools**: one radial gradient anchored to a section's top or its heading — `primary` at 3–6% alpha (or `#FAFAFA` at 2–4% for neutral sections), fading to transparent by ~70%. A small lightness modulation of the ground; text contrast is unaffected. Max one per section. -6. **Ghost section numerals**: oversized Source Code Pro 600 numerals (`01`, `02`, …) at 5–8% alpha `foreground`, placed behind section headings — the device from the Brand Guidelines' own section pages. Decorative (`aria-hidden`), numbering only top-level page sections, 3–4 per page max. -7. **Ruler dividers**: a full-container tick-mark strip (baseline + graduated ticks, `foreground` at ≤18%) as the *strong* section divider; plain 1px hairlines remain the quiet default. -8. **Registration marks**: small corner brackets (`primary` at ≤35%, 1.5px stroke, ~20px) on the corners of one feature pocket per view — the drafting-sheet crop-mark garnish. Never on standard cards. +5. **Ambient pools**: one radial gradient centred on a section's heading — `primary` at 8–10% alpha (or `#FAFAFA` at 4–6% for neutral sections), fading to transparent by ~70% and well inside the section on every side. Never anchored to the section's top edge: an ellipse cut in half by the boundary leaves a hard line. A lightness modulation of the ground that is visible on a calibrated monitor without being nameable; text contrast is unaffected. Max one per section. +6. **Ghost section numerals**: oversized Source Code Pro 600 numerals (`01`, `02`, …) at 5–8% alpha `foreground`, anchored to the heading's top edge and raised so most of the numeral stands in the section's top padding and its lower part sits behind the heading's first line — the device from the Brand Guidelines' own section pages. It never reaches the copy below the heading. Decorative (`aria-hidden`), numbering only top-level page sections, 3–4 per page max. +7. **Ruler dividers**: a full-container tick-mark strip (baseline + graduated ticks, `foreground` at ≤18%) as the _strong_ section divider; plain 1px hairlines remain the quiet default. Two standing uses: above the footer's title block (the sheet's bottom edge) and above a page's closing CTA band, unless the section before it already carries one. +8. **Registration marks**: small corner brackets (`primary` at ≤35%, 1.5px stroke, ~20px) set 12px in from the corners of one feature pocket per view — the drafting-sheet crop-mark garnish. Inset, not on the corner: on the edge they read as part of the border; inside, they frame the content. Never on standard cards. ### Scribed register (from the t-shirt art) -The blueprint devices printed *onto* the sheet. Each appears at most once per page unless noted: +The blueprint devices printed _onto_ the sheet. Each appears at most once per page unless noted: 9. **Title block**: the engineering-drawing identity strip — bordered compartments, each an SCP uppercase label (`PROJECT:` / `ORGANIZATION:` / `URL:` …) over an Inter (or SCP for URLs/codes) value, 1px `border` dividers. Its home is the footer bottom (the drawing sheet's corner), horizontal strip ≥ md, stacked on mobile; contact/event pages may use the boxed stack as an info card. 10. **Scribed lineart**: the wireframe gear-bulb (and sibling blueprint drawings) as large, faint decorative art — stroke-only, `foreground` or `primary`, 4–6% opacity, on `card` bands (footer, feature panels), never behind body copy. Obtain the real vector from the merch/brand source files into `src/assets/brand/` (the mockups use a drawn approximation). @@ -99,18 +103,18 @@ The blueprint devices printed *onto* the sheet. Each appears at most once per pa ### Hand markup register (the human layer) -The engineer's markup drawn *over* the sheet — this is what keeps the machined language from feeling sterile. **The machined and scribed layers stay perfectly geometric everywhere; hand markup never earns more real estate than it has now — minor touches, not a style.** All strokes are deliberately imperfect (hand-drawn SVG paths: slight curve, open ends, small rotation), 2–3px, `stroke-linecap: round`. **Pill-shaped UI is banned** (reviewed and rejected): a circled word is a chalk oval, never a `border-radius: 999px` box. +The engineer's markup drawn _over_ the sheet — this is what keeps the machined language from feeling sterile. **The machined and scribed layers stay perfectly geometric everywhere; hand markup is small and functional — a few strokes that mean something, not a style.** The one hand device that appears everywhere is the link underline (§2.14), and it earns that by carrying meaning. All strokes are deliberately imperfect the way a quick hand is: smooth paths with a single gentle curve, open ends, small rotation, 2–3px, `stroke-linecap: round`. Never a ruled line, never a repeating wave, and never pen jitter or displacement filters — the hand shows in the curve, not in noise. **Pill-shaped UI is banned**: a circled word is a chalk oval, never a `border-radius: 999px` box. **Variation rule**: every markup device ships as a set of **at least 3 distinct SVG path variants** (implemented as primitives, e.g. `ChalkOval variant={1|2|3}`), further varied per-instance by small rotation/flip. Two adjacent instances never share a variant — identical "hand-drawn" marks read as a stamp and break the illusion. Swipes vary by rotation (±0.5–2°), inset, and alpha within their range. 12. **Chalk ovals**: key words circled with a hand-drawn open ellipse — `foreground` white (chalk) in hero/photo contexts, `primary` (grease pencil) on the ground. The "Real ⬭Skills⬭. Real ⬭Robots⬭. Real ⬭Fun⬭." treatment; the tagline itself is sanctioned brand copy for heroes/CTAs. Tagline/display contexts only, one run per view. -13. **Highlighter swipes**: a skewed translucent `primary` rectangle (25–35% alpha — **25% is the default**, the only value in the range that keeps white text at AAA: 7.6:1 on the ground, 9.2:1 on `card`; ±0.5–2° rotation, 2–3px radius) behind white key words — the marker-highlight alternative to `primary`-colored text. A heading uses colored text *or* a swipe, never both; verify the white-on-swipe contrast on `/styleguide`. -14. **Chalk underlines**: hand-drawn, slightly curved underline strokes (`primary`). Two uses: beneath a heading, and **inline within a sentence** under a short key phrase (≤3 words — e.g. "building the ~future of STEM~") as the sanctioned in-prose emphasis. Distinct from the machined 32px card rule, which stays perfectly straight — machined vs. hand is a deliberate contrast, never blended. +13. **Highlighter swipes**: a skewed translucent `primary` rectangle (25–35% alpha — **25% is the default**, the only value in the range that keeps white text at AAA: 7.6:1 on the ground, 9.2:1 on `card`; ±0.5–2° rotation, 2–3px radius) behind white key words — the marker-highlight alternative to `primary`-colored text. A heading uses colored text _or_ a swipe, never both; verify the white-on-swipe contrast on `/styleguide`. +14. **Hand underlines — the link grammar**: hand-drawn, slightly curved underline strokes (`primary`). **An underline means a link, always.** Every text link on the site carries one (`--texture-underline`: one smooth stroke with a gentle sag away from the text, stretched to the link's width — to each line's width when it wraps — so there is one curve per line and never a seam; ~2.3px at any width, its ends ~0.16em below the text box; hover shades the text and never moves the stroke); nothing that is not a link is ever underlined in copy. The grammar, in full: _underline_ = link; _swipe alone_ or _oval alone_ = emphasis; _swipe + underline_ = a **featured link**, the one link in a view the page wants noticed. Ovals never appear in body copy. The `ChalkUnderline` primitive remains for display use beneath a heading or tagline phrase — headings never contain links, so it cannot be mistaken for one. Distinct from the machined 32px card rule, which stays perfectly straight — machined vs. hand is a deliberate contrast, never blended. 15. **Sketch arrows**: one hand-drawn curved arrow per page. **An arrow's target is always handwritten annotation text (§3), never a regular-font element** — a sketched arrow pointing at typeset UI breaks the fiction. The annotation may itself be a link (e.g. the handwritten "Become a sponsor"). -Motion note (§6 applies): hand-markup strokes may *draw on* as their entrance (stroke-dashoffset, once, ~500ms, reduced-motion disables) — the one sanctioned decorative animation, because it enacts the metaphor. +Motion note (§6 applies): hand-markup strokes _draw on_ as their entrance (stroke-dashoffset, once, ~1100ms, reduced-motion disables) — the one sanctioned decorative animation, because it enacts the metaphor. The entrance starts when the mark scrolls into view (an IntersectionObserver in `BaseLayout`, the one script motion is allowed), not on page load, so a mark below the fold is never drawn unseen; without JavaScript the stroke is simply present. -**Register budget**: across all machined, scribed, and hand-markup devices (grid, ticks, ruler, numerals, pools, marks, title block, lineart, callouts, ovals, swipes, underlines, arrows), a viewport shows **at most 4 distinct devices**. If a new one enters a view, another leaves. +**Register budget**: across all machined, scribed, and hand-markup devices (grid, ticks, ruler, numerals, pools, marks, title block, lineart, callouts, ovals, swipes, underlines, arrows), a viewport shows **at most 4 distinct devices**. If a new one enters a view, another leaves. Link underlines are affordance, not atmosphere, and do not count. Restraint rule: these are atmosphere, not decoration — if a device is noticeable before the content is, it's too loud. `section-tint` bands (§2 surfaces) count as a device for their boundary. @@ -118,29 +122,29 @@ Restraint rule: these are atmosphere, not decoration — if a device is noticeab Per Brand Guidelines: Orbitron for page headings/titles (avoid very long or small lines), Inter for body/subheadings, Source Code Pro for monospaced/stylistic elements. -| Role | Font | Weights | Where | -|---|---|---|---| -| Display/headings | **Orbitron** (variable) | 500–700 | h1–h3 and eyebrow labels only. Never below h3 size, never italic, never long lines (≤ ~40 chars/line) — ration the display voice. | -| UI & body | **Inter** (variable) | 400/500/600 | Everything else: body, h4–h6, nav, buttons, forms, captions | -| Data voice | **Source Code Pro** | 400/600 | **Stats and numerals, countdowns, dates, spec labels** (ages chips, tier badges, "REF" annotations). The spec-sheet register of the build-document metaphor. | -| Annotation hand | **Architects Daughter** | 400 | Hand-markup annotations only (≤5 words): sketch-arrow targets, margin notes, the handwritten link they point at. Never UI chrome, body copy, headings, or labels. | +| Role | Font | Weights | Where | +| ---------------- | ----------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Display/headings | **Orbitron** (variable) | 500–700 | h1–h3 and eyebrow labels only. Never below h3 size, never italic, never long lines (≤ ~40 chars/line) — ration the display voice. | +| UI & body | **Inter** (variable) | 400/500/600 | Everything else: body, h4–h6, nav, buttons, forms, captions | +| Data voice | **Source Code Pro** | 400/600 | **Stats and numerals, countdowns, dates, spec labels** (ages chips, tier badges, "REF" annotations). The spec-sheet register of the build-document metaphor. | +| Annotation hand | **Architects Daughter** | 400 | Hand-markup annotations only (≤5 words): sketch-arrow targets, margin notes, the handwritten link they point at. Never UI chrome, body copy, headings, or labels. | Fluid scale (clamp between 360px and 1440px viewports), defined as tokens: -| Token | Size (min → max) | Line height | Font | -|---|---|---|---| -| `display` | 2.5rem → 4.25rem | 1.05 | Orbitron 700 | -| `h1` | 2rem → 3rem | 1.1 | Orbitron 700 | -| `h2` | 1.5rem → 2.25rem | 1.15 | Orbitron 600 | -| `h3` | 1.25rem → 1.5rem | 1.25 | Orbitron 600 | -| `h4` | 1.125rem → 1.25rem | 1.35 | Inter 600 | -| `stat` | 1.75rem → 2.25rem | 1.1 | Source Code Pro 600, `primary` | -| `body-lg` | 1.0625rem → 1.1875rem | 1.65 | Inter 400 | -| `body` | 1rem → 1.0625rem | 1.65 | Inter 400 | -| `small` | 0.875rem | 1.5 | Inter 400/500 | -| `label` | 0.6875–0.75rem | 1.4 | Source Code Pro 600, +0.05em tracking, uppercase (spec labels/chips) | - -- **Inter ships with its weight axis trimmed to 400–700** and cannot render heavier: the axis this table does not use was 12 KB of critical-path font (`docs/adr/0011-inter-weight-axis.md`). Widening the range is a `pnpm assets:fonts` change, not just a utility class. +| Token | Size (min → max) | Line height | Font | +| --------- | --------------------- | ----------- | -------------------------------------------------------------------- | +| `display` | 2.5rem → 4.25rem | 1.05 | Orbitron 700 | +| `h1` | 2rem → 3rem | 1.1 | Orbitron 700 | +| `h2` | 1.5rem → 2.25rem | 1.15 | Orbitron 600 | +| `h3` | 1.25rem → 1.5rem | 1.25 | Orbitron 600 | +| `h4` | 1.125rem → 1.25rem | 1.35 | Inter 600 | +| `stat` | 1.75rem → 2.25rem | 1.1 | Source Code Pro 600, `primary` | +| `body-lg` | 1.0625rem → 1.1875rem | 1.65 | Inter 400 | +| `body` | 1rem → 1.0625rem | 1.65 | Inter 400 | +| `small` | 0.875rem | 1.5 | Inter 400/500 | +| `label` | 0.6875–0.75rem | 1.4 | Source Code Pro 600, +0.05em tracking, uppercase (spec labels/chips) | + +- **Inter ships with its weight axis trimmed to 400–700** and cannot render heavier: the axis this table does not use was 12 KB of critical-path font (`docs/adr/0011-inter-weight-axis.md`). Widening the range means re-instancing the committed file (`docs/adr/0014-vendored-fonts.md`), not just a utility class. - Eyebrow labels: Orbitron 500, 12px, uppercase, `+0.08em` tracking, `primary` or `muted` — Orbitron's one all-caps use; SCP `label` is the other sanctioned caps. - Prose measure: 65–75ch (`max-w-prose`). - Implementation note: `body` names both a color (§2) and a size (this table). Tailwind resolves @@ -153,21 +157,21 @@ Fluid scale (clamp between 360px and 1440px viewports), defined as tokens: - **Spacing**: 4px base scale. Component-internal 8–24px; between-component 24–48px. - **Section rhythm**: `py-16` (mobile) / `py-24` (≥ md), consistent on every section. - **Radius — one end-mill**: three values, unified to the machining story. `radius-sm` **4px** (chips, spec labels, badges — stamped plates), `radius-md` **8px** (all controls: buttons, inputs, icon tiles), `radius-lg` **12px** (every cut feature: pockets, panels, framed media — one tool radius for everything machined). Nothing else; `16px` and pill radii are retired. -- **Elevation**: there is none — depth goes *down*, not up (§2 pocket anatomy). No drop shadows anywhere; the inset pocket shadows are the only shadows in the system. +- **Elevation**: there is none — depth goes _down_, not up (§2 pocket anatomy). No drop shadows anywhere; the inset pocket shadows are the only shadows in the system. ## 5. Layout & navigation - Container: `max-w-6xl` (72rem) + `px-4`/`px-6`. Heroes and accent rules full-bleed; content aligned to container. - Grids: 1-col → 2-col (≥ md) → 3-col (≥ lg). **No orphan rows**: plan the math (5 cards = intentional 2+3). - Breakpoints: Tailwind defaults + `3xl` = 120rem. Design mobile-first at 360px. -- **Header (sticky)**: sticky on all viewports, condensing slightly after scroll (pure CSS); `background`/95 with blur fallback, bottom hairline. Desktop: the **full-width color lockup** (`logo-color-full.svg`, ~40px) — the wordmark *is* the identity — then About / Programs ▾ / Sponsors / Donate + primary "Get involved" button. Mobile: the **square mark alone** (brand rules forbid subbing "SC2"; the full name must appear in page content — hero/footer satisfy this). +- **Header (sticky)**: sticky on all viewports, condensing slightly after scroll (pure CSS); `background`/95 with blur fallback, bottom hairline. Desktop: the **full-width color lockup** (`logo-color-full.svg`, ~40px) — the wordmark _is_ the identity — then About / Programs ▾ / Sponsors / Donate + primary "Get involved" button. Mobile: the **square mark alone** (brand rules forbid subbing "SC2"; the full name must appear in page content — hero/footer satisfy this). - **Programs**: desktop hover/focus dropdown (FLL, FRC, Robots, Calendar) whose click/tap target is a real **`/programs` hub page** — zero-JS fallback and the mobile path. Never hover-only. - **Mobile menu**: full-height sheet; ≥48px rows (About, Programs, Sponsors, Donate, Calendar); "Get involved" and "Donate" as large buttons pinned at the bottom; Esc/backdrop closes; `aria-expanded` wired. Nothing is more than two taps away. -- The about page: single flowing column (prose measure) with photo groupings as interleaved timeline sections — the legacy 3-column photo rails are retired. +- The about page: single flowing column (prose measure) with photo groupings as interleaved timeline sections. ## 6. Motion -CSS-only (D3, D20). Motion confirms — it never decorates. +CSS-only. Motion confirms — it never decorates. - Durations: 150ms (hover/focus), 250ms (menus, accordions), 500ms (scroll-in entrances). `ease-out` entrances, `ease-in-out` toggles. - Only `opacity` and `transform` animate. @@ -182,13 +186,13 @@ CSS-only (D3, D20). Motion confirms — it never decorates. - Photo treatment: `radius-lg` framed in sections; full-bleed only in heroes. Consistent warm/neutral grading. - Every image: honest `alt`; decorative pattern/grid SVGs `aria-hidden` with `alt=""`. - OG images (1200×630): photo + scrim + Orbitron title + lockup; one template, per-section variants. -- **Logo usage** (Brand Guidelines): color lockup on light *and* dark; dark/light monochrome variants per background; never recolor, never set the name in another font as a substitute for the lockup where the lockup fits. +- **Logo usage** (Brand Guidelines): color lockup on light _and_ dark; dark/light monochrome variants per background; never recolor, never set the name in another font as a substitute for the lockup where the lockup fits. ## 8. Components tone - **Buttons**: `primary` (theme accent fill + its near-black label — on FRC pages that's Hazard Green fill, etc.), `outline` (1px `border`, `foreground` text; over photography gains a translucent `card` background), `ghost`. One primary per view region. Min touch target 44×44px (nav CTA included). `radius-md`. -- **Links**: in-prose links `primary`-colored **and underlined**; UI links may drop underline at rest but underline on hover/focus. External links: icon at 0.8em + `rel="noopener"`. -- **Cards**: pocket anatomy per §2. FeatureCard = icon in `radius-sm` accent-tinted square (12% alpha `primary` bg, `primary` icon), Inter 600 title with the 32px accent rule, `body` copy, optional footer link. +- **Links**: three forms and no fourth. _In copy_: `primary`-colored with the hand underline (§2.14); a featured link adds the swipe. _In chrome_ (nav, footer, card footers): `ui-link` — `foreground`, no underline at rest, a straight 1px machined hairline on hover/focus. Chrome is a different layer from copy and never takes the hand stroke. _As a reference_: a linked logo or figure with a `Callout` beneath naming the destination (`REF — firstinspires.org`). A link that is an action is a button, not a link; the standalone underlined-text button is retired. External links: icon at 0.8em + `rel="noopener"`. +- **Cards**: pocket anatomy per §2. FeatureCard carries **one identity mark**: a photo when it has one, otherwise a Tabler icon on a stamped plate in the spec-chip anatomy (36px square, 1px 40%-alpha `primary` border, `radius-sm`, transparent fill, `primary` icon) — never both, and never a filled tile. Then an Inter 600 title with its spec chip on the same line (a row holding only a chip is a wasted row), the 32px accent rule, `body` copy, optional footer link. - **Chips/spec labels**: SCP `label` style — uppercase, tracked, 1px 40%-alpha border in the chip's color, transparent bg. Ages ("AGES 9–16"), sponsor tiers (platinum `#CBD5E1`, gold `#FACC15`, silver `#A3A3A3`, bronze `#D08954`), event dates. - **Stat band**: SCP 600 numeral in `primary` + Inter caption in `body`, on a pocket (feature moments get the grid floor). - **Forms**: visible `Label` above every field; `card` bg inputs, 1px `border`, focus = `ring` 2px; errors in the destructive text token with icon + `aria-describedby`. @@ -206,24 +210,24 @@ CSS-only (D3, D20). Motion confirms — it never decorates. ## 10. Do / Don't -| Don't | Do | -|---|---| -| Elevated lighter-than-page cards (the stock AI dark-UI look) | Pockets: darker cards machined *into* the page (§2) | -| Bare, unmodulated `#262626` voids between sections | One atmosphere device per boundary: ambient pool, ghost numeral, ruler divider, or tint band (§2) | -| Circuit-board wallpaper | Engineering grid + dimension ticks, ≤7%, heroes/section breaks only | -| Yellow words, yellow underlines, and blue links competing in one viewport | One action accent per view; blue only in its informational role | -| Orange button on the green FRC page | The page theme's primary pair | -| Hero copy on a busy photo behind a thin scrim on mobile | Text on solid ground below the photo (§7) | -| Body copy in `#A3A3A3` or dimmer | `body #D4D4D4` minimum; muted is captions-only | -| Orbitron paragraphs, tiny Orbitron labels, Orbitron stats | Orbitron = h1–h3 + eyebrows; **SCP owns numbers and spec labels** | -| "SC2" in a heading; "Scstem" in prose | Full name (capitalized STEM); SC2 only in prose with the full name present | -| Faking the logo: gear SVG + name in Inter | Real lockup assets: full-width on desktop chrome, square mark on mobile | -| Filled dark-on-dark tier pills | SCP outline chips per §8 | -| Pill-shaped UI (`border-radius: 999px` capsules) | Radius tokens only; circled words are hand-drawn chalk ovals (§2.12) | -| 5 cards centered as 3+2 with a floating orphan | Grid math planned: intentional 2+3 | -| Drop shadows, glows, hover-lift on pockets | Border warms + floor lifts one step; depth only goes down | -| `alt="image"` / missing alt | Descriptive alt or explicit `alt=""` | -| Generic hero copy ("Empowering the future…") | Specific, local, human | +| Don't | Do | +| ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| Elevated lighter-than-page cards (the stock AI dark-UI look) | Pockets: darker cards machined _into_ the page (§2) | +| Bare, unmodulated `#262626` voids between sections | One atmosphere device per boundary: ambient pool, ghost numeral, ruler divider, or tint band (§2) | +| Circuit-board wallpaper | Engineering grid + dimension ticks, ≤7%, heroes/section breaks only | +| Yellow words, yellow underlines, and blue links competing in one viewport | One action accent per view; blue only in its informational role | +| Orange button on the green FRC page | The page theme's primary pair | +| Hero copy on a busy photo behind a thin scrim on mobile | Text on solid ground below the photo (§7) | +| Body copy in `#A3A3A3` or dimmer | `body #D4D4D4` minimum; muted is captions-only | +| Orbitron paragraphs, tiny Orbitron labels, Orbitron stats | Orbitron = h1–h3 + eyebrows; **SCP owns numbers and spec labels** | +| "SC2" in a heading; "Scstem" in prose | Full name (capitalized STEM); SC2 only in prose with the full name present | +| Faking the logo: gear SVG + name in Inter | Real lockup assets: full-width on desktop chrome, square mark on mobile | +| Filled dark-on-dark tier pills | SCP outline chips per §8 | +| Pill-shaped UI (`border-radius: 999px` capsules) | Radius tokens only; circled words are hand-drawn chalk ovals (§2.12) | +| 5 cards centered as 3+2 with a floating orphan | Grid math planned: intentional 2+3 | +| Drop shadows, glows, hover-lift on pockets | Border warms + floor lifts one step; depth only goes down | +| `alt="image"` / missing alt | Descriptive alt or explicit `alt=""` | +| Generic hero copy ("Empowering the future…") | Specific, local, human | ## 11. Change process diff --git a/README.md b/README.md index dd733fe..a6cd785 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,10 @@ # scstem.org -## Project Setup +The website of the South Central STEM Collective. Astro, deployed by Cloudflare Pages. -1. Install NodeJS. Check `.nvmrc` for the version used by this project. -2. Run `npm install` in the project root to install the necessary dependencies. -3. If using VSCode, install the reccomended extensions. - -## Development - -1. Run `npm run dev` to start NextJS dev server. -2. Browse to `http://localhost:3000` to view the site. - -### Adding a new page - -> [!NOTE] -> Learn more about NextJS App Router [here](https://nextjs.org/docs/app). - -1. Go to `src/app` and create a new directory for the page. - - For example, if you want to create a page at `scstem.org/hello-world`, you'd create a folder named `hello-world` in the `src/app` directory. -2. Add `page.tsx` to your new directory with the following code (suggested: Replace `PageName` with the name of your page): - - Note: Anything returned from your page's default function is wrapped by `src/app/layout.tsx`. That file adds things like the navigation bar, footer, etc. - -```ts - -export const metadata: Metadata = { - title: "My New Page", - description: - "This is my new page", - // ... other metadata fields -}; - -export default function PageName(): ReactNode { - return
Hello World!
; -} +```sh +mise install # pinned node + pnpm +pnpm install +pnpm dev # http://localhost:4321 +pnpm check # everything CI checks, minus the build ``` diff --git a/astro.config.ts b/astro.config.ts index e0ce731..c806954 100644 --- a/astro.config.ts +++ b/astro.config.ts @@ -1,8 +1,7 @@ -import { readFileSync } from "node:fs"; - import sitemap from "@astrojs/sitemap"; import tailwindcss from "@tailwindcss/vite"; import { defineConfig, envField } from "astro/config"; +import { readFileSync } from "node:fs"; // Loaded through jiti, and this module imports nothing from `astro:*`, so the config can read the // same origin everything else derives canonical and OG URLs from. @@ -12,14 +11,12 @@ const outDir = "./dist"; /** * A URL that is both in the sitemap and `noindex` is a "Submitted URL marked 'noindex'" error in - * Search Console, so the two have to agree — and the page itself is the only honest source of - * which it is. `/styleguide` sets `noindex` through the `Seo` prop; an event retired with - * `hidden: true` (D17) becomes a redirect page Astro emits with the same tag. Reading the emitted - * HTML covers both, and anything else that grows a `noindex`, without a second list to maintain. + * Search Console, so the two have to agree, and the emitted page is the only source of which it + * is: `/styleguide` sets `noindex` through the `Seo` prop, and a retired event becomes a redirect + * page Astro emits with the same tag. Reading the HTML covers both without a second list. * * Safe to read here: `@astrojs/sitemap` filters in `astro:build:done`, after every page is on - * disk. `astro:content` is *not* reachable from the config, which is why the events collection - * cannot be consulted directly. + * disk. `astro:content` is not reachable from the config, so the collection cannot be consulted. */ const isIndexable = (page: string): boolean => { const { pathname } = new URL(page); @@ -47,7 +44,7 @@ export default defineConfig({ default: "1x00000000000000000000AA", }), /** - * Cloudflare Web Analytics' beacon token (D21). Public by design — it ships in the page — + * Cloudflare Web Analytics' beacon token. Public by design — it ships in the page — * and empty by default, which is how a preview or a fresh clone runs with no beacon at all. * The production value is set in the Pages dashboard, beside the Turnstile keys; until it * is, GA4 is the only analytics the site has (`docs/analytics.md`). diff --git a/docs/adr/0001-toolchain-split.md b/docs/adr/0001-toolchain-split.md index c45dcba..6191523 100644 --- a/docs/adr/0001-toolchain-split.md +++ b/docs/adr/0001-toolchain-split.md @@ -1,6 +1,6 @@ # 0001 — Split the lint/format toolchain between oxc and ESLint/Prettier -- **Status:** accepted +- **Status:** superseded by [0012](0012-single-toolchain.md) - **Date:** 2026-08-28 - **Decision reference:** D12, D24 (`plan/00-overview.md`) @@ -14,11 +14,11 @@ a typed linter for their frontmatter and an accessibility ruleset for their mark Split ownership by extension, with no overlap: -| Extensions | Linter | Formatter | -| --- | --- | --- | -| `.ts .js .mjs .cjs .json .jsonc .css` | oxlint (`typeAware`, `typeCheck`, vendored anti-slop) | oxfmt | -| `.astro` | ESLint (`strictTypeChecked` + `eslint-plugin-astro` `jsx-a11y-strict`) | Prettier | -| `.md` | — | Prettier | +| Extensions | Linter | Formatter | +| ------------------------------------- | ---------------------------------------------------------------------- | --------- | +| `.ts .js .mjs .cjs .json .jsonc .css` | oxlint (`typeAware`, `typeCheck`, vendored anti-slop) | oxfmt | +| `.astro` | ESLint (`strictTypeChecked` + `eslint-plugin-astro` `jsx-a11y-strict`) | Prettier | +| `.md` | — | Prettier | Import sorting is enabled on both halves — oxfmt's `sortImports` and `eslint-plugin-perfectionist`'s `sort-imports` for `.astro` frontmatter — so the whole repo is diff --git a/docs/adr/0005-webp-only-image-variants.md b/docs/adr/0005-webp-only-image-variants.md index 290d26e..8af8de5 100644 --- a/docs/adr/0005-webp-only-image-variants.md +++ b/docs/adr/0005-webp-only-image-variants.md @@ -7,8 +7,8 @@ `plan/09-assets-performance.md` §2 asks every `` call site to emit "AVIF+WebP formats". Phase 09 is also where the sources it would apply to reached their final shape: every raster in -`src/assets/` is a WebP master, re-encoded by `tools/assets/optimize-sources.mjs` at quality 80 -and capped at 2560px. +`src/assets/` is a WebP master at quality 80, capped at 2560px (prepared as +[0016](0016-source-images-sharp-cli.md) describes). Two measurements taken against `src/assets/sc2/competition-1.webp` (2048×1365, 283 KB) decided how those call sites are configured. @@ -16,13 +16,13 @@ how those call sites are configured. **AVIF costs 40× the encode time for a saving WebP already matches.** Sharp, encoding a 1920px variant on the build machine: -| format | quality | effort | size | time | -| ------ | ------- | ------ | ---- | ---- | -| WebP | 80 | — | 268 KB | 0.37 s | -| WebP | 70 | — | 211 KB | 0.28 s | -| AVIF | 55 | 4 (sharp default) | 173 KB | 11.4 s | -| AVIF | 55 | 2 | 188 KB | 1.8 s | -| AVIF | 55 | 0 | 205 KB | 0.36 s | +| format | quality | effort | size | time | +| ------ | ------- | ----------------- | ------ | ------ | +| WebP | 80 | — | 268 KB | 0.37 s | +| WebP | 70 | — | 211 KB | 0.28 s | +| AVIF | 55 | 4 (sharp default) | 173 KB | 11.4 s | +| AVIF | 55 | 2 | 188 KB | 1.8 s | +| AVIF | 55 | 0 | 205 KB | 0.36 s | AVIF only beats WebP at an effort level that costs eleven seconds per variant. The build emits 172 variants; at sharp's default effort that is roughly half an hour added to every build and @@ -40,7 +40,7 @@ this phase: Those are the widest steps: a re-compression of an already-lossy q80 file at q80, which adds generation loss and bytes at the same time. Separately, Astro fills the `src` attribute — the -fallback for a client that ignores `srcset` — from the source's *intrinsic* size whenever no +fallback for a client that ignores `srcset` — from the source's _intrinsic_ size whenever no `width` prop is given, so a 2560px master produced a 436 KB variant that no page ever displays. ## Decision diff --git a/docs/adr/0008-no-font-preloads.md b/docs/adr/0008-no-font-preloads.md index 30d2be4..df43009 100644 --- a/docs/adr/0008-no-font-preloads.md +++ b/docs/adr/0008-no-font-preloads.md @@ -14,12 +14,12 @@ variable (12.1 KB) — from `src/styles/fonts.ts`, to shorten the flash of fallb Phase 09's head audit measured what that costs. Lighthouse, mobile preset, simulated throttling (1.6 Mbps, 150 ms RTT), one run per configuration: -| URL | | FCP | LCP | CLS | Perf | -| --- | --- | ---: | ---: | ---: | ---: | -| `/programs/frc/robots/` | with preloads | 1054 ms | 2028 ms | 0.000 | 99 | -| `/programs/frc/robots/` | without | **766 ms** | **1366 ms** | 0.000 | 100 | -| `/sponsors/` | with preloads | 1062 ms | 1958 ms | 0.000 | 99 | -| `/sponsors/` | without | **754 ms** | **1129 ms** | 0.000 | 100 | +| URL | | FCP | LCP | CLS | Perf | +| ----------------------- | ------------- | ---------: | ----------: | ----: | ---: | +| `/programs/frc/robots/` | with preloads | 1054 ms | 2028 ms | 0.000 | 99 | +| `/programs/frc/robots/` | without | **766 ms** | **1366 ms** | 0.000 | 100 | +| `/sponsors/` | with preloads | 1062 ms | 1958 ms | 0.000 | 99 | +| `/sponsors/` | without | **754 ms** | **1129 ms** | 0.000 | 100 | A preload is a High-priority request issued from ``, ahead of the render-blocking stylesheet and well ahead of the hero `` the browser finds later in the body. On a link that diff --git a/docs/adr/0010-og-cards.md b/docs/adr/0010-og-cards.md index c5eeff2..348a69a 100644 --- a/docs/adr/0010-og-cards.md +++ b/docs/adr/0010-og-cards.md @@ -1,6 +1,7 @@ # 0010 — Social cards are committed artifacts from one template -- **Status:** accepted +- **Status:** accepted; amended by [0015](0015-og-cards-satori.md), which replaces the renderer + and removes the font setup step - **Date:** 2026-09-01 ## Context @@ -21,7 +22,7 @@ seven files that change when the photography or the section list changes, which ## Decision -`tools/assets/og-cards.mjs` renders all seven cards from one template and is **run by hand**; +`tools/assets/og-cards.ts` renders all seven cards from one template and is **run by hand**; the output is committed. `pnpm assets:og-fonts` is the one-time font step, and `pnpm assets:og` renders. @@ -53,5 +54,5 @@ the output is committed. `pnpm assets:og-fonts` is the one-time font step, and it and neither runs in CI. - A new section wants a new card: add it to the `cards` list, run `pnpm assets:og`, and pass it as `ogImage` with an `ogImageAlt` — `Seo` throws at build without the alt. -- `tools/checks/verify-meta.mjs` asserts every page's `og:image` is absolute and resolves to a +- `tools/checks/verify-meta.ts` asserts every page's `og:image` is absolute and resolves to a built file, so a card that is renamed and not rewired fails CI rather than a card debugger. diff --git a/docs/adr/0011-inter-weight-axis.md b/docs/adr/0011-inter-weight-axis.md index 926ca84..cb4f7a1 100644 --- a/docs/adr/0011-inter-weight-axis.md +++ b/docs/adr/0011-inter-weight-axis.md @@ -1,6 +1,7 @@ # 0011 — Inter ships with its weight axis trimmed to 400–700 -- **Status:** accepted +- **Status:** accepted; amended by [0014](0014-vendored-fonts.md), which retires the script and + vendors the file - **Date:** 2026-09-01 ## Context @@ -18,16 +19,16 @@ render. Measured, instancing each variable face down to the range its role actually spans: -| Face | Shipped | Trimmed | Saving | Range | -| --- | ---: | ---: | ---: | --- | -| Inter latin | 47.1 KB | **35.2 KB** | 11.9 KB | 400–700 | -| Inter latin-ext | 83.1 KB | **57.9 KB** | 25.2 KB | 400–700 | -| Source Code Pro latin | 21.5 KB | 18.3 KB | 3.2 KB | 400–600 | -| Orbitron latin | 11.5 KB | 10.8 KB | 0.7 KB | 500–700 | +| Face | Shipped | Trimmed | Saving | Range | +| --------------------- | ------: | ----------: | ------: | ------- | +| Inter latin | 47.1 KB | **35.2 KB** | 11.9 KB | 400–700 | +| Inter latin-ext | 83.1 KB | **57.9 KB** | 25.2 KB | 400–700 | +| Source Code Pro latin | 21.5 KB | 18.3 KB | 3.2 KB | 400–600 | +| Orbitron latin | 11.5 KB | 10.8 KB | 0.7 KB | 500–700 | ## Decision -Instance **Inter only**, to `wght 400 700`, with `tools/assets/font-subset.mjs` +Instance **Inter only**, to `wght 400 700`, with `tools/assets/font-subset.ts` (`pnpm assets:fonts`). Output is committed to `src/styles/fonts/` and `fonts.css` points at it; the `@font-face` range is declared `400 700` to match, so the browser is told what the file can actually do. diff --git a/docs/adr/0012-single-toolchain.md b/docs/adr/0012-single-toolchain.md new file mode 100644 index 0000000..22f1922 --- /dev/null +++ b/docs/adr/0012-single-toolchain.md @@ -0,0 +1,37 @@ +# 0012 — One toolchain: ESLint and Prettier for every file + +- **Status:** accepted +- **Date:** 2026-09-01 +- **Supersedes:** [0001](0001-toolchain-split.md) + +## Context + +ADR 0001 split lint and format ownership by extension: oxlint and oxfmt for `.ts`/`.js`/`.json`/ +`.css`, ESLint and Prettier for `.astro` and `.md`. The split bought speed on the half of the repo +oxc could read, and cost two configs, two sets of ignore rules, a routing hook, a `--ignore-path` +workaround on every oxfmt call, an explicit glob on every Prettier call, and import bans declared +twice. `.astro` files are the bulk of the site, and oxc still cannot read them. + +## Decision + +ESLint and Prettier own every file. + +- `eslint.config.ts`: `typescript-eslint` `strictTypeChecked` + `stylisticTypeChecked` over + `**/*.{ts,mts,js,mjs,astro}` with `projectService`; `eslint-plugin-astro` `recommended` + + `jsx-a11y-strict` over `.astro`; `perfectionist/sort-imports` throughout. +- `.prettierrc.json`: `prettier-plugin-astro` and `prettier-plugin-tailwindcss` (class sorting + in markup and in `cn`/`cva` calls, resolved against `src/styles/global.css`). No per-directory + ignore list for Markdown: tables and lists are formatted like everything else. +- The vendored nkzw oxlint config is removed; `strictTypeChecked` replaces it. The anti-slop rules + return under ESLint in [0013](0013-anti-slop-eslint-port.md). +- `tsgo` (`@typescript/native-preview`) is dropped too: `functions/` and `tools/` typecheck with + the `tsc` the repo already ships for `astro check`. Two compilers for one small tree was not + worth a dependency. + +## Consequences + +- One linter, one formatter, one hook branch. `pnpm lint` is `eslint`, `pnpm fmt` is `prettier`. +- Lint is slower than oxlint on `.ts` files: about 15 s for the repo, type-aware. Acceptable for + a site this size. +- Returning to oxc is a new decision, not a scheduled one: it would need `.astro` support and a + home for the `jsx-a11y-strict` and anti-slop coverage, and it earns its own ADR. diff --git a/docs/adr/0013-anti-slop-eslint-port.md b/docs/adr/0013-anti-slop-eslint-port.md new file mode 100644 index 0000000..5e373f3 --- /dev/null +++ b/docs/adr/0013-anti-slop-eslint-port.md @@ -0,0 +1,43 @@ +# 0013 — Run the vendored anti-slop rules under ESLint + +- **Status:** accepted +- **Date:** 2026-09-01 +- **Amends:** [0012](0012-single-toolchain.md) + +## Context + +ADR 0012 removed `tools/lint/` with oxlint. The nkzw config was a rule preset that +`strictTypeChecked` replaces; the anti-slop plugin was not — its fifteen rules (no `unknown` +parameters or returns, no open dictionary types, no unjustified type assertions, no runtime +`typeof` narrowing, and so on) encode review judgement no typescript-eslint preset carries. They +are written against oxlint's JS-plugin API: `defineRule({ meta, createOnce })`, and AST and scope +types from `@oxlint/plugins`. + +## Decision + +Restore `tools/lint/anti-slop/` and load it through ESLint via a small compat layer. + +- `compat.ts` exports the `defineRule`, `Scope`, `SourceCode`, `Variable` surface the rules + import. `defineRule` returns an ESLint rule module whose `create` calls `createOnce` (no rule + uses oxlint's `before`/`after` hooks or per-file state, so the two are equivalent). + `estree.ts` aliases the oxlint AST type names onto typescript-estree's. +- `@typescript-eslint/utils` becomes a direct dev dependency for those types. It was already in + the tree behind `typescript-eslint`; importing a transitive dependency by name is not allowed. +- The rule source keeps upstream's shape. The deviations — the import specifier, `range` instead + of `start`/`end`, and the places where typescript-estree's AST is `undefined` where oxlint's is + `null` — are listed in `tools/lint/anti-slop/VENDOR.md` so the next upstream copy can + re-apply them. +- `eslint.config.ts` registers the plugin as `anti-slop` and enables every rule as an error for + `.ts`, `.js` and `.astro` (frontmatter). `no-runtime-typeof` allows `typeof` inside type + guards, since a guard from `unknown` is the boundary parser the rule asks for. +- `rules/` and `shared/` are upstream code and excluded from ESLint (`shared/` from knip too); the compat layer + and the plugin object are linted. + +## Consequences + +- One more direct dev dependency, pinned to the `typescript-eslint` version. +- Updating the vendored rules is a re-copy plus the recorded deviations; a new oxlint AST name + needs one alias line in `estree.ts`. +- The rules see typescript-estree's AST. Branches for nodes only oxc emits (parenthesized + expressions and types, V8 intrinsics) are dead; `estree.ts` declares the parenthesized kinds + so those branches still type-check. diff --git a/docs/adr/0014-vendored-fonts.md b/docs/adr/0014-vendored-fonts.md new file mode 100644 index 0000000..3fa0bee --- /dev/null +++ b/docs/adr/0014-vendored-fonts.md @@ -0,0 +1,59 @@ +# 0014 — Font files are vendored, not installed + +- **Status:** accepted +- **Date:** 2026-09-02 +- **Amends:** [0011](0011-inter-weight-axis.md) + +## Context + +Four `@fontsource` packages were dependencies, and none of their CSS was used: `src/styles/fonts.css` +declares every `@font-face` itself so that only the latin subsets reach the build, and pointed at +the packages' woff2 files by bare path. Inter did not even do that — `tools/assets/font-subset.ts` +read the package's file, trimmed its weight axis with fonttools, and wrote a committed copy to +`src/styles/fonts/`. So the packages were file storage: four dependencies knip had to be told to +ignore, one of them a source for a hand-run Python step, and a second script reading the Orbitron +package for the social cards. + +## Decision + +The woff2 files the site serves live in `src/styles/fonts/`, each with its OFL license text beside +it, and the `@fontsource` packages, the knip exceptions, `tools/assets/font-subset.ts`, and +`pnpm assets:fonts` are gone. + +- The files are the Google Fonts builds fontsource 5.3.0 packaged, latin subsets only, so the + unicode ranges in `fonts.css` still describe them. +- Inter keeps the 400–700 instance from ADR 0011. It is now a file with a recorded provenance + rather than the output of a maintained script; the fonttools call that produced it is below, for + the day DESIGN.md §3 widens the range. +- `tools/assets/og-fonts.ts` reads the committed Orbitron file. + +## Regenerating Inter + +From a Google Fonts variable build of Inter (`wght 100 900`, latin and latin-ext subsets), with +`fonttools` and `brotli` installed: + +```py +from fontTools.ttLib import TTFont +from fontTools.varLib.instancer import instantiateVariableFont + +font = instantiateVariableFont(TTFont(source), {"wght": (400, 700)}, inplace=False, + updateFontNames=False) +font.flavor = "woff2" +font.save("src/styles/fonts/inter-latin-wght-400-700.woff2") +``` + +The `@font-face` in `fonts.css` declares `font-weight: 400 700`; change both together. + +## Alternatives considered + +- **Keep the packages, drop the script.** Ships Inter's full axis again (47 KB on the critical + path instead of 35 KB) and keeps four dependencies whose only role is holding files. +- **Keep the packages and the script.** The status quo: two sources of truth for Inter, and a + package bump that means nothing until someone reruns the script. + +## Consequences + +- About 150 KB of binaries in the repository. Fonts change on a timescale of years; a refresh is + a download and a commit, and the diff is the file. +- No dependency update ever touches a font. Fontsource's changelog is the place to hear about a new + upstream build worth taking. diff --git a/docs/adr/0015-og-cards-satori.md b/docs/adr/0015-og-cards-satori.md new file mode 100644 index 0000000..5bc9105 --- /dev/null +++ b/docs/adr/0015-og-cards-satori.md @@ -0,0 +1,52 @@ +# 0015 — Social cards render with Satori and resvg, from a vendored TTF + +- **Status:** accepted +- **Date:** 2026-09-02 +- **Amends:** [0010](0010-og-cards.md) + +## Context + +ADR 0010's renderer composited an SVG overlay onto the photograph with sharp. sharp draws SVG +text through librsvg, which finds fonts through fontconfig, and fontconfig reads neither the +variable woff2 the site ships nor a weight axis. So rendering a card needed a first step, +`tools/assets/og-fonts.ts`, that ran Python with `fonttools` and `brotli` to instance Orbitron to a +static TTF and install it into the user's `~/.fonts`, then `fc-cache`. A repository script that +depends on a second language runtime and writes into the home directory is the wrong shape, and +the title wrapping it fed was an estimate — 0.62 em per character — rather than a measurement. + +`@vercel/og` was the suggested replacement. It wraps Satori and resvg for Vercel's Edge runtime: +a bundled Noto Sans, resvg as WASM, and a web `Response` API. In a Node script the two libraries +it wraps are the dependency. + +## Decision + +`tools/assets/og-cards.ts` builds the card as a Satori element tree, Satori lays it out and +returns SVG with the type embedded, `@resvg/resvg-js` rasterizes it, and sharp writes the JPEG as +before. Both are dev dependencies. + +- Fonts are passed as bytes, not found on the system. `tools/assets/fonts/Orbitron-Bold.ttf` is + the static 700 instance Google Fonts serves (`fonts.googleapis.com/css2?family=Orbitron:wght@700` + to a client that does not advertise woff2; Orbitron 2.001), with its OFL license beside it. + Satori reads TTF, OTF and WOFF and no variable axis, so this is a different file from the woff2 + in `src/styles/fonts/`, not a derivative of it. +- The photograph is cover-cropped by sharp and embedded as a JPEG data URI; the lockup is + rasterized by sharp and embedded as PNG. Satori handles the scrim as a CSS gradient and the + title as wrapped text in a fixed-width column, measured against the real glyphs. +- `og-fonts.ts`, `pnpm assets:og-fonts`, the `fc-cache` knip exception, and the Python requirement + are gone. The script runs on any machine after `pnpm install`. + +## Alternatives considered + +- **`@vercel/og`.** The same engine behind an Edge-runtime wrapper; nothing it adds applies here. +- **Vendor the TTF and point fontconfig at it** with a repository `fonts.conf`. No new + dependencies, but fontconfig configuration differs by platform and the font lookup stays a + system dependency the script only papers over. +- **Keep the Python step.** The status quo this ADR exists to end. + +## Consequences + +- Two dev dependencies, pinned; a native binary for resvg per platform, fetched by pnpm. +- The template is an object tree rather than an SVG string; every container declares + `display: flex`, which Satori requires. +- Card output changes slightly — Satori's line breaks are measured — so the seven committed + JPEGs are regenerated with this change. diff --git a/docs/adr/0016-source-images-sharp-cli.md b/docs/adr/0016-source-images-sharp-cli.md new file mode 100644 index 0000000..3c0e830 --- /dev/null +++ b/docs/adr/0016-source-images-sharp-cli.md @@ -0,0 +1,43 @@ +# 0016 — Source images are prepared with sharp-cli, one file at a time + +- **Status:** accepted +- **Date:** 2026-09-02 + +## Context + +`tools/assets/optimize-sources.ts` walked `src/assets/`, found rasters over 300 KB or 2560 px, +re-encoded them as WebP at quality 80, and wrote the result only when it was at least 5% smaller. +The threshold and the minimum gain existed because the script ran over the whole tree: re-encoding +an already-encoded WebP always changes its size, so without them repeat runs would never converge +and would slowly degrade every image. That is machinery for a problem the workflow created. A +photograph is prepared once, when it arrives; nothing about the tree needs re-walking. + +## Decision + +A new source is converted with `sharp-cli` through `pnpm dlx`, pinned, before it is committed: + +```sh +pnpm dlx sharp-cli@6.0.0 -i camera.jpg -o src/assets//.webp \ + --autoOrient -f webp -q 80 resize 2560 2560 --fit inside --withoutEnlargement +``` + +The same engine sharp already provides to the build, run once on one file. The script, its +`pnpm assets:optimize` entry, and the `tools/lib/fs.ts` walker only it and `verify-meta` shared +are removed; `verify-meta` carries its own walk. + +Logos and other line art stay as SVG or PNG, as `docs/content.md` already says; the command is for +photographs. + +## Alternatives considered + +- **Keep the script.** 96 lines and a fixpoint guard to do what one command does. +- **ImageMagick.** Does the job but is a system dependency, which `docs/adr/0015` just removed + one of. +- **`sharp-cli` as a devDependency.** Runs on one machine, once per photograph; `pnpm dlx` with an + exact version is the same reproducibility with nothing in the tree (the ADR 0007 pattern). + +## Consequences + +- The command lives in `docs/content.md` beside the other content workflows, and a photograph that + skips it is caught the way it always was: by a slow build and a large diff, not by a check. +- Bumping the pinned `sharp-cli` is an edit to two docs, not a lockfile change. diff --git a/docs/adr/0017-font-subset.md b/docs/adr/0017-font-subset.md new file mode 100644 index 0000000..bb369e0 --- /dev/null +++ b/docs/adr/0017-font-subset.md @@ -0,0 +1,78 @@ +# 0017 — The latin faces are subset to the characters English copy uses + +- **Status:** accepted +- **Date:** 2026-09-07 +- **Amends:** [0011](0011-inter-weight-axis.md), [0014](0014-vendored-fonts.md) + +## Context + +The four latin faces in `src/styles/fonts/` were Google Fonts' `latin` builds as fontsource +packages them: 518 glyphs for Inter, covering Latin-1, spacing modifiers, combining marks, the +whole General Punctuation block, and the stylistic alternates that OpenType features reach. The +site's copy, across every built page, uses printable ASCII plus ten characters outside it: the +dashes, curly quotes, the ellipsis, ©, ®, and ≥. + +Fonts are the third request hop on every page (HTML, then the stylesheet, then the faces the +stylesheet names) and Lighthouse's Slow 4G simulation charges every kilobyte of them against LCP, +which the gate was missing by 40 to 200 ms (`0018-lighthouse-over-http2.md`). + +## Decision + +Each latin face is subset to the ranges below with fonttools, output committed, `unicode-range` in +`fonts.css` restated to the same set so a browser fetches a face only for text it can render: + +``` +U+0020-007E printable ASCII +U+00A0-00FF Latin-1 supplement: ©, ®, °, accented vowels, … +U+2010-2027 dashes, quotes, bullet, ellipsis +U+2030-203A per mille, primes, single guillemets +U+20AC € +U+2122 ™ +U+2212 minus +``` + +| Face | Before | After | +| ----------------------------- | ------: | ------: | +| Inter, wght 400–700 | 36.1 KB | 28.2 KB | +| Source Code Pro, wght 200–900 | 22.0 KB | 18.8 KB | +| Orbitron, wght 400–900 | 11.8 KB | 11.0 KB | +| Architects Daughter | 13.2 KB | 12.5 KB | + +Inter's latin-ext face is untouched: it is fetched only for a character outside the latin set, +which no page has. + +The OpenType features kept are fonttools' defaults plus `tnum`, `pnum`, and `ccmp`: +`font-variant-numeric: tabular-nums` is set on `time`, `data`, and the stat numerals, and the +tabular figures have to be in the file for it to mean anything. Name records 0–6, 13, and 14 are +kept so the copyright and OFL notice travel with each file. + +## Regenerating + +From the fontsource 5.3.0 latin builds (`docs/adr/0014-vendored-fonts.md` says where the Inter +instance comes from), with `fonttools` and `brotli` installed: + +```sh +pyftsubset .woff2 \ + --unicodes="U+0020-007E,U+00A0-00FF,U+2010-2027,U+2030-203A,U+20AC,U+2122,U+2212" \ + --layout-features+=tnum,pnum,ccmp --name-IDs=0,1,2,3,4,5,6,13,14 \ + --flavor=woff2 --output-file=src/styles/fonts/.woff2 +``` + +The range in the command and the `unicode-range` in `fonts.css` are the same set; change both. + +## Alternatives considered + +- **Trim Source Code Pro's and Orbitron's weight axes**, as 0011 did for Inter. Measured in + Phase 09 at 3.2 KB and 0.7 KB; the glyph set was where the bytes were. +- **Subset to the characters actually present in the build.** Smaller still, but a news post + with an é would render its accent in the fallback face until someone regenerated the fonts. + Latin-1 is the widest set English copy reaches into without becoming another language. +- **Drop Inter's 700.** DESIGN.md §3 sanctions 400/500/600, but markdown `**bold**` in the event + and news bodies resolves to 700, and a clamped 600 is a design change 0011 declined to make. + +## Consequences + +- A character outside the ranges above renders in the system fallback face. The site's copy has + none; a new one shows up in the browser, not the build. +- No hinting was dropped (`--no-hinting` measured under 200 bytes) and no glyph the copy uses is + gone: the build's text was checked against each subset's character map. diff --git a/docs/adr/0018-lighthouse-over-http2.md b/docs/adr/0018-lighthouse-over-http2.md new file mode 100644 index 0000000..a1daf4c --- /dev/null +++ b/docs/adr/0018-lighthouse-over-http2.md @@ -0,0 +1,72 @@ +# 0018 — The Lighthouse gate measures the build over HTTP/2 and TLS + +- **Status:** accepted +- **Date:** 2026-09-07 +- **Amends:** [0007](0007-lighthouse-ci-gate.md); confirms [0008](0008-no-font-preloads.md) + +## Context + +The Lighthouse gate had failed on every CI run since Phase 09 added it, on the LCP assertion +alone: four of the six budgeted URLs between 2036 and 2201 ms against a 2000 ms budget, while the +same build measured 1730–1820 ms on a developer machine. Two things differed. + +**Chrome.** The runner's Chrome 152 fetches every `loading="lazy"` image within roughly three +viewports during the initial load; the Chrome 141 the local runs used fetched one. On the homepage +that is 115 KB more on the wire before the hero paints, on `/about/` 211 KB, and Lighthouse's +Lantern simulation shares its 1.6 Mbps link equally among requests in flight, whatever their +priority, and counts every request that ended before the observed paint. Reproduced locally with +Chrome 152 for Testing to within 50 ms of CI. + +**Transport.** `astro preview` serves HTTP/1.1 without TLS. Lantern simulates the protocol it +observes, and over HTTP/1.1 every parallel request beyond the first six waits for a connection and +every new connection is charged a handshake at the simulated 150 ms round trip. Cloudflare Pages +serves HTTP/2 over TLS, where one connection carries everything. The gate was measuring a +transport the site is never served on, and on Chrome 152 that transport was worth 450 ms of LCP. + +Measured, Chrome 152, median of three, milliseconds: + +| URL | CI, as found | fonts subset + 672 step, HTTP/1.1 | same build, HTTP/2 + TLS | +| ----------------------- | -----------: | --------------------------------: | -----------------------: | +| `/` | 2138 | 2037 | 1577 | +| `/about/` | 2117 | 2033 | 1427 | +| `/programs/frc/robots/` | 2121 | 1887 | 1455 | +| `/sponsors/` | 2040 | 1881 | 1502 | + +## Decision + +1. **`tools/ci/serve.ts` serves `dist/` for the gate**: HTTP/2 over TLS with a self-signed + certificate it generates with `openssl` on first run, gzip on the text types Cloudflare + compresses, `404.html` for a missing path. `lighthouserc.json` starts it in place of + `astro preview` and launches Chrome with `--ignore-certificate-errors`. +2. **The latin faces are subset** to the characters English copy uses, 12 KB off the fonts every + page loads (`0017-font-subset.md`). +3. **Image ladders gain a 672px step** where a photograph is 92vw wide on a phone. A 360 CSS px + screen at 2x and Lighthouse's 412 px at 1.75x both ask for about 665 device pixels and were + being answered with the 768 or 840 px variant. +4. The LCP budget stays at 2000 ms. With the transport the site is served on, the tightest median + is 1577 ms. + +## Alternatives considered + +- **Preload the three above-the-fold faces.** Over HTTP/1.1 it recovered 100–300 ms on four + pages and nothing on the homepage; over HTTP/2 it changed nothing (1578 vs 1577 ms on `/`, 1428 + vs 1427 on `/about/`). 0008 stands. +- **Inline the stylesheet.** Removes a request hop, but puts the fonts and the hero image on the + wire at the same moment; measured 150–200 ms worse on the homepage. +- **Pin Chrome for the gate.** Reproducible, and blind: Chrome 152's lazy-loading is what + visitors run, so the gate should see it. +- **Raise the budget.** Would have hidden a real 450 ms measurement error rather than fixing it. +- **Reduce the below-the-fold photographs further.** They are already q70 and right-sized; what + remained was the transport. + +## Consequences + +- `openssl` is a requirement of the Lighthouse job and of running `lhci autorun` locally. It is on + every GitHub runner image and on macOS. +- Lighthouse's `uses-http2` diagnostic passes for the first time, and the reports describe the + site as visitors get it. +- LHCI kills the server it started, so the local `astro preview stop` step in `docs/tooling.md` is + gone. +- Chrome on the runner is whatever `ubuntu-latest` ships. A future Chrome can move the numbers + again; the job summary now prints the waterfall behind every URL's LCP so the cause is readable + from the log. diff --git a/docs/analytics.md b/docs/analytics.md index 133cd1c..57261ca 100644 --- a/docs/analytics.md +++ b/docs/analytics.md @@ -1,6 +1,6 @@ # Analytics -Two collectors, both cookieless-by-default, both loading after `load` (D21): +Two collectors, both cookieless-by-default, both loading after `load`: | What | Where it comes from | Status | | ------------------------ | -------------------------------------------------- | -------------------------------- | @@ -65,55 +65,28 @@ dispatches `TRACK_EVENT` with its name. Then add the row above, and mark it a ke ## Verifying it -The snippet is gated on the production hostname, so a local check has to say it is on that host. -Against a production build served by `astro preview`, with the hostname in the emitted snippet -temporarily rewritten to `localhost`, the following was observed in Chromium: - -- `dataLayer` after `load`: `["consent","default",{…}]`, `["js",Date]`, `["config","G-3TPD3DLYBR"]` - — in that order, with the consent defaults first. -- `gtag.js` injected `async` into ``. -- Clicking the header CTA pushed `["event","get_involved_click"]`; a `/donate` link pushed - `["event","donate_click"]`; an `/about` link pushed nothing. -- Dispatching `sc2:track` with `detail: "contact_submit"` pushed `["event","contact_submit"]`. -- On `localhost` without that rewrite: no `dataLayer`, no tag request, on any page. - -In production, GA4's **DebugView** is the equivalent check; `?gtm_debug=x` on a real page turns it -on for that session. +The snippet is gated on the production hostname, so a local check has to pretend to be that host: +build, rewrite the hostname in the emitted snippet to `localhost`, serve with `astro preview`, and +watch `dataLayer` after `load` — `["consent","default",{…}]`, `["js",Date]`, `["config",…]` in +that order, then `["event",…]` entries as links are clicked. In production, GA4's **DebugView** is +the equivalent check; `?gtm_debug=x` on a real page turns it on for that session. ## Owner tasks -These need account access this repository does not have. Tracked in `plan/todo.md`. +These need account access this repository does not have. - **Cloudflare Web Analytics.** Create the site in the Cloudflare dashboard, copy its beacon - token, and set `PUBLIC_CF_BEACON_TOKEN` in the Pages project's build environment (both - production and preview — the hostname gate is what keeps previews out of GA4, and CF Analytics - is per-site anyway). Until then the beacon is simply absent: the snippet skips it on an empty - token. -- **GA4 property review.** Data retention (14 months is the default; 26 is available and worth - taking), an internal-traffic filter if the workshop has a static IP, and unwanted-referral - exclusions for `paypal.com` and `docs.google.com` so a returning donor is not re-attributed to - a referral. + token, and set `PUBLIC_CF_BEACON_TOKEN` in the Pages project's build environment (production + and preview). Until then the beacon is absent: the snippet skips it on an empty token. +- **GA4 property review.** Data retention (14 months is the default; 26 is available), an + internal-traffic filter if the workshop has a static IP, and unwanted-referral exclusions for + `paypal.com` and `docs.google.com` so a returning donor is not re-attributed to a referral. - **Key events.** Mark all six events above as key events in GA4 → Admin → Events. -- **Search Console.** Verify `scstem.org`, submit `https://scstem.org/sitemap-index.xml`, and link - the property to GA4. -- **Bing Webmaster Tools.** Verify, and import from Search Console rather than re-verifying. -- **Structured data.** Run the five JSON-LD shapes (`NGO`, `WebSite`, `BreadcrumbList`, `Event`, - `FAQPage`) through the Rich Results Test and the schema.org validator once the site is on a - reachable URL. `tools/checks/verify-meta.mjs` covers parse-and-type in CI; it cannot cover - Google's own eligibility rules. ## The JS budget covers first-party script only -`plan/00-overview.md` originally budgeted "total client JS < 35 KB gzipped per page **including -analytics**". `gtag.js` is about 35 KB gzipped on its own, so that wording and D21 could not both -hold. Settled by the project owner during Phase 10: **the budget excludes analytics**, and the -overview now says so. - -What that means in practice. The Lighthouse gate measures the site without GA4 — not by omission -but by design, since the production-hostname check keeps the tag out of every local run, every -preview deploy, and CI. First-party JS is 0.7–3.1 KB per page, inline in the document, against a -35 KB budget. Production adds `gtag.js` after `load`, so it never touches LCP or TBT; it does add -to total transfer, which the 1 MB page-weight assertion has ample room for. - -If that trade ever stops being worth it, Cloudflare Web Analytics' ~5 KB cookieless beacon would -satisfy the original literal reading on its own. +The 35 KB gzipped per-page JS budget (`lighthouserc.json`) excludes analytics: `gtag.js` is about +35 KB on its own. The Lighthouse gate measures the site without GA4 because the hostname check +keeps the tag out of every local run, preview deploy, and CI run. Production adds `gtag.js` after +`load`, so it never touches LCP or TBT; it does add to total transfer, which the 1 MB page-weight +assertion has room for. diff --git a/docs/content.md b/docs/content.md index ab1e844..5e33280 100644 --- a/docs/content.md +++ b/docs/content.md @@ -1,7 +1,6 @@ # Editing content -Everything on this site that changes over time is a markdown file in `src/content/`. You do not -need to touch TypeScript to add a sponsor, publish an event, or answer a new question. +Everything on this site that changes over time is a markdown file in `src/content/`. Frontmatter is validated on build: a typo in a field name or a value that is not allowed fails `pnpm build` with a message naming the file and the field. That is the safety net — if it builds, @@ -93,25 +92,19 @@ faq: `-05:00` in winter. That offset is what makes the date correct for someone reading in another timezone, and it feeds the event's structured data. -`description` has a length budget: `tools/checks/verify-meta.mjs` fails the build over 160 -characters, because that is roughly where Google stops printing one, and under 50 because a -one-liner tells a searcher nothing. It also has to be **unique across the site** — two pages with -the same description is the same check's other failure. Write it for someone deciding whether to -click, not as a summary of the page. +`description` has a length budget: `tools/checks/verify-meta.ts` fails CI over 160 characters, +because that is roughly where Google stops printing one, and under 50 because a one-liner tells a +searcher nothing. It also has to be **unique across the site**. Write it for someone deciding +whether to click, not as a summary of the page. -**An event retires itself once its `end` has passed.** The first deploy after the event ends is -the one that does it: the page redirects to its parent, and the URL leaves the sitemap and -`/llms.txt` together. Nothing to remember, and nothing to clean up — dating next season's entry -forward brings the page straight back. +**An event retires itself once its `end` has passed.** On the first deploy after the event ends, +the page redirects to its parent and the URL leaves the sitemap and `/llms.txt`. Dating next +season's entry forward brings the page straight back. -`hidden: true` is still there, for retiring one _early_ or one that has no `end` at all. An entry -with no `end` never retires on its own, for the same reason the countdown never calls it passed: -nothing in the entry says when the event is over, and picking a duration would invent one. That is -why `end` is worth always setting. - -Give every event an `end`. The page counts down to `start`, says "Happening now" from then until -`end`, and "This event has passed" after it — with no `end` it never reaches the last of those, -because nothing in the file says when the event is over. +**Give every event an `end`.** The page counts down to `start`, says "Happening now" from then +until `end`, and "This event has passed" after it. An entry with no `end` never reaches that last +state and never retires on its own: nothing in the file says when the event is over, and picking +a duration would invent one. `hidden: true` is for retiring an event _early_, or one with no `end`. `heroImage` is optional: without one the page uses a photo of the event's program. With one, `heroImageAlt` is required and the build fails without it (`docs/adr/0004`). @@ -125,16 +118,16 @@ Location is **omitted** for anything at the workspace: it defaults to the addres event, which also makes "off-site" visible at a glance. `faq` lists FAQ slugs to show on the page, in the order given. A slug that does not match a file -fails `pnpm check` (`tools/checks/content-references.mjs`) — Astro alone only logs it. +fails the build: Astro names the entry and field, and the page that lists it stops rendering. -### Hide an event after it passes +### Hide an event early ```md hidden: true ``` The page stops rendering and sends anyone who lands on it to its parent — `/` for the open house, -`/programs/frc` for kickoff. Flip it back next season and the page returns unchanged. +`/programs/frc` for kickoff. Flip it back and the page returns unchanged. ### Create a new event @@ -198,10 +191,23 @@ caption: '"Biohazard" - 2026' `alt` describes the photo for someone who cannot see it; `caption` is the visible label. They are different jobs, so they are different fields. +## Add a photograph + +Every photo in `src/assets/` is a WebP master, at most 2560px on its long edge, at quality 80. +Convert a camera file once, before committing it: + +```sh +pnpm dlx sharp-cli@6.0.0 -i camera.jpg -o src/assets//.webp \ + --autoOrient -f webp -q 80 resize 2560 2560 --fit inside --withoutEnlargement +``` + +Nothing enforces this; an unconverted master shows up as a slow build and a large diff +(`docs/adr/0016`). Logos stay SVG or PNG (see "Add a sponsor"). + ## News posts -The `news` collection is scaffolded but has no routes yet (that is phase-2 work). Copy -`src/content/news/template.md`, and leave the template itself as `draft: true`. +The `news` collection is scaffolded but has no routes yet. Copy `src/content/news/template.md`, +and leave the template itself as `draft: true`. ## Where things live diff --git a/docs/tooling.md b/docs/tooling.md index 2cd7504..a6af554 100644 --- a/docs/tooling.md +++ b/docs/tooling.md @@ -5,213 +5,85 @@ ```sh mise install # installs the pinned node + pnpm from mise.toml / mise.lock pnpm install -pnpm check # typecheck + lint + format check + knip +pnpm check # typecheck + lint + format check + knip + repo checks pnpm dev ``` -`mise.toml` pins node and pnpm exactly and commits checksums to `mise.lock`, so every clone and -CI run gets byte-identical tools. `mise.toml`'s `[tasks]` are thin forwarders — **`package.json` -scripts are the single source of truth**; never put a real command in a mise task. +`mise.toml` pins node and pnpm exactly and commits checksums to `mise.lock`, so every clone, CI +run, and Cloudflare Pages build gets the same tools. `package.json` scripts are the single +source of truth for commands. -### Node version +Corepack's `pnpm` shim can shadow the mise-pinned pnpm and rewrite the lockfile with a different +resolver. `which pnpm` must resolve inside mise's shims directory; if not, `corepack disable pnpm`. -Pinned to the node 26 line, matching what the legacy site already ran (`legacy/.nvmrc` was -v26.5.0) and what Cloudflare Pages already serves. Node 26 is the Current line today; move the -pin to 26.x LTS once that line is promoted. +## Toolchain -### The corepack footgun +| Concern | Tool | +| ----------- | ---------------------------------------------------------------------------------------- | +| Lint | ESLint: `strictTypeChecked` + `stylisticTypeChecked`, `astro/jsx-a11y-strict`, anti-slop | +| Format | Prettier, with the Astro and Tailwind plugins | +| Types | `astro check` for `src/` and config files; `tsc -p functions`, `tsc -p tools` | +| Dead code | knip | +| Repo checks | `tools/checks/*.ts` | -Corepack's `pnpm` shim can shadow the mise-pinned pnpm and silently rewrite the lockfile with a -different resolver. `which pnpm` must resolve inside the mise shims directory. If it does not: +TypeScript is the 6.x line everywhere, one compiler for `astro check`, `tsc`, and +`typescript-eslint`. `astro check` (Volar) needs the JavaScript compiler's API, which TypeScript 7 +does not expose yet; when it does and `@astrojs/check` widens its peer range, bump the pin. -```sh -corepack disable pnpm -``` - -### Supply-chain cooldown - -Two independent cooldowns, both intentional: - -- `mise.toml` → `minimum_release_age = "7d"` for node and pnpm themselves. -- `pnpm-workspace.yaml` → `minimumReleaseAge: 10080` (minutes) for every npm dependency. - -Consequence: **pins are the newest version that is at least a week old**, not the newest version. -`pnpm install` refuses fresher ones by design. To take a newer version early, add a -`minimumReleaseAgeExclude` entry and comment why in `pnpm-workspace.yaml`. - -Install scripts are denied by default; `allowBuilds` lists the exceptions (`sharp`, `esbuild`). - -`sharp` is a direct dependency rather than one inherited from Astro: pnpm's isolated layout keeps -Astro's copy where the bundled image service cannot resolve it, so `astro:assets` falls back to -unoptimized passthrough with one warning per image. See `docs/adr/0003-sharp-direct-dependency.md`. - -## Toolchain ownership - -| Extensions | Linter | Formatter | -| ------------------------------------- | -------------------------------------------------------- | --------- | -| `.ts .js .mjs .cjs .json .jsonc .css` | oxlint — type-aware, vendored nkzw config + anti-slop | oxfmt | -| `.astro` | ESLint — typed (`strictTypeChecked`) + `jsx-a11y-strict` | Prettier | -| `.md` | — | Prettier | - -Rationale and the migration seam for collapsing onto oxc: `docs/adr/0001-toolchain-split.md`. - -### Invocation quirks worth knowing - -- **oxfmt reads `.prettierignore` by default.** Since that file exists for Prettier, every oxfmt - invocation passes `--ignore-path .gitignore` — otherwise oxfmt would skip every file it owns. -- **Prettier is called with an explicit `**/*.{astro,md}` glob.** A blanket `*` in - `.prettierignore` prunes whole directories, and gitignore semantics cannot un-prune individual - files inside them, so the glob does the scoping and `.prettierignore` only holds real exclusions. -- **ESLint's global ignores cannot use `ignores: ["**/*", "!**/*.astro"]`** for the same reason. - Scoping comes from every config block being `files: ["**/*.astro"]`. -- **oxlint ignores `**/*.astro`** so the two linters never both own a file. -- **`eslint-plugin-jsx-a11y` is a direct devDependency.** It is an _optional_ peer of - `eslint-plugin-astro`, so without it `astroConfigs["jsx-a11y-strict"]` degrades to `{ rules: {} }` - silently — no install warning, no accessibility linting. Its published peer range stops at - eslint `^9`; `pnpm-workspace.yaml` allows eslint 10 there, and the rules do fire (check with - `eslint --print-config` on any `.astro` file — expect ~33 `jsx-a11y/*` entries, not zero). -- **The `clsx`/`tailwind-merge` and `legacy/*` import bans are declared twice** — once in - `.oxlintrc.json` and again in `eslint.config.ts`. oxlint ignores `.astro`, so the ESLint copy is - what enforces them in the file type the site is built from. Both copies must change together. -- **`functions/` has its own tsconfig** and is typechecked separately (`tsgo -p functions`, part of - `pnpm typecheck`). It uses `moduleResolution: "bundler"` because Cloudflare bundles Functions - with esbuild, which resolves the extensionless `@/*` path aliases the source already uses. It - restates the root tsconfig's strictness flags rather than extending - `astro/tsconfigs/strictest` (which pulls in DOM and Astro types the Workers runtime lacks) — - this is the only code handling untrusted request input, so it must not typecheck more loosely - than `src/`. - -### TypeScript: two compilers, on purpose - -| What | Compiler | -| ------------------------- | ----------------------------------------------------------------- | -| `src/**` and `.astro` | `astro check` → JavaScript TypeScript (`typescript`) | -| `functions/**` | `tsgo` → TypeScript 7, the Go port (`@typescript/native-preview`) | -| oxlint's type-aware rules | `tsgolint` → also Go (`oxlint-tsgolint`) | - -`functions/` runs on TypeScript 7. Measured on this tree: `tsc -p functions` 961 ms, `tsgo -p functions` -182 ms. - -**`astro check` cannot move yet, and that is why `typescript` is still a dependency.** -`@astrojs/check` declares `peerDependencies: { "typescript": "^5.0.0 || ^6.0.0" }` and its language -server is built against the JavaScript compiler's Language Service API. -`@typescript/native-preview` exports only `version` and `versionMajorMinor` — there is no -`typescript.js` or `tsserver.js` in it — so nothing can substitute it there. - -This is not an Astro backlog item; it is upstream. From Astro's own tracking discussion -([withastro/roadmap#1321](https://github.com/withastro/roadmap/discussions/1321)), maintainer -delucis: - -> TypeScript 7 does not yet expose a stable programmatic API, and so tools (such as Volar) which -> embed TypeScript into their own compilers and language services can only currently rely on -> TypeScript 6.0. - -Astro's language server is Volar-based, so the same blocker hits Vue and Svelte. The TypeScript -team is building a replacement for the deprecated JS ("Strada") API and reportedly targets it for -**7.1**; no date is committed. **Watch that discussion** — when the API lands and `@astrojs/check` -widens its peer range, `astro check` can move and `typescript` can be dropped. +### `tools/` -`js/ts.experimental.useTsgo` in `.vscode/settings.json` routes plain `.ts` files to tsgo. That is -independent of the Astro extension, which keeps using its own TS 6 language server for `.astro`. -The tracking discussion does carry reports of import/export resolution oddities in editors with -tsgo enabled — if `.astro` intellisense misbehaves, that setting is the first thing to turn off. +TypeScript scripts run directly by Node (type stripping, no build step), each behind a +`package.json` script: -`@typescript/native-preview` is a `7.0.0-dev.*` build; it is pinned exactly, like every other tool -here, and `pnpm-workspace.yaml`'s `minimumReleaseAge` still applies. +| Script | Does | Runs in | +| ------------ | ----------------------------------------------------------- | --------------- | +| `check:meta` | Every built page's head: unique title/description, og:image | CI, after build | +| `assets:og` | Render the OG cards in `src/assets/og/` | by hand | -In the editor, `js/ts.experimental.useTsgo` routes `.ts` files to tsgo while `js/ts.tsdk.path` keeps -the JavaScript compiler available for the Astro language server — the same split as the table above. +Two more under `tools/ci/` have no script because Lighthouse CI runs them: `serve.ts` serves +`dist/` over HTTP/2 and TLS for the audit, and `lighthouse-summary.ts` writes the job summary. -### Lint rule sources +Only erasable TypeScript syntax (no enums, namespaces, or parameter properties); +`tools/tsconfig.json` enforces it. -oxlint's rule set comes from three places, layered: +## Agent hook -1. **`tools/lint/nkzw/oxlintrc.json`** — a vendored copy of - [`@nkzw/oxlint-config`](https://github.com/nkzw-tech/oxlint-config) (MIT), extended by path from - `.oxlintrc.json`. 146 general-purpose rules: unicorn, typescript-eslint, oxc, import-x, - perfectionist, and the core set. See its `VENDOR.md` for what was dropped (React, Relay and - test-runner rules that do not apply here) and how to update it. -2. **`tools/lint/anti-slop/`** — the vendored plugin below. -3. **`.oxlintrc.json`'s own `rules`** — the `legacy/**` import ban and the anti-slop rule list. - -**Every rule is an error.** `perf` was a warning and is now an error; `style` is off. A warning -nobody has to fix is a rule nobody obeys, so the choice is error or off — which is also the reason -for adopting the nkzw set, whose severities are all `error` upstream. - -**`perfectionist/sort-objects` is off**, and it is the one rule from upstream that is disabled on -its merits rather than for inapplicability. Its autofix reorders an object's keys but leaves leading -comments where they were, so on any object whose keys carry doc comments it silently produces false -documentation. Reproduced on `src/data/site.ts` in a single `--fix` pass: `shortName`'s comment -ended up labelling `description`, `titleTemplate`'s ended up on `email`, and the multi-line comment -for `calendars` ended up on `analytics`. AGENTS.md requires a comment to describe what is there, and -a rule that rewrites them to describe something else cannot be a `--fix` away from green. - -The sibling rules do not have this flaw — `sort-interfaces` and `sort-object-types` were checked -against a commented interface and a commented type literal and carried each comment with its member -— so they stay on, as do `sort-imports`, `sort-enums`, `sort-heritage-clauses` and `sort-jsx-props`. - -Two deliberate exceptions live in `.oxlintrc.json`'s `overrides`: - -- `no-console` is off under `functions/**` and `tools/**`. A Cloudflare Worker's console is its - log stream — `wrangler tail` and the dashboard read nothing else — and a CLI check script's - console is how it reports to the developer running it. Neither is the shipped-debug-logging case - the rule guards. Everywhere else it stays an error. -- Upstream's own `.ts` override is kept, which turns off the correctness rules TypeScript already - covers (`no-undef`, `no-redeclare`, …). That is the config's speed principle, not a gap. - -**Import bans use `**`, not `*`.** A single star matches one path segment, so `legacy/*` allowed -`legacy/data/config` and every deep relative path. Both linters use `["legacy/**", "**/legacy/**"]` -and both are verified against a deep relative import. - -### Vendored lint rules - -`tools/lint/anti-slop/` is a copy of [dmmulroy/anti-slop](https://github.com/dmmulroy/anti-slop) -(MIT), registered as an oxlint `jsPlugin`. See its `VENDOR.md` for the upstream commit and update -procedure. It is excluded from this repo's own lint, format, and typecheck — it is upstream source. - -## Agent hooks - -`.claude/hooks/format-lint.sh` runs on every `Edit`/`Write` from Claude Code: it routes the edited -file to the owning formatter, then the owning linter, and exits 2 with the findings on a lint -failure so they go back to the agent. Files in `legacy/`, `dist/`, `.astro/`, `plan/`, and the -vendored rule directory are skipped. +`.claude/hooks/format-lint.sh` runs on every `Edit`/`Write` from Claude Code: Prettier on the +file, then ESLint if it is a file ESLint reads, exiting 2 with the findings so they go back to +the agent. It exits 0 when `node_modules` is missing. ## Environment variables | Variable | Where | Purpose | | --------------------------- | --------------------- | ------------------------------------- | | `PUBLIC_TURNSTILE_SITE_KEY` | build (public) | Turnstile widget on the contact form | -| `PUBLIC_CF_BEACON_TOKEN` | build (public) | Cloudflare Web Analytics beacon (D21) | +| `PUBLIC_CF_BEACON_TOKEN` | build (public) | Cloudflare Web Analytics beacon | | `TS_SECRET_KEY` | Pages Function secret | Turnstile server-side verification | | `SLACK_FORM_POST_GENERIC` | Pages Function secret | Slack webhook for contact submissions | -`PUBLIC_CF_BEACON_TOKEN` defaults to empty, and an empty token means the beacon is simply not -injected — so a fresh clone and every preview run with GA4 alone. Setting it is an owner task in -`docs/analytics.md`. - -`PUBLIC_TURNSTILE_SITE_KEY` is declared in `astro.config.ts`'s `env.schema`, so pages import it -from `astro:env/client` rather than reaching into an untyped `import.meta.env`. It **defaults to -Cloudflare's documented always-passes test key** (`1x00000000000000000000AA`), which is why a -fresh clone and every preview deploy have a working form with no setup — and why the production -value has to be set deliberately, in the Cloudflare Pages dashboard, alongside the `TS_SECRET_KEY` -its server half checks against. A site key is public by design: it ships in the page's HTML. - -The secret half has a matching always-passes test value baked into `functions/api/form/submit.ts` -for the same reason, so the whole round trip works locally without credentials. +The public ones are declared in `astro.config.ts`'s `env.schema` and imported from +`astro:env/client`. `PUBLIC_TURNSTILE_SITE_KEY` defaults to Cloudflare's always-passes test key +and `PUBLIC_CF_BEACON_TOKEN` to empty (no beacon), so a fresh clone and every preview deploy work +with no setup; production sets both in the Pages dashboard. `functions/api/form/submit.ts` carries +the matching test secret so the form round-trips locally. ## CI -`.github/workflows/ci.yml` runs on every pull request: mise install → `pnpm install ---frozen-lockfile` → `pnpm check` → `pnpm build` → offline link check over `dist/`. All steps -block. Deploys are **not** driven by Actions — Cloudflare Pages' dashboard git integration owns -them (D14). +Cloudflare Pages builds and deploys from git. `.github/workflows/ci.yml` runs on pull requests +and pushes to `main` and `staging`, in three jobs: + +- **Check**: typecheck, lint, format, knip, and the repo checks as separate steps, all of which + run even when an earlier one fails. ESLint findings become inline annotations on the PR. +- **Build**: `pnpm build`, `check:meta`, and an offline link check over `dist/` (lychee). +- **Lighthouse**: `@lhci/cli` via `pnpm dlx` (`docs/adr/0007`) against `tools/ci/serve.ts`, which + serves the build artifact over HTTP/2 and TLS the way Cloudflare does (`docs/adr/0018`), three + runs per URL over six page shapes. The job summary and the log carry the median scores per URL, + every failed assertion, and for each URL the LCP element, its phases, and the request waterfall; + full reports upload as an artifact. ### Performance budgets -`.github/workflows/lighthouse.yml` is the second required check. It builds, serves `dist/` with -`astro preview`, and runs `pnpm dlx @lhci/cli@0.15.1 autorun` three times against six URLs — one -of each page shape: `/`, `/programs/frc/`, `/programs/frc/robots/`, `/sponsors/`, `/openhouse/`, -`/contact/`. Every assertion in `lighthouserc.json` is an error, so a regression blocks the merge. +`lighthouserc.json`, every assertion an error on the median of three runs: | Assertion | Budget | | ------------------------ | --------- | @@ -225,39 +97,12 @@ of each page shape: `/`, `/programs/frc/`, `/programs/frc/robots/`, `/sponsors/` | Script transfer size | < 35 KB | | Total page transfer size | < 1 MB | -Assertions aggregate on the **median** of the three runs. Lantern's LCP for this site carries one -slow run per page — a ~500 ms step in simulated FCP that appears run to run even on an idle -machine — and median-of-three absorbs exactly that. Every run on every budgeted URL is currently -under the 2000 ms LCP budget, the worst being 1953 ms; the tightest medians are `/` and -`/openhouse/` at 1807 ms. Getting there was two font changes, not the image work — see -`plan/09-assets-performance.md` for the before/after and `docs/adr/0008` and `0011` for the -reasoning. - -Mobile emulation with simulated throttling (1.6 Mbps, 150 ms RTT) — the default preset, and the -reason transfer size dominates every metric here. `astro preview` gzips, which is what makes the -measurement comparable to what Cloudflare serves; a server that did not would fail budgets -production meets. - -To run it locally, with a Chrome that Lighthouse can find: +Mobile emulation with simulated throttling, so transfer size and request count dominate, and the +simulation follows the protocol it observes: HTTP/2 with gzip, as served. Locally (`openssl` on +the path for the self-signed certificate): ```sh pnpm build -pnpm dlx @lhci/cli@0.15.1 autorun -pnpm exec astro preview stop # LHCI kills its wrapper; the preview server outlives it +pnpm dlx @lhci/cli@0.15.1 autorun --config=tools/ci/lighthouserc.json +node tools/ci/lighthouse-summary.ts ``` - -Reports land in `.lighthouseci/reports/` (gitignored) as HTML and JSON, and CI uploads them as an -artifact on every run. Why `pnpm dlx` rather than a devDependency or a marketplace action: -`docs/adr/0007-lighthouse-ci-gate.md`. - -The hero video on `/programs/frc/` is `preload="none"` and only arms after the `load` event, so it -does not count against that page's transfer budget during a run — see -`docs/adr/0006-hero-video-encode.md`. - -### Known environment limitation - -The sandboxed environment this repo is sometimes developed in cannot reach -`tuf-repo-cdn.sigstore.dev`, so mise's GitHub artifact-attestation check for the pnpm download -fails there. The committed configuration keeps verification **on**; only that environment relaxes -it via `MISE_AQUA_GITHUB_ATTESTATIONS=false`, and the tool checksums in `mise.lock` still apply. -Real developers and CI verify normally. diff --git a/eslint.config.ts b/eslint.config.ts index 466ff44..36e925c 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -1,77 +1,90 @@ -import { parseForESLint } from "astro-eslint-parser"; -import { configs as astroConfigs } from "eslint-plugin-astro"; +import astro from "eslint-plugin-astro"; import perfectionist from "eslint-plugin-perfectionist"; import { defineConfig, globalIgnores } from "eslint/config"; import tseslint from "typescript-eslint"; +import antiSlop from "./tools/lint/anti-slop/index.ts"; + export default defineConfig( - // Every block below is scoped to `**/*.astro`, so ESLint never reaches the - // extensions oxlint owns. A blanket `ignores: ["**/*"]` cannot be used here: - // 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"], + globalIgnores([ + "legacy/", + "dist/", + ".astro/", + "public/", + // The browser the chrome-devtools MCP server downloads for local sessions; gitignored. + ".browser/", + // Vendored upstream rule source (tools/lint/anti-slop/VENDOR.md); its glue is linted. + "tools/lint/anti-slop/rules/", + "tools/lint/anti-slop/shared/", + ]), { - extends: [tseslint.configs.strictTypeChecked], - files: ["**/*.astro"], + files: ["**/*.{ts,mts,js,mjs,astro}"], + extends: [tseslint.configs.strictTypeChecked, tseslint.configs.stylisticTypeChecked], languageOptions: { - // strictTypeChecked would install the TS parser directly, which cannot read - // `.astro`. The Astro parser stays outermost and delegates frontmatter to it. - parser: { parseForESLint }, - parserOptions: { - extraFileExtensions: [".astro"], - parser: tseslint.parser, - // astro-eslint-parser does not implement projectService; it maps to `project`. - project: true, - tsconfigRootDir: import.meta.dirname, - }, + parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname }, }, - plugins: { perfectionist }, + plugins: { "anti-slop": antiSlop, perfectionist }, rules: { - // Mirrors oxfmt's sortImports so the whole repo is sorted the same way. - "perfectionist/sort-imports": "error", - // Restated from .oxlintrc.json, which ignores `**/*.astro`. ESLint is the only linter - // that reads this extension, so without this the ban is off in the file type the site - // is built from. + "anti-slop/no-chained-type-assertions": "error", + "anti-slop/no-conditional-empty-object-spread": "error", + "anti-slop/no-known-value-widening": "error", + "anti-slop/no-module-mocking": "error", + "anti-slop/no-object-parameters": "error", + "anti-slop/no-reflect-apply": "error", + "anti-slop/no-reflect-get": "error", + // A type guard from `unknown` is the boundary parser the rule asks for. + "anti-slop/no-runtime-typeof": ["error", { allowInTypeGuards: true }], + "anti-slop/no-shape-in-symbol-names": "error", + "anti-slop/no-unknown-parameters": "error", + "anti-slop/no-unknown-returns": "error", + "anti-slop/no-unknown-type-aliases": "error", + "anti-slop/no-unsafe-dictionary-type": "error", + "anti-slop/no-widen-then-assert": "error", + "anti-slop/require-safety-comment-for-type-assertion": "error", + "no-console": "error", "no-restricted-imports": [ "error", { + paths: [ + { name: "classnames", message: "Use `cn` from `@/lib/cn` (AGENTS.md)." }, + { name: "clsx", message: "Use `cn` from `@/lib/cn` (AGENTS.md)." }, + { name: "tailwind-merge", message: "Use `cn` from `@/lib/cn` (AGENTS.md)." }, + ], + // `**`, not `*`: a single star matches one path segment and lets deep imports through. patterns: [ - { - // `**`, not `*`: a single star matches one path segment, so `legacy/*` let - // `legacy/data/config` and every deep relative path through. - group: ["legacy/**", "**/legacy/**"], - message: "legacy/ is reference only — never import from it (plan/00-overview.md)", - }, + { group: ["legacy/**", "**/legacy/**"], message: "legacy/ is reference only." }, ], }, ], - - /** - * astro-eslint-parser does not type the JSX-like expressions in an Astro *template*, so - * every `items.map(() => )` resolves as `error` and trips this rule. It is a gap in - * the parser, not unsafety in the code: frontmatter — the part that holds real logic — is - * fully typed, and `astro check` type-checks templates properly. - * - * Sibling rules in this family (no-unsafe-assignment/-call/-member-access) may need the - * same treatment as templates grow; add them here with the same reasoning, never blanket - * off the whole family. See docs/adr/0001-toolchain-split.md. - */ + "perfectionist/sort-imports": "error", + }, + }, + { + // A Worker's console is its log stream; a CLI script's console is how it reports. + files: ["functions/**", "tools/**"], + rules: { "no-console": "off" }, + }, + { + files: ["**/*.astro"], + extends: [astro.configs.recommended, astro.configs["jsx-a11y-strict"]], + languageOptions: { + parserOptions: { + extraFileExtensions: [".astro"], + parser: tseslint.parser, + // astro-eslint-parser does not implement projectService. + project: true, + projectService: false, + }, + }, + rules: { + // The parser does not type expressions in the template, so every `items.map(() => )` + // resolves as `error`. Frontmatter is fully typed and `astro check` covers the template. "@typescript-eslint/no-unsafe-return": "off", - /** - * A `return` in Astro frontmatter — how a page short-circuits into a redirect or a 404 — - * has no enclosing function node in the parser's AST, and this rule asserts one exists: - * `Non-null Assertion Failed: Expected node to have a parent`, a crash rather than a - * finding. It cannot inspect the construct it exists to check, so it is off for `.astro`. - */ + // A `return` in frontmatter (redirect, 404) has no enclosing function in the parser's AST, + // which crashes this rule. "@typescript-eslint/no-misused-promises": "off", - /** - * A keyboard-reachable scroll container is a real pattern: an `overflow` region is not - * focusable by default, so without `tabindex="0"` its content is unreachable by keyboard - * (WCAG 2.2 SC 2.1.1). `role="region"` with an accessible name is how that container is - * named; the rule only allows `tabpanel` out of the box. Scoped to that one role — every - * other non-interactive element keeps the error. - */ + // A keyboard-reachable scroll container needs `tabindex="0"` (WCAG 2.2 SC 2.1.1) and is + // named with `role="region"`; the rule only allows `tabpanel` by default. "astro/jsx-a11y/no-noninteractive-tabindex": [ "error", { roles: ["tabpanel", "region"], tags: [] }, diff --git a/functions/api/calendar/[name].ts b/functions/api/calendar/[name].ts index 5d36b3b..bf005a1 100644 --- a/functions/api/calendar/[name].ts +++ b/functions/api/calendar/[name].ts @@ -1,12 +1,10 @@ -import { upcomingEvents } from "@/ics"; import type { CalendarEvent } from "@/types"; +import { upcomingEvents } from "@/ics"; + /** - * A branded agenda needs the calendar's events as data, not as a Google iframe (D19) — so this - * fetches the public ICS feed server-side and hands the page JSON. - * - * Doing it here rather than in the browser is what makes the feature possible at all: the feed - * sends no CORS headers, so a page cannot read it directly. + * Fetches the public ICS feed server-side and hands the page JSON. The feed sends no CORS + * headers, so a page cannot read it directly. */ /** @@ -33,7 +31,7 @@ const MAX_AGE = 900; /** What `/api/calendar/` answers with, either way. */ interface CalendarResponse { - events?: Array; + events?: CalendarEvent[]; message?: string; } diff --git a/functions/api/form/submit.ts b/functions/api/form/submit.ts index ece7d4d..7c14444 100644 --- a/functions/api/form/submit.ts +++ b/functions/api/form/submit.ts @@ -1,4 +1,5 @@ import type { GenericFormRequest } from "@/types"; + import { res, validateTurnstile } from "@/util"; export const onRequestPost: PagesFunction<{ diff --git a/functions/api/test.ts b/functions/api/test.ts deleted file mode 100644 index 8122a29..0000000 --- a/functions/api/test.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const onRequestGet: PagesFunction = async () => { - return new Response("Testing!"); -}; diff --git a/functions/ics.ts b/functions/ics.ts index 83df11f..c5fb595 100644 --- a/functions/ics.ts +++ b/functions/ics.ts @@ -1,7 +1,7 @@ import type { CalendarEvent } from "@/types"; /** - * Just enough iCalendar to render an agenda from a public Google Calendar feed (D19). Not a + * Just enough iCalendar to render an agenda from a public Google Calendar feed. Not a * general RFC 5545 implementation: it reads `VEVENT`s, resolves their times, expands the * recurrence rules Google actually emits for a team calendar, and stops there. * @@ -17,14 +17,11 @@ const MAX_OCCURRENCES = 5000; * Content lines may be folded across several physical lines, continued by a leading space or * tab (RFC 5545 §3.1). Unfolding has to happen before anything else is read. */ -const unfold = (feed: string): Array => { - const lines: Array = []; +const unfold = (feed: string): string[] => { + const lines: string[] = []; for (const raw of feed.replaceAll("\r\n", "\n").split("\n")) { - if ((raw.startsWith(" ") || raw.startsWith("\t")) && lines.length > 0) { - lines[lines.length - 1] += raw.slice(1); - } else { - lines.push(raw); - } + const previous = raw.startsWith(" ") || raw.startsWith("\t") ? lines.pop() : undefined; + lines.push(previous === undefined ? raw : previous + raw.slice(1)); } return lines; }; @@ -139,7 +136,7 @@ const parseMoment = (line: Line): Moment | undefined => { ); const allDay = hour === undefined; - const zone = line.params["TZID"]; + const zone = line.params.TZID; if (allDay || utc === "Z" || zone === undefined) { // A date, an explicit UTC stamp, and a floating time all read as written. return { allDay, at: wall, wall, zone: undefined }; @@ -168,15 +165,15 @@ const resolver = }; /** Weekday codes in `BYDAY`, indexed to match `Date#getUTCDay`. */ -const DAYS: ReadonlyArray = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"]; +const DAYS: readonly string[] = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"]; /** The weekday a `BYDAY` code names, or -1 — no assertion, so an unknown code stays a miss. */ const dayIndex = (code: string): number => DAYS.indexOf(code); interface Rule { /** `MO`, `TU`, … for weekly; `2TU`, `-1FR`, … for monthly. */ - byDay: Array; - byMonthDay: Array; + byDay: string[]; + byMonthDay: number[]; count?: number | undefined; freq: "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY"; interval: number; @@ -225,8 +222,8 @@ const expand = ( start: number, windowEnd: number, toInstant: (wall: number) => number, -): Array => { - const occurrences: Array = []; +): number[] => { + const occurrences: number[] = []; const first = new Date(start); const time = { hour: first.getUTCHours(), @@ -251,7 +248,7 @@ const expand = ( */ for (let period = 0; period < MAX_OCCURRENCES; period += 1) { const cursor = new Date(start); - let dates: Array; + let dates: number[]; if (rule.freq === "DAILY") { cursor.setUTCDate(cursor.getUTCDate() + period * rule.interval); @@ -310,7 +307,7 @@ const withTime = (date: number, time: TimeOfDay): number => { }; /** The dates a monthly or yearly rule selects inside the month `cursor` starts. */ -const monthDates = (cursor: Date, rule: Rule, first: Date): Array => { +const monthDates = (cursor: Date, rule: Rule, first: Date): number[] => { const year = cursor.getUTCFullYear(); const month = cursor.getUTCMonth(); const length = new Date(Date.UTC(year, month + 1, 0)).getUTCDate(); @@ -331,7 +328,7 @@ const monthDates = (cursor: Date, rule: Rule, first: Date): Array => { return []; } - const matches: Array = []; + const matches: number[] = []; for (let day = 1; day <= length; day += 1) { const at = Date.UTC(year, month, day); if (new Date(at).getUTCDay() === weekday) { @@ -356,7 +353,7 @@ const monthDates = (cursor: Date, rule: Rule, first: Date): Array => { interface RawEvent { description: string; end?: Moment | undefined; - excluded: Array; + excluded: number[]; location: string; /** Set on an event that overrides one occurrence of its series. */ recurrenceId?: number | undefined; @@ -367,8 +364,8 @@ interface RawEvent { uid: string; } -const parseEvents = (feed: string): Array => { - const events: Array = []; +const parseEvents = (feed: string): RawEvent[] => { + const events: RawEvent[] = []; let current: RawEvent | undefined; for (const raw of unfold(feed)) { @@ -454,7 +451,7 @@ const parseEvents = (feed: string): Array => { * @param from Start of the window, epoch milliseconds. * @param days How far ahead to look. */ -export const upcomingEvents = (feed: string, from: number, days: number): Array => { +export const upcomingEvents = (feed: string, from: number, days: number): CalendarEvent[] => { const until = from + days * 24 * 60 * 60 * 1000; const parsed = parseEvents(feed).filter((event) => event.status !== "CANCELLED"); @@ -469,7 +466,7 @@ export const upcomingEvents = (feed: string, from: number, days: number): Array< ), ); - const results: Array = []; + const results: CalendarEvent[] = []; for (const event of parsed) { const { start } = event; diff --git a/functions/tsconfig.json b/functions/tsconfig.json index d5690da..e4cffab 100644 --- a/functions/tsconfig.json +++ b/functions/tsconfig.json @@ -9,9 +9,8 @@ "@/*": ["./*"] }, "noEmit": true, - // Mirrors the root tsconfig's additions over astro/tsconfigs/strictest. The - // Workers runtime cannot use that base (it pulls in DOM and Astro types), so - // the flags are restated rather than extended. + // The Workers runtime cannot use astro/tsconfigs/strictest (DOM and Astro types), so the + // same strictness is restated. "strict": true, "exactOptionalPropertyTypes": true, "noFallthroughCasesInSwitch": true, diff --git a/functions/types.ts b/functions/types.ts index 92a07d7..61a88f6 100644 --- a/functions/types.ts +++ b/functions/types.ts @@ -21,7 +21,7 @@ export interface TurnstileVerificationResponse { export interface TurnstileResponse { challenge_ts: string; - "error-codes": Array; + "error-codes": string[]; hostname: string; success: boolean; } diff --git a/knip.jsonc b/knip.jsonc index f77a5b0..702a357 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -1,32 +1,10 @@ { "$schema": "./node_modules/knip/schema.json", - "entry": ["src/pages/**", "functions/**"], - "project": ["**/*.{ts,tsx,js,mjs,cjs,astro}"], - "ignore": ["legacy/**", "tools/lint/anti-slop/**"], - // fontconfig's cache tool, called by the one-time OG font setup. Present on any machine - // with fontconfig, which is the only kind that can render the cards at all. - "ignoreBinaries": ["fc-cache"], - // Referenced only from CSS — `tailwindcss` via `@import`, three of the four faces via `url()` - // in src/styles/fonts.css — and knip does not follow imports inside compiled extensions. Inter - // is there for a different reason: fonts.css points at the trimmed copies in src/styles/fonts/, - // and the package is what `pnpm assets:fonts` regenerates them from. - "ignoreDependencies": [ - "tailwindcss", - "@fontsource-variable/inter", - "@fontsource-variable/orbitron", - "@fontsource-variable/source-code-pro", - "@fontsource/architects-daughter" - ], - "rules": { - "files": "error", - "dependencies": "error", - "devDependencies": "error", - "optionalPeerDependencies": "error", - "unlisted": "error", - "binaries": "error", - "unresolved": "error", - "exports": "error", - "types": "error", - "duplicates": "error" - } + // Cloudflare Pages Functions are file-routed, which knip has no plugin for; Lighthouse CI + // starts tools/ci/serve.ts from lighthouserc.json, which knip does not read. + "entry": ["functions/**/*.ts", "tools/ci/serve.ts"], + // serve.ts shells out to the system openssl for its certificate; it is not an npm binary. + "ignoreBinaries": ["openssl"], + // tools/lint/anti-slop/shared is vendored upstream source (see its VENDOR.md). + "ignore": ["legacy/**", "tools/lint/anti-slop/shared/**"], } diff --git a/mise.lock b/mise.lock index 0d2ee5d..d8e6d10 100644 --- a/mise.lock +++ b/mise.lock @@ -21,7 +21,7 @@ checksum = "sha256:84fc4e29e5f86022a40bac50a28a1b9275dd1f32eebbf4db499e2573ff822 url = "https://unofficial-builds.nodejs.org/download/release/v26.7.0/node-v26.7.0-linux-x64-musl.tar.gz" [tools.node."platforms.macos-arm64"] -checksum = "sha256:7ee659a7768e641bbfd5360940660b8e8fd0052f77488f365562bac522fc15d4" +checksum = "blake3:4f50b6ddf5cd964cb15043f91460cf2a7620db68f63dfe7d1e6342d354860336" url = "https://nodejs.org/dist/v26.7.0/node-v26.7.0-darwin-arm64.tar.gz" [tools.node."platforms.macos-x64"] @@ -37,39 +37,45 @@ version = "3.2.1" backend = "npm:@puppeteer/browsers" [[tools."npm:chrome-devtools-mcp"]] -version = "1.7.0" +version = "1.8.0" backend = "npm:chrome-devtools-mcp" [[tools.pnpm]] -version = "11.22.0" +version = "11.24.0" backend = "aqua:pnpm/pnpm" [tools.pnpm."platforms.linux-arm64"] -checksum = "sha256:f1426231f365bdfd46c15fa3d1211c3936ee2c4e557afd304f6c66dbf1b2a8bf" -url = "https://github.com/pnpm/pnpm/releases/download/v11.22.0/pnpm-linux-arm64.tar.gz" -url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/515895951" +checksum = "sha256:52f9cd410ef3bbb1cdd79c399b27772fa6d929bc3cf7c80ff3923a81e1c93be3" +url = "https://github.com/pnpm/pnpm/releases/download/v11.24.0/pnpm-linux-arm64.tar.gz" +url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/527747053" +provenance = "github-attestations" [tools.pnpm."platforms.linux-arm64-musl"] -checksum = "sha256:6e53557024be48e59ab8760f9117c0e5c0e0a37ab420f71f302d86216970d28f" -url = "https://github.com/pnpm/pnpm/releases/download/v11.22.0/pnpm-linux-arm64-musl.tar.gz" -url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/515895950" +checksum = "sha256:950160e3d1d039e0bd1f234da5a307fdc9c1d6b9c030683adfcb85f607c7d500" +url = "https://github.com/pnpm/pnpm/releases/download/v11.24.0/pnpm-linux-arm64-musl.tar.gz" +url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/527747056" +provenance = "github-attestations" [tools.pnpm."platforms.linux-x64"] -checksum = "sha256:4c592fa410eb23b69691a9efb9bf21c87c15b3e9d88c6ec8acdd354a0eb8de71" -url = "https://github.com/pnpm/pnpm/releases/download/v11.22.0/pnpm-linux-x64.tar.gz" -url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/515895948" +checksum = "sha256:0a9b76dedb5fe8cc5e7f3a9fd70854181cfb08a487ebaf0c9cfb044dc860936c" +url = "https://github.com/pnpm/pnpm/releases/download/v11.24.0/pnpm-linux-x64.tar.gz" +url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/527747059" +provenance = "github-attestations" [tools.pnpm."platforms.linux-x64-musl"] -checksum = "sha256:45425b06e747cbcaff4940d7b4a55e694645f15f9339dbf7f2601cfb21400545" -url = "https://github.com/pnpm/pnpm/releases/download/v11.22.0/pnpm-linux-x64-musl.tar.gz" -url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/515895945" +checksum = "sha256:2fce00e10729d0c1f3836d499b908697fd3cd0832332651fbcee79c0a7442dcb" +url = "https://github.com/pnpm/pnpm/releases/download/v11.24.0/pnpm-linux-x64-musl.tar.gz" +url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/527747057" +provenance = "github-attestations" [tools.pnpm."platforms.macos-arm64"] -checksum = "sha256:2000dcc8f0718852c2806ba4dca1edaedf18a4a39264474d5a1c8fcee250adfd" -url = "https://github.com/pnpm/pnpm/releases/download/v11.22.0/pnpm-darwin-arm64.tar.gz" -url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/515895947" +checksum = "sha256:c4df428b6c348b3037ba851359215065ce070d711dc96a9750052fdbfbe240d3" +url = "https://github.com/pnpm/pnpm/releases/download/v11.24.0/pnpm-darwin-arm64.tar.gz" +url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/527747061" +provenance = "github-attestations" [tools.pnpm."platforms.windows-x64"] -checksum = "sha256:1de83ad5100acfd2adb5c8bc6f8a428cee9ff4e365deff57c22bfc0cccaa4ddb" -url = "https://github.com/pnpm/pnpm/releases/download/v11.22.0/pnpm-win32-x64.zip" -url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/515895946" +checksum = "sha256:c5ca642230551342a9fffafe508c181f346e2c79fcae2b1cae4c37ceb423481a" +url = "https://github.com/pnpm/pnpm/releases/download/v11.24.0/pnpm-win32-x64.zip" +url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/527747055" +provenance = "github-attestations" diff --git a/mise.toml b/mise.toml index 34b9246..488c30c 100644 --- a/mise.toml +++ b/mise.toml @@ -5,17 +5,18 @@ npm.package_manager = "pnpm" [tools] node = "26.7.0" -pnpm = "11.22.0" +pnpm = "11.24.0" "npm:@puppeteer/browsers" = "3.2.1" -"npm:chrome-devtools-mcp" = "1.7.0" +"npm:chrome-devtools-mcp" = "1.8.0" [env] _.path = ["{{config_root}}/node_modules/.bin"] ASTRO_TELEMETRY_DISABLED = "1" +# Chrome for the chrome-devtools MCP server (.mcp.json). [tasks."install:chrome"] -description = "Install Chrome for Testing" +description = "Install Chrome for Testing into .browser/" depends = ["clean:chrome"] run = """ bin=$(browsers install chrome@stable --path="$MISE_PROJECT_ROOT/.browser" | tail -n1 | awk '{print $2}') @@ -27,47 +28,3 @@ echo "Chrome linked: .browser/chrome/chrome -> $bin" [tasks."clean:chrome"] description = "Remove the installed Chrome for Testing" run = "rm -rf .browser/chrome" - -[tasks.install] -run = "pnpm install" -description = "Install dependencies" - -[tasks.dev] -run = "pnpm run dev" -description = "Start the Astro dev server" - -[tasks.build] -run = "pnpm run build" -description = "Build the static site to dist/" - -[tasks.preview] -run = "pnpm run preview" -description = "Serve the built site locally" - -[tasks.check] -run = "pnpm run check" -description = "Typecheck + lint + format check + knip" - -[tasks.typecheck] -run = "pnpm run typecheck" -description = "astro check" - -[tasks.lint] -run = "pnpm run lint" -description = "oxlint + eslint" - -[tasks."lint:fix"] -run = "pnpm run lint:fix" -description = "oxlint + eslint with autofix" - -[tasks.fmt] -run = "pnpm run fmt" -description = "oxfmt + prettier, writing changes" - -[tasks."fmt:check"] -run = "pnpm run fmt:check" -description = "oxfmt + prettier in check mode" - -[tasks.knip] -run = "pnpm run knip" -description = "Find unused files, exports, and dependencies" diff --git a/package.json b/package.json index f7b6bc0..bae0615 100644 --- a/package.json +++ b/package.json @@ -7,29 +7,20 @@ "dev": "astro dev", "build": "astro build", "preview": "astro preview", - "typecheck": "astro check && tsgo -p functions", - "lint": "oxlint --type-aware && eslint --max-warnings 0 .", - "lint:fix": "oxlint --type-aware --fix && eslint --max-warnings 0 --fix .", - "fmt": "oxfmt --ignore-path .gitignore && prettier --write \"**/*.{astro,md}\"", - "fmt:check": "oxfmt --check --ignore-path .gitignore && prettier --check \"**/*.{astro,md}\"", + "check": "pnpm run typecheck && pnpm run lint && pnpm run fmt:check && pnpm run knip", + "typecheck": "astro check && tsc -p functions && tsc -p tools", + "lint": "eslint --max-warnings 0", + "lint:fix": "eslint --max-warnings 0 --fix", + "fmt": "prettier --write .", + "fmt:check": "prettier --check .", "knip": "knip", - "check": "pnpm run typecheck && pnpm run lint && pnpm run fmt:check && pnpm run knip && pnpm run check:tokens && pnpm run check:content", - "check:tokens": "node tools/checks/cn-font-size-group.mjs", - "check:content": "node tools/checks/content-references.mjs", - "check:meta": "node tools/checks/verify-meta.mjs", - "assets:optimize": "node tools/assets/optimize-sources.mjs", - "assets:fonts": "node tools/assets/font-subset.mjs", - "assets:og": "node tools/assets/og-cards.mjs", - "assets:og-fonts": "node tools/assets/og-fonts.mjs" + "check:meta": "node tools/checks/verify-meta.ts", + "assets:og": "node tools/assets/og-cards.ts" }, "dependencies": { "@astrojs/sitemap": "3.7.3", - "@fontsource-variable/inter": "5.3.0", - "@fontsource-variable/orbitron": "5.3.0", - "@fontsource-variable/source-code-pro": "5.3.0", - "@fontsource/architects-daughter": "5.3.0", "@tailwindcss/vite": "4.3.3", - "astro": "7.2.4", + "astro": "7.2.6", "class-variance-authority": "0.7.1", "cnfast": "0.1.0", "sharp": "0.35.3", @@ -37,28 +28,22 @@ }, "devDependencies": { "@astrojs/check": "0.9.10", - "@cloudflare/workers-types": "5.20260819.1", - "@oxlint/plugins": "1.79.0", + "@cloudflare/workers-types": "5.20260825.1", + "@resvg/resvg-js": "2.6.2", "@tabler/icons": "3.46.0", - "@types/node": "26.2.0", - "@typescript/native-preview": "7.0.0-dev.20260707.2", - "astro-eslint-parser": "3.1.0", - "eslint": "10.8.1", + "@types/node": "26.3.0", + "@typescript-eslint/utils": "8.68.0", + "eslint": "10.9.1", "eslint-plugin-astro": "3.1.0", "eslint-plugin-jsx-a11y": "6.10.2", "eslint-plugin-perfectionist": "5.10.1", "jiti": "2.7.0", "knip": "6.32.2", - "oxfmt": "0.64.0", - "oxlint": "1.79.0", - "oxlint-tsgolint": "7.0.2001", "prettier": "3.9.6", "prettier-plugin-astro": "0.14.1", "prettier-plugin-tailwindcss": "0.8.1", + "satori": "0.33.4", "typescript": "6.0.3", - "typescript-eslint": "8.67.0" - }, - "engines": { - "node": ">=26" + "typescript-eslint": "8.68.0" } } diff --git a/plan/00-overview.md b/plan/00-overview.md index 4e20a2e..e1a6263 100644 --- a/plan/00-overview.md +++ b/plan/00-overview.md @@ -9,13 +9,13 @@ This directory is the complete implementation plan for rewriting scstem.org from - **Never merge layers individually or resequence merges by hand** — that's the point of the stack: merging a PR cascades every unmerged PR below it, bottom-up, and GitHub auto-rebases what remains. The whole stack lands into `staging` in one cascade at Phase 11. - **Review fixes on a lower layer**: commit on that layer's branch, then restack the branches above (`git rebase --update-refs` from the top of the stack, or the gh-stack tooling) and `gs push`/force-push-with-lease the rebased layers. Never let layers drift. - `staging` and `main` are untouched until Phase 11 (cutover). Cloudflare Pages gives every stack branch its own preview URL, kept noindexed by the existing `public/_headers` rules — **the topmost layer's preview is always the full-site preview** (each layer contains everything below it). -- The old site lives in **`legacy/`** during the rewrite (moved there in Phase 01). It is the *reference* for content, copy, URLs, and behavior. **Never import from `legacy/`; never copy components.** Copy *content and intent*, rebuild the implementation. +- The old site lives in **`legacy/`** during the rewrite (moved there in Phase 01). It is the _reference_ for content, copy, URLs, and behavior. **Never import from `legacy/`; never copy components.** Copy _content and intent_, rebuild the implementation. - Before every commit: `pnpm check && pnpm build` must pass. A phase is done when its acceptance criteria are all checked and CI is green. - When a phase makes a decision not covered here, record it in `docs/adr/NNNN-.md` and note it in the phase PR description. ## Objectives (from project owner) -1. Keep the brand (colors, dark look, voice) while making the design *genuinely better* — SEO + accessibility improvements, consistent brand feel, no "AI slop" aesthetic. +1. Keep the brand (colors, dark look, voice) while making the design _genuinely better_ — SEO + accessibility improvements, consistent brand feel, no "AI slop" aesthetic. 2. Content (sponsors, events, FAQ, robots, etc.) becomes markdown/data files — adding a sponsor should take 2 minutes and one small PR. 3. Astro, fully static, minimal dependencies, **zero client-side framework runtime**. 4. SEO across the board, including AI/agent SEO (llms.txt, structured data, semantic HTML). @@ -28,33 +28,33 @@ This directory is the complete implementation plan for rewriting scstem.org from ## Locked decisions (from planning interview) -| # | Decision | -|---|----------| -| D1 | Clean rewrite; `legacy/` in-tree for reference only; staging site used for verification before go-live. | -| D2 | Content via Astro Content Collections (zod-validated markdown). No CMS now; schemas kept flat/simple so a git-backed CMS (e.g. Keystatic) can be added later without restructuring. | -| D3 | Zero framework runtime. `.astro` components + vanilla ` diff --git a/src/layouts/EventLayout.astro b/src/layouts/EventLayout.astro index b5d1983..053974e 100644 --- a/src/layouts/EventLayout.astro +++ b/src/layouts/EventLayout.astro @@ -22,17 +22,14 @@ import BaseLayout from "@/layouts/BaseLayout.astro"; import { type Breadcrumb, breadcrumbs, event as eventJsonLd, faqPage } from "@/lib/jsonld"; /** - * One layout for every seasonal event (D17). `/openhouse` and `/programs/frc/kickoff` used to be - * a fork of the homepage and a fork of the FRC page; both are now this component reading an - * `events` entry, so a new season is an edit to one markdown file. - * - * Nothing here is specific to either event: the hero, the date, the location, the media links and - * the questions are all frontmatter, and the long-form copy is the entry's body. + * One layout for every seasonal event, so a new season is an edit to one markdown file: the hero, + * the date, the location, the media links and the questions are frontmatter, and the long-form + * copy is the entry's body. */ interface Props { event: CollectionEntry<"events">; /** The trail above this page. Home and the page itself are added here. */ - trail?: ReadonlyArray; + trail?: readonly Breadcrumb[]; } /** The hero for an event that carries no photo of its own, so a new event needs no image work. */ @@ -55,7 +52,7 @@ const { event, trail = [] } = Astro.props; const { data } = event; const { Content } = await render(event); -/** `sc2` is the default look and has no `[data-theme]` block (D16). */ +/** `sc2` is the default look and has no `[data-theme]` block. */ const theme = data.program === "sc2" ? undefined : data.program; if (data.heroImage !== undefined && data.heroImageAlt === undefined) { @@ -70,7 +67,7 @@ const hero = const faqEntries = data.faq === undefined ? [] : await getEntries(data.faq); /** - * The two flat arrays the schema pairs by index (D2). A mismatch would silently drop a link's + * The two flat arrays the schema pairs by index. A mismatch would silently drop a link's * label, so it fails the build the schema comment promises it fails on. */ const hintLabels = data.hintLabels ?? []; @@ -185,13 +182,13 @@ const details = [ {media.map(({ icon, label, url }) => (
  • - - {label} - + + {label} +
  • ))} diff --git a/src/layouts/ProgramLayout.astro b/src/layouts/ProgramLayout.astro index 8591f17..2b63a9b 100644 --- a/src/layouts/ProgramLayout.astro +++ b/src/layouts/ProgramLayout.astro @@ -6,9 +6,8 @@ import type { ProgramTheme } from "@/data/site"; import BaseLayout from "@/layouts/BaseLayout.astro"; /** - * A program page is the same site wearing team colors (DESIGN.md §2, D16) — so this is a thin - * wrapper that sets the theme and nothing else. It replaces legacy's duplicated FRC and FLL - * layouts, one of which was still named `FRCLayout` while rendering FLL. + * A program page is the same site wearing team colors (DESIGN.md §2), so this is a thin wrapper + * that sets the theme and nothing else. */ type Props = Omit, "theme"> & { program: ProgramTheme; diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts index 934876a..e8fcec4 100644 --- a/src/lib/analytics.ts +++ b/src/lib/analytics.ts @@ -1,11 +1,7 @@ /** * The DOM contract between `Analytics.astro` and the scripts that report an event it cannot see - * for itself (D21, taxonomy in `docs/analytics.md`). - * - * Most of the taxonomy is link clicks, which the analytics listener recognizes by destination — - * nothing to annotate, nothing to keep in step with a moved route. A form submission has no - * destination and no click, so it is dispatched instead: one custom event, one name, imported by - * both ends rather than typed twice. + * for itself (taxonomy in `docs/analytics.md`). Link clicks are recognized by destination; a form + * submission has no destination, so it dispatches this event instead. */ export const TRACK_EVENT = "sc2:track"; diff --git a/src/lib/cn.ts b/src/lib/cn.ts index 113d966..18cd364 100644 --- a/src/lib/cn.ts +++ b/src/lib/cn.ts @@ -1,22 +1,18 @@ import { createCn } from "cnfast"; /** - * A `cn` configured for this site's type scale. Components import it from `@/lib/cn` rather than - * `cnfast` directly — that is the whole reason this module exists, so it is not a re-export. + * A `cn` configured for this site's type scale; components import it from here, never from + * `cnfast` directly. * - * ## Why the configuration is necessary + * The merge step knows Tailwind's stock `text-*` sizes but not our semantic ones, so without this + * group it treats every `text-*` class as one conflict and keeps only the last — + * `cn("text-primary-foreground", "text-body")` would collapse to `text-body`. Registering the + * type scale as the font-size group keeps a size and a color side by side. * - * Tailwind builds `text-*` utilities from two token namespaces: `--text-*` (font size) and - * `--color-*` (color). The merge step knows Tailwind's stock scale (`text-sm`, `text-lg`) but - * not our semantic names, so without this group it treats every `text-*` class as one conflict - * group and keeps only the last — `cn("text-primary-foreground", "text-body")` would collapse - * to `text-body`. Registering the type scale as the font-size group keeps a size and a color - * side by side, while two sizes or two colors still resolve to the last. - * - * The list is the closed type scale from DESIGN.md §3, plus `text-copy` (per §3's note: `body` - * names both a color and a size, and the color owns `text-body`). **A new size token in - * `global.css` must be added here too** — `tools/checks/cn-font-size-group.mjs` fails the build - * when the two diverge. + * The list is the type scale from DESIGN.md §3 plus `text-copy` (the size utility, since the + * color owns `text-body`). It mirrors the `--text-*` tokens in `src/styles/global.css` by hand: + * a new size token there is a new entry here, and a token missing here loses to any color beside + * it with no error, only a wrong size in the browser. */ export const cn = createCn({ extend: { diff --git a/src/lib/contrast.ts b/src/lib/contrast.ts index dc59cb2..a9edbc9 100644 --- a/src/lib/contrast.ts +++ b/src/lib/contrast.ts @@ -7,7 +7,7 @@ export const AA_NORMAL = 4.5; export const AAA_NORMAL = 7; -const channels = (hex: string): ReadonlyArray => { +const channels = (hex: string): readonly number[] => { const digits = hex.replace("#", ""); return [0, 2, 4].map((offset) => Number.parseInt(digits.slice(offset, offset + 2), 16)); }; diff --git a/src/lib/event-date.ts b/src/lib/event-date.ts index c91cffc..e1635d9 100644 --- a/src/lib/event-date.ts +++ b/src/lib/event-date.ts @@ -1,9 +1,11 @@ +import { site } from "@/data/site"; + /** * Formats an event's date range from `start`/`end` (DESIGN.md §8's data voice is applied at the * call site; this returns plain text). The timestamps are the only source — an event never * carries display prose that can go stale against them. */ -const ZONE = "America/New_York"; +const ZONE = site.location.timeZone; const dayFormat = new Intl.DateTimeFormat("en-US", { weekday: "long", diff --git a/src/lib/events.ts b/src/lib/events.ts index 3ac03d8..50af31d 100644 --- a/src/lib/events.ts +++ b/src/lib/events.ts @@ -15,11 +15,11 @@ const inService = (entry: CollectionEntry<"events">): boolean => !entry.data.hidden && !hasPassed(entry.data.end); /** - * @public Consumed by the sitemap filter (plan/10). + * @public Consumed by `/llms.txt`. * * Every event whose page renders, in no particular order. */ -export const getVisibleEvents = async (): Promise>> => +export const getVisibleEvents = async (): Promise[]> => (await getCollection("events")).filter(inService); /** diff --git a/src/lib/hand.ts b/src/lib/hand.ts index f711b96..8348b61 100644 --- a/src/lib/hand.ts +++ b/src/lib/hand.ts @@ -1,6 +1,6 @@ /** - * The shared contract of the hand-markup register (DESIGN.md §2, §13), so the devices that draw - * by hand (ChalkOval, ChalkUnderline, SketchArrow) do not restate it. + * The shared contract of the hand-markup register (DESIGN.md §2.12–2.15), so the devices that + * draw by hand (ChalkOval, ChalkUnderline, SketchArrow) do not restate it. */ /** Every device ships three path variants; two adjacent instances must not share one. */ diff --git a/src/lib/images.ts b/src/lib/images.ts index 30cf73b..e6cfd17 100644 --- a/src/lib/images.ts +++ b/src/lib/images.ts @@ -1,13 +1,16 @@ +import type { ImageMetadata } from "astro"; + /** - * The re-encode point for photographic `` variants. + * Quality for photographic `` variants. Every raster in `src/assets/` is already a 2560px + * q80 master (`docs/content.md`, "Add a photograph"), so the default quality recompresses a lossy + * file and can emit a variant larger than its source; 70 stays under the source with no visible + * loss (`docs/adr/0005-webp-only-image-variants.md`). * - * Every raster source in `src/assets/` is already a 2560px q80 master - * (`tools/assets/optimize-sources.mjs`), so a variant generated at astro:assets' default quality - * is a *recompression* of an already-lossy file — at the widest step it came out larger than the - * master it was derived from. 70 puts the 1920px variant comfortably under its source with no - * visible loss on photography (`docs/adr/0005-webp-only-variants.md`). - * - * Logos and line art are excluded on purpose: they are small already, and quantizing flat colour - * is what makes a mark look cheap. + * Not for logos and line art: they are small already, and quantizing flat colour makes a mark + * look cheap. */ export const PHOTO_QUALITY = 70; + +/** Distinguishes an imported asset from a path under `public/`. */ +export const isImageMetadata = (image: ImageMetadata | string): image is ImageMetadata => + typeof image !== "string"; diff --git a/src/lib/jsonld.ts b/src/lib/jsonld.ts index cdb2dab..9afea1c 100644 --- a/src/lib/jsonld.ts +++ b/src/lib/jsonld.ts @@ -8,7 +8,7 @@ import { site, socials } from "@/data/site"; */ /** What a schema.org property is allowed to hold — the concrete contract, not `unknown`. */ -type JsonLdValue = string | number | boolean | JsonLdObject | ReadonlyArray; +type JsonLdValue = string | number | boolean | JsonLdObject | readonly JsonLdValue[]; export interface JsonLdObject { readonly "@context"?: string; @@ -16,6 +16,15 @@ export interface JsonLdObject { readonly [key: string]: JsonLdValue | undefined; } +/** The workshop's address, the only one known part by part. */ +const workspaceAddress = { + "@type": "PostalAddress", + streetAddress: site.location.workspace, + addressLocality: site.location.locality, + addressRegion: site.location.region, + addressCountry: site.location.country, +} as const; + /** * The organization, emitted on every page by BaseLayout. `NGO` rather than `Organization`: it is * the specific type for a nonprofit, and specificity is what makes structured data useful. @@ -29,13 +38,7 @@ export const organization: JsonLdObject = { logo: `${site.url}${site.icons.png512}`, email: site.email, description: site.description, - address: { - "@type": "PostalAddress", - streetAddress: site.location.workspace, - addressLocality: site.location.locality, - addressRegion: site.location.region, - addressCountry: site.location.country, - }, + address: workspaceAddress, areaServed: site.location.areaServed, sameAs: socials.map((social) => social.href), }; @@ -48,7 +51,7 @@ export const webSite: JsonLdObject = { url: site.url, }; -/** @public Consumed by the nested pages that arrive in Phases 07-08. */ +/** @public */ export interface Breadcrumb { readonly name: string; /** Site-root-relative, e.g. `/programs/frc`. */ @@ -56,12 +59,12 @@ export interface Breadcrumb { } /** - * @public Consumed by the nested pages that arrive in Phases 07-08. + * @public * * Breadcrumbs for a nested page. Pass the full trail including the current page; the home link * is added automatically, since every trail starts there. */ -export const breadcrumbs = (trail: ReadonlyArray): JsonLdObject => ({ +export const breadcrumbs = (trail: readonly Breadcrumb[]): JsonLdObject => ({ "@context": "https://schema.org", "@type": "BreadcrumbList", itemListElement: [{ name: "Home", path: "/" }, ...trail].map((crumb, index) => ({ @@ -119,15 +122,7 @@ export const event = ({ * Only the workspace's address is known part by part; an off-site event carries one free-text * line, and schema.org takes either for `address`. */ - address: - locationAddress ?? - ({ - "@type": "PostalAddress", - streetAddress: site.location.workspace, - addressLocality: site.location.locality, - addressRegion: site.location.region, - addressCountry: site.location.country, - } as const), + address: locationAddress ?? workspaceAddress, }, organizer: { "@type": "NGO", @@ -152,7 +147,7 @@ export const event = ({ * Google's FAQ documentation permits. */ export const faqPage = ( - entries: ReadonlyArray<{ readonly answer: string; readonly question: string }>, + entries: readonly { readonly answer: string; readonly question: string }[], ): JsonLdObject => ({ "@context": "https://schema.org", "@type": "FAQPage", diff --git a/src/lib/tokens.ts b/src/lib/tokens.ts index 2f9c121..7359d19 100644 --- a/src/lib/tokens.ts +++ b/src/lib/tokens.ts @@ -87,7 +87,7 @@ export const breakpoint = (name: string): string => required(`--breakpoint-${name}`, theme, "@theme"); /** - * A `--color-*` token as a program theme remaps it (DESIGN.md §2, D16). Falls back to the base + * A `--color-*` token as a program theme remaps it (DESIGN.md §2). Falls back to the base * value, so a theme that does not touch a token still resolves. */ export const programColor = (program: string, name: string): string => { @@ -96,7 +96,7 @@ export const programColor = (program: string, name: string): string => { }; /** Program themes declared in the stylesheet, in source order. */ -export const programThemes = (): ReadonlyArray => [ +export const programThemes = (): readonly string[] => [ ...new Set([...css.matchAll(/\[data-theme="([\w-]+)"]/g)].flatMap((match) => match[1] ?? [])), ]; diff --git a/src/pages/404.astro b/src/pages/404.astro index 6515d7a..46dcdbb 100644 --- a/src/pages/404.astro +++ b/src/pages/404.astro @@ -48,10 +48,10 @@ const destinations = [ noindex title="Page not found" > -
    +

    Error 404

    We can't find that page

    -

    +

    It may have moved, or the link may be out of date. Try one of these instead.

    diff --git a/src/pages/about.astro b/src/pages/about.astro index 26b7e9e..846dd88 100644 --- a/src/pages/about.astro +++ b/src/pages/about.astro @@ -18,15 +18,14 @@ import BaseLayout from "@/layouts/BaseLayout.astro"; import { breadcrumbs } from "@/lib/jsonld"; /** - * Copy is legacy's, verbatim (D8). The structure is not: DESIGN.md §5 retires the two fixed - * photo rails that flanked the article in favor of "a single flowing column with photo groupings - * as interleaved timeline sections", and §8's stat band replaces the bulleted count list. + * Copy is legacy's, verbatim. The layout is DESIGN.md §5's single flowing column with photo + * groupings as interleaved timeline sections, and §8's stat band. */ /** - * The two groupings the narrative turns on, and the one part of legacy's fourteen hardcoded - * imports that was ever a decision: the seasons before the shutdown are the FRC origins story, - * and 2023 onward — the first seasons back, and the first FLL teams — is the next generation. + * The two groupings the narrative turns on: the seasons before the shutdown are the FRC origins + * story, and 2023 onward — the first seasons back, and the first FLL teams — is the next + * generation. */ const NEXT_GENERATION_FROM = 2023; @@ -38,7 +37,6 @@ const teamPhotos = [ const origins = teamPhotos.filter((entry) => entry.data.year < NEXT_GENERATION_FROM); const nextGeneration = teamPhotos.filter((entry) => entry.data.year >= NEXT_GENERATION_FROM); -/** From the legacy list, in its order. The practice bots were a nested bullet under the robots. */ const biohazardStats = [ { value: "1", label: "Regional win" }, { value: "3", label: "Workspaces" }, @@ -107,7 +105,7 @@ const biohazardStats = [ -

    +

    This momentum pushed the team forward, but nobody could anticipate what was around the corner.

    @@ -260,7 +258,7 @@ const biohazardStats = [
    -
    +
    What’s next? That’s the story of SC2, but what about you? Whether you are a student, parent, industry professional, diff --git a/src/pages/calendar/[name].astro b/src/pages/calendar/[name].astro index 2a213ca..9a2e5e2 100644 --- a/src/pages/calendar/[name].astro +++ b/src/pages/calendar/[name].astro @@ -10,12 +10,9 @@ import BaseLayout from "@/layouts/BaseLayout.astro"; import { breadcrumbs } from "@/lib/jsonld"; /** - * Legacy embedded Google's own calendar iframe — Google's chrome, Google's fonts, 600 KB of - * third-party script, and nothing at all for a reader without JavaScript. This renders our own - * agenda from JSON that `/api/calendar/[name]` parses out of the public feed server-side (D19). - * - * The link out to Google is not a fallback bolted on for no-JS: it is in the static HTML on - * every render, because "add this to my own calendar" is a thing people come here to do. + * Renders our own agenda from JSON that `/api/calendar/[name]` parses out of the public feed + * server-side. The link out to Google is in the static HTML on every render: it is the no-JS + * path, and "add this to my own calendar" is a thing people come here to do. */ /** * Astro hoists `getStaticPaths` out of the component's scope, so the table it reads lives inside @@ -84,14 +81,12 @@ const embedUrl = `https://calendar.google.com/calendar/embed?src=${encodeURIComp > - { - /* A photographic hero would push the agenda — the only reason anyone opens this page — - below the fold, so the header is compact and keeps only the hero's closing accent rule. */ - } +

    {nav.calendar.label}

    {calendar.heading}

    -

    {calendar.intro}

    +

    {calendar.intro}

    +

    + + Directions to the workspace + +

    -

    Find us online

    -
      - { - socials.map((social) => ( -
    • - -
    • - )) - } -
    +

    Find us online

    +
    diff --git a/src/pages/donate.astro b/src/pages/donate.astro index 6d70fb8..21b7e31 100644 --- a/src/pages/donate.astro +++ b/src/pages/donate.astro @@ -13,12 +13,9 @@ import BaseLayout from "@/layouts/BaseLayout.astro"; import { breadcrumbs } from "@/lib/jsonld"; /** - * Copy is legacy's, verbatim (D8). What changed is the color story: legacy gave each of the five - * cards a different accent — green, blue, orange, red, yellow — and two of them a differently - * colored button. DESIGN.md §2 allows one action accent per view, so every card here is the - * page's `primary` and the ranking is carried by order and content instead. - * - * The addresses and the EIN set in the data voice (§3): they are spec values, not prose. + * Copy is legacy's, verbatim. DESIGN.md §2 allows one action accent per view, so every card is + * the page's `primary` and the ranking is carried by order and content. The addresses and the + * EIN set in the data voice (§3): they are spec values, not prose. */ const mailingAddress = [ "South Central STEM Collective", @@ -55,10 +52,10 @@ const mailingAddress = [

    We accept checks mailed to our workspace or delivered in person at our community events.

    -

    Make checks payable to:

    -

    {site.name}

    -

    Mailing address:

    -
    +

    Make checks payable to:

    +

    {site.name}

    +

    Mailing address:

    +
    {mailingAddress.map((line) => {line})}
    @@ -82,8 +79,8 @@ const mailingAddress = [
    Name
    {site.name}
    -
    EIN
    -
    86-2328794
    +
    EIN
    +
    {site.ein}

    Please reach out if we are not appearing in your fund’s database.

    diff --git a/src/pages/get-involved.astro b/src/pages/get-involved.astro index 3578130..5ae8bcb 100644 --- a/src/pages/get-involved.astro +++ b/src/pages/get-involved.astro @@ -8,10 +8,6 @@ import { site } from "@/data/site"; import BaseLayout from "@/layouts/BaseLayout.astro"; /** - * Legacy's page was the bare Google Form iframe on a patterned background — no heading, no - * metadata beyond a title, and nothing at all if the embed failed to load. The form itself is - * unchanged; what it gains is a page around it and a way out of it. - * * The iframe is `loading="lazy"` with its height reserved, so it costs nothing until it scrolls * into view and shifts nothing when it arrives. */ @@ -50,7 +46,7 @@ const embedded = `${site.urls.getInvolvedForm}?embedded=true`; for its own controls, so the wrapper declares light rather than letting a dark page produce dark-on-white form fields. --> -
    +