Files
wursor/packages/platform/src/theming.ts
T
2026-04-26 09:09:55 +01:00

90 lines
3.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { z } from 'zod';
// ── White-label Theming ───────────────────────────────────────────────────────
// Enterprise workspaces can override the Originmain UI with their own brand.
// The theming pipeline:
// 1. WorkspaceBrand (stored in workspace.metadata_jsonb) → BrandTokens
// 2. BrandTokens → Fluent 2 BrandVariants (via createLightTheme / createDarkTheme)
// 3. BrandVariants → FluentProvider theme prop
//
// Note: Actual Fluent 2 createLightTheme / createDarkTheme calls happen in
// packages/app/src/lib/workspace-theme.ts to avoid a @fluentui dep here.
export const BrandTokensSchema = z.object({
/** Primary brand colour in 6-digit hex */
primaryColor: z.string().regex(/^#[0-9a-f]{6}$/i),
/** Optional secondary accent */
secondaryColor: z.string().regex(/^#[0-9a-f]{6}$/i).optional(),
/** Brand logo URL (SVG or PNG, ≤512KB) */
logoUrl: z.string().url().optional(),
/** Tab title prefix: e.g. "Acme Design" → "Acme Design — Originmain" */
productName: z.string().max(40).optional(),
/** Favicon URL (ICO, PNG, or SVG) */
faviconUrl: z.string().url().optional(),
});
export type BrandTokens = z.infer<typeof BrandTokensSchema>;
/**
* Derives the 10-shade Fluent 2 BrandVariants record from a single hex colour.
* Shades are generated by interpolating HSL lightness across the standard scale.
* The returned object is passed directly to createLightTheme / createDarkTheme.
*/
export function brandVariantsFromHex(hex: string): Record<`shade${10 | 20 | 30 | 40 | 50 | 60 | 70 | 80 | 90 | 100 | 110 | 120 | 130 | 140 | 150 | 160}`, string> {
const [r, g, b] = hexToRgb(hex);
const [h, s] = rgbToHsl(r, g, b);
// Fluent 2 uses shades 10160 in 10-step increments (16 shades).
// shade10 = lightest, shade160 = darkest.
const shadeNumbers = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160] as const;
const result = {} as Record<string, string>;
for (const shade of shadeNumbers) {
// Map shade index to lightness: shade10 ≈ 95%, shade160 ≈ 10%
const lightness = 95 - ((shade - 10) / 150) * 85;
result[`shade${shade}`] = hslToHex(h, s, lightness);
}
return result as ReturnType<typeof brandVariantsFromHex>;
}
// ── HSL ↔ RGB ↔ Hex helpers ──────────────────────────────────────────────────
function hexToRgb(hex: string): [number, number, number] {
const clean = hex.replace('#', '');
const r = parseInt(clean.slice(0, 2), 16);
const g = parseInt(clean.slice(2, 4), 16);
const b = parseInt(clean.slice(4, 6), 16);
if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) {
throw new Error(`Invalid hex color: "${hex}"`);
}
return [r, g, b];
}
function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
const rn = r / 255, gn = g / 255, bn = b / 255;
const max = Math.max(rn, gn, bn), min = Math.min(rn, gn, bn);
const l = (max + min) / 2;
if (max === min) return [0, 0, l * 100];
const d = max - min;
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
let h = 0;
if (max === rn) h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6;
else if (max === gn) h = ((bn - rn) / d + 2) / 6;
else h = ((rn - gn) / d + 4) / 6;
return [h * 360, s * 100, l * 100];
}
function hslToHex(h: number, s: number, l: number): string {
const sn = s / 100, ln = l / 100;
const a = sn * Math.min(ln, 1 - ln);
const f = (n: number): string => {
const k = (n + h / 30) % 12;
const color = ln - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
return Math.round(255 * color).toString(16).padStart(2, '0');
};
return `#${f(0)}${f(8)}${f(4)}`;
}