dsh-cool-theme 0.6.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -40,6 +40,7 @@ window.__ModuleLoader__.load({
40
40
  const CUSTOM_LIST_STORAGE_KEY = "cool-theme-custom-list";
41
41
  const CUSTOM_ACTIVE_STORAGE_KEY = "cool-theme-custom-active";
42
42
  const CUSTOM_MIGRATED_STORAGE_KEY = "cool-theme-custom-migrated";
43
+ const SIDEBAR_TRANSPARENT_STORAGE_KEY = "cool-theme-sidebar-transparent";
43
44
  const THEME_API_PATH_THEMES = `/cool-theme/api/themes`;
44
45
  const THEME_ARCHIVE_EXTENSION = ".zip";
45
46
  /**
@@ -249,7 +250,7 @@ window.__ModuleLoader__.load({
249
250
  return map;
250
251
  }
251
252
  /** Mix two colors linearly in RGB. weight 0 -> colorA, 1 -> colorB */
252
- function mix(colorA, colorB, weight) {
253
+ function mix$1(colorA, colorB, weight) {
253
254
  const a = hexToRgb(colorA);
254
255
  const b = hexToRgb(colorB);
255
256
  const w = Math.max(0, Math.min(1, weight));
@@ -320,7 +321,7 @@ window.__ModuleLoader__.load({
320
321
  for (const step of steps) {
321
322
  const w = weights[step];
322
323
  if (w === void 0) continue;
323
- const color = step === 500 || step === "500" ? base.toUpperCase() : typeof step === "number" && step > 500 || step === "950" ? mix("#000000", base, w) : mix("#FFFFFF", base, w);
324
+ const color = step === 500 || step === "500" ? base.toUpperCase() : typeof step === "number" && step > 500 || step === "950" ? mix$1("#000000", base, w) : mix$1("#FFFFFF", base, w);
324
325
  map[`${prefix}-${step}`] = color;
325
326
  }
326
327
  return map;
@@ -353,7 +354,7 @@ window.__ModuleLoader__.load({
353
354
  ...buildGreenScale(opts.green),
354
355
  ...buildAmberScale(opts.amber),
355
356
  ...buildRedScale(opts.red),
356
- "--dsw-static-deepseek-700-delete": mix("#000000", opts.deepseek, .7)
357
+ "--dsw-static-deepseek-700-delete": mix$1("#000000", opts.deepseek, .7)
357
358
  };
358
359
  }
359
360
  //#endregion
@@ -2188,6 +2189,226 @@ window.__ModuleLoader__.load({
2188
2189
  return out;
2189
2190
  }
2190
2191
  //#endregion
2192
+ //#region src/client/css/accents.ts
2193
+ /**
2194
+ * Accent candidates, most specific first. Light takes `deepseek-500` and dark
2195
+ * takes `deepseek-400` — the exact steps DSH picks for `state-business-primary`
2196
+ * and `button-info-fill`, so the themed controls land on the colour the shell
2197
+ * already uses for its own emphasis. The blue scale is the fallback for a
2198
+ * palette that renames or omits the DeepSeek one.
2199
+ */
2200
+ const ACCENT_KEYS = {
2201
+ light: [
2202
+ "--dsw-static-deepseek-500",
2203
+ "--dsw-static-deepseek-450",
2204
+ "--dsw-static-blue-500",
2205
+ "--dsw-static-blue-450"
2206
+ ],
2207
+ dark: [
2208
+ "--dsw-static-deepseek-400",
2209
+ "--dsw-static-deepseek-450",
2210
+ "--dsw-static-blue-400",
2211
+ "--dsw-static-blue-450"
2212
+ ]
2213
+ };
2214
+ /**
2215
+ * The step next to the accent on the light→dark axis, which DSH itself uses as
2216
+ * the filled button's hover (`button-info-fill: deepseek-500` with
2217
+ * `button-info-hover: deepseek-400` in light, the pair reversed in dark).
2218
+ */
2219
+ const HOVER_KEYS = {
2220
+ light: [
2221
+ "--dsw-static-deepseek-400",
2222
+ "--dsw-static-deepseek-450",
2223
+ "--dsw-static-blue-400",
2224
+ "--dsw-static-blue-450"
2225
+ ],
2226
+ dark: [
2227
+ "--dsw-static-deepseek-500",
2228
+ "--dsw-static-deepseek-450",
2229
+ "--dsw-static-blue-500",
2230
+ "--dsw-static-blue-450"
2231
+ ]
2232
+ };
2233
+ /**
2234
+ * The palette's extremes, used as the two ink candidates. A preset keeps its
2235
+ * neutral ramp identical in both appearances (the alias layer forks, the
2236
+ * primitives do not), so `neutral-bluish-00` is the lightest colour of the theme
2237
+ * in light *and* dark — which is also the label the shell prints on a Tooltip.
2238
+ */
2239
+ const LIGHTEST_INK_KEY = "--dsw-static-neutral-bluish-00";
2240
+ const DARKEST_INK_KEY = "--dsw-static-neutral-bluish-1000";
2241
+ /**
2242
+ * Alpha of the accent on the interaction overlays. Taken from the literals DSH
2243
+ * ships (`#2631480f`/`#2631481a` light, `#ffffff14`/`#ffffff24` dark), so only
2244
+ * the hue changes and hover weight stays exactly as the shell tuned it.
2245
+ */
2246
+ const OVERLAY_ALPHA = {
2247
+ light: {
2248
+ hover: 6,
2249
+ active: 10
2250
+ },
2251
+ dark: {
2252
+ hover: 8,
2253
+ active: 14
2254
+ }
2255
+ };
2256
+ /** Share of the palette's darkest neutral mixed into the accent for the tooltip. */
2257
+ const TOOLTIP_INK_WEIGHT = .55;
2258
+ /** Shifting applied per retry when the tooltip label is not yet legible. */
2259
+ const TOOLTIP_SHIFT_STEP = .25;
2260
+ /** Retry cap, so a palette whose lightest ink sits next to the bubble cannot spin here. */
2261
+ const TOOLTIP_SHIFT_RETRIES = 6;
2262
+ /** WCAG 2.1 AA contrast for normal text. */
2263
+ const TOOLTIP_MIN_CONTRAST = 4.5;
2264
+ const BLACK = [
2265
+ 0,
2266
+ 0,
2267
+ 0
2268
+ ];
2269
+ const WHITE = [
2270
+ 255,
2271
+ 255,
2272
+ 255
2273
+ ];
2274
+ /**
2275
+ * Read one of `keys` from the palette as RGB. `null` when the palette carries
2276
+ * none of them, or carries a value this layer cannot reason about (a `var()`,
2277
+ * a `color-mix()`, a named colour): without a concrete triple there is no way to
2278
+ * derive an ink or a tooltip that is guaranteed to stay readable.
2279
+ */
2280
+ function pickRgb(palette, keys) {
2281
+ for (const key of keys) {
2282
+ const value = palette[key];
2283
+ if (value === void 0) continue;
2284
+ const rgb = parseColor(value);
2285
+ if (rgb) return rgb;
2286
+ }
2287
+ return null;
2288
+ }
2289
+ function parseColor(value) {
2290
+ const text = value.trim();
2291
+ const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(text);
2292
+ if (hex) {
2293
+ const digits = hex[1].length === 3 ? [...hex[1]].map((d) => d + d).join("") : hex[1];
2294
+ return [
2295
+ Number.parseInt(digits.slice(0, 2), 16),
2296
+ Number.parseInt(digits.slice(2, 4), 16),
2297
+ Number.parseInt(digits.slice(4, 6), 16)
2298
+ ];
2299
+ }
2300
+ const fn = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/i.exec(text);
2301
+ if (fn) return [
2302
+ Number(fn[1]),
2303
+ Number(fn[2]),
2304
+ Number(fn[3])
2305
+ ];
2306
+ return null;
2307
+ }
2308
+ function linear(channel) {
2309
+ const c = channel / 255;
2310
+ return c <= .03928 ? c / 12.92 : ((c + .055) / 1.055) ** 2.4;
2311
+ }
2312
+ /** WCAG relative luminance. */
2313
+ function luminance(rgb) {
2314
+ return .2126 * linear(rgb[0]) + .7152 * linear(rgb[1]) + .0722 * linear(rgb[2]);
2315
+ }
2316
+ function contrastRatio(a, b) {
2317
+ const first = luminance(a);
2318
+ const second = luminance(b);
2319
+ const [high, low] = first >= second ? [first, second] : [second, first];
2320
+ return (high + .05) / (low + .05);
2321
+ }
2322
+ /** Mix in RGB, weight 0 → `from`, 1 → `to` (the space the preset ramps use). */
2323
+ function mix(from, to, weight) {
2324
+ return [
2325
+ from[0] * (1 - weight) + to[0] * weight,
2326
+ from[1] * (1 - weight) + to[1] * weight,
2327
+ from[2] * (1 - weight) + to[2] * weight
2328
+ ];
2329
+ }
2330
+ function toHex$1(rgb) {
2331
+ const channel = (v) => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, "0");
2332
+ return `#${channel(rgb[0])}${channel(rgb[1])}${channel(rgb[2])}`;
2333
+ }
2334
+ function accentPalette(palette, mode) {
2335
+ const accent = pickRgb(palette, ACCENT_KEYS[mode]);
2336
+ const lightest = pickRgb(palette, [LIGHTEST_INK_KEY]);
2337
+ const darkest = pickRgb(palette, [DARKEST_INK_KEY]);
2338
+ if (!accent || !lightest || !darkest) return null;
2339
+ const tooltipTarget = contrastRatio(lightest, BLACK) >= contrastRatio(lightest, WHITE) ? BLACK : WHITE;
2340
+ let tooltipBg = mix(accent, darkest, TOOLTIP_INK_WEIGHT);
2341
+ let retry = 0;
2342
+ while (retry < TOOLTIP_SHIFT_RETRIES && contrastRatio(tooltipBg, lightest) < TOOLTIP_MIN_CONTRAST) {
2343
+ tooltipBg = mix(tooltipBg, tooltipTarget, TOOLTIP_SHIFT_STEP);
2344
+ retry += 1;
2345
+ }
2346
+ const onAccent = contrastRatio(accent, lightest) >= contrastRatio(accent, darkest) ? lightest : darkest;
2347
+ const accentHex = toHex$1(accent);
2348
+ return {
2349
+ accent: accentHex,
2350
+ hover: toHex$1(pickRgb(palette, HOVER_KEYS[mode]) ?? accent),
2351
+ onAccent: toHex$1(onAccent),
2352
+ tooltipBg: toHex$1(tooltipBg),
2353
+ hoverOverlay: `color-mix(in srgb, ${accentHex} ${OVERLAY_ALPHA[mode].hover}%, transparent)`,
2354
+ activeOverlay: `color-mix(in srgb, ${accentHex} ${OVERLAY_ALPHA[mode].active}%, transparent)`
2355
+ };
2356
+ }
2357
+ /**
2358
+ * The alias tokens that carry the accent. Empty when either appearance has no
2359
+ * usable accent: publishing half a layer would leave the two appearances
2360
+ * disagreeing about the theme's brand colour.
2361
+ */
2362
+ function accentOverrides(light, dark) {
2363
+ const lightPalette = accentPalette(light, "light");
2364
+ const darkPalette = accentPalette(dark, "dark");
2365
+ if (!lightPalette || !darkPalette) return {};
2366
+ return {
2367
+ "--dsw-alias-brand-primary": {
2368
+ light: lightPalette.accent,
2369
+ dark: darkPalette.accent
2370
+ },
2371
+ "--dsw-alias-button-primary-fill": {
2372
+ light: lightPalette.accent,
2373
+ dark: darkPalette.accent
2374
+ },
2375
+ "--dsw-alias-button-primary-hover": {
2376
+ light: lightPalette.hover,
2377
+ dark: darkPalette.hover
2378
+ },
2379
+ "--dsw-alias-label-primary-foreground": {
2380
+ light: lightPalette.onAccent,
2381
+ dark: darkPalette.onAccent
2382
+ },
2383
+ "--dsw-alias-tooltip-bg": {
2384
+ light: lightPalette.tooltipBg,
2385
+ dark: darkPalette.tooltipBg
2386
+ },
2387
+ "--dsw-alias-interactive-bg-hover": {
2388
+ light: lightPalette.hoverOverlay,
2389
+ dark: darkPalette.hoverOverlay
2390
+ },
2391
+ "--dsw-alias-interactive-bg-active": {
2392
+ light: lightPalette.activeOverlay,
2393
+ dark: darkPalette.activeOverlay
2394
+ }
2395
+ };
2396
+ }
2397
+ /**
2398
+ * The same layer as CSS, for the fallback used when the client exposes no
2399
+ * `theme.overrideTokens`. DSH declares its aliases on `body`, so the light block
2400
+ * rides on `:root` (a pseudo-class outranks it) and the dark block adds
2401
+ * `html[data-ds-dark-theme] body` to outrank `body[data-ds-dark-theme]`
2402
+ * regardless of where the injected sheet lands in the cascade.
2403
+ */
2404
+ function buildAccentCss(light, dark) {
2405
+ const overrides = accentOverrides(light, dark);
2406
+ const names = Object.keys(overrides);
2407
+ if (names.length === 0) return "";
2408
+ const block = (selector, mode) => `${selector} {\n${names.map((name) => ` ${name}: ${overrides[name][mode]};`).join("\n")}\n}`;
2409
+ return `${block(":root, body", "light")}\n\n${block("html[data-ds-dark-theme] body, body[data-ds-dark-theme]", "dark")}`;
2410
+ }
2411
+ //#endregion
2191
2412
  //#region src/client/css/shiki.ts
2192
2413
  /**
2193
2414
  * Shiki token palette: values behind --shiki-* custom properties emitted by
@@ -2253,15 +2474,24 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
2253
2474
  }
2254
2475
  };
2255
2476
  }
2477
+ /**
2478
+ * Every token a theme publishes, in one layer: the primitive ramps it retints,
2479
+ * plus the alias tokens that move DSH's interactive components onto the theme's
2480
+ * accent (see css/accents.ts). The accent goes last so it wins over any alias a
2481
+ * custom theme's own map happens to carry.
2482
+ */
2256
2483
  function buildOverrides(src) {
2257
2484
  const resolved = resolvePreset(src);
2258
2485
  if (!resolved) return {};
2259
- return primitiveOverrides(resolved.light, resolved.dark);
2486
+ return {
2487
+ ...primitiveOverrides(resolved.light, resolved.dark),
2488
+ ...accentOverrides(resolved.light, resolved.dark)
2489
+ };
2260
2490
  }
2261
2491
  function buildFullCssFallback(src) {
2262
2492
  const resolved = resolvePreset(src);
2263
2493
  if (!resolved) return "";
2264
- return buildPrimitivesCss(resolved.light, resolved.dark);
2494
+ return `${buildPrimitivesCss(resolved.light, resolved.dark)}\n\n${buildAccentCss(resolved.light, resolved.dark)}`;
2265
2495
  }
2266
2496
  //#endregion
2267
2497
  //#region src/client/style-injector.ts
@@ -2458,6 +2688,10 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
2458
2688
  var r = y(e);
2459
2689
  return "hsl(" + r.h + ", " + r.s + "%, " + r.l + "%)";
2460
2690
  };
2691
+ var q = function(e) {
2692
+ var r = y(e);
2693
+ return "hsla(" + r.h + ", " + r.s + "%, " + r.l + "%, " + r.a + ")";
2694
+ };
2461
2695
  var I = function(e) {
2462
2696
  var r = e.h, n = e.s, t = e.v, o = e.a;
2463
2697
  r = r / 360 * 6, n /= 100, t /= 100;
@@ -2671,6 +2905,67 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
2671
2905
  var Z = function(r) {
2672
2906
  return react.default.createElement(J, l({}, r, { colorModel: Q }));
2673
2907
  };
2908
+ var ee = function(r) {
2909
+ var n = r.className, t = r.hsva, o = r.onChange, a = r.onChangeEnd, u = { backgroundImage: "linear-gradient(90deg, " + q(Object.assign({}, t, { a: 0 })) + ", " + q(Object.assign({}, t, { a: 1 })) + ")" }, l = m(["react-colorful__alpha", n]), c = b(100 * t.a);
2910
+ return react.default.createElement("div", { className: l }, react.default.createElement("div", {
2911
+ className: "react-colorful__alpha-gradient",
2912
+ style: u
2913
+ }), react.default.createElement(g, {
2914
+ onMove: function(e) {
2915
+ o({ a: e.left });
2916
+ },
2917
+ onKey: function(e) {
2918
+ o({ a: s(t.a + e.left) });
2919
+ },
2920
+ onEnd: a,
2921
+ "aria-label": "Alpha",
2922
+ "aria-valuetext": c + "%",
2923
+ "aria-valuenow": c,
2924
+ "aria-valuemin": "0",
2925
+ "aria-valuemax": "100"
2926
+ }, react.default.createElement(p, {
2927
+ className: "react-colorful__alpha-pointer",
2928
+ left: t.a,
2929
+ color: q(t)
2930
+ })));
2931
+ };
2932
+ var re = function(n) {
2933
+ var t = n.className, o = n.colorModel, a = n.color, u = void 0 === a ? o.defaultColor : a, i = n.onChange, s = n.onChangeEnd, f = c(n, [
2934
+ "className",
2935
+ "colorModel",
2936
+ "color",
2937
+ "onChange",
2938
+ "onChangeEnd"
2939
+ ]), d = (0, react.useRef)(null);
2940
+ G(d);
2941
+ var v = X(o, u, i, s), h = v[0], g = v[1], p = v[2], b = m(["react-colorful", t]);
2942
+ return react.default.createElement("div", l({}, f, {
2943
+ ref: d,
2944
+ className: b
2945
+ }), react.default.createElement(S, {
2946
+ hsva: h,
2947
+ onChange: g,
2948
+ onChangeEnd: p
2949
+ }), react.default.createElement(R, {
2950
+ hue: h.h,
2951
+ onChange: g,
2952
+ onChangeEnd: p
2953
+ }), react.default.createElement(ee, {
2954
+ hsva: h,
2955
+ onChange: g,
2956
+ onChangeEnd: p,
2957
+ className: "react-colorful__last-control"
2958
+ }));
2959
+ };
2960
+ var ne = {
2961
+ defaultColor: "0001",
2962
+ toHsva: E,
2963
+ fromHsva: w,
2964
+ equal: P
2965
+ };
2966
+ var te = function(r) {
2967
+ return react.default.createElement(re, l({}, r, { colorModel: ne }));
2968
+ };
2674
2969
  var ke = /^#?([0-9A-F]{3,8})$/i;
2675
2970
  var qe = function(r) {
2676
2971
  var n = r.color, u = void 0 === n ? "" : n, s = r.onChange, f = r.onBlur, d = r.escape, v = r.validate, h = r.format, g = r.process, m = c(r, [
@@ -2769,11 +3064,51 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
2769
3064
  const HEX = /^#([0-9a-f]{3,8})$/i;
2770
3065
  const RGB_FN = /^rgba?\(([^)]*)\)$/i;
2771
3066
  const HSL_FN = /^hsla?\(([^)]*)\)$/i;
3067
+ /**
3068
+ * DSH's own `--dsw-alias-border-l1`: a 4% black hairline in light and a 6% white
3069
+ * one in dark. A fresh custom theme starts here, so turning custom mode on
3070
+ * leaves the borders exactly as the shell paints them until the seed is moved.
3071
+ */
3072
+ const DEFAULT_BORDER = {
3073
+ light: {
3074
+ color: "#000000",
3075
+ alpha: 4
3076
+ },
3077
+ dark: {
3078
+ color: "#FFFFFF",
3079
+ alpha: 6
3080
+ }
3081
+ };
3082
+ /**
3083
+ * How far above the seed each derived level sits, in percentage points — DSH's
3084
+ * own spacing. Its light ladder runs 4 / 10 / 12 / 16 and its dark one
3085
+ * 6 / 12 / 16 / 20, so these offsets reproduce the shipped values exactly at the
3086
+ * default seed while letting one picker move every level together.
3087
+ *
3088
+ * `thin` tracks l2 where DSH does not thin it (light) and collapses onto l1
3089
+ * where it does (dark), which is exactly how the shell's pair is authored.
3090
+ */
3091
+ const BORDER_ALPHA_STEPS = {
3092
+ light: {
3093
+ l2: 6,
3094
+ l3: 8,
3095
+ l4: 12,
3096
+ thin: 6
3097
+ },
3098
+ dark: {
3099
+ l2: 6,
3100
+ l3: 10,
3101
+ l4: 14,
3102
+ thin: 0
3103
+ }
3104
+ };
2772
3105
  function isRecord$1(value) {
2773
3106
  return typeof value === "object" && value !== null && !Array.isArray(value);
2774
3107
  }
2775
3108
  const clamp255 = (n) => Math.max(0, Math.min(255, Math.round(n)));
2776
3109
  const clamp01 = (n) => Math.max(0, Math.min(1, n));
3110
+ /** An opacity percentage, folded into the 0–100 the seed stores. */
3111
+ const clampAlpha = (n) => Math.max(0, Math.min(100, Math.round(n)));
2777
3112
  /** Accepts both `a, b, c` and the modern `a b c / d` argument syntax. */
2778
3113
  function fnArgs(body) {
2779
3114
  return body.replace(/\//g, " ").split(/[\s,]+/).filter(Boolean);
@@ -2802,8 +3137,9 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
2802
3137
  * Parse a colour into `#RRGGBB`, or null when it is not a format we understand.
2803
3138
  *
2804
3139
  * Accepted: `#RGB`, `#RGBA`, `#RRGGBB`, `#RRGGBBAA`, `rgb()`/`rgba()` with
2805
- * numbers or percentages, and `hsl()`/`hsla()`. Alpha is parsed but dropped —
2806
- * every token these seeds drive is an opaque theme colour.
3140
+ * numbers or percentages, and `hsl()`/`hsla()`. Alpha is parsed but dropped:
3141
+ * every seed stores an opaque colour, and the one overlay — the border — keeps
3142
+ * its opacity in a field of its own.
2807
3143
  *
2808
3144
  * `primitives.ts` stores `rgb(r, g, b)` while presets store hex, so both shapes
2809
3145
  * have to work; the extra forms are there for what users may type.
@@ -2848,6 +3184,38 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
2848
3184
  function toHex(input) {
2849
3185
  return tryToHex(input) ?? "#000000";
2850
3186
  }
3187
+ /** A stored opacity: a finite number folded into 0–100, anything else the fallback. */
3188
+ function pickAlpha(value, fallback) {
3189
+ return typeof value === "number" && Number.isFinite(value) ? clampAlpha(value) : fallback;
3190
+ }
3191
+ /**
3192
+ * One border alias value, at the seed's own opacity or an override of it.
3193
+ *
3194
+ * Written as `rgba()` rather than an 8-digit hex because the seed stores the
3195
+ * opacity as a percentage and `rgba()` is the form DSH's own border tokens take.
3196
+ */
3197
+ function borderCss(seed, alpha = seed.alpha) {
3198
+ const hex = toHex(seed.color);
3199
+ const channel = (from) => parseInt(hex.slice(from, from + 2), 16);
3200
+ return `rgba(${channel(1)}, ${channel(3)}, ${channel(5)}, ${clampAlpha(alpha) / 100})`;
3201
+ }
3202
+ /**
3203
+ * The whole border ladder, from one seed. l1 is the seed itself and every
3204
+ * stronger level is the same hue at DSH's own spacing above it, so a single
3205
+ * picker moves sidebars, tab bars, cards and buttons together, and a stronger
3206
+ * level can never end up weaker than a subtler one. The alpha is clamped, so a
3207
+ * near-opaque seed flattens the top of the ladder instead of overflowing it.
3208
+ */
3209
+ function borderTokens(mode, seed) {
3210
+ const step = BORDER_ALPHA_STEPS[mode];
3211
+ return {
3212
+ "--dsw-alias-border-l1": borderCss(seed),
3213
+ "--dsw-alias-border-l2": borderCss(seed, seed.alpha + step.l2),
3214
+ "--dsw-alias-border-l3": borderCss(seed, seed.alpha + step.l3),
3215
+ "--dsw-alias-border-l4": borderCss(seed, seed.alpha + step.l4),
3216
+ "--dsw-alias-border-l2-darkmode-thin": borderCss(seed, seed.alpha + step.thin)
3217
+ };
3218
+ }
2851
3219
  function readSeed(map, fallback, key) {
2852
3220
  return toHex(map[key] ?? fallback[key]);
2853
3221
  }
@@ -2888,14 +3256,22 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
2888
3256
  green: pair("--dsw-static-green-500"),
2889
3257
  amber: pair("--dsw-static-amber-500"),
2890
3258
  red: pair("--dsw-static-red-500"),
3259
+ border: {
3260
+ light: { ...DEFAULT_BORDER.light },
3261
+ dark: { ...DEFAULT_BORDER.dark }
3262
+ },
2891
3263
  shiki
2892
3264
  };
2893
3265
  }
2894
- /** v1 carried one neutral ramp shared by both appearances. */
2895
- const CUSTOM_SCHEMA_VERSION_LEGACY = 1;
3266
+ /** Every stored version this build still reads; anything else is foreign data. */
3267
+ const READABLE_SCHEMA_VERSIONS = [
3268
+ 1,
3269
+ 2,
3270
+ 3
3271
+ ];
2896
3272
  function encodeCustom(base, theme) {
2897
3273
  return JSON.stringify({
2898
- v: 2,
3274
+ v: 3,
2899
3275
  base,
2900
3276
  theme
2901
3277
  });
@@ -2906,9 +3282,10 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
2906
3282
  try {
2907
3283
  const parsed = JSON.parse(raw);
2908
3284
  if (!parsed || typeof parsed !== "object") return null;
2909
- if (parsed.v !== 2 && parsed.v !== CUSTOM_SCHEMA_VERSION_LEGACY) return null;
3285
+ const version = parsed.v;
3286
+ if (typeof version !== "number" || !READABLE_SCHEMA_VERSIONS.includes(version)) return null;
2910
3287
  return {
2911
- v: 2,
3288
+ v: 3,
2912
3289
  base: String(parsed.base ?? ""),
2913
3290
  theme: parsed.theme
2914
3291
  };
@@ -2951,6 +3328,13 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
2951
3328
  darkest: pick(v.darkest, fb.darkest)
2952
3329
  };
2953
3330
  };
3331
+ const pickBorder = (value, fb) => {
3332
+ const v = value ?? {};
3333
+ return {
3334
+ color: pick(v.color, fb.color),
3335
+ alpha: pickAlpha(v.alpha, fb.alpha)
3336
+ };
3337
+ };
2954
3338
  const shikiSrc = src.shiki ?? {};
2955
3339
  const shiki = SHIKI_KEYS.reduce((acc, k) => {
2956
3340
  acc[k] = pickPair(shikiSrc[k], fallback.shiki[k]);
@@ -2966,6 +3350,10 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
2966
3350
  green: pickPair(src.green, fallback.green),
2967
3351
  amber: pickPair(src.amber, fallback.amber),
2968
3352
  red: pickPair(src.red, fallback.red),
3353
+ border: {
3354
+ light: pickBorder(src.border?.light, fallback.border.light),
3355
+ dark: pickBorder(src.border?.dark, fallback.border.dark)
3356
+ },
2969
3357
  shiki
2970
3358
  };
2971
3359
  }
@@ -2975,6 +3363,10 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
2975
3363
  * Unlike the presets (which ship one ramp for both appearances), the ramp is
2976
3364
  * built once per appearance, so the light text and the dark text are no longer
2977
3365
  * the two ends of the same colour axis.
3366
+ *
3367
+ * The border aliases are the one set of tokens a seed publishes directly
3368
+ * instead of through a `--dsw-static-*` ramp: DSH keeps its border levels as
3369
+ * alpha overlays in the base stylesheets, so they have no scale to hang off.
2978
3370
  */
2979
3371
  function buildCustomPreset(v) {
2980
3372
  /** One appearance's ramp, published under both prefixes DSH reads from. */
@@ -2997,16 +3389,20 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
2997
3389
  for (const k of SHIKI_KEYS) out[`--shiki-token-${k}`] = v.shiki[k][mode];
2998
3390
  return out;
2999
3391
  };
3392
+ /** Every border alias, derived from this appearance's one border seed. */
3393
+ const border = (mode) => borderTokens(mode, v.border[mode]);
3000
3394
  return {
3001
3395
  label: "Custom",
3002
3396
  light: {
3003
3397
  ...ramp("light"),
3004
3398
  ...semantic("light"),
3399
+ ...border("light"),
3005
3400
  ...shiki("light")
3006
3401
  },
3007
3402
  dark: {
3008
3403
  ...ramp("dark"),
3009
3404
  ...semantic("dark"),
3405
+ ...border("dark"),
3010
3406
  ...shiki("dark")
3011
3407
  }
3012
3408
  };
@@ -3024,8 +3420,8 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
3024
3420
  if (!raw) return [];
3025
3421
  try {
3026
3422
  const parsed = JSON.parse(raw);
3027
- const known = parsed?.v === 2 || parsed?.v === CUSTOM_SCHEMA_VERSION_LEGACY;
3028
- if (!parsed || !known || !Array.isArray(parsed.items)) return [];
3423
+ if (!parsed || typeof parsed.v !== "number" || !READABLE_SCHEMA_VERSIONS.includes(parsed.v)) return [];
3424
+ if (!Array.isArray(parsed.items)) return [];
3029
3425
  const out = [];
3030
3426
  for (const item of parsed.items) {
3031
3427
  const e = item;
@@ -3229,6 +3625,96 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
3229
3625
  return legacy.filter((entry) => !known.has(entry.id));
3230
3626
  }
3231
3627
  //#endregion
3628
+ //#region src/client/assets/delete.svg
3629
+ var delete_default = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\">\n <path d=\"M0 0h24v24H0z\" fill=\"none\" />\n <path fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M10 11v6m4-6v6m5-11v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2\" />\n</svg>\n";
3630
+ //#endregion
3631
+ //#region src/client/assets/edit.svg
3632
+ var edit_default = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\">\n <path d=\"M0 0h24v24H0z\" fill=\"none\" />\n <g fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\">\n <path d=\"M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7\" />\n <path d=\"M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z\" />\n </g>\n</svg>\n";
3633
+ //#endregion
3634
+ //#region src/client/assets/export.svg
3635
+ var export_default = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\">\n <path d=\"M0 0h24v24H0z\" fill=\"none\" />\n <path fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M12 3v12m5-7l-5-5l-5 5m14 7v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\" />\n</svg>\n";
3636
+ //#endregion
3637
+ //#region src/client/assets/import.svg
3638
+ var import_default = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\">\n <path d=\"M0 0h24v24H0z\" fill=\"none\" />\n <g fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\">\n <path d=\"M12 15V3m9 12v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\" />\n <path d=\"m7 10l5 5l5-5\" />\n </g>\n</svg>\n";
3639
+ //#endregion
3640
+ //#region src/client/assets/plus.svg
3641
+ var plus_default = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\">\n <path d=\"M0 0h24v24H0z\" fill=\"none\" />\n <path fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 12h14m-7-7v14\" />\n</svg>\n";
3642
+ //#endregion
3643
+ //#region src/client/assets/theme.svg
3644
+ var theme_default = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\">\n <path d=\"M0 0h24v24H0z\" fill=\"none\" />\n <g fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\">\n <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\" />\n <circle cx=\"13.5\" cy=\"6.5\" r=\".5\" fill=\"currentColor\" />\n <circle cx=\"17.5\" cy=\"10.5\" r=\".5\" fill=\"currentColor\" />\n <circle cx=\"6.5\" cy=\"12.5\" r=\".5\" fill=\"currentColor\" />\n <circle cx=\"8.5\" cy=\"7.5\" r=\".5\" fill=\"currentColor\" />\n </g>\n</svg>\n";
3645
+ //#endregion
3646
+ //#region src/client/icons.tsx
3647
+ /**
3648
+ * The plugin's own icon set.
3649
+ *
3650
+ * Every glyph lives in `assets/` as an SVG, and that file is the source of
3651
+ * truth for its artwork: `tsdown` inlines it as text (the `loader` map in
3652
+ * tsdown.config.ts, described by assets.d.ts), and the wrapper below narrows the
3653
+ * markup to the same `{ size, className }` surface the shell's icons expose, so
3654
+ * a call site swaps one for the other without touching its layout.
3655
+ *
3656
+ * Two properties of the artwork the wrapper keeps: the strokes draw in
3657
+ * `currentColor`, so whichever control owns an icon goes on painting it — hover,
3658
+ * disabled and the danger face included — with no CSS here; and React owns the
3659
+ * `<svg>` root, so `size` stays a real prop instead of riding on the asset's own
3660
+ * `1em` box.
3661
+ */
3662
+ /**
3663
+ * Splits the root `<svg>` off an asset. The halves are needed separately because
3664
+ * React draws the root — that is where `size` and `className` land — while the
3665
+ * inside ships exactly as the file wrote it.
3666
+ *
3667
+ * A source that is not one `<svg>` carrying a `viewBox` is a mistake in this repo
3668
+ * and not something a user can reach, so it throws instead of quietly drawing an
3669
+ * empty box.
3670
+ */
3671
+ function parseAsset(source, file) {
3672
+ const match = /<svg\b[^>]*\bviewBox="([^"]+)"[^>]*>([\s\S]*)<\/svg>/.exec(source);
3673
+ if (match === null) throw new Error(`dsh-cool-theme: assets/${file} is not a single <svg> with a viewBox`);
3674
+ return {
3675
+ viewBox: match[1],
3676
+ inner: match[2]
3677
+ };
3678
+ }
3679
+ /**
3680
+ * Builds one icon component from an asset.
3681
+ *
3682
+ * The inner markup is this repo's own build-time text, never user input — which
3683
+ * is what makes `dangerouslySetInnerHTML` safe here, and the only way to keep the
3684
+ * paths byte-identical to the set's own drawing, `stroke-width` and all.
3685
+ */
3686
+ function icon(displayName, file, source) {
3687
+ const { viewBox, inner } = parseAsset(source, file);
3688
+ const Icon = ({ size = 16, className }) => react.createElement("svg", {
3689
+ xmlns: "http://www.w3.org/2000/svg",
3690
+ width: size,
3691
+ height: size,
3692
+ viewBox,
3693
+ className,
3694
+ "aria-hidden": "true",
3695
+ dangerouslySetInnerHTML: { __html: inner }
3696
+ });
3697
+ Icon.displayName = displayName;
3698
+ return Icon;
3699
+ }
3700
+ const IconDelete = icon("IconDelete", "delete.svg", delete_default);
3701
+ const IconEdit = icon("IconEdit", "edit.svg", edit_default);
3702
+ const IconExport = icon("IconExport", "export.svg", export_default);
3703
+ const IconImport = icon("IconImport", "import.svg", import_default);
3704
+ const IconPlus = icon("IconPlus", "plus.svg", plus_default);
3705
+ /** The palette is the one glyph whose seat belongs to the shell; see below. */
3706
+ const THEME_ASSET = parseAsset(theme_default, "theme.svg");
3707
+ const THEME_NAV_ICON_SIZE = 16;
3708
+ /**
3709
+ * The palette as markup instead of as a component, because its only consumer
3710
+ * patches shell DOM rather than rendering it: `patchThemeNavIcon` in theme.tsx
3711
+ * hides the gear the settings nav draws for an unknown section and writes this
3712
+ * into the seat beside it. The string therefore travels whole, with width and
3713
+ * height pinned to the shell's 16px icon slot — the asset's own `1em` would
3714
+ * resolve against whatever font the nav happens to carry.
3715
+ */
3716
+ const THEME_NAV_MARKUP = `<svg xmlns="http://www.w3.org/2000/svg" width="${THEME_NAV_ICON_SIZE}" height="${THEME_NAV_ICON_SIZE}" viewBox="${THEME_ASSET.viewBox}" aria-hidden="true">${THEME_ASSET.inner}</svg>`;
3717
+ //#endregion
3232
3718
  //#region src/client/components.tsx
3233
3719
  /**
3234
3720
  * Theme settings UI: scheme selector, preset picker, and the custom colour editor.
@@ -3244,11 +3730,61 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
3244
3730
  */
3245
3731
  const SEED_MODE_CONTROL_ID = "ct-seed-mode";
3246
3732
  /**
3247
- * The one glyph size this panel draws at. Every icon here is explicit about it
3248
- * because the shell's artwork does not default uniformly — the chevron falls
3249
- * back to 14, the edit/share/trash/add/import glyphs to 16 — so passing no size
3250
- * silently picks a different scale per icon. 14 is what the shell's own
3251
- * settings pages pass.
3733
+ * Key of the border swatch in the shared colour popover. It is the one seed that
3734
+ * is not a plain fill — it carries an opacity too — which is why its picker is
3735
+ * the alpha-aware one and one control taller (see `seedPickerHeight`).
3736
+ */
3737
+ const BORDER_SEED_KEY = "border";
3738
+ /**
3739
+ * The colour popover's box. It comes in two heights: the picker itself is a
3740
+ * saturation panel plus a hue slider, and the border seed's picker carries an
3741
+ * opacity slider under those. These are estimates for choosing which side of
3742
+ * the swatch to open on — the popover is `position:fixed`, so being taller than
3743
+ * estimated only means it hangs higher.
3744
+ */
3745
+ const PICKER_WIDTH = 224;
3746
+ const PICKER_HEIGHT = 240;
3747
+ /** One more 24px control in the picker, one more 24px of popover. */
3748
+ const PICKER_HEIGHT_WITH_ALPHA = 264;
3749
+ const PICKER_GAP = 8;
3750
+ const PICKER_MARGIN = 8;
3751
+ /**
3752
+ * The alpha checkerboard behind a translucent swatch. Both squares are the same
3753
+ * grey at different alphas, so the pattern reads on a light and a dark panel
3754
+ * alike without needing a colour per appearance.
3755
+ */
3756
+ const ALPHA_CHECKERBOARD = "repeating-conic-gradient(rgba(128, 128, 128, .45) 0% 25%, rgba(128, 128, 128, .18) 0% 50%)";
3757
+ /**
3758
+ * The picker carries opacity as an 8-bit hex channel while a seed keeps it as a
3759
+ * whole percentage, so the two have to be converted at the popover's edge. A
3760
+ * byte is finer than one percent, so every percentage a seed can hold survives
3761
+ * the round trip unchanged.
3762
+ */
3763
+ const opacityToByte = (percent) => Math.round(percent / 100 * 255);
3764
+ const byteToOpacity = (byte) => Math.round(parseInt(byte, 16) / 255 * 100);
3765
+ /**
3766
+ * A seed as the picker reads it: `#RRGGBB`, plus `#RRGGBBAA` when there is an
3767
+ * opacity to carry. An opaque colour stays six digits, which is the form the
3768
+ * picker itself hands back.
3769
+ */
3770
+ function withAlpha(hex, alpha) {
3771
+ if (alpha >= 100) return hex;
3772
+ return `${hex}${opacityToByte(alpha).toString(16).padStart(2, "0").toUpperCase()}`;
3773
+ }
3774
+ /** A picker value back in seed shape: the opaque colour and its opacity. */
3775
+ function splitAlpha(value) {
3776
+ const hex = value.replace(/^#/, "");
3777
+ return {
3778
+ color: `#${hex.slice(0, 6)}`.toUpperCase(),
3779
+ alpha: hex.length >= 8 ? byteToOpacity(hex.slice(6, 8)) : 100
3780
+ };
3781
+ }
3782
+ /**
3783
+ * The one glyph size this panel draws at, passed explicitly by every call site.
3784
+ * The two halves of a row disagree on their own: the shell's chevron falls back
3785
+ * to 14 while this plugin's icons default to 16, so leaving it implicit would
3786
+ * silently mix two scales in the same panel. 14 is what the shell's own settings
3787
+ * pages pass.
3252
3788
  */
3253
3789
  const ICON_SIZE = 14;
3254
3790
  /**
@@ -3269,11 +3805,16 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
3269
3805
  function sameRamp(a, b) {
3270
3806
  return a?.lightest === b?.lightest && a?.darkest === b?.darkest;
3271
3807
  }
3272
- /** Structural equality over all 30 seeds, ignoring key order. */
3808
+ /** Structural equality of one border seed: both its channels have to match. */
3809
+ function sameBorder(a, b) {
3810
+ return a?.color === b?.color && a?.alpha === b?.alpha;
3811
+ }
3812
+ /** Structural equality over every seed, ignoring key order. */
3273
3813
  function sameSeeds(a, b) {
3274
3814
  if (!sameRamp(a.neutral?.light, b.neutral?.light) || !sameRamp(a.neutral?.dark, b.neutral?.dark)) return false;
3275
3815
  if (!samePair(a.accent, b.accent) || !samePair(a.green, b.green)) return false;
3276
3816
  if (!samePair(a.amber, b.amber) || !samePair(a.red, b.red)) return false;
3817
+ if (!sameBorder(a.border?.light, b.border?.light) || !sameBorder(a.border?.dark, b.border?.dark)) return false;
3277
3818
  return SHIKI_KEYS.every((k) => samePair(a.shiki?.[k], b.shiki?.[k]));
3278
3819
  }
3279
3820
  /** The two roles one appearance's neutral ramp fills, in display order. */
@@ -3422,7 +3963,7 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
3422
3963
  }, entry.name)), react.createElement("div", {
3423
3964
  className: "ct-list-actions",
3424
3965
  onClick: (e) => e.stopPropagation()
3425
- }, active ? react.createElement("span", { className: "ct-list-badge" }, t("custom.inUse")) : null, action(t("custom.edit"), react.createElement(_deepseek_ai_dsh_client_ui_primitives.IconEditOutlineRegular, { size: ICON_SIZE }), onEdit, { leave: true }), action(t("custom.export"), react.createElement(_deepseek_ai_dsh_client_ui_primitives.IconShareOutlineRegular, { size: ICON_SIZE }), onExport), action(t("custom.delete"), react.createElement(_deepseek_ai_dsh_client_ui_primitives.IconTrashOutlineRegular, { size: ICON_SIZE }), onDelete, { danger: true })));
3966
+ }, active ? react.createElement("span", { className: "ct-list-badge" }, t("custom.inUse")) : null, action(t("custom.edit"), react.createElement(IconEdit, { size: ICON_SIZE }), onEdit, { leave: true }), action(t("custom.export"), react.createElement(IconExport, { size: ICON_SIZE }), onExport), action(t("custom.delete"), react.createElement(IconDelete, { size: ICON_SIZE }), onDelete, { danger: true })));
3426
3967
  }
