tweakit 0.5.2 → 0.6.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 (111) hide show
  1. package/AGENTS.md +31 -29
  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-JFEZMCLP.js +2 -0
  7. package/dist/tweaks/bezier-JFEZMCLP.js.map +6 -0
  8. package/dist/tweaks/chunk-6UO4DHBN.js +2 -0
  9. package/dist/tweaks/chunk-6UO4DHBN.js.map +6 -0
  10. package/dist/tweaks/chunk-DIQMLBAS.js +2 -0
  11. package/dist/tweaks/chunk-DIQMLBAS.js.map +6 -0
  12. package/dist/tweaks/chunk-XE6OUJBG.js +2 -0
  13. package/dist/tweaks/chunk-XE6OUJBG.js.map +6 -0
  14. package/dist/tweaks/clip-WW7JYO4X.js +2 -0
  15. package/dist/tweaks/clip-WW7JYO4X.js.map +6 -0
  16. package/dist/tweaks/color-YMMFURP6.js +2 -0
  17. package/dist/tweaks/color-YMMFURP6.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-UGN7TXGX.js → fold-U6YRZLCJ.js} +2 -1
  21. package/dist/tweaks/fold-U6YRZLCJ.js.map +6 -0
  22. package/dist/tweaks/gradient-JER7PW4D.js +2 -0
  23. package/dist/tweaks/gradient-JER7PW4D.js.map +6 -0
  24. package/dist/tweaks/heavy-CLD7UAVI.js +2 -0
  25. package/dist/tweaks/heavy-CLD7UAVI.js.map +6 -0
  26. package/dist/tweaks/image-H6QSLGO3.js +4 -0
  27. package/dist/tweaks/image-H6QSLGO3.js.map +6 -0
  28. package/dist/tweaks/image-store-WRKY2NPU.js +2 -0
  29. package/dist/tweaks/image-store-WRKY2NPU.js.map +6 -0
  30. package/dist/tweaks/interval-TEUHQGZN.js +2 -0
  31. package/dist/tweaks/interval-TEUHQGZN.js.map +6 -0
  32. package/dist/tweaks/markup-NIBJD6E2.js +2 -0
  33. package/dist/tweaks/markup-NIBJD6E2.js.map +6 -0
  34. package/dist/tweaks/monitor-3BY7YCGX.js +3 -0
  35. package/dist/tweaks/monitor-3BY7YCGX.js.map +6 -0
  36. package/dist/tweaks/motion-FQPUBKKM.js +2 -0
  37. package/dist/tweaks/motion-FQPUBKKM.js.map +6 -0
  38. package/dist/tweaks/plot-2HBNBZSG.js +3 -0
  39. package/dist/tweaks/plot-2HBNBZSG.js.map +6 -0
  40. package/dist/tweaks/point-VV6XV3I6.js +2 -0
  41. package/dist/tweaks/point-VV6XV3I6.js.map +6 -0
  42. package/dist/tweaks/shadow-5E4E55I2.js +2 -0
  43. package/dist/tweaks/shadow-5E4E55I2.js.map +6 -0
  44. package/dist/tweaks/sides-P3STEVYY.js +2 -0
  45. package/dist/tweaks/sides-P3STEVYY.js.map +6 -0
  46. package/dist/tweaks/spring-HUVSXNVN.js +2 -0
  47. package/dist/tweaks/spring-HUVSXNVN.js.map +6 -0
  48. package/dist/tweaks/tabs-76FW7LQ7.js +2 -0
  49. package/dist/tweaks/tabs-76FW7LQ7.js.map +6 -0
  50. package/dist/tweaks.css +1 -1
  51. package/dist/tweaks.js +6 -3
  52. package/dist/tweaks.js.map +6 -0
  53. package/dist/types/tweaks/easing.d.ts +8 -11
  54. package/dist/types/tweaks/panel.d.ts +6 -4
  55. package/dist/types/tweaks/schema.d.ts +4 -8
  56. package/dist/types/tweaks/standalone.d.ts +3 -3
  57. package/dist/types/tweaks/types.d.ts +278 -101
  58. package/package.json +27 -5
  59. package/src/tweaks/auto.ts +12 -0
  60. package/src/tweaks/clip.ts +94 -0
  61. package/src/tweaks/controls/basic.ts +403 -0
  62. package/src/tweaks/controls/bezier.ts +99 -0
  63. package/src/tweaks/controls/color.ts +412 -0
  64. package/src/tweaks/controls/gradient.ts +240 -0
  65. package/src/tweaks/controls/image.ts +299 -0
  66. package/src/tweaks/controls/interval.ts +104 -0
  67. package/src/tweaks/controls/monitor.ts +105 -0
  68. package/src/tweaks/controls/motion.ts +141 -0
  69. package/src/tweaks/controls/plot.ts +220 -0
  70. package/src/tweaks/controls/point.ts +119 -0
  71. package/src/tweaks/controls/shadow.ts +83 -0
  72. package/src/tweaks/controls/sides.ts +86 -0
  73. package/src/tweaks/controls/spring.ts +148 -0
  74. package/src/tweaks/controls/tabs.ts +47 -0
  75. package/src/tweaks/core.ts +9 -0
  76. package/src/tweaks/easing.ts +93 -0
  77. package/src/tweaks/enhance.ts +20 -0
  78. package/src/tweaks/feedback.ts +119 -0
  79. package/src/tweaks/fold.ts +21 -0
  80. package/src/tweaks/heavy.ts +302 -0
  81. package/src/tweaks/icons.ts +19 -0
  82. package/src/tweaks/image-store.ts +101 -0
  83. package/src/tweaks/lazy.ts +46 -0
  84. package/src/tweaks/markup.ts +154 -0
  85. package/src/tweaks/panel.ts +869 -0
  86. package/src/tweaks/schema.ts +189 -0
  87. package/src/tweaks/shared.ts +525 -0
  88. package/src/tweaks/single.ts +22 -0
  89. package/src/tweaks/standalone.ts +89 -0
  90. package/src/tweaks/types.ts +526 -0
  91. package/src/tweaks.css +959 -0
  92. package/src/wide-gamut.ts +310 -0
  93. package/dist/tweaks/bezier-2RABC6LD.js +0 -1
  94. package/dist/tweaks/chunk-4ATI34PB.js +0 -1
  95. package/dist/tweaks/chunk-7OSCMZ2J.js +0 -1
  96. package/dist/tweaks/chunk-W3JNIJ2F.js +0 -1
  97. package/dist/tweaks/clip-ECGCECEC.js +0 -1
  98. package/dist/tweaks/colour-2RE3FQ5E.js +0 -1
  99. package/dist/tweaks/gradient-DB6XTDHO.js +0 -1
  100. package/dist/tweaks/heavy-PKOMHB3C.js +0 -1
  101. package/dist/tweaks/image-7MCLBOZ3.js +0 -1
  102. package/dist/tweaks/interval-L2E4L3AZ.js +0 -1
  103. package/dist/tweaks/markup-ZJJ6CCWM.js +0 -1
  104. package/dist/tweaks/monitor-IEK742PG.js +0 -2
  105. package/dist/tweaks/motion-N5UNVR55.js +0 -1
  106. package/dist/tweaks/plot-VVTOY3RA.js +0 -2
  107. package/dist/tweaks/point-YMMHSLO7.js +0 -1
  108. package/dist/tweaks/shadow-IKYOV3VN.js +0 -1
  109. package/dist/tweaks/sides-FH227Z54.js +0 -1
  110. package/dist/tweaks/spring-3PKAFIH7.js +0 -1
  111. package/dist/tweaks/tabs-BKK5NIIY.js +0 -1
