feat(app): add AppStateProvider for shared global settings

Central context for the settings a template app shares instead of prop
drilling: theme, viewMode, sidebarCollapsed and the search palette, with
the ⌘K/Ctrl+K shortcut moved out of App.

- settings persist on user action only (an effect-written theme would
  freeze the OS preference for users who never toggled)
- theme still boots from the <html data-theme> attribute the pre-paint
  script set; viewMode mirrors to data-view-mode as the audience-preset
  hook and deliberately gates nothing yet
- Topbar: viewMode segmented control, theme toggle and search now read
  the context; useAppState throws outside the provider
- tests/appState.test.tsx: defaults, persistence, rehydration, garbage
  storage values, ⌘K shortcut
This commit is contained in:
developer committed 2026-09-15 18:34:22 +00:00
1 parent 72ff61f94d
commit d6c7464170
11 files changed
+414 -72

No files matched your search

+1
View File
@@ -35,6 +35,7 @@ Browser tooling here has no console access. Runtime errors therefore render into
## Smart Utils & AI Workflow
- **Global app state**: `src/app/` — `AppStateProvider` (mounted in `main.tsx`) owns the few settings the whole app shares: `theme`, `viewMode`, `sidebarCollapsed`, `searchOpen`, plus the ⌘K/Ctrl+K shortcut. Read them anywhere with `useAppState()` (import from `./app` or `../app`); it throws outside the provider on purpose. Add a setting to `appState.ts` (types) + `AppStateProvider.tsx` (state, DOM mirror, `STORAGE_KEYS` persistence) — never thread a new prop chain. Section-local state (active tab, selected pattern) stays in its section. Persisted settings are written **only on user action**, so an untouched app keeps following `prefers-color-scheme`. `theme` always boots from the `<html data-theme>` attribute the pre-paint script set (storage key `ns-theme` is shared with `index.html`); `viewMode` mirrors to `<html data-view-mode>` and currently gates nothing — it is the audience preset hook for per-persona views.
- **Class helper**: `src/utils/cn.ts` — zero-dependency classnames merging utility for conditional component styles.
- **Timer helper**: `src/utils/useTimeout.ts` — `schedule(fn, ms)` / `clear()`; auto-cancels pending timers on unmount. Never fire-and-forget a raw `setTimeout` in a component.
- **Smart CSS**: `src/utils.css` — ready-to-use helpers (`.dot-canvas`, `.surface-card`, `.surface-sidebar`, `.badge-*`, `.hover-lift`, `.kbd-tag`, `.skeleton-pulse`, `.progress-*`, `.ui-table-*`, `.dialog-*`, `.sheet-*`, `.alert-*`, `.ui-tabs-*`, `.switch-*`).
+1
View File
@@ -46,6 +46,7 @@ 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
components/
ui/ ← composable primitives (Overlay, Dialog, Sheet, Tabs, Alert, …)
icon-registry.ts← the one icon name → lucide component map
+15 -1
View File
@@ -158,6 +158,20 @@
color: var(--muted-foreground);
}
/* viewMode switcher — the shared .segmented look, one size down and quiet
enough to sit between the header links. */
.top-view-mode {
padding: 2px;
gap: 2px;
}
.top-view-mode button {
padding: var(--space-1) var(--space-3);
font-size: var(--text-2xs);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.top-links a {
color: var(--muted-foreground);
text-decoration: none;
@@ -1168,7 +1182,7 @@ footer {
/* ── Responsive ─────────────────────────────────────────────── */
@media (max-width: 1020px) {
.topbar { padding: 0 var(--space-5); gap: var(--space-4); }
.top-links a { display: none; }
.top-links a, .top-view-mode { display: none; }
.body-layout { grid-template-columns: 200px minmax(0, 1fr); }
.hero-section, .content-grid { padding-left: var(--space-7); padding-right: var(--space-7); }
.content-grid { gap: var(--space-7); grid-template-columns: minmax(0, 1fr) 190px; }
+7 -21
View File
@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react'
import { useState } from 'react'
import './App.css'
import { useAppState } from './app'
import { Topbar } from './components/Topbar'
import { Sidebar } from './components/Sidebar'
import { Hero } from './components/Hero'
@@ -22,24 +23,13 @@ import { buttonExamples, buttonTabs, colorTokens, navigationGroups, pageSections
const INSTALL_CMD = 'pnpm add @northstar/ui @northstar/tokens'
export default function App() {
/* App-wide settings live in AppStateProvider; section state stays local. */
const { searchOpen, closeSearch, sidebarCollapsed, toggleSidebar } = useAppState()
const [active, setActive] = useState('Buttons')
const [searchOpen, setSearchOpen] = useState(false)
const [buttonTab, setButtonTab] = useState('Preview')
const [visible, setVisible] = useState(false)
const [menu, setMenu] = useState(false)
const [pattern, setPattern] = useState<PatternId>('metrics')
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault()
setSearchOpen(prev => !prev)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [])
const handleNavigate = (item: { label: string; id?: string; patternId?: PatternId }) => {
setActive(item.label)
@@ -65,12 +55,8 @@ export default function App() {
return (
<div className="app-shell">
<Topbar onOpenSearch={() => setSearchOpen(true)} onMenuToggle={() => setMenu(!menu)} />
<SearchPalette
isOpen={searchOpen}
onClose={() => setSearchOpen(false)}
onSelect={handleSearchSelect}
/>
<Topbar onMenuToggle={() => setMenu(!menu)} />
<SearchPalette isOpen={searchOpen} onClose={closeSearch} onSelect={handleSearchSelect} />
<div className={`body-layout ${menu ? 'menu-open' : ''} ${sidebarCollapsed ? 'sidebar-collapsed' : ''}`}>
<Sidebar
@@ -78,7 +64,7 @@ export default function App() {
groups={navigationGroups}
onSelect={handleNavigate}
collapsed={sidebarCollapsed}
onToggleCollapse={() => setSidebarCollapsed(prev => !prev)}
onToggleCollapse={toggleSidebar}
/>
<main className="main-content">
+137
View File
@@ -0,0 +1,137 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
import {
AppStateContext,
VIEW_MODES,
type AppStateContextValue,
type Theme,
type ViewMode,
} from './appState'
import { STORAGE_KEYS, readFlag, readSetting, writeFlag, writeSetting } from './persist'
/** Theme is a data attribute + class on `<html>`; tokens.css defines both
palettes. Kept in sync with the pre-paint script in index.html. */
function applyTheme(theme: Theme) {
const root = document.documentElement
root.dataset.theme = theme
root.classList.toggle('dark', theme === 'dark')
root.classList.toggle('light', theme === 'light')
}
/** View mode rides on `<html>` as well, so styling and agent tooling can hook
it without a prop. */
function applyViewMode(viewMode: ViewMode) {
document.documentElement.dataset.viewMode = viewMode
}
/** Read the theme the pre-paint script already applied, so first paint stays
flash-free and honors both the saved preference and `prefers-color-scheme`
(tokens.css defaults to dark). Never writes the DOM during state init. */
function initialTheme(): Theme {
return document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark'
}
function initialViewMode(): ViewMode {
return readSetting(STORAGE_KEYS.viewMode, VIEW_MODES) ?? 'default'
}
function initialSidebarCollapsed(): boolean {
return readFlag(STORAGE_KEYS.sidebarCollapsed) ?? false
}
type AppStateProviderProps = {
children: ReactNode
}
/**
* Owns the app-wide settings: theme, view mode, sidebar collapse and the
* search palette, plus the ⌘K / Ctrl+K shortcut that opens it from anywhere.
* Mount it once, above everything that calls `useAppState()`.
*/
export function AppStateProvider({ children }: AppStateProviderProps) {
const [theme, setThemeState] = useState<Theme>(initialTheme)
const [viewMode, setViewModeState] = useState<ViewMode>(initialViewMode)
const [sidebarCollapsed, setSidebarCollapsedState] = useState<boolean>(initialSidebarCollapsed)
const [searchOpen, setSearchOpen] = useState(false)
useEffect(() => {
applyTheme(theme)
}, [theme])
useEffect(() => {
applyViewMode(viewMode)
}, [viewMode])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault()
setSearchOpen(prev => !prev)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [])
/* Settings persist on explicit user action only. An effect would also write
the OS-derived theme during the first mount, freezing the preference and
killing `prefers-color-scheme` for anyone who never toggled. */
const setTheme = useCallback((next: Theme) => {
setThemeState(next)
writeSetting(STORAGE_KEYS.theme, next)
}, [])
const toggleTheme = useCallback(() => {
setTheme(theme === 'dark' ? 'light' : 'dark')
}, [theme, setTheme])
const setViewMode = useCallback((next: ViewMode) => {
setViewModeState(next)
writeSetting(STORAGE_KEYS.viewMode, next)
}, [])
const setSidebarCollapsed = useCallback((next: boolean) => {
setSidebarCollapsedState(next)
writeFlag(STORAGE_KEYS.sidebarCollapsed, next)
}, [])
const toggleSidebar = useCallback(() => {
setSidebarCollapsed(!sidebarCollapsed)
}, [sidebarCollapsed, setSidebarCollapsed])
const openSearch = useCallback(() => setSearchOpen(true), [])
const closeSearch = useCallback(() => setSearchOpen(false), [])
const toggleSearch = useCallback(() => setSearchOpen(prev => !prev), [])
const value = useMemo<AppStateContextValue>(
() => ({
theme,
viewMode,
sidebarCollapsed,
searchOpen,
setTheme,
toggleTheme,
setViewMode,
setSidebarCollapsed,
toggleSidebar,
openSearch,
closeSearch,
toggleSearch,
}),
[
theme,
viewMode,
sidebarCollapsed,
searchOpen,
setTheme,
toggleTheme,
setViewMode,
setSidebarCollapsed,
toggleSidebar,
openSearch,
closeSearch,
toggleSearch,
],
)
return <AppStateContext.Provider value={value}>{children}</AppStateContext.Provider>
}
+55
View File
@@ -0,0 +1,55 @@
import { createContext, useContext } from 'react'
/* ── Global application state ────────────────────────────────────────────────
One provider (AppStateProvider) owns every setting the whole app shares:
theme, audience preset, shell layout and the search palette. Pages and
sections read it with `useAppState()` instead of threading props through
the tree — and a template user gets one obvious place to add their own.
Keep this list short. State belongs here only when two unrelated parts of
the app need it; section-local state (active tab, open accordion, selected
pattern) stays in the section that owns it.
────────────────────────────────────────────────────────────────────────── */
export type Theme = 'dark' | 'light'
/** Audience preset for the template. Nothing gates on it yet — the value is
carried in context and mirrored to `<html data-view-mode>` so per-persona
views can be layered on later without touching a single consumer. */
export type ViewMode = 'default' | 'compact'
/** Runtime allow-list, also used to validate whatever `localStorage` hands back. */
export const VIEW_MODES: readonly ViewMode[] = ['default', 'compact']
/** Settings the user owns — these are persisted between visits. */
export type AppSettings = {
theme: Theme
viewMode: ViewMode
sidebarCollapsed: boolean
}
/** Settings plus the transient shell flags derived from them. */
export type AppState = AppSettings & {
searchOpen: boolean
}
export type AppStateContextValue = AppState & {
setTheme: (theme: Theme) => void
toggleTheme: () => void
setViewMode: (mode: ViewMode) => void
setSidebarCollapsed: (collapsed: boolean) => void
toggleSidebar: () => void
openSearch: () => void
closeSearch: () => void
toggleSearch: () => void
}
export const AppStateContext = createContext<AppStateContextValue | null>(null)
/** Read the shared app state. Throws outside the provider: a missing provider
is a loud dev error, never a silently-uncontrolled component. */
export function useAppState(): AppStateContextValue {
const context = useContext(AppStateContext)
if (!context) throw new Error('useAppState must be used inside <AppStateProvider>')
return context
}
+13
View File
@@ -0,0 +1,13 @@
/* Public surface of the app-state layer — import from './app' or '../app'. */
export { AppStateProvider } from './AppStateProvider'
export {
AppStateContext,
VIEW_MODES,
useAppState,
type AppSettings,
type AppState,
type AppStateContextValue,
type Theme,
type ViewMode,
} from './appState'
export { STORAGE_KEYS } from './persist'
+41
View File
@@ -0,0 +1,41 @@
/* localStorage access for app settings. Private mode, disabled storage and
non-browser environments all throw on access, so every call is guarded and
degrades to "the setting just doesn't persist".
`ns-theme` is a shared contract: the pre-paint script in index.html reads
it before first paint, so changing the key means changing index.html too. */
export const STORAGE_KEYS = {
theme: 'ns-theme',
viewMode: 'ns-view-mode',
sidebarCollapsed: 'ns-sidebar-collapsed',
} as const
/** Stored string, or `undefined` when absent or outside the allowed set. */
export function readSetting<T extends string>(key: string, allowed: readonly T[]): T | undefined {
try {
const stored = localStorage.getItem(key)
if (stored === null) return undefined
return (allowed as readonly string[]).includes(stored) ? (stored as T) : undefined
} catch {
return undefined
}
}
export function writeSetting(key: string, value: string): void {
try {
localStorage.setItem(key, value)
} catch {
/* storage unavailable */
}
}
/** Stored boolean, or `undefined` when absent or unparseable. */
export function readFlag(key: string): boolean | undefined {
const stored = readSetting(key, ['true', 'false'] as const)
return stored === undefined ? undefined : stored === 'true'
}
export function writeFlag(key: string, value: boolean): void {
writeSetting(key, value ? 'true' : 'false')
}
+27 -49
View File
@@ -1,58 +1,18 @@
import { useCallback, useEffect, useState } from 'react'
import { Icon } from './Icon'
import { VIEW_MODES, useAppState, type ViewMode } from '../app'
export type Theme = 'dark' | 'light'
/**
* Read the theme the pre-paint script in index.html already applied to
* <html> — that keeps first paint flash-free and honors both the saved
* preference and the OS `prefers-color-scheme` when nothing is saved.
* This hook never touches the DOM during render/state-initialization.
*/
function getInitialTheme(): Theme {
const attr = document.documentElement.getAttribute('data-theme')
return attr === 'light' ? 'light' : 'dark'
}
/** App theme is a data attribute + class on <html>; tokens.css defines both palettes. */
function applyTheme(theme: Theme) {
const root = document.documentElement
root.dataset.theme = theme
root.classList.toggle('dark', theme === 'dark')
root.classList.toggle('light', theme === 'light')
}
function useTheme() {
const [theme, setThemeState] = useState<Theme>(getInitialTheme)
useEffect(() => {
applyTheme(theme)
}, [theme])
const toggle = useCallback(() => {
const next: Theme = theme === 'dark' ? 'light' : 'dark'
setThemeState(next)
persistTheme(next)
}, [theme])
return { theme, toggle }
}
function persistTheme(theme: Theme) {
try {
localStorage.setItem('ns-theme', theme)
} catch {
/* private mode — theme just won't persist */
}
/** `viewMode` is the template's audience preset (see src/app/appState.ts). */
const VIEW_MODE_LABELS: Record<ViewMode, string> = {
default: 'Default',
compact: 'Compact',
}
type TopbarProps = {
onOpenSearch: () => void
onMenuToggle: () => void
}
export function Topbar({ onOpenSearch, onMenuToggle }: TopbarProps) {
const { theme, toggle } = useTheme()
export function Topbar({ onMenuToggle }: TopbarProps) {
const { theme, toggleTheme, viewMode, setViewMode, openSearch } = useAppState()
return (
<header className="topbar">
@@ -67,7 +27,7 @@ export function Topbar({ onOpenSearch, onMenuToggle }: TopbarProps) {
<button
type="button"
className="top-search"
onClick={onOpenSearch}
onClick={openSearch}
aria-label="Search components, tokens, docs (Press ⌘K)"
>
<Icon name="search" size={16} />
@@ -78,10 +38,28 @@ export function Topbar({ onOpenSearch, onMenuToggle }: TopbarProps) {
<nav className="top-links">
<a href="#docs">Docs</a>
<a href="#github">GitHub ↗</a>
{/* Audience preset held by the app state. Nothing gates on it yet — it
is wired here so the value is reachable and persisted from the UI. */}
<div className="segmented top-view-mode" role="group" aria-label="View mode">
{VIEW_MODES.map(mode => (
<button
key={mode}
type="button"
className={mode === viewMode ? 'selected' : undefined}
aria-pressed={mode === viewMode}
title={`Switch to ${VIEW_MODE_LABELS[mode].toLowerCase()} view`}
onClick={() => setViewMode(mode)}
>
{VIEW_MODE_LABELS[mode]}
</button>
))}
</div>
<button
type="button"
className="icon-button"
onClick={toggle}
onClick={toggleTheme}
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} theme`}
>
<Icon name={theme === 'dark' ? 'sun' : 'moon'} size={17} />
+4 -1
View File
@@ -2,6 +2,7 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { AppStateProvider } from './app'
import { ErrorBoundary } from './dev/ErrorBoundary.tsx'
import { installDevErrorSink } from './dev/errorSink.ts'
@@ -12,7 +13,9 @@ installDevErrorSink()
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ErrorBoundary>
<App />
<AppStateProvider>
<App />
</AppStateProvider>
</ErrorBoundary>
</StrictMode>,
)
+113
View File
@@ -0,0 +1,113 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AppStateProvider, useAppState } from '../src/app'
function Probe() {
const { theme, viewMode, sidebarCollapsed, searchOpen, toggleTheme, setViewMode, toggleSidebar } = useAppState()
return (
<div>
<span data-testid="state">{`${theme}|${viewMode}|${sidebarCollapsed}|${searchOpen}`}</span>
<button onClick={toggleTheme}>theme</button>
<button onClick={() => setViewMode('compact')}>compact</button>
<button onClick={toggleSidebar}>sidebar</button>
</div>
)
}
function renderApp() {
return render(
<AppStateProvider>
<Probe />
</AppStateProvider>,
)
}
const state = () => screen.getByTestId('state').textContent
describe('AppStateProvider', () => {
beforeEach(() => {
localStorage.clear()
document.documentElement.classList.remove('dark', 'light')
document.documentElement.removeAttribute('data-theme')
document.documentElement.removeAttribute('data-view-mode')
})
afterEach(() => {
vi.restoreAllMocks()
})
it('throws when useAppState is called outside the provider', () => {
// React logs the render error too — keep the output readable.
vi.spyOn(console, 'error').mockImplementation(() => {})
expect(() => render(<Probe />)).toThrow(/AppStateProvider/)
})
it('starts from the defaults and mirrors them onto <html>', () => {
renderApp()
expect(state()).toBe('dark|default|false|false')
expect(document.documentElement.dataset.theme).toBe('dark')
expect(document.documentElement.dataset.viewMode).toBe('default')
})
it('persists settings on user action only, so prefers-color-scheme keeps working', () => {
renderApp()
// Nothing was stored by the initial mount — an effect-driven write would
// freeze the OS-derived theme for users who never toggled anything.
expect(localStorage.getItem('ns-theme')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'theme' }))
fireEvent.click(screen.getByRole('button', { name: 'compact' }))
fireEvent.click(screen.getByRole('button', { name: 'sidebar' }))
expect(localStorage.getItem('ns-theme')).toBe('light')
expect(localStorage.getItem('ns-view-mode')).toBe('compact')
expect(localStorage.getItem('ns-sidebar-collapsed')).toBe('true')
expect(document.documentElement.dataset.theme).toBe('light')
expect(document.documentElement.dataset.viewMode).toBe('compact')
expect(state()).toBe('light|compact|true|false')
})
it('rehydrates stored settings on the next mount', () => {
localStorage.setItem('ns-theme', 'light')
localStorage.setItem('ns-view-mode', 'compact')
localStorage.setItem('ns-sidebar-collapsed', 'true')
// Theme boots from the attribute index.html already painted (it resolved
// storage / prefers-color-scheme), so state can never contradict the DOM.
document.documentElement.setAttribute('data-theme', 'light')
renderApp()
expect(state()).toBe('light|compact|true|false')
})
it('ignores stored values outside the allowed set', () => {
localStorage.setItem('ns-view-mode', 'space')
localStorage.setItem('ns-sidebar-collapsed', 'yes')
renderApp()
expect(state()).toBe('dark|default|false|false')
})
it('toggles the search palette with ⌘K / Ctrl+K from anywhere', () => {
function SearchProbe() {
const { searchOpen } = useAppState()
return <span data-testid="search">{String(searchOpen)}</span>
}
render(
<AppStateProvider>
<SearchProbe />
</AppStateProvider>,
)
fireEvent.keyDown(window, { key: 'k', metaKey: true })
expect(screen.getByTestId('search')).toHaveTextContent('true')
fireEvent.keyDown(window, { key: 'k', ctrlKey: true })
expect(screen.getByTestId('search')).toHaveTextContent('false')
// A plain "k" must not hijack typing.
fireEvent.keyDown(window, { key: 'k' })
expect(screen.getByTestId('search')).toHaveTextContent('false')
})
})