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 +35 -0
- package/README.md +26 -17
- package/lib/apply.d.ts +60 -0
- package/lib/apply.js +172 -0
- package/lib/io.js +0 -4
- package/lib/theme-studio.web.js +351 -348
- package/lib/themes.d.ts +4 -13
- package/lib/themes.js +27 -199
- package/lib/tokens.d.ts +67 -0
- package/lib/tokens.js +151 -0
- package/lib/types.d.ts +4 -5
- package/lib/types.js +2 -1
- package/package.json +2 -2
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
|
|
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
|
-
- **
|
|
8
|
-
- **Dark-
|
|
9
|
-
- **Custom accent color
|
|
10
|
-
- **Contrast guard** — a dark-mode accent
|
|
11
|
-
- **Density
|
|
12
|
-
- **
|
|
13
|
-
- **
|
|
14
|
-
- **
|
|
15
|
-
- **
|
|
16
|
-
- **
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
}
|