tweakit 0.5.2 → 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 (120) hide show
  1. package/AGENTS.md +32 -30
  2. package/README.md +55 -29
  3. package/THIRD-PARTY-NOTICES.md +0 -1
  4. package/dist/tweaks/auto.js +2 -0
  5. package/dist/tweaks/auto.js.map +6 -0
  6. package/dist/tweaks/bezier-S2BOXQBM.js +2 -0
  7. package/dist/tweaks/bezier-S2BOXQBM.js.map +6 -0
  8. package/dist/tweaks/chunk-3MZAAMC2.js +2 -0
  9. package/dist/tweaks/chunk-3MZAAMC2.js.map +6 -0
  10. package/dist/tweaks/chunk-ETB35KFV.js +2 -0
  11. package/dist/tweaks/chunk-ETB35KFV.js.map +6 -0
  12. package/dist/tweaks/chunk-YB5GKWZI.js +2 -0
  13. package/dist/tweaks/chunk-YB5GKWZI.js.map +6 -0
  14. package/dist/tweaks/clip-A3VI53BK.js +2 -0
  15. package/dist/tweaks/clip-A3VI53BK.js.map +6 -0
  16. package/dist/tweaks/color-UGYMAF3W.js +2 -0
  17. package/dist/tweaks/color-UGYMAF3W.js.map +6 -0
  18. package/dist/tweaks/core.js +2 -1
  19. package/dist/tweaks/core.js.map +6 -0
  20. package/dist/tweaks/fold-GY47HIQA.js +2 -0
  21. package/dist/tweaks/fold-GY47HIQA.js.map +6 -0
  22. package/dist/tweaks/gradient-BTX25WSH.js +2 -0
  23. package/dist/tweaks/gradient-BTX25WSH.js.map +6 -0
  24. package/dist/tweaks/heavy-NPFIGHRM.js +2 -0
  25. package/dist/tweaks/heavy-NPFIGHRM.js.map +6 -0
  26. package/dist/tweaks/image-HTQQ4WX6.js +4 -0
  27. package/dist/tweaks/image-HTQQ4WX6.js.map +6 -0
  28. package/dist/tweaks/image-store-QD5SMZXG.js +2 -0
  29. package/dist/tweaks/image-store-QD5SMZXG.js.map +6 -0
  30. package/dist/tweaks/interval-BHPI4LRN.js +2 -0
  31. package/dist/tweaks/interval-BHPI4LRN.js.map +6 -0
  32. package/dist/tweaks/markup-QM53TQJZ.js +2 -0
  33. package/dist/tweaks/markup-QM53TQJZ.js.map +6 -0
  34. package/dist/tweaks/monitor-722WIYGW.js +3 -0
  35. package/dist/tweaks/monitor-722WIYGW.js.map +6 -0
  36. package/dist/tweaks/motion-HMCZK6HJ.js +2 -0
  37. package/dist/tweaks/motion-HMCZK6HJ.js.map +6 -0
  38. package/dist/tweaks/plot-D3NQ6XNN.js +3 -0
  39. package/dist/tweaks/plot-D3NQ6XNN.js.map +6 -0
  40. package/dist/tweaks/point-XDXKMDXH.js +2 -0
  41. package/dist/tweaks/point-XDXKMDXH.js.map +6 -0
  42. package/dist/tweaks/presets-EUWNSCUW.js +2 -0
  43. package/dist/tweaks/presets-EUWNSCUW.js.map +6 -0
  44. package/dist/tweaks/shadow-AM7VSRNS.js +2 -0
  45. package/dist/tweaks/shadow-AM7VSRNS.js.map +6 -0
  46. package/dist/tweaks/shadow-root-RYUV3AGS.js +3 -0
  47. package/dist/tweaks/shadow-root-RYUV3AGS.js.map +6 -0
  48. package/dist/tweaks/sides-JWIURDA4.js +2 -0
  49. package/dist/tweaks/sides-JWIURDA4.js.map +6 -0
  50. package/dist/tweaks/spring-BF5GBTPM.js +2 -0
  51. package/dist/tweaks/spring-BF5GBTPM.js.map +6 -0
  52. package/dist/tweaks/tabs-PRVUUZXZ.js +2 -0
  53. package/dist/tweaks/tabs-PRVUUZXZ.js.map +6 -0
  54. package/dist/tweaks.css +1 -1
  55. package/dist/tweaks.js +7 -3
  56. package/dist/tweaks.js.map +6 -0
  57. package/dist/types/tweaks/easing.d.ts +8 -11
  58. package/dist/types/tweaks/enhance.d.ts +3 -2
  59. package/dist/types/tweaks/panel.d.ts +6 -4
  60. package/dist/types/tweaks/schema.d.ts +4 -8
  61. package/dist/types/tweaks/standalone.d.ts +3 -3
  62. package/dist/types/tweaks/types.d.ts +499 -116
  63. package/package.json +30 -5
  64. package/src/tweaks/auto.ts +16 -0
  65. package/src/tweaks/clip.ts +156 -0
  66. package/src/tweaks/controls/basic.ts +727 -0
  67. package/src/tweaks/controls/bezier.ts +204 -0
  68. package/src/tweaks/controls/color.ts +861 -0
  69. package/src/tweaks/controls/gradient.ts +419 -0
  70. package/src/tweaks/controls/image.ts +555 -0
  71. package/src/tweaks/controls/interval.ts +189 -0
  72. package/src/tweaks/controls/monitor.ts +211 -0
  73. package/src/tweaks/controls/motion.ts +316 -0
  74. package/src/tweaks/controls/plot.ts +440 -0
  75. package/src/tweaks/controls/point.ts +208 -0
  76. package/src/tweaks/controls/shadow.ts +156 -0
  77. package/src/tweaks/controls/sides.ts +196 -0
  78. package/src/tweaks/controls/spring.ts +308 -0
  79. package/src/tweaks/controls/tabs.ts +68 -0
  80. package/src/tweaks/core.ts +9 -0
  81. package/src/tweaks/easing.ts +118 -0
  82. package/src/tweaks/enhance.ts +35 -0
  83. package/src/tweaks/feedback.ts +218 -0
  84. package/src/tweaks/fold.ts +33 -0
  85. package/src/tweaks/heavy.ts +626 -0
  86. package/src/tweaks/icons.ts +26 -0
  87. package/src/tweaks/image-store.ts +189 -0
  88. package/src/tweaks/lazy.ts +63 -0
  89. package/src/tweaks/markup.ts +317 -0
  90. package/src/tweaks/panel.ts +1693 -0
  91. package/src/tweaks/presets.ts +85 -0
  92. package/src/tweaks/schema.ts +351 -0
  93. package/src/tweaks/shadow-root.ts +178 -0
  94. package/src/tweaks/shared.ts +1137 -0
  95. package/src/tweaks/single.ts +22 -0
  96. package/src/tweaks/standalone.ts +179 -0
  97. package/src/tweaks/types.ts +1027 -0
  98. package/src/tweaks/undo.ts +104 -0
  99. package/src/tweaks.css +965 -0
  100. package/src/wide-gamut.ts +658 -0
  101. package/dist/tweaks/bezier-2RABC6LD.js +0 -1
  102. package/dist/tweaks/chunk-4ATI34PB.js +0 -1
  103. package/dist/tweaks/chunk-7OSCMZ2J.js +0 -1
  104. package/dist/tweaks/chunk-W3JNIJ2F.js +0 -1
  105. package/dist/tweaks/clip-ECGCECEC.js +0 -1
  106. package/dist/tweaks/colour-2RE3FQ5E.js +0 -1
  107. package/dist/tweaks/fold-UGN7TXGX.js +0 -1
  108. package/dist/tweaks/gradient-DB6XTDHO.js +0 -1
  109. package/dist/tweaks/heavy-PKOMHB3C.js +0 -1
  110. package/dist/tweaks/image-7MCLBOZ3.js +0 -1
  111. package/dist/tweaks/interval-L2E4L3AZ.js +0 -1
  112. package/dist/tweaks/markup-ZJJ6CCWM.js +0 -1
  113. package/dist/tweaks/monitor-IEK742PG.js +0 -2
  114. package/dist/tweaks/motion-N5UNVR55.js +0 -1
  115. package/dist/tweaks/plot-VVTOY3RA.js +0 -2
  116. package/dist/tweaks/point-YMMHSLO7.js +0 -1
  117. package/dist/tweaks/shadow-IKYOV3VN.js +0 -1
  118. package/dist/tweaks/sides-FH227Z54.js +0 -1
  119. package/dist/tweaks/spring-3PKAFIH7.js +0 -1
  120. package/dist/tweaks/tabs-BKK5NIIY.js +0 -1
