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 = [
| Option | Does |
theme | token overrides; see Theming |
persist | localStorage key (or true to key by panel name); enables presets |
+
presets | false keeps persist's saving without the Presets menu |
filter | adds 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 |
floating | start floated: true or { x, y } |
draggable | header 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();
+});