Lifecycle & Configuration
The common API every control shares — atomic configure(), getConfig(), focus(), destroy(), and the base configuration options.
Beyond the reactive value API, every control — including buttons, folders, and monitors — shares a common lifecycle and configuration surface.
The common control interface
interface Control<C> {
configure(config: Partial<C>): this;
getConfig(): Readonly<C>;
focus(): this;
destroy(): void;
isDestroyed(): boolean;
/** Alias for destroy(), enabling `using` declarations. */
[Symbol.dispose](): void;
}configure()
configure() updates one or more options at once. Its defining property is that
it is atomic: the whole proposed configuration is validated as a single
operation before anything is applied.
threshold.configure({
label: "Binary threshold",
hint: "Pixels above this become white",
max: 220,
disabled: false,
});Rules:
- A missing property means "leave unchanged".
- A present property set to
undefinedclears it, where that is supported. - The update is validated as one complete operation.
- Rendering happens only after validation succeeds.
- Value subscriptions fire only after successful application.
- No partially applied state is ever visible.
Atomicity in practice
Consider swapping a slider's range and value together. If applied one field at a
time, an intermediate state could be invalid (min above the old max).
configure() avoids that entirely by validating the whole patch first:
// Valid as a whole — applied atomically.
threshold.configure({
value: 0.5,
min: 0,
max: 1,
step: 0.01,
});If a patch is invalid, configure() rejects the entire change:
threshold.configure({
min: 200,
max: 100, // min > max
});The result:
- Throws a clear error.
- Keeps the previous
min,max, and value. - Notifies no subscribers.
- Touches no DOM.
This is why you should prefer configure() over hypothetical per-field setters
like setMin / setMax, which cannot be atomic.
getConfig()
const config = threshold.getConfig();
console.log(config.min);
console.log(config.max);The returned object is a read-only snapshot — safe to inspect, and not a live reference to internal state. Mutating it does nothing.
focus()
threshold.focus();Focuses the control's primary interactive element — the range/number input for a
slider, the checkbox for a checkbox, the <select> for a select, the disclosure
button for a folder, and so on. Returns the control for chaining.
destroy() and isDestroyed()
threshold.destroy();Destroying a control releases everything it owns:
- Its DOM, and its slot in the parent container
- All subscribers
- Event listeners and resize observers
- Timers and animation frames
- External store bindings
Destroying a folder recursively destroys all of its children — nested folders, monitors, subscriptions, and animation frames included.
After destruction, using the control throws a clear error rather than silently doing nothing:
threshold.get();
// ParamPanel: cannot use SliderControl after destroy()Guard with isDestroyed() when a control's lifetime is uncertain:
if (!threshold.isDestroyed()) {
threshold.set(100);
}Any Unsubscribe function returned by subscribe() remains safe to call after
the control is destroyed.
Automatic disposal with using
Every control implements [Symbol.dispose], so it works with TypeScript's
using declarations (TS 5.2+). A control declared with using is destroyed
automatically when it leaves scope — handy for short-lived controls in tests or
scoped tools:
{
using preview = pp.canvas({ label: "Preview", draw });
preview.invalidate();
} // preview.destroy() runs here automaticallyThis is additive — explicit destroy() remains the primary API.
Base configuration
Every control config extends BaseControlConfig:
interface BaseControlConfig {
/** Stable identifier for serialization, search, and diagnostics. */
id?: string;
/** User-facing label. */
label: string;
/** Optional explanatory text. */
hint?: string;
/** Whether user interaction is disabled. */
disabled?: boolean;
/** Whether the control is shown. */
visible?: boolean;
/** Additional CSS class. */
className?: string;
}Value controls additionally accept resetValue, the baseline used by
reset():
interface ValueControlConfig<T> extends BaseControlConfig {
/** Baseline restored by reset(). Defaults to the construction value. */
resetValue?: T;
}Labels and hints
control.configure({ label: "Edge threshold" });
control.configure({
hint: "Minimum gradient magnitude retained as an edge",
});A hint may render below the control, as a tooltip, or as an accessible description, depending on theme and density.
Disabled
control.configure({ disabled: true });Disabled controls stay visible, cannot be changed by user input, may still be changed programmatically, and expose the correct accessibility attributes.
Visibility
control.configure({ visible: false });Hidden controls remain part of the control tree — they keep their value, stay serializable, and take up no layout space. Continuous rendering pauses while a control is hidden.
Conditional controls
There is no implicit dependency tracking; wire conditions explicitly by subscribing:
mode.subscribe((value) => {
advanced.configure({ visible: value === "advanced" });
});
enabled.subscribe((value) => {
quality.configure({ disabled: !value });
});Explicit subscriptions stay simple and predictable.
Error handling
Programmatic misuse — an invalid range, an unknown select option, using a
destroyed control — throws a ParamPanelError. It carries a stable, machine-
readable code so you can branch on the cause without parsing the message:
type ParamPanelErrorCode =
| "invalid-range"
| "invalid-step"
| "invalid-value"
| "duplicate-id"
| "unknown-option"
| "destroyed"
| "invalid-state"
| "missing-config";
class ParamPanelError extends Error {
readonly code: ParamPanelErrorCode;
readonly controlType?: string;
readonly controlId?: string;
}try {
threshold.configure({ min: 200, max: 100 });
} catch (error) {
if (error instanceof ParamPanelError && error.code === "invalid-range") {
showToast(error.message);
}
}The message stays human-readable and actionable; the code is the stable
contract. User input errors (typing an invalid number) are surfaced in the UI
rather than thrown — only programmatic API misuse throws.