// The ONE way to restyle the design system without forking it: `defineTheme()` validates a set of // token overrides and renders the custom properties that beat `theme.scss` at every specificity // level it emits. There is deliberately no SCSS `@use ... with ()` seam — two ways to change the // accent colour is the ambiguity axiom 1 exists to delete. import { insufficientContrastError, invalidBrandTokenError, runtimeMissingError } from '../errors'; import { contrastRatio } from '../tokens/contrast'; import { CONTRAST_PAIRS } from '../tokens/contrast-pairs'; import { COLOR_ROLES, type ColorRole, colorTokens, type RadiusName, SHADOW_NAMES, type ShadowName, shadowTokens, type Theme, } from '../tokens/tokens'; import { colorDeclarations, fontDeclarations, radiusDeclarations, shadowDeclarations, } from './brand-declarations'; import type { FontSlot, ThemePreset } from './preset-shape'; import { THEME_PRESET_NAMES, THEME_PRESETS, type ThemePresetName } from './presets'; export type { FontSlot } from './preset-shape'; export { FONT_SLOTS } from './preset-shape'; type Themed = Partial>>>; export interface BrandInput { /** * A shipped palette to start from (`THEME_PRESETS`). Every other slot layers onto it role by * role, so `{ preset: 'scifi', colors: { dark: { accent: … } } }` is scifi with your accent. */ preset?: ThemePresetName | undefined; /** Channel overrides per theme. Omit a theme to leave it at the shipped palette. */ colors?: Themed | undefined; /** Shadow overrides per theme, by rung (`SHADOW_NAMES`) — elevation is themed like colour. */ shadows?: Themed | undefined; radius?: Partial> | undefined; font?: Partial> | undefined; } export interface Brand { /** The stylesheet text. Ship it in a ``; } /** * `'sha256-…'` for exactly what `brandStyleTag` puts between the tags — the one `style-src` source * that admits it under the framework's locked CSP. Server/build-only, like the theme script's * hash: `defineTheme` runs at the app's entry point, and the header is written there too. */ export function brandStyleCspSource(brand: Brand): string { if (typeof Bun === 'undefined') { throw runtimeMissingError( 'Bun.CryptoHasher to hash the brand stylesheet', 'call brandStyleCspSource() during build or SSR, never in browser code', ); } return `'sha256-${new Bun.CryptoHasher('sha256').update(brand.css).digest('base64')}'`; } function rule(selector: string, declarations: readonly string[]): string { return `${selector} {\n${declarations.map((line) => ` ${line}`).join('\n')}\n}`; } function indent(block: string): string { return block .split('\n') .map((line) => ` ${line}`) .join('\n'); } /** * The dark media `:root` must answer every token the brand's own `:root` touched. That `:root` * (light) comes after `theme.scss`'s dark media `:root` at equal specificity, so a role overridden * in light only would otherwise reach an OS-dark document with no `data-theme` — scripting off, or * storage throwing in the boot script — on the dark palette it was never measured against. Such a * token is answered with the SHIPPED dark value. Called after both scopes were validated. */ function darkMediaDeclarations( prefix: string, names: readonly K[], scopes: { readonly light: Partial> | undefined; readonly dark: Partial> | undefined; }, shippedDark: Readonly>, ): string[] { const out: string[] = []; for (const name of names) { const value = scopes.dark?.[name] ?? (scopes.light?.[name] === undefined ? undefined : shippedDark[name]); if (value !== undefined) out.push(`--${prefix}-${name}: ${value};`); } return out; } /** * The palette as it will actually render: the shipped channels, with this brand's overrides on * top. Measured on the RESOLVED set and not on the overrides alone, because the pairing that * breaks is nearly always one the author only touched half of — a new `accent` against the * shipped `accent-fg` is the single most common way a brand goes unreadable. */ function assertContrast( theme: Theme, overrides: Partial> | undefined, ): void { if (overrides === undefined) return; const resolved = (role: ColorRole): string => overrides[role] ?? colorTokens[theme][role]; for (const pair of CONTRAST_PAIRS) { // Only pairings this brand can have changed. The rest are the shipped palette, which // `contrast.test.ts` already holds — re-reporting them would blame the app for our colours. if (overrides[pair.fg] === undefined && overrides[pair.bg] === undefined) continue; const fg = resolved(pair.fg); const bg = resolved(pair.bg); const ratio = contrastRatio(fg, bg); if (ratio >= pair.minimum) continue; throw insufficientContrastError(theme, pair.what, pair.fg, pair.bg, ratio, pair.minimum); } }