Tweakit v0.7.2

The panel API

tweaks() returns synchronously: panel.el to mount, panel.params for live values, and the methods on this page. On the code-split build, lazy controls build behind panel.ready.

Events

panel.on(fn) fires on every change with the params bag and the changed key, and returns an unsubscribe function. Move things and watch the feed.

Change something
const log = target.querySelector(".pa-log");
const lines = [];
const panel = tweaks("Events", {
  radius: [12, 0, 40, 1],
  mode: ["calm", "wild"],
  armed: false,
});
mount.append(panel.el);

panel.on((p, changed) => {
  lines.unshift(changed ? `${changed} → ${JSON.stringify(p[changed])}` : "(reset)");
  log.textContent = lines.slice(0, 8).join("\n");
});

mount is the panel's slot inside the stage; target is the demo surface it controls. In your own page you'd just document.body.append(panel.el).

set, setMany, changes & reset

The panel is just another consumer of its own state: panel.set(key, value) moves a control programmatically (listeners 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). 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 moved off its default, in the same shape (what you paste to bake tuned values into source). ⇧-click the toolbar's copy button to copy that instead of the full snapshot (copying one row or folder has its own section below). panel.setName(name) retitles the panel live; the name it was built with stays its storage key, so saved values and presets survive a rename.

const tile = target.querySelector(".pa-tile");
const panel = tweaks("Remote", {
  hue: [260, 0, 360, 1],
  lift: [0, 0, 40, 1],
});
mount.append(panel.el);

const apply = (p) => {
  tile.style.background = `oklch(0.65 0.2 ${p.hue})`;
  tile.style.transform = `translateY(${-p.lift}px)`;
};
panel.on(apply);
apply(panel.params);

target.querySelector(".pa-shuffle").addEventListener("click", () => {
  panel.setMany({ hue: Math.round(Math.random() * 360), lift: Math.round(Math.random() * 40) });
});
target.querySelector(".pa-restore").addEventListener("click", () => panel.reset());

Copy & paste

A row or folder copies on its own. Press in the space beside it, in the panel's side padding, to pick it (it fills), then press ⌘C (Ctrl on Windows and Linux); pick another and press ⌘V. A row copies its value and pastes one back; a folder copies its values object and pastes it into its own keys, so two folders with the same keys trade values. Try it below: pick Key by pressing beside its title, ⌘C, pick Fill, ⌘V, and the second light takes the first's look.

A paste goes through fromJSON, so it's one undo step, saved and announced like any load, and the band flashes as it lands. A value only lands where it fits, the same type as the control holds (a color for a color, one of a list's options); if nothing fits, the band flashes red and nothing changes. With nothing picked, the keys act on the row holding focus; Escape or a press elsewhere drops the pick, and in a text field or over selected text the keys stay the browser's.

const lights = target.querySelectorAll(".cp-light");
const light = (hue, size, soft) => ({ hue: [hue, 0, 360, 1], size: [size, 40, 120, 1], soft: [soft, 0, 30, 1] });
const panel = tweaks("Lights", { key: light(40, 96, 4), fill: light(220, 60, 18) });
mount.append(panel.el);

const paint = (el, l) => {
  el.style.width = el.style.height = `${l.size}px`;
  el.style.background = `oklch(0.78 0.14 ${l.hue})`;
  el.style.filter = `blur(${l.soft}px)`;
};
const apply = (p) => { paint(lights[0], p.key); paint(lights[1], p.fill); };
panel.on(apply);
apply(panel.params);

Save & load state

panel.toJSON() serializes the whole panel, every value 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.

Save to capture state
const out = target.querySelector(".pa-state");
let saved = null;
const panel = tweaks("Card", {
  blur: [12, 0, 40, 1],
  accent: "#7C5CFF",
  shadow: {
    radius: [16, 0, 64, 1],
    opacity: [0.4, 0, 1, 0.05],
  },
});
mount.append(panel.el);

target.querySelector(".pa-do-save").addEventListener("click", () => {
  saved = panel.toJSON();
  out.textContent = JSON.stringify(saved, null, 2);
});
target.querySelector(".pa-do-load").addEventListener("click", () => {
  if (saved) panel.fromJSON(saved);
});

render, disabled & hint

