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:
developer committed 2026-09-15 18:50:34 +00:00
1 parent d6c7464170
commit 9ca7473729
29 files changed
+811 -301

No files matched your search

+11 -3
View File
@@ -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`.
- **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.
- **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
@@ -53,8 +54,14 @@ src/
utils.css ← token-compliant smart CSS utility classes
index.css ← fonts, tokens & utils import, base resets
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
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/
ErrorBoundary.tsx, errorSink.ts ← render runtime errors into #dev-errors
utils/
@@ -77,9 +84,10 @@ src/
Sidebar.tsx, Section.tsx, FormField.tsx, TokenGrid.tsx,
MarkdownRenderer.tsx, CodeEditor.tsx, Button.tsx
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,
check-tokens guard
app-state, routing, check-tokens guard
scripts/check-tokens.mjs ← token contract guard (part of npm run check)
```
+2 -1
View File
@@ -46,7 +46,8 @@ src/
index.css ← fonts, token import, base resets
utils.css ← token-compliant utility classes
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/
ui/ ← composable primitives (Overlay, Dialog, Sheet, Tabs, Alert, …)
icon-registry.ts← the one icon name → lucide component map
+33 -1
View File
@@ -10,7 +10,8 @@
"dependencies": {
"lucide-react": "^1.45.0",
"react": "^19.2.8",
"react-dom": "^19.2.8"
"react-dom": "^19.2.8",
"wouter": "^3.11.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^7.0.1",
@@ -2007,6 +2008,15 @@
"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": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -2235,6 +2245,15 @@
"dev": true,
"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": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz",
@@ -2461,6 +2480,19 @@
"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": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+2 -1
View File
@@ -15,7 +15,8 @@
"dependencies": {
"lucide-react": "^1.45.0",
"react": "^19.2.8",
"react-dom": "^19.2.8"
"react-dom": "^19.2.8",
"wouter": "^3.11.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^7.0.1",
+57
View File
@@ -334,6 +334,7 @@
border: 0;
background: none;
color: var(--sidebar-foreground);
text-decoration: none;
text-align: left;
padding: 9px var(--space-5);
border-radius: 0;
@@ -467,6 +468,7 @@
border-radius: var(--radius-md);
padding: var(--space-2) var(--space-4);
color: var(--foreground);
text-decoration: none;
font-size: var(--text-xs);
display: inline-flex;
align-items: center;
@@ -2023,3 +2025,58 @@ footer {
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); }
}
+59 -192
View File
@@ -1,56 +1,59 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { Route, Switch, useLocation, useSearch } from 'wouter'
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 { 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 { RightRail } from './components/RightRail'
import { SearchPalette, type SearchItem } from './components/SearchPalette'
import { Icon } from './components/Icon'
import { Avatar, Spinner, Switch, Tabs, TabsContent, TabsList, TabsTrigger } from './components/ui'
import { buttonExamples, buttonTabs, colorTokens, navigationGroups, pageSections } from './data/library'
import { OverviewPage } from './pages/OverviewPage'
import { NotFoundPage } from './pages/NotFoundPage'
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() {
/* 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 [active, setActive] = useState('Buttons')
const [buttonTab, setButtonTab] = useState('Preview')
const [visible, setVisible] = useState(false)
const [location, navigate] = useLocation()
const search = useSearch()
const [menu, setMenu] = useState(false)
const [pattern, setPattern] = useState<PatternId>('metrics')
const handleNavigate = (item: { label: string; id?: string; patternId?: PatternId }) => {
setActive(item.label)
setMenu(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 ?? []
// Synchronize active pattern — routing keys on patternId, never on display labels.
if (item.patternId) setPattern(item.patternId)
const targetId = item.id || item.label.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
const el = document.getElementById(targetId)
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
window.history.replaceState(null, '', `#${targetId}`)
}
}
/* 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) => {
if (item.patternId) {
setPattern(item.patternId)
}
handleNavigate({ label: item.title, id: item.targetId })
navigate(item.patternId ? `${item.path}?pattern=${item.patternId}` : item.path)
}
return (
@@ -60,172 +63,40 @@ export default function App() {
<div className={`body-layout ${menu ? 'menu-open' : ''} ${sidebarCollapsed ? 'sidebar-collapsed' : ''}`}>
<Sidebar
active={active}
groups={navigationGroups}
onSelect={handleNavigate}
activeHref={currentHref}
onNavigate={() => setMenu(false)}
collapsed={sidebarCollapsed}
onToggleCollapse={toggleSidebar}
/>
<main className="main-content">
<div className="breadcrumbs">
<span>Components</span>
<span>{current ? current.group : 'Northstar'}</span>
<span>/</span>
<strong>{active}</strong>
</div>
<Hero />
{location === OVERVIEW_PATH ? (
<OverviewPage />
) : (
<div className="content-grid">
<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>
<Switch>
{routes.map(route => (
<Route key={route.path} path={route.path}>
<RoutePage route={route} />
</Route>
))}
<Route>
<NotFoundPage />
</Route>
</Switch>
</div>
<RightRail sections={pageSections} />
{current && <RightRail title={current.group} links={groupItems} activeHref={currentHref} />}
</div>
)}
<footer>
<span>northstar design system</span>
@@ -236,7 +107,3 @@ export default function App() {
</div>
)
}
function copyInstall() {
navigator.clipboard?.writeText(INSTALL_CMD)
}
+15
View File
@@ -11,3 +11,18 @@ export {
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'
+197
View File
@@ -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
}
+3 -9
View File
@@ -6,15 +6,7 @@ import { DeploymentModal } from './examples/DeploymentModal'
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from './ui/Card'
import { Badge } from './ui/Badge'
import { Icon } from './Icon'
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']
import { PATTERNS, type PatternId } from '../data/patterns'
type AIWorkflowSectionProps = {
activePattern?: PatternId
@@ -41,7 +33,9 @@ export function AIWorkflowSection({
{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} />
+17
View File
@@ -1,4 +1,5 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react'
import { Link } from 'wouter'
export type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost'
@@ -15,3 +16,19 @@ export function Button({ variant = 'secondary', className = '', children, ...pro
</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>
)
}
+6 -7
View File
@@ -1,4 +1,4 @@
import { Button } from './Button'
import { ButtonLink } from './Button'
import { Icon } from './Icon'
export function Hero() {
@@ -9,13 +9,12 @@ export function Hero() {
<h1>Build with clarity.</h1>
<p>A considered set of accessible, composable components for building calm, capable interfaces.</p>
<div className="hero-actions">
<Button
variant="primary"
onClick={() => document.getElementById('button-matrix')?.scrollIntoView({ behavior: 'smooth' })}
>
<ButtonLink variant="primary" href="/components/buttons">
Explore components <Icon name="arrow" size={16} />
</Button>
<Button variant="ghost">Read the principles</Button>
</ButtonLink>
<ButtonLink variant="ghost" href="/docs/markdown">
Read the principles
</ButtonLink>
</div>
</div>
+22 -13
View File
@@ -1,22 +1,31 @@
import { Link } from 'wouter'
import { Button } from './Button'
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 (
<aside className="right-rail">
<div className="rail-card">
<span className="eyebrow">On this page</span>
{sections.map((label, i) => {
const id = label.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
return (
<a className={i === 0 ? 'rail-link active' : 'rail-link'} href={`#${id}`} key={label}>
<span 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>
{label}
</a>
)
})}
{item.label}
</Link>
))}
</div>
<div className="rail-card release">
@@ -25,9 +34,9 @@ export function RightRail({ sections }: RightRailProps) {
</div>
<strong>Designed to be extended</strong>
<p>Bring your brand, keep the rhythm. Tokens are portable and components stay unopinionated.</p>
<button className="text-button">
<Link className="text-button" href="/docs/markdown">
Read the docs <Icon name="arrow" size={14} />
</button>
</Link>
</div>
<div className="rail-card">
+15 -14
View File
@@ -3,7 +3,7 @@ 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 './AIWorkflowSection'
import type { PatternId } from '../data/patterns'
export type SearchItem = {
id: string
@@ -11,7 +11,8 @@ export type SearchItem = {
description: string
category: 'Patterns' | 'Components' | 'Foundations' | 'Documentation'
icon: IconName
targetId: string
/** Route this result opens — `patternId` then picks the pattern on /patterns. */
path: string
patternId?: PatternId
}
@@ -23,7 +24,7 @@ const SEARCH_INDEX: SearchItem[] = [
description: 'Telemetry dashboard with StatCards, time range picker, and progress gauges',
category: 'Patterns',
icon: 'sparkles',
targetId: 'application-patterns',
path: '/patterns',
patternId: 'metrics',
},
{
@@ -32,7 +33,7 @@ const SEARCH_INDEX: SearchItem[] = [
description: 'Preferences panel with API keys, toggle switches, and alert banners',
category: 'Patterns',
icon: 'settings',
targetId: 'application-patterns',
path: '/patterns',
patternId: 'settings',
},
{
@@ -41,7 +42,7 @@ const SEARCH_INDEX: SearchItem[] = [
description: 'Real-time service health table with status filtering and search',
category: 'Patterns',
icon: 'layers',
targetId: 'application-patterns',
path: '/patterns',
patternId: 'table',
},
{
@@ -50,7 +51,7 @@ const SEARCH_INDEX: SearchItem[] = [
description: 'Interactive rollout modal with live progress simulation',
category: 'Patterns',
icon: 'terminal',
targetId: 'application-patterns',
path: '/patterns',
patternId: 'deploy',
},
@@ -61,7 +62,7 @@ const SEARCH_INDEX: SearchItem[] = [
description: 'Primary, secondary, destructive, ghost variants with resting, hover, active and focus states',
category: 'Components',
icon: 'arrow-right',
targetId: 'buttons',
path: '/components/buttons',
},
{
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',
category: 'Components',
icon: 'sparkles',
targetId: 'icons',
path: '/components/icons',
},
{
id: 'comp-forms',
@@ -77,7 +78,7 @@ const SEARCH_INDEX: SearchItem[] = [
description: 'Accessible text inputs, secret key revealers, and toggle switches',
category: 'Components',
icon: 'eye',
targetId: 'form-controls',
path: '/components/forms',
},
{
id: 'comp-code',
@@ -85,7 +86,7 @@ const SEARCH_INDEX: SearchItem[] = [
description: 'Zero-drift Dialog, Sheet, Tabs, Table, Alert, Progress, Switch, and StatCard',
category: 'Components',
icon: 'code',
targetId: 'composable-by-default',
path: '/components/code',
},
// Foundations
@@ -95,7 +96,7 @@ const SEARCH_INDEX: SearchItem[] = [
description: 'Pitch black dark theme and pure white daylight light theme tokens',
category: 'Foundations',
icon: 'sun',
targetId: 'color-tokens',
path: '/foundations/colors',
},
{
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',
category: 'Foundations',
icon: 'file',
targetId: 'typography',
path: '/foundations/typography',
},
// Documentation
@@ -113,7 +114,7 @@ const SEARCH_INDEX: SearchItem[] = [
description: 'Changelogs, release notes, and documentation typography',
category: 'Documentation',
icon: 'file',
targetId: 'markdown-rendering',
path: '/docs/markdown',
},
{
id: 'doc-cm',
@@ -121,7 +122,7 @@ const SEARCH_INDEX: SearchItem[] = [
description: 'Syntax-highlighted code editor preview with line numbers and token palette',
category: 'Documentation',
icon: 'terminal',
targetId: 'codemirror-theme',
path: '/docs/editor',
},
]
+18 -12
View File
@@ -1,18 +1,20 @@
import { Link } from 'wouter'
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[] }
type SidebarProps = {
active: string
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
onToggleCollapse?: () => void
}
export function Sidebar({ active, groups, onSelect, collapsed = false, onToggleCollapse }: SidebarProps) {
export function Sidebar({ groups, activeHref, onNavigate, collapsed = false, onToggleCollapse }: SidebarProps) {
return (
<aside className="sidebar">
<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>
</div>
<nav aria-label="Documentation navigation">
<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 => (
<button
{group.items.map(item => {
const isActive = item.href === activeHref
return (
<Link
key={item.label}
className={active === item.label ? 'nav-item active' : 'nav-item'}
onClick={() => onSelect(item)}
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>}
</button>
))}
</Link>
)
})}
</div>
))}
</nav>
+4 -1
View File
@@ -1,3 +1,4 @@
import { Link } from 'wouter'
import { Icon } from './Icon'
import { VIEW_MODES, useAppState, type ViewMode } from '../app'
@@ -17,11 +18,13 @@ export function Topbar({ onMenuToggle }: TopbarProps) {
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
@@ -36,7 +39,7 @@ export function Topbar({ onMenuToggle }: TopbarProps) {
</button>
<nav className="top-links">
<a href="#docs">Docs</a>
<Link href="/docs/markdown">Docs</Link>
<a href="#github">GitHub ↗</a>
{/* Audience preset held by the app state. Nothing gates on it yet — it
+2 -47
View File
@@ -1,56 +1,11 @@
import type { NavigationGroup } from '../components/Sidebar'
import type { ButtonExample } from '../components/ButtonMatrix'
import type { Token } from '../components/TokenGrid'
export const navigationGroups: NavigationGroup[] = [
{
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' },
],
},
]
/* 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 pageSections = [
'Application Patterns',
'Buttons',
'Icons',
'Color tokens',
'Typography',
'Form controls',
'Composable by default',
'Markdown rendering',
'CodeMirror theme',
]
export const buttonExamples: ButtonExample[] = [
{ name: 'Primary', label: 'Deploy changes', variant: 'primary' },
{ name: 'Secondary', label: 'View activity', variant: 'secondary' },
+12
View File
@@ -0,0 +1,12 @@
/* Pattern catalogue for the showcase. A pattern id is a URL parameter
(`/patterns?pattern=table`), so this list is also the source for the
sidebar's pattern entries — see src/app/routes.tsx. */
export const PATTERNS = [
{ id: 'metrics', label: 'Metrics Dashboard', icon: 'sparkles' },
{ id: 'settings', label: 'Settings & Safeguards', icon: 'settings' },
{ id: 'table', label: 'Service Monitor Table', icon: 'layers' },
{ id: 'deploy', label: 'Deployment Flow', icon: 'terminal' },
] as const
export type PatternId = (typeof PATTERNS)[number]['id']
+11
View File
@@ -0,0 +1,11 @@
import { useState } from 'react'
import { ButtonMatrix } from '../components/ButtonMatrix'
import { buttonExamples, buttonTabs } from '../data/library'
export function ButtonsPage() {
const [activeTab, setActiveTab] = useState('Preview')
return (
<ButtonMatrix examples={buttonExamples} tabs={buttonTabs} activeTab={activeTab} onTabChange={setActiveTab} />
)
}
+12
View File
@@ -0,0 +1,12 @@
import { TokenGrid } from '../components/TokenGrid'
import { colorTokens } from '../data/library'
const INSTALL_CMD = 'pnpm add @northstar/ui @northstar/tokens'
function copyInstall() {
navigator.clipboard?.writeText(INSTALL_CMD)
}
export function ColorsPage() {
return <TokenGrid tokens={colorTokens} onCopy={copyInstall} />
}
+5
View File
@@ -0,0 +1,5 @@
import { CodeEditor } from '../components/CodeEditor'
export function EditorPage() {
return <CodeEditor />
}
+60
View File
@@ -0,0 +1,60 @@
import { useState } from 'react'
import { FormField, TextInput } from '../components/FormField'
import { Icon } from '../components/Icon'
import { Avatar, Spinner, Switch, Tabs, TabsContent, TabsList, TabsTrigger } from '../components/ui'
export function FormsPage() {
const [visible, setVisible] = useState(false)
return (
<div className="forms-grid">
<FormField label="Project name" hint="Use a memorable name for your workspace.">
<TextInput defaultValue="Northstar Console" />
</FormField>
<FormField label="API key" readOnlyLabel="read only" hint="Stored securely in your environment.">
<div className="input-with-action">
<TextInput type={visible ? 'text' : 'password'} defaultValue="nsk_live_8f2a1d90" readOnly />
<button onClick={() => setVisible(!visible)} aria-label="Show API key">
<Icon name="eye" size={16} />
</button>
</div>
</FormField>
<label className="toggle-field">
<span>
<b>Enable notifications</b>
<small>Get updates about deployments and incidents.</small>
</span>
<Switch defaultChecked aria-label="Toggle notifications" />
</label>
<div className="toggle-field">
<span>
<b>Primitives in action</b>
<small>Spinner, Avatar and Switch — token-driven, theme-aware components/ui primitives.</small>
</span>
<span className="row-md">
<Avatar name="Mats Tornberg" size="sm" status="online" />
<Avatar name="Grace Hopper" size="sm" />
<Spinner size="sm" label="Loading session" />
</span>
</div>
<div className="toggle-field">
<span>
<b>Tabs primitive</b>
<small>Roving tabindex, arrow-key navigation, aria-selected wiring — try the keyboard.</small>
</span>
<Tabs defaultValue="preview" className="stack-xs">
<TabsList>
<TabsTrigger value="preview">Preview</TabsTrigger>
<TabsTrigger value="code">Code</TabsTrigger>
</TabsList>
<TabsContent value="preview">
<small>Token-driven preview pane.</small>
</TabsContent>
<TabsContent value="code">
<small>import {'{ Tabs }'} from components/ui</small>
</TabsContent>
</Tabs>
</div>
</div>
)
}
+5
View File
@@ -0,0 +1,5 @@
import { IconMatrix } from '../components/IconMatrix'
export function IconsPage() {
return <IconMatrix />
}
+5
View File
@@ -0,0 +1,5 @@
import { MarkdownRenderer } from '../components/MarkdownRenderer'
export function MarkdownPage() {
return <MarkdownRenderer />
}
+24
View File
@@ -0,0 +1,24 @@
import { Link } from 'wouter'
import { EmptyState, EmptyStateDescription, EmptyStateTitle } from '../components/ui'
import { routes } from '../app/routes'
/** Catch-all for unknown URLs — a real 404, no silent redirect. */
export function NotFoundPage() {
return (
<div className="not-found">
<EmptyState>
<EmptyStateTitle>Page not found</EmptyStateTitle>
<EmptyStateDescription>
That route does not exist in this design system. Pick a page from the list instead.
</EmptyStateDescription>
<ul className="overview-links">
{routes.map(route => (
<li key={route.path}>
<Link href={route.path}>{route.label}</Link>
</li>
))}
</ul>
</EmptyState>
</div>
)
}
+37
View File
@@ -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>
</>
)
}
+24
View File
@@ -0,0 +1,24 @@
import { useSearchParams } from 'wouter'
import { AIWorkflowSection } from '../components/AIWorkflowSection'
import { PATTERNS, type PatternId } from '../data/patterns'
/** The selected pattern lives in the URL (`/patterns?pattern=table`), so a
pattern is deep-linkable and the ⌘K palette can jump straight to one. */
export function PatternsPage() {
const [searchParams, setSearchParams] = useSearchParams()
const requested = searchParams.get('pattern')
const pattern: PatternId = PATTERNS.some(p => p.id === requested) ? (requested as PatternId) : 'metrics'
const setPattern = (next: PatternId) => {
setSearchParams(
prev => {
const params = new URLSearchParams(prev)
params.set('pattern', next)
return params
},
{ replace: true },
)
}
return <AIWorkflowSection activePattern={pattern} onPatternChange={setPattern} />
}
+5
View File
@@ -0,0 +1,5 @@
import { CodeSample } from '../components/CodeSample'
export function PrimitivesPage() {
return <CodeSample />
}
+5
View File
@@ -0,0 +1,5 @@
import { TypographySection } from '../components/TypographySection'
export function TypographyPage() {
return <TypographySection />
}
+143
View File
@@ -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()
})
})