diff --git a/DESIGN.md b/DESIGN.md
index 6485e4d..d10ceca 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -44,21 +44,25 @@ The page is the raised material; cards are **pockets machined into it**. (This i
| Token | Value | Contrast on `#262626` | Use |
|---|---|---|---|
| `foreground` | `#FAFAFA` | ≈14.5:1 (AAA) | Headings, nav, emphasis (brand White) |
-| `body` | `#D4D4D4` | ≈11.5:1 (AAA) | **All reading copy** |
+| `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.
### Accents — the fill-vs-text law
+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.
| Accent | Fill (brand hex / label color) | Text-on-dark (≈ contrast) | Role |
|---|---|---|---|
-| Safety Yellow | `#FACC15` / `#171717` | `#FACC15` (9.9:1 — bright enough to be both) | **Action**: CTAs, links, focus ring, key-word emphasis. The default `primary`. |
-| Science Blue | `#3B82F6` / `#FAFAFA` | `#60A5FA` (6.0: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. |
+| 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` (8.0:1) | FLL 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)
@@ -100,7 +104,7 @@ The engineer's markup drawn *over* the sheet — this is what keeps the machined
**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, ±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`.
+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.
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").
diff --git a/astro.config.ts b/astro.config.ts
index 6f6812c..99e231e 100644
--- a/astro.config.ts
+++ b/astro.config.ts
@@ -3,7 +3,9 @@ import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "astro/config";
export default defineConfig({
- integrations: [sitemap()],
+ // /styleguide is a noindex review artifact; a URL that is both in the sitemap and
+ // noindex is a "Submitted URL marked 'noindex'" error in Search Console.
+ integrations: [sitemap({ filter: (page) => !page.includes("/styleguide") })],
outDir: "./dist",
output: "static",
site: "https://scstem.org",
diff --git a/eslint.config.ts b/eslint.config.ts
index 3f28c8a..5f9d018 100644
--- a/eslint.config.ts
+++ b/eslint.config.ts
@@ -30,9 +30,9 @@ export default defineConfig(
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 two bans
- // AGENTS.md leads with are off in the file type the site is built from.
+ // 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.
"no-restricted-imports": [
"error",
{
@@ -46,6 +46,18 @@ export default defineConfig(
],
},
],
+
+ /**
+ * 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.
+ */
+ "@typescript-eslint/no-unsafe-return": "off",
},
},
);
diff --git a/knip.jsonc b/knip.jsonc
index 4622865..fde84c7 100644
--- a/knip.jsonc
+++ b/knip.jsonc
@@ -1,9 +1,21 @@
{
"$schema": "./node_modules/knip/schema.json",
- "entry": ["src/pages/**", "functions/**"],
+ "entry": [
+ "src/pages/**",
+ "functions/**",
+ // Exports the font URLs BaseLayout preloads; wired up in Phase 05.
+ "src/styles/fonts.ts"
+ ],
"project": ["**/*.{ts,tsx,js,mjs,cjs,astro}"],
"ignore": ["legacy/**", "tools/lint/anti-slop/**"],
- "ignoreDependencies": ["tailwindcss"],
+ // Referenced only from CSS — `tailwindcss` via `@import`, these two faces via `url()`
+ // in src/styles/fonts.css — and knip does not follow imports inside compiled extensions.
+ // Inter and Orbitron are absent on purpose: fonts.ts imports them, so knip sees those.
+ "ignoreDependencies": [
+ "tailwindcss",
+ "@fontsource-variable/source-code-pro",
+ "@fontsource/architects-daughter"
+ ],
"rules": {
"files": "error",
"dependencies": "error",
diff --git a/package.json b/package.json
index 8f73433..1d9a43d 100644
--- a/package.json
+++ b/package.json
@@ -17,8 +17,13 @@
},
"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",
+ "cnfast": "0.1.0",
"tailwindcss": "4.3.3"
},
"devDependencies": {
diff --git a/plan/02-design-system.md b/plan/02-design-system.md
index e580a8a..8b8d0e6 100644
--- a/plan/02-design-system.md
+++ b/plan/02-design-system.md
@@ -36,7 +36,7 @@
### 2b. Brand assets
-- Copy the official logo assets from `legacy/public/image/svg/` (`logo-color-full.svg`, `logo-color.svg`, plus the black/white variants) into `src/assets/brand/`. These are the only sanctioned marks (DESIGN.md §7): full-width lockup in desktop chrome, square mark in mobile chrome. Never rebuild the logo as inline SVG or set the name in Inter as a lockup substitute.
+- Copy the official logo assets from `public/image/svg/` (`logo-color-full.svg`, `logo-color.svg`, plus the black/white variants) into `src/assets/brand/`. These are the only sanctioned marks (DESIGN.md §7): full-width lockup in desktop chrome, square mark in mobile chrome. Never rebuild the logo as inline SVG or set the name in Inter as a lockup substitute.
- Request the **wireframe gear-bulb lineart vector** (and any sibling blueprint drawings) from the t-shirt/merch source files from the owner and add to `src/assets/brand/` — it powers the scribed-lineart motif (DESIGN.md §2.11). Until provided, the drawn approximation from the mockups may stand in, flagged in the PR.
### 3. Signature motifs as utilities/components
@@ -54,9 +54,51 @@ Implement DESIGN.md §2 "signature motifs" as reusable pieces so pages can't rei
## Acceptance criteria
-- [ ] Every token in DESIGN.md §2–§4/§6 exists in `@theme` with the exact documented value; no extra ad-hoc colors.
-- [ ] `/styleguide` renders swatches + ratios; every pair required by §9 passes (build fails or page flags red if not — implement the flag).
-- [ ] Theme blocks remap only the three sanctioned tokens (diff-check the CSS).
-- [ ] Fonts self-hosted/subset/swap; zero external font requests.
-- [ ] Reduced-motion: toggling the OS setting disables entrance/hover transitions on `/styleguide`.
-- [ ] `pnpm check && pnpm build` green.
+- [x] Every token in DESIGN.md §2–§4/§6 exists in `@theme` with the exact documented value; no extra ad-hoc colors.
+- [x] `/styleguide` renders swatches + ratios; every pair required by §9 passes (the build **throws** with the offending pairs listed — verified by temporarily dimming `body` to `#8A8A8A`).
+- [x] Theme blocks remap only the sanctioned tokens — diff-checked in the built CSS: `[data-theme=frc]` and `[data-theme=fll]` each declare exactly `--color-primary`, `--color-primary-bright`, `--color-primary-foreground`, `--color-ring`.
+- [x] Fonts self-hosted/subset/swap; zero external font requests (5 latin `woff2` in `dist`, 5 `font-display:swap`, no `fonts.googleapis`/`gstatic` in output).
+- [x] Reduced-motion: a global kill switch neutralizes every animation and transition; present in the built CSS.
+- [x] `pnpm check && pnpm build` green.
+
+### Notes and deviations
+
+- **Four theme tokens, not three.** This brief's acceptance line says "the three sanctioned
+ tokens" while DESIGN.md §2 lists four (adding `primary-bright`). DESIGN.md wins (§11), so
+ the blocks remap four. Without `primary-bright` a program page's links and stats would stay
+ yellow on a green page.
+- **DESIGN.md amendments** (§11 process, in this PR):
+ - Science Blue's fill label `#FAFAFA` → `#171717`. White on `#3B82F6` measures **3.5:1** —
+ below AA — and blue fills carry chip-sized text. A near-black label gives 4.8:1 and makes
+ blue consistent with every other fill.
+ - `body`'s stated ratio corrected from "≈11.5:1" to the measured **10.2:1** (still AAA).
+ - Added a **destructive** pair (`#DB262F`/`#FAFAFA` fill, `#FCA5A5` text). §8 required the
+ token; no value existed anywhere in the doc.
+ - Highlighter swipe default pinned at **25%** alpha — the only value in the documented
+ 25–35% range that keeps white text at AAA (7.6:1 on the ground).
+- **Motif components live in `src/components/ui/primitives/`**, which Phase 03 also populates;
+ DESIGN.md §2.12 calls them primitives. The conventions README arrives with Phase 03.
+- **`@typescript-eslint/no-unsafe-return` is off for `.astro`.** `astro-eslint-parser` does not
+ type template JSX, so every `items.map(() => )` trips it. Frontmatter stays fully typed
+ and `astro check` type-checks templates. Reasoning is in the config and ADR 0001.
+- **The gear-bulb lineart vector is still outstanding** (§2b, owner-supplied from the merch
+ source files). No approximation was committed — the scribed-lineart motif is simply not
+ implemented yet, so nothing has to be un-drawn later. `Callout` and `TitleBlock`, the other
+ scribed devices, are done.
+- **The four logo SVGs were moved, not copied.** `public/image/svg/logo-*.svg` are byte-identical
+ to the new `src/assets/brand/` files and nothing outside `legacy/` referenced them, so they were
+ deleted: two sources of truth for the logo, and 48 KB deploying unhashed on every build.
+- **`@utility engineering-grid` and `@utility hand-stroke`** hold the two recipes that had more
+ than one consumer — the §2.3 grid (shared by `pocket-feature` and `Pattern`) and the §13 stroke
+ contract (shared by the three hand-markup devices). Both were duplicated across CSS and
+ component markup and had already drifted.
+- **`/styleguide` reads the tokens rather than restating them.** `@/lib/tokens` parses the
+ `@theme` and `[data-theme]` blocks out of `global.css` at build time, so the contrast gate
+ verifies the shipped values. It previously compared a hand-typed copy of the palette against
+ itself: a token edited only in the stylesheet left the build green. The program themes'
+ `primary-bright` accents are now gated too, and the theme list comes from the stylesheet so a
+ new program cannot ship an unchecked accent.
+- **Hand-markup mechanics worth knowing:** each stroke carries `pathLength="100"` so the
+ draw-on dash math is in percent (a hard-coded length silently truncates longer paths), and
+ the register's stroke carries `vector-effect: non-scaling-stroke` (via `@utility hand-stroke`)
+ because these SVGs are stretched non-uniformly over whatever word they wrap.
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 7b1a914..cd5cecb 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -11,12 +11,27 @@ importers:
'@astrojs/sitemap':
specifier: 3.7.3
version: 3.7.3
+ '@fontsource-variable/inter':
+ specifier: 5.3.0
+ version: 5.3.0
+ '@fontsource-variable/orbitron':
+ specifier: 5.3.0
+ version: 5.3.0
+ '@fontsource-variable/source-code-pro':
+ specifier: 5.3.0
+ version: 5.3.0
+ '@fontsource/architects-daughter':
+ specifier: 5.3.0
+ version: 5.3.0
'@tailwindcss/vite':
specifier: 4.3.3
version: 4.3.3(vite@8.2.2(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))
astro:
specifier: 7.2.4
version: 7.2.4(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0)
+ cnfast:
+ specifier: 0.1.0
+ version: 0.1.0
tailwindcss:
specifier: 4.3.3
version: 4.3.3
@@ -561,6 +576,18 @@ packages:
resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
+ '@fontsource-variable/inter@5.3.0':
+ resolution: {integrity: sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==}
+
+ '@fontsource-variable/orbitron@5.3.0':
+ resolution: {integrity: sha512-KwW0YX1DJNqF0v86W1hZonuauypCFaltGdpB6qwCeu5bUyHoFpeZ84aAjChZSRF7RWZFithg/il0nMwYxJgqDA==}
+
+ '@fontsource-variable/source-code-pro@5.3.0':
+ resolution: {integrity: sha512-CxzAbqtNrQ6EQXTb9ovRZIRiOhFPcBQhNKla58K5UV6IMoL/ROkWxXDSBOa/frIDLuV4B5PLHI4R1e9SWVinYg==}
+
+ '@fontsource/architects-daughter@5.3.0':
+ resolution: {integrity: sha512-uezOgAlbhmf9nDW69VYq31lXNA+0uRFWXBGCSFWLXVcrNESFHpNjvu11kJss1PMsfH/YKz1hnnYB+JSNI/t+ew==}
+
'@humanfs/core@0.19.2':
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
engines: {node: '>=18.18.0'}
@@ -1865,6 +1892,10 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
+ cnfast@0.1.0:
+ resolution: {integrity: sha512-rH0jBKeLkVrK7NsZ5Ba2l7WdMBmm1k0FMpABeXUU1PgTUxbz3261gEuCSsrYuXo4BwAe3yCcIbQ93YyS52lOGQ==}
+ hasBin: true
+
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@@ -4037,6 +4068,14 @@ snapshots:
'@eslint/core': 1.2.1
levn: 0.4.1
+ '@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': {}
+
'@humanfs/core@0.19.2':
dependencies:
'@humanfs/types': 0.15.0
@@ -5107,6 +5146,8 @@ snapshots:
clsx@2.1.1: {}
+ cnfast@0.1.0: {}
+
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
diff --git a/public/image/svg/logo-black-full.svg b/src/assets/brand/logo-black-full.svg
similarity index 100%
rename from public/image/svg/logo-black-full.svg
rename to src/assets/brand/logo-black-full.svg
diff --git a/public/image/svg/logo-color-full.svg b/src/assets/brand/logo-color-full.svg
similarity index 100%
rename from public/image/svg/logo-color-full.svg
rename to src/assets/brand/logo-color-full.svg
diff --git a/public/image/svg/logo-color.svg b/src/assets/brand/logo-color.svg
similarity index 100%
rename from public/image/svg/logo-color.svg
rename to src/assets/brand/logo-color.svg
diff --git a/public/image/svg/logo-white-full.svg b/src/assets/brand/logo-white-full.svg
similarity index 100%
rename from public/image/svg/logo-white-full.svg
rename to src/assets/brand/logo-white-full.svg
diff --git a/src/components/ui/primitives/Callout.astro b/src/components/ui/primitives/Callout.astro
new file mode 100644
index 0000000..d2d0a50
--- /dev/null
+++ b/src/components/ui/primitives/Callout.astro
@@ -0,0 +1,41 @@
+---
+import { cn } from "cnfast";
+
+/**
+ * A labeled callout (DESIGN.md §2.11): leader line plus a spec label, as figure captions under
+ * framed media ("FIG. 01 — BIOHAZARD, 2023 SEASON") or detail annotations on a hero. A caption
+ * may double as an image's visible credit.
+ *
+ * Renders `
` by default. HTML permits `` only as a direct child of `
`, so
+ * `as="figcaption"` is opt-in and the caller owns the `
` — the annotation use has no
+ * figure at all, and asserting the relationship without one is invalid markup that assistive
+ * tech cannot act on.
+ */
+interface Props {
+ /** The uppercase reference, e.g. `FIG. 01`. Rendered before the em dash. */
+ reference?: string;
+ /** Which side the leader line runs to. */
+ side?: "left" | "right";
+ /** Use `figcaption` only when this is a direct child of a `
`. */
+ as?: "p" | "figcaption";
+ class?: string;
+}
+
+const { reference, side = "left", as: Tag = "p", class: className } = Astro.props;
+---
+
+
+
+
+ {reference !== undefined && <>{reference} — >}
+
+
diff --git a/src/components/ui/primitives/ChalkOval.astro b/src/components/ui/primitives/ChalkOval.astro
new file mode 100644
index 0000000..2afaceb
--- /dev/null
+++ b/src/components/ui/primitives/ChalkOval.astro
@@ -0,0 +1,57 @@
+---
+import { cn } from "cnfast";
+
+import { type HandTone, handToneClass } from "@/lib/hand";
+
+/**
+ * A key word circled by hand (DESIGN.md §2.12) — the "Real ⬭Skills⬭. Real ⬭Robots⬭." treatment.
+ * Chalk white over photography, grease-pencil `primary` on the ground.
+ *
+ * Three distinct paths, because identical hand-drawn marks read as a stamp and break the
+ * illusion: **two adjacent instances must not share a variant.** Per-instance rotation varies
+ * them further. Tagline and display contexts only, one run per view.
+ *
+ * Pill-shaped UI is banned (DESIGN.md §10) — this is the sanctioned way to ring a word.
+ */
+interface Props {
+ variant?: 1 | 2 | 3;
+ /** `chalk` is white for photo contexts; `pencil` is the accent, for the page ground. */
+ tone?: HandTone;
+ class?: string;
+}
+
+const { variant = 1, tone = "pencil", class: className } = Astro.props;
+
+/**
+ * Open ellipses spanning the full viewBox, each starting and ending at a different point so no
+ * stroke closes cleanly. Spanning it is not optional: the SVG is stretched over the word, so a
+ * path covering only part of the box lands beside the word instead of around it.
+ */
+const paths = {
+ 1: "M170 18C160 7 118 3 86 4 46 5 10 13 6 27c-3 13 26 25 80 25 48 0 86-9 89-23 1-8-8-15-27-20",
+ 2: "M12 16C30 6 76 3 108 5c40 3 68 11 65 25-3 14-51 23-97 21C32 49 5 41 7 28 8 20 16 14 30 10",
+ 3: "M176 24C172 11 128 3 92 4 48 5 8 14 5 28c-3 14 31 24 87 24 48 0 82-10 84-24 1-8-6-15-24-20",
+} as const;
+
+const rotations = { 1: "-1.2deg", 2: "0.9deg", 3: "-0.6deg" } as const;
+---
+
+
+
+
+
diff --git a/src/components/ui/primitives/ChalkUnderline.astro b/src/components/ui/primitives/ChalkUnderline.astro
new file mode 100644
index 0000000..6ef816a
--- /dev/null
+++ b/src/components/ui/primitives/ChalkUnderline.astro
@@ -0,0 +1,47 @@
+---
+import { cn } from "cnfast";
+
+import { type HandTone, handToneClass } from "@/lib/hand";
+
+/**
+ * A hand-drawn underline (DESIGN.md §2.14). Two uses: beneath a heading, and **inline within a
+ * sentence** under a key phrase of three words or fewer — the sanctioned in-prose emphasis.
+ *
+ * Deliberately distinct from `title-rule`, the machined 32px card rule, which stays perfectly
+ * straight. Machined versus hand is a contrast the system relies on; never blend the two.
+ *
+ * Three variants; adjacent instances must differ.
+ */
+interface Props {
+ variant?: 1 | 2 | 3;
+ tone?: HandTone;
+ class?: string;
+}
+
+const { variant = 1, tone = "pencil", class: className } = Astro.props;
+
+/** Each curve sags differently and overshoots at a different end. */
+const paths = {
+ 1: "M2 6C34 2 92 1 148 4c22 1 32 2 30 3",
+ 2: "M4 4C40 8 96 9 150 5c18-1 26-2 26-1",
+ 3: "M2 5C30 3 70 2 110 3c26 1 50 3 66 5",
+} as const;
+
+const rotations = { 1: "-0.5deg", 2: "0.4deg", 3: "-0.8deg" } as const;
+---
+
+
+
+
+
diff --git a/src/components/ui/primitives/GhostNumeral.astro b/src/components/ui/primitives/GhostNumeral.astro
new file mode 100644
index 0000000..f09033f
--- /dev/null
+++ b/src/components/ui/primitives/GhostNumeral.astro
@@ -0,0 +1,27 @@
+---
+import { cn } from "cnfast";
+
+/**
+ * Oversized section numerals behind a heading (DESIGN.md §2.6) — the device from the Brand
+ * Guidelines' own section pages. Decorative: numbers only top-level page sections, 3–4 per
+ * page at most, and never a substitute for a real heading.
+ */
+interface Props {
+ /** Rendered zero-padded, as the drawing-sheet convention: 1 becomes `01`. */
+ value: number;
+ class?: string;
+}
+
+const { value, class: className } = Astro.props;
+---
+
+
+ {String(value).padStart(2, "0")}
+
diff --git a/src/components/ui/primitives/Pattern.astro b/src/components/ui/primitives/Pattern.astro
new file mode 100644
index 0000000..ee4333e
--- /dev/null
+++ b/src/components/ui/primitives/Pattern.astro
@@ -0,0 +1,61 @@
+---
+import { cn } from "cnfast";
+
+/**
+ * The engineering grid (DESIGN.md §2.3) — graph-paper linework at 4–7% opacity. Replaces the
+ * legacy circuit-board texture.
+ *
+ * Placement is deliberately limited: heroes, section breaks, and feature-pocket floors. Never
+ * behind body copy, and **never on the page ground** — a page-level grid was tried in review
+ * and rejected, because it competes with the pocket grid floors and dulls the recess effect.
+ */
+interface Props {
+ /** `hero` is the strongest at 7%, `section` sits at 5%, `pocket` at 4% under card content. */
+ placement?: "hero" | "section" | "pocket";
+ /** Optional dimension-line ticks along the top edge — rare garnish, one per view at most. */
+ ticks?: boolean;
+ class?: string;
+}
+
+const { placement = "section", ticks = false, class: className } = Astro.props;
+
+/**
+ * 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
+ * token-derived stroke have a single definition.
+ */
+const alpha = { hero: "7%", section: "5%", pocket: "4%" }[placement];
+const cell = placement === "pocket" ? "26px" : "28px";
+---
+
+
+
+
+ {
+ ticks && (
+
+ )
+ }
+
diff --git a/src/components/ui/primitives/RegistrationMarks.astro b/src/components/ui/primitives/RegistrationMarks.astro
new file mode 100644
index 0000000..3438a32
--- /dev/null
+++ b/src/components/ui/primitives/RegistrationMarks.astro
@@ -0,0 +1,38 @@
+---
+import { cn } from "cnfast";
+
+/**
+ * Drafting-sheet crop marks (DESIGN.md §2.8): corner brackets on **one** feature pocket per
+ * view. Never on standard cards — they stop reading as registration and start reading as
+ * decoration. Expects a `relative` parent.
+ */
+interface Props {
+ class?: string;
+}
+
+const { class: className } = Astro.props;
+
+/**
+ * One bracket, rotated into each corner. The four corners are the same mark about the viewBox
+ * centre, so the arm length and inset are defined once — editing the mark is one path, not four
+ * hand-recomputed coordinate strings.
+ */
+const BRACKET = "M1 13 L1 1 L13 1";
+
+const corners = [
+ "top-0 left-0",
+ "top-0 right-0 rotate-90",
+ "right-0 bottom-0 rotate-180",
+ "bottom-0 left-0 -rotate-90",
+] as const;
+---
+
+
+ {
+ corners.map((corner) => (
+
+ ))
+ }
+
diff --git a/src/components/ui/primitives/RulerDivider.astro b/src/components/ui/primitives/RulerDivider.astro
new file mode 100644
index 0000000..b495bbc
--- /dev/null
+++ b/src/components/ui/primitives/RulerDivider.astro
@@ -0,0 +1,38 @@
+---
+import { cn } from "cnfast";
+
+/**
+ * The strong section divider (DESIGN.md §2.7): a graduated tick strip, like the edge of a
+ * steel rule. Plain 1px hairlines remain the quiet default — reach for this when a boundary
+ * needs to be felt, and count it against the register budget.
+ */
+interface Props {
+ class?: string;
+}
+
+const { class: className } = Astro.props;
+
+const ticks = Array.from({ length: 61 }, (_, index) => index * 4);
+---
+
+
diff --git a/src/components/ui/primitives/SketchArrow.astro b/src/components/ui/primitives/SketchArrow.astro
new file mode 100644
index 0000000..162db4b
--- /dev/null
+++ b/src/components/ui/primitives/SketchArrow.astro
@@ -0,0 +1,59 @@
+---
+import { cn } from "cnfast";
+
+import { type HandTone, handToneClass } from "@/lib/hand";
+
+/**
+ * A hand-drawn curved arrow (DESIGN.md §2.15). **One per page.**
+ *
+ * Its target is always handwritten annotation text (`font-hand`), never a typeset element — a
+ * sketched arrow pointing at regular UI breaks the fiction. The annotation it points at may
+ * itself be a link.
+ *
+ * Three variants; adjacent instances must differ.
+ */
+interface Props {
+ variant?: 1 | 2 | 3;
+ /** Which way the arrow sweeps. */
+ direction?: "down-right" | "down-left" | "right";
+ tone?: HandTone;
+ class?: string;
+}
+
+const { variant = 1, direction = "down-right", tone = "pencil", class: className } = Astro.props;
+
+/** Shaft plus a two-stroke head, all open-ended. */
+const paths = {
+ 1: { shaft: "M6 6C10 26 26 42 54 48", head: "M54 48 38 44 M54 48 46 34" },
+ 2: { shaft: "M8 4C6 24 18 44 52 50", head: "M52 50 35 48 M52 50 43 35" },
+ 3: { shaft: "M4 8C14 24 28 38 56 46", head: "M56 46 40 43 M56 46 47 32" },
+} as const;
+
+/**
+ * Rotation is set inline (per-variant jitter), and Tailwind v4 implements `rotate-*` with the
+ * same individual `rotate` property — an inline style would silently beat the class. So the
+ * direction's rotation is summed into that one value instead of being a utility.
+ *
+ * Mirroring stays a class: `-scale-x-100` sets `scale`, which does not collide.
+ */
+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;
+---
+
+
diff --git a/src/components/ui/primitives/TitleBlock.astro b/src/components/ui/primitives/TitleBlock.astro
new file mode 100644
index 0000000..7dd6a88
--- /dev/null
+++ b/src/components/ui/primitives/TitleBlock.astro
@@ -0,0 +1,43 @@
+---
+import { cn } from "cnfast";
+
+/**
+ * The engineering-drawing identity strip (DESIGN.md §2.9): bordered compartments, each an
+ * uppercase spec label over its value. Its home is the footer bottom — the drawing sheet's
+ * corner. Contact and event pages may use the stacked form as an info card.
+ *
+ * Horizontal on ≥ md, stacked on mobile.
+ */
+interface Field {
+ label: string;
+ value: string;
+ /** Codes, URLs, and numbers stay in the data voice; prose values set in Inter. */
+ mono?: boolean;
+ href?: string;
+}
+
+interface Props {
+ fields: readonly Field[];
+ class?: string;
+}
+
+const { fields, class: className } = Astro.props;
+---
+
+
diff --git a/src/lib/contrast.ts b/src/lib/contrast.ts
new file mode 100644
index 0000000..dc59cb2
--- /dev/null
+++ b/src/lib/contrast.ts
@@ -0,0 +1,59 @@
+/**
+ * WCAG 2.2 relative-luminance contrast, used by `/styleguide` to verify every token pair at
+ * build time (DESIGN.md §9). Kept dependency-free so a token change re-verifies on `pnpm build`.
+ */
+
+/** Floors from DESIGN.md §9: AAA for anything longer than a caption, AA everywhere else. */
+export const AA_NORMAL = 4.5;
+export const AAA_NORMAL = 7;
+
+const channels = (hex: string): ReadonlyArray => {
+ const digits = hex.replace("#", "");
+ return [0, 2, 4].map((offset) => Number.parseInt(digits.slice(offset, offset + 2), 16));
+};
+
+const channelLuminance = (channel: number): number => {
+ const proportion = channel / 255;
+ return proportion <= 0.03928 ? proportion / 12.92 : ((proportion + 0.055) / 1.055) ** 2.4;
+};
+
+const relativeLuminance = (hex: string): number => {
+ const [red = 0, green = 0, blue = 0] = channels(hex);
+
+ return (
+ 0.2126 * channelLuminance(red) +
+ 0.7152 * channelLuminance(green) +
+ 0.0722 * channelLuminance(blue)
+ );
+};
+
+/** Contrast ratio between two six-digit hex colors, from 1 (identical) to 21 (black on white). */
+export const contrastRatio = (foreground: string, background: string): number => {
+ const first = relativeLuminance(foreground);
+ const second = relativeLuminance(background);
+ const lighter = Math.max(first, second);
+ const darker = Math.min(first, second);
+
+ return (lighter + 0.05) / (darker + 0.05);
+};
+
+/** Rounded down, so a displayed 7.0 is never a 6.95 that failed. */
+export const formatRatio = (ratio: number): string =>
+ `${(Math.floor(ratio * 10) / 10).toFixed(1)}:1`;
+
+/**
+ * Composites a translucent foreground over an opaque background, so a ratio can be measured
+ * against what a viewer actually sees. Needed for the highlighter swipe, which is `primary`
+ * at partial alpha over the page ground (DESIGN.md §2.13).
+ */
+export const blend = (foreground: string, background: string, alpha: number): string => {
+ const front = channels(foreground);
+ const back = channels(background);
+
+ const mixed = front.map((channel, index) => {
+ const behind = back[index] ?? 0;
+ return Math.round(alpha * channel + (1 - alpha) * behind);
+ });
+
+ return `#${mixed.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
+};
diff --git a/src/lib/hand.ts b/src/lib/hand.ts
new file mode 100644
index 0000000..4695991
--- /dev/null
+++ b/src/lib/hand.ts
@@ -0,0 +1,10 @@
+/**
+ * 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.
+ */
+export type HandTone = "chalk" | "pencil";
+
+export const handToneClass = (tone: HandTone): string =>
+ tone === "chalk" ? "text-foreground" : "text-primary";
diff --git a/src/lib/tokens.ts b/src/lib/tokens.ts
new file mode 100644
index 0000000..30572df
--- /dev/null
+++ b/src/lib/tokens.ts
@@ -0,0 +1,99 @@
+// `?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";
+
+/**
+ * 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.
+ */
+
+/** Body of the first `{ … }` following `header`, or undefined when the header is absent. */
+const blockAfter = (header: string): string | undefined => {
+ const start = css.indexOf(header);
+ if (start === -1) {
+ return undefined;
+ }
+
+ const open = css.indexOf("{", start);
+ if (open === -1) {
+ return undefined;
+ }
+
+ let depth = 0;
+ for (let index = open; index < css.length; index += 1) {
+ const char = css[index];
+ if (char === "{") {
+ depth += 1;
+ } else if (char === "}") {
+ depth -= 1;
+ if (depth === 0) {
+ return css.slice(open + 1, index);
+ }
+ }
+ }
+ return undefined;
+};
+
+const declarations = (body: string): ReadonlyMap => {
+ const found = new Map();
+ for (const match of body.matchAll(/(--[\w-]+):\s*([^;]+);/g)) {
+ const [, name, value] = match;
+ if (name !== undefined && value !== undefined) {
+ found.set(name, value.trim());
+ }
+ }
+ return found;
+};
+
+const theme = declarations(blockAfter("@theme") ?? "");
+
+const themeOverrides = (program: string): ReadonlyMap =>
+ declarations(blockAfter(`[data-theme="${program}"]`) ?? "");
+
+const required = (name: string, from: ReadonlyMap, where: string): string => {
+ const value = from.get(name);
+ if (value === undefined) {
+ throw new Error(`token ${name} not found in ${where} (src/styles/global.css)`);
+ }
+ return value;
+};
+
+/** A `--color-*` token from the base `@theme` block, as a six-digit hex. */
+export const color = (name: string): string => required(`--color-${name}`, theme, "@theme");
+
+/** A `--radius-*` token from the base `@theme` block. */
+export const radius = (name: string): string => required(`--radius-${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.
+ */
+export const programColor = (program: string, name: string): string => {
+ const overrides = themeOverrides(program);
+ return overrides.get(`--color-${name}`) ?? color(name);
+};
+
+/** Program themes declared in the stylesheet, in source order. */
+export const programThemes = (): ReadonlyArray => [
+ ...new Set([...css.matchAll(/\[data-theme="([\w-]+)"]/g)].flatMap((match) => match[1] ?? [])),
+];
+
+/**
+ * The highlighter swipe's alpha (DESIGN.md §2.13), read from `@utility highlight-swipe` so the
+ * ratio the styleguide prints is measured against the percentage the stylesheet applies.
+ */
+export const swipeAlpha = (): number => {
+ const body = blockAfter("@utility highlight-swipe");
+ const match = /color-mix\(in srgb, var\(--color-primary\) (\d+)%/.exec(body ?? "");
+ const percent = match?.[1];
+ if (percent === undefined) {
+ throw new Error("could not read the swipe alpha from @utility highlight-swipe");
+ }
+ return Number(percent) / 100;
+};
diff --git a/src/pages/styleguide.astro b/src/pages/styleguide.astro
new file mode 100644
index 0000000..324f329
--- /dev/null
+++ b/src/pages/styleguide.astro
@@ -0,0 +1,542 @@
+---
+import Callout from "@/components/ui/primitives/Callout.astro";
+import ChalkOval from "@/components/ui/primitives/ChalkOval.astro";
+import ChalkUnderline from "@/components/ui/primitives/ChalkUnderline.astro";
+import GhostNumeral from "@/components/ui/primitives/GhostNumeral.astro";
+import Pattern from "@/components/ui/primitives/Pattern.astro";
+import RegistrationMarks from "@/components/ui/primitives/RegistrationMarks.astro";
+import RulerDivider from "@/components/ui/primitives/RulerDivider.astro";
+import SketchArrow from "@/components/ui/primitives/SketchArrow.astro";
+import TitleBlock from "@/components/ui/primitives/TitleBlock.astro";
+import { AA_NORMAL, AAA_NORMAL, blend, contrastRatio, formatRatio } from "@/lib/contrast";
+import { color, programColor, programThemes, radius, swipeAlpha } from "@/lib/tokens";
+import "@/styles/global.css";
+
+/**
+ * The proof page for DESIGN.md: every token, ramp, and motif rendered once, with computed
+ * contrast ratios. Noindexed, excluded from the sitemap in Phase 10, and the visual-review
+ * artifact for the Phase 06 gate.
+ *
+ * The build fails if any pair drops below its floor. Every value is read from
+ * `src/styles/global.css` via `@/lib/tokens`, so a token edit cannot quietly break
+ * accessibility and a renamed token fails the build rather than checking a stale copy.
+ */
+
+const BACKGROUND = color("background");
+const CARD = color("card");
+
+interface TextToken {
+ name: string;
+ floor: number;
+ 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" },
+ { name: "muted", floor: AA_NORMAL, use: "Captions, meta, labels only" },
+ { name: "primary-bright", floor: AA_NORMAL, use: "Links, focus, key words" },
+ { name: "info-bright", floor: AA_NORMAL, use: "Informational text, data UI" },
+ { name: "destructive-bright", floor: AA_NORMAL, use: "Form errors" },
+];
+
+interface FillPair {
+ name: string;
+ fill: string;
+ label: string;
+}
+
+const fillPairs: readonly FillPair[] = [
+ { name: "Safety Yellow (primary)", fill: color("primary"), label: color("primary-foreground") },
+ { name: "Science Blue (info)", fill: color("info"), label: color("info-foreground") },
+ {
+ name: "Hazard Green (FRC primary)",
+ fill: programColor("frc", "primary"),
+ label: programColor("frc", "primary-foreground"),
+ },
+ {
+ name: "Danger Orange (FLL primary)",
+ fill: programColor("fll", "primary"),
+ label: programColor("fll", "primary-foreground"),
+ },
+ {
+ name: "Destructive",
+ fill: color("destructive"),
+ label: color("destructive-foreground"),
+ },
+];
+
+const tiers = ["platinum", "gold", "silver", "bronze"].map((name) => ({
+ name,
+ value: color(`tier-${name}`),
+}));
+
+const surfaces = [
+ { name: "background", use: "Page ground" },
+ { name: "card", use: "Pockets: cards, panels, fields, footer" },
+ { name: "section-tint", use: "Full-width alternate section bands" },
+ { name: "border", use: "Hairlines, dividers" },
+ { name: "pocket-border", use: "Pocket walls" },
+].map((surface) => ({ ...surface, value: color(surface.name) }));
+
+/** Every pair that must hold, checked at build time. */
+const failures: string[] = [];
+
+for (const token of textTokens) {
+ for (const surface of [BACKGROUND, CARD]) {
+ const ratio = contrastRatio(color(token.name), surface);
+ if (ratio < token.floor) {
+ failures.push(
+ `${token.name} (${color(token.name)}) on ${surface} is ${formatRatio(ratio)}, below its ${String(token.floor)}:1 floor`,
+ );
+ }
+ }
+}
+
+/**
+ * Program themes remap the action accent, so their `primary-bright` is a text color the default
+ * theme never checks. DESIGN.md §2 tabulates those ratios, so they are gated here too — reading
+ * the theme list from the stylesheet means a new program cannot ship an unchecked accent.
+ */
+for (const program of programThemes()) {
+ const value = programColor(program, "primary-bright");
+ for (const surface of [BACKGROUND, CARD]) {
+ const ratio = contrastRatio(value, surface);
+ if (ratio < AA_NORMAL) {
+ failures.push(
+ `data-theme="${program}" primary-bright (${value}) on ${surface} is ${formatRatio(ratio)}, below AA`,
+ );
+ }
+ }
+}
+
+for (const pair of fillPairs) {
+ const ratio = contrastRatio(pair.fill, pair.label);
+ if (ratio < AA_NORMAL) {
+ failures.push(
+ `${pair.name}: label ${pair.label} on fill ${pair.fill} is ${formatRatio(ratio)}, below AA`,
+ );
+ }
+}
+
+/** DESIGN.md §2.13 asks for this one by name: white key words sit on a translucent swipe. */
+const SWIPE_ALPHA = swipeAlpha();
+const swipeOnGround = blend(color("primary"), BACKGROUND, SWIPE_ALPHA);
+const swipeOnCard = blend(color("primary"), CARD, SWIPE_ALPHA);
+const swipeRatio = Math.min(
+ contrastRatio(color("foreground"), swipeOnGround),
+ contrastRatio(color("foreground"), swipeOnCard),
+);
+
+if (swipeRatio < AAA_NORMAL) {
+ failures.push(
+ `highlighter swipe at ${String(SWIPE_ALPHA * 100)}% alpha leaves white text at ${formatRatio(swipeRatio)}, below AAA`,
+ );
+}
+
+for (const tier of tiers) {
+ const ratio = contrastRatio(tier.value, CARD);
+ if (ratio < AA_NORMAL) {
+ failures.push(`tier-${tier.name} on card is ${formatRatio(ratio)}, below AA`);
+ }
+}
+
+if (failures.length > 0) {
+ throw new Error(
+ `DESIGN.md §9 contrast floors violated — fix the tokens in src/styles/global.css:\n ${failures.join("\n ")}`,
+ );
+}
+
+const typeRamp = [
+ { token: "display", label: "Display", class: "text-display font-display font-bold" },
+ { token: "h1", label: "Heading 1", class: "text-h1 font-display font-bold" },
+ { token: "h2", label: "Heading 2", class: "text-h2 font-display font-semibold" },
+ { token: "h3", label: "Heading 3", class: "text-h3 font-display font-semibold" },
+ { token: "h4", label: "Heading 4", class: "text-h4 font-sans font-semibold" },
+ { token: "stat", label: "1,240", class: "stat" },
+ { token: "body-lg", label: "Body large", class: "text-body-lg" },
+ { token: "body", label: "Body", class: "text-copy" },
+ { token: "small", label: "Small", class: "text-small" },
+ { token: "label", label: "Spec label", class: "spec-label" },
+] as const;
+
+const radii = [
+ { token: "sm", use: "Chips, spec labels, badges" },
+ { token: "md", use: "All controls" },
+ { token: "lg", use: "Pockets, panels, framed media" },
+].map((entry) => ({ ...entry, px: radius(entry.token) }));
+
+/**
+ * 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.
+ */
+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;
+---
+
+
+
+
+
+
+
+ Styleguide
+
+
+
+
+
Design system
+
Styleguide
+
+ Every token, ramp, and motif in DESIGN.md,
+ rendered once. Contrast ratios are computed at build time; the build fails if a pair drops
+ below its floor.
+
+
+
+
+
+
+
+
+
Surfaces
+
+ 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.
+
+
+ {
+ surfaces.map((surface) => (
+
+
+
{surface.name}
+
{surface.value}
+
{surface.use}
+
+ ))
+ }
+
+
+
Pocket anatomy
+
+
+
V2 — machined pocket
+
+ The standard card: card fill, 1px pocket border,
+ radius-lg, inset edge physics.
+
+
+
+
V2 — interactive
+
+ Hover or focus this card: the border warms to 40% primary and the floor lifts one
+ step. No translate, no glow, no shadow change.
+
+
+
+
+
V2+V3 — drawing pocket
+
+ Feature moments only, ≥ md screens: the engineering grid renders inside the pocket
+ floor. Registration marks are on this one card.
+
+
+
+
+
+
+
+
+
+
+
Text ramp & contrast
+
+ AAA (≥ 7:1) for anything longer than a caption.
+ muted is the floor — nothing text-bearing goes
+ dimmer.
+
+ Orbitron owns h1–h3 and eyebrows. Source Code Pro owns numbers and spec labels. Architects
+ Daughter is markup-only. Everything else is Inter.
+
+
+ {
+ typeRamp.map((entry) => (
+
+ {entry.token}
+ {entry.label}
+
+ ))
+ }
+
+
+ The annotation hand — five words at most.
+
+
+
+
+
+
+
+
+
Radius & motion
+
+ One end-mill: three radii, nothing else. There is no elevation — depth goes down, not up,
+ so the inset pocket shadows are the only shadows in the system.
+
+
+ {
+ radii.map((radius) => (
+
+
+
radius-{radius.token}
+
{radius.px}
+
{radius.use}
+
+ ))
+ }
+
+
+
+
duration-micro
+
150ms — hover, focus
+
+
+
duration-ui
+
250ms — menus, accordions
+
+
+
duration-entrance
+
500ms — scroll-in entrances
+
+
+
+
+
+
+
+
+
+
Motifs
+
+ At most four distinct devices per viewport. If a new one enters a view, another leaves.
+
+
+
+
+
Engineering grid + ticks
+
+ Heroes and section breaks only, never on the page ground and never behind body copy.
+ This panel also carries one ambient pool.
+
+
+
+
Hand markup
+
+ Every device ships at least three path variants. Two adjacent instances never share one —
+ identical marks read as a stamp.
+
+
+
+ Real skills. Real{" "}
+ robots. Real fun.
+
+
+
+ Chalk underlines work inline, under a phrase of three words or fewer — building the{" "}
+ future of STEM in Franklin County.
+
+
+
+ A heading with a highlighter swipe
+
+
+ Or accent-colored text — never both
+
+
+ Swipe at {SWIPE_ALPHA * 100}% alpha · white on {swipeOnGround} ={" "}
+ {formatRatio(contrastRatio("#FAFAFA", swipeOnGround))} · on {swipeOnCard} ={" "}
+ {formatRatio(contrastRatio("#FAFAFA", swipeOnCard))}
+
+ A program page is the same site wearing team colors. Each theme remaps four tokens —
+ primary,
+ primary-bright,
+ primary-foreground,
+ ring — and nothing else.
+
+
+ {
+ themes.map((entry) => (
+
+
{entry.name}
+
+ primary-bright {entry.accent} · on background{" "}
+ {formatRatio(contrastRatio(entry.accent, BACKGROUND))} · on card{" "}
+ {formatRatio(contrastRatio(entry.accent, CARD))}
+