Any object-form control can carry a render predicate (show/hide on sibling values; the row folds in and out as a folder does), a disabled flag or predicate (gray out + lock), and a hint tooltip behind an ⓘ marker. Flip “Glow” and watch the other two rows.

Glow me
const chip = target.querySelector(".pa-chip");
const panel = tweaks("Conditional", {
  glow: false,
  color: { type: "color", value: "#7C5CFF",
           render: (get) => get("glow"),          // hidden until glow is on
           hint: "Only rendered while glow is on" },
  strength: { type: "slider", value: 36, min: 0, max: 90, unit: "px",
              disabled: (get) => !get("glow") },  // grayed out instead
});
mount.append(panel.el);

const apply = (p) => {
  chip.style.boxShadow = p.glow ? `0 0 ${p.strength}px ${p.color}` : "none";
};
panel.on(apply);
panel.ready.then(() => apply(panel.params));

Undo & redo

Pass { undo: true } and the panel keeps a debounced history: ⌘Z / ⇧⌘Z (Ctrl on Windows and Linux) work while the panel is hovered or focused (so it never hijacks the page's own undo), a continuous drag coalesces into one step, and panel.undo() / panel.redo() drive it from outside.

const card = target.querySelector(".ud-card");
const panel = tweaks("History", {
  width: [160, 60, 260, 1],
  round: [18, 0, 50, 1],
}, { undo: true });
mount.append(panel.el);

const apply = (p) => {
  card.style.width = `${p.width}px`;
  card.style.borderRadius = `${p.round}px`;
};
panel.on(apply);
apply(panel.params);

target.querySelector(".ud-undo-btn").addEventListener("click", () => panel.undo());
target.querySelector(".ud-redo-btn").addEventListener("click", () => panel.redo());

Persistence & presets

{ persist: "key" } saves values, and a floated panel's position, 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. 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). An uploaded image is too big for localStorage, so it goes to IndexedDB under the same key, and a panel that persists one has it back by panel.ready; the other values don't wait for it. With no IndexedDB, an upload saves inline in localStorage when it fits.

 
const tile = target.querySelector(".ps-tile");
const note = target.querySelector(".ps-note");
const panel = tweaks("Presets", {
  hue: [260, 0, 360, 1],
  size: [120, 40, 190, 1],
}, { persist: "tweakit-docs" });
mount.append(panel.el);

const apply = (p) => {
  tile.style.background = `oklch(0.6 0.18 ${p.hue})`;
  tile.style.width = `${p.size}px`;
  tile.style.height = `${p.size}px`;
};
panel.on(apply);
apply(panel.params);

target.querySelector(".ps-save").addEventListener("click", () => {
  panel.savePreset("mine");
  note.textContent = `presets: ${panel.presets().join(", ")}`;
});
target.querySelector(".ps-load").addEventListener("click", () => {
  note.textContent = panel.loadPreset("mine") ? "loaded “mine”" : "no preset saved yet";
});

Floating & draggable

Every panel is draggable by its header: an inline panel lifts into a floating layer on first drag (draggable: false pins it). Pass floating: true or { x, y } to start it floated, like a classic debug overlay, inset x and y px from the top-left. A floating panel never runs off the window: it caps at the viewport with a gap of y at the far edge (8px once dragged) and scrolls, so a tall panel reaches its last folder by scrolling. Its header stays pinned, and the open folder's header pins under it until the next folder takes its place. A pinned panel ignores a position persist saved; a draggable one restores it. With the title focused, ⌥+arrow keys move the panel 10px at a time (⇧: 50px), the keyboard's drag. A click anywhere on the header outside the toolbar folds the panel. A press becomes a drag only after a few pixels, and Escape mid-drag puts the panel back.

An open editor pane or the presets menu drags by its edge, so you can move it off what it covers. It keeps that spot until it closes. In a pane's field, Escape restores what you typed over and a second Escape closes the pane. Escapes the panel handles stop at the document, so window listeners don't see them.

The placement, z-index and shadow sit at plain .twk-panel specificity, so a class of your own loaded after the stylesheet overrides them: top, right and left for the inset, --twk-float-gap for the far-edge gap or max-height for the whole cap, and z-index and box-shadow as usual.

const btn = target.querySelector(".fl-spawn");
let floater = null;
btn.addEventListener("click", () => {
  if (floater) { floater.el.remove(); floater = null; btn.textContent = "Spawn a floating panel"; return; }
  floater = tweaks("Floating", {
    note: "Drag my header",
    hue: [280, 0, 360, 1],
  }, { floating: { x: 24, y: 96 } });
  document.body.append(floater.el);
  btn.textContent = "Dismiss it";
});

Teardown

panel.destroy() removes the panel, releases every global listener and closes any open popover; the API goes inert. Call it when the host view unmounts.

Standalone controls

Sometimes you want a control, not a panel: a slider in your own settings form, or the color picker behind a swatch your page already draws. mountControl(host, value, opts) builds one control from any schema value (shorthand or verbose) straight into an element of yours and returns { el, set, get, destroy, ready }. Unlike a panel there is no params bag: onChange gets the value, and set() fires it too, so your own state stays the single source. label: "" suppresses the label when the host page has one.

createColorPicker({ value, mode, onChange }) is the picker body alone (the OKLCH plane, strips and channel fields, no trigger row), for a swatch of your own. Append el wherever you like (a popover, a sidebar), call reflow() after showing it, and read get() back in whatever notation the mode dropdown is set to: hex gives #RRGGBB; the wide-gamut modes give a CSS color like oklch(…), which a native <input type="color"> won't accept; feed that only hex. Both handles exist synchronously; on the code-split build a lazy control fills its el in behind ready.

gradientCss(value, direction?) and gradientStops(value) turn a gradient control's value into CSS, easing expanded and blend space honored; the editor's own preview draws through the same function. Pure string work: no control needs to be mounted. See Gradient.

import { mountControl, createColorPicker } from "tweakit";

const blur = mountControl(form, [12, 0, 40, 1], {
  key: "blur", label: "",                      // the form already labels it
  onChange: (v) => card.style.filter = "blur(" + v + "px)",
});
blur.set(20);                                   // moves the slider AND runs onChange

const picker = createColorPicker({
  value: "oklch(0.7 0.15 200)", mode: "oklch",
  onChange: (c) => swatch.style.background = c,
});
myPopover.append(picker.el);
swatch.addEventListener("click", () => { openPopover(); picker.reflow(); });

import { gradientCss } from "tweakit";
panel.on((p) => hero.style.background = gradientCss(p.ramp, 135));

The rest of the options

The full third argument, for reference:

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
floatingstart floated: true or { x, y }
draggableheader dragging, on by default; false pins
toolbarfalse for a bare panel (no copy / reset / presets)
undodebounced undo/redo history
onResetreplace the default reset behavior
stringsthe words it shows and says, by key, over the English defaults; see below
shadowtrue builds the panel in a shadow root, out of reach of page CSS such as #app button { … }; see below

Its words

Every word a panel shows or says (its buttons and tooltips, its sub-editors' labels, what a screen reader hears after a copy or a paste) has a key in strings, with its English default in the Strings type. Pass the ones to change: to translate the panel, or to rename the chrome. A key with a {placeholder} takes a string using it, or a function given the values, for plurals. mountControl, createColorPicker and enhance(root, { strings }) take it too.

tweaks("Réglages", schema, { strings: {
  presets: "Préréglages", reset: "Réinitialiser",
  presetSaved: "Préréglage {name} enregistré",
  pasted: (v) => (v.n === 1 ? "1 valeur collée" : `${v.n} valeurs collées`),
} });

Shielded from page CSS

A page's rules aimed through a wrapper (#app button { padding: 20px }, a .prose container, CMS styles) reach a panel's own buttons, fields and labels. shadow: true builds the panel in a shadow root instead, and its panes in a second root on <body>, where none of that applies. panel.el is then the host, a <twk-host>: append it as usual. The panel still inherits what any element does (the host's font size, color, direction). Theme it with --twk-* tokens on the host itself (.twk-host { --twk-radius: 4px }), which its panes take too, or with theme; your own rules for the kit's classes (.twk-panel { … }) come along as well. Its styles are the kit's rules from the page's own tweakit/css; a page without it links tweaks.css from beside the module, or the stylesheet at shadow: { href }. mountControl and createColorPicker take shadow too.