3427
3968
  /**
3428
3969
  * Which appearance the colour dots edit. `system` resolves through the same
@@ -3438,7 +3979,7 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
3438
3979
  }
3439
3980
  }
3440
3981
  function ThemePanel(props) {
3441
- const { theme, getSelection, setSelection, getCustom, setCustom, setCustomBase, resetCustom, saved, t, onRequestClose } = props;
3982
+ const { theme, getSelection, setSelection, getCustom, setCustom, setCustomBase, resetCustom, saved, getSidebarTransparent, setSidebarTransparent, t, onRequestClose } = props;
3442
3983
  let initScheme = "system";
3443
3984
  try {
3444
3985
  const snap = theme?.getTheme();
@@ -3457,6 +3998,7 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
3457
3998
  });
3458
3999
  const [list, setList] = react.useState(() => saved.list());
3459
4000
  const [activeId, setActiveId] = react.useState(() => saved.activeId());
4001
+ const [sidebarTransparent, setSidebarTransparentState] = react.useState(() => getSidebarTransparent());
3460
4002
  const [pendingDelete, setPendingDelete] = react.useState(null);
3461
4003
  const [importing, setImporting] = react.useState(false);
3462
4004
  /** The hidden picker the import button opens. */
@@ -3497,29 +4039,40 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
3497
4039
  const pickerAnchorRef = react.useRef(null);
