dsh-cool-theme 0.7.0 → 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
@@ -250,7 +250,7 @@ window.__ModuleLoader__.load({
250
250
  return map;
251
251
  }
252
252
  /** Mix two colors linearly in RGB. weight 0 -> colorA, 1 -> colorB */
253
- function mix(colorA, colorB, weight) {
253
+ function mix$1(colorA, colorB, weight) {
254
254
  const a = hexToRgb(colorA);
255
255
  const b = hexToRgb(colorB);
256
256
  const w = Math.max(0, Math.min(1, weight));
@@ -321,7 +321,7 @@ window.__ModuleLoader__.load({
321
321
  for (const step of steps) {
322
322
  const w = weights[step];
323
323
  if (w === void 0) continue;
324
- 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);
325
325
  map[`${prefix}-${step}`] = color;
326
326
  }
327
327
  return map;
@@ -354,7 +354,7 @@ window.__ModuleLoader__.load({
354
354
  ...buildGreenScale(opts.green),
355
355
  ...buildAmberScale(opts.amber),
356
356
  ...buildRedScale(opts.red),
357
- "--dsw-static-deepseek-700-delete": mix("#000000", opts.deepseek, .7)
357
+ "--dsw-static-deepseek-700-delete": mix$1("#000000", opts.deepseek, .7)
358
358
  };
359
359
  }
360
360
  //#endregion
@@ -2189,6 +2189,226 @@ window.__ModuleLoader__.load({
2189
2189
  return out;
2190
2190
  }
2191
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
2192
2412
  //#region src/client/css/shiki.ts
2193
2413
  /**
2194
2414
  * Shiki token palette: values behind --shiki-* custom properties emitted by
@@ -2254,15 +2474,24 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
2254
2474
  }
2255
2475
  };
2256
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
+ */
2257
2483
  function buildOverrides(src) {
2258
2484
  const resolved = resolvePreset(src);
2259
2485
  if (!resolved) return {};
2260
- return primitiveOverrides(resolved.light, resolved.dark);
2486
+ return {
2487
+ ...primitiveOverrides(resolved.light, resolved.dark),
2488
+ ...accentOverrides(resolved.light, resolved.dark)
2489
+ };
2261
2490
  }
2262
2491
  function buildFullCssFallback(src) {
2263
2492
  const resolved = resolvePreset(src);
2264
2493
  if (!resolved) return "";
2265
- return buildPrimitivesCss(resolved.light, resolved.dark);
2494
+ return `${buildPrimitivesCss(resolved.light, resolved.dark)}\n\n${buildAccentCss(resolved.light, resolved.dark)}`;
2266
2495
  }
2267
2496
  //#endregion
2268
2497
  //#region src/client/style-injector.ts
@@ -2459,6 +2688,10 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
2459
2688
  var r = y(e);
2460
2689
  return "hsl(" + r.h + ", " + r.s + "%, " + r.l + "%)";
2461
2690
  };
2691
+ var q = function(e) {
2692
+ var r = y(e);
2693
+ return "hsla(" + r.h + ", " + r.s + "%, " + r.l + "%, " + r.a + ")";
2694
+ };
2462
2695
  var I = function(e) {
2463
2696
  var r = e.h, n = e.s, t = e.v, o = e.a;
2464
2697
  r = r / 360 * 6, n /= 100, t /= 100;
@@ -2672,6 +2905,67 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
2672
2905
  var Z = function(r) {
2673
2906
  return react.default.createElement(J, l({}, r, { colorModel: Q }));
2674
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
+ };
2675
2969
  var ke = /^#?([0-9A-F]{3,8})$/i;
2676
2970
  var qe = function(r) {
2677
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, [
@@ -2770,11 +3064,51 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
2770
3064
  const HEX = /^#([0-9a-f]{3,8})$/i;
2771
3065
  const RGB_FN = /^rgba?\(([^)]*)\)$/i;
2772
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
+ };
2773
3105
  function isRecord$1(value) {
2774
3106
  return typeof value === "object" && value !== null && !Array.isArray(value);
2775
3107
  }
2776
3108
  const clamp255 = (n) => Math.max(0, Math.min(255, Math.round(n)));
2777
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)));
2778
3112
  /** Accepts both `a, b, c` and the modern `a b c / d` argument syntax. */
2779
3113
  function fnArgs(body) {
2780
3114
  return body.replace(/\//g, " ").split(/[\s,]+/).filter(Boolean);
@@ -2803,8 +3137,9 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
2803
3137
  * Parse a colour into `#RRGGBB`, or null when it is not a format we understand.
2804
3138
  *
2805
3139
  * Accepted: `#RGB`, `#RGBA`, `#RRGGBB`, `#RRGGBBAA`, `rgb()`/`rgba()` with
2806
- * numbers or percentages, and `hsl()`/`hsla()`. Alpha is parsed but dropped —
2807
- * 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.
2808
3143
  *
2809
3144
  * `primitives.ts` stores `rgb(r, g, b)` while presets store hex, so both shapes
2810
3145
  * have to work; the extra forms are there for what users may type.
@@ -2849,6 +3184,38 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
2849
3184
  function toHex(input) {
2850
3185
  return tryToHex(input) ?? "#000000";
2851
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
+ }
2852
3219
  function readSeed(map, fallback, key) {
2853
3220
  return toHex(map[key] ?? fallback[key]);
2854
3221
  }
@@ -2889,14 +3256,22 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
2889
3256
  green: pair("--dsw-static-green-500"),
2890
3257
  amber: pair("--dsw-static-amber-500"),
2891
3258
  red: pair("--dsw-static-red-500"),
3259
+ border: {
3260
+ light: { ...DEFAULT_BORDER.light },
3261
+ dark: { ...DEFAULT_BORDER.dark }
3262
+ },
2892
3263
  shiki
2893
3264
  };
2894
3265
  }
2895
- /** v1 carried one neutral ramp shared by both appearances. */
2896
- 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
+ ];
2897
3272
  function encodeCustom(base, theme) {
2898
3273
  return JSON.stringify({
2899
- v: 2,
3274
+ v: 3,
2900
3275
  base,
2901
3276
  theme
2902
3277
  });
@@ -2907,9 +3282,10 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
2907
3282
  try {
2908
3283
  const parsed = JSON.parse(raw);
2909
3284
  if (!parsed || typeof parsed !== "object") return null;
2910
- 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;
2911
3287
  return {
2912
- v: 2,
3288
+ v: 3,
2913
3289
  base: String(parsed.base ?? ""),
2914
3290
  theme: parsed.theme
2915
3291
  };
@@ -2952,6 +3328,13 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
2952
3328
  darkest: pick(v.darkest, fb.darkest)
2953
3329
  };
2954
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
+ };
2955
3338
  const shikiSrc = src.shiki ?? {};
2956
3339
  const shiki = SHIKI_KEYS.reduce((acc, k) => {
2957
3340
  acc[k] = pickPair(shikiSrc[k], fallback.shiki[k]);
@@ -2967,6 +3350,10 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
2967
3350
  green: pickPair(src.green, fallback.green),
2968
3351
  amber: pickPair(src.amber, fallback.amber),
2969
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
+ },
2970
3357
  shiki
2971
3358
  };
2972
3359
  }
