Let a declared node ask for a widget, and write down how

Two gaps in the control vocabulary, both found by reading back what 0a331c7
actually shipped against what it claimed.

**`kind: enum` worked and was undocumented.** The whole point of that kind is
that a node author can declare a control without touching the interface, and
ops/README.md is where a node author looks. A kind absent from the table is one
nobody will use. It is now in the table, with the property that makes it cheap
spelled out: the value is the chosen index carried as an `f32` like every other
parameter, so a uniform expression can read it, the sidecar stores it
unchanged, and nothing along the way needed a second kind of value.

**A declared node could not ask for a widget at all.** `Presentation` gained an
ordered preference list and a demand, and `tone_curve` and `framing` use them —
but both are hand-written Rust. A YAML node had no way to say that several of
its parameters form one control, so the generated half of the pipeline was
locked out of the half of FR-DEV-3a that makes widgets extensible. Nodes now
take a `presentation:` block:

    presentation:
      widgets: [colour_wheel]
      demand: { two_dimensional: true }
      params: [hue, strength]

`demand` carries exactly two flags and there is deliberately no key for a pixel
width, a breakpoint or a platform name — those are the frontend's to decide,
and ARCH §4.3a is explicit that a core reasoning about them will eventually be
wrong about a display it never saw.

Widget names are spelled in the YAML the way `WidgetKind` spells them, so a
declaration and descriptor.rs cannot drift into two vocabularies for one idea,
and an unknown one is a build error listing the six that exist. `params:` is
checked against the node's own parameters: the widget claims what it names and
the generic path skips what was claimed, so a typo would silently drop a
parameter out of *both* — the one mistake here that produces no error and no
control.

Verified by declaring a presentation on a node, confirming the generated
`fn presentation` matched, and confirming both error paths report the file and
the key; then reverted, since no node in the chain wants a widget yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 23:54:10 +02:00
co-authored by Claude Opus 5
parent 5e4b8de18d
commit 23f0c4b76a
2 changed files with 201 additions and 1 deletions
+59
View File
@@ -54,6 +54,10 @@ define: # helpers this node alone needs
wgsl: | # `c` is linear RGB in and out
c = c * gain;
presentation: # optional; omit for one control per parameter
widgets: [tone_curve] # a hint, most preferred first
params: [exposure] # what that widget owns
tests:
- name: one_stop_is_a_doubling
why: The definition of a stop.
@@ -69,11 +73,32 @@ tests:
| `stops` | exposure-like, in stops | `min`, `max` |
| `fraction` | 0…1 | `default` |
| `switch` | a toggle, neutral off | — |
| `enum` | one of a short, fixed list | `variants` |
| `scalar` | anything else | `min`, `max`, `default`, `unit`, `scale`, `precision` |
Reach for `amount` first. Writing its range out by hand in each node is how one
of them comes to disagree with the rest.
An `enum` names its choices as localisation keys, most-neutral first:
```yaml
params:
method:
label: param.method
kind: enum
variants: [param.method.fast, param.method.exact]
```
The value is the chosen **index**, carried as an `f32` like every other
parameter — so a uniform expression can use it (`method` is 0 or 1), the
sidecar stores it unchanged, and nothing along the way needed a second kind of
value. The default is always the first variant, so `reset` means what it means
everywhere else; if your neutral choice is not first, reorder the list rather
than reaching for a `default:`.
Two variants is the minimum. A one-entry list is a control the user cannot
change, and the build rejects it.
### Uniform expressions
Arithmetic (`+ - * /`), parentheses, numbers, this node's parameters, and:
@@ -91,6 +116,40 @@ default, which is the honest rule and the right one for nearly everything. A
node whose neutral is something else declares `active:` as an expression —
non-zero means active.
### Presentation
Optional, and absent from nearly every node: one control per parameter is the
right answer for a list of unrelated sliders, which is what most operations
are. Declare it when several parameters form **one** conceptual control.
```yaml
presentation:
widgets: [colour_wheel] # most preferred first
demand:
two_dimensional: true # dragged in x and y at once
precise_pointing: false # needs accuracy finer than a fingertip
params: [hue, strength] # what the widget owns, in its order
```
Widgets: `tone_curve`, `colour_wheel`, `crop_overlay`, `gradient_handle`,
`brush_mask`, `white_point`.
**It is a hint and only a hint.** The frontend walks `widgets` and takes the
first it both implements and can afford; if it implements none of them, or
cannot meet the `demand`, it renders the parameters as ordinary sliders and the
edit still works. So it is safe to name a widget nothing draws yet — the node
degrades to sliders rather than breaking. That fallback is also why every
parameter must stay an ordinary scalar: it is what there is to fall back *to*.
`demand` says what the widget inherently needs, never what the screen has.
There is deliberately no way to write a pixel width, a breakpoint or a platform
name here — those are the frontend's to decide, and a node that reasoned about
them would eventually be wrong about a display it never saw (ARCH §4.3a).
Parameters named in `params:` are claimed by the widget and not drawn
separately, so a typo would take a parameter out of both. The build checks each
name against the node's own `params:` and fails if it does not match.
### Tests
| Key | Asserts |