3498
4040
  /** The popover node itself; outside-pointerdown detection needs its bounds. */
3499
4041
  const pickerPopRef = react.useRef(null);
3500
- /** Viewport coordinates that put the popover beside its anchor swatch. */
3501
- function placePicker(anchor) {
3502
- const WIDTH = 224;
3503
- const HEIGHT = 240;
3504
- const GAP = 8;
3505
- const MARGIN = 8;
4042
+ /**
4043
+ * Height of the box the open popover was placed with, captured beside its
4044
+ * anchor. The two popovers differ by one control, and a scroll or resize has
4045
+ * to rebuild the same box; the clicked dot is not in that scope, so its own
4046
+ * fact travels here rather than being re-derived from the dot's key.
4047
+ */
4048
+ const pickerHeightRef = react.useRef(PICKER_HEIGHT);
4049
+ /**
4050
+ * Viewport coordinates that put the popover beside its anchor swatch. The
4051
+ * caller passes the height of the box being opened, because the border seed's
4052
+ * popover carries one more row than every other seed's.
4053
+ */
4054
+ function placePicker(anchor, height) {
3506
4055
  const rect = anchor.getBoundingClientRect();
3507
4056
  const viewW = window.innerWidth;
3508
4057
  const viewH = window.innerHeight;
3509
4058
  return {
3510
- left: Math.min(Math.max(MARGIN, rect.right - WIDTH), Math.max(MARGIN, viewW - WIDTH - MARGIN)),
3511
- top: rect.bottom + GAP + HEIGHT <= viewH - MARGIN ? rect.bottom + GAP : Math.max(MARGIN, rect.top - GAP - HEIGHT)
4059
+ left: Math.min(Math.max(PICKER_MARGIN, rect.right - PICKER_WIDTH), Math.max(PICKER_MARGIN, viewW - PICKER_WIDTH - PICKER_MARGIN)),
4060
+ top: rect.bottom + PICKER_GAP + height <= viewH - PICKER_MARGIN ? rect.bottom + PICKER_GAP : Math.max(PICKER_MARGIN, rect.top - PICKER_GAP - height)
3512
4061
  };
3513
4062
  }
3514
- /** Toggle one seed's popover; `anchor` is the swatch that was clicked. */
3515
- function togglePicker(key, anchor) {
4063
+ /**
4064
+ * Toggle one seed's popover; `anchor` is the swatch that was clicked and
4065
+ * `height` the height of the box it opens.
4066
+ */
4067
+ function togglePicker(key, anchor, height) {
3516
4068
  if (pickerDotKey === key) {
3517
4069
  closePicker();
3518
4070
  return;
3519
4071
  }
3520
4072
  pickerAnchorRef.current = anchor;
4073
+ pickerHeightRef.current = height;
3521
4074
  setPickerDotKey(key);
3522
- setPickerPos(placePicker(anchor));
4075
+ setPickerPos(placePicker(anchor, height));
3523
4076
  }
3524
4077
  function closePicker() {
3525
4078
  setPickerDotKey(null);
@@ -3544,7 +4097,7 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
3544
4097
  const anchor = pickerAnchorRef.current;
3545
4098
  if (!anchor) return;
3546
4099
  cancelAnimationFrame(raf);
3547
- raf = requestAnimationFrame(() => setPickerPos(placePicker(anchor)));
4100
+ raf = requestAnimationFrame(() => setPickerPos(placePicker(anchor, pickerHeightRef.current)));
3548
4101
  };
3549
4102
  window.addEventListener("scroll", update, true);
3550
4103
  window.addEventListener("resize", update);
@@ -3553,7 +4106,7 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
3553
4106
  window.removeEventListener("resize", update);
3554
4107
  cancelAnimationFrame(raf);
3555
4108
  };
3556
- }, [pickerOpen]);
4109
+ }, [pickerOpen, pickerDotKey]);
3557
4110
  /** Re-read both the list and the active pointer after any list mutation. */
