返回 DeepSeek-Reasonix
themePack.ts
根目录 / desktop / frontend / src / lib / themePack.ts
1 // themePack.ts applies controlled Theme Pack V1/V2 overlays on top of the existing
2 // auto/light/dark + baseStyle system. Packs cannot execute CSS/JS or load remote
3 // resources — only semantic tokens, recipe enums, and local background images.
4
5 import { applyTheme, getTheme, getThemeStyle, isThemeStyle, type Theme, type ThemeStyle } from "./theme";
6 import { codeReadabilityDecls, deriveCodeReadabilityPalette } from "./codeReadability";
7
8 export type ThemePackTokens = {
9 light?: Record<string, string>;
10 dark?: Record<string, string>;
11 };
12
13 export type ThemePackRecipes = {
14 density?: "compact" | "comfortable" | string;
15 corners?: "square" | "soft" | "round" | string;
16 };
17
18 export type ThemePackBackground = {
19 image?: string;
20 focusX: number;
21 focusY: number;
22 safeArea?: "left" | "right" | "center" | string;
23 homeOpacity: number;
24 taskOpacity: number;
25 overlayStrength: number;
26 paneOpacity: number;
27 };
28
29 export type ThemePackSceneBackground = {
30 image?: string;
31 focusX: number;
32 focusY: number;
33 safeArea?: "left" | "right" | "center" | string;
34 opacity: number;
35 overlayStrength: number;
36 paneOpacity: number;
37 };
38
39 export type ThemeContrastWarning = {
40 mode: string;
41 pair: string;
42 ratio: number;
43 minimum: number;
44 suggest?: string;
45 };
46
47 export type ThemePackKind = "base" | "official" | "user" | "plugin";
48
49 export type ThemePackView = {
50 id: string;
51 name: string;
52 author?: string;
53 description?: string;
54 license?: string;
55 baseStyle: string;
56 builtin: boolean;
57 /** New in the official-themes release; old backends/mocks may omit it. */
58 kind?: ThemePackKind;
59 active: boolean;
60 hasBackground: boolean;
61 backgroundUrl?: string;
62 taskBackgroundUrl?: string;
63 previewUrl?: string;
64 nameKey?: string;
65 descriptionKey?: string;
66 /** Set when kind === "plugin": the contributing plugin's name (read-only badge). */
67 pluginName?: string;
68 /** Non-fatal plugin theme discovery issues (invalid files skipped). */
69 warnings?: string[];
70 tokens: ThemePackTokens;
71 recipes: ThemePackRecipes;
72 background?: ThemePackBackground | null;
73 taskBackground?: ThemePackSceneBackground | null;
74 contrastWarnings?: ThemeContrastWarning[];
75 };
76
77 /**
78 * Resolve the pack group. Older mocks/responses without `kind` fall back to
79 * the historical builtin flag: builtin ? "base" : "user".
80 */
81 export function themePackKind(pack: Pick<ThemePackView, "kind" | "builtin">): ThemePackKind {
82 if (pack.kind === "base" || pack.kind === "official" || pack.kind === "user" || pack.kind === "plugin") return pack.kind;
83 return pack.builtin ? "base" : "user";
84 }
85
86 export type ThemeActiveView = {
87 activeThemeId?: string;
88 pack?: ThemePackView | null;
89 };
90
91 export type ThemeSaveInput = {
92 id: string;
93 name: string;
94 author?: string;
95 description?: string;
96 license?: string;
97 baseStyle: string;
98 tokens: ThemePackTokens;
99 recipes: ThemePackRecipes;
100 background?: ThemePackBackground | null;
101 taskBackground?: ThemePackSceneBackground | null;
102 backgroundDataUrl?: string;
103 taskBackgroundDataUrl?: string;
104 clearBackground?: boolean;
105 clearTaskBackground?: boolean;
106 replace?: boolean;
107 activate?: boolean;
108 };
109
110 export type ThemeImportResult = {
111 pack: ThemePackView;
112 replaced: boolean;
113 needsReplace?: boolean;
114 pendingId?: string;
115 };
116
117 export type ThemeScene = "home" | "task";
118
119 const PACK_STYLE_ID = "reasonix-theme-pack-overlay";
120 const TOKEN_KEYS = [
121 "bg",
122 "bgSoft",
123 "bgElev",
124 "panel",
125 "sidebar",
126 "chat",
127 "workspace",
128 "workspaceFiles",
129 "border",
130 "borderSoft",
131 "fg",
132 "fgDim",
133 "fgFaint",
134 "accent",
135 "accentFg",
136 "ok",
137 "warn",
138 "err",
139 ] as const;
140
141 const TOKEN_TO_CSS: Record<string, string[]> = {
142 bg: ["--bg", "--stage"],
143 bgSoft: ["--bg-soft", "--surface-3"],
144 bgElev: ["--bg-elev"],
145 panel: ["--panel", "--bg-elev", "--surface"],
146 sidebar: ["--sidebar-bg"],
147 chat: ["--chat-bg"],
148 workspace: ["--workspace-preview-bg"],
149 workspaceFiles: ["--workspace-files-bg"],
150 border: ["--border"],
151 borderSoft: ["--border-soft"],
152 fg: ["--fg", "--text"],
153 fgDim: ["--fg-dim", "--text-2"],
154 fgFaint: ["--fg-faint", "--text-3"],
155 accent: ["--accent", "--accent-strong", "--control-primary-bg"],
156 accentFg: ["--accent-fg", "--control-primary-fg"],
157 ok: ["--ok"],
158 warn: ["--warn"],
159 err: ["--err"],
160 };
161
162 let activePack: ThemePackView | null = null;
163 let activeScene: ThemeScene = "home";
164 /** User config appearance under the pack (restored on clear / restore-default). */
165 let baseAppearance: { theme: Theme; style: ThemeStyle } | null = null;
166 let previewSnapshot: {
167 pack: ThemePackView | null;
168 theme: Theme;
169 style: ThemeStyle;
170 baseAppearance: { theme: Theme; style: ThemeStyle } | null;
171 } | null = null;
172
173 // Browser development uses Vite-bundled copies of the same official images
174 // that the desktop service serves through /__reasonix_theme_asset/. Only
175 // exact, internally
176 // registered URLs may cross the background URL safety boundary.
177 const trustedBundledThemeBackgroundURLs = new Set<string>();
178
179 export function registerTrustedThemeBackgroundURLs(urls: readonly string[]): void {
180 if (typeof window === "undefined" || !window.location) return;
181 for (const raw of urls) {
182 try {
183 const parsed = new URL(raw, window.location.href);
184 if (parsed.origin !== window.location.origin) continue;
185 const path = decodeURIComponent(parsed.pathname);
186 const viteDevOfficial = /\/desktop\/themes\/official\/official-[a-z0-9-]+\/background\.webp$/.test(path);
187 const viteBuiltOfficial = /^\/assets\/background-[a-zA-Z0-9_-]+\.webp$/.test(path);
188 if (viteDevOfficial || viteBuiltOfficial) trustedBundledThemeBackgroundURLs.add(parsed.href);
189 } catch {
190 // Ignore malformed candidates; they remain outside the allow-list.
191 }
192 }
193 }
194
195 export function getActiveThemePack(): ThemePackView | null {
196 return activePack;
197 }
198
199 export function getThemeScene(): ThemeScene {
200 return activeScene;
201 }
202
203 export function getBaseAppearance(): { theme: Theme; style: ThemeStyle } | null {
204 return baseAppearance ? { ...baseAppearance } : null;
205 }
206
207 export function isThemeTokenKey(key: string): boolean {
208 return (TOKEN_KEYS as readonly string[]).includes(key);
209 }
210
211 export function themeTokenKeys(): readonly string[] {
212 return TOKEN_KEYS;
213 }
214
215 /**
216 * Remember the user's config appearance before a pack overrides baseStyle.
217 * Call from settings load when preferences are known, or let applyThemePack
218 * snapshot automatically on first non-preview apply.
219 */
220 export function setBaseAppearance(theme: Theme, style: ThemeStyle): void {
221 baseAppearance = { theme, style };
222 }
223
224 /**
225 * Apply a configured appearance without replacing an active pack's live base
226 * style. The configured values remain the restore target when the pack is
227 * cleared, while the pack continues to own the effective visual direction.
228 */
229 export function applyConfiguredBaseAppearance(theme: Theme, style: ThemeStyle): void {
230 setBaseAppearance(theme, style);
231 applyTheme(theme, style, { persist: false });
232 if (activePack) applyThemePack(activePack);
233 }
234
235 /** Apply or clear the active theme pack overlay. Pass null only clears overlay attrs — prefer clearThemePack(). */
236 export function applyThemePack(pack: ThemePackView | null | undefined, options?: { preview?: boolean }): void {
237 if (typeof document === "undefined") return;
238 const next = pack ?? null;
239 if (!options?.preview) {
240 activePack = next;
241 }
242
243 const root = document.documentElement;
244 if (!next) {
245 root.removeAttribute("data-theme-pack");
246 removePackStyleElement();
247 clearBackgroundCSSVars(root);
248 return;
249 }
250
251 // Snapshot config appearance once before the first pack overrides baseStyle.
252 if (!options?.preview && !baseAppearance) {
253 baseAppearance = { theme: getTheme(), style: getThemeStyle() };
254 }
255
256 root.setAttribute("data-theme-pack", next.id);
257
258 // Base style from the pack (inherits remaining tokens from the direction sheets).
259 const style = isThemeStyle(next.baseStyle) ? next.baseStyle : getThemeStyle();
260 applyTheme(getTheme(), style, { persist: false });
261
262 const css = buildPackOverlayCSS(next);
263 ensurePackStyleElement().textContent = css;
264 applyBackgroundCSSVars(root, next);
265 applyThemeScene(activeScene);
266 }
267
268 /**
269 * Clear the active pack and restore the user's base appearance (theme mode + style).
270 * Fixes: enabling Aurora then "restore default" must return data-theme-style to Graphite
271 * (or whatever was configured), not leave the pack's baseStyle behind.
272 */
273 export function clearThemePack(): void {
274 previewSnapshot = null;
275 activePack = null;
276 if (typeof document !== "undefined") {
277 const root = document.documentElement;
278 root.removeAttribute("data-theme-pack");
279 removePackStyleElement();
280 clearBackgroundCSSVars(root);
281 }
282 if (baseAppearance) {
283 applyTheme(baseAppearance.theme, baseAppearance.style, { persist: false });
284 }
285 // Keep baseAppearance so subsequent applyThemePack can re-snapshot if needed;
286 // after full clear the restored style IS the base.
287 }
288
289 /** Scene is home (full background) vs task (dimmed + overlay). Does not touch chat state. */
290 export function applyThemeScene(scene: ThemeScene): void {
291 activeScene = scene === "task" ? "task" : "home";
292 if (typeof document === "undefined") return;
293 const app = document.querySelector(".app") ?? document.documentElement;
294 app.setAttribute("data-theme-scene", activeScene);
295 // Also mirror on root for CSS that targets :root.
296 document.documentElement.setAttribute("data-theme-scene", activeScene);
297 }
298
299 export function beginThemePreview(pack: ThemePackView): void {
300 if (!previewSnapshot) {
301 previewSnapshot = {
302 pack: activePack,
303 theme: getTheme(),
304 style: getThemeStyle(),
305 baseAppearance: baseAppearance ? { ...baseAppearance } : null,
306 };
307 }
308 applyThemePack(pack, { preview: true });
309 }
310
311 export function cancelThemePreview(): void {
312 if (!previewSnapshot) return;
313 const snap = previewSnapshot;
314 previewSnapshot = null;
315 baseAppearance = snap.baseAppearance;
316 applyTheme(snap.theme, snap.style, { persist: false });
317 if (snap.pack) {
318 applyThemePack(snap.pack);
319 } else {
320 // No active pack under the preview — strip overlay without changing restored style again.
321 activePack = null;
322 if (typeof document !== "undefined") {
323 const root = document.documentElement;
324 root.removeAttribute("data-theme-pack");
325 removePackStyleElement();
326 clearBackgroundCSSVars(root);
327 }
328 }
329 }
330
331 export function commitThemePreview(pack: ThemePackView | null): void {
332 previewSnapshot = null;
333 if (pack) {
334 applyThemePack(pack);
335 } else {
336 clearThemePack();
337 }
338 }
339
340 /**
341 * Clear the preview snapshot without restoring the original theme.
342 * Use this after persistent activation succeeds and before editor cleanup so
343 * cancelThemePreview() cannot overwrite the newly applied theme.
344 */
345 export function clearPreviewSnapshotOnly(): void {
346 previewSnapshot = null;
347 }
348
349 function ensurePackStyleElement(): HTMLStyleElement {
350 let el = document.getElementById(PACK_STYLE_ID) as HTMLStyleElement | null;
351 if (!el) {
352 el = document.createElement("style");
353 el.id = PACK_STYLE_ID;
354 // Append last so pack root overrides win over the base stylesheets.
355 // Element-scoped Creation code palettes intentionally remain local.
356 document.head.appendChild(el);
357 } else if (el.parentElement === document.head) {
358 document.head.appendChild(el);
359 }
360 return el;
361 }
362
363 function removePackStyleElement(): void {
364 const el = document.getElementById(PACK_STYLE_ID);
365 if (!el) return;
366 if (typeof el.remove === "function") el.remove();
367 else el.parentElement?.removeChild(el);
368 }
369
370 function buildPackOverlayCSS(pack: ThemePackView): string {
371 const lightTokens = pack.tokens?.light || {};
372 const darkTokens = pack.tokens?.dark || {};
373 const light = joinDecls(
374 tokensToDecls(lightTokens),
375 codeReadabilityDecls(deriveCodeReadabilityPalette("light", pack.baseStyle, lightTokens)),
376 );
377 const dark = joinDecls(
378 tokensToDecls(darkTokens),
379 codeReadabilityDecls(deriveCodeReadabilityPalette("dark", pack.baseStyle, darkTokens)),
380 );
381 const recipes = recipeDecls(pack.recipes);
382 const chunks: string[] = [];
383
384 // Recipe vars apply in both modes.
385 if (recipes) {
386 chunks.push(`:root[data-theme-pack="${cssEscape(pack.id)}"]{${recipes}}`);
387 }
388
389 // Dark tokens (default / forced dark / auto-dark).
390 if (dark) {
391 chunks.push(`:root[data-theme-pack="${cssEscape(pack.id)}"]{${dark}}`);
392 chunks.push(`:root[data-theme="dark"][data-theme-pack="${cssEscape(pack.id)}"]{${dark}}`);
393 }
394 // Light tokens.
395 if (light) {
396 chunks.push(`:root[data-theme="light"][data-theme-pack="${cssEscape(pack.id)}"]{${light}}`);
397 chunks.push(`@media (prefers-color-scheme: light){:root:not([data-theme])[data-theme-pack="${cssEscape(pack.id)}"]{${light}}}`);
398 }
399
400 // Soft accent derived when accent is set.
401 const accentDark = pack.tokens?.dark?.accent;
402 const accentLight = pack.tokens?.light?.accent;
403 if (accentDark && isSafeHex(accentDark)) {
404 chunks.push(
405 `:root[data-theme-pack="${cssEscape(pack.id)}"]{--accent-soft: color-mix(in srgb, ${accentDark} 16%, transparent);}`,
406 );
407 }
408 if (accentLight && isSafeHex(accentLight)) {
409 chunks.push(
410 `:root[data-theme="light"][data-theme-pack="${cssEscape(pack.id)}"]{--accent-soft: color-mix(in srgb, ${accentLight} 12%, transparent);}`,
411 );
412 }
413
414 return chunks.join("\n");
415 }
416
417 function joinDecls(...groups: string[]): string {
418 return groups.filter(Boolean).join(";");
419 }
420
421 function tokensToDecls(tokens?: Record<string, string>): string {
422 if (!tokens) return "";
423 const parts: string[] = [];
424 for (const [key, value] of Object.entries(tokens)) {
425 if (!isThemeTokenKey(key) || !isSafeHex(value)) continue;
426 const cssVars = TOKEN_TO_CSS[key] ?? [];
427 for (const css of cssVars) {
428 parts.push(`${css}:${value}`);
429 }
430 }
431 return parts.join(";");
432 }
433
434 function recipeDecls(recipes?: ThemePackRecipes): string {
435 if (!recipes) return "";
436 const parts: string[] = [];
437 const density = recipes.density === "compact" ? "compact" : "comfortable";
438 const corners = recipes.corners === "square" || recipes.corners === "round" ? recipes.corners : "soft";
439 if (density === "compact") {
440 parts.push("--theme-density-pad:6px", "--theme-density-gap:6px", "--theme-row-h:28px");
441 } else {
442 parts.push("--theme-density-pad:10px", "--theme-density-gap:10px", "--theme-row-h:34px");
443 }
444 if (corners === "square") {
445 parts.push("--r-s:0px", "--r:2px", "--r-l:4px", "--radius:2px");
446 } else if (corners === "round") {
447 parts.push("--r-s:8px", "--r:14px", "--r-l:18px", "--radius:14px");
448 } else {
449 parts.push("--r-s:5px", "--r:8px", "--r-l:11px", "--radius:8px");
450 }
451 return parts.join(";");
452 }
453
454 function applyBackgroundCSSVars(root: HTMLElement, pack: ThemePackView): void {
455 const home = pack.background;
456 const homeUrl = pack.backgroundUrl || "";
457 const task = pack.taskBackground;
458 const taskUrl = pack.taskBackgroundUrl || "";
459 const safeHomeUrl = Boolean(home && homeUrl && isSafeBackgroundURL(homeUrl));
460 const safeTaskUrl = Boolean(task && taskUrl && isSafeBackgroundURL(taskUrl));
461 if ((!safeHomeUrl && !safeTaskUrl) || !pack.hasBackground) {
462 clearBackgroundCSSVars(root);
463 return;
464 }
465
466 if (safeHomeUrl && home) {
467 root.style.setProperty("--theme-bg-home-image", `url("${cssUrlEscape(homeUrl)}")`);
468 root.style.setProperty("--theme-bg-home-focus-x", `${clamp01(home.focusX) * 100}%`);
469 root.style.setProperty("--theme-bg-home-focus-y", `${clamp01(home.focusY) * 100}%`);
470 root.style.setProperty("--theme-bg-home-opacity", String(clamp01(home.homeOpacity ?? 1)));
471 // Pane transparency: how much the background shows through the UI panes.
472 const homePane = clamp01(home.paneOpacity ?? 0.50);
473 root.style.setProperty("--theme-pane-alpha", String(homePane));
474 // Pre-computed percentages for CSS (avoids calc() compat issues).
475 // Clamp to 100% to prevent color-mix from receiving values > 100%.
476 root.style.setProperty("--theme-pane-shell-pct", `${Math.min((homePane + 0.08) * 100, 100)}%`);
477 root.style.setProperty("--theme-pane-card-pct", `${Math.min((homePane + 0.26) * 100, 100)}%`);
478 root.style.setProperty("--theme-pane-session-hover-pct", `${Math.min((homePane + 0.26) * 100, 100)}%`);
479 root.style.setProperty("--theme-pane-child-pct", `${Math.min((homePane + 0.30) * 100, 100)}%`);
480 root.style.setProperty("--theme-pane-interact-pct", `${Math.min((homePane + 0.40) * 100, 100)}%`);
481 root.style.setProperty("--theme-pane-overlay-pct", `${Math.min((homePane + 0.40) * 100, 100)}%`);
482 // Legacy aliases keep V1 tests and third-party diagnostics stable.
483 root.style.setProperty("--theme-bg-image", `url("${cssUrlEscape(homeUrl)}")`);
484 root.style.setProperty("--theme-bg-focus-x", `${clamp01(home.focusX) * 100}%`);
485 root.style.setProperty("--theme-bg-focus-y", `${clamp01(home.focusY) * 100}%`);
486 } else {
487 root.style.setProperty("--theme-bg-home-image", "none");
488 }
489
490 const taskSource = safeTaskUrl && task ? task : home;
491 const effectiveTaskUrl = safeTaskUrl ? taskUrl : safeHomeUrl ? homeUrl : "";
492 if (taskSource && effectiveTaskUrl) {
493 root.style.setProperty("--theme-bg-task-image", `url("${cssUrlEscape(effectiveTaskUrl)}")`);
494 root.style.setProperty("--theme-bg-task-focus-x", `${clamp01(taskSource.focusX) * 100}%`);
495 root.style.setProperty("--theme-bg-task-focus-y", `${clamp01(taskSource.focusY) * 100}%`);
496 } else {
497 root.style.setProperty("--theme-bg-task-image", "none");
498 }
499 const taskOpacity = task ? task.opacity : home?.taskOpacity;
500 const taskOverlay = task ? task.overlayStrength : home?.overlayStrength;
501 root.style.setProperty("--theme-bg-task-opacity", String(clamp01(taskOpacity ?? 0.28)));
502 root.style.setProperty("--theme-bg-task-overlay", String(clamp01(taskOverlay ?? 0.62)));
503 root.style.setProperty("--theme-bg-overlay", String(clamp01(taskOverlay ?? 0.62)));
504 // Task scene pane transparency (defaults to home paneOpacity if not set on task scene).
505 const taskPane = clamp01(task?.paneOpacity ?? home?.paneOpacity ?? 0.68);
506 root.style.setProperty("--theme-pane-task-alpha", String(taskPane));
507 root.style.setProperty("--theme-pane-task-shell-pct", `${Math.min((taskPane + 0.08) * 100, 100)}%`);
508 root.style.setProperty("--theme-pane-task-card-pct", `${Math.min((taskPane + 0.14) * 100, 100)}%`);
509 root.style.setProperty("--theme-pane-task-session-hover-pct", `${Math.min((taskPane + 0.26) * 100, 100)}%`);
510 root.style.setProperty("--theme-pane-task-child-pct", `${Math.min((taskPane + 0.30) * 100, 100)}%`);
511 root.style.setProperty("--theme-pane-task-interact-pct", `${Math.min((taskPane + 0.40) * 100, 100)}%`);
512 root.style.setProperty("--theme-pane-task-overlay-pct", `${Math.min((taskPane + 0.40) * 100, 100)}%`);
513 const safe = taskSource?.safeArea === "left" || taskSource?.safeArea === "right" ? taskSource.safeArea : "center";
514 root.setAttribute("data-theme-safe-area", safe);
515 root.setAttribute("data-theme-has-bg", "true");
516 }
517
518 function clearBackgroundCSSVars(root: HTMLElement): void {
519 root.style.removeProperty("--theme-bg-home-image");
520 root.style.removeProperty("--theme-bg-home-focus-x");
521 root.style.removeProperty("--theme-bg-home-focus-y");
522 root.style.removeProperty("--theme-bg-task-image");
523 root.style.removeProperty("--theme-bg-task-focus-x");
524 root.style.removeProperty("--theme-bg-task-focus-y");
525 root.style.removeProperty("--theme-bg-task-overlay");
526 root.style.removeProperty("--theme-bg-image");
527 root.style.removeProperty("--theme-bg-focus-x");
528 root.style.removeProperty("--theme-bg-focus-y");
529 root.style.removeProperty("--theme-bg-home-opacity");
530 root.style.removeProperty("--theme-bg-task-opacity");
531 root.style.removeProperty("--theme-bg-overlay");
532 root.style.removeProperty("--theme-pane-alpha");
533 root.style.removeProperty("--theme-pane-task-alpha");
534 root.style.removeProperty("--theme-pane-shell-pct");
535 root.style.removeProperty("--theme-pane-task-shell-pct");
536 root.style.removeProperty("--theme-pane-card-pct");
537 root.style.removeProperty("--theme-pane-task-card-pct");
538 root.style.removeProperty("--theme-pane-session-hover-pct");
539 root.style.removeProperty("--theme-pane-child-pct");
540 root.style.removeProperty("--theme-pane-interact-pct");
541 root.style.removeProperty("--theme-pane-overlay-pct");
542 root.style.removeProperty("--theme-pane-task-session-hover-pct");
543 root.style.removeProperty("--theme-pane-task-child-pct");
544 root.style.removeProperty("--theme-pane-task-interact-pct");
545 root.style.removeProperty("--theme-pane-task-overlay-pct");
546 root.removeAttribute("data-theme-safe-area");
547 root.removeAttribute("data-theme-has-bg");
548 }
549
550 export function isSafeHex(value: string): boolean {
551 return /^#([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(value.trim());
552 }
553
554 export function isSafeBackgroundURL(url: string): boolean {
555 const u = url.trim();
556 if (!u) return false;
557 if (u.startsWith("/__reasonix_theme_asset/")) return true;
558 if (u.startsWith("data:image/png;base64,")) return true;
559 if (u.startsWith("data:image/jpeg;base64,")) return true;
560 if (u.startsWith("data:image/jpg;base64,")) return true;
561 if (u.startsWith("data:image/webp;base64,")) return true;
562 if (u.startsWith("blob:")) return true;
563 if (trustedBundledThemeBackgroundURLs.has(u)) return true;
564 return false;
565 }
566
567 function clamp01(v: number): number {
568 if (!Number.isFinite(v)) return 0.5;
569 return Math.min(1, Math.max(0, v));
570 }
571
572 function cssEscape(value: string): string {
573 // Keep ":" so plugin theme ids (plugin:<plugin>:<theme>) still match the
574 // quoted data-theme-pack attribute selector — a colon is legal inside a
575 // quoted attribute value and cannot break out of it.
576 return value.replace(/[^a-zA-Z0-9_:-]/g, "");
577 }
578
579 function cssUrlEscape(url: string): string {
580 return url.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
581 }
582
583 /** Build a draft pack view for live editor preview (may use data-URL background). */
584 export function draftPackView(input: {
585 id: string;
586 name: string;
587 baseStyle: string;
588 tokens: ThemePackTokens;
589 recipes: ThemePackRecipes;
590 background?: ThemePackBackground | null;
591 backgroundUrl?: string;
592 taskBackground?: ThemePackSceneBackground | null;
593 taskBackgroundUrl?: string;
594 }): ThemePackView {
595 return {
596 id: input.id || "preview",
597 name: input.name || "Preview",
598 baseStyle: input.baseStyle || "graphite",
599 builtin: false,
600 active: false,
601 hasBackground: Boolean((input.backgroundUrl && input.background) || (input.taskBackgroundUrl && input.taskBackground)),
602 backgroundUrl: input.backgroundUrl,
603 taskBackgroundUrl: input.taskBackgroundUrl,
604 tokens: input.tokens || {},
605 recipes: input.recipes || { density: "comfortable", corners: "soft" },
606 background: input.background ?? undefined,
607 taskBackground: input.taskBackground ?? undefined,
608 };
609 }
610
611 export function emptyThemeTokens(): ThemePackTokens {
612 return { light: {}, dark: {} };
613 }
614
615 export function defaultBackground(): ThemePackBackground {
616 return {
617 focusX: 0.5,
618 focusY: 0.5,
619 safeArea: "center",
620 homeOpacity: 1,
621 taskOpacity: 0.28,
622 overlayStrength: 0.62,
623 paneOpacity: 0.50,
624 };
625 }
626
627 export function defaultTaskBackground(): ThemePackSceneBackground {
628 return {
629 focusX: 0.5,
630 focusY: 0.5,
631 safeArea: "center",
632 opacity: 0.28,
633 overlayStrength: 0.62,
634 paneOpacity: 0.68,
635 };
636 }
637
637 lines TYPESCRIPT