Initial commit

This commit is contained in:
mats committed 2026-10-19 16:04:17 +02:00
commit afed023152
8152 files changed
+2086461

No files matched your search

+2228
View File
File diff suppressed because it is too large. Load diff
+109
View File
@@ -0,0 +1,109 @@
import { useEffect, useState } from 'react'
import { Route, Switch, useLocation, useSearch } from 'wouter'
import './App.css'
import {
OVERVIEW_LABEL,
OVERVIEW_PATH,
activeHref,
navigationGroups,
routeForPath,
routes,
useAppState,
type AppRoute,
} from './app'
import { Topbar } from './components/Topbar'
import { Sidebar } from './components/Sidebar'
import { Section } from './components/Section'
import { RightRail } from './components/RightRail'
import { SearchPalette, type SearchItem } from './components/SearchPalette'
import { OverviewPage } from './pages/OverviewPage'
import { NotFoundPage } from './pages/NotFoundPage'
/** Wraps a registry page in its Section — src/app/routes.tsx owns the copy. */
function RoutePage({ route }: { route: AppRoute }) {
const Page = route.page
return (
<Section
index={`${route.index} / ${route.kicker}`}
title={route.title}
description={route.description}
meta={route.meta}
>
<Page />
</Section>
)
}
export default function App() {
/* App-wide settings live in AppStateProvider; shell state stays local. */
const { searchOpen, closeSearch, sidebarCollapsed, toggleSidebar } = useAppState()
const [location, navigate] = useLocation()
const search = useSearch()
const [menu, setMenu] = useState(false)
const current = routeForPath(location)
const active = current?.label ?? (location === OVERVIEW_PATH ? OVERVIEW_LABEL : 'Not found')
const currentHref = activeHref(location, search)
const groupItems = navigationGroups.find(group => group.label === current?.group)?.items ?? []
/* A client-side navigation keeps the previous scroll position otherwise. */
useEffect(() => {
window.scrollTo({ top: 0 })
}, [location])
/* One palette result → one route; patterns carry their id in the query. */
const handleSearchSelect = (item: SearchItem) => {
navigate(item.patternId ? `${item.path}?pattern=${item.patternId}` : item.path)
}
return (
<div className="app-shell">
<Topbar onMenuToggle={() => setMenu(!menu)} />
<SearchPalette isOpen={searchOpen} onClose={closeSearch} onSelect={handleSearchSelect} />
<div className={`body-layout ${menu ? 'menu-open' : ''} ${sidebarCollapsed ? 'sidebar-collapsed' : ''}`}>
<Sidebar
groups={navigationGroups}
activeHref={currentHref}
onNavigate={() => setMenu(false)}
collapsed={sidebarCollapsed}
onToggleCollapse={toggleSidebar}
/>
<main className="main-content">
<div className="breadcrumbs">
<span>{current ? current.group : 'Northstar'}</span>
<span>/</span>
<strong>{active}</strong>
</div>
{location === OVERVIEW_PATH ? (
<OverviewPage />
) : (
<div className="content-grid">
<div className="content-main">
<Switch>
{routes.map(route => (
<Route key={route.path} path={route.path}>
<RoutePage route={route} />
</Route>
))}
<Route>
<NotFoundPage />
</Route>
</Switch>
</div>
{current && <RightRail title={current.group} links={groupItems} activeHref={currentHref} />}
</div>
)}
<footer>
<span>northstar design system</span>
<span className="mono">made for the long haul · 2025</span>
</footer>
</main>
</div>
</div>
)
}
+60
View File
@@ -0,0 +1,60 @@
/* The single fetch wrapper every api module goes through. It knows two
things: JSON in/out, and the backend's error envelope
{"error":{"code","message"}} (see backend/internal/api/respond.go). */
/** Wire shape of every error the Go API returns. */
type ApiErrorEnvelope = {
error: { code: string; message: string }
}
/** One error type for transport failures, HTTP errors and validation
* rejections — components switch on `code`, not on string matching. */
export class ApiError extends Error {
readonly status: number
readonly code: string
constructor(status: number, code: string, message: string) {
super(message)
this.name = 'ApiError'
this.status = status
this.code = code
}
}
/** Empty in dev (the vite proxy serves /api same-origin); point at a real
* backend elsewhere via VITE_API_URL (e.g. http://localhost:8080). */
const API_BASE: string = import.meta.env.VITE_API_URL ?? ''
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
let response: Response
try {
response = await fetch(`${API_BASE}${path}`, {
...init,
headers: { 'Content-Type': 'application/json', ...init?.headers },
})
} catch (cause) {
// fetch only rejects on network-level problems (backend down, offline).
throw new ApiError(
0,
'network',
`Could not reach the API (${
cause instanceof Error ? cause.message : String(cause)
}). Is the Go server running?`,
)
}
if (response.status === 204) {
return undefined as T
}
const body: unknown = await response.json().catch(() => null)
if (!response.ok) {
const envelope = (body as ApiErrorEnvelope | null)?.error
throw new ApiError(
response.status,
envelope?.code ?? 'unknown',
envelope?.message ?? response.statusText,
)
}
return body as T
}
+60
View File
@@ -0,0 +1,60 @@
/* Typed client for the items resource (backend/internal/items is the
source of truth for the wire format). One file per resource; every
function returns promises the SWR hooks consume. */
import { apiFetch } from './client'
export type ItemStatus = 'active' | 'archived'
export type Item = {
id: number
title: string
description: string
status: ItemStatus
createdAt: string
updatedAt: string
}
/** POST body — description/status are optional server-side too. */
export type NewItem = {
title: string
description?: string
status?: ItemStatus
}
/** PATCH body — absent fields stay untouched on the server. */
export type ItemPatch = {
title?: string
description?: string
status?: ItemStatus
}
export type ItemQuery = {
status?: ItemStatus
limit?: number
offset?: number
}
export function listItems(query: ItemQuery = {}): Promise<Item[]> {
const params = new URLSearchParams()
if (query.status) params.set('status', query.status)
if (query.limit !== undefined) params.set('limit', String(query.limit))
if (query.offset !== undefined) params.set('offset', String(query.offset))
const qs = params.toString()
return apiFetch<Item[]>(`/api/v1/items${qs ? `?${qs}` : ''}`)
}
export function getItem(id: number): Promise<Item> {
return apiFetch<Item>(`/api/v1/items/${id}`)
}
export function createItem(input: NewItem): Promise<Item> {
return apiFetch<Item>('/api/v1/items', { method: 'POST', body: JSON.stringify(input) })
}
export function updateItem(id: number, patch: ItemPatch): Promise<Item> {
return apiFetch<Item>(`/api/v1/items/${id}`, { method: 'PATCH', body: JSON.stringify(patch) })
}
export function deleteItem(id: number): Promise<void> {
return apiFetch<void>(`/api/v1/items/${id}`, { method: 'DELETE' })
}
+137
View File
@@ -0,0 +1,137 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
import {
AppStateContext,
VIEW_MODES,
type AppStateContextValue,
type Theme,
type ViewMode,
} from './appState'
import { STORAGE_KEYS, readFlag, readSetting, writeFlag, writeSetting } from './persist'
/** Theme is a data attribute + class on `<html>`; tokens.css defines both
palettes. Kept in sync with the pre-paint script in index.html. */
function applyTheme(theme: Theme) {
const root = document.documentElement
root.dataset.theme = theme
root.classList.toggle('dark', theme === 'dark')
root.classList.toggle('light', theme === 'light')
}
/** View mode rides on `<html>` as well, so styling and agent tooling can hook
it without a prop. */
function applyViewMode(viewMode: ViewMode) {
document.documentElement.dataset.viewMode = viewMode
}
/** Read the theme the pre-paint script already applied, so first paint stays
flash-free and honors both the saved preference and `prefers-color-scheme`
(tokens.css defaults to dark). Never writes the DOM during state init. */
function initialTheme(): Theme {
return document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark'
}
function initialViewMode(): ViewMode {
return readSetting(STORAGE_KEYS.viewMode, VIEW_MODES) ?? 'default'
}
function initialSidebarCollapsed(): boolean {
return readFlag(STORAGE_KEYS.sidebarCollapsed) ?? false
}
type AppStateProviderProps = {
children: ReactNode
}
/**
* Owns the app-wide settings: theme, view mode, sidebar collapse and the
* search palette, plus the ⌘K / Ctrl+K shortcut that opens it from anywhere.
* Mount it once, above everything that calls `useAppState()`.
*/
export function AppStateProvider({ children }: AppStateProviderProps) {
const [theme, setThemeState] = useState<Theme>(initialTheme)
const [viewMode, setViewModeState] = useState<ViewMode>(initialViewMode)
const [sidebarCollapsed, setSidebarCollapsedState] = useState<boolean>(initialSidebarCollapsed)
const [searchOpen, setSearchOpen] = useState(false)
useEffect(() => {
applyTheme(theme)
}, [theme])
useEffect(() => {
applyViewMode(viewMode)
}, [viewMode])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault()
setSearchOpen(prev => !prev)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [])
/* Settings persist on explicit user action only. An effect would also write
the OS-derived theme during the first mount, freezing the preference and
killing `prefers-color-scheme` for anyone who never toggled. */
const setTheme = useCallback((next: Theme) => {
setThemeState(next)
writeSetting(STORAGE_KEYS.theme, next)
}, [])
const toggleTheme = useCallback(() => {
setTheme(theme === 'dark' ? 'light' : 'dark')
}, [theme, setTheme])
const setViewMode = useCallback((next: ViewMode) => {
setViewModeState(next)
writeSetting(STORAGE_KEYS.viewMode, next)
}, [])
const setSidebarCollapsed = useCallback((next: boolean) => {
setSidebarCollapsedState(next)
writeFlag(STORAGE_KEYS.sidebarCollapsed, next)
}, [])
const toggleSidebar = useCallback(() => {
setSidebarCollapsed(!sidebarCollapsed)
}, [sidebarCollapsed, setSidebarCollapsed])
const openSearch = useCallback(() => setSearchOpen(true), [])
const closeSearch = useCallback(() => setSearchOpen(false), [])
const toggleSearch = useCallback(() => setSearchOpen(prev => !prev), [])
const value = useMemo<AppStateContextValue>(
() => ({
theme,
viewMode,
sidebarCollapsed,
searchOpen,
setTheme,
toggleTheme,
setViewMode,
setSidebarCollapsed,
toggleSidebar,
openSearch,
closeSearch,
toggleSearch,
}),
[
theme,
viewMode,
sidebarCollapsed,
searchOpen,
setTheme,
toggleTheme,
setViewMode,
setSidebarCollapsed,
toggleSidebar,
openSearch,
closeSearch,
toggleSearch,
],
)
return <AppStateContext.Provider value={value}>{children}</AppStateContext.Provider>
}
+55
View File
@@ -0,0 +1,55 @@
import { createContext, useContext } from 'react'
/* ── Global application state ────────────────────────────────────────────────
One provider (AppStateProvider) owns every setting the whole app shares:
theme, audience preset, shell layout and the search palette. Pages and
sections read it with `useAppState()` instead of threading props through
the tree — and a template user gets one obvious place to add their own.
Keep this list short. State belongs here only when two unrelated parts of
the app need it; section-local state (active tab, open accordion, selected
pattern) stays in the section that owns it.
────────────────────────────────────────────────────────────────────────── */
export type Theme = 'dark' | 'light'
/** Audience preset for the template. Nothing gates on it yet — the value is
carried in context and mirrored to `<html data-view-mode>` so per-persona
views can be layered on later without touching a single consumer. */
export type ViewMode = 'default' | 'compact'
/** Runtime allow-list, also used to validate whatever `localStorage` hands back. */
export const VIEW_MODES: readonly ViewMode[] = ['default', 'compact']
/** Settings the user owns — these are persisted between visits. */
export type AppSettings = {
theme: Theme
viewMode: ViewMode
sidebarCollapsed: boolean
}
/** Settings plus the transient shell flags derived from them. */
export type AppState = AppSettings & {
searchOpen: boolean
}
export type AppStateContextValue = AppState & {
setTheme: (theme: Theme) => void
toggleTheme: () => void
setViewMode: (mode: ViewMode) => void
setSidebarCollapsed: (collapsed: boolean) => void
toggleSidebar: () => void
openSearch: () => void
closeSearch: () => void
toggleSearch: () => void
}
export const AppStateContext = createContext<AppStateContextValue | null>(null)
/** Read the shared app state. Throws outside the provider: a missing provider
is a loud dev error, never a silently-uncontrolled component. */
export function useAppState(): AppStateContextValue {
const context = useContext(AppStateContext)
if (!context) throw new Error('useAppState must be used inside <AppStateProvider>')
return context
}
+28
View File
@@ -0,0 +1,28 @@
/* Public surface of the app-state layer — import from './app' or '../app'. */
export { AppStateProvider } from './AppStateProvider'
export {
AppStateContext,
VIEW_MODES,
useAppState,
type AppSettings,
type AppState,
type AppStateContextValue,
type Theme,
type ViewMode,
} from './appState'
export { STORAGE_KEYS } from './persist'
export {
GROUP_BLURBS,
OVERVIEW_LABEL,
OVERVIEW_PATH,
PATTERNS_PATH,
ROUTE_GROUPS,
activeHref,
navigationGroups,
patternFromSearch,
routeForPath,
routes,
routesInGroup,
type AppRoute,
type RouteGroup,
} from './routes'
+41
View File
@@ -0,0 +1,41 @@
/* localStorage access for app settings. Private mode, disabled storage and
non-browser environments all throw on access, so every call is guarded and
degrades to "the setting just doesn't persist".
`ns-theme` is a shared contract: the pre-paint script in index.html reads
it before first paint, so changing the key means changing index.html too. */
export const STORAGE_KEYS = {
theme: 'ns-theme',
viewMode: 'ns-view-mode',
sidebarCollapsed: 'ns-sidebar-collapsed',
} as const
/** Stored string, or `undefined` when absent or outside the allowed set. */
export function readSetting<T extends string>(key: string, allowed: readonly T[]): T | undefined {
try {
const stored = localStorage.getItem(key)
if (stored === null) return undefined
return (allowed as readonly string[]).includes(stored) ? (stored as T) : undefined
} catch {
return undefined
}
}
export function writeSetting(key: string, value: string): void {
try {
localStorage.setItem(key, value)
} catch {
/* storage unavailable */
}
}
/** Stored boolean, or `undefined` when absent or unparseable. */
export function readFlag(key: string): boolean | undefined {
const stored = readSetting(key, ['true', 'false'] as const)
return stored === undefined ? undefined : stored === 'true'
}
export function writeFlag(key: string, value: boolean): void {
writeSetting(key, value ? 'true' : 'false')
}
+220
View File
@@ -0,0 +1,220 @@
import type { ComponentType, ReactNode } from 'react'
import { PATTERNS } from '../data/patterns'
import type { NavigationGroup } from '../components/Sidebar'
import { PatternsPage } from '../pages/PatternsPage'
import { ButtonsPage } from '../pages/ButtonsPage'
import { IconsPage } from '../pages/IconsPage'
import { ColorsPage } from '../pages/ColorsPage'
import { TypographyPage } from '../pages/TypographyPage'
import { FormsPage } from '../pages/FormsPage'
import { PrimitivesPage } from '../pages/PrimitivesPage'
import { MarkdownPage } from '../pages/MarkdownPage'
import { EditorPage } from '../pages/EditorPage'
import { ItemsPage } from '../pages/ItemsPage'
/* ── The route table ─────────────────────────────────────────────────────────
One registry drives the router, the sidebar, the breadcrumbs and the ⌘K
palette, so navigation can never drift from the pages that exist. Add a
page by adding an entry here plus a component in src/pages/.
────────────────────────────────────────────────────────────────────────── */
export const PATTERNS_PATH = '/patterns'
/** Live backend demo page (vite proxies /api to the Go server on :8080). */
export const ITEMS_PATH = '/items'
export const ROUTE_GROUPS = ['Application Patterns', 'Components', 'Foundations', 'Documentation'] as const
export type RouteGroup = (typeof ROUTE_GROUPS)[number]
export const GROUP_BLURBS: Record<RouteGroup, string> = {
'Application Patterns': 'Production-grade composites that ship as one drop-in unit.',
Components: 'The atoms: buttons, icons, form controls and the primitives behind them.',
Foundations: 'The tokens that carry the system — color roles and the type scale.',
Documentation: 'Editorial and developer surfaces for docs, changelogs and embedded code.',
}
export type AppRoute = {
path: string
group: RouteGroup
/** Sidebar entry, breadcrumb tail and search result title. */
label: string
/** Rendered above the section heading as `02 / COMPONENTS`. */
index: string
kicker: string
title: string
description: string
/** Right-hand action in the section heading (api pill, doc link, …). */
meta?: ReactNode
page: ComponentType
}
export const routes: AppRoute[] = [
{
path: PATTERNS_PATH,
group: 'Application Patterns',
label: 'Application Patterns',
index: '01',
kicker: 'PATTERNS & EXAMPLES',
title: 'Reusable Application Patterns',
description:
'Production-grade, fully functional composable components ready to drop into any project.',
meta: <span className="api-pill">4 Reusable Components</span>,
page: PatternsPage,
},
{
path: '/components/buttons',
group: 'Components',
label: 'Buttons',
index: '02',
kicker: 'COMPONENTS',
title: 'Buttons',
description: 'Actions with a clear hierarchy and dependable feedback.',
meta: <span className="api-pill">Button · 12 variants</span>,
page: ButtonsPage,
},
{
path: '/components/icons',
group: 'Components',
label: 'Icons',
index: '03',
kicker: 'COMPONENTS',
title: 'Icons (Lucide)',
description:
'Crisp, scalable SVG icons powered by Lucide. Tree-shakeable, stroke-matched, and token-aware.',
meta: <span className="api-pill">iconRegistry · 41 curated · lucide 1,400+</span>,
page: IconsPage,
},
{
path: '/foundations/colors',
group: 'Foundations',
label: 'Color tokens',
index: '04',
kicker: 'FOUNDATIONS',
title: 'Color tokens',
description: 'Functional color roles in CSS Color 4 lab() keep every surface intentional.',
meta: <span className="api-pill">48 tokens · lab()</span>,
page: ColorsPage,
},
{
path: '/foundations/typography',
group: 'Foundations',
label: 'Typography',
index: '05',
kicker: 'FOUNDATIONS',
title: 'Typography',
description: 'Modular rem-based type scale with dual-family hierarchy (Manrope & DM Mono).',
meta: <span className="api-pill">Type scale · 9→70px</span>,
page: TypographyPage,
},
{
path: '/components/forms',
group: 'Components',
label: 'Form controls',
index: '06',
kicker: 'PRIMITIVES',
title: 'Form controls',
description: 'Inputs designed for focus, flow, and the occasional messy edge case.',
page: FormsPage,
},
{
path: '/components/code',
group: 'Components',
label: 'Composable code',
index: '07',
kicker: 'CODE',
title: 'Composable by default',
description: 'Small APIs. Strong defaults. No surprises.',
page: PrimitivesPage,
},
{
path: '/docs/markdown',
group: 'Documentation',
label: 'Markdown rendering',
index: '08',
kicker: 'CONTENT',
title: 'Markdown rendering',
description: 'A readable editorial layer for changelogs, docs, and product notes.',
meta: <span className="api-pill">Markdown · styled</span>,
page: MarkdownPage,
},
{
path: '/docs/editor',
group: 'Documentation',
label: 'CodeMirror theme',
index: '09',
kicker: 'DEVELOPER TOOLING',
title: 'CodeMirror theme',
description: 'A focused editor palette for examples, playgrounds, and embedded code.',
meta: <span className="api-pill">codemirror · dark</span>,
page: EditorPage,
},
{
path: ITEMS_PATH,
group: 'Application Patterns',
label: 'Items (live API)',
index: '10',
kicker: 'FULLSTACK',
title: 'Live data — Go API + SWR',
description:
'The Go backend wired end to end: typed api modules, SWR hooks, mutations, and every loading state.',
meta: <span className="api-pill">SWR · /api/v1/items · SQLite/Postgres</span>,
page: ItemsPage,
},
]
/** Sidebar counts shown next to a few entries. */
const NAV_COUNTS: Partial<Record<string, number>> = {
'/components/buttons': 12,
'/components/icons': 41,
}
export const OVERVIEW_PATH = '/'
export const OVERVIEW_LABEL = 'Overview'
export function routeForPath(path: string): AppRoute | undefined {
return routes.find(route => route.path === path)
}
export function routesInGroup(group: RouteGroup): AppRoute[] {
return routes.filter(route => route.group === group)
}
/** Sidebar navigation: every group plus the individual patterns as sub-entries. */
export const navigationGroups: NavigationGroup[] = [
{
label: 'Application Patterns',
/* Patterns first (query-addressed), then the group's standalone routes. */
items: [
...PATTERNS.map(pattern => ({
label: pattern.label,
href: `${PATTERNS_PATH}?pattern=${pattern.id}`,
})),
...routesInGroup('Application Patterns').map(route => ({
label: route.label,
href: route.path,
})),
],
},
...ROUTE_GROUPS.filter(group => group !== 'Application Patterns').map(group => ({
label: group,
items: routesInGroup(group).map(route => ({
label: route.label,
href: route.path,
count: NAV_COUNTS[route.path],
})),
})),
]
/** Validate `?pattern=` without trusting the URL. */
export function patternFromSearch(search: string): (typeof PATTERNS)[number]['id'] {
const requested = new URLSearchParams(search).get('pattern')
return PATTERNS.some(pattern => pattern.id === requested)
? (requested as (typeof PATTERNS)[number]['id'])
: 'metrics'
}
/** The nav entry the current URL belongs to — pattern variants share a path. */
export function activeHref(location: string, search: string): string {
return location === PATTERNS_PATH ? `${PATTERNS_PATH}?pattern=${patternFromSearch(search)}` : location
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="77" height="47" fill="none" aria-labelledby="vite-logo-title" viewBox="0 0 77 47"><title id="vite-logo-title">Vite</title><style>.parenthesis{fill:#000}@media (prefers-color-scheme:dark){.parenthesis{fill:#fff}}</style><path fill="#9135ff" d="M40.151 45.71c-.663.844-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.493c-.92 0-1.457-1.04-.92-1.788l7.479-10.471c1.07-1.498 0-3.578-1.842-3.578H15.443c-.92 0-1.456-1.04-.92-1.788l9.696-13.576c.213-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.472c-1.07 1.497 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.087.89 1.83L40.153 45.712z"/><mask id="a" width="48" height="47" x="14" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M40.047 45.71c-.663.843-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.389c-.92 0-1.457-1.04-.92-1.788l7.479-10.472c1.07-1.497 0-3.578-1.842-3.578H15.34c-.92 0-1.456-1.04-.92-1.788l9.696-13.575c.213-.297.556-.474.92-.474H53.93c.92 0 1.456 1.04.92 1.788L47.37 13.03c-1.07 1.498 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.088.89 1.831L40.049 45.712z"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#eee6ff" rx="5.508" ry="14.704" transform="rotate(269.814 20.96 11.29)scale(-1 1)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#eee6ff" rx="10.399" ry="29.851" transform="rotate(89.814 -16.902 -8.275)scale(1 -1)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#8900ff" rx="5.508" ry="30.487" transform="rotate(89.814 -19.197 -7.127)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.928 4.177)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.738 5.52)scale(1 -1)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#eee6ff" rx="14.072" ry="22.078" transform="rotate(93.35 31.245 55.578)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx="14.592" cy="9.743" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(39.51 14.592 9.743)"/></g><g filter="url(#k)"><ellipse cx="61.728" cy="-5.321" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 61.728 -5.32)"/></g><g filter="url(#l)"><ellipse cx="55.618" cy="7.104" fill="#00c2ff" rx="5.971" ry="9.665" transform="rotate(37.892 55.618 7.104)"/></g><g filter="url(#m)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#n)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#o)"><ellipse cx="49.857" cy="30.678" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 49.857 30.678)"/></g><g filter="url(#p)"><ellipse cx="52.623" cy="33.171" fill="#00c2ff" rx="5.971" ry="15.297" transform="rotate(37.892 52.623 33.17)"/></g></g><path d="M6.919 0c-9.198 13.166-9.252 33.575 0 46.789h6.215c-9.25-13.214-9.196-33.623 0-46.789zm62.424 0h-6.215c9.198 13.166 9.252 33.575 0 46.789h6.215c9.25-13.214 9.196-33.623 0-46.789" class="parenthesis"/><defs><filter id="b" width="60.045" height="41.654" x="-5.564" y="16.92" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-40.407" y="-6.762" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-35.435" y="2.801" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-30.84" y="20.8" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-29.307" y="21.949" color-interpolation-filters="sRGB" filterUnits="usLine truncated

After

Width:  |  Height:  |  Size: 8.5 KiB

+62
View File
@@ -0,0 +1,62 @@
import { Avatar, Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/ui'
import { login, useSession } from './useSession'
/** Landing-page card showing the live OIDC session: who is signed in and
* the way in/out. Doubles as the template's auth wiring demo. */
export function SessionCard() {
const { status, user, signOut } = useSession()
return (
<Card className="stack-xs session-card">
<CardHeader>
<CardTitle>Session</CardTitle>
<CardDescription>OIDC authorization-code flow against users.k6n.net.</CardDescription>
</CardHeader>
<CardContent className="stack-xs">
{status === 'loading' && (
<div className="session-identity">
<span className="session-avatar skeleton-pulse" aria-hidden="true" />
<span className="session-lines">
<span className="skeleton-pulse" aria-hidden="true" />
<span className="skeleton-pulse" aria-hidden="true" />
</span>
</div>
)}
{status === 'anonymous' && (
<>
<p className="session-note">Not signed in — the API answers 401 until you are.</p>
<Button variant="primary" onClick={() => login('/')}>
Sign in
</Button>
</>
)}
{status === 'authenticated' && user && (
<>
<div className="session-identity">
<Avatar name={user.name || user.email} size="md" status="online" />
<div className="session-lines">
<b>{user.name || user.email}</b>
{user.name && user.email && user.email !== user.name && <span>{user.email}</span>}
<small>sub: {user.sub}</small>
</div>
</div>
{user.roles.length > 0 && (
<div className="session-roles">
{user.roles.map(role => (
<Badge key={role} variant="outline">
{role}
</Badge>
))}
</div>
)}
<Button variant="secondary" onClick={() => void signOut()}>
Sign out
</Button>
</>
)}
</CardContent>
</Card>
)
}
+86
View File
@@ -0,0 +1,86 @@
import { useEffect, useRef, useState } from 'react'
import { Icon } from '../components/Icon'
import { login, useSession } from './useSession'
/** Topbar identity: Sign in when anonymous, an avatar with a sign-out
* popover when authenticated. Session state comes from the backend's
* /api/auth/session via useSession. */
export function UserMenu() {
const { status, user, signOut } = useSession()
const [open, setOpen] = useState(false)
const rootRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const onPointerDown = (event: PointerEvent) => {
if (rootRef.current?.contains(event.target as Node) === false) setOpen(false)
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false)
}
document.addEventListener('pointerdown', onPointerDown)
document.addEventListener('keydown', onKeyDown)
return () => {
document.removeEventListener('pointerdown', onPointerDown)
document.removeEventListener('keydown', onKeyDown)
}
}, [open])
if (status === 'loading') {
return <span className="avatar skeleton-pulse" aria-hidden="true" />
}
if (status === 'anonymous' || user === null) {
return (
<button type="button" className="top-signin" onClick={() => login()}>
Sign in
</button>
)
}
const label = user.name || user.email
return (
<div className="user-menu" ref={rootRef}>
<button
type="button"
className="avatar"
onClick={() => setOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={open}
aria-label={label}
title={label}
>
{initials(label)}
</button>
{open && (
<div className="user-menu-popover" role="menu">
<div className="user-menu-identity">
{user.name && <b>{user.name}</b>}
<span>{user.email}</span>
</div>
<button
type="button"
role="menuitem"
className="user-menu-signout"
onClick={() => {
setOpen(false)
void signOut()
}}
>
<Icon name="log-out" size={14} />
Sign out
</button>
</div>
)}
</div>
)
}
function initials(label: string): string {
const parts = label.trim().split(/\s+/).filter(Boolean)
return parts
.slice(0, 2)
.map((part) => part.charAt(0))
.join('')
.toUpperCase()
}
+67
View File
@@ -0,0 +1,67 @@
import { useCallback, useEffect, useState } from 'react'
/** Identity reported by GET /api/auth/session (the Go backend's session cookie). */
export type SessionUser = {
sub: string
name: string
email: string
roles: string[]
}
export type SessionStatus = 'loading' | 'anonymous' | 'authenticated'
type SessionResponse = {
authenticated: boolean
user: SessionUser | null
}
/** Full-page navigation to the backend's OIDC login; the issuer bounces
* back through /api/auth/callback and then to returnTo. */
export function login(returnTo?: string): void {
const target = returnTo ?? window.location.pathname + window.location.search
window.location.href = `/api/auth/login?returnTo=${encodeURIComponent(target)}`
}
/** Who is signed in, straight from the session cookie. */
export function useSession() {
const [status, setStatus] = useState<SessionStatus>('loading')
const [user, setUser] = useState<SessionUser | null>(null)
useEffect(() => {
let alive = true
fetch('/api/auth/session')
.then((res) => (res.ok ? (res.json() as Promise<SessionResponse>) : { authenticated: false, user: null }))
.then((data) => {
if (!alive) return
if (data.authenticated && data.user) {
setUser(data.user)
setStatus('authenticated')
} else {
setUser(null)
setStatus('anonymous')
}
})
.catch(() => {
// Offline backend behaves like "not signed in" — the app is still usable.
if (alive) {
setUser(null)
setStatus('anonymous')
}
})
return () => {
alive = false
}
}, [])
const signOut = useCallback(async () => {
try {
await fetch('/api/auth/logout', { method: 'POST' })
} catch {
// Best effort from the UI; the cookie expires on its own otherwise.
}
setUser(null)
setStatus('anonymous')
}, [])
return { status, user, login, signOut }
}
@@ -0,0 +1,81 @@
import { useState } from 'react'
import { MetricsDashboard } from './examples/MetricsDashboard'
import { SettingsPanel } from './examples/SettingsPanel'
import { ServiceTable } from './examples/ServiceTable'
import { DeploymentModal } from './examples/DeploymentModal'
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from './ui/Card'
import { Badge } from './ui/Badge'
import { Icon } from './Icon'
import { PATTERNS, type PatternId } from '../data/patterns'
type AIWorkflowSectionProps = {
activePattern?: PatternId
onPatternChange?: (pattern: PatternId) => void
}
export function AIWorkflowSection({
activePattern: controlledPattern,
onPatternChange,
}: AIWorkflowSectionProps = {}) {
const [internalPattern, setInternalPattern] = useState<PatternId>('metrics')
const activePattern = controlledPattern ?? internalPattern
const handleSelect = (id: PatternId) => {
setInternalPattern(id)
onPatternChange?.(id)
}
return (
<div className="stack-lg">
{/* Pattern Selection Header */}
<div className="flex-between" style={{ flexWrap: 'wrap', gap: 'var(--space-3)' }}>
<div className="segmented">
{PATTERNS.map(p => (
<button
key={p.id}
type="button"
className={activePattern === p.id ? 'selected' : ''}
aria-pressed={activePattern === p.id}
onClick={() => handleSelect(p.id)}
>
<Icon name={p.icon} size={15} />
<span>{p.label}</span>
</button>
))}
</div>
<div className="row-xs" style={{ alignItems: 'center' }}>
<Badge variant="outline">Interactive Component</Badge>
<Badge variant="primary">Composable</Badge>
</div>
</div>
{/* Render Active Real Example Component */}
<div className="surface-card" style={{ padding: 'var(--space-6)', borderRadius: 'var(--radius-xl)' }}>
{activePattern === 'metrics' && <MetricsDashboard />}
{activePattern === 'settings' && <SettingsPanel />}
{activePattern === 'table' && <ServiceTable />}
{activePattern === 'deploy' && (
<div className="stack-md" style={{ maxWidth: 640, margin: '0 auto', textAlign: 'center', padding: 'var(--space-6) 0' }}>
<Card>
<CardHeader>
<CardTitle>Canary Deployment Orchestrator</CardTitle>
<CardDescription>
A complete interactive modal flow demonstrating Dialog, Alert, Switch, and Progress components.
</CardDescription>
</CardHeader>
<CardContent>
<div style={{ padding: 'var(--space-6) 0' }}>
<DeploymentModal />
</div>
</CardContent>
</Card>
</div>
)}
</div>
</div>
)
}
+34
View File
@@ -0,0 +1,34 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react'
import { Link } from 'wouter'
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>
)
}
type ButtonLinkProps = {
href: string
variant?: ButtonVariant
className?: string
children: ReactNode
}
/** The same visual action, rendered as a route link (`<a href>`). */
export function ButtonLink({ href, variant = 'secondary', className = '', children }: ButtonLinkProps) {
return (
<Link href={href} className={`button ${variant} ${className}`.trim()}>
{children}
</Link>
)
}
+78
View File
@@ -0,0 +1,78 @@
import { Button } from './Button'
import { Spinner } from './ui'
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 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
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">
<Spinner size="xs" />
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>
</>
)
}
+51
View File
@@ -0,0 +1,51 @@
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">
{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>
)
}
+55
View File
@@ -0,0 +1,55 @@
import { useState } from 'react'
import { useTimeout } from '../utils/useTimeout'
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 { schedule } = useTimeout()
const copy = () => {
navigator.clipboard?.writeText(INSTALL_CMD)
setCopied(true)
schedule(() => 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>
)
}
+25
View File
@@ -0,0 +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} />
}
+40
View File
@@ -0,0 +1,40 @@
import { ButtonLink } 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">
<ButtonLink variant="primary" href="/components/buttons">
Explore components <Icon name="arrow" size={16} />
</ButtonLink>
<ButtonLink variant="ghost" href="/docs/markdown">
Read the principles
</ButtonLink>
</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>
)
}
+49
View File
@@ -0,0 +1,49 @@
import type { ComponentType, SVGProps } from 'react'
import { Layers, type LucideIcon, type LucideProps } from 'lucide-react'
import { iconMap, type IconName } from './icon-registry'
export type { IconName, IconCategory, IconRegistryEntry } from './icon-registry'
export type { LucideIcon, LucideProps }
export type IconProps = Omit<SVGProps<SVGSVGElement>, 'ref'> & {
name?: IconName
icon?: LucideIcon | ComponentType<{ size?: number | string; strokeWidth?: number; className?: string }>
size?: number | string
strokeWidth?: number
className?: string
}
/**
* Universal Icon component backed by lucide-react.
* Accepts either a registry name (e.g. name="sparkles") or a direct Lucide
* icon component (e.g. icon={Sparkles}). Unknown names throw in dev — so the
* dev error sink surfaces them — and fall back to <Layers /> in prod.
*/
export function Icon({
name,
icon: IconComponent,
size = 18,
strokeWidth = 1.8,
className,
...props
}: IconProps) {
const namedIcon = name ? iconMap[name] : undefined
if (!IconComponent && !namedIcon) {
const message = `[Icon] Unknown icon name "${String(name)}". Add it to iconMap in src/components/icon-registry.ts or pass an explicit \`icon\` prop.`
if (import.meta.env.DEV) throw new Error(message)
console.error(message)
}
const Component = IconComponent ?? namedIcon ?? Layers
return (
<Component
size={size}
strokeWidth={strokeWidth}
className={className}
aria-hidden="true"
{...props}
/>
)
}
+141
View File
@@ -0,0 +1,141 @@
import { useState, useMemo } from 'react'
import { useTimeout } from '../utils/useTimeout'
import { Icon } from './Icon'
import { iconRegistry, type IconRegistryEntry } from './icon-registry'
import { Kbd } from './ui/Kbd'
import { Badge } from './ui/Badge'
type IconItem = IconRegistryEntry
/* The curated showcase set — components come straight from the shared
registry in Icon.tsx, so there is no second icon list to drift. */
const ICONS: IconItem[] = iconRegistry
const CATEGORIES = ['All', 'Navigation', 'Interface', 'System', 'Status'] as const
type Category = (typeof CATEGORIES)[number]
export function IconMatrix() {
const [activeCategory, setActiveCategory] = useState<Category>('All')
const [search, setSearch] = useState('')
const [size, setSize] = useState<number>(20)
const [copiedName, setCopiedName] = useState<string | null>(null)
const { schedule } = useTimeout()
const filteredIcons = useMemo(() => {
return ICONS.filter(icon => {
const matchesCategory = activeCategory === 'All' || icon.category === activeCategory
const matchesSearch =
icon.name.toLowerCase().includes(search.toLowerCase()) ||
icon.pascalName.toLowerCase().includes(search.toLowerCase())
return matchesCategory && matchesSearch
})
}, [activeCategory, search])
const copyUsage = (icon: IconItem) => {
const code = `<${icon.pascalName} size={${size}} />`
navigator.clipboard?.writeText(code)
setCopiedName(icon.name)
schedule(() => setCopiedName(null), 2000)
}
return (
<div className="icon-matrix-container">
{/* Header controls */}
<div className="icon-matrix-header">
<div className="icon-search-bar">
<Icon name="search" size={16} />
<input
type="text"
placeholder="Search icons (e.g. arrow, check, sparkles)..."
aria-label="Search icons"
value={search}
onChange={e => setSearch(e.target.value)}
className="icon-search-input"
/>
{search && (
<button className="icon-clear-btn" onClick={() => setSearch('')}>
<Icon name="close" size={14} />
</button>
)}
</div>
<div className="icon-size-picker">
<span className="mono muted">Size:</span>
{[16, 20, 24].map(s => (
<button
key={s}
className={`icon-size-btn ${size === s ? 'active' : ''}`}
onClick={() => setSize(s)}
>
{s}px
</button>
))}
</div>
</div>
{/* Category tabs */}
<div className="icon-category-tabs">
{CATEGORIES.map(cat => (
<button
key={cat}
className={`icon-cat-btn ${activeCategory === cat ? 'active' : ''}`}
onClick={() => setActiveCategory(cat)}
>
{cat}
<span className="icon-cat-count">
{cat === 'All' ? ICONS.length : ICONS.filter(i => i.category === cat).length}
</span>
</button>
))}
</div>
{/* Grid of icons */}
<div className="icon-grid">
{filteredIcons.map(icon => {
const isCopied = copiedName === icon.name
return (
<button
key={icon.name}
className={`icon-tile ${isCopied ? 'copied' : ''}`}
onClick={() => copyUsage(icon)}
title={`Click to copy <${icon.pascalName} />`}
>
<div className="icon-tile-glyph">
{isCopied ? (
<Icon name="check" size={size} className="accent" />
) : (
<icon.component size={size} strokeWidth={1.8} />
)}
</div>
<span className="icon-tile-name">{icon.name}</span>
<span className="icon-tile-action">
{isCopied ? 'Copied!' : <Kbd>Copy</Kbd>}
</span>
</button>
)
})}
</div>
{filteredIcons.length === 0 && (
<div className="empty-state">
<div className="empty-state-icon">
<Icon name="search" size={24} />
</div>
<h4 style={{ margin: '0 0 var(--space-2)' }}>No icons found</h4>
<p className="muted" style={{ margin: 0, fontSize: 'var(--text-xs)' }}>
No icon matches "{search}". You can use any of Lucide's 1,400+ icons directly via{' '}
<code>lucide-react</code>.
</p>
</div>
)}
{/* Footer usage tip */}
<div className="icon-matrix-footer">
<div className="icon-footer-pill">
<Badge variant="outline">lucide-react</Badge>
<span>Over 1,400+ SVG icons available. Fully tree-shaken and styled via <code>currentColor</code>.</span>
</div>
</div>
</div>
)
}
@@ -0,0 +1,60 @@
export type MarkdownRendererProps = {
title?: string
kicker?: string
}
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>
)
}
+51
View File
@@ -0,0 +1,51 @@
import { Link } from 'wouter'
import { Button } from './Button'
import { Icon } from './Icon'
import type { NavigationItem } from './Sidebar'
type RightRailProps = {
/** Group the current page belongs to — becomes the rail heading. */
title: string
links: NavigationItem[]
activeHref: string
}
export function RightRail({ title, links, activeHref }: RightRailProps) {
return (
<aside className="right-rail">
<div className="rail-card">
<span className="eyebrow">{title}</span>
{links.map((item, i) => (
<Link
className={item.href === activeHref ? 'rail-link active' : 'rail-link'}
href={item.href}
aria-current={item.href === activeHref ? 'page' : undefined}
key={item.label}
>
<span>0{i + 1}</span>
{item.label}
</Link>
))}
</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>
<Link className="text-button" href="/docs/markdown">
Read the docs <Icon name="arrow" size={14} />
</Link>
</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>
)
}
+329
View File
@@ -0,0 +1,329 @@
import { useState, useEffect, useMemo, useRef, type KeyboardEvent as ReactKeyboardEvent } from 'react'
import { Icon, type IconName } from './Icon'
import { Badge } from './ui/Badge'
import { Kbd } from './ui/Kbd'
import { trapTabKey, useOverlayLifecycle } from './ui/overlayBehavior'
import type { PatternId } from '../data/patterns'
export type SearchItem = {
id: string
title: string
description: string
category: 'Patterns' | 'Components' | 'Foundations' | 'Documentation'
icon: IconName
/** Route this result opens — `patternId` then picks the pattern on /patterns. */
path: string
patternId?: PatternId
}
const SEARCH_INDEX: SearchItem[] = [
// Patterns
{
id: 'pat-metrics',
title: 'Metrics Dashboard',
description: 'Telemetry dashboard with StatCards, time range picker, and progress gauges',
category: 'Patterns',
icon: 'sparkles',
path: '/patterns',
patternId: 'metrics',
},
{
id: 'pat-settings',
title: 'Settings & Safeguards',
description: 'Preferences panel with API keys, toggle switches, and alert banners',
category: 'Patterns',
icon: 'settings',
path: '/patterns',
patternId: 'settings',
},
{
id: 'pat-table',
title: 'Service Monitor Table',
description: 'Real-time service health table with status filtering and search',
category: 'Patterns',
icon: 'layers',
path: '/patterns',
patternId: 'table',
},
{
id: 'pat-deploy',
title: 'Canary Deployment Flow',
description: 'Interactive rollout modal with live progress simulation',
category: 'Patterns',
icon: 'terminal',
path: '/patterns',
patternId: 'deploy',
},
{
id: 'pat-items',
title: 'Items (live API)',
description: 'Full-stack demo: typed api modules, SWR hooks, live Postgres data',
category: 'Patterns',
icon: 'database',
path: '/items',
},
// Components
{
id: 'comp-buttons',
title: 'Buttons & Action States',
description: 'Primary, secondary, destructive, ghost variants with resting, hover, active and focus states',
category: 'Components',
icon: 'arrow-right',
path: '/components/buttons',
},
{
id: 'comp-icons',
title: 'Lucide Icons Explorer',
description: 'Over 1,400+ tree-shaken SVG icons with search, category filtering and 1-click copy',
category: 'Components',
icon: 'sparkles',
path: '/components/icons',
},
{
id: 'comp-forms',
title: 'Form Controls & Inputs',
description: 'Accessible text inputs, secret key revealers, and toggle switches',
category: 'Components',
icon: 'eye',
path: '/components/forms',
},
{
id: 'comp-code',
title: 'Composable UI Primitives',
description: 'Zero-drift Dialog, Sheet, Tabs, Table, Alert, Progress, Switch, and StatCard',
category: 'Components',
icon: 'code',
path: '/components/code',
},
// Foundations
{
id: 'found-colors',
title: 'Color Tokens (CSS Color 4 lab())',
description: 'Pitch black dark theme and pure white daylight light theme tokens',
category: 'Foundations',
icon: 'sun',
path: '/foundations/colors',
},
{
id: 'found-type',
title: 'Typography & Scale',
description: 'Modular rem-based 9px to 70px scale with Manrope and DM Mono families',
category: 'Foundations',
icon: 'file',
path: '/foundations/typography',
},
// Documentation
{
id: 'doc-md',
title: 'Markdown Editorial Layer',
description: 'Changelogs, release notes, and documentation typography',
category: 'Documentation',
icon: 'file',
path: '/docs/markdown',
},
{
id: 'doc-cm',
title: 'CodeMirror Editor Theme',
description: 'Syntax-highlighted code editor preview with line numbers and token palette',
category: 'Documentation',
icon: 'terminal',
path: '/docs/editor',
},
]
type SearchPaletteProps = {
isOpen: boolean
onClose: () => void
onSelect: (item: SearchItem) => void
initialQuery?: string
}
const LISTBOX_ID = 'search-palette-listbox'
/** Command-palette modal built on the same overlay behavior as Dialog/Sheet:
focus trap, focus restore, scroll lock, Escape — plus combobox semantics
(role=listbox + aria-activedescendant) for the results. */
export function SearchPalette({ isOpen, onClose, onSelect, initialQuery = '' }: SearchPaletteProps) {
const [query, setQuery] = useState(initialQuery)
const [selectedIndex, setSelectedIndex] = useState(0)
const panelRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
/* Same lifecycle as DialogContent: scroll lock + focus capture/restore. */
useOverlayLifecycle(isOpen, panelRef)
useEffect(() => {
if (isOpen) {
inputRef.current?.focus()
}
}, [isOpen, initialQuery])
const results = useMemo(() => {
if (!query.trim()) return SEARCH_INDEX
const q = query.toLowerCase()
return SEARCH_INDEX.filter(
item =>
item.title.toLowerCase().includes(q) ||
item.description.toLowerCase().includes(q) ||
item.category.toLowerCase().includes(q),
)
}, [query])
/* Keep the selection inside the result set when the query shrinks it. */
const activeIndex = results.length === 0 ? -1 : Math.min(selectedIndex, results.length - 1)
const activeItem = activeIndex >= 0 ? results[activeIndex] : undefined
// Keyboard navigation (combobox pattern: arrows move aria-activedescendant)
useEffect(() => {
if (!isOpen) return
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
onClose()
} else if (e.key === 'ArrowDown') {
e.preventDefault()
setSelectedIndex(prev => (results.length ? (prev + 1) % results.length : 0))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setSelectedIndex(prev => (results.length ? (prev - 1 + results.length) % results.length : 0))
} else if (e.key === 'Enter' && activeItem) {
e.preventDefault()
onSelect(activeItem)
onClose()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isOpen, results.length, activeItem, onClose, onSelect])
const handlePanelKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
trapTabKey(e, panelRef.current)
}
/* Closed palette must not render at all: the backdrop is `position: fixed;
inset: 0`, so rendering it unconditionally left the modal permanently on
screen (and ate every click on the page underneath). */
if (!isOpen) return null
return (
<div className="search-palette-backdrop" onClick={onClose}>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label="Search"
tabIndex={-1}
className="search-palette-modal"
onKeyDown={handlePanelKeyDown}
onClick={e => e.stopPropagation()}
>
{/* Search Input Bar */}
<div className="search-palette-head">
<Icon name="search" size={18} className="search-palette-icon" />
<input
ref={inputRef}
type="text"
role="combobox"
aria-expanded={results.length > 0}
aria-controls={LISTBOX_ID}
aria-activedescendant={activeItem ? `search-option-${activeItem.id}` : undefined}
aria-label="Search components, tokens, and docs"
className="search-palette-input"
placeholder="Type a command, component, or token..."
value={query}
onChange={e => {
setQuery(e.target.value)
setSelectedIndex(0)
}}
/>
{query && (
<button
type="button"
className="icon-button"
onClick={() => {
setQuery('')
setSelectedIndex(0)
}}
aria-label="Clear search"
>
<Icon name="close" size={14} />
</button>
)}
<Kbd>ESC</Kbd>
</div>
{/* Results List */}
<div className="search-palette-body">
{results.length > 0 ? (
<ul id={LISTBOX_ID} role="listbox" aria-label="Search results" className="search-palette-results">
{results.map((item, idx) => {
const isSelected = idx === activeIndex
return (
<li
key={item.id}
id={`search-option-${item.id}`}
role="option"
aria-selected={isSelected}
className={`search-palette-item ${isSelected ? 'selected' : ''}`}
onClick={() => {
onSelect(item)
onClose()
}}
onMouseEnter={() => setSelectedIndex(idx)}
>
<div className="search-item-glyph">
<Icon name={item.icon} size={16} />
</div>
<div className="search-item-content">
<div className="search-item-title-row">
<span className="search-item-title">{item.title}</span>
<Badge variant="outline">{item.category}</Badge>
</div>
<p className="search-item-desc">{item.description}</p>
</div>
{isSelected && (
<div className="search-item-action">
<Kbd>↵</Kbd>
</div>
)}
</li>
)
})}
</ul>
) : (
<div className="search-palette-empty">
<Icon name="search" size={24} />
<p>
No results found for &ldquo;{query}&rdquo;
</p>
<span>Try searching for &ldquo;Buttons&rdquo;, &ldquo;Metrics&rdquo;, &ldquo;Tokens&rdquo;, or &ldquo;Icons&rdquo;.</span>
</div>
)}
</div>
{/* Footer info */}
<div className="search-palette-footer">
<div className="row-xs" style={{ alignItems: 'center' }}>
<Kbd>↑</Kbd>
<Kbd>↓</Kbd>
<span>to navigate</span>
</div>
<div className="row-xs" style={{ alignItems: 'center' }}>
<Kbd>↵</Kbd>
<span>to select</span>
</div>
<div className="row-xs" style={{ alignItems: 'center' }}>
<Kbd>ESC</Kbd>
<span>to close</span>
</div>
</div>
</div>
</div>
)
}
+27
View File
@@ -0,0 +1,27 @@
import type { ReactNode } from 'react'
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>
)
}
+74
View File
@@ -0,0 +1,74 @@
import { Link } from 'wouter'
import { Icon } from './Icon'
export type NavigationItem = { label: string; href: string; count?: number }
export type NavigationGroup = { label: string; items: NavigationItem[] }
type SidebarProps = {
groups: NavigationGroup[]
/** Nav href the current URL resolves to — pattern variants share a path. */
activeHref: string
/** Fired when any nav entry is clicked — the shell closes its mobile menu. */
onNavigate?: () => void
collapsed?: boolean
onToggleCollapse?: () => void
}
export function Sidebar({ groups, activeHref, onNavigate, collapsed = false, onToggleCollapse }: SidebarProps) {
return (
<aside className="sidebar">
<div className="sidebar-heading">
<div className="sidebar-library">
<Icon name="layers" size={13} /> Library
</div>
<button
type="button"
className="sidebar-collapse"
onClick={onToggleCollapse}
aria-pressed={collapsed}
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
{collapsed ? '⌘+' : '⌘−'}
</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" onClick={onNavigate}>
{groups.map(group => (
<div className="nav-group" key={group.label}>
<span className="nav-label">{group.label}</span>
{group.items.map(item => {
const isActive = item.href === activeHref
return (
<Link
key={item.label}
href={item.href}
className={isActive ? 'nav-item active' : 'nav-item'}
aria-current={isActive ? 'page' : undefined}
>
<span className="nav-glyph" aria-hidden="true">
{item.count ? '◈' : '·'}
</span>
<span>{item.label}</span>
{item.count !== undefined && <span className="nav-count">{item.count}</span>}
</Link>
)
})}
</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>
)
}
+24
View File
@@ -0,0 +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>
)
}
+81
View File
@@ -0,0 +1,81 @@
import { Link } from 'wouter'
import { Icon } from './Icon'
import { VIEW_MODES, useAppState, type ViewMode } from '../app'
import { UserMenu } from '../auth/UserMenu'
/** `viewMode` is the template's audience preset (see src/app/appState.ts). */
const VIEW_MODE_LABELS: Record<ViewMode, string> = {
default: 'Default',
compact: 'Compact',
}
type TopbarProps = {
onMenuToggle: () => void
}
export function Topbar({ onMenuToggle }: TopbarProps) {
const { theme, toggleTheme, viewMode, setViewMode, openSearch } = useAppState()
return (
<header className="topbar">
<div className="brand">
<Link href="/" className="brand-link">
<div className="brand-mark">
<span />
</div>
<b>northstar</b>
<span className="version">v0.8.2</span>
</Link>
</div>
<button
type="button"
className="top-search"
onClick={openSearch}
aria-label="Search components, tokens, docs (Press ⌘K)"
>
<Icon name="search" size={16} />
<span className="top-search-placeholder">Search components, tokens, docs...</span>
<kbd>⌘ K</kbd>
</button>
<nav className="top-links">
<Link href="/docs/markdown">Docs</Link>
<a href="#github">GitHub ↗</a>
{/* Audience preset held by the app state. Nothing gates on it yet — it
is wired here so the value is reachable and persisted from the UI. */}
<div className="segmented top-view-mode" role="group" aria-label="View mode">
{VIEW_MODES.map(mode => (
<button
key={mode}
type="button"
className={mode === viewMode ? 'selected' : undefined}
aria-pressed={mode === viewMode}
title={`Switch to ${VIEW_MODE_LABELS[mode].toLowerCase()} view`}
onClick={() => setViewMode(mode)}
>
{VIEW_MODE_LABELS[mode]}
</button>
))}
</div>
<button
type="button"
className="icon-button"
onClick={toggleTheme}
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} theme`}
>
<Icon name={theme === 'dark' ? 'sun' : 'moon'} size={17} />
</button>
{/* OIDC identity — Sign in / avatar + sign-out popover (src/auth). */}
<UserMenu />
</nav>
<button type="button" className="mobile-menu icon-button" onClick={onMenuToggle} aria-label="Toggle navigation">
<Icon name="menu" />
</button>
</header>
)
}
@@ -0,0 +1,124 @@
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from './ui/Card'
import { Badge } from './ui/Badge'
import { Kbd } from './ui/Kbd'
const TYPE_SCALE = [
{ token: '--text-hero', rem: 'clamp(2.625rem, 5vw, 4.375rem)', px: '42→70px', role: 'Display / Hero Title', sample: 'Calm, capable design.' },
{ token: '--text-2xl', rem: '1.75rem', px: '28px', role: 'Section Headlines', sample: 'Structured for AI Uniformity' },
{ token: '--text-xl', rem: '1.5625rem', px: '25px', role: 'Sub-headlines & Major Cards', sample: 'Fast, token-enforced iteration' },
{ token: '--text-lg', rem: '1.3125rem', px: '21px', role: 'Card Titles & Dialog Heads', sample: 'Zero hardcoded pixel values' },
{ token: '--text-base', rem: '1rem', px: '16px', role: 'Lede Copy & Lead Sentences', sample: 'The baseline font size scaled by --root-font-size.' },
{ token: '--text-md', rem: '0.875rem', px: '14px', role: 'Longform Body Text', sample: 'Engineered for dense technical dashboards and documentation.' },
{ token: '--text-sm', rem: '0.75rem', px: '12px', role: 'Form Controls & Descriptions', sample: 'Input labels, table rows, and secondary details.' },
{ token: '--text-xs', rem: '0.6875rem', px: '11px', role: 'Action Buttons & Nav Items', sample: 'Action buttons, status badges, and sidebar navigation.' },
{ token: '--text-2xs', rem: '0.625rem', px: '10px', role: 'Eyebrows & Section Indices', sample: 'METADATA LABELS & CATEGORY EYEBROWS' },
{ token: '--text-3xs', rem: '0.5625rem', px: '9px', role: 'Micro Badges & Mono Tags', sample: '01 / APPLICATION PATTERNS' },
]
export function TypographySection() {
return (
<div className="stack-lg">
<div className="grid-2">
<Card>
<CardHeader>
<div className="flex-between">
<CardTitle>Sans-Serif Primary Family</CardTitle>
<Badge variant="primary">Manrope</Badge>
</div>
<CardDescription>
Modern geometric grotesque optimized for high-density UI and numeral legibility.
</CardDescription>
</CardHeader>
<CardContent>
<div style={{ fontFamily: 'var(--font-sans)', fontSize: 'var(--text-lg)', fontWeight: 600 }}>
ABCDEFGHIJKLMNOPQRSTUVWXYZ
<br />
abcdefghijklmnopqrstuvwxyz
<br />
0123456789 &amp; @ # % $ /
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex-between">
<CardTitle>Monospace Code Family</CardTitle>
<Badge variant="secondary">DM Mono</Badge>
</div>
<CardDescription>
Tabular numerals and proportional spacing for code blocks, tokens, and telemetry.
</CardDescription>
</CardHeader>
<CardContent>
<div className="mono" style={{ fontSize: 'var(--text-md)', color: 'var(--primary)' }}>
const system = &apos;northstar&apos;
<br />
latency: 14.2ms | memory: 128MB
<br />
0123456789 ()=&gt;&#123;&#125;[]
</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<div className="flex-between">
<CardTitle>Modular Rem-Based Type Scale</CardTitle>
<div className="row-xs">
<span className="mono muted" style={{ fontSize: 'var(--text-3xs)' }}>Root:</span>
<Kbd>112%</Kbd>
</div>
</div>
<CardDescription>
Scales uniformly via <code>--root-font-size</code>. Built on a 9px → 70px responsive rhythm.
</CardDescription>
</CardHeader>
<CardContent>
<div className="stack-md">
{TYPE_SCALE.map(item => (
<div
key={item.token}
style={{
display: 'grid',
gridTemplateColumns: '160px 80px 1fr',
alignItems: 'baseline',
gap: 'var(--space-4)',
padding: 'var(--space-3) 0',
borderBottom: '1px solid var(--border)',
}}
>
<div>
<code style={{ fontSize: 'var(--text-xs)', color: 'var(--primary)', fontFamily: 'var(--font-mono)' }}>
{item.token}
</code>
<div style={{ fontSize: 'var(--text-3xs)', color: 'var(--muted-foreground)' }}>
{item.role}
</div>
</div>
<div className="mono muted" style={{ fontSize: 'var(--text-3xs)' }}>
{item.px}
</div>
<div
style={{
fontSize: `var(${item.token})`,
fontWeight: item.token.includes('hero') || item.token.includes('2xl') ? 800 : 500,
lineHeight: 1.25,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{item.sample}
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
)
}
@@ -0,0 +1,144 @@
import { useEffect, useState } from 'react'
import { useTimeout } from '../../utils/useTimeout'
import {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogClose,
} from '../ui/Dialog'
import { Alert, AlertTitle, AlertDescription } from '../ui/Alert'
import { Switch } from '../ui/Switch'
import { Progress } from '../ui/Progress'
import { Badge } from '../ui/Badge'
import { Icon } from '../Icon'
export function DeploymentModal() {
const [open, setOpen] = useState(false)
const [canaryRouting, setCanaryRouting] = useState(true)
const [skipTests, setSkipTests] = useState(false)
const [deploying, setDeploying] = useState(false)
const [progress, setProgress] = useState(0)
const [deployed, setDeployed] = useState(false)
const { schedule: scheduleDeploy, clear: clearDeploy } = useTimeout()
const { schedule: scheduleReset } = useTimeout()
/* Closing cancels in-flight rollout timers so they never fire on a hidden dialog.
The post-close content reset uses its own timer so it survives this cleanup. */
useEffect(() => {
if (!open) clearDeploy()
}, [open, clearDeploy])
const handleStartDeploy = () => {
setDeploying(true)
setProgress(15)
scheduleDeploy(() => setProgress(45), 500)
scheduleDeploy(() => setProgress(85), 1000)
scheduleDeploy(() => {
setProgress(100)
setDeploying(false)
setDeployed(true)
}, 1500)
}
const handleReset = () => {
setOpen(false)
scheduleReset(() => {
setDeploying(false)
setProgress(0)
setDeployed(false)
}, 300)
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<button className="button primary">
<Icon name="sparkles" size={15} /> Promote Release to Canary
</button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<div className="flex-between" style={{ marginBottom: 'var(--space-2)' }}>
<DialogTitle>Canary Rollout: release-2026.09.12</DialogTitle>
<Badge variant="primary">v2.4.0</Badge>
</div>
<DialogDescription>
Targeting production cluster <code style={{ color: 'var(--primary)', fontFamily: 'var(--font-mono)' }}>canary-prod-01</code>.
</DialogDescription>
</DialogHeader>
<div className="stack-md" style={{ margin: 'var(--space-4) 0' }}>
{!deployed ? (
<>
<Alert variant="warning">
<AlertTitle>Canary Traffic Notice</AlertTitle>
<AlertDescription>
This deployment will divert 10% of production ingress traffic to the new build.
</AlertDescription>
</Alert>
<div className="stack-sm" style={{ background: 'var(--secondary)', padding: 'var(--space-4)', borderRadius: 'var(--radius-lg)' }}>
<div className="flex-between">
<span style={{ fontSize: 'var(--text-xs)', fontWeight: 500 }}>
Gradual Traffic Ingress (10% → 50% → 100%)
</span>
<Switch checked={canaryRouting} onCheckedChange={setCanaryRouting} />
</div>
<div className="flex-between" style={{ paddingTop: 'var(--space-2)', borderTop: '1px solid var(--border)' }}>
<span style={{ fontSize: 'var(--text-xs)', fontWeight: 500 }}>
Bypass Pre-Flight Synthetic Probes
</span>
<Switch checked={skipTests} onCheckedChange={setSkipTests} />
</div>
</div>
{deploying && (
<div className="stack-xs">
<Progress value={progress} label="Applying Canary Weights..." />
<span className="mono muted" style={{ fontSize: 'var(--text-3xs)' }}>
Streaming build manifests to regional edge nodes...
</span>
</div>
)}
</>
) : (
<Alert variant="positive">
<AlertTitle>Deployment Succeeded!</AlertTitle>
<AlertDescription>
Build is now receiving live canary traffic. Telemetry reports 0 errors and 14ms latency.
</AlertDescription>
</Alert>
)}
</div>
<DialogFooter>
{!deployed ? (
<>
<DialogClose asChild>
<button className="button secondary" disabled={deploying}>
Cancel
</button>
</DialogClose>
<button
className="button primary"
onClick={handleStartDeploy}
disabled={deploying}
>
<Icon name={deploying ? 'refresh' : 'arrow-right'} size={14} />
{deploying ? 'Deploying...' : 'Confirm Rollout'}
</button>
</>
) : (
<button className="button primary" style={{ width: '100%' }} onClick={handleReset}>
<Icon name="check" size={14} /> Done
</button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,133 @@
import { useState } from 'react'
import { StatCard } from '../ui/StatCard'
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '../ui/Card'
import { Progress } from '../ui/Progress'
import { Badge } from '../ui/Badge'
import { Icon } from '../Icon'
export type MetricsDashboardProps = {
title?: string
description?: string
className?: string
}
export function MetricsDashboard({
title = 'System Overview',
description = 'Real-time telemetry and resource saturation across clusters.',
className = '',
}: MetricsDashboardProps) {
const [timeRange, setTimeRange] = useState<'24h' | '7d' | '30d'>('24h')
return (
<div className={`stack-lg ${className}`}>
{/* Dashboard Topbar */}
<div className="flex-between" style={{ flexWrap: 'wrap', gap: 'var(--space-3)' }}>
<div>
<div className="row-xs" style={{ marginBottom: 'var(--space-1)' }}>
<Badge variant="positive">Live Telemetry</Badge>
<Badge variant="outline">All Regions</Badge>
</div>
<h3 style={{ margin: 'var(--space-1) 0', fontSize: 'var(--text-lg)', fontWeight: 700 }}>
{title}
</h3>
<p style={{ margin: 0, color: 'var(--muted-foreground)', fontSize: 'var(--text-xs)' }}>
{description}
</p>
</div>
<div className="row-xs">
{(['24h', '7d', '30d'] as const).map(range => (
<button
key={range}
className={`button ${timeRange === range ? 'secondary' : 'ghost'}`}
style={{ padding: 'var(--space-1) var(--space-3)', fontSize: 'var(--text-xs)' }}
onClick={() => setTimeRange(range)}
>
{range}
</button>
))}
</div>
</div>
{/* Stat Cards Row */}
<div className="grid-3">
<StatCard
title="Throughput"
value={timeRange === '24h' ? '14.2k req/s' : timeRange === '7d' ? '12.8k req/s' : '11.5k req/s'}
change="+18.4%"
trend="up"
caption="vs previous period"
icon={<Icon name="sparkles" size={16} />}
/>
<StatCard
title="P99 Latency"
value={timeRange === '24h' ? '18ms' : timeRange === '7d' ? '22ms' : '25ms'}
change="-4.2ms"
trend="up"
caption="edge distribution"
icon={<Icon name="clock" size={16} />}
/>
<StatCard
title="Error Rate"
value="0.008%"
change="-0.002%"
trend="up"
caption="within SLA (<0.05%)"
icon={<Icon name="shield" size={16} />}
/>
</div>
{/* Cluster Resource Gauges Card */}
<Card>
<CardHeader>
<div className="flex-between">
<CardTitle>Resource Utilization</CardTitle>
<span className="mono muted" style={{ fontSize: 'var(--text-3xs)' }}>Cluster: canary-prod-01</span>
</div>
<CardDescription>
Compute, memory, and persistent volume headroom.
</CardDescription>
</CardHeader>
<CardContent>
<div className="stack-md">
<div>
<div className="flex-between" style={{ marginBottom: 'var(--space-1)' }}>
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--foreground)', fontWeight: 500 }}>
Worker CPU Saturation
</span>
<span className="mono" style={{ fontSize: 'var(--text-xs)', color: 'var(--primary)' }}>
48%
</span>
</div>
<Progress value={48} />
</div>
<div>
<div className="flex-between" style={{ marginBottom: 'var(--space-1)' }}>
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--foreground)', fontWeight: 500 }}>
Memory Allocation (16 GB Pool)
</span>
<span className="mono" style={{ fontSize: 'var(--text-xs)', color: 'var(--primary)' }}>
74%
</span>
</div>
<Progress value={74} />
</div>
<div>
<div className="flex-between" style={{ marginBottom: 'var(--space-1)' }}>
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--foreground)', fontWeight: 500 }}>
Network Ingress Bandwidth
</span>
<span className="mono" style={{ fontSize: 'var(--text-xs)', color: 'var(--chart-2)' }}>
28%
</span>
</div>
<Progress value={28} />
</div>
</div>
</CardContent>
</Card>
</div>
)
}
@@ -0,0 +1,149 @@
import { useState, useMemo } from 'react'
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '../ui/Table'
import { Badge } from '../ui/Badge'
import { Kbd } from '../ui/Kbd'
import { EmptyState, EmptyStateIcon, EmptyStateTitle, EmptyStateDescription } from '../ui/EmptyState'
import { Icon } from '../Icon'
type Service = {
id: string
name: string
region: string
status: 'healthy' | 'degraded' | 'scaling'
latency: string
memory: string
instances: number
}
const INITIAL_SERVICES: Service[] = [
{ id: '1', name: 'api-gateway', region: 'us-east-1', status: 'healthy', latency: '12ms', memory: '128 MB', instances: 8 },
{ id: '2', name: 'vector-indexer', region: 'eu-central-1', status: 'healthy', latency: '34ms', memory: '890 MB', instances: 4 },
{ id: '3', name: 'model-inference', region: 'us-west-2', status: 'scaling', latency: '78ms', memory: '4.2 GB', instances: 16 },
{ id: '4', name: 'session-broker', region: 'us-east-1', status: 'healthy', latency: '8ms', memory: '64 MB', instances: 6 },
{ id: '5', name: 'analytics-worker', region: 'ap-northeast-1', status: 'degraded', latency: '142ms', memory: '1.8 GB', instances: 2 },
{ id: '6', name: 'audit-logger', region: 'eu-west-1', status: 'healthy', latency: '19ms', memory: '210 MB', instances: 3 },
]
export function ServiceTable() {
const [search, setSearch] = useState('')
const [filterStatus, setFilterStatus] = useState<string>('all')
const filteredServices = useMemo(() => {
return INITIAL_SERVICES.filter(svc => {
const matchesSearch = svc.name.toLowerCase().includes(search.toLowerCase()) ||
svc.region.toLowerCase().includes(search.toLowerCase())
const matchesStatus = filterStatus === 'all' || svc.status === filterStatus
return matchesSearch && matchesStatus
})
}, [search, filterStatus])
return (
<div className="stack-md">
{/* Table Toolbar */}
<div className="flex-between" style={{ flexWrap: 'wrap', gap: 'var(--space-3)' }}>
<div className="icon-search-bar" style={{ maxWidth: 320 }}>
<Icon name="search" size={15} />
<input
type="text"
placeholder="Filter services or regions..."
value={search}
onChange={e => setSearch(e.target.value)}
className="icon-search-input"
/>
{search && (
<button className="icon-clear-btn" onClick={() => setSearch('')}>
<Icon name="close" size={13} />
</button>
)}
</div>
<div className="row-xs">
{['all', 'healthy', 'scaling', 'degraded'].map(st => (
<button
key={st}
className={`button ${filterStatus === st ? 'secondary' : 'ghost'}`}
style={{
padding: 'var(--space-1) var(--space-3)',
fontSize: 'var(--text-xs)',
textTransform: 'capitalize',
}}
onClick={() => setFilterStatus(st)}
>
{st}
</button>
))}
</div>
</div>
{/* Table Surface */}
{filteredServices.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Service Name</TableHead>
<TableHead>Region</TableHead>
<TableHead>Status</TableHead>
<TableHead>P99 Latency</TableHead>
<TableHead>Memory</TableHead>
<TableHead>Nodes</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredServices.map(svc => (
<TableRow key={svc.id}>
<TableCell style={{ fontWeight: 600 }}>
<div className="row-xs" style={{ alignItems: 'center' }}>
<span
style={{
width: 7,
height: 7,
borderRadius: '50%',
background: svc.status === 'healthy' ? 'var(--chart-2)' : svc.status === 'scaling' ? 'var(--primary)' : 'var(--destructive)',
boxShadow: `0 0 8px ${svc.status === 'healthy' ? 'var(--chart-2)' : svc.status === 'scaling' ? 'var(--primary)' : 'var(--destructive)'}`,
}}
/>
{svc.name}
</div>
</TableCell>
<TableCell>
<Kbd>{svc.region}</Kbd>
</TableCell>
<TableCell>
<Badge
variant={svc.status === 'healthy' ? 'positive' : svc.status === 'scaling' ? 'primary' : 'destructive'}
>
{svc.status}
</Badge>
</TableCell>
<TableCell className="mono" style={{ color: svc.status === 'degraded' ? 'var(--destructive)' : 'var(--primary)' }}>
{svc.latency}
</TableCell>
<TableCell className="mono" style={{ color: 'var(--muted-foreground)' }}>
{svc.memory}
</TableCell>
<TableCell className="mono" style={{ color: 'var(--foreground)' }}>
{svc.instances}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<EmptyState>
<EmptyStateIcon>
<Icon name="search" size={24} />
</EmptyStateIcon>
<EmptyStateTitle>No matching services</EmptyStateTitle>
<EmptyStateDescription>
No active nodes or services match the current filter query "{search}".
</EmptyStateDescription>
</EmptyState>
)}
<div className="flex-between muted" style={{ fontSize: 'var(--text-3xs)', padding: '0 var(--space-2)' }}>
<span>Showing {filteredServices.length} of {INITIAL_SERVICES.length} deployed services</span>
<span className="mono">Synced live via cluster coordinator</span>
</div>
</div>
)
}
@@ -0,0 +1,161 @@
import { useState } from 'react'
import { useTimeout } from '../../utils/useTimeout'
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '../ui/Card'
import { Switch } from '../ui/Switch'
import { Badge } from '../ui/Badge'
import { Alert, AlertTitle, AlertDescription } from '../ui/Alert'
import { Tooltip } from '../ui/Tooltip'
import { Kbd } from '../ui/Kbd'
import { Icon } from '../Icon'
export function SettingsPanel() {
const [apiKeyVisible, setApiKeyVisible] = useState(false)
const [copied, setCopied] = useState(false)
const [saved, setSaved] = useState(false)
// Toggles
const [canaryEnabled, setCanaryEnabled] = useState(true)
const [telemetryEnabled, setTelemetryEnabled] = useState(true)
const [autoRollback, setAutoRollback] = useState(false)
const apiKey = 'nsk_live_98a72ef01bc34d89a2'
const { schedule } = useTimeout()
const copyKey = () => {
navigator.clipboard?.writeText(apiKey)
setCopied(true)
schedule(() => setCopied(false), 2000)
}
const handleSave = () => {
setSaved(true)
schedule(() => setSaved(false), 2500)
}
return (
<div className="stack-lg">
<Card>
<CardHeader>
<div className="flex-between">
<CardTitle>Environment &amp; Access Keys</CardTitle>
<Badge variant="primary">Production</Badge>
</div>
<CardDescription>
Manage authentication credentials and runtime ingress access.
</CardDescription>
</CardHeader>
<CardContent>
<div className="stack-md">
<div>
<label style={{ display: 'block', fontSize: 'var(--text-xs)', fontWeight: 600, marginBottom: 'var(--space-2)' }}>
Production Secret API Key
</label>
<div className="row-xs" style={{ alignItems: 'center' }}>
<div
style={{
flex: 1,
background: 'var(--secondary)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-md)',
padding: 'var(--space-2) var(--space-3)',
fontFamily: 'var(--font-mono)',
fontSize: 'var(--text-xs)',
color: 'var(--foreground)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<span>
{apiKeyVisible ? apiKey : '••••••••••••••••••••••••••••••••'}
</span>
<button
className="icon-button"
style={{ padding: 4 }}
onClick={() => setApiKeyVisible(!apiKeyVisible)}
aria-label={apiKeyVisible ? 'Hide API key' : 'Show API key'}
>
<Icon name={apiKeyVisible ? 'eye' : 'eye'} size={14} />
</button>
</div>
<Tooltip content="Copy key to clipboard">
<button className="button secondary" onClick={copyKey}>
<Icon name={copied ? 'check' : 'copy'} size={14} />
{copied ? 'Copied' : 'Copy'}
</button>
</Tooltip>
</div>
</div>
<Alert variant="info">
<AlertTitle>Zero-Trust Rotation Enabled</AlertTitle>
<AlertDescription>
Keys are automatically renewed every 90 days. Next scheduled rotation in 14 days.
</AlertDescription>
</Alert>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Deployment &amp; Runtime Safeguards</CardTitle>
<CardDescription>
Fine-tune canary threshold tolerances and telemetry logging.
</CardDescription>
</CardHeader>
<CardContent>
<div className="stack-md">
<div className="flex-between">
<div>
<div style={{ fontSize: 'var(--text-sm)', fontWeight: 600, color: 'var(--foreground)' }}>
Automated Canary Progression
</div>
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--muted-foreground)', marginTop: 2 }}>
Gradually increase traffic from 5% to 100% based on error budget.
</div>
</div>
<Switch checked={canaryEnabled} onCheckedChange={setCanaryEnabled} />
</div>
<div className="flex-between" style={{ paddingTop: 'var(--space-3)', borderTop: '1px solid var(--border)' }}>
<div>
<div style={{ fontSize: 'var(--text-sm)', fontWeight: 600, color: 'var(--foreground)' }}>
Detailed OpenTelemetry Tracing
</div>
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--muted-foreground)', marginTop: 2 }}>
Emit span metrics for all inbound HTTP and gRPC remote procedure calls.
</div>
</div>
<Switch checked={telemetryEnabled} onCheckedChange={setTelemetryEnabled} />
</div>
<div className="flex-between" style={{ paddingTop: 'var(--space-3)', borderTop: '1px solid var(--border)' }}>
<div>
<div style={{ fontSize: 'var(--text-sm)', fontWeight: 600, color: 'var(--foreground)' }}>
Immediate Circuit-Breaker Rollback
</div>
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--muted-foreground)', marginTop: 2 }}>
Instantly revert to prior green build if P99 latency breaches 100ms.
</div>
</div>
<Switch checked={autoRollback} onCheckedChange={setAutoRollback} />
</div>
</div>
</CardContent>
<CardFooter>
<span style={{ fontSize: 'var(--text-3xs)', color: 'var(--muted-foreground)' }}>
Shortcut: <Kbd>Ctrl</Kbd> + <Kbd>S</Kbd> to persist
</span>
<div className="row-xs" style={{ marginLeft: 'auto' }}>
<button className="button ghost">Discard</button>
<button className="button primary" onClick={handleSave}>
<Icon name={saved ? 'check' : 'sparkles'} size={14} />
{saved ? 'Saved Successfully' : 'Save Changes'}
</button>
</div>
</CardFooter>
</Card>
</div>
)
}
@@ -0,0 +1,4 @@
export * from './MetricsDashboard'
export * from './SettingsPanel'
export * from './ServiceTable'
export * from './DeploymentModal'
+182
View File
@@ -0,0 +1,182 @@
/* The one icon registry — canonical names (lucide-style kebab) + components.
IconMatrix, SearchPalette and every <Icon name="…"/> consumer type-check
against this map. Kept in its own module so it can be imported without
pulling in components (and without breaking react-fast-refresh). */
import {
AlertCircle,
AlertTriangle,
ArrowLeft,
ArrowRight,
Calendar,
Check,
CheckCircle2,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronUp,
Clock,
Code2,
Copy,
Cpu,
Database,
Download,
ExternalLink,
Eye,
FileText,
Filter,
Folder,
HelpCircle,
Info,
Layers,
LogOut,
Menu,
Minus,
Moon,
Plus,
RefreshCw,
Search,
Settings,
Shield,
SlidersHorizontal,
Sparkles,
Sun,
Terminal,
Trash2,
Upload,
User,
X,
type LucideIcon,
} from 'lucide-react'
const iconComponents = {
// Navigation
'arrow-right': ArrowRight,
'arrow-left': ArrowLeft,
'chevron-right': ChevronRight,
'chevron-left': ChevronLeft,
'chevron-down': ChevronDown,
'chevron-up': ChevronUp,
'external-link': ExternalLink,
menu: Menu,
// System & AI
sparkles: Sparkles,
terminal: Terminal,
code: Code2,
cpu: Cpu,
layers: Layers,
sun: Sun,
moon: Moon,
database: Database,
// Interface & actions
search: Search,
copy: Copy,
settings: Settings,
sliders: SlidersHorizontal,
filter: Filter,
plus: Plus,
minus: Minus,
trash: Trash2,
refresh: RefreshCw,
download: Download,
upload: Upload,
folder: Folder,
file: FileText,
user: User,
'log-out': LogOut,
calendar: Calendar,
clock: Clock,
// Status & feedback
check: Check,
'check-circle': CheckCircle2,
warning: AlertTriangle,
alert: AlertCircle,
info: Info,
close: X,
shield: Shield,
help: HelpCircle,
eye: Eye,
} as const
/** Legacy Northstar shorthands kept as aliases so old call sites keep working.
Values are direct references so iconMap stays Record<IconName, LucideIcon>. */
const iconAliases = {
arrow: ArrowRight,
sparkle: Sparkles,
x: X,
'trash-2': Trash2,
'alert-triangle': AlertTriangle,
'alert-circle': AlertCircle,
} as const
export const iconMap = { ...iconComponents, ...iconAliases }
export type IconName = keyof typeof iconMap
export type IconCategory = 'Navigation' | 'Interface' | 'System' | 'Status'
export type IconRegistryEntry = {
name: IconName
pascalName: string
category: IconCategory
component: LucideIcon
}
/* Curated showcase metadata — the compiler forces one entry per canonical
icon, so a rename in `iconComponents` breaks here loudly (and in tests). */
const iconMeta: Record<keyof typeof iconComponents, { pascalName: string; category: IconCategory }> = {
'arrow-right': { pascalName: 'ArrowRight', category: 'Navigation' },
'arrow-left': { pascalName: 'ArrowLeft', category: 'Navigation' },
'chevron-right': { pascalName: 'ChevronRight', category: 'Navigation' },
'chevron-left': { pascalName: 'ChevronLeft', category: 'Navigation' },
'chevron-down': { pascalName: 'ChevronDown', category: 'Navigation' },
'chevron-up': { pascalName: 'ChevronUp', category: 'Navigation' },
'external-link': { pascalName: 'ExternalLink', category: 'Navigation' },
menu: { pascalName: 'Menu', category: 'Navigation' },
sparkles: { pascalName: 'Sparkles', category: 'System' },
terminal: { pascalName: 'Terminal', category: 'System' },
code: { pascalName: 'Code2', category: 'System' },
cpu: { pascalName: 'Cpu', category: 'System' },
layers: { pascalName: 'Layers', category: 'System' },
sun: { pascalName: 'Sun', category: 'System' },
moon: { pascalName: 'Moon', category: 'System' },
database: { pascalName: 'Database', category: 'System' },
search: { pascalName: 'Search', category: 'Interface' },
copy: { pascalName: 'Copy', category: 'Interface' },
settings: { pascalName: 'Settings', category: 'Interface' },
sliders: { pascalName: 'SlidersHorizontal', category: 'Interface' },
filter: { pascalName: 'Filter', category: 'Interface' },
plus: { pascalName: 'Plus', category: 'Interface' },
minus: { pascalName: 'Minus', category: 'Interface' },
trash: { pascalName: 'Trash2', category: 'Interface' },
refresh: { pascalName: 'RefreshCw', category: 'Interface' },
download: { pascalName: 'Download', category: 'Interface' },
upload: { pascalName: 'Upload', category: 'Interface' },
folder: { pascalName: 'Folder', category: 'Interface' },
file: { pascalName: 'FileText', category: 'Interface' },
user: { pascalName: 'User', category: 'Interface' },
'log-out': { pascalName: 'LogOut', category: 'Interface' },
calendar: { pascalName: 'Calendar', category: 'Interface' },
clock: { pascalName: 'Clock', category: 'Interface' },
check: { pascalName: 'Check', category: 'Status' },
'check-circle': { pascalName: 'CheckCircle2', category: 'Status' },
warning: { pascalName: 'AlertTriangle', category: 'Status' },
alert: { pascalName: 'AlertCircle', category: 'Status' },
info: { pascalName: 'Info', category: 'Status' },
close: { pascalName: 'X', category: 'Status' },
shield: { pascalName: 'Shield', category: 'Status' },
help: { pascalName: 'HelpCircle', category: 'Status' },
eye: { pascalName: 'Eye', category: 'Status' },
}
/** Curated icon explorer data (used by IconMatrix). Aliases are not listed. */
export const iconRegistry: IconRegistryEntry[] = (
Object.entries(iconComponents) as [keyof typeof iconComponents, LucideIcon][]
).map(([name, component]) => ({
name: name as IconName,
pascalName: iconMeta[name].pascalName,
category: iconMeta[name].category,
component,
}))
+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 (
<p
className={cn(className)}
style={{
margin: 0,
fontSize: 'var(--text-xs)',
color: 'var(--muted-foreground)',
lineHeight: 1.5,
}}
{...props}
/>
)
}
+112
View File
@@ -0,0 +1,112 @@
import { cn } from '../../utils/cn'
export type AvatarSize = 'xs' | 'sm' | 'md' | 'lg'
export type AvatarStatus = 'online' | 'away' | 'offline'
export type AvatarProps = {
name: string
src?: string
size?: AvatarSize
status?: AvatarStatus
className?: string
}
const dimensions: Record<AvatarSize, string> = {
xs: 'var(--space-5)',
sm: 'var(--space-6)',
md: 'var(--space-7)',
lg: 'var(--space-9)',
}
const fontSizes: Record<AvatarSize, string> = {
xs: 'var(--text-3xs)',
sm: 'var(--text-2xs)',
md: 'var(--text-xs)',
lg: 'var(--text-base)',
}
const dotSizes: Record<AvatarSize, string> = {
xs: 'var(--space-1)',
sm: 'var(--space-1)',
md: 'var(--space-2)',
lg: 'var(--space-2)',
}
const statusColors: Record<AvatarStatus, string> = {
online: 'var(--chart-2)',
away: 'var(--chart-3)',
offline: 'var(--muted-foreground)',
}
function getInitials(name: string): string {
const initials = name
.trim()
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((word) => word.charAt(0))
.join('')
.toUpperCase()
return initials || '?'
}
export function Avatar({ name, src, size = 'md', status, className }: AvatarProps) {
const dimension = dimensions[size]
return (
<span
className={cn('flex-center', className)}
role={src ? undefined : 'img'}
aria-label={src ? undefined : name || 'Avatar'}
style={{
position: 'relative',
flexShrink: 0,
width: dimension,
height: dimension,
borderRadius: 'var(--radius-pill)',
background: 'color-mix(in srgb, var(--primary) 16%, var(--background))',
color: 'var(--primary)',
fontFamily: 'var(--font-sans)',
fontSize: fontSizes[size],
fontWeight: 700,
lineHeight: 1,
letterSpacing: '0.02em',
userSelect: 'none',
}}
>
{src ? (
<img
src={src}
alt={name}
style={{
display: 'block',
width: '100%',
height: '100%',
objectFit: 'cover',
borderRadius: 'var(--radius-pill)',
}}
/>
) : (
<span aria-hidden="true">{getInitials(name)}</span>
)}
{status && (
<span
aria-hidden="true"
style={{
position: 'absolute',
right: 0,
bottom: 0,
boxSizing: 'content-box',
width: dotSizes[size],
height: dotSizes[size],
borderRadius: 'var(--radius-pill)',
background: statusColors[status],
border: '2px solid var(--background)',
}}
/>
)}
</span>
)
}
+16
View File
@@ -0,0 +1,16 @@
import type { HTMLAttributes } from 'react'
import { cn } from '../../utils/cn'
export type BadgeVariant = 'primary' | 'secondary' | 'destructive' | 'positive' | 'outline'
type BadgeProps = HTMLAttributes<HTMLSpanElement> & {
variant?: BadgeVariant
}
export function Badge({ variant = 'primary', className, children, ...props }: BadgeProps) {
return (
<span className={cn('badge', `badge-${variant}`, className)} {...props}>
{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}
/>
)
}
+79
View File
@@ -0,0 +1,79 @@
import type { CSSProperties, HTMLAttributes, ReactNode } from 'react'
import {
Overlay,
OverlayClose,
OverlayContent,
OverlayDescription,
OverlayFooter,
OverlayHeader,
OverlayTitle,
OverlayTrigger,
} from './Overlay'
/* Dialog is a thin skin over the shared Overlay primitive — all behavior
(focus trap, Escape, scroll lock, focus restore, aria wiring) lives there. */
export type DialogProps = {
children: ReactNode
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
}
export function Dialog(props: DialogProps) {
return <Overlay {...props} />
}
export function DialogTrigger(props: Parameters<typeof OverlayTrigger>[0]) {
return <OverlayTrigger {...props} />
}
export function DialogContent({ children, className }: { children: ReactNode; className?: string }) {
return (
<OverlayContent
backdropClassName="dialog-backdrop"
panelClassName="dialog-panel"
closeButtonLabel="Close dialog"
className={className}
>
{children}
</OverlayContent>
)
}
export function DialogHeader({ className, style, ...props }: HTMLAttributes<HTMLDivElement>) {
const defaultStyle: CSSProperties = { marginBottom: 'var(--space-4)', ...style }
return <OverlayHeader className={className} style={defaultStyle} {...props} />
}
export function DialogTitle({ className, style, ...props }: HTMLAttributes<HTMLHeadingElement>) {
const defaultStyle: CSSProperties = {
margin: 0,
fontSize: 'var(--text-xl)',
fontWeight: 700,
color: 'var(--foreground)',
letterSpacing: '-0.03em',
...style,
}
return <OverlayTitle className={className} style={defaultStyle} {...props} />
}
export function DialogDescription({ className, style, ...props }: HTMLAttributes<HTMLParagraphElement>) {
const defaultStyle: CSSProperties = {
margin: 0,
fontSize: 'var(--text-sm)',
color: 'var(--muted-foreground)',
lineHeight: 1.5,
...style,
}
return <OverlayDescription className={className} style={defaultStyle} {...props} />
}
export function DialogFooter({ className, style, ...props }: HTMLAttributes<HTMLDivElement>) {
const defaultStyle: CSSProperties = { marginTop: 'var(--space-6)', ...style }
return <OverlayFooter className={className} style={defaultStyle} {...props} />
}
export function DialogClose(props: Parameters<typeof OverlayClose>[0]) {
return <OverlayClose {...props} />
}
+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} />
}
+294
View File
@@ -0,0 +1,294 @@
import {
createContext,
useContext,
useState,
useEffect,
useRef,
useCallback,
useId,
useMemo,
cloneElement,
isValidElement,
type ReactNode,
type ReactElement,
type HTMLAttributes,
type ButtonHTMLAttributes,
type CSSProperties,
type MouseEvent as ReactMouseEvent,
type KeyboardEvent as ReactKeyboardEvent,
} from 'react'
import { cn } from '../../utils/cn'
import { trapTabKey, useOverlayLifecycle } from './overlayBehavior'
/* ── Overlay: the one modal primitive ─────────────────────────────
Focus trap, Escape-to-close, scroll lock, focus restore, and the
aria wiring all live here. Dialog and Sheet are thin wrappers that
only contribute class names and type-scale defaults. */
type OverlayContextType = {
open: boolean
setOpen: (open: boolean) => void
titleId: string
descriptionId: string
/** True while a <OverlayTitle> is mounted — aria-labelledby is only wired then. */
hasTitle: boolean
/** True while a <OverlayDescription> is mounted. */
hasDescription: boolean
registerTitle: (present: boolean) => void
registerDescription: (present: boolean) => void
}
const OverlayContext = createContext<OverlayContextType | null>(null)
function useOverlay() {
const ctx = useContext(OverlayContext)
if (!ctx) throw new Error('Overlay components must be used within an <Overlay> (Dialog/Sheet)')
return ctx
}
/* ── Trigger / Close buttons ────────────────────────────────────── */
/* Neutralize UA button chrome so the trigger/close buttons stay visually
identical to the spans they replaced. */
const neutralButtonStyle: CSSProperties = {
background: 'none',
border: 0,
padding: 0,
font: 'inherit',
color: 'inherit',
cursor: 'pointer',
}
type OverlayActionProps = ButtonHTMLAttributes<HTMLButtonElement> & {
/** Attach open/close behavior to the caller's own element instead of rendering a wrapper <button>. */
asChild?: boolean
}
/* Shared Trigger/Close body. `asChild` merges the click behavior into the
caller's element so styled buttons don't end up nested inside a button. */
function OverlayButton({
next,
setOpen,
children,
className,
onClick,
style,
asChild = false,
...props
}: OverlayActionProps & { next: boolean; setOpen: (next: boolean) => void }) {
const handleActivate = (e: ReactMouseEvent<HTMLElement>) => {
onClick?.(e as ReactMouseEvent<HTMLButtonElement>)
if (!e.defaultPrevented) setOpen(next)
}
if (asChild && isValidElement(children)) {
const child = children as ReactElement<{
className?: string
style?: CSSProperties
}>
const merged: { onClick: typeof handleActivate; className?: string; style?: CSSProperties } = {
onClick: handleActivate,
}
if (className) merged.className = cn(child.props.className, className)
if (style) merged.style = { ...child.props.style, ...style }
return cloneElement(child, merged)
}
return (
<button
{...props}
type="button"
className={className}
style={{ ...neutralButtonStyle, ...style }}
onClick={handleActivate}
>
{children}
</button>
)
}
/* ── Root ───────────────────────────────────────────────────────── */
export type OverlayProps = {
children: ReactNode
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
}
export function Overlay({ children, open: controlledOpen, defaultOpen = false, onOpenChange }: OverlayProps) {
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen)
const isControlled = controlledOpen !== undefined
const open = isControlled ? controlledOpen : uncontrolledOpen
const [hasTitle, setHasTitle] = useState(false)
const [hasDescription, setHasDescription] = useState(false)
const titleId = useId()
const descriptionId = useId()
const setOpen = useCallback(
(next: boolean) => {
if (!isControlled) setUncontrolledOpen(next)
onOpenChange?.(next)
},
[isControlled, onOpenChange],
)
const registerTitle = useCallback((present: boolean) => setHasTitle(present), [])
const registerDescription = useCallback((present: boolean) => setHasDescription(present), [])
const contextValue = useMemo(
() => ({
open,
setOpen,
titleId,
descriptionId,
hasTitle,
hasDescription,
registerTitle,
registerDescription,
}),
[open, setOpen, titleId, descriptionId, hasTitle, hasDescription, registerTitle, registerDescription],
)
return <OverlayContext.Provider value={contextValue}>{children}</OverlayContext.Provider>
}
export function OverlayTrigger(props: OverlayActionProps) {
const { setOpen } = useOverlay()
return <OverlayButton {...props} setOpen={setOpen} next />
}
/* ── Content: backdrop + panel + close button ───────────────────── */
export type OverlayContentProps = {
children: ReactNode
className?: string
/** Backdrop class from the wrapper (e.g. `dialog-backdrop`). */
backdropClassName: string
/** Panel class from the wrapper (e.g. `dialog-panel`). */
panelClassName: string
/** Accessible label for the ✕ button ("Close dialog" / "Close sheet"). */
closeButtonLabel: string
}
export function OverlayContent({
children,
className,
backdropClassName,
panelClassName,
closeButtonLabel,
}: OverlayContentProps) {
const { open, setOpen, titleId, descriptionId, hasTitle, hasDescription } = useOverlay()
const panelRef = useRef<HTMLDivElement>(null)
useOverlayLifecycle(open, panelRef)
/* Escape-to-close: subscribed once per open, not on every render. */
useEffect(() => {
if (!open) return
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [open, setOpen])
if (!open) return null
const handlePanelKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
trapTabKey(e, panelRef.current)
}
return (
<div className={backdropClassName} onClick={() => setOpen(false)}>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby={hasTitle ? titleId : undefined}
aria-describedby={hasDescription ? descriptionId : undefined}
tabIndex={-1}
className={cn(panelClassName, className)}
onKeyDown={handlePanelKeyDown}
onClick={e => e.stopPropagation()}
>
<button
type="button"
className="dialog-close-btn"
onClick={() => setOpen(false)}
aria-label={closeButtonLabel}
>
✕
</button>
{children}
</div>
</div>
)
}
/* ── Header / Title / Description / Footer ──────────────────────── */
export function OverlayHeader({
className,
style,
...props
}: HTMLAttributes<HTMLDivElement> & { style?: CSSProperties }) {
return <div className={cn('stack-xs', className)} style={style} {...props} />
}
export function OverlayTitle({
className,
style,
...props
}: HTMLAttributes<HTMLHeadingElement> & { style?: CSSProperties }) {
const { titleId, registerTitle } = useOverlay()
/* Register so the panel's aria-labelledby only points at a mounted title. */
useEffect(() => {
registerTitle(true)
return () => registerTitle(false)
}, [registerTitle])
return <h2 id={titleId} className={cn(className)} style={style} {...props} />
}
export function OverlayDescription({
className,
style,
...props
}: HTMLAttributes<HTMLParagraphElement> & { style?: CSSProperties }) {
const { descriptionId, registerDescription } = useOverlay()
useEffect(() => {
registerDescription(true)
return () => registerDescription(false)
}, [registerDescription])
return <p id={descriptionId} className={cn(className)} style={style} {...props} />
}
export function OverlayFooter({
className,
style,
...props
}: HTMLAttributes<HTMLDivElement> & { style?: CSSProperties }) {
return (
<div
className={cn('flex-between', className)}
style={{
paddingTop: 'var(--space-4)',
borderTop: '1px solid var(--border)',
gap: 'var(--space-3)',
...style,
}}
{...props}
/>
)
}
export function OverlayClose(props: OverlayActionProps) {
const { setOpen } = useOverlay()
return <OverlayButton {...props} setOpen={setOpen} next={false} />
}
+48
View File
@@ -0,0 +1,48 @@
import { useId, 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) {
/* Guard NaN from max={0}: clamp against a sane fallback instead. */
const safeMax = max > 0 ? max : 100
const percentage = Math.min(Math.max(Math.round((value / safeMax) * 100), 0), 100)
const labelId = useId()
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 id={labelId}>{label}</span>}
{!indeterminate && <span style={{ fontFamily: 'var(--font-mono)' }}>{percentage}%</span>}
</div>
)}
<div
className="progress-container"
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={indeterminate ? undefined : percentage}
aria-busy={indeterminate ? true : undefined}
aria-labelledby={label ? labelId : undefined}
>
<div
className={cn('progress-fill', indeterminate && 'progress-indeterminate')}
style={!indeterminate ? { width: `${percentage}%` } : undefined}
/>
</div>
</div>
)
}
+80
View File
@@ -0,0 +1,80 @@
import type { CSSProperties, HTMLAttributes, ReactNode } from 'react'
import {
Overlay,
OverlayClose,
OverlayContent,
OverlayDescription,
OverlayFooter,
OverlayHeader,
OverlayTitle,
OverlayTrigger,
} from './Overlay'
/* Sheet is the slide-over skin of the shared Overlay primitive — identical
focus trap / Escape / scroll-lock behavior as Dialog, only different
type-scale defaults and panel classes. */
export type SheetProps = {
children: ReactNode
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
}
export function Sheet(props: SheetProps) {
return <Overlay {...props} />
}
export function SheetTrigger(props: Parameters<typeof OverlayTrigger>[0]) {
return <OverlayTrigger {...props} />
}
export function SheetContent({ children, className }: { children: ReactNode; className?: string }) {
return (
<OverlayContent
backdropClassName="sheet-backdrop"
panelClassName="sheet-panel"
closeButtonLabel="Close sheet"
className={className}
>
{children}
</OverlayContent>
)
}
export function SheetHeader({ className, style, ...props }: HTMLAttributes<HTMLDivElement>) {
const defaultStyle: CSSProperties = { marginBottom: 'var(--space-6)', ...style }
return <OverlayHeader className={className} style={defaultStyle} {...props} />
}
export function SheetTitle({ className, style, ...props }: HTMLAttributes<HTMLHeadingElement>) {
const defaultStyle: CSSProperties = {
margin: 0,
fontSize: 'var(--text-lg)',
fontWeight: 700,
color: 'var(--foreground)',
letterSpacing: '-0.02em',
...style,
}
return <OverlayTitle className={className} style={defaultStyle} {...props} />
}
export function SheetDescription({ className, style, ...props }: HTMLAttributes<HTMLParagraphElement>) {
const defaultStyle: CSSProperties = {
margin: 0,
fontSize: 'var(--text-sm)',
color: 'var(--muted-foreground)',
lineHeight: 1.5,
...style,
}
return <OverlayDescription className={className} style={defaultStyle} {...props} />
}
export function SheetFooter({ className, style, ...props }: HTMLAttributes<HTMLDivElement>) {
const defaultStyle: CSSProperties = { marginTop: 'auto', ...style }
return <OverlayFooter className={className} style={defaultStyle} {...props} />
}
export function SheetClose(props: Parameters<typeof OverlayClose>[0]) {
return <OverlayClose {...props} />
}
+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}
/>
)
}
+61
View File
@@ -0,0 +1,61 @@
import type { CSSProperties } from 'react'
import { cn } from '../../utils/cn'
import './spinner.css'
export type SpinnerSize = 'xs' | 'sm' | 'md' | 'lg'
export type SpinnerProps = {
size?: SpinnerSize
label?: string
className?: string
}
/** Ring geometry — sizes come from the spacing scale, never from literals. */
const sizeStyles: Record<SpinnerSize, CSSProperties> = {
xs: { width: 'var(--space-3)', height: 'var(--space-3)', borderWidth: '1px' },
sm: { width: 'var(--space-4)', height: 'var(--space-4)', borderWidth: '2px' },
md: { width: 'var(--space-6)', height: 'var(--space-6)', borderWidth: '2px' },
lg: { width: 'var(--space-8)', height: 'var(--space-8)', borderWidth: '3px' },
}
/** Screen-reader-only clip pattern, kept inline so no CSS file has to change. */
const visuallyHidden: CSSProperties = {
position: 'absolute',
width: '1px',
height: '1px',
padding: 0,
margin: '-1px',
overflow: 'hidden',
clip: 'rect(0 0 0 0)',
whiteSpace: 'nowrap',
borderWidth: 0,
}
export function Spinner({ size = 'md', label, className }: SpinnerProps) {
return (
<div
role="status"
aria-live="polite"
className={cn('ui-spinner', `ui-spinner-${size}`, className)}
>
<span
aria-hidden="true"
style={{
display: 'inline-block',
boxSizing: 'border-box',
borderStyle: 'solid',
borderColor: 'var(--border)',
borderTopColor: 'var(--primary)',
borderRadius: '50%',
animation: 'spin 0.8s linear infinite',
...sizeStyles[size],
}}
/>
{label ? (
<span className="visually-hidden" style={visuallyHidden}>
{label}
</span>
) : null}
</div>
)
}
+100
View File
@@ -0,0 +1,100 @@
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
/** Sentiment — drives the badge color. Up = good news, down = bad news. */
trend?: 'up' | 'down' | 'neutral'
/**
* Arrow direction — which way the number moved. Defaults to the sign of
* `change`, so "trend=up" (good) with change="-4.2ms" renders a green
* "↓ -4.2ms" instead of the nonsensical "↑ -4.2ms".
*/
direction?: 'up' | 'down' | 'neutral'
caption?: string
icon?: ReactNode
className?: string
}
/** "↓ good latency" needs an arrow that follows the sign of the change. */
function inferDirection(change?: string): 'up' | 'down' | 'neutral' {
if (!change || !/[0-9]/.test(change)) return 'neutral'
return change.trim().startsWith('-') ? 'down' : 'up'
}
export function StatCard({
title,
value,
change,
trend = 'neutral',
direction,
caption,
icon,
className,
}: StatCardProps) {
const arrowDirection = direction ?? inferDirection(change)
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'}>
{arrowDirection === 'up' ? '↑' : arrowDirection === '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}
/>
)
}
+160
View File
@@ -0,0 +1,160 @@
import {
createContext,
useContext,
useId,
useState,
type HTMLAttributes,
type KeyboardEvent,
type ReactNode,
} from 'react'
import { cn } from '../../utils/cn'
type TabsContextType = {
value: string
setValue: (value: string) => void
baseId: string
}
const TabsContext = createContext<TabsContextType | null>(null)
function useTabs() {
const ctx = useContext(TabsContext)
if (!ctx) throw new Error('Tabs components must be used within <Tabs>')
return ctx
}
/** Stable, SSR-safe pairing between a trigger and its panel. */
function partId(baseId: string, kind: 'trigger' | 'content', value: string) {
return `${baseId}-${kind}-${value.replace(/[^A-Za-z0-9_-]+/g, '-')}`
}
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 baseId = useId()
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, baseId }}>
<div className={cn('ui-tabs-root', className)}>{children}</div>
</TabsContext.Provider>
)
}
const NAV_KEYS = new Set(['ArrowRight', 'ArrowLeft', 'Home', 'End'])
export function TabsList({ className, onKeyDown, ...props }: HTMLAttributes<HTMLDivElement>) {
const { setValue } = useTabs()
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
onKeyDown?.(event)
if (event.defaultPrevented || !NAV_KEYS.has(event.key)) return
const tabs = Array.from(
event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="tab"]:not([disabled])'),
)
if (tabs.length === 0) return
const focused = tabs.indexOf(document.activeElement as HTMLButtonElement)
const selected = tabs.findIndex((tab) => tab.getAttribute('aria-selected') === 'true')
const current = focused >= 0 ? focused : selected
if (current < 0) return
let next: number
switch (event.key) {
case 'ArrowRight':
next = (current + 1) % tabs.length
break
case 'ArrowLeft':
next = (current - 1 + tabs.length) % tabs.length
break
case 'Home':
next = 0
break
default:
next = tabs.length - 1
break
}
const nextTab = tabs[next]
if (nextTab === undefined) return
const nextValue = nextTab.dataset.value
if (nextValue === undefined) return
event.preventDefault()
nextTab.focus()
setValue(nextValue)
}
return (
<div
className={cn('ui-tabs-list', className)}
role="tablist"
aria-orientation="horizontal"
onKeyDown={handleKeyDown}
{...props}
/>
)
}
type TabsTriggerProps = HTMLAttributes<HTMLButtonElement> & {
value: string
}
export function TabsTrigger({ value, className, children, ...props }: TabsTriggerProps) {
const { value: selectedValue, setValue, baseId } = useTabs()
const isActive = selectedValue === value
return (
<button
type="button"
id={partId(baseId, 'trigger', value)}
role="tab"
aria-selected={isActive}
aria-controls={partId(baseId, 'content', value)}
tabIndex={isActive ? 0 : -1}
data-value={value}
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, baseId } = useTabs()
const isActive = selectedValue === value
return (
<div
id={partId(baseId, 'content', value)}
role="tabpanel"
aria-labelledby={partId(baseId, 'trigger', value)}
tabIndex={0}
hidden={!isActive}
className={cn('ui-tabs-content', className)}
{...props}
>
{children}
</div>
)
}
+82
View File
@@ -0,0 +1,82 @@
import {
Fragment,
cloneElement,
isValidElement,
useId,
useState,
type ReactElement,
type ReactNode,
} from 'react'
import { cn } from '../../utils/cn'
type TooltipProps = {
content: ReactNode
children: ReactNode
className?: string
}
type ChildProps = {
onClick?: unknown
tabIndex?: number
href?: string
'aria-describedby'?: string
}
/** Intrinsic elements that already participate in the tab order. */
const NATURALLY_FOCUSABLE = new Set(['button', 'input', 'select', 'textarea', 'summary'])
function childIsFocusable(child: ReactElement): boolean {
const props = child.props as ChildProps
if (props.tabIndex !== undefined) return props.tabIndex >= 0
if (props.onClick !== undefined) return true
if (typeof child.type === 'string') {
if (child.type === 'a') return props.href !== undefined
return NATURALLY_FOCUSABLE.has(child.type)
}
return false
}
export function Tooltip({ content, children, className }: TooltipProps) {
const id = useId()
const [open, setOpen] = useState(false)
// Plain text / fragments cannot carry ARIA props, so wrap them in a span first.
const isPlainText = typeof children === 'string' || typeof children === 'number'
const child = (
!isPlainText && isValidElement(children) && children.type !== Fragment
? children
: <span>{children}</span>
) as ReactElement<ChildProps>
const existingDescribedBy = child.props['aria-describedby']
const describedBy = existingDescribedBy ? `${existingDescribedBy} ${id}` : id
const clonedChild = cloneElement(child, { 'aria-describedby': describedBy })
// Only make the wrapper focusable when the child itself cannot receive focus.
const wrapperNeedsFocus = !childIsFocusable(child)
return (
<span
className={cn('tooltip-wrapper', className)}
tabIndex={wrapperNeedsFocus ? 0 : undefined}
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
onKeyDown={(event) => {
if (event.key === 'Escape') setOpen(false)
}}
>
{clonedChild}
<span
id={id}
className="tooltip-box"
role="tooltip"
aria-hidden={!open}
data-open={open ? 'true' : 'false'}
>
{content}
</span>
</span>
)
}
+19
View File
@@ -0,0 +1,19 @@
export * from './Badge'
export * from './Card'
export * from './StatCard'
export * from './Kbd'
export * from './Overlay'
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'
export * from './Avatar'
export * from './Spinner'
export * from '../Icon'
export * from '../Button'
@@ -0,0 +1,53 @@
import { useEffect, type RefObject } from 'react'
import type { KeyboardEvent as ReactKeyboardEvent } from 'react'
/* Behavior shared by every overlay (Dialog, Sheet, SearchPalette).
Kept in a behavior module so component files stay fast-refresh friendly. */
const focusableSelector =
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
/** Keep Tab / Shift+Tab inside `container`. Call from an onKeyDown handler. */
export function trapTabKey(e: ReactKeyboardEvent | KeyboardEvent, container: HTMLElement | null) {
if (e.key !== 'Tab' || !container) return
const focusable = Array.from(container.querySelectorAll<HTMLElement>(focusableSelector))
if (focusable.length === 0) {
e.preventDefault()
container.focus()
return
}
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (!first || !last) return
const active = document.activeElement
if (e.shiftKey) {
if (active === first || active === container) {
e.preventDefault()
last.focus()
}
} else if (active === last) {
e.preventDefault()
first.focus()
}
}
/**
* Modal open/close lifecycle shared by every overlay: lock body scroll while
* open (restoring the previous value) and restore focus to whatever held it
* before the overlay opened. Escape handling stays with the caller so overlays
* can ignore or intercept it (e.g. SearchPalette just closes).
*/
export function useOverlayLifecycle(open: boolean, panelRef: RefObject<HTMLElement | null>) {
useEffect(() => {
if (!open) return
const previouslyFocused = document.activeElement as HTMLElement | null
const previousOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
panelRef.current?.focus()
return () => {
document.body.style.overflow = previousOverflow
if (previouslyFocused && document.contains(previouslyFocused)) previouslyFocused.focus()
}
}, [open, panelRef])
}
+5
View File
@@ -0,0 +1,5 @@
@keyframes spin {
to {
transform: rotate(360deg);
}
}
+25
View File
@@ -0,0 +1,25 @@
import type { ButtonExample } from '../components/ButtonMatrix'
import type { Token } from '../components/TokenGrid'
/* Navigation lives in src/app/routes.tsx, next to the route table it has to
match — see tests/routing.test.tsx, which keeps the two in sync. */
export const buttonTabs = ['Preview', 'API reference', 'Usage']
export const buttonExamples: ButtonExample[] = [
{ name: 'Primary', label: 'Deploy changes', variant: 'primary' },
{ name: 'Secondary', label: 'View activity', variant: 'secondary' },
{ name: 'Destructive', label: 'Delete project', variant: 'danger' },
{ name: 'Ghost', label: 'Cancel', variant: 'ghost' },
]
export const colorTokens: Token[] = [
{ name: 'Primary', color: 'var(--primary)', value: 'lab(89.9% -47.9 -15.7)' },
{ name: 'Background', color: 'var(--background)', value: 'lab(0% 0 0)' },
{ name: 'Card', color: 'var(--card)', value: 'lab(0.9% 0 0)' },
{ 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)' },
]
+12
View File
@@ -0,0 +1,12 @@
/* Pattern catalogue for the showcase. A pattern id is a URL parameter
(`/patterns?pattern=table`), so this list is also the source for the
sidebar's pattern entries — see src/app/routes.tsx. */
export const PATTERNS = [
{ id: 'metrics', label: 'Metrics Dashboard', icon: 'sparkles' },
{ id: 'settings', label: 'Settings & Safeguards', icon: 'settings' },
{ id: 'table', label: 'Service Monitor Table', icon: 'layers' },
{ id: 'deploy', label: 'Deployment Flow', icon: 'terminal' },
] as const
export type PatternId = (typeof PATTERNS)[number]['id']
+59
View File
@@ -0,0 +1,59 @@
import { Component, type ErrorInfo, type ReactNode } from 'react'
type ErrorBoundaryProps = {
children: ReactNode
}
type ErrorBoundaryState = {
error: Error | null
}
/** Catches render-time crashes: in dev the message + stack are shown inline and
mirrored into #dev-errors (see src/dev/errorSink.ts); in prod we degrade to
a plain message instead of a white screen. */
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
state: ErrorBoundaryState = { error: null }
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { error }
}
componentDidCatch(error: Error, info: ErrorInfo) {
if (import.meta.env.DEV) {
const host = document.getElementById('dev-errors')
if (host) {
const box = document.createElement('div')
box.setAttribute('role', 'alert')
box.setAttribute('data-dev-error', 'react-render')
box.textContent = `React render error: ${error.message}\n${info.componentStack ?? ''}`
host.appendChild(box)
}
}
}
render() {
const { error } = this.state
if (!error) return this.props.children
return (
<div
style={{
padding: 'var(--space-6)',
margin: 'var(--space-6) auto',
maxWidth: '42rem',
fontFamily: 'var(--font-mono)',
fontSize: 'var(--text-sm)',
color: 'var(--foreground)',
background: 'var(--card)',
border: '1px solid var(--destructive)',
borderRadius: 'var(--radius-lg)',
whiteSpace: 'pre-wrap',
}}
>
<strong>Something broke while rendering.</strong>
{'\n\n'}
{error.message}
</div>
)
}
}
+55
View File
@@ -0,0 +1,55 @@
/* Dev-only error sink.
Agents and headless verification often have no console access, so runtime
errors are rendered into the #dev-errors element in index.html where they
can be read with plain DOM inspection (or by humans — no white screens). */
type ErrorPart = { title: string; detail: string }
function renderError({ title, detail }: ErrorPart) {
const host = document.getElementById('dev-errors')
if (!host) return
const box = document.createElement('div')
box.setAttribute('role', 'alert')
box.setAttribute('data-dev-error', 'true')
box.style.cssText = [
'position: relative',
'z-index: 2147483647',
'margin: 8px',
'padding: 12px 14px',
'border: 1px solid var(--destructive, red)',
'border-radius: var(--radius-lg, 8px)',
'background: var(--destructive, red)',
'color: var(--destructive-foreground, white)',
'font-family: var(--font-mono, monospace)',
'font-size: 12px',
'white-space: pre-wrap',
'word-break: break-word',
].join(';')
const heading = document.createElement('strong')
heading.textContent = title
box.appendChild(heading)
const body = document.createElement('div')
body.textContent = detail
box.appendChild(body)
host.appendChild(box)
}
export function installDevErrorSink() {
if (!import.meta.env.DEV) return
window.onerror = (message, source, line, column, error) => {
renderError({
title: `window.onerror: ${String(message)}`,
detail: `${source ?? ''}:${line ?? 0}:${column ?? 0}\n${error?.stack ?? ''}`,
})
}
window.addEventListener('unhandledrejection', event => {
const reason = event.reason instanceof Error ? (event.reason.stack ?? event.reason.message) : String(event.reason)
renderError({ title: 'unhandledrejection', detail: reason })
})
}
+76
View File
@@ -0,0 +1,76 @@
/* SWR data hooks for the items resource. Layers: pages import hooks,
hooks import api modules, api modules import the client — never a layer
skip. Pages therefore never see fetch/URLs, and every cache entry is
addressed through itemsKeys so mutations invalidate consistently. */
import { useCallback } from 'react'
import useSWR, { useSWRConfig, type SWRConfiguration } from 'swr'
import * as itemsApi from '../api/items'
import type { Item, ItemPatch, ItemQuery, NewItem } from '../api/items'
/** Stable cache keys for lists and details. */
export const itemsKeys = {
list: (query: ItemQuery = {}) => ['items', 'list', query] as const,
detail: (id: number) => ['items', 'detail', id] as const,
}
/** Matches every items cache entry — used to revalidate after mutations. */
function isItemsKey(key: unknown): boolean {
return Array.isArray(key) && key[0] === 'items'
}
/** keepPreviousData keeps the table mounted while filters swap the key. */
const listConfig: SWRConfiguration<Item[]> = { keepPreviousData: true }
export function useItems(query: ItemQuery = {}) {
const { data, error, isLoading, mutate } = useSWR(
itemsKeys.list(query),
() => itemsApi.listItems(query),
listConfig,
)
return { items: data, error, isLoading, refresh: mutate }
}
/** Detail fetching; pass null to skip (no id selected yet). */
export function useItem(id: number | null) {
const { data, error, isLoading } = useSWR(
id !== null ? itemsKeys.detail(id) : null,
() => itemsApi.getItem(id as number),
)
return { item: data ?? (id === null ? undefined : data), error, isLoading }
}
/**
* Mutations. Each action calls the api module and then revalidates every
* items cache entry (lists and details) in one matcher-based mutate.
*/
export function useItemActions() {
const { mutate } = useSWRConfig()
const create = useCallback(
async (input: NewItem): Promise<Item> => {
const item = await itemsApi.createItem(input)
await mutate(isItemsKey)
return item
},
[mutate],
)
const update = useCallback(
async (id: number, patch: ItemPatch): Promise<Item> => {
const item = await itemsApi.updateItem(id, patch)
await mutate(isItemsKey)
return item
},
[mutate],
)
const remove = useCallback(
async (id: number): Promise<void> => {
await itemsApi.deleteItem(id)
await mutate(isItemsKey)
},
[mutate],
)
return { create, update, remove }
}
+5
View File
@@ -0,0 +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 './tokens.css';
@import './utils.css';
:root { font-family:var(--font-sans); color:var(--foreground); background:var(--background); 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; }
+21
View File
@@ -0,0 +1,21 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { AppStateProvider } from './app'
import { ErrorBoundary } from './dev/ErrorBoundary.tsx'
import { installDevErrorSink } from './dev/errorSink.ts'
/* Surface runtime errors in #dev-errors — failures render into the DOM so
agents without console access (and humans) see messages, not white screens. */
installDevErrorSink()
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ErrorBoundary>
<AppStateProvider>
<App />
</AppStateProvider>
</ErrorBoundary>
</StrictMode>,
)
+11
View File
@@ -0,0 +1,11 @@
import { useState } from 'react'
import { ButtonMatrix } from '../components/ButtonMatrix'
import { buttonExamples, buttonTabs } from '../data/library'
export function ButtonsPage() {
const [activeTab, setActiveTab] = useState('Preview')
return (
<ButtonMatrix examples={buttonExamples} tabs={buttonTabs} activeTab={activeTab} onTabChange={setActiveTab} />
)
}
+12
View File
@@ -0,0 +1,12 @@
import { TokenGrid } from '../components/TokenGrid'
import { colorTokens } from '../data/library'
const INSTALL_CMD = 'pnpm add @northstar/ui @northstar/tokens'
function copyInstall() {
navigator.clipboard?.writeText(INSTALL_CMD)
}
export function ColorsPage() {
return <TokenGrid tokens={colorTokens} onCopy={copyInstall} />
}
+5
View File
@@ -0,0 +1,5 @@
import { CodeEditor } from '../components/CodeEditor'
export function EditorPage() {
return <CodeEditor />
}
+60
View File
@@ -0,0 +1,60 @@
import { useState } from 'react'
import { FormField, TextInput } from '../components/FormField'
import { Icon } from '../components/Icon'
import { Avatar, Spinner, Switch, Tabs, TabsContent, TabsList, TabsTrigger } from '../components/ui'
export function FormsPage() {
const [visible, setVisible] = useState(false)
return (
<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>
<Switch defaultChecked aria-label="Toggle notifications" />
</label>
<div className="toggle-field">
<span>
<b>Primitives in action</b>
<small>Spinner, Avatar and Switch — token-driven, theme-aware components/ui primitives.</small>
</span>
<span className="row-md">
<Avatar name="Mats Tornberg" size="sm" status="online" />
<Avatar name="Grace Hopper" size="sm" />
<Spinner size="sm" label="Loading session" />
</span>
</div>
<div className="toggle-field">
<span>
<b>Tabs primitive</b>
<small>Roving tabindex, arrow-key navigation, aria-selected wiring — try the keyboard.</small>
</span>
<Tabs defaultValue="preview" className="stack-xs">
<TabsList>
<TabsTrigger value="preview">Preview</TabsTrigger>
<TabsTrigger value="code">Code</TabsTrigger>
</TabsList>
<TabsContent value="preview">
<small>Token-driven preview pane.</small>
</TabsContent>
<TabsContent value="code">
<small>import {'{ Tabs }'} from components/ui</small>
</TabsContent>
</Tabs>
</div>
</div>
)
}
+5
View File
@@ -0,0 +1,5 @@
import { IconMatrix } from '../components/IconMatrix'
export function IconsPage() {
return <IconMatrix />
}
+364
View File
@@ -0,0 +1,364 @@
/* The full-stack wiring example: this page talks to the Go backend through
three layers — src/api/items.ts (typed requests), src/hooks/useItems.ts
(SWR data + actions), and this UI. Every loading/error/empty state is
handled, so it doubles as the reference for the next live page. */
import { useState, type CSSProperties, type FormEvent } from 'react'
import {
Alert,
AlertDescription,
AlertTitle,
Badge,
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
EmptyState,
EmptyStateActions,
EmptyStateDescription,
EmptyStateIcon,
EmptyStateTitle,
Icon,
Skeleton,
Spinner,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '../components/ui'
import { FormField, TextInput } from '../components/FormField'
import { login } from '../auth/useSession'
import { ApiError } from '../api/client'
import type { ItemStatus } from '../api/items'
import { useItemActions, useItems } from '../hooks/useItems'
type StatusFilter = 'all' | ItemStatus
const STATUS_FILTERS: StatusFilter[] = ['all', 'active', 'archived']
const dateFormat = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' })
const smallButton: CSSProperties = {
padding: 'var(--space-1) var(--space-3)',
fontSize: 'var(--text-xs)',
}
export function ItemsPage() {
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
const [createOpen, setCreateOpen] = useState(false)
const [busyId, setBusyId] = useState<number | null>(null)
const [actionError, setActionError] = useState<string | null>(null)
const { items, error, isLoading, refresh } = useItems(
statusFilter === 'all' ? {} : { status: statusFilter },
)
const { update, remove } = useItemActions()
/* Row actions share one busy slot and surface failures next to the table. */
const run = async (id: number, fn: () => Promise<unknown>) => {
setBusyId(id)
setActionError(null)
try {
await fn()
} catch (err) {
setActionError(err instanceof ApiError ? err.message : 'Unexpected error')
} finally {
setBusyId(null)
}
}
const tableHead = (
<TableHeader>
<TableRow>
<TableHead>Item</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
<TableHead>Updated</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
)
return (
<div className="stack-md">
{/* Toolbar */}
<div className="flex-between" style={{ flexWrap: 'wrap', gap: 'var(--space-3)' }}>
<div className="row-xs">
{STATUS_FILTERS.map(filter => (
<button
key={filter}
className={`button ${statusFilter === filter ? 'secondary' : 'ghost'}`}
style={{ ...smallButton, textTransform: 'capitalize' }}
onClick={() => setStatusFilter(filter)}
>
{filter}
</button>
))}
</div>
<div className="row-xs">
<Button variant="ghost" style={smallButton} onClick={() => refresh()}>
<Icon name="refresh" size={13} /> Refresh
</Button>
<Button variant="primary" style={smallButton} onClick={() => setCreateOpen(true)}>
<Icon name="plus" size={13} /> New item
</Button>
</div>
</div>
{actionError && (
<Alert variant="destructive" icon={<Icon name="warning" size={18} />}>
<AlertTitle>Action failed</AlertTitle>
<AlertDescription>{actionError}</AlertDescription>
</Alert>
)}
{/* First load without any data → 401 (OIDC on, no session) or a
network error, each with its own way out. */}
{error && !items ? (
error instanceof ApiError && error.code === 'unauthorized' ? (
<Alert variant="warning" icon={<Icon name="shield" size={18} />}>
<AlertTitle>Sign in required</AlertTitle>
<AlertDescription>
This API requires an authenticated session. Signing in redirects to the
configured OIDC issuer and lands back on this page.
</AlertDescription>
<div className="row-xs" style={{ marginTop: 'var(--space-3)' }}>
<Button variant="primary" onClick={() => login()}>
Sign in
</Button>
</div>
</Alert>
) : (
<Alert variant="destructive" icon={<Icon name="alert" size={18} />}>
<AlertTitle>Cannot reach the Go API</AlertTitle>
<AlertDescription>{error.message}</AlertDescription>
<AlertDescription>
Start it with{' '}
<code className="mono" style={{ color: 'var(--foreground)' }}>
cd backend && make migrate-up && make run
</code>{' '}
— SQLite is the default, so no database server is needed, and vite proxies{' '}
<code className="mono">/api</code> to <code className="mono">:8080</code>.
</AlertDescription>
<div className="row-xs" style={{ marginTop: 'var(--space-3)' }}>
<Button variant="secondary" onClick={() => refresh()}>
<Icon name="refresh" size={13} /> Retry
</Button>
</div>
</Alert>
)
) : isLoading && !items ? (
/* First load → skeleton rows while the request is in flight. */
<Table>
{tableHead}
<TableBody>
{[0, 1, 2, 3].map(row => (
<TableRow key={row}>
<TableCell>
<Skeleton width="60%" />
</TableCell>
<TableCell>
<Skeleton width={64} />
</TableCell>
<TableCell>
<Skeleton width={140} />
</TableCell>
<TableCell>
<Skeleton width={140} />
</TableCell>
<TableCell>
<Skeleton width={120} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : items && items.length === 0 ? (
<EmptyState>
<EmptyStateIcon>
<Icon name="database" size={24} />
</EmptyStateIcon>
<EmptyStateTitle>No items yet</EmptyStateTitle>
<EmptyStateDescription>
This table reads live from the Go API's database (SQLite by default, Postgres via
DATABASE_URL). Create the first item — it POSTs to{' '}
<code className="mono">/api/v1/items</code> and SWR revalidates the list.
</EmptyStateDescription>
<EmptyStateActions>
<Button variant="primary" onClick={() => setCreateOpen(true)}>
<Icon name="plus" size={14} /> New item
</Button>
</EmptyStateActions>
</EmptyState>
) : (
<Table>
{tableHead}
<TableBody>
{items?.map(item => (
<TableRow key={item.id}>
<TableCell>
<div style={{ fontWeight: 600 }}>{item.title}</div>
{item.description && (
<div
className="mono"
style={{
marginTop: 'var(--space-1)',
fontSize: 'var(--text-2xs)',
color: 'var(--muted-foreground)',
}}
>
{item.description}
</div>
)}
</TableCell>
<TableCell>
<Badge variant={item.status === 'active' ? 'positive' : 'secondary'}>
{item.status}
</Badge>
</TableCell>
<TableCell className="mono" style={{ color: 'var(--muted-foreground)' }}>
{dateFormat.format(new Date(item.createdAt))}
</TableCell>
<TableCell className="mono" style={{ color: 'var(--muted-foreground)' }}>
{dateFormat.format(new Date(item.updatedAt))}
</TableCell>
<TableCell>
{busyId === item.id ? (
<Spinner size="xs" />
) : (
<div className="row-xs">
<button
className="button ghost"
style={smallButton}
onClick={() =>
run(item.id, () =>
update(item.id, {
status: item.status === 'active' ? 'archived' : 'active',
}),
)
}
>
{item.status === 'active' ? 'Archive' : 'Restore'}
</button>
<button
className="button ghost"
style={{ ...smallButton, color: 'var(--destructive)' }}
aria-label={`Delete ${item.title}`}
onClick={() => run(item.id, () => remove(item.id))}
>
<Icon name="trash" size={13} />
</button>
</div>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<div className="flex-between muted" style={{ fontSize: 'var(--text-3xs)', padding: '0 var(--space-2)' }}>
<span>
{items?.length ?? 0} item{items?.length === 1 ? '' : 's'} · revalidated by SWR
(reconnects refetch automatically)
</span>
<span className="mono">
GET /api/v1/items{statusFilter === 'all' ? '' : `?status=${statusFilter}`}
</span>
</div>
<CreateItemDialog open={createOpen} onOpenChange={setCreateOpen} />
</div>
)
}
function CreateItemDialog({
open,
onOpenChange,
}: {
open: boolean
onOpenChange: (open: boolean) => void
}) {
const { create } = useItemActions()
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [submitting, setSubmitting] = useState(false)
const [submitError, setSubmitError] = useState<string | null>(null)
const reset = () => {
setTitle('')
setDescription('')
setSubmitting(false)
setSubmitError(null)
}
const handleSubmit = async (event: FormEvent) => {
event.preventDefault()
setSubmitting(true)
setSubmitError(null)
try {
await create({ title, description: description.trim() === '' ? undefined : description })
reset()
onOpenChange(false)
} catch (err) {
setSubmitting(false)
setSubmitError(err instanceof ApiError ? err.message : 'Unexpected error')
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>New item</DialogTitle>
<DialogDescription>
POSTs to <code className="mono">/api/v1/items</code>; the list revalidates
through SWR on success.
</DialogDescription>
</DialogHeader>
<div className="stack-sm">
<FormField label="Title" hint="Required · max 200 characters">
<TextInput
value={title}
onChange={e => setTitle(e.target.value)}
maxLength={200}
placeholder="Ship the api template"
autoFocus
/>
</FormField>
<FormField label="Description" hint="Optional context shown under the title">
<TextInput
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="What is this item about?"
/>
</FormField>
{submitError && (
<Alert variant="destructive" icon={<Icon name="alert" size={16} />}>
<AlertDescription>{submitError}</AlertDescription>
</Alert>
)}
</div>
<DialogFooter>
<div className="row-sm" style={{ justifyContent: 'flex-end' }}>
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)} disabled={submitting}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={submitting || title.trim() === ''}>
{submitting ? <Spinner size="xs" /> : <Icon name="plus" size={13} />}
Create item
</Button>
</div>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
+5
View File
@@ -0,0 +1,5 @@
import { MarkdownRenderer } from '../components/MarkdownRenderer'
export function MarkdownPage() {
return <MarkdownRenderer />
}
+24
View File
@@ -0,0 +1,24 @@
import { Link } from 'wouter'
import { EmptyState, EmptyStateDescription, EmptyStateTitle } from '../components/ui'
import { routes } from '../app/routes'
/** Catch-all for unknown URLs — a real 404, no silent redirect. */
export function NotFoundPage() {
return (
<div className="not-found">
<EmptyState>
<EmptyStateTitle>Page not found</EmptyStateTitle>
<EmptyStateDescription>
That route does not exist in this design system. Pick a page from the list instead.
</EmptyStateDescription>
<ul className="overview-links">
{routes.map(route => (
<li key={route.path}>
<Link href={route.path}>{route.label}</Link>
</li>
))}
</ul>
</EmptyState>
</div>
)
}
+40
View File
@@ -0,0 +1,40 @@
import { Link } from 'wouter'
import { Hero } from '../components/Hero'
import { Icon } from '../components/Icon'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/ui'
import { GROUP_BLURBS, ROUTE_GROUPS, routesInGroup } from '../app/routes'
import { SessionCard } from '../auth/SessionCard'
/** Landing route (`/`): the hero, the live session, then one card per
group, so every page is reachable without the sidebar. */
export function OverviewPage() {
return (
<>
<Hero />
<div className="overview-grid">
<SessionCard />
{ROUTE_GROUPS.map(group => (
<Card key={group} className="stack-xs">
<CardHeader>
<CardTitle>{group}</CardTitle>
<CardDescription>{GROUP_BLURBS[group]}</CardDescription>
</CardHeader>
<CardContent>
<ul className="overview-links">
{routesInGroup(group).map(route => (
<li key={route.path}>
<Link href={route.path}>
{route.label} <Icon name="arrow" size={13} />
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
))}
</div>
</>
)
}
+24
View File
@@ -0,0 +1,24 @@
import { useSearchParams } from 'wouter'
import { AIWorkflowSection } from '../components/AIWorkflowSection'
import { PATTERNS, type PatternId } from '../data/patterns'
/** The selected pattern lives in the URL (`/patterns?pattern=table`), so a
pattern is deep-linkable and the ⌘K palette can jump straight to one. */
export function PatternsPage() {
const [searchParams, setSearchParams] = useSearchParams()
const requested = searchParams.get('pattern')
const pattern: PatternId = PATTERNS.some(p => p.id === requested) ? (requested as PatternId) : 'metrics'
const setPattern = (next: PatternId) => {
setSearchParams(
prev => {
const params = new URLSearchParams(prev)
params.set('pattern', next)
return params
},
{ replace: true },
)
}
return <AIWorkflowSection activePattern={pattern} onPatternChange={setPattern} />
}
+5
View File
@@ -0,0 +1,5 @@
import { CodeSample } from '../components/CodeSample'
export function PrimitivesPage() {
return <CodeSample />
}
+5
View File
@@ -0,0 +1,5 @@
import { TypographySection } from '../components/TypographySection'
export function TypographyPage() {
return <TypographySection />
}
+145
View File
@@ -0,0 +1,145 @@
/* ─────────────────────────────────────────────────────────────
Northstar Design System — Single Source of Truth Tokens.
Modern, high-velocity design system configured for AI-assisted
workflows and rapid iteration with strict uniformity.
Tokens utilize CSS Color 4 lab(...) space:
• Dark: Pitch black, deep cards, radiant electric mint primary
• Light: Pure white daylight, crisp charcoal primary, high contrast
───────────────────────────────────────────────────────────── */
/* ── Global scale, typography & spacing ───────────────────────── */
:root {
/* Global amplifier: scales every rem-based font size & spacing */
--root-font-size: 112%;
/* ── Type scale (rem-based, 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', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-mono: 'DM Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
/* ── Spacing (4px rhythm) ─────────────────── */
--space-1: 0.25rem; /* 4px */
--space-2: 0.5rem; /* 8px */
--space-3: 0.75rem; /* 12px */
--space-4: 1rem; /* 16px */
--space-5: 1.25rem; /* 20px */
--space-6: 1.5rem; /* 24px */
--space-7: 2rem; /* 32px */
--space-8: 2.5rem; /* 40px */
--space-9: 3rem; /* 48px */
--space-10: 4rem; /* 64px */
--space-11: 5rem; /* 80px */
/* ── Radii ────────────────────────────────── */
--radius-sm: 5px;
--radius-md: 6px;
--radius-lg: 9px;
--radius-xl: 12px;
--radius-2xl: 14px;
--radius-pill: 9999px;
/* ── Focus ────────────────────────────────── */
--focus-ring: 2px solid var(--ring);
--focus-offset: 2px;
}
/* ── Light Palette (Exact lab() Specification) ───────────────── */
:root,
:root[data-theme='light'],
.light {
color-scheme: light;
--background: lab(100% 0 0);
--foreground: lab(2.75381% 0 0);
--card: lab(97.68% -.0000298023 .0000119209);
--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(98.26% 0 0);
--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);
}
/* ── 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(100% 0 0);
--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);
}
html {
font-size: var(--root-font-size);
}
+550
View File
@@ -0,0 +1,550 @@
/* ─────────────────────────────────────────────────────────────
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;
align-items: center;
gap: 3px;
padding: 4px;
background: var(--secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
}
.ui-tabs-trigger {
border: 1px solid transparent;
background: none;
color: var(--muted-foreground);
font-size: var(--text-sm);
font-family: var(--font-sans);
font-weight: 500;
line-height: 1.4;
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-md);
cursor: pointer;
display: inline-flex;
align-items: center;
gap: var(--space-2);
transition: all 0.15s ease;
white-space: nowrap;
}
.ui-tabs-trigger:hover {
color: var(--foreground);
}
.ui-tabs-trigger.active {
background: var(--card);
color: var(--foreground);
border-color: color-mix(in srgb, var(--border) 60%, transparent);
font-weight: 600;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.16);
}
.ui-tabs-trigger svg {
display: block;
flex-shrink: 0;
}
.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-box[data-open='true'] {
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);
}
/* ── Shared Focus Ring ──────────────────────────────────────── */
:where(.ui-tabs-trigger, .switch-root, .dialog-close-btn):focus-visible {
outline: var(--focus-ring);
outline-offset: var(--focus-offset);
}
+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(' ')
}
+39
View File
@@ -0,0 +1,39 @@
import { useCallback, useEffect, useRef } from 'react'
/**
* setTimeout that cleans up after itself: every pending timer is cleared when
* the component unmounts, and `clear()` cancels them early (e.g. when a dialog
* closes mid-animation). Prevents the classic "setState after close/unmount"
* leak from fire-and-forget `setTimeout(...)` calls.
*
* Usage:
* const { schedule, clear } = useTimeout()
* schedule(() => setCopied(false), 2000)
*/
export function useTimeout() {
const timers = useRef<Set<ReturnType<typeof setTimeout>>>(new Set())
useEffect(() => {
const pending = timers.current
return () => {
pending.forEach(clearTimeout)
pending.clear()
}
}, [])
const schedule = useCallback((fn: () => void, ms: number) => {
const id = setTimeout(() => {
timers.current.delete(id)
fn()
}, ms)
timers.current.add(id)
return id
}, [])
const clear = useCallback(() => {
timers.current.forEach(clearTimeout)
timers.current.clear()
}, [])
return { schedule, clear }
}