3558
4111
  function syncSaved() {
3559
4112
  setList(saved.list());
@@ -3916,6 +4469,17 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
3916
4469
  onSelect: (v) => pickPreset(v),
3917
4470
  disabled: customOn
3918
4471
  })),
4472
+ react.createElement("div", {
4473
+ className: "ct-row",
4474
+ key: "sidebar-transparency"
4475
+ }, react.createElement("div", { className: "ct-row-main" }, react.createElement("div", { className: "ct-row-title" }, t("sidebar.title")), react.createElement("div", { className: "ct-row-desc" }, t("sidebar.desc"))), react.createElement(_deepseek_ai_dsh_client_ui_primitives.Switch, {
4476
+ checked: sidebarTransparent,
4477
+ label: t("sidebar.title"),
4478
+ onChange: (on) => {
4479
+ setSidebarTransparentState(on);
4480
+ setSidebarTransparent(on);
4481
+ }
4482
+ })),
3919
4483
  react.createElement("div", {
3920
4484
  className: "ct-row ct-row-flush",
3921
4485
  key: "custom-toggle"
@@ -3926,6 +4490,12 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
3926
4490
  }))
3927
4491
  ];
3928
4492
  if (customOn) {
4493
+ /**
4494
+ * Height of the box one seed's dot opens. Only the border seed's picker
4495
+ * carries an opacity slider, and that seed is the one that declares it, so
4496
+ * the height follows `SeedDot.alpha` rather than a name.
4497
+ */
4498
+ const seedPickerHeight = (dot) => dot.alpha ? PICKER_HEIGHT_WITH_ALPHA : PICKER_HEIGHT;
3929
4499
  /** A group row: its name on the left, every seed's dot on the right. */
3930
4500
  const dotRow = (key, label, dots) => react.createElement("div", {
3931
4501
  className: "ct-seed-row",
@@ -3938,11 +4508,11 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
3938
4508
  children: react.createElement("button", {
3939
4509
  type: "button",
3940
4510
  className: "ct-seed-dot",
3941
- style: { background: d.value },
4511
+ style: d.swatch ?? { background: d.value },
3942
4512
  "aria-haspopup": "dialog",
3943
4513
  "aria-expanded": pickerDotKey === d.key,
3944
4514
  "aria-label": `${d.name} — ${d.hint}`,
3945
- onClick: (e) => togglePicker(d.key, e.currentTarget)
4515
+ onClick: (e) => togglePicker(d.key, e.currentTarget, seedPickerHeight(d))
3946
4516
  })
3947
4517
  }))));
