@d0nj/dsh-ui-custom 0.0.0-stage → 0.2.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,488 @@
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
+ import { findHostedFont, hostedFamilies, loadHostedFont } from './hosted-fonts.js';
18
+ /** Shared inline styles, computed from theme tokens. */
19
+ function styles() {
20
+ return {
21
+ root: {
22
+ display: 'flex',
23
+ flexDirection: 'column',
24
+ gap: 24,
25
+ padding: '4px 0 32px',
26
+ color: 'var(--dsw-alias-label-primary)',
27
+ fontFamily: 'inherit',
28
+ },
29
+ section: { display: 'flex', flexDirection: 'column', gap: 10 },
30
+ heading: { fontSize: 13, fontWeight: 600, color: 'var(--dsw-alias-label-primary)' },
31
+ hint: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)', lineHeight: 1.5 },
32
+ grid: {
33
+ display: 'grid',
34
+ gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))',
35
+ gap: 10,
36
+ },
37
+ card: {
38
+ display: 'flex',
39
+ flexDirection: 'column',
40
+ gap: 8,
41
+ padding: 10,
42
+ borderRadius: 'var(--d0nj-radius-md, 8px)',
43
+ border: '1px solid var(--dsw-alias-border-l1)',
44
+ // No background and no colour token here: a card represents a theme, so it
45
+ // paints that theme's own surface (set per card, below). Using a token made
46
+ // every card show the live theme instead, which read as banding.
47
+ background: 'transparent',
48
+ cursor: 'pointer',
49
+ textAlign: 'left',
50
+ color: 'var(--dsw-alias-label-primary)',
51
+ font: 'inherit',
52
+ // The UA focus ring lingers on whichever card was last clicked; a
53
+ // focus-visible ring keeps keyboard affordance without that trail.
54
+ outline: 'none',
55
+ },
56
+ cardActive: {
57
+ borderColor: 'var(--dsw-alias-brand-primary)',
58
+ borderWidth: 2,
59
+ padding: 9,
60
+ },
61
+ swatches: { display: 'flex', gap: 4 },
62
+ swatch: {
63
+ width: 22,
64
+ height: 22,
65
+ borderRadius: 'var(--d0nj-radius-sm, 4px)',
66
+ border: '1px solid var(--dsw-alias-border-l1)',
67
+ },
68
+ row: {
69
+ display: 'grid',
70
+ gridTemplateColumns: 'minmax(120px, 1fr) auto auto',
71
+ gap: 8,
72
+ alignItems: 'center',
73
+ padding: '6px 0',
74
+ borderBottom: '1px solid var(--dsw-alias-border-l1)',
75
+ },
76
+ rowLabel: { display: 'flex', flexDirection: 'column', gap: 2 },
77
+ input: {
78
+ width: 92,
79
+ padding: '4px 6px',
80
+ borderRadius: 'var(--d0nj-radius-sm, 4px)',
81
+ border: '1px solid var(--dsw-alias-border-l1)',
82
+ background: 'var(--dsw-alias-bg-base)',
83
+ color: 'var(--dsw-alias-label-primary)',
84
+ font: 'inherit',
85
+ fontSize: 12,
86
+ },
87
+ select: {
88
+ width: 260,
89
+ padding: '4px 6px',
90
+ borderRadius: 'var(--d0nj-radius-sm, 4px)',
91
+ border: '1px solid var(--dsw-alias-border-l1)',
92
+ background: 'var(--dsw-alias-bg-base)',
93
+ color: 'var(--dsw-alias-label-primary)',
94
+ font: 'inherit',
95
+ fontSize: 12,
96
+ },
97
+ fontStack: { display: 'flex', flexDirection: 'column', gap: 4, width: 260 },
98
+ fontRow: { gap: 18 },
99
+ combo: { position: 'relative', width: 260 },
100
+ listbox: {
101
+ position: 'absolute',
102
+ zIndex: 10,
103
+ top: '100%',
104
+ left: 0,
105
+ right: 0,
106
+ marginTop: 4,
107
+ maxHeight: 240,
108
+ overflowY: 'auto',
109
+ padding: 4,
110
+ borderRadius: 'var(--d0nj-radius-sm, 4px)',
111
+ border: '1px solid var(--dsw-alias-border-l2)',
112
+ // A popup over arbitrary content cannot inherit the surface behind it, so
113
+ // it paints the module fill; only the element's own list uses this literal.
114
+ background: 'var(--dsw-alias-bg-module-platform, var(--dsw-alias-bg-layer-1))',
115
+ boxShadow: 'var(--dsw-elevation-lv2, 0 4px 12px rgba(0, 0, 0, 0.2))',
116
+ },
117
+ option: {
118
+ padding: '4px 6px',
119
+ borderRadius: 'var(--d0nj-radius-sm, 4px)',
120
+ fontSize: 12,
121
+ color: 'var(--dsw-alias-label-primary)',
122
+ cursor: 'pointer',
123
+ whiteSpace: 'nowrap',
124
+ overflow: 'hidden',
125
+ textOverflow: 'ellipsis',
126
+ },
127
+ optionActive: { background: 'var(--dsw-alias-interactive-bg-hover, var(--dsw-alias-bg-layer-2))' },
128
+ colorInput: {
129
+ width: 34,
130
+ height: 26,
131
+ padding: 0,
132
+ border: '1px solid var(--dsw-alias-border-l1)',
133
+ borderRadius: 'var(--d0nj-radius-sm, 4px)',
134
+ background: 'transparent',
135
+ cursor: 'pointer',
136
+ },
137
+ button: {
138
+ padding: '5px 10px',
139
+ borderRadius: 'var(--d0nj-radius-md, 8px)',
140
+ border: '1px solid var(--dsw-alias-border-l2)',
141
+ background: 'var(--dsw-alias-bg-layer-2)',
142
+ color: 'var(--dsw-alias-label-primary)',
143
+ font: 'inherit',
144
+ fontSize: 12,
145
+ cursor: 'pointer',
146
+ },
147
+ warning: {
148
+ padding: '8px 10px',
149
+ borderRadius: 'var(--d0nj-radius-md, 8px)',
150
+ border: '1px solid var(--dsw-alias-state-warn-primary)',
151
+ color: 'var(--dsw-alias-state-warn-primary)',
152
+ fontSize: 12,
153
+ },
154
+ preview: {
155
+ display: 'flex',
156
+ flexDirection: 'column',
157
+ gap: 8,
158
+ padding: 14,
159
+ borderRadius: 'var(--d0nj-radius-lg, 12px)',
160
+ border: '1px solid var(--dsw-alias-border-l1)',
161
+ background: 'var(--dsw-alias-bg-base)',
162
+ },
163
+ row2: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' },
164
+ };
165
+ }
166
+ /**
167
+ * Combobox state per role.
168
+ *
169
+ * Kept at module scope because the panel re-renders on every settings echo, and
170
+ * state created inside the render would reset to closed each pass.
171
+ */
172
+ const combos = new Map();
173
+ /** Exported so tests do not inherit an open list between cases. */
174
+ export function resetCombos() {
175
+ combos.clear();
176
+ }
177
+ /**
178
+ * @param key - the role's accessible label.
179
+ * @returns the state for that role, created on first use.
180
+ */
181
+ function comboState(key) {
182
+ const existing = combos.get(key);
183
+ if (existing !== undefined)
184
+ return existing;
185
+ const created = { open: false, query: '', active: 0 };
186
+ combos.set(key, created);
187
+ return created;
188
+ }
189
+ /**
190
+ * Fonts offered by the picker.
191
+ *
192
+ * Hosted rather than local: a browser cannot enumerate installed fonts without a
193
+ * permission-gated API most lack, and a local family looks different elsewhere.
194
+ */
195
+ let fontChoiceCache;
196
+ /** @returns the families the picker offers. */
197
+ function fontChoices() {
198
+ fontChoiceCache ??= hostedFamilies();
199
+ return fontChoiceCache;
200
+ }
201
+ /**
202
+ * Render the Theme Studio page.
203
+ *
204
+ * Written against a minimal `React.createElement` shim rather than JSX so the
205
+ * module needs no transform at build time; the client bundle supplies React.
206
+ *
207
+ * @param React - the React namespace from the module table.
208
+ * @param props - current state and the actions to invoke.
209
+ * @returns the section element.
210
+ */
211
+ export function ThemeStudio(React, props) {
212
+ const h = React.createElement;
213
+ const s = styles();
214
+ const { state, actions, presets } = props;
215
+ // Local mirrors. The panel must react to a click immediately: a settings write
216
+ // is a round-trip through the Host, and waiting for the echo makes every
217
+ // control feel dead when the write is slow, refused, or not mirrored at all.
218
+ // Local state is the source of truth for rendering; the action is the command.
219
+ const [localPreset, setLocalPreset] = React.useState(state.basePreset);
220
+ const [localTokens, setLocalTokens] = React.useState({});
221
+ const [localGeometry, setLocalGeometry] = React.useState(state.geometry);
222
+ const [localTypography, setLocalTypography] = React.useState(state.typography);
223
+ // The counter must keep changing: writing the same number makes React bail out.
224
+ const [renderTick, setRender] = React.useState(0);
225
+ const choices = fontChoices();
226
+ const rerender = () => setRender(renderTick + 1);
227
+ /**
228
+ * @param role - font role being changed.
229
+ * @param value - CSS stack to apply.
230
+ */
231
+ const applyFont = (role, value) => {
232
+ const hosted = findHostedFont(value);
233
+ if (hosted === undefined) {
234
+ actions.setFont(role, value);
235
+ return;
236
+ }
237
+ void loadHostedFont(hosted).then(() => actions.setFont(role, value));
238
+ };
239
+ /**
240
+ * @param ariaLabel - accessible name for the input.
241
+ * @param family - current stack.
242
+ * @param onChange - writes the chosen stack.
243
+ * @returns the combobox element.
244
+ */
245
+ const fontCombo = (ariaLabel, family, onChange) => {
246
+ const combo = comboState(ariaLabel);
247
+ // Not the word "Default", which could collide with a family of that name.
248
+ const DEFAULT_CHOICE = '\u0000default';
249
+ const isDefault = (choice) => choice === DEFAULT_CHOICE;
250
+ const matches = () => {
251
+ const query = combo.query.trim().toLowerCase();
252
+ if (query === '')
253
+ return [DEFAULT_CHOICE, ...choices.slice(0, 39)];
254
+ const filtered = choices.filter((choice) => choice.toLowerCase().includes(query));
255
+ const keepDefault = 'default (built-in)'.includes(query) || 'built-in'.includes(query);
256
+ return (keepDefault ? [DEFAULT_CHOICE, ...filtered] : filtered).slice(0, 40);
257
+ };
258
+ const commit = (value) => {
259
+ combo.open = false;
260
+ combo.query = '';
261
+ combo.active = 0;
262
+ const next = isDefault(value) ? '' : value;
263
+ if (next !== combo.written)
264
+ onChange(next);
265
+ combo.written = undefined;
266
+ rerender();
267
+ };
268
+ const visible = combo.open ? matches() : [];
269
+ return h('div', { style: s.combo }, h('input', {
270
+ type: 'text',
271
+ role: 'combobox',
272
+ style: s.select,
273
+ 'aria-label': ariaLabel,
274
+ 'aria-expanded': combo.open,
275
+ 'aria-autocomplete': 'list',
276
+ placeholder: 'Default (built-in stack)',
277
+ value: family,
278
+ onFocus: (event) => {
279
+ combo.open = true;
280
+ combo.query = '';
281
+ combo.active = 0;
282
+ event.target.select?.();
283
+ rerender();
284
+ },
285
+ onInput: (event) => {
286
+ combo.open = true;
287
+ combo.query = event.target.value;
288
+ combo.active = 0;
289
+ combo.written = event.target.value;
290
+ onChange(event.target.value);
291
+ rerender();
292
+ },
293
+ onKeyDown: (event) => {
294
+ const list = visible;
295
+ if (event.key === 'Escape') {
296
+ combo.open = false;
297
+ combo.query = '';
298
+ rerender();
299
+ return;
300
+ }
301
+ if (event.key === 'ArrowDown' && list.length > 0) {
302
+ event.preventDefault();
303
+ combo.active = (combo.active + 1) % list.length;
304
+ rerender();
305
+ return;
306
+ }
307
+ if (event.key === 'ArrowUp' && list.length > 0) {
308
+ event.preventDefault();
309
+ combo.active = (combo.active - 1 + list.length) % list.length;
310
+ rerender();
311
+ return;
312
+ }
313
+ if (event.key === 'Enter') {
314
+ const picked = list[combo.active];
315
+ if (combo.open && picked !== undefined)
316
+ commit(picked);
317
+ else if (combo.open && combo.query.trim() !== '')
318
+ commit(combo.query.trim());
319
+ }
320
+ },
321
+ onBlur: () => {
322
+ combo.open = false;
323
+ combo.query = '';
324
+ rerender();
325
+ },
326
+ }), combo.open && visible.length > 0
327
+ ? h('div', { style: s.listbox, role: 'listbox' }, ...visible.map((choice, index) => h('div', {
328
+ key: choice,
329
+ role: 'option',
330
+ 'aria-selected': index === combo.active,
331
+ style: {
332
+ ...s.option,
333
+ ...(isDefault(choice) ? {} : { fontFamily: `"${choice}"` }),
334
+ ...(index === combo.active ? s.optionActive : {}),
335
+ },
336
+ // Fires before the input's blur, which would close the list first.
337
+ onMouseDown: (event) => {
338
+ event.preventDefault();
339
+ commit(choice);
340
+ },
341
+ }, isDefault(choice) ? 'Default (built-in stack)' : choice)))
342
+ : null);
343
+ };
344
+ /** The preset currently selected, for filling a missing mode from its colours. */
345
+ const basePreset = presets.find((preset) => preset.id === (localPreset ?? state.basePreset));
346
+ /**
347
+ * The effective value of one token: the local edit, else the stored override,
348
+ * else the base preset's own value for that mode.
349
+ */
350
+ const effective = (name) => {
351
+ const local = localTokens[name];
352
+ if (local !== undefined)
353
+ return local;
354
+ const stored = state.tokens.find((row) => row.name === name)?.override;
355
+ if (stored !== undefined)
356
+ return stored;
357
+ const presetValue = basePreset === undefined ? undefined : basePreset.tokens[name];
358
+ return presetValue ?? { light: '', dark: '' };
359
+ };
360
+ const presetCards = [
361
+ { id: 'none', label: 'No preset', description: 'Start from the built-in palette.', colorScheme: 'dark', tokens: {} },
362
+ ...presets,
363
+ ].map((preset) => {
364
+ const base = preset.tokens['--dsw-alias-bg-base'];
365
+ const accent = preset.tokens['--dsw-alias-brand-primary'];
366
+ const label = preset.tokens['--dsw-alias-label-primary'];
367
+ const active = (localPreset ?? state.basePreset) === preset.id;
368
+ return h('button', {
369
+ key: preset.id,
370
+ type: 'button',
371
+ style: {
372
+ ...s.card,
373
+ // Paint the preset's own surface and text colour, so the grid shows what
374
+ // each theme looks like rather than the one currently applied.
375
+ background: base?.light ?? 'transparent',
376
+ color: label?.light ?? 'var(--dsw-alias-label-primary)',
377
+ ...(active ? s.cardActive : {}),
378
+ },
379
+ 'aria-pressed': active,
380
+ onClick: () => {
381
+ setLocalPreset(preset.id);
382
+ setLocalTokens({});
383
+ actions.setPreset(preset.id);
384
+ },
385
+ }, 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));
386
+ });
387
+ const tokenRows = state.tokens.map((row) => {
388
+ const current = effective(row.name);
389
+ /**
390
+ * Apply one mode's edit and write both modes.
391
+ *
392
+ * The theme runtime needs a value for each scheme, so a half-typed row is
393
+ * completed from the preset rather than withheld: withholding was why typing
394
+ * appeared to do nothing until both fields happened to be filled.
395
+ */
396
+ const edit = (mode, value) => {
397
+ const next = { ...current, [mode]: value };
398
+ setLocalTokens({ ...localTokens, [row.name]: next });
399
+ if (next.light.length > 0 && next.dark.length > 0)
400
+ actions.setToken(row.name, next.light, next.dark);
401
+ };
402
+ 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', {
403
+ style: s.colorInput,
404
+ type: 'color',
405
+ 'aria-label': `${row.label} light`,
406
+ value: /^#[0-9a-f]{6}$/iu.test(current.light) ? current.light : '#000000',
407
+ onChange: (event) => edit('light', event.target.value),
408
+ }), h('input', {
409
+ style: s.input,
410
+ type: 'text',
411
+ 'aria-label': `${row.label} light value`,
412
+ placeholder: 'light',
413
+ value: current.light,
414
+ onChange: (event) => edit('light', event.target.value),
415
+ })), h('div', { style: s.row2 }, h('input', {
416
+ style: s.colorInput,
417
+ type: 'color',
418
+ 'aria-label': `${row.label} dark`,
419
+ value: /^#[0-9a-f]{6}$/iu.test(current.dark) ? current.dark : '#000000',
420
+ onChange: (event) => edit('dark', event.target.value),
421
+ }), h('input', {
422
+ style: s.input,
423
+ type: 'text',
424
+ 'aria-label': `${row.label} dark value`,
425
+ placeholder: 'dark',
426
+ value: current.dark,
427
+ onChange: (event) => edit('dark', event.target.value),
428
+ }), h('button', {
429
+ type: 'button',
430
+ style: s.button,
431
+ onClick: () => {
432
+ const next = { ...localTokens };
433
+ delete next[row.name];
434
+ setLocalTokens(next);
435
+ actions.clearToken(row.name);
436
+ },
437
+ }, 'Reset')));
438
+ });
439
+ const fontRow = (role, label, family, size) => {
440
+ const edit = (value) => {
441
+ const key = role === 'text' ? 'textFontFamily' : role === 'code' ? 'codeFontFamily' : 'terminalFontFamily';
442
+ setLocalTypography({ ...(localTypography ?? state.typography), [key]: value });
443
+ applyFont(role, value);
444
+ };
445
+ return h('div', { style: { ...s.row, ...s.fontRow } }, h('div', { style: s.rowLabel }, h('span', { style: { fontSize: 12, fontWeight: 600 } }, label)), fontCombo(`${label} font family`, family, edit), h('div', { style: s.row2 }, h('input', {
446
+ style: s.input,
447
+ type: 'number',
448
+ min: 8,
449
+ max: 24,
450
+ 'aria-label': `${label} size`,
451
+ value: size,
452
+ onChange: (event) => {
453
+ const px = Number(event.target.value);
454
+ const key = role === 'text' ? 'fontSize' : role === 'code' ? 'codeFontSize' : 'terminalFontSize';
455
+ setLocalTypography({ ...(localTypography ?? state.typography), [key]: px });
456
+ actions.setFontSize(role, px);
457
+ },
458
+ }), h('span', { style: s.hint }, 'px')));
459
+ };
460
+ return h('div', { style: s.root }, state.writable
461
+ ? null
462
+ : 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', {
463
+ type: 'range',
464
+ min: 0,
465
+ max: 24,
466
+ value: (localGeometry ?? state.geometry).radius,
467
+ 'aria-label': 'Corner radius',
468
+ onChange: (event) => {
469
+ const radius = Number(event.target.value);
470
+ setLocalGeometry({ ...(localGeometry ?? state.geometry), radius });
471
+ actions.setRadius(radius);
472
+ },
473
+ }), 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', {
474
+ key: density,
475
+ type: 'button',
476
+ style: (localGeometry ?? state.geometry).density === density
477
+ ? { ...s.button, borderColor: 'var(--dsw-alias-brand-primary)' }
478
+ : s.button,
479
+ 'aria-pressed': (localGeometry ?? state.geometry).density === density,
480
+ onClick: () => {
481
+ setLocalGeometry({ ...(localGeometry ?? state.geometry), density });
482
+ actions.setDensity(density);
483
+ },
484
+ }, 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
485
+ ? h('section', { style: s.section }, ...state.warnings.map((warning, index) => h('div', { key: String(index), style: s.warning }, warning)))
486
+ : null, h('section', { style: s.section }, h('button', { type: 'button', style: s.button, onClick: () => actions.resetAll() }, 'Reset all customization')));
487
+ }
488
+ //# sourceMappingURL=panel.js.map
@@ -0,0 +1,74 @@
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 parsed shape of this plugin's config.
31
+ *
32
+ * Each field is volatile in the schema, so the Host hands `apply` a Volatile
33
+ * reference rather than a plain value; {@link toDocument} unwraps either form.
34
+ */
35
+ export interface PluginConfig {
36
+ basePreset?: string;
37
+ tokens?: Record<string, {
38
+ light: string;
39
+ dark: string;
40
+ }>;
41
+ typography?: {
42
+ textFontFamily?: string;
43
+ codeFontFamily?: string;
44
+ terminalFontFamily?: string;
45
+ fontSize?: number;
46
+ codeFontSize?: number;
47
+ terminalFontSize?: number;
48
+ };
49
+ geometry?: {
50
+ radius?: number;
51
+ density?: 'compact' | 'comfortable' | 'spacious';
52
+ };
53
+ }
54
+ /**
55
+ * Project a stored config into a complete customization document.
56
+ *
57
+ * The schema defaults every field, but a config read back from a partially
58
+ * written document may still be missing sections, so this fills them once here
59
+ * rather than at each use site.
60
+ * @param config - the config handed to `apply`.
61
+ * @returns a complete document.
62
+ */
63
+ export declare function toDocument(input: unknown): CustomizationDocument;
64
+ /**
65
+ * Host plugin body.
66
+ *
67
+ * The Host contributes nothing at runtime: the browser half owns the theme
68
+ * layer and fonts, and the settings service owns persistence.
69
+ */
70
+ export declare function apply(): void;
71
+ export { PRESETS, TOKEN_SPECS, TOKEN_GROUPS, PRESET_IDS } from './spec.js';
72
+ export { resolveCustomization } from './resolve.js';
73
+ export type { CustomizationDocument, ResolvedCustomization } from './resolve.js';
74
+ //# sourceMappingURL=index.d.ts.map