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 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 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
- - **6 pre-built presets**: Ocean, Forest, Sunset, Monochrome, Nord, Dracula
8
- - **Custom accent color** picker with hex input
9
- - **Density** control: Compact / Comfortable / Spacious
10
- - **Border radius** control: Sharp / Rounded / Soft
11
- - **Font family** selection: System / Monospace / Serif
12
- - **Custom CSS** textarea for advanced `--property: value;` overrides
13
- - **Live preview** with badges, cards, and buttons
14
- - **Instant apply** — changes take effect immediately via CSS custom properties
15
- - **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
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
- Or from source:
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) 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:
35
47
 
36
- 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.
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
+ }