返回 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 Wails serves through /__reasonix_theme_asset/. Only exact, internally
175 // registered URLs may cross the background URL safety boundary.
176 const trustedBundledThemeBackgroundURLs = new Set<string>();
177
178 export function registerTrustedThemeBackgroundURLs(urls: readonly string[]): void {
179 if (typeof window === "undefined" || !window.location) return;
180 for (const raw of urls) {
181 try {
182 const parsed = new URL(raw, window.location.href);
183 if (parsed.origin !== window.location.origin) continue;
184 const path = decodeURIComponent(parsed.pathname);
185 const viteDevOfficial = /\/desktop\/themes\/official\/official-[a-z0-9-]+\/background\.webp$/.test(path);
186 const viteBuiltOfficial = /^\/assets\/background-[a-zA-Z0-9_-]+\.webp$/.test(path);
187 if (viteDevOfficial || viteBuiltOfficial) trustedBundledThemeBackgroundURLs.add(parsed.href);
188 } catch {
189 // Ignore malformed candidates; they remain outside the allow-list.
190 }
191 }
192 }
193
194 export function getActiveThemePack(): ThemePackView | null {
195 return activePack;
196 }
197
198 export function getThemeScene(): ThemeScene {
199 return activeScene;
200 }
201
202 export function getBaseAppearance(): { theme: Theme; style: ThemeStyle } | null {
203 return baseAppearance ? { ...baseAppearance } : null;
204 }
205
206 export function isThemeTokenKey(key: string): boolean {
207 return (TOKEN_KEYS as readonly string[]).includes(key);
208 }
209
210 export function themeTokenKeys(): readonly string[] {
211 return TOKEN_KEYS;
212 }
213
214 /**
215 * Remember the user's config appearance before a pack overrides baseStyle.
216 * Call from settings load when preferences are known, or let applyThemePack
217 * snapshot automatically on first non-preview apply.
218 */
219 export function setBaseAppearance(theme: Theme, style: ThemeStyle): void {
220 baseAppearance = { theme, style };
221 }
222
223 /**
224 * Apply a configured appearance without replacing an active pack's live base
225 * style. The configured values remain the restore target when the pack is
226 * cleared, while the pack continues to own the effective visual direction.
227 */
228 export function applyConfiguredBaseAppearance(theme: Theme, style: ThemeStyle): void {
229 setBaseAppearance(theme, style);
230 applyTheme(theme, style, { persist: false });
231 if (activePack) applyThemePack(activePack);
232 }
233
234 /** Apply or clear the active theme pack overlay. Pass null only clears overlay attrs — prefer clearThemePack(). */
235 export function applyThemePack(pack: ThemePackView | null | undefined, options?: { preview?: boolean }): void {
236 if (typeof document === "undefined") return;
237 const next = pack ?? null;
238 if (!options?.preview) {
239 activePack = next;
240 }
241
242 const root = document.documentElement;
243 if (!next) {
244 root.removeAttribute("data-theme-pack");
245 removePackStyleElement();
246 clearBackgroundCSSVars(root);
247 return;
248 }
249
250 // Snapshot config appearance once before the first pack overrides baseStyle.
251 if (!options?.preview && !baseAppearance) {
252 baseAppearance = { theme: getTheme(), style: getThemeStyle() };
253 }
254
255 root.setAttribute("data-theme-pack", next.id);
256
257 // Base style from the pack (inherits remaining tokens from the direction sheets).
258 const style = isThemeStyle(next.baseStyle) ? next.baseStyle : getThemeStyle();
259 applyTheme(getTheme(), style, { persist: false });
260
261 const css = buildPackOverlayCSS(next);
262 ensurePackStyleElement().textContent = css;
263 applyBackgroundCSSVars(root, next);
264 applyThemeScene(activeScene);
265 }
266
267 /**
268 * Clear the active pack and restore the user's base appearance (theme mode + style).
269 * Fixes: enabling Aurora then "restore default" must return data-theme-style to Graphite
270 * (or whatever was configured), not leave the pack's baseStyle behind.
271 */
272 export function clearThemePack(): void {
273 previewSnapshot = null;
274 activePack = null;
275 if (typeof document !== "undefined") {
276 const root = document.documentElement;
277 root.removeAttribute("data-theme-pack");
278 removePackStyleElement();
279 clearBackgroundCSSVars(root);
280 }
281 if (baseAppearance) {
282 applyTheme(baseAppearance.theme, baseAppearance.style, { persist: false });
283 }
284 // Keep baseAppearance so subsequent applyThemePack can re-snapshot if needed;
285 // after full clear the restored style IS the base.
286 }
287
288 /** Scene is home (full background) vs task (dimmed + overlay). Does not touch chat state. */
289 export function applyThemeScene(scene: ThemeScene): void {
290 activeScene = scene === "task" ? "task" : "home";
291 if (typeof document === "undefined") return;
292 const app = document.querySelector(".app") ?? document.documentElement;
293 app.setAttribute("data-theme-scene", activeScene);
294 // Also mirror on root for CSS that targets :root.
295 document.documentElement.setAttribute("data-theme-scene", activeScene);
296 }
297
298 export function beginThemePreview(pack: ThemePackView): void {
299 if (!previewSnapshot) {
300 previewSnapshot = {
301 pack: activePack,
302 theme: getTheme(),
303 style: getThemeStyle(),
304 baseAppearance: baseAppearance ? { ...baseAppearance } : null,
305 };
306 }
307 applyThemePack(pack, { preview: true });
308 }
309
310 export function cancelThemePreview(): void {
311 if (!previewSnapshot) return;
312 const snap = previewSnapshot;
313 previewSnapshot = null;
314 baseAppearance = snap.baseAppearance;
315 applyTheme(snap.theme, snap.style, { persist: false });
316 if (snap.pack) {
317 applyThemePack(snap.pack);
318 } else {
319 // No active pack under the preview — strip overlay without changing restored style again.
320 activePack = null;
321 if (typeof document !== "undefined") {
322 const root = document.documentElement;
323 root.removeAttribute("data-theme-pack");
324 removePackStyleElement();
325 clearBackgroundCSSVars(root);
326 }
327 }
328 }
329
330 export function commitThemePreview(pack: ThemePackView | null): void {
331 previewSnapshot = null;
332 if (pack) {
333 applyThemePack(pack);
334 } else {
335 clearThemePack();
336 }
337 }
338
339 /**
340 * Clear the preview snapshot without restoring the original theme.
341 * Use this after persistent activation succeeds and before editor cleanup so
342 * cancelThemePreview() cannot overwrite the newly applied theme.
343 */
344 export function clearPreviewSnapshotOnly(): void {
345 previewSnapshot = null;
346 }
347
348 function ensurePackStyleElement(): HTMLStyleElement {
349 let el = document.getElementById(PACK_STYLE_ID) as HTMLStyleElement | null;
350 if (!el) {
351 el = document.createElement("style");
352 el.id = PACK_STYLE_ID;
353 // Append last so pack root overrides win over the base stylesheets.
354 // Element-scoped Creation code palettes intentionally remain local.
355 document.head.appendChild(el);
356 } else if (el.parentElement === document.head) {
357 document.head.appendChild(el);
358 }
359 return el;
360 }
361
362 function removePackStyleElement(): void {
363 const el = document.getElementById(PACK_STYLE_ID);
364 if (!el) return;
365 if (typeof el.remove === "function") el.remove();
366 else el.parentElement?.removeChild(el);
367 }
368
369 function buildPackOverlayCSS(pack: ThemePackView): string {
370 const lightTokens = pack.tokens?.light || {};
371 const darkTokens = pack.tokens?.dark || {};
372 const light = joinDecls(
373 tokensToDecls(lightTokens),
374 codeReadabilityDecls(deriveCodeReadabilityPalette("light", pack.baseStyle, lightTokens)),
375 );
376 const dark = joinDecls(
377 tokensToDecls(darkTokens),
378 codeReadabilityDecls(deriveCodeReadabilityPalette("dark", pack.baseStyle, darkTokens)),
379 );
380 const recipes = recipeDecls(pack.recipes);
381 const chunks: string[] = [];
382
383 // Recipe vars apply in both modes.
384 if (recipes) {
385 chunks.push(`:root[data-theme-pack="${cssEscape(pack.id)}"]{${recipes}}`);
386 }
387
388 // Dark tokens (default / forced dark / auto-dark).
389 if (dark) {
390 chunks.push(`:root[data-theme-pack="${cssEscape(pack.id)}"]{${dark}}`);
391 chunks.push(`:root[data-theme="dark"][data-theme-pack="${cssEscape(pack.id)}"]{${dark}}`);
392 }
393 // Light tokens.
394 if (light) {
395 chunks.push(`:root[data-theme="light"][data-theme-pack="${cssEscape(pack.id)}"]{${light}}`);
396 chunks.push(`@media (prefers-color-scheme: light){:root:not([data-theme])[data-theme-pack="${cssEscape(pack.id)}"]{${light}}}`);
397 }
398
399 // Soft accent derived when accent is set.
400 const accentDark = pack.tokens?.dark?.accent;
401 const accentLight = pack.tokens?.light?.accent;
402 if (accentDark && isSafeHex(accentDark)) {
403 chunks.push(
404 `:root[data-theme-pack="${cssEscape(pack.id)}"]{--accent-soft: color-mix(in srgb, ${accentDark} 16%, transparent);}`,
405 );
406 }
407 if (accentLight && isSafeHex(accentLight)) {
408 chunks.push(
409 `:root[data-theme="light"][data-theme-pack="${cssEscape(pack.id)}"]{--accent-soft: color-mix(in srgb, ${accentLight} 12%, transparent);}`,
410 );
411 }
412
413 return chunks.join("\n");
414 }
415
416 function joinDecls(...groups: string[]): string {
417 return groups.filter(Boolean).join(";");
418 }
419
420 function tokensToDecls(tokens?: Record<string, string>): string {
421 if (!tokens) return "";
422 const parts: string[] = [];
423 for (const [key, value] of Object.entries(tokens)) {
424 if (!isThemeTokenKey(key) || !isSafeHex(value)) continue;
425 const cssVars = TOKEN_TO_CSS[key] ?? [];
426 for (const css of cssVars) {
427 parts.push(`${css}:${value}`);
428 }
429 }
430 return parts.join(";");
431 }
432
433 function recipeDecls(recipes?: ThemePackRecipes): string {
434 if (!recipes) return "";
435 const parts: string[] = [];
436 const density = recipes.density === "compact" ? "compact" : "comfortable";
437 const corners = recipes.corners === "square" || recipes.corners === "round" ? recipes.corners : "soft";
438 if (density === "compact") {
439 parts.push("--theme-density-pad:6px", "--theme-density-gap:6px", "--theme-row-h:28px");
440 } else {
441 parts.push("--theme-density-pad:10px", "--theme-density-gap:10px", "--theme-row-h:34px");
442 }
443 if (corners === "square") {
444 parts.push("--r-s:0px", "--r:2px", "--r-l:4px", "--radius:2px");
445 } else if (corners === "round") {
446 parts.push("--r-s:8px", "--r:14px", "--r-l:18px", "--radius:14px");
447 } else {
448 parts.push("--r-s:5px", "--r:8px", "--r-l:11px", "--radius:8px");
449 }
450 return parts.join(";");
451 }
452
453 function applyBackgroundCSSVars(root: HTMLElement, pack: ThemePackView): void {
454 const home = pack.background;
455 const homeUrl = pack.backgroundUrl || "";
456 const task = pack.taskBackground;
457 const taskUrl = pack.taskBackgroundUrl || "";
458 const safeHomeUrl = Boolean(home && homeUrl && isSafeBackgroundURL(homeUrl));
459 const safeTaskUrl = Boolean(task && taskUrl && isSafeBackgroundURL(taskUrl));
460 if ((!safeHomeUrl && !safeTaskUrl) || !pack.hasBackground) {
461 clearBackgroundCSSVars(root);
462 return;
463 }
464
465 if (safeHomeUrl && home) {
466 root.style.setProperty("--theme-bg-home-image", `url("${cssUrlEscape(homeUrl)}")`);
467 root.style.setProperty("--theme-bg-home-focus-x", `${clamp01(home.focusX) * 100}%`);
468 root.style.setProperty("--theme-bg-home-focus-y", `${clamp01(home.focusY) * 100}%`);
469 root.style.setProperty("--theme-bg-home-opacity", String(clamp01(home.homeOpacity ?? 1)));
470 // Pane transparency: how much the background shows through the UI panes.
471 const homePane = clamp01(home.paneOpacity ?? 0.50);
472 root.style.setProperty("--theme-pane-alpha", String(homePane));
473 // Pre-computed percentages for CSS (avoids calc() compat issues).
474 // Clamp to 100% to prevent color-mix from receiving values > 100%.
475 root.style.setProperty("--theme-pane-shell-pct", `${Math.min((homePane + 0.08) * 100, 100)}%`);
476 root.style.setProperty("--theme-pane-card-pct", `${Math.min((homePane + 0.26) * 100, 100)}%`);
477 root.style.setProperty("--theme-pane-session-hover-pct", `${Math.min((homePane + 0.26) * 100, 100)}%`);
478 root.style.setProperty("--theme-pane-child-pct", `${Math.min((homePane + 0.30) * 100, 100)}%`);
479 root.style.setProperty("--theme-pane-interact-pct", `${Math.min((homePane + 0.40) * 100, 100)}%`);
480 // Legacy aliases keep V1 tests and third-party diagnostics stable.
481 root.style.setProperty("--theme-bg-image", `url("${cssUrlEscape(homeUrl)}")`);
482 root.style.setProperty("--theme-bg-focus-x", `${clamp01(home.focusX) * 100}%`);
483 root.style.setProperty("--theme-bg-focus-y", `${clamp01(home.focusY) * 100}%`);
484 } else {
485 root.style.setProperty("--theme-bg-home-image", "none");
486 }
487
488 const taskSource = safeTaskUrl && task ? task : home;
489 const effectiveTaskUrl = safeTaskUrl ? taskUrl : safeHomeUrl ? homeUrl : "";
490 if (taskSource && effectiveTaskUrl) {
491 root.style.setProperty("--theme-bg-task-image", `url("${cssUrlEscape(effectiveTaskUrl)}")`);
492 root.style.setProperty("--theme-bg-task-focus-x", `${clamp01(taskSource.focusX) * 100}%`);
493 root.style.setProperty("--theme-bg-task-focus-y", `${clamp01(taskSource.focusY) * 100}%`);
494 } else {
495 root.style.setProperty("--theme-bg-task-image", "none");
496 }
497 const taskOpacity = task ? task.opacity : home?.taskOpacity;
498 const taskOverlay = task ? task.overlayStrength : home?.overlayStrength;
499 root.style.setProperty("--theme-bg-task-opacity", String(clamp01(taskOpacity ?? 0.28)));
500 root.style.setProperty("--theme-bg-task-overlay", String(clamp01(taskOverlay ?? 0.62)));
501 root.style.setProperty("--theme-bg-overlay", String(clamp01(taskOverlay ?? 0.62)));
502 // Task scene pane transparency (defaults to home paneOpacity if not set on task scene).
503 const taskPane = clamp01(task?.paneOpacity ?? home?.paneOpacity ?? 0.68);
504 root.style.setProperty("--theme-pane-task-alpha", String(taskPane));
505 root.style.setProperty("--theme-pane-task-shell-pct", `${Math.min((taskPane + 0.08) * 100, 100)}%`);
506 root.style.setProperty("--theme-pane-task-card-pct", `${Math.min((taskPane + 0.14) * 100, 100)}%`);
507 root.style.setProperty("--theme-pane-task-session-hover-pct", `${Math.min((taskPane + 0.26) * 100, 100)}%`);
508 root.style.setProperty("--theme-pane-task-child-pct", `${Math.min((taskPane + 0.30) * 100, 100)}%`);
509 root.style.setProperty("--theme-pane-task-interact-pct", `${Math.min((taskPane + 0.40) * 100, 100)}%`);
510 const safe = taskSource?.safeArea === "left" || taskSource?.safeArea === "right" ? taskSource.safeArea : "center";
511 root.setAttribute("data-theme-safe-area", safe);
512 root.setAttribute("data-theme-has-bg", "true");
513 }
514
515 function clearBackgroundCSSVars(root: HTMLElement): void {
516 root.style.removeProperty("--theme-bg-home-image");
517 root.style.removeProperty("--theme-bg-home-focus-x");
518 root.style.removeProperty("--theme-bg-home-focus-y");
519 root.style.removeProperty("--theme-bg-task-image");
520 root.style.removeProperty("--theme-bg-task-focus-x");
521 root.style.removeProperty("--theme-bg-task-focus-y");
522 root.style.removeProperty("--theme-bg-task-overlay");
523 root.style.removeProperty("--theme-bg-image");
524 root.style.removeProperty("--theme-bg-focus-x");
525 root.style.removeProperty("--theme-bg-focus-y");
526 root.style.removeProperty("--theme-bg-home-opacity");
527 root.style.removeProperty("--theme-bg-task-opacity");
528 root.style.removeProperty("--theme-bg-overlay");
529 root.style.removeProperty("--theme-pane-alpha");
530 root.style.removeProperty("--theme-pane-task-alpha");
531 root.style.removeProperty("--theme-pane-shell-pct");
532 root.style.removeProperty("--theme-pane-task-shell-pct");
533 root.style.removeProperty("--theme-pane-card-pct");
534 root.style.removeProperty("--theme-pane-task-card-pct");
535 root.style.removeProperty("--theme-pane-session-hover-pct");
536 root.style.removeProperty("--theme-pane-child-pct");
537 root.style.removeProperty("--theme-pane-interact-pct");
538 root.style.removeProperty("--theme-pane-task-session-hover-pct");
539 root.style.removeProperty("--theme-pane-task-child-pct");
540 root.style.removeProperty("--theme-pane-task-interact-pct");
541 root.removeAttribute("data-theme-safe-area");
542 root.removeAttribute("data-theme-has-bg");
543 }
544
545 export function isSafeHex(value: string): boolean {
546 return /^#([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(value.trim());
547 }
548
549 export function isSafeBackgroundURL(url: string): boolean {
550 const u = url.trim();
551 if (!u) return false;
552 if (u.startsWith("/__reasonix_theme_asset/")) return true;
553 if (u.startsWith("data:image/png;base64,")) return true;
554 if (u.startsWith("data:image/jpeg;base64,")) return true;
555 if (u.startsWith("data:image/jpg;base64,")) return true;
556 if (u.startsWith("data:image/webp;base64,")) return true;
557 if (u.startsWith("blob:")) return true;
558 if (trustedBundledThemeBackgroundURLs.has(u)) return true;
559 return false;
560 }
561
562 function clamp01(v: number): number {
563 if (!Number.isFinite(v)) return 0.5;
564 return Math.min(1, Math.max(0, v));
565 }
566
567 function cssEscape(value: string): string {
568 // Keep ":" so plugin theme ids (plugin:<plugin>:<theme>) still match the
569 // quoted data-theme-pack attribute selector — a colon is legal inside a
570 // quoted attribute value and cannot break out of it.
571 return value.replace(/[^a-zA-Z0-9_:-]/g, "");
572 }
573
574 function cssUrlEscape(url: string): string {
575 return url.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
576 }
577
578 /** Build a draft pack view for live editor preview (may use data-URL background). */
579 export function draftPackView(input: {
580 id: string;
581 name: string;
582 baseStyle: string;
583 tokens: ThemePackTokens;
584 recipes: ThemePackRecipes;
585 background?: ThemePackBackground | null;
586 backgroundUrl?: string;
587 taskBackground?: ThemePackSceneBackground | null;
588 taskBackgroundUrl?: string;
589 }): ThemePackView {
590 return {
591 id: input.id || "preview",
592 name: input.name || "Preview",
593 baseStyle: input.baseStyle || "graphite",
594 builtin: false,
595 active: false,
596 hasBackground: Boolean((input.backgroundUrl && input.background) || (input.taskBackgroundUrl && input.taskBackground)),
597 backgroundUrl: input.backgroundUrl,
598 taskBackgroundUrl: input.taskBackgroundUrl,
599 tokens: input.tokens || {},
600 recipes: input.recipes || { density: "comfortable", corners: "soft" },
601 background: input.background ?? undefined,
602 taskBackground: input.taskBackground ?? undefined,
603 };
604 }
605
606 export function emptyThemeTokens(): ThemePackTokens {
607 return { light: {}, dark: {} };
608 }
609
610 export function defaultBackground(): ThemePackBackground {
611 return {
612 focusX: 0.5,
613 focusY: 0.5,
614 safeArea: "center",
615 homeOpacity: 1,
616 taskOpacity: 0.28,
617 overlayStrength: 0.62,
618 paneOpacity: 0.50,
619 };
620 }
621
622 export function defaultTaskBackground(): ThemePackSceneBackground {
623 return {
624 focusX: 0.5,
625 focusY: 0.5,
626 safeArea: "center",
627 opacity: 0.28,
628 overlayStrength: 0.62,
629 paneOpacity: 0.68,
630 };
631 }
632
632 lines TYPESCRIPT