HTML5 UI library with Blender RNA-style data binding.
- plans live in documentation/plans
- plans should always be committed to the repo
- plans should be pressure tested with an agent with fresh context when created, and the pressure tested results folded back into the plan
- when executing plans the status of each stage/task/phase/wave/etc should be written into the plan itself and marked as completed when done.
- A todos list lives in
todos.mdand is committed to the repo. Check items off as they are completed.
Comments are prose, so the Prose rules below govern them as well. The rules in this section are the ones that apply only to code.
- Inline notes and doc comments are punctuated differently. An inline
//note is a fragment with no terminal period; a/** … */doc comment is a punctuated sentence. One line each, unless the fact genuinely needs two. - Non-doc comments use
//. Doc comments use proper/** … */brackets. Don't use/* … */for ordinary inline commentary. - Non-doc comments are at most 3 lines. A longer block comment is allowed sparingly — budget roughly one per 500 lines of a file — for genuinely load-bearing context that can't be stated in three lines.
- Doc comments stay reasonably concise. Say what the thing is and any non-obvious contract; don't restate the signature or narrate the implementation.
- Temporary comments are marked
CLAUDENOTE:. Any scratch/working comment Claude writes gets that prefix, and all of them must be removed before the final commit of a plan (or at the end of the plan, whichever comes first).
See 'documentation/proseStyle.md'.
This project uses pnpm as its package manager. Use pnpm (and pnpm run …)
rather than npm.
pnpm run build # Rollup bundle → dist/pathux.js
pnpm run typecheck # tsgo --noEmit
pnpm run test # vitest
pnpm run format # prettier --write
pnpm run format:check # prettier --checkUse tsgo to typecheck instead of tsc, e.g.
pnpm exec tsgo --noEmit.
pnpm nwjs and pnpm electron launch the example/ app in NW.js (SDK
flavor, from the nw devDependency) or Electron respectively, with the
Chrome DevTools Protocol enabled on port 9222 (override with --port=<n>).
Both build example/dist/app.js first if missing. pnpm nwjs also opens the
DevTools window automatically (pass --no-devtools to suppress it).
example/package.json doubles as the NW.js manifest: its main is
nwjs_app.html, which sets window.haveNwjs so the app picks the NW.js
platform backend (scripts/platforms/nwjs/). Electron ignores that field —
pnpm electron runs electron_app.cjs directly, whose electron_app.html
sets window.haveElectron.
Drive the running app (either runtime) over CDP with pnpm cdp (each
invocation connects, acts, and disconnects; the app keeps running):
pnpm cdp pages # list debuggable pages
pnpm cdp eval "document.title" # evaluate JS in the app page
pnpm cdp screenshot shot.png # capture the window
pnpm cdp click 100 200 # click at viewport coordinates
pnpm cdp key Enter # press a key (Playwright key names)Screenshots are captured at the window's devicePixelRatio (often 1.25 on
Windows) while click takes CSS-pixel coordinates. Divide screenshot pixel
positions by devicePixelRatio before clicking.
For richer automation, import connectApp() from buildtools/cdp.mjs
in a Node script: it returns { browser, page } where page is a Playwright
Page connected over CDP; browser.close() only disconnects.
The NW.js and Electron binaries are downloaded by their packages' postinstall
scripts (allowed via pnpm.onlyBuiltDependencies). If a launcher reports a
missing binary, run pnpm rebuild nw / pnpm rebuild electron.
By design vectors do not have a simple index signature. Instead, indices up to LEN type to number, while indices above LEN type to number | undefined.
This is to prevent mixing of incompatible vectors.
This can create problems with iteration, for example:
let v = new Vector3()
for (let i=0; i<3; i++) {
// will not work
v[i] = i
// will work
v[i] = i as Number3
}
//alternative with IndexRange:
for (const i of IndexRange(3)) {
v[i] = i
}scripts/ — main source (TypeScript, converting from JS)
scripts/path-controller/ — git submodule (data binding, tool system, math)
scripts/core/ — UIBase, Container, theme, animation
scripts/widgets/ — UI widget classes (extend UIBase)
scripts/screen/ — FrameManager, ScreenArea, area management
scripts/platforms/ — platform abstraction (web, electron)
scripts/simple/ — simple app framework
documentation/ — documentation source (markdown)
dist/ — built output
generated/ — auto-generated catalogs: data paths (pnpm run gen:paths) and theme keys (pnpm run gen:themes)
See documentation/controller.md for the datapath
controller overview: how model classes are wrapped (DataAPI / DataStruct /
DataPath), how the UI references values by path, and how to look up structs by
name (getStructByName).
See documentation/container.md for how Container binds those
paths. Note: many pathux widgets will modify the data model through an undoable
DataPathSetOp unless you explicitly disable this.
Valid path strings for container.prop("..."), related widget methods, and
<prop path="..."> xmlpage tags are catalogued in generated/API_PATHS.md
(human/LLM-readable) and generated/api-paths.json (machine-readable), with each
path's type, UI name, range, unit, and enum items. generated/datapaths.ts exports
a KnownDataPath union of the valid non-indexed paths. Regenerate after changing any
api_define with pnpm run gen:paths (walks the app's defineAPI()).
Widgets subscribe to their datapaths instead of re-reading them per frame; the
old per-widget updateDataPath() protocol is removed. The runtime lives in
scripts/path-controller/controller/pathwatch.ts.
Widget protocol (both overridable on UIBase):
watchPath()— declare bindings once by callingthis.addPathWatch(pathOrAttr, opts?)(idempotent per path; the base implementation watches thedatapathattribute when present). Runs automatically fromupdate()oncectxexists, and re-runs when thedatapathattribute changes.opts.onChangeroutes a binding somewhere other thanupdateFromPath(multi-path widgets).updateFromPath(value, info)— the reaction, called only when the value actually changed.info = { resolved, path, prop, source };info.resolved === falsereplaces the oldval === undefined → internalDisabledcheck. The watcher owns read + snapshot + prop-aware compare (including in-place vector mutation), so do not re-diff, but a widget-side no-op guard is fine.refreshPathWatches()re-delivers current values past the diff — call it when a widget stops gating reactions (e.g. textbox blur).
Write side: api.setValue (and everything through it — setPathValue,
tool ops, mass-set, undo/redo) notifies automatically. For raw model writes,
call the typed api.updateFrom<T>(path, prop) / api.updateChanged<T>(prop)
(checked against the generated StructCatalog, pnpm run gen:paths), or
api.notifyChange(path). api.notifyChange() with no arguments bumps the
structural epoch and wakes every watcher — use after tree-shape changes
(active-object switch, list insert/remove, context swap).
Delivery: notifications coalesce onto one requestAnimationFrame flush
(1000 sets in a frame → one reaction). Per-binding debounce via
addPathWatch("datapath", { debounce }): "raf" (default), "immediate", or
{ trailing: ms } for heavy widgets (Curve1DWidget uses 200, ColorPicker 100).
In tests, drive the flush synchronously with flushPathNotifications().
Compat safety net: UIBase.dataPathPolling (static, default true) keeps
every watcher also polling from update() — same cadence and diff as the old
protocol — so un-instrumented raw writes are still caught. Per-widget
pollDataPath: boolean | "auto" overrides it in either direction. Widgets
must keep chaining super.update() (or drive this._updatePathWatchers()
directly, as Label does) or their watchers neither build nor poll.
Lifecycle: watchers are removed in remove()/removeChild() and rebuilt on
the next update() if the widget is reused; the registry holds only weak
refs (a FinalizationRegistry prunes anything missed). See
tests/pathWatch.test.ts for the behavioral contract.
See documentation/theming.md for the full write-up: theme variables, the generated theme file, the editor widget and its variable mode, and the export button a client app wraps it in.
A theme is a record of style classes, each mapping keys to values (colors,
numbers, booleans, CSSFont, ThemeScrollBars, and nested sub-records such as
disabled/highlight). Widgets read values with UIBase.getDefault(key),
which searches the widget's own style class (from define().style), then
parentStyle, then base.
scripts/core/theme.tsholds path.ux'sDefaultTheme.setTheme(record)(inscripts/core/ui_base.ts) merges an app theme into the live globalthemeobject rather than replacing it, so an app only supplies the keys it overrides. After a runtime change callflagThemeUpdate()and repaint withscreen.completeSetCSS()/screen.completeUpdate().- An app's theme file is the source of truth for its theme values. It is a
TypeScript module holding a
themeVarsblock and athemerecord referencing those variables throughgetVars, loaded withsetTheme(instanceThemeVars(theme, themeVars)).example/theme.tsis a working one;example/core/app.tsloads it. - The variable system lives in
scripts/core/ui_theme_utils.ts(getVars,instanceThemeVars,createThemeFile,parseVarComments), the color and export helpers inscripts/core/ui_theme.ts, and the editor widget (theme-editor-x) inscripts/widgets/theme_editor.ts. All of it is re-exported from thepathuxentry point. - The editor edits the live theme in place; the edits are runtime-only until an
app-supplied Export button regenerates the theme file with
createThemeFile.
The theme file governs values; the keys widgets read are typed separately. The
keys are catalogued so getDefault can be type-checked, mirroring the data-path
catalog above.
- Declare the keys a widget uses in its
static define().theme, mapping each to at.*token fromscripts/core/theme_schema.ts(t.number,t.color,t.font, …; nest an object for sub-records likedisabled/highlight). Declare the returned type — a"12px"value reads back as a number, so uset.number. A widget inherits its parent class's declarations; only list what it adds or overrides. Annotate the migrateddefine()with the exportedUIBaseDefinitionreturn type so subclasses that omitthemestill satisfy the static-side variance check. - Opt in to typed lookups by passing the class name as
UIBase's third type param:class Button<CTX…> extends ButtonEventBase<CTX, "Button">(thread aSELF extends string = "UIBase"param through any intermediate base class). - Regenerate with
pnpm run gen:themes(walks the registry, resolves inheritance +theme.tsin JS, emits flat per-class types). It writesgenerated/themes.ts(augments theThemeKeyRegistryseam) andgenerated/themes.json.generated/is gitignored — it's a build artifact. - Strict check: the default
pnpm run typecheckkeepsgetDefaultloose (empty seam) so the library builds standalone; existingas number/as CSSFontcasts stay load-bearing there. Runpnpm run gen:themes && pnpm run typecheck:themes(includes the catalog) to type-checkgetDefaultagainst the per-class keys for migrated widgets. - CI: run
pnpm run gen:themes --strict(fails on adefine().themekey absent fromtheme.ts— i.e. a typo) followed bypnpm run typecheck:themes. - An optional, opt-in ESLint rule (
buildtools/eslint-rules/valid-theme-key.mjs) flags literalgetDefault("…")keys absent from the whole catalog. It is not wired intoeslint.config.jsby default (it currently surfaces ~15 legitimately un-themed keys read with runtime defaults); enable it as apathux/valid-theme-keywarning if you want typo coverage.
Do not add type annotations if types can be inferred from the assignment.
Annotate function parameters, return types where the type is non-obvious, and
variables whose inferred type would be too wide (unknown out of JSON.parse).
Leave const x = 5, let s = "hello" and the like to inference.
The same rule applies to agents delegated conversion work — tell them.
TypeScript: strictNullChecks: true in all tsconfigs
No any: except at JSON.parse boundaries, immediately narrowed
Formatting: prettier (see .prettierrc)
Tests: vitest for unit tests, Playwright for DOM widget tests
Modules: ES modules ("type": "module" in package.json)
Entry point: scripts/pathux.ts → re-exported from root pathux.js
Splitting a module that pathux.ts re-exports must not add any name to the barrel
unless the original module already exported it. pathux.ts re-exports with
export *, so a name exported from any module in that chain silently becomes
public API, and a .d.ts diff does not catch it — the leak arrives through an
export * from "…" line that reads identically either way.
Keep an internal helper unexported inside a module the barrel reaches. Put a
helper two modules share in a module that is imported but never export *-ed.
Verify by diffing the sorted Object.keys() of the built dist/pathux.js
against a pre-refactor baseline.
Historically widgets signal value/selection changes through bespoke
on_change-style callback properties; very few use real DOM events. We are
moving towards standard DOM events (e.g. dispatchEvent(new CustomEvent("change", { detail }))) so consumers can use
addEventListener. scripts/widgets/ui_listbox.ts is the first widget
converted: it dispatches a "change" event (detail = { id, item }) and keeps
its on_change callback only as a deprecated backwards-compat shim. New
widgets should prefer DOM events; convert existing callbacks opportunistically,
leaving the old callback in place and @deprecated.
- Containers and property binding — how
Container.propand the other build methods (slider,check,listenum,colorbutton, …) turn aToolProperty-typed datapath into a widget: which widget each property type gets, thePropFlagsandPackFlagsthat steer the choice, the three slider styles, path prefixes, mass set, and undo. - Menus —
Menu,DropBoxand the menu wrangler (scripts/menu/;scripts/widgets/ui_menu.tsis a deprecated re-export shim): menu templates (tool paths, separators, custom entries, submenus),container.menu,createMenu/startMenufor context menus, enum dropdowns, andEditor.registerAppMenufor the app menu bar. - ListBox — scrollable single-select list, in
either manual (
addItem) orDataList-backed mode, with a typed"change"event and user-resizable corner grip. - Asset gallery — searchable, virtualized grid of
thumbnails (
AssetGallery,AssetGalleryGrid,AssetThumb), the sharedThumbnailCachebehind it, andpickAssetPopupfor choosing one item. - Dock panels — dockable/floatable editor
panels (
PanelManagerinscripts/screen/dock_panels.ts): editors declare panels indefinePanels(), users drag them between edge regions, float them, or collapse regions to edge-aligned tab rails; layouts serialize viaArea.STRUCT'spanelLayoutfield. - Node editor — the node-graph data model
(
scripts/graph/, exported as thenodegraphnamespace: node/socket types, groups, DSL, ToolOps, data API) plus the editor layer:NodeGraphViewis a hostable graph widget, andNodeEditoris an Area shell shipped unregistered for the consumer toArea.register.
Children of UIBase should all take a CTX generic parameter that extends
IContextBase and defaults to IContextBase. They should pass this parameter
up the inheritance chain, e.g.:
class MyWidget<CTX extends IContextBase = IContextBase> extends UIBase<CTX> {
}If widgets implement getValue with a specific type they should pass that to UIBase, e.g.
class MyNumberWidget<CTX extends IContextBase = IContextBase> extends UIBase<CTX, number> {
value = 1.0
getValue(): number {return this.value}
setValue(value: number) {return this.value}
}ToolOp has a strongly typed property system. Properties are created at runtime in the tooldef static method, and their types are declared in parameters that are passed up the inheritance chain to ToolOp.
For example:
class Tool extends ToolOp<{
input1: FloatProperty,
input2: IntProperty
}, {
output1: StringProperty,
output2: BoolProperty
}> {
static tooldef() {
return {
toolpath: 'tool',
// inputs must match input list in generic parameters
inputs: {
input1: new FloatProperty(2.0)
input2: new IntProperty(1)
},
// outputs must match output list in generic parameters
outputs: {
output1: new StringProperty("out1"),
output2: new BoolProperty(false),
}
}
}
}To handle inheritance, do this:
class Tool1<Inputs extends PropertySlots, Outputs extends PropertySlots> extends ToolOp<
Inputs & {
input1: FloatProperty,
input2: IntProperty
},
Outputs & {
output1: StringProperty,
output2: BoolProperty
}> {
static tooldef() {
return {
toolpath: 'tool1',
// inputs must match input list in generic parameters
inputs: {
input1: new FloatProperty(2.0)
input2: new IntProperty(1)
},
// outputs must match output list in generic parameters
outputs: {
output1: new StringProperty("out1"),
output2: new BoolProperty(false),
}
}
}
}
// tool2 will have access to both it's parent class's properties and it's own
class Tool2 extends Tool1<{
input3: FloatProperty,
input4: IntProperty
}, {
output3: StringProperty,
output4: BoolProperty
}> {
exec(ctx){
const {input1, input2, input3, input4} = this.getInputs()
}
static tooldef() {
return {
toolpath: 'tool2',
// inputs must match input list in generic parameters
inputs: {
input3: new FloatProperty(2.0)
input4: new IntProperty(1)
},
// outputs must match output list in generic parameters
outputs: {
output3: new StringProperty("out1"),
output4: new BoolProperty(false),
}
}
}
}Editors and widgets do not implement modal dragging behaviours themselves —
no _dragging flags, no pointermove/pointerup listeners tracking a drag.
A pointerdown handler decides that a gesture has started and spawns a modal
ToolOp (is_modal: true in its tooldef) via
ctx.toolstack.execTool(ctx, new Op(...), event); passing the event routes
the pointer capture to the op, which then receives on_pointermove /
on_pointerup / on_pointercancel and keyboard events until it calls
modalEnd. The node editor's scripts/editors/nodeeditor/gesture_ops.ts
and PanZoomPanOp in scripts/widgets/ui_panzoom.ts are the reference
implementations.
Every ToolOp must be properly undo/redo-able. A gesture op that only
previews (or changes pure view state such as selection or the camera)
carries UndoFlags.NO_UNDO and commits any document change on release by
dispatching a separate, undoable op — for the node editor, through the
view's delegate — so the committed op is the single entry the gesture
leaves on the undo stack. Escape cancels the gesture and restores whatever
its preview changed. Per the modal-tool rule in
scripts/path-controller/toolsys/toolsys.ts, an op may hold direct widget
pointers during the drag but must clear them in modalEnd.
Client apps often layer a toolmode system on top of an editor, where the active toolmode's pointerdown decides which modal ToolOp to spawn. Not all clients do this; path.ux's own editors spawn their ops directly and leave any toolmode layer to the client.
The scripts/path-controller/ directory is a git submodule. Commit changes there separately before updating the parent repo's submodule pointer.
cd scripts/path-controller && git add -A && git commit -m "msg"
cd ../.. && git add scripts/path-controller && git commit -m "update submodule"Commit a parent repo and its submodules together whenever their branch names
match, or both are on their default branches: make the submodule commit, then bump
the parent's gitlink, as one logical change. This applies both to
path-controller under path.ux and to path.ux itself under any superproject that
embeds it. (Pinned third-party submodules — e.g. a parent's extern/imgui
— are the exception and are bumped deliberately, not auto-co-committed; path.ux has
no such pinned submodules.)
Parent on a branch, submodule on its default branch: do not silently commit or
advance the submodule's shared default branch. Ask the user whether they want to
commit and/or push the submodule's default branch (and bump the gitlink) before
doing so. This applies to path-controller under path.ux and to path.ux itself
under any superproject.
Worktree teardown: before removing a worktree, every submodule sitting on its default branch (path.ux has no pinned exceptions) must be committed and pushed, so no work is lost when the checkout goes away.
Commit messages should be terse and use bullet points. The message should lead with what the purpose of the commit is and high level information.