dsh-theme-studio 0.1.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 +49 -0
- package/README.md +26 -13
- package/lib/apply.d.ts +60 -0
- package/lib/apply.js +172 -0
- package/lib/io.d.ts +48 -0
- package/lib/io.js +142 -0
- package/lib/theme-studio.web.js +641 -254
- package/lib/themes.d.ts +4 -10
- package/lib/themes.js +27 -115
- package/lib/tokens.d.ts +67 -0
- package/lib/tokens.js +151 -0
- package/lib/types.d.ts +8 -4
- package/lib/types.js +4 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,54 @@
|
|
|
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
|
+
|
|
38
|
+
## 0.2.0
|
|
39
|
+
|
|
40
|
+
- 4 new presets: Gruvbox, Solarized, Tokyo Night, Catppuccin.
|
|
41
|
+
- Dark-mode aware presets via `darkTokens`; the panel watches `body[data-ds-dark-theme]`.
|
|
42
|
+
- Separate dark-mode accent color.
|
|
43
|
+
- Contrast guard: a dark accent below the WCAG luminance floor is lightened in
|
|
44
|
+
steps, and the panel reports that it was adjusted.
|
|
45
|
+
- Animation toggle (disables UI transitions).
|
|
46
|
+
- Theme import/export as JSON, with validation; accepts both the envelope and a
|
|
47
|
+
bare preferences object, and reports a real error for non-theme input.
|
|
48
|
+
- Unified token resolution: switching a preset off now clears the properties it
|
|
49
|
+
had set instead of leaving them behind.
|
|
50
|
+
- 26 tests across theme data and color/IO logic.
|
|
51
|
+
|
|
3
52
|
## 0.1.0
|
|
4
53
|
|
|
5
54
|
- Initial release.
|
package/README.md
CHANGED
|
@@ -1,18 +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
|
-
- **
|
|
9
|
-
- **
|
|
10
|
-
- **
|
|
11
|
-
- **
|
|
12
|
-
- **
|
|
13
|
-
- **
|
|
14
|
-
- **
|
|
15
|
-
- **
|
|
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
|
|
16
26
|
|
|
17
27
|
## Install
|
|
18
28
|
|
|
@@ -20,7 +30,7 @@ A [dsh](https://github.com/deepseek-ai/deepseek-harness) plugin that lets you cu
|
|
|
20
30
|
dsh plugin add dsh-theme-studio
|
|
21
31
|
```
|
|
22
32
|
|
|
23
|
-
|
|
33
|
+
From source:
|
|
24
34
|
|
|
25
35
|
```bash
|
|
26
36
|
git clone https://github.com/hj01857655/dsh-theme-studio.git
|
|
@@ -31,9 +41,12 @@ dsh plugin add link:.
|
|
|
31
41
|
|
|
32
42
|
## How it works
|
|
33
43
|
|
|
34
|
-
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:
|
|
35
47
|
|
|
36
|
-
|
|
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.
|
|
37
50
|
|
|
38
51
|
## License
|
|
39
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.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,142 @@
|
|
|
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 FONTS = new Set(['system', 'mono', 'serif']);
|
|
22
|
+
function asColor(value) {
|
|
23
|
+
if (typeof value !== 'string')
|
|
24
|
+
return null;
|
|
25
|
+
const trimmed = value.trim();
|
|
26
|
+
if (trimmed === '')
|
|
27
|
+
return null;
|
|
28
|
+
return /^#[0-9a-fA-F]{3,8}$/.test(trimmed) ? trimmed : null;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Parse a theme JSON document.
|
|
32
|
+
*
|
|
33
|
+
* Every field is validated and falls back to the default rather than throwing,
|
|
34
|
+
* so a partially-hand-edited file still applies what it can. The only hard
|
|
35
|
+
* failure is input that is not a JSON object at all — that returns `null` so
|
|
36
|
+
* the caller can report a real import error instead of silently applying defaults.
|
|
37
|
+
*/
|
|
38
|
+
export function parseTheme(raw) {
|
|
39
|
+
let parsed;
|
|
40
|
+
try {
|
|
41
|
+
parsed = JSON.parse(raw);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
47
|
+
return null;
|
|
48
|
+
const doc = parsed;
|
|
49
|
+
// Accept both the envelope and a bare preferences object.
|
|
50
|
+
const source = (doc['preferences'] !== null && typeof doc['preferences'] === 'object' && !Array.isArray(doc['preferences']))
|
|
51
|
+
? doc['preferences']
|
|
52
|
+
: doc;
|
|
53
|
+
const result = { ...DEFAULT_PREFERENCES };
|
|
54
|
+
if (typeof source['preset'] === 'string' || source['preset'] === null) {
|
|
55
|
+
result.preset = source['preset'];
|
|
56
|
+
}
|
|
57
|
+
result.accentColor = asColor(source['accentColor']);
|
|
58
|
+
result.darkAccentColor = asColor(source['darkAccentColor']);
|
|
59
|
+
if (typeof source['density'] === 'string' && DENSITIES.has(source['density'])) {
|
|
60
|
+
result.density = source['density'];
|
|
61
|
+
}
|
|
62
|
+
if (typeof source['fontFamily'] === 'string' && FONTS.has(source['fontFamily'])) {
|
|
63
|
+
result.fontFamily = source['fontFamily'];
|
|
64
|
+
}
|
|
65
|
+
if (typeof source['animations'] === 'boolean')
|
|
66
|
+
result.animations = source['animations'];
|
|
67
|
+
if (typeof source['customCss'] === 'string')
|
|
68
|
+
result.customCss = source['customCss'];
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
/** Parse `#rgb` or `#rrggbb` into RGB components; `null` when unparseable. */
|
|
72
|
+
export function hexToRgb(hex) {
|
|
73
|
+
const value = hex.trim().replace(/^#/, '');
|
|
74
|
+
if (value.length === 3) {
|
|
75
|
+
const [r, g, b] = value.split('');
|
|
76
|
+
return [parseInt(r + r, 16), parseInt(g + g, 16), parseInt(b + b, 16)];
|
|
77
|
+
}
|
|
78
|
+
if (value.length === 6) {
|
|
79
|
+
return [
|
|
80
|
+
parseInt(value.slice(0, 2), 16),
|
|
81
|
+
parseInt(value.slice(2, 4), 16),
|
|
82
|
+
parseInt(value.slice(4, 6), 16),
|
|
83
|
+
];
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
/** WCAG relative luminance, 0 (black) → 1 (white). */
|
|
88
|
+
export function luminance(hex) {
|
|
89
|
+
const rgb = hexToRgb(hex);
|
|
90
|
+
if (rgb === null)
|
|
91
|
+
return 0;
|
|
92
|
+
const [r, g, b] = rgb.map((c) => {
|
|
93
|
+
const s = c / 255;
|
|
94
|
+
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
|
95
|
+
});
|
|
96
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
97
|
+
}
|
|
98
|
+
/** Convert RGB components back into a `#rrggbb` string. */
|
|
99
|
+
export function rgbToHex(r, g, b) {
|
|
100
|
+
const clamp = (n) => Math.max(0, Math.min(255, Math.round(n)));
|
|
101
|
+
return '#' + [r, g, b].map((n) => clamp(n).toString(16).padStart(2, '0')).join('');
|
|
102
|
+
}
|
|
103
|
+
/** Blend a color toward white by `amount` (0–1). */
|
|
104
|
+
export function lighten(hex, amount) {
|
|
105
|
+
const rgb = hexToRgb(hex);
|
|
106
|
+
if (rgb === null)
|
|
107
|
+
return hex;
|
|
108
|
+
const [r, g, b] = rgb;
|
|
109
|
+
return rgbToHex(r + (255 - r) * amount, g + (255 - g) * amount, b + (255 - b) * amount);
|
|
110
|
+
}
|
|
111
|
+
/** Blend a color toward black by `amount` (0–1). */
|
|
112
|
+
export function darken(hex, amount) {
|
|
113
|
+
const rgb = hexToRgb(hex);
|
|
114
|
+
if (rgb === null)
|
|
115
|
+
return hex;
|
|
116
|
+
const [r, g, b] = rgb;
|
|
117
|
+
return rgbToHex(r * (1 - amount), g * (1 - amount), b * (1 - amount));
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Ensure an accent color stays legible on a dark background.
|
|
121
|
+
*
|
|
122
|
+
* A mid-tone accent that reads well on white can sit too close to a dark
|
|
123
|
+
* surface. Rather than silently swapping the user's color, we lighten it in
|
|
124
|
+
* steps until it clears a luminance floor and report whether we changed it, so
|
|
125
|
+
* the panel can say so instead of the user wondering why their pick looks off.
|
|
126
|
+
*/
|
|
127
|
+
export function ensureDarkContrast(hex) {
|
|
128
|
+
const MIN_LUMINANCE = 0.18;
|
|
129
|
+
// An unparseable value can't be measured, so it can't be corrected either —
|
|
130
|
+
// returning it unadjusted keeps the panel from claiming it fixed something.
|
|
131
|
+
if (hexToRgb(hex) === null)
|
|
132
|
+
return { color: hex, adjusted: false };
|
|
133
|
+
if (luminance(hex) >= MIN_LUMINANCE)
|
|
134
|
+
return { color: hex, adjusted: false };
|
|
135
|
+
let current = hex;
|
|
136
|
+
for (let i = 0; i < 10; i += 1) {
|
|
137
|
+
current = lighten(current, 0.12);
|
|
138
|
+
if (luminance(current) >= MIN_LUMINANCE)
|
|
139
|
+
return { color: current, adjusted: true };
|
|
140
|
+
}
|
|
141
|
+
return { color: current, adjusted: true };
|
|
142
|
+
}
|