@@ -0,0 +1,1693 @@
1
+ /* tweaks() — the panel factory: builds the shell + controls from a schema and returns the live
2
+ * API. Persistence, presets, undo, floating drag and the lazy-window queue live here. */
3
+ import {
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,
30
+ } from "./shared.js";
31
+ import { metaFor, VALUELESS } from "./schema.js";
32
+ import { ensureForMetas, scanTypes } from "./lazy.js";
33
+ import { createFolder, createControl } from "./controls/basic.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";
45
+ import { ICON_PRESETS, ICON_X } from "./icons.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";
58
+ import type { Meta } from "./schema.js";
59
+ import type { ClipCtx, Scope } from "./clip.js";
60
+ import type { PanelEl } from "./shared.js";
61
+ import type { TabsControl } from "./controls/tabs.js";
62
+
63
+ /** A params object or one of its folder/tabs sub-objects — the holder a control writes into. */
64
+ type Target = Record<string, unknown>;
65
+ /** A valued control's wiring: where its value lives, its set/get, the reset pair (raw, def) and its dotted path. */
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
+ }
80
+ /** A values snapshot — params as JSON round-trips it. */
81
+ export type Snapshot = Record<string, any>;
82
+
83
+ let topFloating: HTMLElement | null = null; // the floating panel raised last (raise())
84
+ const liveKeys = new Map<string, number>(); // persist keys of the panels alive now, for the shared-key warning
85
+ let picked: [HTMLElement, () => void] | null = null; // the panel holding the page's one pick, and how to drop it
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;
90
+
91
+ /** Build a panel from `schema`; the shell and API exist synchronously, lazy controls fill in on `ready`.
92
+ * A schema written inline (or `as const`) types `params` from its shape. */
93
+ export function tweaks<const S extends HeldSchema>(
94
+ name: string,
95
+ schema: S & KnownTypes<S>,
96
+ opts?: TweaksOptions,
97
+ ): Panel<ParamsOf<S>>;
98
+ export function tweaks(name: string, schema: Schema, opts: TweaksOptions = {}): Panel {
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
106
+ const params: Params = {};
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;
109
+ const entries: Entry[] = []; // flattened across folders, for reset + persist
110
+ // dotted path → type of each button, monitor, separator: set() says it holds no value
111
+ const valueless = new Map<string, string>();
112
+ const listeners = new Set<(p: Params, last?: string) => void>();
113
+ const own = new Set<(p: Params, last?: string) => void>(); // undo + conditionals: all a silent write calls
114
+ const cleanups: Array<() => void> = []; // releases for destroy(): document/window listeners, timers
115
+ let destroyed = false; // set by destroy(): assemble() bails, mutators go silent
116
+ let assembled = false; // set at the end of assemble(); no controls exist before
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.
119
+ const queue: Array<() => void> = [];
120
+ let failed = false; // the chunks failed: the panel can never be built
121
+ let built = -1; // cleanups.length as assemble() enters build()
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
+ };
128
+ let liftSlot: HTMLSpanElement | null = null; // the placeholder a lifted panel leaves in its slot
129
+ // what the lift carried from the slot's context (lift()), under the panel's own theme
130
+ const carried: Record<string, string> = {};
131
+ // The values snapshot: undefined written as null (JSON would drop the key).
132
+ const replacer = (_k: string, v: unknown) => (v === undefined ? null : v);
133
+ // params as the schema shapes it, folders and tab pages included: a value the host parks on params is its own, so
134
+ // snapshots, restores and undo carry only what a control holds. Null-prototype: a folder named "toString" is its own.
135
+ const valuesOf = (ms: Meta[], from: Target): Snapshot => {
136
+ const o: Snapshot = Object.create(null);
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
+ }
148
+ return o;
149
+ };
150
+ const snapshot = (): Snapshot => JSON.parse(JSON.stringify(valuesOf(metas, params), replacer));
151
+ // Storage keys — opt-in via opts.persist (a string key, or `true` to key by name).
152
+ const persistKey = opts.persist ? `tw:${opts.persist === true ? name : opts.persist}` : null;
153
+ const presetsKey = persistKey ? `${persistKey}:presets` : null;
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.
158
+ let holdsKey = !!persistKey;
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.
191
+ const unwrap = (s: any, k: string) => (s?.v === 1 ? s[k] : null);
192
+ const writeValues = (snap: Snapshot) => writeStore(persistKey!, { v: 1, values: snap });
193
+ const writePresets = (all: unknown) => writeStore(presetsKey!, { v: 1, presets: all });
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);
197
+ // Take one entry's value out of a snapshot; true when it was there. Reflect: an undeletable key is left, not thrown.
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
+ };
204
+ // A control with `persist: false` stays out of what is saved and out of what is restored.
205
+ const unsaved = (snap: Snapshot | null) => {
206
+ for (const e of entries) if (!e.persist) drop(snap, e);
207
+ return snap;
208
+ };
209
+ // ── Images in IndexedDB (image-store.ts): what storage holds for an uploaded image is its ref, and `savedImages` maps this
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
215
+ // stays saved as it was until its row is written. ──
216
+ // both null without IndexedDB
217
+ let imgs: ReturnType<(typeof import("./image-store.js"))["bind"]> | null = null,
218
+ savedImages: Map<string, string> | null = null;
219
+ let imagesReady = false; // the store has answered, or failed to load
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:");
224
+ const isDataUrl = (v: unknown): v is string => typeof v === "string" && v.startsWith("data:");
225
+ const imageEntries = () => entries.filter((e) => e.m.type === "image");
226
+ const unsavable = new Map<Entry, unknown>();
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
+ };
232
+ const toRefs = (snap: Snapshot) => {
233
+ const stored = imagesReady && imgs ? readStore(persistKey!) : undefined; // undefined: no store to hold an upload
234
+ for (const e of imageEntries()) {
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
+ }
246
+ if (!isDataUrl(v) || stored === undefined) continue; // a URL is small, and a data URL with no store saves inline
247
+ const ref = imgs!.save(e, v, stored);
248
+ if (ref) o[e.key] = ref;
249
+ }
250
+ return snap;
251
+ };
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.
254
+ const late = (e: Entry, held: unknown, url: string) => {
255
+ if (destroyed || valueChanged(held, e.get()) || !tryAssign(e, url)) return false;
256
+ undoApi?.patch(e.path, held, e.get()); // not a step of its own: ⌘Z mustn't take a restored image back out
257
+ return true;
258
+ };
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.
261
+ const fromRefs = (snap: Snapshot | null, pending: [Entry, string][] = []) => {
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
+ }
273
+ return snap;
274
+ };
275
+ const restore = (snap: Snapshot | null, silent?: boolean) => {
276
+ const pending: [Entry, string][] = [];
277
+ applySnapshot(fromRefs(snap, pending), silent);
278
+ for (const [e, ref] of pending) {
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);
282
+ if (!imagesReady || !imgs) continue; // no store: the ref stays saved until the row is written
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
+ });
291
+ }
292
+ };
293
+ // The store answered: each waiting row the host and the person left alone takes its image, in one notify.
294
+ const restoreImages = () => {
295
+ imagesReady = true;
296
+ // not built yet: the build resolves refs itself
297
+ if (!assembled || destroyed || failed) {
298
+ waiting.clear();
299
+ return;
300
+ }
301
+ let changed = false;
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
312
+ };
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.
315
+ const pruneImages = () => imgs?.prune([readStore(persistKey!), listPresets()]);
316
+ // Debounced save; destroy() flushes a pending write rather than dropping it. saveNow writes at once.
317
+ let saveNow = () => {};
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
+ : () => {};
350
+ // Each listener runs isolated: a throwing callback can't break the others or skip persist().
351
+ // `last` is read once, so a write one listener makes can't change the key the rest are told.
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).
365
+ const notAValue = (v: unknown) => typeof v === "function" || typeof v === "symbol" || typeof v === "bigint";
366
+ // An explicit write also settles a row still waiting for its stored image: the host's or the person's value wins.
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
+ };
382
+ // …and the reset write: the opened form, then the authored value (restoreDefault).
383
+ const assignDefault = (e: Entry) => {
384
+ waiting.delete(e);
385
+ restoreDefault(e, e.raw, e.def);
386
+ e.target[e.key] = e.get();
387
+ };
388
+ // opts.onReset replaces the default; a reset() called from INSIDE it performs the default instead of recursing.
389
+ let inOnReset = false;
390
+ const resetAll = () => {
391
+ if (typeof opts.onReset === "function" && !inOnReset) {
392
+ inOnReset = true;
393
+ try {
394
+ return opts.onReset();
395
+ } finally {
396
+ inOnReset = false;
397
+ }
398
+ }
399
+ for (const e of entries) {
400
+ try {
401
+ assignDefault(e);
402
+ } catch (err) {
403
+ console.error(`[tweaks] resetting "${e.path.join(".")}" failed — control skipped:`, err);
404
+ }
405
+ }
406
+ last = undefined;
407
+ notify();
408
+ };
409
+
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);
417
+ // Stop pointer events leaking past the panel to a stage listening on window.
418
+ stopPointerLeak(panel);
419
+ const header = el("div", "twk-header");
420
+ // The title is the collapse toggle; the toolbar beside it never collapses.
421
+ const titleBtn = btn("twk-header-toggle");
422
+ titleBtn.setAttribute("aria-expanded", "true");
423
+ let title = name; // setName() moves it; the storage key keeps the built name
424
+ const titleEl = txt("span", "twk-title", title);
425
+ titleBtn.append(titleEl);
426
+ const toolbar = el("div", "twk-toolbar");
427
+ // The values moved off their defaults, keyed by dotted path — setMany()'s shape, for baking tuned values into source.
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
+ };
433
+ // ⇧-click copies the changed values only; reset restores every default or runs opts.onReset.
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);
439
+ // A picked row's paste (pasteScope, in the lazy clip chunk) writes through the panel's own load path; ctx lends it.
440
+ // A paste is its own undo step: what was pending commits first, the paste right after, so neither merges with a drag.
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);
454
+ // Presets share persistence's storage, so the button needs it on.
455
+ let presetsBtn: HTMLButtonElement | null = null;
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");
461
+ toolbar.append(presetsBtn);
462
+ }
463
+ toolbar.append(copyBtn, resetBtn);
464
+ header.append(titleBtn);
465
+ if (opts.toolbar !== false) header.append(toolbar); // toolbar:false → a bare panel, e.g. an embedded demo
466
+ // The buttons act on controls that only exist once assemble() has built them: inert until then.
467
+ const toolbarBtns = [copyBtn, resetBtn, presetsBtn].filter(Boolean);
468
+ for (const b of toolbarBtns) b.disabled = 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
+ );
484
+ // A bottom-parked floating panel grows past the viewport when it expands: re-clamp once the collapse has settled.
485
+ const toggleBody = () => foldBody(panel, titleBtn, body, !panel.classList.contains("is-collapsed"), reclamp);
486
+ titleBtn.addEventListener("click", toggleBody);
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.
489
+ let pressedHeader = false;
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");
502
+ body.append(controls);
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;
508
+
509
+ // Stash the theme so the portaled popovers (which lose the panel's inherited vars) re-apply it on open.
510
+ let themeVars = resolveTheme(opts.theme);
511
+ applyThemeVars(panel, themeVars);
512
+ panel._twTheme = themeVars;
513
+
514
+ // ── Floating position — seeded in the shell, so an opts.floating / persisted panel is fixed in place
515
+ // the moment tweaks() returns instead of jumping when the lazy chunks land. ──
516
+ const draggable = opts.draggable !== false;
517
+ const MARGIN = 8,
518
+ SNAP = 28; // px: viewport inset; snap distance to park against an edge
519
+ // opts.floating's x / y are insets from the viewport's top-left, as px / py below are.
520
+ const place = typeof opts.floating === "object" && opts.floating ? opts.floating : null;
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.
524
+ // The viewport from clientWidth / clientHeight: a fixed box's right and bottom edges sit inside the page scrollbars,
525
+ // which innerWidth / innerHeight count. Only in standards mode: with no doctype the root's client box is the page's
526
+ // content, not the viewport (and jsdom has no layout: 0) — the inner size there.
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
+ });
534
+ const posKey = persistKey ? `${persistKey}:pos` : null;
535
+ // A pinned panel sits where opts.floating puts it; only a draggable one restores a saved spot.
536
+ const stored = draggable && posKey ? readStore(posKey) : null;
537
+ const saved = stored && typeof stored === "object" ? stored : null;
538
+ const start = saved || place || { x: 16, y: 16 };
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).
560
+ // Custom properties, not left / top / max-height: the stylesheet maps them, so host CSS can still override each one.
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
+ };
567
+ const clampPos = () => {
568
+ readGap();
569
+ const { maxX, maxY } = bounds();
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.
577
+ px = clamp(x0, Math.min(MARGIN, x0), maxX);
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
+ }
590
+ };
591
+ if ((draggable || opts.floating) && (opts.floating || saved)) {
592
+ panel.dataset.mode = "floating";
593
+ clampPos();
594
+ apply();
595
+ // A position saved on a larger monitor restores off-viewport: re-clamp once mounted (offsetWidth is 0 until then).
596
+ requestAnimationFrame(reclamp);
597
+ }
598
+
599
+ // Per-control reset. The target is LABEL_SEL — the hint marker's list, so the two can't drift — or the root.
600
+ const resetEntry = (e: Entry) => {
601
+ assignDefault(e);
602
+ last = e.path.join(".");
603
+ notify();
604
+ };
605
+ const wireReset = (root: HTMLElement, entry: Entry) => {
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");
610
+ // Coarse pointers get a press-and-hold reset: the desktop double-click fights double-tap-zoom on touch.
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
+ };
622
+ t.addEventListener("pointerdown", (e) => {
623
+ e.stopPropagation();
624
+ held = false; // a press on the readout mustn't jump the slider
625
+ if (e.pointerType !== "touch" && e.pointerType !== "pen") return;
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();
638
+ });
639
+ t.addEventListener("pointerup", cancelHold);
640
+ t.addEventListener("pointercancel", cancelHold);
641
+ t.addEventListener("pointerleave", cancelHold);
642
+ // a long-press mustn't raise the text callout
643
+ t.addEventListener("contextmenu", (e) => {
644
+ if (held) e.preventDefault();
645
+ });
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();
663
+ resetEntry(entry);
664
+ });
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.
677
+ root.addEventListener("keydown", (e) => {
678
+ if (!e.altKey || e.metaKey || e.ctrlKey || (e.key !== "Backspace" && e.key !== "Delete")) return;
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();
683
+ resetEntry(entry);
684
+ });
685
+ };
686
+
687
+ // Conditional controls — `render` shows/hides, `disabled` grays-out + locks, both re-evaluated on every change;
688
+ // `hint` is a static tooltip.
689
+ const conditionals: Array<{ node: HTMLElement; m: Meta }> = [];
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).
692
+ let fold: typeof import("./fold.js").fold | undefined;
693
+ const foldRow = (node: HTMLElement & { _hid?: boolean }, hide: boolean) => {
694
+ if (!node._hid === !hide) return false;
695
+ node._hid = hide;
696
+ if (fold) fold(node, hide);
697
+ else node.classList.toggle("twk-cond-hidden", hide);
698
+ return !hide;
699
+ };
700
+ const registerCond = (node: HTMLElement, m: Meta) => {
701
+ if (m.hint) addHintMarker(node, m.hint);
702
+ if (m.render || m.disabled != null) conditionals.push({ node, m });
703
+ };
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[] }> = [];
707
+
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
+ };
713
+
714
+ const build = (container: HTMLElement, ms: Meta[], target: Target, basePath: string[] = []) => {
715
+ for (const m of ms) {
716
+ if (m.type === "tabs") {
717
+ const sub: Target = {};
718
+ target[m.key] = sub;
719
+ const makeTabs = getControl<TabsControl>("tabs");
720
+ const tabsCtrl = makeTabs && makeTabs(m);
721
+ if (!tabsCtrl) continue; // ensured before assemble; skipped if its chunk failed
722
+ tabsCtrls.push({ path: [...basePath, m.key], ctrl: tabsCtrl, pageKeys: m.pages.map((p) => p.key) });
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);
730
+ continue;
731
+ }
732
+ if (m.type === "folder") {
733
+ const sub: Target = {};
734
+ target[m.key] = sub;
735
+ const f = createFolder(m);
736
+ folderEls.push({ path: [...basePath, m.key], el: f.el, setCollapsed: f.setCollapsed });
737
+ scopes.set(f.el, { kind: "folder", path: [...basePath, m.key], label: m.label });
738
+ build(f.body, m.children, sub, [...basePath, m.key]);
739
+ registerCond(f.el, m);
740
+ container.append(f.el);
741
+ continue;
742
+ }
743
+ const valued = !VALUELESS.has(m.type);
744
+ const dotted = [...basePath, m.key].join("."); // a bare leaf couldn't tell size from card.size
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
+ );
760
+ if (!ctrl) continue;
761
+ adopt(ctrl);
762
+ if (!valued) valueless.set(dotted, m.type);
763
+ if (valued) {
764
+ // reset() restores `raw` over the form the control OPENED on (`def`), read before the parked value applies —
765
+ // see restoreDefault.
766
+ const def = ctrl.get();
767
+ // A value a host parked on params before assemble ran wins over the schema default.
768
+ if (hasOwn(target, m.key)) ctrl.set(target[m.key]);
769
+ target[m.key] = ctrl.get();
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 });
787
+ }
788
+ registerCond(ctrl.el, m);
789
+ container.append(ctrl.el);
790
+ }
791
+ };
792
+
793
+ // ── Snapshots in: persisted sessions, presets, fromJSON, undo. Per-entry isolation: a control whose
794
+ // set() throws on hostile stored data (a corrupt snapshot, a hand-edited preset) costs only its own value. ──
795
+ const applySnapshot = (snap: Snapshot | null, silent?: boolean) => {
796
+ if (!snap || typeof snap !== "object") return;
797
+ for (const e of entries) {
798
+ const v = atPath(snap, e.path);
799
+ if (v === undefined) continue;
800
+ tryAssign(e, v);
801
+ }
802
+ last = undefined;
803
+ notify(silent);
804
+ };
805
+ // ── Named presets (opt-in via opts.persist) — snapshots under "<key>:presets" ──
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
+ };
811
+ // False when storage refused the write, so the caller can say so instead of claiming a save.
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
+ };
828
+
829
+ // ── Whole-panel UI state (toJSON / fromJSON) — folder open/closed and the active tab, keyed by dotted path ──
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(".");
832
+ const collectUI = () => {
833
+ const ui: NonNullable<PanelState["ui"]> = {};
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
+ }
844
+ return ui;
845
+ };
846
+ const applyUI = (ui: PanelState["ui"]) => {
847
+ if (!ui || typeof ui !== "object") return;
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
+ }
859
+ };
860
+
861
+ // ── Repositioning — drag the header to move the panel ──
862
+ // An inline panel lifts into a floating layer on the first drag, at the spot it sat, then tracks the
863
+ // pointer; on release it eases to a near viewport edge, and the position persists to "<key>:pos".
864
+ if (draggable || opts.floating) {
865
+ if (draggable) panel.dataset.draggable = "true"; // CSS grab cursor on the header — the affordance
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.
868
+ const lift = () => {
869
+ if (panel.dataset.mode === "floating") return;
870
+ const r = panel.getBoundingClientRect();
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";
877
+ liftSlot.setAttribute("aria-hidden", "true");
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;
881
+ }
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.
884
+ // So does the rest of the slot's context: the text properties the kit leaves to inheritance (a host's font-size,
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.
890
+ applyThemeVars(panel, null);
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);
914
+ carryScheme(panel, panel);
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;
918
+ applyThemeVars(panel, themeVars);
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.
923
+ if (liftSlot && typeof MutationObserver === "function") {
924
+ const obs = new MutationObserver(() => {
925
+ if (liftSlot && !liftSlot.isConnected) {
926
+ obs.disconnect();
927
+ liftSlot = null;
928
+ box.remove();
929
+ }
930
+ });
931
+ obs.observe(document.documentElement, { childList: true, subtree: true });
932
+ cleanups.push(() => obs.disconnect());
933
+ }
934
+ };
935
+ // Two floating panels share one stylesheet z-index; the last touched gets a higher one. A class at plain .twk-panel
936
+ // specificity, not an inline value, so a host's own z-index still wins after a drag.
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
+ };
957
+
958
+ let dragId: number | null = null,
959
+ sx = 0,
960
+ sy = 0,
961
+ ox = 0,
962
+ oy = 0,
963
+ snapT = 0;
964
+ // Document listeners (capture, so the panel's own pointer-stop can't hide them) for the press's duration.
965
+ // Capture is taken only past the threshold: on the press it would retarget a plain click off the title.
966
+ const listen = (on: boolean) => {
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);
976
+ };
977
+ // Escape mid-press cancels: the panel returns to where the drag began and the release's click is swallowed. Capture
978
+ // phase, so it beats a pane's Escape.
979
+ const onDragKey = (e: KeyboardEvent) => {
980
+ if (e.key !== "Escape" || dragId === null) return;
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
+ }
995
+ panel.classList.remove("is-dragging");
996
+ swallowClick = true;
997
+ document.addEventListener(
998
+ "pointerup",
999
+ () =>
1000
+ setTimeout(() => {
1001
+ swallowClick = false;
1002
+ }, 0),
1003
+ { capture: true, once: true },
1004
+ );
1005
+ };
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
+ });
1044
+ const onMove = (e: PointerEvent) => {
1045
+ if (e.pointerId !== dragId) return;
1046
+ // The window lost the pointer: bail as every other drag surface does.
1047
+ if (e.buttons === 0) {
1048
+ endDrag(e);
1049
+ return;
1050
+ }
1051
+ const dx = e.clientX - sx,
1052
+ dy = e.clientY - sy;
1053
+ if (!dragMoved) {
1054
+ // Drag slop: quick clicks wander a few px, fingers more. Collapsing is common and moving rare, so favor the
1055
+ // click.
1056
+ if (Math.hypot(dx, dy) < (e.pointerType === "mouse" ? 6 : 10)) return;
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 {}
1064
+ panel.classList.add("is-dragging");
1065
+ }
1066
+ moveTo(ox, oy, dx, dy);
1067
+ };
1068
+ const endDrag = (e: PointerEvent) => {
1069
+ if (e.pointerId !== dragId) return;
1070
+ try {
1071
+ header.releasePointerCapture(dragId);
1072
+ } catch {}
1073
+ dragId = null;
1074
+ listen(false);
1075
+ // Swallow exactly the click that follows the release; let the flag lapse in case none comes (a pointercancel).
1076
+ swallowClick = dragMoved;
1077
+ if (swallowClick)
1078
+ setTimeout(() => {
1079
+ swallowClick = false;
1080
+ }, 0);
1081
+ if (dragMoved) {
1082
+ // Edge magnetism: a drop near a side eases the rest of the way to the margin.
1083
+ const { maxX, maxY } = bounds();
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;
1088
+ // The ease is a class (tweaks.css), so the reduced-motion kill-switch covers it.
1089
+ // One timer at a time: an earlier drop's would strip the class 340ms after it, partway through this snap.
1090
+ clearTimeout(snapT);
1091
+ panel.classList.add("is-snapping");
1092
+ snapT = setTimeout(() => panel.classList.remove("is-snapping"), 340);
1093
+ apply();
1094
+ savePos();
1095
+ }
1096
+ panel.classList.remove("is-dragging");
1097
+ };
1098
+ header.addEventListener("lostpointercapture", endDrag); // implicit capture loss mid-drag ends it like a release
1099
+ cleanups.push(() => listen(false)); // a destroy() mid-press releases the document listeners
1100
+ }
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.
1103
+ const watchResize = () => {
1104
+ if (!(draggable || opts.floating)) return;
1105
+ reclamp(); // a resize during the lazy window went unheard; clamp now
1106
+ cleanups.push(onLive(panel, [[window, "resize"]], reclamp));
1107
+ };
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.
1112
+ if (presetsBtn) {
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);
1154
+ };
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
+ });
1170
+ });
1171
+ }
1172
+
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;
1187
+
1188
+ // ── Pick and copy / paste — a press beside a row (in the panel's side padding, or a gap between rows) picks it, as
1189
+ // a layer is picked in a design tool; beside a folder's header, the folder. ⌘C / ⌘V (ctrl elsewhere) then act on the
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
1193
+ // press anywhere else, drops the pick; a text field or a text selection keeps the browser's own keys. ──
1194
+ const scopes = new Map<Element, Scope>(); // a row's or folder's root → its scope, filled by build()
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
+ };
1204
+ // Out of sight: hidden by its condition, on a tab page not showing, or inside a collapsed folder or panel.
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
+ );
1210
+ let pick: [Element, Scope] | undefined;
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
+ };
1223
+ const choose = (el: Element, s: Scope) => {
1224
+ if (picked && picked[0] !== panel) picked[1](); // one pick on the page
1225
+ unpick();
1226
+ pick = [el, s];
1227
+ picked = [panel, unpick];
1228
+ el.classList.add("is-picked");
1229
+ document.addEventListener("pointerdown", away, true); // only while something is picked
1230
+ // Focus moves to what was picked (a folder's header, a row's control), so its keys work and a screen reader names
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.
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?.();
1238
+ document.getSelection?.()?.removeAllRanges();
1239
+ };
1240
+ cleanups.push(unpick);
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
+ );
1272
+ // Called from the panel's one keydown listener (registered by assemble(), beside the undo keys). Focus steps to the
1273
+ // hidden field for the keypress (the copy selected in it), the browser copies or pastes there, and focus steps back.
1274
+ const onClipKey = (e: KeyboardEvent) => {
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]();
1287
+ if (pick && hidden(pick[0])) unpick(); // a pick out of sight takes nothing
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");
1305
+ // The browser's copy or paste can land some milliseconds after this keydown (an app's menu passes it on), so focus
1306
+ // steps back only when it lands, or after a second with none; it's caught page-wide, wherever focus is by then.
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) => {
1322
+ if (ev.target !== ta && inEditable(ev.target as Element)) return; // a field focused meanwhile keeps its own paste
1323
+ ev.preventDefault();
1324
+ done();
1325
+ if (json) return (ev.clipboardData?.setData("text/plain", json), copied(!!ev.clipboardData));
1326
+ const t = ev.clipboardData?.getData("text/plain") ?? null;
1327
+ import("./clip.js").then(
1328
+ (m) => m.pasteInto(t, (t) => m.pasteScope(ctx, s, t), ctx, band),
1329
+ () => {},
1330
+ );
1331
+ });
1332
+ if (!ta.isConnected) document.body.appendChild(ta);
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);
1343
+ };
1344
+
1345
+ // Build the controls, then everything that needs them: the conditionals' first pass, the persisted restore,
1346
+ // the undo seed, the queued API calls. Synchronous when every control type is registered, else behind panel.ready.
1347
+ const assemble = () => {
1348
+ if (destroyed) return; // destroy() before the chunks landed: nothing to build
1349
+ built = cleanups.length;
1350
+ using(opts.strings, () => build(controls, metas, params));
1351
+ if (conditionals.length) {
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
+ };
1359
+ const applyConditionals = () => {
1360
+ let revealed = false;
1361
+ for (const { node, m } of conditionals) {
1362
+ // A throwing predicate leaves the node as-is rather than aborting the pass.
1363
+ try {
1364
+ if (m.render && foldRow(node, !m.render(getVal))) revealed = true;
1365
+ if (m.disabled != null) {
1366
+ const d = typeof m.disabled === "function" ? m.disabled(getVal) : m.disabled;
1367
+ setDisabled(node, !!d);
1368
+ }
1369
+ } catch {}
1370
+ }
1371
+ if (revealed) requestReflow(); // built hidden it measured 0×0; shown, let it re-measure
1372
+ };
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
+ );
1383
+ }
1384
+ // Restore last session, notifying: on the lazy path a host may already have subscribed.
1385
+ if (persistKey) restore(unsaved(unwrap(readStore(persistKey), "values")));
1386
+ if (destroyed) return; // a listener may have destroyed the panel on that notify
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
+ );
1396
+ assembled = true;
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
+ }
1407
+ for (const b of toolbarBtns) b.disabled = false; // the controls exist now
1408
+ };
1409
+
1410
+ // A set() past a hard range clamps, which is right, but silent it reads as the value not taking: say so, once a key.
1411
+ const clamped = new Set<Entry>();
1412
+ // Each number asked for is held to the range the control resolved (derived bounds, a point's per component), not the
1413
+ // schema's min and max, which a derived range leaves unset; a hair past an end is float noise, not a clamp.
1414
+ const clampNote = (e: Entry, key: string, v: unknown) => {
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]];
1430
+ for (const [k, n] of asked) {
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
+ }
1438
+ }
1439
+ };
1440
+ // Resolve a (possibly dotted) key, apply, report whether the leaf changed; never notifies, so setMany fires one
1441
+ // notify.
1442
+ const applySet = (key: string, v: unknown) => {
1443
+ if (String(key).split(".").some(isReservedKey)) return false; // never write through to the prototype
1444
+ const parts = String(key).split(".");
1445
+ // The path exactly as changes() names it first, so a key holding a dot ("a.b", "f.a.b") is reachable; two controls
1446
+ // whose paths read the same (a top-level "a.b" beside folder a's b) are ambiguous, not a guess.
1447
+ const exact = entries.filter((x) => x.path.join(".") === key);
1448
+ if (exact.length > 1) {
1449
+ notice("ambiguousPath", key, exact.length);
1450
+ return false;
1451
+ }
1452
+ let e: Entry | undefined = exact[0];
1453
+ if (!e && parts.length > 1) {
1454
+ const t = valueless.get(key);
1455
+ notice(t ? "noValue" : "noPath", key, t);
1456
+ return false;
1457
+ }
1458
+ if (!e) {
1459
+ const matches = entries.filter((x) => x.key === key);
1460
+ if (matches.length > 1) {
1461
+ notice("ambiguousKey", key, matches.length, matches[0].path.join("."));
1462
+ return false;
1463
+ }
1464
+ e = matches[0];
1465
+ }
1466
+ // A key no control owns is refused: written through, a typo landed on params and in every save after.
1467
+ // A button, monitor or separator is there but holds nothing to set, which "no control has that key" would misstate.
1468
+ const vt = !e && (valueless.get(key) || [...valueless].find(([p]) => p.split(".").pop() === key)?.[1]);
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
+ }
1479
+ const prev = e.target[e.key];
1480
+ // Same isolation as applySnapshot: a hostile value costs only that key.
1481
+ if (notAValue(v)) 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
+ }
1488
+ clampNote(e, key, v);
1489
+ if (!valueChanged(prev, e.target[e.key])) return false; // keeps a store-sync listener from echoing forever
1490
+ last = e.path.join("."); // on((p, last)) sees programmatic sets like control edits
1491
+ return true;
1492
+ };
1493
+
1494
+ // Every mutating method goes through later(): live at once, queued in the lazy window, silent after destroy().
1495
+ const api = {
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
+ },
1511
+ // Batch write: listeners + persist fire once for the whole batch.
1512
+ setMany(values, o) {
1513
+ if (values == null || typeof values !== "object") return;
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);
1533
+ },
1534
+ // Whole-panel state — values + UI as a JSON-safe object (the standard toJSON hook). Before ready there
1535
+ // are no values yet. fromJSON applies one back: values (one notify), then the UI silently, outside undo.
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
+ },
1543
+ fromJSON(state, o) {
1544
+ if (!state || typeof state !== "object") return;
1545
+ const silent = o?.silent;
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
+ });
1553
+ },
1554
+ // Live theming clears the prior theme first, so setTheme(null) reverts to the default look.
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
+ },
1573
+ // Presets API (no-ops without opts.persist); before ready they queue like set() does.
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
+ },
1592
+ presets: () => (destroyed ? [] : Object.keys(listPresets())),
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
+ },
1600
+ // Teardown. Safe to call before ready resolves — assemble() sees the flag and bails.
1601
+ destroy() {
1602
+ if (destroyed) return;
1603
+ destroyed = true;
1604
+ releaseKey();
1605
+ closeActivePopover(panel); // page-wide singletons: close only what's anchored here
1606
+ hideHintNow(panel);
1607
+ for (const fn of cleanups.splice(0)) {
1608
+ try {
1609
+ fn();
1610
+ } catch {}
1611
+ }
1612
+ listeners.clear();
1613
+ if (liftSlot) {
1614
+ liftSlot.remove();
1615
+ liftSlot = null;
1616
+ }
1617
+ if (topFloating === panel) topFloating = null;
1618
+ panel.dataset.twkDestroyed = ""; // onLive listeners release on their next event by this mark
1619
+ box.remove();
1620
+ },
1621
+ } as Panel; // `ready` is filled in just below
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.
1625
+ // A store that fails to load just saves no uploads.
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;
1648
+ if (!images) imagesReady = true;
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);
1653
+ const lazy = ensureForMetas(metas);
1654
+ if (lazy) {
1655
+ // A failed chunk or a throwing build leaves the panel unbuildable: queued calls dropped, later calls refused,
1656
+ // ready rejects. A partway build must not leave live controls behind an inert API: release what it wired.
1657
+ const fail = (e: unknown) => {
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
+ );
1678
+ throw e;
1679
+ };
1680
+ const building = lazy.then(assemble);
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
+ );
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);
1691
+ }
1692
+ return api as Panel;
1693
+ }