Template
feat(app): route every page with wouter
Replace the single-scroll showcase and its anchor/scrollIntoView navigation with real routes: /, /patterns, /components/*, /foundations/*, /docs/*, plus a 404 fallback. - src/app/routes.tsx is the route table and the only source for paths, section headings, sidebar entries, breadcrumbs and the rail, so navigation cannot drift from the pages that exist - src/pages/* holds one component per route; App.tsx is now the shell (topbar, sidebar, breadcrumbs, Switch, right rail, scroll-to-top) - the active pattern moves into the URL (/patterns?pattern=table) and the sidebar/palette deep-link into it; SearchPalette results now carry a path - Sidebar/RightRail render <Link> with aria-current; Hero and Topbar use route links too (ButtonLink keeps the button visual) - PATTERNS + PatternId move to src/data/patterns.ts (no non-component export in a component file), pattern buttons gain aria-pressed - tests/routing.test.tsx: registry/nav invariants, per-route rendering, sidebar navigation, pattern deep links, 404
This commit is contained in:
1 parent
d6c7464170
commit
9ca7473729
29 files changed
+827
-317
No files matched your search
@@ -44,6 +44,7 @@ Browser tooling here has no console access. Runtime errors therefore render into
|
|||||||
- **StatCard**: `trend` is sentiment (badge color), `direction` is the arrow and defaults to the sign of `change` — a "good" latency drop is `trend="up"` with `change="-4.2ms"` rendering a green `↓ -4.2ms`.
|
- **StatCard**: `trend` is sentiment (badge color), `direction` is the arrow and defaults to the sign of `change` — a "good" latency drop is `trend="up"` with `change="-4.2ms"` rendering a green `↓ -4.2ms`.
|
||||||
- **Reusable Application Patterns**: `src/components/examples/` (`MetricsDashboard`, `SettingsPanel`, `ServiceTable`, `DeploymentModal`).
|
- **Reusable Application Patterns**: `src/components/examples/` (`MetricsDashboard`, `SettingsPanel`, `ServiceTable`, `DeploymentModal`).
|
||||||
- **Icon Library**: the registry in `src/components/icon-registry.ts` maps every `IconName` to a lucide component (strictly typed — typos fail `tsc`, unknown names at runtime **throw in dev** so the error sink surfaces them). 41 curated icons power the showcase; `lucide-react` ships 1,400+ for direct import.
|
- **Icon Library**: the registry in `src/components/icon-registry.ts` maps every `IconName` to a lucide component (strictly typed — typos fail `tsc`, unknown names at runtime **throw in dev** so the error sink surfaces them). 41 curated icons power the showcase; `lucide-react` ships 1,400+ for direct import.
|
||||||
|
- **Routing — wouter (3 kB)**: the route table is `src/app/routes.tsx` and it drives the `<Switch>` in `App.tsx`, the sidebar, the breadcrumbs, the right rail and the ⌘K palette, so navigation cannot drift from the pages that exist. Adding a page = a `src/pages/*.tsx` component plus one registry entry (`path`, `group`, `label`, `index`, `kicker`, `title`, `description`, `meta`, `page`). Navigation is real links (`<Link href>`), never `onClick` + `scrollIntoView`; the shell scrolls to top on every location change. State that belongs in the URL lives in the query (`/patterns?pattern=table`), read with `useSearchParams` and validated by a registry helper (`patternFromSearch`). Deep links work because Vite serves the SPA fallback.
|
||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
@@ -53,8 +54,14 @@ src/
|
|||||||
utils.css ← token-compliant smart CSS utility classes
|
utils.css ← token-compliant smart CSS utility classes
|
||||||
index.css ← fonts, tokens & utils import, base resets
|
index.css ← fonts, tokens & utils import, base resets
|
||||||
App.css ← application component styles (token-driven, plain CSS)
|
App.css ← application component styles (token-driven, plain CSS)
|
||||||
App.tsx ← page composition & showcase layout
|
App.tsx ← app shell: topbar + sidebar + breadcrumbs + <Switch> routes + right rail
|
||||||
main.tsx ← mounts App inside the ErrorBoundary, installs the dev error sink
|
main.tsx ← mounts App inside the ErrorBoundary, installs the dev error sink
|
||||||
|
app/
|
||||||
|
AppStateProvider.tsx ← global settings (theme, viewMode, sidebar, ⌘K search) + persistence
|
||||||
|
appState.ts ← context, types, useAppState()
|
||||||
|
routes.tsx ← route table: paths, headings, nav groups, URL helpers
|
||||||
|
persist.ts ← guarded localStorage read/write + storage keys
|
||||||
|
pages/ ← one component per route (OverviewPage, ButtonsPage, PatternsPage, …)
|
||||||
dev/
|
dev/
|
||||||
ErrorBoundary.tsx, errorSink.ts ← render runtime errors into #dev-errors
|
ErrorBoundary.tsx, errorSink.ts ← render runtime errors into #dev-errors
|
||||||
utils/
|
utils/
|
||||||
@@ -77,9 +84,10 @@ src/
|
|||||||
Sidebar.tsx, Section.tsx, FormField.tsx, TokenGrid.tsx,
|
Sidebar.tsx, Section.tsx, FormField.tsx, TokenGrid.tsx,
|
||||||
MarkdownRenderer.tsx, CodeEditor.tsx, Button.tsx
|
MarkdownRenderer.tsx, CodeEditor.tsx, Button.tsx
|
||||||
data/
|
data/
|
||||||
library.ts ← demo content, nav groups (patternId routing) & color tokens
|
library.ts ← demo content (button examples, color tokens)
|
||||||
|
patterns.ts ← pattern catalogue + PatternId (feeds /patterns?pattern=…)
|
||||||
tests/ ← vitest suites (jsdom): overlay, icon-registry, useTimeout,
|
tests/ ← vitest suites (jsdom): overlay, icon-registry, useTimeout,
|
||||||
check-tokens guard
|
app-state, routing, check-tokens guard
|
||||||
scripts/check-tokens.mjs ← token contract guard (part of npm run check)
|
scripts/check-tokens.mjs ← token contract guard (part of npm run check)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,8 @@ src/
|
|||||||
index.css ← fonts, token import, base resets
|
index.css ← fonts, token import, base resets
|
||||||
utils.css ← token-compliant utility classes
|
utils.css ← token-compliant utility classes
|
||||||
App.css ← application showcase styles
|
App.css ← application showcase styles
|
||||||
app/ ← AppStateProvider + useAppState(): theme, viewMode, sidebar, ⌘K search
|
app/ ← AppStateProvider + useAppState(), and the route table (wouter)
|
||||||
|
pages/ ← one page component per route
|
||||||
components/
|
components/
|
||||||
ui/ ← composable primitives (Overlay, Dialog, Sheet, Tabs, Alert, …)
|
ui/ ← composable primitives (Overlay, Dialog, Sheet, Tabs, Alert, …)
|
||||||
icon-registry.ts← the one icon name → lucide component map
|
icon-registry.ts← the one icon name → lucide component map
|
||||||
|
|||||||
Generated
+33
-1
@@ -10,7 +10,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lucide-react": "^1.45.0",
|
"lucide-react": "^1.45.0",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8"
|
"react-dom": "^19.2.8",
|
||||||
|
"wouter": "^3.11.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/jest-dom": "^7.0.1",
|
"@testing-library/jest-dom": "^7.0.1",
|
||||||
@@ -2007,6 +2008,15 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/regexparam": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/regexparam/-/regexparam-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/require-from-string": {
|
"node_modules/require-from-string": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||||
@@ -2235,6 +2245,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/use-sync-external-store": {
|
||||||
|
"version": "1.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz",
|
||||||
|
"integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "8.3.0",
|
"version": "8.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz",
|
||||||
@@ -2461,6 +2480,19 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wouter": {
|
||||||
|
"version": "3.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wouter/-/wouter-3.11.0.tgz",
|
||||||
|
"integrity": "sha512-xyMLHhytdGhIjXVRcSjeYbXd7TR1lrxVTPLGBWmmyW52ZuWVQ1jXmsgoJ4G5s4tjloJCq7h4i2eREljWUQ4+/A==",
|
||||||
|
"license": "Unlicense",
|
||||||
|
"dependencies": {
|
||||||
|
"regexparam": "^3.0.0",
|
||||||
|
"use-sync-external-store": "^1.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/xml-name-validator": {
|
"node_modules/xml-name-validator": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||||
|
|||||||
+2
-1
@@ -15,7 +15,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lucide-react": "^1.45.0",
|
"lucide-react": "^1.45.0",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8"
|
"react-dom": "^19.2.8",
|
||||||
|
"wouter": "^3.11.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/jest-dom": "^7.0.1",
|
"@testing-library/jest-dom": "^7.0.1",
|
||||||
|
|||||||
+57
@@ -334,6 +334,7 @@
|
|||||||
border: 0;
|
border: 0;
|
||||||
background: none;
|
background: none;
|
||||||
color: var(--sidebar-foreground);
|
color: var(--sidebar-foreground);
|
||||||
|
text-decoration: none;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 9px var(--space-5);
|
padding: 9px var(--space-5);
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
@@ -467,6 +468,7 @@
|
|||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
padding: var(--space-2) var(--space-4);
|
padding: var(--space-2) var(--space-4);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
|
text-decoration: none;
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2023,3 +2025,58 @@ footer {
|
|||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Routing shell ──────────────────────────────────────────── */
|
||||||
|
.brand-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Landing route: one card per group, on the hero's own rhythm. */
|
||||||
|
.overview-grid {
|
||||||
|
max-width: 1180px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 var(--space-9) var(--space-11);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
|
||||||
|
gap: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-links {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-links a {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-links a:hover {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-6);
|
||||||
|
justify-items: center;
|
||||||
|
padding: var(--space-9) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found .overview-links {
|
||||||
|
justify-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.overview-grid { padding-bottom: var(--space-9); }
|
||||||
|
}
|
||||||
+62
-195
@@ -1,56 +1,59 @@
|
|||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Route, Switch, useLocation, useSearch } from 'wouter'
|
||||||
import './App.css'
|
import './App.css'
|
||||||
import { useAppState } from './app'
|
import {
|
||||||
|
OVERVIEW_LABEL,
|
||||||
|
OVERVIEW_PATH,
|
||||||
|
activeHref,
|
||||||
|
navigationGroups,
|
||||||
|
routeForPath,
|
||||||
|
routes,
|
||||||
|
useAppState,
|
||||||
|
type AppRoute,
|
||||||
|
} from './app'
|
||||||
import { Topbar } from './components/Topbar'
|
import { Topbar } from './components/Topbar'
|
||||||
import { Sidebar } from './components/Sidebar'
|
import { Sidebar } from './components/Sidebar'
|
||||||
import { Hero } from './components/Hero'
|
|
||||||
import { ButtonMatrix } from './components/ButtonMatrix'
|
|
||||||
import { TokenGrid } from './components/TokenGrid'
|
|
||||||
import { FormField, TextInput } from './components/FormField'
|
|
||||||
import { CodeSample } from './components/CodeSample'
|
|
||||||
import { MarkdownRenderer } from './components/MarkdownRenderer'
|
|
||||||
import { CodeEditor } from './components/CodeEditor'
|
|
||||||
import { AIWorkflowSection, type PatternId } from './components/AIWorkflowSection'
|
|
||||||
import { IconMatrix } from './components/IconMatrix'
|
|
||||||
import { TypographySection } from './components/TypographySection'
|
|
||||||
import { Section } from './components/Section'
|
import { Section } from './components/Section'
|
||||||
import { RightRail } from './components/RightRail'
|
import { RightRail } from './components/RightRail'
|
||||||
import { SearchPalette, type SearchItem } from './components/SearchPalette'
|
import { SearchPalette, type SearchItem } from './components/SearchPalette'
|
||||||
import { Icon } from './components/Icon'
|
import { OverviewPage } from './pages/OverviewPage'
|
||||||
import { Avatar, Spinner, Switch, Tabs, TabsContent, TabsList, TabsTrigger } from './components/ui'
|
import { NotFoundPage } from './pages/NotFoundPage'
|
||||||
import { buttonExamples, buttonTabs, colorTokens, navigationGroups, pageSections } from './data/library'
|
|
||||||
|
|
||||||
const INSTALL_CMD = 'pnpm add @northstar/ui @northstar/tokens'
|
/** 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() {
|
export default function App() {
|
||||||
/* App-wide settings live in AppStateProvider; section state stays local. */
|
/* App-wide settings live in AppStateProvider; shell state stays local. */
|
||||||
const { searchOpen, closeSearch, sidebarCollapsed, toggleSidebar } = useAppState()
|
const { searchOpen, closeSearch, sidebarCollapsed, toggleSidebar } = useAppState()
|
||||||
const [active, setActive] = useState('Buttons')
|
const [location, navigate] = useLocation()
|
||||||
const [buttonTab, setButtonTab] = useState('Preview')
|
const search = useSearch()
|
||||||
const [visible, setVisible] = useState(false)
|
|
||||||
const [menu, setMenu] = useState(false)
|
const [menu, setMenu] = useState(false)
|
||||||
const [pattern, setPattern] = useState<PatternId>('metrics')
|
|
||||||
|
|
||||||
const handleNavigate = (item: { label: string; id?: string; patternId?: PatternId }) => {
|
const current = routeForPath(location)
|
||||||
setActive(item.label)
|
const active = current?.label ?? (location === OVERVIEW_PATH ? OVERVIEW_LABEL : 'Not found')
|
||||||
setMenu(false)
|
const currentHref = activeHref(location, search)
|
||||||
|
const groupItems = navigationGroups.find(group => group.label === current?.group)?.items ?? []
|
||||||
|
|
||||||
// Synchronize active pattern — routing keys on patternId, never on display labels.
|
/* A client-side navigation keeps the previous scroll position otherwise. */
|
||||||
if (item.patternId) setPattern(item.patternId)
|
useEffect(() => {
|
||||||
|
window.scrollTo({ top: 0 })
|
||||||
const targetId = item.id || item.label.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
|
}, [location])
|
||||||
const el = document.getElementById(targetId)
|
|
||||||
if (el) {
|
|
||||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
|
||||||
window.history.replaceState(null, '', `#${targetId}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/* One palette result → one route; patterns carry their id in the query. */
|
||||||
const handleSearchSelect = (item: SearchItem) => {
|
const handleSearchSelect = (item: SearchItem) => {
|
||||||
if (item.patternId) {
|
navigate(item.patternId ? `${item.path}?pattern=${item.patternId}` : item.path)
|
||||||
setPattern(item.patternId)
|
|
||||||
}
|
|
||||||
handleNavigate({ label: item.title, id: item.targetId })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -60,172 +63,40 @@ export default function App() {
|
|||||||
|
|
||||||
<div className={`body-layout ${menu ? 'menu-open' : ''} ${sidebarCollapsed ? 'sidebar-collapsed' : ''}`}>
|
<div className={`body-layout ${menu ? 'menu-open' : ''} ${sidebarCollapsed ? 'sidebar-collapsed' : ''}`}>
|
||||||
<Sidebar
|
<Sidebar
|
||||||
active={active}
|
|
||||||
groups={navigationGroups}
|
groups={navigationGroups}
|
||||||
onSelect={handleNavigate}
|
activeHref={currentHref}
|
||||||
|
onNavigate={() => setMenu(false)}
|
||||||
collapsed={sidebarCollapsed}
|
collapsed={sidebarCollapsed}
|
||||||
onToggleCollapse={toggleSidebar}
|
onToggleCollapse={toggleSidebar}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main className="main-content">
|
<main className="main-content">
|
||||||
<div className="breadcrumbs">
|
<div className="breadcrumbs">
|
||||||
<span>Components</span>
|
<span>{current ? current.group : 'Northstar'}</span>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
<strong>{active}</strong>
|
<strong>{active}</strong>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Hero />
|
{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>
|
||||||
|
|
||||||
<div className="content-grid">
|
{current && <RightRail title={current.group} links={groupItems} activeHref={currentHref} />}
|
||||||
<div className="content-main">
|
|
||||||
<Section
|
|
||||||
id="application-patterns"
|
|
||||||
index="01 / 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>}
|
|
||||||
>
|
|
||||||
<AIWorkflowSection activePattern={pattern} onPatternChange={setPattern} />
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section
|
|
||||||
id="buttons"
|
|
||||||
index="02 / COMPONENTS"
|
|
||||||
title="Buttons"
|
|
||||||
description="Actions with a clear hierarchy and dependable feedback."
|
|
||||||
meta={<span className="api-pill">Button · 12 variants</span>}
|
|
||||||
>
|
|
||||||
<ButtonMatrix
|
|
||||||
examples={buttonExamples}
|
|
||||||
tabs={buttonTabs}
|
|
||||||
activeTab={buttonTab}
|
|
||||||
onTabChange={setButtonTab}
|
|
||||||
/>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section
|
|
||||||
id="icons"
|
|
||||||
index="03 / 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>}
|
|
||||||
>
|
|
||||||
<IconMatrix />
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section
|
|
||||||
id="color-tokens"
|
|
||||||
index="04 / FOUNDATIONS"
|
|
||||||
title="Color tokens"
|
|
||||||
description="Functional color roles in CSS Color 4 lab() keep every surface intentional."
|
|
||||||
meta={
|
|
||||||
<button className="text-button">
|
|
||||||
View all tokens <Icon name="arrow" size={15} />
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<TokenGrid tokens={colorTokens} onCopy={copyInstall} />
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section
|
|
||||||
id="typography"
|
|
||||||
index="05 / 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>}
|
|
||||||
>
|
|
||||||
<TypographySection />
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section
|
|
||||||
id="form-controls"
|
|
||||||
index="06 / PRIMITIVES"
|
|
||||||
title="Form controls"
|
|
||||||
description="Inputs designed for focus, flow, and the occasional messy edge case."
|
|
||||||
>
|
|
||||||
<div className="forms-grid">
|
|
||||||
<FormField label="Project name" hint="Use a memorable name for your workspace.">
|
|
||||||
<TextInput defaultValue="Northstar Console" />
|
|
||||||
</FormField>
|
|
||||||
<FormField label="API key" readOnlyLabel="read only" hint="Stored securely in your environment.">
|
|
||||||
<div className="input-with-action">
|
|
||||||
<TextInput type={visible ? 'text' : 'password'} defaultValue="nsk_live_8f2a1d90" readOnly />
|
|
||||||
<button onClick={() => setVisible(!visible)} aria-label="Show API key">
|
|
||||||
<Icon name="eye" size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</FormField>
|
|
||||||
<label className="toggle-field">
|
|
||||||
<span>
|
|
||||||
<b>Enable notifications</b>
|
|
||||||
<small>Get updates about deployments and incidents.</small>
|
|
||||||
</span>
|
|
||||||
<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>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section
|
|
||||||
id="composable-by-default"
|
|
||||||
index="07 / CODE"
|
|
||||||
title="Composable by default"
|
|
||||||
description="Small APIs. Strong defaults. No surprises."
|
|
||||||
>
|
|
||||||
<CodeSample />
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section
|
|
||||||
id="markdown-rendering"
|
|
||||||
index="08 / CONTENT"
|
|
||||||
title="Markdown rendering"
|
|
||||||
description="A readable editorial layer for changelogs, docs, and product notes."
|
|
||||||
meta={<span className="api-pill">Markdown · styled</span>}
|
|
||||||
>
|
|
||||||
<MarkdownRenderer />
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section
|
|
||||||
id="codemirror-theme"
|
|
||||||
index="09 / DEVELOPER TOOLING"
|
|
||||||
title="CodeMirror theme"
|
|
||||||
description="A focused editor palette for examples, playgrounds, and embedded code."
|
|
||||||
meta={<span className="api-pill">codemirror · dark</span>}
|
|
||||||
>
|
|
||||||
<CodeEditor />
|
|
||||||
</Section>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<RightRail sections={pageSections} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<span>northstar design system</span>
|
<span>northstar design system</span>
|
||||||
@@ -236,7 +107,3 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyInstall() {
|
|
||||||
navigator.clipboard?.writeText(INSTALL_CMD)
|
|
||||||
}
|
|
||||||
@@ -11,3 +11,18 @@ export {
|
|||||||
type ViewMode,
|
type ViewMode,
|
||||||
} from './appState'
|
} from './appState'
|
||||||
export { STORAGE_KEYS } from './persist'
|
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,197 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
/* ── 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'
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 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',
|
||||||
|
items: PATTERNS.map(pattern => ({
|
||||||
|
label: pattern.label,
|
||||||
|
href: `${PATTERNS_PATH}?pattern=${pattern.id}`,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
...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
|
||||||
|
}
|
||||||
@@ -6,15 +6,7 @@ import { DeploymentModal } from './examples/DeploymentModal'
|
|||||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from './ui/Card'
|
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from './ui/Card'
|
||||||
import { Badge } from './ui/Badge'
|
import { Badge } from './ui/Badge'
|
||||||
import { Icon } from './Icon'
|
import { Icon } from './Icon'
|
||||||
|
import { PATTERNS, type PatternId } from '../data/patterns'
|
||||||
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']
|
|
||||||
|
|
||||||
type AIWorkflowSectionProps = {
|
type AIWorkflowSectionProps = {
|
||||||
activePattern?: PatternId
|
activePattern?: PatternId
|
||||||
@@ -41,7 +33,9 @@ export function AIWorkflowSection({
|
|||||||
{PATTERNS.map(p => (
|
{PATTERNS.map(p => (
|
||||||
<button
|
<button
|
||||||
key={p.id}
|
key={p.id}
|
||||||
|
type="button"
|
||||||
className={activePattern === p.id ? 'selected' : ''}
|
className={activePattern === p.id ? 'selected' : ''}
|
||||||
|
aria-pressed={activePattern === p.id}
|
||||||
onClick={() => handleSelect(p.id)}
|
onClick={() => handleSelect(p.id)}
|
||||||
>
|
>
|
||||||
<Icon name={p.icon} size={15} />
|
<Icon name={p.icon} size={15} />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
||||||
|
import { Link } from 'wouter'
|
||||||
|
|
||||||
export type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost'
|
export type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost'
|
||||||
|
|
||||||
@@ -15,3 +16,19 @@ export function Button({ variant = 'secondary', className = '', children, ...pro
|
|||||||
</button>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Button } from './Button'
|
import { ButtonLink } from './Button'
|
||||||
import { Icon } from './Icon'
|
import { Icon } from './Icon'
|
||||||
|
|
||||||
export function Hero() {
|
export function Hero() {
|
||||||
@@ -9,13 +9,12 @@ export function Hero() {
|
|||||||
<h1>Build with clarity.</h1>
|
<h1>Build with clarity.</h1>
|
||||||
<p>A considered set of accessible, composable components for building calm, capable interfaces.</p>
|
<p>A considered set of accessible, composable components for building calm, capable interfaces.</p>
|
||||||
<div className="hero-actions">
|
<div className="hero-actions">
|
||||||
<Button
|
<ButtonLink variant="primary" href="/components/buttons">
|
||||||
variant="primary"
|
|
||||||
onClick={() => document.getElementById('button-matrix')?.scrollIntoView({ behavior: 'smooth' })}
|
|
||||||
>
|
|
||||||
Explore components <Icon name="arrow" size={16} />
|
Explore components <Icon name="arrow" size={16} />
|
||||||
</Button>
|
</ButtonLink>
|
||||||
<Button variant="ghost">Read the principles</Button>
|
<ButtonLink variant="ghost" href="/docs/markdown">
|
||||||
|
Read the principles
|
||||||
|
</ButtonLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,31 @@
|
|||||||
|
import { Link } from 'wouter'
|
||||||
import { Button } from './Button'
|
import { Button } from './Button'
|
||||||
import { Icon } from './Icon'
|
import { Icon } from './Icon'
|
||||||
|
import type { NavigationItem } from './Sidebar'
|
||||||
|
|
||||||
type RightRailProps = { sections: string[] }
|
type RightRailProps = {
|
||||||
|
/** Group the current page belongs to — becomes the rail heading. */
|
||||||
|
title: string
|
||||||
|
links: NavigationItem[]
|
||||||
|
activeHref: string
|
||||||
|
}
|
||||||
|
|
||||||
export function RightRail({ sections }: RightRailProps) {
|
export function RightRail({ title, links, activeHref }: RightRailProps) {
|
||||||
return (
|
return (
|
||||||
<aside className="right-rail">
|
<aside className="right-rail">
|
||||||
<div className="rail-card">
|
<div className="rail-card">
|
||||||
<span className="eyebrow">On this page</span>
|
<span className="eyebrow">{title}</span>
|
||||||
{sections.map((label, i) => {
|
{links.map((item, i) => (
|
||||||
const id = label.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
|
<Link
|
||||||
return (
|
className={item.href === activeHref ? 'rail-link active' : 'rail-link'}
|
||||||
<a className={i === 0 ? 'rail-link active' : 'rail-link'} href={`#${id}`} key={label}>
|
href={item.href}
|
||||||
<span>0{i + 1}</span>
|
aria-current={item.href === activeHref ? 'page' : undefined}
|
||||||
{label}
|
key={item.label}
|
||||||
</a>
|
>
|
||||||
)
|
<span>0{i + 1}</span>
|
||||||
})}
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rail-card release">
|
<div className="rail-card release">
|
||||||
@@ -25,9 +34,9 @@ export function RightRail({ sections }: RightRailProps) {
|
|||||||
</div>
|
</div>
|
||||||
<strong>Designed to be extended</strong>
|
<strong>Designed to be extended</strong>
|
||||||
<p>Bring your brand, keep the rhythm. Tokens are portable and components stay unopinionated.</p>
|
<p>Bring your brand, keep the rhythm. Tokens are portable and components stay unopinionated.</p>
|
||||||
<button className="text-button">
|
<Link className="text-button" href="/docs/markdown">
|
||||||
Read the docs <Icon name="arrow" size={14} />
|
Read the docs <Icon name="arrow" size={14} />
|
||||||
</button>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rail-card">
|
<div className="rail-card">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Icon, type IconName } from './Icon'
|
|||||||
import { Badge } from './ui/Badge'
|
import { Badge } from './ui/Badge'
|
||||||
import { Kbd } from './ui/Kbd'
|
import { Kbd } from './ui/Kbd'
|
||||||
import { trapTabKey, useOverlayLifecycle } from './ui/overlayBehavior'
|
import { trapTabKey, useOverlayLifecycle } from './ui/overlayBehavior'
|
||||||
import type { PatternId } from './AIWorkflowSection'
|
import type { PatternId } from '../data/patterns'
|
||||||
|
|
||||||
export type SearchItem = {
|
export type SearchItem = {
|
||||||
id: string
|
id: string
|
||||||
@@ -11,7 +11,8 @@ export type SearchItem = {
|
|||||||
description: string
|
description: string
|
||||||
category: 'Patterns' | 'Components' | 'Foundations' | 'Documentation'
|
category: 'Patterns' | 'Components' | 'Foundations' | 'Documentation'
|
||||||
icon: IconName
|
icon: IconName
|
||||||
targetId: string
|
/** Route this result opens — `patternId` then picks the pattern on /patterns. */
|
||||||
|
path: string
|
||||||
patternId?: PatternId
|
patternId?: PatternId
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,7 +24,7 @@ const SEARCH_INDEX: SearchItem[] = [
|
|||||||
description: 'Telemetry dashboard with StatCards, time range picker, and progress gauges',
|
description: 'Telemetry dashboard with StatCards, time range picker, and progress gauges',
|
||||||
category: 'Patterns',
|
category: 'Patterns',
|
||||||
icon: 'sparkles',
|
icon: 'sparkles',
|
||||||
targetId: 'application-patterns',
|
path: '/patterns',
|
||||||
patternId: 'metrics',
|
patternId: 'metrics',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -32,7 +33,7 @@ const SEARCH_INDEX: SearchItem[] = [
|
|||||||
description: 'Preferences panel with API keys, toggle switches, and alert banners',
|
description: 'Preferences panel with API keys, toggle switches, and alert banners',
|
||||||
category: 'Patterns',
|
category: 'Patterns',
|
||||||
icon: 'settings',
|
icon: 'settings',
|
||||||
targetId: 'application-patterns',
|
path: '/patterns',
|
||||||
patternId: 'settings',
|
patternId: 'settings',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -41,7 +42,7 @@ const SEARCH_INDEX: SearchItem[] = [
|
|||||||
description: 'Real-time service health table with status filtering and search',
|
description: 'Real-time service health table with status filtering and search',
|
||||||
category: 'Patterns',
|
category: 'Patterns',
|
||||||
icon: 'layers',
|
icon: 'layers',
|
||||||
targetId: 'application-patterns',
|
path: '/patterns',
|
||||||
patternId: 'table',
|
patternId: 'table',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -50,7 +51,7 @@ const SEARCH_INDEX: SearchItem[] = [
|
|||||||
description: 'Interactive rollout modal with live progress simulation',
|
description: 'Interactive rollout modal with live progress simulation',
|
||||||
category: 'Patterns',
|
category: 'Patterns',
|
||||||
icon: 'terminal',
|
icon: 'terminal',
|
||||||
targetId: 'application-patterns',
|
path: '/patterns',
|
||||||
patternId: 'deploy',
|
patternId: 'deploy',
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -61,7 +62,7 @@ const SEARCH_INDEX: SearchItem[] = [
|
|||||||
description: 'Primary, secondary, destructive, ghost variants with resting, hover, active and focus states',
|
description: 'Primary, secondary, destructive, ghost variants with resting, hover, active and focus states',
|
||||||
category: 'Components',
|
category: 'Components',
|
||||||
icon: 'arrow-right',
|
icon: 'arrow-right',
|
||||||
targetId: 'buttons',
|
path: '/components/buttons',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'comp-icons',
|
id: 'comp-icons',
|
||||||
@@ -69,7 +70,7 @@ const SEARCH_INDEX: SearchItem[] = [
|
|||||||
description: 'Over 1,400+ tree-shaken SVG icons with search, category filtering and 1-click copy',
|
description: 'Over 1,400+ tree-shaken SVG icons with search, category filtering and 1-click copy',
|
||||||
category: 'Components',
|
category: 'Components',
|
||||||
icon: 'sparkles',
|
icon: 'sparkles',
|
||||||
targetId: 'icons',
|
path: '/components/icons',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'comp-forms',
|
id: 'comp-forms',
|
||||||
@@ -77,7 +78,7 @@ const SEARCH_INDEX: SearchItem[] = [
|
|||||||
description: 'Accessible text inputs, secret key revealers, and toggle switches',
|
description: 'Accessible text inputs, secret key revealers, and toggle switches',
|
||||||
category: 'Components',
|
category: 'Components',
|
||||||
icon: 'eye',
|
icon: 'eye',
|
||||||
targetId: 'form-controls',
|
path: '/components/forms',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'comp-code',
|
id: 'comp-code',
|
||||||
@@ -85,7 +86,7 @@ const SEARCH_INDEX: SearchItem[] = [
|
|||||||
description: 'Zero-drift Dialog, Sheet, Tabs, Table, Alert, Progress, Switch, and StatCard',
|
description: 'Zero-drift Dialog, Sheet, Tabs, Table, Alert, Progress, Switch, and StatCard',
|
||||||
category: 'Components',
|
category: 'Components',
|
||||||
icon: 'code',
|
icon: 'code',
|
||||||
targetId: 'composable-by-default',
|
path: '/components/code',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Foundations
|
// Foundations
|
||||||
@@ -95,7 +96,7 @@ const SEARCH_INDEX: SearchItem[] = [
|
|||||||
description: 'Pitch black dark theme and pure white daylight light theme tokens',
|
description: 'Pitch black dark theme and pure white daylight light theme tokens',
|
||||||
category: 'Foundations',
|
category: 'Foundations',
|
||||||
icon: 'sun',
|
icon: 'sun',
|
||||||
targetId: 'color-tokens',
|
path: '/foundations/colors',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'found-type',
|
id: 'found-type',
|
||||||
@@ -103,7 +104,7 @@ const SEARCH_INDEX: SearchItem[] = [
|
|||||||
description: 'Modular rem-based 9px to 70px scale with Manrope and DM Mono families',
|
description: 'Modular rem-based 9px to 70px scale with Manrope and DM Mono families',
|
||||||
category: 'Foundations',
|
category: 'Foundations',
|
||||||
icon: 'file',
|
icon: 'file',
|
||||||
targetId: 'typography',
|
path: '/foundations/typography',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Documentation
|
// Documentation
|
||||||
@@ -113,7 +114,7 @@ const SEARCH_INDEX: SearchItem[] = [
|
|||||||
description: 'Changelogs, release notes, and documentation typography',
|
description: 'Changelogs, release notes, and documentation typography',
|
||||||
category: 'Documentation',
|
category: 'Documentation',
|
||||||
icon: 'file',
|
icon: 'file',
|
||||||
targetId: 'markdown-rendering',
|
path: '/docs/markdown',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'doc-cm',
|
id: 'doc-cm',
|
||||||
@@ -121,7 +122,7 @@ const SEARCH_INDEX: SearchItem[] = [
|
|||||||
description: 'Syntax-highlighted code editor preview with line numbers and token palette',
|
description: 'Syntax-highlighted code editor preview with line numbers and token palette',
|
||||||
category: 'Documentation',
|
category: 'Documentation',
|
||||||
icon: 'terminal',
|
icon: 'terminal',
|
||||||
targetId: 'codemirror-theme',
|
path: '/docs/editor',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+25
-19
@@ -1,18 +1,20 @@
|
|||||||
|
import { Link } from 'wouter'
|
||||||
import { Icon } from './Icon'
|
import { Icon } from './Icon'
|
||||||
import type { PatternId } from './AIWorkflowSection'
|
|
||||||
|
|
||||||
export type NavigationItem = { label: string; count?: number; id?: string; patternId?: PatternId }
|
export type NavigationItem = { label: string; href: string; count?: number }
|
||||||
export type NavigationGroup = { label: string; items: NavigationItem[] }
|
export type NavigationGroup = { label: string; items: NavigationItem[] }
|
||||||
|
|
||||||
type SidebarProps = {
|
type SidebarProps = {
|
||||||
active: string
|
|
||||||
groups: NavigationGroup[]
|
groups: NavigationGroup[]
|
||||||
onSelect: (item: NavigationItem) => void
|
/** 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
|
collapsed?: boolean
|
||||||
onToggleCollapse?: () => void
|
onToggleCollapse?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({ active, groups, onSelect, collapsed = false, onToggleCollapse }: SidebarProps) {
|
export function Sidebar({ groups, activeHref, onNavigate, collapsed = false, onToggleCollapse }: SidebarProps) {
|
||||||
return (
|
return (
|
||||||
<aside className="sidebar">
|
<aside className="sidebar">
|
||||||
<div className="sidebar-heading">
|
<div className="sidebar-heading">
|
||||||
@@ -35,23 +37,27 @@ export function Sidebar({ active, groups, onSelect, collapsed = false, onToggleC
|
|||||||
<p>Principles and primitives for product teams.</p>
|
<p>Principles and primitives for product teams.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav aria-label="Documentation navigation">
|
<nav aria-label="Documentation navigation" onClick={onNavigate}>
|
||||||
{groups.map(group => (
|
{groups.map(group => (
|
||||||
<div className="nav-group" key={group.label}>
|
<div className="nav-group" key={group.label}>
|
||||||
<span className="nav-label">{group.label}</span>
|
<span className="nav-label">{group.label}</span>
|
||||||
{group.items.map(item => (
|
{group.items.map(item => {
|
||||||
<button
|
const isActive = item.href === activeHref
|
||||||
key={item.label}
|
return (
|
||||||
className={active === item.label ? 'nav-item active' : 'nav-item'}
|
<Link
|
||||||
onClick={() => onSelect(item)}
|
key={item.label}
|
||||||
>
|
href={item.href}
|
||||||
<span className="nav-glyph" aria-hidden="true">
|
className={isActive ? 'nav-item active' : 'nav-item'}
|
||||||
{item.count ? '◈' : '·'}
|
aria-current={isActive ? 'page' : undefined}
|
||||||
</span>
|
>
|
||||||
<span>{item.label}</span>
|
<span className="nav-glyph" aria-hidden="true">
|
||||||
{item.count !== undefined && <span className="nav-count">{item.count}</span>}
|
{item.count ? '◈' : '·'}
|
||||||
</button>
|
</span>
|
||||||
))}
|
<span>{item.label}</span>
|
||||||
|
{item.count !== undefined && <span className="nav-count">{item.count}</span>}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Link } from 'wouter'
|
||||||
import { Icon } from './Icon'
|
import { Icon } from './Icon'
|
||||||
import { VIEW_MODES, useAppState, type ViewMode } from '../app'
|
import { VIEW_MODES, useAppState, type ViewMode } from '../app'
|
||||||
|
|
||||||
@@ -17,11 +18,13 @@ export function Topbar({ onMenuToggle }: TopbarProps) {
|
|||||||
return (
|
return (
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
<div className="brand">
|
<div className="brand">
|
||||||
<div className="brand-mark">
|
<Link href="/" className="brand-link">
|
||||||
<span />
|
<div className="brand-mark">
|
||||||
</div>
|
<span />
|
||||||
<b>northstar</b>
|
</div>
|
||||||
<span className="version">v0.8.2</span>
|
<b>northstar</b>
|
||||||
|
<span className="version">v0.8.2</span>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -36,7 +39,7 @@ export function Topbar({ onMenuToggle }: TopbarProps) {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<nav className="top-links">
|
<nav className="top-links">
|
||||||
<a href="#docs">Docs</a>
|
<Link href="/docs/markdown">Docs</Link>
|
||||||
<a href="#github">GitHub ↗</a>
|
<a href="#github">GitHub ↗</a>
|
||||||
|
|
||||||
{/* Audience preset held by the app state. Nothing gates on it yet — it
|
{/* Audience preset held by the app state. Nothing gates on it yet — it
|
||||||
|
|||||||
+2
-47
@@ -1,56 +1,11 @@
|
|||||||
import type { NavigationGroup } from '../components/Sidebar'
|
|
||||||
import type { ButtonExample } from '../components/ButtonMatrix'
|
import type { ButtonExample } from '../components/ButtonMatrix'
|
||||||
import type { Token } from '../components/TokenGrid'
|
import type { Token } from '../components/TokenGrid'
|
||||||
|
|
||||||
export const navigationGroups: NavigationGroup[] = [
|
/* 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. */
|
||||||
label: 'Application Patterns',
|
|
||||||
items: [
|
|
||||||
{ label: 'Metrics Dashboard', id: 'application-patterns', patternId: 'metrics' },
|
|
||||||
{ label: 'Settings Panel', id: 'application-patterns', patternId: 'settings' },
|
|
||||||
{ label: 'Service Table', id: 'application-patterns', patternId: 'table' },
|
|
||||||
{ label: 'Deployment Flow', id: 'application-patterns', patternId: 'deploy' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Components',
|
|
||||||
items: [
|
|
||||||
{ label: 'Buttons', count: 12, id: 'buttons' },
|
|
||||||
{ label: 'Icons', count: 41, id: 'icons' },
|
|
||||||
{ label: 'Form controls', id: 'form-controls' },
|
|
||||||
{ label: 'Composable code', id: 'composable-by-default' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Foundations',
|
|
||||||
items: [
|
|
||||||
{ label: 'Color tokens', id: 'color-tokens' },
|
|
||||||
{ label: 'Typography', id: 'typography' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Documentation',
|
|
||||||
items: [
|
|
||||||
{ label: 'Markdown rendering', id: 'markdown-rendering' },
|
|
||||||
{ label: 'CodeMirror theme', id: 'codemirror-theme' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export const buttonTabs = ['Preview', 'API reference', 'Usage']
|
export const buttonTabs = ['Preview', 'API reference', 'Usage']
|
||||||
|
|
||||||
export const pageSections = [
|
|
||||||
'Application Patterns',
|
|
||||||
'Buttons',
|
|
||||||
'Icons',
|
|
||||||
'Color tokens',
|
|
||||||
'Typography',
|
|
||||||
'Form controls',
|
|
||||||
'Composable by default',
|
|
||||||
'Markdown rendering',
|
|
||||||
'CodeMirror theme',
|
|
||||||
]
|
|
||||||
|
|
||||||
export const buttonExamples: ButtonExample[] = [
|
export const buttonExamples: ButtonExample[] = [
|
||||||
{ name: 'Primary', label: 'Deploy changes', variant: 'primary' },
|
{ name: 'Primary', label: 'Deploy changes', variant: 'primary' },
|
||||||
{ name: 'Secondary', label: 'View activity', variant: 'secondary' },
|
{ name: 'Secondary', label: 'View activity', variant: 'secondary' },
|
||||||
|
|||||||
@@ -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,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,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,37 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
/** Landing route (`/`): the hero plus one card per group, so every page is
|
||||||
|
reachable without the sidebar. */
|
||||||
|
export function OverviewPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Hero />
|
||||||
|
|
||||||
|
<div className="overview-grid">
|
||||||
|
{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,143 @@
|
|||||||
|
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import App from '../src/App'
|
||||||
|
import {
|
||||||
|
AppStateProvider,
|
||||||
|
PATTERNS_PATH,
|
||||||
|
activeHref,
|
||||||
|
navigationGroups,
|
||||||
|
patternFromSearch,
|
||||||
|
routeForPath,
|
||||||
|
routes,
|
||||||
|
} from '../src/app'
|
||||||
|
|
||||||
|
function renderAt(url: string) {
|
||||||
|
window.history.pushState(null, '', url)
|
||||||
|
return render(
|
||||||
|
<AppStateProvider>
|
||||||
|
<App />
|
||||||
|
</AppStateProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The sidebar and the overview cards can link to the same page — scope the
|
||||||
|
queries that must land on one of them. */
|
||||||
|
const sidebar = () => within(document.querySelector('.sidebar') as HTMLElement)
|
||||||
|
const overview = () => within(document.querySelector('.overview-grid') as HTMLElement)
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
/* jsdom has no layout — the shell's scroll-to-top effect would hit the
|
||||||
|
"not implemented" hook on every navigation. */
|
||||||
|
vi.spyOn(window, 'scrollTo').mockImplementation(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('route table', () => {
|
||||||
|
it('keeps every sidebar entry pointed at a route that exists', () => {
|
||||||
|
const known = new Set(routes.map(route => route.path))
|
||||||
|
|
||||||
|
for (const group of navigationGroups) {
|
||||||
|
for (const item of group.items) {
|
||||||
|
const [path] = item.href.split('?')
|
||||||
|
expect(known.has(path!)).toBe(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves no route unreachable from the sidebar', () => {
|
||||||
|
const navPaths = new Set(
|
||||||
|
navigationGroups.flatMap(group => group.items.map(item => item.href.split('?')[0]!)),
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const route of routes) {
|
||||||
|
expect(navPaths.has(route.path)).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('looks routes up by path and rejects unknown ones', () => {
|
||||||
|
expect(routeForPath('/components/buttons')?.label).toBe('Buttons')
|
||||||
|
expect(routeForPath('/nope')).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('pattern URLs', () => {
|
||||||
|
it('accepts a known pattern, falls back on anything else', () => {
|
||||||
|
expect(patternFromSearch('?pattern=table')).toBe('table')
|
||||||
|
expect(patternFromSearch('?pattern=space')).toBe('metrics')
|
||||||
|
expect(patternFromSearch('')).toBe('metrics')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks the matching pattern entry active', () => {
|
||||||
|
expect(activeHref(PATTERNS_PATH, '?pattern=deploy')).toBe('/patterns?pattern=deploy')
|
||||||
|
expect(activeHref(PATTERNS_PATH, '')).toBe('/patterns?pattern=metrics')
|
||||||
|
expect(activeHref('/components/icons', '')).toBe('/components/icons')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('routing', () => {
|
||||||
|
it('renders the overview at /', () => {
|
||||||
|
renderAt('/')
|
||||||
|
|
||||||
|
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Build with clarity.')
|
||||||
|
expect(overview().getByText('Foundations')).toBeInTheDocument()
|
||||||
|
expect(overview().getByRole('link', { name: /Color tokens/ })).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
'/foundations/colors',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the page registered for a URL', () => {
|
||||||
|
for (const route of routes) {
|
||||||
|
const { unmount } = renderAt(route.path)
|
||||||
|
expect(screen.getByText(route.title, { selector: '.section-heading h2' })).toBeInTheDocument()
|
||||||
|
unmount()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks the sidebar entry for the current URL as current', () => {
|
||||||
|
renderAt('/foundations/colors')
|
||||||
|
|
||||||
|
expect(sidebar().getByRole('link', { name: 'Color tokens' })).toHaveAttribute('aria-current', 'page')
|
||||||
|
expect(sidebar().getByRole('link', { name: 'Typography' })).not.toHaveAttribute('aria-current')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('navigates on a sidebar click without a full page load', () => {
|
||||||
|
renderAt('/')
|
||||||
|
const link = sidebar().getByRole('link', { name: 'Typography' })
|
||||||
|
|
||||||
|
fireEvent.click(link)
|
||||||
|
|
||||||
|
expect(window.location.pathname).toBe('/foundations/typography')
|
||||||
|
expect(link).toHaveAttribute('aria-current', 'page')
|
||||||
|
expect(screen.getByText('Typography', { selector: '.section-heading h2' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens the pattern named in the URL', () => {
|
||||||
|
renderAt('/patterns?pattern=table')
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: /Service Monitor Table/ })).toHaveAttribute('aria-pressed', 'true')
|
||||||
|
expect(screen.getByRole('button', { name: /Metrics Dashboard/ })).toHaveAttribute('aria-pressed', 'false')
|
||||||
|
// Sidebar pattern entries share /patterns and differ only by the query.
|
||||||
|
expect(sidebar().getByRole('link', { name: 'Service Monitor Table' })).toHaveAttribute(
|
||||||
|
'aria-current',
|
||||||
|
'page',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('writes the chosen pattern into the URL', () => {
|
||||||
|
renderAt('/patterns')
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Deployment Flow/ }))
|
||||||
|
|
||||||
|
expect(window.location.search).toBe('?pattern=deploy')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to a 404 page instead of a blank screen', () => {
|
||||||
|
renderAt('/definitely/not/a/route')
|
||||||
|
|
||||||
|
expect(screen.getByText('Page not found')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in new issue
Block a user