From 349569dc2601992574cc77a29315e7db3e762ef3 Mon Sep 17 00:00:00 2001 From: Carson Seese Date: Fri, 28 Aug 2026 00:27:02 +0000 Subject: [PATCH 1/2] =?UTF-8?q?overhaul:=20design=20system=20=E2=80=94=20t?= =?UTF-8?q?okens,=20fonts,=20program=20themes,=20styleguide?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turn DESIGN.md into working CSS: the full token set, fluid type scale, self-hosted fonts, program themes, the motif components, and a /styleguide page that proves all of it. Contrast is verified at build time, not by eye. /styleguide computes every pair required by DESIGN.md §9 and throws with the offending pairs listed if one drops below its floor, so a token edit cannot quietly break accessibility. That verification caught two defects in the spec, amended here per DESIGN.md §11: - Science Blue's fill used a white label, which measures 3.5:1 — below AA, on fills that carry chip-sized text. Changed to the brand near-black (4.9:1), which also makes blue consistent with every other fill. - body's stated ratio was "≈11.5:1"; it measures 10.2:1. Still AAA. Also added a destructive pair, which §8 required but the doc never valued, and pinned the highlighter swipe at 25% alpha — the only value in the documented range that keeps white text at AAA. Fonts are declared face by face rather than through the @fontsource CSS, so only latin subsets reach the build: five woff2 files, no cyrillic or greek. Program themes remap exactly four tokens — primary, primary-bright, primary-foreground, ring — diff-checked in the built CSS. The brief said three; DESIGN.md §2 says four, and without primary-bright a program page's links and stats would stay yellow on a green page. Hand-markup strokes carry pathLength="100" so the draw-on animation's dash math is in percent; a hard-coded length truncates any path longer than the guess, which is what first left the chalk ovals as open arcs. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YRfxMh7FLjQtDbb1BEsCbR --- DESIGN.md | 12 +- eslint.config.ts | 18 +- knip.jsonc | 16 +- package.json | 5 + plan/02-design-system.md | 41 +- pnpm-lock.yaml | 41 ++ src/assets/brand/logo-black-full.svg | 1 + src/assets/brand/logo-color-full.svg | 1 + src/assets/brand/logo-color.svg | 1 + src/assets/brand/logo-white-full.svg | 30 ++ src/components/ui/primitives/Callout.astro | 34 ++ src/components/ui/primitives/ChalkOval.astro | 63 +++ .../ui/primitives/ChalkUnderline.astro | 52 ++ .../ui/primitives/GhostNumeral.astro | 27 + src/components/ui/primitives/Pattern.astro | 62 +++ .../ui/primitives/RegistrationMarks.astro | 42 ++ .../ui/primitives/RulerDivider.astro | 38 ++ .../ui/primitives/SketchArrow.astro | 63 +++ src/components/ui/primitives/TitleBlock.astro | 43 ++ src/lib/contrast.ts | 59 +++ src/pages/styleguide.astro | 488 ++++++++++++++++++ src/styles/fonts.css | 74 +++ src/styles/fonts.ts | 12 + src/styles/global.css | 415 ++++++++++++++- 24 files changed, 1622 insertions(+), 16 deletions(-) create mode 100644 src/assets/brand/logo-black-full.svg create mode 100644 src/assets/brand/logo-color-full.svg create mode 100644 src/assets/brand/logo-color.svg create mode 100644 src/assets/brand/logo-white-full.svg create mode 100644 src/components/ui/primitives/Callout.astro create mode 100644 src/components/ui/primitives/ChalkOval.astro create mode 100644 src/components/ui/primitives/ChalkUnderline.astro create mode 100644 src/components/ui/primitives/GhostNumeral.astro create mode 100644 src/components/ui/primitives/Pattern.astro create mode 100644 src/components/ui/primitives/RegistrationMarks.astro create mode 100644 src/components/ui/primitives/RulerDivider.astro create mode 100644 src/components/ui/primitives/SketchArrow.astro create mode 100644 src/components/ui/primitives/TitleBlock.astro create mode 100644 src/lib/contrast.ts create mode 100644 src/pages/styleguide.astro create mode 100644 src/styles/fonts.css create mode 100644 src/styles/fonts.ts diff --git a/DESIGN.md b/DESIGN.md index 6485e4d..f40d63f 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. | +| Science Blue | `#3B82F6` / `#171717` | `#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. | | 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` (7.9:1) | FLL theme accent pair | +| Destructive | `#DB262F` / `#FAFAFA` (4.6:1) | `#FCA5A5` (8.0: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.7: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/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..e0ce3e6 100644 --- a/plan/02-design-system.md +++ b/plan/02-design-system.md @@ -54,9 +54,38 @@ 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.9: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.7: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. +- **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 + ovals/underlines use `vector-effect="non-scaling-stroke"` because their SVG is stretched + non-uniformly over whatever word it wraps. 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/src/assets/brand/logo-black-full.svg b/src/assets/brand/logo-black-full.svg new file mode 100644 index 0000000..f51e805 --- /dev/null +++ b/src/assets/brand/logo-black-full.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/brand/logo-color-full.svg b/src/assets/brand/logo-color-full.svg new file mode 100644 index 0000000..64915a2 --- /dev/null +++ b/src/assets/brand/logo-color-full.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/brand/logo-color.svg b/src/assets/brand/logo-color.svg new file mode 100644 index 0000000..2acccc3 --- /dev/null +++ b/src/assets/brand/logo-color.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/brand/logo-white-full.svg b/src/assets/brand/logo-white-full.svg new file mode 100644 index 0000000..85ac746 --- /dev/null +++ b/src/assets/brand/logo-white-full.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/ui/primitives/Callout.astro b/src/components/ui/primitives/Callout.astro new file mode 100644 index 0000000..178184a --- /dev/null +++ b/src/components/ui/primitives/Callout.astro @@ -0,0 +1,34 @@ +--- +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. + */ +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"; + class?: string; +} + +const { reference, side = "left", 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..4a3ad3b --- /dev/null +++ b/src/components/ui/primitives/ChalkOval.astro @@ -0,0 +1,63 @@ +--- +import { cn } from "cnfast"; + +/** + * 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?: "chalk" | "pencil"; + 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..cbc9ac6 --- /dev/null +++ b/src/components/ui/primitives/ChalkUnderline.astro @@ -0,0 +1,52 @@ +--- +import { cn } from "cnfast"; + +/** + * 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?: "chalk" | "pencil"; + 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; +--- + + diff --git a/src/components/ui/primitives/Pattern.astro b/src/components/ui/primitives/Pattern.astro new file mode 100644 index 0000000..ca3d79b --- /dev/null +++ b/src/components/ui/primitives/Pattern.astro @@ -0,0 +1,62 @@ +--- +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; + +const opacity = { hero: 0.07, section: 0.05, pocket: 0.04 }[placement]; +const cell = placement === "pocket" ? 26 : 28; +--- + + diff --git a/src/components/ui/primitives/RegistrationMarks.astro b/src/components/ui/primitives/RegistrationMarks.astro new file mode 100644 index 0000000..e073f50 --- /dev/null +++ b/src/components/ui/primitives/RegistrationMarks.astro @@ -0,0 +1,42 @@ +--- +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; + +const corners = [ + { key: "tl", path: "M1 13 L1 1 L13 1" }, + { key: "tr", path: "M11 1 L23 1 L23 13" }, + { key: "br", path: "M23 11 L23 23 L11 23" }, + { key: "bl", path: "M13 23 L1 23 L1 11" }, +] as const; + +const position = { + tl: "top-0 left-0", + tr: "top-0 right-0", + br: "right-0 bottom-0", + bl: "bottom-0 left-0", +} as const; +--- + + 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..1cac8f8 --- /dev/null +++ b/src/components/ui/primitives/SketchArrow.astro @@ -0,0 +1,63 @@ +--- +import { cn } from "cnfast"; + +/** + * 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?: "chalk" | "pencil"; + 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; + +const flip = { + "down-right": "", + "down-left": "-scale-x-100", + right: "-rotate-45", +} as const; + +const rotations = { 1: "0deg", 2: "-3deg", 3: "2deg" } 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; +--- + +
+ { + fields.map((field) => ( +
+
{field.label}
+
+ {field.href === undefined ? field.value : {field.value}} +
+
+ )) + } +
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/pages/styleguide.astro b/src/pages/styleguide.astro new file mode 100644 index 0000000..b9a90db --- /dev/null +++ b/src/pages/styleguide.astro @@ -0,0 +1,488 @@ +--- +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 "@/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 — a token edit cannot quietly break + * accessibility. + */ + +const BACKGROUND = "#262626"; +const CARD = "#171717"; + +interface TextToken { + name: string; + value: string; + floor: number; + use: string; +} + +const textTokens: readonly TextToken[] = [ + { name: "foreground", value: "#FAFAFA", floor: AAA_NORMAL, use: "Headings, nav, emphasis" }, + { name: "body", value: "#D4D4D4", floor: AAA_NORMAL, use: "All reading copy" }, + { name: "muted", value: "#A3A3A3", floor: AA_NORMAL, use: "Captions, meta, labels only" }, + { name: "primary-bright", value: "#FACC15", floor: AA_NORMAL, use: "Links, focus, key words" }, + { name: "info-bright", value: "#60A5FA", floor: AA_NORMAL, use: "Informational text, data UI" }, + { name: "destructive-bright", value: "#FCA5A5", floor: AA_NORMAL, use: "Form errors" }, +]; + +interface FillPair { + name: string; + fill: string; + label: string; +} + +const fillPairs: readonly FillPair[] = [ + { name: "Safety Yellow (primary)", fill: "#FACC15", label: "#171717" }, + { name: "Science Blue (info)", fill: "#3B82F6", label: "#171717" }, + { name: "Hazard Green (FRC primary)", fill: "#16A34A", label: "#08240F" }, + { name: "Danger Orange (FLL primary)", fill: "#F97316", label: "#241102" }, + { name: "Destructive", fill: "#DB262F", label: "#FAFAFA" }, +]; + +const tiers = [ + { name: "platinum", value: "#CBD5E1" }, + { name: "gold", value: "#FACC15" }, + { name: "silver", value: "#A3A3A3" }, + { name: "bronze", value: "#D08954" }, +] as const; + +const surfaces = [ + { name: "background", value: BACKGROUND, use: "Page ground" }, + { name: "card", value: CARD, use: "Pockets: cards, panels, fields, footer" }, + { name: "section-tint", value: "#212121", use: "Full-width alternate section bands" }, + { name: "border", value: "#3A3A3A", use: "Hairlines, dividers" }, + { name: "pocket-border", value: "#383838", use: "Pocket walls" }, +] as const; + +/** 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(token.value, surface); + if (ratio < token.floor) { + failures.push( + `${token.name} (${token.value}) on ${surface} is ${formatRatio(ratio)}, below its ${String(token.floor)}:1 floor`, + ); + } + } +} + +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 = 0.25; +const swipeOnGround = blend("#FACC15", BACKGROUND, SWIPE_ALPHA); +const swipeOnCard = blend("#FACC15", CARD, SWIPE_ALPHA); +const swipeRatio = Math.min( + contrastRatio("#FAFAFA", swipeOnGround), + contrastRatio("#FAFAFA", 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-body" }, + { token: "small", label: "Small", class: "text-small" }, + { token: "label", label: "Spec label", class: "spec-label" }, +] as const; + +const radii = [ + { token: "sm", px: "4px", use: "Chips, spec labels, badges" }, + { token: "md", px: "8px", use: "All controls" }, + { token: "lg", px: "12px", use: "Pockets, panels, framed media" }, +] as const; + +const themes = [ + { name: "Default (SC2 — Safety Yellow)", theme: undefined }, + { name: 'data-theme="frc" (Hazard Green)', theme: "frc" }, + { name: 'data-theme="fll" (Danger Orange)', theme: "fll" }, +] 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. +

+
+ + + + + + + + + + + + + { + textTokens.map((token) => { + const onBackground = contrastRatio(token.value, BACKGROUND); + const onCard = contrastRatio(token.value, CARD); + return ( + + + + + + + + + ); + }) + } + +
TokenValueOn backgroundOn cardFloorUse
+ {token.name} + {token.value}{formatRatio(onBackground)}{formatRatio(onCard)}{token.floor}:1{token.use}
+
+
+ + + + +
+ +

Accents — the fill-vs-text law

+

+ Every accent is a pair: the brand hex for fills with a near-black label, and a brightened + variant for text on dark. One action accent per view. +

+
+ { + fillPairs.map((pair) => ( +
+
+ Label on fill +
+

{pair.name}

+

+ {pair.fill} / {pair.label} ·{" "} + {formatRatio(contrastRatio(pair.fill, pair.label))} +

+
+ )) + } +
+ +

Sponsor tiers

+
+ { + tiers.map((tier) => ( + + {tier.name} · {formatRatio(contrastRatio(tier.value, CARD))} + + )) + } +
+
+ + + + +
+ +

Typography

+

+ 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))} +