@@ -2976,6 +3363,10 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
2976
3363
  * Unlike the presets (which ship one ramp for both appearances), the ramp is
2977
3364
  * built once per appearance, so the light text and the dark text are no longer
2978
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.
2979
3370
  */
2980
3371
  function buildCustomPreset(v) {
2981
3372
  /** One appearance's ramp, published under both prefixes DSH reads from. */
@@ -2998,16 +3389,20 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
2998
3389
  for (const k of SHIKI_KEYS) out[`--shiki-token-${k}`] = v.shiki[k][mode];
2999
3390
  return out;
3000
3391
  };
3392
+ /** Every border alias, derived from this appearance's one border seed. */
3393
+ const border = (mode) => borderTokens(mode, v.border[mode]);
3001
3394
  return {
3002
3395
  label: "Custom",
3003
3396
  light: {
3004
3397
  ...ramp("light"),
3005
3398
  ...semantic("light"),
3399
+ ...border("light"),
3006
3400
  ...shiki("light")
3007
3401
  },
3008
3402
  dark: {
3009
3403
  ...ramp("dark"),
3010
3404
  ...semantic("dark"),
3405
+ ...border("dark"),
3011
3406
  ...shiki("dark")
3012
3407
  }
3013
3408
  };
@@ -3025,8 +3420,8 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
3025
3420
  if (!raw) return [];
