npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

tweaks

v0.6.0

Published

A GUI library with an immediate mode API to inspect, tweak and save values back to your code

Readme

Tweaks

A JavaScript GUI library to easily tweak values and save changes back to your code.

Think of it as a mix of lil-gui and Tweakpane with the immediate mode API of Dear ImGui.

I made this to fix a few pain points with existing JavaScript GUI libraries:

  • Reactive by default: the data is the single source of truth. The GUI always reflects the current values, with no bindings or callbacks to keep in sync.
  • UI state persists across reloads: folder open and close states, panel positions, and scroll positions are all remembered automatically.
  • Save changes back to your code: tweak values in the browser, hit save, and the tweaks server rewrites the original source in place. No more copy pasting values from the GUI into your code.
  • Docked mode: wraps the page in an iframe so the GUI never covers what you are working on.

Getting started

Tweaks saves by rewriting your source files, so it needs a local server running alongside your app. Install the package, then pick the setup that matches your project:

npm install tweaks

Within an existing Vite server setup

Vite projects need no separate server. The plugin mounts the tweaks routes on the vite dev server origin and injects the docked mode boot tag into served html, so saving and docked mode work with no further setup:

// vite.config.js
import { defineConfig } from 'vite';
import tweaks from 'tweaks/vite';

export default defineConfig({
  plugins: [tweaks()],
});

Saves run the same codemod as npx tweaks and read the same config file. The plugin is dev only, builds ship untouched. SSR frameworks need one manual tag, see Docked mode.

Without a Vite server

Start the tweaks server in your project folder:

npx tweaks              # default port 7348
npx tweaks --port 3000  # custom port

It holds the tweaks store and writes each SAVE back into your source, see Saving to source. It does not serve your app, so run your own dev server alongside it.

The panel finds the server on its own, whatever port it runs on. It checks the page's own origin first, then scans localhost around the default port, and any server it reaches points it at every other running one. When several servers run at once, the page connects to the one whose scan.roots hold its own calls, so each project reaches its own server. For a different origin or path set GUI.settings.serverEndpoint = 'https://host/__tweaks__/'. CORS is open by default, so a page served from localhost on any port may save to the project, and allowedOrigins in the config file narrows that to yours.

Manage running servers:

npx tweaks list              # list running tweaks servers
npx tweaks kill              # stop every tracked server
npx tweaks kill --port 3000  # stop one
npx tweaks --version         # print the installed version

With no tweaks server reachable the panel goes offline. Unsaved edits and panel state still survive reloads through the sessionStorage overlay, but there is no commit target, see Persistence.

Both setups read an optional config file at your project root, mainly to narrow which folders a save searches. See Saving changes for that file, how values reach your source, and the version and undo safety nets.

Creating Tweaks

Tweaks are values that sync with the GUI and can be saved back to your code after a change.

import { createTweaks, Int } from 'tweaks';
import { GUI } from 'tweaks/gui';

const particlesParams = createTweaks('Particles', {
  count: Int(100, 10, 500), // Force an Int with a 10 to 500 range
  speed: 1.5,
  visible: true,
  color: '#4D9CFF',
});

function refreshParticles() {
  // Particles code
}

GUI.render(() => {
  // Render particlesParams tweaks and call refreshParticles when a value changes
  if (GUI.Tweaks(particlesParams)) refreshParticles();
});

Registering tweaks

Create a group with a schema:

const myTweaks = createTweaks('My Tweaks', {
  speed: Float(1, 0, 10, 0.1),
  count: Int(5, 0, 100),
  name: Str('default'),
  active: Bool(true),
  tint: Color('#ff6600'),
});

Inferred types

A schema value can also be a bare literal, and Tweaks infers the type from it:

const myTweaks = createTweaks('My Tweaks', {
  speed: 1.5,      // number -> Float, bounds auto-inferred
  active: true,    // boolean -> Bool
  size: '320px',   // a number with a unit -> Num
  tint: '#ff6600', // a color string -> Color
  name: 'default', // any other string -> Str
});

A number infers Float, a boolean Bool, and a string infers Num when it is a number with a unit (320px, 1.5em, 50%), Color when it is a color (3, 4, 6, or 8 digit hex, or a complete rgb(...), rgba(...), hsl(...), hsla(...)), otherwise Str, so a plain numeric string like '42' stays a Str. A nested plain object becomes an auto-folder (see below), and a value with no matching type, like a function, is left out. Pass an explicit factory such as Float(1.5, 0, 10, 0.1) when you want specific bounds, a step, or a forced type. The same inference applies to group.add('size', '320px'), and the inferTweak(value) export runs it programmatically, returning the inferred descriptor or null for a value with no matching type.

Re-registering

Registering a key that already exists refreshes its bounds, options, and order. A default that differs from the saved baseline is read as a source edit: the baseline adopts the new literal, and the value follows it unless an unsaved edit is seated. A different type resets the prop to the new literal.

A host that rebuilds from parameters it captured at load declares stale defaults, the old literal rather than the source. Wrap that rebuild in preserveTweakState(fn): inside the scope createTweaks, add(), and the per-type methods refresh registration metadata and return the live value without moving the baseline or resetting the type. The scope is synchronous, nests, and restores the outer mode when fn throws.

import { createTweaks, preserveTweakState } from 'tweaks';

const g = createTweaks('g', { x: 1 });
// ... the user edits x to 9 and saves, the source now holds 9 ...
preserveTweakState(() => g.add('x', 1)); // returns 9, saved stays 9
g.add('x', 1);                         // the literal the save replaced moves nothing either
g.add('x', 4);                         // a real source pass adopts 4

A re-entry the host cannot wrap, a scope refresh or a conditional call that re-runs your module, still carries the literal the panel's own save replaced. The first save of a value records that literal on the page, so a bare re-registration with it refreshes metadata and adopts nothing, while any other literal stays a source edit. The record lives in the page only; a reload loads the saved module and needs none.

Built-in types

| Type | Factory | Parameters | Widget | |------|---------|------------|--------| | Float | Float(default, min, max, step) | All optional | Number input + slider | | Int | Int(default, min, max) | All optional, step forced to 1 | Number input + slider | | Num | Num(default, min, max, step) | All optional, default can be a string with unit | Measure input + slider | | Str | Str(default) | Optional | Text input | | Bool | Bool(default) | Optional | Checkbox | | Color | Color(default) | Optional | Color picker | | Select | Select(options, default) | options: array or object; default optional | Dropdown |

Auto-inferred bounds

When Float, Int, or Num are called with only a default value (no min/max/step), reasonable bounds are auto-inferred:

| Default value | Min | Max | Step (Float/Num) | Step (Int) | |---------------|-----|-----|-----------------|------------| | 0 | 0 | 100 | 0.1 | 1 | | up to 1 | 0 | value x 3 | 0.001 | 1 | | up to 10 | 0 | value x 3 | 0.01 | 1 | | up to 100 | 0 | value x 3 | 0.1 | 1 | | above 100 | 0 | value x 3 | 1 | 1 | | negative | -abs(value) x 3 | 0 | (same as above) | 1 |

