From 2704500fe00cc01ac72844e40ddb35484534b0a6 Mon Sep 17 00:00:00 2001 From: Ryan Kiley Date: Sun, 11 Oct 2026 17:34:14 -0700 Subject: [PATCH] Add silent writes, presets: false and persist: false Three options a host needs once a panel is part of an app. { silent: true } on set, setMany and fromJSON skips the on() listeners, so a mirrored change doesn't echo back, while conditionals, persistence and undo still run. presets: false keeps persistence without the Presets menu, and a control's persist: false leaves it out of the saved session. A folder that should start closed already has a way, fromJSON({ ui: { folders: { shadow: true } } }), now documented on the Panel API page. test/panel-options covers it. --- site/pages/panel-api.mjs | 14 ++++++-- src/tweaks/panel.ts | 45 ++++++++++++++---------- src/tweaks/schema.ts | 3 +- src/tweaks/types.ts | 24 ++++++++++--- test/panel-options.test.mjs | 70 +++++++++++++++++++++++++++++++++++++ 5 files changed, 131 insertions(+), 25 deletions(-) create mode 100644 test/panel-options.test.mjs diff --git a/site/pages/panel-api.mjs b/site/pages/panel-api.mjs index 4934724..fb5901a 100644 --- a/site/pages/panel-api.mjs +++ b/site/pages/panel-api.mjs @@ -49,7 +49,11 @@ export const examples = [ fire, the UI follows), and panel.reset() restores every default, the same thing the toolbar's reset button does. To move several at once, hand panel.setMany({ key: v, "folder.child": w }) a flat map: it applies the - whole batch and notifies once, not once per key (the shuffle below sets two). Each + whole batch and notifies once, not once per key (the shuffle below sets two). Pass + { silent: true } as the last argument to set, + setMany or fromJSON to apply a value without calling your + on() listeners, for mirroring a change the host already made so it + doesn't echo back; conditional controls, persistence and undo still follow it. Each control resets on its own too: double-click its label (or a slider's readout), hold it on touch, or press ⌥+Backspace with focus inside it.

The reverse, panel.changes(), is a flat map of every value that has @@ -99,6 +103,8 @@ export const examples = [ plus UI state (which folders are collapsed, which tab is open), to a plain object, separate from the localStorage presets. Persist it your own way: a file, a URL, a server. panel.fromJSON(state) restores it (unknown paths skipped). + It takes part of one too: panel.fromJSON({ ui: { folders: { shadow: true } } }) + right after tweaks() starts that folder closed, by dotted path. Tweak things, collapse the folder, Save, change more, then Load to snap back.

`, target: ` @@ -213,7 +219,10 @@ export const examples = [ to localStorage (reload this page and the panel comes back as you left it) and unlocks presets: a presets menu appears in the toolbar, and savePreset / loadPreset / - deletePreset / presets() drive the same store from code.

`, + deletePreset / presets() drive the same store from code. + presets: false keeps the saving and leaves the menu out. A control + with persist: false is left out of the saved session, so it opens on its + schema value every load (a “playing” toggle that should start off).

`, target: `
@@ -334,6 +343,7 @@ export const examples = [ OptionDoes themetoken overrides; see Theming persistlocalStorage key (or true to key by panel name); enables presets + presetsfalse keeps persist's saving without the Presets menu filteradds a fuzzy search toggle to the toolbar (the field swaps in for the title); it needs the toolbar, so it is ignored with toolbar: false floatingstart floated: true or { x, y } draggableheader dragging, on by default; false pins diff --git a/src/tweaks/panel.ts b/src/tweaks/panel.ts index ee7bc9d..2cb0fde 100644 --- a/src/tweaks/panel.ts +++ b/src/tweaks/panel.ts @@ -20,7 +20,7 @@ import type { TabsControl } from "./controls/tabs.js"; type Target = Record; /** A valued control's wiring: where its value lives (target + key), its set/get, the pair * reset restores (the authored value and the form it opened on), and its dotted path. */ -interface Entry { target: Target; key: string; set: Control["set"]; get: Control["get"]; raw: unknown; def: unknown; path: string[] } +interface Entry { target: Target; key: string; set: Control["set"]; get: Control["get"]; raw: unknown; def: unknown; path: string[]; persist: boolean } /** A values snapshot — params minus `_last`, as JSON round-trips it (persist, presets, * undo, toJSON). A parsed JSON blob: its leaves are whatever the controls, or a * hand-edited store, hold. */ @@ -38,6 +38,7 @@ export function tweaks(name: string, schema: Schema, opts: TweaksOptions = {}): const entries: Entry[] = []; // { target, key, set, get, def, path } — flattened across folders, for reset + persist const subTrees = new Set(); // the folder/tabs params sub-objects — set() refuses to overwrite one (doing so orphaned every child entry silently) const listeners = new Set<(p: Params, last?: string) => void>(); + const own = new Set<(p: Params, last?: string) => void>(); // the panel's own listeners (undo, conditionals), also in `listeners`: all a silent write calls const cleanups: Array<() => void> = []; // every global attachment (document/window listeners, pending timers) registers its release here for destroy() let destroyed = false; // flipped by destroy(): assemble() bails, the mutating API methods go silent let assembled = false; // flipped at the end of assemble() — until then the controls don't exist @@ -75,15 +76,23 @@ export function tweaks(name: string, schema: Schema, opts: TweaksOptions = {}): // The live values save to localStorage, debounced. destroy() flushes a pending write // rather than dropping it — the last edit before a teardown (an SPA route change right // after a tweak) used to vanish with the timer. + const atPath = (obj: Snapshot | null, path: string[]): unknown => path.reduce((o, k) => (o == null ? undefined : o[k]), obj); + // A control with `persist: false` stays out of what is saved, and out of what is restored + // (a value stored before the flag was set must not come back). + const unsaved = (snap: Snapshot | null) => { + for (const e of entries) if (!e.persist) { const o: any = atPath(snap, e.path.slice(0, -1)); if (o && typeof o === "object") Reflect.deleteProperty(o, e.key); } // Reflect: a key a stored value can't drop (an array's length) is left, not thrown + return snap; + }; const persist = persistKey ? (() => { let saveT = 0; - const save = () => { saveT = 0; writeStore(persistKey, snapshot()); }; + const save = () => { saveT = 0; writeStore(persistKey, unsaved(snapshot())); }; cleanups.push(() => { if (saveT) { clearTimeout(saveT); save(); } }); return () => { clearTimeout(saveT); saveT = setTimeout(save, 150); }; })() : () => {}; // Each listener runs isolated: a throwing on() callback (or internal listener) - // can't break the others, skip persist(), or bubble back out through set(). - const notify = () => { listeners.forEach((fn) => { try { fn(params, params._last); } catch (e) { console.error("[tweaks] listener threw:", e); } }); if (!destroyed) persist(); }; // a listener may destroy() the panel: the flush cleanup already ran, so no new save may be armed behind it + // can't break the others, skip persist(), or bubble back out through set(). `last` is + // read once, so a write one listener makes can't change the key the rest are told. + const notify = (silent?: boolean, last = params._last) => { (silent ? own : listeners).forEach((fn) => { try { fn(params, last); } catch (e) { console.error("[tweaks] listener threw:", e); } }); if (!destroyed) persist(); }; // a listener may destroy() the panel: the flush cleanup already ran, so no new save may be armed behind it // Push a value into a control and mirror what it actually took back onto params — the // one write path behind restores and set(). A function/symbol/bigint is no control value // (the list control stores any value as-is, and JSON can't carry those: every snapshot @@ -134,7 +143,7 @@ export function tweaks(name: string, schema: Schema, opts: TweaksOptions = {}): const resetBtn = makeResetBtn(resetAll); // Presets button appears only when persistence is on (presets share its storage). let presetsBtn: HTMLButtonElement | null = null; - if (persistKey) { + if (persistKey && opts.presets !== false) { // opts.presets:false keeps the saving, without the menu presetsBtn = toolbarBtn("", ICON_PRESETS, "Presets"); presetsBtn.setAttribute("aria-haspopup", "menu"); presetsBtn.setAttribute("aria-expanded", "false"); toolbar.append(presetsBtn); @@ -318,7 +327,7 @@ export function tweaks(name: string, schema: Schema, opts: TweaksOptions = {}): // that window queue and replay after the build instead.) if (hasOwn(target, m.key)) ctrl.set(target[m.key]); target[m.key] = ctrl.get(); - const entry = { target, key: m.key, set: ctrl.set, get: ctrl.get, raw: m.value, def, path: [...basePath, m.key] }; + const entry = { target, key: m.key, set: ctrl.set, get: ctrl.get, raw: m.value, def, path: [...basePath, m.key], persist: m.persist !== false }; entries.push(entry); wireReset(ctrl.el, entry); } registerCond(ctrl.el, m); @@ -363,9 +372,8 @@ export function tweaks(name: string, schema: Schema, opts: TweaksOptions = {}): // elsewhere — must cost only its own value. Unguarded, one throw abandoned every // remaining entry AND skipped the notify/persist below, leaving the panel silently // half-restored with listeners none the wiser. ── - const atPath = (obj: Snapshot, path: string[]): unknown => path.reduce((o, k) => (o == null ? undefined : o[k]), obj); const owned = new Set(metas.map((m) => m.key)); // top-level control, folder and tabs keys — everything else on params is a bag key - const applySnapshot = (snap: Snapshot | null, fire = true) => { + const applySnapshot = (snap: Snapshot | null, silent?: boolean) => { if (!snap || typeof snap !== "object") return; for (const e of entries) { const v = atPath(snap, e.path); if (v === undefined) continue; @@ -376,7 +384,7 @@ export function tweaks(name: string, schema: Schema, opts: TweaksOptions = {}): // so a restore (undo, a preset, fromJSON) brings their values back too. A key params // doesn't hold is still skipped, like a control path that no longer exists. for (const k of Object.keys(snap)) if (!owned.has(k) && k !== "_last" && hasOwn(params, k) && !isReservedKey(k) && !notAValue(snap[k])) { try { params[k] = snap[k]; } catch (err) { console.error(`[tweaks] restoring "${k}" failed — value skipped:`, err); } } // a getter-only bag key costs only itself, like a control that refuses - params._last = undefined; if (fire) notify(); + params._last = undefined; notify(silent); }; // ── Named presets (opt-in via opts.persist) — snapshots under ":presets". The API // layer below guards the key + name and queues these in the lazy window. ── @@ -606,7 +614,7 @@ export function tweaks(name: string, schema: Schema, opts: TweaksOptions = {}): }; const record = () => { if (applyingHistory) return; clearTimeout(histTimer); histTimer = setTimeout(commit, 350); }; const flush = () => { if (histTimer) { clearTimeout(histTimer); commit(); } }; // commit a pending edit first, so ⌘Z right after a change still undoes it - listeners.add(record); + listeners.add(record); own.add(record); const restore = (idx: number) => { applyingHistory = true; applySnapshot(history[idx]); applyingHistory = false; histIdx = idx; }; const undo = () => { flush(); if (histIdx > 0) restore(histIdx - 1); }; const redo = () => { flush(); if (histIdx < history.length - 1) restore(histIdx + 1); }; @@ -652,12 +660,12 @@ export function tweaks(name: string, schema: Schema, opts: TweaksOptions = {}): } if (revealed) requestReflow(); // a control built behind a false render condition measured 0×0 (blank canvas/SVG, a stuck pill) — once shown, let it re-measure, as a tab page does }; - listeners.add(applyConditionals); applyConditionals(); + listeners.add(applyConditionals); own.add(applyConditionals); applyConditionals(); } // Restore last session, notifying: on the lazy path assemble runs after tweaks() // returned, so a host that already subscribed must hear the restored values (on the // synchronous path nobody is subscribed yet, so the notify is free). - if (persistKey) applySnapshot(readStore(persistKey)); + if (persistKey) applySnapshot(unsaved(readStore(persistKey))); if (destroyed) return; // a listener may have destroyed the panel on that notify — no listener may register on, and no call may reach, a torn-down panel undoApi?.arm(); watchResize(); assembled = true; @@ -717,15 +725,15 @@ export function tweaks(name: string, schema: Schema, opts: TweaksOptions = {}): const api = { el: panel, params, on(fn) { if (destroyed) return () => {}; listeners.add(fn); return () => listeners.delete(fn); }, - set(key, v) { const k = String(key); later(() => { if (applySet(k, v)) notify(); }); }, // the key resolves at the call: one that can't stringify throws here, not out of the replay + set(key, v, o) { const k = String(key), silent = o?.silent; later(() => { if (applySet(k, v)) notify(silent); }); }, // the key resolves at the call: one that can't stringify throws here, not out of the replay // Batch write — apply a flat map of (possibly dotted) keys, e.g. // setMany({ "shadow.radius": 28, blur: 48 }), firing listeners + persist ONCE for the // whole batch rather than per key as a set() loop would. Same path resolution and // no-op / reserved / bad-path guards as set(); unrecognised keys warn-and-skip. - setMany(values) { + setMany(values, o) { if (values == null || typeof values !== "object") return; - const batch = Object.entries(values); // read now, so a host mutating the object after the call can't change what a lazy-window replay applies - later(() => { let changed = false; for (const [k, v] of batch) { try { if (applySet(k, v)) changed = true; } catch (e) { console.error(`[tweaks] setMany("${k}") failed — key skipped:`, e); } } if (changed) notify(); }); // per-key isolation: a bag value the change check can't compare (circular) costs only its key, and the rest still notify + const batch = Object.entries(values), silent = o?.silent; // read now, so a host mutating the object after the call can't change what a lazy-window replay applies + later(() => { let changed = false; for (const [k, v] of batch) { try { if (applySet(k, v)) changed = true; } catch (e) { console.error(`[tweaks] setMany("${k}") failed — key skipped:`, e); } } if (changed) notify(silent); }); // per-key isolation: a bag value the change check can't compare (circular) costs only its key, and the rest still notify }, reset() { if (assembled && !destroyed) spinReset(resetBtn); later(resetAll); }, // Whole-panel state — values + UI (open folders, active tabs) as a plain JSON-safe @@ -737,10 +745,11 @@ export function tweaks(name: string, schema: Schema, opts: TweaksOptions = {}): // change, and outside undo so a ⌘Z reverts values without thrashing folders/tabs). toJSON() { return destroyed ? { values: {}, ui: {} } : { values: snapshot(), ui: collectUI() }; }, changes() { return destroyed ? {} : changed(); }, // empty in the lazy window too — no entries yet - fromJSON(state) { + fromJSON(state, o) { if (!state || typeof state !== "object") return; + const silent = o?.silent; 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, like setMany (one level): a host re-pointing a key after the call can't change what a lazy-window replay applies - later(() => { if (values) applySnapshot(values); applyUI(ui); }); + later(() => { if (values) applySnapshot(values, silent); applyUI(ui); }); }, // Live theming — re-applies --tw-* vars to the panel (and future popovers). Clears // the prior theme first, so setTheme(null) reverts to the default monochrome look. diff --git a/src/tweaks/schema.ts b/src/tweaks/schema.ts index 386ccd1..490a609 100644 --- a/src/tweaks/schema.ts +++ b/src/tweaks/schema.ts @@ -22,7 +22,7 @@ interface Meta { type: string; key: string; label: string; value?: any; // the control's own value shape, host-authored; each constructor checks what it accepts // Per-control options (ControlOptions), attached by metaFor. - render?: (get: Get) => boolean; disabled?: boolean | ((get: Get) => boolean); hint?: string; + render?: (get: Get) => boolean; disabled?: boolean | ((get: Get) => boolean); hint?: string; persist?: boolean; // Ranges — slider, number, interval, monitor. min?: number; max?: number; step?: number; soft?: boolean; unit?: string; corners?: boolean; // unit: slider / number, display only; corners: the sides control labels its four as corners // Single-select — list, radiogrid. @@ -61,6 +61,7 @@ function metaFor(key: string, value: any, depth = 0): Meta | null { if (typeof value.render === "function") meta.render = value.render; if (value.disabled != null) meta.disabled = value.disabled; if (value.hint != null) meta.hint = String(value.hint); + if (value.persist === false) meta.persist = false; } return meta; } diff --git a/src/tweaks/types.ts b/src/tweaks/types.ts index 1a32777..a1ff848 100644 --- a/src/tweaks/types.ts +++ b/src/tweaks/types.ts @@ -67,6 +67,10 @@ export interface ControlOptions { disabled?: boolean | ((get: Get) => boolean); /** A tooltip revealed by an ⓘ marker beside the label. */ hint?: string; + /** `false` leaves this control out of what `opts.persist` saves, so it opens on its schema + * value every load (presets, undo and `toJSON()` still carry it). On a control, not a + * plain folder object, where `persist` is a child. */ + persist?: boolean; } /** The verbose object forms a schema value can take: `{ type: "…", … }`. */ @@ -147,12 +151,23 @@ export interface Theme { [token: string]: string | number | undefined; } +/** Options for a programmatic write — `set()`, `setMany()`, `fromJSON()`. */ +export interface SetOptions { + /** Apply without calling your `on()` listeners: for mirroring a value the host already + * acted on (another tab, its own state), so the change doesn't echo back. Conditional + * controls, persistence and undo still follow it: it's an undo step like any other. */ + silent?: boolean; +} + /** Panel options (third arg to `tweaks()`). */ export interface TweaksOptions { /** Theme overrides (friendly names or raw `--tw-*`); `null` / omitted = default monochrome. */ theme?: Theme | null; /** Persist values to localStorage + enable presets — a key, or `true` to key by name. */ persist?: string | boolean; + /** With `persist`, `false` keeps saving values but leaves the Presets menu out of the + * toolbar (the presets API still works). On by default. */ + presets?: boolean; /** Add a filter/search field to the toolbar. */ filter?: boolean; /** Start the panel already floated (fixed-positioned): `true` → top-left, or an explicit `{x,y}`. */ @@ -189,7 +204,8 @@ export interface PanelState { ui?: { /** Folder collapse state → `true` when collapsed. Keys are the folder's path with each * segment JSON-pointer-escaped (`.`→`~0`, `~`→`~1`) then joined by `.` — injective, so a - * literal dot in a key can't collide with the nesting separator. Treat keys as opaque. */ + * literal dot in a key can't collide with the nesting separator. For plain keys that's + * the dotted path, so `fromJSON({ ui: { folders: { shadow: true } } })` starts one closed. */ folders?: Record; /** Active tab → the active page's key, under the tabs control's path (escaped as above). */ tabs?: Record; @@ -209,12 +225,12 @@ export interface Panel { /** Set a control's value programmatically. Nested controls take a dotted path * ("folder.child", "tabs.page.child"); a bare key also reaches a nested control * when it's unambiguous. A key matching no control is stored on `params` as-is. */ - set(key: string, value: unknown): void; + set(key: string, value: unknown, options?: SetOptions): void; /** Set several controls at once — a flat map of keys (dotted for nested controls), e.g. * `setMany({ "shadow.radius": 28, blur: 48 })`. Resolves each key like `set()`, then fires * listeners + persists ONCE for the whole batch, where N separate `set()` calls would fire * N times. Unknown / ambiguous / reserved keys warn and are skipped. */ - setMany(values: Record): void; + setMany(values: Record, options?: SetOptions): void; /** Reset every control to its default (or run `opts.onReset`). */ reset(): void; /** Serialize the whole panel — every control value plus UI state (collapsed folders, @@ -233,7 +249,7 @@ export interface Panel { * `{ ui }` to restore chrome only. The value restore is undoable (one history step); the * UI restore is intentionally outside undo, so a Cmd-Z reverts values without thrashing * folders/tabs. */ - fromJSON(state: PanelState): void; + fromJSON(state: PanelState, options?: SetOptions): void; /** Re-theme the panel live; `null` reverts to the default. */ setTheme(theme?: Theme | null): void; /** Retitle the panel live — the header and the copy toast follow. The name it was built diff --git a/test/panel-options.test.mjs b/test/panel-options.test.mjs new file mode 100644 index 0000000..164e23e --- /dev/null +++ b/test/panel-options.test.mjs @@ -0,0 +1,70 @@ +/* Panel options a host reaches for once a panel is part of an app: a write that doesn't + * echo to on() ({ silent }), saving values without the Presets menu (presets: false), and + * a control left out of the saved session (persist: false). Runs against the built + * single-file bundle under jsdom. */ +import test from "node:test"; +import assert from "node:assert/strict"; +import "./_setup-dom.mjs"; + +const { tweaks } = await import(new URL("../dist/tweaks.js", import.meta.url)); +const stored = (k) => JSON.parse(localStorage.getItem(k)); + +test("a silent set, setMany or fromJSON skips on() but still runs conditionals, persistence and undo", async () => { + localStorage.clear(); + const p = tweaks("Silent", { live: true, time: { type: "slider", value: 450, min: 300, max: 1725, step: 15, render: (get) => !get("live") }, zoom: [100, 25, 300, 1] }, { persist: true, undo: true }); + let calls = 0; p.on(() => { calls++; }); + p.set("live", false, { silent: true }); + assert.ok(!p.el.querySelector(".tw-slider-wrap").classList.contains("tw-cond-hidden"), "the conditional still follows"); + p.setMany({ time: 600 }, { silent: true }); + p.fromJSON({ values: { zoom: 90 } }, { silent: true }); + assert.equal(calls, 0, "no echo"); + await new Promise((r) => setTimeout(r, 200)); // past the persist debounce + assert.equal(stored("tw:Silent").zoom, 90, "still saved"); + p.undo(); // flushes the pending history step the silent writes made + assert.equal(p.params.zoom, 100, "silent writes are in the undo history"); + p.destroy(); localStorage.clear(); +}); + +test("a silent write made inside on() doesn't change the key the other listeners hear", () => { + const p = tweaks("Last", { a: [0, 0, 10, 1], b: [0, 0, 10, 1] }); + p.on((v, last) => { if (last === "a") p.set("b", v.a, { silent: true }); }); + const heard = []; p.on((v, last) => heard.push(last)); + p.set("a", 7); + assert.deepEqual([heard, p.params.b], [["a"], 7]); + p.destroy(); +}); + +test("presets: false keeps persistence without the Presets menu", () => { + localStorage.clear(); + const p = tweaks("NoPresets", { blur: [24, 0, 100, 1] }, { persist: true, presets: false }); + assert.equal(p.el.querySelector('[aria-label="Presets"]'), null, "no menu"); + assert.equal(p.savePreset("a"), true, "the presets API still works"); + p.set("blur", 40); p.destroy(); // destroy() flushes the pending save + assert.equal(stored("tw:NoPresets").blur, 40, "values still saved"); + localStorage.clear(); +}); + +test("persist: false keeps a control out of what is saved and what is restored", () => { + localStorage.clear(); + localStorage.setItem("tw:Session", JSON.stringify({ media: true, name: "Ana", look: { media2: true } })); + const off = { type: "checkbox", value: false, persist: false }; + const p = tweaks("Session", { media: off, name: "Maya", look: { media2: off }, fx: { glow: { on: off } } }, { persist: true }); // fx: a folder the stored session predates + assert.deepEqual([p.params.media, p.params.look.media2, p.params.name], [false, false, "Ana"], "stored values for it don't come back; the rest do"); + p.setMany({ media: true, name: "Bo", "look.media2": true }); + assert.equal(p.toJSON().values.media, true, "toJSON still carries it"); + p.destroy(); + assert.deepEqual(stored("tw:Session"), { name: "Bo", look: {}, fx: { glow: {} } }, "not saved, nested either"); + localStorage.clear(); +}); + +test("a stored value that can't drop a persist: false key doesn't break the build", () => { + localStorage.setItem("tw:Odd", JSON.stringify({ F: [true] })); // a hand-edited store: the folder saved as an array, whose length can't be deleted + let p; assert.doesNotThrow(() => { p = tweaks("Odd", { F: { length: { type: "checkbox", value: false, persist: false } } }, { persist: true }); }); + p.destroy(); localStorage.clear(); +}); + +test("a plain folder object's persist is its child, not an option", () => { + const p = tweaks("Child", { group: { persist: false, size: [2, 0, 4, 1] } }); + assert.equal(p.params.group.persist, false, "built as a checkbox, as before"); + p.destroy(); +});