diff --git a/.gitignore b/.gitignore index b8a7d65..07ddd6d 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,6 @@ pnpm-debug.log* # typescript *.tsbuildinfo + +# local Chrome for the devtools MCP (mise run chrome:install) +.browser/ diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..e5d4d50 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,13 @@ +{ + "mcpServers": { + "chrome-devtools": { + "command": "chrome-devtools-mcp", + "args": [ + "--user-data-dir=.browser/chrome/user-data", + "--executable-path=.browser/chrome/chrome", + "--usage-statistics=false" + ], + "type": "stdio" + } + } +} diff --git a/.prettierignore b/.prettierignore index 61fd6f4..77c609c 100644 --- a/.prettierignore +++ b/.prettierignore @@ -11,3 +11,4 @@ tools/lint/anti-slop/** plan/** docs/adr/** DESIGN.md +.browser/** diff --git a/brand/Brand Guidelines.pdf b/brand/Brand Guidelines.pdf new file mode 100644 index 0000000..c1840de Binary files /dev/null and b/brand/Brand Guidelines.pdf differ diff --git a/brand/blueprint-shirt/back.png b/brand/blueprint-shirt/back.png new file mode 100644 index 0000000..53b7be9 Binary files /dev/null and b/brand/blueprint-shirt/back.png differ diff --git a/brand/blueprint-shirt/front.png b/brand/blueprint-shirt/front.png new file mode 100644 index 0000000..f84e49d Binary files /dev/null and b/brand/blueprint-shirt/front.png differ diff --git a/brand/logo/black-full-width.svg b/brand/logo/black-full-width.svg new file mode 100644 index 0000000..f6364de --- /dev/null +++ b/brand/logo/black-full-width.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/brand/logo/black.svg b/brand/logo/black.svg new file mode 100644 index 0000000..6068b6c --- /dev/null +++ b/brand/logo/black.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/brand/logo/color-full-width.svg b/brand/logo/color-full-width.svg new file mode 100644 index 0000000..64915a2 --- /dev/null +++ b/brand/logo/color-full-width.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/brand/logo/color.svg b/brand/logo/color.svg new file mode 100644 index 0000000..821252e --- /dev/null +++ b/brand/logo/color.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/functions/api/form/submit.ts b/functions/api/form/submit.ts index 7621a3e..ece7d4d 100644 --- a/functions/api/form/submit.ts +++ b/functions/api/form/submit.ts @@ -7,10 +7,8 @@ export const onRequestPost: PagesFunction<{ }> = async ({ env, request }) => { const data = await request.json(); - let key = env.TS_SECRET_KEY; - if (!key) { - key = "1x0000000000000000000000000000000AA"; - } + // Falls back to Turnstile's documented always-passes test key for local dev without a secret. + const key = env.TS_SECRET_KEY || "1x0000000000000000000000000000000AA"; try { const ts = await validateTurnstile( diff --git a/functions/util.ts b/functions/util.ts index c793763..2c444a9 100644 --- a/functions/util.ts +++ b/functions/util.ts @@ -1,12 +1,5 @@ import type { APIResponse, TurnstileResponse, TurnstileVerificationResponse } from "@/types"; -/** - * Helper function to generate response message to return to the client. Helps standardize - * communication and error logging between API and web frontend. - * @param apiResponse A standardized response object, shared between the API and web frontend - * @param status HTTP status code - * @returns A standard HTTP Response object - */ export const res = (apiResponse: APIResponse, status: number): Response => { if (!apiResponse.success && apiResponse.error) { console.error(apiResponse.error); @@ -20,13 +13,6 @@ export const res = (apiResponse: APIResponse, status: number): Response => { }); }; -/** - * Helper function to verify CF Turnstile challenges - * @param secretKey Turnstile secret key (generated from the Cloudflare Dashboard) - * @param response Response provided by the Turnstile client - * @param ip IP Provided by the Turnstile client - * @returns A boolean indicating whether or not the turnstile verification passed - */ export const validateTurnstile = async ( secretKey: string, response: string, diff --git a/mise.lock b/mise.lock index 3782ebf..0d2ee5d 100644 --- a/mise.lock +++ b/mise.lock @@ -32,6 +32,14 @@ url = "https://nodejs.org/dist/v26.7.0/node-v26.7.0-darwin-x64.tar.gz" checksum = "sha256:d3bd72755141ed32bbcd841228ee81897c8a98d50dfa7dae2179399a0a7c90f8" url = "https://nodejs.org/dist/v26.7.0/node-v26.7.0-win-x64.zip" +[[tools."npm:@puppeteer/browsers"]] +version = "3.2.1" +backend = "npm:@puppeteer/browsers" + +[[tools."npm:chrome-devtools-mcp"]] +version = "1.7.0" +backend = "npm:chrome-devtools-mcp" + [[tools.pnpm]] version = "11.22.0" backend = "aqua:pnpm/pnpm" diff --git a/mise.toml b/mise.toml index 6d23adf..34b9246 100644 --- a/mise.toml +++ b/mise.toml @@ -7,10 +7,27 @@ npm.package_manager = "pnpm" node = "26.7.0" pnpm = "11.22.0" +"npm:@puppeteer/browsers" = "3.2.1" +"npm:chrome-devtools-mcp" = "1.7.0" + [env] _.path = ["{{config_root}}/node_modules/.bin"] ASTRO_TELEMETRY_DISABLED = "1" +[tasks."install:chrome"] +description = "Install Chrome for Testing" +depends = ["clean:chrome"] +run = """ +bin=$(browsers install chrome@stable --path="$MISE_PROJECT_ROOT/.browser" | tail -n1 | awk '{print $2}') +ln -sfn "$bin" "$MISE_PROJECT_ROOT/.browser/chrome/chrome" +mkdir -p "$MISE_PROJECT_ROOT/.browser/chrome/user-data" +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" diff --git a/plan/00-overview.md b/plan/00-overview.md index 692ac59..1386991 100644 --- a/plan/00-overview.md +++ b/plan/00-overview.md @@ -131,6 +131,44 @@ This directory is the complete implementation plan for rewriting scstem.org from - [ ] Anything user-visible respects `DESIGN.md` (from Phase 02 onward). - [ ] Phase file's acceptance checkboxes updated in the phase PR. +## Standing rules from the midpoint review (binding for Phases 06+) + +A simplify + code-review + browser-verification pass ran between Phases 05 and 06 and landed as +the **`overhaul/midpoint-checkin`** layer (stacked on `overhaul/05-app-shell`; **Phase 06 stacks +on it**, not on 05 directly). These are the mistake patterns it found and fixed — reintroducing +one is a review blocker: + +1. **Data drives chrome.** Routes, labels, and nav structure come from `src/data/site.ts` + (`nav.primary` with `surfaces`/`panel`, `nav.calendar`, `nav.cta`, `site.icons`, + `site.ogImage`). Never re-type an href or label a component can reference; never address a + list entry by index. The 404 page throws at build when a route it derives from `nav.primary` + disappears — that failure is the mechanism working, not a bug to suppress. +2. **Tokens have one home.** Any value declared in `src/styles/global.css` `@theme` is read + through `@/lib/tokens` (`color`, `radius`, `duration`, `breakpoint`, `programColor`, + `programThemes`) — never restated as a literal in TS, frontmatter, or a meta tag. Component + ` - diff --git a/src/components/ui/primitives/ChalkOval.astro b/src/components/ui/primitives/ChalkOval.astro index 9326162..7de0186 100644 --- a/src/components/ui/primitives/ChalkOval.astro +++ b/src/components/ui/primitives/ChalkOval.astro @@ -1,6 +1,6 @@ --- import { cn } from "@/lib/cn"; -import { type HandTone, handToneClass } from "@/lib/hand"; +import { handMarkClass, type HandTone, handToneClass, type HandVariant } from "@/lib/hand"; /** * A key word circled by hand (DESIGN.md §2.12) — the "Real ⬭Skills⬭. Real ⬭Robots⬭." treatment. @@ -13,7 +13,7 @@ import { type HandTone, handToneClass } from "@/lib/hand"; * Pill-shaped UI is banned (DESIGN.md §10) — this is the sanctioned way to ring a word. */ interface Props { - variant?: 1 | 2 | 3; + variant?: HandVariant; /** `chalk` is white for photo contexts; `pencil` is the accent, for the page ground. */ tone?: HandTone; class?: string; @@ -40,7 +40,8 @@ const rotations = { 1: "-1.2deg", 2: "0.9deg", 3: "-0.6deg" } as const;