@@ -0,0 +1,869 @@
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, btn, txt, clamp, popover, closeActivePopover, stopPointerLeak, foldBody,
5
+ applyThemeVars, resolveTheme, carryScheme, onLive, setDisabled, requestReflow, quietFocus,
6
+ getControl, notice, isObj, hasOwn, isReservedKey, valueChanged, restoreDefault,
7
+ } from "./shared.js";
8
+ import { metaFor, VALUELESS } from "./schema.js";
9
+ import { ensureForMetas, scanTypes } from "./lazy.js";
10
+ import { createFolder, createControl } from "./controls/basic.js";
11
+ import { makeCopyBtn, makeResetBtn, toolbarBtn, copyText, spinReset, announce, hideHintNow, addHintMarker, LABEL_SEL } from "./feedback.js";
12
+ import { ICON_PRESETS, ICON_X } from "./icons.js";
13
+ import type { Schema, HeldSchema, TweaksOptions, Panel, Params, ParamsOf, KnownTypes, PanelState, Control } from "./types.js";
14
+ import type { Meta } from "./schema.js";
15
+ import type { ClipCtx, Scope } from "./clip.js";
16
+ import type { PanelEl } from "./shared.js";
17
+ import type { TabsControl } from "./controls/tabs.js";
18
+
19
+ /** A params object or one of its folder/tabs sub-objects — the holder a control writes into. */
20
+ type Target = Record<string, unknown>;
21
+ /** 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"] }
23
+ /** A values snapshot — params as JSON round-trips it. */
24
+ export type Snapshot = Record<string, any>;
25
+
26
+ let topFloating: HTMLElement | null = null; // the floating panel raised last (raise())
27
+ const liveKeys = new Map<string, number>(); // persist keys of the panels alive now, for the shared-key warning
28
+ 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;
32
+
33
+ /** Build a panel from `schema`; the shell and API exist synchronously, lazy controls fill in on `ready`.
34
+ * 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>>;
36
+ export function tweaks(name: string, schema: Schema, opts: TweaksOptions = {}): Panel {
37
+ const metas = Object.entries(schema).map(([k, v]) => metaFor(k, v)).filter(Boolean);
38
+ 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
40
+ 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
42
+ const listeners = new Set<(p: Params, last?: string) => void>();
43
+ const own = new Set<(p: Params, last?: string) => void>(); // undo + conditionals: all a silent write calls
44
+ const cleanups: Array<() => void> = []; // releases for destroy(): document/window listeners, timers
45
+ let destroyed = false; // set by destroy(): assemble() bails, mutators go silent
46
+ 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.
48
+ 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
+ let failed = false; // the chunks failed: the panel can never be built
51
+ 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
53
+ 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
55
+ // The values snapshot: undefined written as null (JSON would drop the key).
56
+ const replacer = (_k: string, v: unknown) => v === undefined ? null : v;
57
+ // params as the schema shapes it, folders and tab pages included: a value the host parks on params is its own, so
58
+ // snapshots, restores and undo carry only what a control holds. Null-prototype: a folder named "toString" is its own.
59
+ const valuesOf = (ms: Meta[], from: Target): Snapshot => {
60
+ 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
+ }
67
+ return o;
68
+ };
69
+ const snapshot = (): Snapshot => JSON.parse(JSON.stringify(valuesOf(metas, params), replacer));
70
+ // Storage keys — opt-in via opts.persist (a string key, or `true` to key by name).
71
+ const persistKey = opts.persist ? `tw:${opts.persist === true ? name : opts.persist}` : null;
72
+ 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.
75
+ 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.
83
+ const unwrap = (s: any, k: string) => (s?.v === 1 ? s[k] : null);
84
+ const writeValues = (snap: Snapshot) => writeStore(persistKey!, { v: 1, values: snap });
85
+ 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
87
+ // 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; };
89
+ // 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; };
91
+ // ── 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
96
+ // 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
98
+ 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
101
+ const isDataUrl = (v: unknown): v is string => typeof v === "string" && v.startsWith("data:");
102
+ const imageEntries = () => entries.filter((e) => e.m.type === "image");
103
+ 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"); };
105
+ const toRefs = (snap: Snapshot) => {
106
+ const stored = imagesReady && imgs ? readStore(persistKey!) : undefined; // undefined: no store to hold an upload
107
+ 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
110
+ if (!isDataUrl(v) || stored === undefined) continue; // a URL is small, and a data URL with no store saves inline
111
+ const ref = imgs!.save(e, v, stored);
112
+ if (ref) o[e.key] = ref;
113
+ }
114
+ return snap;
115
+ };
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.
118
+ const late = (e: Entry, held: unknown, url: string) => {
119
+ if (destroyed || valueChanged(held, e.get()) || !tryAssign(e, url)) return false;
120
+ undoApi?.patch(e.path, held, e.get()); // not a step of its own: ⌘Z mustn't take a restored image back out
121
+ return true;
122
+ };
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.
125
+ 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
+ }
132
+ return snap;
133
+ };
134
+ const restore = (snap: Snapshot | null, silent?: boolean) => {
135
+ const pending: [Entry, string][] = [];
136
+ applySnapshot(fromRefs(snap, pending), silent);
137
+ 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
139
+ 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(); } });
141
+ }
142
+ };
143
+ // The store answered: each waiting row the host and the person left alone takes its image, in one notify.
144
+ const restoreImages = () => {
145
+ imagesReady = true;
146
+ if (!assembled || destroyed || failed) { waiting.clear(); return; } // not built yet: the build resolves refs itself
147
+ 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
152
+ };
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.
155
+ const pruneImages = () => imgs?.prune([readStore(persistKey!), listPresets()]);
156
+ // Debounced save; destroy() flushes a pending write rather than dropping it. saveNow writes at once.
157
+ 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
+ })() : () => {};
176
+ // Each listener runs isolated: a throwing callback can't break the others or skip persist().
177
+ // `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).
180
+ const notAValue = (v: unknown) => typeof v === "function" || typeof v === "symbol" || typeof v === "bigint";
181
+ // 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; } };
184
+ // …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(); };
186
+ // opts.onReset replaces the default; a reset() called from INSIDE it performs the default instead of recursing.
187
+ let inOnReset = false;
188
+ const resetAll = () => {
189
+ if (typeof opts.onReset === "function" && !inOnReset) {
190
+ inOnReset = true;
191
+ try { return opts.onReset(); } finally { inOnReset = false; }
192
+ }
193
+ for (const e of entries) {
194
+ try { assignDefault(e); }
195
+ catch (err) { console.error(`[tweaks] resetting "${e.path.join(".")}" failed — control skipped:`, err); }
196
+ }
197
+ last = undefined; notify();
198
+ };
199
+
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);
204
+ // Stop pointer events leaking past the panel to a stage listening on window.
205
+ stopPointerLeak(panel);
206
+ const header = el("div", "tw-header");
207
+ // The title is the collapse toggle; the toolbar beside it never collapses.
208
+ const titleBtn = btn("tw-header-toggle");
209
+ titleBtn.setAttribute("aria-expanded", "true");
210
+ 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");
213
+ // 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)); };
215
+ // ⇧-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]; });
217
+ // A picked row's paste (pasteScope, in the lazy clip chunk) writes through the panel's own load path; ctx lends it.
218
+ // 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);
221
+ // Presets share persistence's storage, so the button needs it on.
222
+ 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");
226
+ toolbar.append(presetsBtn);
227
+ }
228
+ toolbar.append(copyBtn, resetBtn);
229
+ header.append(titleBtn);
230
+ if (opts.toolbar !== false) header.append(toolbar); // toolbar:false → a bare panel, e.g. an embedded demo
231
+ // The buttons act on controls that only exist once assemble() has built them: inert until then.
232
+ const toolbarBtns = [copyBtn, resetBtn, presetsBtn].filter(Boolean);
233
+ 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);
237
+ // A bottom-parked floating panel grows past the viewport when it expands: re-clamp once the collapse has settled.
238
+ const toggleBody = () => foldBody(panel, titleBtn, body, !panel.classList.contains("is-collapsed"), reclamp);
239
+ 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.
242
+ 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");
247
+ body.append(controls);
248
+ panel.append(header, body);
249
+
250
+ // Stash the theme so the portaled popovers (which lose the panel's inherited vars) re-apply it on open.
251
+ let themeVars = resolveTheme(opts.theme);
252
+ applyThemeVars(panel, themeVars); panel._twTheme = themeVars;
253
+
254
+ // ── Floating position — seeded in the shell, so an opts.floating / persisted panel is fixed in place
255
+ // the moment tweaks() returns instead of jumping when the lazy chunks land. ──
256
+ const draggable = opts.draggable !== false;
257
+ const MARGIN = 8, SNAP = 28; // px: viewport inset; snap distance to park against an edge
258
+ // opts.floating's x / y are insets from the viewport's top-left, as px / py below are.
259
+ 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.
263
+ // The viewport from clientWidth / clientHeight: a fixed box's right and bottom edges sit inside the page scrollbars,
264
+ // which innerWidth / innerHeight count. Only in standards mode: with no doctype the root's client box is the page's
265
+ // 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) });
269
+ const posKey = persistKey ? `${persistKey}:pos` : null;
270
+ // A pinned panel sits where opts.floating puts it; only a draggable one restores a saved spot.
271
+ const stored = draggable && posKey ? readStore(posKey) : null;
272
+ const saved = stored && typeof stored === "object" ? stored : null;
273
+ 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).
284
+ // 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)`); };
286
+ const clampPos = () => {
287
+ readGap();
288
+ 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.
291
+ 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);
296
+ };
297
+ const reclamp = () => { if (!destroyed && panel.dataset.mode === "floating" && panel.isConnected) { clampPos(); apply(); } };
298
+ if ((draggable || opts.floating) && (opts.floating || saved)) {
299
+ panel.dataset.mode = "floating"; clampPos(); apply();
300
+ // A position saved on a larger monitor restores off-viewport: re-clamp once mounted (offsetWidth is 0 until then).
301
+ requestAnimationFrame(reclamp);
302
+ }
303
+
304
+ // 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(); };
306
+ 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";
311
+ // 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"); } };
314
+ t.addEventListener("pointerdown", (e) => {
315
+ e.stopPropagation(); held = false; // a press on the readout mustn't jump the slider
316
+ 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);
319
+ });
320
+ t.addEventListener("pointermove", (e) => { if (holdT && Math.abs(e.clientX - hx) + Math.abs(e.clientY - hy) > 8) cancelHold(); });
321
+ t.addEventListener("pointerup", cancelHold);
322
+ t.addEventListener("pointercancel", cancelHold);
323
+ 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);
329
+ });
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
336
+ resetEntry(entry);
337
+ });
338
+ // The keyboard reset, Alt-qualified so a bare Backspace in a text field stays a backspace and the gradient editor keeps Delete.
339
+ root.addEventListener("keydown", (e) => {
340
+ 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
343
+ resetEntry(entry);
344
+ });
345
+ };
346
+
347
+ // Conditional controls — `render` shows/hides, `disabled` grays-out + locks, both re-evaluated on every change; `hint` is a static tooltip.
348
+ 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).
350
+ let fold: typeof import("./fold.js").fold | undefined;
351
+ const foldRow = (node: HTMLElement & { _hid?: boolean }, hide: boolean) => {
352
+ if (!node._hid === !hide) return false;
353
+ node._hid = hide;
354
+ if (fold) fold(node, hide); else node.classList.toggle("tw-cond-hidden", hide);
355
+ return !hide;
356
+ };
357
+ const registerCond = (node: HTMLElement, m: Meta) => {
358
+ if (m.hint) addHintMarker(node, m.hint);
359
+ if (m.render || m.disabled != null) conditionals.push({ node, m });
360
+ };
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
362
+
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); };
365
+
366
+ const build = (container: HTMLElement, ms: Meta[], target: Target, basePath: string[] = []) => {
367
+ for (const m of ms) {
368
+ if (m.type === "tabs") {
369
+ const sub: Target = {}; target[m.key] = sub;
370
+ const makeTabs = getControl<TabsControl>("tabs");
371
+ const tabsCtrl = makeTabs && makeTabs(m);
372
+ if (!tabsCtrl) continue; // ensured before assemble; skipped if its chunk failed
373
+ 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);
376
+ continue;
377
+ }
378
+ if (m.type === "folder") {
379
+ const sub: Target = {}; target[m.key] = sub;
380
+ const f = createFolder(m);
381
+ folderEls.push({ path: [...basePath, m.key], el: f.el, setCollapsed: f.setCollapsed });
382
+ 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);
384
+ continue;
385
+ }
386
+ const valued = !VALUELESS.has(m.type);
387
+ 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
390
+ if (!ctrl) continue;
391
+ adopt(ctrl);
392
+ if (!valued) valueless.set(dotted, m.type);
393
+ if (valued) {
394
+ // reset() restores `raw` over the form the control OPENED on (`def`), read before the parked value applies — see restoreDefault.
395
+ const def = ctrl.get();
396
+ // A value a host parked on params before assemble ran wins over the schema default.
397
+ if (hasOwn(target, m.key)) ctrl.set(target[m.key]);
398
+ 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 });
401
+ }
402
+ registerCond(ctrl.el, m);
403
+ container.append(ctrl.el);
404
+ }
405
+ };
406
+
407
+ // ── Snapshots in: persisted sessions, presets, fromJSON, undo. Per-entry isolation: a control whose
408
+ // set() throws on hostile stored data (a corrupt snapshot, a hand-edited preset) costs only its own value. ──
409
+ const applySnapshot = (snap: Snapshot | null, silent?: boolean) => {
410
+ if (!snap || typeof snap !== "object") return;
411
+ for (const e of entries) {
412
+ const v = atPath(snap, e.path); if (v === undefined) continue;
413
+ tryAssign(e, v);
414
+ }
415
+ last = undefined; notify(silent);
416
+ };
417
+ // ── 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
419
+ // 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(); };
423
+
424
+ // ── 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
426
+ const collectUI = () => {
427
+ 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; }
430
+ return ui;
431
+ };
432
+ const applyUI = (ui: PanelState["ui"]) => {
433
+ 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
436
+ };
437
+
438
+ // ── Repositioning — drag the header to move the panel ──
439
+ // An inline panel lifts into a floating layer on the first drag, at the spot it sat, then tracks the
440
+ // pointer; on release it eases to a near viewport edge, and the position persists to "<key>:pos".
441
+ if (draggable || opts.floating) {
442
+ 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.
444
+ const lift = () => {
445
+ if (panel.dataset.mode === "floating") return;
446
+ 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";
451
+ liftSlot.setAttribute("aria-hidden", "true");
452
+ panel.before(liftSlot);
453
+ }
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.
455
+ // 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.
461
+ 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);
464
+ 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;
468
+ 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.
471
+ if (liftSlot && typeof MutationObserver === "function") {
472
+ const obs = new MutationObserver(() => { if (liftSlot && !liftSlot.isConnected) { obs.disconnect(); liftSlot = null; panel.remove(); } });
473
+ obs.observe(document.documentElement, { childList: true, subtree: true });
474
+ cleanups.push(() => obs.disconnect());
475
+ }
476
+ };
477
+ // Two floating panels share one stylesheet z-index; the last touched gets a higher one. A class at plain .tw-panel
478
+ // 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 }); };
483
+
484
+ let dragId: number | null = null, sx = 0, sy = 0, ox = 0, oy = 0, snapT = 0;
485
+ // Document listeners (capture, so the panel's own pointer-stop can't hide them) for the press's duration.
486
+ // Capture is taken only past the threshold: on the press it would retarget a plain click off the title.
487
+ 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);
490
+ };
491
+ // Escape mid-press cancels: the panel returns to where the drag began and the release's click is swallowed. Capture
492
+ // phase, so it beats a pane's Escape.
493
+ const onDragKey = (e: KeyboardEvent) => {
494
+ 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(); }
499
+ panel.classList.remove("is-dragging");
500
+ swallowClick = true; document.addEventListener("pointerup", () => setTimeout(() => { swallowClick = false; }, 0), { capture: true, once: true });
501
+ };
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
+ });
526
+ const onMove = (e: PointerEvent) => {
527
+ if (e.pointerId !== dragId) return;
528
+ // 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;
531
+ if (!dragMoved) {
532
+ // Drag slop: quick clicks wander a few px, fingers more. Collapsing is common and moving rare, so favor the click.
533
+ 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 {}
536
+ panel.classList.add("is-dragging");
537
+ }
538
+ moveTo(ox, oy, dx, dy);
539
+ };
540
+ const endDrag = (e: PointerEvent) => {
541
+ if (e.pointerId !== dragId) return;
542
+ try { header.releasePointerCapture(dragId); } catch {}
543
+ dragId = null; listen(false);
544
+ // Swallow exactly the click that follows the release; let the flag lapse in case none comes (a pointercancel).
545
+ swallowClick = dragMoved;
546
+ if (swallowClick) setTimeout(() => { swallowClick = false; }, 0);
547
+ if (dragMoved) {
548
+ // Edge magnetism: a drop near a side eases the rest of the way to the margin.
549
+ 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;
552
+ // The ease is a class (tweaks.css), so the reduced-motion kill-switch covers it.
553
+ // 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);
555
+ apply();
556
+ savePos();
557
+ }
558
+ panel.classList.remove("is-dragging");
559
+ };
560
+ header.addEventListener("lostpointercapture", endDrag); // implicit capture loss mid-drag ends it like a release
561
+ cleanups.push(() => listen(false)); // a destroy() mid-press releases the document listeners
562
+ }
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.
564
+ const watchResize = () => {
565
+ if (!(draggable || opts.floating)) return;
566
+ reclamp(); // a resize during the lazy window went unheard; clamp now
567
+ cleanups.push(onLive(panel, [[window, "resize"]], reclamp));
568
+ };
569
+
570
+ 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
+ }
590
+ };
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(); },
596
+ });
597
+ saveBtn.addEventListener("click", doSave);
598
+ input.addEventListener("keydown", (e) => { if (e.key === "Enter") doSave(); });
599
+ }
600
+
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;
647
+
648
+ // ── Pick and copy / paste — a press beside a row (in the panel's side padding, or a gap between rows) picks it, as
649
+ // 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
652
+ // press anywhere else, drops the pick; a text field or a text selection keeps the browser's own keys. ──
653
+ 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; };
656
+ // 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"));
658
+ 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; };
661
+ const choose = (el: Element, s: Scope) => {
662
+ 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");
664
+ document.addEventListener("pointerdown", away, true); // only while something is picked
665
+ // Focus moves to what was picked (a folder's header, a row's control), so its keys work and a screen reader names
666
+ // 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?.();
669
+ document.getSelection?.()?.removeAllRanges();
670
+ };
671
+ 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));
690
+ // Called from the panel's one keydown listener (registered by assemble(), beside the undo keys). Focus steps to the
691
+ // hidden field for the keypress (the copy selected in it), the browser copies or pastes there, and focus steps back.
692
+ 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
697
+ 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");
703
+ // The browser's copy or paste can land some milliseconds after this keydown (an app's menu passes it on), so focus
704
+ // 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) => {
708
+ 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);
711
+ 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
+ };
714
+ 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
717
+ };
718
+
719
+ // Build the controls, then everything that needs them: the conditionals' first pass, the persisted restore,
720
+ // the undo seed, the queued API calls. Synchronous when every control type is registered, else behind panel.ready.
721
+ const assemble = () => {
722
+ if (destroyed) return; // destroy() before the chunks landed: nothing to build
723
+ built = cleanups.length; build(controls, metas, params);
724
+ 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]; };
727
+ const applyConditionals = () => {
728
+ let revealed = false;
729
+ for (const { node, m } of conditionals) {
730
+ // A throwing predicate leaves the node as-is rather than aborting the pass.
731
+ try {
732
+ 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); }
734
+ } catch {}
735
+ }
736
+ if (revealed) requestReflow(); // built hidden it measured 0×0; shown, let it re-measure
737
+ };
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
740
+ }
741
+ // Restore last session, notifying: on the lazy path a host may already have subscribed.
742
+ if (persistKey) restore(unsaved(unwrap(readStore(persistKey), "values")));
743
+ 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
746
+ 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); } }
749
+ for (const b of toolbarBtns) b.disabled = false; // the controls exist now
750
+ };
751
+
752
+ // A set() past a hard range clamps, which is right, but silent it reads as the value not taking: say so, once a key.
753
+ const clamped = new Set<Entry>();
754
+ // Each number asked for is held to the range the control resolved (derived bounds, a point's per component), not the
755
+ // schema's min and max, which a derived range leaves unset; a hair past an end is float noise, not a clamp.
756
+ 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]];
760
+ 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; }
763
+ }
764
+ };
765
+ // Resolve a (possibly dotted) key, apply, report whether the leaf changed; never notifies, so setMany fires one notify.
766
+ const applySet = (key: string, v: unknown) => {
767
+ if (String(key).split(".").some(isReservedKey)) return false; // never write through to the prototype
768
+ const parts = String(key).split(".");
769
+ // The path exactly as changes() names it first, so a key holding a dot ("a.b", "f.a.b") is reachable; two controls
770
+ // whose paths read the same (a top-level "a.b" beside folder a's b) are ambiguous, not a guess.
771
+ const exact = entries.filter((x) => x.path.join(".") === key);
772
+ if (exact.length > 1) { notice("ambiguousPath", key, exact.length); return false; }
773
+ 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; }
775
+ if (!e) {
776
+ const matches = entries.filter((x) => x.key === key);
777
+ if (matches.length > 1) { notice("ambiguousKey", key, matches.length, matches[0].path.join(".")); return false; }
778
+ e = matches[0];
779
+ }
780
+ // A key no control owns is refused: written through, a typo landed on params and in every save after.
781
+ // A button, monitor or separator is there but holds nothing to set, which "no control has that key" would misstate.
782
+ 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; }
784
+ const prev = e.target[e.key];
785
+ // Same isolation as applySnapshot: a hostile value costs only that key.
786
+ if (notAValue(v)) return false;
787
+ try { assign(e, v); } catch (err) { console.error(`[tweaks] set("${key}") failed — value skipped:`, err); return false; }
788
+ clampNote(e, key, v);
789
+ if (!valueChanged(prev, e.target[e.key])) return false; // keeps a store-sync listener from echoing forever
790
+ last = e.path.join("."); // on((p, last)) sees programmatic sets like control edits
791
+ return true;
792
+ };
793
+
794
+ // Every mutating method goes through later(): live at once, queued in the lazy window, silent after destroy().
795
+ 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
799
+ // Batch write: listeners + persist fire once for the whole batch.
800
+ setMany(values, o) {
801
+ 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
804
+ },
805
+ reset() { if (assembled && !destroyed) spinReset(resetBtn); later(resetAll); },
806
+ // Whole-panel state — values + UI as a JSON-safe object (the standard toJSON hook). Before ready there
807
+ // 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
810
+ fromJSON(state, o) {
811
+ if (!state || typeof state !== "object") return;
812
+ 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); });
815
+ },
816
+ // 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")); },
820
+ // 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)); },
824
+ 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
826
+ // Teardown. Safe to call before ready resolves — assemble() sees the flag and bails.
827
+ destroy() {
828
+ if (destroyed) return;
829
+ destroyed = true;
830
+ releaseKey();
831
+ closeActivePopover(panel); // page-wide singletons: close only what's anchored here
832
+ hideHintNow(panel);
833
+ for (const fn of cleanups.splice(0)) { try { fn(); } catch {} }
834
+ listeners.clear();
835
+ if (liftSlot) { liftSlot.remove(); liftSlot = null; }
836
+ if (topFloating === panel) topFloating = null;
837
+ panel.dataset.twDestroyed = ""; // onLive listeners release on their next event by this mark
838
+ panel.remove();
839
+ },
840
+ } 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.
843
+ // 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;
849
+ if (!images) imagesReady = true;
850
+ const imagesIn = images && images.then(restoreImages);
851
+ const lazy = ensureForMetas(metas);
852
+ if (lazy) {
853
+ // A failed chunk or a throwing build leaves the panel unbuildable: queued calls dropped, later calls refused,
854
+ // ready rejects. A partway build must not leave live controls behind an inert API: release what it wired.
855
+ 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
861
+ throw e;
862
+ };
863
+ 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
865
+ api.ready.catch(() => {}); // a handled fork: no unhandled-rejection noise
866
+ }
867
+ else { assemble(); api.ready = panel.ready = imagesIn ? imagesIn.then(() => api) : Promise.resolve(api); }
868
+ return api as Panel;
869
+ }