Skip to content

Phase 2 - Code Examples

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.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}"
}
}
}

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 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));
}
}
/* 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);
}
}
// 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 files
const 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 variables
function 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 file
const 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');

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>

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 text
const 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).`);
src/components/atoms/ThemeToggle.astro
<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>