dsh-cool-theme 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -34,6 +34,12 @@ window.__ModuleLoader__.load({
34
34
  */
35
35
  const THEME_STORAGE_KEY = "cool-theme-preset";
36
36
  const THEME_STORAGE_KEY_LEGACY = "cooltea-theme-preset";
37
+ const CUSTOM_PRESET_ID = "custom";
38
+ const CUSTOM_THEME_STORAGE_KEY = "cool-theme-custom";
39
+ const CUSTOM_LIST_STORAGE_KEY = "cool-theme-custom-list";
40
+ const CUSTOM_ACTIVE_STORAGE_KEY = "cool-theme-custom-active";
41
+ const CUSTOM_MIGRATED_STORAGE_KEY = "cool-theme-custom-migrated";
42
+ const THEME_API_PATH_THEMES = `/cool-theme/api/themes`;
37
43
  //#endregion
38
44
  //#region src/client/presets/_helpers.ts
39
45
  /**
@@ -1979,6 +1985,11 @@ window.__ModuleLoader__.load({
1979
1985
  }
1980
1986
  }
1981
1987
  };
1988
+ /**
1989
+ * Presets that override nothing: the shell keeps its own colours. The single
1990
+ * source of this set, so the token layer and the panel cannot drift on it.
1991
+ */
1992
+ const NOOP_PRESET_IDS = /* @__PURE__ */ new Set(["native", "dsh"]);
1982
1993
  const presetOptions = [{
1983
1994
  value: "dsh",
1984
1995
  label: PRESETS.dsh.label
@@ -2157,10 +2168,17 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
2157
2168
  function buildBaseCss() {
2158
2169
  return `${buildPrimitivesCss()}\n\n${SHIKI_CSS}`;
2159
2170
  }
2160
- const NOOP_PRESETS = /* @__PURE__ */ new Set(["native", "dsh"]);
2161
- function resolvePreset(id) {
2162
- if (NOOP_PRESETS.has(id)) return null;
2163
- const preset = PRESETS[id];
2171
+ /**
2172
+ * Accepts either a preset id or an already-built `PresetDef` (what a custom
2173
+ * theme produces). Both go through the identical merge, so a custom theme is
2174
+ * not a special case anywhere downstream.
2175
+ */
2176
+ function resolvePreset(src) {
2177
+ let preset;
2178
+ if (typeof src === "string") {
2179
+ if (NOOP_PRESET_IDS.has(src)) return null;
2180
+ preset = PRESETS[src] ?? null;
2181
+ } else preset = src;
2164
2182
  if (!preset) return null;
2165
2183
  return {
2166
2184
  light: {
@@ -2173,16 +2191,15 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
2173
2191
  }
2174
2192
  };
2175
2193
  }
2176
- function buildOverrides(id) {
2177
- const resolved = resolvePreset(id);
2194
+ function buildOverrides(src) {
2195
+ const resolved = resolvePreset(src);
2178
2196
  if (!resolved) return {};
2179
2197
  return primitiveOverrides(resolved.light, resolved.dark);
2180
2198
  }
2181
- function buildFullCssFallback(id) {
2182
- const resolved = resolvePreset(id);
2199
+ function buildFullCssFallback(src) {
2200
+ const resolved = resolvePreset(src);
2183
2201
  if (!resolved) return "";
2184
- const toBlock = (m) => Object.entries(m).map(([k, v]) => ` ${k}: ${v};`).join("\n");
2185
- return `:root{\n${toBlock(resolved.light)}\n}\nhtml[data-ds-dark-theme], body[data-ds-dark-theme]{\n${toBlock(resolved.dark)}\n}`;
2202
+ return buildPrimitivesCss(resolved.light, resolved.dark);
2186
2203
  }
2187
2204
  //#endregion
2188
2205
  //#region src/client/style-injector.ts
@@ -2223,10 +2240,486 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
2223
2240
  };
2224
2241
  }
2225
2242
  //#endregion
2243
+ //#region src/client/custom.ts
2244
+ /** The nine `--shiki-token-*` slots, in stylesheet order. */
2245
+ const SHIKI_KEYS = [
2246
+ "constant",
2247
+ "string",
2248
+ "comment",
2249
+ "keyword",
2250
+ "parameter",
2251
+ "function",
2252
+ "string-expression",
2253
+ "punctuation",
2254
+ "link"
2255
+ ];
2256
+ /** Seed values used when a source preset cannot supply one (e.g. the no-op `dsh`). */
2257
+ const SHIKI_FALLBACK = {
2258
+ light: {
2259
+ constant: "#1C7ED6",
2260
+ string: "#2F9E44",
2261
+ comment: "#868E96",
2262
+ keyword: "#D6336C",
2263
+ parameter: "#E8590C",
2264
+ function: "#6741D9",
2265
+ "string-expression": "#2B8A3E",
2266
+ punctuation: "#495057",
2267
+ link: "#1971C2"
2268
+ },
2269
+ dark: {
2270
+ constant: "#4DABF7",
2271
+ string: "#69DB7C",
2272
+ comment: "#ADB5BD",
2273
+ keyword: "#FAA2C1",
2274
+ parameter: "#FFA94D",
2275
+ function: "#B197FC",
2276
+ "string-expression": "#8CE99A",
2277
+ punctuation: "#CED4DA",
2278
+ link: "#74C0FC"
2279
+ }
2280
+ };
2281
+ const HEX = /^#([0-9a-f]{3,8})$/i;
2282
+ const RGB_FN = /^rgba?\(([^)]*)\)$/i;
2283
+ const HSL_FN = /^hsla?\(([^)]*)\)$/i;
2284
+ function isRecord$1(value) {
2285
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2286
+ }
2287
+ const clamp255 = (n) => Math.max(0, Math.min(255, Math.round(n)));
2288
+ const clamp01 = (n) => Math.max(0, Math.min(1, n));
2289
+ /** Accepts both `a, b, c` and the modern `a b c / d` argument syntax. */
2290
+ function fnArgs(body) {
2291
+ return body.replace(/\//g, " ").split(/[\s,]+/).filter(Boolean);
2292
+ }
2293
+ /** A colour channel: `128` or a percentage. Returns null for anything else. */
2294
+ function channel(token) {
2295
+ const pct = token.endsWith("%");
2296
+ const v = parseFloat(pct ? token.slice(0, -1) : token);
2297
+ if (!Number.isFinite(v)) return null;
2298
+ return pct ? v / 100 * 255 : v;
2299
+ }
2300
+ function hslToRgb(h, s, l) {
2301
+ const a = s * Math.min(l, 1 - l);
2302
+ const f = (n) => {
2303
+ const k = (n + h / 30) % 12;
2304
+ return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1));
2305
+ };
2306
+ return [
2307
+ clamp255(f(0) * 255),
2308
+ clamp255(f(8) * 255),
2309
+ clamp255(f(4) * 255)
2310
+ ];
2311
+ }
2312
+ const toRgbHex = (rgb) => "#" + rgb.map((n) => clamp255(n).toString(16).padStart(2, "0")).join("").toUpperCase();
2313
+ /**
2314
+ * Parse a colour into `#RRGGBB`, or null when it is not a format we understand.
2315
+ *
2316
+ * Accepted: `#RGB`, `#RGBA`, `#RRGGBB`, `#RRGGBBAA`, `rgb()`/`rgba()` with
2317
+ * numbers or percentages, and `hsl()`/`hsla()`. Alpha is parsed but dropped —
2318
+ * every token these seeds drive is an opaque theme colour.
2319
+ *
2320
+ * `primitives.ts` stores `rgb(r, g, b)` while presets store hex, so both shapes
2321
+ * have to work; the extra forms are there for what users may type.
2322
+ */
2323
+ function tryToHex(input) {
2324
+ if (typeof input !== "string") return null;
2325
+ const s = input.trim();
2326
+ if (!s) return null;
2327
+ const hex = s.match(HEX);
2328
+ if (hex) {
2329
+ const d = hex[1];
2330
+ const twice = (c) => c + c;
2331
+ if (d.length === 3 || d.length === 4) return `#${twice(d[0])}${twice(d[1])}${twice(d[2])}`.toUpperCase();
2332
+ if (d.length === 6 || d.length === 8) return `#${d.slice(0, 6)}`.toUpperCase();
2333
+ return null;
2334
+ }
2335
+ const rgbFn = s.match(RGB_FN);
2336
+ if (rgbFn) {
2337
+ const parts = fnArgs(rgbFn[1]);
2338
+ if (parts.length < 3) return null;
2339
+ const rgb = parts.slice(0, 3).map(channel);
2340
+ if (rgb.some((n) => n === null)) return null;
2341
+ return toRgbHex(rgb);
2342
+ }
2343
+ const hslFn = s.match(HSL_FN);
2344
+ if (hslFn) {
2345
+ const parts = fnArgs(hslFn[1]);
2346
+ if (parts.length < 3) return null;
2347
+ const h = parseFloat(parts[0].replace(/deg$/i, ""));
2348
+ const sat = parseFloat(parts[1]);
2349
+ const lig = parseFloat(parts[2]);
2350
+ if (![
2351
+ h,
2352
+ sat,
2353
+ lig
2354
+ ].every(Number.isFinite)) return null;
2355
+ return toRgbHex(hslToRgb((h % 360 + 360) % 360, clamp01(sat / 100), clamp01(lig / 100)));
2356
+ }
2357
+ return null;
2358
+ }
2359
+ /** `tryToHex` with a black fallback, for call sites that need a guaranteed value. */
2360
+ function toHex(input) {
2361
+ return tryToHex(input) ?? "#000000";
2362
+ }
2363
+ function readSeed(map, fallback, key) {
2364
+ return toHex(map[key] ?? fallback[key]);
2365
+ }
2366
+ /**
2367
+ * Recover the seed inputs from an already-generated preset.
2368
+ *
2369
+ * Exact by construction: `buildScale` writes the `start`/`end` arguments to
2370
+ * steps 00/1000, and `buildTintShadeScale` writes the base to step 500. This is
2371
+ * what lets a user start from any preset without re-declaring its palette.
2372
+ */
2373
+ function extractSeeds(base) {
2374
+ const seed = (key, mode) => readSeed(mode === "light" ? base.light : base.dark, mode === "light" ? PRIMITIVES_LIGHT : PRIMITIVES_DARK, key);
2375
+ const pair = (key) => ({
2376
+ light: seed(key, "light"),
2377
+ dark: seed(key, "dark")
2378
+ });
2379
+ /** Both ends of one appearance's ramp, read from that appearance's map. */
2380
+ const ramp = (mode) => ({
2381
+ lightest: seed("--dsw-static-neutral-bluish-00", mode),
2382
+ darkest: seed("--dsw-static-neutral-bluish-1000", mode)
2383
+ });
2384
+ const shiki = SHIKI_KEYS.reduce((acc, k) => {
2385
+ const cssKey = `--shiki-token-${k}`;
2386
+ const lightMap = base.light;
2387
+ const darkMap = base.dark;
2388
+ acc[k] = {
2389
+ light: toHex(lightMap[cssKey] ?? SHIKI_FALLBACK.light[k]),
2390
+ dark: toHex(darkMap[cssKey] ?? SHIKI_FALLBACK.dark[k])
2391
+ };
2392
+ return acc;
2393
+ }, {});
2394
+ return {
2395
+ neutral: {
2396
+ light: ramp("light"),
2397
+ dark: ramp("dark")
2398
+ },
2399
+ accent: pair("--dsw-static-deepseek-500"),
2400
+ green: pair("--dsw-static-green-500"),
2401
+ amber: pair("--dsw-static-amber-500"),
2402
+ red: pair("--dsw-static-red-500"),
2403
+ shiki
2404
+ };
2405
+ }
2406
+ /** v1 carried one neutral ramp shared by both appearances. */
2407
+ const CUSTOM_SCHEMA_VERSION_LEGACY = 1;
2408
+ function encodeCustom(base, theme) {
2409
+ return JSON.stringify({
2410
+ v: 2,
2411
+ base,
2412
+ theme
2413
+ });
2414
+ }
2415
+ /** Parse a stored blob. Returns null for absent/corrupt/foreign-version data. */
2416
+ function parseEnvelope(raw) {
2417
+ if (!raw) return null;
2418
+ try {
2419
+ const parsed = JSON.parse(raw);
2420
+ if (!parsed || typeof parsed !== "object") return null;
2421
+ if (parsed.v !== 2 && parsed.v !== CUSTOM_SCHEMA_VERSION_LEGACY) return null;
2422
+ return {
2423
+ v: 2,
2424
+ base: String(parsed.base ?? ""),
2425
+ theme: parsed.theme
2426
+ };
2427
+ } catch {
2428
+ return null;
2429
+ }
2430
+ }
2431
+ /**
2432
+ * The neutral seeds in the current shape. A pre-v2 blob carried one ramp shared
2433
+ * by both appearances; repeating it per appearance reproduces exactly what that
2434
+ * blob rendered before the split, so existing themes migrate without a visible
2435
+ * change.
2436
+ */
2437
+ function neutralSeeds(src) {
2438
+ if (isRecord$1(src.neutral)) return src.neutral;
2439
+ const shared = {
2440
+ lightest: src.neutralLightest,
2441
+ darkest: src.neutralDarkest
2442
+ };
2443
+ return {
2444
+ light: shared,
2445
+ dark: shared
2446
+ };
2447
+ }
2448
+ /** Repair a partially-invalid object against `fallback`, keeping every valid colour. */
2449
+ function normalizeCustom(raw, fallback) {
2450
+ const src = raw ?? {};
2451
+ const pick = (value, fb) => tryToHex(value) ?? fb;
2452
+ const pickPair = (value, fb) => {
2453
+ const v = value ?? {};
2454
+ return {
2455
+ light: pick(v.light, fb.light),
2456
+ dark: pick(v.dark, fb.dark)
2457
+ };
2458
+ };
2459
+ const pickRamp = (value, fb) => {
2460
+ const v = value ?? {};
2461
+ return {
2462
+ lightest: pick(v.lightest, fb.lightest),
2463
+ darkest: pick(v.darkest, fb.darkest)
2464
+ };
2465
+ };
2466
+ const shikiSrc = src.shiki ?? {};
2467
+ const shiki = SHIKI_KEYS.reduce((acc, k) => {
2468
+ acc[k] = pickPair(shikiSrc[k], fallback.shiki[k]);
2469
+ return acc;
2470
+ }, {});
2471
+ const neutral = neutralSeeds(src);
2472
+ return {
2473
+ neutral: {
2474
+ light: pickRamp(neutral.light, fallback.neutral.light),
2475
+ dark: pickRamp(neutral.dark, fallback.neutral.dark)
2476
+ },
2477
+ accent: pickPair(src.accent, fallback.accent),
2478
+ green: pickPair(src.green, fallback.green),
2479
+ amber: pickPair(src.amber, fallback.amber),
2480
+ red: pickPair(src.red, fallback.red),
2481
+ shiki
2482
+ };
2483
+ }
2484
+ /**
2485
+ * Turn seeds into a full `PresetDef` using the presets' own helpers.
2486
+ *
2487
+ * Unlike the presets (which ship one ramp for both appearances), the ramp is
2488
+ * built once per appearance, so the light text and the dark text are no longer
2489
+ * the two ends of the same colour axis.
2490
+ */
2491
+ function buildCustomPreset(v) {
2492
+ /** One appearance's ramp, published under both prefixes DSH reads from. */
2493
+ const ramp = (mode) => {
2494
+ const { lightest, darkest } = v.neutral[mode];
2495
+ return {
2496
+ ...buildScale("--dsw-static-neutral-bluish", lightest, darkest, BLUISH_STEPS),
2497
+ ...buildScale("--dsw-static-neutral", lightest, darkest, NEUTRAL_STEPS)
2498
+ };
2499
+ };
2500
+ const semantic = (mode) => buildSemanticScales({
2501
+ deepseek: v.accent[mode],
2502
+ blue: v.accent[mode],
2503
+ green: v.green[mode],
2504
+ amber: v.amber[mode],
2505
+ red: v.red[mode]
2506
+ });
2507
+ const shiki = (mode) => {
2508
+ const out = {};
2509
+ for (const k of SHIKI_KEYS) out[`--shiki-token-${k}`] = v.shiki[k][mode];
2510
+ return out;
2511
+ };
2512
+ return {
2513
+ label: "Custom",
2514
+ light: {
2515
+ ...ramp("light"),
2516
+ ...semantic("light"),
2517
+ ...shiki("light")
2518
+ },
2519
+ dark: {
2520
+ ...ramp("dark"),
2521
+ ...semantic("dark"),
2522
+ ...shiki("dark")
2523
+ }
2524
+ };
2525
+ }
2526
+ /** Collision-resistant enough for a local list, and stable across reloads. */
2527
+ function newThemeId() {
2528
+ return `ct_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
2529
+ }
2530
+ /**
2531
+ * Parse the saved list. Entries with a corrupt theme are repaired against
2532
+ * `fallbackFor(base)` rather than dropped, so one bad record cannot lose the
2533
+ * rest of the list; only structurally unusable entries are skipped.
2534
+ */
2535
+ function decodeList(raw, fallbackFor) {
2536
+ if (!raw) return [];
2537
+ try {
2538
+ const parsed = JSON.parse(raw);
2539
+ const known = parsed?.v === 2 || parsed?.v === CUSTOM_SCHEMA_VERSION_LEGACY;
2540
+ if (!parsed || !known || !Array.isArray(parsed.items)) return [];
2541
+ const out = [];
2542
+ for (const item of parsed.items) {
2543
+ const e = item;
2544
+ if (typeof e?.id !== "string" || typeof e?.name !== "string") continue;
2545
+ const base = typeof e.base === "string" ? e.base : "";
2546
+ out.push({
2547
+ id: e.id,
2548
+ name: e.name,
2549
+ base,
2550
+ theme: normalizeCustom(e.theme, fallbackFor(base)),
2551
+ assets: Array.isArray(e.assets) ? e.assets : []
2552
+ });
2553
+ }
2554
+ return out;
2555
+ } catch {
2556
+ return [];
2557
+ }
2558
+ }
2559
+ /** `${base} 1`, `${base} 2`, … skipping names already taken. */
2560
+ function nextThemeName(base, taken) {
2561
+ const used = new Set(taken);
2562
+ for (let n = 1;; n++) {
2563
+ const candidate = `${base} ${n}`;
2564
+ if (!used.has(candidate)) return candidate;
2565
+ }
2566
+ }
2567
+ //#endregion
2568
+ //#region src/client/theme-files.ts
2569
+ /**
2570
+ * Browser half of the theme-file transport.
2571
+ *
2572
+ * The Host owns the files; this module is the only place that knows the wire
2573
+ * shape, so the rest of the client keeps working with plain `SavedTheme`
2574
+ * records. Every call is same-origin against the Host's own prefix route.
2575
+ */
2576
+ /** Repair one stored document against the preset its seeds derive from. */
2577
+ function documentToSaved(doc, fallbackFor) {
2578
+ return {
2579
+ id: doc.id,
2580
+ name: doc.name,
2581
+ base: doc.base,
2582
+ theme: normalizeCustom(doc.theme, fallbackFor(doc.base)),
2583
+ assets: Array.isArray(doc.assets) ? doc.assets : []
2584
+ };
2585
+ }
2586
+ /** The document payload for one entry; timestamps stay the Host's business. */
2587
+ function savedToDocument(entry) {
2588
+ return {
2589
+ v: 1,
2590
+ id: entry.id,
2591
+ name: entry.name,
2592
+ base: entry.base,
2593
+ theme: entry.theme,
2594
+ assets: entry.assets
2595
+ };
2596
+ }
2597
+ function isRecord(value) {
2598
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2599
+ }
2600
+ /**
2601
+ * The Host answered as something other than this API — its route half is not
2602
+ * mounted (the usual cause is a DSH process started before this plugin's host
2603
+ * half existed). Distinguished from a write failure because only this one tells
2604
+ * the user to restart, and the browser copy is still intact meanwhile.
2605
+ */
2606
+ var ThemeApiUnavailableError = class extends Error {};
2607
+ /** One JSON round trip; a non-OK status becomes an Error carrying the reason. */
2608
+ async function callApi(path, init) {
2609
+ const response = await fetch(path, {
2610
+ ...init,
2611
+ headers: {
2612
+ "content-type": "application/json",
2613
+ ...init?.headers
2614
+ }
2615
+ });
2616
+ let payload;
2617
+ try {
2618
+ payload = await response.json();
2619
+ } catch {
2620
+ throw new ThemeApiUnavailableError(`${String(response.status)} ${response.statusText}`);
2621
+ }
2622
+ if (!response.ok) {
2623
+ if (response.status === 404 || response.status === 405) throw new ThemeApiUnavailableError(`${String(response.status)} ${response.statusText}`);
2624
+ const reason = isRecord(payload) && typeof payload.error === "string" ? payload.error : response.statusText;
2625
+ throw new Error(reason);
2626
+ }
2627
+ return isRecord(payload) ? payload : {};
2628
+ }
2629
+ /**
2630
+ * The documents in one roster response, repaired into `SavedTheme` records.
2631
+ * Every call on this route answers with the same `{ themes: [...] }` body, so
2632
+ * both reads share this one parser: an entry missing an id or a name is skipped
2633
+ * rather than failing the whole roster.
2634
+ */
2635
+ function payloadThemes(payload, fallbackFor) {
2636
+ const documents = Array.isArray(payload.themes) ? payload.themes : [];
2637
+ const out = [];
2638
+ for (const doc of documents) {
2639
+ if (!isRecord(doc) || typeof doc.id !== "string" || typeof doc.name !== "string") continue;
2640
+ out.push(documentToSaved({
2641
+ v: typeof doc.v === "number" ? doc.v : 1,
2642
+ id: doc.id,
2643
+ name: doc.name,
2644
+ base: typeof doc.base === "string" ? doc.base : "",
2645
+ theme: doc.theme,
2646
+ assets: Array.isArray(doc.assets) ? doc.assets : [],
2647
+ createdAt: "",
2648
+ updatedAt: ""
2649
+ }, fallbackFor));
2650
+ }
2651
+ return out;
2652
+ }
2653
+ /** Every stored theme, in the Host's creation-time order (oldest first). */
2654
+ async function fetchThemes(fallbackFor) {
2655
+ return payloadThemes(await callApi(THEME_API_PATH_THEMES), fallbackFor);
2656
+ }
2657
+ /** Insert or replace the given entries; resolves with what the Host stored. */
2658
+ async function pushThemes(entries, fallbackFor) {
2659
+ return payloadThemes(await callApi(THEME_API_PATH_THEMES, {
2660
+ method: "POST",
2661
+ body: JSON.stringify({ themes: entries.map(savedToDocument) })
2662
+ }), fallbackFor);
2663
+ }
2664
+ /** Remove one theme's directory. */
2665
+ async function removeTheme(id) {
2666
+ await callApi(`${THEME_API_PATH_THEMES}/${encodeURIComponent(id)}`, { method: "DELETE" });
2667
+ }
2668
+ /**
2669
+ * The themes a browser saved before files became the store, minus those the
2670
+ * Host already has.
2671
+ *
2672
+ * Ids are the join key: a theme another browser already migrated must not be
2673
+ * written twice, while one this roster lacks would otherwise disappear from the
2674
+ * UI. Callers adopt the result, then stop reading the browser copy.
2675
+ */
2676
+ function legacyThemesToAdopt(legacy, hostThemes) {
2677
+ const known = new Set(hostThemes.map((entry) => entry.id));
2678
+ return legacy.filter((entry) => !known.has(entry.id));
2679
+ }
2680
+ //#endregion
2226
2681
  //#region src/client/components.tsx
2227
2682
  /**
2228
- * Theme settings UI: scheme selector + preset picker.
2683
+ * Theme settings UI: scheme selector, preset picker, and the custom colour editor.
2229
2684
  */
2685
+ /** Reserved menu value that switches to the user-defined theme. */
2686
+ const CUSTOM_SELECTION = CUSTOM_PRESET_ID;
2687
+ /** Structural equality of the two seeds in one pair. */
2688
+ function samePair(a, b) {
2689
+ return a?.light === b?.light && a?.dark === b?.dark;
2690
+ }
2691
+ /** Structural equality of the two endpoints of one neutral ramp. */
2692
+ function sameRamp(a, b) {
2693
+ return a?.lightest === b?.lightest && a?.darkest === b?.darkest;
2694
+ }
2695
+ /** Structural equality over all 30 seeds, ignoring key order. */
2696
+ function sameSeeds(a, b) {
2697
+ if (!sameRamp(a.neutral?.light, b.neutral?.light) || !sameRamp(a.neutral?.dark, b.neutral?.dark)) return false;
2698
+ if (!samePair(a.accent, b.accent) || !samePair(a.green, b.green)) return false;
2699
+ if (!samePair(a.amber, b.amber) || !samePair(a.red, b.red)) return false;
2700
+ return SHIKI_KEYS.every((k) => samePair(a.shiki?.[k], b.shiki?.[k]));
2701
+ }
2702
+ /** The two roles one appearance's neutral ramp fills, in display order. */
2703
+ const NEUTRAL_ROLES = ["background", "foreground"];
2704
+ /**
2705
+ * The ramp end that paints an appearance's background or foreground. DSH points
2706
+ * its aliases at step 00 for light surfaces and step 1000 for light text, then
2707
+ * swaps both in dark (950 surfaces, 50 text), so the same end means opposite
2708
+ * things in the two appearances. Naming the role and resolving it per appearance
2709
+ * keeps the swatches in a stable background-then-foreground order.
2710
+ */
2711
+ function neutralEndForRole(mode, role) {
2712
+ return role === "background" === (mode === "light") ? "lightest" : "darkest";
2713
+ }
2714
+ /**
2715
+ * Whether the open editor holds work a close would throw away. A brand-new
2716
+ * theme has never been committed, so it always counts as unsaved; a reopened
2717
+ * entry is dirty only once its name or one of its seeds actually moved.
2718
+ */
2719
+ function isDirty(open, current) {
2720
+ if (open.id === null) return true;
2721
+ return open.name.trim() !== open.originName.trim() || !sameSeeds(current, open.originTheme);
2722
+ }
2230
2723
  function IconChevron() {
2231
2724
  return react.createElement("svg", {
2232
2725
  width: 14,
@@ -2239,25 +2732,23 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
2239
2732
  fill: "currentColor"
2240
2733
  }));
2241
2734
  }
2242
- function IconCheck() {
2735
+ /** The menu's selected-row tick: 16px, pushed to the trailing edge. */
2736
+ function IconCheck(props) {
2737
+ const { size = 16, className } = props;
2243
2738
  return react.createElement("svg", {
2244
- width: 16,
2245
- height: 16,
2739
+ width: size,
2740
+ height: size,
2246
2741
  viewBox: "0 0 16 16",
2247
2742
  fill: "none",
2248
2743
  xmlns: "http://www.w3.org/2000/svg",
2249
- style: {
2250
- marginLeft: "auto",
2251
- color: "var(--dsw-alias-label-primary)",
2252
- display: "inline-flex"
2253
- }
2744
+ className
2254
2745
  }, react.createElement("path", {
2255
2746
  d: "M15.0498 3.92579L8.49512 12.3818C8.25774 12.6881 8.04517 12.9645 7.84668 13.1689C7.63957 13.3823 7.38732 13.5841 7.04492 13.6719C6.86373 13.7183 6.6757 13.7346 6.48926 13.7197C6.13666 13.6915 5.8528 13.5355 5.6123 13.3604C5.38201 13.1926 5.12573 12.9567 4.83984 12.6953L1.03125 9.21289L1.96875 8.1875L5.77734 11.6699C6.08684 11.9529 6.27773 12.1249 6.43066 12.2363C6.50183 12.2882 6.54699 12.3135 6.57324 12.3252C6.58525 12.3305 6.59269 12.3322 6.5957 12.333C6.59802 12.3336 6.59961 12.334 6.59961 12.334C6.63317 12.3367 6.66758 12.3335 6.7002 12.3252C6.7002 12.3252 6.70211 12.3251 6.7041 12.3242C6.70698 12.3229 6.71348 12.319 6.72461 12.3115C6.74849 12.2956 6.78843 12.2642 6.84961 12.2012C6.98138 12.0654 7.13957 11.8628 7.39648 11.5313L13.9502 3.07422L15.0498 3.92579Z",
2256
2747
  fill: "currentColor"
2257
2748
  }));
2258
2749
  }
2259
2750
  function SchemeMenu(props) {
2260
- const { value, options, onSelect } = props;
2751
+ const { value, options, onSelect, disabled } = props;
2261
2752
  const [open, setOpen] = react.useState(false);
2262
2753
  const rootRef = react.useRef(null);
2263
2754
  const listRef = react.useRef(null);
@@ -2289,10 +2780,11 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
2289
2780
  }, react.createElement("button", {
2290
2781
  type: "button",
2291
2782
  className: "ct-select",
2783
+ disabled,
2292
2784
  "aria-haspopup": "menu",
2293
- "aria-expanded": open,
2785
+ "aria-expanded": open && !disabled,
2294
2786
  onClick: () => setOpen(!open)
2295
- }, react.createElement("span", { className: "ct-select-label" }, selected?.label ?? ""), react.createElement("span", { className: "ct-select-chevron" }, react.createElement(IconChevron, null))), open ? react.createElement("div", {
2787
+ }, react.createElement("span", { className: "ct-select-label" }, selected?.label ?? ""), react.createElement("span", { className: "ct-select-chevron" }, react.createElement(IconChevron, null))), open && !disabled ? react.createElement("div", {
2296
2788
  ref: listRef,
2297
2789
  className: "ct-menu-list",
2298
2790
  style: {
@@ -2314,27 +2806,429 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
2314
2806
  setOpen(false);
2315
2807
  onSelect(o.value);
2316
2808
  }
2317
- }, react.createElement("span", { className: "ct-menu-item-label" }, o.label), sel ? react.createElement(IconCheck, null) : null);
2809
+ }, react.createElement("span", { className: "ct-menu-item-label" }, o.label), sel ? react.createElement(IconCheck, { className: "ct-menu-check" }) : null);
2318
2810
  })) : null);
2319
2811
  }
2812
+ /** Accessible on/off switch used to enable the custom theme. */
2813
+ function Switch(props) {
2814
+ const { checked, label, onChange } = props;
2815
+ return react.createElement("button", {
2816
+ type: "button",
2817
+ role: "switch",
2818
+ "aria-checked": checked,
2819
+ "aria-label": label,
2820
+ className: checked ? "ct-switch ct-switch-on" : "ct-switch",
2821
+ onClick: () => onChange(!checked)
2822
+ }, react.createElement("span", { className: "ct-switch-knob" }));
2823
+ }
2824
+ /**
2825
+ * Confirmation dialog for destructive actions, mirroring DSH's own Modal
2826
+ * (design-platform mask token + blur, r24 card on layer-2, prominent shadow).
2827
+ * Rendered inline rather than through a portal so it needs no react-dom import.
2828
+ */
2829
+ function ConfirmDialog(props) {
2830
+ const { title, description, confirmLabel, cancelLabel, onConfirm, onCancel } = props;
2831
+ react.useEffect(() => {
2832
+ const onKey = (e) => {
2833
+ if (e.key === "Escape") onCancel();
2834
+ };
2835
+ document.addEventListener("keydown", onKey);
2836
+ return () => document.removeEventListener("keydown", onKey);
2837
+ }, [onCancel]);
2838
+ return react.createElement("div", { className: "ct-modal-root" }, react.createElement("div", {
2839
+ className: "ct-modal-mask",
2840
+ "aria-hidden": true,
2841
+ onClick: onCancel
2842
+ }), react.createElement("div", {
2843
+ className: "ct-modal-card",
2844
+ role: "dialog",
2845
+ "aria-modal": true,
2846
+ "aria-label": title
2847
+ }, react.createElement("div", { className: "ct-modal-title" }, title), react.createElement("div", { className: "ct-modal-desc" }, description), react.createElement("div", { className: "ct-modal-footer" }, react.createElement("button", {
2848
+ type: "button",
2849
+ className: "ct-btn",
2850
+ onClick: onCancel
2851
+ }, cancelLabel), react.createElement("button", {
2852
+ type: "button",
2853
+ className: "ct-btn ct-btn-danger",
2854
+ onClick: onConfirm
2855
+ }, confirmLabel))));
2856
+ }
2857
+ /** Which banner a failed roster call deserves. */
2858
+ function toastKeyFor(error) {
2859
+ return error instanceof ThemeApiUnavailableError ? "custom.toast.unavailable" : "custom.toast.failed";
2860
+ }
2861
+ /**
2862
+ * Transient confirmation banner: a leading result badge plus the copy, top
2863
+ * center, sliding in, holding, fading out, then reporting done so the owner can
2864
+ * unmount it. Metrics, tokens, badge and timing follow DSH's own toast — the
2865
+ * badge is the success circle its confirmations wear — but the component is
2866
+ * local: the plugin must keep answering a save on a host that does not share
2867
+ * the primitives module.
2868
+ *
2869
+ * TOAST_HOLD_MS and TOAST_FADE_MS have to agree with the `ct-toast` animation
2870
+ * in the injected stylesheet, which owns the visual timing.
2871
+ * @param props.text - resolved, already-localized copy.
2872
+ * @param props.onDone - called once the fade completes; unmount the toast here.
2873
+ */
2874
+ function Toast(props) {
2875
+ const { text, onDone } = props;
2876
+ react.useEffect(() => {
2877
+ const timer = setTimeout(onDone, 4e3);
2878
+ return () => clearTimeout(timer);
2879
+ }, [onDone]);
2880
+ return react.createElement("div", {
2881
+ className: "ct-toast",
2882
+ role: "status"
2883
+ }, react.createElement(ToastBadge), react.createElement("span", { className: "ct-toast-text" }, text));
2884
+ }
2885
+ /** The leading badge every confirmation wears: a success ring around the check. */
2886
+ function ToastBadge() {
2887
+ return react.createElement("span", {
2888
+ className: "ct-toast-icon",
2889
+ "aria-hidden": true
2890
+ }, react.createElement(IconCheck, { size: 12 }));
2891
+ }
2892
+ /**
2893
+ * Plus glyph for the add button, path data taken verbatim from DSH's own
2894
+ * `IconPlusOutline16` so the two controls read as the same affordance.
2895
+ */
2896
+ function IconPlus(props) {
2897
+ const { size = 14 } = props;
2898
+ return react.createElement("svg", {
2899
+ width: size,
2900
+ height: size,
2901
+ viewBox: "0 0 16 16",
2902
+ fill: "none",
2903
+ xmlns: "http://www.w3.org/2000/svg",
2904
+ "aria-hidden": true,
2905
+ focusable: false
2906
+ }, react.createElement("path", {
2907
+ d: "M8.64453 1.5V7.34961H14.5V8.65039H8.64453V14.5H7.34473V8.65039H1.5V7.34961H7.34473V1.5H8.64453Z",
2908
+ fill: "currentColor"
2909
+ }));
2910
+ }
2911
+ /**
2912
+ * One collapsed saved-theme card: the name activates the theme, and the trailing
2913
+ * controls open the editor or delete it. The card as a whole is a click target
2914
+ * too, but its controls own their clicks.
2915
+ */
2916
+ function ThemeCard(props) {
2917
+ const { entry, active, disabled, t, onActivate, onEdit, onDelete } = props;
2918
+ /** One dense capsule action; `danger` is the borderless delete variant. */
2919
+ const action = (label, onClick, danger) => react.createElement("button", {
2920
+ type: "button",
2921
+ className: danger ? "ct-list-btn ct-list-btn-danger" : "ct-list-btn",
2922
+ onClick
2923
+ }, label);
2924
+ return react.createElement("div", {
2925
+ className: "ct-list-row",
2926
+ onClick: disabled ? void 0 : onActivate
2927
+ }, react.createElement("span", { className: "ct-list-identity" }, react.createElement("button", {
2928
+ className: "ct-list-name",
2929
+ type: "button",
2930
+ disabled,
2931
+ onClick: (e) => {
2932
+ e.stopPropagation();
2933
+ onActivate();
2934
+ }
2935
+ }, entry.name)), react.createElement("div", {
2936
+ className: "ct-list-actions",
2937
+ onClick: (e) => e.stopPropagation()
2938
+ }, active ? react.createElement("span", { className: "ct-list-badge" }, t("custom.inUse")) : null, action(t("custom.edit"), onEdit), action(t("custom.delete"), onDelete, true)));
2939
+ }
2940
+ /**
2941
+ * Which appearance the colour dots edit. `system` resolves through the same
2942
+ * media query the shell uses, so the editor opens on what is on screen.
2943
+ */
2944
+ function effectiveMode(scheme) {
2945
+ if (scheme === "dark") return "dark";
2946
+ if (scheme === "light") return "light";
2947
+ try {
2948
+ return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
2949
+ } catch {
2950
+ return "light";
2951
+ }
2952
+ }
2320
2953
  function ThemePanel(props) {
2321
- const { theme, getPreset, setPreset, t } = props;
2954
+ const { theme, getSelection, setSelection, getCustom, setCustom, setCustomBase, resetCustom, saved, getHostToast, t, onRequestClose } = props;
2322
2955
  let initScheme = "system";
2323
2956
  try {
2324
2957
  const snap = theme?.getTheme();
2325
2958
  if (snap?.preference) initScheme = snap.preference;
2326
2959
  } catch {}
2327
2960
  const [scheme, setScheme] = react.useState(initScheme);
2328
- const [active, setActive] = react.useState(() => getPreset());
2329
- function pickScheme(id) {
2961
+ const [seedMode, setSeedMode] = react.useState(() => effectiveMode(initScheme));
2962
+ react.useEffect(() => {
2963
+ setSeedMode(effectiveMode(scheme));
2964
+ }, [scheme]);
2965
+ const [selection, setSelectionState] = react.useState(() => getSelection());
2966
+ const [custom, setCustomState] = react.useState(() => getCustom().theme);
2967
+ const [shownPreset, setShownPreset] = react.useState(() => {
2968
+ const initial = getSelection();
2969
+ return initial === CUSTOM_SELECTION ? getCustom().base : initial;
2970
+ });
2971
+ const [list, setList] = react.useState(() => saved.list());
2972
+ const [activeId, setActiveId] = react.useState(() => saved.activeId());
2973
+ const [pendingDelete, setPendingDelete] = react.useState(null);
2974
+ const [editing, setEditing] = react.useState(null);
2975
+ const [confirmClose, setConfirmClose] = react.useState(false);
2976
+ const [confirmLeave, setConfirmLeave] = react.useState(false);
2977
+ const editingRef = react.useRef(null);
2978
+ editingRef.current = editing;
2979
+ const savingRef = react.useRef(false);
2980
+ const schemeRef = react.useRef(scheme);
2981
+ schemeRef.current = scheme;
2982
+ const dirtyRef = react.useRef(false);
2983
+ dirtyRef.current = editing !== null && isDirty(editing, custom);
2984
+ const confirmCloseRef = react.useRef(false);
2985
+ confirmCloseRef.current = confirmClose;
2986
+ const confirmLeaveRef = react.useRef(false);
2987
+ confirmLeaveRef.current = confirmLeave;
2988
+ const pendingDeleteRef = react.useRef(null);
2989
+ pendingDeleteRef.current = pendingDelete;
2990
+ const [toast, setToast] = react.useState(null);
2991
+ const toastSeq = react.useRef(0);
2992
+ function showToast(text) {
2993
+ toastSeq.current += 1;
2994
+ setToast({
2995
+ seq: toastSeq.current,
2996
+ text
2997
+ });
2998
+ }
2999
+ /** Re-read both the list and the active pointer after any list mutation. */
3000
+ function syncSaved() {
3001
+ setList(saved.list());
3002
+ setActiveId(saved.activeId());
3003
+ }
3004
+ react.useEffect(() => {
3005
+ let cancelled = false;
3006
+ saved.refresh().then(() => {
3007
+ if (!cancelled) syncSaved();
3008
+ }).catch((error) => {
3009
+ if (!cancelled) showToast(t(toastKeyFor(error)));
3010
+ });
3011
+ return () => {
3012
+ cancelled = true;
3013
+ };
3014
+ }, []);
3015
+ /** Run one roster mutation, surfacing a failed round trip as a banner. */
3016
+ async function mutate(operation) {
3017
+ try {
3018
+ return await operation();
3019
+ } catch (error) {
3020
+ showToast(t(toastKeyFor(error)));
3021
+ return null;
3022
+ }
3023
+ }
3024
+ function onLoadSaved(id) {
3025
+ const next = saved.load(id);
3026
+ if (next) setCustomState(next);
3027
+ syncSaved();
3028
+ }
3029
+ /** Snapshot the state an edit must return to when it is cancelled. */
3030
+ function beginEditing(id, name, originName, originTheme) {
3031
+ return {
3032
+ id,
3033
+ name,
3034
+ originId: saved.activeId(),
3035
+ originDraft: getCustom().theme,
3036
+ originName,
3037
+ originTheme,
3038
+ originScheme: schemeRef.current
3039
+ };
3040
+ }
3041
+ /** Commit an appearance preference, through the Theme service when it is there. */
3042
+ function applyAppearance(id) {
2330
3043
  setScheme(id);
2331
3044
  try {
2332
3045
  theme?.setTheme(id);
2333
3046
  } catch {}
2334
3047
  }
3048
+ /**
3049
+ * Preview an appearance on the whole page while its colours are edited: a
3050
+ * custom theme carries both variants, so the other one's seeds are only
3051
+ * visible once the page actually wears that appearance. A no-op when the page
3052
+ * already resolves to `mode` — clicking the active half of the toggle must not
3053
+ * quietly turn a `system` preference into a pinned one.
3054
+ */
3055
+ function previewAppearance(mode) {
3056
+ if (effectiveMode(schemeRef.current) === mode) return;
3057
+ applyAppearance(mode);
3058
+ }
3059
+ /**
3060
+ * End a preview: put the appearance the editor opened on back. The preference
3061
+ * is a global setting the mode toggle only borrows, so it must not survive the
3062
+ * editor — not even on save, which commits the colours alone.
3063
+ */
3064
+ function restoreAppearance(open) {
3065
+ if (schemeRef.current === open.originScheme) return;
3066
+ applyAppearance(open.originScheme);
3067
+ }
3068
+ /**
3069
+ * Undo an open (unsaved) edit: editing previews live and persists on every
3070
+ * change, so abandoning the card has to write the pre-edit theme back.
3071
+ */
3072
+ function revertEditing(open) {
3073
+ restoreAppearance(open);
3074
+ if (open.originId) onLoadSaved(open.originId);
3075
+ else {
3076
+ setCustom(open.originDraft);
3077
+ setCustomState(open.originDraft);
3078
+ saved.setActive(null);
3079
+ }
3080
+ }
3081
+ react.useEffect(() => () => {
3082
+ const open = editingRef.current;
3083
+ if (!open || savingRef.current) return;
3084
+ if (schemeRef.current !== open.originScheme) try {
3085
+ theme?.setTheme(open.originScheme);
3086
+ } catch {}
3087
+ if (open.originId) saved.load(open.originId);
3088
+ else {
3089
+ setCustom(open.originDraft);
3090
+ saved.setActive(null);
3091
+ }
3092
+ }, []);
3093
+ react.useEffect(() => {
3094
+ /** The header button carrying the `settings.close` seat, if `node` is in it. */
3095
+ function isCloseButton(node) {
3096
+ const seat = node.ownerDocument.querySelector("[data-slot=\"settings.close\"]");
3097
+ const button = node.closest("button");
3098
+ return !!seat && !!button && button.contains(seat);
3099
+ }
3100
+ /** The settings panel itself; the close seat is unique to it. */
3101
+ function settingsPanel() {
3102
+ return document.querySelector("[data-slot=\"settings.close\"]")?.closest("[role=\"dialog\"]") ?? null;
3103
+ }
3104
+ function onKeyDown(e) {
3105
+ if (e.key !== "Escape") return;
3106
+ if (confirmCloseRef.current || confirmLeaveRef.current) {
3107
+ e.preventDefault();
3108
+ e.stopPropagation();
3109
+ setConfirmClose(false);
3110
+ setConfirmLeave(false);
3111
+ return;
3112
+ }
3113
+ if (!dirtyRef.current || !onRequestClose) return;
3114
+ e.preventDefault();
3115
+ e.stopPropagation();
3116
+ setConfirmClose(true);
3117
+ }
3118
+ function onClick(e) {
3119
+ if (!dirtyRef.current || confirmCloseRef.current || confirmLeaveRef.current || pendingDeleteRef.current) return;
3120
+ const target = e.target instanceof Element ? e.target : null;
3121
+ if (!target) return;
3122
+ if (target.closest("[data-ct-editor]")) return;
3123
+ const panel = settingsPanel();
3124
+ if (!!panel && !panel.contains(target) || isCloseButton(target)) {
3125
+ if (!onRequestClose) return;
3126
+ e.preventDefault();
3127
+ e.stopPropagation();
3128
+ setConfirmClose(true);
3129
+ return;
3130
+ }
3131
+ if (!panel || !panel.contains(target)) return;
3132
+ e.preventDefault();
3133
+ e.stopPropagation();
3134
+ setConfirmLeave(true);
3135
+ }
3136
+ document.addEventListener("keydown", onKeyDown, true);
3137
+ document.addEventListener("click", onClick, true);
3138
+ return () => {
3139
+ document.removeEventListener("keydown", onKeyDown, true);
3140
+ document.removeEventListener("click", onClick, true);
3141
+ };
3142
+ }, [onRequestClose]);
3143
+ /** Open a fresh, unsaved theme seeded from the currently selected preset. */
3144
+ function startAdd() {
3145
+ const origin = beginEditing(null, nextThemeName(t("custom.defaultName"), list.map((e) => e.name)), "", getCustom().theme);
3146
+ setCustomBase(shownPreset);
3147
+ const draft = resetCustom();
3148
+ setEditing(origin);
3149
+ setCustomState(draft);
3150
+ }
3151
+ /** Open an existing card for editing; it also becomes the previewed theme. */
3152
+ function startEdit(entry) {
3153
+ const next = beginEditing(entry.id, entry.name, entry.name, entry.theme);
3154
+ setEditing(next);
3155
+ onLoadSaved(entry.id);
3156
+ }
3157
+ /** Discard the open editor and put the previously applied theme back. */
3158
+ function cancelEdit() {
3159
+ if (!editing) return;
3160
+ revertEditing(editing);
3161
+ setEditing(null);
3162
+ syncSaved();
3163
+ }
3164
+ /** Commit the open editor: create a new entry, or update the one being edited. */
3165
+ async function saveEdit() {
3166
+ if (!editing) return;
3167
+ const name = editing.name.trim() || t("custom.defaultName");
3168
+ const id = editing.id;
3169
+ savingRef.current = true;
3170
+ const next = await mutate(() => id === null ? saved.create(name) : saved.update(id, name));
3171
+ savingRef.current = false;
3172
+ if (next === null) return;
3173
+ setEditing(null);
3174
+ restoreAppearance(editing);
3175
+ syncSaved();
3176
+ setCustomState(getCustom().theme);
3177
+ showToast(t("custom.toast.saved"));
3178
+ }
3179
+ async function onConfirmDelete() {
3180
+ if (!pendingDelete) return;
3181
+ const id = pendingDelete.id;
3182
+ setPendingDelete(null);
3183
+ setEditing((cur) => cur?.id === id ? null : cur);
3184
+ const next = await mutate(() => saved.remove(id));
3185
+ if (next === null) return;
3186
+ setActiveId(saved.activeId());
3187
+ setCustomState(getCustom().theme);
3188
+ setList(saved.list());
3189
+ if (next.length === 0) {
3190
+ setEditing(null);
3191
+ setCustomBase(shownPreset);
3192
+ setCustomState(resetCustom());
3193
+ }
3194
+ }
3195
+ function pickScheme(id) {
3196
+ applyAppearance(id);
3197
+ setEditing((cur) => cur ? {
3198
+ ...cur,
3199
+ originScheme: id
3200
+ } : cur);
3201
+ }
3202
+ /**
3203
+ * The preset picker. Unreachable while custom mode is on — the picker is
3204
+ * disabled then — so this only ever applies the chosen preset.
3205
+ */
2335
3206
  function pickPreset(id) {
2336
- setActive(id);
2337
- setPreset(id);
3207
+ setSelectionState(id);
3208
+ setSelection(id);
3209
+ setShownPreset(id);
3210
+ }
3211
+ /**
3212
+ * The custom switch. Preset and custom are independent selections: turning
3213
+ * custom on leaves the preset row untouched, and turning it off goes back to
3214
+ * the preset the user had chosen, not to the custom theme's own base.
3215
+ */
3216
+ function toggleCustom(on) {
3217
+ if (on) {
3218
+ setSelectionState(CUSTOM_SELECTION);
3219
+ setSelection(CUSTOM_SELECTION);
3220
+ setCustomState(getCustom().theme);
3221
+ return;
3222
+ }
3223
+ if (editing) revertEditing(editing);
3224
+ setEditing(null);
3225
+ setSelectionState(shownPreset);
3226
+ setSelection(shownPreset);
3227
+ setShownPreset(shownPreset);
3228
+ }
3229
+ function editCustom(next) {
3230
+ setCustomState(next);
3231
+ setCustom(next);
2338
3232
  }
2339
3233
  const schemeOptions = [
2340
3234
  {
@@ -2350,20 +3244,227 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
2350
3244
  label: t("scheme.system")
2351
3245
  }
2352
3246
  ];
3247
+ const customOn = selection === CUSTOM_SELECTION;
3248
+ const children = [
3249
+ react.createElement("div", {
3250
+ className: "ct-row",
3251
+ key: "appearance"
3252
+ }, react.createElement("div", { className: "ct-row-main" }, react.createElement("div", { className: "ct-row-title" }, t("appearance.title")), react.createElement("div", { className: "ct-row-desc" }, t("appearance.desc"))), react.createElement(SchemeMenu, {
3253
+ value: scheme,
3254
+ options: schemeOptions,
3255
+ onSelect: pickScheme
3256
+ })),
3257
+ react.createElement("div", {
3258
+ className: "ct-row",
3259
+ key: "presets"
3260
+ }, react.createElement("div", { className: "ct-row-main" }, react.createElement("div", { className: "ct-row-title" }, t("presets.title")), react.createElement("div", { className: "ct-row-desc" }, t(customOn ? "presets.disabled" : "presets.desc"))), react.createElement(SchemeMenu, {
3261
+ value: customOn ? shownPreset : selection,
3262
+ options: presetOptions,
3263
+ onSelect: (v) => pickPreset(v),
3264
+ disabled: customOn
3265
+ })),
3266
+ react.createElement("div", {
3267
+ className: "ct-row ct-row-flush",
3268
+ key: "custom-toggle"
3269
+ }, react.createElement("div", { className: "ct-row-main" }, react.createElement("div", { className: "ct-row-title" }, t("custom.title")), react.createElement("div", { className: "ct-row-desc" }, t("custom.toggle.desc"))), react.createElement(Switch, {
3270
+ checked: customOn,
3271
+ label: t("custom.title"),
3272
+ onChange: toggleCustom
3273
+ }))
3274
+ ];
3275
+ if (customOn) {
3276
+ /** A group row: its name on the left, every seed's dot on the right. */
3277
+ const dotRow = (key, label, dots) => react.createElement("div", {
3278
+ className: "ct-seed-row",
3279
+ key
3280
+ }, react.createElement("div", { className: "ct-seed-label" }, label), react.createElement("div", { className: "ct-seed-dots" }, ...dots.map((d) => react.createElement("label", {
3281
+ key: d.key,
3282
+ className: "ct-seed-dot",
3283
+ style: { background: d.value },
3284
+ "data-tip": d.name
3285
+ }, react.createElement("input", {
3286
+ className: "ct-seed-input",
3287
+ type: "color",
3288
+ value: d.value,
3289
+ "aria-label": `${d.name} — ${d.hint}`,
3290
+ onChange: (e) => d.onChange(e.target.value.toUpperCase())
3291
+ })))));
3292
+ const pair = (key, title, desc) => ({
3293
+ key,
3294
+ name: t(title),
3295
+ hint: t(desc),
3296
+ value: custom[key][seedMode],
3297
+ onChange: (v) => editCustom({
3298
+ ...custom,
3299
+ [key]: {
3300
+ ...custom[key],
3301
+ [seedMode]: v
3302
+ }
3303
+ })
3304
+ });
3305
+ const neutralDots = NEUTRAL_ROLES.map((role) => {
3306
+ const end = neutralEndForRole(seedMode, role);
3307
+ return {
3308
+ key: `neutral-${end}`,
3309
+ name: t(role === "background" ? "custom.neutral.roleBackground" : "custom.neutral.roleForeground"),
3310
+ hint: `${t("custom.neutral.title")} · ${t("custom.neutral.desc")} — ${t(end === "lightest" ? "custom.neutral.lightest" : "custom.neutral.darkest")}`,
3311
+ value: custom.neutral[seedMode][end],
3312
+ onChange: (v) => editCustom({
3313
+ ...custom,
3314
+ neutral: {
3315
+ ...custom.neutral,
3316
+ [seedMode]: {
3317
+ ...custom.neutral[seedMode],
3318
+ [end]: v
3319
+ }
3320
+ }
3321
+ })
3322
+ };
3323
+ });
3324
+ const seedDots = [
3325
+ pair("accent", "custom.accent.title", "custom.accent.desc"),
3326
+ ...neutralDots,
3327
+ pair("green", "custom.green.title", "custom.green.desc"),
3328
+ pair("amber", "custom.amber.title", "custom.amber.desc"),
3329
+ pair("red", "custom.red.title", "custom.red.desc")
3330
+ ];
3331
+ const shikiDots = SHIKI_KEYS.map((k) => ({
3332
+ key: k,
3333
+ name: t(`shiki.${k}`),
3334
+ hint: t("custom.shiki.desc"),
3335
+ value: custom.shiki[k][seedMode],
3336
+ onChange: (v) => editCustom({
3337
+ ...custom,
3338
+ shiki: {
3339
+ ...custom.shiki,
3340
+ [k]: {
3341
+ ...custom.shiki[k],
3342
+ [seedMode]: v
3343
+ }
3344
+ }
3345
+ })
3346
+ }));
3347
+ /** The light/dark segmented control above the dots. */
3348
+ const modeButton = (id, label) => react.createElement("button", {
3349
+ key: id,
3350
+ type: "button",
3351
+ className: seedMode === id ? "ct-mode-btn ct-mode-btn-on" : "ct-mode-btn",
3352
+ "aria-pressed": seedMode === id,
3353
+ onClick: () => {
3354
+ setSeedMode(id);
3355
+ previewAppearance(id);
3356
+ }
3357
+ }, label);
3358
+ const modeToggle = react.createElement("div", {
3359
+ className: "ct-mode",
3360
+ role: "group",
3361
+ "aria-label": t("custom.mode.label")
3362
+ }, modeButton("light", t("custom.seed.light")), modeButton("dark", t("custom.seed.dark")));
3363
+ const editorName = react.createElement("input", {
3364
+ className: "ct-editor-name",
3365
+ type: "text",
3366
+ value: editing?.name ?? "",
3367
+ placeholder: t("custom.name.placeholder"),
3368
+ "aria-label": t("custom.name.placeholder"),
3369
+ autoFocus: true,
3370
+ onChange: (e) => setEditing((cur) => cur ? {
3371
+ ...cur,
3372
+ name: e.target.value
3373
+ } : cur),
3374
+ onKeyDown: (e) => {
3375
+ if (e.key === "Enter") saveEdit();
3376
+ else if (e.key === "Escape") cancelEdit();
3377
+ }
3378
+ });
3379
+ const editorActions = react.createElement("div", { className: "ct-editor-actions" }, react.createElement("button", {
3380
+ type: "button",
3381
+ className: "ct-btn",
3382
+ onClick: cancelEdit
3383
+ }, t("custom.cancel")), react.createElement("button", {
3384
+ type: "button",
3385
+ className: "ct-btn ct-btn-primary",
3386
+ onClick: () => void saveEdit()
3387
+ }, t("custom.save")));
3388
+ const editorCard = (key) => react.createElement("div", {
3389
+ className: "ct-editor",
3390
+ key,
3391
+ "data-ct-editor": "true"
3392
+ }, react.createElement("div", { className: "ct-editor-head" }, editorName, modeToggle), dotRow("seeds", t("custom.group.seeds"), seedDots), dotRow("shiki", t("custom.group.shiki"), shikiDots), editorActions);
3393
+ const cards = list.map((entry) => editing && editing.id === entry.id ? editorCard(entry.id) : react.createElement(ThemeCard, {
3394
+ key: entry.id,
3395
+ entry,
3396
+ active: activeId === entry.id,
3397
+ disabled: editing !== null,
3398
+ t,
3399
+ onActivate: () => onLoadSaved(entry.id),
3400
+ onEdit: () => startEdit(entry),
3401
+ onDelete: () => setPendingDelete(entry)
3402
+ }));
3403
+ if (editing && editing.id === null) cards.push(editorCard("new-editor"));
3404
+ children.push(react.createElement("div", {
3405
+ className: "ct-list",
3406
+ key: "saved-list"
3407
+ }, ...cards), react.createElement("button", {
3408
+ key: "add",
3409
+ type: "button",
3410
+ className: "ct-add-btn",
3411
+ disabled: editing !== null,
3412
+ onClick: startAdd
3413
+ }, react.createElement(IconPlus, null), t("custom.add")));
3414
+ }
3415
+ if (pendingDelete) children.push(react.createElement(ConfirmDialog, {
3416
+ key: "confirm-delete",
3417
+ title: t("custom.delete.title"),
3418
+ description: t("custom.delete.desc").replace("{0}", pendingDelete.name),
3419
+ confirmLabel: t("custom.delete"),
3420
+ cancelLabel: t("custom.cancel"),
3421
+ onConfirm: onConfirmDelete,
3422
+ onCancel: () => setPendingDelete(null)
3423
+ }));
3424
+ if (confirmClose && editing && onRequestClose) children.push(react.createElement(ConfirmDialog, {
3425
+ key: "confirm-close",
3426
+ title: t("custom.unsaved.title"),
3427
+ description: t("custom.unsaved.desc").replace("{0}", editing.name.trim() || t("custom.defaultName")),
3428
+ confirmLabel: t("custom.unsaved.confirm"),
3429
+ cancelLabel: t("custom.unsaved.cancel"),
3430
+ onConfirm: () => {
3431
+ setConfirmClose(false);
3432
+ onRequestClose();
3433
+ },
3434
+ onCancel: () => setConfirmClose(false)
3435
+ }));
3436
+ if (confirmLeave && editing) children.push(react.createElement(ConfirmDialog, {
3437
+ key: "confirm-leave",
3438
+ title: t("custom.unsaved.title"),
3439
+ description: t("custom.unsaved.leaveDesc").replace("{0}", editing.name.trim() || t("custom.defaultName")),
3440
+ confirmLabel: t("custom.unsaved.leaveConfirm"),
3441
+ cancelLabel: t("custom.unsaved.cancel"),
3442
+ onConfirm: () => {
3443
+ setConfirmLeave(false);
3444
+ cancelEdit();
3445
+ },
3446
+ onCancel: () => setConfirmLeave(false)
3447
+ }));
3448
+ if (toast) {
3449
+ const onDone = () => setToast((cur) => cur?.seq === toast.seq ? null : cur);
3450
+ const HostToast = getHostToast();
3451
+ children.push(HostToast ? react.createElement(HostToast, {
3452
+ key: `toast-${String(toast.seq)}`,
3453
+ text: toast.text,
3454
+ icon: react.createElement(ToastBadge),
3455
+ onDone
3456
+ }) : react.createElement(Toast, {
3457
+ key: `toast-${String(toast.seq)}`,
3458
+ text: toast.text,
3459
+ onDone
3460
+ }));
3461
+ }
2353
3462
  return react.createElement("div", { style: {
2354
3463
  display: "flex",
2355
3464
  flexDirection: "column",
2356
3465
  maxWidth: 760,
2357
3466
  paddingBottom: 8
2358
- } }, react.createElement("div", { className: "ct-row" }, react.createElement("div", { className: "ct-row-main" }, react.createElement("div", { className: "ct-row-title" }, t("appearance.title")), react.createElement("div", { className: "ct-row-desc" }, t("appearance.desc"))), react.createElement(SchemeMenu, {
2359
- value: scheme,
2360
- options: schemeOptions,
2361
- onSelect: pickScheme
2362
- })), react.createElement("div", { className: "ct-row" }, react.createElement("div", { className: "ct-row-main" }, react.createElement("div", { className: "ct-row-title" }, t("presets.title")), react.createElement("div", { className: "ct-row-desc" }, t("presets.desc"))), react.createElement(SchemeMenu, {
2363
- value: active,
2364
- options: presetOptions,
2365
- onSelect: (v) => pickPreset(v)
2366
- })));
3467
+ } }, ...children);
2367
3468
  }
2368
3469
  //#endregion
2369
3470
  //#region src/client/locales.ts
@@ -2379,9 +3480,60 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
2379
3480
  "appearance.desc": "深色 / 浅色 / 跟随系统",
2380
3481
  "presets.title": "预设主题",
2381
3482
  "presets.desc": "一键套用主流配色方案",
3483
+ "presets.disabled": "自定义主题已启用,关闭后才能选择预设",
2382
3484
  "scheme.light": "浅色",
2383
3485
  "scheme.dark": "深色",
2384
- "scheme.system": "跟随系统"
3486
+ "scheme.system": "跟随系统",
3487
+ "custom.title": "自定义主题",
3488
+ "custom.toggle.desc": "创建属于自己的个性化主题",
3489
+ "custom.group.seeds": "主色/辅助色",
3490
+ "custom.group.shiki": "代码块",
3491
+ "custom.mode.label": "编辑的外观",
3492
+ "custom.neutral.title": "中性色",
3493
+ "custom.neutral.desc": "界面底色与正文。浅色与深色各一条色阶",
3494
+ "custom.neutral.lightest": "最浅色端",
3495
+ "custom.neutral.darkest": "最深色端",
3496
+ "custom.neutral.roleBackground": "背景色",
3497
+ "custom.neutral.roleForeground": "前景色",
3498
+ "custom.accent.title": "主色",
3499
+ "custom.accent.desc": "按钮与强调色(deepseek 与 blue 合并为一组)",
3500
+ "custom.green.title": "成功",
3501
+ "custom.green.desc": "成功状态",
3502
+ "custom.amber.title": "警告",
3503
+ "custom.amber.desc": "警告状态",
3504
+ "custom.red.title": "错误",
3505
+ "custom.red.desc": "错误状态",
3506
+ "custom.shiki.desc": "9 个语法标记的颜色",
3507
+ "custom.seed.light": "浅色",
3508
+ "custom.seed.dark": "深色",
3509
+ "custom.save": "保存",
3510
+ "custom.add": "添加自定义主题",
3511
+ "custom.edit": "编辑",
3512
+ "custom.name.placeholder": "主题名称",
3513
+ "custom.defaultName": "自定义主题",
3514
+ "custom.inUse": "使用中",
3515
+ "custom.delete": "删除",
3516
+ "custom.cancel": "取消",
3517
+ "custom.toast.saved": "已保存",
3518
+ "custom.toast.failed": "主题文件操作失败",
3519
+ "custom.toast.unavailable": "主题文件接口未就绪,请重启 DSH 服务",
3520
+ "custom.delete.title": "删除该自定义主题?",
3521
+ "custom.delete.desc": "将删除「{0}」,且无法恢复。",
3522
+ "custom.unsaved.title": "放弃未保存的修改?",
3523
+ "custom.unsaved.desc": "「{0}」的修改尚未保存,关闭设置后将丢失。",
3524
+ "custom.unsaved.confirm": "放弃并关闭",
3525
+ "custom.unsaved.leaveDesc": "「{0}」的修改尚未保存,是否确认放弃?",
3526
+ "custom.unsaved.leaveConfirm": "放弃修改",
3527
+ "custom.unsaved.cancel": "继续编辑",
3528
+ "shiki.constant": "常量",
3529
+ "shiki.string": "字符串",
3530
+ "shiki.comment": "注释",
3531
+ "shiki.keyword": "关键字",
3532
+ "shiki.parameter": "参数",
3533
+ "shiki.function": "函数",
3534
+ "shiki.string-expression": "模板字符串",
3535
+ "shiki.punctuation": "标点",
3536
+ "shiki.link": "链接"
2385
3537
  };
2386
3538
  /** English dictionary, checked complete against the zh key set. */
2387
3539
  const en = {
@@ -2390,15 +3542,67 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
2390
3542
  "appearance.desc": "Dark / Light / System",
2391
3543
  "presets.title": "Preset themes",
2392
3544
  "presets.desc": "Apply popular color schemes in one click",
3545
+ "presets.disabled": "Custom theme is on — turn it off to choose a preset",
2393
3546
  "scheme.light": "Light",
2394
3547
  "scheme.dark": "Dark",
2395
- "scheme.system": "System"
3548
+ "scheme.system": "System",
3549
+ "custom.title": "Custom theme",
3550
+ "custom.toggle.desc": "Create your own personalized theme",
3551
+ "custom.group.seeds": "Primary/supporting",
3552
+ "custom.group.shiki": "Code block",
3553
+ "custom.mode.label": "Appearance being edited",
3554
+ "custom.neutral.title": "Neutral",
3555
+ "custom.neutral.desc": "Surfaces and body text. One ramp per appearance",
3556
+ "custom.neutral.lightest": "Lightest end",
3557
+ "custom.neutral.darkest": "Darkest end",
3558
+ "custom.neutral.roleBackground": "Background",
3559
+ "custom.neutral.roleForeground": "Foreground",
3560
+ "custom.accent.title": "Accent",
3561
+ "custom.accent.desc": "Buttons and emphasis (deepseek and blue merged into one)",
3562
+ "custom.green.title": "Success",
3563
+ "custom.green.desc": "Success states",
3564
+ "custom.amber.title": "Warning",
3565
+ "custom.amber.desc": "Warning states",
3566
+ "custom.red.title": "Error",
3567
+ "custom.red.desc": "Error states",
3568
+ "custom.shiki.desc": "Colors for the nine syntax tokens",
3569
+ "custom.seed.light": "Light",
3570
+ "custom.seed.dark": "Dark",
3571
+ "custom.save": "Save",
3572
+ "custom.add": "Add custom theme",
3573
+ "custom.edit": "Edit",
3574
+ "custom.name.placeholder": "Theme name",
3575
+ "custom.defaultName": "Custom theme",
3576
+ "custom.inUse": "In use",
3577
+ "custom.delete": "Delete",
3578
+ "custom.cancel": "Cancel",
3579
+ "custom.toast.saved": "Saved",
3580
+ "custom.toast.failed": "Theme file operation failed",
3581
+ "custom.toast.unavailable": "Theme file API not ready — restart the DSH service",
3582
+ "custom.delete.title": "Delete this custom theme?",
3583
+ "custom.delete.desc": "\"{0}\" will be removed. This cannot be undone.",
3584
+ "custom.unsaved.title": "Discard unsaved changes?",
3585
+ "custom.unsaved.desc": "Changes to \"{0}\" have not been saved and will be lost when settings close.",
3586
+ "custom.unsaved.confirm": "Discard and close",
3587
+ "custom.unsaved.leaveDesc": "Changes to \"{0}\" have not been saved. Discard them?",
3588
+ "custom.unsaved.leaveConfirm": "Discard changes",
3589
+ "custom.unsaved.cancel": "Keep editing",
3590
+ "shiki.constant": "Constant",
3591
+ "shiki.string": "String",
3592
+ "shiki.comment": "Comment",
3593
+ "shiki.keyword": "Keyword",
3594
+ "shiki.parameter": "Parameter",
3595
+ "shiki.function": "Function",
3596
+ "shiki.string-expression": "Template string",
3597
+ "shiki.punctuation": "Punctuation",
3598
+ "shiki.link": "Link"
2396
3599
  };
2397
3600
  //#endregion
2398
3601
  //#region src/client/theme.tsx
2399
3602
  const BASE_CSS = [
2400
3603
  ".ct-select{box-sizing:border-box;display:inline-flex;align-items:center;gap:12px;height:36px;padding:0 14px;border:none;border-radius:18px;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-primary);cursor:pointer;font:inherit;font-size:14px;line-height:22px;white-space:nowrap;width:auto;min-width:0;max-width:100%;}",
2401
3604
  ".ct-select:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);}",
3605
+ ".ct-select:disabled{opacity:.5;cursor:not-allowed;}",
2402
3606
  ".ct-select:focus-visible{outline:none;box-shadow:0 0 0 2px var(--dsw-alias-border-l3);}",
2403
3607
  ".ct-select-label{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:left;}",
2404
3608
  ".ct-select-chevron{flex:none;color:var(--dsw-alias-label-tertiary);display:inline-flex;transition:transform 120ms ease;}",
@@ -2418,13 +3622,80 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
2418
3622
  ".ct-menu-item-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}",
2419
3623
  ".ct-row{border-bottom:1px solid var(--dsw-alias-border-l2);padding:16px 2px;display:flex;align-items:center;gap:16px;}",
2420
3624
  ".ct-row:last-child{border-bottom:none;}",
3625
+ ".ct-row-flush{border-bottom:none;}",
2421
3626
  ".ct-row-main{flex:1;min-width:0;display:flex;flex-direction:column;gap:4px;padding-right:48px;}",
2422
3627
  ".ct-row-title{color:var(--dsw-alias-label-primary);font-size:14px;font-weight:400;line-height:22px;}",
2423
- ".ct-row-desc{color:var(--dsw-alias-label-tertiary);font-size:12px;font-weight:400;line-height:18px;}"
3628
+ ".ct-row-desc{color:var(--dsw-alias-label-tertiary);font-size:12px;font-weight:400;line-height:18px;}",
3629
+ ".ct-seed-row{display:flex;align-items:center;gap:16px;padding:12px 0;}",
3630
+ ".ct-seed-label{flex:0 0 auto;min-width:96px;padding-left:3px;font-size:14px;line-height:22px;color:var(--dsw-alias-label-primary);}",
3631
+ ".ct-seed-dots{flex:1 1 auto;display:flex;flex-wrap:wrap;align-items:center;justify-content:flex-end;gap:10px;}",
3632
+ ".ct-seed-dot{position:relative;box-sizing:border-box;display:inline-block;flex:none;width:26px;height:26px;border:1px solid var(--dsw-alias-border-l2);border-radius:50%;cursor:pointer;}",
3633
+ ".ct-seed-input{position:absolute;inset:0;box-sizing:border-box;width:100%;height:100%;padding:0;border:none;background:transparent;opacity:0;cursor:pointer;}",
3634
+ ".ct-seed-dot:focus-within{outline:none;box-shadow:0 0 0 2px var(--dsw-alias-border-l3);}",
3635
+ ".ct-seed-dot::after{content:attr(data-tip);position:absolute;bottom:calc(100% + 7px);left:50%;z-index:20;transform:translateX(-50%);padding:3px 8px;border-radius:6px;background:var(--dsw-alias-label-primary,#151517);color:var(--dsw-alias-bg-layer-3,#fff);font-size:11px;font-weight:400;line-height:17px;white-space:nowrap;opacity:0;pointer-events:none;transition:opacity .12s;}",
3636
+ ".ct-seed-dot:hover::after,.ct-seed-dot:focus-within::after{opacity:1;}",
3637
+ ".ct-list{margin-top:4px;display:flex;flex-direction:column;gap:8px;}",
3638
+ ".ct-list-row{display:flex;align-items:center;gap:10px;padding:12px 14px;border:.5px solid var(--dsw-alias-border-l4);border-radius:16px;background:transparent;cursor:pointer;transition:border-color .16s ease,background .16s ease;}",
3639
+ ".ct-list-row:hover{background:var(--dsw-alias-interactive-bg-hover);}",
3640
+ ".ct-list-identity{display:inline-flex;align-items:center;gap:6px;flex:1 1 auto;min-width:0;}",
3641
+ ".ct-list-name{min-width:0;text-align:left;border:none;background:transparent;cursor:pointer;font:inherit;font-size:14px;line-height:22px;font-weight:500;color:var(--dsw-alias-label-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding:0;}",
3642
+ ".ct-list-name:disabled{cursor:default;}",
3643
+ ".ct-list-actions{display:inline-flex;align-items:center;gap:4px;flex:none;margin-left:auto;}",
3644
+ ".ct-list-badge{display:inline-flex;align-items:center;height:28px;padding:0 10px;border-radius:14px;background:var(--dsw-alias-bg-module-platform,var(--dsw-alias-bg-layer-2));color:var(--dsw-alias-label-secondary,var(--dsw-alias-label-tertiary));font-size:12px;line-height:18px;white-space:nowrap;}",
3645
+ ".ct-list-btn{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;height:28px;padding:0 10px;border:.5px solid var(--dsw-alias-border-l3);border-radius:14px;background:transparent;color:var(--dsw-alias-label-primary);cursor:pointer;font:inherit;font-size:12px;line-height:18px;}",
3646
+ ".ct-list-btn:hover{background:var(--dsw-alias-interactive-bg-hover);}",
3647
+ ".ct-list-btn:focus-visible{outline:none;box-shadow:0 0 0 2px var(--dsw-alias-border-l3);}",
3648
+ ".ct-list-btn-danger{border:none;color:var(--dsw-alias-state-error-primary);}",
3649
+ ".ct-list-btn-danger:hover{background:var(--dsw-alias-interactive-bg-hover-danger);}",
3650
+ ".ct-editor{padding:16px;border:.5px solid var(--dsw-alias-border-l4);border-radius:16px;background:transparent;display:flex;flex-direction:column;gap:0;}",
3651
+ ".ct-editor-head{display:flex;align-items:center;gap:10px;padding-bottom:12px;}",
3652
+ ".ct-editor-name{flex:1 1 auto;min-width:0;box-sizing:border-box;height:36px;padding:0 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:14px;line-height:22px;}",
3653
+ ".ct-editor-name::placeholder{color:var(--dsw-alias-label-tertiary);}",
3654
+ ".ct-editor-name:hover{border-color:var(--dsw-alias-border-l3);}",
3655
+ ".ct-editor-name:focus{outline:none;border-color:var(--dsw-alias-border-l3);background:var(--dsw-alias-bg-base);}",
3656
+ ".ct-mode{box-sizing:border-box;display:inline-flex;flex:none;height:36px;padding:2px;border-radius:10px;background:var(--dsw-alias-bg-module-platform,var(--dsw-alias-bg-layer-2));}",
3657
+ ".ct-mode-btn{box-sizing:border-box;height:32px;padding:0 10px;border:none;border-radius:8px;background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer;font:inherit;font-size:12px;line-height:18px;}",
3658
+ ".ct-mode-btn:hover{color:var(--dsw-alias-label-primary);}",
3659
+ ".ct-mode-btn-on{background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);}",
3660
+ ".ct-mode-btn:focus-visible{outline:none;box-shadow:0 0 0 2px var(--dsw-alias-border-l3);}",
3661
+ ".ct-editor-actions{display:flex;justify-content:flex-end;gap:12px;padding-top:12px;}",
3662
+ ".ct-add-btn{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;gap:6px;width:100%;height:44px;margin-top:8px;padding:0 14px;border:1px dashed var(--dsw-alias-border-l3);border-radius:16px;background:transparent;color:var(--dsw-alias-label-primary);cursor:pointer;font:inherit;font-size:14px;line-height:22px;}",
3663
+ ".ct-add-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);}",
3664
+ ".ct-add-btn:disabled{opacity:.4;cursor:default;}",
3665
+ ".ct-add-btn:focus-visible{outline:none;box-shadow:0 0 0 2px var(--dsw-alias-border-l3);}",
3666
+ ".ct-modal-root{position:fixed;inset:0;z-index:1000;display:flex;align-items:center;justify-content:center;padding:24px;}",
3667
+ ".ct-modal-mask{position:absolute;inset:0;background:var(--dsw-alias-bg-mask-1,rgba(0,0,0,.24));backdrop-filter:var(--dsw-mask-blur,blur(2px));}",
3668
+ ".ct-modal-card{position:relative;z-index:1;display:flex;flex-direction:column;gap:12px;width:min(380px,100%);padding:24px;border-radius:24px;background:var(--dsw-alias-bg-layer-2);box-shadow:var(--dsw-elevation-prominent);}",
3669
+ ".ct-modal-title{font-size:16px;line-height:24px;font-weight:600;color:var(--dsw-alias-label-primary);}",
3670
+ ".ct-modal-desc{font-size:13px;line-height:20px;color:var(--dsw-alias-label-tertiary);}",
3671
+ ".ct-modal-footer{display:flex;justify-content:flex-end;gap:12px;margin-top:12px;}",
3672
+ ".ct-switch{position:relative;flex:none;width:40px;height:24px;padding:0;border:none;border-radius:12px;background:var(--dsw-alias-border-l2);cursor:pointer;transition:background 150ms ease;}",
3673
+ ".ct-switch-on{background:var(--dsw-static-deepseek-500,rgb(65,118,230));}",
3674
+ ".ct-switch-knob{position:absolute;top:3px;left:3px;width:18px;height:18px;border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform 150ms ease;}",
3675
+ ".ct-switch-on .ct-switch-knob{transform:translateX(16px);}",
3676
+ ".ct-switch:focus-visible{outline:none;box-shadow:0 0 0 2px var(--dsw-alias-border-l3);}",
3677
+ ".ct-btn{box-sizing:border-box;display:inline-flex;align-items:center;height:34px;padding:0 16px;border:1px solid var(--dsw-alias-border-l2);border-radius:17px;background:transparent;color:var(--dsw-alias-label-primary);cursor:pointer;font:inherit;font-size:13px;line-height:20px;}",
3678
+ ".ct-btn:hover{background:var(--dsw-alias-interactive-bg-hover);}",
3679
+ ".ct-btn:focus-visible{outline:none;box-shadow:0 0 0 2px var(--dsw-alias-border-l3);}",
3680
+ ".ct-btn-primary{background:var(--dsw-static-deepseek-500,rgb(65,118,230));border-color:transparent;color:#fff;}",
3681
+ ".ct-btn-primary:hover{background:var(--dsw-static-deepseek-450,var(--dsw-static-deepseek-500,rgb(86,134,254)));}",
3682
+ ".ct-btn-danger{background:var(--dsw-static-red-500,rgb(239,68,68));border-color:transparent;color:#fff;}",
3683
+ ".ct-btn-danger:hover{background:var(--dsw-static-red-600,var(--dsw-static-red-500,rgb(236,19,19)));}",
3684
+ ".ct-toast{position:fixed;top:40px;left:50%;z-index:1100;pointer-events:none;display:flex;align-items:center;gap:10px;width:max-content;max-width:min(640px,calc(100vw - 48px));padding:12px 16px;border-radius:14px;background:var(--dsw-alias-button-contrast-fill);color:var(--dsw-alias-label-primary-inverted);font-size:14px;line-height:22px;box-shadow:var(--dsw-shadow-lv3);transform:translateX(-50%);animation:ct-toast-in 160ms ease-out,ct-toast-fade 1s ease 3s forwards;}",
3685
+ ".ct-toast-icon{display:grid;place-items:center;flex:none;width:18px;height:18px;border:1.5px solid var(--dsw-alias-state-success-primary);border-radius:50%;corner-shape:round;color:var(--dsw-alias-state-success-primary);}",
3686
+ ".ct-toast-text{min-width:0;}",
3687
+ "@keyframes ct-toast-in{from{opacity:0;transform:translate(-50%,-6px);}to{opacity:1;transform:translate(-50%,0);}}",
3688
+ "@keyframes ct-toast-fade{to{opacity:0;}}",
3689
+ "@media (prefers-reduced-motion: reduce){.ct-toast{animation:ct-toast-fade 1s ease 3s forwards;}}"
2424
3690
  ].join("\n");
2425
- const NOOP = /* @__PURE__ */ new Set(["native", "dsh"]);
2426
- function isValidPreset(v) {
2427
- return !!v && (NOOP.has(v) || v in PRESETS);
3691
+ function isValidSelection(v) {
3692
+ return !!v && (v === CUSTOM_SELECTION || NOOP_PRESET_IDS.has(v) || v in PRESETS);
3693
+ }
3694
+ function isPresetId(v) {
3695
+ return typeof v === "string" && v in PRESETS;
3696
+ }
3697
+ function presetDef(id) {
3698
+ return PRESETS[id] ?? PRESETS.dsh;
2428
3699
  }
2429
3700
  function registerTheme(ctx) {
2430
3701
  const slots = ctx.get("slots");
@@ -2457,43 +3728,188 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
2457
3728
  if (!baselineDisposer) baselineDisposer = injector.insert(buildBaseCss());
2458
3729
  } else if (baselineDisposer) baselineDisposer = release(baselineDisposer);
2459
3730
  }
2460
- function getPreset() {
2461
- for (const key of [THEME_STORAGE_KEY, THEME_STORAGE_KEY_LEGACY]) try {
2462
- const cur = localStorage.getItem(key);
2463
- if (!isValidPreset(cur)) continue;
3731
+ /** Raw localStorage access; an absent or blocked store reads as empty, writes are dropped. */
3732
+ function readRaw(key) {
3733
+ try {
3734
+ return localStorage.getItem(key);
3735
+ } catch {
3736
+ return null;
3737
+ }
3738
+ }
3739
+ function writeRaw(key, value) {
3740
+ try {
3741
+ if (value === null) localStorage.removeItem(key);
3742
+ else localStorage.setItem(key, value);
3743
+ } catch {}
3744
+ }
3745
+ function getSelection() {
3746
+ for (const key of [THEME_STORAGE_KEY, THEME_STORAGE_KEY_LEGACY]) {
3747
+ const cur = readRaw(key);
3748
+ if (!isValidSelection(cur)) continue;
2464
3749
  const normalized = cur === "native" ? "dsh" : cur;
2465
- if (cur !== normalized) try {
2466
- localStorage.setItem(THEME_STORAGE_KEY, normalized);
2467
- } catch {}
2468
- else if (key === "cooltea-theme-preset") try {
2469
- localStorage.setItem(THEME_STORAGE_KEY, cur);
2470
- } catch {}
3750
+ if (cur !== normalized || key === "cooltea-theme-preset") writeRaw(THEME_STORAGE_KEY, normalized);
2471
3751
  return normalized;
2472
- } catch {}
3752
+ }
2473
3753
  return "dsh";
2474
3754
  }
2475
- function applyPreset(id) {
3755
+ /** The preset the custom seeds derive from, restored from the stored blob. */
3756
+ function customBase() {
3757
+ const env = parseEnvelope(readRaw(CUSTOM_THEME_STORAGE_KEY));
3758
+ if (env && isPresetId(env.base)) return env.base;
3759
+ return lastPreset;
3760
+ }
3761
+ function loadCustom() {
3762
+ const base = customBase();
3763
+ const fallback = extractSeeds(presetDef(base));
3764
+ return {
3765
+ base,
3766
+ theme: normalizeCustom(parseEnvelope(readRaw(CUSTOM_THEME_STORAGE_KEY))?.theme, fallback)
3767
+ };
3768
+ }
3769
+ function persistCustom(theme) {
3770
+ writeRaw(CUSTOM_THEME_STORAGE_KEY, encodeCustom(customBase(), theme));
3771
+ }
3772
+ /**
3773
+ * Choose the preset a future custom theme seeds from, without touching the
3774
+ * colours currently on screen. The custom editor stays the live theme; the
3775
+ * preset is only the template the next "add" inherits.
3776
+ */
3777
+ function setCustomBase(base) {
3778
+ lastPreset = base;
3779
+ writeRaw(CUSTOM_THEME_STORAGE_KEY, encodeCustom(base, loadCustom().theme));
3780
+ }
3781
+ function apply(src) {
2476
3782
  overrideDispose = release(overrideDispose);
2477
3783
  fallbackDispose = release(fallbackDispose);
2478
- if (NOOP.has(id)) {
3784
+ const target = src === CUSTOM_SELECTION ? buildCustomPreset(loadCustom().theme) : src;
3785
+ if (typeof target === "string" && NOOP_PRESET_IDS.has(target)) {
2479
3786
  ensureBaseline(false);
2480
3787
  return;
2481
3788
  }
2482
3789
  ensureBaseline(true);
2483
- const overrides = buildOverrides(id);
3790
+ const overrides = buildOverrides(target);
2484
3791
  if (theme?.overrideTokens) try {
2485
3792
  overrideDispose = theme.overrideTokens("dsh-cool-theme", overrides);
2486
3793
  return;
2487
3794
  } catch {}
2488
- fallbackDispose = injector.insert(buildFullCssFallback(id));
3795
+ fallbackDispose = injector.insert(buildFullCssFallback(target));
2489
3796
  }
2490
- function setPreset(id) {
2491
- try {
2492
- localStorage.setItem(THEME_STORAGE_KEY, id);
2493
- } catch {}
2494
- applyPreset(id);
3797
+ function setSelection(id) {
3798
+ writeRaw(THEME_STORAGE_KEY, id);
3799
+ if (id !== CUSTOM_SELECTION) lastPreset = id;
3800
+ apply(id);
3801
+ }
3802
+ function setCustom(next) {
3803
+ persistCustom(next);
3804
+ apply(buildCustomPreset(next));
3805
+ }
3806
+ function resetCustom() {
3807
+ const theme = extractSeeds(presetDef(customBase()));
3808
+ persistCustom(theme);
3809
+ apply(buildCustomPreset(theme));
3810
+ return theme;
3811
+ }
3812
+ const fallbackForBase = (base) => extractSeeds(presetDef(isPresetId(base) ? base : lastPreset));
3813
+ /**
3814
+ * The roster as the Host last reported it; `null` means it has not answered
3815
+ * yet. Files are the source of truth, so this is a read-through cache the
3816
+ * panel renders from, and every mutation is a round trip plus a refresh.
3817
+ */
3818
+ let themeCache = null;
3819
+ /** The legacy browser list, read only until the Host has answered. */
3820
+ const legacyList = () => decodeList(readRaw(CUSTOM_LIST_STORAGE_KEY), fallbackForBase);
3821
+ /** The roster to render and resolve ids against. */
3822
+ const readList = () => themeCache ?? legacyList();
3823
+ function readActiveId() {
3824
+ const id = readRaw(CUSTOM_ACTIVE_STORAGE_KEY);
3825
+ return id && readList().some((e) => e.id === id) ? id : null;
3826
+ }
3827
+ /**
3828
+ * Adopt the themes this browser saved before they lived in files. Runs once:
3829
+ * the marker keeps a stale browser copy from being migrated twice, and is only
3830
+ * set after a confirmed write, so a failed migration retries next load.
3831
+ */
3832
+ async function migrateLegacyThemes(hostThemes) {
3833
+ if (readRaw("cool-theme-custom-migrated") !== null) return hostThemes;
3834
+ const adopt = legacyThemesToAdopt(legacyList(), hostThemes);
3835
+ if (adopt.length > 0) await pushThemes(adopt, fallbackForBase);
3836
+ writeRaw(CUSTOM_MIGRATED_STORAGE_KEY, (/* @__PURE__ */ new Date()).toISOString());
3837
+ writeRaw(CUSTOM_LIST_STORAGE_KEY, null);
3838
+ return adopt.length > 0 ? fetchThemes(fallbackForBase) : hostThemes;
3839
+ }
3840
+ /** Re-read the roster from the Host, migrating the legacy list on first load. */
3841
+ async function refreshSaved() {
3842
+ themeCache = await migrateLegacyThemes(await fetchThemes(fallbackForBase));
3843
+ return themeCache;
3844
+ }
3845
+ /** Load a saved entry into the draft and make it the active theme. */
3846
+ function loadSaved(id) {
3847
+ const entry = readList().find((e) => e.id === id);
3848
+ if (!entry) return null;
3849
+ if (isPresetId(entry.base)) lastPreset = entry.base;
3850
+ writeRaw(CUSTOM_ACTIVE_STORAGE_KEY, entry.id);
3851
+ writeRaw(CUSTOM_THEME_STORAGE_KEY, encodeCustom(entry.base, entry.theme));
3852
+ apply(buildCustomPreset(entry.theme));
3853
+ return entry.theme;
2495
3854
  }
2496
- applyPreset(getPreset());
3855
+ const saved = {
3856
+ /** The cached roster, or the legacy browser list until the Host answers. */
3857
+ list: readList,
3858
+ activeId: readActiveId,
3859
+ load: loadSaved,
3860
+ /** Point the "in use" badge at an entry, or clear it entirely. */
3861
+ setActive(id) {
3862
+ writeRaw(CUSTOM_ACTIVE_STORAGE_KEY, id);
3863
+ },
3864
+ refresh: refreshSaved,
3865
+ /** Persist the current draft as a new named entry and make it active. */
3866
+ async create(name) {
3867
+ const draft = loadCustom();
3868
+ const entry = {
3869
+ id: newThemeId(),
3870
+ name,
3871
+ base: draft.base,
3872
+ theme: draft.theme,
3873
+ assets: []
3874
+ };
3875
+ await pushThemes([entry], fallbackForBase);
3876
+ writeRaw(CUSTOM_ACTIVE_STORAGE_KEY, entry.id);
3877
+ return refreshSaved();
3878
+ },
3879
+ /** Write the current draft (and a new name) back to an existing entry. */
3880
+ async update(id, name) {
3881
+ const entry = readList().find((e) => e.id === id);
3882
+ if (!entry) return readList();
3883
+ const draft = loadCustom();
3884
+ await pushThemes([{
3885
+ ...entry,
3886
+ name,
3887
+ theme: draft.theme
3888
+ }], fallbackForBase);
3889
+ return refreshSaved();
3890
+ },
3891
+ async remove(id) {
3892
+ if (readRaw("cool-theme-custom-active") === id) writeRaw(CUSTOM_ACTIVE_STORAGE_KEY, null);
3893
+ await removeTheme(id);
3894
+ const next = await refreshSaved();
3895
+ if (readActiveId() === null && next[0]) loadSaved(next[0].id);
3896
+ return next;
3897
+ }
3898
+ };
3899
+ const initialSelection = getSelection();
3900
+ let lastPreset = initialSelection === CUSTOM_SELECTION ? "dsh" : initialSelection;
3901
+ if (initialSelection === CUSTOM_SELECTION) {
3902
+ const env = parseEnvelope(readRaw(CUSTOM_THEME_STORAGE_KEY));
3903
+ if (env && isPresetId(env.base)) lastPreset = env.base;
3904
+ }
3905
+ apply(initialSelection);
3906
+ let sharedToast = null;
3907
+ const modules = ctx.get("modules");
3908
+ try {
3909
+ modules?.import?.("@deepseek-ai/dsh-client-ui-primitives").then((mod) => {
3910
+ if (typeof mod?.Toast === "function") sharedToast = mod.Toast;
3911
+ }).catch(() => {});
3912
+ } catch {}
2497
3913
  ctx.effect(() => () => {
2498
3914
  release(pluginCssDisposer);
2499
3915
  baselineDisposer = release(baselineDisposer);
@@ -2512,8 +3928,15 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
2512
3928
  return slots.register(options, (props) => react.createElement(ThemePanel, {
2513
3929
  t: props?.t ?? ((key) => zh[key]),
2514
3930
  theme,
2515
- getPreset,
2516
- setPreset
3931
+ getSelection,
3932
+ setSelection,
3933
+ getCustom: loadCustom,
3934
+ setCustom,
3935
+ setCustomBase,
3936
+ resetCustom,
3937
+ saved,
3938
+ getHostToast: () => sharedToast,
3939
+ onRequestClose: typeof props?.close === "function" ? props.close : null
2517
3940
  }));
2518
3941
  });
2519
3942
  const PALETTE_SVG = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" aria-hidden=\"true\"><path d=\"M0 0h24v24H0z\" fill=\"none\"/><g fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"><path d=\"M12 22a1 1 0 0 1 0-20a10 9 0 0 1 10 9a5 5 0 0 1-5 5h-2.25a1.75 1.75 0 0 0-1.4 2.8l.3.4a1.75 1.75 0 0 1-1.4 2.8z\"/><circle cx=\"13.5\" cy=\"6.5\" r=\".5\" fill=\"currentColor\"/><circle cx=\"17.5\" cy=\"10.5\" r=\".5\" fill=\"currentColor\"/><circle cx=\"6.5\" cy=\"12.5\" r=\".5\" fill=\"currentColor\"/><circle cx=\"8.5\" cy=\"7.5\" r=\".5\" fill=\"currentColor\"/></g></svg>";
@@ -2524,18 +3947,16 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
2524
3947
  try {
2525
3948
  currentLabel = t("nav");
2526
3949
  } catch {}
2527
- const candidates = new Set([
3950
+ const candidates = [
2528
3951
  currentLabel,
2529
3952
  zh.nav,
2530
3953
  en.nav
2531
- ].filter(Boolean));
3954
+ ].filter(Boolean);
2532
3955
  const cells = document.querySelectorAll("[class*=\"navCell\"], button[class*=\"navCell\"]");
2533
- const toPatch = [];
2534
- if (cells.length > 0) cells.forEach((c) => toPatch.push(c));
2535
- else document.querySelectorAll("[class*=\"nav\"] button").forEach((b) => toPatch.push(b));
3956
+ const toPatch = cells.length > 0 ? Array.from(cells) : Array.from(document.querySelectorAll("[class*=\"nav\"] button"));
2536
3957
  for (const cell of toPatch) {
2537
3958
  const text = ((cell.querySelector("[class*=\"navLabel\"]") || cell)?.textContent || cell.textContent || "").trim();
2538
- if (!Array.from(candidates).some((lbl) => text === lbl || text.includes(lbl))) continue;
3959
+ if (!candidates.some((lbl) => text === lbl || text.includes(lbl))) continue;
2539
3960
  if (cell.querySelector("[data-palette-icon]")) continue;
2540
3961
  const svg = cell.querySelector("svg");
2541
3962
  if (!svg) continue;