dsh-theme-studio 0.1.0 → 0.2.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/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0
4
+
5
+ - 4 new presets: Gruvbox, Solarized, Tokyo Night, Catppuccin.
6
+ - Dark-mode aware presets via `darkTokens`; the panel watches `body[data-ds-dark-theme]`.
7
+ - Separate dark-mode accent color.
8
+ - Contrast guard: a dark accent below the WCAG luminance floor is lightened in
9
+ steps, and the panel reports that it was adjusted.
10
+ - Animation toggle (disables UI transitions).
11
+ - Theme import/export as JSON, with validation; accepts both the envelope and a
12
+ bare preferences object, and reports a real error for non-theme input.
13
+ - Unified token resolution: switching a preset off now clears the properties it
14
+ had set instead of leaving them behind.
15
+ - 26 tests across theme data and color/IO logic.
16
+
3
17
  ## 0.1.0
4
18
 
5
19
  - Initial release.
package/README.md CHANGED
@@ -4,13 +4,17 @@ A [dsh](https://github.com/deepseek-ai/deepseek-harness) plugin that lets you cu
4
4
 
5
5
  ## Features
6
6
 
7
- - **6 pre-built presets**: Ocean, Forest, Sunset, Monochrome, Nord, Dracula
8
- - **Custom accent color** picker with hex input
7
+ - **10 pre-built presets**: Ocean, Forest, Sunset, Monochrome, Nord, Dracula, Gruvbox, Solarized, Tokyo Night, Catppuccin
8
+ - **Dark-mode aware presets** presets that ship a `darkTokens` palette switch automatically when dsh enters dark mode (watched via `MutationObserver` on `body[data-ds-dark-theme]`)
9
+ - **Custom accent color** picker with hex input, plus a **separate dark-mode accent**
10
+ - **Contrast guard** — a dark-mode accent that would be illegible on a dark surface is lightened in steps until it clears a WCAG luminance floor, and the panel says so rather than silently changing your pick
9
11
  - **Density** control: Compact / Comfortable / Spacious
10
12
  - **Border radius** control: Sharp / Rounded / Soft
11
13
  - **Font family** selection: System / Monospace / Serif
14
+ - **Animation toggle** — turn off UI transitions for a snappier, low-motion interface
12
15
  - **Custom CSS** textarea for advanced `--property: value;` overrides
13
- - **Live preview** with badges, cards, and buttons
16
+ - **Import / export** copy a theme to the clipboard as JSON, or paste / upload one back
17
+ - **Live preview** with badges, cards, and buttons, labelled with the current light/dark mode
14
18
  - **Instant apply** — changes take effect immediately via CSS custom properties
15
19
  - **Persistent** — preferences saved in `localStorage`, no server round-trips
16
20
 
package/lib/io.d.ts ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Theme import/export and color utilities for dsh-theme-studio.
3
+ *
4
+ * Kept free of DOM access so the logic is testable outside a browser.
5
+ *
6
+ * @module io
7
+ */
8
+ import type { ThemePreferences } from './types.js';
9
+ /** The wire format for exported themes. */
10
+ export interface ExportedTheme {
11
+ $schema: 'dsh-theme-studio/v1';
12
+ name?: string;
13
+ preferences: ThemePreferences;
14
+ exportedAt: string;
15
+ }
16
+ /** Serialize preferences into a portable JSON string. */
17
+ export declare function exportTheme(prefs: ThemePreferences, name?: string): string;
18
+ /**
19
+ * Parse a theme JSON document.
20
+ *
21
+ * Every field is validated and falls back to the default rather than throwing,
22
+ * so a partially-hand-edited file still applies what it can. The only hard
23
+ * failure is input that is not a JSON object at all — that returns `null` so
24
+ * the caller can report a real import error instead of silently applying defaults.
25
+ */
26
+ export declare function parseTheme(raw: string): ThemePreferences | null;
27
+ /** Parse `#rgb` or `#rrggbb` into RGB components; `null` when unparseable. */
28
+ export declare function hexToRgb(hex: string): [number, number, number] | null;
29
+ /** WCAG relative luminance, 0 (black) → 1 (white). */
30
+ export declare function luminance(hex: string): number;
31
+ /** Convert RGB components back into a `#rrggbb` string. */
32
+ export declare function rgbToHex(r: number, g: number, b: number): string;
33
+ /** Blend a color toward white by `amount` (0–1). */
34
+ export declare function lighten(hex: string, amount: number): string;
35
+ /** Blend a color toward black by `amount` (0–1). */
36
+ export declare function darken(hex: string, amount: number): string;
37
+ /**
38
+ * Ensure an accent color stays legible on a dark background.
39
+ *
40
+ * A mid-tone accent that reads well on white can sit too close to a dark
41
+ * surface. Rather than silently swapping the user's color, we lighten it in
42
+ * steps until it clears a luminance floor and report whether we changed it, so
43
+ * the panel can say so instead of the user wondering why their pick looks off.
44
+ */
45
+ export declare function ensureDarkContrast(hex: string): {
46
+ color: string;
47
+ adjusted: boolean;
48
+ };
package/lib/io.js ADDED
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Theme import/export and color utilities for dsh-theme-studio.
3
+ *
4
+ * Kept free of DOM access so the logic is testable outside a browser.
5
+ *
6
+ * @module io
7
+ */
8
+ import { DEFAULT_PREFERENCES } from './types.js';
9
+ /** Serialize preferences into a portable JSON string. */
10
+ export function exportTheme(prefs, name) {
11
+ const payload = {
12
+ $schema: 'dsh-theme-studio/v1',
13
+ preferences: prefs,
14
+ exportedAt: new Date().toISOString(),
15
+ };
16
+ if (name !== undefined)
17
+ payload.name = name;
18
+ return JSON.stringify(payload, null, 2);
19
+ }
20
+ const DENSITIES = new Set(['compact', 'comfortable', 'spacious']);
21
+ const RADII = new Set(['sharp', 'rounded', 'soft']);
22
+ const FONTS = new Set(['system', 'mono', 'serif']);
23
+ function asColor(value) {
24
+ if (typeof value !== 'string')
25
+ return null;
26
+ const trimmed = value.trim();
27
+ if (trimmed === '')
28
+ return null;
29
+ return /^#[0-9a-fA-F]{3,8}$/.test(trimmed) ? trimmed : null;
30
+ }
31
+ /**
32
+ * Parse a theme JSON document.
33
+ *
34
+ * Every field is validated and falls back to the default rather than throwing,
35
+ * so a partially-hand-edited file still applies what it can. The only hard
36
+ * failure is input that is not a JSON object at all — that returns `null` so
37
+ * the caller can report a real import error instead of silently applying defaults.
38
+ */
39
+ export function parseTheme(raw) {
40
+ let parsed;
41
+ try {
42
+ parsed = JSON.parse(raw);
43
+ }
44
+ catch {
45
+ return null;
46
+ }
47
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
48
+ return null;
49
+ const doc = parsed;
50
+ // Accept both the envelope and a bare preferences object.
51
+ const source = (doc['preferences'] !== null && typeof doc['preferences'] === 'object' && !Array.isArray(doc['preferences']))
52
+ ? doc['preferences']
53
+ : doc;
54
+ const result = { ...DEFAULT_PREFERENCES };
55
+ if (typeof source['preset'] === 'string' || source['preset'] === null) {
56
+ result.preset = source['preset'];
57
+ }
58
+ result.accentColor = asColor(source['accentColor']);
59
+ result.darkAccentColor = asColor(source['darkAccentColor']);
60
+ if (typeof source['density'] === 'string' && DENSITIES.has(source['density'])) {
61
+ result.density = source['density'];
62
+ }
63
+ if (typeof source['radius'] === 'string' && RADII.has(source['radius'])) {
64
+ result.radius = source['radius'];
65
+ }
66
+ if (typeof source['fontFamily'] === 'string' && FONTS.has(source['fontFamily'])) {
67
+ result.fontFamily = source['fontFamily'];
68
+ }
69
+ if (typeof source['animations'] === 'boolean')
70
+ result.animations = source['animations'];
71
+ if (typeof source['customCss'] === 'string')
72
+ result.customCss = source['customCss'];
73
+ return result;
74
+ }
75
+ /** Parse `#rgb` or `#rrggbb` into RGB components; `null` when unparseable. */
76
+ export function hexToRgb(hex) {
77
+ const value = hex.trim().replace(/^#/, '');
78
+ if (value.length === 3) {
79
+ const [r, g, b] = value.split('');
80
+ return [parseInt(r + r, 16), parseInt(g + g, 16), parseInt(b + b, 16)];
81
+ }
82
+ if (value.length === 6) {
83
+ return [
84
+ parseInt(value.slice(0, 2), 16),
85
+ parseInt(value.slice(2, 4), 16),
86
+ parseInt(value.slice(4, 6), 16),
87
+ ];
88
+ }
89
+ return null;
90
+ }
91
+ /** WCAG relative luminance, 0 (black) → 1 (white). */
92
+ export function luminance(hex) {
93
+ const rgb = hexToRgb(hex);
94
+ if (rgb === null)
95
+ return 0;
96
+ const [r, g, b] = rgb.map((c) => {
97
+ const s = c / 255;
98
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
99
+ });
100
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
101
+ }
102
+ /** Convert RGB components back into a `#rrggbb` string. */
103
+ export function rgbToHex(r, g, b) {
104
+ const clamp = (n) => Math.max(0, Math.min(255, Math.round(n)));
105
+ return '#' + [r, g, b].map((n) => clamp(n).toString(16).padStart(2, '0')).join('');
106
+ }
107
+ /** Blend a color toward white by `amount` (0–1). */
108
+ export function lighten(hex, amount) {
109
+ const rgb = hexToRgb(hex);
110
+ if (rgb === null)
111
+ return hex;
112
+ const [r, g, b] = rgb;
113
+ return rgbToHex(r + (255 - r) * amount, g + (255 - g) * amount, b + (255 - b) * amount);
114
+ }
115
+ /** Blend a color toward black by `amount` (0–1). */
116
+ export function darken(hex, amount) {
117
+ const rgb = hexToRgb(hex);
118
+ if (rgb === null)
119
+ return hex;
120
+ const [r, g, b] = rgb;
121
+ return rgbToHex(r * (1 - amount), g * (1 - amount), b * (1 - amount));
122
+ }
123
+ /**
124
+ * Ensure an accent color stays legible on a dark background.
125
+ *
126
+ * A mid-tone accent that reads well on white can sit too close to a dark
127
+ * surface. Rather than silently swapping the user's color, we lighten it in
128
+ * steps until it clears a luminance floor and report whether we changed it, so
129
+ * the panel can say so instead of the user wondering why their pick looks off.
130
+ */
131
+ export function ensureDarkContrast(hex) {
132
+ const MIN_LUMINANCE = 0.18;
133
+ // An unparseable value can't be measured, so it can't be corrected either —
134
+ // returning it unadjusted keeps the panel from claiming it fixed something.
135
+ if (hexToRgb(hex) === null)
136
+ return { color: hex, adjusted: false };
137
+ if (luminance(hex) >= MIN_LUMINANCE)
138
+ return { color: hex, adjusted: false };
139
+ let current = hex;
140
+ for (let i = 0; i < 10; i += 1) {
141
+ current = lighten(current, 0.12);
142
+ if (luminance(current) >= MIN_LUMINANCE)
143
+ return { color: current, adjusted: true };
144
+ }
145
+ return { color: current, adjusted: true };
146
+ }
@@ -10,6 +10,9 @@ var zh = {
10
10
  accentSection: "\u5F3A\u8C03\u8272",
11
11
  accentHint: "\u81EA\u5B9A\u4E49\u754C\u9762\u5F3A\u8C03\u8272\uFF0C\u8986\u76D6\u9884\u8BBE\u4E2D\u7684\u5F3A\u8C03\u8272",
12
12
  accentPlaceholder: "#4B8BBE",
13
+ darkAccentSection: "\u6697\u8272\u6A21\u5F0F\u5F3A\u8C03\u8272",
14
+ darkAccentHint: "\u6697\u8272\u6A21\u5F0F\u4E0B\u4F7F\u7528\u7684\u5F3A\u8C03\u8272\uFF0C\u7559\u7A7A\u5219\u6CBF\u7528\u4E0A\u9762\u7684\u989C\u8272",
15
+ preserveContrast: "\u5DF2\u81EA\u52A8\u63D0\u4EAE\u4EE5\u4FDD\u8BC1\u6697\u8272\u4E0B\u53EF\u8BFB",
13
16
  densitySection: "\u5BC6\u5EA6",
14
17
  densityCompact: "\u7D27\u51D1",
15
18
  densityComfortable: "\u8212\u9002",
@@ -25,6 +28,20 @@ var zh = {
25
28
  customCssSection: "\u81EA\u5B9A\u4E49 CSS",
26
29
  customCssHint: "\u76F4\u63A5\u8986\u76D6 CSS \u81EA\u5B9A\u4E49\u5C5E\u6027\uFF0C\u6BCF\u884C\u4E00\u4E2A `--property: value;`",
27
30
  customCssPlaceholder: "--accent: #ff0000;\n--border: 1px solid red;",
31
+ behaviorSection: "\u884C\u4E3A",
32
+ animations: "\u754C\u9762\u52A8\u753B",
33
+ animationsHint: "\u5173\u95ED\u53EF\u51CF\u5C11\u52A8\u753B\u5E72\u6270\uFF0C\u63D0\u5347\u54CD\u5E94\u901F\u5EA6",
34
+ ioSection: "\u5BFC\u5165 / \u5BFC\u51FA",
35
+ export: "\u5BFC\u51FA\u4E3B\u9898",
36
+ import: "\u5BFC\u5165\u4E3B\u9898",
37
+ importHint: "\u7C98\u8D34\u5BFC\u51FA\u7684 JSON\uFF0C\u6216\u4E0A\u4F20 .json \u6587\u4EF6",
38
+ importPlaceholder: '{"preset":"nord","accentColor":"#5e81ac",...}',
39
+ importFile: "\u9009\u62E9\u6587\u4EF6",
40
+ importSuccess: "\u4E3B\u9898\u5DF2\u5BFC\u5165",
41
+ importFailed: "\u5BFC\u5165\u5931\u8D25\uFF1A\u4E0D\u662F\u6709\u6548\u7684\u4E3B\u9898 JSON",
42
+ exportSuccess: "\u4E3B\u9898\u5DF2\u590D\u5236\u5230\u526A\u8D34\u677F",
43
+ exportFailed: "\u590D\u5236\u5931\u8D25\uFF0C\u8BF7\u624B\u52A8\u590D\u5236",
44
+ copy: "\u590D\u5236",
28
45
  preview: "\u9884\u89C8",
29
46
  apply: "\u5E94\u7528",
30
47
  reset: "\u91CD\u7F6E\u4E3A\u9ED8\u8BA4",
@@ -46,6 +63,9 @@ var en = {
46
63
  accentSection: "Accent Color",
47
64
  accentHint: "Custom accent color, overrides the preset",
48
65
  accentPlaceholder: "#4B8BBE",
66
+ darkAccentSection: "Dark Mode Accent",
67
+ darkAccentHint: "Accent used in dark mode; leave empty to reuse the color above",
68
+ preserveContrast: "Lightened automatically for dark-mode readability",
49
69
  densitySection: "Density",
50
70
  densityCompact: "Compact",
51
71
  densityComfortable: "Comfortable",
@@ -61,6 +81,20 @@ var en = {
61
81
  customCssSection: "Custom CSS",
62
82
  customCssHint: "Override CSS custom properties directly, one `--property: value;` per line",
63
83
  customCssPlaceholder: "--accent: #ff0000;\n--border: 1px solid red;",
84
+ behaviorSection: "Behavior",
85
+ animations: "UI Animations",
86
+ animationsHint: "Turn off to reduce motion and speed up interaction",
87
+ ioSection: "Import / Export",
88
+ export: "Export Theme",
89
+ import: "Import Theme",
90
+ importHint: "Paste exported JSON, or upload a .json file",
91
+ importPlaceholder: '{"preset":"nord","accentColor":"#5e81ac",...}',
92
+ importFile: "Choose File",
93
+ importSuccess: "Theme imported",
94
+ importFailed: "Import failed: not a valid theme JSON",
95
+ exportSuccess: "Theme copied to clipboard",
96
+ exportFailed: "Copy failed, please copy manually",
97
+ copy: "Copy",
64
98
  preview: "Preview",
65
99
  apply: "Apply",
66
100
  reset: "Reset to Default",
@@ -74,15 +108,17 @@ var en = {
74
108
  };
75
109
 
76
110
  // src/client/view.tsx
77
- import { useCallback as useCallback2, useEffect as useEffect2, useState as useState2 } from "react";
111
+ import { useCallback as useCallback2, useEffect as useEffect2, useRef, useState as useState2 } from "react";
78
112
 
79
113
  // src/types.ts
80
114
  var DEFAULT_PREFERENCES = {
81
115
  preset: null,
82
116
  accentColor: null,
117
+ darkAccentColor: null,
83
118
  density: "comfortable",
84
119
  radius: "rounded",
85
120
  fontFamily: "system",
121
+ animations: true,
86
122
  customCss: ""
87
123
  };
88
124
  var STORAGE_KEY = "dsh-theme-studio";
@@ -159,6 +195,81 @@ var PRESETS = [
159
195
  "--dsw-alias-state-business-primary": "#bd93f9",
160
196
  "--dsw-alias-state-business-secondary": "#3a3a5c",
161
197
  "--dsw-alias-interactive-bg-hover": "rgba(189,147,249,0.12)"
198
+ },
199
+ darkTokens: {
200
+ "--dsw-alias-state-business-secondary": "#44475a",
201
+ "--dsw-alias-interactive-bg-hover": "rgba(189,147,249,0.18)"
202
+ }
203
+ },
204
+ {
205
+ id: "gruvbox",
206
+ name: "Gruvbox",
207
+ description: "Retro groove with warm earthy tones",
208
+ tokens: {
209
+ "--accent": "#d65d0e",
210
+ "--accent-hover": "#cc241d",
211
+ "--dsw-alias-state-business-primary": "#d65d0e",
212
+ "--dsw-alias-state-business-secondary": "#fabd2f",
213
+ "--dsw-alias-interactive-bg-hover": "rgba(214,93,14,0.08)"
214
+ },
215
+ darkTokens: {
216
+ "--accent": "#fe8019",
217
+ "--accent-hover": "#fabd2f",
218
+ "--dsw-alias-state-business-primary": "#fe8019",
219
+ "--dsw-alias-state-business-secondary": "#3c3836"
220
+ }
221
+ },
222
+ {
223
+ id: "solarized",
224
+ name: "Solarized",
225
+ description: "Ethan Schoonover's precision palette",
226
+ tokens: {
227
+ "--accent": "#268bd2",
228
+ "--accent-hover": "#1e6fa8",
229
+ "--dsw-alias-state-business-primary": "#268bd2",
230
+ "--dsw-alias-state-business-secondary": "#eee8d5",
231
+ "--dsw-alias-interactive-bg-hover": "rgba(38,139,210,0.08)"
232
+ },
233
+ darkTokens: {
234
+ "--accent": "#839496",
235
+ "--accent-hover": "#93a1a1",
236
+ "--dsw-alias-state-business-primary": "#268bd2",
237
+ "--dsw-alias-state-business-secondary": "#073642"
238
+ }
239
+ },
240
+ {
241
+ id: "tokyo-night",
242
+ name: "Tokyo Night",
243
+ description: "Inspired by the lights of downtown Tokyo at night",
244
+ tokens: {
245
+ "--accent": "#7aa2f7",
246
+ "--accent-hover": "#6183f0",
247
+ "--dsw-alias-state-business-primary": "#7aa2f7",
248
+ "--dsw-alias-state-business-secondary": "#bb9af7",
249
+ "--dsw-alias-interactive-bg-hover": "rgba(122,162,247,0.08)"
250
+ },
251
+ darkTokens: {
252
+ "--dsw-alias-state-business-secondary": "#1a1b26",
253
+ "--dsw-alias-interactive-bg-hover": "rgba(122,162,247,0.15)"
254
+ }
255
+ },
256
+ {
257
+ id: "catppuccin",
258
+ name: "Catppuccin",
259
+ description: "Soothing pastel theme for high contrast",
260
+ tokens: {
261
+ "--accent": "#89b4fa",
262
+ "--accent-hover": "#74a8fc",
263
+ "--dsw-alias-state-business-primary": "#89b4fa",
264
+ "--dsw-alias-state-business-secondary": "#f5e0dc",
265
+ "--dsw-alias-interactive-bg-hover": "rgba(137,180,250,0.08)"
266
+ },
267
+ darkTokens: {
268
+ "--accent": "#cba6f7",
269
+ "--accent-hover": "#b48bef",
270
+ "--dsw-alias-state-business-primary": "#cba6f7",
271
+ "--dsw-alias-state-business-secondary": "#313244",
272
+ "--dsw-alias-interactive-bg-hover": "rgba(203,166,247,0.12)"
162
273
  }
163
274
  }
164
275
  ];
@@ -198,10 +309,116 @@ var FONT_TOKENS = {
198
309
  mono: { "--dsh-font-family": "'JetBrains Mono', 'Fira Code', monospace" },
199
310
  serif: { "--dsh-font-family": "'Georgia', 'Times New Roman', serif" }
200
311
  };
312
+ var ANIMATION_ON_TOKENS = {
313
+ "--dsh-transition-fast": "0.15s ease",
314
+ "--dsh-transition-normal": "0.25s ease"
315
+ };
316
+ var ANIMATION_OFF_TOKENS = {
317
+ "--dsh-transition-fast": "0s",
318
+ "--dsh-transition-normal": "0s"
319
+ };
201
320
  function getPreset(id) {
202
321
  return PRESETS.find((p) => p.id === id);
203
322
  }
204
323
 
324
+ // src/io.ts
325
+ function exportTheme(prefs, name) {
326
+ const payload = {
327
+ $schema: "dsh-theme-studio/v1",
328
+ preferences: prefs,
329
+ exportedAt: (/* @__PURE__ */ new Date()).toISOString()
330
+ };
331
+ if (name !== void 0) payload.name = name;
332
+ return JSON.stringify(payload, null, 2);
333
+ }
334
+ var DENSITIES = /* @__PURE__ */ new Set(["compact", "comfortable", "spacious"]);
335
+ var RADII = /* @__PURE__ */ new Set(["sharp", "rounded", "soft"]);
336
+ var FONTS = /* @__PURE__ */ new Set(["system", "mono", "serif"]);
337
+ function asColor(value) {
338
+ if (typeof value !== "string") return null;
339
+ const trimmed = value.trim();
340
+ if (trimmed === "") return null;
341
+ return /^#[0-9a-fA-F]{3,8}$/.test(trimmed) ? trimmed : null;
342
+ }
343
+ function parseTheme(raw) {
344
+ let parsed;
345
+ try {
346
+ parsed = JSON.parse(raw);
347
+ } catch {
348
+ return null;
349
+ }
350
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null;
351
+ const doc = parsed;
352
+ const source = doc["preferences"] !== null && typeof doc["preferences"] === "object" && !Array.isArray(doc["preferences"]) ? doc["preferences"] : doc;
353
+ const result = { ...DEFAULT_PREFERENCES };
354
+ if (typeof source["preset"] === "string" || source["preset"] === null) {
355
+ result.preset = source["preset"];
356
+ }
357
+ result.accentColor = asColor(source["accentColor"]);
358
+ result.darkAccentColor = asColor(source["darkAccentColor"]);
359
+ if (typeof source["density"] === "string" && DENSITIES.has(source["density"])) {
360
+ result.density = source["density"];
361
+ }
362
+ if (typeof source["radius"] === "string" && RADII.has(source["radius"])) {
363
+ result.radius = source["radius"];
364
+ }
365
+ if (typeof source["fontFamily"] === "string" && FONTS.has(source["fontFamily"])) {
366
+ result.fontFamily = source["fontFamily"];
367
+ }
368
+ if (typeof source["animations"] === "boolean") result.animations = source["animations"];
369
+ if (typeof source["customCss"] === "string") result.customCss = source["customCss"];
370
+ return result;
371
+ }
372
+ function hexToRgb(hex) {
373
+ const value = hex.trim().replace(/^#/, "");
374
+ if (value.length === 3) {
375
+ const [r, g, b] = value.split("");
376
+ return [parseInt(r + r, 16), parseInt(g + g, 16), parseInt(b + b, 16)];
377
+ }
378
+ if (value.length === 6) {
379
+ return [
380
+ parseInt(value.slice(0, 2), 16),
381
+ parseInt(value.slice(2, 4), 16),
382
+ parseInt(value.slice(4, 6), 16)
383
+ ];
384
+ }
385
+ return null;
386
+ }
387
+ function luminance(hex) {
388
+ const rgb = hexToRgb(hex);
389
+ if (rgb === null) return 0;
390
+ const [r, g, b] = rgb.map((c) => {
391
+ const s = c / 255;
392
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
393
+ });
394
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
395
+ }
396
+ function rgbToHex(r, g, b) {
397
+ const clamp = (n) => Math.max(0, Math.min(255, Math.round(n)));
398
+ return "#" + [r, g, b].map((n) => clamp(n).toString(16).padStart(2, "0")).join("");
399
+ }
400
+ function lighten(hex, amount) {
401
+ const rgb = hexToRgb(hex);
402
+ if (rgb === null) return hex;
403
+ const [r, g, b] = rgb;
404
+ return rgbToHex(
405
+ r + (255 - r) * amount,
406
+ g + (255 - g) * amount,
407
+ b + (255 - b) * amount
408
+ );
409
+ }
410
+ function ensureDarkContrast(hex) {
411
+ const MIN_LUMINANCE = 0.18;
412
+ if (hexToRgb(hex) === null) return { color: hex, adjusted: false };
413
+ if (luminance(hex) >= MIN_LUMINANCE) return { color: hex, adjusted: false };
414
+ let current = hex;
415
+ for (let i = 0; i < 10; i += 1) {
416
+ current = lighten(current, 0.12);
417
+ if (luminance(current) >= MIN_LUMINANCE) return { color: current, adjusted: true };
418
+ }
419
+ return { color: current, adjusted: true };
420
+ }
421
+
205
422
  // src/client/ui.tsx
206
423
  import {
207
424
  createContext,
@@ -655,6 +872,21 @@ function Field({
655
872
 
656
873
  // src/client/view.tsx
657
874
  import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
875
+ var MANAGED_PROPERTIES = [
876
+ "--accent",
877
+ "--accent-hover",
878
+ "--dsh-content-font-size",
879
+ "--dsh-spacing-unit",
880
+ "--dsh-radius-small",
881
+ "--dsh-radius-medium",
882
+ "--dsh-radius-large",
883
+ "--dsh-font-family",
884
+ "--dsh-transition-fast",
885
+ "--dsh-transition-normal",
886
+ "--dsw-alias-state-business-primary",
887
+ "--dsw-alias-state-business-secondary",
888
+ "--dsw-alias-interactive-bg-hover"
889
+ ];
658
890
  function loadPreferences() {
659
891
  if (typeof localStorage === "undefined") return { ...DEFAULT_PREFERENCES };
660
892
  try {
@@ -669,59 +901,71 @@ function savePreferences(prefs) {
669
901
  if (typeof localStorage === "undefined") return;
670
902
  localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
671
903
  }
672
- function applyTheme(prefs) {
673
- if (typeof document === "undefined") return;
674
- const root = document.documentElement;
675
- const allTokens = {};
676
- if (prefs.preset !== null) {
677
- const preset = getPreset(prefs.preset);
678
- if (preset !== void 0) Object.assign(allTokens, preset.tokens);
679
- }
680
- if (prefs.accentColor !== null && prefs.accentColor.trim() !== "") {
681
- allTokens["--accent"] = prefs.accentColor;
904
+ function isDarkMode() {
905
+ if (typeof document === "undefined") return false;
906
+ return document.body.hasAttribute("data-ds-dark-theme");
907
+ }
908
+ function observeDarkMode(onChange) {
909
+ if (typeof document === "undefined" || typeof MutationObserver === "undefined") {
910
+ return () => {
911
+ };
682
912
  }
683
- Object.assign(allTokens, DENSITY_TOKENS[prefs.density] ?? {});
684
- Object.assign(allTokens, RADIUS_TOKENS[prefs.radius] ?? {});
685
- Object.assign(allTokens, FONT_TOKENS[prefs.fontFamily] ?? {});
686
- for (const [prop, value] of Object.entries(allTokens)) {
687
- root.style.setProperty(prop, value);
913
+ const observer = new MutationObserver(() => onChange(isDarkMode()));
914
+ observer.observe(document.body, { attributes: true, attributeFilter: ["data-ds-dark-theme"] });
915
+ return () => observer.disconnect();
916
+ }
917
+ function resolveTokens(prefs, dark) {
918
+ const tokens = {};
919
+ let contrastAdjusted = false;
920
+ const preset = prefs.preset !== null ? getPreset(prefs.preset) : void 0;
921
+ if (preset !== void 0) {
922
+ Object.assign(tokens, preset.tokens);
923
+ if (dark && preset.darkTokens !== void 0) Object.assign(tokens, preset.darkTokens);
688
924
  }
689
- if (prefs.customCss.trim() !== "") {
690
- for (const line of prefs.customCss.split("\n")) {
691
- const match = line.match(/^\s*(--[\w-]+)\s*:\s*(.+?)\s*;?\s*$/);
692
- if (match !== null) {
693
- root.style.setProperty(match[1], match[2]);
694
- }
925
+ const chosenAccent = dark ? prefs.darkAccentColor ?? prefs.accentColor : prefs.accentColor;
926
+ if (chosenAccent !== null && chosenAccent.trim() !== "") {
927
+ let accent = chosenAccent;
928
+ if (dark) {
929
+ const guarded = ensureDarkContrast(accent);
930
+ accent = guarded.color;
931
+ contrastAdjusted = guarded.adjusted;
695
932
  }
933
+ tokens["--accent"] = accent;
696
934
  }
935
+ Object.assign(tokens, DENSITY_TOKENS[prefs.density] ?? {});
936
+ Object.assign(tokens, RADIUS_TOKENS[prefs.radius] ?? {});
937
+ Object.assign(tokens, FONT_TOKENS[prefs.fontFamily] ?? {});
938
+ Object.assign(tokens, prefs.animations ? ANIMATION_ON_TOKENS : ANIMATION_OFF_TOKENS);
939
+ return { tokens, contrastAdjusted };
940
+ }
941
+ function parseCustomCss(css) {
942
+ const out = {};
943
+ for (const line of css.split("\n")) {
944
+ const match = line.match(/^\s*(--[\w-]+)\s*:\s*(.+?)\s*;?\s*$/);
945
+ if (match !== null) out[match[1]] = match[2];
946
+ }
947
+ return out;
948
+ }
949
+ function applyTheme(prefs, dark = isDarkMode()) {
950
+ if (typeof document === "undefined") return false;
951
+ const root = document.documentElement;
952
+ const { tokens, contrastAdjusted } = resolveTokens(prefs, dark);
953
+ const custom = parseCustomCss(prefs.customCss);
954
+ const merged = { ...tokens, ...custom };
955
+ for (const prop of MANAGED_PROPERTIES) {
956
+ if (!(prop in merged)) root.style.removeProperty(prop);
957
+ }
958
+ for (const [prop, value] of Object.entries(merged)) root.style.setProperty(prop, value);
959
+ return contrastAdjusted;
697
960
  }
698
961
  function clearTheme() {
699
962
  if (typeof document === "undefined") return;
700
963
  const root = document.documentElement;
701
- const known = /* @__PURE__ */ new Set([
702
- "--accent",
703
- "--accent-hover",
704
- "--dsh-content-font-size",
705
- "--dsh-spacing-unit",
706
- "--dsh-radius-small",
707
- "--dsh-radius-medium",
708
- "--dsh-radius-large",
709
- "--dsh-font-family",
710
- "--dsw-alias-state-business-primary",
711
- "--dsw-alias-state-business-secondary",
712
- "--dsw-alias-interactive-bg-hover"
713
- ]);
714
- for (const prop of known) root.style.removeProperty(prop);
715
- const prefs = loadPreferences();
716
- if (prefs.customCss.trim() !== "") {
717
- for (const line of prefs.customCss.split("\n")) {
718
- const match = line.match(/^\s*(--[\w-]+)\s*:/);
719
- if (match !== null) root.style.removeProperty(match[1]);
720
- }
721
- }
964
+ for (const prop of MANAGED_PROPERTIES) root.style.removeProperty(prop);
722
965
  }
723
966
  function PresetCard({ preset, selected, onClick }) {
724
967
  const accent = preset.tokens["--accent"] ?? "#4B8BBE";
968
+ const secondary = preset.tokens["--dsw-alias-state-business-secondary"] ?? "#e5e5e5";
725
969
  return /* @__PURE__ */ jsxs2(
726
970
  "div",
727
971
  {
@@ -735,12 +979,16 @@ function PresetCard({ preset, selected, onClick }) {
735
979
  transition: "all 0.15s ease"
736
980
  },
737
981
  children: [
738
- /* @__PURE__ */ jsxs2("div", { style: { display: "flex", alignItems: "center", gap: 8, marginBottom: 4 }, children: [
739
- /* @__PURE__ */ jsx2("div", { style: { width: 16, height: 16, borderRadius: "50%", background: accent, border: "1px solid rgba(128,128,128,0.2)" } }),
982
+ /* @__PURE__ */ jsxs2("div", { style: { display: "flex", alignItems: "center", gap: 8, marginBottom: 6 }, children: [
983
+ /* @__PURE__ */ jsxs2("div", { style: { display: "flex", gap: 3 }, children: [
984
+ /* @__PURE__ */ jsx2("div", { style: { width: 14, height: 14, borderRadius: "50%", background: accent, border: "1px solid rgba(128,128,128,0.2)" } }),
985
+ /* @__PURE__ */ jsx2("div", { style: { width: 14, height: 14, borderRadius: "50%", background: secondary, border: "1px solid rgba(128,128,128,0.2)" } })
986
+ ] }),
740
987
  /* @__PURE__ */ jsx2("strong", { style: { fontSize: 13 }, children: preset.name }),
741
- selected && /* @__PURE__ */ jsx2(Badge, { color: "info", children: "\u2713" })
988
+ preset.darkTokens !== void 0 && /* @__PURE__ */ jsx2(Badge, { color: "info", children: "\u25D0" }),
989
+ selected && /* @__PURE__ */ jsx2(Badge, { color: "success", children: "\u2713" })
742
990
  ] }),
743
- /* @__PURE__ */ jsx2("div", { style: { fontSize: 11, opacity: 0.6 }, children: preset.description })
991
+ /* @__PURE__ */ jsx2("div", { style: { fontSize: 11, opacity: 0.6, lineHeight: 1.4 }, children: preset.description })
744
992
  ]
745
993
  }
746
994
  );
@@ -767,55 +1015,174 @@ function Segmented({ value, options, onChange }) {
767
1015
  opt.value
768
1016
  )) });
769
1017
  }
770
- function PreviewArea({ t }) {
771
- return /* @__PURE__ */ jsx2(Card, { title: t("preview"), icon: "\u{1F441}", children: /* @__PURE__ */ jsxs2("div", { style: { display: "flex", flexDirection: "column", gap: 10 }, children: [
772
- /* @__PURE__ */ jsxs2("div", { style: { display: "flex", gap: 8, alignItems: "center" }, children: [
773
- /* @__PURE__ */ jsx2(Badge, { color: "info", children: "Info" }),
774
- /* @__PURE__ */ jsx2(Badge, { color: "success", children: "Success" }),
775
- /* @__PURE__ */ jsx2(Badge, { color: "warning", children: "Warning" }),
776
- /* @__PURE__ */ jsx2(Badge, { color: "error", children: "Error" })
777
- ] }),
778
- /* @__PURE__ */ jsxs2(Card, { children: [
779
- /* @__PURE__ */ jsx2("div", { style: { fontSize: 13, marginBottom: 8 }, children: t("text") }),
780
- /* @__PURE__ */ jsxs2("div", { style: { display: "flex", gap: 8 }, children: [
781
- /* @__PURE__ */ jsx2(Button, { variant: "primary", size: "sm", children: t("button") }),
782
- /* @__PURE__ */ jsx2(Button, { variant: "secondary", size: "sm", children: "Secondary" }),
783
- /* @__PURE__ */ jsx2(Button, { variant: "danger", size: "sm", children: "Danger" })
1018
+ function ColorRow({ value, onChange, placeholder }) {
1019
+ return /* @__PURE__ */ jsxs2("div", { style: { display: "flex", gap: 10, alignItems: "center" }, children: [
1020
+ /* @__PURE__ */ jsx2(
1021
+ "input",
1022
+ {
1023
+ type: "color",
1024
+ value: value ?? "#4B8BBE",
1025
+ onChange: (e) => onChange(e.target.value),
1026
+ style: { width: 40, height: 40, cursor: "pointer", borderRadius: 8, border: "1px solid var(--border, rgba(128,128,128,0.2))" }
1027
+ }
1028
+ ),
1029
+ /* @__PURE__ */ jsx2(
1030
+ Input,
1031
+ {
1032
+ value: value ?? "",
1033
+ onChange: (e) => onChange(e.target.value.trim() === "" ? null : e.target.value),
1034
+ placeholder,
1035
+ style: { maxWidth: 200 }
1036
+ }
1037
+ ),
1038
+ value !== null && /* @__PURE__ */ jsx2(Button, { variant: "ghost", size: "sm", onClick: () => onChange(null), children: "\u2715" })
1039
+ ] });
1040
+ }
1041
+ function PreviewArea({ t, dark }) {
1042
+ return /* @__PURE__ */ jsx2(
1043
+ Card,
1044
+ {
1045
+ title: t("preview"),
1046
+ icon: "\u{1F441}",
1047
+ actions: /* @__PURE__ */ jsx2(Badge, { color: dark ? "info" : "warning", children: dark ? "\u{1F319} dark" : "\u2600 light" }),
1048
+ children: /* @__PURE__ */ jsxs2("div", { style: { display: "flex", flexDirection: "column", gap: 10 }, children: [
1049
+ /* @__PURE__ */ jsxs2("div", { style: { display: "flex", gap: 8, alignItems: "center" }, children: [
1050
+ /* @__PURE__ */ jsx2(Badge, { color: "info", children: "Info" }),
1051
+ /* @__PURE__ */ jsx2(Badge, { color: "success", children: "Success" }),
1052
+ /* @__PURE__ */ jsx2(Badge, { color: "warning", children: "Warning" }),
1053
+ /* @__PURE__ */ jsx2(Badge, { color: "error", children: "Error" })
1054
+ ] }),
1055
+ /* @__PURE__ */ jsxs2(Card, { children: [
1056
+ /* @__PURE__ */ jsx2("div", { style: { fontSize: 13, marginBottom: 8 }, children: t("text") }),
1057
+ /* @__PURE__ */ jsxs2("div", { style: { display: "flex", gap: 8 }, children: [
1058
+ /* @__PURE__ */ jsx2(Button, { variant: "primary", size: "sm", children: t("button") }),
1059
+ /* @__PURE__ */ jsx2(Button, { variant: "secondary", size: "sm", children: "Secondary" }),
1060
+ /* @__PURE__ */ jsx2(Button, { variant: "danger", size: "sm", children: "Danger" })
1061
+ ] })
1062
+ ] })
784
1063
  ] })
785
- ] })
786
- ] }) });
1064
+ }
1065
+ );
787
1066
  }
788
1067
  function ThemePanelInner({ t }) {
789
1068
  const toast = useToast();
790
1069
  const [prefs, setPrefs] = useState2(loadPreferences);
1070
+ const [dark, setDark] = useState2(isDarkMode);
1071
+ const [contrastAdjusted, setContrastAdjusted] = useState2(false);
791
1072
  const [confirmReset, setConfirmReset] = useState2(false);
1073
+ const [importText, setImportText] = useState2("");
1074
+ const [importOpen, setImportOpen] = useState2(false);
1075
+ const fileInput = useRef(null);
792
1076
  useEffect2(() => {
793
- applyTheme(prefs);
1077
+ setContrastAdjusted(applyTheme(prefs, dark));
794
1078
  savePreferences(prefs);
795
- }, [prefs]);
1079
+ }, [prefs, dark]);
1080
+ useEffect2(() => observeDarkMode(setDark), []);
796
1081
  const update = useCallback2((key, value) => {
797
1082
  setPrefs((p) => ({ ...p, [key]: value }));
798
1083
  }, []);
799
1084
  const handleReset = useCallback2(() => {
800
1085
  clearTheme();
801
1086
  setPrefs({ ...DEFAULT_PREFERENCES });
1087
+ setConfirmReset(false);
802
1088
  toast("success", t("resetted"));
803
1089
  }, [t, toast]);
804
- return /* @__PURE__ */ jsxs2("div", { style: { display: "flex", flexDirection: "column", gap: 16, maxWidth: 820 }, children: [
805
- /* @__PURE__ */ jsxs2("header", { style: { display: "flex", alignItems: "center", gap: 12 }, children: [
1090
+ const handleExport = useCallback2(() => {
1091
+ const json = exportTheme(prefs);
1092
+ const clipboard = typeof navigator !== "undefined" ? navigator.clipboard : void 0;
1093
+ if (clipboard === void 0) {
1094
+ setImportText(json);
1095
+ setImportOpen(true);
1096
+ toast("warning", t("exportFailed"));
1097
+ return;
1098
+ }
1099
+ clipboard.writeText(json).then(
1100
+ () => toast("success", t("exportSuccess")),
1101
+ () => {
1102
+ setImportText(json);
1103
+ setImportOpen(true);
1104
+ toast("warning", t("exportFailed"));
1105
+ }
1106
+ );
1107
+ }, [prefs, t, toast]);
1108
+ const runImport = useCallback2((raw) => {
1109
+ const parsed = parseTheme(raw);
1110
+ if (parsed === null) {
1111
+ toast("error", t("importFailed"));
1112
+ return;
1113
+ }
1114
+ setPrefs(parsed);
1115
+ setImportOpen(false);
1116
+ setImportText("");
1117
+ toast("success", t("importSuccess"));
1118
+ }, [t, toast]);
1119
+ const handleFile = useCallback2((file) => {
1120
+ if (file === void 0) return;
1121
+ file.text().then(runImport, () => toast("error", t("importFailed")));
1122
+ }, [runImport, t, toast]);
1123
+ return /* @__PURE__ */ jsxs2("div", { style: { display: "flex", flexDirection: "column", gap: 16, maxWidth: 860 }, children: [
1124
+ /* @__PURE__ */ jsxs2("header", { style: { display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap" }, children: [
806
1125
  /* @__PURE__ */ jsxs2("strong", { style: { fontSize: 15 }, children: [
807
1126
  "\u{1F3A8} ",
808
1127
  t("title")
809
1128
  ] }),
810
1129
  /* @__PURE__ */ jsx2("span", { style: { fontSize: 12, opacity: 0.6 }, children: t("subtitle") }),
811
1130
  /* @__PURE__ */ jsx2("span", { style: { flex: 1 } }),
1131
+ /* @__PURE__ */ jsxs2(Button, { variant: "secondary", size: "sm", onClick: handleExport, children: [
1132
+ "\u2934 ",
1133
+ t("export")
1134
+ ] }),
1135
+ /* @__PURE__ */ jsxs2(Button, { variant: "secondary", size: "sm", onClick: () => setImportOpen((v) => !v), children: [
1136
+ "\u2935 ",
1137
+ t("import")
1138
+ ] }),
812
1139
  /* @__PURE__ */ jsxs2(Button, { variant: "danger", size: "sm", onClick: () => setConfirmReset(true), children: [
813
1140
  "\u21BA ",
814
1141
  t("reset")
815
1142
  ] })
816
1143
  ] }),
1144
+ importOpen && /* @__PURE__ */ jsxs2(Card, { title: t("import"), icon: "\u2935", children: [
1145
+ /* @__PURE__ */ jsx2(Field, { label: t("import"), hint: t("importHint"), children: /* @__PURE__ */ jsx2(
1146
+ Textarea,
1147
+ {
1148
+ value: importText,
1149
+ onChange: (e) => setImportText(e.target.value),
1150
+ placeholder: t("importPlaceholder"),
1151
+ rows: 5
1152
+ }
1153
+ ) }),
1154
+ /* @__PURE__ */ jsxs2("div", { style: { display: "flex", gap: 8, marginTop: 8 }, children: [
1155
+ /* @__PURE__ */ jsx2(
1156
+ Button,
1157
+ {
1158
+ variant: "primary",
1159
+ size: "sm",
1160
+ onClick: () => runImport(importText),
1161
+ disabled: importText.trim() === "",
1162
+ children: t("import")
1163
+ }
1164
+ ),
1165
+ /* @__PURE__ */ jsxs2(Button, { variant: "secondary", size: "sm", onClick: () => fileInput.current?.click(), children: [
1166
+ "\u{1F4C4} ",
1167
+ t("importFile")
1168
+ ] }),
1169
+ /* @__PURE__ */ jsx2(
1170
+ "input",
1171
+ {
1172
+ ref: fileInput,
1173
+ type: "file",
1174
+ accept: "application/json,.json",
1175
+ style: { display: "none" },
1176
+ onChange: (e) => {
1177
+ handleFile(e.target.files?.[0]);
1178
+ e.target.value = "";
1179
+ }
1180
+ }
1181
+ )
1182
+ ] })
1183
+ ] }),
817
1184
  /* @__PURE__ */ jsx2(SectionTitle, { icon: "\u{1F3AD}", children: t("presetSection") }),
818
- /* @__PURE__ */ jsxs2("div", { style: { display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))", gap: 10 }, children: [
1185
+ /* @__PURE__ */ jsxs2("div", { style: { display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(190px, 1fr))", gap: 10 }, children: [
819
1186
  /* @__PURE__ */ jsx2(
820
1187
  PresetCard,
821
1188
  {
@@ -836,28 +1203,28 @@ function ThemePanelInner({ t }) {
836
1203
  ] }),
837
1204
  /* @__PURE__ */ jsx2(SectionTitle, { icon: "\u{1F308}", children: t("accentSection") }),
838
1205
  /* @__PURE__ */ jsxs2(Card, { children: [
839
- /* @__PURE__ */ jsxs2("div", { style: { display: "flex", gap: 10, alignItems: "center" }, children: [
840
- /* @__PURE__ */ jsx2(
841
- "input",
842
- {
843
- type: "color",
844
- value: prefs.accentColor ?? "#4B8BBE",
845
- onChange: (e) => update("accentColor", e.target.value),
846
- style: { width: 40, height: 40, cursor: "pointer", borderRadius: 8, border: "1px solid var(--border, rgba(128,128,128,0.2))" }
847
- }
848
- ),
849
- /* @__PURE__ */ jsx2(
850
- Input,
851
- {
852
- value: prefs.accentColor ?? "",
853
- onChange: (e) => update("accentColor", e.target.value || null),
854
- placeholder: t("accentPlaceholder"),
855
- style: { maxWidth: 200 }
856
- }
857
- ),
858
- prefs.accentColor !== null && /* @__PURE__ */ jsx2(Button, { variant: "ghost", size: "sm", onClick: () => update("accentColor", null), children: "\u2715" })
859
- ] }),
860
- /* @__PURE__ */ jsx2("div", { style: { fontSize: 11, opacity: 0.5, marginTop: 6 }, children: t("accentHint") })
1206
+ /* @__PURE__ */ jsx2(
1207
+ ColorRow,
1208
+ {
1209
+ value: prefs.accentColor,
1210
+ onChange: (v) => update("accentColor", v),
1211
+ placeholder: t("accentPlaceholder")
1212
+ }
1213
+ ),
1214
+ /* @__PURE__ */ jsx2("div", { style: { fontSize: 11, opacity: 0.5, marginTop: 6 }, children: t("accentHint") }),
1215
+ /* @__PURE__ */ jsx2("div", { style: { height: 14 } }),
1216
+ /* @__PURE__ */ jsx2(Field, { label: t("darkAccentSection"), hint: t("darkAccentHint"), children: /* @__PURE__ */ jsx2(
1217
+ ColorRow,
1218
+ {
1219
+ value: prefs.darkAccentColor,
1220
+ onChange: (v) => update("darkAccentColor", v),
1221
+ placeholder: t("accentPlaceholder")
1222
+ }
1223
+ ) }),
1224
+ contrastAdjusted && /* @__PURE__ */ jsx2("div", { style: { marginTop: 8 }, children: /* @__PURE__ */ jsxs2(Badge, { color: "warning", children: [
1225
+ "\u26A0 ",
1226
+ t("preserveContrast")
1227
+ ] }) })
861
1228
  ] }),
862
1229
  /* @__PURE__ */ jsx2(SectionTitle, { icon: "\u{1F4D0}", children: t("densitySection") }),
863
1230
  /* @__PURE__ */ jsx2(
@@ -898,6 +1265,23 @@ function ThemePanelInner({ t }) {
898
1265
  ]
899
1266
  }
900
1267
  ),
1268
+ /* @__PURE__ */ jsx2(SectionTitle, { icon: "\u2699", children: t("behaviorSection") }),
1269
+ /* @__PURE__ */ jsxs2(Card, { children: [
1270
+ /* @__PURE__ */ jsxs2("div", { style: { display: "flex", alignItems: "center", gap: 10 }, children: [
1271
+ /* @__PURE__ */ jsx2(
1272
+ "input",
1273
+ {
1274
+ id: "theme-animations",
1275
+ type: "checkbox",
1276
+ checked: prefs.animations,
1277
+ onChange: (e) => update("animations", e.target.checked),
1278
+ style: { width: 16, height: 16, cursor: "pointer" }
1279
+ }
1280
+ ),
1281
+ /* @__PURE__ */ jsx2("label", { htmlFor: "theme-animations", style: { fontSize: 13, cursor: "pointer" }, children: t("animations") })
1282
+ ] }),
1283
+ /* @__PURE__ */ jsx2("div", { style: { fontSize: 11, opacity: 0.5, marginTop: 6, marginLeft: 26 }, children: t("animationsHint") })
1284
+ ] }),
901
1285
  /* @__PURE__ */ jsx2(SectionTitle, { icon: "\u270F\uFE0F", children: t("customCssSection") }),
902
1286
  /* @__PURE__ */ jsx2(Card, { children: /* @__PURE__ */ jsx2(Field, { label: t("customCssSection"), hint: t("customCssHint"), children: /* @__PURE__ */ jsx2(
903
1287
  Textarea,
@@ -908,7 +1292,7 @@ function ThemePanelInner({ t }) {
908
1292
  rows: 4
909
1293
  }
910
1294
  ) }) }),
911
- /* @__PURE__ */ jsx2(PreviewArea, { t }),
1295
+ /* @__PURE__ */ jsx2(PreviewArea, { t, dark }),
912
1296
  confirmReset && /* @__PURE__ */ jsx2(
913
1297
  ConfirmDialog,
914
1298
  {
package/lib/themes.d.ts CHANGED
@@ -15,5 +15,8 @@ export declare const DENSITY_TOKENS: Record<string, Record<string, string>>;
15
15
  export declare const RADIUS_TOKENS: Record<string, Record<string, string>>;
16
16
  /** Font family → CSS custom property overrides */
17
17
  export declare const FONT_TOKENS: Record<string, Record<string, string>>;
18
+ /** Animation → CSS custom property overrides */
19
+ export declare const ANIMATION_ON_TOKENS: Record<string, string>;
20
+ export declare const ANIMATION_OFF_TOKENS: Record<string, string>;
18
21
  /** Look up a preset by id. */
19
22
  export declare function getPreset(id: string): ThemePreset | undefined;
package/lib/themes.js CHANGED
@@ -79,6 +79,81 @@ export const PRESETS = [
79
79
  '--dsw-alias-state-business-secondary': '#3a3a5c',
80
80
  '--dsw-alias-interactive-bg-hover': 'rgba(189,147,249,0.12)',
81
81
  },
82
+ darkTokens: {
83
+ '--dsw-alias-state-business-secondary': '#44475a',
84
+ '--dsw-alias-interactive-bg-hover': 'rgba(189,147,249,0.18)',
85
+ },
86
+ },
87
+ {
88
+ id: 'gruvbox',
89
+ name: 'Gruvbox',
90
+ description: 'Retro groove with warm earthy tones',
91
+ tokens: {
92
+ '--accent': '#d65d0e',
93
+ '--accent-hover': '#cc241d',
94
+ '--dsw-alias-state-business-primary': '#d65d0e',
95
+ '--dsw-alias-state-business-secondary': '#fabd2f',
96
+ '--dsw-alias-interactive-bg-hover': 'rgba(214,93,14,0.08)',
97
+ },
98
+ darkTokens: {
99
+ '--accent': '#fe8019',
100
+ '--accent-hover': '#fabd2f',
101
+ '--dsw-alias-state-business-primary': '#fe8019',
102
+ '--dsw-alias-state-business-secondary': '#3c3836',
103
+ },
104
+ },
105
+ {
106
+ id: 'solarized',
107
+ name: 'Solarized',
108
+ description: 'Ethan Schoonover\'s precision palette',
109
+ tokens: {
110
+ '--accent': '#268bd2',
111
+ '--accent-hover': '#1e6fa8',
112
+ '--dsw-alias-state-business-primary': '#268bd2',
113
+ '--dsw-alias-state-business-secondary': '#eee8d5',
114
+ '--dsw-alias-interactive-bg-hover': 'rgba(38,139,210,0.08)',
115
+ },
116
+ darkTokens: {
117
+ '--accent': '#839496',
118
+ '--accent-hover': '#93a1a1',
119
+ '--dsw-alias-state-business-primary': '#268bd2',
120
+ '--dsw-alias-state-business-secondary': '#073642',
121
+ },
122
+ },
123
+ {
124
+ id: 'tokyo-night',
125
+ name: 'Tokyo Night',
126
+ description: 'Inspired by the lights of downtown Tokyo at night',
127
+ tokens: {
128
+ '--accent': '#7aa2f7',
129
+ '--accent-hover': '#6183f0',
130
+ '--dsw-alias-state-business-primary': '#7aa2f7',
131
+ '--dsw-alias-state-business-secondary': '#bb9af7',
132
+ '--dsw-alias-interactive-bg-hover': 'rgba(122,162,247,0.08)',
133
+ },
134
+ darkTokens: {
135
+ '--dsw-alias-state-business-secondary': '#1a1b26',
136
+ '--dsw-alias-interactive-bg-hover': 'rgba(122,162,247,0.15)',
137
+ },
138
+ },
139
+ {
140
+ id: 'catppuccin',
141
+ name: 'Catppuccin',
142
+ description: 'Soothing pastel theme for high contrast',
143
+ tokens: {
144
+ '--accent': '#89b4fa',
145
+ '--accent-hover': '#74a8fc',
146
+ '--dsw-alias-state-business-primary': '#89b4fa',
147
+ '--dsw-alias-state-business-secondary': '#f5e0dc',
148
+ '--dsw-alias-interactive-bg-hover': 'rgba(137,180,250,0.08)',
149
+ },
150
+ darkTokens: {
151
+ '--accent': '#cba6f7',
152
+ '--accent-hover': '#b48bef',
153
+ '--dsw-alias-state-business-primary': '#cba6f7',
154
+ '--dsw-alias-state-business-secondary': '#313244',
155
+ '--dsw-alias-interactive-bg-hover': 'rgba(203,166,247,0.12)',
156
+ },
82
157
  },
83
158
  ];
84
159
  /** Density → CSS custom property overrides */
@@ -120,6 +195,15 @@ export const FONT_TOKENS = {
120
195
  mono: { '--dsh-font-family': "'JetBrains Mono', 'Fira Code', monospace" },
121
196
  serif: { '--dsh-font-family': "'Georgia', 'Times New Roman', serif" },
122
197
  };
198
+ /** Animation → CSS custom property overrides */
199
+ export const ANIMATION_ON_TOKENS = {
200
+ '--dsh-transition-fast': '0.15s ease',
201
+ '--dsh-transition-normal': '0.25s ease',
202
+ };
203
+ export const ANIMATION_OFF_TOKENS = {
204
+ '--dsh-transition-fast': '0s',
205
+ '--dsh-transition-normal': '0s',
206
+ };
123
207
  /** Look up a preset by id. */
124
208
  export function getPreset(id) {
125
209
  return PRESETS.find((p) => p.id === id);
package/lib/types.d.ts CHANGED
@@ -10,6 +10,8 @@ export interface ThemePreset {
10
10
  description: string;
11
11
  /** CSS custom property → value, applied to document.documentElement.style */
12
12
  tokens: Record<string, string>;
13
+ /** Tokens only applied when dsh is in dark mode (body[data-ds-dark-theme]) */
14
+ darkTokens?: Record<string, string>;
13
15
  }
14
16
  /** User-selectable density preference. */
15
17
  export type Density = 'compact' | 'comfortable' | 'spacious';
@@ -21,9 +23,12 @@ export type FontFamily = 'system' | 'mono' | 'serif';
21
23
  export interface ThemePreferences {
22
24
  preset: string | null;
23
25
  accentColor: string | null;
26
+ /** Separate accent color for dark mode; null = use accentColor */
27
+ darkAccentColor: string | null;
24
28
  density: Density;
25
29
  radius: Radius;
26
30
  fontFamily: FontFamily;
31
+ animations: boolean;
27
32
  customCss: string;
28
33
  }
29
34
  /** Default preferences — nothing overridden, dsh's built-in theme wins. */
package/lib/types.js CHANGED
@@ -7,9 +7,11 @@
7
7
  export const DEFAULT_PREFERENCES = {
8
8
  preset: null,
9
9
  accentColor: null,
10
+ darkAccentColor: null,
10
11
  density: 'comfortable',
11
12
  radius: 'rounded',
12
13
  fontFamily: 'system',
14
+ animations: true,
13
15
  customCss: '',
14
16
  };
15
17
  /** localStorage key for persisting preferences. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-theme-studio",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Customize the dsh UI theme: pick presets, set accent colors, adjust density, border radius, and font family, or write custom CSS variable overrides.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",