embedTheme.ts (8754B)
1 export type EmbedPresetId = "default" | "light" | "dark" | "midnight" | "minimal"; 2 3 export type EmbedFontId = "system" | "sans" | "serif"; 4 5 export type EmbedThemeOverrides = { 6 preset?: EmbedPresetId; 7 accent?: string | null; 8 bg?: string | null; 9 panel?: string | null; 10 text?: string | null; 11 fgMuted?: string | null; 12 radius?: number | null; 13 font?: EmbedFontId | null; 14 }; 15 16 export type EmbedThemeTokens = { 17 bg: string; 18 bgPanel: string; 19 text: string; 20 textMuted: string; 21 accent: string; 22 accentSecondary: string; 23 border: string; 24 radius: string; 25 shadow: string; 26 fontBody: string; 27 fontDisplay: string; 28 }; 29 30 export const EMBED_PRESET_IDS: EmbedPresetId[] = [ 31 "default", 32 "light", 33 "dark", 34 "midnight", 35 "minimal", 36 ]; 37 38 export const EMBED_FONT_IDS: EmbedFontId[] = ["system", "sans", "serif"]; 39 40 const FONT_STACKS: Record<EmbedFontId, { body: string; display: string }> = { 41 system: { 42 body: 'system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif', 43 display: 'system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif', 44 }, 45 sans: { 46 body: '"Source Sans 3", system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif', 47 display: '"Source Sans 3", system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif', 48 }, 49 serif: { 50 body: '"Source Sans 3", system-ui, sans-serif', 51 display: '"Fraunces", Georgia, "Times New Roman", serif', 52 }, 53 }; 54 55 const PRESETS: Record<EmbedPresetId, EmbedThemeTokens> = { 56 default: { 57 bg: "#000f28", 58 bgPanel: "#0a1f3d", 59 text: "#f2f7ff", 60 textMuted: "rgba(242, 247, 255, 0.74)", 61 accent: "#fbc02d", 62 accentSecondary: "#40c4ff", 63 border: "rgba(64, 196, 255, 0.38)", 64 radius: "14px", 65 shadow: "0 14px 42px rgba(0, 10, 30, 0.45)", 66 fontBody: FONT_STACKS.sans.body, 67 fontDisplay: FONT_STACKS.serif.display, 68 }, 69 light: { 70 bg: "#f1f5f9", 71 bgPanel: "#ffffff", 72 text: "#0f172a", 73 textMuted: "#64748b", 74 accent: "#e64a19", 75 accentSecondary: "#3498db", 76 border: "rgba(52, 152, 219, 0.35)", 77 radius: "12px", 78 shadow: "0 8px 24px rgba(15, 23, 42, 0.12)", 79 fontBody: FONT_STACKS.sans.body, 80 fontDisplay: FONT_STACKS.serif.display, 81 }, 82 dark: { 83 bg: "#121212", 84 bgPanel: "#1e1e1e", 85 text: "#f5f5f5", 86 textMuted: "rgba(245, 245, 245, 0.68)", 87 accent: "#fbc02d", 88 accentSecondary: "#90caf9", 89 border: "rgba(255, 255, 255, 0.14)", 90 radius: "12px", 91 shadow: "0 10px 32px rgba(0, 0, 0, 0.55)", 92 fontBody: FONT_STACKS.sans.body, 93 fontDisplay: FONT_STACKS.sans.display, 94 }, 95 midnight: { 96 bg: "#000f28", 97 bgPanel: "#001b44", 98 text: "#f2f7ff", 99 textMuted: "rgba(242, 247, 255, 0.72)", 100 accent: "#40c4ff", 101 accentSecondary: "#3498db", 102 border: "rgba(64, 196, 255, 0.42)", 103 radius: "14px", 104 shadow: "0 16px 48px rgba(0, 8, 24, 0.6)", 105 fontBody: FONT_STACKS.sans.body, 106 fontDisplay: FONT_STACKS.serif.display, 107 }, 108 minimal: { 109 bg: "#0b1220", 110 bgPanel: "#111827", 111 text: "#e5e7eb", 112 textMuted: "#9ca3af", 113 accent: "#fbbf24", 114 accentSecondary: "#93c5fd", 115 border: "rgba(156, 163, 175, 0.28)", 116 radius: "8px", 117 shadow: "none", 118 fontBody: FONT_STACKS.system.body, 119 fontDisplay: FONT_STACKS.system.display, 120 }, 121 }; 122 123 export const MM_CSS_VAR_KEYS = [ 124 "--mm-bg", 125 "--mm-bg-panel", 126 "--mm-text", 127 "--mm-text-muted", 128 "--mm-accent", 129 "--mm-accent-secondary", 130 "--mm-border", 131 "--mm-radius", 132 "--mm-shadow", 133 "--mm-font-body", 134 "--mm-font-display", 135 ] as const; 136 137 const HEX3 = /^[0-9a-fA-F]{3}$/; 138 const HEX6 = /^[0-9a-fA-F]{6}$/; 139 140 /** Parse hex color from URL or postMessage (with/without #, optional URI encoding). */ 141 export function parseHexColor(raw: string | null | undefined): string | null { 142 if (raw === null || raw === undefined) return null; 143 let s = raw.trim(); 144 if (!s) return null; 145 try { 146 s = decodeURIComponent(s); 147 } catch { 148 return null; 149 } 150 if (s.startsWith("#")) s = s.slice(1); 151 if (HEX3.test(s)) { 152 s = s 153 .split("") 154 .map((c) => c + c) 155 .join(""); 156 } 157 if (!HEX6.test(s)) return null; 158 return `#${s.toUpperCase()}`; 159 } 160 161 export function parseEmbedRadius(raw: string | null | undefined): number | null { 162 if (raw === null || raw === undefined) return null; 163 const s = raw.trim(); 164 if (!s) return null; 165 const n = Number.parseInt(s, 10); 166 if (!Number.isFinite(n)) return null; 167 return Math.min(24, Math.max(0, n)); 168 } 169 170 export function parseEmbedPreset(raw: string | null | undefined): EmbedPresetId | null { 171 if (!raw) return null; 172 const id = raw.trim().toLowerCase() as EmbedPresetId; 173 return EMBED_PRESET_IDS.includes(id) ? id : null; 174 } 175 176 export function parseEmbedFont(raw: string | null | undefined): EmbedFontId | null { 177 if (!raw) return null; 178 const id = raw.trim().toLowerCase() as EmbedFontId; 179 return EMBED_FONT_IDS.includes(id) ? id : null; 180 } 181 182 export function resolveEmbedTheme(overrides: EmbedThemeOverrides = {}): EmbedThemeTokens { 183 const presetId = overrides.preset ?? "default"; 184 const base = PRESETS[presetId] ?? PRESETS.default; 185 const fontId = overrides.font ?? null; 186 const fontStack = fontId ? FONT_STACKS[fontId] : null; 187 188 return { 189 bg: parseHexColor(overrides.bg) ?? base.bg, 190 bgPanel: parseHexColor(overrides.panel) ?? base.bgPanel, 191 text: parseHexColor(overrides.text) ?? base.text, 192 textMuted: parseHexColor(overrides.fgMuted) ?? base.textMuted, 193 accent: parseHexColor(overrides.accent) ?? base.accent, 194 accentSecondary: base.accentSecondary, 195 border: base.border, 196 radius: 197 overrides.radius !== null && overrides.radius !== undefined 198 ? `${overrides.radius}px` 199 : base.radius, 200 shadow: base.shadow, 201 fontBody: fontStack?.body ?? base.fontBody, 202 fontDisplay: fontStack?.display ?? base.fontDisplay, 203 }; 204 } 205 206 export function tokensToCssVars(tokens: EmbedThemeTokens): Record<string, string> { 207 return { 208 "--mm-bg": tokens.bg, 209 "--mm-bg-panel": tokens.bgPanel, 210 "--mm-text": tokens.text, 211 "--mm-text-muted": tokens.textMuted, 212 "--mm-accent": tokens.accent, 213 "--mm-accent-secondary": tokens.accentSecondary, 214 "--mm-border": tokens.border, 215 "--mm-radius": tokens.radius, 216 "--mm-shadow": tokens.shadow, 217 "--mm-font-body": tokens.fontBody, 218 "--mm-font-display": tokens.fontDisplay, 219 }; 220 } 221 222 export function applyEmbedTheme(overrides: EmbedThemeOverrides = {}): EmbedThemeTokens { 223 const tokens = resolveEmbedTheme(overrides); 224 const root = document.documentElement; 225 for (const [key, value] of Object.entries(tokensToCssVars(tokens))) { 226 root.style.setProperty(key, value); 227 } 228 return tokens; 229 } 230 231 export function clearEmbedTheme(): void { 232 const root = document.documentElement; 233 for (const key of MM_CSS_VAR_KEYS) { 234 root.style.removeProperty(key); 235 } 236 } 237 238 export function parseThemePayload(raw: unknown): EmbedThemeOverrides | null { 239 if (!raw || typeof raw !== "object") return null; 240 const o = raw as Record<string, unknown>; 241 const overrides: EmbedThemeOverrides = {}; 242 243 if (typeof o.preset === "string") { 244 const preset = parseEmbedPreset(o.preset); 245 if (preset) overrides.preset = preset; 246 } 247 if (typeof o.font === "string") { 248 const font = parseEmbedFont(o.font); 249 if (font) overrides.font = font; 250 } 251 252 const radiusRaw = o.radius; 253 if (typeof radiusRaw === "number" && Number.isFinite(radiusRaw)) { 254 overrides.radius = Math.min(24, Math.max(0, Math.round(radiusRaw))); 255 } else if (typeof radiusRaw === "string") { 256 const radius = parseEmbedRadius(radiusRaw); 257 if (radius !== null) overrides.radius = radius; 258 } 259 260 if (typeof o.accent === "string") { 261 const accent = parseHexColor(o.accent); 262 if (accent) overrides.accent = accent; 263 } 264 if (typeof o.bg === "string") { 265 const bg = parseHexColor(o.bg); 266 if (bg) overrides.bg = bg; 267 } 268 if (typeof o.panel === "string") { 269 const panel = parseHexColor(o.panel); 270 if (panel) overrides.panel = panel; 271 } 272 if (typeof o.text === "string") { 273 const text = parseHexColor(o.text); 274 if (text) overrides.text = text; 275 } 276 const fgRaw = 277 typeof o.fgMuted === "string" 278 ? o.fgMuted 279 : typeof o.textMuted === "string" 280 ? o.textMuted 281 : null; 282 if (fgRaw) { 283 const fgMuted = parseHexColor(fgRaw); 284 if (fgMuted) overrides.fgMuted = fgMuted; 285 } 286 287 return Object.keys(overrides).length > 0 ? overrides : null; 288 } 289 290 export function mergeThemeOverrides( 291 base: EmbedThemeOverrides, 292 patch: EmbedThemeOverrides, 293 ): EmbedThemeOverrides { 294 return { ...base, ...patch }; 295 } 296 297 /** Strip # for URL query params (SoundCloud-style). */ 298 export function hexForQueryParam(hex: string | null | undefined): string | null { 299 const parsed = parseHexColor(hex); 300 return parsed ? parsed.slice(1).toLowerCase() : null; 301 }