** This file was created with an agent. **
Status: PROPOSAL / not implemented. As of 2026-07-16,
static INTERFACEandstatic REQUIREMENTSon widgets do not exist in the codebase, and neither does thewidget()/field()/group()recipe DSL described below. The code snippets in this document illustrate a proposed API — they are not descriptions of current behaviour. The analysis of the current limitations (stringly-typed dependency mappings, the flat 1:1_defineGenericWidgets,uiElementsMapas the reuse baseline) is accurate at the time of writing. Treat this as an RFC.Originally generated 2026-02-25; filed under
docs/planning/2026-07-16.Part 3 of 3 — the most current view in the widget-setup series. The “Corrections Applied” section below overrules the central theses of the two prior iterations:
design-analysis-self-describing-widget-setup.md(part 1, proposesstatic REQUIREMENTS) anddesign-analysis-widget-setup-evaluation.md(part 2, critiques part 1 and proposes model-declared UI hints).
REGISTERED_GENERIC_KEYMOMENT_FIELDS friction is a minor annoyance,
not the central problem. The real design challenge is composability.TypeRoof currently has two widget construction modes:
_defineGenericWidgets)this._defineGenericWidgets(
TypeClass
, fieldName => allowList.has(fieldName)
, {zone: 'local'}
, GENERIC
, injectable
)
Strength: Zero boilerplate per field. Weakness: Flat list. No grouping, no ordering, no interstitials. Iterates fields one-by-one — assumes 1:1 field→widget.
[
{zone: 'main', rootPath: Path.fromParts(...)}
, [['fontSize', 'value'], ['/font', 'font']]
, UIColorChooser
, zones, 'Text Color', getDefaults, updateDefaultsDependencies, requireUpdateDefaults
]
Strength: Total control. Weakness: Verbose, error-prone, requires deep internal knowledge.
┌─────────────────────────────────┐
│ Section: "Typography" │ ← label/container
│ ┌─────────────────────────────┐ │
│ │ [auto: font selector] │ │ ← type-driven
│ │ [auto: font size] │ │ ← type-driven
│ └─────────────────────────────┘ │
│ ── separator ── │ ← interstitial
│ Section: "Contextual Settings" │ ← label/container
│ ┌─────────────────────────────┐ │
│ │ [auto: padMode] │ │ ← type-driven
│ │ [auto: customPad] │ │ ← conditional visibility
│ │ [auto: charGroup] │ │ ← complex: 1:N
│ └─────────────────────────────┘ │
│ ── separator ── │ ← interstitial
│ Section: "Appearance" │ ← label/container
│ ┌─────────────────────────────┐ │
│ │ [custom: color picker] │ │ ← manual override
│ │ [auto: showCellBoxes] │ │ ← type-driven
│ └─────────────────────────────┘ │
└─────────────────────────────────┘
1:1 — Field → Widget (common, simple)
padMode → one select dropdown. The happy path.
1:N — Field → Multiple Widgets (complex fields)
charGroup → UICharGroupContainer builds multiple sub-widgets internally.
The field is complex enough to warrant an entire sub-UI.
N:1 — Multiple Fields → One Widget (composite controls)
FontSelect reads activeFontKey (to select) + options (to populate
the dropdown). It consumes two model paths through one widget.
UIColorChooser reads/writes through its rootPath but also consumes
animationProperties@ via requireUpdateDefaults for defaults.
0:1 — No Field → Widget (derived/action UI) Preview displays, action buttons, computed status indicators.
Currently, dependency mappings are untyped string pairs:
// ComponentWrapper receives:
rawDependencyMapping = [
['fontSize', 'value'] // external 'fontSize' → internal 'value'
, ['/font', 'font'] // external '/font' → internal 'font'
]
The widget knows it calls this.getEntry('value') and
this.getEntry('font') — but nothing declares what types those
entries should be. The binding is stringly-typed.
Each widget declares its typed interface — what bindings it accepts, what types it expects on each, and what role each binding serves.
// FontSelect implicitly expects:
// 'activeFontKey' — a ForeignKey-ish string value (read/write)
// 'options' — a list/map of available fonts (read-only)
// But this is only discoverable by reading the source:
update(changed) {
if(changed.has('options')) // ← implicit: needs 'options'
this._updateOptions(changed.get('options'));
if(changed.has('activeFontKey')) // ← implicit: needs 'activeFontKey'
this._updateValue(changed.get('activeFontKey'));
}
// UIToggleButton implicitly expects:
// 'boolean' — a BooleanModel value (read/write)
update(changedMap) {
if(changedMap.has('boolean')) // ← implicit: needs 'boolean'
this._ui.update(changedMap.get('boolean').value);
}
export class FontSelect extends _BaseComponent {
// Typed interface declaration
static INTERFACE = {
activeFontKey: {
type: StringModel // or ForeignKeyModel
, role: 'value' // semantic: this is the primary editable value
, access: 'read-write'
, required: true
}
, options: {
type: InstalledFontsModel // or a compatible type
, role: 'options' // semantic: populates choices
, access: 'read-only'
, required: true
}
};
// ...
}
export class UIToggleButton extends _BaseComponent {
static INTERFACE = {
boolean: {
type: BooleanModel
, role: 'value'
, access: 'read-write'
, required: true
}
};
// ...
}
export class UIResetButton extends _BaseComponent {
static INTERFACE = {
target: {
type: _AbstractStructModel // accepts any struct
, role: 'action-target'
, access: 'write-only' // never reads, only resets
, required: true
}
};
}
export class UIColorChooser extends _BaseContainerComponent {
static INTERFACE = {
color: {
type: ColorModel
, role: 'value'
, access: 'read-write'
, required: true
, isRootPath: true // this binding sets the rootPath
}
};
// Constructor args are separate from bindings
static REQUIREMENTS = [
require('settings:rootPath')
, require('zones')
, require('label')
, require('getDefault')
, require('updateDefaultsDependencies')
, require('raw:requireUpdateDefaults')
];
}
Widget bindings have three access modes:
| Access | Reads model? | Writes model? | Example |
|---|---|---|---|
read-only |
✅ | ❌ | Preview display, status indicator, font metrics viewer |
write-only |
❌ | ✅ | Action button, file picker that sets a path, reset trigger |
read-write |
✅ | ✅ | Most form inputs (select, text, toggle, color chooser) |
This distinction matters for user-facing composition:
A user composing an interface could filter: “Show me widgets that can display this font metric” vs “Show me widgets that can edit this color.”
These serve different purposes and are not redundant:
| Aspect | INTERFACE | REQUIREMENTS |
|---|---|---|
| What it describes | Data bindings (model paths → widget) | Constructor parameters |
| When it’s used | Runtime: wiring model state to widget updates | Build time: creating the widget instance |
| Cardinality | Maps internal names to expected types | Positional constructor args |
| Purpose | Compatibility checking, smart suggestions | Dependency resolution |
| User-facing | Yes: “which widgets work with this data?” | No: internal plumbing |
A widget’s INTERFACE says: “I consume a BooleanModel on my boolean binding.”
A widget’s REQUIREMENTS says: “My constructor needs a label string and a classToken.”
INTERFACE is about data flow. REQUIREMENTS is about instantiation.
Given a model field of type ColorModel, a recipe builder can query:
function findCompatibleWidgets(modelType) {
return ALL_WIDGETS.filter(W =>
Object.values(W.INTERFACE || {}).some(binding =>
binding.role === 'value'
&& (modelType === binding.type
|| modelType.prototype instanceof binding.type)
)
);
}
findCompatibleWidgets(ColorModel)
// → [UIColorChooser, UISimpleColorPicker, UIColorSliders, ...]
A user building a custom interface gets presented: “For this color field, you can use: Color Chooser (full), Simple Color Picker, or Color Sliders.”
Different widgets for the same data type — user preference:
// All three accept BooleanModel on 'boolean' binding:
class UIToggleButton { static INTERFACE = { boolean: { type: BooleanModel, role: 'value' } } }
class UICheckbox { static INTERFACE = { boolean: { type: BooleanModel, role: 'value' } } }
class UISwitch { static INTERFACE = { boolean: { type: BooleanModel, role: 'value' } } }
In a recipe:
field('showCellBoxes') // default: system picks
field('showCellBoxes', { prefer: UISwitch }) // user override: switch style
For N:1 widgets, INTERFACE declares all required bindings:
class UIPositionPicker extends _BaseComponent {
static INTERFACE = {
x: { type: NumberModel, role: 'coordinate', access: 'read-write', required: true }
, y: { type: NumberModel, role: 'coordinate', access: 'read-write', required: true }
};
}
A recipe builder can validate that both bindings are satisfied:
widget(UIPositionPicker, {
bindings: { x: 'positionX', y: 'positionY' }
})
// Validator checks: positionX is NumberModel ✓, positionY is NumberModel ✓
If INTERFACE declares what internal names the widget uses, and the recipe declares which model paths to bind, the dependency mappings can be auto-generated:
// Instead of manually writing:
[['fontSize', 'value'], ['/font', 'font']]
// The recipe processor generates it from:
widget(UINumberInput, {
bindings: { value: 'fontSize' }
// font not needed for UINumberInput
})
// → dependencyMappings = [['fontSize', 'value']]
An agent (like me) implementing a new layout can ask:
“What widgets accept PadModeModel?” instead of searching source code.
A documentation generator can produce widget catalogs:
“FontSelect: requires activeFontKey (StringModel, read-write) and
options (InstalledFontsModel, read-only).”
widget()// Fully explicit — N:1 binding, validated by INTERFACE
widget(UIPositionPicker, {
bindings: { x: 'positionX', y: 'positionY' }
})
field()// 1:1 — auto-resolves widget from model type
field('padMode')
// 1:1 with preference — user chose a specific widget style
field('padMode', { prefer: UIRadioGroup })
// 1:1 with conditional visibility
field('customPad', { visibleWhen: { field: 'padMode', notEquals: 'empty' } })
When field() is processed:
PadModeOrEmptyModel)role: 'value')prefer if specified, otherwise use uiElementsMap defaultgroup('Contextual Settings', [
field('charGroup')
, field('padMode')
, field('customPad')
])
separator()
autoFields({ exclude: ['keyMoments'] })
autoFields()// Iterate remaining model fields, find compatible widgets, auto-bind
autoFields({
exclude: ['keyMoments', 'activeActors']
, preferenceMap: {
'showCellBoxes': UISwitch // user preference for this field
}
})
┌──────────────────────┐
│ Model (metamodel) │ Fields with types (PadModeModel, ColorModel, ...)
└──────────┬───────────┘
│ "What data exists?"
▼
┌──────────────────────┐
│ Widget INTERFACE │ Typed bindings (what data each widget accepts)
└──────────┬───────────┘
│ "Which widgets are compatible?"
▼
┌──────────────────────┐
│ Recipe │ Composition (field, widget, group, separator)
│ │ with optional user preferences
└──────────┬───────────┘
│ "How should widgets be arranged?"
▼
┌──────────────────────┐
│ Recipe Processor │ Resolves bindings, validates types, generates
│ │ widget setup arrays using _getWidgetConfig
└──────────┬───────────┘
│ "Build the actual widgets"
▼
┌──────────────────────┐
│ ComponentWrapper │ Manages lifecycle, rootPath, dependency mapping
│ + _initWidgets │ (existing infrastructure — unchanged)
└──────────────────────┘
User selects model field "stageBackgroundColor"
│
▼
System reads: TypeClass.fields.get('stageBackgroundColor') → ColorModel
│
▼
System queries: findCompatibleWidgets(ColorModel)
│ Checks each widget's static INTERFACE for role:'value'
│ with type === ColorModel or type instanceof compatible
▼
Presents options:
┌─────────────────────────────────┐
│ ○ Color Chooser (full editor) │ UIColorChooser
│ ○ Simple Swatch │ UIColorSwatch
│ ○ Color Sliders (RGB/HSL) │ UIColorSliders
│ ○ Hex Input │ UIColorHexInput
└─────────────────────────────────┘
User picks → recipe stores preference → renders chosen widget
| Concept | Already exists as | Proposed evolution |
|---|---|---|
| Type → Widget mapping | uiElementsMap |
INTERFACE enables discovery + alternatives |
| Dependency resolution | _getArgumentConfig switch |
Unchanged — recipes produce same output |
| Widget lifecycle | ComponentWrapper |
Unchanged — consumes same setup arrays |
| Model type info | TypeClass.fields |
Unchanged — recipes read from it |
| Named dependencies | require() / InjectDependency |
REQUIREMENTS = same pattern, on class |
| Addition | Purpose | Effort |
|---|---|---|
static INTERFACE on widgets |
Typed binding declarations | Low per widget, gradual |
processRecipe() function |
Recipe → widget setup arrays | Medium (new file) |
findCompatibleWidgets() |
INTERFACE-based widget discovery | Low |
Recipe elements (field, widget, group, etc.) |
Composition DSL | Low |
| Binding validator | Type-check recipe bindings vs INTERFACE | Low |
Phase 1: INTERFACE declarations (non-breaking, additive)
Add static INTERFACE to key widgets: UIToggleButton, FontSelect,
UIColorChooser, UISelectInput, UILineOfTextInput, UINumberInput.
This is pure documentation initially — nothing consumes it yet.
Phase 2: Recipe infrastructure (new file, non-breaking)
Create widget-recipes.mjs with recipe elements and processor.
Convert one layout to recipes as proof of concept.
Phase 3: findCompatibleWidgets (enables smart suggestions)
Build the widget discovery function that queries INTERFACE.
This is the foundation for user-generated interfaces.
Phase 4: REQUIREMENTS (optional, enhances recipes)
Add static REQUIREMENTS to complex widgets where it saves
significant boilerplate in custom() recipe elements.
Phase 5: User-facing recipe builder (the endgame) Visual tool that reads model fields, presents compatible widgets, and produces serializable recipes.
Three complementary concepts, each independently useful:
| Concept | What it solves | User-facing? |
|---|---|---|
| INTERFACE | “What data does this widget accept?” — typed bindings | ★★★★★ |
| Recipes | “How should widgets be composed?” — structure + ordering | ★★★★ |
| REQUIREMENTS | “How is this widget instantiated?” — constructor args | ★★ |
INTERFACE is the most impactful for user-generated interfaces because it answers the question users actually ask: “What can I use to edit this field?”
Recipes are the most impactful for developer ergonomics because they eliminate the manual widget setup boilerplate while supporting composition.
REQUIREMENTS is the most impactful for DRY because it eliminates
duplicate require() declarations between uiElementsMap and call sites.
Together they form a complete composable widget system:
Generated: 2026-02-25
Key insights:
- Field ≠ Widget: cardinalities are 1:1, 1:N, N:1, 0:1
- Widget typed interfaces enable smart suggestions for user-generated UI
- INTERFACE (capability) ≠ REQUIREMENTS (instantiation) — different concerns
- uiElementsMap becomes one consumer of INTERFACE, not the only source