diff --git a/README.md b/README.md
index 7412103..c721694 100644
--- a/README.md
+++ b/README.md
@@ -21,6 +21,7 @@ Front-end work has changed a lot since the SPA-everything era: rendering moved b
## What's implemented
- Scaffold: Next.js 15 App Router, React 19, strict TypeScript, Vitest + Testing Library, GitHub Actions CI, and a design-token stylesheet. The home route lists the planned concepts using an accessible `ConceptCard` component.
+- Design tokens: a typed token tree in `src/tokens` that generates the app's CSS custom properties. The root layout injects the generated `:root` rule at render time, so the token module is the runtime source and there is no second copy to drift. See the `/tokens` route.
## Stack
@@ -50,6 +51,15 @@ import { ConceptCard } from '@/components/concept-card'
/>
```
+Styles reference tokens through a typed helper, so a bad name fails to compile:
+
+```tsx
+import { token } from '@/tokens'
+
+
+// token('color', 'nope') -> type error
+```
+
## Layout
```
diff --git a/app/globals.css b/app/globals.css
index 3cb1f5c..6256ac0 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -1,20 +1,8 @@
+/* Token custom properties (--color-*, --space-*, --radius-*, --font-size-*) are
+ generated from src/tokens and injected by the root layout. Only values that
+ are not part of the token tree live here. */
:root {
color-scheme: light dark;
-
- --color-bg: #0f1115;
- --color-surface: #171a21;
- --color-border: #262b36;
- --color-text: #e7eaf0;
- --color-muted: #9aa3b2;
- --color-accent: #5eead4;
-
- --space-2: 0.5rem;
- --space-3: 0.75rem;
- --space-4: 1rem;
- --space-6: 1.5rem;
- --space-8: 2rem;
-
- --radius: 12px;
--font-sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, monospace;
}
diff --git a/app/layout.tsx b/app/layout.tsx
index 7535867..418d709 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -1,7 +1,10 @@
import type { Metadata, Viewport } from 'next'
import type { ReactNode } from 'react'
+import { cssVariablesToRootRule, tokensToCssVariables } from '@/tokens'
import './globals.css'
+const tokenRootRule = cssVariablesToRootRule(tokensToCssVariables())
+
export const metadata: Metadata = {
title: 'Modern Frontend Lab',
description:
@@ -15,6 +18,9 @@ export const viewport: Viewport = {
export default function RootLayout({ children }: { children: ReactNode }) {
return (
+
+
+
{children}
)
diff --git a/app/page.tsx b/app/page.tsx
index a987043..9c27f04 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -21,7 +21,8 @@ const CONCEPTS: readonly Concept[] = [
{
title: 'Design tokens',
description: 'A typed token system that stays the single origin for color, spacing, and type scale.',
- status: 'planned'
+ status: 'done',
+ href: '/tokens'
},
{
title: 'List virtualization',
diff --git a/app/tokens/page.tsx b/app/tokens/page.tsx
new file mode 100644
index 0000000..6a72de3
--- /dev/null
+++ b/app/tokens/page.tsx
@@ -0,0 +1,53 @@
+import { tokens, token, type TokenGroup } from '@/tokens'
+
+export const metadata = {
+ title: 'Design tokens - Modern Frontend Lab'
+}
+
+function isColor(value: string): boolean {
+ return value.startsWith('#')
+}
+
+export default function TokensPage() {
+ const groups = Object.keys(tokens) as TokenGroup[]
+
+ return (
+
+ Design tokens
+
+ Values come from one typed tree in src/tokens. The CSS custom properties below are generated from it and
+ injected by the root layout, so the swatches and the rest of the app read the same source.
+
+
+ {groups.map((group) => {
+ const scale = tokens[group] as Record
+ return (
+
+ {group}
+
+ {Object.entries(scale).map(([key, value]) => (
+
+
+
-
+ {`--${group.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()}-${key}`}
+
+ - {value}
+
+ ))}
+
+
+ )
+ })}
+
+ )
+}
diff --git a/src/components/concept-card.tsx b/src/components/concept-card.tsx
index 1598906..dbf6362 100644
--- a/src/components/concept-card.tsx
+++ b/src/components/concept-card.tsx
@@ -19,7 +19,7 @@ const cardStyle: React.CSSProperties = {
display: 'block',
padding: 'var(--space-6)',
border: '1px solid var(--color-border)',
- borderRadius: 'var(--radius)',
+ borderRadius: 'var(--radius-md)',
background: 'var(--color-surface)',
color: 'inherit',
textDecoration: 'none'
diff --git a/src/tokens/index.ts b/src/tokens/index.ts
new file mode 100644
index 0000000..df93fed
--- /dev/null
+++ b/src/tokens/index.ts
@@ -0,0 +1,41 @@
+import { tokens, type TokenGroup, type TokenKey } from './tokens'
+
+export { tokens } from './tokens'
+export type { Tokens, TokenGroup, TokenKey } from './tokens'
+
+type TokenTree = Readonly>>>
+
+function toKebab(input: string): string {
+ return input.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
+}
+
+function varName(group: string, key: string): string {
+ return `--${toKebab(group)}-${toKebab(key)}`
+}
+
+export function cssVarName(group: G, key: TokenKey): string {
+ return varName(group, key)
+}
+
+// Returns a `var(--group-key)` reference for use in styles. The generic keeps
+// callers honest: token('color', 'nope') is a compile error, not a silent typo.
+export function token(group: G, key: TokenKey): string {
+ return `var(${cssVarName(group, key)})`
+}
+
+export function tokensToCssVariables(source: TokenTree = tokens): Record {
+ const out: Record = {}
+ for (const [group, scale] of Object.entries(source)) {
+ for (const [key, value] of Object.entries(scale)) {
+ out[varName(group, key)] = value
+ }
+ }
+ return out
+}
+
+export function cssVariablesToRootRule(vars: Record): string {
+ const body = Object.entries(vars)
+ .map(([name, value]) => ` ${name}: ${value};`)
+ .join('\n')
+ return `:root {\n${body}\n}`
+}
diff --git a/src/tokens/tokens.ts b/src/tokens/tokens.ts
new file mode 100644
index 0000000..b2cfddd
--- /dev/null
+++ b/src/tokens/tokens.ts
@@ -0,0 +1,33 @@
+// The single origin for design values. Every CSS custom property and every
+// token() reference is derived from this tree, so there is nowhere else to
+// drift out of sync.
+export const tokens = {
+ color: {
+ bg: '#0f1115',
+ surface: '#171a21',
+ border: '#262b36',
+ text: '#e7eaf0',
+ muted: '#9aa3b2',
+ accent: '#5eead4'
+ },
+ space: {
+ '2': '0.5rem',
+ '3': '0.75rem',
+ '4': '1rem',
+ '6': '1.5rem',
+ '8': '2rem'
+ },
+ radius: {
+ md: '12px'
+ },
+ fontSize: {
+ sm: '0.8rem',
+ base: '1rem',
+ lg: '1.05rem',
+ xl: '1.5rem'
+ }
+} as const
+
+export type Tokens = typeof tokens
+export type TokenGroup = keyof Tokens
+export type TokenKey = keyof Tokens[G] & string
diff --git a/test/tokens.test.ts b/test/tokens.test.ts
new file mode 100644
index 0000000..7774105
--- /dev/null
+++ b/test/tokens.test.ts
@@ -0,0 +1,62 @@
+import {
+ cssVarName,
+ cssVariablesToRootRule,
+ token,
+ tokens,
+ tokensToCssVariables
+} from '@/tokens'
+
+describe('cssVarName', () => {
+ it('kebab-cases the group and joins with the key', () => {
+ expect(cssVarName('color', 'accent')).toBe('--color-accent')
+ expect(cssVarName('fontSize', 'base')).toBe('--font-size-base')
+ })
+
+ it('leaves numeric-style keys intact', () => {
+ expect(cssVarName('space', '2')).toBe('--space-2')
+ })
+})
+
+describe('token', () => {
+ it('wraps the variable name in a var() reference', () => {
+ expect(token('color', 'accent')).toBe('var(--color-accent)')
+ expect(token('radius', 'md')).toBe('var(--radius-md)')
+ })
+})
+
+describe('tokensToCssVariables', () => {
+ it('emits one entry per leaf and preserves the value', () => {
+ const vars = tokensToCssVariables()
+ const leafCount = Object.values(tokens).reduce((n, scale) => n + Object.keys(scale).length, 0)
+ expect(Object.keys(vars)).toHaveLength(leafCount)
+ expect(vars['--color-accent']).toBe(tokens.color.accent)
+ expect(vars['--font-size-xl']).toBe(tokens.fontSize.xl)
+ })
+
+ it('accepts a custom token source', () => {
+ const custom = { color: { brand: '#ff0000' } } as const
+ expect(tokensToCssVariables(custom)).toEqual({ '--color-brand': '#ff0000' })
+ })
+
+ it('handles an empty group without emitting anything for it', () => {
+ const custom = { color: {}, space: { '1': '4px' } } as const
+ expect(tokensToCssVariables(custom)).toEqual({ '--space-1': '4px' })
+ })
+})
+
+describe('cssVariablesToRootRule', () => {
+ it('builds a :root block with every declaration', () => {
+ const rule = cssVariablesToRootRule({ '--color-accent': '#5eead4', '--space-4': '1rem' })
+ expect(rule).toBe(':root {\n --color-accent: #5eead4;\n --space-4: 1rem;\n}')
+ })
+
+ it('produces an empty-bodied block for no variables', () => {
+ expect(cssVariablesToRootRule({})).toBe(':root {\n\n}')
+ })
+
+ it('round-trips the real token tree into a rule containing the accent color', () => {
+ const rule = cssVariablesToRootRule(tokensToCssVariables())
+ expect(rule).toContain('--color-accent: #5eead4;')
+ expect(rule.startsWith(':root {')).toBe(true)
+ })
+})