A fractional default narrows the step to its own precision, up to six decimals, so Float(150.25) steps by 0.01 rather than 1.

Negative defaults flip the range to end at 0:

Float(50)         // slider 0 to 150, step 0.1
Float(-150)       // slider -450 to 0, step 1
Float(0.5)        // slider 0 to 1.5, step 0.001
Float(5)          // slider 0 to 15, step 0.01
Float(500)        // slider 0 to 1500, step 1
Float(50, 0, 200) // explicit bounds, step inferred when omitted

Num (number with unit)

Num works like Float but accepts a unit suffix (px, %, em, deg, etc.). The value includes the unit string, the slider operates on the numeric part.

Num('50px')         // value = '50px', slider 0 to 150
Num('100%')         // value = '100%', slider 0 to 300
Num(50)             // value = 50, no unit until user types one
Num('50px', 0, 200) // explicit bounds

The unit can be changed by typing in the input field (e.g., type 50em to switch from px to em). Removing the unit (type just 50) clears it. The slider preserves the current unit.

Select

Select renders a dropdown. Pass an array or object as options; the second argument is the default value.

// Array: the stored value is the selected element. The default can be the element or an index.
Select(['low', 'medium', 'high'])          // default 'low', the first element
Select(['low', 'medium', 'high'], 'medium') // default 'medium'
Select(['low', 'medium', 'high'], 1)        // an index also works, it resolves to 'medium'

// Object: keys are stored values, object values are display names
Select({ idle: 'Idle', walk: 'Walk', run: 'Run' })       // default 'idle'
Select({ idle: 'Idle', walk: 'Walk', run: 'Run' }, 'walk') // default 'walk'

Auto-folders

Tweaks are organized into collapsible folders via two mechanisms that can be combined freely.

Nested plain objects in the schema create one level of folder:

const params = createTweaks('Config', {
  speed: Float(1, 0, 10),
  physics: {
    gravity: Float(9.8, 0, 20),
    friction: Float(0.5, 0, 1),
  },
  name: Str('hello'),
});

params.physics.gravity  // 9.8
params.physics.friction // 0.5

Slash-separated keys create deep folder hierarchies of any depth:

const params = createTweaks('Config');

params.Float('physics/advanced/threshold', 0.1);
params.Float('physics/advanced/iterations', 10);
params.Float('physics/basic/gravity', 9.8);
params.Float('render/quality', 1);

Renders as:

Config
├─ physics
│  ├─ advanced
│  │  ├─ threshold
│  │  └─ iterations
│  └─ basic
│     └─ gravity
└─ render
   └─ quality

Both forms can be combined. A slash in a top-level schema key opens the deep path, the nested object groups tweaks inside it:

const params = createTweaks('Config', {
  'physics/advanced': {
    threshold: Float(0.1),
    iterations: Int(10),
  },
  'physics/basic': {
    gravity: Float(9.8),
  },
});

The leaf segment becomes the row label; intermediate segments become folder headers. Folder open/close state persists across reloads. Editing a tweak inside a nested folder propagates the unsaved indicator (the dot) up every ancestor folder, so a collapsed branch still flags that something inside has unsaved changes.

Folder values save back to your source like any other, whether you wrote them as nested objects, slash keys, or a mix of both, and a params.Float('a/b/c', value) registration is updated in place too.

Or register properties individually:

const myTweaks = createTweaks('My Tweaks');

myTweaks.Float('speed', 1, 0, 10, 0.1);
myTweaks.Int('count', 5, 0, 100);
myTweaks.Str('name', 'default');
myTweaks.Bool('active', true);
myTweaks.Color('tint', '#ff6600');

Or with group.add():

const myTweaks = createTweaks('My Tweaks');

myTweaks.add('speed', Float(1, 0, 10, 0.1));
myTweaks.add('count', Int(5, 0, 100));

Reading and writing values

Read or write values directly on the group like a regular JS Object:

console.log(myTweaks.speed); // 1
myTweaks.speed = 5;

Reads go through a live getter, so read the group each time in a render or animation loop. A value that holds functions from code reads as a copy with its functions, see Functions in values. Destructuring like const { speed } = myTweaks, or caching myTweaks.speed in a setup-time const, copies the value once and goes stale, the copy never sees later edits from the panel.

Rendering tweaks

GUI.Tweaks(group) renders all registered properties at the call position and registers the group with the parent panel's SAVE / REVERT / version status bar, which sits between the window bar and the panel content, toggled by the window bar's status button. Every created group is also listed in the tweakGroups export, a Map keyed by group name:

GUI.render(() => {
  if (!GUI.BeginPanel('Settings')) return;
  GUI.Tweaks(myTweaks);
  GUI.BeginGroup('actions', 'div.tweaks_row');
    if (GUI.ButtonInput('Reset')) reset();
  GUI.EndGroup();
  GUI.EndPanel();
});

In the example above, the action buttons render below the tweaks, and the status bar keeps its place above both.

| Param | Type | Default | Description | |-------|------|---------|-------------| | tweakGroup | TweakGroup | | Group returned by createTweaks() | | filter | string | '' | Segment path narrowing what renders, a trailing * keeps direct children only. See Filtering | | exclude | string[] | undefined | Skip these exact keys |

Returns true the frame a value changes.

Filtering

Pass a filter string to render only a subset of properties:

const params = createTweaks('Config', {
  speed: Float(1, 0, 10),
  physics: {
    gravity: Float(9.8, 0, 20),
    friction: Float(0.5, 0, 1),
  },
});

GUI.render(() => {
  if (!GUI.BeginPanel('Physics only')) return;
  GUI.Tweaks(params, 'physics'); // only renders gravity and friction
  GUI.EndPanel();
});

The filter is a segment path with one glob rule: a trailing * matches exactly one more segment, so it renders direct children of the path only. The bare '*' renders the top-level keys, 'physics/*' the direct children of physics. An optional third argument drops exact keys:

const HIDDEN = ['id'];

GUI.render(() => {
  if (!GUI.BeginPanel('Overview')) return;
  GUI.Tweaks(params, '*', HIDDEN); // top-level rows only, id hidden
  GUI.EndPanel();
});

Rows rendered through any filter form keep their full chrome: revert buttons, unsaved dots, and undo history all work as usual.

The exclude array is read during the call and never stored, so one shared module-level array like HIDDEN above is all you need. Don't write the literal inline in the render callback, that allocates a new array every frame (see Gotchas).

A row whose last save failed shows its unsaved state in the failed save color, and so do the dots of its collapsed folders. The color clears with the save error. A save error stays through a page reload, the one a dev server runs after the save wrote the other changes, until its record reads saved again, a save of that row settles, or a later CONNECT reaches a server.

TweaksFolder / EndTweaksFolder

Wrap GUI.Tweaks() calls in TweaksFolder / EndTweaksFolder to group them inside a collapsible folder while still sharing the parent panel's SAVE and REVERT bar:

