Template
Merge pull request 'Design tokens + agent iteration hardening' (#1) from design-tokens-agent-hardening into main
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
commit
9d444cc0ec
20 files changed
+874
-57
No files matched your search
@@ -22,3 +22,4 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
.codesearch/
|
||||
@@ -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.
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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
@@ -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'} <<span className="blue">Button</span> variant=<span className="green">"primary"</span>>Deploy changes</<span className="blue">Button</span>>{'\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)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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); }
|
||||
Reference in new issue
Block a user