From d6c7464170b079d670c735a4252de0919214ce53 Mon Sep 17 00:00:00 2001 From: developer Date: Tue, 15 Sep 2026 18:34:22 +0000 Subject: [PATCH] feat(app): add AppStateProvider for shared global settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- AGENTS.md | 1 + README.md | 1 + src/App.css | 16 +++- src/App.tsx | 28 ++----- src/app/AppStateProvider.tsx | 137 +++++++++++++++++++++++++++++++++++ src/app/appState.ts | 55 ++++++++++++++ src/app/index.ts | 13 ++++ src/app/persist.ts | 41 +++++++++++ src/components/Topbar.tsx | 76 +++++++------------ src/main.tsx | 5 +- tests/appState.test.tsx | 113 +++++++++++++++++++++++++++++ 11 files changed, 414 insertions(+), 72 deletions(-) create mode 100644 src/app/AppStateProvider.tsx create mode 100644 src/app/appState.ts create mode 100644 src/app/index.ts create mode 100644 src/app/persist.ts create mode 100644 tests/appState.test.tsx diff --git a/AGENTS.md b/AGENTS.md index c734192..f63ef94 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 `` attribute the pre-paint script set (storage key `ns-theme` is shared with `index.html`); `viewMode` mirrors to `` 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-*`). diff --git a/README.md b/README.md index 6b8176f..e39e01a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/App.css b/src/App.css index 61cdc8f..d94e0ca 100644 --- a/src/App.css +++ b/src/App.css @@ -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; } diff --git a/src/App.tsx b/src/App.tsx index 6043a5b..7aa4ae1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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('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 (
- setSearchOpen(true)} onMenuToggle={() => setMenu(!menu)} /> - setSearchOpen(false)} - onSelect={handleSearchSelect} - /> + setMenu(!menu)} /> +
setSidebarCollapsed(prev => !prev)} + onToggleCollapse={toggleSidebar} />
diff --git a/src/app/AppStateProvider.tsx b/src/app/AppStateProvider.tsx new file mode 100644 index 0000000..4089509 --- /dev/null +++ b/src/app/AppStateProvider.tsx @@ -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 ``; 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 `` 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(initialTheme) + const [viewMode, setViewModeState] = useState(initialViewMode) + const [sidebarCollapsed, setSidebarCollapsedState] = useState(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( + () => ({ + 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 {children} +} diff --git a/src/app/appState.ts b/src/app/appState.ts new file mode 100644 index 0000000..4971408 --- /dev/null +++ b/src/app/appState.ts @@ -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 `` 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(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 ') + return context +} diff --git a/src/app/index.ts b/src/app/index.ts new file mode 100644 index 0000000..e37b522 --- /dev/null +++ b/src/app/index.ts @@ -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' diff --git a/src/app/persist.ts b/src/app/persist.ts new file mode 100644 index 0000000..653a7b9 --- /dev/null +++ b/src/app/persist.ts @@ -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(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') +} diff --git a/src/components/Topbar.tsx b/src/components/Topbar.tsx index e6ee587..4a6f679 100644 --- a/src/components/Topbar.tsx +++ b/src/components/Topbar.tsx @@ -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 - * — 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 ; 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(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 = { + 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 (
@@ -67,7 +27,7 @@ export function Topbar({ onOpenSearch, onMenuToggle }: TopbarProps) { + ))} +
+ + + +
+ ) +} + +function renderApp() { + return render( + + + , + ) +} + +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()).toThrow(/AppStateProvider/) + }) + + it('starts from the defaults and mirrors them onto ', () => { + 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 {String(searchOpen)} + } + render( + + + , + ) + + 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') + }) +})