non ai theme

This commit is contained in:
mats committed 2026-09-12 18:41:42 +02:00
1 parent 9d444cc0ec
commit 61df5e3dcc
28 files changed
+3338 -439

No files matched your search

+28 -12
View File
@@ -17,26 +17,42 @@ React 19 + Vite 8 + TypeScript design-system showcase. Dark-first, token-driven
## Design tokens — the contract ## 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)`. 1. **Single source of truth: `src/tokens.css`.** Every font size, color, spacing, radius, and shadow color comes from a token. The palette is defined in modern CSS Color 4 `lab(...)` color space:
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`. - Dark theme (`.dark`, `[data-theme='dark']`): Pitch black base (`lab(0% 0 0)`), deep cards (`lab(.903296% 0 0)`), radiant electric mint/cyan primary (`lab(89.9321% -47.9163 -15.7303)`), vivid destructive (`lab(54.5811% 71.684 50.9279)`), chart colors 1–5, and subtle dot grid (`--dot-color`).
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. - Light theme (`:root`, `[data-theme='light']`): Pure white daylight base (`lab(100% 0 0)`), crisp charcoal primary (`lab(7.78201% -.0000149012 0)`), and high-contrast surfaces.
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. 2. 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(--primary) N%, transparent)`.
5. "Make the small text a bit larger" = bump `--text-xs`/`--text-2xs`. "Soften a surface" = its token. No grep, no per-component edits. 3. 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`.
4. **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.
5. **Theming:** components read only `var(--*)` and never know the theme. Toggle synchronizes both `document.documentElement.dataset.theme` and `.dark`/`.light` classes.
## Smart Utils & AI Workflow
- **Class helper**: `src/utils/cn.ts` — zero-dependency classnames merging utility for conditional component styles.
- **Smart CSS**: `src/utils.css` — ready-to-use helpers (`.dot-canvas`, `.surface-card`, `.surface-sidebar`, `.badge-*`, `.hover-lift`, `.kbd-tag`, `.skeleton-pulse`).
- **Composable Primitives**: `src/components/ui/` (`Badge.tsx`, `Card.tsx`, `StatCard.tsx`, `Kbd.tsx`).
- **AI Agent System Prompt & Recipes**: `src/data/ai-rules.ts`.
## Structure ## Structure
``` ```
src/ src/
tokens.css ← design tokens + light theme (edit this first) tokens.css ← lab() design tokens + dark & light themes (edit this first)
index.css ← fonts, tokens import, base resets utils.css ← token-compliant smart CSS utility classes
App.css ← all component styles (token-driven, plain CSS) index.css ← fonts, tokens & utils import, base resets
App.tsx ← page composition only App.css ← application component styles (token-driven, plain CSS)
components/ ← one component per file, readable multi-line JSX App.tsx ← page composition & showcase layout
Topbar.tsx ← owns the theme toggle (useTheme inside) utils/
cn.ts ← conditional classnames utility
components/
ui/ ← composable UI primitives (Badge, Card, StatCard, Kbd)
AIWorkflowSection.tsx ← AI workflow and smart utils showcase
Topbar.tsx ← owns the theme toggle (.dark & data-theme)
Hero.tsx, CodeSample.tsx, RightRail.tsx, ButtonMatrix.tsx, Hero.tsx, CodeSample.tsx, RightRail.tsx, ButtonMatrix.tsx,
Sidebar.tsx, Section.tsx, FormField.tsx, TokenGrid.tsx, Sidebar.tsx, Section.tsx, FormField.tsx, TokenGrid.tsx,
MarkdownRenderer.tsx, CodeEditor.tsx, Button.tsx, Icon.tsx MarkdownRenderer.tsx, CodeEditor.tsx, Button.tsx, Icon.tsx
data/library.ts ← demo content for the showcase data/
library.ts ← demo content and color token data for the showcase
ai-rules.ts ← AI copilot prompt and quick implementation recipes
scripts/check-tokens.mjs ← token contract guard (part of npm run check) scripts/check-tokens.mjs ← token contract guard (part of npm run check)
``` ```
+1436 -307
View File
File diff suppressed because it is too large. Load diff
+24 -8
View File
@@ -9,6 +9,7 @@ import { FormField, TextInput } from './components/FormField'
import { CodeSample } from './components/CodeSample' import { CodeSample } from './components/CodeSample'
import { MarkdownRenderer } from './components/MarkdownRenderer' import { MarkdownRenderer } from './components/MarkdownRenderer'
import { CodeEditor } from './components/CodeEditor' import { CodeEditor } from './components/CodeEditor'
import { AIWorkflowSection } from './components/AIWorkflowSection'
import { Section } from './components/Section' import { Section } from './components/Section'
import { RightRail } from './components/RightRail' import { RightRail } from './components/RightRail'
import { Icon } from './components/Icon' import { Icon } from './components/Icon'
@@ -49,8 +50,18 @@ export default function App() {
<div className="content-grid"> <div className="content-grid">
<div className="content-main"> <div className="content-main">
<Section <Section
id="button-matrix" id="ai-workflow-smart-utils"
index="01 / COMPONENTS" index="01 / AI WORKFLOW & SMART UTILS"
title="Smart Utils & AI Iteration"
description="Engineered for AI copilot uniformity, zero visual drift, and instant composable prototyping."
meta={<span className="api-pill">lab() colors · cn() · primitives</span>}
>
<AIWorkflowSection />
</Section>
<Section
id="buttons"
index="02 / COMPONENTS"
title="Buttons" title="Buttons"
description="Actions with a clear hierarchy and dependable feedback." description="Actions with a clear hierarchy and dependable feedback."
meta={<span className="api-pill">Button · 12 variants</span>} meta={<span className="api-pill">Button · 12 variants</span>}
@@ -65,9 +76,10 @@ export default function App() {
</Section> </Section>
<Section <Section
index="02 / FOUNDATIONS" id="color-tokens"
index="03 / FOUNDATIONS"
title="Color tokens" title="Color tokens"
description="Functional color roles keep every surface intentional." description="Functional color roles in CSS Color 4 lab() keep every surface intentional."
meta={ meta={
<button className="text-button"> <button className="text-button">
View all tokens <Icon name="arrow" size={15} /> View all tokens <Icon name="arrow" size={15} />
@@ -78,7 +90,8 @@ export default function App() {
</Section> </Section>
<Section <Section
index="03 / PRIMITIVES" id="form-controls"
index="04 / PRIMITIVES"
title="Form controls" title="Form controls"
description="Inputs designed for focus, flow, and the occasional messy edge case." description="Inputs designed for focus, flow, and the occasional messy edge case."
> >
@@ -107,7 +120,8 @@ export default function App() {
</Section> </Section>
<Section <Section
index="04 / CODE" id="composable-by-default"
index="05 / CODE"
title="Composable by default" title="Composable by default"
description="Small APIs. Strong defaults. No surprises." description="Small APIs. Strong defaults. No surprises."
> >
@@ -115,7 +129,8 @@ export default function App() {
</Section> </Section>
<Section <Section
index="05 / CONTENT" id="markdown-rendering"
index="06 / CONTENT"
title="Markdown rendering" title="Markdown rendering"
description="A readable editorial layer for changelogs, docs, and product notes." description="A readable editorial layer for changelogs, docs, and product notes."
meta={<span className="api-pill">Markdown · styled</span>} meta={<span className="api-pill">Markdown · styled</span>}
@@ -124,7 +139,8 @@ export default function App() {
</Section> </Section>
<Section <Section
index="06 / DEVELOPER TOOLING" id="codemirror-theme"
index="07 / DEVELOPER TOOLING"
title="CodeMirror theme" title="CodeMirror theme"
description="A focused editor palette for examples, playgrounds, and embedded code." description="A focused editor palette for examples, playgrounds, and embedded code."
meta={<span className="api-pill">codemirror · dark</span>} meta={<span className="api-pill">codemirror · dark</span>}
+182
View File
@@ -0,0 +1,182 @@
import { useState } from 'react'
import { StatCard } from './ui/StatCard'
import { Badge } from './ui/Badge'
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './ui/Card'
import { Kbd } from './ui/Kbd'
import { Icon } from './Icon'
import { AI_SYSTEM_PROMPT, AI_QUICK_RECIPES } from '../data/ai-rules'
export function AIWorkflowSection() {
const [copied, setCopied] = useState(false)
const [activeRecipe, setActiveRecipe] = useState(0)
const copyPrompt = () => {
navigator.clipboard?.writeText(AI_SYSTEM_PROMPT)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<div className="stack-lg">
{/* ── Metric Highlights using StatCard Primitives ── */}
<div className="grid-3">
<StatCard
title="Iteration Velocity"
value="4.8x"
change="+42%"
trend="up"
caption="via zero-drift tokens"
icon={<Icon name="sparkle" size={16} />}
/>
<StatCard
title="Token Uniformity"
value="100%"
change="Guaranteed"
trend="up"
caption="check-tokens contract"
icon={<Icon name="check" size={16} />}
/>
<StatCard
title="Smart Primitives"
value="8+ Utils"
change="Zero setup"
trend="neutral"
caption="cn, badges, cards, stats"
icon={<Icon name="terminal" size={16} />}
/>
</div>
{/* ── Dot Canvas & Composable UI Showcase ── */}
<div className="surface-card dot-canvas" style={{ padding: 'var(--space-6)' }}>
<div className="flex-between" style={{ marginBottom: 'var(--space-5)' }}>
<div>
<div className="row-sm" style={{ marginBottom: 'var(--space-1)' }}>
<Badge variant="primary">AI Greenfield</Badge>
<Badge variant="positive">lab() Color 4</Badge>
<Badge variant="outline">Uniform Architecture</Badge>
</div>
<h3 style={{ margin: 'var(--space-2) 0 var(--space-1)', fontSize: 'var(--text-xl)', letterSpacing: '-0.03em' }}>
High-Velocity Autonomous Building Canvas
</h3>
<p style={{ margin: 0, color: 'var(--muted-foreground)', fontSize: 'var(--text-sm)' }}>
Rendered on the user-defined <Kbd>--dot-color</Kbd> canvas with composable primitives.
</p>
</div>
<button className="button primary" onClick={copyPrompt}>
<Icon name={copied ? 'check' : 'copy'} size={14} />
{copied ? 'Copied Prompt!' : 'Copy Copilot Prompt'}
</button>
</div>
{/* ── Component Primitives Gallery ── */}
<div className="grid-2">
<Card>
<CardHeader>
<div className="flex-between">
<CardTitle>Autonomous Agent Run</CardTitle>
<Badge variant="positive">Active</Badge>
</div>
<CardDescription>
Executing token validation and layout synthesis across 4 workers.
</CardDescription>
</CardHeader>
<CardContent>
<div className="stack-sm">
<div className="flex-between" style={{ fontSize: 'var(--text-xs)', color: 'var(--muted-foreground)' }}>
<span>Token Verification</span>
<span style={{ color: 'var(--primary)', fontFamily: 'var(--font-mono)' }}>PASS (0 errors)</span>
</div>
<div className="flex-between" style={{ fontSize: 'var(--text-xs)', color: 'var(--muted-foreground)' }}>
<span>Color Engine</span>
<span style={{ fontFamily: 'var(--font-mono)' }}>CSS Color 4 lab()</span>
</div>
<div className="flex-between" style={{ fontSize: 'var(--text-xs)', color: 'var(--muted-foreground)' }}>
<span>Theme Switching</span>
<span style={{ fontFamily: 'var(--font-mono)' }}>.dark &amp; [data-theme]</span>
</div>
</div>
</CardContent>
<CardFooter>
<span style={{ fontSize: 'var(--text-3xs)', color: 'var(--muted-foreground)', fontFamily: 'var(--font-mono)' }}>
Press <Kbd>⌘</Kbd> + <Kbd>K</Kbd> for palette
</span>
<button className="button secondary" style={{ padding: 'var(--space-1) var(--space-3)' }}>
View Spec
</button>
</CardFooter>
</Card>
<Card>
<CardHeader>
<div className="flex-between">
<CardTitle>Smart Primitives &amp; Status</CardTitle>
<Badge variant="secondary">Composable</Badge>
</div>
<CardDescription>
Zero-drift badges, cards, and keyboard indicators ready for prompt generation.
</CardDescription>
</CardHeader>
<CardContent>
<div className="stack-sm">
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--muted-foreground)', marginBottom: 'var(--space-1)' }}>
Badge Variants:
</div>
<div className="row-xs" style={{ flexWrap: 'wrap', gap: 'var(--space-2)' }}>
<Badge variant="primary">Primary</Badge>
<Badge variant="positive">Positive</Badge>
<Badge variant="secondary">Secondary</Badge>
<Badge variant="destructive">Destructive</Badge>
<Badge variant="outline">Outline</Badge>
</div>
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--muted-foreground)', marginTop: 'var(--space-3)', marginBottom: 'var(--space-1)' }}>
Shortcuts &amp; Flags:
</div>
<div className="row-xs" style={{ gap: 'var(--space-2)' }}>
<Kbd>Shift</Kbd> + <Kbd>D</Kbd>
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--muted-foreground)' }}>toggle theme</span>
</div>
</div>
</CardContent>
<CardFooter>
<span style={{ fontSize: 'var(--text-3xs)', color: 'var(--chart-2)', fontFamily: 'var(--font-mono)' }}>
✓ Strict token contract compliant
</span>
</CardFooter>
</Card>
</div>
</div>
{/* ── AI Quick Prompt Recipes ── */}
<div className="code-card">
<div className="code-head">
<div className="segmented">
{AI_QUICK_RECIPES.map((recipe, idx) => (
<button
key={recipe.title}
className={activeRecipe === idx ? 'selected' : ''}
onClick={() => setActiveRecipe(idx)}
>
{recipe.title}
</button>
))}
</div>
<button
className="copy-button"
onClick={() => {
navigator.clipboard?.writeText(AI_QUICK_RECIPES[activeRecipe].code)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}}
>
<Icon name={copied ? 'check' : 'copy'} size={13} />
{copied ? 'Copied' : 'Copy Recipe'}
</button>
</div>
<pre>
<code>{AI_QUICK_RECIPES[activeRecipe].code}</code>
</pre>
</div>
</div>
)
}
+2
View File
@@ -10,6 +10,8 @@ const paths: Record<string, string> = {
arrow: 'M5 12h14m-6-6 6 6-6 6', arrow: 'M5 12h14m-6-6 6 6-6 6',
menu: 'M4 6h16M4 12h16M4 18h16', 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', layers: 'm12 3 9 5-9 5-9-5 9-5Zm-9 9 9 5 9-5M3 16l9 5 9-5',
sparkle: 'm12 3 2.5 6.5L21 12l-6.5 2.5L12 21l-2.5-6.5L3 12l6.5-2.5L12 3Z',
terminal: 'm4 17 6-6-6-6m8 14h8',
} }
export function Icon({ name, size = 18 }: IconProps) { export function Icon({ name, size = 18 }: IconProps) {
+6 -3
View File
@@ -8,12 +8,15 @@ export function RightRail({ sections }: RightRailProps) {
<aside className="right-rail"> <aside className="right-rail">
<div className="rail-card"> <div className="rail-card">
<span className="eyebrow">On this page</span> <span className="eyebrow">On this page</span>
{sections.map((label, i) => ( {sections.map((label, i) => {
<a className={i === 0 ? 'rail-link active' : 'rail-link'} href={i === 0 ? '#button-matrix' : '#'} key={label}> const id = label.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
return (
<a className={i === 0 ? 'rail-link active' : 'rail-link'} href={`#${id}`} key={label}>
<span>0{i + 1}</span> <span>0{i + 1}</span>
{label} {label}
</a> </a>
))} )
})}
</div> </div>
<div className="rail-card release"> <div className="rail-card release">
+23 -3
View File
@@ -9,13 +9,33 @@ type TopbarProps = {
/** App theme is a data attribute on <html>; tokens.css defines both palettes. */ /** App theme is a data attribute on <html>; tokens.css defines both palettes. */
function useTheme() { function useTheme() {
const [theme, setTheme] = useState<'dark' | 'light'>(() => const [theme, setTheme] = useState<'dark' | 'light'>(() => {
localStorage.getItem('ns-theme') === 'light' ? 'light' : 'dark', const saved = typeof window !== 'undefined' ? localStorage.getItem('ns-theme') : null
) const initial = saved === 'light' ? 'light' : 'dark'
if (typeof document !== 'undefined') {
document.documentElement.dataset.theme = initial
if (initial === 'dark') {
document.documentElement.classList.add('dark')
document.documentElement.classList.remove('light')
} else {
document.documentElement.classList.add('light')
document.documentElement.classList.remove('dark')
}
}
return initial
})
const toggle = () => { const toggle = () => {
const next = theme === 'dark' ? 'light' : 'dark' const next = theme === 'dark' ? 'light' : 'dark'
setTheme(next) setTheme(next)
document.documentElement.dataset.theme = next document.documentElement.dataset.theme = next
if (next === 'dark') {
document.documentElement.classList.add('dark')
document.documentElement.classList.remove('light')
} else {
document.documentElement.classList.add('light')
document.documentElement.classList.remove('dark')
}
localStorage.setItem('ns-theme', next) localStorage.setItem('ns-theme', next)
} }
return { theme, toggle } return { theme, toggle }
+53
View File
@@ -0,0 +1,53 @@
import { type HTMLAttributes, type ReactNode } from 'react'
import { cn } from '../../utils/cn'
export type AlertVariant = 'info' | 'positive' | 'warning' | 'destructive'
type AlertProps = HTMLAttributes<HTMLDivElement> & {
variant?: AlertVariant
icon?: ReactNode
}
export function Alert({ variant = 'info', icon, className, children, ...props }: AlertProps) {
return (
<div className={cn('alert', `alert-${variant}`, className)} role="alert" {...props}>
{icon && <div className="alert-icon" style={{ display: 'flex', alignItems: 'center' }}>{icon}</div>}
<div className="stack-xs" style={{ flex: 1, minWidth: 0 }}>
{children}
</div>
</div>
)
}
export function AlertTitle({ className, children, ...props }: HTMLAttributes<HTMLHeadingElement>) {
return (
<h5
className={cn(className)}
style={{
margin: 0,
fontSize: 'var(--text-sm)',
fontWeight: 600,
color: 'var(--foreground)',
lineHeight: 1.4,
}}
{...props}
>
{children}
</h5>
)
}
export function AlertDescription({ className, ...props }: HTMLAttributes<HTMLParagraphElement>) {
return (
<div
className={cn(className)}
style={{
margin: 0,
fontSize: 'var(--text-xs)',
color: 'var(--muted-foreground)',
lineHeight: 1.5,
}}
{...props}
/>
)
}
+18
View File
@@ -0,0 +1,18 @@
import type { ReactNode } from 'react'
import { cn } from '../../utils/cn'
export type BadgeVariant = 'primary' | 'secondary' | 'destructive' | 'positive' | 'outline'
type BadgeProps = {
children: ReactNode
variant?: BadgeVariant
className?: string
}
export function Badge({ children, variant = 'primary', className }: BadgeProps) {
return (
<span className={cn('badge', `badge-${variant}`, className)}>
{children}
</span>
)
}
+61
View File
@@ -0,0 +1,61 @@
import type { HTMLAttributes } from 'react'
import { cn } from '../../utils/cn'
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn('surface-card', className)} {...props} />
}
export function CardHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn('stack-xs', className)} style={{ marginBottom: 'var(--space-4)' }} {...props} />
}
export function CardTitle({ className, children, ...props }: HTMLAttributes<HTMLHeadingElement>) {
return (
<h3
className={cn(className)}
style={{
margin: 0,
fontSize: 'var(--text-lg)',
fontWeight: 700,
color: 'var(--foreground)',
letterSpacing: '-0.02em',
}}
{...props}
>
{children}
</h3>
)
}
export function CardDescription({ className, ...props }: HTMLAttributes<HTMLParagraphElement>) {
return (
<p
className={cn(className)}
style={{
margin: 0,
fontSize: 'var(--text-sm)',
color: 'var(--muted-foreground)',
lineHeight: 1.5,
}}
{...props}
/>
)
}
export function CardContent({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn(className)} {...props} />
}
export function CardFooter({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn('flex-between', className)}
style={{
marginTop: 'var(--space-5)',
paddingTop: 'var(--space-4)',
borderTop: '1px solid var(--border)',
}}
{...props}
/>
)
}
+144
View File
@@ -0,0 +1,144 @@
import { createContext, useContext, useState, useEffect, type ReactNode, type HTMLAttributes } from 'react'
import { cn } from '../../utils/cn'
type DialogContextType = {
open: boolean
setOpen: (open: boolean) => void
}
const DialogContext = createContext<DialogContextType | null>(null)
export function useDialog() {
const ctx = useContext(DialogContext)
if (!ctx) throw new Error('Dialog components must be used within a <Dialog>')
return ctx
}
export type DialogProps = {
children: ReactNode
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
}
export function Dialog({ children, open: controlledOpen, defaultOpen = false, onOpenChange }: DialogProps) {
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen)
const isControlled = controlledOpen !== undefined
const open = isControlled ? controlledOpen : uncontrolledOpen
const setOpen = (next: boolean) => {
if (!isControlled) setUncontrolledOpen(next)
onOpenChange?.(next)
}
return (
<DialogContext.Provider value={{ open, setOpen }}>
{children}
</DialogContext.Provider>
)
}
export function DialogTrigger({ children, className }: { children: ReactNode; className?: string }) {
const { setOpen } = useDialog()
return (
<span className={className} onClick={() => setOpen(true)}>
{children}
</span>
)
}
export function DialogContent({ children, className }: { children: ReactNode; className?: string }) {
const { open, setOpen } = useDialog()
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false)
}
if (open) {
document.addEventListener('keydown', handleKeyDown)
document.body.style.overflow = 'hidden'
}
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.body.style.overflow = ''
}
}, [open, setOpen])
if (!open) return null
return (
<div className="dialog-backdrop" onClick={() => setOpen(false)}>
<div className={cn('dialog-panel', className)} onClick={e => e.stopPropagation()}>
<button
className="dialog-close-btn"
onClick={() => setOpen(false)}
aria-label="Close dialog"
>
✕
</button>
{children}
</div>
</div>
)
}
export function DialogHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn('stack-xs', className)} style={{ marginBottom: 'var(--space-4)' }} {...props} />
}
export function DialogTitle({ className, children, ...props }: HTMLAttributes<HTMLHeadingElement>) {
return (
<h2
className={cn(className)}
style={{
margin: 0,
fontSize: 'var(--text-xl)',
fontWeight: 700,
color: 'var(--foreground)',
letterSpacing: '-0.03em',
}}
{...props}
>
{children}
</h2>
)
}
export function DialogDescription({ className, ...props }: HTMLAttributes<HTMLParagraphElement>) {
return (
<p
className={cn(className)}
style={{
margin: 0,
fontSize: 'var(--text-sm)',
color: 'var(--muted-foreground)',
lineHeight: 1.5,
}}
{...props}
/>
)
}
export function DialogFooter({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn('flex-between', className)}
style={{
marginTop: 'var(--space-6)',
paddingTop: 'var(--space-4)',
borderTop: '1px solid var(--border)',
gap: 'var(--space-3)',
}}
{...props}
/>
)
}
export function DialogClose({ children, className }: { children: ReactNode; className?: string }) {
const { setOpen } = useDialog()
return (
<span className={className} onClick={() => setOpen(false)}>
{children}
</span>
)
}
+48
View File
@@ -0,0 +1,48 @@
import type { HTMLAttributes, ReactNode } from 'react'
import { cn } from '../../utils/cn'
export function EmptyState({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn('empty-state', className)} {...props} />
}
export function EmptyStateIcon({ children, className }: { children: ReactNode; className?: string }) {
return <div className={cn('empty-state-icon', className)}>{children}</div>
}
export function EmptyStateTitle({ className, children, ...props }: HTMLAttributes<HTMLHeadingElement>) {
return (
<h3
className={cn(className)}
style={{
margin: '0 0 var(--space-2)',
fontSize: 'var(--text-lg)',
fontWeight: 700,
color: 'var(--foreground)',
letterSpacing: '-0.02em',
}}
{...props}
>
{children}
</h3>
)
}
export function EmptyStateDescription({ className, ...props }: HTMLAttributes<HTMLParagraphElement>) {
return (
<p
className={cn(className)}
style={{
margin: '0 0 var(--space-5)',
fontSize: 'var(--text-sm)',
color: 'var(--muted-foreground)',
maxWidth: '420px',
lineHeight: 1.6,
}}
{...props}
/>
)
}
export function EmptyStateActions({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn('row-sm', className)} {...props} />
}
+6
View File
@@ -0,0 +1,6 @@
import type { HTMLAttributes } from 'react'
import { cn } from '../../utils/cn'
export function Kbd({ className, ...props }: HTMLAttributes<HTMLElement>) {
return <kbd className={cn('kbd-tag', className)} {...props} />
}
+37
View File
@@ -0,0 +1,37 @@
import type { HTMLAttributes } from 'react'
import { cn } from '../../utils/cn'
type ProgressProps = HTMLAttributes<HTMLDivElement> & {
value?: number
max?: number
indeterminate?: boolean
label?: string
}
export function Progress({
value = 0,
max = 100,
indeterminate = false,
label,
className,
...props
}: ProgressProps) {
const percentage = Math.min(Math.max(Math.round((value / max) * 100), 0), 100)
return (
<div className={cn('stack-xs', className)} {...props}>
{(label || !indeterminate) && (
<div className="flex-between" style={{ fontSize: 'var(--text-xs)', color: 'var(--muted-foreground)' }}>
{label && <span>{label}</span>}
{!indeterminate && <span style={{ fontFamily: 'var(--font-mono)' }}>{percentage}%</span>}
</div>
)}
<div className="progress-container">
<div
className={cn('progress-fill', indeterminate && 'progress-indeterminate')}
style={!indeterminate ? { width: `${percentage}%` } : undefined}
/>
</div>
</div>
)
}
+144
View File
@@ -0,0 +1,144 @@
import { createContext, useContext, useState, useEffect, type ReactNode, type HTMLAttributes } from 'react'
import { cn } from '../../utils/cn'
type SheetContextType = {
open: boolean
setOpen: (open: boolean) => void
}
const SheetContext = createContext<SheetContextType | null>(null)
export function useSheet() {
const ctx = useContext(SheetContext)
if (!ctx) throw new Error('Sheet components must be used within <Sheet>')
return ctx
}
type SheetProps = {
children: ReactNode
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
}
export function Sheet({ children, open: controlledOpen, defaultOpen = false, onOpenChange }: SheetProps) {
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen)
const isControlled = controlledOpen !== undefined
const open = isControlled ? controlledOpen : uncontrolledOpen
const setOpen = (next: boolean) => {
if (!isControlled) setUncontrolledOpen(next)
onOpenChange?.(next)
}
return (
<SheetContext.Provider value={{ open, setOpen }}>
{children}
</SheetContext.Provider>
)
}
export function SheetTrigger({ children, className }: { children: ReactNode; className?: string }) {
const { setOpen } = useSheet()
return (
<span className={className} onClick={() => setOpen(true)}>
{children}
</span>
)
}
export function SheetContent({ children, className }: { children: ReactNode; className?: string }) {
const { open, setOpen } = useSheet()
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false)
}
if (open) {
document.addEventListener('keydown', handleKeyDown)
document.body.style.overflow = 'hidden'
}
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.body.style.overflow = ''
}
}, [open, setOpen])
if (!open) return null
return (
<div className="sheet-backdrop" onClick={() => setOpen(false)}>
<div className={cn('sheet-panel', className)} onClick={e => e.stopPropagation()}>
<button
className="dialog-close-btn"
onClick={() => setOpen(false)}
aria-label="Close sheet"
>
✕
</button>
{children}
</div>
</div>
)
}
export function SheetHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn('stack-xs', className)} style={{ marginBottom: 'var(--space-6)' }} {...props} />
}
export function SheetTitle({ className, children, ...props }: HTMLAttributes<HTMLHeadingElement>) {
return (
<h2
className={cn(className)}
style={{
margin: 0,
fontSize: 'var(--text-lg)',
fontWeight: 700,
color: 'var(--foreground)',
letterSpacing: '-0.02em',
}}
{...props}
>
{children}
</h2>
)
}
export function SheetDescription({ className, ...props }: HTMLAttributes<HTMLParagraphElement>) {
return (
<p
className={cn(className)}
style={{
margin: 0,
fontSize: 'var(--text-sm)',
color: 'var(--muted-foreground)',
lineHeight: 1.5,
}}
{...props}
/>
)
}
export function SheetFooter({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn('flex-between', className)}
style={{
marginTop: 'auto',
paddingTop: 'var(--space-4)',
borderTop: '1px solid var(--border)',
gap: 'var(--space-3)',
}}
{...props}
/>
)
}
export function SheetClose({ children, className }: { children: ReactNode; className?: string }) {
const { setOpen } = useSheet()
return (
<span className={className} onClick={() => setOpen(false)}>
{children}
</span>
)
}
+32
View File
@@ -0,0 +1,32 @@
import type { HTMLAttributes } from 'react'
import { cn } from '../../utils/cn'
export type SkeletonVariant = 'text' | 'circle' | 'rect'
type SkeletonProps = HTMLAttributes<HTMLDivElement> & {
variant?: SkeletonVariant
width?: string | number
height?: string | number
}
export function Skeleton({ variant = 'text', width, height, className, style, ...props }: SkeletonProps) {
const variantClass =
variant === 'circle'
? 'skeleton-circle'
: variant === 'rect'
? 'skeleton-rect'
: 'skeleton-text'
return (
<div
className={cn('skeleton', variantClass, className)}
style={{
width: width !== undefined ? (typeof width === 'number' ? `${width}px` : width) : undefined,
height: height !== undefined ? (typeof height === 'number' ? `${height}px` : height) : undefined,
...style,
}}
aria-hidden="true"
{...props}
/>
)
}
+85
View File
@@ -0,0 +1,85 @@
import type { ReactNode } from 'react'
import { Card } from './Card'
import { Badge } from './Badge'
import { cn } from '../../utils/cn'
type StatCardProps = {
title: string
value: string | number
change?: string
trend?: 'up' | 'down' | 'neutral'
caption?: string
icon?: ReactNode
className?: string
}
export function StatCard({
title,
value,
change,
trend = 'up',
caption,
icon,
className,
}: StatCardProps) {
return (
<Card className={cn('hover-lift', className)}>
<div className="flex-between" style={{ marginBottom: 'var(--space-3)' }}>
<span
style={{
fontFamily: 'var(--font-mono)',
fontSize: 'var(--text-3xs)',
textTransform: 'uppercase',
letterSpacing: '0.08em',
color: 'var(--muted-foreground)',
}}
>
{title}
</span>
{icon && (
<div
style={{
color: 'var(--primary)',
display: 'flex',
alignItems: 'center',
}}
>
{icon}
</div>
)}
</div>
<div
style={{
fontSize: 'var(--text-2xl)',
fontWeight: 800,
color: 'var(--foreground)',
lineHeight: 1.1,
letterSpacing: '-0.04em',
marginBottom: 'var(--space-2)',
}}
>
{value}
</div>
<div className="flex-between" style={{ marginTop: 'var(--space-2)' }}>
{change && (
<Badge variant={trend === 'up' ? 'positive' : trend === 'down' ? 'destructive' : 'secondary'}>
{trend === 'up' ? '↑' : trend === 'down' ? '↓' : '→'} {change}
</Badge>
)}
{caption && (
<span
style={{
fontSize: 'var(--text-3xs)',
color: 'var(--muted-foreground)',
fontFamily: 'var(--font-mono)',
}}
>
{caption}
</span>
)}
</div>
</Card>
)
}
+49
View File
@@ -0,0 +1,49 @@
import { useState } from 'react'
import { cn } from '../../utils/cn'
type SwitchProps = {
checked?: boolean
defaultChecked?: boolean
onCheckedChange?: (checked: boolean) => void
disabled?: boolean
className?: string
id?: string
'aria-label'?: string
}
export function Switch({
checked: controlledChecked,
defaultChecked = false,
onCheckedChange,
disabled = false,
className,
id,
'aria-label': ariaLabel,
}: SwitchProps) {
const [uncontrolledChecked, setUncontrolledChecked] = useState(defaultChecked)
const isControlled = controlledChecked !== undefined
const checked = isControlled ? controlledChecked : uncontrolledChecked
const toggle = () => {
if (disabled) return
const next = !checked
if (!isControlled) setUncontrolledChecked(next)
onCheckedChange?.(next)
}
return (
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
id={id}
aria-label={ariaLabel}
className={cn('switch-root', checked && 'checked', className)}
onClick={toggle}
style={{ opacity: disabled ? 0.5 : 1, cursor: disabled ? 'not-allowed' : 'pointer' }}
>
<span className="switch-thumb" />
</button>
)
}
+49
View File
@@ -0,0 +1,49 @@
import type { HTMLAttributes, TableHTMLAttributes, TdHTMLAttributes, ThHTMLAttributes } from 'react'
import { cn } from '../../utils/cn'
export function Table({ className, ...props }: TableHTMLAttributes<HTMLTableElement>) {
return (
<div className="ui-table-container">
<table className={cn('ui-table', className)} {...props} />
</div>
)
}
export function TableHeader({ className, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
return <thead className={cn(className)} {...props} />
}
export function TableBody({ className, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
return <tbody className={cn(className)} {...props} />
}
export function TableFooter({ className, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
return <tfoot className={cn(className)} {...props} />
}
export function TableRow({ className, ...props }: HTMLAttributes<HTMLTableRowElement>) {
return <tr className={cn(className)} {...props} />
}
export function TableHead({ className, ...props }: ThHTMLAttributes<HTMLTableCellElement>) {
return <th className={cn(className)} {...props} />
}
export function TableCell({ className, ...props }: TdHTMLAttributes<HTMLTableCellElement>) {
return <td className={cn(className)} {...props} />
}
export function TableCaption({ className, ...props }: HTMLAttributes<HTMLTableCaptionElement>) {
return (
<caption
className={cn(className)}
style={{
marginTop: 'var(--space-4)',
fontSize: 'var(--text-3xs)',
color: 'var(--muted-foreground)',
fontFamily: 'var(--font-mono)',
}}
{...props}
/>
)
}
+81
View File
@@ -0,0 +1,81 @@
import { createContext, useContext, useState, type ReactNode, type HTMLAttributes } from 'react'
import { cn } from '../../utils/cn'
type TabsContextType = {
value: string
setValue: (value: string) => void
}
const TabsContext = createContext<TabsContextType | null>(null)
export function useTabs() {
const ctx = useContext(TabsContext)
if (!ctx) throw new Error('Tabs components must be used within <Tabs>')
return ctx
}
type TabsProps = {
defaultValue?: string
value?: string
onValueChange?: (value: string) => void
children: ReactNode
className?: string
}
export function Tabs({ defaultValue = '', value: controlledValue, onValueChange, children, className }: TabsProps) {
const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue)
const isControlled = controlledValue !== undefined
const value = isControlled ? controlledValue : uncontrolledValue
const setValue = (next: string) => {
if (!isControlled) setUncontrolledValue(next)
onValueChange?.(next)
}
return (
<TabsContext.Provider value={{ value, setValue }}>
<div className={cn('ui-tabs-root', className)}>{children}</div>
</TabsContext.Provider>
)
}
export function TabsList({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn('ui-tabs-list', className)} role="tablist" {...props} />
}
type TabsTriggerProps = HTMLAttributes<HTMLButtonElement> & {
value: string
}
export function TabsTrigger({ value, className, children, ...props }: TabsTriggerProps) {
const { value: selectedValue, setValue } = useTabs()
const isActive = selectedValue === value
return (
<button
type="button"
role="tab"
aria-selected={isActive}
className={cn('ui-tabs-trigger', isActive && 'active', className)}
onClick={() => setValue(value)}
{...props}
>
{children}
</button>
)
}
type TabsContentProps = HTMLAttributes<HTMLDivElement> & {
value: string
}
export function TabsContent({ value, className, children, ...props }: TabsContentProps) {
const { value: selectedValue } = useTabs()
if (selectedValue !== value) return null
return (
<div className={cn('ui-tabs-content', className)} role="tabpanel" {...props}>
{children}
</div>
)
}
+19
View File
@@ -0,0 +1,19 @@
import type { ReactNode } from 'react'
import { cn } from '../../utils/cn'
type TooltipProps = {
content: ReactNode
children: ReactNode
className?: string
}
export function Tooltip({ content, children, className }: TooltipProps) {
return (
<span className={cn('tooltip-wrapper', className)} tabIndex={0}>
{children}
<span className="tooltip-box" role="tooltip">
{content}
</span>
</span>
)
}
+14
View File
@@ -0,0 +1,14 @@
export * from './Badge'
export * from './Card'
export * from './StatCard'
export * from './Kbd'
export * from './Dialog'
export * from './Alert'
export * from './EmptyState'
export * from './Tabs'
export * from './Progress'
export * from './Switch'
export * from './Skeleton'
export * from './Table'
export * from './Sheet'
export * from './Tooltip'
+46
View File
@@ -0,0 +1,46 @@
export const AI_SYSTEM_PROMPT = `You are an expert frontend engineer building with Northstar Design System.
Key Architectural Principles:
1. Single source of truth: All colors, typography, spacing, radii, and shadows derive from src/tokens.css.
2. Zero hardcoded colors: NEVER use raw hex (#fff), rgb/rgba, or px font sizes in components or stylesheets.
3. Standard design tokens available:
- Surfaces: var(--background), var(--card), var(--popover), var(--secondary), var(--sidebar)
- Foreground: var(--foreground), var(--card-foreground), var(--muted-foreground)
- Brand & Actions: var(--primary), var(--primary-foreground), var(--ring)
- Status & Charts: var(--destructive), var(--chart-1) through var(--chart-5)
- Spacing: var(--space-1) (4px) to var(--space-11) (80px)
- Typography: var(--text-3xs) to var(--text-2xl), var(--font-sans), var(--font-mono)
4. Use the cn() utility from '@/utils/cn' for conditional class names.
5. Composable UI primitives live in '@/components/ui/' (Badge, Card, StatCard, Kbd).
6. Dot canvas pattern: use .dot-canvas or radial-gradient with var(--dot-color).`
export const AI_QUICK_RECIPES = [
{
title: 'Composing a Metric Card',
code: `<StatCard
title="Active Agents"
value="1,429"
change="+18.4%"
trend="up"
caption="vs last 7 days"
/>`,
},
{
title: 'Using Conditional Classes with cn()',
code: `<div className={cn(
'surface-card hover-lift',
isActive && 'glow-primary',
className
)}>
<Badge variant="primary">Production</Badge>
</div>`,
},
{
title: 'Applying the Dot Grid Canvas',
code: `<div className="dot-canvas p-6 rounded-lg border border-border">
<CardHeader>
<CardTitle>Autonomous Run</CardTitle>
<CardDescription>Pipeline executing across 8 nodes</CardDescription>
</CardHeader>
</div>`,
},
]
+18 -6
View File
@@ -6,12 +6,20 @@ export const navigationGroups: NavigationGroup[] = [
{ label: 'Start here', items: [{ label: 'Overview' }, { label: 'Principles' }, { label: 'Quickstart' }] }, { label: 'Start here', items: [{ label: 'Overview' }, { label: 'Principles' }, { label: 'Quickstart' }] },
{ label: 'Foundations', items: [{ label: 'Color & theme' }, { label: 'Typography' }, { label: 'Spacing' }, { label: 'Elevation' }] }, { label: 'Foundations', items: [{ label: 'Color & theme' }, { label: 'Typography' }, { label: 'Spacing' }, { label: 'Elevation' }] },
{ label: 'Components', items: [{ label: 'Buttons', count: 12 }, { label: 'Form controls' }, { label: 'Badges' }, { label: 'Cards' }, { label: 'Data table' }] }, { label: 'Components', items: [{ label: 'Buttons', count: 12 }, { label: 'Form controls' }, { label: 'Badges' }, { label: 'Cards' }, { label: 'Data table' }] },
{ label: 'Patterns', items: [{ label: 'Command palette' }, { label: 'Empty states' }, { label: 'Settings layouts' }] }, { label: 'AI Workflow', items: [{ label: 'Smart Utils' }, { label: 'Prompt Recipes' }, { label: 'Copilot Rules' }] },
] ]
export const buttonTabs = ['Preview', 'API reference', 'Usage'] export const buttonTabs = ['Preview', 'API reference', 'Usage']
export const pageSections = ['Buttons', 'Color tokens', 'Form controls', 'Markdown rendering', 'CodeMirror theme'] export const pageSections = [
'AI Workflow & Smart Utils',
'Buttons',
'Color tokens',
'Form controls',
'Composable by default',
'Markdown rendering',
'CodeMirror theme',
]
export const buttonExamples: ButtonExample[] = [ export const buttonExamples: ButtonExample[] = [
{ name: 'Primary', label: 'Deploy changes', variant: 'primary' }, { name: 'Primary', label: 'Deploy changes', variant: 'primary' },
@@ -21,8 +29,12 @@ export const buttonExamples: ButtonExample[] = [
] ]
export const colorTokens: Token[] = [ export const colorTokens: Token[] = [
{ name: 'Accent', color: 'var(--accent)', value: '#9b8cff' }, { name: 'Primary', color: 'var(--primary)', value: 'lab(89.9% -47.9 -15.7)' },
{ name: 'Positive', color: 'var(--positive)', value: '#73dfae' }, { name: 'Background', color: 'var(--background)', value: 'lab(0% 0 0)' },
{ name: 'Warning', color: 'var(--warning)', value: '#ffc777' }, { name: 'Card', color: 'var(--card)', value: 'lab(0.9% 0 0)' },
{ name: 'Critical', color: 'var(--critical)', value: '#ff8585' }, { name: 'Destructive', color: 'var(--destructive)', value: 'lab(54.6% 71.7 50.9)' },
{ name: 'Chart 1', color: 'var(--chart-1)', value: 'lab(89.9% -47.9 -15.7)' },
{ name: 'Chart 2', color: 'var(--chart-2)', value: 'lab(67.0% -58.3 19.5)' },
{ name: 'Chart 3', color: 'var(--chart-3)', value: 'lab(72.7% 31.9 97.9)' },
{ name: 'Chart 4', color: 'var(--chart-4)', value: 'lab(52.0% 66.1 -78.2)' },
] ]
+1
View File
@@ -1,4 +1,5 @@
@import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Manrope:wght@400;500;600;700;800&display=swap'); @import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Manrope:wght@400;500;600;700;800&display=swap');
@import './tokens.css'; @import './tokens.css';
@import './utils.css';
:root { font-family:var(--font-sans); color:var(--text); background:var(--bg); font-synthesis:none; text-rendering:optimizeLegibility; -webkit-font-smoothing:antialiased; } :root { font-family:var(--font-sans); color:var(--text); background:var(--bg); font-synthesis:none; text-rendering:optimizeLegibility; -webkit-font-smoothing:antialiased; }
* { box-sizing:border-box; } html { scroll-behavior:smooth; } body { margin:0; min-width:320px; } button,input { font:inherit; } button { cursor:pointer; } * { box-sizing:border-box; } html { scroll-behavior:smooth; } body { margin:0; min-width:320px; } button,input { font:inherit; } button { cursor:pointer; }
+170 -100
View File
@@ -1,22 +1,20 @@
/* ───────────────────────────────────────────────────────────── /* ─────────────────────────────────────────────────────────────
Northstar design tokens — single source of truth. Northstar Design System — Single Source of Truth Tokens.
App.css (and every future stylesheet) consumes ONLY these Modern, high-velocity design system configured for AI-assisted
variables. Change a token here and the whole UI follows. workflows and rapid iteration with strict uniformity.
Key one-liners: Tokens utilize CSS Color 4 lab(...) space:
• global text density ......... --root-font-size (e.g. 112%) • Dark: Pitch black, deep cards, radiant electric mint primary
• "small text a bit larger" ... bump --text-xs / --text-2xs • Light: Pure white daylight, crisp charcoal primary, high contrast
• new color ................... add a token, never a hex
• accent washes ............... color-mix(in srgb, var(--accent) N%, transparent)
───────────────────────────────────────────────────────────── */ ───────────────────────────────────────────────────────────── */
/* ── Global scale, typography & spacing ───────────────────────── */
:root { :root {
/* Global amplifier: every token-driven font size scales from /* Global amplifier: scales every rem-based font size & spacing */
the root size. 100% = 16px. Readability preset: 112%. */
--root-font-size: 112%; --root-font-size: 112%;
/* ── Type scale (rem-based, snaps to the 9→28px grid) ───── */ /* ── Type scale (rem-based, 9→28px grid) ── */
--text-3xs: 0.5625rem; /* 9px micro labels */ --text-3xs: 0.5625rem; /* 9px micro labels */
--text-2xs: 0.625rem; /* 10px eyebrows, meta */ --text-2xs: 0.625rem; /* 10px eyebrows, meta */
--text-xs: 0.6875rem; /* 11px buttons, nav */ --text-xs: 0.6875rem; /* 11px buttons, nav */
@@ -28,102 +26,174 @@
--text-2xl: 1.75rem; /* 28px section titles */ --text-2xl: 1.75rem; /* 28px section titles */
--text-hero: clamp(2.625rem, 5vw, 4.375rem); /* 42→70px display */ --text-hero: clamp(2.625rem, 5vw, 4.375rem); /* 42→70px display */
/* ── Type families ──────────────────────────────────────── */ /* ── Type families ────────────────────────── */
--font-sans: 'Manrope', sans-serif; --font-sans: 'Manrope', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-mono: 'DM Mono', monospace; --font-mono: 'DM Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
/* ── Surfaces ───────────────────────────────────────────── */ /* ── Spacing (4px rhythm) ─────────────────── */
--bg: #0b0b10; /* page base */ --space-1: 0.25rem; /* 4px */
--bg-deep: #0d0d13; /* code blocks, editor, sidebar wash */ --space-2: 0.5rem; /* 8px */
--panel: #111118; /* cards */ --space-3: 0.75rem; /* 12px */
--panel-2: #16161f; /* inputs, tabs */ --space-4: 1rem; /* 16px */
--panel-3: #1b1b25; /* raised panels, hovers */ --space-5: 1.25rem; /* 20px */
--panel-hover: #191923; /* interactive surfaces on hover */ --space-6: 1.5rem; /* 24px */
--panel-active: #252535;/* pressed / selected surfaces */ --space-7: 2rem; /* 32px */
--space-8: 2.5rem; /* 40px */
--space-9: 3rem; /* 48px */
--space-10: 4rem; /* 64px */
--space-11: 5rem; /* 80px */
/* ── Lines ──────────────────────────────────────────────── */ /* ── Radii ────────────────────────────────── */
--line: #292933; /* default border */ --radius-sm: 5px;
--line-soft: #202029; /* hairlines inside dense grids */ --radius-md: 6px;
--line-hover: #4a4760; /* border on hover / accent-tinted */ --radius-lg: 9px;
--line-focus: #5b5486; /* focused inputs, search */ --radius-xl: 12px;
--radius-2xl: 14px;
--radius-pill: 9999px;
/* ── Text ───────────────────────────────────────────────── */ /* ── Focus ────────────────────────────────── */
--text: #f1f0f7; /* primary */ --focus-ring: 2px solid var(--ring);
--text-bright: #f5f3ff; /* active nav, tab-selected */ --focus-offset: 2px;
--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 ────────────────────────────────────────────── /* ── Light Palette (Exact lab() Specification) ───────────────── */
Same token names, daylight values. Components never know the :root,
difference — they only ever read var(--*). Toggle via :root[data-theme='light'],
document.documentElement.dataset.theme = 'light' (Topbar does .light {
this and persists to localStorage under 'ns-theme'). */ color-scheme: light;
: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; --background: lab(100% 0 0);
--line-soft: #e4e2ee; --foreground: lab(2.75381% 0 0);
--line-hover: #b3abda; --card: lab(97.68% -.0000298023 .0000119209);
--line-focus: #8d82cf; --card-foreground: lab(2.75381% 0 0);
--popover: lab(100% 0 0);
--popover-foreground: lab(2.75381% 0 0);
--primary: lab(7.78201% -.0000149012 0);
--primary-foreground: lab(98.26% 0 0);
--secondary: lab(94.2% 0 0);
--secondary-foreground: lab(7.78201% -.0000149012 0);
--muted: lab(94.2% 0 0);
--muted-foreground: lab(39.68% 0 0);
--accent: lab(94.2% 0 0);
--accent-foreground: lab(7.78201% -.0000149012 0);
--destructive: lab(48.4493% 77.4328 61.5452);
--destructive-foreground: lab(48.4493% 77.4328 61.5452);
--border: lab(86.08% -.0000298023 0);
--input: lab(86.08% -.0000298023 0);
--ring: lab(42% 0 0);
--chart-1: lab(57.1026% 64.2584 89.8886);
--chart-2: lab(55.0223% -41.0774 -3.90277);
--chart-3: lab(30.372% -13.1853 -18.7887);
--chart-4: lab(80.1641% 16.6016 99.2089);
--chart-5: lab(72.7183% 31.8672 97.9407);
--sidebar: lab(96.52% -.0000298023 .0000119209);
--sidebar-foreground: lab(2.75381% 0 0);
--sidebar-primary: lab(7.78201% -.0000149012 0);
--sidebar-primary-foreground: lab(98.26% 0 0);
--sidebar-accent: lab(94.2% 0 0);
--sidebar-accent-foreground: lab(7.78201% -.0000149012 0);
--sidebar-border: lab(86.08% -.0000298023 0);
--sidebar-ring: lab(42% 0 0);
--dot-color: lab(7.78201% -.0000149012 0 / .05);
--text: #1e1a30; /* ── Legacy Northstar mappings for light theme ── */
--text-bright: #141126; --bg: var(--background);
--text-soft: #4b4665; --bg-deep: var(--sidebar);
--text-code: #383354; --panel: var(--card);
--muted: #625d7d; --panel-2: var(--secondary);
--subtle: #8d89a3; --panel-3: color-mix(in srgb, var(--card) 85%, var(--border));
--panel-hover: color-mix(in srgb, var(--card) 70%, var(--border));
--panel-active: color-mix(in srgb, var(--secondary) 85%, var(--border));
/* Deeper accent + saturated status colors for light-surface contrast */ --line: var(--border);
--accent: #6b46e5; --line-soft: var(--sidebar-border);
--accent-contrast: #ffffff; --line-hover: var(--ring);
--positive: #178a5c; --line-focus: var(--ring);
--warning: #a05f00;
--critical: #d14141; --text: var(--foreground);
--pink: #bb2fc0; --text-bright: var(--foreground);
--blue: #2f66c9; --text-soft: var(--muted-foreground);
--text-code: var(--foreground);
--subtle: color-mix(in srgb, var(--muted-foreground) 75%, transparent);
--accent-brand: var(--primary);
--accent-contrast: var(--primary-foreground);
--positive: var(--chart-2);
--warning: var(--chart-3);
--critical: var(--destructive);
--pink: var(--chart-5);
--blue: var(--chart-4);
} }
html { font-size: var(--root-font-size); } /* ── Dark Palette (Exact lab() Specification) ────────────────── */
:root:not([data-theme='light']),
:root[data-theme='dark'],
.dark {
color-scheme: dark;
--background: lab(0% 0 0);
--foreground: lab(94.78% 0 0);
--card: lab(.903296% 0 0);
--card-foreground: lab(94.78% 0 0);
--popover: lab(.903296% 0 0);
--popover-foreground: lab(94.78% 0 0);
--primary: lab(89.9321% -47.9163 -15.7303);
--primary-foreground: lab(0% 0 0);
--secondary: lab(2.47865% 0 0);
--secondary-foreground: lab(94.78% 0 0);
--muted: lab(2.47865% 0 0);
--muted-foreground: lab(60.56% 0 0);
--accent: lab(1.98454% 0 0);
--accent-foreground: lab(89.9321% -47.9163 -15.7303);
--destructive: lab(54.5811% 71.684 50.9279);
--destructive-foreground: lab(54.5811% 71.684 50.9279);
--border: lab(4.8411% 0 0);
--input: lab(1.98454% 0 0);
--ring: lab(89.9321% -47.9163 -15.7303);
--chart-1: lab(89.9321% -47.9163 -15.7303);
--chart-2: lab(66.9756% -58.27 19.5419);
--chart-3: lab(72.7183% 31.8672 97.9407);
--chart-4: lab(52.0183% 66.11 -78.2316);
--chart-5: lab(56.101% 79.4328 31.4532);
--sidebar: lab(.498049% 0 0);
--sidebar-foreground: lab(94.78% 0 0);
--sidebar-primary: lab(89.9321% -47.9163 -15.7303);
--sidebar-primary-foreground: lab(0% 0 0);
--sidebar-accent: lab(1.98454% 0 0);
--sidebar-accent-foreground: lab(89.9321% -47.9163 -15.7303);
--sidebar-border: lab(4.8411% 0 0);
--sidebar-ring: lab(89.9321% -47.9163 -15.7303);
--dot-color: lab(89.9321% -47.9163 -15.7303 / .045);
/* ── Legacy Northstar mappings for dark theme ── */
--bg: var(--background);
--bg-deep: var(--sidebar);
--panel: var(--card);
--panel-2: var(--secondary);
--panel-3: color-mix(in srgb, var(--card) 85%, var(--border));
--panel-hover: color-mix(in srgb, var(--card) 60%, var(--border));
--panel-active: color-mix(in srgb, var(--secondary) 75%, var(--border));
--line: var(--border);
--line-soft: var(--sidebar-border);
--line-hover: color-mix(in srgb, var(--ring) 50%, var(--border));
--line-focus: var(--ring);
--text: var(--foreground);
--text-bright: var(--foreground);
--text-soft: var(--muted-foreground);
--text-code: var(--foreground);
--subtle: color-mix(in srgb, var(--muted-foreground) 65%, transparent);
--accent-brand: var(--primary);
--accent-contrast: var(--primary-foreground);
--positive: var(--chart-2);
--warning: var(--chart-3);
--critical: var(--destructive);
--pink: var(--chart-5);
--blue: var(--chart-4);
}
html {
font-size: var(--root-font-size);
}
+533
View File
@@ -0,0 +1,533 @@
/* ─────────────────────────────────────────────────────────────
Northstar Design System — Smart AI Utilities & Primitives.
Consumes ONLY tokens. Strictly compliant with token contract.
───────────────────────────────────────────────────────────── */
/* ── Dot Canvas & Background Grids ───────────────────────────── */
.dot-canvas {
background-image: radial-gradient(var(--dot-color) 1px, transparent 1px);
background-size: 24px 24px;
}
.dot-canvas-dense {
background-image: radial-gradient(var(--dot-color) 1px, transparent 1px);
background-size: 16px 16px;
}
/* ── Layout Helpers ─────────────────────────────────────────── */
.flex-center {
display: flex;
align-items: center;
justify-content: center;
}
.flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
.flex-col {
display: flex;
flex-direction: column;
}
.stack-xs { display: flex; flex-direction: column; gap: var(--space-1); }
.stack-sm { display: flex; flex-direction: column; gap: var(--space-2); }
.stack-md { display: flex; flex-direction: column; gap: var(--space-4); }
.stack-lg { display: flex; flex-direction: column; gap: var(--space-6); }
.row-xs { display: flex; align-items: center; gap: var(--space-1); }
.row-sm { display: flex; align-items: center; gap: var(--space-2); }
.row-md { display: flex; align-items: center; gap: var(--space-4); }
.grid-2 {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-4);
}
.grid-3 {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-4);
}
.grid-4 {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--space-4);
}
@media (max-width: 760px) {
.grid-2, .grid-3, .grid-4 {
grid-template-columns: 1fr;
}
}
/* ── Semantic Surface Containers ────────────────────────────── */
.surface-card {
background: var(--card);
color: var(--card-foreground);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--space-5);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.12);
}
.surface-sidebar {
background: var(--sidebar);
color: var(--sidebar-foreground);
border-right: 1px solid var(--sidebar-border);
}
.surface-popover {
background: var(--popover);
color: var(--popover-foreground);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
}
.surface-muted {
background: var(--secondary);
color: var(--secondary-foreground);
border: 1px solid var(--border);
border-radius: var(--radius-md);
}
/* ── Smart Badges & Status Pills ────────────────────────────── */
.badge {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
font-family: var(--font-mono);
font-size: var(--text-3xs);
font-weight: 500;
line-height: 1;
white-space: nowrap;
border: 1px solid transparent;
}
.badge-primary {
background: color-mix(in srgb, var(--primary) 14%, transparent);
color: var(--primary);
border-color: color-mix(in srgb, var(--primary) 28%, transparent);
}
.badge-secondary {
background: var(--secondary);
color: var(--secondary-foreground);
border-color: var(--border);
}
.badge-destructive {
background: color-mix(in srgb, var(--destructive) 14%, transparent);
color: var(--destructive);
border-color: color-mix(in srgb, var(--destructive) 28%, transparent);
}
.badge-positive {
background: color-mix(in srgb, var(--chart-2) 14%, transparent);
color: var(--chart-2);
border-color: color-mix(in srgb, var(--chart-2) 28%, transparent);
}
.badge-outline {
background: transparent;
color: var(--muted-foreground);
border-color: var(--border);
}
/* ── Interactive Elements & Utilities ───────────────────────── */
.hover-lift {
transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
}
.hover-lift:hover {
transform: translateY(-2px);
border-color: var(--ring);
box-shadow: 0 8px 24px color-mix(in srgb, var(--primary) 10%, rgba(0, 0, 0, 0.2));
}
.kbd-tag {
font-family: var(--font-mono);
font-size: var(--text-3xs);
color: var(--muted-foreground);
background: var(--secondary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: var(--space-1) var(--space-2);
}
.skeleton-pulse {
background: color-mix(in srgb, var(--foreground) 7%, var(--card));
border-radius: var(--radius-md);
animation: skeleton-shimmer 1.8s ease-in-out infinite;
}
@keyframes skeleton-shimmer {
0%, 100% { opacity: 0.45; }
50% { opacity: 0.85; }
}
.glow-primary {
box-shadow: 0 0 24px color-mix(in srgb, var(--primary) 25%, transparent);
}
/* ── Composable Dialog / Modal ──────────────────────────────── */
.dialog-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.72);
backdrop-filter: blur(8px);
z-index: 50;
display: grid;
place-items: center;
padding: var(--space-4);
animation: dialog-fade-in 0.2s ease-out;
}
.dialog-panel {
background: var(--card);
color: var(--card-foreground);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
max-width: 520px;
width: 100%;
padding: var(--space-6);
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.4), 0 0 0 1px color-mix(in srgb, var(--primary) 12%, transparent);
animation: dialog-scale-in 0.2s cubic-bezier(0.16, 1, 0.3, 1);
position: relative;
}
@keyframes dialog-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes dialog-scale-in {
from { opacity: 0; transform: scale(0.96) translateY(8px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
.dialog-close-btn {
position: absolute;
top: var(--space-4);
right: var(--space-4);
border: 0;
background: none;
color: var(--muted-foreground);
border-radius: var(--radius-md);
padding: var(--space-1);
display: grid;
place-items: center;
cursor: pointer;
transition: 0.15s;
}
.dialog-close-btn:hover {
background: var(--secondary);
color: var(--foreground);
}
/* ── Composable Alert / Callout ─────────────────────────────── */
.alert {
display: flex;
gap: var(--space-3);
padding: var(--space-4);
border-radius: var(--radius-lg);
border: 1px solid var(--border);
background: var(--card);
color: var(--foreground);
position: relative;
line-height: 1.5;
}
.alert-info {
background: color-mix(in srgb, var(--primary) 6%, var(--card));
border-color: color-mix(in srgb, var(--primary) 24%, var(--border));
}
.alert-info .alert-icon {
color: var(--primary);
}
.alert-positive {
background: color-mix(in srgb, var(--chart-2) 6%, var(--card));
border-color: color-mix(in srgb, var(--chart-2) 24%, var(--border));
}
.alert-positive .alert-icon {
color: var(--chart-2);
}
.alert-warning {
background: color-mix(in srgb, var(--chart-3) 6%, var(--card));
border-color: color-mix(in srgb, var(--chart-3) 24%, var(--border));
}
.alert-warning .alert-icon {
color: var(--chart-3);
}
.alert-destructive {
background: color-mix(in srgb, var(--destructive) 8%, var(--card));
border-color: color-mix(in srgb, var(--destructive) 28%, var(--border));
}
.alert-destructive .alert-icon {
color: var(--destructive);
}
/* ── Composable Tabs ────────────────────────────────────────── */
.ui-tabs-list {
display: inline-flex;
gap: var(--space-1);
padding: 4px;
background: var(--secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
}
.ui-tabs-trigger {
border: 0;
background: none;
color: var(--muted-foreground);
font-size: var(--text-xs);
font-family: var(--font-sans);
font-weight: 500;
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-md);
cursor: pointer;
transition: 0.2s;
white-space: nowrap;
}
.ui-tabs-trigger:hover {
color: var(--foreground);
}
.ui-tabs-trigger.active {
background: var(--card);
color: var(--foreground);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.14);
}
.ui-tabs-content {
margin-top: var(--space-4);
}
/* ── Empty State ────────────────────────────────────────────── */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: var(--space-10) var(--space-6);
border: 1px dashed var(--border);
border-radius: var(--radius-xl);
background: color-mix(in srgb, var(--secondary) 50%, var(--card));
}
.empty-state-icon {
width: 48px;
height: 48px;
border-radius: var(--radius-lg);
background: color-mix(in srgb, var(--primary) 12%, var(--secondary));
color: var(--primary);
display: grid;
place-items: center;
margin-bottom: var(--space-4);
}
/* ── Progress Bar ───────────────────────────────────────────── */
.progress-container {
width: 100%;
height: 8px;
background: var(--secondary);
border: 1px solid var(--border);
border-radius: var(--radius-pill);
overflow: hidden;
position: relative;
}
.progress-fill {
height: 100%;
background: var(--primary);
border-radius: var(--radius-pill);
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 0 10px color-mix(in srgb, var(--primary) 60%, transparent);
}
.progress-indeterminate {
width: 40%;
position: absolute;
animation: progress-slide 1.5s infinite ease-in-out;
}
@keyframes progress-slide {
0% { transform: translateX(-100%); }
100% { transform: translateX(350%); }
}
/* ── Switch Component ───────────────────────────────────────── */
.switch-root {
width: 40px;
height: 22px;
border-radius: var(--radius-pill);
background: var(--border);
border: 1px solid var(--border);
padding: 2px;
display: inline-flex;
align-items: center;
cursor: pointer;
transition: background-color 0.2s ease, border-color 0.2s ease;
}
.switch-root.checked {
background: var(--primary);
border-color: var(--primary);
}
.switch-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--muted-foreground);
transition: transform 0.2s cubic-bezier(0.2, 0.8, 0.2, 1), background-color 0.2s ease;
}
.switch-root.checked .switch-thumb {
background: var(--primary-foreground);
transform: translateX(18px);
}
/* ── Composable Table ───────────────────────────────────────── */
.ui-table-container {
width: 100%;
overflow-x: auto;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--card);
}
.ui-table {
width: 100%;
border-collapse: collapse;
text-align: left;
font-size: var(--text-sm);
color: var(--foreground);
}
.ui-table th {
padding: var(--space-3) var(--space-4);
font: var(--text-3xs) var(--font-mono);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted-foreground);
background: var(--secondary);
border-bottom: 1px solid var(--border);
}
.ui-table td {
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--border);
}
.ui-table tr:last-child td {
border-bottom: 0;
}
.ui-table tr:hover td {
background: color-mix(in srgb, var(--foreground) 3%, var(--card));
}
/* ── Composable Sheet / Drawer ──────────────────────────────── */
.sheet-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(6px);
z-index: 50;
animation: dialog-fade-in 0.2s ease-out;
}
.sheet-panel {
position: fixed;
top: 0;
bottom: 0;
right: 0;
width: 100%;
max-width: 420px;
background: var(--card);
color: var(--card-foreground);
border-left: 1px solid var(--border);
z-index: 51;
padding: var(--space-6);
display: flex;
flex-direction: column;
box-shadow: -10px 0 35px rgba(0, 0, 0, 0.35);
animation: sheet-slide-in 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes sheet-slide-in {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
/* ── Tooltip ────────────────────────────────────────────────── */
.tooltip-wrapper {
position: relative;
display: inline-flex;
}
.tooltip-box {
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%) translateY(-6px);
background: var(--popover);
color: var(--popover-foreground);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: var(--space-1) var(--space-2);
font: var(--text-3xs) var(--font-mono);
white-space: nowrap;
pointer-events: none;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.2);
z-index: 40;
opacity: 0;
transition: opacity 0.15s ease, transform 0.15s ease;
}
.tooltip-wrapper:hover .tooltip-box,
.tooltip-wrapper:focus-within .tooltip-box {
opacity: 1;
transform: translateX(-50%) translateY(-8px);
}
/* ── Skeleton Shapes ────────────────────────────────────────── */
.skeleton {
background: color-mix(in srgb, var(--foreground) 7%, var(--card));
animation: skeleton-shimmer 1.8s ease-in-out infinite;
display: block;
}
.skeleton-text {
height: 14px;
border-radius: var(--radius-sm);
width: 100%;
}
.skeleton-circle {
border-radius: 50%;
flex-shrink: 0;
}
.skeleton-rect {
border-radius: var(--radius-lg);
}
+29
View File
@@ -0,0 +1,29 @@
type ClassValue = string | number | boolean | undefined | null | { [key: string]: boolean | undefined | null } | ClassValue[]
/**
* Smart class merging utility for rapid AI component authoring.
* Composes class names conditionally and cleanly flattens nested arrays/objects.
*
* Usage:
* cn('base-button', variant === 'primary' && 'button-primary', { 'is-active': active })
*/
export function cn(...inputs: ClassValue[]): string {
const classes: string[] = []
for (const input of inputs) {
if (!input) continue
if (typeof input === 'string' || typeof input === 'number') {
classes.push(String(input))
} else if (Array.isArray(input)) {
const nested = cn(...input)
if (nested) classes.push(nested)
} else if (typeof input === 'object') {
for (const [key, value] of Object.entries(input)) {
if (value) classes.push(key)
}
}
}
return classes.join(' ')
}