3026
3421
  try {
3027
3422
  const parsed = JSON.parse(raw);
3028
- const known = parsed?.v === 2 || parsed?.v === CUSTOM_SCHEMA_VERSION_LEGACY;
3029
- 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 [];
3030
3425
  const out = [];
3031
3426
  for (const item of parsed.items) {
3032
3427
  const e = item;
@@ -3230,6 +3625,96 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
3230
3625
  return legacy.filter((entry) => !known.has(entry.id));
3231
3626
  }
3232
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
3233
3718
  //#region src/client/components.tsx
3234
3719
  /**
3235
3720
  * Theme settings UI: scheme selector, preset picker, and the custom colour editor.
@@ -3245,11 +3730,61 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
3245
3730
  */
3246
3731
  const SEED_MODE_CONTROL_ID = "ct-seed-mode";
3247
3732
  /**
3248
- * The one glyph size this panel draws at. Every icon here is explicit about it
3249
- * because the shell's artwork does not default uniformly — the chevron falls
3250
- * back to 14, the edit/share/trash/add/import glyphs to 16 — so passing no size
3251
- * silently picks a different scale per icon. 14 is what the shell's own
3252
- * 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.
3253
3788
  */
3254
3789
  const ICON_SIZE = 14;
3255
3790
  /**
@@ -3270,11 +3805,16 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
3270
3805
  function sameRamp(a, b) {
3271
3806
  return a?.lightest === b?.lightest && a?.darkest === b?.darkest;
3272
3807
  }
3273
- /** 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. */
3274
3813
  function sameSeeds(a, b) {
3275
3814
  if (!sameRamp(a.neutral?.light, b.neutral?.light) || !sameRamp(a.neutral?.dark, b.neutral?.dark)) return false;
3276
3815
  if (!samePair(a.accent, b.accent) || !samePair(a.green, b.green)) return false;
3277
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;
3278
3818
  return SHIKI_KEYS.every((k) => samePair(a.shiki?.[k], b.shiki?.[k]));
3279
3819
  }
3280
3820
  /** The two roles one appearance's neutral ramp fills, in display order. */
@@ -3423,7 +3963,7 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
3423
3963
  }, entry.name)), react.createElement("div", {
3424
3964
  className: "ct-list-actions",
3425
3965
  onClick: (e) => e.stopPropagation()
3426
- }, 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 })));
3427
3967
  }
3428
3968
  /**
3429
3969
  * Which appearance the colour dots edit. `system` resolves through the same
@@ -3499,29 +4039,40 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
3499
4039
  const pickerAnchorRef = react.useRef(null);
3500
4040
  /** The popover node itself; outside-pointerdown detection needs its bounds. */
3501
4041
  const pickerPopRef = react.useRef(null);
3502
- /** Viewport coordinates that put the popover beside its anchor swatch. */
3503
- function placePicker(anchor) {
3504
- const WIDTH = 224;
3505
- const HEIGHT = 240;
3506
- const GAP = 8;
3507
- 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) {
3508
4055
  const rect = anchor.getBoundingClientRect();
3509
4056
  const viewW = window.innerWidth;
3510
4057
  const viewH = window.innerHeight;
3511
4058
  return {
3512
- left: Math.min(Math.max(MARGIN, rect.right - WIDTH), Math.max(MARGIN, viewW - WIDTH - MARGIN)),
3513
- 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)
3514
4061
  };
3515
4062
  }
3516
- /** Toggle one seed's popover; `anchor` is the swatch that was clicked. */
3517
- 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) {
3518
4068
  if (pickerDotKey === key) {
3519
4069
  closePicker();
3520
4070
  return;
3521
4071
  }
3522
4072
  pickerAnchorRef.current = anchor;
4073
+ pickerHeightRef.current = height;
3523
4074
  setPickerDotKey(key);
3524
- setPickerPos(placePicker(anchor));
4075
+ setPickerPos(placePicker(anchor, height));
3525
4076
  }
3526
4077
  function closePicker() {
3527
4078
  setPickerDotKey(null);
@@ -3546,7 +4097,7 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
3546
4097
  const anchor = pickerAnchorRef.current;
3547
4098
  if (!anchor) return;
3548
4099
  cancelAnimationFrame(raf);
3549
- raf = requestAnimationFrame(() => setPickerPos(placePicker(anchor)));
4100
+ raf = requestAnimationFrame(() => setPickerPos(placePicker(anchor, pickerHeightRef.current)));
3550
4101
  };
3551
4102
  window.addEventListener("scroll", update, true);
3552
4103
  window.addEventListener("resize", update);
@@ -3555,7 +4106,7 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
3555
4106
  window.removeEventListener("resize", update);
3556
4107
  cancelAnimationFrame(raf);
3557
4108
  };
3558
- }, [pickerOpen]);
4109
+ }, [pickerOpen, pickerDotKey]);
3559
4110
  /** Re-read both the list and the active pointer after any list mutation. */
3560
4111
  function syncSaved() {
3561
4112
  setList(saved.list());
@@ -3939,6 +4490,12 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
3939
4490
  }))
3940
4491
  ];
3941
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;
3942
4499
  /** A group row: its name on the left, every seed's dot on the right. */
3943
4500
  const dotRow = (key, label, dots) => react.createElement("div", {
3944
4501
  className: "ct-seed-row",
@@ -3951,11 +4508,11 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
3951
4508
  children: react.createElement("button", {
3952
4509
  type: "button",
3953
4510
  className: "ct-seed-dot",
3954
- style: { background: d.value },
4511
+ style: d.swatch ?? { background: d.value },
3955
4512
  "aria-haspopup": "dialog",
3956
4513
  "aria-expanded": pickerDotKey === d.key,
3957
4514
  "aria-label": `${d.name} — ${d.hint}`,
3958
- onClick: (e) => togglePicker(d.key, e.currentTarget)
4515
+ onClick: (e) => togglePicker(d.key, e.currentTarget, seedPickerHeight(d))
3959
4516
  })
3960
4517
  }))));