index * 10).map( + (x) => `M${String(x)} 1V${String(x % 50 === 0 ? 9 : 5)}`, + ), +].join(""); + /** * The grid itself is `@utility engineering-grid` in global.css, shared with `pocket-feature`. * Placement only picks the two custom properties it reads, so the linework's geometry and its @@ -44,17 +52,13 @@ const cell = placement === "pocket" ? "26px" : "28px"; preserveAspectRatio="none" viewBox="0 0 240 12" > - - {Array.from({ length: 25 }, (_, index) => index * 10).map((x) => ( - - ))} + ) } diff --git a/src/components/ui/primitives/RulerDivider.astro b/src/components/ui/primitives/RulerDivider.astro index ef5c9a3..3778af0 100644 --- a/src/components/ui/primitives/RulerDivider.astro +++ b/src/components/ui/primitives/RulerDivider.astro @@ -12,7 +12,12 @@ interface Props { const { class: className } = Astro.props; +/** Baseline plus 61 graduated ticks, as one path — one element instead of one per tick. */ const ticks = Array.from({ length: 61 }, (_, index) => index * 4); +const strip = [ + "M0 15H240", + ...ticks.map((x) => `M${String(x)} ${String(x % 20 === 0 ? 4 : x % 8 === 0 ? 8 : 11)}V15`), +].join(""); --- index * 4); role="presentation" viewBox="0 0 240 16" > - - { - ticks.map((x) => ( - - )) - } + + diff --git a/src/components/ui/primitives/SketchArrow.astro b/src/components/ui/primitives/SketchArrow.astro index 4671b9f..6cfe794 100644 --- a/src/components/ui/primitives/SketchArrow.astro +++ b/src/components/ui/primitives/SketchArrow.astro @@ -1,6 +1,6 @@ --- import { cn } from "@/lib/cn"; -import { type HandTone, handToneClass } from "@/lib/hand"; +import { handMarkClass, type HandTone, handToneClass, type HandVariant } from "@/lib/hand"; /** * A hand-drawn curved arrow (DESIGN.md §2.15). **One per page.** @@ -12,7 +12,7 @@ import { type HandTone, handToneClass } from "@/lib/hand"; * Three variants; adjacent instances must differ. */ interface Props { - variant?: 1 | 2 | 3; + variant?: HandVariant; /** Which way the arrow sweeps. */ direction?: "down-right" | "down-left" | "right"; tone?: HandTone; @@ -37,20 +37,17 @@ const paths = { */ const rotations = { 1: "0deg", 2: "-3deg", 3: "2deg" } as const; -const directionRotation = { "down-right": "0deg", "down-left": "0deg", right: "-45deg" } as const; - -const mirror = { "down-right": "", "down-left": "-scale-x-100", right: "" } as const; +const directions = { + "down-right": { rotate: "0deg", mirror: "" }, + "down-left": { rotate: "0deg", mirror: "-scale-x-100" }, + right: { rotate: "-45deg", mirror: "" }, +} as const; --- , "theme"> & { + program: ProgramTheme; +}; -const { title, description, program, ogImage, canonical } = Astro.props; +const { program, ...rest } = Astro.props; --- - + diff --git a/src/lib/cn.ts b/src/lib/cn.ts index 30197dc..113d966 100644 --- a/src/lib/cn.ts +++ b/src/lib/cn.ts @@ -8,18 +8,15 @@ import { createCn } from "cnfast"; * * 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 it treated every `text-*` class as one conflict group and kept - * only the last one. - * - * The damage was silent and real: `cn("text-primary-foreground", "text-body")` collapsed to - * `text-body`, which rendered every primary button's label in body gray on Safety Yellow — - * about 1.3:1. Registering the type scale as the font-size group fixes it structurally, so - * `cn("text-small", "text-muted")` now keeps both, while two sizes or two colors still - * resolve to the last. + * 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**, or it will silently lose to any color beside it. + * `global.css` must be added here too** — `tools/checks/cn-font-size-group.mjs` fails the build + * when the two diverge. */ export const cn = createCn({ extend: { diff --git a/src/lib/event-date.ts b/src/lib/event-date.ts index 0ef5d6e..2feaf3d 100644 --- a/src/lib/event-date.ts +++ b/src/lib/event-date.ts @@ -1,10 +1,7 @@ /** * 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). - * - * Events used to carry a `displayDate` string alongside the timestamps, which meant every event - * stated its date twice and rescheduling one left stale prose behind. Timestamps are the only - * source now. + * 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"; diff --git a/src/lib/hand.ts b/src/lib/hand.ts index 4695991..f711b96 100644 --- a/src/lib/hand.ts +++ b/src/lib/hand.ts @@ -1,10 +1,16 @@ /** - * The hand-markup register's two tones (DESIGN.md §2, §13). `chalk` is white, for marks over - * photography; `pencil` is the grease-pencil accent, for marks on the page ground. - * - * Shared so the mapping is not restated in every device that draws by hand. + * 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. */ + +/** Every device ships three path variants; two adjacent instances must not share one. */ +export type HandVariant = 1 | 2 | 3; + +/** `chalk` is white, for marks over photography; `pencil` is the grease-pencil accent. */ export type HandTone = "chalk" | "pencil"; export const handToneClass = (tone: HandTone): string => tone === "chalk" ? "text-foreground" : "text-primary"; + +/** Every hand-drawn SVG: draw-on entrance, the shared stroke recipe, and no hit target. */ +export const handMarkClass = "draw-on hand-stroke pointer-events-none"; diff --git a/src/lib/jsonld.ts b/src/lib/jsonld.ts index a1a1a11..d9875c7 100644 --- a/src/lib/jsonld.ts +++ b/src/lib/jsonld.ts @@ -26,7 +26,7 @@ export const organization: JsonLdObject = { name: site.name, alternateName: site.shortName, url: site.url, - logo: `${site.url}/icon-512.png`, + logo: `${site.url}${site.icons.png512}`, email: site.email, description: site.description, address: { diff --git a/src/lib/tokens.ts b/src/lib/tokens.ts index 30572df..2f9c121 100644 --- a/src/lib/tokens.ts +++ b/src/lib/tokens.ts @@ -1,16 +1,17 @@ // `?raw` inlines the stylesheet's source at build time. `readFileSync` cannot be used here: // the page is bundled before it is prerendered, so a path relative to this module no longer // points at `src/`. -import css from "@/styles/global.css?raw"; +import source from "@/styles/global.css?raw"; + +// Comments are stripped before parsing: a `[data-theme="light"]` mentioned in prose would +// otherwise register as a program theme, and a commented-out declaration would read as live. +const css = source.replaceAll(/\/\*[\s\S]*?\*\//g, ""); /** * Reads the design tokens out of `src/styles/global.css` at build time, so anything verifying - * them verifies the values the site actually ships. - * - * `/styleguide` used to restate every hex as a TS literal, which meant its contrast gate - * compared one copy of the palette against another: editing a token in the stylesheet alone - * left the build green against the stale value. The stylesheet stays authoritative - * (DESIGN.md §11) and this module is the only reader. + * them verifies the values the site actually ships. The stylesheet is authoritative + * (DESIGN.md §11) and this module is its only reader — a consumer restating a value as a + * literal can drift from the stylesheet with a green build. */ /** Body of the first `{ … }` following `header`, or undefined when the header is absent. */ @@ -53,8 +54,16 @@ const declarations = (body: string): ReadonlyMap => { const theme = declarations(blockAfter("@theme") ?? ""); -const themeOverrides = (program: string): ReadonlyMap => - declarations(blockAfter(`[data-theme="${program}"]`) ?? ""); +const overridesByProgram = new Map>(); + +const themeOverrides = (program: string): ReadonlyMap => { + let overrides = overridesByProgram.get(program); + if (overrides === undefined) { + overrides = declarations(blockAfter(`[data-theme="${program}"]`) ?? ""); + overridesByProgram.set(program, overrides); + } + return overrides; +}; const required = (name: string, from: ReadonlyMap, where: string): string => { const value = from.get(name); @@ -70,6 +79,13 @@ export const color = (name: string): string => required(`--color-${name}`, theme /** A `--radius-*` token from the base `@theme` block. */ export const radius = (name: string): string => required(`--radius-${name}`, theme, "@theme"); +/** A `--duration-*` token from the base `@theme` block. */ +export const duration = (name: string): string => required(`--duration-${name}`, theme, "@theme"); + +/** A `--breakpoint-*` token from the base `@theme` block. */ +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 * value, so a theme that does not touch a token still resolves. diff --git a/src/pages/404.astro b/src/pages/404.astro index d4457f9..f2242cb 100644 --- a/src/pages/404.astro +++ b/src/pages/404.astro @@ -12,16 +12,35 @@ import BaseLayout from "@/layouts/BaseLayout.astro"; * Noindexed, and useful rather than cute: the links are the four places people were most likely * headed. */ + +/** Titles come from the nav inventory; a destination it no longer carries fails the build. */ +const navTitle = (href: string): string => { + const link = nav.primary.find((entry) => entry.href === href); + if (link === undefined) { + throw new Error(`404 destinations: ${href} is not in nav.primary`); + } + return link.label; +}; + const destinations = [ - { href: "/programs", description: "FIRST LEGO League and FIRST Robotics Competition." }, - { href: "/about", description: "Who we are and what we have built since 2013." }, - { href: "/sponsors", description: "The businesses that make this possible." }, - { href: "/contact", description: "Ask us anything — we answer." }, -].map((destination) => ({ - ...destination, - /** Titles come from the nav inventory, so a renamed route renames itself here too. */ - title: nav.primary.find((link) => link.href === destination.href)?.label ?? "Contact", -})); + { + href: "/programs", + title: navTitle("/programs"), + description: "FIRST LEGO League and FIRST Robotics Competition.", + }, + { + href: "/about", + title: navTitle("/about"), + description: "Who we are and what we have built since 2013.", + }, + { + href: "/sponsors", + title: navTitle("/sponsors"), + description: "The businesses that make this possible.", + }, + /** Contact is not a nav route, so its title is its own. */ + { href: "/contact", title: "Contact", description: "Ask us anything — we answer." }, +]; --- - + diff --git a/src/pages/site.webmanifest.ts b/src/pages/site.webmanifest.ts index 8efb185..99c7dda 100644 --- a/src/pages/site.webmanifest.ts +++ b/src/pages/site.webmanifest.ts @@ -1,13 +1,11 @@ import type { APIRoute } from "astro"; import { site } from "@/data/site"; +import { color } from "@/lib/tokens"; /** - * The web app manifest, built from `src/data/site.ts`. - * - * It used to be a static `public/site.webmanifest` restating the org name, a third variant of the - * description, and the two brand colours as literals — none of which could follow a rename or a - * token change. Emitting it from the same constants everything else reads means it cannot drift. + * The web app manifest, emitted from the same constants and tokens everything else reads, so a + * rename or a token change cannot leave it behind. */ export const GET: APIRoute = () => new Response( @@ -18,12 +16,12 @@ export const GET: APIRoute = () => description: site.description, start_url: "/", display: "standalone", - background_color: site.chrome.backgroundColor, - theme_color: site.chrome.themeColor, + background_color: color("background"), + theme_color: color("primary"), icons: [ - { src: "/icon.svg", type: "image/svg+xml", sizes: "any" }, - { src: "/icon-192.png", type: "image/png", sizes: "192x192" }, - { src: "/icon-512.png", type: "image/png", sizes: "512x512" }, + { src: site.icons.svg, type: "image/svg+xml", sizes: "any" }, + { src: site.icons.png192, type: "image/png", sizes: "192x192" }, + { src: site.icons.png512, type: "image/png", sizes: "512x512" }, /** * A separate, padded render. The unpadded mark spans ~88% of its box, so a launcher's * circular mask — which keeps only the central 80%-diameter circle — clipped the outer @@ -32,7 +30,7 @@ export const GET: APIRoute = () => * of cropping. */ { - src: "/icon-maskable-512.png", + src: site.icons.maskable512, type: "image/png", sizes: "512x512", purpose: "maskable", diff --git a/src/pages/styleguide.astro b/src/pages/styleguide.astro index 0c3c403..f97203f 100644 --- a/src/pages/styleguide.astro +++ b/src/pages/styleguide.astro @@ -2,7 +2,9 @@ import Accordion from "@/components/ui/primitives/Accordion.astro"; import AccordionItem from "@/components/ui/primitives/AccordionItem.astro"; import Badge from "@/components/ui/primitives/Badge.astro"; +import { badgeToneNames } from "@/components/ui/primitives/Badge.variants"; import Button from "@/components/ui/primitives/Button.astro"; +import { buttonVariantNames } from "@/components/ui/primitives/Button.variants"; import Callout from "@/components/ui/primitives/Callout.astro"; import Card from "@/components/ui/primitives/Card.astro"; import CardContent from "@/components/ui/primitives/CardContent.astro"; @@ -27,9 +29,10 @@ import Skeleton from "@/components/ui/primitives/Skeleton.astro"; import SketchArrow from "@/components/ui/primitives/SketchArrow.astro"; import Textarea from "@/components/ui/primitives/Textarea.astro"; import TitleBlock from "@/components/ui/primitives/TitleBlock.astro"; +import { site } from "@/data/site"; import BaseLayout from "@/layouts/BaseLayout.astro"; import { AA_NORMAL, AAA_NORMAL, blend, contrastRatio, formatRatio } from "@/lib/contrast"; -import { color, programColor, programThemes, radius, swipeAlpha } from "@/lib/tokens"; +import { color, duration, programColor, programThemes, radius, swipeAlpha } from "@/lib/tokens"; /** * The proof page for DESIGN.md: every token, ramp, and motif rendered once, with computed @@ -50,11 +53,6 @@ interface TextToken { use: string; } -/** - * Values are never written here — `color()` reads them out of `src/styles/global.css`, so a - * token edited in the stylesheet alone still re-verifies, and a renamed token fails the build - * instead of silently checking a stale copy. - */ const textTokens: readonly TextToken[] = [ { name: "foreground", floor: AAA_NORMAL, use: "Headings, nav, emphasis" }, { name: "body", floor: AAA_NORMAL, use: "All reading copy" }, @@ -190,28 +188,28 @@ const radii = [ { token: "lg", use: "Pockets, panels, framed media" }, ].map((entry) => ({ ...entry, px: radius(entry.token) })); -const buttonVariantNames = ["default", "secondary", "outline", "ghost", "link"] as const; +const durations = [ + { token: "micro", use: "hover, focus" }, + { token: "ui", use: "menus, accordions" }, + { token: "entrance", use: "scroll-in entrances" }, +].map((entry) => ({ ...entry, value: duration(entry.token) })); + const buttonSizes = ["sm", "md", "lg"] as const; -const badgeTones = ["default", "muted", "info", "platinum", "gold", "silver", "bronze"] as const; /** * DESIGN.md §2 tabulates each accent's text-on-dark ratio. Rendering the measured value here * keeps that table checkable: accents are AA-scoped (links, focus, key words — never long copy), - * so a value below 7:1 is in-bounds, but a value below 4.5:1 fails the gate above. + * so a value below 7:1 is in-bounds, but a value below 4.5:1 fails the gate above. The list is + * read from the stylesheet like the gate's, so a new program theme renders here automatically. */ const themes = [ - { name: "Default (SC2 — Safety Yellow)", theme: undefined, accent: color("primary-bright") }, - { - name: 'data-theme="frc" (Hazard Green)', - theme: "frc", - accent: programColor("frc", "primary-bright"), - }, - { - name: 'data-theme="fll" (Danger Orange)', - theme: "fll", - accent: programColor("fll", "primary-bright"), - }, -] as const; + { name: "Default (SC2)", theme: undefined, accent: color("primary-bright") }, + ...programThemes().map((program) => ({ + name: `data-theme="${program}"`, + theme: program, + accent: programColor(program, "primary-bright"), + })), +]; --- -

Surfaces

- The page is raised material; cards are pockets machined into it. Cards are + The page is raised material; cards are pockets machined into it. Cards are{" "} darker than the page — the opposite of the default elevated-card dark UI.

@@ -261,7 +258,7 @@ const themes = [

V2 — machined pocket

- The standard card: card fill, 1px pocket border, + The standard card: card fill, 1px pocket border,{" "} radius-lg, inset edge physics.

@@ -285,7 +282,6 @@ const themes = [ -

Text ramp & contrast

@@ -333,7 +329,6 @@ const themes = [ -

Accents — the fill-vs-text law

@@ -375,7 +370,6 @@ const themes = [ -

Typography

@@ -400,7 +394,6 @@ const themes = [ -

Radius & motion

@@ -410,38 +403,35 @@ const themes = [

{ - radii.map((radius) => ( + radii.map((entry) => (
-

radius-{radius.token}

-

{radius.px}

-

{radius.use}

+

radius-{entry.token}

+

{entry.px}

+

{entry.use}

)) }
-
-
duration-micro
-
150ms — hover, focus
-
-
-
duration-ui
-
250ms — menus, accordions
-
-
-
duration-entrance
-
500ms — scroll-in entrances
-
+ { + durations.map((entry) => ( +
+
duration-{entry.token}
+
+ {entry.value} — {entry.use} +
+
+ )) + }
-

Motifs

@@ -482,8 +472,10 @@ const themes = [

Swipe at {SWIPE_ALPHA * 100}% alpha · white on {swipeOnGround} ={" "} - {formatRatio(contrastRatio("#FAFAFA", swipeOnGround))} · on {swipeOnCard} ={" "} - {formatRatio(contrastRatio("#FAFAFA", swipeOnCard))} + {formatRatio(contrastRatio(color("foreground"), swipeOnGround))} · on {swipeOnCard} ={ + " " + } + {formatRatio(contrastRatio(color("foreground"), swipeOnCard))}

@@ -502,16 +494,15 @@ const themes = [
-

Primitives

@@ -558,7 +549,7 @@ const themes = [

Badge

- {badgeTones.map((tone) => {tone})} + {badgeToneNames.map((tone) => {tone})} Ages 9–16
@@ -598,12 +589,20 @@ const themes = [
- +

- Yes — it is a native <details> element. The shared - name attribute is what closes the others. + Yes — it is a native <details> element. The shared name attribute is what closes the others.

@@ -686,7 +686,6 @@ const themes = [ -

Program themes

diff --git a/src/styles/global.css b/src/styles/global.css index 10fb8d6..df824df 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -99,6 +99,9 @@ --ease-toggle: cubic-bezier(0.4, 0, 0.2, 1); /* ease-in-out */ /* --- Layout ----------------------------------------------------------------------------- */ + /* Tailwind's default md, declared so it is emitted as a variable: the utilities below and the + Navbar sheet script read it, so the mobile/desktop boundary has one definition. */ + --breakpoint-md: 48rem; --breakpoint-3xl: 120rem; } @@ -301,7 +304,7 @@ /* Feature pocket V2+V3, "drawing pocket" — the grid is *inside* the pocket, and only on screens wide enough for it to read. Dense card grids and mobile stay plain V2. */ @utility pocket-feature { - @media (width >= 48rem) { + @media (width >= theme(--breakpoint-md)) { @apply engineering-grid; } } @@ -414,7 +417,7 @@ &::before { background: radial-gradient( ellipse 80% 60% at 50% 0%, - rgb(250 250 250 / 0.03), + color-mix(in srgb, var(--color-foreground) 3%, transparent), transparent 70% ); } @@ -424,7 +427,7 @@ @utility section-y { padding-block: 4rem; - @media (width >= 48rem) { + @media (width >= theme(--breakpoint-md)) { padding-block: 6rem; } } @@ -463,21 +466,38 @@ max-width: 70ch; } +/** + * The carousel's snap track (Carousel.astro). Slides arrive through a slot, so their sizing + * cannot live in the component's scoped style; defining it here also lets the md boundary read + * the breakpoint token. `--carousel-item-basis` is set inline by the component. + */ +@utility carousel-track { + & > * { + scroll-snap-align: start; + flex: 0 0 100%; + } + + @media (width >= theme(--breakpoint-md)) { + & > * { + flex-basis: var(--carousel-item-basis, 45%); + } + } +} + /* The page container (DESIGN.md §5). One definition, so pages cannot reinvent the gutters. */ @utility container-page { margin-inline: auto; max-width: var(--container-6xl); padding-inline: 1rem; - @media (width >= 48rem) { + @media (width >= theme(--breakpoint-md)) { padding-inline: 1.5rem; } } /** * Chrome links (DESIGN.md §8): "UI links may drop underline at rest but underline on - * hover/focus". The underline half was missing everywhere — every chrome link wrote - * `no-underline` and stopped there, so a link was distinguished by color alone on hover. + * hover/focus". Both halves live here so a call site cannot ship one without the other. */ @utility ui-link { color: var(--color-foreground); diff --git a/tools/assets/optimize-sources.mjs b/tools/assets/optimize-sources.mjs index cd1ba12..f380c2b 100644 --- a/tools/assets/optimize-sources.mjs +++ b/tools/assets/optimize-sources.mjs @@ -56,29 +56,35 @@ let savedBytes = 0; for (const path of (await walk(ROOT)).toSorted()) { const before = (await stat(path)).size; - const image = sharp(path); - const { width, height, format } = await image.metadata(); - if (width === undefined || height === undefined) { + const pipeline = sharp(path).rotate(); + const meta = await pipeline.metadata(); + if (meta.width === undefined || meta.height === undefined) { continue; } - const tooLarge = Math.max(width, height) > MAX_DIMENSION; - if (!tooLarge && before < SIZE_THRESHOLD) { + // metadata() reports pre-EXIF-rotation dimensions; rotate() writes the rotated image, so an + // orientation of 5-8 swaps the axes of everything logged below. + const swapped = meta.orientation !== undefined && meta.orientation >= 5; + const width = swapped ? meta.height : meta.width; + const height = swapped ? meta.width : meta.height; + + const scale = Math.min(1, MAX_DIMENSION / Math.max(width, height)); + if (scale === 1 && before < SIZE_THRESHOLD) { continue; } - const resized = tooLarge - ? sharp(path) - .rotate() - .resize({ - width: width >= height ? MAX_DIMENSION : undefined, - height: height > width ? MAX_DIMENSION : undefined, + const resized = + scale === 1 + ? pipeline + : pipeline.resize({ + width: MAX_DIMENSION, + height: MAX_DIMENSION, + fit: "inside", withoutEnlargement: true, - }) - : sharp(path).rotate(); + }); const encoded = - format === "png" + meta.format === "png" ? await resized.png({ quality: QUALITY, compressionLevel: 9 }).toBuffer() : await resized.webp({ quality: QUALITY }).toBuffer(); @@ -87,10 +93,10 @@ for (const path of (await walk(ROOT)).toSorted()) { continue; } - const meta = await sharp(encoded).metadata(); console.log( `${write ? "write" : "would"} ${path} ${String(width)}x${String(height)} ${fmt(before)}` + - ` -> ${String(meta.width ?? 0)}x${String(meta.height ?? 0)} ${fmt(encoded.length)}`, + ` -> ${String(Math.round(width * scale))}x${String(Math.round(height * scale))}` + + ` ${fmt(encoded.length)}`, ); if (write) { await writeFile(path, encoded); diff --git a/tools/checks/cn-font-size-group.mjs b/tools/checks/cn-font-size-group.mjs index 3c49543..75176e4 100644 --- a/tools/checks/cn-font-size-group.mjs +++ b/tools/checks/cn-font-size-group.mjs @@ -1,9 +1,8 @@ /** * `cn`'s `font-size` class group is a hand-maintained mirror of DESIGN.md §3's type scale. A * token missing from it silently loses to any color beside it — the failure only shows up as a - * wrong size in a browser, which is exactly how it was found the first time. - * - * This makes the divergence a `pnpm check` failure instead. Run from the repo root. + * wrong size in a browser. This makes the divergence a `pnpm check` failure instead. Run from + * the repo root. */ import { readFileSync } from "node:fs"; diff --git a/tools/checks/content-references.mjs b/tools/checks/content-references.mjs index a6d6c19..ba9488b 100644 --- a/tools/checks/content-references.mjs +++ b/tools/checks/content-references.mjs @@ -22,16 +22,6 @@ const COLLECTION_DIRS = { faq: "src/content/faq", }; -const ids = async (collection) => { - const dir = COLLECTION_DIRS[collection]; - const entries = await readdir(dir, { withFileTypes: true }); - return new Set( - entries - .filter((entry) => entry.isFile() && extname(entry.name) === ".md") - .map((entry) => basename(entry.name, ".md")), - ); -}; - /** The frontmatter block, as raw text. */ const frontmatter = (source) => { const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(source); @@ -57,44 +47,51 @@ const listField = (block, field) => { return values; }; -/** Every collection's ids, and every entry's frontmatter, read once up front. */ -const knownIds = new Map( - await Promise.all(Object.keys(COLLECTION_DIRS).map(async (name) => [name, await ids(name)])), +const references = Object.entries(REFERENCE_FIELDS).flatMap(([collection, fields]) => + Object.entries(fields).map(([field, target]) => ({ collection, field, target })), ); -const entriesByCollection = new Map( +const sources = new Set(references.map(({ collection }) => collection)); +const targets = new Set(references.map(({ target }) => target)); + +const loaded = new Map( await Promise.all( - Object.entries(REFERENCE_FIELDS).map(async ([collection]) => { + [...sources.union(targets)].map(async (collection) => { const dir = COLLECTION_DIRS[collection]; - const files = (await readdir(dir)).filter((name) => extname(name) === ".md"); - const entries = await Promise.all( - files.map(async (file) => ({ - id: basename(file, ".md"), - block: frontmatter(await readFile(join(dir, file), "utf8")), - })), - ); - return [collection, entries]; + const files = (await readdir(dir, { withFileTypes: true })) + .filter((entry) => entry.isFile() && extname(entry.name) === ".md") + .map((entry) => entry.name); + return [ + collection, + { + ids: new Set(files.map((file) => basename(file, ".md"))), + // Frontmatter is only read where a reference field can appear. + entries: sources.has(collection) + ? await Promise.all( + files.map(async (file) => ({ + id: basename(file, ".md"), + block: frontmatter(await readFile(join(dir, file), "utf8")), + })), + ) + : [], + }, + ]; }), ), ); -const failures = []; - -for (const [collection, fields] of Object.entries(REFERENCE_FIELDS)) { - for (const [field, target] of Object.entries(fields)) { - const known = knownIds.get(target) ?? new Set(); - for (const entry of entriesByCollection.get(collection) ?? []) { - for (const id of listField(entry.block, field)) { - if (!known.has(id)) { - failures.push( - `${collection}/${entry.id}: ${field} references "${id}", ` + - `which is not an entry in the ${target} collection`, - ); - } - } - } - } -} +const failures = references.flatMap(({ collection, field, target }) => { + const known = loaded.get(target).ids; + return loaded.get(collection).entries.flatMap((entry) => + listField(entry.block, field) + .filter((id) => !known.has(id)) + .map( + (id) => + `${collection}/${entry.id}: ${field} references "${id}", ` + + `which is not an entry in the ${target} collection`, + ), + ); +}); if (failures.length > 0) { console.error("tools/checks/content-references: dangling content references"); diff --git a/tools/checks/stale-link-ignores.mjs b/tools/checks/stale-link-ignores.mjs index 1ba974e..9d32604 100644 --- a/tools/checks/stale-link-ignores.mjs +++ b/tools/checks/stale-link-ignores.mjs @@ -23,9 +23,8 @@ const routes = readFileSync(IGNORE_FILE, "utf8") .map((pattern) => { const route = /^\/dist(?\/.*?)\$$/u.exec(pattern)?.groups?.route; /** - * An entry this script cannot parse used to be dropped silently — invisible to the staleness - * check while lychee still applied it as a regex, which is exactly the permanent blind spot - * the guard exists to prevent. A malformed entry is a hard failure instead. + * A malformed entry is a hard failure: lychee would still apply it as a regex while this + * staleness check could not see it — the permanent blind spot the guard exists to prevent. */ if (route === undefined) { console.error(