dsh-theme-studio 0.2.0 → 0.3.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,40 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0
4
+
5
+ **Fixed: the plugin previously had no effect on the UI.**
6
+
7
+ Two independent bugs, both invisible in the panel:
8
+
9
+ 1. Every property was written to `document.documentElement`. dsh defines its
10
+ palette on `body` and `body[data-ds-dark-theme]`, and body's own declaration
11
+ wins over the value inherited from html — so all writes were overridden.
12
+ 2. The token names were invented. Only 3 of 16 (`--dsw-alias-state-business-primary`,
13
+ `--dsw-alias-interactive-bg-hover`, `--dsh-content-font-size`) exist in dsh;
14
+ `--accent`, `--border`, `--dsh-radius-*`, `--dsh-transition-*` and
15
+ `--dsw-alias-state-business-secondary` do not.
16
+
17
+ The panel's preview read the same invented names with fallbacks, so it changed
18
+ color and made the misconfiguration look like success.
19
+
20
+ Also in this release:
21
+
22
+ - Every name now comes from `src/tokens.ts`, verified against the shipped dsh
23
+ stylesheets, and `tests/tokens.test.mjs` fails if an unverified name is written.
24
+ - Properties go to `document.body` (`tests/apply.test.mjs` asserts documentElement
25
+ is never touched).
26
+ - Border-radius control removed: dsh hardcodes radii per component and ships no
27
+ radius token, so there was no honest implementation.
28
+ - Animation toggle reimplemented as a stylesheet using `0.001ms` rather than `0s`,
29
+ so `transitionend` and `animationend` still fire.
30
+ - Accent now drives the whole family (business, brand, link, primary button,
31
+ hover tint) instead of a single token.
32
+ - Switching a preset off clears the properties it had set.
33
+ - Custom CSS parse rejects braces and comments and only accepts `--dsw-*` /
34
+ `--dsh-*` names, so a typo cannot write arbitrary CSS.
35
+ - 12 presets (was 10); 47 tests (was 26), including a DOM double that tests the
36
+ real apply path.
37
+
3
38
  ## 0.2.0
4
39
 
5
40
  - 4 new presets: Gruvbox, Solarized, Tokyo Night, Catppuccin.
package/README.md CHANGED
@@ -1,22 +1,28 @@
1
1
  # dsh-theme-studio
2
2
 