+ + + +
+
+ Framed media, 2px primary border +
+ +

Title block

+ +
+ + + + +
+ +

Program themes

+

+ 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 button + + + Outline button + + + An in-prose link + + 12 + +
+
+
+ )) + } +
+
+
+ + diff --git a/src/styles/fonts.css b/src/styles/fonts.css new file mode 100644 index 0000000..d74d127 --- /dev/null +++ b/src/styles/fonts.css @@ -0,0 +1,74 @@ +/** + * Self-hosted faces, latin subsets only (DESIGN.md §3). The @fontsource packages ship every + * subset in their own CSS; declaring the faces here keeps cyrillic/greek/vietnamese out of the + * build entirely. Vite resolves the bare package paths and fingerprints each file. + * + * Unicode ranges are copied from the upstream @fontsource CSS. + */ + +/* Inter — UI and body. */ +@font-face { + font-family: "Inter"; + font-style: normal; + font-display: swap; + font-weight: 100 900; + src: url("@fontsource-variable/inter/files/inter-latin-wght-normal.woff2") + format("woff2-variations"); + unicode-range: + U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, + U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +@font-face { + font-family: "Inter"; + font-style: normal; + font-display: swap; + font-weight: 100 900; + src: url("@fontsource-variable/inter/files/inter-latin-ext-wght-normal.woff2") + format("woff2-variations"); + unicode-range: + U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, + U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, + U+A720-A7FF; +} + +/* Orbitron — display headings and eyebrow labels. Latin only upstream. */ +@font-face { + font-family: "Orbitron"; + font-style: normal; + font-display: swap; + font-weight: 400 900; + src: url("@fontsource-variable/orbitron/files/orbitron-latin-wght-normal.woff2") + format("woff2-variations"); + unicode-range: + U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, + U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +/* Source Code Pro — the data voice: numerals, stats, spec labels. */ +@font-face { + font-family: "Source Code Pro"; + font-style: normal; + font-display: swap; + font-weight: 200 900; + src: url("@fontsource-variable/source-code-pro/files/source-code-pro-latin-wght-normal.woff2") + format("woff2-variations"); + unicode-range: + U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, + U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +/** + * Architects Daughter — the annotation hand, markup only (≤5 words per page). Latin subset + * alone, and never preloaded: it renders below the fold or not at all. + */ +@font-face { + font-family: "Architects Daughter"; + font-style: normal; + font-display: swap; + font-weight: 400; + src: url("@fontsource/architects-daughter/files/architects-daughter-latin-400-normal.woff2") + format("woff2"); + unicode-range: + U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, + U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} diff --git a/src/styles/fonts.ts b/src/styles/fonts.ts new file mode 100644 index 0000000..3e204e6 --- /dev/null +++ b/src/styles/fonts.ts @@ -0,0 +1,12 @@ +import interLatin from "@fontsource-variable/inter/files/inter-latin-wght-normal.woff2?url"; +import orbitronLatin from "@fontsource-variable/orbitron/files/orbitron-latin-wght-normal.woff2?url"; + +/** + * The two faces that render above the fold on every page, for `` in + * BaseLayout. Source Code Pro and Architects Daughter are deliberately absent: their first + * paint is below the fold, and preloading four faces costs more than it saves. + * + * These are the same emitted assets the `@font-face` rules in `fonts.css` point at, so a + * preload always matches the URL the face resolves to. + */ +export const preloadedFonts = [interLatin, orbitronLatin] as const; diff --git a/src/styles/global.css b/src/styles/global.css index 87dd884..c25ee58 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -1,7 +1,420 @@ @import "tailwindcss"; +@import "./fonts.css"; /* legacy/ is checked in and reference-only, so Tailwind's automatic source detection would otherwise emit utilities for the retired Next.js site. */ @source not "../../legacy"; -/* Design tokens land here in Phase 02 (see DESIGN.md). */ +/** + * The token set from DESIGN.md. Values here are normative-by-copy: if one disagrees with the + * doc, the doc wins (DESIGN.md §11). `/styleguide` renders every pair's computed contrast ratio + * and fails visibly when one drops below its floor. + * + * Dark is the only theme today (D15). Base tokens live on `:root` with dark values as the + * defaults, so a light theme is a `[data-theme="light"]` block that re-declares them — a token + * addition, not a refactor. That block would land right after the program themes below. + */ +@theme { + /* --- Surfaces: the recessed system (DESIGN.md §2) --------------------------------------- */ + /* The page is raised material; cards are pockets machined into it. Cards are *darker*. */ + --color-background: #262626; + --color-card: #171717; + --color-section-tint: #212121; + --color-border: #3a3a3a; + /* Pocket walls read one step brighter than a plain divider. */ + --color-pocket-border: #383838; + /* Interactive pocket hover: the floor lifts one step, nothing translates or glows. */ + --color-card-hover: #1a1a1a; + + /* --- Text ramp (DESIGN.md §2) ----------------------------------------------------------- */ + --color-foreground: #fafafa; /* 14.5:1 on background — headings, nav, emphasis */ + --color-body: #d4d4d4; /* 10.2:1 — all reading copy */ + --color-muted: #a3a3a3; /* 6.0:1 — captions, meta, labels only. Never paragraphs. */ + + /* --- Accents: the fill-vs-text law (DESIGN.md §2) --------------------------------------- */ + /* Every accent is a pair: brand hex for fills with a near-black label, and a brightened + variant for text/icons/focus on dark. Safety Yellow is bright enough to be both. */ + --color-primary: #facc15; + --color-primary-bright: #facc15; + --color-primary-foreground: #171717; + --color-ring: #facc15; + + /* Science Blue — informational only, and the reserved primary of a future light theme. */ + --color-info: #3b82f6; + --color-info-bright: #60a5fa; + --color-info-foreground: #171717; + + /* Errors. Added in Phase 02 under DESIGN.md §11: §8 requires a destructive text token but + the doc named no value. Follows the same pair law. */ + --color-destructive: #db262f; + --color-destructive-bright: #fca5a5; + --color-destructive-foreground: #fafafa; + + /* --- Sponsor tiers (DESIGN.md §8) ------------------------------------------------------- */ + --color-tier-platinum: #cbd5e1; + --color-tier-gold: #facc15; + --color-tier-silver: #a3a3a3; + --color-tier-bronze: #d08954; + + /* --- Typography (DESIGN.md §3) ---------------------------------------------------------- */ + --font-display: "Orbitron", ui-sans-serif, system-ui, sans-serif; + --font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + --font-mono: "Source Code Pro", ui-monospace, "SFMono-Regular", Menlo, monospace; + --font-hand: "Architects Daughter", "Comic Sans MS", cursive; + + /* Fluid between 360px and 1440px viewports. */ + --text-display: clamp(2.5rem, 1.9167rem + 2.5926vw, 4.25rem); + --text-display--line-height: 1.05; + --text-h1: clamp(2rem, 1.6667rem + 1.4815vw, 3rem); + --text-h1--line-height: 1.1; + --text-h2: clamp(1.5rem, 1.25rem + 1.1111vw, 2.25rem); + --text-h2--line-height: 1.15; + --text-h3: clamp(1.25rem, 1.1667rem + 0.3704vw, 1.5rem); + --text-h3--line-height: 1.25; + --text-h4: clamp(1.125rem, 1.0833rem + 0.1852vw, 1.25rem); + --text-h4--line-height: 1.35; + --text-stat: clamp(1.75rem, 1.5833rem + 0.7407vw, 2.25rem); + --text-stat--line-height: 1.1; + --text-body-lg: clamp(1.0625rem, 1.0208rem + 0.1852vw, 1.1875rem); + --text-body-lg--line-height: 1.65; + --text-body: clamp(1rem, 0.9792rem + 0.0926vw, 1.0625rem); + --text-body--line-height: 1.65; + --text-small: 0.875rem; + --text-small--line-height: 1.5; + --text-label: clamp(0.6875rem, 0.6667rem + 0.0926vw, 0.75rem); + --text-label--line-height: 1.4; + --text-label--letter-spacing: 0.05em; + + /* --- Radius: one end-mill (DESIGN.md §4) ------------------------------------------------ */ + /* Three values, nothing else. 16px and pill radii are retired. */ + --radius-sm: 4px; /* chips, spec labels, badges */ + --radius-md: 8px; /* every control: buttons, inputs, icon tiles */ + --radius-lg: 12px; /* every cut feature: pockets, panels, framed media */ + + /* --- Motion (DESIGN.md §6) -------------------------------------------------------------- */ + --duration-micro: 150ms; /* hover, focus */ + --duration-ui: 250ms; /* menus, accordions */ + --duration-entrance: 500ms; /* scroll-in entrances */ + --ease-entrance: cubic-bezier(0, 0, 0.2, 1); /* ease-out */ + --ease-toggle: cubic-bezier(0.4, 0, 0.2, 1); /* ease-in-out */ + + /* --- Layout ----------------------------------------------------------------------------- */ + --breakpoint-3xl: 120rem; +} + +/** + * Program themes (DESIGN.md §2, D16). A program page is the same site wearing team colors: + * these blocks remap the action accent and nothing else. Adding a program is one block. + */ +[data-theme="frc"] { + --color-primary: #16a34a; /* Hazard Green */ + --color-primary-bright: #3ecf6e; + --color-primary-foreground: #08240f; + --color-ring: #3ecf6e; +} + +[data-theme="fll"] { + --color-primary: #f97316; /* Danger Orange */ + --color-primary-bright: #fb923c; + --color-primary-foreground: #241102; + --color-ring: #fb923c; +} + +@layer base { + :root { + color-scheme: dark; + } + + html { + -webkit-text-size-adjust: 100%; + } + + body { + background-color: var(--color-background); + color: var(--color-body); + font-family: var(--font-sans); + font-size: var(--text-body); + line-height: var(--text-body--line-height); + -webkit-font-smoothing: antialiased; + } + + /* Orbitron owns h1–h3 and stops there (DESIGN.md §3): h4–h6 are Inter. */ + h1, + h2, + h3 { + color: var(--color-foreground); + font-family: var(--font-display); + font-weight: 600; + text-wrap: balance; + } + + h1 { + font-size: var(--text-h1); + font-weight: 700; + line-height: var(--text-h1--line-height); + } + + h2 { + font-size: var(--text-h2); + line-height: var(--text-h2--line-height); + } + + h3 { + font-size: var(--text-h3); + line-height: var(--text-h3--line-height); + } + + h4, + h5, + h6 { + color: var(--color-foreground); + font-family: var(--font-sans); + font-size: var(--text-h4); + font-weight: 600; + line-height: var(--text-h4--line-height); + } + + p { + text-wrap: pretty; + } + + /* In-prose links are accent-colored and underlined (DESIGN.md §8). */ + a { + color: var(--color-primary-bright); + text-decoration-thickness: 1px; + text-underline-offset: 0.2em; + } + + /* Never removed, on any interactive element (DESIGN.md §9). */ + :focus-visible { + outline: 2px solid var(--color-ring); + outline-offset: 2px; + } + + ::selection { + background-color: var(--color-primary); + color: var(--color-primary-foreground); + } + + /* Numerals and dates speak in the data voice wherever they stand alone. */ + time, + data { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + } + + /* Media never overflows its column. */ + img, + picture, + video, + svg { + display: block; + max-width: 100%; + height: auto; + } +} + +/** + * Global reduced-motion kill switch (DESIGN.md §6, §9). Every animation in the system is + * confirmation, never content, so removing all of them costs nothing. + */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + +/** + * Signature motifs (DESIGN.md §2) as utilities, so pages cannot reinvent them. The register + * budget is a review rule, not something CSS can enforce: at most 4 distinct devices per + * viewport, and one atmosphere device per section boundary. + */ + +/* Pocket anatomy V2, "machined pocket" — the standard card. Depth goes down, never up. */ +@utility pocket { + background-color: var(--color-card); + border: 1px solid var(--color-pocket-border); + border-radius: var(--radius-lg); + box-shadow: + inset 0 2px 8px rgb(0 0 0 / 0.55), + inset 0 -1px 0 rgb(255 255 255 / 0.05); +} + +/* Interactive pockets: the border warms and the floor lifts one step. Nothing translates, + nothing glows, the shadow does not change. */ +@utility pocket-interactive { + transition: + background-color var(--duration-micro) var(--ease-toggle), + border-color var(--duration-micro) var(--ease-toggle); + + &:hover, + &:focus-within { + background-color: var(--color-card-hover); + border-color: color-mix(in srgb, var(--color-primary) 40%, transparent); + } +} + +/* 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) { + background-image: + linear-gradient(to right, rgb(250 250 250 / 0.05) 1px, transparent 1px), + linear-gradient(to bottom, rgb(250 250 250 / 0.05) 1px, transparent 1px); + background-size: 26px 26px; + } +} + +/* The 2px primary hairline that ends a hero, full-bleed. */ +@utility accent-rule { + height: 2px; + background-color: var(--color-primary); + border: 0; +} + +/* The 32px rule under a card title. Machined: perfectly straight, unlike a chalk underline. */ +@utility title-rule { + width: 32px; + height: 2px; + background-color: var(--color-primary); + border: 0; +} + +/* The "team picture frame" for photo collages and feature media. */ +@utility media-frame { + border: 2px solid var(--color-primary); + border-radius: var(--radius-lg); + overflow: hidden; +} + +/* Highlighter swipe: a skewed translucent marker stroke behind white key words. A heading + uses `primary` text *or* a swipe, never both. */ +@utility highlight-swipe { + position: relative; + color: var(--color-foreground); + isolation: isolate; + + &::before { + content: ""; + position: absolute; + inset: 0.08em -0.12em 0.06em -0.12em; + z-index: -1; + /* 25% keeps white-on-swipe at AAA (7.7:1 on the ground); verified on /styleguide. */ + background-color: color-mix(in srgb, var(--color-primary) 25%, transparent); + border-radius: 2px; + transform: rotate(-0.8deg); + } +} + +/* Eyebrow label — Orbitron's one all-caps use (DESIGN.md §3). */ +@utility eyebrow { + font-family: var(--font-display); + font-size: 0.75rem; + font-weight: 500; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +/* Spec label — the other sanctioned caps: Source Code Pro, tracked. */ +@utility spec-label { + font-family: var(--font-mono); + font-size: var(--text-label); + font-weight: 600; + letter-spacing: var(--text-label--letter-spacing); + line-height: var(--text-label--line-height); + text-transform: uppercase; +} + +/* Spec chip — a stamped plate: transparent fill, 40%-alpha border in the chip's own color. */ +@utility spec-chip { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.1875rem 0.5rem; + border: 1px solid currentcolor; + border-radius: var(--radius-sm); + background-color: transparent; +} + +/* Stat numeral — the data voice (DESIGN.md §8). */ +@utility stat { + font-family: var(--font-mono); + font-size: var(--text-stat); + font-weight: 600; + line-height: var(--text-stat--line-height); + color: var(--color-primary-bright); + font-variant-numeric: tabular-nums; +} + +/* Ambient pool — a small lightness modulation of the ground, anchored to a section's top. + One per section, never stacked with another atmosphere device, never behind a photo. */ +@utility ambient-pool { + position: relative; + isolation: isolate; + + &::before { + content: ""; + position: absolute; + inset: 0; + z-index: -1; + pointer-events: none; + background: radial-gradient( + ellipse 80% 60% at 50% 0%, + color-mix(in srgb, var(--color-primary) 5%, transparent), + transparent 70% + ); + } +} + +@utility ambient-pool-neutral { + &::before { + background: radial-gradient( + ellipse 80% 60% at 50% 0%, + rgb(250 250 250 / 0.03), + transparent 70% + ); + } +} + +/* Section rhythm (DESIGN.md §4) — consistent on every section. */ +@utility section-y { + padding-block: 4rem; + + @media (width >= 48rem) { + padding-block: 6rem; + } +} + +/* Prose measure (DESIGN.md §3). */ +@utility measure { + max-width: 70ch; +} + +/** + * Hand-markup entrance: strokes draw themselves on. The one sanctioned decorative animation, + * because it enacts the metaphor. Reduced motion disables it via the global kill switch, and + * the final state is a fully drawn stroke either way. + */ +@keyframes draw-on { + from { + stroke-dashoffset: 100; + } + to { + stroke-dashoffset: 0; + } +} + +/** + * Paired with `pathLength="100"` on the animated path, so the dash math is in percent and the + * stroke always ends fully drawn. Hard-coded lengths silently truncate any path longer than + * the guess. + */ +@utility draw-on { + stroke-dasharray: 100; + animation: draw-on var(--duration-entrance) var(--ease-entrance) both; +} From 32b3bd859333b75a8b59bcab8a34220f44003b1e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 11:17:27 +0000 Subject: [PATCH 2/2] overhaul: close review findings on the design system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three DESIGN.md §8 rules were declared but not in force. In-prose links set only underline thickness and offset, and preflight's `text-decoration: inherit` resolved the line to `none`, so links were colour-only; `spec-chip` used an opaque `currentcolor` border where the doc asks for 40% alpha; and adding `svg` to the base `max-width: 100%` rule clamped ChalkOval's deliberate 2rem overshoot, since `max-width` cannot be overridden by a `width` utility. `SketchArrow direction="right"` was a silent no-op: Tailwind v4 implements `-rotate-45` with the individual `rotate` property, which the per-variant inline `style` overrode. Direction is now summed into that one value; mirroring stays a class because `scale` does not collide. `--color-body` and `--text-body` both claimed `.text-body` and the colour won, leaving the size token unreachable and the type-ramp row demonstrating a colour. Added `@utility text-copy` and pointed the ramp at it. `/styleguide`'s contrast gate compared a hand-typed copy of the palette against itself, so a token edited only in the stylesheet left the build green. `@/lib/tokens` now parses the `@theme` and `[data-theme]` blocks out of `global.css`, and the program themes' `primary-bright` accents are gated too — with the theme list read from the stylesheet, so a new program cannot ship an unchecked accent. Verified: dimming `--color-muted`, renaming a token, breaking the FLL accent, and changing the swipe alpha each now fail the build. DESIGN.md's accent ratios disagreed with the repo's own verifier. Danger Orange is 6.6:1, not 7.9:1 — the old number sat above the 7:1 AAA line while the real value sits below it. Corrected alongside Safety Yellow (9.8), Science Blue (5.9), destructive text (7.9) and the swipe (7.6), and the measured value now renders per theme on the page. `Callout` emitted `
` with no `
` anywhere on the page. It renders `

` by default; `as="figcaption"` is opt-in and the caller owns the figure. `ambient-pool-neutral` overrides only a `::before` background, so used alone it emitted no pseudo-element — documented as the modifier it is. Quality: the §2.3 engineering grid was defined twice, in CSS and as an inline style with a raw `#FAFAFA`, already disagreeing on alpha; it is now one `@utility engineering-grid` driven by custom properties off `--color-foreground`. The §13 stroke contract was duplicated across three hand-markup devices, which had already drifted on `vector-effect`; it is now `@utility hand-stroke` plus a shared tone mapping. RegistrationMarks encoded one bracket as four hand-computed paths. The four `public/image/svg/logo-*.svg` files were byte-identical to `src/assets/brand/` and unreferenced, deploying 48 KB unhashed. The page was `noindex` and in the sitemap — a "Submitted URL marked 'noindex'" error. Added a sitemap filter. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BX5PrKuYNRLVxiEj3eejhs --- DESIGN.md | 10 +- astro.config.ts | 4 +- plan/02-design-system.md | 23 ++- public/image/svg/logo-black-full.svg | 1 - public/image/svg/logo-color-full.svg | 1 - public/image/svg/logo-color.svg | 1 - public/image/svg/logo-white-full.svg | 30 ---- src/components/ui/primitives/Callout.astro | 13 +- src/components/ui/primitives/ChalkOval.astro | 20 +-- .../ui/primitives/ChalkUnderline.astro | 17 +- src/components/ui/primitives/Pattern.astro | 19 +-- .../ui/primitives/RegistrationMarks.astro | 30 ++-- .../ui/primitives/SketchArrow.astro | 44 +++-- src/lib/hand.ts | 10 ++ src/lib/tokens.ts | 99 +++++++++++ src/pages/styleguide.astro | 158 ++++++++++++------ src/styles/global.css | 80 ++++++++- 17 files changed, 377 insertions(+), 183 deletions(-) delete mode 100644 public/image/svg/logo-black-full.svg delete mode 100644 public/image/svg/logo-color-full.svg delete mode 100644 public/image/svg/logo-color.svg delete mode 100644 public/image/svg/logo-white-full.svg create mode 100644 src/lib/hand.ts create mode 100644 src/lib/tokens.ts diff --git a/DESIGN.md b/DESIGN.md index f40d63f..d10ceca 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -58,11 +58,11 @@ Every brand accent is a **token pair**: the brand hex for *fills* (buttons, band | 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` / `#171717` | `#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` (7.9:1) | FLL theme accent pair | -| Destructive | `#DB262F` / `#FAFAFA` (4.6:1) | `#FCA5A5` (8.0:1) | **Errors only**: form validation, destructive confirmations. Not a brand accent and never decorative — it appears when something is wrong and nowhere else. | +| 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) @@ -104,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 — **25% is the default**, the only value in the range that keeps white text at AAA: 7.7: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`. +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/plan/02-design-system.md b/plan/02-design-system.md index e0ce3e6..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 @@ -69,13 +69,13 @@ Implement DESIGN.md §2 "signature motifs" as reusable pieces so pages can't rei 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.9:1 and makes + 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.7:1 on the ground). + 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 @@ -85,7 +85,20 @@ Implement DESIGN.md §2 "signature motifs" as reusable pieces so pages can't rei 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 - ovals/underlines use `vector-effect="non-scaling-stroke"` because their SVG is stretched - non-uniformly over whatever word it wraps. + 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/public/image/svg/logo-black-full.svg b/public/image/svg/logo-black-full.svg deleted file mode 100644 index f51e805..0000000 --- a/public/image/svg/logo-black-full.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/image/svg/logo-color-full.svg b/public/image/svg/logo-color-full.svg deleted file mode 100644 index 64915a2..0000000 --- a/public/image/svg/logo-color-full.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/image/svg/logo-color.svg b/public/image/svg/logo-color.svg deleted file mode 100644 index 2acccc3..0000000 --- a/public/image/svg/logo-color.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/image/svg/logo-white-full.svg b/public/image/svg/logo-white-full.svg deleted file mode 100644 index 85ac746..0000000 --- a/public/image/svg/logo-white-full.svg +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/components/ui/primitives/Callout.astro b/src/components/ui/primitives/Callout.astro index 178184a..d2d0a50 100644 --- a/src/components/ui/primitives/Callout.astro +++ b/src/components/ui/primitives/Callout.astro @@ -5,19 +5,26 @@ 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", class: className } = Astro.props; +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 index 4a3ad3b..2afaceb 100644 --- a/src/components/ui/primitives/ChalkOval.astro +++ b/src/components/ui/primitives/ChalkOval.astro @@ -1,6 +1,8 @@ --- 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. @@ -14,7 +16,7 @@ import { cn } from "cnfast"; interface Props { variant?: 1 | 2 | 3; /** `chalk` is white for photo contexts; `pencil` is the accent, for the page ground. */ - tone?: "chalk" | "pencil"; + tone?: HandTone; class?: string; } @@ -39,25 +41,17 @@ 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 index cbc9ac6..6ef816a 100644 --- a/src/components/ui/primitives/ChalkUnderline.astro +++ b/src/components/ui/primitives/ChalkUnderline.astro @@ -1,6 +1,8 @@ --- 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. @@ -12,7 +14,7 @@ import { cn } from "cnfast"; */ interface Props { variant?: 1 | 2 | 3; - tone?: "chalk" | "pencil"; + tone?: HandTone; class?: string; } @@ -33,20 +35,13 @@ const rotations = { 1: "-0.5deg", 2: "0.4deg", 3: "-0.8deg" } as const; diff --git a/src/components/ui/primitives/Pattern.astro b/src/components/ui/primitives/Pattern.astro index ca3d79b..ee4333e 100644 --- a/src/components/ui/primitives/Pattern.astro +++ b/src/components/ui/primitives/Pattern.astro @@ -19,8 +19,13 @@ interface Props { const { placement = "section", ticks = false, class: className } = Astro.props; -const opacity = { hero: 0.07, section: 0.05, pocket: 0.04 }[placement]; -const cell = placement === "pocket" ? 26 : 28; +/** + * 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"; ---
{ diff --git a/src/components/ui/primitives/RegistrationMarks.astro b/src/components/ui/primitives/RegistrationMarks.astro index e073f50..3438a32 100644 --- a/src/components/ui/primitives/RegistrationMarks.astro +++ b/src/components/ui/primitives/RegistrationMarks.astro @@ -12,30 +12,26 @@ interface Props { 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 = [ - { key: "tl", path: "M1 13 L1 1 L13 1" }, - { key: "tr", path: "M11 1 L23 1 L23 13" }, - { key: "br", path: "M23 11 L23 23 L11 23" }, - { key: "bl", path: "M13 23 L1 23 L1 11" }, + "top-0 left-0", + "top-0 right-0 rotate-90", + "right-0 bottom-0 rotate-180", + "bottom-0 left-0 -rotate-90", ] as const; - -const position = { - tl: "top-0 left-0", - tr: "top-0 right-0", - br: "right-0 bottom-0", - bl: "bottom-0 left-0", -} as const; ---

Title block

(

{entry.name}

+

+ primary-bright {entry.accent} · on background{" "} + {formatRatio(contrastRatio(entry.accent, BACKGROUND))} · on card{" "} + {formatRatio(contrastRatio(entry.accent, CARD))} +

Primary button @@ -466,7 +520,7 @@ const themes = [ Outline button - + An in-prose link 12 diff --git a/src/styles/global.css b/src/styles/global.css index c25ee58..269eda2 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -178,9 +178,12 @@ text-wrap: pretty; } - /* In-prose links are accent-colored and underlined (DESIGN.md §8). */ + /* In-prose links are accent-colored and underlined (DESIGN.md §8). Preflight sets + `text-decoration: inherit` on `a`, which resolves to `none` from body — so the line + must be declared here, not only its thickness and offset. */ a { color: var(--color-primary-bright); + text-decoration-line: underline; text-decoration-thickness: 1px; text-underline-offset: 0.2em; } @@ -203,15 +206,20 @@ font-variant-numeric: tabular-nums; } - /* Media never overflows its column. */ + /* Media never overflows its column. `svg` is deliberately excluded: `max-width` is a + different property from `width`, so a utilities-layer `width` cannot override it, and + the hand-markup primitives size their strokes past the text box on purpose. */ img, picture, - video, - svg { + video { display: block; max-width: 100%; height: auto; } + + svg { + display: block; + } } /** @@ -259,14 +267,36 @@ } } +/** + * The engineering grid (DESIGN.md §2.3). One definition, two consumers: `pocket-feature` below + * and `Pattern.astro`. Cell size and stroke alpha are custom properties, and the stroke reads + * `--color-foreground` rather than a literal hex — so the grid follows a future light theme's + * token set instead of needing a component edit (D15). Not `currentcolor`: this utility lands on + * elements that also hold text, and the linework must not be tied to the copy's color. + */ +@utility engineering-grid { + --grid-cell: 26px; + --grid-alpha: 5%; + --grid-line: color-mix(in srgb, var(--color-foreground) var(--grid-alpha), transparent); + + background-image: + linear-gradient(to right, var(--grid-line) 1px, transparent 1px), + linear-gradient(to bottom, var(--grid-line) 1px, transparent 1px); + background-size: var(--grid-cell) var(--grid-cell); + + /* A `var()` alpha cannot be pre-resolved, so the build's computed fallback for `--grid-line` + lands on opaque foreground — solid white linework over content. The grid is atmosphere, + never information, so drop it entirely where `color-mix` is unavailable. */ + @supports not (color: color-mix(in srgb, red, red)) { + background-image: none; + } +} + /* 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) { - background-image: - linear-gradient(to right, rgb(250 250 250 / 0.05) 1px, transparent 1px), - linear-gradient(to bottom, rgb(250 250 250 / 0.05) 1px, transparent 1px); - background-size: 26px 26px; + @apply engineering-grid; } } @@ -336,7 +366,7 @@ align-items: center; gap: 0.375rem; padding: 0.1875rem 0.5rem; - border: 1px solid currentcolor; + border: 1px solid color-mix(in srgb, currentcolor 40%, transparent); border-radius: var(--radius-sm); background-color: transparent; } @@ -371,6 +401,9 @@ } } +/* Modifier for `ambient-pool`, not a peer of it: this only re-declares the gradient, so it + must be composed as `ambient-pool ambient-pool-neutral`. Used alone it emits a `::before` + with no `content` and renders nothing. */ @utility ambient-pool-neutral { &::before { background: radial-gradient( @@ -390,6 +423,35 @@ } } +/** + * The shared stroke contract for the hand-markup register (DESIGN.md §13): 2.5px round-capped + * `currentColor`, with `non-scaling-stroke` so the weight stays even when the SVG is stretched + * non-uniformly over a word. Applied to the ``; the paths carry only their `d` and + * `pathLength`, so a change to the hand's weight is one edit rather than one per device. + */ +@utility hand-stroke { + fill: none; + + & path { + stroke: currentcolor; + stroke-linecap: round; + stroke-width: 2.5; + vector-effect: non-scaling-stroke; + } +} + +/** + * `body` names both a color (§2) and a type size (§3), and Tailwind resolves the color + * namespace first — so `text-body` is the *color*, matching `text-muted` and `text-foreground`, + * and the size would otherwise be unreachable. Both tokens stay faithful to the doc; the size + * gets this utility. Elements inherit the body size from the base layer, so reach for + * `text-copy` only when resetting something back to it. + */ +@utility text-copy { + font-size: var(--text-body); + line-height: var(--text-body--line-height); +} + /* Prose measure (DESIGN.md §3). */ @utility measure { max-width: 70ch;