3961
4518
  const pair = (key, title, desc) => ({
@@ -3990,9 +4547,46 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
3990
4547
  })
3991
4548
  };
3992
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
+ };
3993
4586
  const seedDots = [
3994
4587
  pair("accent", "custom.accent.title", "custom.accent.desc"),
3995
4588
  ...neutralDots,
4589
+ borderDot(),
3996
4590
  pair("green", "custom.green.title", "custom.green.desc"),
3997
4591
  pair("amber", "custom.amber.title", "custom.amber.desc"),
3998
4592
  pair("red", "custom.red.title", "custom.red.desc")
@@ -4056,17 +4650,18 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
4056
4650
  onClick: () => void saveEdit()
4057
4651
  }, t("custom.save")));
4058
4652
  const pickerDot = pickerDotKey ? [...seedDots, ...shikiDots].find((d) => d.key === pickerDotKey) ?? null : null;
4653
+ const pickerAlpha = pickerDot?.alpha;
4059
4654
  const pickerPop = pickerDot && pickerPos ? react.createElement("div", {
4060
4655
  key: "color-picker",
4061
- className: "ct-color-pop",
4656
+ className: pickerAlpha ? "ct-color-pop ct-color-pop-alpha" : "ct-color-pop",
4062
4657
  ref: pickerPopRef,
4063
4658
  style: {
4064
4659
  left: pickerPos.left,
4065
4660
  top: pickerPos.top
4066
4661
  }
4067
- }, react.createElement(Z, {
4068
- color: pickerDot.value,
4069
- 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())
4070
4665
  }), react.createElement(Oe, {
4071
4666
  className: "ct-hex-input",
4072
4667
  color: pickerDot.value,
@@ -4106,13 +4701,13 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
4106
4701
  className: "ct-add-btn",
4107
4702
  disabled: importing,
4108
4703
  onClick: startImport
4109
- }, 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", {
4110
4705
  key: "add",
4111
4706
  type: "button",
4112
4707
  className: "ct-add-btn",
4113
4708
  disabled: editing !== null,
4114
4709
  onClick: startAdd
4115
- }, 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", {
4116
4711
  key: "import-input",
4117
4712
  ref: importInputRef,
4118
4713
  type: "file",
@@ -4212,6 +4807,8 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
4212
4807
  "custom.amber.desc": "警告状态",
4213
4808
  "custom.red.title": "错误",
4214
4809
  "custom.red.desc": "错误状态",
4810
+ "custom.border.title": "边框",
4811
+ "custom.border.desc": "分隔线与描边的颜色和透明度;较强的边框会自动加深",
4215
4812
  "custom.shiki.desc": "9 个语法标记的颜色",
4216
4813
  "custom.seed.light": "浅色",
4217
4814
  "custom.seed.dark": "深色",
@@ -4287,6 +4884,8 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
4287
4884
  "custom.amber.desc": "Warning states",
4288
4885
  "custom.red.title": "Error",
4289
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",
4290
4889
  "custom.shiki.desc": "Colors for the nine syntax tokens",
4291
4890
  "custom.seed.light": "Light",
4292
4891
  "custom.seed.dark": "Dark",
@@ -4353,6 +4952,8 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
4353
4952
  ".ct-seed-dot:focus-visible{outline:none;box-shadow:0 0 0 2px var(--dsw-alias-border-l3);}",
4354
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;}",
4355
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;}",
4356
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;}",
4357
4958
  ".ct-color-pop .ct-hex-input:focus{border-color:var(--dsw-alias-border-l3);}",
4358
4959
  ".ct-list{margin-top:4px;display:flex;flex-direction:column;gap:8px;}",
@@ -4727,7 +5328,6 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
4727
5328
  onRequestClose: typeof props?.close === "function" ? props.close : null
4728
5329
  }));
4729
5330
  });
4730
- 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>";
4731
5331
  let paletteObs = null;
4732
5332
  function patchThemeNavIcon() {
4733
5333
  if (typeof document === "undefined") return;
@@ -4755,7 +5355,7 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
4755
5355
  holder.style.display = "inline-flex";
4756
5356
  holder.style.flex = "none";
4757
5357
  holder.setAttribute("aria-hidden", "true");
4758
- holder.innerHTML = PALETTE_SVG;
5358
+ holder.innerHTML = THEME_NAV_MARKUP;
4759
5359
  svg.parentNode?.insertBefore(holder, svg.nextSibling);
4760
5360
  }
4761
5361
  }