3948
4518
  const pair = (key, title, desc) => ({
@@ -3977,9 +4547,46 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
3977
4547
  })
3978
4548
  };
3979
4549
  });
4550
+ /**
4551
+ * The border swatch. It is the one seed that is an overlay rather than a
4552
+ * fill, so it is also the only one whose picker carries an opacity slider
4553
+ * (`SeedDot.alpha`) and whose swatch previews that opacity over a
4554
+ * checkerboard. DSH's whole border ladder follows from it: l1 is the seed
4555
+ * and every stronger level is derived while the palette is built.
4556
+ */
4557
+ const borderDot = () => {
4558
+ const seed = custom.border[seedMode];
4559
+ const set = (next) => editCustom({
4560
+ ...custom,
4561
+ border: {
4562
+ ...custom.border,
4563
+ [seedMode]: {
4564
+ ...seed,
4565
+ ...next
4566
+ }
4567
+ }
4568
+ });
4569
+ const rgba = borderCss(seed);
4570
+ return {
4571
+ key: BORDER_SEED_KEY,
4572
+ name: t("custom.border.title"),
4573
+ hint: `${t("custom.border.desc")} — ${seed.alpha}%`,
4574
+ value: seed.color,
4575
+ onChange: (v) => set({ color: v }),
4576
+ alpha: {
4577
+ value: seed.alpha,
4578
+ onPicked: (v) => set(splitAlpha(v))
4579
+ },
4580
+ swatch: {
4581
+ backgroundImage: `linear-gradient(${rgba}, ${rgba}), ${ALPHA_CHECKERBOARD}`,
4582
+ backgroundSize: "auto, 10px 10px"
4583
+ }
4584
+ };
4585
+ };
3980
4586
  const seedDots = [
3981
4587
  pair("accent", "custom.accent.title", "custom.accent.desc"),
3982
4588
  ...neutralDots,
4589
+ borderDot(),
3983
4590
  pair("green", "custom.green.title", "custom.green.desc"),
3984
4591
  pair("amber", "custom.amber.title", "custom.amber.desc"),
3985
4592
  pair("red", "custom.red.title", "custom.red.desc")
@@ -4043,17 +4650,18 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
4043
4650
  onClick: () => void saveEdit()
4044
4651
  }, t("custom.save")));