3
- A [dsh](https://github.com/deepseek-ai/deepseek-harness) plugin that lets you customize the UI theme: pick pre-built color presets, set a custom accent color, adjust density, border radius, and font family, or write custom CSS variable overrides.
3
+ A [dsh](https://github.com/deepseek-ai/deepseek-harness) plugin that customizes the UI theme: accent presets, a custom accent color, content density, font family, an animation toggle, and raw design-token overrides.
4
+
5
+ ## What it can and cannot change
6
+
7
+ This plugin only writes design tokens that **actually exist in dsh**, verified by reading the shipped stylesheets (`@deepseek-ai/dsh-client-ui-theme/lib/client.js`). Every token it writes is listed in `src/tokens.ts` and enforced by `tests/tokens.test.mjs`.
8
+
9
+ That constraint has two visible consequences:
10
+
11
+ - **No border-radius control.** dsh hardcodes `border-radius` per component — including `50%` circles and `corner-shape: round` — and ships no radius token. There is no faithful way to offer this, so it is not offered.
12
+ - **The animation toggle is a stylesheet, not a token.** dsh ships no motion tokens, so disabling animations injects a rule compressing transition and animation durations to `0.001ms` rather than `0s` — a zero duration can stop `transitionend` / `animationend` from firing and hang components that wait on them.
4
13
 
5
14
  ## Features
6
15
 
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
11
- - **Density** control: Compact / Comfortable / Spacious
12
- - **Border radius** control: Sharp / Rounded / Soft
13
- - **Font family** selection: System / Monospace / Serif
14
- - **Animation toggle** — turn off UI transitions for a snappier, low-motion interface
15
- - **Custom CSS** textarea for advanced `--property: value;` overrides
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
18
- - **Instant apply** — changes take effect immediately via CSS custom properties
19
- - **Persistent** — preferences saved in `localStorage`, no server round-trips
16
+ - **12 presets**: Ocean, Forest, Sunset, Monochrome, Nord, Dracula, Gruvbox, Solarized, Tokyo Night, Catppuccin, Rosé, Ember
17
+ - **Dark-aware presets** — a preset whose dark accent differs from its light one adapts automatically when dsh enters dark mode
18
+ - **Custom accent color**, plus a separate dark-mode accent
19
+ - **Contrast guard** — a dark-mode accent below a WCAG luminance floor is lightened in steps, and the panel reports that it was adjusted
20
+ - **Density**: Compact / Comfortable / Spacious (via the verified `--dsh-content-font-size` token)
21
+ - **Font family**: System / Monospace / Serif
22
+ - **Animation toggle**
23
+ - **Custom CSS** — override any `--dsw-*` or `--dsh-*` token
24
+ - **Import / export** themes as validated JSON
25
+ - **Live preview** rendering the real tokens, showing the accent value currently in effect
20
26
 
21
27
  ## Install
22
28
 
@@ -24,7 +30,7 @@ A [dsh](https://github.com/deepseek-ai/deepseek-harness) plugin that lets you cu
24
30
  dsh plugin add dsh-theme-studio
25
31
  ```
26
32
 
27
- Or from source:
33
+ From source:
28
34
 
29
35
  ```bash
30
36
  git clone https://github.com/hj01857655/dsh-theme-studio.git
@@ -35,9 +41,12 @@ dsh plugin add link:.
35
41
 
36
42
  ## How it works
37
43
 
38
- The plugin registers a `settings.section` slot (order 47) that renders a Theme Studio page in the dsh Settings panel. All theme state is client-side: preferences persist in `localStorage` and are applied by setting CSS custom properties on `document.documentElement.style`.
44
+ The plugin registers a `settings.section` slot (order 47). Preferences persist in `localStorage`.
45
+
46
+ Two implementation details are load-bearing:
39
47
 
40
- The overrides target dsh's `--dsw-alias-*` semantic token layer, so they cascade through every component that reads the design tokensno component patching required.
48
+ 1. **Properties are written to `document.body`, not `documentElement`.** dsh defines its palette on `body` and `body[data-ds-dark-theme]`. A custom property set on `documentElement` is inherited into `body`, but body's own declaration for the same property wins so writing to `documentElement` changes nothing at all. An earlier version made exactly that mistake.
49
+ 2. **The preview is not a mock.** It renders with the same variable names the application uses, so a wrong token looks wrong in the preview too, rather than being masked by a fallback color.
41
50
 
42
51
  ## License
43
52
 
package/lib/apply.d.ts ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Applying a theme to the live document.
3
+ *
4
+ * The important detail here is WHERE the properties are written. dsh defines
5
+ * its whole palette on `body` and `body[data-ds-dark-theme]`
6
+ * (`@deepseek-ai/dsh-client-ui-theme/lib/client.js`). Custom properties set on
7
+ * `documentElement` are inherited into `body`, but body's own declaration for
8
+ * the same property wins — so writing to `documentElement` produces exactly
9
+ * zero visible change. Everything is therefore written to `document.body`.
10
+ *
11
+ * Dark mode is read from the same attribute dsh's theme plugin toggles, so a
12
+ * preset's dark palette follows the host without a separate user action.
13
+ *
14
+ * These functions touch the DOM, so the pure resolution logic lives in
15
+ * `resolve.ts` and is what the tests exercise.
16
+ *
17
+ * @module apply
18
+ */
19
+ import type { ThemePreferences } from './types.js';
20
+ /**
21
+ * True when a custom-CSS property name belongs to dsh's own token namespaces.
22
+ *
23
+ * This is the boundary for the escape hatch: users may override any token dsh
24
+ * actually owns, but not arbitrary CSS (`position`, `display`, …) which could
25
+ * break layout in ways the panel can't undo.
26
+ */
27
+ export declare function isDshToken(name: string): boolean;
28
+ /** dsh marks dark mode with this attribute on <body>. */
29
+ export declare function isDarkMode(): boolean;
30
+ /** Watch the dark-mode attribute; returns an unsubscribe function. */
31
+ export declare function observeDarkMode(onChange: (dark: boolean) => void): () => void;
32
+ /**
33
+ * Resolve the accent color for the current mode.
34
+ *
35
+ * In dark mode the accent is pushed through the contrast guard, because a color
36
+ * that reads well on white can sink into a dark surface. Returns whether the
37
+ * guard changed anything so the panel can say so instead of silently swapping
38
+ * the user's pick.
39
+ */
40
+ export declare function resolveAccent(prefs: ThemePreferences, dark: boolean): {
41
+ accent: string | null;
42
+ adjusted: boolean;
43
+ };
44
+ /**
45
+ * Parse `--property: value;` lines from the custom CSS textarea.
46
+ *
47
+ * Only names approved by the caller are kept, so a typo or a deliberately
48
+ * broad override can't reach the document.
49
+ */ export declare function parseCustomCss(css: string, allow: (name: string) => boolean): Record<string, string>;
50
+ /** Remove every property this plugin may have set on the target. */
51
+ export declare function clearManagedProperties(fullCustomCss?: string): void;
52
+ /**
53
+ * Apply a preference set to the document.
54
+ *
55
+ * Returns whether the dark-mode contrast guard changed the accent, so the panel
56
+ * can report a real adjustment rather than an assumed one.
57
+ */
58
+ export declare function applyTheme(prefs: ThemePreferences, dark?: boolean): boolean;
59
+ /** Remove everything the plugin applied, including the motion stylesheet. */
60
+ export declare function resetTheme(): void;
package/lib/apply.js ADDED
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Applying a theme to the live document.
3
+ *
4
+ * The important detail here is WHERE the properties are written. dsh defines
5
+ * its whole palette on `body` and `body[data-ds-dark-theme]`
6
+ * (`@deepseek-ai/dsh-client-ui-theme/lib/client.js`). Custom properties set on
7
+ * `documentElement` are inherited into `body`, but body's own declaration for
8
+ * the same property wins — so writing to `documentElement` produces exactly
9
+ * zero visible change. Everything is therefore written to `document.body`.
10
+ *
11
+ * Dark mode is read from the same attribute dsh's theme plugin toggles, so a
12
+ * preset's dark palette follows the host without a separate user action.
13
+ *
14
+ * These functions touch the DOM, so the pure resolution logic lives in
15
+ * `resolve.ts` and is what the tests exercise.
16
+ *
17
+ * @module apply
18
+ */
19
+ import { STYLE_ELEMENT_ID } from './types.js';
20
+ import { DENSITY_TOKENS, FONT_TOKENS, MOTION_OFF_CSS, accentTokens } from './tokens.js';
21
+ import { getPreset } from './themes.js';
22
+ import { ensureDarkContrast } from './io.js';
23
+ /** The element dsh defines its palette on — writing anywhere else is ignored. */
24
+ function themeTarget() {
25
+ if (typeof document === 'undefined')
26
+ return null;
27
+ return document.body ?? null;
28
+ }
29
+ /**
30
+ * True when a custom-CSS property name belongs to dsh's own token namespaces.
31
+ *
32
+ * This is the boundary for the escape hatch: users may override any token dsh
33
+ * actually owns, but not arbitrary CSS (`position`, `display`, …) which could
34
+ * break layout in ways the panel can't undo.
35
+ */
36
+ export function isDshToken(name) {
37
+ return /^--dsh-\S+$/.test(name) || /^--dsw-\S+$/.test(name);
38
+ }
39
+ /** dsh marks dark mode with this attribute on <body>. */
40
+ export function isDarkMode() {
41
+ if (typeof document === 'undefined')
42
+ return false;
43
+ return document.body.hasAttribute('data-ds-dark-theme');
44
+ }
45
+ /** Watch the dark-mode attribute; returns an unsubscribe function. */
46
+ export function observeDarkMode(onChange) {
47
+ if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') {
48
+ return () => { };
49
+ }
50
+ const observer = new MutationObserver(() => onChange(isDarkMode()));
51
+ observer.observe(document.body, {
52
+ attributes: true,
53
+ attributeFilter: ['data-ds-dark-theme'],
54
+ });
55
+ return () => observer.disconnect();
56
+ }
57
+ /**
58
+ * Resolve the accent color for the current mode.
59
+ *
60
+ * In dark mode the accent is pushed through the contrast guard, because a color
61
+ * that reads well on white can sink into a dark surface. Returns whether the
62
+ * guard changed anything so the panel can say so instead of silently swapping
63
+ * the user's pick.
64
+ */
65
+ export function resolveAccent(prefs, dark) {
66
+ const chosen = dark ? (prefs.darkAccentColor ?? prefs.accentColor) : prefs.accentColor;
67
+ if (chosen === null || chosen.trim() === '')
68
+ return { accent: null, adjusted: false };
69
+ if (!dark)
70
+ return { accent: chosen, adjusted: false };
71
+ const guarded = ensureDarkContrast(chosen);
72
+ return { accent: guarded.color, adjusted: guarded.adjusted };
73
+ }
74
+ /**
75
+ * Parse `--property: value;` lines from the custom CSS textarea.
76
+ *
77
+ * Only names approved by the caller are kept, so a typo or a deliberately
78
+ * broad override can't reach the document.
79
+ */ export function parseCustomCss(css, allow) {
80
+ const out = {};
81
+ for (const line of css.split('\n')) {
82
+ // Reject values containing braces or a comment opener so a line can't
83
+ // smuggle in a new rule or escape the declaration it looks like.
84
+ const match = line.match(/^\s*(--[\w-]+)\s*:\s*([^;{}/*]+?)\s*;?\s*$/);
85
+ if (match === null)
86
+ continue;
87
+ if (!allow(match[1]))
88
+ continue;
89
+ out[match[1]] = match[2];
90
+ }
91
+ return out;
92
+ }
93
+ /** Remove every property this plugin may have set on the target. */
94
+ export function clearManagedProperties(fullCustomCss = '') {
95
+ const target = themeTarget();
96
+ if (target === null)
97
+ return;
98
+ const names = new Set([
99
+ ...Object.keys(accentTokens('#000000')),
100
+ ...Object.values(DENSITY_TOKENS).flatMap((t) => Object.keys(t)),
101
+ ...Object.values(FONT_TOKENS).flatMap((t) => Object.keys(t)),
102
+ ]);
103
+ for (const line of fullCustomCss.split('\n')) {
104
+ const match = line.match(/^\s*(--[\w-]+)\s*:/);
105
+ if (match !== null)
106
+ names.add(match[1]);
107
+ }
108
+ for (const name of names)
109
+ target.style.removeProperty(name);
110
+ }
111
+ /** Ensure the plugin's own stylesheet exists; returns it or null. */
112
+ function motionStylesheet() {
113
+ if (typeof document === 'undefined')
114
+ return null;
115
+ let el = document.getElementById(STYLE_ELEMENT_ID);
116
+ if (el === null) {
117
+ el = document.createElement('style');
118
+ el.id = STYLE_ELEMENT_ID;
119
+ document.head.appendChild(el);
120
+ }
121
+ return el;
122
+ }
123
+ /**
124
+ * Apply a preference set to the document.
125
+ *
126
+ * Returns whether the dark-mode contrast guard changed the accent, so the panel
127
+ * can report a real adjustment rather than an assumed one.
128
+ */
129
+ export function applyTheme(prefs, dark = isDarkMode()) {
130
+ const target = themeTarget();
131
+ if (target === null)
132
+ return false;
133
+ clearManagedProperties(prefs.customCss);
134
+ const tokens = {};
135
+ // Preset accents first, so an explicit accent color overrides its preset.
136
+ if (prefs.preset !== null) {
137
+ const found = getPreset(prefs.preset);
138
+ if (found !== undefined) {
139
+ Object.assign(tokens, found.tokens);
140
+ if (dark && found.darkTokens !== undefined)
141
+ Object.assign(tokens, found.darkTokens);
142
+ }
143
+ }
144
+ const { accent, adjusted } = resolveAccent(prefs, dark);
145
+ if (accent !== null)
146
+ Object.assign(tokens, accentTokens(accent));
147
+ Object.assign(tokens, DENSITY_TOKENS[prefs.density] ?? {});
148
+ Object.assign(tokens, FONT_TOKENS[prefs.fontFamily] ?? {});
149
+ for (const [name, value] of Object.entries(tokens)) {
150
+ target.style.setProperty(name, value);
151
+ }
152
+ // Custom CSS is user-authored, so it goes last and wins — but it stays
153
+ // inside dsh's own variable namespaces, so a typo can't write arbitrary
154
+ // properties onto the element.
155
+ const custom = parseCustomCss(prefs.customCss, isDshToken);
156
+ for (const [name, value] of Object.entries(custom)) {
157
+ target.style.setProperty(name, value);
158
+ }
159
+ const sheet = motionStylesheet();
160
+ if (sheet !== null)
161
+ sheet.textContent = prefs.animations ? '' : MOTION_OFF_CSS;
162
+ return adjusted;
163
+ }
164
+ /** Remove everything the plugin applied, including the motion stylesheet. */
165
+ export function resetTheme() {
166
+ clearManagedProperties();
167
+ const sheet = typeof document !== 'undefined'
168
+ ? document.getElementById(STYLE_ELEMENT_ID)
169
+ : null;
170
+ if (sheet !== null)
171
+ sheet.textContent = '';
172
+ }
package/lib/io.js CHANGED
@@ -18,7 +18,6 @@ export function exportTheme(prefs, name) {
18
18
  return JSON.stringify(payload, null, 2);
19
19
  }
20
20
  const DENSITIES = new Set(['compact', 'comfortable', 'spacious']);
21
- const RADII = new Set(['sharp', 'rounded', 'soft']);
22
21
  const FONTS = new Set(['system', 'mono', 'serif']);
23
22
  function asColor(value) {
24
23
  if (typeof value !== 'string')
@@ -60,9 +59,6 @@ export function parseTheme(raw) {
60
59
  if (typeof source['density'] === 'string' && DENSITIES.has(source['density'])) {
61
60
  result.density = source['density'];
62
61
  }
63
- if (typeof source['radius'] === 'string' && RADII.has(source['radius'])) {
64
- result.radius = source['radius'];
65
- }
66
62
  if (typeof source['fontFamily'] === 'string' && FONTS.has(source['fontFamily'])) {
67
63
  result.fontFamily = source['fontFamily'];
68
64
  }