overhaul: 02 design system - #16
Conversation
Turn DESIGN.md into working CSS: the full token set, fluid type scale, self-hosted fonts, program themes, the motif components, and a /styleguide page that proves all of it. Contrast is verified at build time, not by eye. /styleguide computes every pair required by DESIGN.md §9 and throws with the offending pairs listed if one drops below its floor, so a token edit cannot quietly break accessibility. That verification caught two defects in the spec, amended here per DESIGN.md §11: - Science Blue's fill used a white label, which measures 3.5:1 — below AA, on fills that carry chip-sized text. Changed to the brand near-black (4.9:1), which also makes blue consistent with every other fill. - body's stated ratio was "≈11.5:1"; it measures 10.2:1. Still AAA. Also added a destructive pair, which §8 required but the doc never valued, and pinned the highlighter swipe at 25% alpha — the only value in the documented range that keeps white text at AAA. Fonts are declared face by face rather than through the @fontsource CSS, so only latin subsets reach the build: five woff2 files, no cyrillic or greek. Program themes remap exactly four tokens — primary, primary-bright, primary-foreground, ring — diff-checked in the built CSS. The brief said three; DESIGN.md §2 says four, and without primary-bright a program page's links and stats would stay yellow on a green page. Hand-markup strokes carry pathLength="100" so the draw-on animation's dash math is in percent; a hard-coded length truncates any path longer than the guess, which is what first left the chalk ovals as open arcs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YRfxMh7FLjQtDbb1BEsCbR
bf8dfc9 to
59ac30c
Compare
CS-5
left a comment
There was a problem hiding this comment.
Code review of the design-system layer. Verified by installing this branch, running pnpm check (green) and pnpm build, then reading the emitted dist/_astro/global.*.css, dist/styleguide/index.html and dist/sitemap-0.xml, and by compiling global.css against the pinned tailwindcss@4.3.3 to confirm what the utilities actually resolve to. Contrast numbers below come from this PR's own src/lib/contrast.ts.
The structure is good and the build-time contrast idea is the right instinct. Eleven findings, four of which change what ships:
Rendering / behavior
global.css— in-prose links get no underline:text-decoration-lineis never set, and preflight'sa { text-decoration: inherit }resolves it tonone. DESIGN.md §8 unimplemented, and links become color-only.global.css— addingsvgto the basemax-width: 100%rule clampsChalkOval'sw-[calc(100%+2rem)], so the oval renders word-width and offset 1rem left instead of ringing the word.SketchArrow.astro—direction="right"is dead: the inlinerotatestyle overrides the-rotate-45class, since Tailwind v4 rotation uses therotateproperty.global.css—--color-bodyand--text-bodyboth claim.text-body; the color wins, so the body size token has no usable utility (and the type-ramp row demonstrating it silently sets a color).
The guard itself
5. styleguide.astro — the contrast check reads hex literals duplicated into the page, not global.css, so a token edit on the CSS side alone leaves the build green against stale values.
6. DESIGN.md — Danger Orange's "corrected" 7.9:1 is the destructive token's number; #FB923C measures 6.6:1. The error crosses the AAA line, and the guard's AA-only floor can't catch it.
Smaller
7. /styleguide is noindex but still listed in dist/sitemap-0.xml — one-line filter in astro.config.ts.
8. spec-chip's border is opaque currentcolor, while its comment and DESIGN.md §8 both specify 40% alpha.
9. Callout renders <figcaption> with no <figure> anywhere on the page.
10. ambient-pool-neutral silently renders nothing unless composed with ambient-pool.
11. Swipe (7.6 vs 7.7) and Science Blue label (4.8 vs 4.9) doc figures are each 0.1 off the verifier.
Nothing here is structural — 1, 2 and 3 are the ones I'd fix before merge.
Generated by Claude Code
| /* 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; |
There was a problem hiding this comment.
In-prose links are not actually underlined.
Tailwind's preflight already ships a { color: inherit; text-decoration: inherit }. Since this rule sets only text-decoration-thickness and text-underline-offset — never text-decoration-line — the line style stays inherit, which resolves to none from body. Both declarations here are inert.
Verified in the built stylesheet (dist/_astro/global.*.css):
a{color:var(--color-primary-bright);text-underline-offset:.2em;text-decoration-thickness:1px}No underline declaration anywhere. So on /styleguide both "An in-prose link" and the handwritten "Become a sponsor" render as accent-colored text with no underline — DESIGN.md §8 ("in-prose links primary-colored and underlined") is unimplemented, and link identification falls back to color alone (WCAG 1.4.1).
| /* 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; | |
| a { | |
| color: var(--color-primary-bright); | |
| text-decoration-line: underline; | |
| text-decoration-thickness: 1px; | |
| text-underline-offset: 0.2em; | |
| } |
Generated by Claude Code
| /* Media never overflows its column. */ | ||
| img, | ||
| picture, | ||
| video, | ||
| svg { | ||
| display: block; | ||
| max-width: 100%; | ||
| height: auto; | ||
| } |
There was a problem hiding this comment.
Adding svg to this rule breaks ChalkOval.
ChalkOval.astro:42 sizes its SVG as -inset-x-4 … w-[calc(100%+2rem)] so the stroke overshoots the word by 1rem on each side. max-width is a different property from width, so the utilities-layer width cannot override this base-layer max-width: 100% — the computed width collapses to the containing block's width while left stays at -1rem.
Result: the oval renders word-width and shifted 1rem to the left, ending before the word does, instead of ringing it. This is exactly the failure mode the component's own comment warns about ("a path covering only part of the box lands beside the word instead of around it") — reintroduced from the stylesheet side.
Preflight already covers img, video; it deliberately leaves svg out. Either drop svg here, or give the oval/underline SVGs max-width: none.
| /* Media never overflows its column. */ | |
| img, | |
| picture, | |
| video, | |
| svg { | |
| display: block; | |
| max-width: 100%; | |
| height: auto; | |
| } | |
| /* Media never overflows its column. */ | |
| img, | |
| picture, | |
| video { | |
| display: block; | |
| max-width: 100%; | |
| height: auto; | |
| } | |
| svg { | |
| display: block; | |
| } |
Generated by Claude Code
| className, | ||
| )} | ||
| fill="none" | ||
| style={`rotate: ${rotations[variant]}`} |
There was a problem hiding this comment.
direction="right" is a silent no-op.
Tailwind v4 implements rotation with the individual rotate property, not a transform. Compiled against the pinned tailwindcss@4.3.3:
.-rotate-45 { rotate: calc(45deg * -1); }That is the same property this inline style sets, and an inline style always beats a class. So flip.right on line 33 never applies: <SketchArrow direction="right" /> renders at rotations[1] = 0deg, i.e. identical to down-right. Variants 2/3 land at -3deg/2deg rather than -45deg.
down-left survives only because -scale-x-100 sets scale, a different property.
Fold the direction into the same value, e.g.:
const directionRotation = { "down-right": "0deg", "down-left": "0deg", right: "-45deg" } as const;
// style={`rotate: calc(${rotations[variant]} + ${directionRotation[direction]})`}Generated by Claude Code
|
|
||
| /* --- 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 */ |
There was a problem hiding this comment.
--color-body collides with --text-body on the class text-body.
Tailwind's text-* namespace serves both colors and font sizes, so --color-body (here) and --text-body (line 82) both claim .text-body. The color wins — the built stylesheet has exactly one rule and no font-size counterpart:
.text-body{color:var(--color-body)}Two consequences:
--text-bodyhas no reachable utility at all.styleguide.astro:130lists{ token: "body", class: "text-body" }in the type ramp intending to demonstrate the size token; it sets a color instead. It only looks correct because that row inheritsbody's font size, which happens to bevar(--text-body).- Any future page writing
class="text-body"for sizing silently gets a color change — the kind of thing that survives review because it renders plausibly.
Worth renaming one side (e.g. --color-text-body, or --text-base for the size) so the two namespaces stop overlapping.
Generated by Claude Code
| | 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 | |
There was a problem hiding this comment.
This correction has the wrong number — the two rows look swapped.
Running the repo's own src/lib/contrast.ts against #262626:
| Value | Measured |
|---|---|
#FB923C (Danger Orange text) |
6.6:1 |
#FCA5A5 (Destructive text) |
7.9:1 |
So 7.9:1 belongs to the destructive row below (which states 8.0:1), and Danger Orange is 6.6:1. The old 8.0:1 was wrong, but 7.9:1 is wrong in a more dangerous way: it sits above the 7:1 AAA line while the real value sits below it. Anyone trusting this table will use #FB923C for reading copy on an FLL-themed page believing it clears AAA.
The build guard can't catch this — styleguide.astro floors every *-bright token at AA_NORMAL, never at the ratio the doc claims. If these documented numbers are meant to be normative, the guard should assert against them too.
Generated by Claude Code
| <head> | ||
| <meta charset="utf-8" /> | ||
| <meta content="width=device-width, initial-scale=1" name="viewport" /> | ||
| <meta content="noindex, nofollow" name="robots" /> |
There was a problem hiding this comment.
noindex isn't enough — the page is also being submitted in the sitemap.
The sitemap integration is already live on this branch (astro.config.ts:9, sitemap() with no filter), so this page ships in the index from the first deploy. After pnpm build:
<!-- dist/sitemap-0.xml -->
<url><loc>https://scstem.org/</loc></url>
<url><loc>https://scstem.org/styleguide/</loc></url>A URL that is both in the sitemap and noindex is the "Submitted URL marked 'noindex'" error in Search Console. The PR body defers the exclusion to Phase 10, but the cost of doing it now is one line:
integrations: [sitemap({ filter: (page) => !page.includes("/styleguide") })],Generated by Claude Code
| /* 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; | ||
| } |
There was a problem hiding this comment.
The 40% alpha in the comment isn't in the code.
border: 1px solid currentcolor is fully opaque, so the tier chips on /styleguide get a border as bright as their label. DESIGN.md §8 asks for "1px 40%-alpha border in the chip's color", and per CLAUDE.md the doc wins. The comment currently describes something that isn't there.
| /* 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; | |
| } | |
| /* 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 color-mix(in srgb, currentcolor 40%, transparent); | |
| border-radius: var(--radius-sm); | |
| background-color: transparent; | |
| } |
Generated by Claude Code
| const { reference, side = "left", class: className } = Astro.props; | ||
| --- | ||
|
|
||
| <figcaption |
There was a problem hiding this comment.
<figcaption> with no <figure> ancestor.
HTML permits figcaption only as the first or last child of a figure. The built page has neither:
$ grep -c '<figure' dist/styleguide/index.html
0
$ grep -o '<figcaption[^>]*>' dist/styleguide/index.html
<figcaption class="spec-label flex items-center gap-2 text-muted mt-3">
At the one call site (styleguide.astro:429-430) the Callout is a sibling of the media div, so assistive tech gets no figure/caption relationship and validators flag the document. Since the component is also documented for "detail annotations on a hero" — where there is no figure at all — the element choice is doing work it can't do.
Either wrap the media in a <figure> inside the component (media in a slot), or emit a plain <p>/<div> and make callers responsible for the <figure>.
Generated by Claude Code
| @utility ambient-pool-neutral { | ||
| &::before { | ||
| background: radial-gradient( | ||
| ellipse 80% 60% at 50% 0%, | ||
| rgb(250 250 250 / 0.03), | ||
| transparent 70% | ||
| ); | ||
| } | ||
| } |
There was a problem hiding this comment.
Reads like a peer of ambient-pool, but it only overrides background — no content, position, inset, z-index, pointer-events, and nothing on the host element.
So class="ambient-pool-neutral" on its own generates a ::before with no content, which means no pseudo-element and no pool: it fails silently, with nothing in the build to catch it. Composed as ambient-pool ambient-pool-neutral it works (I checked the emitted order — the override lands after), but nothing here says that's required.
Either repeat the structural declarations so it stands alone, or say in the comment that it is a modifier requiring ambient-pool.
Generated by Claude Code
|
|
||
| 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`. |
There was a problem hiding this comment.
Small numbers nit, but this doc is normative-by-copy so it's worth getting exact: src/lib/contrast.ts gives 7.6:1, not 7.7:1, for #FAFAFA over the 25% swipe on #262626 (blend → #5B5022). The card figure of 9.2:1 checks out. /styleguide prints 7.6, so the page and the doc it exists to prove disagree on first read.
Same in plan/02-design-system.md: Science Blue's new #171717 label measures 4.8:1, not 4.9:1.
Both still clear their floors — just a numbers fix so the doc matches the verifier.
Generated by Claude Code
|
# Conflicts: # eslint.config.ts # src/styles/global.css
Three DESIGN.md §8 rules were declared but not in force. In-prose links set only underline thickness and offset, and preflight's `text-decoration: inherit` resolved the line to `none`, so links were colour-only; `spec-chip` used an opaque `currentcolor` border where the doc asks for 40% alpha; and adding `svg` to the base `max-width: 100%` rule clamped ChalkOval's deliberate 2rem overshoot, since `max-width` cannot be overridden by a `width` utility. `SketchArrow direction="right"` was a silent no-op: Tailwind v4 implements `-rotate-45` with the individual `rotate` property, which the per-variant inline `style` overrode. Direction is now summed into that one value; mirroring stays a class because `scale` does not collide. `--color-body` and `--text-body` both claimed `.text-body` and the colour won, leaving the size token unreachable and the type-ramp row demonstrating a colour. Added `@utility text-copy` and pointed the ramp at it. `/styleguide`'s contrast gate compared a hand-typed copy of the palette against itself, so a token edited only in the stylesheet left the build green. `@/lib/tokens` now parses the `@theme` and `[data-theme]` blocks out of `global.css`, and the program themes' `primary-bright` accents are gated too — with the theme list read from the stylesheet, so a new program cannot ship an unchecked accent. Verified: dimming `--color-muted`, renaming a token, breaking the FLL accent, and changing the swipe alpha each now fail the build. DESIGN.md's accent ratios disagreed with the repo's own verifier. Danger Orange is 6.6:1, not 7.9:1 — the old number sat above the 7:1 AAA line while the real value sits below it. Corrected alongside Safety Yellow (9.8), Science Blue (5.9), destructive text (7.9) and the swipe (7.6), and the measured value now renders per theme on the page. `Callout` emitted `<figcaption>` with no `<figure>` anywhere on the page. It renders `<p>` by default; `as="figcaption"` is opt-in and the caller owns the figure. `ambient-pool-neutral` overrides only a `::before` background, so used alone it emitted no pseudo-element — documented as the modifier it is. Quality: the §2.3 engineering grid was defined twice, in CSS and as an inline style with a raw `#FAFAFA`, already disagreeing on alpha; it is now one `@utility engineering-grid` driven by custom properties off `--color-foreground`. The §13 stroke contract was duplicated across three hand-markup devices, which had already drifted on `vector-effect`; it is now `@utility hand-stroke` plus a shared tone mapping. RegistrationMarks encoded one bracket as four hand-computed paths. The four `public/image/svg/logo-*.svg` files were byte-identical to `src/assets/brand/` and unreferenced, deploying 48 KB unhashed. The page was `noindex` and in the sitemap — a "Submitted URL marked 'noindex'" error. Added a sitemap filter. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BX5PrKuYNRLVxiEj3eejhs
Layer 2 of the overhaul stack, on
overhaul/01-foundation.plan/02-design-system.md.DESIGN.mdturned into working CSS.DESIGN.md— see "Design amendments" below, it needs your eyes.What's here
Tokens (
src/styles/global.css) — the full §2–§4/§6 set as a Tailwind v4@themeblock: the recessed surface system, the AAA text ramp, fill/text accent pairs, sponsor tiers, the fluid type scale asclamp()between 360px and 1440px, the three-value radius scale, motion durations and easings.Program themes remap exactly four tokens —
primary,primary-bright,primary-foreground,ring— and nothing else, diff-checked in the built CSS.Fonts, self-hosted. Declared face by face rather than importing the
@fontsourceCSS, because those ship every subset: this waydistcontains 5 latinwoff2files and no cyrillic, greek, or vietnamese.src/styles/fonts.tsexports the two above-the-fold URLs for Phase 05's preload.Motifs as components so pages can't reinvent them:
Pattern(engineering grid + dimension ticks),GhostNumeral,RulerDivider,RegistrationMarks,TitleBlock,Callout, and the hand-markup set —ChalkOval,ChalkUnderline,SketchArrow, each with three distinct paths. Plus utilities for the pocket anatomy, accent rules, media frame, highlighter swipe, ambient pools, spec labels and chips./styleguide— every token, ramp, and motif rendered once, with computed contrast ratios, and each section repeated underdata-theme="frc"and"fll". Noindexed.Contrast is verified at build, not by eye
The page computes every pair DESIGN.md §9 requires and throws with the offending pairs listed if one drops below its floor. Verified by temporarily dimming
bodyto#8A8A8A:A token edit can't quietly break accessibility now.
Design amendments (DESIGN.md §11) — please review
That verification caught two real defects in the spec:
#FAFAFAon#3B82F6measures 3.5:1, and blue fills carry chip-sized text (calendar/event chips, data UI). Changed the label to the brand near-black#171717→ 4.9:1, which also stops blue being the one fill with a white label. Science Blue's own hex is untouched, so its reservation as the future light-theme primary stands.body's documented ratio was wrong — "≈11.5:1" vs. a measured 10.2:1. Still AAA; the decision holds, only the number changed.Two gaps filled:
destructivepair (#DB262F/#FAFAFAfill at 4.6:1,#FCA5A5text at 8.0:1). §8 requires "the destructive text token" but no value existed anywhere in the doc.card). §2.13 asked for this to be verified on/styleguide; the page now prints the measured numbers.Also corrected Danger Orange's stated 8.0:1 to the measured 7.9:1.
Deviations from the phase brief
primary-bright. The doc wins (§11) — and without it, links and stat numerals on a green FRC page would stay yellow.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-returnis off for.astro.astro-eslint-parserdoesn't type template JSX, so everyitems.map(() => <El />)resolves aserrorand trips the rule. Frontmatter — where the real logic lives — stays fully typed, andastro checktype-checks templates properly. The config comment names the sibling rules that may need the same treatment, and says not to blanket-off the family.TitleBlock,Callout) are done.Notes
Two mechanics worth knowing, both of which bit during implementation: hand-markup strokes carry
pathLength="100"so the draw-on dash math is in percent — a hard-coded length silently truncates any longer path, which is what first left the chalk ovals as open arcs — and the ovals and underlines usevector-effect="non-scaling-stroke", since their SVG is stretched non-uniformly over whatever word it wraps.I rendered the page at 1280px and 390px and iterated on it; both hold up, and the contrast table scrolls inside its own container rather than the page.
Generated by Claude Code