Initial commit
This commit is contained in:
commit
afed023152
8152 files changed
+2086461
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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' })
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 “{query}”
|
||||
</p>
|
||||
<span>Try searching for “Buttons”, “Metrics”, “Tokens”, or “Icons”.</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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 & @ # % $ /
|
||||
</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 = 'northstar'
|
||||
<br />
|
||||
latency: 14.2ms | memory: 128MB
|
||||
<br />
|
||||
0123456789 ()=>{}[]
|
||||
</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 & 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 & 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'
|
||||
@@ -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,
|
||||
}))
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -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)' },
|
||||
]
|
||||
@@ -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']
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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} />
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { CodeEditor } from '../components/CodeEditor'
|
||||
|
||||
export function EditorPage() {
|
||||
return <CodeEditor />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { IconMatrix } from '../components/IconMatrix'
|
||||
|
||||
export function IconsPage() {
|
||||
return <IconMatrix />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { MarkdownRenderer } from '../components/MarkdownRenderer'
|
||||
|
||||
export function MarkdownPage() {
|
||||
return <MarkdownRenderer />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { CodeSample } from '../components/CodeSample'
|
||||
|
||||
export function PrimitivesPage() {
|
||||
return <CodeSample />
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { TypographySection } from '../components/TypographySection'
|
||||
|
||||
export function TypographyPage() {
|
||||
return <TypographySection />
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(' ')
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in new issue
Block a user