Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions src/brand/BrandProvider.tsx
Original file line number Diff line number Diff line change
@@ -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<ResolvedBrand>(resolveBrand());

Expand All @@ -13,10 +14,11 @@ export interface BrandProviderProps {
/**
* Makes a resolved brand (palette + logo) available to descendants. `<Surface>`
* 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 <BrandContext.Provider value={resolved}>{children}</BrandContext.Provider>;
}

Expand Down
1 change: 1 addition & 0 deletions src/brand/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { resolveBrand, brandVibeOverrides } from './resolveBrand';
export { BrandProvider, useBrand } from './BrandProvider';
export type { BrandProviderProps } from './BrandProvider';
export { useColorScheme, useResolvedBrand } from './useColorScheme';
21 changes: 21 additions & 0 deletions src/brand/resolveBrand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
14 changes: 10 additions & 4 deletions src/brand/resolveBrand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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),
};
}
127 changes: 127 additions & 0 deletions src/brand/useColorScheme.test.ts
Original file line number Diff line number Diff line change
@@ -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<Listener>();
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');
});
});
51 changes: 51 additions & 0 deletions src/brand/useColorScheme.ts
Original file line number Diff line number Diff line change
@@ -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 };
9 changes: 8 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
24 changes: 22 additions & 2 deletions src/types/brand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,28 @@ export interface Brand {
logo?: BrandLogo;
}

/** What consumers pass to `<Surface>`/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 `<BrandProvider>`) 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 `<Surface>`/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<BrandLogo> {}
Expand Down
Loading