Phase 2 - Code Examples
Code Examples
Section titled “Code Examples”Design Tokens Structure
Section titled “Design Tokens Structure”The tokens were redesigned under ADR-047 (role-based v2, “cold minimal”): the base palette is slate, violet, rose, amber, green, spaceCadet, white, and charcoal — there is no gray scale and no blue primary ramp in base.json (primary is mapped in semantic.json).
// tokens/base.json (condensed — the real file also holds fontFamily,// fontSize, spacing, borderRadius, shadow, and motion groups){ "color": { "slate": { "50": { "value": "228 22% 98%" }, "100": { "value": "228 20% 96%" }, "200": { "value": "228 16% 90%" }, "300": { "value": "228 14% 80%" }, "400": { "value": "228 13% 66%" }, "500": { "value": "228 12% 52%" }, "600": { "value": "228 13% 42%" }, "700": { "value": "228 15% 30%" }, "800": { "value": "228 17% 20%" }, "900": { "value": "228 20% 13%" }, "950": { "value": "228 24% 9%" } }, "violet": { "50": { "value": "257 100% 97%" }, "500": { "value": "256 86% 63%" }, "950": { "value": "256 50% 21%" } }, "rose": { "...": "accent ramp" }, "amber": { "...": "warning ramp" }, "green": { "...": "success ramp" }, "spaceCadet": { "value": "230 22% 7%" }, "white": { "value": "228 24% 99%" }, "charcoal": { "value": "228 24% 12%" } }}Semantic Tokens
Section titled “Semantic Tokens”semantic.json holds full 11-step primary/secondary scales (referencing the violet and rose ramps) plus flat role tokens, each with a light value and a dark override:
// tokens/semantic.json (condensed — the full set also includes// surfaceRaised, surfaceAccent, borderEmphasis, primaryForeground,// link, success, warning, and error){ "semantic": { "primary": { "50": { "value": "{color.violet.50}" }, "500": { "value": "{color.violet.500}" }, "950": { "value": "{color.violet.950}" } }, "background": { "value": "{color.slate.50}", "dark": "{color.spaceCadet}" }, "surface": { "value": "{color.white}", "dark": "{color.slate.900}" }, "foreground": { "value": "{color.charcoal}", "dark": "{color.slate.50}" }, "mutedForeground": { "value": "{color.slate.600}", "dark": "{color.slate.400}" }, "border": { "value": "{color.slate.200}", "dark": "{color.slate.800}" } }}Tailwind CSS Considerations
Section titled “Tailwind CSS Considerations”Background on the Tailwind v4 choice and the image-optimization strategy lives in Phase 2 - Design System & Tokens — the notes are not duplicated here.
Tailwind Configuration (v4, CSS-first)
Section titled “Tailwind Configuration (v4, CSS-first)”Tailwind v4 has no tailwind.config.ts and no JSON-import-into-config step. Configuration lives in CSS: src/styles/global.css imports Tailwind, imports the generated token stylesheet (tokens/dist/tokens.css, produced by pnpm run tokens:build), and maps those CSS variables to Tailwind utilities via @theme inline. The plugin is registered in astro.config.mjs under vite.plugins as @tailwindcss/vite — not as an Astro integration.
/* src/styles/global.css — Tailwind v4 CSS-first configuration */
/* Tailwind first, so tokens.css overrides the vars it emits */@import 'tailwindcss';
/* Design tokens: defines --color-*, --spacing-*, etc. in :root and .dark */@import '../../tokens/dist/tokens.css';
/* Optional plugins are registered in CSS too */@plugin "@tailwindcss/typography";
/* Class-based dark mode (equivalent to v3 darkMode: "class") */@variant dark (&:where(.dark, .dark *));
/* Map design tokens → Tailwind utilities. `inline` makes Tailwind inline the values instead of emitting new custom properties, avoiding a naming clash with tokens.css. */@theme inline { --color-slate-50: hsl(var(--color-slate-50)); --color-slate-100: hsl(var(--color-slate-100)); /* ... one line per token scale entry ... */ --color-primary-500: hsl(var(--color-primary-500));
/* Motion tokens surface as utility values the same way */ --ease-bounce-in: cubic-bezier(0.68, -0.55, 0.265, 1.55);}
/* Custom utilities replace v3 plugin addUtilities() calls */@utility focus-visible-ring { &:focus-visible { outline: none; box-shadow: 0 0 0 2px hsl(var(--color-primary-500)); }}CSS Architecture
Section titled “CSS Architecture”/* src/styles/global.css (continued) — v4 uses a single import, not the old @tailwind base/components/utilities directives */@import 'tailwindcss';
@layer base { :root { /* Base colors */ --color-slate-50: 228 22% 98%; --color-slate-100: 228 20% 96%; /* ... rest of colors */
/* Semantic tokens */ --background: var(--color-slate-50); --foreground: var(--color-charcoal);
/* Motion tokens */ --transition-base: 150ms ease-in-out; --transition-slow: 300ms ease-in-out; --transition-bounce: 500ms cubic-bezier(0.68, -0.55, 0.265, 1.55); }
:root.dark { --background: var(--color-space-cadet); --foreground: var(--color-slate-50); }
/* Reduced motion preferences */ @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; } }
/* Focus visible polyfill */ .focus-visible { outline: 2px solid var(--color-primary-500); outline-offset: 2px; }
/* Base typography */ html { font-family: system-ui, -apple-system, sans-serif; font-size: 16px; line-height: 1.5; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }
/* Color scheme */ html { color-scheme: light; }
html.dark { color-scheme: dark; }}
@layer utilities { /* Text balance for headings */ .text-balance { text-wrap: balance; }
/* Fluid typography (optional) */ .fluid-text-sm { font-size: clamp(0.875rem, 0.8rem + 0.25vw, 1rem); }
.fluid-text-base { font-size: clamp(1rem, 0.925rem + 0.25vw, 1.125rem); }
.fluid-text-lg { font-size: clamp(1.125rem, 1rem + 0.5vw, 1.5rem); }}Token Build Script
Section titled “Token Build Script”// scripts/src/build-tokens.ts (run via `pnpm run tokens:build`)import { readFileSync, writeFileSync } from 'fs';import { join } from 'path';
interface TokenValue { value: string; dark?: string; lineHeight?: string;}
interface TokenGroup { [key: string]: TokenValue | TokenGroup;}
function isTokenValue(obj: any): obj is TokenValue { return obj && typeof obj.value === 'string';}
function processTokens(tokens: TokenGroup, prefix = ''): Record<string, any> { const result: Record<string, any> = {};
for (const [key, value] of Object.entries(tokens)) { const tokenKey = prefix ? `${prefix}-${key}` : key;
if (isTokenValue(value)) { result[tokenKey] = value.value; } else { Object.assign(result, processTokens(value as TokenGroup, tokenKey)); } }
return result;}
// Read token filesconst baseTokens = JSON.parse(readFileSync(join('tokens', 'base.json'), 'utf-8'));const semanticTokens = JSON.parse(readFileSync(join('tokens', 'semantic.json'), 'utf-8'));
// The build emits two artifacts into tokens/dist/: tailwind-tokens.json (a// flat token map kept for tooling that wants JSON) and tokens.css — Tailwind// v4 consumes the CSS variables via @theme inline in global.css. This// simplified example shows only the tokens.css side; see the starter's// scripts/src/build-tokens.ts for the full version.
// Generate CSS variablesfunction generateCSSVariables(tokens: TokenGroup, prefix = ''): string[] { const lines: string[] = [];
for (const [key, value] of Object.entries(tokens)) { const varName = prefix ? `--${prefix}-${key}` : `--${key}`;
if (isTokenValue(value)) { lines.push(` ${varName}: ${value.value};`); } else { lines.push(...generateCSSVariables(value as TokenGroup, prefix ? `${prefix}-${key}` : key)); } }
return lines;}
// Generate CSS fileconst cssContent = `/* Auto-generated from design tokens */:root {${generateCSSVariables(baseTokens).join('\n')}}
/* Dark mode overrides */:root.dark {${generateCSSVariables(semanticTokens.semantic) .filter(line => line.includes('dark')) .join('\n')}}`;
writeFileSync(join('tokens', 'dist', 'tokens.css'), cssContent);
console.log('✅ Design tokens built successfully');Accessibility Utilities
Section titled “Accessibility Utilities”The starter ships no VisuallyHidden component — src/components/a11y/ contains only SkipLink.astro. Visually-hidden text uses Tailwind’s built-in sr-only utility directly, which removes a component’s worth of indirection for a one-class pattern:
<span class="sr-only">Open main menu</span>WCAG Contrast Validation
Section titled “WCAG Contrast Validation”The contrast gate (pnpm run design:validate) resolves the flat semantic role tokens — token references like {color.slate.600} and literal HSL alike — and sweeps both light and dark mode. It exits non-zero if any body-text pair drops below 4.5:1 or any large-text / non-text pair below 3:1:
// scripts/src/validate-contrast.ts (condensed — see the starter for the full file)const aaNormal = 4.5; // body textconst aaLarge = 3.0; // large text (>=18pt / 14pt bold) and non-text UI
interface Pair { fg: string; bg: string; min: number; note?: string;}
// Body-text roles over the two surfaces they sit on.const pairs: Pair[] = [ { fg: 'foreground', bg: 'background', min: aaNormal }, { fg: 'foreground', bg: 'surface', min: aaNormal }, { fg: 'mutedForeground', bg: 'background', min: aaNormal }, { fg: 'mutedForeground', bg: 'surface', min: aaNormal }, { fg: 'link', bg: 'background', min: aaNormal }, { fg: 'link', bg: 'surface', min: aaNormal }, { fg: 'success', bg: 'background', min: aaNormal }, { fg: 'success', bg: 'surface', min: aaNormal }, { fg: 'error', bg: 'background', min: aaNormal }, { fg: 'error', bg: 'surface', min: aaNormal }, { fg: 'primaryForeground', bg: 'primary.600', min: aaNormal }, // warning is amber — held to the 3:1 large-text / non-text bar; its real // usages are decorative marks, badge fills, and large Callout headings. { fg: 'warning', bg: 'background', min: aaLarge, note: 'large-text/non-text only' }, { fg: 'warning', bg: 'surface', min: aaLarge, note: 'large-text/non-text only' },];
// channel(role, mode) resolves a role (or scale step like primary.600) to an// HSL channel string for the given mode ('light' uses .value, 'dark' prefers// .dark), dereferencing {color.*} against base.json. contrast() is the// standard WCAG relative-luminance ratio.const modes = ['light', 'dark'] as const;const failures: string[] = [];
for (const { fg, bg, min, note } of pairs) { for (const mode of modes) { const ratio = contrast(hslStringToRgb(channel(fg, mode)), hslStringToRgb(channel(bg, mode))); if (ratio < min) { const tag = note ? ` (${note})` : ''; failures.push(`${fg} on ${bg} [${mode}]${tag}: ${ratio.toFixed(2)}:1 (<${min})`); } }}
if (failures.length) { console.error('❌ WCAG-AA contrast failures:'); for (const f of failures) { console.error(` ${f}`); } process.exit(1);}console.log(`✅ All ${pairs.length} semantic colour pairs meet WCAG-AA contrast (light + dark).`);Dark Mode Implementation
Section titled “Dark Mode Implementation”<button id="theme-toggle" type="button" class="focus-visible-ring rounded-lg p-2 hover:bg-gray-100 dark:hover:bg-gray-800" aria-label="Toggle dark mode"> <svg class="h-5 w-5 dark:hidden" fill="currentColor" viewBox="0 0 20 20"> <path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"></path> </svg> <svg class="hidden h-5 w-5 dark:block" fill="currentColor" viewBox="0 0 20 20"> <path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.706-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" fill-rule="evenodd" clip-rule="evenodd"></path> </svg></button>
<script> // Theme toggle logic const theme = (() => { if (typeof localStorage !== 'undefined' && localStorage.getItem('theme')) { return localStorage.getItem('theme'); } if (window.matchMedia('(prefers-color-scheme: dark)').matches) { return 'dark'; } return 'light'; })();
if (theme === 'dark') { document.documentElement.classList.add('dark'); }
window.localStorage.setItem('theme', theme);
const toggle = document.getElementById('theme-toggle'); toggle?.addEventListener('click', () => { const isDark = document.documentElement.classList.toggle('dark'); localStorage.setItem('theme', isDark ? 'dark' : 'light'); });</script>