dashcat
v0.0.1-alpha.1
Published
a minimal, beautiful gui for tweaking values
Maintainers
Readme
# npm coming soon! for now, install from github:
> npm install isaac-mason/dashcatdashcat
dashcat is a minimal, beautiful gui for tweaking values and watching data — vanilla, functional, dark, and monospace. It's a lil-gui / Leva-style control panel with a dockable tab/split layout, first-class mathcat shapes, and zero runtime dependencies.
Table Of Contents
- Overview
- Quick start
- Binding
- Controls
- mathcat shapes
- Watching data — monitors & graphs
- Folders & filter
- Dependent & optional controls
- Custom controls
- Panels, tabs & docking
- Theming
- API
Overview
- You own the state. dashcat instruments objects, stores, and engine state you already have — it reads and writes them in place, it never owns them.
- Explicit, with smart defaults. One verb,
add(target, key, options). The widget is auto-detected from the value; acontroloption overrides it and unlocks the mathcat shapes. - Tweak and watch. Sliders, colors, vectors, and rotations for editing; monitors and live graphs (with auto-scaling units) for observing.
- Dockable. Panels are tabs — drag to reorder, split into new regions, collapse, and close/reopen. No fixed single panel.
- Functional & tiny. No classes, no framework, one injected scoped stylesheet, and a few KB gzipped.
Quick start
import { dashboard } from 'dashcat';
const state = { speed: 1, label: 'hello', enabled: true, quality: 'high' };
const dash = dashboard();
const panel = dash.panel({ title: 'settings' });
panel.add(state, 'speed', { min: 0, max: 10 }); // number + range → slider
panel.add(state, 'label'); // string → text
panel.add(state, 'enabled'); // boolean → toggle
panel.add(state, 'quality', { options: ['low', 'medium', 'high'] }); // → select
panel.button('reset', () => console.log('clicked')); // an action buttonThe widget is chosen from the value's type: number → slider/number, boolean → toggle, string → text, number[] → vector, and options → select.
Binding
add binds an object property, a get/set accessor (for nested / derived state), or — for the read-only watch controls — a plain getter:
// bind an object property (the common case)
panel.add(state, 'speed', { min: 0, max: 10 });
// or a get/set accessor — nested, derived, or unit-converted state
const camera = { fov: 60 };
panel.add({ get: () => camera.fov, set: (v) => (camera.fov = v) }, { min: 20, max: 90, label: 'fov' });
// or a getter only — read-only, for the watch controls
panel.monitor(() => performance.now());Controls
Ambiguous values take an explicit control. A number[3] might be a vector or a color; a string might be text or a hex color:
// the widget is auto-detected from the value. ambiguous ones — a `number[]` could be a
// vector or a color, a string could be text or a hex color — name the `control`:
const material = { tint: [0.9, 0.2, 0.1] };
panel.add(material, 'tint', { control: 'color' });Built-in control names: number, slider, text, toggle, select, color, vec2, vec3, vec4, spherical, euler, quaternion, interval. Prop-less helpers: button, buttonGroup, html, element.
Every add returns a chainable handle:
panel
.add(state, 'speed', { min: 0, max: 10 })
.onChange((v) => console.log('speed', v))
.onFinishChange((v) => console.log('done', v))
.listen(); // reflect external changes to state.speedmathcat shapes
mathcat's shapes are plain number tuples (Vec3 = [x, y, z], Quat = [x, y, z, w], Color = [r, g, b]), so dashcat edits them in place with no adapter. Pass a list of editors to flip between them:
// mathcat shapes are plain number tuples; dashcat reads & writes them in place
const transform = { position: [0, 1, 0], rotation: [0, 0, 0, 1] };
panel.add(transform, 'position', { control: 'vec3', step: 0.1 });
panel.add(transform, 'rotation', { control: ['euler', 'quaternion'] }); // flip between editorsWatching data — monitors & graphs
Read-only controls that poll a getter (throttled with interval). unit picks a smart auto-scaling formatter (bytes, duration, si, %) or a literal suffix, applied to the value and a graph's min/avg/max. Off-screen and inactive-tab charts pause automatically.
const stats = { fps: 60, memory: 1.4e9, frame: 16.6 };
panel.monitor(() => stats.fps, { unit: 'fps' }); // → "58 fps"
panel.monitor(() => stats.memory, { unit: 'bytes' }); // → "1.4 GB"
panel.graph(() => stats.frame, { unit: 'duration', min: 0, max: 33 }); // → "16.6 ms" + min/avg/maxFolders & filter
const lighting = panel.folder('lighting');
lighting.add(state, 'speed', { min: 0, max: 1, label: 'intensity' });
panel.filter(); // a search box that hides controls whose label doesn't matchDependent & optional controls
show and disable accept predicates that read your state directly (no keyed store). optional adds a toggle that nulls the value when off:
const opts = { mode: 'basic', detail: 0.5, shadows: true, bias: 0.02, maxDistance: 100 as number | null };
// `show` / `disable` take predicates that read your state directly
panel.add(opts, 'detail', { show: () => opts.mode === 'advanced' });
panel.add(opts, 'bias', { min: 0, max: 0.1, step: 0.001, disable: () => !opts.shadows });
// `optional` adds a checkbox that toggles the value on/off (→ null)
panel.add(opts, 'maxDistance', { min: 0, max: 500, optional: true });Custom controls
A control is just a Control<T> — a function that builds its dom from base and returns the handle. Pass it as control; it mounts exactly like a built-in, with no registration:
import { base, type Control, el, on } from 'dashcat';
// a 5-star rating — a Control<number> built with the same `base` the built-ins use
const rating: Control<number> = (ctx, prop) => {
const b = base<number>(ctx, prop, prop.name ?? 'rating');
const stars = [0, 1, 2, 3, 4].map((i) => {
const star = el('button', 'dc-button', { type: 'button', textContent: '★' });
b.onDispose(on(star, 'click', () => b.handle.set(i + 1)));
return star;
});
b.controlEl.append(...stars);
b.render(() => stars.forEach((s, i) => (s.style.opacity = prop.get() > i ? '1' : '0.3')));
return b.handle;
};
panel.add(state, 'speed', { control: rating }); // mounts exactly like a built-inPanels, tabs & docking
// panels are tabs in a dockable layout: drag a tab to reorder, drop it on an
// edge to split into a new region, collapse a group, or close (stash) & reopen.
const perf = dash.panel({ title: 'perf', dock: 'bottom' });
perf.graph(() => stats.fps, { unit: 'fps', min: 0, max: 120 });Theming
Everything is scoped under .dashcat and driven by CSS custom properties — override any of them to retheme:
.dashcat {
--dc-surface: #17191e; --dc-surface-muted: #202329;
--dc-fg: #e7e8ea; --dc-fg-muted: #8b9096;
--dc-border: #3a3f47; --dc-accent: #2b5fd9;
--dc-font: ui-monospace, monospace; --dc-size: 11px;
}API
dashboard
/** create a dockable dashboard — the host for panels, tabs, and splits. */
export function dashboard(opts: DashboardOptions = {}): Dashboard;add options
/** options for `add`. the widget is auto-detected from the value; `control` overrides it. */
export type AddOptions<T> = {
label?: string;
hint?: string;
listen?: boolean;
show?: boolean | (() => boolean);
disable?: boolean | (() => boolean);
onChange?: (value: T) => void;
onFinishChange?: (value: T) => void;
/** fires once when an edit begins; `onEditEnd` is an alias of `onFinishChange`. */
onEditStart?: (value: T) => void;
onEditEnd?: (value: T) => void;
/** show a checkbox that toggles the value on/off (sets it to `null` when off). needs a non-null initial value. */
optional?: boolean;
/** explicit ordering within the panel/folder (lower first; default insertion order). */
order?: number;
/** override the widget: a built-in name, a custom `Control<T>`, or a list → a flip switch. */
control?: ControlName<T> | Control<T> | Array<ControlName<T> | Control<T>>;
// widget config — each control reads what it needs
min?: number;
max?: number;
step?: number;
options?: readonly T[] | Record<string, T>;
space?: 'linear' | 'srgb';
format?: (value: T) => string;
};Control handle
/** the live handle returned when a control is mounted. everything chains. */
export type Handle<T> = {
readonly row: HTMLElement;
get(): T;
set(value: T, finished?: boolean): Handle<T>;
onChange(fn: (value: T) => void): Handle<T>;
onFinishChange(fn: (value: T) => void): Handle<T>;
/** fires once when an edit begins (drag start / first change of an interaction). */
onEditStart(fn: (value: T) => void): Handle<T>;
name(label: string): Handle<T>;
hint(text: string): Handle<T>;
listen(enable?: boolean): Handle<T>;
show(visible: boolean | (() => boolean)): Handle<T>;
disable(disabled?: boolean | (() => boolean)): Handle<T>;
/** flip the active editor of a switchable control (no-op otherwise). */
view(which: string | number): Handle<T>;
refresh(): Handle<T>;
destroy(): void;
};Authoring a control
/**
* a control is a widget for a `Prop<T>`. it's a plain factory: given a context
* and a prop, it builds its dom and returns a live handle. built-in and custom
* controls are the same shape, so they mount identically.
*/
export type Control<T> = (ctx: Context, prop: Prop<T>) => Handle<T>;// a `prop` is a reference to state you already own — dashcat instruments it, it
// never owns it. three not-owned shapes: object+key, a get/set lens, or a
// getter-only source (read-only, for visibility). reactivity is a property of
// the binding: a `subscribe` means push updates, its absence means opt into
// `.listen()` polling.
export type Prop<T> = {
get(): T;
/** absent ⇒ read-only (a monitor/graph source). */
set?(value: T): void;
/** present ⇒ reactive; the control subscribes instead of polling. */
subscribe?(onChange: () => void): () => void;
/** display name, used as the default control label. filled in by the obj+key adapter. */
name?: string;
};