diff --git a/src/brand/BrandProvider.tsx b/src/brand/BrandProvider.tsx index 25cce84..87347c6 100644 --- a/src/brand/BrandProvider.tsx +++ b/src/brand/BrandProvider.tsx @@ -1,7 +1,8 @@ -import { createContext, useContext, useMemo } from 'react'; +import { createContext, useContext } from 'react'; import type { ReactNode } from 'react'; import type { BrandConfig, ResolvedBrand } from '../types/brand'; import { resolveBrand } from './resolveBrand'; +import { useResolvedBrand } from './useColorScheme'; const BrandContext = createContext(resolveBrand()); @@ -13,10 +14,11 @@ export interface BrandProviderProps { /** * Makes a resolved brand (palette + logo) available to descendants. `` * wraps its subtree in this so custom compositions can read the brand without - * threading it manually. Mirrors `VibeProvider`. + * threading it manually. Mirrors `VibeProvider`. Themed brands follow the OS + * colour scheme via `useResolvedBrand`. */ export function BrandProvider({ brand, children }: BrandProviderProps) { - const resolved = useMemo(() => resolveBrand(brand), [brand]); + const resolved = useResolvedBrand(brand); return {children}; } diff --git a/src/brand/index.ts b/src/brand/index.ts index cb1cd56..351bcb6 100644 --- a/src/brand/index.ts +++ b/src/brand/index.ts @@ -1,3 +1,4 @@ export { resolveBrand, brandVibeOverrides } from './resolveBrand'; export { BrandProvider, useBrand } from './BrandProvider'; export type { BrandProviderProps } from './BrandProvider'; +export { useColorScheme, useResolvedBrand } from './useColorScheme'; diff --git a/src/brand/resolveBrand.test.ts b/src/brand/resolveBrand.test.ts index 96bb0ae..c7bc559 100644 --- a/src/brand/resolveBrand.test.ts +++ b/src/brand/resolveBrand.test.ts @@ -56,4 +56,25 @@ describe('resolveBrand', () => { it('respects an explicit logo height', () => { expect(resolveBrand({ logo: { src: 'l.png', width: 120, height: 40 } }).logo?.height).toBe(40); }); + + it('picks the light side of a ThemedBrand by default', () => { + const resolved = resolveBrand({ + light: { primary: '#f00', page: '#fff' }, + dark: { primary: '#0ff', page: '#000' }, + }); + expect(resolved.vibeOverrides.fill).toBe('#f00'); + expect(resolved.vibeOverrides.background).toBe('#fff'); + }); + + it('picks the dark side of a ThemedBrand when scheme=dark', () => { + const resolved = resolveBrand( + { + light: { primary: '#f00', page: '#fff' }, + dark: { primary: '#0ff', page: '#000' }, + }, + 'dark', + ); + expect(resolved.vibeOverrides.fill).toBe('#0ff'); + expect(resolved.vibeOverrides.background).toBe('#000'); + }); }); diff --git a/src/brand/resolveBrand.ts b/src/brand/resolveBrand.ts index 33c2e72..f6276bc 100644 --- a/src/brand/resolveBrand.ts +++ b/src/brand/resolveBrand.ts @@ -7,6 +7,7 @@ import type { ResolvedBrand, ResolvedBrandLogo, } from '../types/brand'; +import { isThemedBrand } from '../types/brand'; const DEFAULT_LOGO_POSITION = 'bottom-right' as const; const DEFAULT_LOGO_WIDTH = 64; @@ -46,13 +47,18 @@ export function brandVibeOverrides(brand: Brand): BrandVibeOverrides { * brand contributes. The single boundary between loose brand config and the * strict internal shape — mirrors `resolveVibe`. */ -export function resolveBrand(brand?: BrandConfig): ResolvedBrand { +export function resolveBrand( + brand?: BrandConfig, + /** Forces a side of a `ThemedBrand`. Defaults to `'light'` (SSR-safe). */ + scheme: 'light' | 'dark' = 'light', +): ResolvedBrand { if (brand === undefined) { return { palette: DEFAULT_PALETTE, vibeOverrides: {} }; } + const plain: Brand = isThemedBrand(brand) ? brand[scheme] : brand; return { - palette: brand.palette && brand.palette.length > 0 ? brand.palette : DEFAULT_PALETTE, - logo: brand.logo ? resolveLogo(brand.logo) : undefined, - vibeOverrides: brandVibeOverrides(brand), + palette: plain.palette && plain.palette.length > 0 ? plain.palette : DEFAULT_PALETTE, + logo: plain.logo ? resolveLogo(plain.logo) : undefined, + vibeOverrides: brandVibeOverrides(plain), }; } diff --git a/src/brand/useColorScheme.test.ts b/src/brand/useColorScheme.test.ts new file mode 100644 index 0000000..bc40bf0 --- /dev/null +++ b/src/brand/useColorScheme.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it, beforeEach, afterEach } from 'vitest'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; + +// Project convention is no-jsdom (see src/interactive/InteractiveChart.test.ts). +// We test the pure helper directly, then exercise `useResolvedBrand` via SSR +// rendering — which runs the hook's initial state (its SSR-safe path) without +// firing effects. The hydrated/`matchMedia`-driven update is browser-only and +// not unit-tested here. + +type Listener = (ev: { matches: boolean }) => void; + +function installFakeWindow(initialMatches: boolean) { + const listeners = new Set(); + const mq = { + get matches() { + return initialMatches; + }, + addEventListener: (_evt: string, cb: Listener) => { + listeners.add(cb); + }, + removeEventListener: (_evt: string, cb: Listener) => { + listeners.delete(cb); + }, + }; + (globalThis as { window?: unknown }).window = { matchMedia: () => mq }; +} + +let originalWindow: unknown; + +beforeEach(() => { + originalWindow = (globalThis as { window?: unknown }).window; +}); + +afterEach(() => { + (globalThis as { window?: unknown }).window = originalWindow; +}); + +describe('readSystemScheme', () => { + it('falls back to the default scheme when window is undefined (SSR)', async () => { + (globalThis as { window?: unknown }).window = undefined; + const { __readSystemSchemeForTest } = await import('./useColorScheme'); + expect(__readSystemSchemeForTest('light')).toBe('light'); + expect(__readSystemSchemeForTest('dark')).toBe('dark'); + }); + + it('returns dark when matchMedia reports a dark preference', async () => { + installFakeWindow(true); + const { __readSystemSchemeForTest } = await import('./useColorScheme'); + expect(__readSystemSchemeForTest('light')).toBe('dark'); + }); + + it('returns light when matchMedia reports no dark preference', async () => { + installFakeWindow(false); + const { __readSystemSchemeForTest } = await import('./useColorScheme'); + expect(__readSystemSchemeForTest('dark')).toBe('light'); + }); +}); + +describe('useResolvedBrand (via SSR render)', () => { + it('returns the light side by default during SSR for an auto-mode themed brand', async () => { + installFakeWindow(true); // even with dark OS pref, SSR returns 'light' + const { useResolvedBrand } = await import('./useColorScheme'); + + let captured = ''; + function Probe() { + const resolved = useResolvedBrand({ + light: { primary: '#f00' }, + dark: { primary: '#0ff' }, + }); + captured = resolved.vibeOverrides.fill ?? ''; + return null; + } + renderToStaticMarkup(createElement(Probe)); + expect(captured).toBe('#f00'); + }); + + it('honours mode=light even when the OS prefers dark', async () => { + installFakeWindow(true); + const { useResolvedBrand } = await import('./useColorScheme'); + + let captured = ''; + function Probe() { + const resolved = useResolvedBrand({ + mode: 'light', + light: { primary: '#f00' }, + dark: { primary: '#0ff' }, + }); + captured = resolved.vibeOverrides.fill ?? ''; + return null; + } + renderToStaticMarkup(createElement(Probe)); + expect(captured).toBe('#f00'); + }); + + it('honours mode=dark even when the OS prefers light', async () => { + installFakeWindow(false); + const { useResolvedBrand } = await import('./useColorScheme'); + + let captured = ''; + function Probe() { + const resolved = useResolvedBrand({ + mode: 'dark', + light: { primary: '#f00' }, + dark: { primary: '#0ff' }, + }); + captured = resolved.vibeOverrides.fill ?? ''; + return null; + } + renderToStaticMarkup(createElement(Probe)); + expect(captured).toBe('#0ff'); + }); + + it('passes through plain brands unchanged', async () => { + installFakeWindow(true); + const { useResolvedBrand } = await import('./useColorScheme'); + + let captured = ''; + function Probe() { + const resolved = useResolvedBrand({ primary: '#abc' }); + captured = resolved.vibeOverrides.fill ?? ''; + return null; + } + renderToStaticMarkup(createElement(Probe)); + expect(captured).toBe('#abc'); + }); +}); diff --git a/src/brand/useColorScheme.ts b/src/brand/useColorScheme.ts new file mode 100644 index 0000000..2764388 --- /dev/null +++ b/src/brand/useColorScheme.ts @@ -0,0 +1,51 @@ +import { useEffect, useState } from 'react'; +import type { BrandConfig, BrandMode, ResolvedBrand } from '../types/brand'; +import { isThemedBrand } from '../types/brand'; +import { resolveBrand } from './resolveBrand'; + +const MEDIA_QUERY = '(prefers-color-scheme: dark)'; + +function readSystemScheme(defaultScheme: 'light' | 'dark'): 'light' | 'dark' { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { + return defaultScheme; + } + return window.matchMedia(MEDIA_QUERY).matches ? 'dark' : 'light'; +} + +/** + * Subscribes to the OS colour-scheme preference. SSR-safe: returns + * `defaultScheme` (default `'light'`) until a browser is available, then + * resolves to the live `prefers-color-scheme` value. + */ +export function useColorScheme(defaultScheme: 'light' | 'dark' = 'light'): 'light' | 'dark' { + const [scheme, setScheme] = useState<'light' | 'dark'>(defaultScheme); + + useEffect(() => { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return; + const mq = window.matchMedia(MEDIA_QUERY); + const sync = () => setScheme(mq.matches ? 'dark' : 'light'); + sync(); + mq.addEventListener('change', sync); + return () => mq.removeEventListener('change', sync); + }, []); + + return scheme; +} + +/** + * Resolve a brand, honouring a `ThemedBrand`'s `mode`: + * `'auto'` (default) — follows `useColorScheme()` + * `'light'` / `'dark'` — pinned + * + * For plain `Brand`s this is just `resolveBrand(brand)`. Charts that want + * to follow the OS theme should swap `resolveBrand(brand)` for this hook. + */ +export function useResolvedBrand(brand?: BrandConfig): ResolvedBrand { + const systemScheme = useColorScheme(); + if (!isThemedBrand(brand)) return resolveBrand(brand); + const mode: BrandMode = brand.mode ?? 'auto'; + const scheme: 'light' | 'dark' = mode === 'auto' ? systemScheme : mode; + return resolveBrand(brand, scheme); +} + +export { readSystemScheme as __readSystemSchemeForTest }; diff --git a/src/index.ts b/src/index.ts index e3d457c..02fd22e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,7 +14,14 @@ export { export type { VibeProviderProps } from './vibe'; // Brand engine -export { resolveBrand, brandVibeOverrides, BrandProvider, useBrand } from './brand'; +export { + resolveBrand, + brandVibeOverrides, + BrandProvider, + useBrand, + useColorScheme, + useResolvedBrand, +} from './brand'; export type { BrandProviderProps } from './brand'; // Calculation layer (D3, DOM-free) diff --git a/src/types/brand.ts b/src/types/brand.ts index ebf496a..3842d46 100644 --- a/src/types/brand.ts +++ b/src/types/brand.ts @@ -43,8 +43,28 @@ export interface Brand { logo?: BrandLogo; } -/** What consumers pass to ``/charts. Currently identical to `Brand`. */ -export type BrandConfig = Brand; +/** Which colour scheme to pick from a `ThemedBrand`. `'auto'` follows the OS. */ +export type BrandMode = 'light' | 'dark' | 'auto'; + +/** + * A pair of brands tagged for light and dark colour schemes. Pass one of + * these to a chart's `brand` prop (or to ``) to follow the + * user's OS theme; explicit `mode` forces a side. + */ +export interface ThemedBrand { + /** `'auto'` (default) follows `prefers-color-scheme`; otherwise pinned. */ + mode?: BrandMode; + light: Brand; + dark: Brand; +} + +/** What consumers pass to ``/charts: a plain brand or a themed pair. */ +export type BrandConfig = Brand | ThemedBrand; + +/** Type guard for the themed shape. */ +export function isThemedBrand(brand?: BrandConfig): brand is ThemedBrand { + return !!brand && typeof brand === 'object' && 'light' in brand && 'dark' in brand; +} /** A logo with every layout field filled in. */ export interface ResolvedBrandLogo extends Required {}