@adia-ai/web-modules 0.8.12 → 0.8.14

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.
@@ -11,7 +11,6 @@ theme-panel {
11
11
  --theme-panel-gap: var(--a-space-3);
12
12
  --theme-panel-pad: var(--a-space-3);
13
13
  --theme-panel-min-width: 260px;
14
- --theme-panel-row-gap: var(--a-space-1);
15
14
  --theme-panel-preset-gap: var(--a-space-2);
16
15
  }
17
16
 
@@ -26,6 +25,20 @@ theme-panel {
26
25
  min-width: var(--theme-panel-min-width);
27
26
  }
28
27
 
28
+ /* The panel's own chrome is pinned to a fixed density register
29
+ ("compact" — 0.85/0.75) on the <theme-provider scale="ui-md"> wrapper
30
+ — the single place that locks the panel's own size/scale, independent
31
+ of whatever --a-density/[scale] the Density slider / Scale select is
32
+ driving on the TARGET. Both share :root as a DOM ancestor by default,
33
+ so without this the panel's own controls balloon in lockstep with the
34
+ page it's controlling (verified live: dragging Density to 1.5, or
35
+ Scale to content-lg, also enlarged the popover's own buttons/select).
36
+ theme-provider is display:contents, so this adds no box of its own. */
37
+ theme-provider {
38
+ --a-density: 0.85;
39
+ --a-radius-k: 0.75;
40
+ }
41
+
29
42
  [part="scheme"] {
30
43
  display: flex;
31
44
  }
@@ -34,14 +47,9 @@ theme-panel {
34
47
  flex: 1;
35
48
  }
36
49
 
37
- [part="themes"] {
38
- display: flex;
39
- gap: var(--theme-panel-row-gap);
40
- flex-wrap: wrap;
41
- }
42
-
43
- /* Labelled control row — "Density" / "Scale Context" label on the left,
44
- the preset buttons / register segmented pushed to the right. */
50
+ /* Labelled control row — "Theme" / "Preset" / "Scale" label on the left,
51
+ the select-ui / preset buttons pushed to the right. */
52
+ [part="theme-row"],
45
53
  [part="preset-row"] {
46
54
  display: flex;
47
55
  align-items: center;
@@ -55,5 +63,10 @@ theme-panel {
55
63
  flex-wrap: wrap;
56
64
  }
57
65
 
58
- /* field-ui already provides its own label + layout; nothing extra here */
66
+ /* Reset its own row at the very bottom (separated by a divider in
67
+ markup order), distinct from the Preset row's "Default" button. */
68
+ [part="reset-row"] {
69
+ display: flex;
70
+ }
71
+
59
72
  }
@@ -6,9 +6,12 @@ light/dark switching, plus optional localStorage persistence behind a
6
6
  small attribute API.
7
7
 
8
8
  Drops into any consumer's <popover-ui slot="content"> (the canonical
9
- composition) or directly into a sidebar section. Eight named themes
10
- ship by default; section visibility flips on/off via boolean attributes
11
- ([parametric], [presets], [scheme-toggle]).
9
+ composition) or directly into a sidebar section. The 12 named identities
10
+ in styles/themes.css ship by default (a <select-ui> Theme row); pass
11
+ [themes="slug1 slug2"] to restrict, or "" to suppress the row entirely.
12
+ Section visibility flips on/off via boolean attributes ([parametric],
13
+ [presets], [register], [scheme-toggle]); a Reset row (own button, bottom
14
+ of the panel) always renders and clears everything via .reset().
12
15
 
13
16
  Promoted from the duplicated <div id="theme-panel"> block in site/ and
14
17
  playgrounds/admin-shell/ — see .claude/docs/specs/theme-panel-module.md.
@@ -29,6 +32,7 @@ export interface ThemePanelThemeChangeEventDetail {
29
32
  theme: string;
30
33
  density: string;
31
34
  radius: string;
35
+ scale: string;
32
36
  scheme: string;
33
37
  source: string;
34
38
  }
@@ -45,15 +49,20 @@ playgrounds and embedded demos do not silently mutate a user's
45
49
  docs-shell preferences.
46
50
  */
47
51
  persist: boolean;
48
- /** Renders the compact / reset / spacious preset row. Each preset
49
- applies a (density, radius) pair; "default" also clears the
50
- [theme] attribute.
52
+ /** Renders the compact / default / spacious preset row. Each preset
53
+ applies a (density, radius) pair only it does not touch [theme],
54
+ [scheme], or [scale]. Full state clearing lives in the panel's
55
+ always-rendered Reset row (bottom, own button, calls .reset()).
51
56
  */
52
57
  presets: boolean;
53
- /** Renders a "Scale Context" row (verse / regular / prose) a
54
- <segmented-ui> that toggles the [verse] / [prose] typographic register
55
- on the target element (regular clears both). Persisted like the other
56
- knobs when [persist] is set.
58
+ /** Renders a "Scale" row a <select-ui> offering the six ancestor
59
+ [scale] tiers (ui-sm | ui-md | ui-lg | content-sm | content-md |
60
+ content-lg, plus a Default option that clears the attribute)
61
+ writing [scale="<tier>"] directly on the target element, matching
62
+ theme-provider.class.js's own `scale` prop values exactly.
63
+ Supersedes the old verse/regular/prose register (deprecated
64
+ [verse]/[prose] aliases); this control never writes them.
65
+ Persisted like the other knobs when [persist] is set.
57
66
  */
58
67
  register: boolean;
59
68
  /** Initial color-scheme. "auto" follows prefers-color-scheme via a
@@ -67,10 +76,15 @@ an explicit light or dark choice.
67
76
  demos.
68
77
  */
69
78
  target: string;
70
- /** Space-separated list of theme slugs to render as buttons. Author
71
- may restrict ([themes="default ocean"]) or extend. Tolerant
72
- unknown slugs render a button; clicking applies [theme=slug]
73
- regardless of whether themes.css has a matching block.
79
+ /** Space-separated list of theme slugs to render in the Theme
80
+ <select-ui>. Defaults to the 12 named identities shipped in
81
+ styles/themes.css atlas, signal, terrarium, ledger, arcade,
82
+ meridian, solstice, glacier, ember, botanica, vector, velour
83
+ (each a real recolor + real Google/system font pairing, not a
84
+ decorative hue nudge — see that file's header). Pass "" to
85
+ suppress the Theme row entirely. Tolerant — unknown slugs still
86
+ render as an option; selecting one applies [theme=slug] regardless
87
+ of whether themes.css has a matching block.
74
88
  */
75
89
  themes: string;
76
90
 
@@ -10,9 +10,20 @@
10
10
  * in site/ and playgrounds/admin-shell/.
11
11
  *
12
12
  * Author-supplied attributes (read once at connect):
13
- * [themes="slug1 slug2 …"] space-separated theme list; default = 8 named
14
- * [parametric] renders density + radius sliders
15
- * [presets] renders compact / reset / spacious preset row
13
+ * [themes="slug1 slug2 …"] space-separated theme list; default = the
14
+ * 12 named identities shipped in
15
+ * styles/themes.css (atlas, signal,
16
+ * terrarium, ledger, arcade, meridian,
17
+ * solstice, glacier, ember, botanica,
18
+ * vector, velour). Rendered as a <select-ui>
19
+ * (Theme row); pass "" to suppress the row
20
+ * entirely.
21
+ * [parametric] renders the Density slider row (continuous --a-density)
22
+ * [presets] renders the Preset row (compact / default / spacious
23
+ * density+radius quick-picks — does not touch theme/scheme/scale)
24
+ * [register] renders the Scale row — one of the six
25
+ * [scale] tiers (ui-sm/ui-md/ui-lg/
26
+ * content-sm/content-md/content-lg)
16
27
  * [scheme-toggle] renders an integrated light/dark segmented-ui
17
28
  * [persist] writes selections to localStorage
18
29
  * [storage-prefix="adia-theme-"] LS key namespace
@@ -24,14 +35,16 @@
24
35
  * [active-scheme] resolved scheme (auto collapsed to light|dark)
25
36
  * [active-density] mirrors current --a-density on target
26
37
  * [active-radius] mirrors current --a-radius-k on target
38
+ * [active-scale] current [scale] tier on target; empty for none
27
39
  *
28
40
  * Events:
29
41
  * theme-change — bubbles. detail: { theme, scheme, density, radius, source }
30
- * source is one of: 'theme'|'slider'|'preset'|'scheme'|'reset'|'programmatic'
42
+ * source is one of: 'theme'|'slider'|'preset'|'scale'|'scheme'|'reset'|'programmatic'
31
43
  *
32
44
  * Public methods:
33
- * .apply({theme?, scheme?, density?, radius?}) — programmatic write
34
- * .reset() — clear all state + emit reset event
45
+ * .apply({theme?, scheme?, density?, radius?, scale?}) — programmatic write
46
+ * .reset() — clear all state + emit reset event; also wired to the
47
+ * panel's own Reset row (own button, bottom, always rendered)
35
48
  *
36
49
  * The element is light-DOM and stamps its internals imperatively in
37
50
  * connected(). External CSS / JS reacts via reflected attributes, e.g.
@@ -40,12 +53,23 @@
40
53
 
41
54
  import { UIElement } from '../../../web-components/core/element.js';
42
55
 
43
- const DEFAULT_THEMES = ['default', 'ocean', 'forest', 'sunset', 'lavender', 'rose', 'slate', 'midnight'];
56
+ // The 12 named identities shipped in styles/themes.css (real recolor +
57
+ // real typeface pairing per theme, not a decorative hue nudge) — see that
58
+ // file's header for the color/typography mechanism. A consumer can still
59
+ // override with [themes="slug1 slug2 …"] to offer a different set.
60
+ const DEFAULT_THEMES = [
61
+ 'atlas', 'signal', 'terrarium', 'ledger', 'arcade', 'meridian',
62
+ 'solstice', 'glacier', 'ember', 'botanica', 'vector', 'velour',
63
+ ];
44
64
  const PRESETS = {
45
65
  compact: { density: 0.85, radius: 0.75 },
46
- default: { density: 1, radius: 1, clearTheme: true },
66
+ default: { density: 1, radius: 1 },
47
67
  spacious: { density: 1.15, radius: 1.25 },
48
68
  };
69
+ // The six ancestor [scale] tiers (styles/scale.css) — matches
70
+ // theme-provider.class.js's SCALE_TIERS exactly. '' = no override (the
71
+ // unscaled base register).
72
+ const SCALE_TIERS = ['', 'ui-sm', 'ui-md', 'ui-lg', 'content-sm', 'content-md', 'content-lg'];
49
73
 
50
74
  const titleCase = (s) => s.length ? s[0].toUpperCase() + s.slice(1) : s;
51
75
 
@@ -64,7 +88,7 @@ class ThemePanel extends UIElement {
64
88
  activeDensity: { type: String, default: '', reflect: true, attribute: 'active-density' },
65
89
  activeRadius: { type: String, default: '', reflect: true, attribute: 'active-radius' },
66
90
  register: { type: Boolean, default: false, reflect: true },
67
- activeRegister:{ type: String, default: '', reflect: true, attribute: 'active-register' },
91
+ activeScale: { type: String, default: '', reflect: true, attribute: 'active-scale' },
68
92
  };
69
93
 
70
94
  static template = () => null;
@@ -75,7 +99,9 @@ class ThemePanel extends UIElement {
75
99
  #densityEl = null;
76
100
  #radiusEl = null;
77
101
  #schemeEl = null;
78
- #registerEl = null;
102
+ #scaleEl = null;
103
+ #themeEl = null;
104
+ #resetEl = null;
79
105
  #stamped = false;
80
106
 
81
107
  connected() {
@@ -113,24 +139,24 @@ class ThemePanel extends UIElement {
113
139
  target.style.removeProperty('--a-density');
114
140
  target.style.removeProperty('--a-radius-k');
115
141
  target.style.removeProperty('color-scheme');
116
- target.removeAttribute('verse');
117
- target.removeAttribute('prose');
142
+ target.removeAttribute('scale');
118
143
  if (this.persist) {
119
- for (const k of ['theme', 'scheme', 'density', 'radius', 'register']) {
144
+ for (const k of ['theme', 'scheme', 'density', 'radius', 'scale']) {
120
145
  try { localStorage.removeItem(`${this.storagePrefix}${k}`); } catch {}
121
146
  }
122
147
  }
123
148
  this.activeTheme = '';
124
149
  this.activeDensity = '';
125
150
  this.activeRadius = '';
126
- this.activeRegister = '';
151
+ this.activeScale = '';
127
152
  // Resolved scheme falls back to prefers-color-scheme
128
153
  const resolved = this.#resolvePrefersScheme();
129
154
  this.activeScheme = resolved;
130
155
  if (this.#schemeEl) this.#schemeEl.value = resolved;
131
156
  if (this.#densityEl) this.#densityEl.value = 1;
132
157
  if (this.#radiusEl) this.#radiusEl.value = 1;
133
- if (this.#registerEl) this.#registerEl.value = 'regular';
158
+ if (this.#scaleEl) this.#scaleEl.value = '';
159
+ if (this.#themeEl) this.#themeEl.value = '';
134
160
  this.#emit('reset');
135
161
  }
136
162
 
@@ -157,24 +183,40 @@ class ThemePanel extends UIElement {
157
183
  this.#schemeEl = seg;
158
184
  }
159
185
 
160
- // Theme button row
161
- const themeLabel = document.createElement('text-ui');
162
- themeLabel.setAttribute('variant', 'label');
163
- themeLabel.textContent = 'Theme';
164
- frag.appendChild(themeLabel);
165
-
166
- const themesRow = document.createElement('div');
167
- themesRow.setAttribute('part', 'themes');
186
+ // Theme row — a <select-ui> (matches the Scale row's control pattern)
187
+ // offering every slug in [themes], only when that list is non-empty.
168
188
  const themeSlugs = (this.themes || '').trim().split(/\s+/).filter(Boolean);
169
- for (const slug of themeSlugs) {
170
- const btn = document.createElement('button-ui');
171
- btn.setAttribute('data-theme-slug', slug);
172
- btn.setAttribute('text', titleCase(slug));
173
- btn.setAttribute('variant', 'outline');
174
- btn.setAttribute('size', 'sm');
175
- themesRow.appendChild(btn);
189
+ if (themeSlugs.length) {
190
+ const themeWrap = document.createElement('div');
191
+ themeWrap.setAttribute('part', 'theme-row');
192
+ const themeLabel = document.createElement('text-ui');
193
+ themeLabel.setAttribute('variant', 'label');
194
+ themeLabel.textContent = 'Theme';
195
+ themeWrap.appendChild(themeLabel);
196
+
197
+ const select = document.createElement('select-ui');
198
+ select.setAttribute('part', 'themes');
199
+ select.setAttribute('size', 'sm');
200
+ select.id = `${this.#uid}-theme`;
201
+ // themeSlugs is attribute/A2UI-driven and tolerant of unknown values
202
+ // (theme-panel.d.ts's documented contract) — build <option> elements
203
+ // via the DOM rather than interpolating slugs into innerHTML, so a
204
+ // slug containing `"` or `<` can't break out of the option markup.
205
+ const mkOption = (value, label) => {
206
+ const opt = document.createElement('option');
207
+ opt.value = value;
208
+ opt.textContent = label;
209
+ return opt;
210
+ };
211
+ select.append(
212
+ mkOption('', 'Default'),
213
+ ...themeSlugs.map((slug) => mkOption(slug, titleCase(slug))),
214
+ );
215
+ select.setAttribute('value', '');
216
+ themeWrap.appendChild(select);
217
+ frag.appendChild(themeWrap);
218
+ this.#themeEl = select;
176
219
  }
177
- frag.appendChild(themesRow);
178
220
 
179
221
  // Optional parametric block
180
222
  if (this.parametric) {
@@ -185,30 +227,32 @@ class ThemePanel extends UIElement {
185
227
  paramLabel.textContent = 'Parametric';
186
228
  frag.appendChild(paramLabel);
187
229
 
188
- const densityField = document.createElement('field-ui');
189
- densityField.setAttribute('label', 'Density');
230
+ // slider-ui renders [label] ... [value][suffix] on one header row and
231
+ // the track below natively (its own doc comment: "Wrap in <field-ui>
232
+ // only when you need external label-row composition") — passing
233
+ // [label] directly here, not via a wrapping <field-ui>, is what
234
+ // produces the one-row label+value layout instead of three stacked
235
+ // rows.
190
236
  const density = document.createElement('slider-ui');
191
237
  density.setAttribute('part', 'density');
238
+ density.setAttribute('label', 'Density');
192
239
  density.setAttribute('value', '1');
193
240
  density.setAttribute('min', '0.5');
194
241
  density.setAttribute('max', '1.5');
195
242
  density.setAttribute('step', '0.05');
196
243
  density.setAttribute('suffix', '×');
197
- densityField.appendChild(density);
198
- frag.appendChild(densityField);
244
+ frag.appendChild(density);
199
245
  this.#densityEl = density;
200
246
 
201
- const radiusField = document.createElement('field-ui');
202
- radiusField.setAttribute('label', 'Radius');
203
247
  const radius = document.createElement('slider-ui');
204
248
  radius.setAttribute('part', 'radius');
249
+ radius.setAttribute('label', 'Radius');
205
250
  radius.setAttribute('value', '1');
206
251
  radius.setAttribute('min', '0');
207
252
  radius.setAttribute('max', '2');
208
253
  radius.setAttribute('step', '0.1');
209
254
  radius.setAttribute('suffix', '×');
210
- radiusField.appendChild(radius);
211
- frag.appendChild(radiusField);
255
+ frag.appendChild(radius);
212
256
  this.#radiusEl = radius;
213
257
  }
214
258
 
@@ -217,11 +261,14 @@ class ThemePanel extends UIElement {
217
261
  frag.appendChild(document.createElement('divider-ui'));
218
262
 
219
263
  if (this.presets) {
264
+ // Labelled "Preset" (not "Density") — it's a combined density+radius
265
+ // quick-pick, distinct from the Density slider above; sharing the
266
+ // "Density" label with the slider row read as two Density rows.
220
267
  const presetWrap = document.createElement('div');
221
268
  presetWrap.setAttribute('part', 'preset-row');
222
269
  const presetLabel = document.createElement('text-ui');
223
270
  presetLabel.setAttribute('variant', 'label');
224
- presetLabel.textContent = 'Density';
271
+ presetLabel.textContent = 'Preset';
225
272
  presetWrap.appendChild(presetLabel);
226
273
 
227
274
  const presetsRow = document.createElement('div');
@@ -229,7 +276,7 @@ class ThemePanel extends UIElement {
229
276
  for (const name of ['compact', 'default', 'spacious']) {
230
277
  const btn = document.createElement('button-ui');
231
278
  btn.setAttribute('data-preset', name);
232
- btn.setAttribute('text', name === 'default' ? 'Reset' : titleCase(name));
279
+ btn.setAttribute('text', titleCase(name));
233
280
  btn.setAttribute('variant', 'outline');
234
281
  btn.setAttribute('size', 'sm');
235
282
  presetsRow.appendChild(btn);
@@ -238,46 +285,89 @@ class ThemePanel extends UIElement {
238
285
  frag.appendChild(presetWrap);
239
286
  }
240
287
 
241
- // Scale context verse / regular / prose typographic register on the target
288
+ // Scale — the six ancestor [scale] tiers (styles/scale.css) applied
289
+ // directly to target, matching theme-provider.class.js's own `scale`
290
+ // prop values exactly. Supersedes the old verse/regular/prose row —
291
+ // [verse]/[prose] are deprecated aliases now, not something a NEW
292
+ // control should write.
242
293
  if (this.register) {
243
- const regWrap = document.createElement('div');
244
- regWrap.setAttribute('part', 'preset-row');
245
- const regLabel = document.createElement('text-ui');
246
- regLabel.setAttribute('variant', 'label');
247
- regLabel.textContent = 'Scale Context';
248
- regWrap.appendChild(regLabel);
249
-
250
- const seg = document.createElement('segmented-ui');
251
- seg.setAttribute('part', 'register');
252
- seg.setAttribute('size', 'sm');
253
- seg.id = `${this.#uid}-register`;
254
- for (const name of ['verse', 'regular', 'prose']) {
255
- const s = document.createElement('segment-ui');
256
- s.setAttribute('value', name);
257
- s.setAttribute('text', name);
258
- seg.appendChild(s);
259
- }
260
- seg.setAttribute('value', 'regular');
261
- regWrap.appendChild(seg);
262
- frag.appendChild(regWrap);
263
- this.#registerEl = seg;
294
+ const scaleWrap = document.createElement('div');
295
+ scaleWrap.setAttribute('part', 'preset-row');
296
+ const scaleLabel = document.createElement('text-ui');
297
+ scaleLabel.setAttribute('variant', 'label');
298
+ scaleLabel.textContent = 'Scale';
299
+ scaleWrap.appendChild(scaleLabel);
300
+
301
+ const select = document.createElement('select-ui');
302
+ select.setAttribute('part', 'scale');
303
+ select.setAttribute('size', 'sm');
304
+ select.id = `${this.#uid}-scale`;
305
+ select.innerHTML = SCALE_TIERS
306
+ .map((tier) => `<option value="${tier}">${tier || 'Default'}</option>`)
307
+ .join('');
308
+ select.setAttribute('value', '');
309
+ scaleWrap.appendChild(select);
310
+ frag.appendChild(scaleWrap);
311
+ this.#scaleEl = select;
264
312
  }
265
313
  }
266
314
 
267
- this.appendChild(frag);
315
+ // Reset — its own row at the very bottom, separated by a divider.
316
+ // Distinct from the Preset row's "Default" button: Default only sets
317
+ // density/radius; Reset clears everything (theme, scheme, density,
318
+ // radius, scale) via the public .reset() method.
319
+ frag.appendChild(document.createElement('divider-ui'));
320
+ const resetRow = document.createElement('div');
321
+ resetRow.setAttribute('part', 'reset-row');
322
+ const resetBtn = document.createElement('button-ui');
323
+ resetBtn.setAttribute('part', 'reset');
324
+ resetBtn.setAttribute('text', 'Reset');
325
+ resetBtn.setAttribute('variant', 'ghost');
326
+ resetBtn.setAttribute('size', 'sm');
327
+ resetRow.appendChild(resetBtn);
328
+ frag.appendChild(resetRow);
329
+ this.#resetEl = resetBtn;
330
+
331
+ // Wrap the stamped content in <theme-provider scale="ui-md"> — locks
332
+ // the panel's own chrome to the base size/scale register, independent
333
+ // of whatever [scale]/--a-density the panel's own Scale select or
334
+ // Density slider is driving on the target. Target defaults to :root,
335
+ // a DOM ancestor of theme-panel, so those inherited custom properties
336
+ // would otherwise cascade into the panel's own controls too (verified
337
+ // live: switching Scale to content-lg, or Density to 1.5, enlarged the
338
+ // popover's own controls alongside the page). display:contents, so it
339
+ // adds no box of its own — theme-panel's flex column layout is
340
+ // unaffected. ui-md is byte-identical to the unscaled default
341
+ // (scale.css's own invariant), so this is a visual no-op until an
342
+ // ancestor sets a different tier. Deliberately does NOT set [theme] —
343
+ // the panel's own accent color is meant to track whatever theme is
344
+ // active, only size/scale are locked.
345
+ const provider = document.createElement('theme-provider');
346
+ provider.setAttribute('scale', 'ui-md');
347
+ provider.appendChild(frag);
348
+ this.appendChild(provider);
268
349
  this.#wireListeners();
269
350
  }
270
351
 
271
352
  #wireListeners() {
272
- // Theme buttonsfire 'press' (button-ui), fallback to 'click' for tests
273
- for (const btn of this.querySelectorAll('[part="themes"] button-ui')) {
274
- const slug = btn.getAttribute('data-theme-slug');
275
- const handler = () => this.#onThemeClick(slug);
276
- btn.addEventListener('press', handler);
277
- btn.addEventListener('click', handler);
353
+ // Theme selectfires 'change' (select-ui)
354
+ if (this.#themeEl) {
355
+ const handler = (e) => {
356
+ const v = e.detail?.value ?? this.#themeEl.value;
357
+ this.#onThemeClick(v);
358
+ };
359
+ this.#themeEl.addEventListener('change', handler);
360
+ this.#cleanups.push(() => this.#themeEl.removeEventListener('change', handler));
361
+ }
362
+
363
+ // Reset button
364
+ if (this.#resetEl) {
365
+ const handler = () => this.reset();
366
+ this.#resetEl.addEventListener('press', handler);
367
+ this.#resetEl.addEventListener('click', handler);
278
368
  this.#cleanups.push(() => {
279
- btn.removeEventListener('press', handler);
280
- btn.removeEventListener('click', handler);
369
+ this.#resetEl.removeEventListener('press', handler);
370
+ this.#resetEl.removeEventListener('click', handler);
281
371
  });
282
372
  }
283
373
 
@@ -315,14 +405,14 @@ class ThemePanel extends UIElement {
315
405
  this.#cleanups.push(() => this.#schemeEl.removeEventListener('change', handler));
316
406
  }
317
407
 
318
- // Scale-context register
319
- if (this.#registerEl) {
408
+ // Scale
409
+ if (this.#scaleEl) {
320
410
  const handler = (e) => {
321
- const v = e.detail?.value ?? this.#registerEl.value;
322
- this.#apply({ register: v }, 'register');
411
+ const v = e.detail?.value ?? this.#scaleEl.value;
412
+ this.#apply({ scale: v }, 'scale');
323
413
  };
324
- this.#registerEl.addEventListener('change', handler);
325
- this.#cleanups.push(() => this.#registerEl.removeEventListener('change', handler));
414
+ this.#scaleEl.addEventListener('change', handler);
415
+ this.#cleanups.push(() => this.#scaleEl.removeEventListener('change', handler));
326
416
  }
327
417
  }
328
418
 
@@ -363,15 +453,6 @@ class ThemePanel extends UIElement {
363
453
  #onPresetClick(name) {
364
454
  const preset = PRESETS[name];
365
455
  if (!preset) return;
366
- if (preset.clearTheme) {
367
- // "Reset" preset — clears [theme] too
368
- const target = this.#resolveTarget();
369
- target.removeAttribute('theme');
370
- this.activeTheme = '';
371
- if (this.persist) {
372
- try { localStorage.removeItem(`${this.storagePrefix}theme`); } catch {}
373
- }
374
- }
375
456
  this.#apply({ density: preset.density, radius: preset.radius }, 'preset');
376
457
  if (this.#densityEl) this.#densityEl.value = preset.density;
377
458
  if (this.#radiusEl) this.#radiusEl.value = preset.radius;
@@ -396,6 +477,7 @@ class ThemePanel extends UIElement {
396
477
  else localStorage.removeItem(`${this.storagePrefix}theme`);
397
478
  } catch {}
398
479
  }
480
+ if (this.#themeEl) this.#themeEl.value = this.activeTheme;
399
481
  }
400
482
 
401
483
  if (partial.density !== undefined) {
@@ -435,18 +517,22 @@ class ThemePanel extends UIElement {
435
517
  if (this.#schemeEl) this.#schemeEl.value = this.activeScheme;
436
518
  }
437
519
 
438
- if (partial.register !== undefined) {
439
- const r = partial.register;
440
- target.toggleAttribute('verse', r === 'verse');
441
- target.toggleAttribute('prose', r === 'prose');
442
- this.activeRegister = (r === 'verse' || r === 'prose') ? r : '';
520
+ if (partial.scale !== undefined) {
521
+ const s = partial.scale;
522
+ if (s && SCALE_TIERS.includes(s)) {
523
+ target.setAttribute('scale', s);
524
+ this.activeScale = s;
525
+ } else {
526
+ target.removeAttribute('scale');
527
+ this.activeScale = '';
528
+ }
443
529
  if (this.persist) {
444
530
  try {
445
- if (this.activeRegister) localStorage.setItem(`${this.storagePrefix}register`, this.activeRegister);
446
- else localStorage.removeItem(`${this.storagePrefix}register`);
531
+ if (this.activeScale) localStorage.setItem(`${this.storagePrefix}scale`, this.activeScale);
532
+ else localStorage.removeItem(`${this.storagePrefix}scale`);
447
533
  } catch {}
448
534
  }
449
- if (this.#registerEl) this.#registerEl.value = r;
535
+ if (this.#scaleEl) this.#scaleEl.value = this.activeScale;
450
536
  }
451
537
 
452
538
  this.#emit(source);
@@ -460,6 +546,7 @@ class ThemePanel extends UIElement {
460
546
  scheme: this.activeScheme,
461
547
  density: this.activeDensity ? parseFloat(this.activeDensity) : 1,
462
548
  radius: this.activeRadius ? parseFloat(this.activeRadius) : 1,
549
+ scale: this.activeScale,
463
550
  source,
464
551
  },
465
552
  }));
@@ -469,7 +556,7 @@ class ThemePanel extends UIElement {
469
556
 
470
557
  #initState() {
471
558
  const target = this.#resolveTarget();
472
- let theme, scheme, density, radius, register;
559
+ let theme, scheme, density, radius, scale;
473
560
 
474
561
  if (this.persist) {
475
562
  try {
@@ -479,7 +566,7 @@ class ThemePanel extends UIElement {
479
566
  const r = localStorage.getItem(`${this.storagePrefix}radius`);
480
567
  density = d != null ? parseFloat(d) : undefined;
481
568
  radius = r != null ? parseFloat(r) : undefined;
482
- register = localStorage.getItem(`${this.storagePrefix}register`) || '';
569
+ scale = localStorage.getItem(`${this.storagePrefix}scale`) || '';
483
570
  } catch {}
484
571
  }
485
572
 
@@ -490,6 +577,7 @@ class ThemePanel extends UIElement {
490
577
  } else {
491
578
  this.activeTheme = target.getAttribute('theme') || '';
492
579
  }
580
+ if (this.#themeEl) this.#themeEl.value = this.activeTheme;
493
581
 
494
582
  // Apply scheme
495
583
  if (scheme === 'light' || scheme === 'dark') {
@@ -532,13 +620,12 @@ class ThemePanel extends UIElement {
532
620
  this.activeRadius = String(r);
533
621
  }
534
622
 
535
- // Apply scale-context register
536
- if (this.#registerEl) {
537
- const r = (register === 'verse' || register === 'prose') ? register : 'regular';
538
- target.toggleAttribute('verse', r === 'verse');
539
- target.toggleAttribute('prose', r === 'prose');
540
- this.#registerEl.value = r;
541
- this.activeRegister = (r === 'regular') ? '' : r;
623
+ // Apply scale
624
+ if (this.#scaleEl) {
625
+ const s = SCALE_TIERS.includes(scale) ? scale : (target.getAttribute('scale') || '');
626
+ if (s) target.setAttribute('scale', s);
627
+ this.#scaleEl.value = s;
628
+ this.activeScale = s;
542
629
  }
543
630
  }
544
631