@d0nj/dsh-ui-custom 0.0.0-stage → 0.1.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.
@@ -0,0 +1,294 @@
1
+ /**
2
+ * Theme Studio: the settings page.
3
+ *
4
+ * Live preview is computed from the same resolver the stored document goes
5
+ * through, so what the panel shows and what the plugin applies cannot drift.
6
+ * Writes go through `configForms.get('ui-custom').set/unset`, which means the
7
+ * panel never touches YAML, never races a revision, and is reversible field by
8
+ * field.
9
+ *
10
+ * Styling follows the plugin rules: theme tokens only (`--dsw-alias-*`, plus the
11
+ * `--d0nj-*` geometry tokens this plugin derives), no literal colours for
12
+ * chrome. The swatches themselves are the one place a literal colour is correct,
13
+ * because they *are* the colour being chosen.
14
+ *
15
+ * @module @d0nj/dsh-ui-custom/panel
16
+ */
17
+ /** Shared inline styles, computed from theme tokens. */
18
+ function styles() {
19
+ return {
20
+ root: {
21
+ display: 'flex',
22
+ flexDirection: 'column',
23
+ gap: 24,
24
+ padding: '4px 0 32px',
25
+ color: 'var(--dsw-alias-label-primary)',
26
+ fontFamily: 'inherit',
27
+ },
28
+ section: { display: 'flex', flexDirection: 'column', gap: 10 },
29
+ heading: { fontSize: 13, fontWeight: 600, color: 'var(--dsw-alias-label-primary)' },
30
+ hint: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)', lineHeight: 1.5 },
31
+ grid: {
32
+ display: 'grid',
33
+ gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))',
34
+ gap: 10,
35
+ },
36
+ card: {
37
+ display: 'flex',
38
+ flexDirection: 'column',
39
+ gap: 8,
40
+ padding: 10,
41
+ borderRadius: 'var(--d0nj-radius-md, 8px)',
42
+ border: '1px solid var(--dsw-alias-border-l1)',
43
+ // No background and no colour token here: a card represents a theme, so it
44
+ // paints that theme's own surface (set per card, below). Using a token made
45
+ // every card show the live theme instead, which read as banding.
46
+ background: 'transparent',
47
+ cursor: 'pointer',
48
+ textAlign: 'left',
49
+ color: 'var(--dsw-alias-label-primary)',
50
+ font: 'inherit',
51
+ // The UA focus ring lingers on whichever card was last clicked; a
52
+ // focus-visible ring keeps keyboard affordance without that trail.
53
+ outline: 'none',
54
+ },
55
+ cardActive: {
56
+ borderColor: 'var(--dsw-alias-brand-primary)',
57
+ borderWidth: 2,
58
+ padding: 9,
59
+ },
60
+ swatches: { display: 'flex', gap: 4 },
61
+ swatch: {
62
+ width: 22,
63
+ height: 22,
64
+ borderRadius: 'var(--d0nj-radius-sm, 4px)',
65
+ border: '1px solid var(--dsw-alias-border-l1)',
66
+ },
67
+ row: {
68
+ display: 'grid',
69
+ gridTemplateColumns: 'minmax(120px, 1fr) auto auto',
70
+ gap: 8,
71
+ alignItems: 'center',
72
+ padding: '6px 0',
73
+ borderBottom: '1px solid var(--dsw-alias-border-l1)',
74
+ },
75
+ rowLabel: { display: 'flex', flexDirection: 'column', gap: 2 },
76
+ input: {
77
+ width: 92,
78
+ padding: '4px 6px',
79
+ borderRadius: 'var(--d0nj-radius-sm, 4px)',
80
+ border: '1px solid var(--dsw-alias-border-l1)',
81
+ background: 'var(--dsw-alias-bg-base)',
82
+ color: 'var(--dsw-alias-label-primary)',
83
+ font: 'inherit',
84
+ fontSize: 12,
85
+ },
86
+ colorInput: {
87
+ width: 34,
88
+ height: 26,
89
+ padding: 0,
90
+ border: '1px solid var(--dsw-alias-border-l1)',
91
+ borderRadius: 'var(--d0nj-radius-sm, 4px)',
92
+ background: 'transparent',
93
+ cursor: 'pointer',
94
+ },
95
+ button: {
96
+ padding: '5px 10px',
97
+ borderRadius: 'var(--d0nj-radius-md, 8px)',
98
+ border: '1px solid var(--dsw-alias-border-l2)',
99
+ background: 'var(--dsw-alias-bg-layer-2)',
100
+ color: 'var(--dsw-alias-label-primary)',
101
+ font: 'inherit',
102
+ fontSize: 12,
103
+ cursor: 'pointer',
104
+ },
105
+ warning: {
106
+ padding: '8px 10px',
107
+ borderRadius: 'var(--d0nj-radius-md, 8px)',
108
+ border: '1px solid var(--dsw-alias-state-warn-primary)',
109
+ color: 'var(--dsw-alias-state-warn-primary)',
110
+ fontSize: 12,
111
+ },
112
+ preview: {
113
+ display: 'flex',
114
+ flexDirection: 'column',
115
+ gap: 8,
116
+ padding: 14,
117
+ borderRadius: 'var(--d0nj-radius-lg, 12px)',
118
+ border: '1px solid var(--dsw-alias-border-l1)',
119
+ background: 'var(--dsw-alias-bg-base)',
120
+ },
121
+ row2: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' },
122
+ };
123
+ }
124
+ /**
125
+ * Render the Theme Studio page.
126
+ *
127
+ * Written against a minimal `React.createElement` shim rather than JSX so the
128
+ * module needs no transform at build time; the client bundle supplies React.
129
+ *
130
+ * @param React - the React namespace from the module table.
131
+ * @param props - current state and the actions to invoke.
132
+ * @returns the section element.
133
+ */
134
+ export function ThemeStudio(React, props) {
135
+ const h = React.createElement;
136
+ const s = styles();
137
+ const { state, actions, presets } = props;
138
+ // Local mirrors. The panel must react to a click immediately: a settings write
139
+ // is a round-trip through the Host, and waiting for the echo makes every
140
+ // control feel dead when the write is slow, refused, or not mirrored at all.
141
+ // Local state is the source of truth for rendering; the action is the command.
142
+ const [localPreset, setLocalPreset] = React.useState(state.basePreset);
143
+ const [localTokens, setLocalTokens] = React.useState({});
144
+ const [localGeometry, setLocalGeometry] = React.useState(state.geometry);
145
+ const [localTypography, setLocalTypography] = React.useState(state.typography);
146
+ /** The preset currently selected, for filling a missing mode from its colours. */
147
+ const basePreset = presets.find((preset) => preset.id === (localPreset ?? state.basePreset));
148
+ /**
149
+ * The effective value of one token: the local edit, else the stored override,
150
+ * else the base preset's own value for that mode.
151
+ */
152
+ const effective = (name) => {
153
+ const local = localTokens[name];
154
+ if (local !== undefined)
155
+ return local;
156
+ const stored = state.tokens.find((row) => row.name === name)?.override;
157
+ if (stored !== undefined)
158
+ return stored;
159
+ const presetValue = basePreset === undefined ? undefined : basePreset.tokens[name];
160
+ return presetValue ?? { light: '', dark: '' };
161
+ };
162
+ const presetCards = [
163
+ { id: 'none', label: 'No preset', description: 'Start from the built-in palette.', colorScheme: 'dark', tokens: {} },
164
+ ...presets,
165
+ ].map((preset) => {
166
+ const base = preset.tokens['--dsw-alias-bg-base'];
167
+ const accent = preset.tokens['--dsw-alias-brand-primary'];
168
+ const label = preset.tokens['--dsw-alias-label-primary'];
169
+ const active = (localPreset ?? state.basePreset) === preset.id;
170
+ return h('button', {
171
+ key: preset.id,
172
+ type: 'button',
173
+ style: {
174
+ ...s.card,
175
+ // Paint the preset's own surface and text colour, so the grid shows what
176
+ // each theme looks like rather than the one currently applied.
177
+ background: base?.light ?? 'transparent',
178
+ color: label?.light ?? 'var(--dsw-alias-label-primary)',
179
+ ...(active ? s.cardActive : {}),
180
+ },
181
+ 'aria-pressed': active,
182
+ onClick: () => {
183
+ setLocalPreset(preset.id);
184
+ setLocalTokens({});
185
+ actions.setPreset(preset.id);
186
+ },
187
+ }, h('div', { style: s.swatches }, h('span', { style: { ...s.swatch, background: base?.light ?? 'transparent' } }), h('span', { style: { ...s.swatch, background: base?.dark ?? 'transparent' } }), h('span', { style: { ...s.swatch, background: accent?.dark ?? 'transparent' } }), h('span', { style: { ...s.swatch, background: label?.dark ?? 'transparent' } })), h('span', { style: { fontSize: 13, fontWeight: 600 } }, preset.label), h('span', { style: s.hint }, preset.description));
188
+ });
189
+ const tokenRows = state.tokens.map((row) => {
190
+ const current = effective(row.name);
191
+ /**
192
+ * Apply one mode's edit and write both modes.
193
+ *
194
+ * The theme runtime needs a value for each scheme, so a half-typed row is
195
+ * completed from the preset rather than withheld: withholding was why typing
196
+ * appeared to do nothing until both fields happened to be filled.
197
+ */
198
+ const edit = (mode, value) => {
199
+ const next = { ...current, [mode]: value };
200
+ setLocalTokens({ ...localTokens, [row.name]: next });
201
+ if (next.light.length > 0 && next.dark.length > 0)
202
+ actions.setToken(row.name, next.light, next.dark);
203
+ };
204
+ return h('div', { key: row.name, style: s.row }, h('div', { style: s.rowLabel }, h('span', { style: { fontSize: 12, fontWeight: 600 } }, row.label), h('span', { style: s.hint }, row.description)), h('div', { style: s.row2 }, h('input', {
205
+ style: s.colorInput,
206
+ type: 'color',
207
+ 'aria-label': `${row.label} light`,
208
+ value: /^#[0-9a-f]{6}$/iu.test(current.light) ? current.light : '#000000',
209
+ onChange: (event) => edit('light', event.target.value),
210
+ }), h('input', {
211
+ style: s.input,
212
+ type: 'text',
213
+ 'aria-label': `${row.label} light value`,
214
+ placeholder: 'light',
215
+ value: current.light,
216
+ onChange: (event) => edit('light', event.target.value),
217
+ })), h('div', { style: s.row2 }, h('input', {
218
+ style: s.colorInput,
219
+ type: 'color',
220
+ 'aria-label': `${row.label} dark`,
221
+ value: /^#[0-9a-f]{6}$/iu.test(current.dark) ? current.dark : '#000000',
222
+ onChange: (event) => edit('dark', event.target.value),
223
+ }), h('input', {
224
+ style: s.input,
225
+ type: 'text',
226
+ 'aria-label': `${row.label} dark value`,
227
+ placeholder: 'dark',
228
+ value: current.dark,
229
+ onChange: (event) => edit('dark', event.target.value),
230
+ }), h('button', {
231
+ type: 'button',
232
+ style: s.button,
233
+ onClick: () => {
234
+ const next = { ...localTokens };
235
+ delete next[row.name];
236
+ setLocalTokens(next);
237
+ actions.clearToken(row.name);
238
+ },
239
+ }, 'Reset')));
240
+ });
241
+ const fontRow = (role, label, family, size) => h('div', { style: s.row }, h('div', { style: s.rowLabel }, h('span', { style: { fontSize: 12, fontWeight: 600 } }, label)), h('input', {
242
+ style: { ...s.input, width: 260 },
243
+ type: 'text',
244
+ 'aria-label': `${label} font stack`,
245
+ placeholder: 'Font family, fallback, sans-serif',
246
+ value: family,
247
+ onChange: (event) => {
248
+ const key = role === 'text' ? 'textFontFamily' : role === 'code' ? 'codeFontFamily' : 'terminalFontFamily';
249
+ setLocalTypography({ ...(localTypography ?? state.typography), [key]: event.target.value });
250
+ actions.setFont(role, event.target.value);
251
+ },
252
+ }), h('div', { style: s.row2 }, h('input', {
253
+ style: s.input,
254
+ type: 'number',
255
+ min: 8,
256
+ max: 24,
257
+ 'aria-label': `${label} size`,
258
+ value: size,
259
+ onChange: (event) => {
260
+ const px = Number(event.target.value);
261
+ const key = role === 'text' ? 'fontSize' : role === 'code' ? 'codeFontSize' : 'terminalFontSize';
262
+ setLocalTypography({ ...(localTypography ?? state.typography), [key]: px });
263
+ actions.setFontSize(role, px);
264
+ },
265
+ }), h('span', { style: s.hint }, 'px')));
266
+ return h('div', { style: s.root }, state.writable
267
+ ? null
268
+ : h('div', { style: s.warning }, 'This profile does not accept settings writes; changes will not persist.'), h('section', { style: s.section }, h('h3', { style: s.heading }, 'Base theme'), h('p', { style: s.hint }, 'A preset supplies a complete token set. Tune individual values below it.'), h('div', { style: s.grid }, presetCards)), h('section', { style: s.section }, h('h3', { style: s.heading }, 'Live preview'), h('div', { style: s.preview }, h('span', { style: { fontSize: 15, fontWeight: 600 } }, 'The quick brown fox'), h('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } }, 'Secondary text sits one step down.'), h('div', { style: s.row2 }, h('span', { style: { ...s.button, background: 'var(--dsw-alias-brand-primary)', color: 'var(--dsw-alias-bg-base)' } }, 'Accent'), h('span', { style: { ...s.button, borderColor: 'var(--dsw-alias-state-error-primary)' } }, 'Error'), h('span', { style: { ...s.button, borderColor: 'var(--dsw-alias-state-success-primary)' } }, 'Success'), h('span', { style: { ...s.button, borderColor: 'var(--dsw-alias-state-warn-primary)' } }, 'Warning')))), h('section', { style: s.section }, h('h3', { style: s.heading }, 'Typography'), fontRow('text', 'Interface text', (localTypography ?? state.typography).textFontFamily, (localTypography ?? state.typography).fontSize), fontRow('code', 'Code', (localTypography ?? state.typography).codeFontFamily, (localTypography ?? state.typography).codeFontSize), fontRow('terminal', 'Terminal', (localTypography ?? state.typography).terminalFontFamily, (localTypography ?? state.typography).terminalFontSize)), h('section', { style: s.section }, h('h3', { style: s.heading }, 'Geometry'), h('div', { style: s.row }, h('div', { style: s.rowLabel }, h('span', { style: { fontSize: 12, fontWeight: 600 } }, 'Corner radius')), h('input', {
269
+ type: 'range',
270
+ min: 0,
271
+ max: 24,
272
+ value: (localGeometry ?? state.geometry).radius,
273
+ 'aria-label': 'Corner radius',
274
+ onChange: (event) => {
275
+ const radius = Number(event.target.value);
276
+ setLocalGeometry({ ...(localGeometry ?? state.geometry), radius });
277
+ actions.setRadius(radius);
278
+ },
279
+ }), h('span', { style: s.hint }, `${String((localGeometry ?? state.geometry).radius)}px`)), h('div', { style: s.row }, h('div', { style: s.rowLabel }, h('span', { style: { fontSize: 12, fontWeight: 600 } }, 'Density')), h('div', { style: s.row2 }, ...['compact', 'comfortable', 'spacious'].map((density) => h('button', {
280
+ key: density,
281
+ type: 'button',
282
+ style: (localGeometry ?? state.geometry).density === density
283
+ ? { ...s.button, borderColor: 'var(--dsw-alias-brand-primary)' }
284
+ : s.button,
285
+ 'aria-pressed': (localGeometry ?? state.geometry).density === density,
286
+ onClick: () => {
287
+ setLocalGeometry({ ...(localGeometry ?? state.geometry), density });
288
+ actions.setDensity(density);
289
+ },
290
+ }, density))), h('span', {}))), h('section', { style: s.section }, h('h3', { style: s.heading }, 'Colour tokens'), h('p', { style: s.hint }, 'Both values are required: one per palette mode, so switching scheme stays legible.'), ...tokenRows), state.warnings.length > 0
291
+ ? h('section', { style: s.section }, ...state.warnings.map((warning, index) => h('div', { key: String(index), style: s.warning }, warning)))
292
+ : null, h('section', { style: s.section }, h('button', { type: 'button', style: s.button, onClick: () => actions.resetAll() }, 'Reset all customization')));
293
+ }
294
+ //# sourceMappingURL=panel.js.map
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Host half: the customization document and its validation.
3
+ *
4
+ * The plugin does no rendering and holds no state of its own on the Host. It
5
+ * contributes one Config schema, which the profile stores and the browser half
6
+ * reads through its settings namespace, so a choice made in the panel survives a
7
+ * reload and a machine switch without this process tracking anything.
8
+ *
9
+ * `Config` is the contract; `resolve.ts` is the meaning. Keeping them apart means
10
+ * the panel can preview a change with the same resolver the stored document
11
+ * goes through, instead of reimplementing the merge rules in the browser.
12
+ *
13
+ * @module @d0nj/dsh-ui-custom
14
+ */
15
+ import z from '@deepseek-ai/schemastery';
16
+ import type { CustomizationDocument } from './resolve.js';
17
+ /** Cordis plugin name used by Loader diagnostics. */
18
+ export declare const name = "ui-custom";
19
+ /** No services are required: this plugin only contributes configuration. */
20
+ export declare const inject: readonly [];
21
+ /**
22
+ * The plugin's config: one customization document.
23
+ *
24
+ * `basePreset` accepts any preset id or `none`. The resolver reports an unknown
25
+ * id as a warning rather than failing validation, because a stored document
26
+ * naming a preset from a newer version must not stop the plugin mounting.
27
+ */
28
+ export declare const Config: ReturnType<typeof z.object>;
29
+ /**
30
+ * The resolved config value.
31
+ *
32
+ * Fields are mutable and optional where the schema supplies defaults, matching
33
+ * what `z.object` produces; nothing in this package mutates them.
34
+ */
35
+ /**
36
+ * The parsed shape of this plugin's config.
37
+ *
38
+ * Each field is olatile in the schema, so the Host hands pply a
39
+ * Volatile reference for it rather than a plain value; {@link toDocument}
40
+ * unwraps either form.
41
+ */
42
+ export interface PluginConfig {
43
+ basePreset?: string;
44
+ tokens?: Record<string, {
45
+ light: string;
46
+ dark: string;
47
+ }>;
48
+ typography?: {
49
+ textFontFamily?: string;
50
+ codeFontFamily?: string;
51
+ terminalFontFamily?: string;
52
+ fontSize?: number;
53
+ codeFontSize?: number;
54
+ terminalFontSize?: number;
55
+ };
56
+ geometry?: {
57
+ radius?: number;
58
+ density?: 'compact' | 'comfortable' | 'spacious';
59
+ };
60
+ }
61
+ /**
62
+ * Project a stored config into a complete customization document.
63
+ *
64
+ * The schema defaults every field, but a config read back from a partially
65
+ * written document may still be missing sections, so this fills them once here
66
+ * rather than at each use site.
67
+ * @param config - the config handed to `apply`.
68
+ * @returns a complete document.
69
+ */
70
+ export declare function toDocument(input: unknown): CustomizationDocument;
71
+ /**
72
+ * Host plugin body.
73
+ *
74
+ * The Host contributes no runtime behavior: the browser half owns the theme
75
+ * override layer, and the settings service owns persistence. Returning nothing
76
+ * keeps the mount a pure configuration declaration, so a profile can hold this
77
+ * row without this process doing any work per session.
78
+ */
79
+ export declare function apply(): void;
80
+ export { PRESETS, TOKEN_SPECS, TOKEN_GROUPS, PRESET_IDS } from './spec.js';
81
+ export { resolveCustomization } from './resolve.js';
82
+ export type { CustomizationDocument, ResolvedCustomization } from './resolve.js';
83
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Host half: the customization document and its validation.
3
+ *
4
+ * The plugin does no rendering and holds no state of its own on the Host. It
5
+ * contributes one Config schema, which the profile stores and the browser half
6
+ * reads through its settings namespace, so a choice made in the panel survives a
7
+ * reload and a machine switch without this process tracking anything.
8
+ *
9
+ * `Config` is the contract; `resolve.ts` is the meaning. Keeping them apart means
10
+ * the panel can preview a change with the same resolver the stored document
11
+ * goes through, instead of reimplementing the merge rules in the browser.
12
+ *
13
+ * @module @d0nj/dsh-ui-custom
14
+ */
15
+ import z from '@deepseek-ai/schemastery';
16
+ import { DENSITIES, FONT_SIZE_RANGES, PRESET_IDS, RADIUS_MAX, RADIUS_MIN } from './spec.js';
17
+ /** Cordis plugin name used by Loader diagnostics. */
18
+ export const name = 'ui-custom';
19
+ /** No services are required: this plugin only contributes configuration. */
20
+ export const inject = [];
21
+ /** One token override, with both palette modes required. */
22
+ const TokenModesConfig = z.object({
23
+ light: z.string().description('Value used while the light palette is active.'),
24
+ dark: z.string().description('Value used while the dark palette is active.'),
25
+ });
26
+ /** Font stacks and sizes. */
27
+ const TypographyConfig = z.object({
28
+ textFontFamily: z
29
+ .string()
30
+ .description('Interface and body font stack. Empty keeps the built-in stack.'),
31
+ codeFontFamily: z.string().description('Code font stack. Empty keeps the built-in stack.'),
32
+ terminalFontFamily: z
33
+ .string()
34
+ .description('Sidebar terminal font stack. Empty keeps the built-in stack.'),
35
+ fontSize: z
36
+ .number()
37
+ .step(1)
38
+ .min(FONT_SIZE_RANGES.text.min)
39
+ .max(FONT_SIZE_RANGES.text.max)
40
+ .default(FONT_SIZE_RANGES.text.fallback)
41
+ .description('Conversation text size in px.'),
42
+ codeFontSize: z
43
+ .number()
44
+ .step(1)
45
+ .min(FONT_SIZE_RANGES.code.min)
46
+ .max(FONT_SIZE_RANGES.code.max)
47
+ .default(FONT_SIZE_RANGES.code.fallback)
48
+ .description('Code block size in px.'),
49
+ terminalFontSize: z
50
+ .number()
51
+ .step(1)
52
+ .min(FONT_SIZE_RANGES.terminal.min)
53
+ .max(FONT_SIZE_RANGES.terminal.max)
54
+ .default(FONT_SIZE_RANGES.terminal.fallback)
55
+ .description('Sidebar terminal size in px.'),
56
+ });
57
+ /** Corner radius and spacing density. */
58
+ const GeometryConfig = z.object({
59
+ radius: z
60
+ .number()
61
+ .step(1)
62
+ .min(RADIUS_MIN)
63
+ .max(RADIUS_MAX)
64
+ .default(8)
65
+ .description('Corner radius in px.'),
66
+ density: z
67
+ .union([z.const('compact'), z.const('comfortable'), z.const('spacious')])
68
+ .default('comfortable')
69
+ .description('Spacing density.'),
70
+ });
71
+ /**
72
+ * The plugin's config: one customization document.
73
+ *
74
+ * `basePreset` accepts any preset id or `none`. The resolver reports an unknown
75
+ * id as a warning rather than failing validation, because a stored document
76
+ * naming a preset from a newer version must not stop the plugin mounting.
77
+ */
78
+ // Typed as the schema base so the emitted declaration stays portable: the fully
79
+ // inferred type of a z.object chain names cosmokit through a pnpm store path.
80
+ export const Config = z.object({
81
+ basePreset: z
82
+ .union([z.const('none'), ...PRESET_IDS.map((id) => z.const(id))])
83
+ .default('none')
84
+ .volatile()
85
+ .description(`Preset to build on: one of ${PRESET_IDS.join(', ')}, or none.`),
86
+ tokens: z
87
+ .dict(TokenModesConfig)
88
+ .volatile()
89
+ .description('Per-token overrides applied over the preset, keyed by CSS custom property name.'),
90
+ typography: TypographyConfig.volatile(),
91
+ geometry: GeometryConfig.volatile(),
92
+ });
93
+ /**
94
+ * Project a stored config into a complete customization document.
95
+ *
96
+ * The schema defaults every field, but a config read back from a partially
97
+ * written document may still be missing sections, so this fills them once here
98
+ * rather than at each use site.
99
+ * @param config - the config handed to `apply`.
100
+ * @returns a complete document.
101
+ */
102
+ export function toDocument(input) {
103
+ const config = (input ?? {});
104
+ const read = (value) => {
105
+ if (value !== null && typeof value === 'object' && 'get' in value) {
106
+ return value.get();
107
+ }
108
+ return value;
109
+ };
110
+ const geometry = read(config.geometry);
111
+ const typography = read(config.typography);
112
+ const density = geometry?.density;
113
+ return {
114
+ basePreset: read(config.basePreset) ?? 'none',
115
+ tokens: read(config.tokens) ?? {},
116
+ typography: {
117
+ textFontFamily: typography?.textFontFamily ?? '',
118
+ codeFontFamily: typography?.codeFontFamily ?? '',
119
+ terminalFontFamily: typography?.terminalFontFamily ?? '',
120
+ fontSize: typography?.fontSize ?? FONT_SIZE_RANGES.text.fallback,
121
+ codeFontSize: typography?.codeFontSize ?? FONT_SIZE_RANGES.code.fallback,
122
+ terminalFontSize: typography?.terminalFontSize ?? FONT_SIZE_RANGES.terminal.fallback,
123
+ },
124
+ geometry: {
125
+ radius: geometry?.radius ?? 8,
126
+ density: density !== undefined && DENSITIES.includes(density)
127
+ ? density
128
+ : 'comfortable',
129
+ },
130
+ };
131
+ }
132
+ /**
133
+ * Host plugin body.
134
+ *
135
+ * The Host contributes no runtime behavior: the browser half owns the theme
136
+ * override layer, and the settings service owns persistence. Returning nothing
137
+ * keeps the mount a pure configuration declaration, so a profile can hold this
138
+ * row without this process doing any work per session.
139
+ */
140
+ export function apply() { }
141
+ export { PRESETS, TOKEN_SPECS, TOKEN_GROUPS, PRESET_IDS } from './spec.js';
142
+ export { resolveCustomization } from './resolve.js';
143
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Resolving a customization document into theme tokens.
3
+ *
4
+ * Three inputs combine, in this order:
5
+ *
6
+ * 1. A **preset**, which is a complete token set.
7
+ * 2. **Per-token overrides**, applied on top of the preset.
8
+ * 3. **Geometry**, expanded into whichever tokens it can express.
9
+ *
10
+ * The result is one `ThemeTokenOverrides` object — token name to `{light, dark}` —
11
+ * which is exactly what `ctx.theme.overrideTokens` accepts. Keeping the merge
12
+ * here, as a pure function, is what lets the panel preview a change and the Host
13
+ * validate one from the same code.
14
+ *
15
+ * @module @d0nj/dsh-ui-custom/resolve
16
+ */
17
+ import { TOKEN_NAMES } from './spec.js';
18
+ import type { Density, FontRole, TokenModes } from './spec.js';
19
+ /** Geometry knobs the panel exposes. */
20
+ export interface Geometry {
21
+ /** Corner radius in CSS pixels, 0–24. */
22
+ readonly radius: number;
23
+ /** Spacing density. */
24
+ readonly density: Density;
25
+ }
26
+ /** Typography settings the plugin applies through the theme service. */
27
+ export interface Typography {
28
+ /** Interface and body font stack; empty keeps the built-in stack. */
29
+ readonly textFontFamily: string;
30
+ /** Code font stack; empty keeps the built-in stack. */
31
+ readonly codeFontFamily: string;
32
+ /** Sidebar terminal font stack; empty keeps the built-in stack. */
33
+ readonly terminalFontFamily: string;
34
+ /** Font sizes per role, each within its own range. */
35
+ readonly fontSize: number;
36
+ readonly codeFontSize: number;
37
+ readonly terminalFontSize: number;
38
+ }
39
+ /** The customization document, as the Host config carries it. */
40
+ export interface CustomizationDocument {
41
+ /** Preset id, or `none` for no base. */
42
+ readonly basePreset: string;
43
+ /** Per-token overrides applied over the preset. */
44
+ readonly tokens: Readonly<Record<string, TokenModes>>;
45
+ /** Typography applied through the theme service. */
46
+ readonly typography: Typography;
47
+ /** Geometry applied as derived tokens. */
48
+ readonly geometry: Geometry;
49
+ }
50
+ /** Result of resolving a document. */
51
+ export interface ResolvedCustomization {
52
+ /** Complete token overrides for `ctx.theme.overrideTokens`. */
53
+ readonly tokens: Readonly<Record<string, TokenModes>>;
54
+ /** Typography to apply, with out-of-range sizes corrected. */
55
+ readonly typography: Typography;
56
+ /** Problems found while resolving, for the panel to show. */
57
+ readonly warnings: readonly string[];
58
+ }
59
+ /**
60
+ * Whether a value is a plausible CSS colour.
61
+ *
62
+ * Colour tokens are rendered straight into CSS custom properties, so an
63
+ * arbitrary string would not be a security hole but would silently produce an
64
+ * unstyled surface. Rejecting it here turns that into a visible warning.
65
+ * @param value - candidate colour value.
66
+ * @returns true when the value looks like a CSS colour.
67
+ */
68
+ export declare function isColor(value: string): boolean;
69
+ /**
70
+ * Clamp a number into a closed range.
71
+ * @param value - candidate value.
72
+ * @param min - lower bound.
73
+ * @param max - upper bound.
74
+ * @returns the clamped integer.
75
+ */
76
+ export declare function clamp(value: number, min: number, max: number): number;
77
+ /**
78
+ * Expand geometry into token values.
79
+ *
80
+ * The documented token set has no radius or spacing tokens, so geometry is
81
+ * expressed as a *shadow layer* of `--d0nj-*` variables the panel's own styles
82
+ * consume, plus any `--dsw-*` tokens that exist. Emitting our own names keeps
83
+ * this additive: a token the harness does not define cannot break a surface,
84
+ * and the panel styles read ours.
85
+ *
86
+ * @param geometry - the requested geometry.
87
+ * @returns derived custom properties with identical light and dark values.
88
+ */
89
+ export declare function geometryTokens(geometry: Geometry): Record<string, TokenModes>;
90
+ /**
91
+ * Correct one role's font size into its accepted range.
92
+ * @param role - font role.
93
+ * @param px - requested size.
94
+ * @returns the in-range size.
95
+ */
96
+ export declare function correctFontSize(role: FontRole, px: number): number;
97
+ /**
98
+ * Whether a string names a known density.
99
+ * @param value - candidate density.
100
+ * @returns true for a known density.
101
+ */
102
+ export declare function isDensity(value: string): value is Density;
103
+ /**
104
+ * Resolve a customization document into theme tokens and typography.
105
+ *
106
+ * Merge order is preset, then explicit overrides, then geometry, so a user who
107
+ * picks a preset and then tweaks one token keeps the tweak, and geometry always
108
+ * wins over a token of the same name (it is the more specific control).
109
+ *
110
+ * @param document - the customization document.
111
+ * @returns the token overrides, corrected typography, and any warnings.
112
+ */
113
+ export declare function resolveCustomization(document: CustomizationDocument): ResolvedCustomization;
114
+ /** Every documented token name, re-exported for the panel's reset action. */
115
+ export { TOKEN_NAMES };
116
+ //# sourceMappingURL=resolve.d.ts.map