/** * ThemeDef -- theme primitive for constraint-based adaptive rendering. * * A theme maps a set of token names to variant-keyed values, enabling * coherent multi-variant token resolution. Content-addressed via FNV-1a. * * @module */ import type { ContentAddress } from './brands.js'; import { CanonicalCbor } from './cbor.js'; import { fnv1aBytes } from './fnv.js'; import { ValidationError } from '@czap/error'; interface ThemeDef { readonly _tag: 'ThemeDef'; readonly _version: 1; readonly id: ContentAddress; readonly name: string; readonly variants: V; readonly tokens: Record>; readonly meta?: Record; } interface ThemeFactory { make(config: { readonly name: string; readonly variants: V; readonly tokens: Record>; readonly meta?: ThemeDef['meta']; }): ThemeDef; } function deterministicId( name: string, variants: V, tokens: ThemeDef['tokens'], meta: ThemeDef['meta'] | undefined, ): ContentAddress { return fnv1aBytes( CanonicalCbor.encode({ _tag: 'ThemeDef', _version: 1, name, variants, tokens, meta: meta ?? null, }), ); } /** * Resolve all tokens for a given variant, returning a map of token name to value. * * Iterates the theme's token map and extracts each token's value for the * specified variant. * * @example * ```ts * const theme = Theme.make({ * name: 'brand', * variants: ['light', 'dark'], * tokens: { bg: { light: '#fff', dark: '#111' }, fg: { light: '#000', dark: '#eee' } }, * }); * const darkTokens = Theme.tap(theme, 'dark'); * // darkTokens === { bg: '#111', fg: '#eee' } * ``` */ function _tap(theme: ThemeDef, variant: V[number] & string): Record { const result: Record = {}; for (const [tokenName, variantMap] of Object.entries(theme.tokens)) { result[tokenName] = variantMap[variant]; } return result; } /** * Theme namespace -- theme primitive for constraint-based adaptive rendering. * * Map token names to variant-keyed values, enabling coherent multi-variant * token resolution (e.g. light/dark themes). Content-addressed via FNV-1a. * * @example * ```ts * import { Theme } from '@czap/core'; * * const theme = Theme.make({ * name: 'brand', * variants: ['light', 'dark'], * tokens: { * bg: { light: '#fff', dark: '#111' }, * fg: { light: '#000', dark: '#eee' }, * }, * }); * const lightTokens = Theme.tap(theme, 'light'); * // lightTokens === { bg: '#fff', fg: '#000' } * ``` */ export const Theme: ThemeFactory & { tap: typeof _tap; } = { /** * Create a new ThemeDef from a configuration object. * * Validates that every token has a value for each declared variant. * The resulting object is frozen and content-addressed. * * @example * ```ts * const theme = Theme.make({ * name: 'ocean', * variants: ['light', 'dark'], * tokens: { primary: { light: '#0066cc', dark: '#3399ff' } }, * meta: { light: { label: 'Light', mode: 'light' }, dark: { label: 'Dark', mode: 'dark' } }, * }); * // theme._tag === 'ThemeDef' * // theme.id === 'fnv1a:...' * ``` */ make(config: { readonly name: string; readonly variants: V; readonly tokens: Record>; readonly meta?: ThemeDef['meta']; }): ThemeDef { const variantSet = new Set(config.variants as readonly string[]); for (const [tokenName, variantMap] of Object.entries(config.tokens)) { for (const variant of variantSet) { if (!(variant in variantMap)) { throw ValidationError('Theme.make', `Token "${tokenName}" is missing value for variant "${variant}"`); } } } const id = deterministicId(config.name, config.variants, config.tokens, config.meta); return Object.freeze({ _tag: 'ThemeDef' as const, _version: 1 as const, id, name: config.name, variants: config.variants, tokens: config.tokens, ...(config.meta !== undefined ? { meta: config.meta } : {}), }); }, tap: _tap, }; export declare namespace Theme { /** Structural shape of a {@link Theme} definition, parameterized by its variant tuple `V`. */ export type Shape = ThemeDef; }