4045
4652
  const pickerDot = pickerDotKey ? [...seedDots, ...shikiDots].find((d) => d.key === pickerDotKey) ?? null : null;
4653
+ const pickerAlpha = pickerDot?.alpha;
4046
4654
  const pickerPop = pickerDot && pickerPos ? react.createElement("div", {
4047
4655
  key: "color-picker",
4048
- className: "ct-color-pop",
4656
+ className: pickerAlpha ? "ct-color-pop ct-color-pop-alpha" : "ct-color-pop",
4049
4657
  ref: pickerPopRef,
4050
4658
  style: {
4051
4659
  left: pickerPos.left,
4052
4660
  top: pickerPos.top
4053
4661
  }
4054
- }, react.createElement(Z, {
4055
- color: pickerDot.value,
4056
- onChange: (v) => pickerDot.onChange(v.toUpperCase())
4662
+ }, react.createElement(pickerAlpha ? te : Z, {
4663
+ color: pickerAlpha ? withAlpha(pickerDot.value, pickerAlpha.value) : pickerDot.value,
4664
+ onChange: pickerAlpha ? pickerAlpha.onPicked : (v) => pickerDot.onChange(v.toUpperCase())
4057
4665
  }), react.createElement(Oe, {
4058
4666
  className: "ct-hex-input",
4059
4667
  color: pickerDot.value,
@@ -4093,13 +4701,13 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
4093
4701
  className: "ct-add-btn",
4094
4702
  disabled: importing,
4095
4703
  onClick: startImport
4096
- }, react.createElement(_deepseek_ai_dsh_client_ui_primitives.IconDownloadOutlineRegular, { size: ICON_SIZE }), t("custom.import")), react.createElement("button", {
4704
+ }, react.createElement(IconImport, { size: ICON_SIZE }), t("custom.import")), react.createElement("button", {
4097
4705
  key: "add",
4098
4706
  type: "button",
4099
4707
  className: "ct-add-btn",
4100
4708
  disabled: editing !== null,
4101
4709
  onClick: startAdd
4102
- }, react.createElement(_deepseek_ai_dsh_client_ui_primitives.IconPlusOutlineRegular, { size: ICON_SIZE }), t("custom.add")), react.createElement("input", {
4710
+ }, react.createElement(IconPlus, { size: ICON_SIZE }), t("custom.add")), react.createElement("input", {
4103
4711
  key: "import-input",
4104
4712
  ref: importInputRef,
4105
4713
  type: "file",
@@ -4175,6 +4783,8 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
4175
4783
  "presets.title": "预设主题",
4176
4784
  "presets.desc": "一键套用主流配色方案",
4177
4785
  "presets.disabled": "自定义主题已启用,关闭后才能选择预设",
4786
+ "sidebar.title": "侧边栏透明",
4787
+ "sidebar.desc": "侧边栏面板透明效果,仅 Mac 端生效",
4178
4788
  "scheme.light": "浅色",
4179
4789
  "scheme.dark": "深色",
4180
4790
  "scheme.system": "跟随系统",
@@ -4197,6 +4807,8 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
4197
4807
  "custom.amber.desc": "警告状态",
4198
4808
  "custom.red.title": "错误",
4199
4809
  "custom.red.desc": "错误状态",
4810
+ "custom.border.title": "边框",
4811
+ "custom.border.desc": "分隔线与描边的颜色和透明度;较强的边框会自动加深",
4200
4812
  "custom.shiki.desc": "9 个语法标记的颜色",
4201
4813
  "custom.seed.light": "浅色",
4202
4814
  "custom.seed.dark": "深色",
@@ -4248,6 +4860,8 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
4248
4860
  "presets.title": "Preset themes",
4249
4861
  "presets.desc": "Apply popular color schemes in one click",
4250
4862
  "presets.disabled": "Custom theme is on — turn it off to choose a preset",
4863
+ "sidebar.title": "Transparent sidebar",
4864
+ "sidebar.desc": "Sidebar panel transparency effect, macOS only",
4251
4865
  "scheme.light": "Light",
4252
4866
  "scheme.dark": "Dark",
4253
4867
  "scheme.system": "System",
@@ -4270,6 +4884,8 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
4270
4884
  "custom.amber.desc": "Warning states",
4271
4885
  "custom.red.title": "Error",
4272
4886
  "custom.red.desc": "Error states",
4887
+ "custom.border.title": "Border",
4888
+ "custom.border.desc": "Color and opacity for separators and outlines; the stronger border is derived automatically",
4273
4889
  "custom.shiki.desc": "Colors for the nine syntax tokens",
4274
4890
  "custom.seed.light": "Light",
4275
4891
  "custom.seed.dark": "Dark",
@@ -4336,6 +4952,8 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
4336
4952
  ".ct-seed-dot:focus-visible{outline:none;box-shadow:0 0 0 2px var(--dsw-alias-border-l3);}",
4337
4953
  ".ct-color-pop{position:fixed;z-index:1200;box-sizing:border-box;width:224px;padding:12px;border:1px solid var(--dsw-alias-border-l4);border-radius:var(--dsw-radius-lg);background:var(--dsw-alias-bg-layer-2);box-shadow:var(--dsw-shadow-lv3);display:flex;flex-direction:column;gap:10px;}",
4338
4954
  ".ct-color-pop .react-colorful{width:198px;height:170px;}",
4955
+ ".ct-color-pop-alpha .react-colorful{height:194px;}",
4956
+ ".ct-color-pop .react-colorful__pointer{width:24px;height:24px;}",
4339
4957
  ".ct-color-pop .ct-hex-input{box-sizing:border-box;width:100%;height:34px;padding:0 10px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;line-height:20px;text-align:center;outline:none;}",
4340
4958
  ".ct-color-pop .ct-hex-input:focus{border-color:var(--dsw-alias-border-l3);}",
4341
4959
  ".ct-list{margin-top:4px;display:flex;flex-direction:column;gap:8px;}",
@@ -4434,6 +5052,25 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
4434
5052
  else localStorage.setItem(key, value);
4435
5053
  } catch {}
4436
5054
  }
5055
+ const SIDEBAR_OPAQUE_CSS = "html[data-platform=\"darwin\"] [class*=\"sidebarCol\"]:not([class*=\"sidebarCollapsed\"]){background:var(--dsw-specific-sidebar-fill) !important;}";
5056
+ let sidebarOpaqueDisposer = null;
5057
+ /**
5058
+ * Whether the sidebar keeps DSH's translucency. Only an explicit '1' turns it
5059
+ * on: an absent key means opaque, which is the state a fresh install starts in.
5060
+ */
5061
+ function sidebarTransparent() {
5062
+ return readRaw(SIDEBAR_TRANSPARENT_STORAGE_KEY) === "1";
5063
+ }
5064
+ /** Paint the sidebar column opaque (on), or hand it back to DSH (off). */
5065
+ function applySidebarOpaque(opaque) {
5066
+ if (opaque) {
5067
+ if (!sidebarOpaqueDisposer) sidebarOpaqueDisposer = injector.insert(SIDEBAR_OPAQUE_CSS);
5068
+ } else if (sidebarOpaqueDisposer) sidebarOpaqueDisposer = release(sidebarOpaqueDisposer);
5069
+ }
5070
+ function setSidebarTransparent(on) {
5071
+ writeRaw(SIDEBAR_TRANSPARENT_STORAGE_KEY, on ? "1" : "0");
5072
+ applySidebarOpaque(!on);
5073
+ }
4437
5074
  function getSelection() {
4438
5075
  for (const key of [THEME_STORAGE_KEY, THEME_STORAGE_KEY_LEGACY]) {
4439
5076
  const cur = readRaw(key);
@@ -4657,6 +5294,7 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
4657
5294
  if (env && isPresetId(env.base)) lastPreset = env.base;
4658
5295
  }
4659
5296
  apply(initialSelection);
5297
+ applySidebarOpaque(!sidebarTransparent());
4660
5298
  ctx.effect(() => () => {
4661
5299
  disposed = true;
4662
5300
  cancelApply();
@@ -4664,6 +5302,7 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
4664
5302
  baselineDisposer = release(baselineDisposer);
4665
5303
  overrideDispose = release(overrideDispose);
4666
5304
  fallbackDispose = release(fallbackDispose);
5305
+ sidebarOpaqueDisposer = release(sidebarOpaqueDisposer);
4667
5306
  injector.disposeAll();
4668
5307
  });
4669
5308
  slots.inject("settings.section", () => {
@@ -4684,10 +5323,11 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
4684
5323
  setCustomBase,
4685
5324
  resetCustom,
4686
5325
  saved,
5326
+ getSidebarTransparent: sidebarTransparent,
5327
+ setSidebarTransparent,
4687
5328
  onRequestClose: typeof props?.close === "function" ? props.close : null
4688
5329
  }));
4689
5330
  });
4690
- 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>";
4691
5331
  let paletteObs = null;
4692
5332
  function patchThemeNavIcon() {
4693
5333
  if (typeof document === "undefined") return;
@@ -4715,7 +5355,7 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
4715
5355
  holder.style.display = "inline-flex";
4716
5356
  holder.style.flex = "none";
4717
5357
  holder.setAttribute("aria-hidden", "true");
4718
- holder.innerHTML = PALETTE_SVG;
5358
+ holder.innerHTML = THEME_NAV_MARKUP;
4719
5359
  svg.parentNode?.insertBefore(holder, svg.nextSibling);
4720
5360
  }
4721
5361
  }