dsh-cool-theme 0.4.0 → 0.6.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/README.md +10 -10
- package/README.zh.md +10 -10
- package/lib/client.js +1940 -75
- package/lib/index.js +830 -6
- package/lib/types/client/components.d.ts +43 -3
- package/lib/types/client/components.d.ts.map +1 -1
- package/lib/types/client/custom.d.ts +124 -0
- package/lib/types/client/custom.d.ts.map +1 -0
- package/lib/types/client/locales.d.ts +124 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/presets.d.ts +19 -835
- package/lib/types/client/presets.d.ts.map +1 -1
- package/lib/types/client/theme-files.d.ts +51 -0
- package/lib/types/client/theme-files.d.ts.map +1 -0
- package/lib/types/client/theme.d.ts.map +1 -1
- package/lib/types/client/tokens.d.ts +16 -3
- package/lib/types/client/tokens.d.ts.map +1 -1
- package/lib/types/contract.d.ts +78 -0
- package/lib/types/contract.d.ts.map +1 -1
- package/lib/types/host/index.d.ts +6 -3
- package/lib/types/host/index.d.ts.map +1 -1
- package/lib/types/host/theme-files.d.ts +44 -0
- package/lib/types/host/theme-files.d.ts.map +1 -0
- package/lib/types/host/theme-share.d.ts +47 -0
- package/lib/types/host/theme-share.d.ts.map +1 -0
- package/lib/types/host/zip.d.ts +39 -0
- package/lib/types/host/zip.d.ts.map +1 -0
- package/lib/types/presets-ids.d.ts +17 -0
- package/lib/types/presets-ids.d.ts.map +1 -0
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -34,6 +34,67 @@ 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`;
|
|
43
|
+
const THEME_ARCHIVE_EXTENSION = ".zip";
|
|
44
|
+
/**
|
|
45
|
+
* Import bounds. A theme is a small JSON document, so anything past these caps
|
|
46
|
+
* is refused before it is parsed rather than after it has been held in memory.
|
|
47
|
+
*/
|
|
48
|
+
const THEME_ARCHIVE_MAX_BYTES = 8388608;
|
|
49
|
+
/** Route segments below {@link THEME_API_PATH_THEMES}: `/<id>/export`, `/import`. */
|
|
50
|
+
const THEME_API_SEGMENT_EXPORT = "export";
|
|
51
|
+
const THEME_API_PATH_IMPORT = `${THEME_API_PATH_THEMES}/import`;
|
|
52
|
+
//#endregion
|
|
53
|
+
//#region src/presets-ids.ts
|
|
54
|
+
/**
|
|
55
|
+
* The preset ids, in one place both halves can read.
|
|
56
|
+
*
|
|
57
|
+
* `presets.ts` carries the colour maps and is client-only; the Host needs the
|
|
58
|
+
* same id vocabulary to tell an archive that derives from a preset we ship from
|
|
59
|
+
* one that does not, so the names live here and that file checks itself against
|
|
60
|
+
* them.
|
|
61
|
+
*/
|
|
62
|
+
const PRESET_IDS = [
|
|
63
|
+
"aura",
|
|
64
|
+
"ayu",
|
|
65
|
+
"catppuccin",
|
|
66
|
+
"catppuccin-frappe",
|
|
67
|
+
"catppuccin-macchiato",
|
|
68
|
+
"cobalt2",
|
|
69
|
+
"cursor",
|
|
70
|
+
"dracula",
|
|
71
|
+
"dsh",
|
|
72
|
+
"everforest",
|
|
73
|
+
"flexoki",
|
|
74
|
+
"github",
|
|
75
|
+
"gruvbox",
|
|
76
|
+
"kanagawa",
|
|
77
|
+
"lucent-orng",
|
|
78
|
+
"material",
|
|
79
|
+
"matrix",
|
|
80
|
+
"mercury",
|
|
81
|
+
"monokai",
|
|
82
|
+
"nightowl",
|
|
83
|
+
"nord",
|
|
84
|
+
"onedark",
|
|
85
|
+
"opencode",
|
|
86
|
+
"orng",
|
|
87
|
+
"osaka-jade",
|
|
88
|
+
"palenight",
|
|
89
|
+
"rosepine",
|
|
90
|
+
"solarized",
|
|
91
|
+
"synthwave84",
|
|
92
|
+
"system",
|
|
93
|
+
"tokyonight",
|
|
94
|
+
"vercel",
|
|
95
|
+
"vesper",
|
|
96
|
+
"zenburn"
|
|
97
|
+
];
|
|
37
98
|
//#endregion
|
|
38
99
|
//#region src/client/presets/_helpers.ts
|
|
39
100
|
/**
|
|
@@ -1913,6 +1974,12 @@ window.__ModuleLoader__.load({
|
|
|
1913
1974
|
});
|
|
1914
1975
|
//#endregion
|
|
1915
1976
|
//#region src/client/presets.ts
|
|
1977
|
+
/**
|
|
1978
|
+
* Every shipped preset, one entry per {@link PRESET_IDS} name. The mapped type
|
|
1979
|
+
* makes the two lists agree at compile time: a preset without a colour map, or a
|
|
1980
|
+
* name with no entry here, is a type error rather than a theme that goes missing
|
|
1981
|
+
* from the picker (or from an import's validation).
|
|
1982
|
+
*/
|
|
1916
1983
|
const PRESETS = {
|
|
1917
1984
|
aura,
|
|
1918
1985
|
ayu,
|
|
@@ -1979,10 +2046,15 @@ window.__ModuleLoader__.load({
|
|
|
1979
2046
|
}
|
|
1980
2047
|
}
|
|
1981
2048
|
};
|
|
2049
|
+
/**
|
|
2050
|
+
* Presets that override nothing: the shell keeps its own colours. The single
|
|
2051
|
+
* source of this set, so the token layer and the panel cannot drift on it.
|
|
2052
|
+
*/
|
|
2053
|
+
const NOOP_PRESET_IDS = /* @__PURE__ */ new Set(["native", "dsh"]);
|
|
1982
2054
|
const presetOptions = [{
|
|
1983
2055
|
value: "dsh",
|
|
1984
2056
|
label: PRESETS.dsh.label
|
|
1985
|
-
}, ...
|
|
2057
|
+
}, ...PRESET_IDS.filter((id) => id !== "dsh").map((id) => ({
|
|
1986
2058
|
value: id,
|
|
1987
2059
|
label: PRESETS[id].label
|
|
1988
2060
|
}))];
|
|
@@ -2157,10 +2229,17 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
|
|
|
2157
2229
|
function buildBaseCss() {
|
|
2158
2230
|
return `${buildPrimitivesCss()}\n\n${SHIKI_CSS}`;
|
|
2159
2231
|
}
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2232
|
+
/**
|
|
2233
|
+
* Accepts either a preset id or an already-built `PresetDef` (what a custom
|
|
2234
|
+
* theme produces). Both go through the identical merge, so a custom theme is
|
|
2235
|
+
* not a special case anywhere downstream.
|
|
2236
|
+
*/
|
|
2237
|
+
function resolvePreset(src) {
|
|
2238
|
+
let preset;
|
|
2239
|
+
if (typeof src === "string") {
|
|
2240
|
+
if (NOOP_PRESET_IDS.has(src)) return null;
|
|
2241
|
+
preset = PRESETS[src] ?? null;
|
|
2242
|
+
} else preset = src;
|
|
2164
2243
|
if (!preset) return null;
|
|
2165
2244
|
return {
|
|
2166
2245
|
light: {
|
|
@@ -2173,16 +2252,15 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
|
|
|
2173
2252
|
}
|
|
2174
2253
|
};
|
|
2175
2254
|
}
|
|
2176
|
-
function buildOverrides(
|
|
2177
|
-
const resolved = resolvePreset(
|
|
2255
|
+
function buildOverrides(src) {
|
|
2256
|
+
const resolved = resolvePreset(src);
|
|
2178
2257
|
if (!resolved) return {};
|
|
2179
2258
|
return primitiveOverrides(resolved.light, resolved.dark);
|
|
2180
2259
|
}
|
|
2181
|
-
function buildFullCssFallback(
|
|
2182
|
-
const resolved = resolvePreset(
|
|
2260
|
+
function buildFullCssFallback(src) {
|
|
2261
|
+
const resolved = resolvePreset(src);
|
|
2183
2262
|
if (!resolved) return "";
|
|
2184
|
-
|
|
2185
|
-
return `:root{\n${toBlock(resolved.light)}\n}\nhtml[data-ds-dark-theme], body[data-ds-dark-theme]{\n${toBlock(resolved.dark)}\n}`;
|
|
2263
|
+
return buildPrimitivesCss(resolved.light, resolved.dark);
|
|
2186
2264
|
}
|
|
2187
2265
|
//#endregion
|
|
2188
2266
|
//#region src/client/style-injector.ts
|
|
@@ -2223,10 +2301,559 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
|
|
|
2223
2301
|
};
|
|
2224
2302
|
}
|
|
2225
2303
|
//#endregion
|
|
2304
|
+
//#region src/client/custom.ts
|
|
2305
|
+
/** The nine `--shiki-token-*` slots, in stylesheet order. */
|
|
2306
|
+
const SHIKI_KEYS = [
|
|
2307
|
+
"constant",
|
|
2308
|
+
"string",
|
|
2309
|
+
"comment",
|
|
2310
|
+
"keyword",
|
|
2311
|
+
"parameter",
|
|
2312
|
+
"function",
|
|
2313
|
+
"string-expression",
|
|
2314
|
+
"punctuation",
|
|
2315
|
+
"link"
|
|
2316
|
+
];
|
|
2317
|
+
/** Seed values used when a source preset cannot supply one (e.g. the no-op `dsh`). */
|
|
2318
|
+
const SHIKI_FALLBACK = {
|
|
2319
|
+
light: {
|
|
2320
|
+
constant: "#1C7ED6",
|
|
2321
|
+
string: "#2F9E44",
|
|
2322
|
+
comment: "#868E96",
|
|
2323
|
+
keyword: "#D6336C",
|
|
2324
|
+
parameter: "#E8590C",
|
|
2325
|
+
function: "#6741D9",
|
|
2326
|
+
"string-expression": "#2B8A3E",
|
|
2327
|
+
punctuation: "#495057",
|
|
2328
|
+
link: "#1971C2"
|
|
2329
|
+
},
|
|
2330
|
+
dark: {
|
|
2331
|
+
constant: "#4DABF7",
|
|
2332
|
+
string: "#69DB7C",
|
|
2333
|
+
comment: "#ADB5BD",
|
|
2334
|
+
keyword: "#FAA2C1",
|
|
2335
|
+
parameter: "#FFA94D",
|
|
2336
|
+
function: "#B197FC",
|
|
2337
|
+
"string-expression": "#8CE99A",
|
|
2338
|
+
punctuation: "#CED4DA",
|
|
2339
|
+
link: "#74C0FC"
|
|
2340
|
+
}
|
|
2341
|
+
};
|
|
2342
|
+
const HEX = /^#([0-9a-f]{3,8})$/i;
|
|
2343
|
+
const RGB_FN = /^rgba?\(([^)]*)\)$/i;
|
|
2344
|
+
const HSL_FN = /^hsla?\(([^)]*)\)$/i;
|
|
2345
|
+
function isRecord$1(value) {
|
|
2346
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2347
|
+
}
|
|
2348
|
+
const clamp255 = (n) => Math.max(0, Math.min(255, Math.round(n)));
|
|
2349
|
+
const clamp01 = (n) => Math.max(0, Math.min(1, n));
|
|
2350
|
+
/** Accepts both `a, b, c` and the modern `a b c / d` argument syntax. */
|
|
2351
|
+
function fnArgs(body) {
|
|
2352
|
+
return body.replace(/\//g, " ").split(/[\s,]+/).filter(Boolean);
|
|
2353
|
+
}
|
|
2354
|
+
/** A colour channel: `128` or a percentage. Returns null for anything else. */
|
|
2355
|
+
function channel(token) {
|
|
2356
|
+
const pct = token.endsWith("%");
|
|
2357
|
+
const v = parseFloat(pct ? token.slice(0, -1) : token);
|
|
2358
|
+
if (!Number.isFinite(v)) return null;
|
|
2359
|
+
return pct ? v / 100 * 255 : v;
|
|
2360
|
+
}
|
|
2361
|
+
function hslToRgb(h, s, l) {
|
|
2362
|
+
const a = s * Math.min(l, 1 - l);
|
|
2363
|
+
const f = (n) => {
|
|
2364
|
+
const k = (n + h / 30) % 12;
|
|
2365
|
+
return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1));
|
|
2366
|
+
};
|
|
2367
|
+
return [
|
|
2368
|
+
clamp255(f(0) * 255),
|
|
2369
|
+
clamp255(f(8) * 255),
|
|
2370
|
+
clamp255(f(4) * 255)
|
|
2371
|
+
];
|
|
2372
|
+
}
|
|
2373
|
+
const toRgbHex = (rgb) => "#" + rgb.map((n) => clamp255(n).toString(16).padStart(2, "0")).join("").toUpperCase();
|
|
2374
|
+
/**
|
|
2375
|
+
* Parse a colour into `#RRGGBB`, or null when it is not a format we understand.
|
|
2376
|
+
*
|
|
2377
|
+
* Accepted: `#RGB`, `#RGBA`, `#RRGGBB`, `#RRGGBBAA`, `rgb()`/`rgba()` with
|
|
2378
|
+
* numbers or percentages, and `hsl()`/`hsla()`. Alpha is parsed but dropped —
|
|
2379
|
+
* every token these seeds drive is an opaque theme colour.
|
|
2380
|
+
*
|
|
2381
|
+
* `primitives.ts` stores `rgb(r, g, b)` while presets store hex, so both shapes
|
|
2382
|
+
* have to work; the extra forms are there for what users may type.
|
|
2383
|
+
*/
|
|
2384
|
+
function tryToHex(input) {
|
|
2385
|
+
if (typeof input !== "string") return null;
|
|
2386
|
+
const s = input.trim();
|
|
2387
|
+
if (!s) return null;
|
|
2388
|
+
const hex = s.match(HEX);
|
|
2389
|
+
if (hex) {
|
|
2390
|
+
const d = hex[1];
|
|
2391
|
+
const twice = (c) => c + c;
|
|
2392
|
+
if (d.length === 3 || d.length === 4) return `#${twice(d[0])}${twice(d[1])}${twice(d[2])}`.toUpperCase();
|
|
2393
|
+
if (d.length === 6 || d.length === 8) return `#${d.slice(0, 6)}`.toUpperCase();
|
|
2394
|
+
return null;
|
|
2395
|
+
}
|
|
2396
|
+
const rgbFn = s.match(RGB_FN);
|
|
2397
|
+
if (rgbFn) {
|
|
2398
|
+
const parts = fnArgs(rgbFn[1]);
|
|
2399
|
+
if (parts.length < 3) return null;
|
|
2400
|
+
const rgb = parts.slice(0, 3).map(channel);
|
|
2401
|
+
if (rgb.some((n) => n === null)) return null;
|
|
2402
|
+
return toRgbHex(rgb);
|
|
2403
|
+
}
|
|
2404
|
+
const hslFn = s.match(HSL_FN);
|
|
2405
|
+
if (hslFn) {
|
|
2406
|
+
const parts = fnArgs(hslFn[1]);
|
|
2407
|
+
if (parts.length < 3) return null;
|
|
2408
|
+
const h = parseFloat(parts[0].replace(/deg$/i, ""));
|
|
2409
|
+
const sat = parseFloat(parts[1]);
|
|
2410
|
+
const lig = parseFloat(parts[2]);
|
|
2411
|
+
if (![
|
|
2412
|
+
h,
|
|
2413
|
+
sat,
|
|
2414
|
+
lig
|
|
2415
|
+
].every(Number.isFinite)) return null;
|
|
2416
|
+
return toRgbHex(hslToRgb((h % 360 + 360) % 360, clamp01(sat / 100), clamp01(lig / 100)));
|
|
2417
|
+
}
|
|
2418
|
+
return null;
|
|
2419
|
+
}
|
|
2420
|
+
/** `tryToHex` with a black fallback, for call sites that need a guaranteed value. */
|
|
2421
|
+
function toHex(input) {
|
|
2422
|
+
return tryToHex(input) ?? "#000000";
|
|
2423
|
+
}
|
|
2424
|
+
function readSeed(map, fallback, key) {
|
|
2425
|
+
return toHex(map[key] ?? fallback[key]);
|
|
2426
|
+
}
|
|
2427
|
+
/**
|
|
2428
|
+
* Recover the seed inputs from an already-generated preset.
|
|
2429
|
+
*
|
|
2430
|
+
* Exact by construction: `buildScale` writes the `start`/`end` arguments to
|
|
2431
|
+
* steps 00/1000, and `buildTintShadeScale` writes the base to step 500. This is
|
|
2432
|
+
* what lets a user start from any preset without re-declaring its palette.
|
|
2433
|
+
*/
|
|
2434
|
+
function extractSeeds(base) {
|
|
2435
|
+
const seed = (key, mode) => readSeed(mode === "light" ? base.light : base.dark, mode === "light" ? PRIMITIVES_LIGHT : PRIMITIVES_DARK, key);
|
|
2436
|
+
const pair = (key) => ({
|
|
2437
|
+
light: seed(key, "light"),
|
|
2438
|
+
dark: seed(key, "dark")
|
|
2439
|
+
});
|
|
2440
|
+
/** Both ends of one appearance's ramp, read from that appearance's map. */
|
|
2441
|
+
const ramp = (mode) => ({
|
|
2442
|
+
lightest: seed("--dsw-static-neutral-bluish-00", mode),
|
|
2443
|
+
darkest: seed("--dsw-static-neutral-bluish-1000", mode)
|
|
2444
|
+
});
|
|
2445
|
+
const shiki = SHIKI_KEYS.reduce((acc, k) => {
|
|
2446
|
+
const cssKey = `--shiki-token-${k}`;
|
|
2447
|
+
const lightMap = base.light;
|
|
2448
|
+
const darkMap = base.dark;
|
|
2449
|
+
acc[k] = {
|
|
2450
|
+
light: toHex(lightMap[cssKey] ?? SHIKI_FALLBACK.light[k]),
|
|
2451
|
+
dark: toHex(darkMap[cssKey] ?? SHIKI_FALLBACK.dark[k])
|
|
2452
|
+
};
|
|
2453
|
+
return acc;
|
|
2454
|
+
}, {});
|
|
2455
|
+
return {
|
|
2456
|
+
neutral: {
|
|
2457
|
+
light: ramp("light"),
|
|
2458
|
+
dark: ramp("dark")
|
|
2459
|
+
},
|
|
2460
|
+
accent: pair("--dsw-static-deepseek-500"),
|
|
2461
|
+
green: pair("--dsw-static-green-500"),
|
|
2462
|
+
amber: pair("--dsw-static-amber-500"),
|
|
2463
|
+
red: pair("--dsw-static-red-500"),
|
|
2464
|
+
shiki
|
|
2465
|
+
};
|
|
2466
|
+
}
|
|
2467
|
+
/** v1 carried one neutral ramp shared by both appearances. */
|
|
2468
|
+
const CUSTOM_SCHEMA_VERSION_LEGACY = 1;
|
|
2469
|
+
function encodeCustom(base, theme) {
|
|
2470
|
+
return JSON.stringify({
|
|
2471
|
+
v: 2,
|
|
2472
|
+
base,
|
|
2473
|
+
theme
|
|
2474
|
+
});
|
|
2475
|
+
}
|
|
2476
|
+
/** Parse a stored blob. Returns null for absent/corrupt/foreign-version data. */
|
|
2477
|
+
function parseEnvelope(raw) {
|
|
2478
|
+
if (!raw) return null;
|
|
2479
|
+
try {
|
|
2480
|
+
const parsed = JSON.parse(raw);
|
|
2481
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
2482
|
+
if (parsed.v !== 2 && parsed.v !== CUSTOM_SCHEMA_VERSION_LEGACY) return null;
|
|
2483
|
+
return {
|
|
2484
|
+
v: 2,
|
|
2485
|
+
base: String(parsed.base ?? ""),
|
|
2486
|
+
theme: parsed.theme
|
|
2487
|
+
};
|
|
2488
|
+
} catch {
|
|
2489
|
+
return null;
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2492
|
+
/**
|
|
2493
|
+
* The neutral seeds in the current shape. A pre-v2 blob carried one ramp shared
|
|
2494
|
+
* by both appearances; repeating it per appearance reproduces exactly what that
|
|
2495
|
+
* blob rendered before the split, so existing themes migrate without a visible
|
|
2496
|
+
* change.
|
|
2497
|
+
*/
|
|
2498
|
+
function neutralSeeds(src) {
|
|
2499
|
+
if (isRecord$1(src.neutral)) return src.neutral;
|
|
2500
|
+
const shared = {
|
|
2501
|
+
lightest: src.neutralLightest,
|
|
2502
|
+
darkest: src.neutralDarkest
|
|
2503
|
+
};
|
|
2504
|
+
return {
|
|
2505
|
+
light: shared,
|
|
2506
|
+
dark: shared
|
|
2507
|
+
};
|
|
2508
|
+
}
|
|
2509
|
+
/** Repair a partially-invalid object against `fallback`, keeping every valid colour. */
|
|
2510
|
+
function normalizeCustom(raw, fallback) {
|
|
2511
|
+
const src = raw ?? {};
|
|
2512
|
+
const pick = (value, fb) => tryToHex(value) ?? fb;
|
|
2513
|
+
const pickPair = (value, fb) => {
|
|
2514
|
+
const v = value ?? {};
|
|
2515
|
+
return {
|
|
2516
|
+
light: pick(v.light, fb.light),
|
|
2517
|
+
dark: pick(v.dark, fb.dark)
|
|
2518
|
+
};
|
|
2519
|
+
};
|
|
2520
|
+
const pickRamp = (value, fb) => {
|
|
2521
|
+
const v = value ?? {};
|
|
2522
|
+
return {
|
|
2523
|
+
lightest: pick(v.lightest, fb.lightest),
|
|
2524
|
+
darkest: pick(v.darkest, fb.darkest)
|
|
2525
|
+
};
|
|
2526
|
+
};
|
|
2527
|
+
const shikiSrc = src.shiki ?? {};
|
|
2528
|
+
const shiki = SHIKI_KEYS.reduce((acc, k) => {
|
|
2529
|
+
acc[k] = pickPair(shikiSrc[k], fallback.shiki[k]);
|
|
2530
|
+
return acc;
|
|
2531
|
+
}, {});
|
|
2532
|
+
const neutral = neutralSeeds(src);
|
|
2533
|
+
return {
|
|
2534
|
+
neutral: {
|
|
2535
|
+
light: pickRamp(neutral.light, fallback.neutral.light),
|
|
2536
|
+
dark: pickRamp(neutral.dark, fallback.neutral.dark)
|
|
2537
|
+
},
|
|
2538
|
+
accent: pickPair(src.accent, fallback.accent),
|
|
2539
|
+
green: pickPair(src.green, fallback.green),
|
|
2540
|
+
amber: pickPair(src.amber, fallback.amber),
|
|
2541
|
+
red: pickPair(src.red, fallback.red),
|
|
2542
|
+
shiki
|
|
2543
|
+
};
|
|
2544
|
+
}
|
|
2545
|
+
/**
|
|
2546
|
+
* Turn seeds into a full `PresetDef` using the presets' own helpers.
|
|
2547
|
+
*
|
|
2548
|
+
* Unlike the presets (which ship one ramp for both appearances), the ramp is
|
|
2549
|
+
* built once per appearance, so the light text and the dark text are no longer
|
|
2550
|
+
* the two ends of the same colour axis.
|
|
2551
|
+
*/
|
|
2552
|
+
function buildCustomPreset(v) {
|
|
2553
|
+
/** One appearance's ramp, published under both prefixes DSH reads from. */
|
|
2554
|
+
const ramp = (mode) => {
|
|
2555
|
+
const { lightest, darkest } = v.neutral[mode];
|
|
2556
|
+
return {
|
|
2557
|
+
...buildScale("--dsw-static-neutral-bluish", lightest, darkest, BLUISH_STEPS),
|
|
2558
|
+
...buildScale("--dsw-static-neutral", lightest, darkest, NEUTRAL_STEPS)
|
|
2559
|
+
};
|
|
2560
|
+
};
|
|
2561
|
+
const semantic = (mode) => buildSemanticScales({
|
|
2562
|
+
deepseek: v.accent[mode],
|
|
2563
|
+
blue: v.accent[mode],
|
|
2564
|
+
green: v.green[mode],
|
|
2565
|
+
amber: v.amber[mode],
|
|
2566
|
+
red: v.red[mode]
|
|
2567
|
+
});
|
|
2568
|
+
const shiki = (mode) => {
|
|
2569
|
+
const out = {};
|
|
2570
|
+
for (const k of SHIKI_KEYS) out[`--shiki-token-${k}`] = v.shiki[k][mode];
|
|
2571
|
+
return out;
|
|
2572
|
+
};
|
|
2573
|
+
return {
|
|
2574
|
+
label: "Custom",
|
|
2575
|
+
light: {
|
|
2576
|
+
...ramp("light"),
|
|
2577
|
+
...semantic("light"),
|
|
2578
|
+
...shiki("light")
|
|
2579
|
+
},
|
|
2580
|
+
dark: {
|
|
2581
|
+
...ramp("dark"),
|
|
2582
|
+
...semantic("dark"),
|
|
2583
|
+
...shiki("dark")
|
|
2584
|
+
}
|
|
2585
|
+
};
|
|
2586
|
+
}
|
|
2587
|
+
/** Collision-resistant enough for a local list, and stable across reloads. */
|
|
2588
|
+
function newThemeId() {
|
|
2589
|
+
return `ct_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
|
|
2590
|
+
}
|
|
2591
|
+
/**
|
|
2592
|
+
* Parse the saved list. Entries with a corrupt theme are repaired against
|
|
2593
|
+
* `fallbackFor(base)` rather than dropped, so one bad record cannot lose the
|
|
2594
|
+
* rest of the list; only structurally unusable entries are skipped.
|
|
2595
|
+
*/
|
|
2596
|
+
function decodeList(raw, fallbackFor) {
|
|
2597
|
+
if (!raw) return [];
|
|
2598
|
+
try {
|
|
2599
|
+
const parsed = JSON.parse(raw);
|
|
2600
|
+
const known = parsed?.v === 2 || parsed?.v === CUSTOM_SCHEMA_VERSION_LEGACY;
|
|
2601
|
+
if (!parsed || !known || !Array.isArray(parsed.items)) return [];
|
|
2602
|
+
const out = [];
|
|
2603
|
+
for (const item of parsed.items) {
|
|
2604
|
+
const e = item;
|
|
2605
|
+
if (typeof e?.id !== "string" || typeof e?.name !== "string") continue;
|
|
2606
|
+
const base = typeof e.base === "string" ? e.base : "";
|
|
2607
|
+
out.push({
|
|
2608
|
+
id: e.id,
|
|
2609
|
+
name: e.name,
|
|
2610
|
+
base,
|
|
2611
|
+
theme: normalizeCustom(e.theme, fallbackFor(base)),
|
|
2612
|
+
assets: Array.isArray(e.assets) ? e.assets : []
|
|
2613
|
+
});
|
|
2614
|
+
}
|
|
2615
|
+
return out;
|
|
2616
|
+
} catch {
|
|
2617
|
+
return [];
|
|
2618
|
+
}
|
|
2619
|
+
}
|
|
2620
|
+
/** `${base} 1`, `${base} 2`, … skipping names already taken. */
|
|
2621
|
+
function nextThemeName(base, taken) {
|
|
2622
|
+
const used = new Set(taken);
|
|
2623
|
+
for (let n = 1;; n++) {
|
|
2624
|
+
const candidate = `${base} ${n}`;
|
|
2625
|
+
if (!used.has(candidate)) return candidate;
|
|
2626
|
+
}
|
|
2627
|
+
}
|
|
2628
|
+
//#endregion
|
|
2629
|
+
//#region src/client/theme-files.ts
|
|
2630
|
+
/**
|
|
2631
|
+
* Browser half of the theme-file transport.
|
|
2632
|
+
*
|
|
2633
|
+
* The Host owns the files; this module is the only place that knows the wire
|
|
2634
|
+
* shape, so the rest of the client keeps working with plain `SavedTheme`
|
|
2635
|
+
* records. Every call is same-origin against the Host's own prefix route.
|
|
2636
|
+
*/
|
|
2637
|
+
/** Repair one stored document against the preset its seeds derive from. */
|
|
2638
|
+
function documentToSaved(doc, fallbackFor) {
|
|
2639
|
+
return {
|
|
2640
|
+
id: doc.id,
|
|
2641
|
+
name: doc.name,
|
|
2642
|
+
base: doc.base,
|
|
2643
|
+
theme: normalizeCustom(doc.theme, fallbackFor(doc.base)),
|
|
2644
|
+
assets: Array.isArray(doc.assets) ? doc.assets : []
|
|
2645
|
+
};
|
|
2646
|
+
}
|
|
2647
|
+
/** The document payload for one entry; timestamps stay the Host's business. */
|
|
2648
|
+
function savedToDocument(entry) {
|
|
2649
|
+
return {
|
|
2650
|
+
v: 1,
|
|
2651
|
+
id: entry.id,
|
|
2652
|
+
name: entry.name,
|
|
2653
|
+
base: entry.base,
|
|
2654
|
+
theme: entry.theme,
|
|
2655
|
+
assets: entry.assets
|
|
2656
|
+
};
|
|
2657
|
+
}
|
|
2658
|
+
function isRecord(value) {
|
|
2659
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2660
|
+
}
|
|
2661
|
+
/**
|
|
2662
|
+
* The Host answered as something other than this API — its route half is not
|
|
2663
|
+
* mounted (the usual cause is a DSH process started before this plugin's host
|
|
2664
|
+
* half existed). Distinguished from a write failure because only this one tells
|
|
2665
|
+
* the user to restart, and the browser copy is still intact meanwhile.
|
|
2666
|
+
*/
|
|
2667
|
+
var ThemeApiUnavailableError = class extends Error {};
|
|
2668
|
+
/** One JSON round trip; a non-OK status becomes an Error carrying the reason. */
|
|
2669
|
+
async function callApi(path, init) {
|
|
2670
|
+
const response = await fetch(path, {
|
|
2671
|
+
...init,
|
|
2672
|
+
headers: {
|
|
2673
|
+
"content-type": "application/json",
|
|
2674
|
+
...init?.headers
|
|
2675
|
+
}
|
|
2676
|
+
});
|
|
2677
|
+
let payload;
|
|
2678
|
+
try {
|
|
2679
|
+
payload = await response.json();
|
|
2680
|
+
} catch {
|
|
2681
|
+
throw new ThemeApiUnavailableError(`${String(response.status)} ${response.statusText}`);
|
|
2682
|
+
}
|
|
2683
|
+
if (!response.ok) {
|
|
2684
|
+
if (response.status === 404 || response.status === 405) throw new ThemeApiUnavailableError(`${String(response.status)} ${response.statusText}`);
|
|
2685
|
+
const reason = isRecord(payload) && typeof payload.error === "string" ? payload.error : response.statusText;
|
|
2686
|
+
throw new Error(reason);
|
|
2687
|
+
}
|
|
2688
|
+
return isRecord(payload) ? payload : {};
|
|
2689
|
+
}
|
|
2690
|
+
/**
|
|
2691
|
+
* The documents in one roster response, repaired into `SavedTheme` records.
|
|
2692
|
+
* Every call on this route answers with the same `{ themes: [...] }` body, so
|
|
2693
|
+
* both reads share this one parser: an entry missing an id or a name is skipped
|
|
2694
|
+
* rather than failing the whole roster.
|
|
2695
|
+
*/
|
|
2696
|
+
function payloadThemes(payload, fallbackFor) {
|
|
2697
|
+
const documents = Array.isArray(payload.themes) ? payload.themes : [];
|
|
2698
|
+
const out = [];
|
|
2699
|
+
for (const doc of documents) {
|
|
2700
|
+
if (!isRecord(doc) || typeof doc.id !== "string" || typeof doc.name !== "string") continue;
|
|
2701
|
+
out.push(documentToSaved({
|
|
2702
|
+
v: typeof doc.v === "number" ? doc.v : 1,
|
|
2703
|
+
id: doc.id,
|
|
2704
|
+
name: doc.name,
|
|
2705
|
+
base: typeof doc.base === "string" ? doc.base : "",
|
|
2706
|
+
theme: doc.theme,
|
|
2707
|
+
assets: Array.isArray(doc.assets) ? doc.assets : [],
|
|
2708
|
+
createdAt: "",
|
|
2709
|
+
updatedAt: ""
|
|
2710
|
+
}, fallbackFor));
|
|
2711
|
+
}
|
|
2712
|
+
return out;
|
|
2713
|
+
}
|
|
2714
|
+
/** Every stored theme, in the Host's creation-time order (oldest first). */
|
|
2715
|
+
async function fetchThemes(fallbackFor) {
|
|
2716
|
+
return payloadThemes(await callApi(THEME_API_PATH_THEMES), fallbackFor);
|
|
2717
|
+
}
|
|
2718
|
+
/** Insert or replace the given entries; resolves with what the Host stored. */
|
|
2719
|
+
async function pushThemes(entries, fallbackFor) {
|
|
2720
|
+
return payloadThemes(await callApi(THEME_API_PATH_THEMES, {
|
|
2721
|
+
method: "POST",
|
|
2722
|
+
body: JSON.stringify({ themes: entries.map(savedToDocument) })
|
|
2723
|
+
}), fallbackFor);
|
|
2724
|
+
}
|
|
2725
|
+
/** Remove one theme's directory. */
|
|
2726
|
+
async function removeTheme(id) {
|
|
2727
|
+
await callApi(`${THEME_API_PATH_THEMES}/${encodeURIComponent(id)}`, { method: "DELETE" });
|
|
2728
|
+
}
|
|
2729
|
+
/** One theme the Host stored on the client's behalf, as a stored document. */
|
|
2730
|
+
function storedTheme(payload) {
|
|
2731
|
+
const doc = isRecord(payload.theme) ? payload.theme : {};
|
|
2732
|
+
if (typeof doc.id !== "string" || typeof doc.name !== "string") throw new Error("the Host did not report a stored theme");
|
|
2733
|
+
return {
|
|
2734
|
+
id: doc.id,
|
|
2735
|
+
name: doc.name
|
|
2736
|
+
};
|
|
2737
|
+
}
|
|
2738
|
+
/**
|
|
2739
|
+
* Download one theme as an archive. The response is the archive itself rather
|
|
2740
|
+
* than JSON, so this is the one call that does not go through {@link callApi}.
|
|
2741
|
+
*/
|
|
2742
|
+
async function exportTheme(id, name) {
|
|
2743
|
+
const response = await fetch(`${THEME_API_PATH_THEMES}/${encodeURIComponent(id)}/${THEME_API_SEGMENT_EXPORT}`);
|
|
2744
|
+
if (response.status === 404 || response.status === 405) throw new ThemeApiUnavailableError(`${String(response.status)} ${response.statusText}`);
|
|
2745
|
+
if (!response.ok) throw new Error(`${String(response.status)} ${response.statusText}`);
|
|
2746
|
+
saveBlob(await response.blob(), suggestedName(response.headers.get("content-disposition"), name));
|
|
2747
|
+
}
|
|
2748
|
+
/**
|
|
2749
|
+
* The archive's own filename when the Host sent one, else the theme's name. The
|
|
2750
|
+
* Host is the authority because only it knows the theme id behind a bad name.
|
|
2751
|
+
*/
|
|
2752
|
+
function suggestedName(disposition, fallback) {
|
|
2753
|
+
const encoded = disposition?.match(/filename\*=UTF-8''([^;]+)/i)?.[1];
|
|
2754
|
+
if (encoded !== void 0) try {
|
|
2755
|
+
return decodeURIComponent(encoded);
|
|
2756
|
+
} catch {}
|
|
2757
|
+
const plain = disposition?.match(/filename="?([^";]+)"?/i)?.[1];
|
|
2758
|
+
if (plain !== void 0 && plain !== "") return plain;
|
|
2759
|
+
const name = fallback.trim() === "" ? "theme" : fallback.trim();
|
|
2760
|
+
return name.endsWith(".zip") ? name : `${name}${THEME_ARCHIVE_EXTENSION}`;
|
|
2761
|
+
}
|
|
2762
|
+
/** Hand one blob to the browser's own download machinery. */
|
|
2763
|
+
function saveBlob(blob, fileName) {
|
|
2764
|
+
const url = URL.createObjectURL(blob);
|
|
2765
|
+
try {
|
|
2766
|
+
const anchor = document.createElement("a");
|
|
2767
|
+
anchor.href = url;
|
|
2768
|
+
anchor.download = fileName;
|
|
2769
|
+
anchor.rel = "noopener";
|
|
2770
|
+
document.body.append(anchor);
|
|
2771
|
+
anchor.click();
|
|
2772
|
+
anchor.remove();
|
|
2773
|
+
} finally {
|
|
2774
|
+
setTimeout(() => {
|
|
2775
|
+
URL.revokeObjectURL(url);
|
|
2776
|
+
}, 0);
|
|
2777
|
+
}
|
|
2778
|
+
}
|
|
2779
|
+
/**
|
|
2780
|
+
* Upload one archive. The Host validates it, names the theme and stores it, so
|
|
2781
|
+
* a success means the roster already has the theme — the answer carries what to
|
|
2782
|
+
* report, not what the client still has to write.
|
|
2783
|
+
*/
|
|
2784
|
+
async function importTheme(file) {
|
|
2785
|
+
if (file.size > 8388608) throw new Error(`archive is larger than ${String(Math.round(THEME_ARCHIVE_MAX_BYTES / 1048576))} MB`);
|
|
2786
|
+
return storedTheme(await callApi(THEME_API_PATH_IMPORT, {
|
|
2787
|
+
method: "POST",
|
|
2788
|
+
headers: { "content-type": "application/octet-stream" },
|
|
2789
|
+
body: file
|
|
2790
|
+
}));
|
|
2791
|
+
}
|
|
2792
|
+
/**
|
|
2793
|
+
* The themes a browser saved before files became the store, minus those the
|
|
2794
|
+
* Host already has.
|
|
2795
|
+
*
|
|
2796
|
+
* Ids are the join key: a theme another browser already migrated must not be
|
|
2797
|
+
* written twice, while one this roster lacks would otherwise disappear from the
|
|
2798
|
+
* UI. Callers adopt the result, then stop reading the browser copy.
|
|
2799
|
+
*/
|
|
2800
|
+
function legacyThemesToAdopt(legacy, hostThemes) {
|
|
2801
|
+
const known = new Set(hostThemes.map((entry) => entry.id));
|
|
2802
|
+
return legacy.filter((entry) => !known.has(entry.id));
|
|
2803
|
+
}
|
|
2804
|
+
//#endregion
|
|
2226
2805
|
//#region src/client/components.tsx
|
|
2227
2806
|
/**
|
|
2228
|
-
* Theme settings UI: scheme selector
|
|
2807
|
+
* Theme settings UI: scheme selector, preset picker, and the custom colour editor.
|
|
2808
|
+
*/
|
|
2809
|
+
/** Reserved menu value that switches to the user-defined theme. */
|
|
2810
|
+
const CUSTOM_SELECTION = CUSTOM_PRESET_ID;
|
|
2811
|
+
/**
|
|
2812
|
+
* Which message an import failure deserves. The Host answers with its own
|
|
2813
|
+
* reason, so the shape of that reason is what selects the localized text; an
|
|
2814
|
+
* unrecognised failure keeps the Host's own words rather than hiding them.
|
|
2229
2815
|
*/
|
|
2816
|
+
function importFailureMessage(t, error) {
|
|
2817
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2818
|
+
if (error instanceof ThemeApiUnavailableError) return t("custom.toast.unavailable");
|
|
2819
|
+
return t(/not a zip archive|zip central directory|zip local header|zip64|unsupported zip compression/i.test(message) ? "custom.importFailed.notArchive" : /missing theme\.json/i.test(message) ? "custom.importFailed.noDocument" : /newer than this plugin/i.test(message) ? "custom.importFailed.tooNew" : /carries media|unsupported media type|unusable media path/i.test(message) ? "custom.importFailed.media" : /larger than|too large|too many files/i.test(message) ? "custom.importFailed.tooLarge" : /not valid JSON|must be|unusable theme|did not report/i.test(message) ? "custom.importFailed.badDocument" : "custom.importFailed.other").replace("{0}", message);
|
|
2820
|
+
}
|
|
2821
|
+
/** Structural equality of the two seeds in one pair. */
|
|
2822
|
+
function samePair(a, b) {
|
|
2823
|
+
return a?.light === b?.light && a?.dark === b?.dark;
|
|
2824
|
+
}
|
|
2825
|
+
/** Structural equality of the two endpoints of one neutral ramp. */
|
|
2826
|
+
function sameRamp(a, b) {
|
|
2827
|
+
return a?.lightest === b?.lightest && a?.darkest === b?.darkest;
|
|
2828
|
+
}
|
|
2829
|
+
/** Structural equality over all 30 seeds, ignoring key order. */
|
|
2830
|
+
function sameSeeds(a, b) {
|
|
2831
|
+
if (!sameRamp(a.neutral?.light, b.neutral?.light) || !sameRamp(a.neutral?.dark, b.neutral?.dark)) return false;
|
|
2832
|
+
if (!samePair(a.accent, b.accent) || !samePair(a.green, b.green)) return false;
|
|
2833
|
+
if (!samePair(a.amber, b.amber) || !samePair(a.red, b.red)) return false;
|
|
2834
|
+
return SHIKI_KEYS.every((k) => samePair(a.shiki?.[k], b.shiki?.[k]));
|
|
2835
|
+
}
|
|
2836
|
+
/** The two roles one appearance's neutral ramp fills, in display order. */
|
|
2837
|
+
const NEUTRAL_ROLES = ["background", "foreground"];
|
|
2838
|
+
/**
|
|
2839
|
+
* The ramp end that paints an appearance's background or foreground. DSH points
|
|
2840
|
+
* its aliases at step 00 for light surfaces and step 1000 for light text, then
|
|
2841
|
+
* swaps both in dark (950 surfaces, 50 text), so the same end means opposite
|
|
2842
|
+
* things in the two appearances. Naming the role and resolving it per appearance
|
|
2843
|
+
* keeps the swatches in a stable background-then-foreground order.
|
|
2844
|
+
*/
|
|
2845
|
+
function neutralEndForRole(mode, role) {
|
|
2846
|
+
return role === "background" === (mode === "light") ? "lightest" : "darkest";
|
|
2847
|
+
}
|
|
2848
|
+
/**
|
|
2849
|
+
* Whether the open editor holds work a close would throw away. A brand-new
|
|
2850
|
+
* theme has never been committed, so it always counts as unsaved; a reopened
|
|
2851
|
+
* entry is dirty only once its name or one of its seeds actually moved.
|
|
2852
|
+
*/
|
|
2853
|
+
function isDirty(open, current) {
|
|
2854
|
+
if (open.id === null) return true;
|
|
2855
|
+
return open.name.trim() !== open.originName.trim() || !sameSeeds(current, open.originTheme);
|
|
2856
|
+
}
|
|
2230
2857
|
function IconChevron() {
|
|
2231
2858
|
return react.createElement("svg", {
|
|
2232
2859
|
width: 14,
|
|
@@ -2239,25 +2866,23 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
|
|
|
2239
2866
|
fill: "currentColor"
|
|
2240
2867
|
}));
|
|
2241
2868
|
}
|
|
2242
|
-
|
|
2869
|
+
/** The menu's selected-row tick: 16px, pushed to the trailing edge. */
|
|
2870
|
+
function IconCheck(props) {
|
|
2871
|
+
const { size = 16, className } = props;
|
|
2243
2872
|
return react.createElement("svg", {
|
|
2244
|
-
width:
|
|
2245
|
-
height:
|
|
2873
|
+
width: size,
|
|
2874
|
+
height: size,
|
|
2246
2875
|
viewBox: "0 0 16 16",
|
|
2247
2876
|
fill: "none",
|
|
2248
2877
|
xmlns: "http://www.w3.org/2000/svg",
|
|
2249
|
-
|
|
2250
|
-
marginLeft: "auto",
|
|
2251
|
-
color: "var(--dsw-alias-label-primary)",
|
|
2252
|
-
display: "inline-flex"
|
|
2253
|
-
}
|
|
2878
|
+
className
|
|
2254
2879
|
}, react.createElement("path", {
|
|
2255
2880
|
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
2881
|
fill: "currentColor"
|
|
2257
2882
|
}));
|
|
2258
2883
|
}
|
|
2259
2884
|
function SchemeMenu(props) {
|
|
2260
|
-
const { value, options, onSelect } = props;
|
|
2885
|
+
const { value, options, onSelect, disabled } = props;
|
|
2261
2886
|
const [open, setOpen] = react.useState(false);
|
|
2262
2887
|
const rootRef = react.useRef(null);
|
|
2263
2888
|
const listRef = react.useRef(null);
|
|
@@ -2289,10 +2914,11 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
|
|
|
2289
2914
|
}, react.createElement("button", {
|
|
2290
2915
|
type: "button",
|
|
2291
2916
|
className: "ct-select",
|
|
2917
|
+
disabled,
|
|
2292
2918
|
"aria-haspopup": "menu",
|
|
2293
|
-
"aria-expanded": open,
|
|
2919
|
+
"aria-expanded": open && !disabled,
|
|
2294
2920
|
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", {
|
|
2921
|
+
}, 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
2922
|
ref: listRef,
|
|
2297
2923
|
className: "ct-menu-list",
|
|
2298
2924
|
style: {
|
|
@@ -2314,27 +2940,622 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
|
|
|
2314
2940
|
setOpen(false);
|
|
2315
2941
|
onSelect(o.value);
|
|
2316
2942
|
}
|
|
2317
|
-
}, react.createElement("span", { className: "ct-menu-item-label" }, o.label), sel ? react.createElement(IconCheck,
|
|
2943
|
+
}, react.createElement("span", { className: "ct-menu-item-label" }, o.label), sel ? react.createElement(IconCheck, { className: "ct-menu-check" }) : null);
|
|
2318
2944
|
})) : null);
|
|
2319
2945
|
}
|
|
2946
|
+
/** Accessible on/off switch used to enable the custom theme. */
|
|
2947
|
+
function Switch(props) {
|
|
2948
|
+
const { checked, label, onChange } = props;
|
|
2949
|
+
return react.createElement("button", {
|
|
2950
|
+
type: "button",
|
|
2951
|
+
role: "switch",
|
|
2952
|
+
"aria-checked": checked,
|
|
2953
|
+
"aria-label": label,
|
|
2954
|
+
className: checked ? "ct-switch ct-switch-on" : "ct-switch",
|
|
2955
|
+
onClick: () => onChange(!checked)
|
|
2956
|
+
}, react.createElement("span", { className: "ct-switch-knob" }));
|
|
2957
|
+
}
|
|
2958
|
+
/**
|
|
2959
|
+
* Confirmation dialog for destructive actions, mirroring DSH's own Modal
|
|
2960
|
+
* (design-platform mask token + blur, r24 card on layer-2, prominent shadow).
|
|
2961
|
+
* Rendered inline rather than through a portal so it needs no react-dom import.
|
|
2962
|
+
*/
|
|
2963
|
+
function ConfirmDialog(props) {
|
|
2964
|
+
const { title, description, confirmLabel, cancelLabel, onConfirm, onCancel } = props;
|
|
2965
|
+
react.useEffect(() => {
|
|
2966
|
+
const onKey = (e) => {
|
|
2967
|
+
if (e.key === "Escape") onCancel();
|
|
2968
|
+
};
|
|
2969
|
+
document.addEventListener("keydown", onKey);
|
|
2970
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
2971
|
+
}, [onCancel]);
|
|
2972
|
+
return react.createElement("div", { className: "ct-modal-root" }, react.createElement("div", {
|
|
2973
|
+
className: "ct-modal-mask",
|
|
2974
|
+
"aria-hidden": true,
|
|
2975
|
+
onClick: onCancel
|
|
2976
|
+
}), react.createElement("div", {
|
|
2977
|
+
className: "ct-modal-card",
|
|
2978
|
+
role: "dialog",
|
|
2979
|
+
"aria-modal": true,
|
|
2980
|
+
"aria-label": title
|
|
2981
|
+
}, 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", {
|
|
2982
|
+
type: "button",
|
|
2983
|
+
className: "ct-btn",
|
|
2984
|
+
onClick: onCancel
|
|
2985
|
+
}, cancelLabel), react.createElement("button", {
|
|
2986
|
+
type: "button",
|
|
2987
|
+
className: "ct-btn ct-btn-danger",
|
|
2988
|
+
onClick: onConfirm
|
|
2989
|
+
}, confirmLabel))));
|
|
2990
|
+
}
|
|
2991
|
+
/** Which banner a failed roster call deserves. */
|
|
2992
|
+
function toastKeyFor(error) {
|
|
2993
|
+
return error instanceof ThemeApiUnavailableError ? "custom.toast.unavailable" : "custom.toast.failed";
|
|
2994
|
+
}
|
|
2995
|
+
/**
|
|
2996
|
+
* Transient confirmation banner: a leading result badge plus the copy, top
|
|
2997
|
+
* center, sliding in, holding, fading out, then reporting done so the owner can
|
|
2998
|
+
* unmount it. Metrics, tokens, badge and timing follow DSH's own toast — the
|
|
2999
|
+
* badge is the success circle its confirmations wear — but the component is
|
|
3000
|
+
* local: the plugin must keep answering a save on a host that does not share
|
|
3001
|
+
* the primitives module.
|
|
3002
|
+
*
|
|
3003
|
+
* TOAST_HOLD_MS and TOAST_FADE_MS have to agree with the `ct-toast` animation
|
|
3004
|
+
* in the injected stylesheet, which owns the visual timing.
|
|
3005
|
+
* @param props.text - resolved, already-localized copy.
|
|
3006
|
+
* @param props.onDone - called once the fade completes; unmount the toast here.
|
|
3007
|
+
*/
|
|
3008
|
+
function Toast(props) {
|
|
3009
|
+
const { text, onDone } = props;
|
|
3010
|
+
react.useEffect(() => {
|
|
3011
|
+
const timer = setTimeout(onDone, 4e3);
|
|
3012
|
+
return () => clearTimeout(timer);
|
|
3013
|
+
}, [onDone]);
|
|
3014
|
+
return react.createElement("div", {
|
|
3015
|
+
className: "ct-toast",
|
|
3016
|
+
role: "status"
|
|
3017
|
+
}, react.createElement(ToastBadge), react.createElement("span", { className: "ct-toast-text" }, text));
|
|
3018
|
+
}
|
|
3019
|
+
/** The leading badge every confirmation wears: a success ring around the check. */
|
|
3020
|
+
function ToastBadge() {
|
|
3021
|
+
return react.createElement("span", {
|
|
3022
|
+
className: "ct-toast-icon",
|
|
3023
|
+
"aria-hidden": true
|
|
3024
|
+
}, react.createElement(IconCheck, { size: 12 }));
|
|
3025
|
+
}
|
|
3026
|
+
/**
|
|
3027
|
+
* Plus glyph for the add button, path data taken verbatim from DSH's own
|
|
3028
|
+
* `IconPlusOutline16` so the two controls read as the same affordance.
|
|
3029
|
+
*/
|
|
3030
|
+
function IconPlus(props) {
|
|
3031
|
+
const { size = 14 } = props;
|
|
3032
|
+
return react.createElement("svg", {
|
|
3033
|
+
width: size,
|
|
3034
|
+
height: size,
|
|
3035
|
+
viewBox: "0 0 16 16",
|
|
3036
|
+
fill: "none",
|
|
3037
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
3038
|
+
"aria-hidden": true,
|
|
3039
|
+
focusable: false
|
|
3040
|
+
}, react.createElement("path", {
|
|
3041
|
+
d: "M8.64453 1.5V7.34961H14.5V8.65039H8.64453V14.5H7.34473V8.65039H1.5V7.34961H7.34473V1.5H8.64453Z",
|
|
3042
|
+
fill: "currentColor"
|
|
3043
|
+
}));
|
|
3044
|
+
}
|
|
3045
|
+
/**
|
|
3046
|
+
* Lucide `edit` (the pencil in a square), path data verbatim. Row actions are
|
|
3047
|
+
* icon-only, so the glyph has to read as the label the accessible name still
|
|
3048
|
+
* carries.
|
|
3049
|
+
*/
|
|
3050
|
+
function IconEdit(props) {
|
|
3051
|
+
const { size = 14 } = props;
|
|
3052
|
+
return react.createElement("svg", {
|
|
3053
|
+
width: size,
|
|
3054
|
+
height: size,
|
|
3055
|
+
viewBox: "0 0 24 24",
|
|
3056
|
+
fill: "none",
|
|
3057
|
+
stroke: "currentColor",
|
|
3058
|
+
strokeWidth: 2,
|
|
3059
|
+
strokeLinecap: "round",
|
|
3060
|
+
strokeLinejoin: "round",
|
|
3061
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
3062
|
+
"aria-hidden": true,
|
|
3063
|
+
focusable: false
|
|
3064
|
+
}, react.createElement("path", { d: "M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" }), react.createElement("path", { d: "M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" }));
|
|
3065
|
+
}
|
|
3066
|
+
/** Lucide `trash`, path data verbatim; the row's danger action. */
|
|
3067
|
+
function IconTrash(props) {
|
|
3068
|
+
const { size = 14 } = props;
|
|
3069
|
+
return react.createElement("svg", {
|
|
3070
|
+
width: size,
|
|
3071
|
+
height: size,
|
|
3072
|
+
viewBox: "0 0 24 24",
|
|
3073
|
+
fill: "none",
|
|
3074
|
+
stroke: "currentColor",
|
|
3075
|
+
strokeWidth: 2,
|
|
3076
|
+
strokeLinecap: "round",
|
|
3077
|
+
strokeLinejoin: "round",
|
|
3078
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
3079
|
+
"aria-hidden": true,
|
|
3080
|
+
focusable: false
|
|
3081
|
+
}, react.createElement("path", { d: "M3 6h18" }), react.createElement("path", { d: "M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" }), react.createElement("path", { d: "M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" }), react.createElement("line", {
|
|
3082
|
+
x1: 10,
|
|
3083
|
+
y1: 11,
|
|
3084
|
+
x2: 10,
|
|
3085
|
+
y2: 17
|
|
3086
|
+
}), react.createElement("line", {
|
|
3087
|
+
x1: 14,
|
|
3088
|
+
y1: 11,
|
|
3089
|
+
x2: 14,
|
|
3090
|
+
y2: 17
|
|
3091
|
+
}));
|
|
3092
|
+
}
|
|
3093
|
+
/** Lucide `share`, path data verbatim; sends one theme out as an archive. */
|
|
3094
|
+
function IconShare(props) {
|
|
3095
|
+
const { size = 14 } = props;
|
|
3096
|
+
return react.createElement("svg", {
|
|
3097
|
+
width: size,
|
|
3098
|
+
height: size,
|
|
3099
|
+
viewBox: "0 0 24 24",
|
|
3100
|
+
fill: "none",
|
|
3101
|
+
stroke: "currentColor",
|
|
3102
|
+
strokeWidth: 2,
|
|
3103
|
+
strokeLinecap: "round",
|
|
3104
|
+
strokeLinejoin: "round",
|
|
3105
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
3106
|
+
"aria-hidden": true,
|
|
3107
|
+
focusable: false
|
|
3108
|
+
}, react.createElement("path", { d: "M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" }), react.createElement("polyline", { points: "16 6 12 2 8 6" }), react.createElement("line", {
|
|
3109
|
+
x1: 12,
|
|
3110
|
+
y1: 2,
|
|
3111
|
+
x2: 12,
|
|
3112
|
+
y2: 15
|
|
3113
|
+
}));
|
|
3114
|
+
}
|
|
3115
|
+
/** Lucide `download`, path data verbatim; takes one archive in. */
|
|
3116
|
+
function IconDownload(props) {
|
|
3117
|
+
const { size = 14 } = props;
|
|
3118
|
+
return react.createElement("svg", {
|
|
3119
|
+
width: size,
|
|
3120
|
+
height: size,
|
|
3121
|
+
viewBox: "0 0 24 24",
|
|
3122
|
+
fill: "none",
|
|
3123
|
+
stroke: "currentColor",
|
|
3124
|
+
strokeWidth: 2,
|
|
3125
|
+
strokeLinecap: "round",
|
|
3126
|
+
strokeLinejoin: "round",
|
|
3127
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
3128
|
+
"aria-hidden": true,
|
|
3129
|
+
focusable: false
|
|
3130
|
+
}, react.createElement("path", { d: "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" }), react.createElement("polyline", { points: "7 10 12 15 17 10" }), react.createElement("line", {
|
|
3131
|
+
x1: 12,
|
|
3132
|
+
y1: 15,
|
|
3133
|
+
x2: 12,
|
|
3134
|
+
y2: 3
|
|
3135
|
+
}));
|
|
3136
|
+
}
|
|
3137
|
+
/**
|
|
3138
|
+
* One collapsed saved-theme card: the name activates the theme, and the trailing
|
|
3139
|
+
* controls export it, open the editor or delete it. The card as a whole is a
|
|
3140
|
+
* click target too, but its controls own their clicks.
|
|
3141
|
+
*/
|
|
3142
|
+
function ThemeCard(props) {
|
|
3143
|
+
const { entry, active, disabled, t, onActivate, onExport, onEdit, onDelete } = props;
|
|
3144
|
+
/**
|
|
3145
|
+
* One square icon action; `danger` is the tinted delete variant. The glyph is
|
|
3146
|
+
* decorative, so the label travels as the accessible name instead of as text,
|
|
3147
|
+
* and as `data-tip` for the hover tooltip. `leave` marks the button on the
|
|
3148
|
+
* markup the outside-click guard watches (see the guard's own comment).
|
|
3149
|
+
*/
|
|
3150
|
+
const action = (label, icon, onClick, variant) => react.createElement("button", {
|
|
3151
|
+
type: "button",
|
|
3152
|
+
className: variant === "danger" ? "ct-list-btn ct-list-btn-danger ct-tip" : "ct-list-btn ct-tip",
|
|
3153
|
+
"aria-label": label,
|
|
3154
|
+
"data-tip": label,
|
|
3155
|
+
...variant === "leave" ? {
|
|
3156
|
+
"data-ct-leave": "true",
|
|
3157
|
+
"data-ct-entry": entry.id
|
|
3158
|
+
} : {},
|
|
3159
|
+
onClick
|
|
3160
|
+
}, icon);
|
|
3161
|
+
return react.createElement("div", {
|
|
3162
|
+
className: "ct-list-row",
|
|
3163
|
+
"data-ct-leave": "true",
|
|
3164
|
+
"data-ct-entry": entry.id,
|
|
3165
|
+
onClick: disabled ? void 0 : onActivate
|
|
3166
|
+
}, react.createElement("span", { className: "ct-list-identity" }, react.createElement("button", {
|
|
3167
|
+
className: "ct-list-name",
|
|
3168
|
+
type: "button",
|
|
3169
|
+
disabled,
|
|
3170
|
+
onClick: (e) => {
|
|
3171
|
+
e.stopPropagation();
|
|
3172
|
+
onActivate();
|
|
3173
|
+
}
|
|
3174
|
+
}, entry.name)), react.createElement("div", {
|
|
3175
|
+
className: "ct-list-actions",
|
|
3176
|
+
onClick: (e) => e.stopPropagation()
|
|
3177
|
+
}, active ? react.createElement("span", { className: "ct-list-badge" }, t("custom.inUse")) : null, action(t("custom.edit"), react.createElement(IconEdit, { size: 14 }), onEdit, "leave"), action(t("custom.export"), react.createElement(IconShare, { size: 14 }), onExport), action(t("custom.delete"), react.createElement(IconTrash, { size: 14 }), onDelete, "danger")));
|
|
3178
|
+
}
|
|
3179
|
+
/**
|
|
3180
|
+
* Which appearance the colour dots edit. `system` resolves through the same
|
|
3181
|
+
* media query the shell uses, so the editor opens on what is on screen.
|
|
3182
|
+
*/
|
|
3183
|
+
function effectiveMode(scheme) {
|
|
3184
|
+
if (scheme === "dark") return "dark";
|
|
3185
|
+
if (scheme === "light") return "light";
|
|
3186
|
+
try {
|
|
3187
|
+
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
|
3188
|
+
} catch {
|
|
3189
|
+
return "light";
|
|
3190
|
+
}
|
|
3191
|
+
}
|
|
2320
3192
|
function ThemePanel(props) {
|
|
2321
|
-
const { theme,
|
|
3193
|
+
const { theme, getSelection, setSelection, getCustom, setCustom, setCustomBase, resetCustom, saved, getHostToast, t, onRequestClose } = props;
|
|
2322
3194
|
let initScheme = "system";
|
|
2323
3195
|
try {
|
|
2324
3196
|
const snap = theme?.getTheme();
|
|
2325
3197
|
if (snap?.preference) initScheme = snap.preference;
|
|
2326
3198
|
} catch {}
|
|
2327
3199
|
const [scheme, setScheme] = react.useState(initScheme);
|
|
2328
|
-
const [
|
|
2329
|
-
|
|
3200
|
+
const [seedMode, setSeedMode] = react.useState(() => effectiveMode(initScheme));
|
|
3201
|
+
react.useEffect(() => {
|
|
3202
|
+
setSeedMode(effectiveMode(scheme));
|
|
3203
|
+
}, [scheme]);
|
|
3204
|
+
const [selection, setSelectionState] = react.useState(() => getSelection());
|
|
3205
|
+
const [custom, setCustomState] = react.useState(() => getCustom().theme);
|
|
3206
|
+
const [shownPreset, setShownPreset] = react.useState(() => {
|
|
3207
|
+
const initial = getSelection();
|
|
3208
|
+
return initial === CUSTOM_SELECTION ? getCustom().base : initial;
|
|
3209
|
+
});
|
|
3210
|
+
const [list, setList] = react.useState(() => saved.list());
|
|
3211
|
+
const [activeId, setActiveId] = react.useState(() => saved.activeId());
|
|
3212
|
+
const [pendingDelete, setPendingDelete] = react.useState(null);
|
|
3213
|
+
const [importing, setImporting] = react.useState(false);
|
|
3214
|
+
/** The hidden picker the import button opens. */
|
|
3215
|
+
const importInputRef = react.useRef(null);
|
|
3216
|
+
const [editing, setEditing] = react.useState(null);
|
|
3217
|
+
const [confirmClose, setConfirmClose] = react.useState(false);
|
|
3218
|
+
const [pendingLeave, setPendingLeave] = react.useState(null);
|
|
3219
|
+
const editingRef = react.useRef(null);
|
|
3220
|
+
editingRef.current = editing;
|
|
3221
|
+
const savingRef = react.useRef(false);
|
|
3222
|
+
const schemeRef = react.useRef(scheme);
|
|
3223
|
+
schemeRef.current = scheme;
|
|
3224
|
+
const dirtyRef = react.useRef(false);
|
|
3225
|
+
dirtyRef.current = editing !== null && isDirty(editing, custom);
|
|
3226
|
+
const confirmCloseRef = react.useRef(false);
|
|
3227
|
+
confirmCloseRef.current = confirmClose;
|
|
3228
|
+
const pendingLeaveRef = react.useRef(null);
|
|
3229
|
+
pendingLeaveRef.current = pendingLeave;
|
|
3230
|
+
const listRef = react.useRef(list);
|
|
3231
|
+
listRef.current = list;
|
|
3232
|
+
const pendingDeleteRef = react.useRef(null);
|
|
3233
|
+
pendingDeleteRef.current = pendingDelete;
|
|
3234
|
+
const [toast, setToast] = react.useState(null);
|
|
3235
|
+
const toastSeq = react.useRef(0);
|
|
3236
|
+
function showToast(text) {
|
|
3237
|
+
toastSeq.current += 1;
|
|
3238
|
+
setToast({
|
|
3239
|
+
seq: toastSeq.current,
|
|
3240
|
+
text
|
|
3241
|
+
});
|
|
3242
|
+
}
|
|
3243
|
+
/** Re-read both the list and the active pointer after any list mutation. */
|
|
3244
|
+
function syncSaved() {
|
|
3245
|
+
setList(saved.list());
|
|
3246
|
+
setActiveId(saved.activeId());
|
|
3247
|
+
}
|
|
3248
|
+
react.useEffect(() => {
|
|
3249
|
+
let cancelled = false;
|
|
3250
|
+
saved.refresh().then(() => {
|
|
3251
|
+
if (!cancelled) syncSaved();
|
|
3252
|
+
}).catch((error) => {
|
|
3253
|
+
if (!cancelled) showToast(t(toastKeyFor(error)));
|
|
3254
|
+
});
|
|
3255
|
+
return () => {
|
|
3256
|
+
cancelled = true;
|
|
3257
|
+
};
|
|
3258
|
+
}, []);
|
|
3259
|
+
/** Run one roster mutation, surfacing a failed round trip as a banner. */
|
|
3260
|
+
async function mutate(operation) {
|
|
3261
|
+
try {
|
|
3262
|
+
return await operation();
|
|
3263
|
+
} catch (error) {
|
|
3264
|
+
showToast(t(toastKeyFor(error)));
|
|
3265
|
+
return null;
|
|
3266
|
+
}
|
|
3267
|
+
}
|
|
3268
|
+
/** Load a card: the store moves the active pointer itself, so `syncSaved`
|
|
3269
|
+
* re-reads the badge rather than a separate `setActive` call. */
|
|
3270
|
+
function onLoadSaved(id) {
|
|
3271
|
+
const next = saved.load(id);
|
|
3272
|
+
if (next) setCustomState(next);
|
|
3273
|
+
syncSaved();
|
|
3274
|
+
}
|
|
3275
|
+
/** Snapshot the state an edit must return to when it is cancelled. */
|
|
3276
|
+
function beginEditing(id, name, originName, originTheme) {
|
|
3277
|
+
return {
|
|
3278
|
+
id,
|
|
3279
|
+
name,
|
|
3280
|
+
originId: saved.activeId(),
|
|
3281
|
+
originDraft: getCustom().theme,
|
|
3282
|
+
originName,
|
|
3283
|
+
originTheme,
|
|
3284
|
+
originScheme: schemeRef.current
|
|
3285
|
+
};
|
|
3286
|
+
}
|
|
3287
|
+
/** Commit an appearance preference, through the Theme service when it is there. */
|
|
3288
|
+
function applyAppearance(id) {
|
|
2330
3289
|
setScheme(id);
|
|
2331
3290
|
try {
|
|
2332
3291
|
theme?.setTheme(id);
|
|
2333
3292
|
} catch {}
|
|
2334
3293
|
}
|
|
3294
|
+
/**
|
|
3295
|
+
* Preview an appearance on the whole page while its colours are edited: a
|
|
3296
|
+
* custom theme carries both variants, so the other one's seeds are only
|
|
3297
|
+
* visible once the page actually wears that appearance. A no-op when the page
|
|
3298
|
+
* already resolves to `mode` — clicking the active half of the toggle must not
|
|
3299
|
+
* quietly turn a `system` preference into a pinned one.
|
|
3300
|
+
*/
|
|
3301
|
+
function previewAppearance(mode) {
|
|
3302
|
+
if (effectiveMode(schemeRef.current) === mode) return;
|
|
3303
|
+
applyAppearance(mode);
|
|
3304
|
+
}
|
|
3305
|
+
/**
|
|
3306
|
+
* End a preview: put the appearance the editor opened on back. The preference
|
|
3307
|
+
* is a global setting the mode toggle only borrows, so it must not survive the
|
|
3308
|
+
* editor — not even on save, which commits the colours alone.
|
|
3309
|
+
*/
|
|
3310
|
+
function restoreAppearance(open) {
|
|
3311
|
+
if (schemeRef.current === open.originScheme) return;
|
|
3312
|
+
applyAppearance(open.originScheme);
|
|
3313
|
+
}
|
|
3314
|
+
/**
|
|
3315
|
+
* Undo an open (unsaved) edit: editing previews live and persists on every
|
|
3316
|
+
* change, so abandoning the card has to write the pre-edit theme back.
|
|
3317
|
+
*/
|
|
3318
|
+
function revertEditing(open) {
|
|
3319
|
+
restoreAppearance(open);
|
|
3320
|
+
if (open.originId) onLoadSaved(open.originId);
|
|
3321
|
+
else {
|
|
3322
|
+
setCustom(open.originDraft);
|
|
3323
|
+
setCustomState(open.originDraft);
|
|
3324
|
+
saved.setActive(null);
|
|
3325
|
+
}
|
|
3326
|
+
}
|
|
3327
|
+
react.useEffect(() => () => {
|
|
3328
|
+
const open = editingRef.current;
|
|
3329
|
+
if (!open || savingRef.current) return;
|
|
3330
|
+
if (schemeRef.current !== open.originScheme) try {
|
|
3331
|
+
theme?.setTheme(open.originScheme);
|
|
3332
|
+
} catch {}
|
|
3333
|
+
if (open.originId) saved.load(open.originId);
|
|
3334
|
+
else {
|
|
3335
|
+
setCustom(open.originDraft);
|
|
3336
|
+
saved.setActive(null);
|
|
3337
|
+
}
|
|
3338
|
+
}, []);
|
|
3339
|
+
react.useEffect(() => {
|
|
3340
|
+
/** The header button carrying the `settings.close` seat, if `node` is in it. */
|
|
3341
|
+
function isCloseButton(node) {
|
|
3342
|
+
const seat = node.ownerDocument.querySelector("[data-slot=\"settings.close\"]");
|
|
3343
|
+
const button = node.closest("button");
|
|
3344
|
+
return !!seat && !!button && button.contains(seat);
|
|
3345
|
+
}
|
|
3346
|
+
/** The settings panel itself; the close seat is unique to it. */
|
|
3347
|
+
function settingsPanel() {
|
|
3348
|
+
return document.querySelector("[data-slot=\"settings.close\"]")?.closest("[role=\"dialog\"]") ?? null;
|
|
3349
|
+
}
|
|
3350
|
+
/**
|
|
3351
|
+
* Whether `node` sits on a nav *cell* — the buttons that switch sections —
|
|
3352
|
+
* rather than anywhere else in the rail. Cells are the only buttons in the
|
|
3353
|
+
* nav, and the shell marks the active one with `aria-current`; taking that
|
|
3354
|
+
* button's parent gives the cell list itself, which still matches once the
|
|
3355
|
+
* active cell moves to whatever was just clicked.
|
|
3356
|
+
*/
|
|
3357
|
+
function isNavCell(panel, node) {
|
|
3358
|
+
const list = panel?.querySelector("nav button[aria-current]")?.parentElement;
|
|
3359
|
+
const button = node.closest("button");
|
|
3360
|
+
return !!list && !!button && list.contains(button);
|
|
3361
|
+
}
|
|
3362
|
+
function onKeyDown(e) {
|
|
3363
|
+
if (e.key !== "Escape") return;
|
|
3364
|
+
if (confirmCloseRef.current || pendingLeaveRef.current) {
|
|
3365
|
+
e.preventDefault();
|
|
3366
|
+
e.stopPropagation();
|
|
3367
|
+
setConfirmClose(false);
|
|
3368
|
+
setPendingLeave(null);
|
|
3369
|
+
return;
|
|
3370
|
+
}
|
|
3371
|
+
if (!dirtyRef.current || !onRequestClose) return;
|
|
3372
|
+
e.preventDefault();
|
|
3373
|
+
e.stopPropagation();
|
|
3374
|
+
setConfirmClose(true);
|
|
3375
|
+
}
|
|
3376
|
+
function onClick(e) {
|
|
3377
|
+
if (!dirtyRef.current || confirmCloseRef.current || pendingLeaveRef.current || pendingDeleteRef.current) return;
|
|
3378
|
+
const target = e.target instanceof Element ? e.target : null;
|
|
3379
|
+
if (!target) return;
|
|
3380
|
+
if (target.closest("[data-ct-editor]")) return;
|
|
3381
|
+
const panel = settingsPanel();
|
|
3382
|
+
if (!!panel && !panel.contains(target) || isCloseButton(target)) {
|
|
3383
|
+
if (!onRequestClose) return;
|
|
3384
|
+
e.preventDefault();
|
|
3385
|
+
e.stopPropagation();
|
|
3386
|
+
setConfirmClose(true);
|
|
3387
|
+
return;
|
|
3388
|
+
}
|
|
3389
|
+
if (!panel || !panel.contains(target)) return;
|
|
3390
|
+
if (isNavCell(panel, target)) {
|
|
3391
|
+
if (!onRequestClose) return;
|
|
3392
|
+
e.preventDefault();
|
|
3393
|
+
e.stopPropagation();
|
|
3394
|
+
setConfirmClose(true);
|
|
3395
|
+
return;
|
|
3396
|
+
}
|
|
3397
|
+
const leave = target.closest("[data-ct-leave]");
|
|
3398
|
+
if (!leave) return;
|
|
3399
|
+
const entry = listRef.current.find((item) => item.id === leave.getAttribute("data-ct-entry"));
|
|
3400
|
+
if (!entry) return;
|
|
3401
|
+
e.preventDefault();
|
|
3402
|
+
e.stopPropagation();
|
|
3403
|
+
setPendingLeave({
|
|
3404
|
+
entry,
|
|
3405
|
+
edit: !!target.closest("button[data-ct-leave]")
|
|
3406
|
+
});
|
|
3407
|
+
}
|
|
3408
|
+
document.addEventListener("keydown", onKeyDown, true);
|
|
3409
|
+
document.addEventListener("click", onClick, true);
|
|
3410
|
+
return () => {
|
|
3411
|
+
document.removeEventListener("keydown", onKeyDown, true);
|
|
3412
|
+
document.removeEventListener("click", onClick, true);
|
|
3413
|
+
};
|
|
3414
|
+
}, [onRequestClose]);
|
|
3415
|
+
/** Open a fresh, unsaved theme seeded from the currently selected preset. */
|
|
3416
|
+
function startAdd() {
|
|
3417
|
+
const origin = beginEditing(null, nextThemeName(t("custom.defaultName"), list.map((e) => e.name)), "", getCustom().theme);
|
|
3418
|
+
setCustomBase(shownPreset);
|
|
3419
|
+
const draft = resetCustom();
|
|
3420
|
+
setEditing(origin);
|
|
3421
|
+
setCustomState(draft);
|
|
3422
|
+
}
|
|
3423
|
+
/** Open an existing card for editing; it also becomes the previewed theme. */
|
|
3424
|
+
function startEdit(entry) {
|
|
3425
|
+
const next = beginEditing(entry.id, entry.name, entry.name, entry.theme);
|
|
3426
|
+
setEditing(next);
|
|
3427
|
+
onLoadSaved(entry.id);
|
|
3428
|
+
}
|
|
3429
|
+
/** Discard the open editor and put the previously applied theme back. */
|
|
3430
|
+
function cancelEdit() {
|
|
3431
|
+
if (!editing) return;
|
|
3432
|
+
revertEditing(editing);
|
|
3433
|
+
setEditing(null);
|
|
3434
|
+
syncSaved();
|
|
3435
|
+
}
|
|
3436
|
+
/**
|
|
3437
|
+
* Send one card out as an archive. Export never touches the draft or the
|
|
3438
|
+
* roster, so it stays available while an editor is open and needs no guard.
|
|
3439
|
+
*/
|
|
3440
|
+
async function exportCard(entry) {
|
|
3441
|
+
try {
|
|
3442
|
+
await exportTheme(entry.id, entry.name);
|
|
3443
|
+
showToast(t("custom.toast.exported").replace("{0}", entry.name));
|
|
3444
|
+
} catch (error) {
|
|
3445
|
+
showToast(t(error instanceof ThemeApiUnavailableError ? "custom.toast.unavailable" : "custom.toast.failed"));
|
|
3446
|
+
}
|
|
3447
|
+
}
|
|
3448
|
+
/**
|
|
3449
|
+
* Take one archive in. The Host validates the archive and writes the theme it
|
|
3450
|
+
* carried — a theme is a document, so an import stores exactly what an export
|
|
3451
|
+
* packed, with the Host choosing the id and a free name. The draft is never
|
|
3452
|
+
* involved, so importing leaves an open editor exactly as it was.
|
|
3453
|
+
*/
|
|
3454
|
+
async function importArchive(file) {
|
|
3455
|
+
setImporting(true);
|
|
3456
|
+
try {
|
|
3457
|
+
const imported = await importTheme(file);
|
|
3458
|
+
await reloadRoster();
|
|
3459
|
+
showToast(t("custom.toast.imported").replace("{0}", imported.name));
|
|
3460
|
+
} catch (error) {
|
|
3461
|
+
showToast(importFailureMessage(t, error));
|
|
3462
|
+
} finally {
|
|
3463
|
+
setImporting(false);
|
|
3464
|
+
}
|
|
3465
|
+
}
|
|
3466
|
+
/**
|
|
3467
|
+
* Re-read the roster after a write the store did not make. The import route is
|
|
3468
|
+
* the Host's own: the cached list still holds the pre-import roster, so without
|
|
3469
|
+
* this the new card only appears once some later mutation refreshes the cache.
|
|
3470
|
+
* A failed re-read is not a failed import — the theme is stored either way — so
|
|
3471
|
+
* the card is left to the next roster mutation rather than reported as an error.
|
|
3472
|
+
*/
|
|
3473
|
+
async function reloadRoster() {
|
|
3474
|
+
try {
|
|
3475
|
+
await saved.refresh();
|
|
3476
|
+
syncSaved();
|
|
3477
|
+
} catch {}
|
|
3478
|
+
}
|
|
3479
|
+
/** Open the picker, clearing the previous choice so the same file can be re-picked. */
|
|
3480
|
+
function startImport() {
|
|
3481
|
+
const input = importInputRef.current;
|
|
3482
|
+
if (!input) return;
|
|
3483
|
+
input.value = "";
|
|
3484
|
+
input.click();
|
|
3485
|
+
}
|
|
3486
|
+
function onImportPicked(e) {
|
|
3487
|
+
const file = e.target.files?.[0];
|
|
3488
|
+
e.target.value = "";
|
|
3489
|
+
if (file) importArchive(file);
|
|
3490
|
+
}
|
|
3491
|
+
/** Commit the open editor: create a new entry, or update the one being edited. */
|
|
3492
|
+
async function saveEdit() {
|
|
3493
|
+
if (!editing) return;
|
|
3494
|
+
const name = editing.name.trim() || t("custom.defaultName");
|
|
3495
|
+
const id = editing.id;
|
|
3496
|
+
savingRef.current = true;
|
|
3497
|
+
const next = await mutate(() => id === null ? saved.create(name) : saved.update(id, name));
|
|
3498
|
+
savingRef.current = false;
|
|
3499
|
+
if (next === null) return;
|
|
3500
|
+
setEditing(null);
|
|
3501
|
+
restoreAppearance(editing);
|
|
3502
|
+
syncSaved();
|
|
3503
|
+
setCustomState(getCustom().theme);
|
|
3504
|
+
showToast(t("custom.toast.saved"));
|
|
3505
|
+
}
|
|
3506
|
+
async function onConfirmDelete() {
|
|
3507
|
+
if (!pendingDelete) return;
|
|
3508
|
+
const id = pendingDelete.id;
|
|
3509
|
+
setPendingDelete(null);
|
|
3510
|
+
setEditing((cur) => cur?.id === id ? null : cur);
|
|
3511
|
+
const next = await mutate(() => saved.remove(id));
|
|
3512
|
+
if (next === null) return;
|
|
3513
|
+
setActiveId(saved.activeId());
|
|
3514
|
+
setCustomState(getCustom().theme);
|
|
3515
|
+
setList(saved.list());
|
|
3516
|
+
if (next.length === 0) {
|
|
3517
|
+
setEditing(null);
|
|
3518
|
+
setCustomBase(shownPreset);
|
|
3519
|
+
setCustomState(resetCustom());
|
|
3520
|
+
}
|
|
3521
|
+
}
|
|
3522
|
+
function pickScheme(id) {
|
|
3523
|
+
applyAppearance(id);
|
|
3524
|
+
setEditing((cur) => cur ? {
|
|
3525
|
+
...cur,
|
|
3526
|
+
originScheme: id
|
|
3527
|
+
} : cur);
|
|
3528
|
+
}
|
|
3529
|
+
/**
|
|
3530
|
+
* The preset picker. Unreachable while custom mode is on — the picker is
|
|
3531
|
+
* disabled then — so this only ever applies the chosen preset.
|
|
3532
|
+
*/
|
|
2335
3533
|
function pickPreset(id) {
|
|
2336
|
-
|
|
2337
|
-
|
|
3534
|
+
setSelectionState(id);
|
|
3535
|
+
setSelection(id);
|
|
3536
|
+
setShownPreset(id);
|
|
3537
|
+
}
|
|
3538
|
+
/**
|
|
3539
|
+
* The custom switch. Preset and custom are independent selections: turning
|
|
3540
|
+
* custom on leaves the preset row untouched, and turning it off goes back to
|
|
3541
|
+
* the preset the user had chosen, not to the custom theme's own base.
|
|
3542
|
+
*/
|
|
3543
|
+
function toggleCustom(on) {
|
|
3544
|
+
if (on) {
|
|
3545
|
+
setSelectionState(CUSTOM_SELECTION);
|
|
3546
|
+
setSelection(CUSTOM_SELECTION);
|
|
3547
|
+
setCustomState(getCustom().theme);
|
|
3548
|
+
return;
|
|
3549
|
+
}
|
|
3550
|
+
if (editing) revertEditing(editing);
|
|
3551
|
+
setEditing(null);
|
|
3552
|
+
setSelectionState(shownPreset);
|
|
3553
|
+
setSelection(shownPreset);
|
|
3554
|
+
setShownPreset(shownPreset);
|
|
3555
|
+
}
|
|
3556
|
+
function editCustom(next) {
|
|
3557
|
+
setCustomState(next);
|
|
3558
|
+
setCustom(next);
|
|
2338
3559
|
}
|
|
2339
3560
|
const schemeOptions = [
|
|
2340
3561
|
{
|
|
@@ -2350,20 +3571,249 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
|
|
|
2350
3571
|
label: t("scheme.system")
|
|
2351
3572
|
}
|
|
2352
3573
|
];
|
|
3574
|
+
const customOn = selection === CUSTOM_SELECTION;
|
|
3575
|
+
const children = [
|
|
3576
|
+
react.createElement("div", {
|
|
3577
|
+
className: "ct-row",
|
|
3578
|
+
key: "appearance"
|
|
3579
|
+
}, 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, {
|
|
3580
|
+
value: scheme,
|
|
3581
|
+
options: schemeOptions,
|
|
3582
|
+
onSelect: pickScheme
|
|
3583
|
+
})),
|
|
3584
|
+
react.createElement("div", {
|
|
3585
|
+
className: "ct-row",
|
|
3586
|
+
key: "presets"
|
|
3587
|
+
}, 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, {
|
|
3588
|
+
value: customOn ? shownPreset : selection,
|
|
3589
|
+
options: presetOptions,
|
|
3590
|
+
onSelect: (v) => pickPreset(v),
|
|
3591
|
+
disabled: customOn
|
|
3592
|
+
})),
|
|
3593
|
+
react.createElement("div", {
|
|
3594
|
+
className: "ct-row ct-row-flush",
|
|
3595
|
+
key: "custom-toggle"
|
|
3596
|
+
}, 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, {
|
|
3597
|
+
checked: customOn,
|
|
3598
|
+
label: t("custom.title"),
|
|
3599
|
+
onChange: toggleCustom
|
|
3600
|
+
}))
|
|
3601
|
+
];
|
|
3602
|
+
if (customOn) {
|
|
3603
|
+
/** A group row: its name on the left, every seed's dot on the right. */
|
|
3604
|
+
const dotRow = (key, label, dots) => react.createElement("div", {
|
|
3605
|
+
className: "ct-seed-row",
|
|
3606
|
+
key
|
|
3607
|
+
}, react.createElement("div", { className: "ct-seed-label" }, label), react.createElement("div", { className: "ct-seed-dots" }, ...dots.map((d) => react.createElement("label", {
|
|
3608
|
+
key: d.key,
|
|
3609
|
+
className: "ct-seed-dot ct-tip",
|
|
3610
|
+
style: { background: d.value },
|
|
3611
|
+
"data-tip": d.name
|
|
3612
|
+
}, react.createElement("input", {
|
|
3613
|
+
className: "ct-seed-input",
|
|
3614
|
+
type: "color",
|
|
3615
|
+
value: d.value,
|
|
3616
|
+
"aria-label": `${d.name} — ${d.hint}`,
|
|
3617
|
+
onChange: (e) => d.onChange(e.target.value.toUpperCase())
|
|
3618
|
+
})))));
|
|
3619
|
+
const pair = (key, title, desc) => ({
|
|
3620
|
+
key,
|
|
3621
|
+
name: t(title),
|
|
3622
|
+
hint: t(desc),
|
|
3623
|
+
value: custom[key][seedMode],
|
|
3624
|
+
onChange: (v) => editCustom({
|
|
3625
|
+
...custom,
|
|
3626
|
+
[key]: {
|
|
3627
|
+
...custom[key],
|
|
3628
|
+
[seedMode]: v
|
|
3629
|
+
}
|
|
3630
|
+
})
|
|
3631
|
+
});
|
|
3632
|
+
const neutralDots = NEUTRAL_ROLES.map((role) => {
|
|
3633
|
+
const end = neutralEndForRole(seedMode, role);
|
|
3634
|
+
return {
|
|
3635
|
+
key: `neutral-${end}`,
|
|
3636
|
+
name: t(role === "background" ? "custom.neutral.roleBackground" : "custom.neutral.roleForeground"),
|
|
3637
|
+
hint: `${t("custom.neutral.title")} · ${t("custom.neutral.desc")} — ${t(end === "lightest" ? "custom.neutral.lightest" : "custom.neutral.darkest")}`,
|
|
3638
|
+
value: custom.neutral[seedMode][end],
|
|
3639
|
+
onChange: (v) => editCustom({
|
|
3640
|
+
...custom,
|
|
3641
|
+
neutral: {
|
|
3642
|
+
...custom.neutral,
|
|
3643
|
+
[seedMode]: {
|
|
3644
|
+
...custom.neutral[seedMode],
|
|
3645
|
+
[end]: v
|
|
3646
|
+
}
|
|
3647
|
+
}
|
|
3648
|
+
})
|
|
3649
|
+
};
|
|
3650
|
+
});
|
|
3651
|
+
const seedDots = [
|
|
3652
|
+
pair("accent", "custom.accent.title", "custom.accent.desc"),
|
|
3653
|
+
...neutralDots,
|
|
3654
|
+
pair("green", "custom.green.title", "custom.green.desc"),
|
|
3655
|
+
pair("amber", "custom.amber.title", "custom.amber.desc"),
|
|
3656
|
+
pair("red", "custom.red.title", "custom.red.desc")
|
|
3657
|
+
];
|
|
3658
|
+
const shikiDots = SHIKI_KEYS.map((k) => ({
|
|
3659
|
+
key: k,
|
|
3660
|
+
name: t(`shiki.${k}`),
|
|
3661
|
+
hint: t("custom.shiki.desc"),
|
|
3662
|
+
value: custom.shiki[k][seedMode],
|
|
3663
|
+
onChange: (v) => editCustom({
|
|
3664
|
+
...custom,
|
|
3665
|
+
shiki: {
|
|
3666
|
+
...custom.shiki,
|
|
3667
|
+
[k]: {
|
|
3668
|
+
...custom.shiki[k],
|
|
3669
|
+
[seedMode]: v
|
|
3670
|
+
}
|
|
3671
|
+
}
|
|
3672
|
+
})
|
|
3673
|
+
}));
|
|
3674
|
+
/** The light/dark segmented control above the dots. */
|
|
3675
|
+
const modeButton = (id, label) => react.createElement("button", {
|
|
3676
|
+
key: id,
|
|
3677
|
+
type: "button",
|
|
3678
|
+
className: seedMode === id ? "ct-mode-btn ct-mode-btn-on" : "ct-mode-btn",
|
|
3679
|
+
"aria-pressed": seedMode === id,
|
|
3680
|
+
onClick: () => {
|
|
3681
|
+
setSeedMode(id);
|
|
3682
|
+
previewAppearance(id);
|
|
3683
|
+
}
|
|
3684
|
+
}, label);
|
|
3685
|
+
const modeToggle = react.createElement("div", {
|
|
3686
|
+
className: "ct-mode",
|
|
3687
|
+
role: "group",
|
|
3688
|
+
"aria-label": t("custom.mode.label")
|
|
3689
|
+
}, modeButton("light", t("custom.seed.light")), modeButton("dark", t("custom.seed.dark")));
|
|
3690
|
+
const editorName = react.createElement("input", {
|
|
3691
|
+
className: "ct-editor-name",
|
|
3692
|
+
type: "text",
|
|
3693
|
+
value: editing?.name ?? "",
|
|
3694
|
+
placeholder: t("custom.name.placeholder"),
|
|
3695
|
+
"aria-label": t("custom.name.placeholder"),
|
|
3696
|
+
autoFocus: true,
|
|
3697
|
+
onChange: (e) => setEditing((cur) => cur ? {
|
|
3698
|
+
...cur,
|
|
3699
|
+
name: e.target.value
|
|
3700
|
+
} : cur),
|
|
3701
|
+
onKeyDown: (e) => {
|
|
3702
|
+
if (e.key === "Enter") saveEdit();
|
|
3703
|
+
else if (e.key === "Escape") cancelEdit();
|
|
3704
|
+
}
|
|
3705
|
+
});
|
|
3706
|
+
const editorActions = react.createElement("div", { className: "ct-editor-actions" }, react.createElement("button", {
|
|
3707
|
+
type: "button",
|
|
3708
|
+
className: "ct-btn",
|
|
3709
|
+
onClick: cancelEdit
|
|
3710
|
+
}, t("custom.cancel")), react.createElement("button", {
|
|
3711
|
+
type: "button",
|
|
3712
|
+
className: "ct-btn ct-btn-primary",
|
|
3713
|
+
onClick: () => void saveEdit()
|
|
3714
|
+
}, t("custom.save")));
|
|
3715
|
+
const editorCard = (key) => react.createElement("div", {
|
|
3716
|
+
className: "ct-editor",
|
|
3717
|
+
key,
|
|
3718
|
+
"data-ct-editor": "true"
|
|
3719
|
+
}, react.createElement("div", { className: "ct-editor-head" }, editorName, modeToggle), dotRow("seeds", t("custom.group.seeds"), seedDots), dotRow("shiki", t("custom.group.shiki"), shikiDots), editorActions);
|
|
3720
|
+
const cards = list.map((entry) => editing && editing.id === entry.id ? editorCard(entry.id) : react.createElement(ThemeCard, {
|
|
3721
|
+
key: entry.id,
|
|
3722
|
+
entry,
|
|
3723
|
+
active: activeId === entry.id,
|
|
3724
|
+
disabled: editing !== null,
|
|
3725
|
+
t,
|
|
3726
|
+
onActivate: () => onLoadSaved(entry.id),
|
|
3727
|
+
onExport: () => void exportCard(entry),
|
|
3728
|
+
onEdit: () => startEdit(entry),
|
|
3729
|
+
onDelete: () => setPendingDelete(entry)
|
|
3730
|
+
}));
|
|
3731
|
+
if (editing && editing.id === null) cards.push(editorCard("new-editor"));
|
|
3732
|
+
children.push(react.createElement("div", {
|
|
3733
|
+
className: "ct-list",
|
|
3734
|
+
key: "saved-list"
|
|
3735
|
+
}, ...cards), react.createElement("div", {
|
|
3736
|
+
className: "ct-add-row",
|
|
3737
|
+
key: "add-row"
|
|
3738
|
+
}, react.createElement("button", {
|
|
3739
|
+
key: "import",
|
|
3740
|
+
type: "button",
|
|
3741
|
+
className: "ct-add-btn",
|
|
3742
|
+
disabled: importing,
|
|
3743
|
+
onClick: startImport
|
|
3744
|
+
}, react.createElement(IconDownload, null), t("custom.import")), react.createElement("button", {
|
|
3745
|
+
key: "add",
|
|
3746
|
+
type: "button",
|
|
3747
|
+
className: "ct-add-btn",
|
|
3748
|
+
disabled: editing !== null,
|
|
3749
|
+
onClick: startAdd
|
|
3750
|
+
}, react.createElement(IconPlus, null), t("custom.add")), react.createElement("input", {
|
|
3751
|
+
key: "import-input",
|
|
3752
|
+
ref: importInputRef,
|
|
3753
|
+
type: "file",
|
|
3754
|
+
accept: `${THEME_ARCHIVE_EXTENSION},application/zip`,
|
|
3755
|
+
style: { display: "none" },
|
|
3756
|
+
onChange: onImportPicked
|
|
3757
|
+
})));
|
|
3758
|
+
}
|
|
3759
|
+
if (pendingDelete) children.push(react.createElement(ConfirmDialog, {
|
|
3760
|
+
key: "confirm-delete",
|
|
3761
|
+
title: t("custom.delete.title"),
|
|
3762
|
+
description: t("custom.delete.desc").replace("{0}", pendingDelete.name),
|
|
3763
|
+
confirmLabel: t("custom.delete"),
|
|
3764
|
+
cancelLabel: t("custom.cancel"),
|
|
3765
|
+
onConfirm: onConfirmDelete,
|
|
3766
|
+
onCancel: () => setPendingDelete(null)
|
|
3767
|
+
}));
|
|
3768
|
+
if (confirmClose && editing && onRequestClose) children.push(react.createElement(ConfirmDialog, {
|
|
3769
|
+
key: "confirm-close",
|
|
3770
|
+
title: t("custom.unsaved.title"),
|
|
3771
|
+
description: t("custom.unsaved.desc").replace("{0}", editing.name.trim() || t("custom.defaultName")),
|
|
3772
|
+
confirmLabel: t("custom.unsaved.confirm"),
|
|
3773
|
+
cancelLabel: t("custom.unsaved.cancel"),
|
|
3774
|
+
onConfirm: () => {
|
|
3775
|
+
setConfirmClose(false);
|
|
3776
|
+
onRequestClose();
|
|
3777
|
+
},
|
|
3778
|
+
onCancel: () => setConfirmClose(false)
|
|
3779
|
+
}));
|
|
3780
|
+
if (pendingLeave && editing) children.push(react.createElement(ConfirmDialog, {
|
|
3781
|
+
key: "confirm-leave",
|
|
3782
|
+
title: t("custom.unsaved.title"),
|
|
3783
|
+
description: t("custom.unsaved.leaveDesc").replace("{0}", editing.name.trim() || t("custom.defaultName")),
|
|
3784
|
+
confirmLabel: t("custom.unsaved.leaveConfirm"),
|
|
3785
|
+
cancelLabel: t("custom.unsaved.cancel"),
|
|
3786
|
+
onConfirm: () => {
|
|
3787
|
+
const next = pendingLeave;
|
|
3788
|
+
setPendingLeave(null);
|
|
3789
|
+
cancelEdit();
|
|
3790
|
+
if (next.entry) {
|
|
3791
|
+
if (next.edit) startEdit(next.entry);
|
|
3792
|
+
else onLoadSaved(next.entry.id);
|
|
3793
|
+
}
|
|
3794
|
+
},
|
|
3795
|
+
onCancel: () => setPendingLeave(null)
|
|
3796
|
+
}));
|
|
3797
|
+
if (toast) {
|
|
3798
|
+
const onDone = () => setToast((cur) => cur?.seq === toast.seq ? null : cur);
|
|
3799
|
+
const HostToast = getHostToast();
|
|
3800
|
+
children.push(HostToast ? react.createElement(HostToast, {
|
|
3801
|
+
key: `toast-${String(toast.seq)}`,
|
|
3802
|
+
text: toast.text,
|
|
3803
|
+
icon: react.createElement(ToastBadge),
|
|
3804
|
+
onDone
|
|
3805
|
+
}) : react.createElement(Toast, {
|
|
3806
|
+
key: `toast-${String(toast.seq)}`,
|
|
3807
|
+
text: toast.text,
|
|
3808
|
+
onDone
|
|
3809
|
+
}));
|
|
3810
|
+
}
|
|
2353
3811
|
return react.createElement("div", { style: {
|
|
2354
3812
|
display: "flex",
|
|
2355
3813
|
flexDirection: "column",
|
|
2356
3814
|
maxWidth: 760,
|
|
2357
3815
|
paddingBottom: 8
|
|
2358
|
-
} },
|
|
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
|
-
})));
|
|
3816
|
+
} }, ...children);
|
|
2367
3817
|
}
|
|
2368
3818
|
//#endregion
|
|
2369
3819
|
//#region src/client/locales.ts
|
|
@@ -2379,9 +3829,71 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
|
|
|
2379
3829
|
"appearance.desc": "深色 / 浅色 / 跟随系统",
|
|
2380
3830
|
"presets.title": "预设主题",
|
|
2381
3831
|
"presets.desc": "一键套用主流配色方案",
|
|
3832
|
+
"presets.disabled": "自定义主题已启用,关闭后才能选择预设",
|
|
2382
3833
|
"scheme.light": "浅色",
|
|
2383
3834
|
"scheme.dark": "深色",
|
|
2384
|
-
"scheme.system": "跟随系统"
|
|
3835
|
+
"scheme.system": "跟随系统",
|
|
3836
|
+
"custom.title": "自定义主题",
|
|
3837
|
+
"custom.toggle.desc": "创建属于自己的个性化主题",
|
|
3838
|
+
"custom.group.seeds": "主色/辅助色",
|
|
3839
|
+
"custom.group.shiki": "代码块",
|
|
3840
|
+
"custom.mode.label": "编辑的外观",
|
|
3841
|
+
"custom.neutral.title": "中性色",
|
|
3842
|
+
"custom.neutral.desc": "界面底色与正文。浅色与深色各一条色阶",
|
|
3843
|
+
"custom.neutral.lightest": "最浅色端",
|
|
3844
|
+
"custom.neutral.darkest": "最深色端",
|
|
3845
|
+
"custom.neutral.roleBackground": "背景色",
|
|
3846
|
+
"custom.neutral.roleForeground": "前景色",
|
|
3847
|
+
"custom.accent.title": "主色",
|
|
3848
|
+
"custom.accent.desc": "按钮与强调色(deepseek 与 blue 合并为一组)",
|
|
3849
|
+
"custom.green.title": "成功",
|
|
3850
|
+
"custom.green.desc": "成功状态",
|
|
3851
|
+
"custom.amber.title": "警告",
|
|
3852
|
+
"custom.amber.desc": "警告状态",
|
|
3853
|
+
"custom.red.title": "错误",
|
|
3854
|
+
"custom.red.desc": "错误状态",
|
|
3855
|
+
"custom.shiki.desc": "9 个语法标记的颜色",
|
|
3856
|
+
"custom.seed.light": "浅色",
|
|
3857
|
+
"custom.seed.dark": "深色",
|
|
3858
|
+
"custom.save": "保存",
|
|
3859
|
+
"custom.add": "添加自定义主题",
|
|
3860
|
+
"custom.edit": "编辑",
|
|
3861
|
+
"custom.export": "导出",
|
|
3862
|
+
"custom.import": "导入主题",
|
|
3863
|
+
"custom.name.placeholder": "主题名称",
|
|
3864
|
+
"custom.defaultName": "自定义主题",
|
|
3865
|
+
"custom.inUse": "使用中",
|
|
3866
|
+
"custom.delete": "删除",
|
|
3867
|
+
"custom.cancel": "取消",
|
|
3868
|
+
"custom.toast.saved": "已保存",
|
|
3869
|
+
"custom.toast.failed": "主题文件操作失败",
|
|
3870
|
+
"custom.toast.unavailable": "主题文件接口未就绪,请重启 DSH 服务",
|
|
3871
|
+
"custom.toast.exported": "已导出「{0}」",
|
|
3872
|
+
"custom.toast.imported": "已导入「{0}」",
|
|
3873
|
+
"custom.importFailed.notArchive": "导入失败:不是主题压缩包",
|
|
3874
|
+
"custom.importFailed.noDocument": "导入失败:压缩包缺少 theme.json",
|
|
3875
|
+
"custom.importFailed.badDocument": "导入失败:theme.json 不是有效的主题文件",
|
|
3876
|
+
"custom.importFailed.tooNew": "导入失败:主题文件版本比当前插件更新",
|
|
3877
|
+
"custom.importFailed.media": "导入失败:主题包含媒体文件,暂不支持导入",
|
|
3878
|
+
"custom.importFailed.tooLarge": "导入失败:压缩包超过大小上限",
|
|
3879
|
+
"custom.importFailed.other": "导入失败:{0}",
|
|
3880
|
+
"custom.delete.title": "删除该自定义主题?",
|
|
3881
|
+
"custom.delete.desc": "将删除「{0}」,且无法恢复。",
|
|
3882
|
+
"custom.unsaved.title": "放弃未保存的修改?",
|
|
3883
|
+
"custom.unsaved.desc": "「{0}」的修改尚未保存,关闭设置后将丢失。",
|
|
3884
|
+
"custom.unsaved.confirm": "放弃并关闭",
|
|
3885
|
+
"custom.unsaved.leaveDesc": "「{0}」的修改尚未保存,是否确认放弃?",
|
|
3886
|
+
"custom.unsaved.leaveConfirm": "放弃修改",
|
|
3887
|
+
"custom.unsaved.cancel": "继续编辑",
|
|
3888
|
+
"shiki.constant": "常量",
|
|
3889
|
+
"shiki.string": "字符串",
|
|
3890
|
+
"shiki.comment": "注释",
|
|
3891
|
+
"shiki.keyword": "关键字",
|
|
3892
|
+
"shiki.parameter": "参数",
|
|
3893
|
+
"shiki.function": "函数",
|
|
3894
|
+
"shiki.string-expression": "模板字符串",
|
|
3895
|
+
"shiki.punctuation": "标点",
|
|
3896
|
+
"shiki.link": "链接"
|
|
2385
3897
|
};
|
|
2386
3898
|
/** English dictionary, checked complete against the zh key set. */
|
|
2387
3899
|
const en = {
|
|
@@ -2390,15 +3902,78 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
|
|
|
2390
3902
|
"appearance.desc": "Dark / Light / System",
|
|
2391
3903
|
"presets.title": "Preset themes",
|
|
2392
3904
|
"presets.desc": "Apply popular color schemes in one click",
|
|
3905
|
+
"presets.disabled": "Custom theme is on — turn it off to choose a preset",
|
|
2393
3906
|
"scheme.light": "Light",
|
|
2394
3907
|
"scheme.dark": "Dark",
|
|
2395
|
-
"scheme.system": "System"
|
|
3908
|
+
"scheme.system": "System",
|
|
3909
|
+
"custom.title": "Custom theme",
|
|
3910
|
+
"custom.toggle.desc": "Create your own personalized theme",
|
|
3911
|
+
"custom.group.seeds": "Primary/supporting",
|
|
3912
|
+
"custom.group.shiki": "Code block",
|
|
3913
|
+
"custom.mode.label": "Appearance being edited",
|
|
3914
|
+
"custom.neutral.title": "Neutral",
|
|
3915
|
+
"custom.neutral.desc": "Surfaces and body text. One ramp per appearance",
|
|
3916
|
+
"custom.neutral.lightest": "Lightest end",
|
|
3917
|
+
"custom.neutral.darkest": "Darkest end",
|
|
3918
|
+
"custom.neutral.roleBackground": "Background",
|
|
3919
|
+
"custom.neutral.roleForeground": "Foreground",
|
|
3920
|
+
"custom.accent.title": "Accent",
|
|
3921
|
+
"custom.accent.desc": "Buttons and emphasis (deepseek and blue merged into one)",
|
|
3922
|
+
"custom.green.title": "Success",
|
|
3923
|
+
"custom.green.desc": "Success states",
|
|
3924
|
+
"custom.amber.title": "Warning",
|
|
3925
|
+
"custom.amber.desc": "Warning states",
|
|
3926
|
+
"custom.red.title": "Error",
|
|
3927
|
+
"custom.red.desc": "Error states",
|
|
3928
|
+
"custom.shiki.desc": "Colors for the nine syntax tokens",
|
|
3929
|
+
"custom.seed.light": "Light",
|
|
3930
|
+
"custom.seed.dark": "Dark",
|
|
3931
|
+
"custom.save": "Save",
|
|
3932
|
+
"custom.add": "Add custom theme",
|
|
3933
|
+
"custom.edit": "Edit",
|
|
3934
|
+
"custom.export": "Export",
|
|
3935
|
+
"custom.import": "Import theme",
|
|
3936
|
+
"custom.name.placeholder": "Theme name",
|
|
3937
|
+
"custom.defaultName": "Custom theme",
|
|
3938
|
+
"custom.inUse": "In use",
|
|
3939
|
+
"custom.delete": "Delete",
|
|
3940
|
+
"custom.cancel": "Cancel",
|
|
3941
|
+
"custom.toast.saved": "Saved",
|
|
3942
|
+
"custom.toast.failed": "Theme file operation failed",
|
|
3943
|
+
"custom.toast.unavailable": "Theme file API not ready — restart the DSH service",
|
|
3944
|
+
"custom.toast.exported": "Exported \"{0}\"",
|
|
3945
|
+
"custom.toast.imported": "Imported \"{0}\"",
|
|
3946
|
+
"custom.importFailed.notArchive": "Import failed: not a theme archive",
|
|
3947
|
+
"custom.importFailed.noDocument": "Import failed: the archive has no theme.json",
|
|
3948
|
+
"custom.importFailed.badDocument": "Import failed: theme.json is not a usable theme",
|
|
3949
|
+
"custom.importFailed.tooNew": "Import failed: the theme file is newer than this plugin reads",
|
|
3950
|
+
"custom.importFailed.media": "Import failed: the theme carries media, which cannot be imported yet",
|
|
3951
|
+
"custom.importFailed.tooLarge": "Import failed: the archive is over the size limit",
|
|
3952
|
+
"custom.importFailed.other": "Import failed: {0}",
|
|
3953
|
+
"custom.delete.title": "Delete this custom theme?",
|
|
3954
|
+
"custom.delete.desc": "\"{0}\" will be removed. This cannot be undone.",
|
|
3955
|
+
"custom.unsaved.title": "Discard unsaved changes?",
|
|
3956
|
+
"custom.unsaved.desc": "Changes to \"{0}\" have not been saved and will be lost when settings close.",
|
|
3957
|
+
"custom.unsaved.confirm": "Discard and close",
|
|
3958
|
+
"custom.unsaved.leaveDesc": "Changes to \"{0}\" have not been saved. Discard them?",
|
|
3959
|
+
"custom.unsaved.leaveConfirm": "Discard changes",
|
|
3960
|
+
"custom.unsaved.cancel": "Keep editing",
|
|
3961
|
+
"shiki.constant": "Constant",
|
|
3962
|
+
"shiki.string": "String",
|
|
3963
|
+
"shiki.comment": "Comment",
|
|
3964
|
+
"shiki.keyword": "Keyword",
|
|
3965
|
+
"shiki.parameter": "Parameter",
|
|
3966
|
+
"shiki.function": "Function",
|
|
3967
|
+
"shiki.string-expression": "Template string",
|
|
3968
|
+
"shiki.punctuation": "Punctuation",
|
|
3969
|
+
"shiki.link": "Link"
|
|
2396
3970
|
};
|
|
2397
3971
|
//#endregion
|
|
2398
3972
|
//#region src/client/theme.tsx
|
|
2399
3973
|
const BASE_CSS = [
|
|
2400
3974
|
".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
3975
|
".ct-select:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);}",
|
|
3976
|
+
".ct-select:disabled{opacity:.5;cursor:not-allowed;}",
|
|
2402
3977
|
".ct-select:focus-visible{outline:none;box-shadow:0 0 0 2px var(--dsw-alias-border-l3);}",
|
|
2403
3978
|
".ct-select-label{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:left;}",
|
|
2404
3979
|
".ct-select-chevron{flex:none;color:var(--dsw-alias-label-tertiary);display:inline-flex;transition:transform 120ms ease;}",
|
|
@@ -2418,13 +3993,89 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
|
|
|
2418
3993
|
".ct-menu-item-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}",
|
|
2419
3994
|
".ct-row{border-bottom:1px solid var(--dsw-alias-border-l2);padding:16px 2px;display:flex;align-items:center;gap:16px;}",
|
|
2420
3995
|
".ct-row:last-child{border-bottom:none;}",
|
|
3996
|
+
".ct-row-flush{border-bottom:none;}",
|
|
2421
3997
|
".ct-row-main{flex:1;min-width:0;display:flex;flex-direction:column;gap:4px;padding-right:48px;}",
|
|
2422
3998
|
".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;}"
|
|
3999
|
+
".ct-row-desc{color:var(--dsw-alias-label-tertiary);font-size:12px;font-weight:400;line-height:18px;}",
|
|
4000
|
+
".ct-seed-row{display:flex;align-items:center;gap:16px;padding:12px 0;}",
|
|
4001
|
+
".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);}",
|
|
4002
|
+
".ct-seed-dots{flex:1 1 auto;display:flex;flex-wrap:wrap;align-items:center;justify-content:flex-end;gap:10px;}",
|
|
4003
|
+
".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;}",
|
|
4004
|
+
".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;}",
|
|
4005
|
+
".ct-seed-dot:focus-within{outline:none;box-shadow:0 0 0 2px var(--dsw-alias-border-l3);}",
|
|
4006
|
+
".ct-tip::after{content:attr(data-tip);position:absolute;bottom:calc(100% + 6px);left:50%;transform:translateX(-50%);z-index:100;box-sizing:border-box;width:max-content;max-width:220px;padding:3px 7px;border-radius:8px;background:var(--dsw-alias-tooltip-bg);color:var(--dsw-static-neutral-bluish-00);font-size:13px;font-weight:400;line-height:20px;white-space:pre-line;overflow-wrap:break-word;text-align:left;pointer-events:none;opacity:0;visibility:hidden;transition:opacity 120ms ease;}",
|
|
4007
|
+
".ct-seed-dot:hover::after,.ct-seed-dot:focus-within::after{opacity:1;visibility:visible;}",
|
|
4008
|
+
".ct-list-btn:hover::after,.ct-list-btn:focus-visible::after{opacity:1;visibility:visible;}",
|
|
4009
|
+
"@media (prefers-reduced-motion: reduce){.ct-tip::after{transition:none;}}",
|
|
4010
|
+
".ct-list{margin-top:4px;display:flex;flex-direction:column;gap:8px;}",
|
|
4011
|
+
".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;}",
|
|
4012
|
+
".ct-list-row:hover{background:var(--dsw-alias-interactive-bg-hover);}",
|
|
4013
|
+
".ct-list-identity{display:inline-flex;align-items:center;gap:6px;flex:1 1 auto;min-width:0;}",
|
|
4014
|
+
".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;}",
|
|
4015
|
+
".ct-list-name:disabled{cursor:default;}",
|
|
4016
|
+
".ct-list-actions{display:inline-flex;align-items:center;gap:4px;flex:none;margin-left:auto;}",
|
|
4017
|
+
".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;}",
|
|
4018
|
+
".ct-list-badge + .ct-list-btn{margin-left:8px;}",
|
|
4019
|
+
".ct-list-btn{position:relative;box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;height:28px;width:28px;padding:0;border:none;border-radius:14px;background:transparent;color:var(--dsw-alias-label-primary);cursor:pointer;font:inherit;line-height:0;}",
|
|
4020
|
+
".ct-list-btn:hover{background:var(--dsw-alias-interactive-bg-hover);}",
|
|
4021
|
+
".ct-list-btn:active{background:var(--dsw-alias-interactive-bg-active);}",
|
|
4022
|
+
".ct-list-btn:focus-visible{outline:none;box-shadow:0 0 0 2px var(--dsw-alias-border-l3);}",
|
|
4023
|
+
".ct-list-btn:disabled{opacity:.4;cursor:not-allowed;}",
|
|
4024
|
+
".ct-list-btn:disabled:hover{background:transparent;}",
|
|
4025
|
+
".ct-list-btn-danger{color:var(--dsw-alias-state-error-primary);}",
|
|
4026
|
+
".ct-list-btn-danger:hover{background:var(--dsw-alias-interactive-bg-hover-danger);}",
|
|
4027
|
+
".ct-list-btn-danger:active{background:var(--dsw-alias-interactive-bg-hover-danger);}",
|
|
4028
|
+
".ct-editor{padding:16px;border:.5px solid var(--dsw-alias-border-l4);border-radius:16px;background:transparent;display:flex;flex-direction:column;gap:0;}",
|
|
4029
|
+
".ct-editor-head{display:flex;align-items:center;gap:10px;padding-bottom:12px;}",
|
|
4030
|
+
".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;}",
|
|
4031
|
+
".ct-editor-name::placeholder{color:var(--dsw-alias-label-tertiary);}",
|
|
4032
|
+
".ct-editor-name:hover{border-color:var(--dsw-alias-border-l3);}",
|
|
4033
|
+
".ct-editor-name:focus{outline:none;border-color:var(--dsw-alias-border-l3);background:var(--dsw-alias-bg-base);}",
|
|
4034
|
+
".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));}",
|
|
4035
|
+
".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;}",
|
|
4036
|
+
".ct-mode-btn:hover{color:var(--dsw-alias-label-primary);}",
|
|
4037
|
+
".ct-mode-btn-on{background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);}",
|
|
4038
|
+
".ct-mode-btn:focus-visible{outline:none;box-shadow:0 0 0 2px var(--dsw-alias-border-l3);}",
|
|
4039
|
+
".ct-editor-actions{display:flex;justify-content:flex-end;gap:12px;padding-top:12px;}",
|
|
4040
|
+
".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;}",
|
|
4041
|
+
".ct-add-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);}",
|
|
4042
|
+
".ct-add-btn:disabled{opacity:.4;cursor:default;}",
|
|
4043
|
+
".ct-add-btn:focus-visible{outline:none;box-shadow:0 0 0 2px var(--dsw-alias-border-l3);}",
|
|
4044
|
+
".ct-add-row{display:flex;align-items:stretch;gap:8px;margin-top:8px;}",
|
|
4045
|
+
".ct-add-row .ct-add-btn{flex:1 1 0;width:auto;margin-top:0;min-width:0;}",
|
|
4046
|
+
".ct-modal-root{position:fixed;inset:0;z-index:1000;display:flex;align-items:center;justify-content:center;padding:24px;}",
|
|
4047
|
+
".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));}",
|
|
4048
|
+
".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);}",
|
|
4049
|
+
".ct-modal-title{font-size:16px;line-height:24px;font-weight:600;color:var(--dsw-alias-label-primary);}",
|
|
4050
|
+
".ct-modal-desc{font-size:13px;line-height:20px;color:var(--dsw-alias-label-tertiary);}",
|
|
4051
|
+
".ct-modal-footer{display:flex;justify-content:flex-end;gap:12px;margin-top:12px;}",
|
|
4052
|
+
".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;}",
|
|
4053
|
+
".ct-switch-on{background:var(--dsw-static-deepseek-500,rgb(65,118,230));}",
|
|
4054
|
+
".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;}",
|
|
4055
|
+
".ct-switch-on .ct-switch-knob{transform:translateX(16px);}",
|
|
4056
|
+
".ct-switch:focus-visible{outline:none;box-shadow:0 0 0 2px var(--dsw-alias-border-l3);}",
|
|
4057
|
+
".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;}",
|
|
4058
|
+
".ct-btn:hover{background:var(--dsw-alias-interactive-bg-hover);}",
|
|
4059
|
+
".ct-btn:focus-visible{outline:none;box-shadow:0 0 0 2px var(--dsw-alias-border-l3);}",
|
|
4060
|
+
".ct-btn-primary{background:var(--dsw-static-deepseek-500,rgb(65,118,230));border-color:transparent;color:#fff;}",
|
|
4061
|
+
".ct-btn-primary:hover{background:var(--dsw-static-deepseek-450,var(--dsw-static-deepseek-500,rgb(86,134,254)));}",
|
|
4062
|
+
".ct-btn-danger{background:var(--dsw-static-red-500,rgb(239,68,68));border-color:transparent;color:#fff;}",
|
|
4063
|
+
".ct-btn-danger:hover{background:var(--dsw-static-red-600,var(--dsw-static-red-500,rgb(236,19,19)));}",
|
|
4064
|
+
".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;}",
|
|
4065
|
+
".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);}",
|
|
4066
|
+
".ct-toast-text{min-width:0;}",
|
|
4067
|
+
"@keyframes ct-toast-in{from{opacity:0;transform:translate(-50%,-6px);}to{opacity:1;transform:translate(-50%,0);}}",
|
|
4068
|
+
"@keyframes ct-toast-fade{to{opacity:0;}}",
|
|
4069
|
+
"@media (prefers-reduced-motion: reduce){.ct-toast{animation:ct-toast-fade 1s ease 3s forwards;}}"
|
|
2424
4070
|
].join("\n");
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
4071
|
+
function isValidSelection(v) {
|
|
4072
|
+
return !!v && (v === CUSTOM_SELECTION || NOOP_PRESET_IDS.has(v) || v in PRESETS);
|
|
4073
|
+
}
|
|
4074
|
+
function isPresetId(v) {
|
|
4075
|
+
return typeof v === "string" && v in PRESETS;
|
|
4076
|
+
}
|
|
4077
|
+
function presetDef(id) {
|
|
4078
|
+
return PRESETS[id] ?? PRESETS.dsh;
|
|
2428
4079
|
}
|
|
2429
4080
|
function registerTheme(ctx) {
|
|
2430
4081
|
const slots = ctx.get("slots");
|
|
@@ -2457,44 +4108,253 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
|
|
|
2457
4108
|
if (!baselineDisposer) baselineDisposer = injector.insert(buildBaseCss());
|
|
2458
4109
|
} else if (baselineDisposer) baselineDisposer = release(baselineDisposer);
|
|
2459
4110
|
}
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
4111
|
+
/** Raw localStorage access; an absent or blocked store reads as empty, writes are dropped. */
|
|
4112
|
+
function readRaw(key) {
|
|
4113
|
+
try {
|
|
4114
|
+
return localStorage.getItem(key);
|
|
4115
|
+
} catch {
|
|
4116
|
+
return null;
|
|
4117
|
+
}
|
|
4118
|
+
}
|
|
4119
|
+
function writeRaw(key, value) {
|
|
4120
|
+
try {
|
|
4121
|
+
if (value === null) localStorage.removeItem(key);
|
|
4122
|
+
else localStorage.setItem(key, value);
|
|
4123
|
+
} catch {}
|
|
4124
|
+
}
|
|
4125
|
+
function getSelection() {
|
|
4126
|
+
for (const key of [THEME_STORAGE_KEY, THEME_STORAGE_KEY_LEGACY]) {
|
|
4127
|
+
const cur = readRaw(key);
|
|
4128
|
+
if (!isValidSelection(cur)) continue;
|
|
2464
4129
|
const normalized = cur === "native" ? "dsh" : cur;
|
|
2465
|
-
if (cur !== normalized)
|
|
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 {}
|
|
4130
|
+
if (cur !== normalized || key === "cooltea-theme-preset") writeRaw(THEME_STORAGE_KEY, normalized);
|
|
2471
4131
|
return normalized;
|
|
2472
|
-
}
|
|
4132
|
+
}
|
|
2473
4133
|
return "dsh";
|
|
2474
4134
|
}
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
if (
|
|
4135
|
+
/** The preset the custom seeds derive from, restored from the stored blob. */
|
|
4136
|
+
function customBase() {
|
|
4137
|
+
const env = parseEnvelope(readRaw(CUSTOM_THEME_STORAGE_KEY));
|
|
4138
|
+
if (env && isPresetId(env.base)) return env.base;
|
|
4139
|
+
return lastPreset;
|
|
4140
|
+
}
|
|
4141
|
+
function loadCustom() {
|
|
4142
|
+
const base = customBase();
|
|
4143
|
+
const fallback = extractSeeds(presetDef(base));
|
|
4144
|
+
return {
|
|
4145
|
+
base,
|
|
4146
|
+
theme: normalizeCustom(parseEnvelope(readRaw(CUSTOM_THEME_STORAGE_KEY))?.theme, fallback)
|
|
4147
|
+
};
|
|
4148
|
+
}
|
|
4149
|
+
function persistCustom(theme) {
|
|
4150
|
+
writeRaw(CUSTOM_THEME_STORAGE_KEY, encodeCustom(customBase(), theme));
|
|
4151
|
+
}
|
|
4152
|
+
/**
|
|
4153
|
+
* Choose the preset a future custom theme seeds from, without touching the
|
|
4154
|
+
* colours currently on screen. The custom editor stays the live theme; the
|
|
4155
|
+
* preset is only the template the next "add" inherits.
|
|
4156
|
+
*/
|
|
4157
|
+
function setCustomBase(base) {
|
|
4158
|
+
lastPreset = base;
|
|
4159
|
+
writeRaw(CUSTOM_THEME_STORAGE_KEY, encodeCustom(base, loadCustom().theme));
|
|
4160
|
+
}
|
|
4161
|
+
/**
|
|
4162
|
+
* Install a palette. The override layer for our source is replaced in place
|
|
4163
|
+
* rather than torn down first: `theme.overrideTokens` restacks the same source
|
|
4164
|
+
* atomically, while disposing the old layer emits an extra `theme/change` with
|
|
4165
|
+
* our variables missing. The shell's presenter answers that by removing every
|
|
4166
|
+
* token from `body`, so the whole page falls back to the base palette and
|
|
4167
|
+
* repaints before the new layer lands — twice the DOM work and a visible
|
|
4168
|
+
* flash per change, which is exactly what a dragged colour slider produces.
|
|
4169
|
+
*/
|
|
4170
|
+
function apply(src) {
|
|
4171
|
+
const target = src === CUSTOM_SELECTION ? buildCustomPreset(loadCustom().theme) : src;
|
|
4172
|
+
if (typeof target === "string" && NOOP_PRESET_IDS.has(target)) {
|
|
4173
|
+
overrideDispose = release(overrideDispose);
|
|
4174
|
+
fallbackDispose = release(fallbackDispose);
|
|
2479
4175
|
ensureBaseline(false);
|
|
2480
4176
|
return;
|
|
2481
4177
|
}
|
|
2482
4178
|
ensureBaseline(true);
|
|
2483
|
-
const overrides = buildOverrides(
|
|
4179
|
+
const overrides = buildOverrides(target);
|
|
2484
4180
|
if (theme?.overrideTokens) try {
|
|
2485
4181
|
overrideDispose = theme.overrideTokens("dsh-cool-theme", overrides);
|
|
4182
|
+
fallbackDispose = release(fallbackDispose);
|
|
2486
4183
|
return;
|
|
2487
4184
|
} catch {}
|
|
2488
|
-
|
|
4185
|
+
overrideDispose = release(overrideDispose);
|
|
4186
|
+
fallbackDispose = release(fallbackDispose);
|
|
4187
|
+
fallbackDispose = injector.insert(buildFullCssFallback(target));
|
|
2489
4188
|
}
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
4189
|
+
/**
|
|
4190
|
+
* Coalesce a burst of palette applications into one.
|
|
4191
|
+
*
|
|
4192
|
+
* A native colour input fires `change` on every pointer move while its slider
|
|
4193
|
+
* is dragged. Each application rebuilds both ramps, rewrites every token on
|
|
4194
|
+
* `body` and forces a style flush in the shell's presenter, so applying once
|
|
4195
|
+
* per event is what makes the colour editor stutter. The first change lands
|
|
4196
|
+
* immediately and the rest are throttled to one per interval, newest request
|
|
4197
|
+
* winning: the palette the pointer is released on is always the one applied.
|
|
4198
|
+
*/
|
|
4199
|
+
const APPLY_INTERVAL_MS = 80;
|
|
4200
|
+
let pendingApply = null;
|
|
4201
|
+
let applyTimer = null;
|
|
4202
|
+
let lastApplyAt = 0;
|
|
4203
|
+
let disposed = false;
|
|
4204
|
+
/** Run the newest pending application now, if any. */
|
|
4205
|
+
function flushApply() {
|
|
4206
|
+
if (applyTimer !== null) {
|
|
4207
|
+
clearTimeout(applyTimer);
|
|
4208
|
+
applyTimer = null;
|
|
4209
|
+
}
|
|
4210
|
+
const run = pendingApply;
|
|
4211
|
+
pendingApply = null;
|
|
4212
|
+
if (!run) return;
|
|
4213
|
+
lastApplyAt = Date.now();
|
|
4214
|
+
run();
|
|
4215
|
+
}
|
|
4216
|
+
/** Drop a coalesced application, for a fiber that is going away. */
|
|
4217
|
+
function cancelApply() {
|
|
4218
|
+
if (applyTimer !== null) {
|
|
4219
|
+
clearTimeout(applyTimer);
|
|
4220
|
+
applyTimer = null;
|
|
4221
|
+
}
|
|
4222
|
+
pendingApply = null;
|
|
4223
|
+
}
|
|
4224
|
+
/**
|
|
4225
|
+
* Apply a palette, at most once per interval while a burst of edits is
|
|
4226
|
+
* running. The callback is deferred whole, so a dropped intermediate edit
|
|
4227
|
+
* never even rebuilds its ramps; only the newest one is executed.
|
|
4228
|
+
*/
|
|
4229
|
+
function scheduleApply(run) {
|
|
4230
|
+
if (disposed) return;
|
|
4231
|
+
pendingApply = run;
|
|
4232
|
+
const elapsed = Date.now() - lastApplyAt;
|
|
4233
|
+
if (elapsed >= APPLY_INTERVAL_MS) {
|
|
4234
|
+
flushApply();
|
|
4235
|
+
return;
|
|
4236
|
+
}
|
|
4237
|
+
if (applyTimer === null) applyTimer = setTimeout(flushApply, APPLY_INTERVAL_MS - elapsed);
|
|
4238
|
+
}
|
|
4239
|
+
function setSelection(id) {
|
|
4240
|
+
writeRaw(THEME_STORAGE_KEY, id);
|
|
4241
|
+
if (id !== CUSTOM_SELECTION) lastPreset = id;
|
|
4242
|
+
scheduleApply(() => apply(id));
|
|
4243
|
+
}
|
|
4244
|
+
function setCustom(next) {
|
|
4245
|
+
persistCustom(next);
|
|
4246
|
+
scheduleApply(() => apply(buildCustomPreset(next)));
|
|
4247
|
+
}
|
|
4248
|
+
function resetCustom() {
|
|
4249
|
+
const theme = extractSeeds(presetDef(customBase()));
|
|
4250
|
+
persistCustom(theme);
|
|
4251
|
+
scheduleApply(() => apply(buildCustomPreset(theme)));
|
|
4252
|
+
return theme;
|
|
4253
|
+
}
|
|
4254
|
+
const fallbackForBase = (base) => extractSeeds(presetDef(isPresetId(base) ? base : lastPreset));
|
|
4255
|
+
/**
|
|
4256
|
+
* The roster as the Host last reported it; `null` means it has not answered
|
|
4257
|
+
* yet. Files are the source of truth, so this is a read-through cache the
|
|
4258
|
+
* panel renders from, and every mutation is a round trip plus a refresh.
|
|
4259
|
+
*/
|
|
4260
|
+
let themeCache = null;
|
|
4261
|
+
/** The legacy browser list, read only until the Host has answered. */
|
|
4262
|
+
const legacyList = () => decodeList(readRaw(CUSTOM_LIST_STORAGE_KEY), fallbackForBase);
|
|
4263
|
+
/** The roster to render and resolve ids against. */
|
|
4264
|
+
const readList = () => themeCache ?? legacyList();
|
|
4265
|
+
function readActiveId() {
|
|
4266
|
+
const id = readRaw(CUSTOM_ACTIVE_STORAGE_KEY);
|
|
4267
|
+
return id && readList().some((e) => e.id === id) ? id : null;
|
|
4268
|
+
}
|
|
4269
|
+
/**
|
|
4270
|
+
* Adopt the themes this browser saved before they lived in files. Runs once:
|
|
4271
|
+
* the marker keeps a stale browser copy from being migrated twice, and is only
|
|
4272
|
+
* set after a confirmed write, so a failed migration retries next load.
|
|
4273
|
+
*/
|
|
4274
|
+
async function migrateLegacyThemes(hostThemes) {
|
|
4275
|
+
if (readRaw("cool-theme-custom-migrated") !== null) return hostThemes;
|
|
4276
|
+
const adopt = legacyThemesToAdopt(legacyList(), hostThemes);
|
|
4277
|
+
if (adopt.length > 0) await pushThemes(adopt, fallbackForBase);
|
|
4278
|
+
writeRaw(CUSTOM_MIGRATED_STORAGE_KEY, (/* @__PURE__ */ new Date()).toISOString());
|
|
4279
|
+
writeRaw(CUSTOM_LIST_STORAGE_KEY, null);
|
|
4280
|
+
return adopt.length > 0 ? fetchThemes(fallbackForBase) : hostThemes;
|
|
2495
4281
|
}
|
|
2496
|
-
|
|
4282
|
+
/** Re-read the roster from the Host, migrating the legacy list on first load. */
|
|
4283
|
+
async function refreshSaved() {
|
|
4284
|
+
themeCache = await migrateLegacyThemes(await fetchThemes(fallbackForBase));
|
|
4285
|
+
return themeCache;
|
|
4286
|
+
}
|
|
4287
|
+
/** Load a saved entry into the draft and make it the active theme. */
|
|
4288
|
+
function loadSaved(id) {
|
|
4289
|
+
const entry = readList().find((e) => e.id === id);
|
|
4290
|
+
if (!entry) return null;
|
|
4291
|
+
if (isPresetId(entry.base)) lastPreset = entry.base;
|
|
4292
|
+
writeRaw(CUSTOM_ACTIVE_STORAGE_KEY, entry.id);
|
|
4293
|
+
writeRaw(CUSTOM_THEME_STORAGE_KEY, encodeCustom(entry.base, entry.theme));
|
|
4294
|
+
scheduleApply(() => apply(buildCustomPreset(entry.theme)));
|
|
4295
|
+
return entry.theme;
|
|
4296
|
+
}
|
|
4297
|
+
const saved = {
|
|
4298
|
+
/** The cached roster, or the legacy browser list until the Host answers. */
|
|
4299
|
+
list: readList,
|
|
4300
|
+
activeId: readActiveId,
|
|
4301
|
+
load: loadSaved,
|
|
4302
|
+
/** Point the "in use" badge at an entry, or clear it entirely. */
|
|
4303
|
+
setActive(id) {
|
|
4304
|
+
writeRaw(CUSTOM_ACTIVE_STORAGE_KEY, id);
|
|
4305
|
+
},
|
|
4306
|
+
refresh: refreshSaved,
|
|
4307
|
+
/** Persist the current draft as a new named entry and make it active. */
|
|
4308
|
+
async create(name) {
|
|
4309
|
+
const draft = loadCustom();
|
|
4310
|
+
const entry = {
|
|
4311
|
+
id: newThemeId(),
|
|
4312
|
+
name,
|
|
4313
|
+
base: draft.base,
|
|
4314
|
+
theme: draft.theme,
|
|
4315
|
+
assets: []
|
|
4316
|
+
};
|
|
4317
|
+
await pushThemes([entry], fallbackForBase);
|
|
4318
|
+
writeRaw(CUSTOM_ACTIVE_STORAGE_KEY, entry.id);
|
|
4319
|
+
return refreshSaved();
|
|
4320
|
+
},
|
|
4321
|
+
/** Write the current draft (and a new name) back to an existing entry. */
|
|
4322
|
+
async update(id, name) {
|
|
4323
|
+
const entry = readList().find((e) => e.id === id);
|
|
4324
|
+
if (!entry) return readList();
|
|
4325
|
+
const draft = loadCustom();
|
|
4326
|
+
await pushThemes([{
|
|
4327
|
+
...entry,
|
|
4328
|
+
name,
|
|
4329
|
+
theme: draft.theme
|
|
4330
|
+
}], fallbackForBase);
|
|
4331
|
+
return refreshSaved();
|
|
4332
|
+
},
|
|
4333
|
+
async remove(id) {
|
|
4334
|
+
if (readRaw("cool-theme-custom-active") === id) writeRaw(CUSTOM_ACTIVE_STORAGE_KEY, null);
|
|
4335
|
+
await removeTheme(id);
|
|
4336
|
+
const next = await refreshSaved();
|
|
4337
|
+
if (readActiveId() === null && next[0]) loadSaved(next[0].id);
|
|
4338
|
+
return next;
|
|
4339
|
+
}
|
|
4340
|
+
};
|
|
4341
|
+
const initialSelection = getSelection();
|
|
4342
|
+
let lastPreset = initialSelection === CUSTOM_SELECTION ? "dsh" : initialSelection;
|
|
4343
|
+
if (initialSelection === CUSTOM_SELECTION) {
|
|
4344
|
+
const env = parseEnvelope(readRaw(CUSTOM_THEME_STORAGE_KEY));
|
|
4345
|
+
if (env && isPresetId(env.base)) lastPreset = env.base;
|
|
4346
|
+
}
|
|
4347
|
+
apply(initialSelection);
|
|
4348
|
+
let sharedToast = null;
|
|
4349
|
+
const modules = ctx.get("modules");
|
|
4350
|
+
try {
|
|
4351
|
+
modules?.import?.("@deepseek-ai/dsh-client-ui-primitives").then((mod) => {
|
|
4352
|
+
if (typeof mod?.Toast === "function") sharedToast = mod.Toast;
|
|
4353
|
+
}).catch(() => {});
|
|
4354
|
+
} catch {}
|
|
2497
4355
|
ctx.effect(() => () => {
|
|
4356
|
+
disposed = true;
|
|
4357
|
+
cancelApply();
|
|
2498
4358
|
release(pluginCssDisposer);
|
|
2499
4359
|
baselineDisposer = release(baselineDisposer);
|
|
2500
4360
|
overrideDispose = release(overrideDispose);
|
|
@@ -2512,8 +4372,15 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
|
|
|
2512
4372
|
return slots.register(options, (props) => react.createElement(ThemePanel, {
|
|
2513
4373
|
t: props?.t ?? ((key) => zh[key]),
|
|
2514
4374
|
theme,
|
|
2515
|
-
|
|
2516
|
-
|
|
4375
|
+
getSelection,
|
|
4376
|
+
setSelection,
|
|
4377
|
+
getCustom: loadCustom,
|
|
4378
|
+
setCustom,
|
|
4379
|
+
setCustomBase,
|
|
4380
|
+
resetCustom,
|
|
4381
|
+
saved,
|
|
4382
|
+
getHostToast: () => sharedToast,
|
|
4383
|
+
onRequestClose: typeof props?.close === "function" ? props.close : null
|
|
2517
4384
|
}));
|
|
2518
4385
|
});
|
|
2519
4386
|
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 +4391,16 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
|
|
|
2524
4391
|
try {
|
|
2525
4392
|
currentLabel = t("nav");
|
|
2526
4393
|
} catch {}
|
|
2527
|
-
const candidates =
|
|
4394
|
+
const candidates = [
|
|
2528
4395
|
currentLabel,
|
|
2529
4396
|
zh.nav,
|
|
2530
4397
|
en.nav
|
|
2531
|
-
].filter(Boolean)
|
|
4398
|
+
].filter(Boolean);
|
|
2532
4399
|
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));
|
|
4400
|
+
const toPatch = cells.length > 0 ? Array.from(cells) : Array.from(document.querySelectorAll("[class*=\"nav\"] button"));
|
|
2536
4401
|
for (const cell of toPatch) {
|
|
2537
4402
|
const text = ((cell.querySelector("[class*=\"navLabel\"]") || cell)?.textContent || cell.textContent || "").trim();
|
|
2538
|
-
if (!
|
|
4403
|
+
if (!candidates.some((lbl) => text === lbl || text.includes(lbl))) continue;
|
|
2539
4404
|
if (cell.querySelector("[data-palette-icon]")) continue;
|
|
2540
4405
|
const svg = cell.querySelector("svg");
|
|
2541
4406
|
if (!svg) continue;
|