Design tokens + agent iteration hardening #1

Merged
mats merged 1 commits from design-tokens-agent-hardening into main 2026-09-12 13:35:00 +02:00
20 changed files with 874 additions and 57 deletions
Showing only changes of commit 47127db84b - Show all commits

No files matched your search

+1
View File
@@ -22,3 +22,4 @@ dist-ssr
*.njsproj
*.sln
*.sw?
.codesearch/
+47
View File
@@ -0,0 +1,47 @@
# Northstar Design System — agent handbook
React 19 + Vite 8 + TypeScript design-system showcase. Dark-first, token-driven UI with a light theme.
## Commands
| Task | Command |
|---|---|
| Dev server | `npm run dev` (vite.config.ts pins `0.0.0.0:5173`) |
| Full gate: lint + token contract + typecheck + build | `npm run check` — run before declaring any change done |
| Lint only | `npm run lint` (oxlint) |
## Sandboxed worker dev flow
- `vite.config.ts` allows `*.preview.k6n.net`; expose port **5173** via `repo_instance_expose` for a preview URL.
- Removing `allowedHosts` breaks preview ingress with a 403 ("host not allowed"). Don't.
## Design tokens — the contract
1. **Single source of truth: `src/tokens.css`.** Every font size, color, spacing, radius, and shadow color in `src/App.css` comes from a token. The guard `scripts/check-tokens.mjs` (in `npm run check`) bans hardcoded hex colors, px font sizes, and raw `rgba()` in application CSS — new colors go in as tokens, accent washes use `color-mix(in srgb, var(--accent) N%, transparent)`.
2. Typography scale (`--text-3xs` … `--text-2xl`, `--text-hero`) is rem-based on a 9→28px grid. Longform body `--text-md`, buttons/nav `--text-xs`, meta/eyebrows `--text-2xs`/`--text-3xs`.
3. **Global density knob: `--root-font-size`** (default `112%` readability preset, baseline 16px = `100%`). One line scales every token-driven size app-wide; spacing tokens follow.
4. **Theming:** components read only `var(--*)` and never know the theme. `:root[data-theme='light']` in tokens.css holds the daylight palette. Toggle = `document.documentElement.dataset.theme` (Topbar's sun/moon button, persisted in `localStorage['ns-theme']`). Adding a third theme = one more token block.
5. "Make the small text a bit larger" = bump `--text-xs`/`--text-2xs`. "Soften a surface" = its token. No grep, no per-component edits.
## Structure
```
src/
tokens.css ← design tokens + light theme (edit this first)
index.css ← fonts, tokens import, base resets
App.css ← all component styles (token-driven, plain CSS)
App.tsx ← page composition only
components/ ← one component per file, readable multi-line JSX
Topbar.tsx ← owns the theme toggle (useTheme inside)
Hero.tsx, CodeSample.tsx, RightRail.tsx, ButtonMatrix.tsx,
Sidebar.tsx, Section.tsx, FormField.tsx, TokenGrid.tsx,
MarkdownRenderer.tsx, CodeEditor.tsx, Button.tsx, Icon.tsx
data/library.ts ← demo content for the showcase
scripts/check-tokens.mjs ← token contract guard (part of npm run check)
```
## Iteration workflow for visual changes
1. Change the token (or component markup) — never a literal.
2. `npm run check` until green.
3. Verify live: dev server + preview URL; screenshot at desktop width; toggle light/dark via the topbar button.
+1
View File
@@ -7,6 +7,7 @@
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"check": "oxlint && node scripts/check-tokens.mjs && npm run build",
"preview": "vite preview"
},
"dependencies": {
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env node
/* Token contract guard — runs as part of `npm run check`.
Bans hardcoded values in application CSS so every visual change
goes through src/tokens.css. */
import { readFileSync, readdirSync, statSync } from 'node:fs'
import { join } from 'node:path'
const CSS_ROOTS = ['src']
const EXEMPT = new Set(['tokens.css'])
// Neutral shine/shadow literals stay allowed (they are surfaces, not palette).
const RGB_ALLOW = [/rgba\(\s*0\s*,\s*0\s*,\s*0/, /rgba\(\s*255\s*,\s*255\s*,\s*255/]
const violations = []
function walk(dir) {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry)
if (statSync(full).isDirectory()) {
walk(full)
} else if (full.endsWith('.css') && !EXEMPT.has(entry)) {
checkFile(full, readFileSync(full, 'utf8'))
}
}
}
function checkFile(file, css) {
css.split('\n').forEach((line, i) => {
const n = `${file}:${i + 1}`
const hex = line.match(/#[0-9a-fA-F]{3,8}\b/)
if (hex) violations.push(`${n} hex color ${hex[0]} — add a token in tokens.css instead`)
if (/font(-size)?\s*:[^;}]*\d+px/.test(line)) violations.push(`${n} px font size — use var(--text-*)`)
const rgb = line.match(/rgba?\(/)
if (rgb && !RGB_ALLOW.some(re => re.test(line))) {
violations.push(`${n} raw rgba() — use color-mix(in srgb, var(--token) N%, transparent)`)
}
})
}
CSS_ROOTS.forEach(walk)
if (violations.length) {
console.error(`\n✗ token contract violations:\n\n${violations.join('\n')}\n`)
process.exit(1)
} else {
console.log('✓ token contract OK — no hardcoded colors or font sizes found')
}
+2 -2
View File
@@ -254,7 +254,7 @@ footer{max-width:1180px;margin:0 auto;padding:var(--space-7) var(--space-9) var(
.editor-tab{padding:var(--space-3) var(--space-4);color:var(--subtle);font:var(--text-3xs) var(--font-mono);border-right:1px solid var(--line)}
.editor-tab.active{color:var(--text-bright);background:var(--bg-deep);border-top:2px solid var(--accent);padding-top:10px}
.editor-actions{margin-left:auto;color:var(--subtle);font:var(--text-3xs) var(--font-mono);letter-spacing:3px}
.editor-body{display:grid;grid-template-columns:43px 1fr;min-height:218px;background:linear-gradient(90deg,rgba(20,21,30,.7),transparent 16%)}
.editor-body{display:grid;grid-template-columns:43px 1fr;min-height:218px;background:linear-gradient(90deg,color-mix(in srgb,var(--bg) 70%,transparent),transparent 16%)}
.line-numbers{padding:var(--space-4) 10px var(--space-4) 0;border-right:1px solid var(--line-soft);text-align:right;color:var(--subtle);font:var(--text-xs)/1.72 var(--font-mono);user-select:none}
.editor-body pre{margin:0;padding:var(--space-4) var(--space-5);font:var(--text-sm)/1.72 var(--font-mono);overflow:auto;color:var(--text-code)}
.cm-comment{color:var(--subtle);font-style:italic}
@@ -275,7 +275,7 @@ footer{max-width:1180px;margin:0 auto;padding:var(--space-7) var(--space-9) var(
/* ── Motion & finishing touches ─────────────────────────────── */
@keyframes rise-in{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:translateY(0)}}
@keyframes soft-glow{0%,100%{box-shadow:0 0 0 rgba(166,155,255,0)}50%{box-shadow:0 0 34px color-mix(in srgb,var(--accent) 16%,transparent)}}
@keyframes soft-glow{0%,100%{box-shadow:0 0 0 transparent}50%{box-shadow:0 0 34px color-mix(in srgb,var(--accent) 16%,transparent)}}
@keyframes orbit-drift{from{transform:rotate(-22deg) rotate(0deg)}to{transform:rotate(-22deg) rotate(360deg)}}
@keyframes orbit-drift-reverse{from{transform:rotate(58deg) rotate(360deg)}to{transform:rotate(58deg) rotate(0deg)}}
@keyframes shimmer{0%{background-position:200% 0}100%{background-position:-200% 0}}
+136 -30
View File
@@ -1,45 +1,151 @@
import { useState } from 'react'
import './App.css'
import { Icon } from './components/Icon'
import { Sidebar } from './components/Sidebar'
import { Topbar } from './components/Topbar'
import { Button } from './components/Button'
import { Sidebar } from './components/Sidebar'
import { Hero } from './components/Hero'
import { ButtonMatrix } from './components/ButtonMatrix'
import { CodeEditor } from './components/CodeEditor'
import { FormField, TextInput } from './components/FormField'
import { MarkdownRenderer } from './components/MarkdownRenderer'
import { Section } from './components/Section'
import { TokenGrid } from './components/TokenGrid'
import { buttonExamples, buttonTabs, colorTokens, navigationGroups } from './data/library'
import { FormField, TextInput } from './components/FormField'
import { CodeSample } from './components/CodeSample'
import { MarkdownRenderer } from './components/MarkdownRenderer'
import { CodeEditor } from './components/CodeEditor'
import { Section } from './components/Section'
import { RightRail } from './components/RightRail'
import { Icon } from './components/Icon'
import { buttonExamples, buttonTabs, colorTokens, navigationGroups, pageSections } from './data/library'
function App() {
const INSTALL_CMD = 'pnpm add @northstar/ui @northstar/tokens'
export default function App() {
const [active, setActive] = useState('Buttons')
const [query, setQuery] = useState('')
const [buttonTab, setButtonTab] = useState('Preview')
const [copied, setCopied] = useState(false)
const [visible, setVisible] = useState(false)
const [menu, setMenu] = useState(false)
const copy = () => { navigator.clipboard?.writeText('pnpm add @northstar/ui @northstar/tokens'); setCopied(true); setTimeout(() => setCopied(false), 1600) }
return <div className="app-shell">
<Topbar query={query} onQueryChange={setQuery} onMenuToggle={() => setMenu(!menu)} />
<div className={`body-layout ${menu ? 'menu-open' : ''}`}>
<Sidebar active={active} groups={navigationGroups} onSelect={item => { setActive(item); setMenu(false) }} />
<main className="main-content">
<div className="breadcrumbs"><span>Components</span><span>/</span><strong>{active}</strong></div>
<section className="hero-section"><div className="hero-copy"><span className="eyebrow accent">Component library</span><h1>Build with clarity.</h1><p>A considered set of accessible, composable components for building calm, capable interfaces.</p><div className="hero-actions"><Button variant="primary" onClick={() => document.getElementById('button-matrix')?.scrollIntoView({ behavior: 'smooth' })}>Explore components <Icon name="arrow" size={16} /></Button><Button variant="ghost">Read the principles</Button></div></div><div className="hero-orbit"><div className="orbit orbit-one" /><div className="orbit orbit-two" /><div className="orbit-core"><div className="brand-mark large"><span /></div><span>system<br /><b>ready</b></span></div><span className="orbit-label label-one">TOKENS <b>48</b></span><span className="orbit-label label-two">A11Y <b>AAA</b></span><span className="orbit-label label-three">RUNTIME <b>FAST</b></span></div></section>
<div className="content-grid"><div className="content-main">
<Section id="button-matrix" index="01 / COMPONENTS" title="Buttons" description="Actions with a clear hierarchy and dependable feedback." meta={<span className="api-pill">Button · 12 variants</span>}><ButtonMatrix examples={buttonExamples} tabs={buttonTabs} filter={query} activeTab={buttonTab} onTabChange={setButtonTab} /></Section>
<Section index="02 / FOUNDATIONS" title="Color tokens" description="Functional color roles keep every surface intentional." meta={<button className="text-button">View all tokens <Icon name="arrow" size={15} /></button>}><TokenGrid tokens={colorTokens} onCopy={copy} /></Section>
<Section index="03 / PRIMITIVES" title="Form controls" description="Inputs designed for focus, flow, and the occasional messy edge case."><div className="forms-grid"><FormField label="Project name" hint="Use a memorable name for your workspace."><TextInput defaultValue="Northstar Console" /></FormField><FormField label="API key" readOnlyLabel="read only" hint="Stored securely in your environment."><div className="input-with-action"><TextInput type={visible ? 'text' : 'password'} defaultValue="nsk_live_8f2a1d90" readOnly /><button onClick={() => setVisible(!visible)} aria-label="Show API key"><Icon name="eye" size={16} /></button></div></FormField><label className="toggle-field"><span><b>Enable notifications</b><small>Get updates about deployments and incidents.</small></span><button className="toggle on"><span /></button></label></div></Section>
<Section index="04 / CODE" title="Composable by default" description="Small APIs. Strong defaults. No surprises."><div className="code-card"><div className="code-head"><span className="mono muted">button.tsx</span><button className="copy-button" onClick={copy}><Icon name={copied ? 'check' : 'copy'} size={14} /> {copied ? 'Copied' : 'Copy code'}</button></div><pre><code><span className="pink">import</span> {'{ Button }'} <span className="pink">from</span> <span className="green">'@northstar/ui'</span>{'\n\n'}<span className="pink">export function</span> <span className="blue">DeployAction</span>() {'{'}{'\n'} <span className="pink">return</span> ({'\n'} &lt;<span className="blue">Button</span> variant=<span className="green">"primary"</span>&gt;Deploy changes&lt;/<span className="blue">Button</span>&gt;{'\n'} ){ '\n'}{'}'}</code></pre></div></Section>
<Section index="05 / CONTENT" title="Markdown rendering" description="A readable editorial layer for changelogs, docs, and product notes." meta={<span className="api-pill">Markdown · styled</span>}><MarkdownRenderer /></Section>
<Section index="06 / DEVELOPER TOOLING" title="CodeMirror theme" description="A focused editor palette for examples, playgrounds, and embedded code." meta={<span className="api-pill">codemirror · dark</span>}><CodeEditor /></Section>
</div><aside className="right-rail"><div className="rail-card"><span className="eyebrow">On this page</span>{['Buttons', 'Color tokens', 'Form controls', 'Markdown rendering', 'CodeMirror theme'].map((x, i) => <a className={i === 0 ? 'rail-link active' : 'rail-link'} href={i === 0 ? '#button-matrix' : '#'} key={x}><span>0{i + 1}</span>{x}</a>)}</div><div className="rail-card release"><div className="release-icon"><Icon name="layers" size={18} /></div><strong>Designed to be extended</strong><p>Bring your brand, keep the rhythm. Tokens are portable and components stay unopinionated.</p><button className="text-button">Read the docs <Icon name="arrow" size={14} /></button></div><div className="rail-card"><span className="eyebrow">Need a hand?</span><p className="rail-copy">Check the contribution guide or say hello in the community.</p><Button variant="secondary" className="full">Open community <span>↗</span></Button></div></aside></div>
<footer><span>northstar design system</span><span className="mono">made for the long haul · 2025</span></footer>
</main>
return (
<div className="app-shell">
<Topbar query={query} onQueryChange={setQuery} onMenuToggle={() => setMenu(!menu)} />
<div className={`body-layout ${menu ? 'menu-open' : ''}`}>
<Sidebar
active={active}
groups={navigationGroups}
onSelect={item => {
setActive(item)
setMenu(false)
}}
/>
<main className="main-content">
<div className="breadcrumbs">
<span>Components</span>
<span>/</span>
<strong>{active}</strong>
</div>
<Hero />
<div className="content-grid">
<div className="content-main">
<Section
id="button-matrix"
index="01 / COMPONENTS"
title="Buttons"
description="Actions with a clear hierarchy and dependable feedback."
meta={<span className="api-pill">Button · 12 variants</span>}
>
<ButtonMatrix
examples={buttonExamples}
tabs={buttonTabs}
filter={query}
activeTab={buttonTab}
onTabChange={setButtonTab}
/>
</Section>
<Section
index="02 / FOUNDATIONS"
title="Color tokens"
description="Functional color roles keep every surface intentional."
meta={
<button className="text-button">
View all tokens <Icon name="arrow" size={15} />
</button>
}
>
<TokenGrid tokens={colorTokens} onCopy={copyInstall} />
</Section>
<Section
index="03 / PRIMITIVES"
title="Form controls"
description="Inputs designed for focus, flow, and the occasional messy edge case."
>
<div className="forms-grid">
<FormField label="Project name" hint="Use a memorable name for your workspace.">
<TextInput defaultValue="Northstar Console" />
</FormField>
<FormField label="API key" readOnlyLabel="read only" hint="Stored securely in your environment.">
<div className="input-with-action">
<TextInput type={visible ? 'text' : 'password'} defaultValue="nsk_live_8f2a1d90" readOnly />
<button onClick={() => setVisible(!visible)} aria-label="Show API key">
<Icon name="eye" size={16} />
</button>
</div>
</FormField>
<label className="toggle-field">
<span>
<b>Enable notifications</b>
<small>Get updates about deployments and incidents.</small>
</span>
<button className="toggle on" aria-label="Toggle notifications">
<span />
</button>
</label>
</div>
</Section>
<Section
index="04 / CODE"
title="Composable by default"
description="Small APIs. Strong defaults. No surprises."
>
<CodeSample />
</Section>
<Section
index="05 / CONTENT"
title="Markdown rendering"
description="A readable editorial layer for changelogs, docs, and product notes."
meta={<span className="api-pill">Markdown · styled</span>}
>
<MarkdownRenderer />
</Section>
<Section
index="06 / DEVELOPER TOOLING"
title="CodeMirror theme"
description="A focused editor palette for examples, playgrounds, and embedded code."
meta={<span className="api-pill">codemirror · dark</span>}
>
<CodeEditor />
</Section>
</div>
<RightRail sections={pageSections} />
</div>
<footer>
<span>northstar design system</span>
<span className="mono">made for the long haul · 2025</span>
</footer>
</main>
</div>
</div>
</div>
)
}
export default App
function copyInstall() {
navigator.clipboard?.writeText(INSTALL_CMD)
}
+12 -4
View File
@@ -1,9 +1,17 @@
import type { ReactNode, ButtonHTMLAttributes } from 'react'
import type { ButtonHTMLAttributes, ReactNode } from 'react'
type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost'
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & { variant?: ButtonVariant; children: ReactNode }
export type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost'
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: ButtonVariant
children: ReactNode
}
/** Shared action primitive. Visual variants stay in App.css so themes can override them. */
export function Button({ variant = 'secondary', className = '', children, ...props }: ButtonProps) {
return <button className={`button ${variant} ${className}`.trim()} {...props}>{children}</button>
return (
<button className={`button ${variant} ${className}`.trim()} {...props}>
{children}
</button>
)
}
+71 -4
View File
@@ -1,10 +1,77 @@
import { Button } from './Button'
import { Icon } from './Icon'
export type ButtonExample = { name: string; label: string; variant: 'primary' | 'secondary' | 'danger' | 'ghost' }
type ButtonMatrixProps = { examples: ButtonExample[]; tabs: string[]; filter?: string; activeTab?: string; onTabChange?: (tab: string) => void }
export type ButtonExample = {
name: string
label: string
variant: 'primary' | 'secondary' | 'danger' | 'ghost'
}
type ButtonMatrixProps = {
examples: ButtonExample[]
tabs: string[]
filter?: string
activeTab?: string
onTabChange?: (tab: string) => void
}
export function ButtonMatrix({ examples, tabs, filter = '', activeTab, onTabChange }: ButtonMatrixProps) {
const visible = examples.filter(example => `${example.name} ${example.label}`.toLowerCase().includes(filter.toLowerCase()))
return <><div className="tabs">{tabs.map(tab => <button className={tab === activeTab ? 'tab active' : 'tab'} key={tab} onClick={() => onTabChange?.(tab)}>{tab}</button>)}</div><div className="showcase"><div className="showcase-head"><span className="eyebrow">Interactive states</span><span className="mono muted">button.tsx</span></div><div className="button-matrix">{visible.map(example => <div className="button-row" key={example.name}><span className="row-label">{example.name}</span><Button variant={example.variant}>{example.label}</Button><Button variant={example.variant} className="hover-state">{example.label}</Button><Button variant={example.variant} className="icon-only" aria-label={`${example.name} icon button`}><Icon name="copy" size={15} /></Button><Button variant={example.variant} className="loading"><span className="spinner" />Loading</Button></div>)}</div><div className="showcase-foot"><span className="muted">Density</span><button className="density active">Compact <span>32px</span></button><button className="density">Default <span>40px</span></button><span className="foot-check"><Icon name="check" size={14} /> Keyboard ready</span></div></div></>
const visible = examples.filter(example =>
`${example.name} ${example.label}`.toLowerCase().includes(filter.toLowerCase()),
)
return (
<>
<div className="tabs">
{tabs.map(tab => (
<button
key={tab}
className={tab === activeTab ? 'tab active' : 'tab'}
onClick={() => onTabChange?.(tab)}
>
{tab}
</button>
))}
</div>
<div className="showcase">
<div className="showcase-head">
<span className="eyebrow">Interactive states</span>
<span className="mono muted">button.tsx</span>
</div>
<div className="button-matrix">
{visible.map(example => (
<div className="button-row" key={example.name}>
<span className="row-label">{example.name}</span>
<Button variant={example.variant}>{example.label}</Button>
<Button variant={example.variant} className="hover-state">
{example.label}
</Button>
<Button variant={example.variant} className="icon-only" aria-label={`${example.name} icon button`}>
<Icon name="copy" size={15} />
</Button>
<Button variant={example.variant} className="loading">
<span className="spinner" />
Loading
</Button>
</div>
))}
</div>
<div className="showcase-foot">
<span className="muted">Density</span>
<button className="density active">
Compact <span>32px</span>
</button>
<button className="density">
Default <span>40px</span>
</button>
<span className="foot-check">
<Icon name="check" size={14} /> Keyboard ready
</span>
</div>
</div>
</>
)
}
+45 -1
View File
@@ -2,6 +2,50 @@ import { Icon } from './Icon'
export type CodeEditorProps = { fileName?: string; language?: string }
const LINE_NUMBERS = [1, 2, 3, 4, 5, 6, 7, 8]
export function CodeEditor({ fileName = 'theme.ts', language = 'TypeScript' }: CodeEditorProps) {
return <div className="editor-card"><div className="editor-tabs"><span className="editor-dot" /><span className="editor-tab active">{fileName}</span><span className="editor-tab">tokens.css</span><span className="editor-actions">● ● ●</span></div><div className="editor-body"><div className="line-numbers">1<br />2<br />3<br />4<br />5<br />6<br />7<br />8</div><pre><code><span className="cm-comment">// A calm editor for focused work</span>{'\n'}<span className="cm-keyword">export const</span> <span className="cm-variable">theme</span> = {'{'}{'\n'} <span className="cm-property">name</span>: <span className="cm-string">'northstar-dark'</span>,{'\n'} <span className="cm-property">colors</span>: {'{'}{'\n'} <span className="cm-property">accent</span>: <span className="cm-string">'#a69bff'</span>,{'\n'} <span className="cm-property">keyword</span>: <span className="cm-string">'#e58bff'</span>,{'\n'} <span className="cm-property">comment</span>: <span className="cm-string">'#666572'</span>{'\n'} {'}'}{'\n'}{'}'}</code></pre></div><div className="editor-status"><span>Ln 4, Col 18</span><span>{language} · UTF-8</span><span className="editor-ready"><Icon name="check" size={12} /> No issues</span></div></div>
return (
<div className="editor-card">
<div className="editor-tabs">
<span className="editor-dot" />
<span className="editor-tab active">{fileName}</span>
<span className="editor-tab">tokens.css</span>
<span className="editor-actions">● ● ●</span>
</div>
<div className="editor-body">
<div className="line-numbers">
{LINE_NUMBERS.map(n => (
<span key={n}>
{n}
<br />
</span>
))}
</div>
<pre>
<code>
<span className="cm-comment">// A calm editor for focused work</span>
{'\n'}
<span className="cm-keyword">export const</span> <span className="cm-variable">theme</span> = {'{'}
{'\n'} <span className="cm-property">name</span>: <span className="cm-string">'northstar-dark'</span>,
{'\n'} <span className="cm-property">colors</span>: {'{'}
{'\n'} <span className="cm-property">accent</span>: <span className="cm-string">'#a69bff'</span>,
{'\n'} <span className="cm-property">keyword</span>: <span className="cm-string">'#e58bff'</span>,
{'\n'} <span className="cm-property">comment</span>: <span className="cm-string">'#666572'</span>
{'\n'} {'}'}
{'\n'}{'}'}
</code>
</pre>
</div>
<div className="editor-status">
<span>Ln 4, Col 18</span>
<span>{language} · UTF-8</span>
<span className="editor-ready">
<Icon name="check" size={12} /> No issues
</span>
</div>
</div>
)
}
+53
View File
@@ -0,0 +1,53 @@
import { useState } from 'react'
import { Icon } from './Icon'
const INSTALL_CMD = 'pnpm add @northstar/ui @northstar/tokens'
/** Tokenized segments keep the code sample syntax-highlighted without a markdown dependency. */
type Segment = [className: 'pink' | 'green' | 'blue' | 'plain', text: string]
const CODE_LINES: Segment[] = [
['pink', 'import'],
['plain', ' { Button } '],
['pink', 'from'],
['green', " '@northstar/ui'"],
['plain', '\n\nexport function '],
['blue', 'DeployAction'],
['plain', '() {\n return (\n'],
['plain', ' <Button variant="primary">Deploy changes</Button>\n )\n}'],
]
export function CodeSample() {
const [copied, setCopied] = useState(false)
const copy = () => {
navigator.clipboard?.writeText(INSTALL_CMD)
setCopied(true)
setTimeout(() => setCopied(false), 1600)
}
return (
<div className="code-card">
<div className="code-head">
<span className="mono muted">button.tsx</span>
<button className="copy-button" onClick={copy}>
<Icon name={copied ? 'check' : 'copy'} size={14} />
{copied ? 'Copied' : 'Copy code'}
</button>
</div>
<pre>
<code>
{CODE_LINES.map(([className, text], i) =>
className === 'plain' ? (
<span key={i}>{text}</span>
) : (
<span key={i} className={className}>
{text}
</span>
),
)}
</code>
</pre>
</div>
)
}
+23 -3
View File
@@ -1,5 +1,25 @@
import type { InputHTMLAttributes, ReactNode } from 'react'
type FormFieldProps = { label: string; hint: string; children: ReactNode; readOnlyLabel?: string }
export function FormField({ label, hint, children, readOnlyLabel }: FormFieldProps) { return <label className="field"><span>{label}{readOnlyLabel && <em>{readOnlyLabel}</em>}</span>{children}<small>{hint}</small></label> }
export function TextInput(props: InputHTMLAttributes<HTMLInputElement>) { return <input {...props} /> }
type FormFieldProps = {
label: string
hint: string
children: ReactNode
readOnlyLabel?: string
}
export function FormField({ label, hint, children, readOnlyLabel }: FormFieldProps) {
return (
<label className="field">
<span>
{label}
{readOnlyLabel && <em>{readOnlyLabel}</em>}
</span>
{children}
<small>{hint}</small>
</label>
)
}
export function TextInput(props: InputHTMLAttributes<HTMLInputElement>) {
return <input {...props} />
}
+41
View File
@@ -0,0 +1,41 @@
import { Button } from './Button'
import { Icon } from './Icon'
export function Hero() {
return (
<section className="hero-section">
<div className="hero-copy">
<span className="eyebrow accent">Component library</span>
<h1>Build with clarity.</h1>
<p>A considered set of accessible, composable components for building calm, capable interfaces.</p>
<div className="hero-actions">
<Button
variant="primary"
onClick={() => document.getElementById('button-matrix')?.scrollIntoView({ behavior: 'smooth' })}
>
Explore components <Icon name="arrow" size={16} />
</Button>
<Button variant="ghost">Read the principles</Button>
</div>
</div>
<div className="hero-orbit" aria-hidden="true">
<div className="orbit orbit-one" />
<div className="orbit orbit-two" />
<div className="orbit-core">
<div className="brand-mark large">
<span />
</div>
<span>
system
<br />
<b>ready</b>
</span>
</div>
<span className="orbit-label label-one">TOKENS <b>48</b></span>
<span className="orbit-label label-two">A11Y <b>AAA</b></span>
<span className="orbit-label label-three">RUNTIME <b>FAST</b></span>
</div>
</section>
)
}
+24 -2
View File
@@ -1,9 +1,31 @@
type IconProps = { name: string; size?: number }
const paths: Record<string, string> = {
search: 'M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm6-2 4 4', sun: 'M12 3v2m0 14v2M4 12h2m12 0h2M12 8a4 4 0 1 1 0 8 4 4 0 0 1 0-8Z', copy: 'M8 8h10v12H8zM6 16H4V4h12v2', check: 'm5 12 4 4L19 6', eye: 'M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6S2 12 2 12Zm10 3a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z', arrow: 'M5 12h14m-6-6 6 6-6 6', menu: 'M4 6h16M4 12h16M4 18h16', layers: 'm12 3 9 5-9 5-9-5 9-5Zm-9 9 9 5 9-5M3 16l9 5 9-5',
search: 'M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm6-2 4 4',
sun: 'M12 3v2m0 14v2M4 12h2m12 0h2M12 8a4 4 0 1 1 0 8 4 4 0 0 1 0-8Z',
moon: 'M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z',
copy: 'M8 8h10v12H8zM6 16H4V4h12v2',
check: 'm5 12 4 4L19 6',
eye: 'M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6S2 12 2 12Zm10 3a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z',
arrow: 'M5 12h14m-6-6 6 6-6 6',
menu: 'M4 6h16M4 12h16M4 18h16',
layers: 'm12 3 9 5-9 5-9-5 9-5Zm-9 9 9 5 9-5M3 16l9 5 9-5',
}
export function Icon({ name, size = 18 }: IconProps) {
return <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d={paths[name] || paths.layers} /></svg>
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d={paths[name] || paths.layers} />
</svg>
)
}
+57 -4
View File
@@ -1,7 +1,60 @@
import type { ReactNode } from 'react'
export type MarkdownRendererProps = {
title?: string
kicker?: string
}
export type MarkdownRendererProps = { title?: string; kicker?: string; children?: ReactNode }
export function MarkdownRenderer({
title = 'Quiet power, shipped weekly.',
kicker = 'NORTHSTAR / RELEASE NOTES',
}: MarkdownRendererProps) {
return (
<div className="markdown-card">
<div className="markdown-toolbar">
<span className="eyebrow">Preview</span>
<span className="mono muted">release-notes.md</span>
</div>
export function MarkdownRenderer({ title = 'Quiet power, shipped weekly.', kicker = 'NORTHSTAR / RELEASE NOTES' }: MarkdownRendererProps) {
return <div className="markdown-card"><div className="markdown-toolbar"><span className="eyebrow">Preview</span><span className="mono muted">release-notes.md</span></div><article className="markdown-body"><span className="md-kicker">{kicker}</span><h3>{title}</h3><p>Our components help teams move quickly without making the interface feel rushed.</p><div className="md-callout"><span className="callout-mark">✦</span><p><strong>New in 0.8.2</strong><br />Button focus rings now respect your theme while keeping a dependable contrast ratio.</p></div><h4>What changed</h4><ul><li>Added <code>accent-subtle</code> surface token</li><li>Improved keyboard navigation in menus</li><li>Reduced layout shift during loading states</li></ul><div className="md-table"><div><span>Token</span><span>Value</span><span>Role</span></div><div><code>surface-1</code><span>#111118</span><span>Elevated panels</span></div><div><code>accent</code><span className="md-accent">#A69BFF</span><span>Interactive focus</span></div></div></article></div>
<article className="markdown-body">
<span className="md-kicker">{kicker}</span>
<h3>{title}</h3>
<p>Our components help teams move quickly without making the interface feel rushed.</p>
<div className="md-callout">
<span className="callout-mark">✦</span>
<p>
<strong>New in 0.8.2</strong>
<br />
Button focus rings now respect your theme while keeping a dependable contrast ratio.
</p>
</div>
<h4>What changed</h4>
<ul>
<li>
Added <code>accent-subtle</code> surface token
</li>
<li>Improved keyboard navigation in menus</li>
<li>Reduced layout shift during loading states</li>
</ul>
<div className="md-table">
<div>
<span>Token</span>
<span>Value</span>
<span>Role</span>
</div>
<div>
<code>surface-1</code>
<span>#111118</span>
<span>Elevated panels</span>
</div>
<div>
<code>accent</code>
<span className="md-accent">#A69BFF</span>
<span>Interactive focus</span>
</div>
</div>
</article>
</div>
)
}
+39
View File
@@ -0,0 +1,39 @@
import { Button } from './Button'
import { Icon } from './Icon'
type RightRailProps = { sections: string[] }
export function RightRail({ sections }: RightRailProps) {
return (
<aside className="right-rail">
<div className="rail-card">
<span className="eyebrow">On this page</span>
{sections.map((label, i) => (
<a className={i === 0 ? 'rail-link active' : 'rail-link'} href={i === 0 ? '#button-matrix' : '#'} key={label}>
<span>0{i + 1}</span>
{label}
</a>
))}
</div>
<div className="rail-card release">
<div className="release-icon">
<Icon name="layers" size={18} />
</div>
<strong>Designed to be extended</strong>
<p>Bring your brand, keep the rhythm. Tokens are portable and components stay unopinionated.</p>
<button className="text-button">
Read the docs <Icon name="arrow" size={14} />
</button>
</div>
<div className="rail-card">
<span className="eyebrow">Need a hand?</span>
<p className="rail-copy">Check the contribution guide or say hello in the community.</p>
<Button variant="secondary" className="full">
Open community <span>↗</span>
</Button>
</div>
</aside>
)
}
+22 -2
View File
@@ -1,7 +1,27 @@
import type { ReactNode } from 'react'
type SectionProps = { index: string; title: string; description: string; meta?: ReactNode; children: ReactNode; id?: string; className?: string }
type SectionProps = {
index: string
title: string
description: string
meta?: ReactNode
children: ReactNode
id?: string
className?: string
}
export function Section({ index, title, description, meta, children, id, className = '' }: SectionProps) {
return <section className={`section-block ${className}`.trim()} id={id}><div className="section-heading"><div><span className="section-index">{index}</span><h2>{title}</h2><p>{description}</p></div>{meta}</div>{children}</section>
return (
<section className={`section-block ${className}`.trim()} id={id}>
<div className="section-heading">
<div>
<span className="section-index">{index}</span>
<h2>{title}</h2>
<p>{description}</p>
</div>
{meta}
</div>
{children}
</section>
)
}
+52 -2
View File
@@ -2,8 +2,58 @@ import { Icon } from './Icon'
export type NavigationItem = { label: string; count?: number }
export type NavigationGroup = { label: string; items: NavigationItem[] }
type SidebarProps = { active: string; groups: NavigationGroup[]; onSelect: (item: string) => void }
type SidebarProps = {
active: string
groups: NavigationGroup[]
onSelect: (item: string) => void
}
export function Sidebar({ active, groups, onSelect }: SidebarProps) {
return <aside className="sidebar"><div className="sidebar-heading"><div className="sidebar-library"><Icon name="layers" size={13} /> Library</div><button className="sidebar-collapse" aria-label="Collapse sidebar">⌘</button></div><div className="sidebar-intro"><span className="eyebrow">Northstar system</span><p>Principles and primitives for product teams.</p></div><nav aria-label="Documentation navigation">{groups.map(group => <div className="nav-group" key={group.label}><span className="nav-label">{group.label}</span>{group.items.map(item => <button key={item.label} className={active === item.label ? 'nav-item active' : 'nav-item'} onClick={() => onSelect(item.label)}><span className="nav-glyph" aria-hidden="true">{item.count ? '◈' : '·'}</span><span>{item.label}</span>{item.count !== undefined && <span className="nav-count">{item.count}</span>}</button>)}</div>)}</nav><div className="sidebar-bottom"><div className="status-line"><span className="status-dot" />All systems operational</div><span className="mono muted">Updated 2 min ago</span></div></aside>
return (
<aside className="sidebar">
<div className="sidebar-heading">
<div className="sidebar-library">
<Icon name="layers" size={13} /> Library
</div>
<button className="sidebar-collapse" aria-label="Collapse sidebar">
⌘
</button>
</div>
<div className="sidebar-intro">
<span className="eyebrow">Northstar system</span>
<p>Principles and primitives for product teams.</p>
</div>
<nav aria-label="Documentation navigation">
{groups.map(group => (
<div className="nav-group" key={group.label}>
<span className="nav-label">{group.label}</span>
{group.items.map(item => (
<button
key={item.label}
className={active === item.label ? 'nav-item active' : 'nav-item'}
onClick={() => onSelect(item.label)}
>
<span className="nav-glyph" aria-hidden="true">
{item.count ? '◈' : '·'}
</span>
<span>{item.label}</span>
{item.count !== undefined && <span className="nav-count">{item.count}</span>}
</button>
))}
</div>
))}
</nav>
<div className="sidebar-bottom">
<div className="status-line">
<span className="status-dot" />
All systems operational
</div>
<span className="mono muted">Updated 2 min ago</span>
</div>
</aside>
)
}
+17 -1
View File
@@ -1,8 +1,24 @@
import { Icon } from './Icon'
export type Token = { name: string; color: string; value: string }
type TokenGridProps = { tokens: Token[]; onCopy?: (value: string) => void }
export function TokenGrid({ tokens, onCopy }: TokenGridProps) {
return <div className="color-grid">{tokens.map(token => <div className="color-card" key={token.name}><div className="swatch" style={{ background: token.color }} /><div><strong>{token.name}</strong><span className="mono">{token.value}</span></div><button className="copy-icon" onClick={() => onCopy?.(token.value)} aria-label={`Copy ${token.name} token`}><Icon name="copy" size={14} /></button></div>)}</div>
return (
<div className="color-grid">
{tokens.map(token => (
<div className="color-card" key={token.name}>
<div className="swatch" style={{ background: token.color }} />
<div>
<strong>{token.name}</strong>
<span className="mono">{token.value}</span>
</div>
<button className="copy-icon" onClick={() => onCopy?.(token.value)} aria-label={`Copy ${token.name} token`}>
<Icon name="copy" size={14} />
</button>
</div>
))}
</div>
)
}
+56 -2
View File
@@ -1,7 +1,61 @@
import { useState } from 'react'
import { Icon } from './Icon'
type TopbarProps = { query: string; onQueryChange: (value: string) => void; onMenuToggle: () => void }
type TopbarProps = {
query: string
onQueryChange: (value: string) => void
onMenuToggle: () => void
}
/** App theme is a data attribute on <html>; tokens.css defines both palettes. */
function useTheme() {
const [theme, setTheme] = useState<'dark' | 'light'>(() =>
localStorage.getItem('ns-theme') === 'light' ? 'light' : 'dark',
)
const toggle = () => {
const next = theme === 'dark' ? 'light' : 'dark'
setTheme(next)
document.documentElement.dataset.theme = next
localStorage.setItem('ns-theme', next)
}
return { theme, toggle }
}
export function Topbar({ query, onQueryChange, onMenuToggle }: TopbarProps) {
return <header className="topbar"><div className="brand"><div className="brand-mark"><span /></div><b>northstar</b><span className="version">v0.8.2</span></div><div className="top-search"><Icon name="search" size={16} /><input value={query} onChange={e => onQueryChange(e.target.value)} placeholder="Search components, tokens, docs..." /><kbd>⌘ K</kbd></div><nav className="top-links"><a href="#docs">Docs</a><a href="#github">GitHub ↗</a><button className="icon-button" aria-label="Toggle theme"><Icon name="sun" size={17} /></button><button className="avatar">MS</button></nav><button className="mobile-menu icon-button" onClick={onMenuToggle} aria-label="Toggle navigation"><Icon name="menu" /></button></header>
const { theme, toggle } = useTheme()
return (
<header className="topbar">
<div className="brand">
<div className="brand-mark">
<span />
</div>
<b>northstar</b>
<span className="version">v0.8.2</span>
</div>
<div className="top-search">
<Icon name="search" size={16} />
<input
value={query}
onChange={e => onQueryChange(e.target.value)}
placeholder="Search components, tokens, docs..."
/>
<kbd>⌘ K</kbd>
</div>
<nav className="top-links">
<a href="#docs">Docs</a>
<a href="#github">GitHub ↗</a>
<button className="icon-button" onClick={toggle} aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} theme`}>
<Icon name={theme === 'dark' ? 'sun' : 'moon'} size={17} />
</button>
<button className="avatar">MS</button>
</nav>
<button className="mobile-menu icon-button" onClick={onMenuToggle} aria-label="Toggle navigation">
<Icon name="menu" />
</button>
</header>
)
}
+129
View File
@@ -0,0 +1,129 @@
/* ─────────────────────────────────────────────────────────────
Northstar design tokens — single source of truth.
App.css (and every future stylesheet) consumes ONLY these
variables. Change a token here and the whole UI follows.
Key one-liners:
• global text density ......... --root-font-size (e.g. 112%)
• "small text a bit larger" ... bump --text-xs / --text-2xs
• new color ................... add a token, never a hex
• accent washes ............... color-mix(in srgb, var(--accent) N%, transparent)
───────────────────────────────────────────────────────────── */
:root {
/* Global amplifier: every token-driven font size scales from
the root size. 100% = 16px. Readability preset: 112%. */
--root-font-size: 112%;
/* ── Type scale (rem-based, snaps to the 9→28px grid) ───── */
--text-3xs: 0.5625rem; /* 9px micro labels */
--text-2xs: 0.625rem; /* 10px eyebrows, meta */
--text-xs: 0.6875rem; /* 11px buttons, nav */
--text-sm: 0.75rem; /* 12px inputs, copy */
--text-md: 0.875rem; /* 14px longform body */
--text-base: 1rem; /* 16px ledes, brand */
--text-lg: 1.3125rem; /* 21px card headings */
--text-xl: 1.5625rem; /* 25px sub-headlines */
--text-2xl: 1.75rem; /* 28px section titles */
--text-hero: clamp(2.625rem, 5vw, 4.375rem); /* 42→70px display */
/* ── Type families ──────────────────────────────────────── */
--font-sans: 'Manrope', sans-serif;
--font-mono: 'DM Mono', monospace;
/* ── Surfaces ───────────────────────────────────────────── */
--bg: #0b0b10; /* page base */
--bg-deep: #0d0d13; /* code blocks, editor, sidebar wash */
--panel: #111118; /* cards */
--panel-2: #16161f; /* inputs, tabs */
--panel-3: #1b1b25; /* raised panels, hovers */
--panel-hover: #191923; /* interactive surfaces on hover */
--panel-active: #252535;/* pressed / selected surfaces */
/* ── Lines ──────────────────────────────────────────────── */
--line: #292933; /* default border */
--line-soft: #202029; /* hairlines inside dense grids */
--line-hover: #4a4760; /* border on hover / accent-tinted */
--line-focus: #5b5486; /* focused inputs, search */
/* ── Text ───────────────────────────────────────────────── */
--text: #f1f0f7; /* primary */
--text-bright: #f5f3ff; /* active nav, tab-selected */
--text-soft: #b8b6c4; /* longform body */
--text-code: #c8c5d2; /* code blocks */
--muted: #92919e; /* secondary */
--subtle: #666572; /* tertiary, disabled */
/* ── Accents & status ───────────────────────────────────── */
--accent: #a69bff;
--accent-contrast: #171526; /* text/icons placed on accent */
--positive: #73dfae;
--warning: #ffc777;
--critical: #ff8585;
--pink: #e58bff;
--blue: #9bcbff;
/* ── Spacing (4px rhythm; 1 = 4px … 11 = 80px) ──────────── */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.25rem;
--space-6: 1.5rem;
--space-7: 2rem;
--space-8: 2.5rem;
--space-9: 3rem;
--space-10: 4rem;
--space-11: 5rem;
/* ── Radii ──────────────────────────────────────────────── */
--radius-sm: 5px; /* kbd, inputs */
--radius-md: 6px; /* buttons, nav items */
--radius-lg: 9px; /* cards, showcases */
--radius-xl: 12px; /* brand mark, orbit core */
--radius-2xl: 14px; /* callouts */
--radius-pill: 20px; /* toggles */
/* ── Focus ──────────────────────────────────────────────── */
--focus-ring: 2px solid var(--accent);
--focus-offset: 3px;
}
/* ── Light theme ──────────────────────────────────────────────
Same token names, daylight values. Components never know the
difference — they only ever read var(--*). Toggle via
document.documentElement.dataset.theme = 'light' (Topbar does
this and persists to localStorage under 'ns-theme'). */
:root[data-theme='light'] {
--bg: #f6f5fb;
--bg-deep: #efedf6;
--panel: #ffffff;
--panel-2: #f3f1f9;
--panel-3: #eae7f4;
--panel-hover: #e7e4f1;
--panel-active: #dedbf0;
--line: #d8d5e7;
--line-soft: #e4e2ee;
--line-hover: #b3abda;
--line-focus: #8d82cf;
--text: #1e1a30;
--text-bright: #141126;
--text-soft: #4b4665;
--text-code: #383354;
--muted: #625d7d;
--subtle: #8d89a3;
/* Deeper accent + saturated status colors for light-surface contrast */
--accent: #6b46e5;
--accent-contrast: #ffffff;
--positive: #178a5c;
--warning: #a05f00;
--critical: #d14141;
--pink: #bb2fc0;
--blue: #2f66c9;
}
html { font-size: var(--root-font-size); }