Let an operation say what it is about, so the panel can group without naming

Tool tabs need a taxonomy, and the taxonomy was the problem: a table in
`ui/` mapping operation to tab breaks FR-DEV-3a, and a `group:` field risks
what `ui-refinement.md` condemned `starts-group` for — the core deciding
where the panel draws things.

`Attribute` threads the needle. It says what an operation *is* — tone,
colour, detail, optics, geometry, effect — which is the same category as
`ParamKind` and squarely on the core's side of ARCH §4.3a's line. What is
drawn, where it sits and whether it is visible stay the frontend's. There is
no attribute for "the third tab", the enum's order is declaration order
rather than screen order, and a frontend may render these as tabs, as
headings, or ignore them.

The payoff is that a tab strip can be *derived*: the groups are the
attributes present in the capability list, so the interface names no
operation and needs no table to keep in step. An operation joins the right
group by declaring what it is, which is the one thing its author is well
placed to say.

Plural, because the tone curve is genuinely both — an RGB curve is tonal and
the per-channel curves are chromatic, and filing it under one would hide it
from half the people looking for it.

Required and non-empty, enforced in `build.rs`, and the failure was checked
by removing the line rather than assumed. An operation with no attribute is
invisible to a panel that groups by them; a build that stops costs ten
seconds, a control nobody can find costs more. The vocabulary is closed for
the same reason: a typo would otherwise invent a category holding exactly one
operation, which looks like a deliberate one until somebody counts.

Six tests over the real chain, including the hand-written operations that
`build.rs` never sees and so cannot check.
This commit is contained in:
2026-08-22 10:10:00 +02:00
parent acd694c2cf
commit 7421837c8a
26 changed files with 385 additions and 17 deletions
+27
View File
@@ -9,6 +9,29 @@ indistinguishable downstream from a hand-written operation: the same
`&'static OpDescriptor`, the same fused-shader composition, the same sidecar
round-trip.
## `attributes:` — what the operation is about
Required, one or more of `tone`, `colour`, `detail`, `optics`, `geometry`,
`effect`. The build fails without it, and that is deliberate: an operation with
no attribute is invisible to an interface that groups by them, and a control
that silently does not exist is a worse failure than a build that stops.
**Say what the operation is, not where you want it drawn.** The core declares
capabilities; the frontend composes (ARCH §4.3a). `tone` means "this is about
lightness", not "put this in the second tab" — the interface is free to render
attributes as tabs, as headings, or not at all, and that choice may differ
between a tablet and a desktop without this file changing.
**Plural is normal.** The tone curve is `[tone, colour]` because an RGB curve
is tonal and the per-channel curves are chromatic; it appears wherever the
frontend decides that means. Reach for a second attribute when an operation
genuinely answers two questions, not to make it easier to find.
**The vocabulary is closed.** A typo is a build error rather than a new
category containing exactly one operation — which looks identical to a
deliberate new category until somebody notices the group with one control in
it.
## What you get for free
A node dropped in here arrives with:
@@ -16,6 +39,9 @@ A node dropped in here arrives with:
- **Controls.** The develop panel builds them from the parameters' declared
kinds (FR-DEV-3c). It never learns the node's name.
- **A place in the chain**, from `order:`.
- **A place in the panel**, from `attributes:`. The interface groups by these
and names no operation, so a node arrives in the right group by saying what
it is rather than by anyone editing a list in `ui/`.
- **Persistence.** Parameters are ordinary scalars, so the sidecar writes them
with everything else.
- **Its tests**, compiled from `tests:` and run with `cargo test`.
@@ -28,6 +54,7 @@ A node dropped in here arrives with:
id: exposure # must match the filename
label: op.exposure # a localisation key, never a display string
order: 20 # where it sits in the chain
attributes: [tone] # what it is about; one or more, and required
doc: | # becomes the generated module's documentation
Exposure — a linear gain, expressed in stops.
+1
View File
@@ -1,6 +1,7 @@
id: blacks_whites
label: op.blacks_whites
order: 50
attributes: [tone]
doc: |
Blacks and whites — the endpoints, where the image clips.
+1
View File
@@ -1,6 +1,7 @@
id: brilliance
label: op.brilliance
order: 70
attributes: [tone]
doc: |
Brilliance — shadows up and highlights down at once.
+1
View File
@@ -1,5 +1,6 @@
id: colour_mixer
order: 100
attributes: [colour]
rust: ColourMixer
why_rust: |
+1
View File
@@ -1,6 +1,7 @@
id: contrast
label: op.contrast
order: 30
attributes: [tone]
doc: |
Contrast — an S-curve about a fixed mid-point.
+1
View File
@@ -2,6 +2,7 @@
id: exposure
label: op.exposure
order: 20
attributes: [tone]
doc: |
Exposure — a linear gain, expressed in stops.
@@ -1,6 +1,7 @@
id: highlights_shadows
label: op.highlights_shadows
order: 40
attributes: [tone]
doc: |
Highlights and shadows — broad, overlapping recovery at both ends.
+1
View File
@@ -1,6 +1,7 @@
id: saturation
label: op.saturation
order: 90
attributes: [colour]
doc: |
Saturation — every colour's distance from grey, scaled equally.
+5
View File
@@ -8,6 +8,11 @@
# to learn.
id: tone_curve
order: 60
# Both, and this is the case the plural exists for: the RGB curve is
# tonal and the per-channel curves are chromatic. Filing it under one
# would hide it from half the people looking for it.
attributes: [tone, colour]
rust: ToneCurve
why_rust: |
+1
View File
@@ -1,6 +1,7 @@
id: vibrance
label: op.vibrance
order: 80
attributes: [colour]
doc: |
Vibrance — saturation weighted toward the muted colours.
+1
View File
@@ -1,6 +1,7 @@
id: white_balance
label: op.white_balance
order: 10
attributes: [colour]
doc: |
White balance — temperature and tint, relative to as-shot.