Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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'

<div style={{ color: token('color', 'accent'), padding: token('space', '4') }} />
// token('color', 'nope') -> type error
```

## Layout

```
Expand Down
18 changes: 3 additions & 15 deletions app/globals.css
Original file line number Diff line number Diff line change
@@ -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;
}
Expand Down
6 changes: 6 additions & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -15,6 +18,9 @@ export const viewport: Viewport = {
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<head>
<style dangerouslySetInnerHTML={{ __html: tokenRootRule }} />
</head>
<body>{children}</body>
</html>
)
Expand Down
3 changes: 2 additions & 1 deletion app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
53 changes: 53 additions & 0 deletions app/tokens/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main style={{ maxWidth: '52rem', margin: '0 auto', padding: token('space', '8') + ' ' + token('space', '4') }}>
<h1>Design tokens</h1>
<p style={{ color: token('color', 'muted') }}>
Values come from one typed tree in <code>src/tokens</code>. 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.
</p>

{groups.map((group) => {
const scale = tokens[group] as Record<string, string>
return (
<section key={group} aria-label={group} style={{ marginTop: token('space', '8') }}>
<h2 style={{ fontSize: token('fontSize', 'lg') }}>{group}</h2>
<dl style={{ display: 'grid', gap: token('space', '2'), margin: 0 }}>
{Object.entries(scale).map(([key, value]) => (
<div key={key} style={{ display: 'flex', alignItems: 'center', gap: token('space', '4') }}>
<span
aria-hidden
style={{
width: '1.5rem',
height: '1.5rem',
borderRadius: token('radius', 'md'),
border: '1px solid var(--color-border)',
background: isColor(value) ? value : 'transparent',
flexShrink: 0
}}
/>
<dt style={{ fontFamily: 'var(--font-mono)', minWidth: '10rem' }}>
{`--${group.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()}-${key}`}
</dt>
<dd style={{ margin: 0, color: token('color', 'muted') }}>{value}</dd>
</div>
))}
</dl>
</section>
)
})}
</main>
)
}
2 changes: 1 addition & 1 deletion src/components/concept-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
41 changes: 41 additions & 0 deletions src/tokens/index.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, Readonly<Record<string, string>>>>

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<G extends TokenGroup>(group: G, key: TokenKey<G>): 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<G extends TokenGroup>(group: G, key: TokenKey<G>): string {
return `var(${cssVarName(group, key)})`
}

export function tokensToCssVariables(source: TokenTree = tokens): Record<string, string> {
const out: Record<string, string> = {}
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, string>): string {
const body = Object.entries(vars)
.map(([name, value]) => ` ${name}: ${value};`)
.join('\n')
return `:root {\n${body}\n}`
}
33 changes: 33 additions & 0 deletions src/tokens/tokens.ts
Original file line number Diff line number Diff line change
@@ -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<G extends TokenGroup> = keyof Tokens[G] & string
62 changes: 62 additions & 0 deletions test/tokens.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading