tweakit 0.6.0 → 0.7.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.
Files changed (132) hide show
  1. package/AGENTS.md +5 -5
  2. package/README.md +3 -3
  3. package/dist/tweaks/auto.js +1 -1
  4. package/dist/tweaks/auto.js.map +1 -1
  5. package/dist/tweaks/bezier-S2BOXQBM.js +2 -0
  6. package/dist/tweaks/bezier-S2BOXQBM.js.map +6 -0
  7. package/dist/tweaks/chunk-3MZAAMC2.js +2 -0
  8. package/dist/tweaks/chunk-3MZAAMC2.js.map +6 -0
  9. package/dist/tweaks/chunk-ETB35KFV.js +2 -0
  10. package/dist/tweaks/chunk-ETB35KFV.js.map +6 -0
  11. package/dist/tweaks/chunk-YB5GKWZI.js +2 -0
  12. package/dist/tweaks/chunk-YB5GKWZI.js.map +6 -0
  13. package/dist/tweaks/clip-A3VI53BK.js +2 -0
  14. package/dist/tweaks/clip-A3VI53BK.js.map +6 -0
  15. package/dist/tweaks/color-UGYMAF3W.js +2 -0
  16. package/dist/tweaks/core.js +1 -1
  17. package/dist/tweaks/core.js.map +3 -3
  18. package/dist/tweaks/fold-GY47HIQA.js +2 -0
  19. package/dist/tweaks/fold-GY47HIQA.js.map +6 -0
  20. package/dist/tweaks/gradient-BTX25WSH.js +2 -0
  21. package/dist/tweaks/gradient-BTX25WSH.js.map +6 -0
  22. package/dist/tweaks/{heavy-CLD7UAVI.js → heavy-NPFIGHRM.js} +2 -2
  23. package/dist/tweaks/image-HTQQ4WX6.js +4 -0
  24. package/dist/tweaks/image-HTQQ4WX6.js.map +6 -0
  25. package/dist/tweaks/{image-store-WRKY2NPU.js → image-store-QD5SMZXG.js} +1 -1
  26. package/dist/tweaks/image-store-QD5SMZXG.js.map +6 -0
  27. package/dist/tweaks/interval-BHPI4LRN.js +2 -0
  28. package/dist/tweaks/interval-BHPI4LRN.js.map +6 -0
  29. package/dist/tweaks/markup-QM53TQJZ.js +2 -0
  30. package/dist/tweaks/markup-QM53TQJZ.js.map +6 -0
  31. package/dist/tweaks/monitor-722WIYGW.js +3 -0
  32. package/dist/tweaks/monitor-722WIYGW.js.map +6 -0
  33. package/dist/tweaks/motion-HMCZK6HJ.js +2 -0
  34. package/dist/tweaks/motion-HMCZK6HJ.js.map +6 -0
  35. package/dist/tweaks/plot-D3NQ6XNN.js +3 -0
  36. package/dist/tweaks/plot-D3NQ6XNN.js.map +6 -0
  37. package/dist/tweaks/point-XDXKMDXH.js +2 -0
  38. package/dist/tweaks/point-XDXKMDXH.js.map +6 -0
  39. package/dist/tweaks/presets-EUWNSCUW.js +2 -0
  40. package/dist/tweaks/presets-EUWNSCUW.js.map +6 -0
  41. package/dist/tweaks/shadow-AM7VSRNS.js +2 -0
  42. package/dist/tweaks/shadow-AM7VSRNS.js.map +6 -0
  43. package/dist/tweaks/shadow-root-RYUV3AGS.js +3 -0
  44. package/dist/tweaks/shadow-root-RYUV3AGS.js.map +6 -0
  45. package/dist/tweaks/sides-JWIURDA4.js +2 -0
  46. package/dist/tweaks/sides-JWIURDA4.js.map +6 -0
  47. package/dist/tweaks/spring-BF5GBTPM.js +2 -0
  48. package/dist/tweaks/spring-BF5GBTPM.js.map +6 -0
  49. package/dist/tweaks/tabs-PRVUUZXZ.js +2 -0
  50. package/dist/tweaks/tabs-PRVUUZXZ.js.map +6 -0
  51. package/dist/tweaks.css +1 -1
  52. package/dist/tweaks.js +5 -4
  53. package/dist/tweaks.js.map +3 -3
  54. package/dist/types/tweaks/enhance.d.ts +3 -2
  55. package/dist/types/tweaks/types.d.ts +278 -72
  56. package/package.json +5 -2
  57. package/src/tweaks/auto.ts +9 -5
  58. package/src/tweaks/clip.ts +84 -22
  59. package/src/tweaks/controls/basic.ts +463 -139
  60. package/src/tweaks/controls/bezier.ts +152 -47
  61. package/src/tweaks/controls/color.ts +610 -161
  62. package/src/tweaks/controls/gradient.ts +256 -77
  63. package/src/tweaks/controls/image.ts +388 -132
  64. package/src/tweaks/controls/interval.ts +126 -41
  65. package/src/tweaks/controls/monitor.ts +142 -36
  66. package/src/tweaks/controls/motion.ts +231 -56
  67. package/src/tweaks/controls/plot.ts +321 -101
  68. package/src/tweaks/controls/point.ts +134 -45
  69. package/src/tweaks/controls/shadow.ts +103 -30
  70. package/src/tweaks/controls/sides.ts +150 -40
  71. package/src/tweaks/controls/spring.ts +220 -60
  72. package/src/tweaks/controls/tabs.ts +39 -18
  73. package/src/tweaks/core.ts +1 -1
  74. package/src/tweaks/easing.ts +45 -20
  75. package/src/tweaks/enhance.ts +21 -6
  76. package/src/tweaks/feedback.ts +133 -34
  77. package/src/tweaks/fold.ts +22 -10
  78. package/src/tweaks/heavy.ts +461 -137
  79. package/src/tweaks/icons.ts +12 -5
  80. package/src/tweaks/image-store.ts +131 -43
  81. package/src/tweaks/lazy.ts +36 -19
  82. package/src/tweaks/markup.ts +255 -92
  83. package/src/tweaks/panel.ts +1262 -438
  84. package/src/tweaks/presets.ts +85 -0
  85. package/src/tweaks/schema.ts +213 -51
  86. package/src/tweaks/shadow-root.ts +178 -0
  87. package/src/tweaks/shared.ts +802 -190
  88. package/src/tweaks/standalone.ts +120 -30
  89. package/src/tweaks/types.ts +644 -143
  90. package/src/tweaks/undo.ts +104 -0
  91. package/src/tweaks.css +556 -550
  92. package/src/wide-gamut.ts +473 -125
  93. package/dist/tweaks/bezier-JFEZMCLP.js +0 -2
  94. package/dist/tweaks/bezier-JFEZMCLP.js.map +0 -6
  95. package/dist/tweaks/chunk-6UO4DHBN.js +0 -2
  96. package/dist/tweaks/chunk-6UO4DHBN.js.map +0 -6
  97. package/dist/tweaks/chunk-DIQMLBAS.js +0 -2
  98. package/dist/tweaks/chunk-DIQMLBAS.js.map +0 -6
  99. package/dist/tweaks/chunk-XE6OUJBG.js +0 -2
  100. package/dist/tweaks/chunk-XE6OUJBG.js.map +0 -6
  101. package/dist/tweaks/clip-WW7JYO4X.js +0 -2
  102. package/dist/tweaks/clip-WW7JYO4X.js.map +0 -6
  103. package/dist/tweaks/color-YMMFURP6.js +0 -2
  104. package/dist/tweaks/fold-U6YRZLCJ.js +0 -2
  105. package/dist/tweaks/fold-U6YRZLCJ.js.map +0 -6
  106. package/dist/tweaks/gradient-JER7PW4D.js +0 -2
  107. package/dist/tweaks/gradient-JER7PW4D.js.map +0 -6
  108. package/dist/tweaks/image-H6QSLGO3.js +0 -4
  109. package/dist/tweaks/image-H6QSLGO3.js.map +0 -6
  110. package/dist/tweaks/image-store-WRKY2NPU.js.map +0 -6
  111. package/dist/tweaks/interval-TEUHQGZN.js +0 -2
  112. package/dist/tweaks/interval-TEUHQGZN.js.map +0 -6
  113. package/dist/tweaks/markup-NIBJD6E2.js +0 -2
  114. package/dist/tweaks/markup-NIBJD6E2.js.map +0 -6
  115. package/dist/tweaks/monitor-3BY7YCGX.js +0 -3
  116. package/dist/tweaks/monitor-3BY7YCGX.js.map +0 -6
  117. package/dist/tweaks/motion-FQPUBKKM.js +0 -2
  118. package/dist/tweaks/motion-FQPUBKKM.js.map +0 -6
  119. package/dist/tweaks/plot-2HBNBZSG.js +0 -3
  120. package/dist/tweaks/plot-2HBNBZSG.js.map +0 -6
  121. package/dist/tweaks/point-VV6XV3I6.js +0 -2
  122. package/dist/tweaks/point-VV6XV3I6.js.map +0 -6
  123. package/dist/tweaks/shadow-5E4E55I2.js +0 -2
  124. package/dist/tweaks/shadow-5E4E55I2.js.map +0 -6
  125. package/dist/tweaks/sides-P3STEVYY.js +0 -2
  126. package/dist/tweaks/sides-P3STEVYY.js.map +0 -6
  127. package/dist/tweaks/spring-HUVSXNVN.js +0 -2
  128. package/dist/tweaks/spring-HUVSXNVN.js.map +0 -6
  129. package/dist/tweaks/tabs-76FW7LQ7.js +0 -2
  130. package/dist/tweaks/tabs-76FW7LQ7.js.map +0 -6
  131. /package/dist/tweaks/{color-YMMFURP6.js.map → color-UGYMAF3W.js.map} +0 -0
  132. /package/dist/tweaks/{heavy-CLD7UAVI.js.map → heavy-NPFIGHRM.js.map} +0 -0
@@ -1,16 +1,60 @@
1
1
  /* tweaks() — the panel factory: builds the shell + controls from a schema and returns the live
2
2
  * API. Persistence, presets, undo, floating drag and the lazy-window queue live here. */
3
3
  import {
4
- el, btn, txt, clamp, popover, closeActivePopover, stopPointerLeak, foldBody,
5
- applyThemeVars, resolveTheme, carryScheme, onLive, setDisabled, requestReflow, quietFocus,
6
- getControl, notice, isObj, hasOwn, isReservedKey, valueChanged, restoreDefault,
4
+ el,
5
+ btn,
6
+ txt,
7
+ clamp,
8
+ popover,
9
+ closeActivePopover,
10
+ stopPointerLeak,
11
+ foldBody,
12
+ applyThemeVars,
13
+ resolveTheme,
14
+ carryScheme,
15
+ onLive,
16
+ setDisabled,
17
+ requestReflow,
18
+ quietFocus,
19
+ activeIn,
20
+ isolate,
21
+ using,
22
+ words,
23
+ getControl,
24
+ notice,
25
+ isObj,
26
+ hasOwn,
27
+ isReservedKey,
28
+ valueChanged,
29
+ restoreDefault,
7
30
  } from "./shared.js";
8
31
  import { metaFor, VALUELESS } from "./schema.js";
9
32
  import { ensureForMetas, scanTypes } from "./lazy.js";
10
33
  import { createFolder, createControl } from "./controls/basic.js";
11
- import { makeCopyBtn, makeResetBtn, toolbarBtn, copyText, spinReset, announce, hideHintNow, addHintMarker, LABEL_SEL } from "./feedback.js";
34
+ import {
35
+ makeCopyBtn,
36
+ makeResetBtn,
37
+ toolbarBtn,
38
+ copyText,
39
+ spinReset,
40
+ announce,
41
+ hideHintNow,
42
+ addHintMarker,
43
+ LABEL_SEL,
44
+ } from "./feedback.js";
12
45
  import { ICON_PRESETS, ICON_X } from "./icons.js";
13
- import type { Schema, HeldSchema, TweaksOptions, Panel, Params, ParamsOf, KnownTypes, PanelState, Control } from "./types.js";
46
+ import { createUndo } from "./undo.js";
47
+ import type {
48
+ Schema,
49
+ HeldSchema,
50
+ TweaksOptions,
51
+ Panel,
52
+ Params,
53
+ ParamsOf,
54
+ KnownTypes,
55
+ PanelState,
56
+ Control,
57
+ } from "./types.js";
14
58
  import type { Meta } from "./schema.js";
15
59
  import type { ClipCtx, Scope } from "./clip.js";
16
60
  import type { PanelEl } from "./shared.js";
@@ -19,210 +63,401 @@ import type { TabsControl } from "./controls/tabs.js";
19
63
  /** A params object or one of its folder/tabs sub-objects — the holder a control writes into. */
20
64
  type Target = Record<string, unknown>;
21
65
  /** A valued control's wiring: where its value lives, its set/get, the reset pair (raw, def) and its dotted path. */
22
- export interface Entry { target: Target; key: string; set: Control["set"]; get: Control["get"]; raw: unknown; def: unknown; path: string[]; persist: boolean; m: Meta; el: HTMLElement; note?: Control["note"]; range?: Control["range"] }
66
+ export interface Entry {
67
+ target: Target;
68
+ key: string;
69
+ set: Control["set"];
70
+ get: Control["get"];
71
+ raw: unknown;
72
+ def: unknown;
73
+ path: string[];
74
+ persist: boolean;
75
+ m: Meta;
76
+ el: HTMLElement;
77
+ note?: Control["note"];
78
+ range?: Control["range"];
79
+ }
23
80
  /** A values snapshot — params as JSON round-trips it. */
24
81
  export type Snapshot = Record<string, any>;
25
82
 
26
83
  let topFloating: HTMLElement | null = null; // the floating panel raised last (raise())
27
84
  const liveKeys = new Map<string, number>(); // persist keys of the panels alive now, for the shared-key warning
28
85
  let picked: [HTMLElement, () => void] | null = null; // the panel holding the page's one pick, and how to drop it
29
- // The page's one hidden field that a picked ⌘C / ⌘V focuses for the keypress, so the browser's own copy and paste run on
30
- // it: the system clipboard both ways, with no permission to ask. `onCatch` is that keypress's answer.
31
- let catchEl: HTMLTextAreaElement | null = null, onCatch: ((e: ClipboardEvent) => void) | null = null;
86
+ // The page's one hidden field that a picked ⌘C / ⌘V focuses for the keypress, so the browser's own copy and paste run
87
+ // on it: the system clipboard both ways, with no permission to ask. `onCatch` is that keypress's answer.
88
+ let catchEl: HTMLTextAreaElement | null = null,
89
+ onCatch: ((e: ClipboardEvent) => void) | null = null;
32
90
 
33
91
  /** Build a panel from `schema`; the shell and API exist synchronously, lazy controls fill in on `ready`.
34
92
  * A schema written inline (or `as const`) types `params` from its shape. */
35
- export function tweaks<const S extends HeldSchema>(name: string, schema: S & KnownTypes<S>, opts?: TweaksOptions): Panel<ParamsOf<S>>;
93
+ export function tweaks<const S extends HeldSchema>(
94
+ name: string,
95
+ schema: S & KnownTypes<S>,
96
+ opts?: TweaksOptions,
97
+ ): Panel<ParamsOf<S>>;
36
98
  export function tweaks(name: string, schema: Schema, opts: TweaksOptions = {}): Panel {
37
- const metas = Object.entries(schema).map(([k, v]) => metaFor(k, v)).filter(Boolean);
99
+ // an untitled tab page's name is a word too
100
+ const metas = using(opts.strings, () =>
101
+ Object.entries(schema)
102
+ .map(([k, v]) => metaFor(k, v))
103
+ .filter(Boolean),
104
+ );
105
+ const tr = words(opts.strings); // the panel's own words; its controls take them as assemble() builds them
38
106
  const params: Params = {};
39
- let last: string | undefined; // the dotted path the latest change touched (`card.size`), as set() and changes() name it: on()'s second argument
107
+ // the dotted path the latest change touched (`card.size`), as set() and changes() name it: on()'s second argument
108
+ let last: string | undefined;
40
109
  const entries: Entry[] = []; // flattened across folders, for reset + persist
41
- const valueless = new Map<string, string>(); // dotted path → type of each button, monitor, separator: set() says it holds no value
110
+ // dotted path → type of each button, monitor, separator: set() says it holds no value
111
+ const valueless = new Map<string, string>();
42
112
  const listeners = new Set<(p: Params, last?: string) => void>();
43
113
  const own = new Set<(p: Params, last?: string) => void>(); // undo + conditionals: all a silent write calls
44
114
  const cleanups: Array<() => void> = []; // releases for destroy(): document/window listeners, timers
45
115
  let destroyed = false; // set by destroy(): assemble() bails, mutators go silent
46
116
  let assembled = false; // set at the end of assemble(); no controls exist before
47
- // The lazy window: a mutating API call made before the controls exist queues here and replays in order once assemble() has built them.
117
+ // The lazy window: a mutating API call made before the controls exist queues here and replays in order once
118
+ // assemble() has built them.
48
119
  const queue: Array<() => void> = [];
49
- // The last preset op queued per name: a loadPreset() behind a save is accepted, one behind a delete refused.
50
120
  let failed = false; // the chunks failed: the panel can never be built
51
121
  let built = -1; // cleanups.length as assemble() enters build()
52
- const later = (fn: () => void) => { if (destroyed) return; if (failed) return; assembled ? fn() : queue.push(fn); }; // the failed build already said why
122
+ // the failed build already said why
123
+ const later = (fn: () => void) => {
124
+ if (destroyed) return;
125
+ if (failed) return;
126
+ assembled ? fn() : queue.push(fn);
127
+ };
53
128
  let liftSlot: HTMLSpanElement | null = null; // the placeholder a lifted panel leaves in its slot
54
- const carried: Record<string, string> = {}; // what the lift carried from the slot's context (lift()), under the panel's own theme
129
+ // what the lift carried from the slot's context (lift()), under the panel's own theme
130
+ const carried: Record<string, string> = {};
55
131
  // The values snapshot: undefined written as null (JSON would drop the key).
56
- const replacer = (_k: string, v: unknown) => v === undefined ? null : v;
132
+ const replacer = (_k: string, v: unknown) => (v === undefined ? null : v);
57
133
  // params as the schema shapes it, folders and tab pages included: a value the host parks on params is its own, so
58
134
  // snapshots, restores and undo carry only what a control holds. Null-prototype: a folder named "toString" is its own.
59
135
  const valuesOf = (ms: Meta[], from: Target): Snapshot => {
60
136
  const o: Snapshot = Object.create(null);
61
- for (const m of ms) if (hasOwn(from, m.key)) { // skipped and valueless rows hold nothing
62
- const v: unknown = from[m.key];
63
- if (m.type === "folder") o[m.key] = valuesOf(m.children, v as Target);
64
- else if (m.type === "tabs") { const t: Snapshot = o[m.key] = Object.create(null); for (const pg of m.pages) if (hasOwn(v as Target, pg.key)) t[pg.key] = valuesOf(pg.children, (v as Target)[pg.key] as Target); }
65
- else o[m.key] = v;
66
- }
137
+ for (const m of ms)
138
+ if (hasOwn(from, m.key)) {
139
+ // skipped and valueless rows hold nothing
140
+ const v: unknown = from[m.key];
141
+ if (m.type === "folder") o[m.key] = valuesOf(m.children, v as Target);
142
+ else if (m.type === "tabs") {
143
+ const t: Snapshot = (o[m.key] = Object.create(null));
144
+ for (const pg of m.pages)
145
+ if (hasOwn(v as Target, pg.key)) t[pg.key] = valuesOf(pg.children, (v as Target)[pg.key] as Target);
146
+ } else o[m.key] = v;
147
+ }
67
148
  return o;
68
149
  };
69
150
  const snapshot = (): Snapshot => JSON.parse(JSON.stringify(valuesOf(metas, params), replacer));
70
151
  // Storage keys — opt-in via opts.persist (a string key, or `true` to key by name).
71
152
  const persistKey = opts.persist ? `tw:${opts.persist === true ? name : opts.persist}` : null;
72
153
  const presetsKey = persistKey ? `${persistKey}:presets` : null;
73
- // Two live panels on one key overwrite each other's saves, the last write winning: say so. A remount after destroy() is fine.
74
- // The key is held until destroy(), or until a failed build leaves the panel unable to save: a remount then is quiet too.
154
+ // Two live panels on one key overwrite each other's saves, the last write winning: say so. A remount after destroy()
155
+ // is fine.
156
+ // The key is held until destroy(), or until a failed build leaves the panel unable to save: a remount then is quiet
157
+ // too.
75
158
  let holdsKey = !!persistKey;
76
- if (persistKey) { const n = liveKeys.get(persistKey) || 0; if (n) notice("sharedKey", persistKey.slice(3)); liveKeys.set(persistKey, n + 1); }
77
- const releaseKey = () => { if (!holdsKey) return; holdsKey = false; const n = (liveKeys.get(persistKey!) || 1) - 1; if (n) liveKeys.set(persistKey!, n); else liveKeys.delete(persistKey!); };
78
- const readStore = (k: string): any => { try { return JSON.parse(localStorage.getItem(k) || "null"); } catch { return null; } }; // any: a parsed JSON blob, whatever the store holds
79
- const writeStore = (k: string, v: unknown) => { try { localStorage.setItem(k, JSON.stringify(v)); return true; } catch { return false; } }; // false: storage off, full or denied
80
- // Saved values and presets carry a format version, { v: 1, values } and { v: 1, presets }, so a later change to what's
81
- // stored (a color's precision, a gray's hue) can tell old data from new. Anything else (0.5's bare object) reads as
82
- // nothing, and the panel opens on its defaults.
159
+ if (persistKey) {
160
+ const n = liveKeys.get(persistKey) || 0;
161
+ if (n) notice("sharedKey", persistKey.slice(3));
162
+ liveKeys.set(persistKey, n + 1);
163
+ }
164
+ const releaseKey = () => {
165
+ if (!holdsKey) return;
166
+ holdsKey = false;
167
+ const n = (liveKeys.get(persistKey!) || 1) - 1;
168
+ if (n) liveKeys.set(persistKey!, n);
169
+ else liveKeys.delete(persistKey!);
170
+ };
171
+ // any: a parsed JSON blob, whatever the store holds
172
+ const readStore = (k: string): any => {
173
+ try {
174
+ return JSON.parse(localStorage.getItem(k) || "null");
175
+ } catch {
176
+ return null;
177
+ }
178
+ };
179
+ // false: storage off, full or denied
180
+ const writeStore = (k: string, v: unknown) => {
181
+ try {
182
+ localStorage.setItem(k, JSON.stringify(v));
183
+ return true;
184
+ } catch {
185
+ return false;
186
+ }
187
+ };
188
+ // Saved values and presets carry a format version, { v: 1, values } and { v: 1, presets }, so a later change to
189
+ // what's stored (a color's precision, a gray's hue) can tell old data from new. Anything else (0.5's bare object)
190
+ // reads as nothing, and the panel opens on its defaults.
83
191
  const unwrap = (s: any, k: string) => (s?.v === 1 ? s[k] : null);
84
192
  const writeValues = (snap: Snapshot) => writeStore(persistKey!, { v: 1, values: snap });
85
193
  const writePresets = (all: unknown) => writeStore(presetsKey!, { v: 1, presets: all });
86
- const atPath = (obj: Snapshot | null, path: string[]): unknown => path.reduce<any>((o, k) => (o != null && hasOwn(o, k) ? o[k] : undefined), obj); // own keys only: a control keyed toString must not read Object.prototype's
194
+ // own keys only: a control keyed toString must not read Object.prototype's
195
+ const atPath = (obj: Snapshot | null, path: string[]): unknown =>
196
+ path.reduce<any>((o, k) => (o != null && hasOwn(o, k) ? o[k] : undefined), obj);
87
197
  // Take one entry's value out of a snapshot; true when it was there. Reflect: an undeletable key is left, not thrown.
88
- const drop = (snap: Snapshot | null, e: Entry) => { const o: any = atPath(snap, e.path.slice(0, -1)); if (!o || typeof o !== "object" || !hasOwn(o, e.key)) return false; Reflect.deleteProperty(o, e.key); return true; };
198
+ const drop = (snap: Snapshot | null, e: Entry) => {
199
+ const o: any = atPath(snap, e.path.slice(0, -1));
200
+ if (!o || typeof o !== "object" || !hasOwn(o, e.key)) return false;
201
+ Reflect.deleteProperty(o, e.key);
202
+ return true;
203
+ };
89
204
  // A control with `persist: false` stays out of what is saved and out of what is restored.
90
- const unsaved = (snap: Snapshot | null) => { for (const e of entries) if (!e.persist) drop(snap, e); return snap; };
205
+ const unsaved = (snap: Snapshot | null) => {
206
+ for (const e of entries) if (!e.persist) drop(snap, e);
207
+ return snap;
208
+ };
91
209
  // ── Images in IndexedDB (image-store.ts): what storage holds for an uploaded image is its ref, and `savedImages` maps this
92
- // panel's refs to their data URLs. The build doesn't wait for them: every other value restores as it builds, and a stored
93
- // image waits in `waiting` until the store answers, then fills in (restoreImages), by `ready`. With no store to hold an
94
- // upload (not answered yet, unavailable, or it refused that image), the data URL saves inline, as 0.5 saved it, and moves
95
- // to the store on a later save; a write storage refuses then leaves the inline images out. With no store, a stored ref
210
+ // panel's refs to their data URLs. The build doesn't wait for them: every other value restores as it builds, and a
211
+ // stored image waits in `waiting` until the store answers, then fills in (restoreImages), by `ready`. With no store
212
+ // to hold an upload (not answered yet, unavailable, or it refused that image), the data URL saves inline, as 0.5
213
+ // saved it, and moves to the store on a later save; a write storage refuses then leaves the inline images out. With
214
+ // no store, a stored ref
96
215
  // stays saved as it was until its row is written. ──
97
- let imgs: ReturnType<typeof import("./image-store.js")["bind"]> | null = null, savedImages: Map<string, string> | null = null; // both null without IndexedDB
216
+ // both null without IndexedDB
217
+ let imgs: ReturnType<(typeof import("./image-store.js"))["bind"]> | null = null,
218
+ savedImages: Map<string, string> | null = null;
98
219
  let imagesReady = false; // the store has answered, or failed to load
99
- const waiting = new Map<Entry, [string, unknown]>(); // a row's stored ref, and the value it held while the store loaded or the image was fetched
100
- const isRef = (v: unknown): v is string => typeof v === "string" && v.startsWith("tw-image:"); // image-store.ts's ref, readable before that chunk loads
220
+ // a row's stored ref, and the value it held while the store loaded or the image was fetched
221
+ const waiting = new Map<Entry, [string, unknown]>();
222
+ // image-store.ts's ref, readable before that chunk loads
223
+ const isRef = (v: unknown): v is string => typeof v === "string" && v.startsWith("tw-image:");
101
224
  const isDataUrl = (v: unknown): v is string => typeof v === "string" && v.startsWith("data:");
102
225
  const imageEntries = () => entries.filter((e) => e.m.type === "image");
103
226
  const unsavable = new Map<Entry, unknown>();
104
- const notSaved = (e: Entry, v: unknown) => { if (unsavable.get(e) === v) return; unsavable.set(e, v); e.note?.("Not saved"); };
227
+ const notSaved = (e: Entry, v: unknown) => {
228
+ if (unsavable.get(e) === v) return;
229
+ unsavable.set(e, v);
230
+ e.note?.(tr("notSaved", "Not saved"));
231
+ };
105
232
  const toRefs = (snap: Snapshot) => {
106
233
  const stored = imagesReady && imgs ? readStore(persistKey!) : undefined; // undefined: no store to hold an upload
107
234
  for (const e of imageEntries()) {
108
- const o: any = atPath(snap, e.path.slice(0, -1)), v = o?.[e.key], w = waiting.get(e);
109
- if (w && o && hasOwn(o, e.key)) { if (!valueChanged(w[1], v)) { o[e.key] = w[0]; continue; } waiting.delete(e); } // still loading or no store: keep the stored ref; a write since settles it
235
+ const o: any = atPath(snap, e.path.slice(0, -1)),
236
+ v = o?.[e.key],
237
+ w = waiting.get(e);
238
+ // still loading or no store: keep the stored ref; a write since settles it
239
+ if (w && o && hasOwn(o, e.key)) {
240
+ if (!valueChanged(w[1], v)) {
241
+ o[e.key] = w[0];
242
+ continue;
243
+ }
244
+ waiting.delete(e);
245
+ }
110
246
  if (!isDataUrl(v) || stored === undefined) continue; // a URL is small, and a data URL with no store saves inline
111
247
  const ref = imgs!.save(e, v, stored);
112
248
  if (ref) o[e.key] = ref;
113
249
  }
114
250
  return snap;
115
251
  };
116
- // An image a restore names by ref: from the store's answer, or once it answers; a ref the store has never seen here (a
117
- // preset another tab saved) is fetched. It lands only on a row the host and the person left alone meanwhile.
252
+ // An image a restore names by ref: from the store's answer, or once it answers; a ref the store has never seen here
253
+ // (a preset another tab saved) is fetched. It lands only on a row the host and the person left alone meanwhile.
118
254
  const late = (e: Entry, held: unknown, url: string) => {
119
255
  if (destroyed || valueChanged(held, e.get()) || !tryAssign(e, url)) return false;
120
256
  undoApi?.patch(e.path, held, e.get()); // not a step of its own: ⌘Z mustn't take a restored image back out
121
257
  return true;
122
258
  };
123
- // A stored snapshot back to values, the way applySnapshot takes them: each known ref resolved, an unknown one left out
124
- // (its control keeps its value) and listed, so `restore` can fill it in once it arrives.
259
+ // A stored snapshot back to values, the way applySnapshot takes them: each known ref resolved, an unknown one left
260
+ // out (its control keeps its value) and listed, so `restore` can fill it in once it arrives.
125
261
  const fromRefs = (snap: Snapshot | null, pending: [Entry, string][] = []) => {
126
- if (snap && typeof snap === "object") for (const e of imageEntries()) {
127
- const v = atPath(snap, e.path); if (!isRef(v)) continue;
128
- const url = savedImages?.get(v);
129
- if (url) (atPath(snap, e.path.slice(0, -1)) as any)[e.key] = url;
130
- else { drop(snap, e); pending.push([e, v]); }
131
- }
262
+ if (snap && typeof snap === "object")
263
+ for (const e of imageEntries()) {
264
+ const v = atPath(snap, e.path);
265
+ if (!isRef(v)) continue;
266
+ const url = savedImages?.get(v);
267
+ if (url) (atPath(snap, e.path.slice(0, -1)) as any)[e.key] = url;
268
+ else {
269
+ drop(snap, e);
270
+ pending.push([e, v]);
271
+ }
272
+ }
132
273
  return snap;
133
274
  };
134
275
  const restore = (snap: Snapshot | null, silent?: boolean) => {
135
276
  const pending: [Entry, string][] = [];
136
277
  applySnapshot(fromRefs(snap, pending), silent);
137
278
  for (const [e, ref] of pending) {
138
- const w: [string, unknown] = [ref, e.get()]; waiting.set(e, w); // a write meanwhile settles it, even back to the value held
279
+ // a write meanwhile settles it, even back to the value held
280
+ const w: [string, unknown] = [ref, e.get()];
281
+ waiting.set(e, w);
139
282
  if (!imagesReady || !imgs) continue; // no store: the ref stays saved until the row is written
140
- imgs.fetch(ref).then((url) => { if (waiting.get(e) !== w) return; waiting.delete(e); if (url && late(e, w[1], url)) { last = undefined; notify(); } });
283
+ imgs.fetch(ref).then((url) => {
284
+ if (waiting.get(e) !== w) return;
285
+ waiting.delete(e);
286
+ if (url && late(e, w[1], url)) {
287
+ last = undefined;
288
+ notify();
289
+ }
290
+ });
141
291
  }
142
292
  };
143
293
  // The store answered: each waiting row the host and the person left alone takes its image, in one notify.
144
294
  const restoreImages = () => {
145
295
  imagesReady = true;
146
- if (!assembled || destroyed || failed) { waiting.clear(); return; } // not built yet: the build resolves refs itself
296
+ // not built yet: the build resolves refs itself
297
+ if (!assembled || destroyed || failed) {
298
+ waiting.clear();
299
+ return;
300
+ }
147
301
  let changed = false;
148
- for (const [e, [ref, held]] of [...waiting]) { const url = savedImages?.get(ref); if (url && late(e, held, url)) changed = true; }
149
- if (imgs) waiting.clear(); // no store: each ref stays waiting, so no save writes the default over it and no later session prunes it
150
- if (changed) { last = undefined; notify(); }
151
- else if (imgs) persist(); // an upload saved inline while the store loaded moves to it now
302
+ for (const [e, [ref, held]] of [...waiting]) {
303
+ const url = savedImages?.get(ref);
304
+ if (url && late(e, held, url)) changed = true;
305
+ }
306
+ // no store: each ref stays waiting, so no save writes the default over it and no later session prunes it
307
+ if (imgs) waiting.clear();
308
+ if (changed) {
309
+ last = undefined;
310
+ notify();
311
+ } else if (imgs) persist(); // an upload saved inline while the store loaded moves to it now
152
312
  };
153
- // Delete the records nothing names any more: not the saved values, not a preset. Only records this panel knows, loaded
154
- // or written: another tab's panel on the same key may have just stored one this panel has never seen.
313
+ // Delete the records nothing names any more: not the saved values, not a preset. Only records this panel knows,
314
+ // loaded or written: another tab's panel on the same key may have just stored one this panel has never seen.
155
315
  const pruneImages = () => imgs?.prune([readStore(persistKey!), listPresets()]);
156
316
  // Debounced save; destroy() flushes a pending write rather than dropping it. saveNow writes at once.
157
317
  let saveNow = () => {};
158
- const persist = persistKey ? (() => {
159
- let saveT = 0;
160
- // A write storage refuses (off, denied, or full of something else) is dropped silently, unless an image saved inline may
161
- // be what didn't fit: then those are left out, their rows say so, and the rest is written.
162
- const save = () => {
163
- saveT = 0;
164
- const snap = toRefs(unsaved(snapshot()));
165
- if (!writeValues(snap)) {
166
- const inline = imageEntries().filter((e) => isDataUrl(atPath(snap, e.path)));
167
- if (!inline.length) return;
168
- for (const e of inline) { notSaved(e, atPath(snap, e.path)); drop(snap, e); }
169
- if (!writeValues(snap)) return;
170
- }
171
- pruneImages();
172
- };
173
- saveNow = save; cleanups.push(() => { if (saveT) { clearTimeout(saveT); save(); } });
174
- return () => { clearTimeout(saveT); saveT = setTimeout(save, 150); };
175
- })() : () => {};
318
+ const persist = persistKey
319
+ ? (() => {
320
+ let saveT = 0;
321
+ // A write storage refuses (off, denied, or full of something else) is dropped silently, unless an image saved
322
+ // inline may be what didn't fit: then those are left out, their rows say so, and the rest is written.
323
+ const save = () => {
324
+ saveT = 0;
325
+ const snap = toRefs(unsaved(snapshot()));
326
+ if (!writeValues(snap)) {
327
+ const inline = imageEntries().filter((e) => isDataUrl(atPath(snap, e.path)));
328
+ if (!inline.length) return;
329
+ for (const e of inline) {
330
+ notSaved(e, atPath(snap, e.path));
331
+ drop(snap, e);
332
+ }
333
+ if (!writeValues(snap)) return;
334
+ }
335
+ pruneImages();
336
+ };
337
+ saveNow = save;
338
+ cleanups.push(() => {
339
+ if (saveT) {
340
+ clearTimeout(saveT);
341
+ save();
342
+ }
343
+ });
344
+ return () => {
345
+ clearTimeout(saveT);
346
+ saveT = setTimeout(save, 150);
347
+ };
348
+ })()
349
+ : () => {};
176
350
  // Each listener runs isolated: a throwing callback can't break the others or skip persist().
177
351
  // `last` is read once, so a write one listener makes can't change the key the rest are told.
178
- const notify = (silent?: boolean, changed = last) => { (silent ? own : listeners).forEach((fn) => { try { fn(params, changed); } catch (e) { console.error("[tweaks] listener threw:", e); } }); if (!destroyed) persist(); }; // a listener may destroy() the panel: arm no save behind it
179
- // The one write path behind restores and set(). A function/symbol/bigint is no control value (JSON can't carry those).
352
+ // a listener may destroy() the panel: arm no save behind it
353
+ const notify = (silent?: boolean, changed = last) => {
354
+ (silent ? own : listeners).forEach((fn) => {
355
+ try {
356
+ fn(params, changed);
357
+ } catch (e) {
358
+ console.error("[tweaks] listener threw:", e);
359
+ }
360
+ });
361
+ if (!destroyed) persist();
362
+ };
363
+ // The one write path behind restores and set(). A function/symbol/bigint is no control value (JSON can't carry
364
+ // those).
180
365
  const notAValue = (v: unknown) => typeof v === "function" || typeof v === "symbol" || typeof v === "bigint";
181
366
  // An explicit write also settles a row still waiting for its stored image: the host's or the person's value wins.
182
- const assign = (e: Entry, v: unknown) => { if (notAValue(v)) throw new TypeError(`a ${typeof v} is not a control value`); waiting.delete(e); e.set(v); e.target[e.key] = e.get(); };
183
- const tryAssign = (e: Entry, v: unknown) => { try { assign(e, v); return true; } catch (err) { console.error(`[tweaks] restoring "${e.path.join(".")}" failed — value skipped:`, err); return false; } };
367
+ const assign = (e: Entry, v: unknown) => {
368
+ if (notAValue(v)) throw new TypeError(`a ${typeof v} is not a control value`);
369
+ waiting.delete(e);
370
+ e.set(v);
371
+ e.target[e.key] = e.get();
372
+ };
373
+ const tryAssign = (e: Entry, v: unknown) => {
374
+ try {
375
+ assign(e, v);
376
+ return true;
377
+ } catch (err) {
378
+ console.error(`[tweaks] restoring "${e.path.join(".")}" failed — value skipped:`, err);
379
+ return false;
380
+ }
381
+ };
184
382
  // …and the reset write: the opened form, then the authored value (restoreDefault).
185
- const assignDefault = (e: Entry) => { waiting.delete(e); restoreDefault(e, e.raw, e.def); e.target[e.key] = e.get(); };
383
+ const assignDefault = (e: Entry) => {
384
+ waiting.delete(e);
385
+ restoreDefault(e, e.raw, e.def);
386
+ e.target[e.key] = e.get();
387
+ };
186
388
  // opts.onReset replaces the default; a reset() called from INSIDE it performs the default instead of recursing.
187
389
  let inOnReset = false;
188
390
  const resetAll = () => {
189
391
  if (typeof opts.onReset === "function" && !inOnReset) {
190
392
  inOnReset = true;
191
- try { return opts.onReset(); } finally { inOnReset = false; }
393
+ try {
394
+ return opts.onReset();
395
+ } finally {
396
+ inOnReset = false;
397
+ }
192
398
  }
193
399
  for (const e of entries) {
194
- try { assignDefault(e); }
195
- catch (err) { console.error(`[tweaks] resetting "${e.path.join(".")}" failed — control skipped:`, err); }
400
+ try {
401
+ assignDefault(e);
402
+ } catch (err) {
403
+ console.error(`[tweaks] resetting "${e.path.join(".")}" failed — control skipped:`, err);
404
+ }
196
405
  }
197
- last = undefined; notify();
406
+ last = undefined;
407
+ notify();
198
408
  };
199
409
 
200
- const panel: PanelEl = el("div", "tw-panel"); panel.dataset.mode = "inline";
201
- // A landmark, so a screen reader can jump to the panel and hear which one; an unnamed region is no landmark, so a blank name falls back.
202
- const landmark = (n: string) => panel.setAttribute("aria-label", n.trim() || "Tweaks");
203
- panel.setAttribute("role", "region"); landmark(name);
410
+ const panel: PanelEl = el("div", "twk-panel");
411
+ panel.dataset.mode = "inline";
412
+ // A landmark, so a screen reader can jump to the panel and hear which one; an unnamed region is no landmark, so a
413
+ // blank name falls back.
414
+ const landmark = (n: string) => panel.setAttribute("aria-label", n.trim() || tr("panel", "Tweaks"));
415
+ panel.setAttribute("role", "region");
416
+ landmark(name);
204
417
  // Stop pointer events leaking past the panel to a stage listening on window.
205
418
  stopPointerLeak(panel);
206
- const header = el("div", "tw-header");
419
+ const header = el("div", "twk-header");
207
420
  // The title is the collapse toggle; the toolbar beside it never collapses.
208
- const titleBtn = btn("tw-header-toggle");
421
+ const titleBtn = btn("twk-header-toggle");
209
422
  titleBtn.setAttribute("aria-expanded", "true");
210
423
  let title = name; // setName() moves it; the storage key keeps the built name
211
- const titleEl = txt("span", "tw-title", title); titleBtn.append(titleEl);
212
- const toolbar = el("div", "tw-toolbar");
424
+ const titleEl = txt("span", "twk-title", title);
425
+ titleBtn.append(titleEl);
426
+ const toolbar = el("div", "twk-toolbar");
213
427
  // The values moved off their defaults, keyed by dotted path — setMany()'s shape, for baking tuned values into source.
214
- const changed = (): Record<string, unknown> => { const out: Record<string, unknown> = {}; for (const e of entries) if (valueChanged(e.get(), e.def)) out[e.path.join(".")] = e.get(); return JSON.parse(JSON.stringify(out, replacer)); };
428
+ const changed = (): Record<string, unknown> => {
429
+ const out: Record<string, unknown> = {};
430
+ for (const e of entries) if (valueChanged(e.get(), e.def)) out[e.path.join(".")] = e.get();
431
+ return JSON.parse(JSON.stringify(out, replacer));
432
+ };
215
433
  // ⇧-click copies the changed values only; reset restores every default or runs opts.onReset.
216
- const copyBtn = makeCopyBtn((changedOnly) => { if (!changedOnly) return [JSON.stringify(snapshot(), null, 2), -1]; const c = changed(); return [JSON.stringify(c, null, 2), Object.keys(c).length]; });
434
+ const copyBtn = makeCopyBtn((changedOnly) => {
435
+ if (!changedOnly) return [JSON.stringify(snapshot(), null, 2), -1];
436
+ const c = changed();
437
+ return [JSON.stringify(c, null, 2), Object.keys(c).length];
438
+ }, tr);
217
439
  // A picked row's paste (pasteScope, in the lazy clip chunk) writes through the panel's own load path; ctx lends it.
218
440
  // A paste is its own undo step: what was pending commits first, the paste right after, so neither merges with a drag.
219
- const ctx: ClipCtx = { panel, entries, fromJSON: (st) => { undoApi?.flush(); api.fromJSON(st); undoApi?.flush(); }, announce, alive: () => !destroyed };
220
- const resetBtn = makeResetBtn(resetAll);
441
+ const ctx: ClipCtx = {
442
+ panel,
443
+ entries,
444
+ fromJSON: (st) => {
445
+ undoApi?.flush();
446
+ api.fromJSON(st);
447
+ undoApi?.flush();
448
+ },
449
+ announce,
450
+ alive: () => !destroyed,
451
+ tr,
452
+ };
453
+ const resetBtn = makeResetBtn(resetAll, tr);
221
454
  // Presets share persistence's storage, so the button needs it on.
222
455
  let presetsBtn: HTMLButtonElement | null = null;
223
- if (persistKey && opts.presets !== false) { // opts.presets:false keeps the saving, without the menu
224
- presetsBtn = toolbarBtn("", ICON_PRESETS, "Presets");
225
- presetsBtn.setAttribute("aria-haspopup", "menu"); presetsBtn.setAttribute("aria-expanded", "false");
456
+ if (persistKey && opts.presets !== false) {
457
+ // opts.presets:false keeps the saving, without the menu
458
+ presetsBtn = toolbarBtn("", ICON_PRESETS, tr("presets", "Presets"));
459
+ presetsBtn.setAttribute("aria-haspopup", "menu");
460
+ presetsBtn.setAttribute("aria-expanded", "false");
226
461
  toolbar.append(presetsBtn);
227
462
  }
228
463
  toolbar.append(copyBtn, resetBtn);
@@ -231,173 +466,324 @@ export function tweaks(name: string, schema: Schema, opts: TweaksOptions = {}):
231
466
  // The buttons act on controls that only exist once assemble() has built them: inert until then.
232
467
  const toolbarBtns = [copyBtn, resetBtn, presetsBtn].filter(Boolean);
233
468
  for (const b of toolbarBtns) b.disabled = true;
234
- // swallowClick lives on the header, not the title: once the header holds pointer capture, the click ending a drag is dispatched there.
235
- let dragMoved = false, swallowClick = false;
236
- header.addEventListener("click", (e) => { if (swallowClick) { swallowClick = false; e.preventDefault(); e.stopPropagation(); } }, true);
469
+ // swallowClick lives on the header, not the title: once the header holds pointer capture, the click ending a drag is
470
+ // dispatched there.
471
+ let dragMoved = false,
472
+ swallowClick = false;
473
+ header.addEventListener(
474
+ "click",
475
+ (e) => {
476
+ if (swallowClick) {
477
+ swallowClick = false;
478
+ e.preventDefault();
479
+ e.stopPropagation();
480
+ }
481
+ },
482
+ true,
483
+ );
237
484
  // A bottom-parked floating panel grows past the viewport when it expands: re-clamp once the collapse has settled.
238
485
  const toggleBody = () => foldBody(panel, titleBtn, body, !panel.classList.contains("is-collapsed"), reclamp);
239
486
  titleBtn.addEventListener("click", toggleBody);
240
- // A click anywhere on the header outside the toolbar folds the panel, if the press began on the header or title (a press
241
- // slid off a toolbar button also clicks here). A drag's closing click arrives defaultPrevented.
487
+ // A click anywhere on the header outside the toolbar folds the panel, if the press began on the header or title (a
488
+ // press slid off a toolbar button also clicks here). A drag's closing click arrives defaultPrevented.
242
489
  let pressedHeader = false;
243
- header.addEventListener("pointerdown", (e) => { pressedHeader = e.target === header || titleBtn.contains(e.target as Node); }, true);
244
- header.addEventListener("click", (e) => { if (e.target === header && pressedHeader && !e.defaultPrevented) toggleBody(); });
245
- const body = el("div", "tw-body");
246
- const controls = el("div", "tw-controls");
490
+ header.addEventListener(
491
+ "pointerdown",
492
+ (e) => {
493
+ pressedHeader = e.target === header || titleBtn.contains(e.target as Node);
494
+ },
495
+ true,
496
+ );
497
+ header.addEventListener("click", (e) => {
498
+ if (e.target === header && pressedHeader && !e.defaultPrevented) toggleBody();
499
+ });
500
+ const body = el("div", "twk-body");
501
+ const controls = el("div", "twk-controls");
247
502
  body.append(controls);
248
503
  panel.append(header, body);
504
+ // opts.shadow: the panel lives in a shadow root on its own host, out of page CSS's reach; shadow-root.ts styles it
505
+ // and gives its portals a root of their own. `box` is what sits in the page and moves on a lift.
506
+ const iso = isolate(panel, opts.shadow),
507
+ box = iso.box;
249
508
 
250
509
  // Stash the theme so the portaled popovers (which lose the panel's inherited vars) re-apply it on open.
251
510
  let themeVars = resolveTheme(opts.theme);
252
- applyThemeVars(panel, themeVars); panel._twTheme = themeVars;
511
+ applyThemeVars(panel, themeVars);
512
+ panel._twTheme = themeVars;
253
513
 
254
514
  // ── Floating position — seeded in the shell, so an opts.floating / persisted panel is fixed in place
255
515
  // the moment tweaks() returns instead of jumping when the lazy chunks land. ──
256
516
  const draggable = opts.draggable !== false;
257
- const MARGIN = 8, SNAP = 28; // px: viewport inset; snap distance to park against an edge
517
+ const MARGIN = 8,
518
+ SNAP = 28; // px: viewport inset; snap distance to park against an edge
258
519
  // opts.floating's x / y are insets from the viewport's top-left, as px / py below are.
259
520
  const place = typeof opts.floating === "object" && opts.floating ? opts.floating : null;
260
- // The vertical clamp uses the panel's full height, scrollHeight, not its capped one: clamping on the cap would ratchet
261
- // (every position fits, so a tall panel drags up but never back down). +1: it rounds, so a panel parked at the far edge
262
- // isn't capped a fraction short of its rows.
521
+ // The vertical clamp uses the panel's full height, scrollHeight, not its capped one: clamping on the cap would
522
+ // ratchet (every position fits, so a tall panel drags up but never back down). +1: it rounds, so a panel parked at
523
+ // the far edge isn't capped a fraction short of its rows.
263
524
  // The viewport from clientWidth / clientHeight: a fixed box's right and bottom edges sit inside the page scrollbars,
264
525
  // which innerWidth / innerHeight count. Only in standards mode: with no doctype the root's client box is the page's
265
526
  // content, not the viewport (and jsdom has no layout: 0) — the inner size there.
266
- const root = () => document.compatMode === "CSS1Compat" ? document.documentElement : null;
267
- const vw = () => root()?.clientWidth || window.innerWidth, vh = () => root()?.clientHeight || window.innerHeight;
268
- const bounds = () => ({ maxX: Math.max(MARGIN, vw() - panel.offsetWidth - MARGIN), maxY: Math.max(MARGIN, vh() - panel.scrollHeight - 1 - gap) });
527
+ const root = () => (document.compatMode === "CSS1Compat" ? document.documentElement : null);
528
+ const vw = () => root()?.clientWidth || window.innerWidth,
529
+ vh = () => root()?.clientHeight || window.innerHeight;
530
+ const bounds = () => ({
531
+ maxX: Math.max(MARGIN, vw() - panel.offsetWidth - MARGIN),
532
+ maxY: Math.max(MARGIN, vh() - panel.scrollHeight - 1 - gap),
533
+ });
269
534
  const posKey = persistKey ? `${persistKey}:pos` : null;
270
535
  // A pinned panel sits where opts.floating puts it; only a draggable one restores a saved spot.
271
536
  const stored = draggable && posKey ? readStore(posKey) : null;
272
537
  const saved = stored && typeof stored === "object" ? stored : null;
273
538
  const start = saved || place || { x: 16, y: 16 };
274
- const inset = (v: unknown) => { const n = parseFloat(v as string); return n >= 0 ? n : 16; }; // 0 is an inset, not a missing one
275
- const x0 = inset(start.x), y0 = inset(start.y);
276
- let px = x0, py = y0;
277
- // The gap a capped panel keeps from the far edge: while it sits at its opts.floating placement, the inset that gave the
278
- // near one (16 for `true`), so a panel placed 16px down stops 16px short of the bottom. Once dragged or restored, it has
279
- // left that placement: the margin, as the drag clamp keeps. --tw-float-gap overrides either (tweaks.css registers it
280
- // as a <length>, so a rem or vh value computes to px here).
281
- let moved = !!saved, gap = MARGIN;
282
- const readGap = () => { const v = parseFloat(getComputedStyle(panel).getPropertyValue("--tw-float-gap")); gap = v >= 0 ? v : opts.floating && !moved ? inset(place?.y) : MARGIN; };
283
- // A fixed box ignores page scroll, so a floating panel caps at the viewport past its near edge and scrolls (tweaks.css).
539
+ // 0 is an inset, not a missing one
540
+ const inset = (v: unknown) => {
541
+ const n = parseFloat(v as string);
542
+ return n >= 0 ? n : 16;
543
+ };
544
+ const x0 = inset(start.x),
545
+ y0 = inset(start.y);
546
+ let px = x0,
547
+ py = y0;
548
+ // The gap a capped panel keeps from the far edge: while it sits at its opts.floating placement, the inset that gave
549
+ // the near one (16 for `true`), so a panel placed 16px down stops 16px short of the bottom. Once dragged or restored,
550
+ // it has left that placement: the margin, as the drag clamp keeps. --twk-float-gap overrides either (tweaks.css
551
+ // registers it as a <length>, so a rem or vh value computes to px here).
552
+ let moved = !!saved,
553
+ gap = MARGIN;
554
+ const readGap = () => {
555
+ const v = parseFloat(getComputedStyle(panel).getPropertyValue("--twk-float-gap"));
556
+ gap = v >= 0 ? v : opts.floating && !moved ? inset(place?.y) : MARGIN;
557
+ };
558
+ // A fixed box ignores page scroll, so a floating panel caps at the viewport past its near edge and scrolls
559
+ // (tweaks.css).
284
560
  // Custom properties, not left / top / max-height: the stylesheet maps them, so host CSS can still override each one.
285
- const apply = () => { const s = panel.style; s.setProperty("--tw-float-x", px + "px"); s.setProperty("--tw-float-y", py + "px"); s.setProperty("--tw-float-max-h", `calc(100dvh - ${py + gap}px)`); };
561
+ const apply = () => {
562
+ const s = panel.style;
563
+ s.setProperty("--twk-float-x", px + "px");
564
+ s.setProperty("--twk-float-y", py + "px");
565
+ s.setProperty("--twk-float-max-h", `calc(100dvh - ${py + gap}px)`);
566
+ };
286
567
  const clampPos = () => {
287
568
  readGap();
288
569
  const { maxX, maxY } = bounds();
289
- if (draggable) { px = clamp(px, MARGIN, maxX); py = clamp(py, MARGIN, maxY); return; }
290
- // A pinned panel clamps from its placement each time, so a window that narrows and widens again hands its spot back.
570
+ if (draggable) {
571
+ px = clamp(px, MARGIN, maxX);
572
+ py = clamp(py, MARGIN, maxY);
573
+ return;
574
+ }
575
+ // A pinned panel clamps from its placement each time, so a window that narrows and widens again hands its spot
576
+ // back.
291
577
  px = clamp(x0, Math.min(MARGIN, x0), maxX);
292
- // It keeps its insets while they leave it half the window (or its full height, if less); past that the inset and the
293
- // far gap shrink together, so a short window never caps it to nothing.
294
- const room = vh() - Math.min(panel.scrollHeight + 1, vh() / 2), k = Math.min(1, room / (y0 + gap || 1));
295
- py = Math.round(y0 * k); gap = Math.round(gap * k);
578
+ // It keeps its insets while they leave it half the window (or its full height, if less); past that the inset and
579
+ // the far gap shrink together, so a short window never caps it to nothing.
580
+ const room = vh() - Math.min(panel.scrollHeight + 1, vh() / 2),
581
+ k = Math.min(1, room / (y0 + gap || 1));
582
+ py = Math.round(y0 * k);
583
+ gap = Math.round(gap * k);
584
+ };
585
+ const reclamp = () => {
586
+ if (!destroyed && panel.dataset.mode === "floating" && panel.isConnected) {
587
+ clampPos();
588
+ apply();
589
+ }
296
590
  };
297
- const reclamp = () => { if (!destroyed && panel.dataset.mode === "floating" && panel.isConnected) { clampPos(); apply(); } };
298
591
  if ((draggable || opts.floating) && (opts.floating || saved)) {
299
- panel.dataset.mode = "floating"; clampPos(); apply();
592
+ panel.dataset.mode = "floating";
593
+ clampPos();
594
+ apply();
300
595
  // A position saved on a larger monitor restores off-viewport: re-clamp once mounted (offsetWidth is 0 until then).
301
596
  requestAnimationFrame(reclamp);
302
597
  }
303
598
 
304
599
  // Per-control reset. The target is LABEL_SEL — the hint marker's list, so the two can't drift — or the root.
305
- const resetEntry = (e: Entry) => { assignDefault(e); last = e.path.join("."); notify(); };
600
+ const resetEntry = (e: Entry) => {
601
+ assignDefault(e);
602
+ last = e.path.join(".");
603
+ notify();
604
+ };
306
605
  const wireReset = (root: HTMLElement, entry: Entry) => {
307
- const t = root.querySelector<HTMLElement>(".tw-slider-value")
308
- || root.querySelector<HTMLElement>(LABEL_SEL)
309
- || root;
310
- t.classList.add("tw-resettable"); t.title = "Double-click or hold to reset";
606
+ const t =
607
+ root.querySelector<HTMLElement>(".twk-slider-value") || root.querySelector<HTMLElement>(LABEL_SEL) || root;
608
+ t.classList.add("twk-resettable");
609
+ t.title = tr("resetHint", "Double-click or hold to reset");
311
610
  // Coarse pointers get a press-and-hold reset: the desktop double-click fights double-tap-zoom on touch.
312
- let holdT = 0, hx = 0, hy = 0, held = false;
313
- const cancelHold = () => { if (holdT) { clearTimeout(holdT); holdT = 0; t.classList.remove("tw-reset-holding"); } };
611
+ let holdT = 0,
612
+ hx = 0,
613
+ hy = 0,
614
+ held = false;
615
+ const cancelHold = () => {
616
+ if (holdT) {
617
+ clearTimeout(holdT);
618
+ holdT = 0;
619
+ t.classList.remove("twk-reset-holding");
620
+ }
621
+ };
314
622
  t.addEventListener("pointerdown", (e) => {
315
- e.stopPropagation(); held = false; // a press on the readout mustn't jump the slider
623
+ e.stopPropagation();
624
+ held = false; // a press on the readout mustn't jump the slider
316
625
  if (e.pointerType !== "touch" && e.pointerType !== "pen") return;
317
- hx = e.clientX; hy = e.clientY; t.classList.add("tw-reset-holding");
318
- holdT = setTimeout(() => { holdT = 0; t.classList.remove("tw-reset-holding", "is-editable"); held = true; resetEntry(entry); }, 500);
626
+ hx = e.clientX;
627
+ hy = e.clientY;
628
+ t.classList.add("twk-reset-holding");
629
+ holdT = setTimeout(() => {
630
+ holdT = 0;
631
+ t.classList.remove("twk-reset-holding", "is-editable");
632
+ held = true;
633
+ resetEntry(entry);
634
+ }, 500);
635
+ });
636
+ t.addEventListener("pointermove", (e) => {
637
+ if (holdT && Math.abs(e.clientX - hx) + Math.abs(e.clientY - hy) > 8) cancelHold();
319
638
  });
320
- t.addEventListener("pointermove", (e) => { if (holdT && Math.abs(e.clientX - hx) + Math.abs(e.clientY - hy) > 8) cancelHold(); });
321
639
  t.addEventListener("pointerup", cancelHold);
322
640
  t.addEventListener("pointercancel", cancelHold);
323
641
  t.addEventListener("pointerleave", cancelHold);
324
- t.addEventListener("contextmenu", (e) => { if (held) e.preventDefault(); }); // a long-press mustn't raise the text callout
325
- t.addEventListener("click", (e) => { if (held) { e.preventDefault(); e.stopImmediatePropagation(); held = false; } }, true); // a completed hold-reset swallows the trailing tap-to-edit
326
- t.addEventListener("dblclick", (e) => {
327
- if (t === root && (e.target as Element).closest?.("input, textarea")) return; // a double-click in a text field is a word-select
328
- e.preventDefault(); e.stopPropagation(); resetEntry(entry);
642
+ // a long-press mustn't raise the text callout
643
+ t.addEventListener("contextmenu", (e) => {
644
+ if (held) e.preventDefault();
329
645
  });
330
- // Click #1 can swap the readout for the inline input, so the dblclick lands there; delegated on the root.
331
- if (t.classList.contains("tw-slider-value")) root.addEventListener("dblclick", (e) => {
332
- const inp = e.target as HTMLElement;
333
- if (!inp.classList?.contains("tw-slider-input")) return;
334
- e.preventDefault(); e.stopPropagation();
335
- inp.blur(); // commits + closes the editor; a no-op change, so no notify
646
+ // a completed hold-reset swallows the trailing tap-to-edit
647
+ t.addEventListener(
648
+ "click",
649
+ (e) => {
650
+ if (held) {
651
+ e.preventDefault();
652
+ e.stopImmediatePropagation();
653
+ held = false;
654
+ }
655
+ },
656
+ true,
657
+ );
658
+ t.addEventListener("dblclick", (e) => {
659
+ // a double-click in a text field is a word-select
660
+ if (t === root && (e.target as Element).closest?.("input, textarea")) return;
661
+ e.preventDefault();
662
+ e.stopPropagation();
336
663
  resetEntry(entry);
337
664
  });
338
- // The keyboard reset, Alt-qualified so a bare Backspace in a text field stays a backspace and the gradient editor keeps Delete.
665
+ // Click #1 can swap the readout for the inline input, so the dblclick lands there; delegated on the root.
666
+ if (t.classList.contains("twk-slider-value"))
667
+ root.addEventListener("dblclick", (e) => {
668
+ const inp = e.target as HTMLElement;
669
+ if (!inp.classList?.contains("twk-slider-input")) return;
670
+ e.preventDefault();
671
+ e.stopPropagation();
672
+ inp.blur(); // commits + closes the editor; a no-op change, so no notify
673
+ resetEntry(entry);
674
+ });
675
+ // The keyboard reset, Alt-qualified so a bare Backspace in a text field stays a backspace and the gradient editor
676
+ // keeps Delete.
339
677
  root.addEventListener("keydown", (e) => {
340
678
  if (!e.altKey || e.metaKey || e.ctrlKey || (e.key !== "Backspace" && e.key !== "Delete")) return;
341
- e.preventDefault(); e.stopPropagation();
342
- if ((e.target as HTMLElement).classList?.contains("tw-slider-input")) (e.target as HTMLElement).blur(); // an open inline editor commits + closes first
679
+ e.preventDefault();
680
+ e.stopPropagation();
681
+ // an open inline editor commits + closes first
682
+ if ((e.target as HTMLElement).classList?.contains("twk-slider-input")) (e.target as HTMLElement).blur();
343
683
  resetEntry(entry);
344
684
  });
345
685
  };
346
686
 
347
- // Conditional controls — `render` shows/hides, `disabled` grays-out + locks, both re-evaluated on every change; `hint` is a static tooltip.
687
+ // Conditional controls — `render` shows/hides, `disabled` grays-out + locks, both re-evaluated on every change;
688
+ // `hint` is a static tooltip.
348
689
  const conditionals: Array<{ node: HTMLElement; m: Meta }> = [];
349
- // fold.ts is a lazy chunk the first pass imports, so that pass just toggles; foldRow returns whether the row was revealed (re-measure).
690
+ // fold.ts is a lazy chunk the first pass imports, so that pass just toggles; foldRow returns whether the row was
691
+ // revealed (re-measure).
350
692
  let fold: typeof import("./fold.js").fold | undefined;
351
693
  const foldRow = (node: HTMLElement & { _hid?: boolean }, hide: boolean) => {
352
694
  if (!node._hid === !hide) return false;
353
695
  node._hid = hide;
354
- if (fold) fold(node, hide); else node.classList.toggle("tw-cond-hidden", hide);
696
+ if (fold) fold(node, hide);
697
+ else node.classList.toggle("twk-cond-hidden", hide);
355
698
  return !hide;
356
699
  };
357
700
  const registerCond = (node: HTMLElement, m: Meta) => {
358
701
  if (m.hint) addHintMarker(node, m.hint);
359
702
  if (m.render || m.disabled != null) conditionals.push({ node, m });
360
703
  };
361
- const folderEls: Array<{ path: string[]; el: HTMLElement; setCollapsed: (c: boolean) => void }> = [], tabsCtrls: Array<{ path: string[]; ctrl: TabsControl; pageKeys: string[] }> = []; // UI state for toJSON/fromJSON, keyed by path
704
+ // UI state for toJSON/fromJSON, keyed by path
705
+ const folderEls: Array<{ path: string[]; el: HTMLElement; setCollapsed: (c: boolean) => void }> = [],
706
+ tabsCtrls: Array<{ path: string[]; ctrl: TabsControl; pageKeys: string[] }> = [];
362
707
 
363
- // A control's own loop (the monitor, the FPS graph) hands back a `destroy`; a panel destroyed before it ever connected would run it forever.
364
- const adopt = (ctrl: Control) => { if (typeof ctrl.destroy === "function") cleanups.push(ctrl.destroy); };
708
+ // A control's own loop (the monitor, the FPS graph) hands back a `destroy`; a panel destroyed before it ever
709
+ // connected would run it forever.
710
+ const adopt = (ctrl: Control) => {
711
+ if (typeof ctrl.destroy === "function") cleanups.push(ctrl.destroy);
712
+ };
365
713
 
366
714
  const build = (container: HTMLElement, ms: Meta[], target: Target, basePath: string[] = []) => {
367
715
  for (const m of ms) {
368
716
  if (m.type === "tabs") {
369
- const sub: Target = {}; target[m.key] = sub;
717
+ const sub: Target = {};
718
+ target[m.key] = sub;
370
719
  const makeTabs = getControl<TabsControl>("tabs");
371
720
  const tabsCtrl = makeTabs && makeTabs(m);
372
721
  if (!tabsCtrl) continue; // ensured before assemble; skipped if its chunk failed
373
722
  tabsCtrls.push({ path: [...basePath, m.key], ctrl: tabsCtrl, pageKeys: m.pages.map((p) => p.key) });
374
- m.pages.forEach((page, i) => { const psub: Target = {}; sub[page.key] = psub; build(tabsCtrl.bodies[i], page.children, psub, [...basePath, m.key, page.key]); });
375
- registerCond(tabsCtrl.el, m); container.append(tabsCtrl.el);
723
+ m.pages.forEach((page, i) => {
724
+ const psub: Target = {};
725
+ sub[page.key] = psub;
726
+ build(tabsCtrl.bodies[i], page.children, psub, [...basePath, m.key, page.key]);
727
+ });
728
+ registerCond(tabsCtrl.el, m);
729
+ container.append(tabsCtrl.el);
376
730
  continue;
377
731
  }
378
732
  if (m.type === "folder") {
379
- const sub: Target = {}; target[m.key] = sub;
733
+ const sub: Target = {};
734
+ target[m.key] = sub;
380
735
  const f = createFolder(m);
381
736
  folderEls.push({ path: [...basePath, m.key], el: f.el, setCollapsed: f.setCollapsed });
382
737
  scopes.set(f.el, { kind: "folder", path: [...basePath, m.key], label: m.label });
383
- build(f.body, m.children, sub, [...basePath, m.key]); registerCond(f.el, m); container.append(f.el);
738
+ build(f.body, m.children, sub, [...basePath, m.key]);
739
+ registerCond(f.el, m);
740
+ container.append(f.el);
384
741
  continue;
385
742
  }
386
743
  const valued = !VALUELESS.has(m.type);
387
744
  const dotted = [...basePath, m.key].join("."); // a bare leaf couldn't tell size from card.size
388
- let entry: Entry | undefined; // a change made in the panel settles a row waiting for its stored image, as assign does
389
- const ctrl = createControl(m, valued ? (v: unknown) => { if (!valueChanged(target[m.key], v)) return; if (entry) waiting.delete(entry); target[m.key] = v; last = dotted; notify(); } : () => {}); // same-value emits (a re-entrant echo) don't notify
745
+ // a change made in the panel settles a row waiting for its stored image, as assign does
746
+ let entry: Entry | undefined;
747
+ // same-value emits (a re-entrant echo) don't notify
748
+ const ctrl = createControl(
749
+ m,
750
+ valued
751
+ ? (v: unknown) => {
752
+ if (!valueChanged(target[m.key], v)) return;
753
+ if (entry) waiting.delete(entry);
754
+ target[m.key] = v;
755
+ last = dotted;
756
+ notify();
757
+ }
758
+ : () => {},
759
+ );
390
760
  if (!ctrl) continue;
391
761
  adopt(ctrl);
392
762
  if (!valued) valueless.set(dotted, m.type);
393
763
  if (valued) {
394
- // reset() restores `raw` over the form the control OPENED on (`def`), read before the parked value applies — see restoreDefault.
764
+ // reset() restores `raw` over the form the control OPENED on (`def`), read before the parked value applies —
765
+ // see restoreDefault.
395
766
  const def = ctrl.get();
396
767
  // A value a host parked on params before assemble ran wins over the schema default.
397
768
  if (hasOwn(target, m.key)) ctrl.set(target[m.key]);
398
769
  target[m.key] = ctrl.get();
399
- entry = { target, key: m.key, set: ctrl.set, get: ctrl.get, raw: m.value, def, path: [...basePath, m.key], persist: m.persist !== false, m, el: ctrl.el, note: ctrl.note, range: ctrl.range };
400
- entries.push(entry); wireReset(ctrl.el, entry); scopes.set(ctrl.el, { kind: "row", entry, label: m.label });
770
+ entry = {
771
+ target,
772
+ key: m.key,
773
+ set: ctrl.set,
774
+ get: ctrl.get,
775
+ raw: m.value,
776
+ def,
777
+ path: [...basePath, m.key],
778
+ persist: m.persist !== false,
779
+ m,
780
+ el: ctrl.el,
781
+ note: ctrl.note,
782
+ range: ctrl.range,
783
+ };
784
+ entries.push(entry);
785
+ wireReset(ctrl.el, entry);
786
+ scopes.set(ctrl.el, { kind: "row", entry, label: m.label });
401
787
  }
402
788
  registerCond(ctrl.el, m);
403
789
  container.append(ctrl.el);
@@ -409,30 +795,67 @@ export function tweaks(name: string, schema: Schema, opts: TweaksOptions = {}):
409
795
  const applySnapshot = (snap: Snapshot | null, silent?: boolean) => {
410
796
  if (!snap || typeof snap !== "object") return;
411
797
  for (const e of entries) {
412
- const v = atPath(snap, e.path); if (v === undefined) continue;
798
+ const v = atPath(snap, e.path);
799
+ if (v === undefined) continue;
413
800
  tryAssign(e, v);
414
801
  }
415
- last = undefined; notify(silent);
802
+ last = undefined;
803
+ notify(silent);
416
804
  };
417
805
  // ── Named presets (opt-in via opts.persist) — snapshots under "<key>:presets" ──
418
- const listPresets = (): Record<string, Snapshot> => { const p = presetsKey ? unwrap(readStore(presetsKey), "presets") : null; return Object.assign(Object.create(null), p && typeof p === "object" ? p : {}); }; // null-proto: a preset named "__proto__" is an ordinary key
806
+ // null-proto: a preset named "__proto__" is an ordinary key
807
+ const listPresets = (): Record<string, Snapshot> => {
808
+ const p = presetsKey ? unwrap(readStore(presetsKey), "presets") : null;
809
+ return Object.assign(Object.create(null), p && typeof p === "object" ? p : {});
810
+ };
419
811
  // False when storage refused the write, so the caller can say so instead of claiming a save.
420
- const savePreset = (nm: string) => { const all = listPresets(); all[nm] = toRefs(snapshot()); return writePresets(all); }; // the menu announces a refusal
421
- const loadPreset = (nm: string) => { const all = listPresets(); if (all[nm]) restore(all[nm]); }; // re-read at replay: a delete queued ahead of it wins
422
- const deletePreset = (nm: string) => { const all = listPresets(); delete all[nm]; if (writePresets(all)) pruneImages(); };
812
+ // the menu announces a refusal
813
+ const savePreset = (nm: string) => {
814
+ const all = listPresets();
815
+ all[nm] = toRefs(snapshot());
816
+ return writePresets(all);
817
+ };
818
+ // re-read at replay: a delete queued ahead of it wins
819
+ const loadPreset = (nm: string) => {
820
+ const all = listPresets();
821
+ if (all[nm]) restore(all[nm]);
822
+ };
823
+ const deletePreset = (nm: string) => {
824
+ const all = listPresets();
825
+ delete all[nm];
826
+ if (writePresets(all)) pruneImages();
827
+ };
423
828
 
424
829
  // ── Whole-panel UI state (toJSON / fromJSON) — folder open/closed and the active tab, keyed by dotted path ──
425
- const pathKey = (p: string[]) => p.map((k) => String(k).replace(/~/g, "~1").replace(/\./g, "~0")).join("."); // JSON-pointer escaping, so a literal "." can't collide
830
+ // JSON-pointer escaping, so a literal "." can't collide
831
+ const pathKey = (p: string[]) => p.map((k) => String(k).replace(/~/g, "~1").replace(/\./g, "~0")).join(".");
426
832
  const collectUI = () => {
427
833
  const ui: NonNullable<PanelState["ui"]> = {};
428
- if (folderEls.length) { const f: Record<string, boolean> = {}; for (const fe of folderEls) f[pathKey(fe.path)] = fe.el.classList.contains("is-collapsed"); ui.folders = f; }
429
- if (tabsCtrls.length) { const t: Record<string, string | null> = {}; for (const tc of tabsCtrls) t[pathKey(tc.path)] = tc.pageKeys[tc.ctrl.active()] ?? null; ui.tabs = t; }
834
+ if (folderEls.length) {
835
+ const f: Record<string, boolean> = {};
836
+ for (const fe of folderEls) f[pathKey(fe.path)] = fe.el.classList.contains("is-collapsed");
837
+ ui.folders = f;
838
+ }
839
+ if (tabsCtrls.length) {
840
+ const t: Record<string, string | null> = {};
841
+ for (const tc of tabsCtrls) t[pathKey(tc.path)] = tc.pageKeys[tc.ctrl.active()] ?? null;
842
+ ui.tabs = t;
843
+ }
430
844
  return ui;
431
845
  };
432
846
  const applyUI = (ui: PanelState["ui"]) => {
433
847
  if (!ui || typeof ui !== "object") return;
434
- if (ui.folders) for (const fe of folderEls) { const c = ui.folders[pathKey(fe.path)]; if (typeof c === "boolean") fe.setCollapsed(c); }
435
- if (ui.tabs) for (const tc of tabsCtrls) { const i = tc.pageKeys.indexOf(ui.tabs[pathKey(tc.path)]); if (i >= 0 && i !== tc.ctrl.active()) tc.ctrl.activate(i); } // a re-activate would fire a spurious tw-reflow
848
+ if (ui.folders)
849
+ for (const fe of folderEls) {
850
+ const c = ui.folders[pathKey(fe.path)];
851
+ if (typeof c === "boolean") fe.setCollapsed(c);
852
+ }
853
+ // a re-activate would fire a spurious twk-reflow
854
+ if (ui.tabs)
855
+ for (const tc of tabsCtrls) {
856
+ const i = tc.pageKeys.indexOf(ui.tabs[pathKey(tc.path)]);
857
+ if (i >= 0 && i !== tc.ctrl.active()) tc.ctrl.activate(i);
858
+ }
436
859
  };
437
860
 
438
861
  // ── Repositioning — drag the header to move the panel ──
@@ -440,118 +863,233 @@ export function tweaks(name: string, schema: Schema, opts: TweaksOptions = {}):
440
863
  // pointer; on release it eases to a near viewport edge, and the position persists to "<key>:pos".
441
864
  if (draggable || opts.floating) {
442
865
  if (draggable) panel.dataset.draggable = "true"; // CSS grab cursor on the header — the affordance
443
- // Lift in place at the current rect; a same-size placeholder stays in the old slot, or the host container reflows mid-drag.
866
+ // Lift in place at the current rect; a same-size placeholder stays in the old slot, or the host container reflows
867
+ // mid-drag.
444
868
  const lift = () => {
445
869
  if (panel.dataset.mode === "floating") return;
446
870
  const r = panel.getBoundingClientRect();
447
- px = r.left; py = r.top;
448
- if (panel.parentNode) {
449
- liftSlot = el("span", "tw-lift-slot");
450
- liftSlot.style.width = r.width + "px"; liftSlot.style.height = r.height + "px";
871
+ px = r.left;
872
+ py = r.top;
873
+ if (box.parentNode) {
874
+ liftSlot = el("span", "twk-lift-slot");
875
+ liftSlot.style.width = r.width + "px";
876
+ liftSlot.style.height = r.height + "px";
451
877
  liftSlot.setAttribute("aria-hidden", "true");
452
- panel.before(liftSlot);
878
+ // a shadow panel's scheme pin is read at its slot from here on
879
+ box.before(liftSlot);
880
+ (box as any)._twSlot = liftSlot;
453
881
  }
454
- // Portal to <body>: a transformed/filter/contain ancestor would become the fixed panel's containing block. The scheme scope doesn't ride along — carry it.
882
+ // Portal to <body>: a transformed/filter/contain ancestor would become the fixed panel's containing block. The
883
+ // scheme scope doesn't ride along — carry it.
455
884
  // So does the rest of the slot's context: the text properties the kit leaves to inheritance (a host's font-size,
456
- // letter-spacing, color, direction, lang) and every --tw-* token, inherited or matched through an ancestor selector
457
- // (`.my-sidebar .tw-panel { --tw-radius: 4px }`, as the theming page shows). Read with the panel's own theme off, so
458
- // opts.theme / setTheme() still layer on top and setTheme(null) falls back to the host's values; only what the move
459
- // changes is written, inline. A host rule reaching inside through an ancestor (`#app button { … }`) still stops
460
- // matching: only a shadow root could keep that.
885
+ // letter-spacing, color, direction, lang) and every --twk-* token, inherited or matched through an ancestor
886
+ // selector (`.my-sidebar .twk-panel { --twk-radius: 4px }`, as the theming page shows). Read with the panel's own
887
+ // theme off, so opts.theme / setTheme() still layer on top and setTheme(null) falls back to the host's values;
888
+ // only what the move changes is written, inline. A host rule reaching inside through an ancestor
889
+ // (`#app button { … }`) still stops matching: only a shadow root could keep that.
461
890
  applyThemeVars(panel, null);
462
- const cs = getComputedStyle(panel), lang = panel.closest("[lang]")?.getAttribute("lang"), was: Record<string, string> = {};
463
- for (const k of ["font-family", "font-size", "font-weight", "font-style", "font-stretch", "font-variant", "font-feature-settings", "font-variation-settings", "font-kerning", "line-height", "letter-spacing", "word-spacing", "text-transform", "text-align", "color", "direction", ...Array.from(cs).filter((k) => k.startsWith("--tw-"))]) was[k] = cs.getPropertyValue(k);
891
+ const cs = getComputedStyle(panel),
892
+ lang = box.closest("[lang]")?.getAttribute("lang"),
893
+ was: Record<string, string> = {};
894
+ for (const k of [
895
+ "font-family",
896
+ "font-size",
897
+ "font-weight",
898
+ "font-style",
899
+ "font-stretch",
900
+ "font-variant",
901
+ "font-feature-settings",
902
+ "font-variation-settings",
903
+ "font-kerning",
904
+ "line-height",
905
+ "letter-spacing",
906
+ "word-spacing",
907
+ "text-transform",
908
+ "text-align",
909
+ "color",
910
+ "direction",
911
+ ...Array.from(cs).filter((k) => k.startsWith("--twk-")),
912
+ ])
913
+ was[k] = cs.getPropertyValue(k);
464
914
  carryScheme(panel, panel);
465
- document.body.appendChild(panel);
466
- for (const k in was) if (cs.getPropertyValue(k) !== was[k]) panel.style.setProperty(k, carried[k] = was[k]);
467
- if (lang && panel.closest("[lang]")?.getAttribute("lang") !== lang) panel.lang = lang;
915
+ document.body.appendChild(box);
916
+ for (const k in was) if (cs.getPropertyValue(k) !== was[k]) panel.style.setProperty(k, (carried[k] = was[k]));
917
+ if (lang && box.closest("[lang]")?.getAttribute("lang") !== lang) panel.lang = lang;
468
918
  applyThemeVars(panel, themeVars);
469
- panel.dataset.mode = "floating"; apply();
470
- // A host that removes its container without destroy() would leave the lifted panel on screen; when the placeholder leaves the document, the panel follows.
919
+ panel.dataset.mode = "floating";
920
+ apply();
921
+ // A host that removes its container without destroy() would leave the lifted panel on screen; when the
922
+ // placeholder leaves the document, the panel follows.
471
923
  if (liftSlot && typeof MutationObserver === "function") {
472
- const obs = new MutationObserver(() => { if (liftSlot && !liftSlot.isConnected) { obs.disconnect(); liftSlot = null; panel.remove(); } });
924
+ const obs = new MutationObserver(() => {
925
+ if (liftSlot && !liftSlot.isConnected) {
926
+ obs.disconnect();
927
+ liftSlot = null;
928
+ box.remove();
929
+ }
930
+ });
473
931
  obs.observe(document.documentElement, { childList: true, subtree: true });
474
932
  cleanups.push(() => obs.disconnect());
475
933
  }
476
934
  };
477
- // Two floating panels share one stylesheet z-index; the last touched gets a higher one. A class at plain .tw-panel
935
+ // Two floating panels share one stylesheet z-index; the last touched gets a higher one. A class at plain .twk-panel
478
936
  // specificity, not an inline value, so a host's own z-index still wins after a drag.
479
- const raise = () => { if (topFloating && topFloating !== panel) topFloating.classList.remove("is-raised"); panel.classList.add("is-raised"); topFloating = panel; };
480
- const grab = () => { moved = true; lift(); raise(); readGap(); };
481
- const moveTo = (x: number, y: number, dx: number, dy: number) => { const { maxX, maxY } = bounds(); px = clamp(x + dx, MARGIN, maxX); py = clamp(y + dy, MARGIN, maxY); apply(); };
482
- const savePos = () => { if (posKey) writeStore(posKey, { x: px, y: py }); };
937
+ const raise = () => {
938
+ if (topFloating && topFloating !== panel) topFloating.classList.remove("is-raised");
939
+ panel.classList.add("is-raised");
940
+ topFloating = panel;
941
+ };
942
+ const grab = () => {
943
+ moved = true;
944
+ lift();
945
+ raise();
946
+ readGap();
947
+ };
948
+ const moveTo = (x: number, y: number, dx: number, dy: number) => {
949
+ const { maxX, maxY } = bounds();
950
+ px = clamp(x + dx, MARGIN, maxX);
951
+ py = clamp(y + dy, MARGIN, maxY);
952
+ apply();
953
+ };
954
+ const savePos = () => {
955
+ if (posKey) writeStore(posKey, { x: px, y: py });
956
+ };
483
957
 
484
- let dragId: number | null = null, sx = 0, sy = 0, ox = 0, oy = 0, snapT = 0;
958
+ let dragId: number | null = null,
959
+ sx = 0,
960
+ sy = 0,
961
+ ox = 0,
962
+ oy = 0,
963
+ snapT = 0;
485
964
  // Document listeners (capture, so the panel's own pointer-stop can't hide them) for the press's duration.
486
965
  // Capture is taken only past the threshold: on the press it would retarget a plain click off the title.
487
966
  const listen = (on: boolean) => {
488
- for (const [t, fn] of [["pointermove", onMove], ["pointerup", endDrag], ["pointercancel", endDrag]] as Array<[string, (e: PointerEvent) => void]>) on ? document.addEventListener(t, fn, true) : document.removeEventListener(t, fn, true);
489
- on ? document.addEventListener("keydown", onDragKey, true) : document.removeEventListener("keydown", onDragKey, true);
967
+ for (const [t, fn] of [
968
+ ["pointermove", onMove],
969
+ ["pointerup", endDrag],
970
+ ["pointercancel", endDrag],
971
+ ] as Array<[string, (e: PointerEvent) => void]>)
972
+ on ? document.addEventListener(t, fn, true) : document.removeEventListener(t, fn, true);
973
+ on
974
+ ? document.addEventListener("keydown", onDragKey, true)
975
+ : document.removeEventListener("keydown", onDragKey, true);
490
976
  };
491
977
  // Escape mid-press cancels: the panel returns to where the drag began and the release's click is swallowed. Capture
492
978
  // phase, so it beats a pane's Escape.
493
979
  const onDragKey = (e: KeyboardEvent) => {
494
980
  if (e.key !== "Escape" || dragId === null) return;
495
- e.preventDefault(); e.stopPropagation();
496
- const id = dragId; dragId = null; listen(false); // before the release, so a lostpointercapture finds no drag to end
497
- try { header.releasePointerCapture(id); } catch {}
498
- if (dragMoved) { px = ox; py = oy; apply(); }
981
+ e.preventDefault();
982
+ e.stopPropagation();
983
+ // before the release, so a lostpointercapture finds no drag to end
984
+ const id = dragId;
985
+ dragId = null;
986
+ listen(false);
987
+ try {
988
+ header.releasePointerCapture(id);
989
+ } catch {}
990
+ if (dragMoved) {
991
+ px = ox;
992
+ py = oy;
993
+ apply();
994
+ }
499
995
  panel.classList.remove("is-dragging");
500
- swallowClick = true; document.addEventListener("pointerup", () => setTimeout(() => { swallowClick = false; }, 0), { capture: true, once: true });
996
+ swallowClick = true;
997
+ document.addEventListener(
998
+ "pointerup",
999
+ () =>
1000
+ setTimeout(() => {
1001
+ swallowClick = false;
1002
+ }, 0),
1003
+ { capture: true, once: true },
1004
+ );
501
1005
  };
502
- if (draggable) header.addEventListener("pointerdown", (e) => { // draggable:false keeps lift/clamp, never the press
503
- if (e.button !== 0 || dragId !== null || (e.target as Element).closest(".tw-toolbar, input, textarea, select")) return;
504
- dragId = e.pointerId; sx = e.clientX; sy = e.clientY; dragMoved = false;
505
- listen(true);
506
- // Regrab mid–edge-snap: pick the panel up where it visually is, not the parked target.
507
- if (panel.classList.contains("is-snapping")) {
508
- clearTimeout(snapT);
509
- const cur = getComputedStyle(panel);
510
- px = parseFloat(cur.left) || px; py = parseFloat(cur.top) || py;
511
- panel.classList.remove("is-snapping"); apply(); // …and drop the snap transition, so the grab is 1:1
512
- }
513
- });
514
- // The keyboard's drag: with the title focused, Alt+arrows move the panel 10px (Shift: 50px) through the same lift, clamp
515
- // and saved position as a pointer drag. Alt-qualified, so a bare arrow stays the page's.
516
- if (draggable) header.addEventListener("keydown", (e) => {
517
- if (e.target !== titleBtn || !e.altKey || e.metaKey || e.ctrlKey) return;
518
- const d = e.shiftKey ? 50 : 10, dx = e.key === "ArrowRight" ? d : e.key === "ArrowLeft" ? -d : 0, dy = e.key === "ArrowDown" ? d : e.key === "ArrowUp" ? -d : 0;
519
- if (!dx && !dy) return;
520
- e.preventDefault(); e.stopPropagation();
521
- const lifting = panel.dataset.mode !== "floating";
522
- grab();
523
- if (lifting) titleBtn.focus({ preventScroll: true }); // the lift moves the panel to <body>, which drops focus; the next step needs it back
524
- moveTo(px, py, dx, dy); savePos();
525
- });
1006
+ if (draggable)
1007
+ header.addEventListener("pointerdown", (e) => {
1008
+ // draggable:false keeps lift/clamp, never the press
1009
+ if (e.button !== 0 || dragId !== null || (e.target as Element).closest(".twk-toolbar, input, textarea, select"))
1010
+ return;
1011
+ dragId = e.pointerId;
1012
+ sx = e.clientX;
1013
+ sy = e.clientY;
1014
+ dragMoved = false;
1015
+ listen(true);
1016
+ // Regrab mid–edge-snap: pick the panel up where it visually is, not the parked target.
1017
+ if (panel.classList.contains("is-snapping")) {
1018
+ clearTimeout(snapT);
1019
+ const cur = getComputedStyle(panel);
1020
+ px = parseFloat(cur.left) || px;
1021
+ py = parseFloat(cur.top) || py;
1022
+ panel.classList.remove("is-snapping");
1023
+ apply(); // …and drop the snap transition, so the grab is 1:1
1024
+ }
1025
+ });
1026
+ // The keyboard's drag: with the title focused, Alt+arrows move the panel 10px (Shift: 50px) through the same lift,
1027
+ // clamp and saved position as a pointer drag. Alt-qualified, so a bare arrow stays the page's.
1028
+ if (draggable)
1029
+ header.addEventListener("keydown", (e) => {
1030
+ if (e.target !== titleBtn || !e.altKey || e.metaKey || e.ctrlKey) return;
1031
+ const d = e.shiftKey ? 50 : 10,
1032
+ dx = e.key === "ArrowRight" ? d : e.key === "ArrowLeft" ? -d : 0,
1033
+ dy = e.key === "ArrowDown" ? d : e.key === "ArrowUp" ? -d : 0;
1034
+ if (!dx && !dy) return;
1035
+ e.preventDefault();
1036
+ e.stopPropagation();
1037
+ const lifting = panel.dataset.mode !== "floating";
1038
+ grab();
1039
+ // the lift moves the panel to <body>, which drops focus; the next step needs it back
1040
+ if (lifting) titleBtn.focus({ preventScroll: true });
1041
+ moveTo(px, py, dx, dy);
1042
+ savePos();
1043
+ });
526
1044
  const onMove = (e: PointerEvent) => {
527
1045
  if (e.pointerId !== dragId) return;
528
1046
  // The window lost the pointer: bail as every other drag surface does.
529
- if (e.buttons === 0) { endDrag(e); return; }
530
- const dx = e.clientX - sx, dy = e.clientY - sy;
1047
+ if (e.buttons === 0) {
1048
+ endDrag(e);
1049
+ return;
1050
+ }
1051
+ const dx = e.clientX - sx,
1052
+ dy = e.clientY - sy;
531
1053
  if (!dragMoved) {
532
- // Drag slop: quick clicks wander a few px, fingers more. Collapsing is common and moving rare, so favor the click.
1054
+ // Drag slop: quick clicks wander a few px, fingers more. Collapsing is common and moving rare, so favor the
1055
+ // click.
533
1056
  if (Math.hypot(dx, dy) < (e.pointerType === "mouse" ? 6 : 10)) return;
534
- dragMoved = true; grab(); ox = px; oy = py; // the (possibly just-lifted) origin, once the drag starts
535
- try { header.setPointerCapture(dragId); } catch {}
1057
+ dragMoved = true;
1058
+ grab();
1059
+ ox = px;
1060
+ oy = py; // the (possibly just-lifted) origin, once the drag starts
1061
+ try {
1062
+ header.setPointerCapture(dragId);
1063
+ } catch {}
536
1064
  panel.classList.add("is-dragging");
537
1065
  }
538
1066
  moveTo(ox, oy, dx, dy);
539
1067
  };
540
1068
  const endDrag = (e: PointerEvent) => {
541
1069
  if (e.pointerId !== dragId) return;
542
- try { header.releasePointerCapture(dragId); } catch {}
543
- dragId = null; listen(false);
1070
+ try {
1071
+ header.releasePointerCapture(dragId);
1072
+ } catch {}
1073
+ dragId = null;
1074
+ listen(false);
544
1075
  // Swallow exactly the click that follows the release; let the flag lapse in case none comes (a pointercancel).
545
1076
  swallowClick = dragMoved;
546
- if (swallowClick) setTimeout(() => { swallowClick = false; }, 0);
1077
+ if (swallowClick)
1078
+ setTimeout(() => {
1079
+ swallowClick = false;
1080
+ }, 0);
547
1081
  if (dragMoved) {
548
1082
  // Edge magnetism: a drop near a side eases the rest of the way to the margin.
549
1083
  const { maxX, maxY } = bounds();
550
- if (px <= MARGIN + SNAP) px = MARGIN; else if (px >= maxX - SNAP) px = maxX;
551
- if (py <= MARGIN + SNAP) py = MARGIN; else if (py >= maxY - SNAP) py = maxY;
1084
+ if (px <= MARGIN + SNAP) px = MARGIN;
1085
+ else if (px >= maxX - SNAP) px = maxX;
1086
+ if (py <= MARGIN + SNAP) py = MARGIN;
1087
+ else if (py >= maxY - SNAP) py = maxY;
552
1088
  // The ease is a class (tweaks.css), so the reduced-motion kill-switch covers it.
553
1089
  // One timer at a time: an earlier drop's would strip the class 340ms after it, partway through this snap.
554
- clearTimeout(snapT); panel.classList.add("is-snapping"); snapT = setTimeout(() => panel.classList.remove("is-snapping"), 340);
1090
+ clearTimeout(snapT);
1091
+ panel.classList.add("is-snapping");
1092
+ snapT = setTimeout(() => panel.classList.remove("is-snapping"), 340);
555
1093
  apply();
556
1094
  savePos();
557
1095
  }
@@ -560,192 +1098,312 @@ export function tweaks(name: string, schema: Schema, opts: TweaksOptions = {}):
560
1098
  header.addEventListener("lostpointercapture", endDrag); // implicit capture loss mid-drag ends it like a release
561
1099
  cleanups.push(() => listen(false)); // a destroy() mid-press releases the document listeners
562
1100
  }
563
- // Registered by assemble(): onLive releases itself once the owner LEAVES the document, which it can only tell if it saw the panel connected first.
1101
+ // Registered by assemble(): onLive releases itself once the owner LEAVES the document, which it can only tell if it
1102
+ // saw the panel connected first.
564
1103
  const watchResize = () => {
565
1104
  if (!(draggable || opts.floating)) return;
566
1105
  reclamp(); // a resize during the lazy window went unheard; clamp now
567
1106
  cleanups.push(onLive(panel, [[window, "resize"]], reclamp));
568
1107
  };
569
1108
 
1109
+ // The menu is presets.ts, downloaded on the button's first hover, focus or press. A press before it lands opens it
1110
+ // once it does, unless Escape, or a press or focus elsewhere, came first, and a second press doesn't queue a second
1111
+ // open; after that the menu's popover owns the clicks. A chunk that fails to load is tried again on the next press.
570
1112
  if (presetsBtn) {
571
- const menu = el("div", "tw-presets-menu");
572
- const saveRow = el("div", "tw-presets-save");
573
- const input = el("input", "tw-presets-input"); input.type = "text"; input.placeholder = "Preset name…"; input.spellcheck = false; input.setAttribute("aria-label", "New preset name"); quietFocus(input);
574
- const saveBtn = txt("button", "tw-presets-savebtn", "Save");
575
- saveRow.append(input, saveBtn);
576
- const list = el("div", "tw-presets-list");
577
- menu.append(saveRow, list);
578
- const renderList = () => {
579
- const all = listPresets(), names = Object.keys(all);
580
- list.replaceChildren();
581
- if (!names.length) return list.append(txt("div", "tw-presets-empty", "No presets yet"));
582
- for (const nm of names) {
583
- const row = el("div", "tw-presets-row");
584
- const load = txt("button", "tw-presets-load", nm);
585
- load.addEventListener("click", () => { loadPreset(nm); menuPop.close(); announce(`Loaded preset ${nm}`); });
586
- const del = btn("tw-presets-del", ICON_X); del.setAttribute("aria-label", `Delete preset ${nm}`);
587
- del.addEventListener("click", (e) => { e.stopPropagation(); deletePreset(nm); renderList(); });
588
- row.append(load, del); list.append(row);
589
- }
1113
+ const b = presetsBtn;
1114
+ let menu: Promise<ReturnType<(typeof import("./presets.js"))["presetsMenu"]>> | undefined,
1115
+ made = false;
1116
+ const load = () =>
1117
+ (menu ||= import("./presets.js").then(
1118
+ (m) => {
1119
+ if (destroyed) throw 0;
1120
+ made = true;
1121
+ return m.presetsMenu(
1122
+ b,
1123
+ () => Object.keys(listPresets()),
1124
+ savePreset,
1125
+ loadPreset,
1126
+ deletePreset,
1127
+ announce,
1128
+ ICON_X,
1129
+ tr,
1130
+ );
1131
+ },
1132
+ (e) => {
1133
+ menu = undefined;
1134
+ if (e !== 0) console.error("[tweaks] presets:", e);
1135
+ throw e;
1136
+ },
1137
+ ));
1138
+ for (const t of ["pointerenter", "focus"])
1139
+ b.addEventListener(
1140
+ t,
1141
+ () => {
1142
+ load().catch(() => {});
1143
+ },
1144
+ { once: true },
1145
+ );
1146
+ let want = false;
1147
+ // Escape, or a press or focus elsewhere
1148
+ const cancel = (e: Event) => {
1149
+ if (e.type === "keydown" ? (e as KeyboardEvent).key === "Escape" : !e.composedPath().includes(b)) want = false;
1150
+ };
1151
+ const watch = (on: boolean) => {
1152
+ for (const t of ["pointerdown", "focusin", "keydown"])
1153
+ (on ? document.addEventListener : document.removeEventListener).call(document, t, cancel, true);
590
1154
  };
591
- const doSave = () => { const nm = input.value.trim(); if (!nm) { input.focus(); return; } if (!savePreset(nm)) { announce(`Preset ${nm} not saved: storage is full`); return; } input.value = ""; renderList(); announce(`Saved preset ${nm}`); }; // a refused save keeps the typed name
592
- // The shared popover shell; align:"end" hangs it off the button's right edge.
593
- const menuPop = popover(presetsBtn, presetsBtn, menu, {
594
- width: 208, fallbackH: 160, align: "end",
595
- onOpen: () => { renderList(); input.focus(); },
1155
+ b.addEventListener("click", () => {
1156
+ if (made || b.disabled || want) return;
1157
+ want = true;
1158
+ watch(true);
1159
+ load()
1160
+ .then(
1161
+ (pop) => {
1162
+ if (want) pop.open();
1163
+ },
1164
+ () => {},
1165
+ )
1166
+ .finally(() => {
1167
+ want = false;
1168
+ watch(false);
1169
+ });
596
1170
  });
597
- saveBtn.addEventListener("click", doSave);
598
- input.addEventListener("keydown", (e) => { if (e.key === "Enter") doSave(); });
599
1171
  }
600
1172
 
601
- // ── Undo / redo (opts.undo) — a debounced history of snapshots, scoped to a hovered or focused panel so it doesn't hijack the page's own undo ──
602
- const undoApi = opts.undo ? (() => {
603
- // Bounded: each step is a deep clone of every value.
604
- const HIST_MAX = 200;
605
- let history: Snapshot[] = [], histIdx = 0, applyingHistory = false, histTimer = 0;
606
- // Walk a new step beside the one before it: a string that didn't change takes the earlier step's copy, so an image's
607
- // data URL is held once across history, not once a step. Strings only, never objects, so no step can edit another.
608
- // True when nothing changed, which spares serializing both steps to compare them.
609
- // (Snapshots are JSON: own keys, no undefined. A key b lacks reads undefined or an inherited function, and never matches.)
610
- const share = (a: any, b: any): boolean => {
611
- if (a === b) return true;
612
- if (!a || !b || typeof a != "object" || typeof b != "object" || Array.isArray(a) != Array.isArray(b)) return false;
613
- let same = Object.keys(a).length == Object.keys(b).length;
614
- for (const k in a) if (a[k] === b[k]) a[k] = b[k]; else if (!share(a[k], b[k])) same = false;
615
- return same;
616
- };
617
- const commit = () => {
618
- histTimer = 0;
619
- const snap = snapshot();
620
- if (share(snap, history[histIdx])) return; // unchanged
621
- history = history.slice(0, histIdx + 1); history.push(snap); // a new edit drops the redo branch
622
- if (history.length > HIST_MAX) history = history.slice(history.length - HIST_MAX); // oldest steps age out
623
- histIdx = history.length - 1;
624
- };
625
- const record = () => { if (applyingHistory) return; clearTimeout(histTimer); histTimer = setTimeout(commit, 350); };
626
- const flush = () => { if (histTimer) { clearTimeout(histTimer); commit(); } }; // so ⌘Z right after a change still undoes that change
627
- listeners.add(record); own.add(record);
628
- const restore = (idx: number) => { applyingHistory = true; applySnapshot(history[idx]); applyingHistory = false; histIdx = idx; };
629
- const undo = () => { flush(); if (histIdx > 0) restore(histIdx - 1); };
630
- const redo = () => { flush(); if (histIdx < history.length - 1) restore(histIdx + 1); };
631
- const focused = () => panel.matches(":hover") || panel.contains(document.activeElement);
632
- // Registered by assemble() — see watchResize for why a global listener waits for the build.
633
- const onKey = (e: KeyboardEvent) => {
634
- if (!focused() || !(e.metaKey || e.ctrlKey) || inEditable(e.target as Element)) return;
635
- const k = e.key.toLowerCase();
636
- if (k === "z") { e.preventDefault(); e.shiftKey ? redo() : undo(); }
637
- else if (k === "y") { e.preventDefault(); redo(); }
638
- };
639
- // patch: a late restore (an image the store just answered for) goes into every step that still held what its row held,
640
- // so it isn't a step of its own, and ⌘Z neither takes it back out nor folds a pending edit into the base.
641
- const patch = (path: string[], held: unknown, v: unknown) => {
642
- const k = path[path.length - 1], h = JSON.parse(JSON.stringify(held ?? null)), nv = JSON.parse(JSON.stringify(v ?? null));
643
- for (const step of history) { const o: any = atPath(step, path.slice(0, -1)); if (o && typeof o === "object" && hasOwn(o, k) && !valueChanged(o[k], h)) o[k] = nv; }
644
- };
645
- return { undo, redo, onKey, flush, arm: () => { history = [snapshot()]; histIdx = 0; }, patch };
646
- })() : null;
1173
+ // ── Undo / redo (opts.undo): undo.ts. inEditable is declared below, and only read on a keypress. ──
1174
+ const undoApi = opts.undo
1175
+ ? createUndo(
1176
+ panel,
1177
+ snapshot,
1178
+ applySnapshot,
1179
+ atPath,
1180
+ (t) => inEditable(t),
1181
+ (fn) => {
1182
+ listeners.add(fn);
1183
+ own.add(fn);
1184
+ },
1185
+ )
1186
+ : null;
647
1187
 
648
1188
  // ── Pick and copy / paste — a press beside a row (in the panel's side padding, or a gap between rows) picks it, as
649
1189
  // a layer is picked in a design tool; beside a folder's header, the folder. ⌘C / ⌘V (ctrl elsewhere) then act on the
650
- // pick, or with none on the page, on the row holding focus. A row copies its value and pastes one back; a folder copies
651
- // its values object and pastes one into its own keys, so two folders with the same keys trade values. Escape, or a
1190
+ // pick, or with none on the page, on the row holding focus. A row copies its value and pastes one back; a folder
1191
+ // copies its values object and pastes one into its own keys, so two folders with the same keys trade values. Escape,
1192
+ // or a
652
1193
  // press anywhere else, drops the pick; a text field or a text selection keeps the browser's own keys. ──
653
1194
  const scopes = new Map<Element, Scope>(); // a row's or folder's root → its scope, filled by build()
654
- const scopeAt = (n: Element | null): Scope | undefined => { for (; n && n !== panel && panel.contains(n); n = n.parentElement) { const s = scopes.get(n); if (s) return s; } };
655
- const rowAt = (n: Element | null): Element | undefined => { for (; n && n !== panel; n = n.parentElement) if (scopes.get(n)?.kind === "row") return n; };
1195
+ const scopeAt = (n: Element | null): Scope | undefined => {
1196
+ for (; n && n !== panel && panel.contains(n); n = n.parentElement) {
1197
+ const s = scopes.get(n);
1198
+ if (s) return s;
1199
+ }
1200
+ };
1201
+ const rowAt = (n: Element | null): Element | undefined => {
1202
+ for (; n && n !== panel; n = n.parentElement) if (scopes.get(n)?.kind === "row") return n;
1203
+ };
656
1204
  // Out of sight: hidden by its condition, on a tab page not showing, or inside a collapsed folder or panel.
657
- const hidden = (el: Element) => !!(el.closest(".tw-cond-hidden, .tw-tabs-page:not([data-active=true])") || el.parentElement?.closest(".is-collapsed"));
1205
+ const hidden = (el: Element) =>
1206
+ !!(
1207
+ el.closest(".twk-cond-hidden, .twk-tabs-page:not([data-active=true])") ||
1208
+ el.parentElement?.closest(".is-collapsed")
1209
+ );
658
1210
  let pick: [Element, Scope] | undefined;
659
- const away = (e: Event) => { const t = e.target as Element; if (!panel.contains(t) && !t.closest?.(".tw-portal")) unpick(); };
660
- const unpick = () => { if (!pick) return; pick[0].classList.remove("is-picked"); pick = undefined; document.removeEventListener("pointerdown", away, true); if (picked?.[0] === panel) picked = null; };
1211
+ // the path: a shadow host retargets e.target
1212
+ const away = (e: Event) => {
1213
+ const t = e.composedPath()[0] as Element;
1214
+ if (!panel.contains(t) && !t.closest?.(".twk-portal")) unpick();
1215
+ };
1216
+ const unpick = () => {
1217
+ if (!pick) return;
1218
+ pick[0].classList.remove("is-picked");
1219
+ pick = undefined;
1220
+ document.removeEventListener("pointerdown", away, true);
1221
+ if (picked?.[0] === panel) picked = null;
1222
+ };
661
1223
  const choose = (el: Element, s: Scope) => {
662
1224
  if (picked && picked[0] !== panel) picked[1](); // one pick on the page
663
- unpick(); pick = [el, s]; picked = [panel, unpick]; el.classList.add("is-picked");
1225
+ unpick();
1226
+ pick = [el, s];
1227
+ picked = [panel, unpick];
1228
+ el.classList.add("is-picked");
664
1229
  document.addEventListener("pointerdown", away, true); // only while something is picked
665
1230
  // Focus moves to what was picked (a folder's header, a row's control), so its keys work and a screen reader names
666
1231
  // it; a press shows no ring. A text field isn't focused, or ⌘V would paste into it as text, and any selection goes.
667
- const f = [...el.querySelectorAll<HTMLElement>('button:not(:disabled):not([tabindex="-1"]), [tabindex="0"]')].find((n) => !n.closest(".tw-hint, .tw-portal, [inert]")); // its tab stop, not a hint or a closed pop's
668
- if (f) f.focus({ preventScroll: true, focusVisible: false } as FocusOptions); else (document.activeElement as HTMLElement | null)?.blur?.();
1232
+ // its tab stop, not a hint or a closed pop's
1233
+ const f = [...el.querySelectorAll<HTMLElement>('button:not(:disabled):not([tabindex="-1"]), [tabindex="0"]')].find(
1234
+ (n) => !n.closest(".twk-hint, .twk-portal, [inert]"),
1235
+ );
1236
+ if (f) f.focus({ preventScroll: true, focusVisible: false } as FocusOptions);
1237
+ else (activeIn(panel) as HTMLElement | null)?.blur?.();
669
1238
  document.getSelection?.()?.removeAllRanges();
670
1239
  };
671
1240
  cleanups.push(unpick);
672
- panel.addEventListener("pointerdown", (e) => {
673
- const t = e.target as Element;
674
- // On a control, or a header: working the picked row keeps the pick, anything else drops it. A touch never picks:
675
- // a phone has no ⌘C, and a scroll that starts in the padding shouldn't pick what it passes.
676
- if (rowAt(t) || t.closest(".tw-header, .tw-folder-header, button, input, textarea, select")) { if (!pick?.[0].contains(t)) unpick(); return; }
677
- if (e.button !== 0 || e.pointerType === "touch") return unpick();
678
- // Beside a row: the row (or folder header) level with the press, by its own box, so nothing overlapping the panel
679
- // can stand in the way. The last match is the deepest: a folder's rows come after it.
680
- let hit: [Element, Scope] | undefined;
681
- for (const [el, s] of scopes) {
682
- const r = (s.kind === "folder" ? el.firstElementChild! : el).getBoundingClientRect();
683
- if (r.height && e.clientY >= r.top && e.clientY < r.bottom && !hidden(el)) hit = [el, s];
684
- }
685
- if (!hit) return unpick();
686
- e.preventDefault(); // no text selection
687
- choose(...hit);
688
- }, true); // capture: a label's own pointerdown stops the event before it bubbles here
689
- const inEditable = (t: Element | null) => !!(t && (t as HTMLElement).matches && ((t as HTMLElement).matches("input, textarea, select") || (t as HTMLElement).isContentEditable));
1241
+ // capture: a label's own pointerdown stops the event before it bubbles here
1242
+ panel.addEventListener(
1243
+ "pointerdown",
1244
+ (e) => {
1245
+ const t = e.target as Element;
1246
+ // On a control, or a header: working the picked row keeps the pick, anything else drops it. A touch never picks:
1247
+ // a phone has no ⌘C, and a scroll that starts in the padding shouldn't pick what it passes.
1248
+ if (rowAt(t) || t.closest(".twk-header, .twk-folder-header, button, input, textarea, select")) {
1249
+ if (!pick?.[0].contains(t)) unpick();
1250
+ return;
1251
+ }
1252
+ if (e.button !== 0 || e.pointerType === "touch") return unpick();
1253
+ // Beside a row: the row (or folder header) level with the press, by its own box, so nothing overlapping the panel
1254
+ // can stand in the way. The last match is the deepest: a folder's rows come after it.
1255
+ let hit: [Element, Scope] | undefined;
1256
+ for (const [el, s] of scopes) {
1257
+ const r = (s.kind === "folder" ? el.firstElementChild! : el).getBoundingClientRect();
1258
+ if (r.height && e.clientY >= r.top && e.clientY < r.bottom && !hidden(el)) hit = [el, s];
1259
+ }
1260
+ if (!hit) return unpick();
1261
+ e.preventDefault(); // no text selection
1262
+ choose(...hit);
1263
+ },
1264
+ true,
1265
+ );
1266
+ const inEditable = (t: Element | null) =>
1267
+ !!(
1268
+ t &&
1269
+ (t as HTMLElement).matches &&
1270
+ ((t as HTMLElement).matches("input, textarea, select") || (t as HTMLElement).isContentEditable)
1271
+ );
690
1272
  // Called from the panel's one keydown listener (registered by assemble(), beside the undo keys). Focus steps to the
691
1273
  // hidden field for the keypress (the copy selected in it), the browser copies or pastes there, and focus steps back.
692
1274
  const onClipKey = (e: KeyboardEvent) => {
693
- if (e.key === "Escape") { if (pick) { e.stopPropagation(); unpick(); } return; }
694
- if (!(e.metaKey || e.ctrlKey) || e.altKey || e.repeat || inEditable(e.target as Element)) return;
695
- const k = e.key.toLowerCase(); if (k !== "c" && k !== "v") return;
696
- if (picked && !picked[0].isConnected) picked[1](); // the picking panel left the page without destroy(): free the slot
1275
+ if (e.key === "Escape") {
1276
+ if (pick) {
1277
+ e.stopPropagation();
1278
+ unpick();
1279
+ }
1280
+ return;
1281
+ }
1282
+ if (!(e.metaKey || e.ctrlKey) || e.altKey || e.repeat || inEditable(e.composedPath()[0] as Element)) return;
1283
+ const k = e.key.toLowerCase();
1284
+ if (k !== "c" && k !== "v") return;
1285
+ // the picking panel left the page without destroy(): free the slot
1286
+ if (picked && !picked[0].isConnected) picked[1]();
697
1287
  if (pick && hidden(pick[0])) unpick(); // a pick out of sight takes nothing
698
- const fe = document.activeElement, s = pick ? pick[1] : picked || !fe || hidden(fe) ? undefined : scopeAt(fe); // focus on a hidden row takes nothing either
699
- if (!s || (k === "c" && String(document.getSelection?.() || ""))) return; // selected text: the browser copies that
700
- const json = k === "c" ? JSON.stringify(s.kind === "row" ? s.entry.get() : atPath(snapshot(), s.path), replacer, 2) : "";
701
- const copied = (ok: boolean) => { if (!destroyed) announce(ok ? `${s.label} copied` : "Copy failed"); }; // words for a screen reader; the toolbar's copy button stays as it is
702
- const band = pick?.[0], back = document.activeElement as HTMLElement | null, ring = !!back?.matches(":focus-visible");
1288
+ // focus on a hidden row takes nothing either
1289
+ const fe = activeIn(panel),
1290
+ s = pick ? pick[1] : picked || !fe || hidden(fe) ? undefined : scopeAt(fe);
1291
+ // a shadow root's own selection where it has one (Chrome); else the document's holds it
1292
+ const rn: any = panel.getRootNode();
1293
+ // selected text: the browser copies that
1294
+ if (!s || (k === "c" && String((rn.getSelection ? rn : document).getSelection?.() || ""))) return;
1295
+ const json =
1296
+ k === "c" ? JSON.stringify(s.kind === "row" ? s.entry.get() : atPath(snapshot(), s.path), replacer, 2) : "";
1297
+ // words for a screen reader; the toolbar's copy button stays as it is
1298
+ const copied = (ok: boolean) => {
1299
+ if (!destroyed)
1300
+ announce(ok ? tr("rowCopied", "{label} copied", { label: s.label }) : tr("copyFailed", "Copy failed"));
1301
+ };
1302
+ const band = pick?.[0],
1303
+ back = activeIn(panel) as HTMLElement | null,
1304
+ ring = !!back?.matches(":focus-visible");
703
1305
  // The browser's copy or paste can land some milliseconds after this keydown (an app's menu passes it on), so focus
704
1306
  // steps back only when it lands, or after a second with none; it's caught page-wide, wherever focus is by then.
705
- if (!catchEl) { catchEl = el("textarea", "tw-sr"); catchEl.tabIndex = -1; catchEl.setAttribute("aria-hidden", "true"); for (const t of ["copy", "paste"]) document.addEventListener(t, (e) => onCatch?.(e as ClipboardEvent), true); }
706
- const ta = catchEl, done = () => { onCatch = null; if (document.activeElement === ta) { ta.blur(); back?.focus?.({ preventScroll: true, focusVisible: ring } as FocusOptions); } };
707
- const h = onCatch = (ev) => {
1307
+ if (!catchEl) {
1308
+ catchEl = el("textarea", "twk-sr");
1309
+ catchEl.tabIndex = -1;
1310
+ catchEl.setAttribute("aria-hidden", "true");
1311
+ for (const t of ["copy", "paste"]) document.addEventListener(t, (e) => onCatch?.(e as ClipboardEvent), true);
1312
+ }
1313
+ const ta = catchEl,
1314
+ done = () => {
1315
+ onCatch = null;
1316
+ if (document.activeElement === ta) {
1317
+ ta.blur();
1318
+ back?.focus?.({ preventScroll: true, focusVisible: ring } as FocusOptions);
1319
+ }
1320
+ };
1321
+ const h = (onCatch = (ev) => {
708
1322
  if (ev.target !== ta && inEditable(ev.target as Element)) return; // a field focused meanwhile keeps its own paste
709
- ev.preventDefault(); done();
710
- if (json) return ev.clipboardData?.setData("text/plain", json), copied(!!ev.clipboardData);
1323
+ ev.preventDefault();
1324
+ done();
1325
+ if (json) return (ev.clipboardData?.setData("text/plain", json), copied(!!ev.clipboardData));
711
1326
  const t = ev.clipboardData?.getData("text/plain") ?? null;
712
- import("./clip.js").then((m) => m.pasteInto(t, (t) => m.pasteScope(ctx, s, t), ctx, band), () => {});
713
- };
1327
+ import("./clip.js").then(
1328
+ (m) => m.pasteInto(t, (t) => m.pasteScope(ctx, s, t), ctx, band),
1329
+ () => {},
1330
+ );
1331
+ });
714
1332
  if (!ta.isConnected) document.body.appendChild(ta);
715
- ta.value = json; ta.focus({ preventScroll: true }); ta.select();
716
- setTimeout(() => { if (onCatch === h) { done(); if (json) copyText(json).then(copied); } }, 1000); // none came: write the copy directly
1333
+ ta.value = json;
1334
+ ta.focus({ preventScroll: true });
1335
+ ta.select();
1336
+ // none came: write the copy directly
1337
+ setTimeout(() => {
1338
+ if (onCatch === h) {
1339
+ done();
1340
+ if (json) copyText(json).then(copied);
1341
+ }
1342
+ }, 1000);
717
1343
  };
718
1344
 
719
1345
  // Build the controls, then everything that needs them: the conditionals' first pass, the persisted restore,
720
1346
  // the undo seed, the queued API calls. Synchronous when every control type is registered, else behind panel.ready.
721
1347
  const assemble = () => {
722
1348
  if (destroyed) return; // destroy() before the chunks landed: nothing to build
723
- built = cleanups.length; build(controls, metas, params);
1349
+ built = cleanups.length;
1350
+ using(opts.strings, () => build(controls, metas, params));
724
1351
  if (conditionals.length) {
725
- const byKey = new Map<string, Entry>(); for (const e of entries) if (!byKey.has(e.key)) byKey.set(e.key, e); // first wins
726
- const getVal = (k: string) => { const e = byKey.get(k); return e ? e.target[e.key] : params[k]; };
1352
+ // first wins
1353
+ const byKey = new Map<string, Entry>();
1354
+ for (const e of entries) if (!byKey.has(e.key)) byKey.set(e.key, e);
1355
+ const getVal = (k: string) => {
1356
+ const e = byKey.get(k);
1357
+ return e ? e.target[e.key] : params[k];
1358
+ };
727
1359
  const applyConditionals = () => {
728
1360
  let revealed = false;
729
1361
  for (const { node, m } of conditionals) {
730
1362
  // A throwing predicate leaves the node as-is rather than aborting the pass.
731
1363
  try {
732
1364
  if (m.render && foldRow(node, !m.render(getVal))) revealed = true;
733
- if (m.disabled != null) { const d = typeof m.disabled === "function" ? m.disabled(getVal) : m.disabled; setDisabled(node, !!d); }
1365
+ if (m.disabled != null) {
1366
+ const d = typeof m.disabled === "function" ? m.disabled(getVal) : m.disabled;
1367
+ setDisabled(node, !!d);
1368
+ }
734
1369
  } catch {}
735
1370
  }
736
1371
  if (revealed) requestReflow(); // built hidden it measured 0×0; shown, let it re-measure
737
1372
  };
738
- listeners.add(applyConditionals); own.add(applyConditionals); applyConditionals();
739
- import("./fold.js").then((m) => { fold = m.fold; }, () => {}); // a fold chunk that fails to load leaves the rows toggling
1373
+ listeners.add(applyConditionals);
1374
+ own.add(applyConditionals);
1375
+ applyConditionals();
1376
+ // a fold chunk that fails to load leaves the rows toggling
1377
+ import("./fold.js").then(
1378
+ (m) => {
1379
+ fold = m.fold;
1380
+ },
1381
+ () => {},
1382
+ );
740
1383
  }
741
1384
  // Restore last session, notifying: on the lazy path a host may already have subscribed.
742
1385
  if (persistKey) restore(unsaved(unwrap(readStore(persistKey), "values")));
743
1386
  if (destroyed) return; // a listener may have destroyed the panel on that notify
744
- undoApi?.arm(); watchResize();
745
- cleanups.push(onLive(panel, [[document, "keydown"]], (e: KeyboardEvent) => { undoApi?.onKey(e); onClipKey(e); })); // one document listener per panel for its keys
1387
+ undoApi?.arm();
1388
+ watchResize();
1389
+ // one document listener per panel for its keys
1390
+ cleanups.push(
1391
+ onLive(panel, [[document, "keydown"]], (e: KeyboardEvent) => {
1392
+ undoApi?.onKey(e);
1393
+ onClipKey(e);
1394
+ }),
1395
+ );
746
1396
  assembled = true;
747
- // Replay after the restore, so an explicit host set() wins over a stored value; a destroy() mid-replay drops the rest.
748
- for (const fn of queue.splice(0)) { if (destroyed) break; try { fn(); } catch (e) { console.error("[tweaks] a call queued before ready failed:", e); } }
1397
+ // Replay after the restore, so an explicit host set() wins over a stored value; a destroy() mid-replay drops the
1398
+ // rest.
1399
+ for (const fn of queue.splice(0)) {
1400
+ if (destroyed) break;
1401
+ try {
1402
+ fn();
1403
+ } catch (e) {
1404
+ console.error("[tweaks] a call queued before ready failed:", e);
1405
+ }
1406
+ }
749
1407
  for (const b of toolbarBtns) b.disabled = false; // the controls exist now
750
1408
  };
751
1409
 
@@ -754,37 +1412,79 @@ export function tweaks(name: string, schema: Schema, opts: TweaksOptions = {}):
754
1412
  // Each number asked for is held to the range the control resolved (derived bounds, a point's per component), not the
755
1413
  // schema's min and max, which a derived range leaves unset; a hair past an end is float noise, not a clamp.
756
1414
  const clampNote = (e: Entry, key: string, v: unknown) => {
757
- const r = e.range; if (!r || e.m.soft || clamped.has(e)) return;
758
- // [component, number] pairs: one number, a list (interval, sides), a sides string, or an object's sides or components.
759
- const asked: [string, unknown][] = typeof v === "string" ? v.trim().split(/\s+/).map((s) => ["", parseFloat(s)]) : Array.isArray(v) ? v.map((n) => ["", n]) : isObj(v) ? Object.entries(v) : [["", v]];
1415
+ const r = e.range;
1416
+ if (!r || e.m.soft || clamped.has(e)) return;
1417
+ // [component, number] pairs: one number, a list (interval, sides), a sides string, or an object's sides or
1418
+ // components.
1419
+ const asked: [string, unknown][] =
1420
+ typeof v === "string"
1421
+ ? v
1422
+ .trim()
1423
+ .split(/\s+/)
1424
+ .map((s) => ["", parseFloat(s)])
1425
+ : Array.isArray(v)
1426
+ ? v.map((n) => ["", n])
1427
+ : isObj(v)
1428
+ ? Object.entries(v)
1429
+ : [["", v]];
760
1430
  for (const [k, n] of asked) {
761
- const b = Array.isArray(r) ? r : r[k], eps = 1e-9 * Math.max(1, Math.abs(n as number));
762
- if (b && typeof n === "number" && (n < b[0] - eps || n > b[1] + eps)) { clamped.add(e); notice("clamp", key, v, b[0], b[1], e.m.type); return; }
1431
+ const b = Array.isArray(r) ? r : r[k],
1432
+ eps = 1e-9 * Math.max(1, Math.abs(n as number));
1433
+ if (b && typeof n === "number" && (n < b[0] - eps || n > b[1] + eps)) {
1434
+ clamped.add(e);
1435
+ notice("clamp", key, v, b[0], b[1], e.m.type);
1436
+ return;
1437
+ }
763
1438
  }
764
1439
  };
765
- // Resolve a (possibly dotted) key, apply, report whether the leaf changed; never notifies, so setMany fires one notify.
1440
+ // Resolve a (possibly dotted) key, apply, report whether the leaf changed; never notifies, so setMany fires one
1441
+ // notify.
766
1442
  const applySet = (key: string, v: unknown) => {
767
1443
  if (String(key).split(".").some(isReservedKey)) return false; // never write through to the prototype
768
1444
  const parts = String(key).split(".");
769
1445
  // The path exactly as changes() names it first, so a key holding a dot ("a.b", "f.a.b") is reachable; two controls
770
1446
  // whose paths read the same (a top-level "a.b" beside folder a's b) are ambiguous, not a guess.
771
1447
  const exact = entries.filter((x) => x.path.join(".") === key);
772
- if (exact.length > 1) { notice("ambiguousPath", key, exact.length); return false; }
1448
+ if (exact.length > 1) {
1449
+ notice("ambiguousPath", key, exact.length);
1450
+ return false;
1451
+ }
773
1452
  let e: Entry | undefined = exact[0];
774
- if (!e && parts.length > 1) { const t = valueless.get(key); notice(t ? "noValue" : "noPath", key, t); return false; }
1453
+ if (!e && parts.length > 1) {
1454
+ const t = valueless.get(key);
1455
+ notice(t ? "noValue" : "noPath", key, t);
1456
+ return false;
1457
+ }
775
1458
  if (!e) {
776
1459
  const matches = entries.filter((x) => x.key === key);
777
- if (matches.length > 1) { notice("ambiguousKey", key, matches.length, matches[0].path.join(".")); return false; }
1460
+ if (matches.length > 1) {
1461
+ notice("ambiguousKey", key, matches.length, matches[0].path.join("."));
1462
+ return false;
1463
+ }
778
1464
  e = matches[0];
779
1465
  }
780
1466
  // A key no control owns is refused: written through, a typo landed on params and in every save after.
781
1467
  // A button, monitor or separator is there but holds nothing to set, which "no control has that key" would misstate.
782
1468
  const vt = !e && (valueless.get(key) || [...valueless].find(([p]) => p.split(".").pop() === key)?.[1]);
783
- if (!e) { if (vt) notice("noValue", key, vt); else notice("noControl", key, metas.some((m) => m.key === key && (m.type === "folder" || m.type === "tabs"))); return false; }
1469
+ if (!e) {
1470
+ if (vt) notice("noValue", key, vt);
1471
+ else
1472
+ notice(
1473
+ "noControl",
1474
+ key,
1475
+ metas.some((m) => m.key === key && (m.type === "folder" || m.type === "tabs")),
1476
+ );
1477
+ return false;
1478
+ }
784
1479
  const prev = e.target[e.key];
785
1480
  // Same isolation as applySnapshot: a hostile value costs only that key.
786
1481
  if (notAValue(v)) return false;
787
- try { assign(e, v); } catch (err) { console.error(`[tweaks] set("${key}") failed — value skipped:`, err); return false; }
1482
+ try {
1483
+ assign(e, v);
1484
+ } catch (err) {
1485
+ console.error(`[tweaks] set("${key}") failed — value skipped:`, err);
1486
+ return false;
1487
+ }
788
1488
  clampNote(e, key, v);
789
1489
  if (!valueChanged(prev, e.target[e.key])) return false; // keeps a store-sync listener from echoing forever
790
1490
  last = e.path.join("."); // on((p, last)) sees programmatic sets like control edits
@@ -793,36 +1493,110 @@ export function tweaks(name: string, schema: Schema, opts: TweaksOptions = {}):
793
1493
 
794
1494
  // Every mutating method goes through later(): live at once, queued in the lazy window, silent after destroy().
795
1495
  const api = {
796
- el: panel, params,
797
- on(fn) { if (destroyed) return () => {}; listeners.add(fn); return () => listeners.delete(fn); },
798
- set(key, v, o) { const k = String(key), silent = o?.silent; later(() => { if (applySet(k, v)) notify(silent); }); }, // a key that can't stringify throws here, not in the replay
1496
+ el: box,
1497
+ params,
1498
+ on(fn) {
1499
+ if (destroyed) return () => {};
1500
+ listeners.add(fn);
1501
+ return () => listeners.delete(fn);
1502
+ },
1503
+ // a key that can't stringify throws here, not in the replay
1504
+ set(key, v, o) {
1505
+ const k = String(key),
1506
+ silent = o?.silent;
1507
+ later(() => {
1508
+ if (applySet(k, v)) notify(silent);
1509
+ });
1510
+ },
799
1511
  // Batch write: listeners + persist fire once for the whole batch.
800
1512
  setMany(values, o) {
801
1513
  if (values == null || typeof values !== "object") return;
802
- const batch = Object.entries(values), silent = o?.silent; // read now, so a later mutation can't reach the replay
803
- later(() => { let changed = 0; for (const [k, v] of batch) { try { if (applySet(k, v)) changed++; } catch (e) { console.error(`[tweaks] setMany("${k}") failed — key skipped:`, e); } } if (changed > 1) last = undefined; if (changed) notify(silent); }); // per-key isolation: a bad value costs only its key; one changed key is named, many aren't
1514
+ const batch = Object.entries(values),
1515
+ silent = o?.silent; // read now, so a later mutation can't reach the replay
1516
+ // per-key isolation: a bad value costs only its key; one changed key is named, many aren't
1517
+ later(() => {
1518
+ let changed = 0;
1519
+ for (const [k, v] of batch) {
1520
+ try {
1521
+ if (applySet(k, v)) changed++;
1522
+ } catch (e) {
1523
+ console.error(`[tweaks] setMany("${k}") failed — key skipped:`, e);
1524
+ }
1525
+ }
1526
+ if (changed > 1) last = undefined;
1527
+ if (changed) notify(silent);
1528
+ });
1529
+ },
1530
+ reset() {
1531
+ if (assembled && !destroyed) spinReset(resetBtn);
1532
+ later(resetAll);
804
1533
  },
805
- reset() { if (assembled && !destroyed) spinReset(resetBtn); later(resetAll); },
806
1534
  // Whole-panel state — values + UI as a JSON-safe object (the standard toJSON hook). Before ready there
807
1535
  // are no values yet. fromJSON applies one back: values (one notify), then the UI silently, outside undo.
808
- toJSON() { return destroyed ? { values: {}, ui: {} } : { values: snapshot(), ui: collectUI() }; },
809
- changes() { return destroyed ? {} : changed(); }, // empty in the lazy window too — no entries yet
1536
+ toJSON() {
1537
+ return destroyed ? { values: {}, ui: {} } : { values: snapshot(), ui: collectUI() };
1538
+ },
1539
+ // empty in the lazy window too — no entries yet
1540
+ changes() {
1541
+ return destroyed ? {} : changed();
1542
+ },
810
1543
  fromJSON(state, o) {
811
1544
  if (!state || typeof state !== "object") return;
812
1545
  const silent = o?.silent;
813
- const values = state.values && typeof state.values === "object" ? { ...state.values } : state.values, ui = state.ui && typeof state.ui === "object" ? { ...state.ui } : state.ui; // read now (one level), like setMany
814
- later(() => { if (values) applySnapshot(values, silent); applyUI(ui); });
1546
+ // read now (one level), like setMany
1547
+ const values = state.values && typeof state.values === "object" ? { ...state.values } : state.values,
1548
+ ui = state.ui && typeof state.ui === "object" ? { ...state.ui } : state.ui;
1549
+ later(() => {
1550
+ if (values) applySnapshot(values, silent);
1551
+ applyUI(ui);
1552
+ });
815
1553
  },
816
1554
  // Live theming clears the prior theme first, so setTheme(null) reverts to the default look.
817
- setName(n) { if (destroyed) return; title = String(n); titleEl.textContent = title; landmark(title); }, // a rename never moves saved values or presets
818
- // A lifted panel's carried context sits under the theme: a key the old theme set and the new one drops falls back to it.
819
- setTheme(theme) { if (destroyed) return; applyThemeVars(panel, null); for (const k in carried) panel.style.setProperty(k, carried[k]); themeVars = resolveTheme(theme); panel._twTheme = themeVars; applyThemeVars(panel, themeVars); window.dispatchEvent(new Event("tw-retheme")); },
1555
+ // a rename never moves saved values or presets
1556
+ setName(n) {
1557
+ if (destroyed) return;
1558
+ title = String(n);
1559
+ titleEl.textContent = title;
1560
+ landmark(title);
1561
+ },
1562
+ // A lifted panel's carried context sits under the theme: a key the old theme set and the new one drops falls back
1563
+ // to it.
1564
+ setTheme(theme) {
1565
+ if (destroyed) return;
1566
+ applyThemeVars(panel, null);
1567
+ for (const k in carried) panel.style.setProperty(k, carried[k]);
1568
+ themeVars = resolveTheme(theme);
1569
+ panel._twTheme = themeVars;
1570
+ applyThemeVars(panel, themeVars);
1571
+ window.dispatchEvent(new Event("twk-retheme"));
1572
+ },
820
1573
  // Presets API (no-ops without opts.persist); before ready they queue like set() does.
821
- savePreset: (nm) => { if (destroyed || failed || !presetsKey || !nm) return false; let ok = true; later(() => { ok = savePreset(nm); }); return ok; }, // queued before ready counts as saved; once built, whether storage took it
822
- loadPreset: (nm) => { if (destroyed || failed || !listPresets()[nm]) return false; later(() => loadPreset(nm)); return true; }, // whether it's stored now; a save still queued before ready isn't yet
823
- deletePreset: (nm) => { if (presetsKey) later(() => deletePreset(nm)); },
1574
+ // queued before ready counts as saved; once built, whether storage took it
1575
+ savePreset: (nm) => {
1576
+ if (destroyed || failed || !presetsKey || !nm) return false;
1577
+ let ok = true;
1578
+ later(() => {
1579
+ ok = savePreset(nm);
1580
+ });
1581
+ return ok;
1582
+ },
1583
+ // whether it's stored now; a save still queued before ready isn't yet
1584
+ loadPreset: (nm) => {
1585
+ if (destroyed || failed || !listPresets()[nm]) return false;
1586
+ later(() => loadPreset(nm));
1587
+ return true;
1588
+ },
1589
+ deletePreset: (nm) => {
1590
+ if (presetsKey) later(() => deletePreset(nm));
1591
+ },
824
1592
  presets: () => (destroyed ? [] : Object.keys(listPresets())),
825
- undo: () => { if (undoApi) later(undoApi.undo); }, redo: () => { if (undoApi) later(undoApi.redo); }, // no-ops without opts.undo; queued before ready, in order
1593
+ // no-ops without opts.undo; queued before ready, in order
1594
+ undo: () => {
1595
+ if (undoApi) later(undoApi.undo);
1596
+ },
1597
+ redo: () => {
1598
+ if (undoApi) later(undoApi.redo);
1599
+ },
826
1600
  // Teardown. Safe to call before ready resolves — assemble() sees the flag and bails.
827
1601
  destroy() {
828
1602
  if (destroyed) return;
@@ -830,40 +1604,90 @@ export function tweaks(name: string, schema: Schema, opts: TweaksOptions = {}):
830
1604
  releaseKey();
831
1605
  closeActivePopover(panel); // page-wide singletons: close only what's anchored here
832
1606
  hideHintNow(panel);
833
- for (const fn of cleanups.splice(0)) { try { fn(); } catch {} }
1607
+ for (const fn of cleanups.splice(0)) {
1608
+ try {
1609
+ fn();
1610
+ } catch {}
1611
+ }
834
1612
  listeners.clear();
835
- if (liftSlot) { liftSlot.remove(); liftSlot = null; }
1613
+ if (liftSlot) {
1614
+ liftSlot.remove();
1615
+ liftSlot = null;
1616
+ }
836
1617
  if (topFloating === panel) topFloating = null;
837
- panel.dataset.twDestroyed = ""; // onLive listeners release on their next event by this mark
838
- panel.remove();
1618
+ panel.dataset.twkDestroyed = ""; // onLive listeners release on their next event by this mark
1619
+ box.remove();
839
1620
  },
840
1621
  } as Panel; // `ready` is filled in just below
841
- // Assemble once the missing modules resolve, surfacing that on ready; else assemble now. A panel that persists an image
842
- // builds without waiting for its stored images; they fill in when the store answers, and `ready` waits for that too.
1622
+ // Assemble once the missing modules resolve, surfacing that on ready; else assemble now. A panel that persists an
1623
+ // image builds without waiting for its stored images; they fill in when the store answers, and `ready` waits for that
1624
+ // too.
843
1625
  // A store that fails to load just saves no uploads.
844
- const images = persistKey && scanTypes(metas).has("image") ? import("./image-store.js").then(async (m) => {
845
- savedImages = await m.load(persistKey);
846
- // A refused upload saves again, inline; after destroy() no save is armed, so it writes now while storage still names that ref.
847
- if (savedImages) imgs = m.bind(persistKey, savedImages, (ref) => { if (!destroyed) persist(); else if (JSON.stringify(readStore(persistKey)).includes(JSON.stringify(ref))) saveNow(); }, listPresets, (v) => !!presetsKey && writePresets(v));
848
- }, () => {}) : null;
1626
+ const images =
1627
+ persistKey && scanTypes(metas).has("image")
1628
+ ? import("./image-store.js").then(
1629
+ async (m) => {
1630
+ savedImages = await m.load(persistKey);
1631
+ // A refused upload saves again, inline; after destroy() no save is armed, so it writes now while storage
1632
+ // still names that ref.
1633
+ if (savedImages)
1634
+ imgs = m.bind(
1635
+ persistKey,
1636
+ savedImages,
1637
+ (ref) => {
1638
+ if (!destroyed) persist();
1639
+ else if (JSON.stringify(readStore(persistKey)).includes(JSON.stringify(ref))) saveNow();
1640
+ },
1641
+ listPresets,
1642
+ (v) => !!presetsKey && writePresets(v),
1643
+ );
1644
+ },
1645
+ () => {},
1646
+ )
1647
+ : null;
849
1648
  if (!images) imagesReady = true;
850
1649
  const imagesIn = images && images.then(restoreImages);
1650
+ // A shadow panel's dressing: `ready` waits for it, not for the stylesheet's download.
1651
+ const shadowIn = iso.dressed;
1652
+ cleanups.push(iso.end);
851
1653
  const lazy = ensureForMetas(metas);
852
1654
  if (lazy) {
853
1655
  // A failed chunk or a throwing build leaves the panel unbuildable: queued calls dropped, later calls refused,
854
1656
  // ready rejects. A partway build must not leave live controls behind an inert API: release what it wired.
855
1657
  const fail = (e: unknown) => {
856
- failed = true; queue.length = 0; releaseKey();
857
- if (built >= 0) for (const fn of cleanups.splice(built)) { try { fn(); } catch {} } // only what the build wired; the shell's stay for destroy()
858
- for (const c of controls.children) (c as HTMLElement).dataset.twDestroyed = ""; // detached below, they'd never see the panel's own mark
859
- listeners.clear(); entries.length = 0; controls.replaceChildren();
860
- if (!destroyed) console.warn(`[tweaks] "${name}": the panel could not be built — ${built >= 0 ? "the build threw" : "its lazy controls failed to load"}; the API is inert`); // the chunk's own error is logged in lazy.ts
1658
+ failed = true;
1659
+ queue.length = 0;
1660
+ releaseKey();
1661
+ // only what the build wired; the shell's stay for destroy()
1662
+ if (built >= 0)
1663
+ for (const fn of cleanups.splice(built)) {
1664
+ try {
1665
+ fn();
1666
+ } catch {}
1667
+ }
1668
+ // detached below, they'd never see the panel's own mark
1669
+ for (const c of controls.children) (c as HTMLElement).dataset.twkDestroyed = "";
1670
+ listeners.clear();
1671
+ entries.length = 0;
1672
+ controls.replaceChildren();
1673
+ // the chunk's own error is logged in lazy.ts
1674
+ if (!destroyed)
1675
+ console.warn(
1676
+ `[tweaks] "${name}": the panel could not be built — ${built >= 0 ? "the build threw" : "its lazy controls failed to load"}; the API is inert`,
1677
+ );
861
1678
  throw e;
862
1679
  };
863
1680
  const building = lazy.then(assemble);
864
- api.ready = panel.ready = (imagesIn ? Promise.all([building, imagesIn]) : building).then(() => api, fail); // fail covers both the chunk and a build that throws
1681
+ // fail covers both the chunk and a build that throws
1682
+ api.ready = panel.ready = (imagesIn || shadowIn ? Promise.all([building, imagesIn, shadowIn]) : building).then(
1683
+ () => api,
1684
+ fail,
1685
+ );
865
1686
  api.ready.catch(() => {}); // a handled fork: no unhandled-rejection noise
1687
+ } else {
1688
+ assemble();
1689
+ api.ready = panel.ready =
1690
+ imagesIn || shadowIn ? Promise.all([imagesIn, shadowIn]).then(() => api) : Promise.resolve(api);
866
1691
  }
867
- else { assemble(); api.ready = panel.ready = imagesIn ? imagesIn.then(() => api) : Promise.resolve(api); }
868
1692
  return api as Panel;
869
1693
  }