返回 DeepSeek-Reasonix
ThemeLibrary.tsx
根目录 / desktop / frontend / src / components / ThemeLibrary.tsx
1 import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2 import { Check, Copy, Download, Pencil, Plus, RotateCcw, Trash2, Upload } from "lucide-react";
3 import { app } from "../lib/bridge";
4 import { useT } from "../lib/i18n";
5 import { THEME_STYLES, type ThemeStyle, isThemeStyle } from "../lib/theme";
6 import {
7 type ThemePackBackground,
8 type ThemePackRecipes,
9 type ThemePackTokens,
10 type ThemePackView,
11 type ThemeSaveInput,
12 applyThemePack,
13 beginThemePreview,
14 cancelThemePreview,
15 clearThemePack,
16 commitThemePreview,
17 defaultBackground,
18 draftPackView,
19 emptyThemeTokens,
20 isSafeHex,
21 themePackKind,
22 themeTokenKeys,
23 } from "../lib/themePack";
24 import { useToast } from "../lib/toast";
25 import { useConfirmDialog } from "./ConfirmDialog";
26
27 type EditorState = {
28 mode: "create" | "edit";
29 id: string;
30 name: string;
31 author: string;
32 description: string;
33 license: string;
34 baseStyle: ThemeStyle;
35 tokens: ThemePackTokens;
36 recipes: ThemePackRecipes;
37 background: ThemePackBackground | null;
38 backgroundDataUrl: string;
39 existingBackgroundUrl: string;
40 tokenMode: "light" | "dark";
41 originalId: string;
42 };
43
44 const TOKEN_GROUPS: { labelKey: string; keys: string[] }[] = [
45 { labelKey: "settings.themeTokens.surfaces", keys: ["bg", "bgSoft", "bgElev", "panel", "sidebar", "chat", "workspace", "workspaceFiles"] },
46 { labelKey: "settings.themeTokens.borderText", keys: ["border", "borderSoft", "fg", "fgDim", "fgFaint"] },
47 { labelKey: "settings.themeTokens.accentStatus", keys: ["accent", "accentFg", "ok", "warn", "err"] },
48 ];
49
50 function slugifyId(name: string): string {
51 const s = name
52 .toLowerCase()
53 .replace(/[^a-z0-9]+/g, "-")
54 .replace(/^-+|-+$/g, "")
55 .slice(0, 48);
56 if (!s) return "my-theme";
57 if (/^[a-z][a-z0-9-]*[a-z0-9]$|^[a-z]$/.test(s)) return s;
58 return `t-${s}`.slice(0, 48);
59 }
60
61 function packToEditor(pack: ThemePackView, mode: "create" | "edit"): EditorState {
62 return {
63 mode,
64 id: mode === "create" ? slugifyId(`${pack.id}-copy`) : pack.id,
65 name: mode === "create" ? `${pack.name} Copy` : pack.name,
66 author: pack.author || "",
67 description: pack.description || "",
68 license: pack.license || "",
69 baseStyle: isThemeStyle(pack.baseStyle) ? pack.baseStyle : "graphite",
70 tokens: {
71 light: { ...(pack.tokens?.light || {}) },
72 dark: { ...(pack.tokens?.dark || {}) },
73 },
74 recipes: {
75 density: pack.recipes?.density === "compact" ? "compact" : "comfortable",
76 corners: pack.recipes?.corners === "square" || pack.recipes?.corners === "round" ? pack.recipes.corners : "soft",
77 },
78 background: pack.background ? { ...pack.background } : null,
79 backgroundDataUrl: "",
80 existingBackgroundUrl: pack.backgroundUrl || "",
81 tokenMode: "dark",
82 originalId: pack.id,
83 };
84 }
85
86 function emptyEditor(): EditorState {
87 return {
88 mode: "create",
89 id: "my-theme",
90 name: "My Theme",
91 author: "",
92 description: "",
93 license: "",
94 baseStyle: "graphite",
95 tokens: emptyThemeTokens(),
96 recipes: { density: "comfortable", corners: "soft" },
97 background: null,
98 backgroundDataUrl: "",
99 existingBackgroundUrl: "",
100 tokenMode: "dark",
101 originalId: "",
102 };
103 }
104
105 export function ThemeLibrarySection() {
106 const t = useT();
107 const { showToast } = useToast();
108 const { confirm, dialog: confirmDialog } = useConfirmDialog();
109 const [packs, setPacks] = useState<ThemePackView[]>([]);
110 const [loading, setLoading] = useState(true);
111 const [editor, setEditor] = useState<EditorState | null>(null);
112 const [busy, setBusy] = useState(false);
113 const previewTimer = useRef<number | null>(null);
114
115 const reload = useCallback(async () => {
116 setLoading(true);
117 try {
118 const list = await app.ListThemePacks();
119 setPacks(list || []);
120 const active = await app.GetActiveThemePack();
121 if (active?.pack) {
122 commitThemePreview(active.pack);
123 } else {
124 const still = (list || []).find((p) => p.active);
125 if (!still) clearThemePack();
126 }
127 } catch (err) {
128 showToast(err instanceof Error ? err.message : String(err), "error");
129 } finally {
130 setLoading(false);
131 }
132 }, []);
133
134 useEffect(() => {
135 void reload();
136 return () => {
137 if (previewTimer.current) window.clearTimeout(previewTimer.current);
138 // Closing settings / leaving the appearance tab must not leave a draft preview applied.
139 cancelThemePreview();
140 };
141 }, [reload]);
142
143 const schedulePreview = useCallback((state: EditorState) => {
144 if (previewTimer.current) window.clearTimeout(previewTimer.current);
145 previewTimer.current = window.setTimeout(() => {
146 const bgUrl = state.backgroundDataUrl || state.existingBackgroundUrl || "";
147 const draft = draftPackView({
148 id: state.id || "preview",
149 name: state.name,
150 baseStyle: state.baseStyle,
151 tokens: state.tokens,
152 recipes: state.recipes,
153 background: state.background,
154 backgroundUrl: bgUrl,
155 });
156 beginThemePreview(draft);
157 }, 80);
158 }, []);
159
160 const openCreate = () => {
161 const state = emptyEditor();
162 setEditor(state);
163 schedulePreview(state);
164 };
165
166 const openEdit = (pack: ThemePackView) => {
167 if (themePackKind(pack) !== "user") {
168 // Editing a base style means copying into a user theme.
169 const state = packToEditor(pack, "create");
170 setEditor(state);
171 schedulePreview(state);
172 return;
173 }
174 const state = packToEditor(pack, "edit");
175 setEditor(state);
176 schedulePreview(state);
177 };
178
179 const openCopy = async (pack: ThemePackView) => {
180 setBusy(true);
181 try {
182 const newId = slugifyId(`${pack.id}-copy`);
183 const created = await app.CopyThemePack(pack.id, newId, `${pack.name} Copy`);
184 showToast(t("settings.themeLibrary.copied", { name: created.name }), "info");
185 await reload();
186 } catch (err) {
187 showToast(err instanceof Error ? err.message : String(err), "error");
188 } finally {
189 setBusy(false);
190 }
191 };
192
193 const activate = async (pack: ThemePackView) => {
194 setBusy(true);
195 try {
196 await app.ActivateThemePack(pack.id);
197 const active = await app.GetActiveThemePack();
198 commitThemePreview(active.pack ?? null);
199 // Sync base style via appearance when activating a pack.
200 if (active.pack && isThemeStyle(active.pack.baseStyle)) {
201 // Appearance style stays independent in config; pack overlay supplies baseStyle live.
202 }
203 await reload();
204 showToast(t("settings.themeLibrary.activated", { name: pack.name }), "info");
205 } catch (err) {
206 showToast(err instanceof Error ? err.message : String(err), "error");
207 } finally {
208 setBusy(false);
209 }
210 };
211
212 const resetDefault = async () => {
213 setBusy(true);
214 try {
215 await app.ResetThemePack();
216 cancelThemePreview();
217 applyThemePack(null);
218 await reload();
219 showToast(t("settings.themeLibrary.resetDone"), "info");
220 } catch (err) {
221 showToast(err instanceof Error ? err.message : String(err), "error");
222 } finally {
223 setBusy(false);
224 }
225 };
226
227 const remove = async (pack: ThemePackView) => {
228 if (themePackKind(pack) !== "user") return;
229 const ok = await confirm({
230 title: t("settings.themeLibrary.confirmDeleteTitle"),
231 message: t("settings.themeLibrary.confirmDelete", { name: packDisplayName(pack, t) }),
232 confirmLabel: t("common.delete"),
233 cancelLabel: t("common.cancel"),
234 tone: "danger",
235 });
236 if (!ok) return;
237 setBusy(true);
238 try {
239 await app.DeleteThemePack(pack.id);
240 await reload();
241 const active = await app.GetActiveThemePack();
242 commitThemePreview(active.pack ?? null);
243 } catch (err) {
244 showToast(err instanceof Error ? err.message : String(err), "error");
245 } finally {
246 setBusy(false);
247 }
248 };
249
250 const doImport = async (replace = false) => {
251 setBusy(true);
252 try {
253 // First call may open a file dialog. On ID conflict the backend stages the
254 // extract and returns needsReplace — confirm then call again with replace=true
255 // (empty path) so the staged import is published without re-picking a file.
256 const result = await app.ImportThemePack("", replace);
257 if (!result) return;
258 if (result.needsReplace) {
259 const ok = await confirm({
260 title: t("settings.themeLibrary.confirmReplaceImportTitle"),
261 message: t("settings.themeLibrary.confirmReplaceImport"),
262 confirmLabel: t("settings.themeLibrary.replaceConfirm"),
263 cancelLabel: t("common.cancel"),
264 });
265 if (!ok) return;
266 const confirmed = await app.ImportThemePack("", true);
267 if (!confirmed?.pack?.id) return;
268 showToast(t("settings.themeLibrary.imported", { name: confirmed.pack.name }), "info");
269 await reload();
270 return;
271 }
272 if (!result.pack?.id) {
273 // Cancelled
274 return;
275 }
276 showToast(t("settings.themeLibrary.imported", { name: result.pack.name }), "info");
277 await reload();
278 } catch (err) {
279 const msg = err instanceof Error ? err.message : String(err);
280 showToast(msg, "error");
281 } finally {
282 setBusy(false);
283 }
284 };
285
286 const doExport = async (pack: ThemePackView) => {
287 if (pack.hasBackground) {
288 const ok = await confirm({
289 title: t("settings.themeLibrary.exportRightsTitle"),
290 message: t("settings.themeLibrary.exportRights"),
291 confirmLabel: t("settings.themeLibrary.exportConfirm"),
292 cancelLabel: t("common.cancel"),
293 });
294 if (!ok) return;
295 }
296 setBusy(true);
297 try {
298 const path = await app.ExportThemePack(pack.id, "");
299 if (path) showToast(t("settings.themeLibrary.exported"), "info");
300 } catch (err) {
301 showToast(err instanceof Error ? err.message : String(err), "error");
302 } finally {
303 setBusy(false);
304 }
305 };
306
307 const cancelEditor = () => {
308 cancelThemePreview();
309 setEditor(null);
310 };
311
312 const saveEditor = async (activateAfter: boolean) => {
313 if (!editor) return;
314 setBusy(true);
315 try {
316 const input: ThemeSaveInput = {
317 id: editor.id.trim(),
318 name: editor.name.trim(),
319 author: editor.author,
320 description: editor.description,
321 license: editor.license,
322 baseStyle: editor.baseStyle,
323 tokens: editor.tokens,
324 recipes: editor.recipes,
325 background: editor.background,
326 backgroundDataUrl: editor.backgroundDataUrl || undefined,
327 clearBackground: editor.background === null && editor.mode === "edit",
328 replace: editor.mode === "edit",
329 activate: activateAfter,
330 };
331 const saved = await app.SaveThemePack(input);
332 commitThemePreview(activateAfter ? saved : (await app.GetActiveThemePack()).pack ?? null);
333 setEditor(null);
334 await reload();
335 showToast(t("settings.themeLibrary.saved", { name: saved.name }), "info");
336 } catch (err) {
337 showToast(err instanceof Error ? err.message : String(err), "error");
338 } finally {
339 setBusy(false);
340 }
341 };
342
343 const updateEditor = (patch: Partial<EditorState>) => {
344 setEditor((prev) => {
345 if (!prev) return prev;
346 const next = { ...prev, ...patch };
347 schedulePreview(next);
348 return next;
349 });
350 };
351
352 const activeId = useMemo(() => packs.find((p) => p.active)?.id ?? "", [packs]);
353 const groups = useMemo(() => {
354 const official: ThemePackView[] = [];
355 const base: ThemePackView[] = [];
356 const user: ThemePackView[] = [];
357 const plugin: ThemePackView[] = [];
358 for (const p of packs) {
359 const kind = themePackKind(p);
360 if (kind === "official") official.push(p);
361 else if (kind === "base") base.push(p);
362 else if (kind === "plugin") plugin.push(p);
363 else user.push(p);
364 }
365 return { official, base, user, plugin };
366 }, [packs]);
367
368 return (
369 <div className="theme-library">
370 <div className="theme-library__toolbar">
371 <button type="button" className="btn btn--small" disabled={busy} onClick={openCreate}>
372 <Plus size={13} /> {t("settings.themeLibrary.new")}
373 </button>
374 <button type="button" className="btn btn--small" disabled={busy} onClick={() => void doImport(false)}>
375 <Upload size={13} /> {t("settings.themeLibrary.import")}
376 </button>
377 <button type="button" className="btn btn--small theme-reset-btn" disabled={busy} onClick={() => void resetDefault()}>
378 <RotateCcw size={13} /> {t("settings.themeLibrary.reset")}
379 </button>
380 </div>
381
382 {loading ? (
383 <div className="theme-lib-card__sub">{t("settings.themeLibrary.loading")}</div>
384 ) : (
385 <>
386 {groups.official.length > 0 && (
387 <section className="theme-library__group" data-group="official">
388 <h4 className="theme-library__heading">{t("settings.themeLibrary.groupOfficial")}</h4>
389 <div className="theme-library__grid theme-library__grid--official">
390 {groups.official.map((pack) => (
391 <OfficialThemeCard
392 key={pack.id}
393 pack={pack}
394 active={pack.id === activeId}
395 busy={busy}
396 onActivate={() => void activate(pack)}
397 onCopy={() => void openCopy(pack)}
398 />
399 ))}
400 </div>
401 </section>
402 )}
403
404 {groups.base.length > 0 && (
405 <section className="theme-library__group" data-group="base">
406 <h4 className="theme-library__heading">{t("settings.themeLibrary.groupBase")}</h4>
407 <div className="theme-library__grid theme-library__grid--base">
408 {groups.base.map((pack) => (
409 <ThemeLibCard
410 key={pack.id}
411 pack={pack}
412 active={pack.id === activeId}
413 busy={busy}
414 onActivate={() => void activate(pack)}
415 onEdit={() => openEdit(pack)}
416 onCopy={() => void openCopy(pack)}
417 onExport={() => void doExport(pack)}
418 onDelete={() => void remove(pack)}
419 />
420 ))}
421 </div>
422 </section>
423 )}
424
425 <section className="theme-library__group" data-group="user">
426 <h4 className="theme-library__heading">{t("settings.themeLibrary.groupUser")}</h4>
427 {groups.user.length === 0 ? (
428 <div className="theme-lib-card__sub">{t("settings.themeLibrary.emptyUser")}</div>
429 ) : (
430 <div className="theme-library__grid">
431 {groups.user.map((pack) => (
432 <ThemeLibCard
433 key={pack.id}
434 pack={pack}
435 active={pack.id === activeId}
436 busy={busy}
437 onActivate={() => void activate(pack)}
438 onEdit={() => openEdit(pack)}
439 onCopy={() => void openCopy(pack)}
440 onExport={() => void doExport(pack)}
441 onDelete={() => void remove(pack)}
442 />
443 ))}
444 </div>
445 )}
446 </section>
447
448 {groups.plugin.length > 0 && (
449 <section className="theme-library__group" data-group="plugin">
450 <h4 className="theme-library__heading">{t("settings.themeLibrary.groupPlugin")}</h4>
451 <div className="theme-library__grid">
452 {groups.plugin.map((pack) => (
453 <ThemeLibCard
454 key={pack.id}
455 pack={pack}
456 active={pack.id === activeId}
457 busy={busy}
458 onActivate={() => void activate(pack)}
459 onEdit={() => openEdit(pack)}
460 onCopy={() => void openCopy(pack)}
461 onExport={() => void doExport(pack)}
462 onDelete={() => void remove(pack)}
463 />
464 ))}
465 </div>
466 </section>
467 )}
468 </>
469 )}
470
471 {editor && (
472 <ThemeEditor
473 state={editor}
474 busy={busy}
475 onChange={updateEditor}
476 onCancel={cancelEditor}
477 onSave={(activateAfter) => void saveEditor(activateAfter)}
478 />
479 )}
480 {confirmDialog}
481 </div>
482 );
483 }
484
485 function packDisplayName(pack: ThemePackView, t: (key: never, vars?: Record<string, string | number>) => string): string {
486 return pack.nameKey ? t(pack.nameKey as never) : pack.name;
487 }
488
489 function packDescription(pack: ThemePackView, t: (key: never, vars?: Record<string, string | number>) => string): string {
490 if (pack.descriptionKey) return t(pack.descriptionKey as never);
491 return pack.description || "";
492 }
493
494 function OfficialThemeCard({
495 pack,
496 active,
497 busy,
498 onActivate,
499 onCopy,
500 }: {
501 pack: ThemePackView;
502 active: boolean;
503 busy: boolean;
504 onActivate: () => void;
505 onCopy: () => void;
506 }) {
507 const t = useT();
508 const name = packDisplayName(pack, t);
509 const desc = packDescription(pack, t);
510 const lightBg = pack.tokens?.light?.bg || "#f4f3ef";
511 const darkBg = pack.tokens?.dark?.bg || "#0c0d10";
512 const accent = pack.tokens?.dark?.accent || pack.tokens?.light?.accent || "#ff6a3d";
513
514 return (
515 <div className={`theme-lib-card theme-lib-card--official${active ? " theme-lib-card--on" : ""}`}>
516 <div className="theme-lib-card__thumb theme-lib-card__thumb--img">
517 {pack.previewUrl ? (
518 <img src={pack.previewUrl} alt={name} loading="lazy" decoding="async" />
519 ) : (
520 <div className="theme-lib-card__thumb-fallback" style={{ background: `linear-gradient(120deg, ${lightBg} 0%, ${lightBg} 55%, ${accent} 140%)` }} />
521 )}
522 </div>
523 <div className="theme-lib-card__meta">
524 <div className="theme-lib-card__name">
525 {name} {active ? <Check size={12} style={{ display: "inline", verticalAlign: "middle" }} /> : null}
526 </div>
527 {desc ? <div className="theme-lib-card__desc">{desc}</div> : null}
528 <div className="theme-lib-card__sub">
529 {pack.license || "MIT"} · {pack.author || "Reasonix Contributors"}
530 </div>
531 </div>
532 <div className="theme-lib-card__swatches" aria-hidden="true">
533 <span className="theme-lib-card__swatch" style={{ background: lightBg }} />
534 <span className="theme-lib-card__swatch" style={{ background: darkBg }} />
535 <span className="theme-lib-card__swatch" style={{ background: accent }} />
536 </div>
537 <div className="theme-lib-card__actions">
538 <button type="button" className="btn btn--small btn--primary" disabled={busy || active} onClick={onActivate}>
539 {active ? t("settings.themeLibrary.active") : t("settings.themeLibrary.enable")}
540 </button>
541 <button type="button" className="btn btn--small" disabled={busy} onClick={onCopy}>
542 <Copy size={12} /> {t("settings.themeLibrary.copyFrom")}
543 </button>
544 </div>
545 </div>
546 );
547 }
548
549 function ThemeLibCard({
550 pack,
551 active,
552 busy,
553 onActivate,
554 onEdit,
555 onCopy,
556 onExport,
557 onDelete,
558 }: {
559 pack: ThemePackView;
560 active: boolean;
561 busy: boolean;
562 onActivate: () => void;
563 onEdit: () => void;
564 onCopy: () => void;
565 onExport: () => void;
566 onDelete: () => void;
567 }) {
568 const t = useT();
569 const kind = themePackKind(pack);
570 const lightBg = pack.tokens?.light?.bg || "#f4f3ef";
571 const darkBg = pack.tokens?.dark?.bg || "#0c0d10";
572 const accent = pack.tokens?.dark?.accent || pack.tokens?.light?.accent || "#ff6a3d";
573 const thumbStyle: Record<string, string> = pack.backgroundUrl
574 ? { backgroundImage: `url("${pack.backgroundUrl}")`, backgroundSize: "cover" }
575 : { ["--thumb-light"]: lightBg, ["--thumb-dark"]: darkBg };
576
577 return (
578 <div className={`theme-lib-card${active ? " theme-lib-card--on" : ""}`}>
579 <div className="theme-lib-card__thumb" style={thumbStyle} />
580 <div className="theme-lib-card__meta">
581 <div className="theme-lib-card__name">
582 {pack.name} {active ? <Check size={12} style={{ display: "inline", verticalAlign: "middle" }} /> : null}
583 </div>
584 <div className="theme-lib-card__sub">
585 {kind === "base"
586 ? t("settings.themeLibrary.builtin")
587 : kind === "plugin"
588 ? pack.pluginName
589 ? t("settings.themeGallery.kindPlugin", { name: pack.pluginName })
590 : t("settings.themeGallery.kindPluginUnknown")
591 : pack.author || t("settings.themeLibrary.userTheme")}
592 {" · "}
593 {pack.baseStyle}
594 </div>
595 </div>
596 <div className="theme-lib-card__swatches" aria-hidden="true">
597 <span className="theme-lib-card__swatch" style={{ background: lightBg }} />
598 <span className="theme-lib-card__swatch" style={{ background: darkBg }} />
599 <span className="theme-lib-card__swatch" style={{ background: accent }} />
600 </div>
601 <div className="theme-lib-card__actions">
602 <button type="button" className="btn btn--small btn--primary" disabled={busy || active} onClick={onActivate}>
603 {active ? t("settings.themeLibrary.active") : t("settings.themeLibrary.enable")}
604 </button>
605 {kind === "user" && (
606 <button type="button" className="btn btn--small" disabled={busy} onClick={onEdit} title={t("settings.themeLibrary.edit")}>
607 <Pencil size={12} />
608 </button>
609 )}
610 {kind !== "plugin" && (
611 <button type="button" className="btn btn--small" disabled={busy} onClick={onCopy} title={t("settings.themeLibrary.copy")}>
612 <Copy size={12} />
613 </button>
614 )}
615 {kind === "user" && (
616 <>
617 <button type="button" className="btn btn--small" disabled={busy} onClick={onExport} title={t("settings.themeLibrary.export")}>
618 <Download size={12} />
619 </button>
620 <button type="button" className="btn btn--small" disabled={busy} onClick={onDelete} title={t("settings.themeLibrary.delete")}>
621 <Trash2 size={12} />
622 </button>
623 </>
624 )}
625 </div>
626 </div>
627 );
628 }
629
630 function ThemeEditor({
631 state,
632 busy,
633 onChange,
634 onCancel,
635 onSave,
636 }: {
637 state: EditorState;
638 busy: boolean;
639 onChange: (patch: Partial<EditorState>) => void;
640 onCancel: () => void;
641 onSave: (activate: boolean) => void;
642 }) {
643 const t = useT();
644 const { showToast } = useToast();
645 const previewRef = useRef<HTMLDivElement>(null);
646 const dragging = useRef(false);
647
648 const setToken = (key: string, value: string) => {
649 const mode = state.tokenMode;
650 const nextTokens = {
651 ...state.tokens,
652 [mode]: { ...(state.tokens[mode] || {}), [key]: value },
653 };
654 // Allow empty to clear override
655 if (!value) {
656 const map = { ...(nextTokens[mode] || {}) };
657 delete map[key];
658 nextTokens[mode] = map;
659 } else if (!isSafeHex(value) && value.length >= 7) {
660 // Keep typing intermediate values without applying invalid hex to preview tokens fully
661 }
662 onChange({ tokens: nextTokens });
663 };
664
665 const pickBackground = async () => {
666 try {
667 const dataUrl = await app.PickThemeBackground();
668 if (!dataUrl) return;
669 const bg = state.background ? { ...state.background } : defaultBackground();
670 onChange({
671 background: bg,
672 backgroundDataUrl: dataUrl,
673 existingBackgroundUrl: "",
674 });
675 } catch (err) {
676 showToast(err instanceof Error ? err.message : String(err), "error");
677 }
678 };
679
680 const onFocusPointer = (clientX: number, clientY: number) => {
681 const el = previewRef.current;
682 if (!el || !state.background) return;
683 const rect = el.getBoundingClientRect();
684 const x = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
685 const y = Math.min(1, Math.max(0, (clientY - rect.top) / rect.height));
686 onChange({ background: { ...state.background, focusX: x, focusY: y } });
687 };
688
689 const bgUrl = state.backgroundDataUrl || state.existingBackgroundUrl;
690 const warnings = useMemo(() => {
691 // Client-side soft check mirroring backend pairs.
692 const out: string[] = [];
693 for (const mode of ["light", "dark"] as const) {
694 const fg = state.tokens[mode]?.fg;
695 const bg = state.tokens[mode]?.bg;
696 if (fg && bg && isSafeHex(fg) && isSafeHex(bg)) {
697 const ratio = contrastRatio(fg, bg);
698 if (ratio < 4.5) out.push(`${mode} fg/bg ${ratio.toFixed(2)} < 4.5`);
699 }
700 }
701 return out;
702 }, [state.tokens]);
703
704 return (
705 <div className="theme-editor">
706 <strong>{state.mode === "create" ? t("settings.themeLibrary.editorCreate") : t("settings.themeLibrary.editorEdit")}</strong>
707
708 <div className="theme-editor__row">
709 <div className="theme-editor__label">{t("settings.themeLibrary.fieldId")}</div>
710 <div className="theme-editor__fields">
711 <input
712 value={state.id}
713 disabled={state.mode === "edit" || busy}
714 onChange={(e) => onChange({ id: e.target.value })}
715 />
716 <input
717 value={state.name}
718 disabled={busy}
719 placeholder={t("settings.themeLibrary.fieldName")}
720 onChange={(e) => onChange({ name: e.target.value })}
721 />
722 <input
723 value={state.author}
724 disabled={busy}
725 placeholder={t("settings.themeLibrary.fieldAuthor")}
726 onChange={(e) => onChange({ author: e.target.value })}
727 />
728 </div>
729 </div>
730
731 <div className="theme-editor__row">
732 <div className="theme-editor__label">{t("settings.themeLibrary.fieldBase")}</div>
733 <div className="set-seg">
734 {THEME_STYLES.map((s) => (
735 <button
736 key={s}
737 type="button"
738 className={`set-seg__btn${state.baseStyle === s ? " set-seg__btn--on" : ""}`}
739 disabled={busy}
740 onClick={() => onChange({ baseStyle: s })}
741 >
742 {s}
743 </button>
744 ))}
745 </div>
746 </div>
747
748 <div className="theme-editor__row">
749 <div className="theme-editor__label">{t("settings.themeLibrary.fieldRecipes")}</div>
750 <div className="theme-editor__fields">
751 <div className="set-seg">
752 {(["comfortable", "compact"] as const).map((d) => (
753 <button
754 key={d}
755 type="button"
756 className={`set-seg__btn${state.recipes.density === d ? " set-seg__btn--on" : ""}`}
757 disabled={busy}
758 onClick={() => onChange({ recipes: { ...state.recipes, density: d } })}
759 >
760 {d}
761 </button>
762 ))}
763 </div>
764 <div className="set-seg">
765 {(["square", "soft", "round"] as const).map((c) => (
766 <button
767 key={c}
768 type="button"
769 className={`set-seg__btn${state.recipes.corners === c ? " set-seg__btn--on" : ""}`}
770 disabled={busy}
771 onClick={() => onChange({ recipes: { ...state.recipes, corners: c } })}
772 >
773 {c}
774 </button>
775 ))}
776 </div>
777 </div>
778 </div>
779
780 <div className="theme-editor__row">
781 <div className="theme-editor__label">{t("settings.themeLibrary.fieldTokens")}</div>
782 <div className="theme-editor__fields">
783 <div className="set-seg">
784 {(["dark", "light"] as const).map((m) => (
785 <button
786 key={m}
787 type="button"
788 className={`set-seg__btn${state.tokenMode === m ? " set-seg__btn--on" : ""}`}
789 onClick={() => onChange({ tokenMode: m })}
790 >
791 {m}
792 </button>
793 ))}
794 </div>
795 {TOKEN_GROUPS.map((group) => (
796 <div key={group.labelKey}>
797 <div className="theme-lib-card__sub" style={{ marginBottom: 6 }}>{t(group.labelKey as never)}</div>
798 <div className="theme-editor__color-grid">
799 {group.keys.filter((k) => themeTokenKeys().includes(k)).map((key) => {
800 const val = state.tokens[state.tokenMode]?.[key] || "";
801 const colorVal = isSafeHex(val) ? val.slice(0, 7) : "#888888";
802 return (
803 <label key={key} className="theme-editor__color">
804 <span>{key}</span>
805 <input
806 type="color"
807 value={colorVal}
808 disabled={busy}
809 onChange={(e) => setToken(key, e.target.value)}
810 />
811 <input
812 type="text"
813 value={val}
814 placeholder="#RRGGBB"
815 disabled={busy}
816 onChange={(e) => setToken(key, e.target.value.trim())}
817 />
818 </label>
819 );
820 })}
821 </div>
822 </div>
823 ))}
824 </div>
825 </div>
826
827 <div className="theme-editor__row">
828 <div className="theme-editor__label">{t("settings.themeLibrary.fieldBackground")}</div>
829 <div className="theme-editor__fields">
830 <div className="theme-library__toolbar">
831 <button type="button" className="btn btn--small" disabled={busy} onClick={() => void pickBackground()}>
832 {t("settings.themeLibrary.pickImage")}
833 </button>
834 <button
835 type="button"
836 className="btn btn--small"
837 disabled={busy || (!state.background && !bgUrl)}
838 onClick={() => onChange({ background: null, backgroundDataUrl: "", existingBackgroundUrl: "" })}
839 >
840 {t("settings.themeLibrary.clearImage")}
841 </button>
842 </div>
843 {state.background && (
844 <>
845 <div
846 ref={previewRef}
847 className="theme-editor__bg-preview"
848 style={bgUrl ? { backgroundImage: `url("${bgUrl}")` } : undefined}
849 onPointerDown={(e) => {
850 dragging.current = true;
851 (e.target as HTMLElement).setPointerCapture?.(e.pointerId);
852 onFocusPointer(e.clientX, e.clientY);
853 }}
854 onPointerMove={(e) => {
855 if (!dragging.current) return;
856 onFocusPointer(e.clientX, e.clientY);
857 }}
858 onPointerUp={() => {
859 dragging.current = false;
860 }}
861 >
862 <span
863 className="theme-editor__focus"
864 style={{ left: `${(state.background.focusX ?? 0.5) * 100}%`, top: `${(state.background.focusY ?? 0.5) * 100}%` }}
865 />
866 </div>
867 <div className="set-seg">
868 {(["left", "center", "right"] as const).map((s) => (
869 <button
870 key={s}
871 type="button"
872 className={`set-seg__btn${state.background?.safeArea === s ? " set-seg__btn--on" : ""}`}
873 onClick={() => onChange({ background: { ...state.background!, safeArea: s } })}
874 >
875 {s}
876 </button>
877 ))}
878 </div>
879 <label className="theme-editor__color">
880 {t("settings.themeLibrary.homeOpacity")}
881 <input
882 type="range"
883 min={0}
884 max={1}
885 step={0.01}
886 value={state.background.homeOpacity}
887 onChange={(e) => onChange({ background: { ...state.background!, homeOpacity: Number(e.target.value) } })}
888 />
889 </label>
890 <label className="theme-editor__color">
891 {t("settings.themeLibrary.taskOpacity")}
892 <input
893 type="range"
894 min={0}
895 max={0.45}
896 step={0.01}
897 value={state.background.taskOpacity}
898 onChange={(e) => onChange({ background: { ...state.background!, taskOpacity: Number(e.target.value) } })}
899 />
900 </label>
901 <label className="theme-editor__color">
902 {t("settings.themeLibrary.overlayStrength")}
903 <input
904 type="range"
905 min={0}
906 max={1}
907 step={0.01}
908 value={state.background.overlayStrength}
909 onChange={(e) => onChange({ background: { ...state.background!, overlayStrength: Number(e.target.value) } })}
910 />
911 </label>
912 </>
913 )}
914 </div>
915 </div>
916
917 {warnings.length > 0 && (
918 <div className="theme-editor__warn">
919 {t("settings.themeLibrary.contrastWarn")}
920 <ul style={{ margin: "6px 0 0", paddingLeft: 18 }}>
921 {warnings.map((w) => (
922 <li key={w}>{w}</li>
923 ))}
924 </ul>
925 </div>
926 )}
927
928 <div className="theme-editor__actions">
929 <button type="button" className="btn btn--small" disabled={busy} onClick={onCancel}>
930 {t("settings.themeLibrary.cancel")}
931 </button>
932 <button type="button" className="btn btn--small" disabled={busy} onClick={() => onSave(false)}>
933 {t("settings.themeLibrary.save")}
934 </button>
935 <button type="button" className="btn btn--small btn--primary" disabled={busy} onClick={() => onSave(true)}>
936 {t("settings.themeLibrary.saveEnable")}
937 </button>
938 </div>
939 </div>
940 );
941 }
942
943 function contrastRatio(a: string, b: string): number {
944 const la = relativeLuminance(a);
945 const lb = relativeLuminance(b);
946 const lighter = Math.max(la, lb);
947 const darker = Math.min(la, lb);
948 return (lighter + 0.05) / (darker + 0.05);
949 }
950
951 function relativeLuminance(hex: string): number {
952 const n = hex.replace("#", "");
953 const r = parseInt(n.slice(0, 2), 16) / 255;
954 const g = parseInt(n.slice(2, 4), 16) / 255;
955 const b = parseInt(n.slice(4, 6), 16) / 255;
956 const lin = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
957 return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
958 }
959
959 lines Plain Text