GUI.render(() => {
  if (!GUI.BeginPanel('Settings')) return;

  GUI.TweaksFolder('Particles', true);
    GUI.Tweaks(particleTweaks);
  GUI.EndTweaksFolder();

  GUI.TweaksFolder('Physics only', false);
    GUI.Tweaks(nestedTweaks, 'physics');
  GUI.EndTweaksFolder();

  GUI.EndPanel();
});

| Function | Param | Type | Default | Description | |----------|-------|------|---------|-------------| | TweaksFolder | label | string | | Folder label | | | expanded | boolean | Ref | undefined | Initial expanded state. Accepts a Ref to persist | | | unsaved | boolean | undefined | Lights the folder's unsaved dot (and its ancestors'), for hosts rendering raw Tweak rows whose unsaved state the automatic Tweaks flagging cannot see | | | failed | boolean | false | With unsaved, turns the dot (and its ancestors') to the failed save color | | EndTweaksFolder | | | | Closes the folder |

TweaksStatusBar

Registers a tweak group with the parent panel's status bar (SAVE, REVERT, and the version selector) without rendering any tweaks UI. Returns true when a value in the group has changed:

GUI.render(() => {
  if (!GUI.BeginPanel('Controls')) return;
  if (GUI.TweaksStatusBar(myTweaks)) {
    // A value changed, refresh
  }
  GUI.EndPanel();
});

| Function | Param | Type | Description | |----------|-------|------|-------------| | TweaksStatusBar | tweakGroup | TweakGroup | The group to register with the panel status bar |

StatusButton / SaveToolbar / GuiScaleRow

The pieces BeginPanel builds its chrome from, exported for custom host windows. StatusButton(toggled) renders the connection dot, the unsaved count, or the failed save count in red over a toggle ref and returns true on the click frame. SaveToolbar() renders the save errors and the server help while no server answers, else the SAVE, REVERT, and version bar followed by the save errors, over every registered group. GuiScaleRow() renders the UI scale select with its step and reset buttons. WindowSizeControls(fitContent) renders the same Minimize and Expand controls as BeginPanel; docked windows render nothing.

const saveOpenRef = GUI.Ref('@saveOpen', false);

GUI.render(() => {
  if (GUI.BeginWindow('My Chrome', true)) {
    GUI.StatusButton(saveOpenRef);
    if (saveOpenRef()) {
      GUI.SaveToolbar();
      GUI.GuiScaleRow();
    }
  }
  GUI.EndWindow();
});

The save and undo keyboard chords, the history baseline, and the unsaved-count rescan activate once any save chrome has rendered, a panel or these pieces.

Rendering as regular widgets

You can also render tweak group properties as regular widgets:

GUI.render(() => {
  GUI.Number(myTweaks, 'speed');
  GUI.Number(myTweaks, 'count');
});

Tweak (single property)

GUI.Tweak(prop, labelKey?) renders one property through its registered type constructor, with no folder chrome. Unlike GUI.Tweaks it takes a bare StateProp instead of a group, so it works for a transient property held outside any registered group, for example a merged or proxy row whose value you write through to the real properties yourself. Returns true the frame the value changes.

// prop is a StateProp-shaped object: { type, value, labelKey, n1, n2, ... }
GUI.render(() => {
  if (GUI.Tweak(prop)) {
    // value changed, apply it wherever it belongs
  }
});

| Param | Type | Default | Description | |-------|------|---------|-------------| | prop | StateProp | | The property to render, dispatched on prop.type | | labelKey | string | prop.labelKey | Row label and key, see LabelKey syntax |

A change still records one undo history entry, exactly like a group-rendered row, so Cmd Z works the same whichever way the row was rendered. Several edits in the same frame coalesce into a single entry.

SetTweakState (manual row state)

GUI.SetTweakState(unsaved, pending, onRevert?, failed?) applies native tweak state to the last emitted row. Use it after Tweak or EndGroup. The call stores state on the row widget, creates no frame object, and adds the revert control once when onRevert is present. It does not register a panel SAVE bar because no group owns the row.

GUI.BeginGroup('name', 'div.tweaks_row.tweaks_text_row', target);
GUI.Label('name');
GUI.TextInput(target, 'name');
GUI.EndGroup('name');
GUI.SetTweakState(nameChanged, savePending, revertName);

| Param | Type | Default | Description | |-------|------|---------|-------------| | unsaved | boolean | | Adds the native unsaved state | | pending | boolean | | Adds the native pending state | | onRevert | function | | Adds the native revert control | | failed | boolean | false | Adds the native failed state, the unsaved color turns to the failed save color |

hasSaveError(group, filter?, exclude?) from tweaks/gui/bridge returns true while a save fault holds a record of the group selection, the same selection group.hasUnsaved(filter, exclude) reads. A leaf key as filter reads that one record. Pass it as failed to SetTweakState and TweaksFolder. It returns at once while no fault exists.

GetTweaksGroup (render context)

A custom type constructor registered with registerTypeGUI receives two arguments, the property it renders and its labelKey, and nothing else. Some widgets need more than their own property, for example a number whose row must show a unit the user edits on a sibling row. GUI.GetTweaksGroup() returns the group GUI.Tweaks is rendering at that moment. Read a sibling value directly as group[key], or reach the sibling property object itself through group.nameSpace[key].

// A group with a plain unit property and a custom-typed speed property.
const car = createTweaks('Car', {
  unit: 'km/h',
  topSpeed: { type: 'Speed', defaultValue: 250 },
});
const emptyUnit = { value: '' };

// The Speed row renders its number input, then the current value of the
// sibling unit property as a trailing label.
registerTypeGUI('Speed', (prop, labelKey) => {
  const group = GUI.GetTweaksGroup();
  const unitProp = group ? group.nameSpace.unit : emptyUnit;
  GUI.BeginGroup(labelKey, 'div.tweaks_row');
  GUI.Label(labelKey);
  const changed = GUI.NumberInput(prop, 'value');
  GUI.DisplayTextInput(unitProp, 'value::.tweaks_width_2');
  GUI.EndGroup(labelKey);
  return changed;
});

GUI.render(() => {
  GUI.Tweaks(car);
});

The function returns null when no GUI.Tweaks render is running. This includes GUI.Tweak, which renders one bare property that belongs to no group, so a constructor that renders through both entry points needs a fallback like the empty unit above.

TweakGroup methods

hasKeys(filter?)

Returns true if the group has any registered keys. With a filter string, only returns true if a key's root prefix matches:

const params = createTweaks('Config', {
  speed: Float(1, 0, 10),
  physics: {
    gravity: Float(9.8, 0, 20),
    friction: Float(0.5, 0, 1),
  },
});

params.hasKeys();          // true, has any keys
params.hasKeys('physics'); // true, has keys under 'physics'
params.hasKeys('audio');   // false, no keys under 'audio'

hasUnsaved(filter?, exclude?)

Returns true if the group has any values that differ from their saved state. The arguments select keys exactly like GUI.Tweaks does: filter is a segment path where a trailing * keeps direct children only, exclude skips exact keys:

if (params.hasUnsaved()) {
  // at least one value was changed since the last save
}
if (params.hasUnsaved('physics')) {
  // a physics value was changed
}
if (params.hasUnsaved('*', ['id'])) {
  // a top-level value other than id was changed
}

hasUnsaved() and hasPending() queries share one lazy global save-state scan. Filters select from its cached prop state. Use getSaveState() when UI chrome needs the global unsavedCount, pending, or savable state. Tweaks invalidates the snapshot after value, saved-state, pending-state, and schema changes.

remove(key) / removeMatching(prefix)

Drop registered props from the group at runtime. remove(key) drops a single leaf; removeMatching(prefix) drops every key whose path starts with prefix + '/'. Both clear the persisted entry so the next save no longer writes it.

params.remove('speed');           // drop one key
params.removeMatching('physics'); // drop physics/gravity, physics/friction, ...

removeMatching batches a single schema-change notification, so multi-field deletes coalesce into one save.

addNonSerializable(key, value)

Registers the non-serializable values, functions and objects, that a value from code holds under key without registering a prop. A host that moves a stored value away from a key it no longer registers calls it with that key's source value, so the moved value still reads its non-serializable values:

params.addNonSerializable('first', { color: (el) => pick(el) });

Hooks

hooks (exported from tweaks) lets external code observe edits. Register a listener with add; listeners cannot be removed, so register once at module load and keep them cheap.

import { hooks } from 'tweaks';

hooks.onAfterMutation.add(() => { needsRefresh = true; });

| Hook | Fires | |------|-------| | onAfterMutation | After any value edit: a widget interaction, a direct group setter write, or a GUI.Tweak change. The library itself uses it to schedule undo history capture | | onSaveState | After save bookkeeping changes, for example when a SAVE round-trip completes |

Several edits in one frame fire onAfterMutation several times but the library coalesces them into a single history capture at the end of the frame. Keep your own listeners equally cheap, a flag write is the right shape, heavy work belongs in the render loop behind that flag.

Custom types

A custom type has up to three concerns, each registered independently:

  1. registerType(name, defaultValue) registers the data type and returns a factory. No GUI dependency, this is all you need for sync and persistence.
  2. registerTypeGUI(name, constructorFn, codec?) registers the GUI renderer (the widget) and, optionally, the source codec that writes the value back to your code on save.

Minimal example

A Gradient type, end to end, data plus widget plus save:

import { registerType } from 'tweaks';
import { GUI, registerTypeGUI } from 'tweaks/gui';

// 1. Data, returns a factory and adds a g.Gradient(...) method to every group.
const Gradient = registerType('Gradient', { from: '#000', to: '#fff' });

// 2. Widget, prop.value is the record, pass it straight to sub-widgets.
registerTypeGUI('Gradient', (prop, labelKey) => {
  let changed = false;
  GUI.Label(labelKey);
  GUI.BeginGroup(labelKey, 'div.tweaks_row');
    if (GUI.ColorInput(prop.value, 'from')) changed = true;
    if (GUI.ColorInput(prop.value, 'to')) changed = true;
  GUI.EndGroup(labelKey);
  return changed;
}, {
  // 3. Save, return the source that goes inside Gradient(...), the wrapper name is kept for you.
  encode: (value) => ({ code: `{ from: '${value.from}', to: '${value.to}' }` }),
});

// Use it like a built-in. On save, Gradient({ ... }) is rewritten in place.
const theme = createTweaks('Theme', { sky: Gradient({ from: '#0a1628', to: '#4D9CFF' }) });

registerType

registerType(name, defaultValue) returns a factory that produces TweakRegister descriptors. A method with the same name is also added to all groups:

const defaultGradient = { from: '#ff0000', to: '#0000ff', midpoint: 50 };
const Gradient = registerType('Gradient', defaultGradient);

const g = createTweaks('Theme', {
  sky: Gradient({ from: '#0a1628', to: '#4D9CFF', midpoint: 60 }),
});

// Or register individually:
g.Gradient('sky', { from: '#0a1628', to: '#4D9CFF', midpoint: 60 });

// g.sky.from = '#0a1628', g.sky.to = '#4D9CFF', g.sky.midpoint = 60
const css = `linear-gradient(${g.sky.from}, ${g.sky.to} ${g.sky.midpoint}%)`;

The factory accepts (defaultValue, n1, n2, ...n7). Extra parameters are available on the prop object as prop.n1 through prop.n7.

Non-serializable values in values

A value can hold non-serializable values: functions from code, for example MyType({ size: 1, color: (el) => pick(el) }), and objects that are not plain objects or arrays, for example an element in { target: $el } or a class instance. The stored value keeps a placeholder in place of each non-serializable value, { function: { group, key, path } } for a function and { object: { group, key, path } } for an object, which names where the non-serializable value was registered. Undo, versions, and a reload keep the rest of the value, and reading the group returns the value with its non-serializable values:

const theme = createTweaks('Theme', { sky: MyType({ size: 1, color: (el) => pick(el) }) });
theme.sky.color(el);                   // the function from your code
theme.sky = { ...theme.sky, size: 2 };   // set the whole value to change it
  • A read returns a copy that holds the non-serializable values, so a nested write like theme.sky.size = 2 does not reach the stored value. Set the whole value instead, a write stores each non-serializable value as its placeholder again.
  • In a type constructor or a codec, prop.value holds the placeholders. withNonSerializable(prop.value) from tweaks returns the value with its non-serializable values. isPlaceholder(value) detects a placeholder, isFunctionPlaceholder(value) a function placeholder only.
  • The placeholders replace only the enumerable members of the value from code. sourceOf(prop.saved) returns that value from code, so a host reads a hidden member it put on it there.
  • A non-serializable value written straight into prop.value, not through the group, is unknown to the registry. Persistence drops such an object like JSON drops a function, and undo skips the value that holds it.
  • A value copied into another group still reads the non-serializable value it came from. When that group registers later in the page, the read returns undefined until then, and the copy's group reports a change: GUI.Tweaks and GUI.TweaksStatusBar return true so you can refresh.
  • A save keeps a non-serializable value's source text only at the place the non-serializable value came from. A value that holds a non-serializable value copied from another key or group has no source form, the save refuses it.

registerTypeGUI

registerTypeGUI(name, constructorFn, codec?) registers the GUI renderer for a type. The constructor runs every frame during rendering and must return true when the value changes. The optional codec is a { encode } object used by server mode to write saved values back to source.

Import from tweaks/gui (the renderer is only useful when the GUI is loaded):

import { registerTypeGUI } from 'tweaks/gui';

The constructor receives a prop object where prop.value holds the current value and a labelKey string for widget identity. Since prop.value here is an object, it can be passed directly as the target for sub-widgets:

registerTypeGUI('Gradient', (prop, labelKey) => {
  let changed = false;
  GUI.Label(labelKey);
  GUI.BeginGroup(labelKey, 'div.tweaks_row');
    if (GUI.ColorInput(prop.value, 'from')) changed = true;
    if (GUI.SliderInput(prop.value, 'midpoint', 0, 100, 1)) changed = true;
    if (GUI.ColorInput(prop.value, 'to')) changed = true;
  GUI.EndGroup(labelKey);
  return changed;
});

This separation enables tree-shaking: code that only needs the data (sync, persistence) can import registerType without pulling in any GUI code. The GUI renderer is registered separately, typically in the entry point that also imports GUI.

A third argument can carry the source codec for the type, used when saving to source:

registerTypeGUI('Gradient', constructorFn, {
  encode: (value) => ({ code: `{ from: '${value.from}', to: '${value.to}', midpoint: ${value.midpoint} }` }),
});

See Per-type codecs for the full save flow.

The prop object (StateProp reference)

Type constructors, codecs, and GUI.Tweak all work with the same prop object. These are the fields meant for you:

| Field | Type | Description | |-------|------|-------------| | type | string | Type name the constructor is dispatched on | | value | any | Current value. Read it to render, write it to change | | saved | any | Committed baseline. A row shows as modified when value differs from saved | | labelKey | string | Default row label and widget identity, see LabelKey syntax | | name | string | Leaf segment of the key, the part after the last slash | | folder | string | Folder prefix for nested keys, empty string when top-level | | n1 … n7 | number | Numeric slots. Built-ins use n1/n2/n3 as min/max/step, custom types use all seven freely | | s1 | string | null | String slot for custom types, null means uninitialized | | list | array | object | Select options | | symmetric | boolean | Render numeric bounds as [-max, max] instead of [0, max] |

A constructor writes value and, when it owns them, its n*/s1 slots. The remaining fields (used, order, pending, folderSegs) are library bookkeeping, treat them as read-only.

inferNumericBounds / inferNumericBoundsFromMagnitude

Helpers exposed on GUI for custom types that wrap a numeric prop and want the same auto-bounds behavior as the built-in Float / Int / Num. Useful inside a registerTypeGUI constructor when the user passed only a default value.

registerTypeGUI('MyNumber', (prop, labelKey) => {
  if (prop.n1 === undefined || isNaN(prop.n1)) GUI.inferNumericBounds(prop);
  return GUI.Number(prop, labelKey, prop.n1, prop.n2, prop.n3);
});

| Function | Signature | Purpose | |----------|-----------|---------| | inferNumericBoundsFromMagnitude | (magnitude, symmetric?) → { n1, n2, n3 } | Pure: derives [min, max, step] from a reference magnitude. Negative magnitudes flip the range to end at 0; pass symmetric: true for [-max, max] | | inferNumericBounds | (prop, symmetric?, minOverride?, maxOverride?, stepOverride?) | Writes the inferred bounds onto prop.n1 / n2 / n3, using prop.saved as the magnitude. Each override skips the corresponding inferred value |

Bounds table is the same as the Auto-inferred bounds section above.

Saving changes

Config file

Optional. Run npx tweaks init to scaffold a tweaks.config.json at your project root, or write one:

{
  "$schema": "./node_modules/tweaks/config.schema.json",
  "port": 3000,
  "scan": { "roots": ["src"] }
}

The dev server reads it at startup, so restart npx tweaks after changing it. The $schema line gives tooling hints and validation, and works with any project. The server reads this plain JSON file; your bundler does not.

| Key | Default | Description | |-----|---------|-------------| | port | 7348 | Port the dev server binds to. | | scan.roots | whole project | Files, folders, or glob patterns to search for saves, relative to the project root. | | scan.ext | js, ts, jsx, tsx, html | File extensions to search. | | scan.exclude | node_modules, .git | Extra directory names to skip. | | allowedOrigins | none | Page origins that may save, for example ["http://localhost:5173"]. Without it, any page served on this machine, on any port, may save to the project: set it when other local servers run. The Vite plugin sends no CORS headers, so a page of another port cannot save through it, and it ignores this key. | | allowedAddresses | none | Addresses of other machines that may save, in addition to this one, for example ["172.17.0.1"] for a browser on the host of a devcontainer. It matters with the Vite plugin when Vite listens on the network, every other address is refused there. The standalone server listens on this machine only. |

scan is the main reason to add a config, it narrows the source search so a save is faster and more precise on a large project. It is server-side only.

Server options, routes and a wrapping CLI

createTweaksServer(options) from tweaks/server/server builds the transport that npx tweaks and the Vite plugin mount. Its allowedOrigins lists the page origins that may save, ping and reach the extra routes, any page of this machine without it. A save, or a POST to an extra route, must carry the application/json content type. Beside apply and owns, it takes routes, an object of extra handlers answered under the base path with the same origin and socket guards as save, for example routes: { license: handler } answers GET /__tweaks__/license; name, the label of the log lines and the config file, tweaks by default; and allowedAddresses, as in the config file. A route that throws answers 500 and the server stays up.

A CLI that wraps the server imports tweaks/server/dev: runDevServer({ args, routes, name, version }) starts the dev server with its own routes, config file name, banner and log label, and runList() and runKill(args) run the list and kill commands. The vite plugin takes routes and name too.

scan.roots takes any mix of folders, files, and glob patterns, relative to the project root:

  • One folder, searched recursively: "roots": ["src"]
  • One file: "roots": ["src/anim.js"]
  • Several folders: "roots": ["src", "lib"]
  • Several files: "roots": ["src/a.js", "src/b.js"]
  • Folders and files together: "roots": ["src", "demos/hero.js"]
  • Every subfolder of one directory: "roots": ["examples/*"]
  • One file per subfolder: "roots": ["examples/*/index.js"]
  • Anywhere in the tree: "roots": ["src/**/*.anim.js"]

A folder is searched recursively and filtered by scan.ext and scan.exclude. A named file is searched directly, whatever its extension. Anything outside roots is never searched, so a save there fails rather than touching the wrong file.

A root containing *, ?, [, or { is a pattern, expanded once at startup:

| | | |---|---| | * | any run of characters inside one path segment, demo*.js | | ** | any depth, src/**/*.js | | ? | exactly one character, demo?.js | | […] | one character from a set, demo[12].js | | {…} | one of several alternatives, {anim,motion}/*.js |

A root that exists on disk is always taken literally, whatever it is spelled with, so a folder really named [legacy] resolves as itself rather than as a character class.

Each match is then treated as a plain folder or file, so scan.ext and scan.exclude still apply, and excluded directories never enter the roots even when a pattern would reach them. Restart the server after adding a file a pattern should pick up, patterns are not re-expanded per save.

A pattern that matches nothing contributes nothing and the server says so on startup. That narrows the save rather than widening it, a typo makes saves fail loudly instead of silently searching your whole project.

Multi-file projects are the reason to reach for a pattern. If your animations live one per demo folder, "roots": ["examples/*/index.js"] keeps every save pinned to the right file, and a call the panel cannot place fails with a reason instead of landing in a neighbor.

A custom port needs no browser setup: a server on a non-default port also parks a tiny relay on the free default port that announces where the real servers run, so the panel finds it wherever it binds.

Persistence

Unsaved edits, versions, undo history, and persisted refs are separate for each page pathname captured at startup, @@ refs excepted, which every page of the origin shares. Reload restores that page's state. Query strings and fragments do not create separate stores. Save existing edits before updating from unscoped storage; old session data is not migrated or deleted.

SAVE (or Cmd S) writes the current values back into your source file, see Saving to source below. This needs the dev server running.

Without the dev server the panel runs offline: the status dot is grey, SAVE is disabled, and everything else keeps working. The status dot opens the server help with a copyable start command while the server is down. Your unsaved changes, the version selector, and the panel's own state such as which folders are open all survive a page reload, so you never lose work in progress. Start the dev server and reload to go online and enable SAVE.

To skip the localhost probe entirely in a production build, set GUI.settings.probeOnLoad = false before the first render; the panel then stays offline with no network request. For a public demo with no server, set GUI.settings.save = false instead: the panel and its versions keep working in the session, SAVE stays disabled with a "Save is disabled in this demo" note, and the page makes no server request. A save the server does not answer within GUI.settings.saveTimeout milliseconds, 30000 by default, reverts to modified with that reason on the panel.

Versions and undo

Two safety nets sit under every edit.

Undo and redo. Cmd Z undoes and Cmd Shift Z redoes (Ctrl on Windows) while a panel is open. Each discrete edit is one entry and a whole drag counts as one, so undoing a slider drag restores the value it started from. The last 500 edits are kept and survive a page reload along with your unsaved changes. If source values changed outside the panel, they become the new undo floor: an unsaved panel value still survives, but undo cannot restore values from the older source. A reload caused by the panel's own save keeps the existing undo and redo stack, also when the save moved a value to another address and the host remapped the stored state at boot. A restore reaches the props your page registers now: a value for a key a group registers again in the rebuild that follows the restore lands there, and a value for a group your source no longer declares is dropped.

Versions. The status bar at the top of the panel holds SAVE, REVERT, and the version selector, which switches between SAVED, the values in your source, and any number of working versions. Clicking the status button in the window bar, the dot with the unsaved count, shows or hides the bar. The + button forks the current values into a new version, and the first edit you make while viewing SAVED forks one automatically, so SAVED always reflects what is actually saved. Switching versions keeps each version's edits, the delete button removes the active version, and REVERT returns every value to SAVED. Undo tracks all of it: undoing past a version switch or delete restores the values, the active selector, and the version list together.

Saving to source

With the dev server running, SAVE writes your changes straight back into your source file. If you wrote Float(60), it becomes Float(73) right where it is. Only the value you changed is touched, the rest of the file, your code, formatting, and comments, stays exactly as it was. There is no separate save file to keep in sync.

This works in .js, .ts, .jsx, and .tsx files, so you save the same way whether your project is plain JavaScript, TypeScript, or React. A value written with a type cast like createTweaks('config', { ... } as Config) saves fine too. Calls written straight into an .html page save as well, inside an inline <script type="module"> block, and the markup around it is left untouched. A module script in a comment or template, or one with src, is inert and stays out of the save scan.

A few files cannot be written to, and there the save is skipped and the file is left untouched: files using an old-style <Type>value cast, and Vue .vue or Svelte .svelte component files. A satisfies check saves like an as cast. Keep your tweak calls in a regular .js or .ts file and saving always works.

While the server is running the panel shows "connected". If it is not running, your changes stay in the browser marked unsaved, nothing is lost, and they save the next time the server is up.

The server finds the file to edit by the id as a quoted literal in the source, id: 'x' in any spelling of spaces, quotes, or a computed ['id'] key, and a marker by its name as a quoted literal or a bare identifier. Numeric ids are resolved from the AST, so decimal, signed, hexadecimal, binary, and octal spellings work. The canonical escaped string forms the writer emits are found too; a different escape spelling, "\x78" for x, stays out of reach of the initial literal scan.

A save the server cannot place, an id outside its scan.roots for example, turns the status dot red and lists each failed change below SAVE in its own corail block, its key then its reason with the code it names in monospace, along with the server command and a CONNECT button, so a save that reached the wrong project's server is diagnosed and fixed in place. The text of a block selects and copies like page text, Cmd C copies a text selection before any app shortcut, and a press on a panel control drops the selection. A block clears when its change is saved or reverted, when its save is retried, or once a server is reached again; while the server is down the blocks show above the server help.

Per-type codecs

The optional third argument to registerTypeGUI is a codec, { encode }, that tells the save engine how to write a custom type's value back to source. Non-server modes never call it.

encode(value, prop) receives the current value and the prop descriptor (so it can read bounds like prop.n1), and returns an object with:

| Field | Required | Description | |-------|----------|-------------| | code | yes | The source expression for the value. The type's own wrapper is kept for you, so return what goes inside it. | | imports | no | Any helper or wrapper names your code references. On save a bare name is added to your import from GUI.settings.importsFrom, a { name, from } entry imports from its own module. | | keepSourceKeys | no | Property names your code leaves out because the value has no source form for them, a function from code for example. The save copies their source text from the call it rewrites, and refuses when that call does not hold exactly one such property. |

Return null instead of an object for a value that has no source form; the engine then leaves that property untouched and its row reverts to modified. Tweaks never calls encode for a value that holds a function copied from another key or group, see Functions in values.

quoteLiteral(value) and keySource(name) from tweaks return the source form of a string and of an object key, the same forms the writer emits, so use them to build code.

When a call has more than one object argument, tell Tweaks which argument contains the fields it can save. configArg is zero-based, so widget(target, options) uses configArg: 1.

Put configArg on find, not on the codec. A codec converts a value back to source code, while find locates the object to edit. configArg is required whenever Tweaks reads or writes config fields. Leave it out only when a name-based entry changes a different argument, such as a marker position.

const Gradient = registerType('Gradient', { from: '#000', to: '#fff' });

// Source is `sky: Gradient({ ... })`. encode returns the object inside Gradient(...), the Gradient
// wrapper and its import are kept for you, so you never list your own type in imports.
registerTypeGUI('Gradient', constructorFn, {
  encode: (value) => ({ code: `{ from: '${value.from}', to: '${value.to}' }` }),
});

Use imports only when your code calls another helper. Point GUI.settings.importsFrom at the module it comes from, and a save adds the import when it is missing. A helper from a different module passes { name, from } instead of a bare name:

GUI.settings.importsFrom = './tweak-helpers.js';

const Tint = registerType('Tint', { color: '#ffffff', amount: 0.5 });

// Source is `bg: Tint(mix('#ffffff', 0.5))`. encode returns the mix(...) call that goes inside Tint,
// and lists mix, so a save adds `import { mix } from './tweak-helpers.js'` when it is absent.
registerTypeGUI('Tint', constructorFn, {
  encode: (value) => ({ code: `mix('${value.color}', ${value.amount})`, imports: ['mix'] }),
});

See registerTypeGUI for the full constructor signature.

Host entry builders

setSaveEntryBuilder(fn) from tweaks/gui/bridge replaces the default builder. On SAVE the bridge calls fn(syncedTweaks) with every group's namespace and expects { entries, onSavePrepared?, onSaveSettled? }. Each entry is the engine's Entry from tweaks/server/save without its requestIndex, plus props, the runtime prop records the entry settles:

| Field | Description | |-------|-------------| | id, prop | Names the entry for a person, in the panel rows and the server log. | | find | { by: 'prop', prop, is, configArg } locates a call by a config property value; { by: 'name', call, name, configArg?, type? } locates it by its first argument, with type naming the registered type; either find can supply owner to restrict matching to member calls on that owner. A property find can also supply call to restrict the method name. | | op, path | set writes code over the property path names inside the config, segment by segment through nested objects and slash keys, and creates a missing tail. delete removes it. elements rewrites an array or single element value from items, each { keep: savedIndex, set?, remove?, bareAs? } or { code }, so kept elements keep their source text. A bare kept element with a field op needs bareAs, the key its value stands for; a removal that leaves an element empty refuses. arg rewrites the call argument argIndex names, the argument count appends one, empty code clears the trailing one. remove takes no path and drops the whole call from its receiver chain, so seq.add(a).add(b) reads seq.add(b) once the first call goes, and a statement left holding only the receiver name goes with its line. A call with no receiver refuses. | | code, wrap, valueArg, imports, keepSourceKeys, argIndex, items, scope | The op's own fields, see the Entry typedef. | | required | The request writes nothing unless this entry resolves. Set it on an entry a page reload depends on, an id change your host stages a remap for. The bridge sets it on every entry that shares a record with another shipped entry. | | props | The StateProp records whose pending value this entry ships. A resolved entry promotes each into saved. | | skip, reason | A local refusal. The entry never ships, its props revert to modified, and the reason renders in the panel. A skip needs a reason. Mark code in it with backticks, `delay`, the panel renders each marked span in monospace. |

The bridge stamps each shipped entry with its zero-based requestIndex and reconciles the response by it. The server rewrites every file in memory first, then writes the entries that resolved, a failed entry stays modified with its reason in the panel. A failed required entry writes nothing in the request. A runtime record shared by several shipped entries promotes only when every one of them resolves.

onSavePrepared(entries, sentValues) runs once per request that ships, after every local refusal, every entry sharing a record with one, and every uncovered marker reverted, and before the post. entries are the exact ShippedEntry records the request carries, with their runtime props, and sentValues[i][j] is the value entries[i].props[j] ships, snapshotted so a later edit cannot change what the callback read. A host stages here whatever a page reload must find before the response arrives, a ledger the next boot consumes for example, from the batch as sent rather than from a guess made when SAVE was pressed. A throw posts nothing: the fault renders in the panel as a host save fault, the sent markers return to modified, onSaveSettled(false) runs once, a queued SAVE still builds, and the returned promise rejects with the fault after the queue settles.

onSaveSettled(applied) runs once per SAVE, true when a shipped entry resolved, false when nothing was written: every entry refused, a dropped connection, a malformed response, a preparation fault, or a batch with nothing to ship. It runs before a queued SAVE builds, so state onSavePrepared staged and this callback rolls back never meets the next request's staging.

An owner restricts a name or property find to that call's fluent chain or variable binding. Named markers can sit inside callbacks, branches, or loops. Set literal: true on a name find to match a string value without matching an identifier with the same spelling. Matching uses their authored identity, not execution order. Duplicate identities, shadowed bindings, and reassigned bindings refuse. The occurrence-based by: 'member' find, occurrence, and memberCount are removed.

One save request is in flight at a time. A SAVE while one is open queues one follow-up that ships the pending values of that moment once the open request settles, a refusal included. A dropped connection drops the queue, the props stay modified and the next SAVE after reconnect ships them.

If a host entry builder throws, the bridge names the fault in the save panel and console, reverts the pending markers so the edit can retry, and rejects the returned promise so the host bug keeps its stack.

buildGroupEntries(name, namespace, entries) appends the default entries of one group, so a host builder can hand plain createTweaks groups to the lib and build only its own.

Remapping stored state at boot

A save can change the address a value registers under on the next load, a renamed id that names its group, or a keyframe the file now holds at another position. remapStoredState(remapper) from tweaks/gui moves every persisted address once: the unclaimed live overlay, every working version, the undo history with its source records, and the records of the save errors. Call it at boot, after the gui module loaded and before the host registers anything, with the mapping the host staged in onSavePrepared:

import { remapStoredState } from 'tweaks/gui';

remapStoredState({
  group: (stateName) => stateName === 'walk' ? 'run' : stateName,   // a renamed id
  key: (stateName, key) => key,                                      // a key under its old state name
  value: (stateName, key, value) => value,                           // a structural value, an order array
});

Each callback is pure and receives the old state name, so a key or value lookup runs before the group name changes. A placeholder inside a value moves with the group and key it names. Every surface is built before any is replaced: a throwing callback, a result that is not a string, or two old addresses that claim one new address changes no store and rethrows, so the host can keep its staged mapping for a later attempt. On success the three stores persist before the call returns.

A host that keys its own global refs by an id it saves moves them the same way with remapStoredRefs(rename): rename(key) returns the next key or the key unchanged, a moved ref overwrites a ref the next key already holds, and the store persists before the call returns.

GUI

Immediate mode

The GUI follows the immediate mode pattern: widgets are declared every frame inside a render callback, not created once and bound to events. There is no setup, no binding, no callback registration. Your data is the single source of truth. Widgets read from it and write to it directly.

Input widgets return true the frame their value changes. Buttons return true the frame they are clicked. This means your logic and your UI live in the same place:

import { GUI } from 'tweaks/gui';

const audio = { volume: 1 };

GUI.render(() => {
  if (GUI.Slider(audio, 'volume', 0, 1, 0.01)) {
    applyVolume(audio.volume); // runs only when the slider moves
  }
  if (GUI.Button('Mute')) {
    audio.volume = 0; // runs only on click
  }
});

The library handles DOM creation, updates, and cleanup automatically. Widgets that are not declared in a frame are removed from the DOM. Widgets that reappear are recreated.

The callback rate is not fixed: idle frames tick at GUI.settings.fps and interaction renders at the display rate. Animation driven from the callback should scale by elapsed time, not by callback count, or it speeds up while a value is being edited.

Widget identity

Widgets are identified by their target + labelKey combination within their parent container. If you need to display the same variable twice with the same widget type in the same folder, use a different label or id to make each widget unique:

// Won't work: same target + labelKey in the same parent, only one widget appears
GUI.Number(obj, 'x', 0, 100);
GUI.Number(obj, 'x', 0, 100);

// Works: different labels make them unique
GUI.Number(obj, 'x::"Position X"', 0, 100);
GUI.Number(obj, 'x::"Duplicate X"', 0, 100);

Minimal setup

<script type="module" src="app.js"></script>
import { GUI } from 'tweaks/gui';

const obj = { x: 0 };

GUI.render(() => {
  if (GUI.BeginPanel('My Panel')) {
    GUI.Number(obj, 'x', 0, 100, 1);
    GUI.EndPanel();
  }
});

GUI.render(callback) registers a function that runs every frame. All widget calls go inside this callback.

For manual control of the render loop:

function loop() {
  GUI.beginRender();
  // widget calls here
  GUI.endRender();
  requestAnimationFrame(loop);
}
requestAnimationFrame(loop);

GUI.requestRender() requests an extra render frame. Useful when external code changes data that the GUI needs to reflect immediately rather than waiting for the next scheduled frame.

Settings

GUI.settings holds runtime configuration shared across the library. Defaults:

GUI.settings.warnings      // true, gate console.warn output from tweaks
GUI.settings.fps           // 10, render loop target frames per second
GUI.settings.debounceTimer // 150, ms debounce on persistence writes
GUI.settings.saveTimeout   // 30000, ms a save waits for the server before it reverts as unanswered
GUI.settings.serverPort    // 7348, port of the discovered tweaks server, synced by auto-discovery
GUI.settings.serverEndpoint // '/__tweaks__/', override for a different origin or path
GUI.settings.serverCommand // 'npx tweaks', the command the help sections show to start the dev server
GUI.settings.importsFrom   // '', module a custom type's wrapper is imported from, for save to add it
GUI.settings.probeOnLoad   // true, probe for the dev server once on load, false stays offline
GUI.settings.save          // true, save to source through the dev server, false disables SAVE with a note and makes no server request

Assignment is live: setting GUI.settings.fps = 30 clears and re-arms the internal render interval immediately.

import { GUI } from 'tweaks/gui';

GUI.settings.warnings = false;
GUI.settings.fps = 60;

Docked mode

Docked mode docks the gui into a resizable shell around your page instead of floating over it. The page moves into an iframe sized by the real leftover viewport, so media queries, viewport units, and window reads stay honest while panels take real space beside it.

The Vite plugin sets it up automatically, injecting the tag above vite's HMR client so every HMR patch and full reload, a save included, lands in the embedded page while the shell and its panels stay put.

SSR frameworks built on vite, SvelteKit, Nuxt, and Astro, render their html outside vite's transformIndexHtml hook, so they get the routes but not the automatic tag. Add it once in the framework's root template after the <title>, src/app.html in SvelteKit, app.head in the Nuxt config, the root layout head in Astro. The plugin serves the boot on the vite origin, so the path stays relative:

<script src="/__tweaks__/iframe.js"></script>

Everywhere else, one classic script tag makes it available, placed as early in the head as you can, before every other script on the page. Placement is the contract: in docked mode everything below the tag goes inert in the top window, which is what you want for page scripts, and the boot names the shell tab from the <title> it holds. Keep the charset meta above it:

<script src="/node_modules/tweaks/iframe.js"></script>

When the tweaks dev server is running, the server that applies saves also serves the boot, one absolute URL that works whatever the docroot, PHP sites and build pipelines included:

<script src="http://localhost:7348/__tweaks__/iframe.js"></script>

Pages loaded over https can use that URL in Chrome and Firefox, which treat localhost as trustworthy, Safari blocks it, use the form below there. Without the server, the file is a dependency-free static asset: copy it in your build step and point the tag at the copy, re-copying each build keeps it in sync with the installed version:

cp node_modules/tweaks/iframe.js public/build/tweaks-iframe.js

With the tag, the page boots docked. The panel bar dock button switches directly between docked and floating mode, reloading across the shell boundary. In docked mode BeginPanel uses its declared pane, or right by default. Without the tag, the page boots floating and the dock button is disabled. The floating choice is remembered per tab in sessionStorage (tweaks_docked), so a page stays floating once the visitor picks it. A tag with data-tweaks-floating, or tweaks({ docked: false }) in the vite plugin, boots a page floating until the visitor presses the dock button.

Custom controls can call GUI.IsDockedModeAvailable() to enable their dock actions and GUI.SetDockedMode(enabled) to enter or leave the shell. SetDockedMode() returns false only when docked mode was requested without the boot script. It returns true when the requested mode is already active or starts the required reload.

In docked mode the top window never executes page code. The boot parses everything below the tag into an inert template, nothing executes, fetches, or plays, and the shell replaces it at DOMContentLoaded, the page then runs once, inside the iframe. Content above the tag still parses and runs in the top window, the tag's position is the boundary, so a dev server's live reload script below it reloads the embedded page alone. Navigating the embedded page follows in the shell, the address bar and tab title mirror each page a link reaches so a reload stays there, a page without the gui exits docked mode automatically, and the embedded page's scroll position survives its reloads, dev server full reloads and the save flow included.

The tag is dev markup: ship it next to your tweaks import and strip both for production. GUI.BeginDockedPane(pane, min, max, direction, size) mounts the widgets emitted until EndDockedPane() in the fixed bottom, left, right, viewportLeft, or viewportRight shell cell. It returns the pane size ref in docked mode and null outside the shell. min and max are visual px. The declared min floors the cell on its axis, and render callbacks sharing one pane get the largest min declared in the frame. direction arranges the windows the pane holds, row when left out, and the pane keeps the last direction declared in the frame. size is the size in visual px of a pane the session has not sized yet; a size the visitor set wins. The left and right cells span the full window height. The bottom cell spans the width between them. The viewport cells hug the embedded iframe and stop above the bottom cell. Empty cells and their gutters collapse through CSS; the GUI creates no pane manager or slot state.

Position windows in a pane

BeginDockedPane selects the shell pane and its direction. Inside it, BeginLayout selects the direction of a nested group. Calls in a row appear from left to right. Calls in a column appear from top to bottom. There is no separate order value or retained window list.

A Splitter fixes the sibling selected by controls: prev fixes the child before it and next fixes the child after it. The other child grows. This right pane keeps the panel on the outside and lets the timeline beside the viewport grow:

if (GUI.BeginDockedPane('right', 520, 2000, 'row')) {
  GUI.BeginLayout('timeline', 'column', 260);
    renderTimeline();
  GUI.EndLayout();
  GUI.Splitter('timelinePanel', GUI.Ref('@panelWidth', 320), 260, 2000, 'next');
  GUI.BeginLayout('panel', 'column', 260);
    renderPanel();
  GUI.EndLayout();
  GUI.EndDockedPane();
}

Two render callbacks that claim the same pane share its direction, so a page window and a tool window stack in the pane without agreeing on a layout key.

Use these call orders for the common placements:

  • Column: emit the main panel first to put it at the top.
  • Left row: emit the panel first, then a prev splitter, then the viewport window.
  • Right row: emit the viewport window f