git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commita28a07e179bed7f814327c89c3d2a031650794e1
parentb7f12bb613
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-01 21:31
feat: a well with a flush run at its end is one field

A text row with its picker and a spinbox with its -/+ run were a recess
and a trough side by side, each turning its own corner at the seam, so
the outline broke there. Prim::Field draws them as one shape: one outline
whose outer half is the same profile all the way round, the inner half
blending from the well's floor to the run's face at the seam, and a step
wall along the seam.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md                             |  53 ++++++++-----
 src/backend/window_runner.rs          |  49 +++++++++++-
 src/layout.rs                         |  21 ++++++
 src/scene/paint.rs                    |  31 ++++++++
 src/vk/shader2d.wgsl                  |  46 +++++++++++-
 src/widget/container/parameters_bg.rs | 130 +++++++++++++++++++++-----------
 src/widget/input/spinbox.rs           | 135 ++++++++++++++++++++--------------
 7 files changed, 347 insertions(+), 118 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index ca60e6d..a08a273 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -317,24 +317,41 @@ cce-files does: a `PopoverCollector` keeps fills as plain rects, so the grown pl
 loses its relief and its corners (`inset_plate` degrades to a flat fill there). The
 designer's params pane went through one until the same day.
 
-### A text row's picker is the field's right end (since 2026-10-01)
-
-A `textpick` row in `ParametersBg` — a TextBox with a completion picker — is one field
-of two parts. The picker (a menu-button Dropdown, `PICK_W` wide) is a flush control
-plate over the whole band below the label, reaching the field's outer edge on the top,
-right and bottom as a dropdown trigger's plate does: square at the seam, the text box's
-radius outside (`Dropdown::set_radii`, and its ring in `troughs`). The TextBox stops at
-the seam with its well's right corners square (`TextBox::joined_right`, which `well()`
-and the pane's `reliefs` both read). Until then the picker was a button nested inside the
-box's well, its face stopping at the base of the well's wall, so it never reached the edge
-a dropdown's ▼ does. `textpick_rows_carry_a_picker` holds the layout.
-
-**A spinbox's -/+ run is its right end the same way** (the same day):
-`Spinbox::relief_parts` makes the run a flush plate over the whole band, out to the
-outer edge, its ring all round, square at the seam and the spinbox radius outside, and
-the value's well ends there square (`relief_parts` returns its radii, which the widget's
-paint and the pane's `reliefs` both use). The seam between - and + crosses the run's face
-between its ring's walls. It was nested inside a full-width well, as the picker was.
+### A well with a flush run at its end is ONE field (since 2026-10-01)
+
+`Prim::Field { rect, radii, depth, split, tint }` (`PaintCtx::field`) is a sunken well
+that ends in a flush run: left of `split` the interior one step down, right of it back at
+the surface's level, as a flush control plate's face. Two controls are drawn so: a
+`ParametersBg` textpick row (the TextBox and its completion picker, a menu-button Dropdown
+`PICK_W` wide) and a `Spinbox` (its value and its -/+ run). In both the run is the
+control's right end, reaching its outer edge on the top, right and bottom as a dropdown
+trigger's plate does.
+
+It is one prim because two did not work: a recess for the well and a trough for the run,
+side by side, each shade their OWN box, so at the seam each turns its own square corner
+and the strong line of the edge jumps — a recess is lit at its outer rim, a trough at its
+inner lip — and the outline reads broken exactly where the two meet. The field's outline
+is evaluated once (the whole rect, its own radii, `MODE_FIELD` in `shader2d.wgsl`). Its
+outer half is ONE profile all the way round — the recess's fall and shoulder — and only
+the inner half differs: on to the floor in the well, mirrored back up to the face in the
+run, blended across one wall width about the seam. Note the run's valley is NOT
+`Prim::Trough`'s, which fits its whole fall and rise into the wall's width, so its outer
+half is a compressed copy of a step that read differently from the well's beside it. The
+seam is the well's floor rising to the run's face — a step along `split` — fading to
+nothing at the outline, where both sides stand at half the step. Never grouped into a host
+plate (its profile is not a monotonic step); the overlay's host-box slot carries `split`.
+The legacy banded path and flat hosts (`layout.rs`'s bridge) draw the two-box form.
+
+The pieces that feed it: `ParametersBg::fields` (the pane's list, drawn after its troughs,
+hover-tinted like them; textpick rows and spinboxes with a run are in neither `reliefs`
+nor `troughs`), `TextBox::joined_right` (the box stops at the seam), `Dropdown::set_radii`
+(the picker's own raised paint, square at the seam), and `Spinbox::relief_parts`, which
+returns a `SpinRelief` — the outline as handed over, its radius and depth, and the run's
+`split` with the engraved -/+ seam; the widget's own paint carves it inside as every well
+is. `textpick_rows_carry_a_picker` and `a_spinbox_is_one_field_with_its_run_at_the_right_end`
+are the tests. Until the same day the picker and the run were nested INSIDE a full-width
+well, their faces stopping at the base of its wall, so they never reached the edge a
+dropdown's ▼ does.
 
 ### A row can open a submenu (since 2026-09-29)
 
diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index 43fc11b..722da17 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -1851,7 +1851,7 @@ fn prim_kind(p: &crate::scene::paint::Prim) -> &'static str {
         P::Quad { .. } => "Quad", P::RoundedRect { .. } => "RoundedRect",
         P::Border { .. } => "Border", P::Bevel { .. } => "Bevel",
         P::Recess { .. } => "Recess", P::Boss { .. } => "Boss",
-        P::Ridge { .. } => "Ridge", P::Trough { .. } => "Trough", P::Plate { .. } => "Plate",
+        P::Ridge { .. } => "Ridge", P::Trough { .. } => "Trough", P::Field { .. } => "Field", P::Plate { .. } => "Plate",
         P::Arc { .. } => "Arc", P::ArcShaded { .. } => "ArcShaded",
         P::Vector { .. } => "Vector", P::Circle { .. } => "Circle",
         P::Sphere { .. } => "Sphere", P::Droplet { .. } => "Droplet",
@@ -2170,6 +2170,26 @@ pub fn tessellate_display_list(
                     made_plate = Some(*rect);
                 }
             }
+            // A sunken well ending in a flush run (see `Prim::Field`): one
+            // outline, one overlay — never grouped, its profile is not a
+            // monotonic step. The cover quad inflates by half the wall, as a
+            // free carve's does; the host-box slot carries the seam's x
+            // (physical px), since nothing fades against a host here.
+            Prim::Field { rect, radii, depth, split, tint } if shader_plates => {
+                let infl = *depth * 0.5 + 2.0;
+                verts.extend(quad_vertices(
+                    rect.x - infl, rect.y - infl,
+                    rect.width + 2.0 * infl, rect.height + 2.0 * infl,
+                    sw, sh, [0.0; 4],
+                ));
+                let mut p = plate_push_raised(rect, *radii, *depth, scale, plate_light, plate_mat, false, None);
+                p.mode = 16.0; // MODE_FIELD
+                if let Some(t) = tint {
+                    p.specular_tint = [t[0], t[1], t[2], 1.0];
+                }
+                p.host = [*split * scale, 0.0, 0.0, 0.0];
+                plate = Some(p);
+            }
             Prim::Recess { rect, radii, depth, edges, .. }
             | Prim::Boss { rect, radii, depth, edges, .. }
             | Prim::Ridge { rect, radii, depth, edges }
@@ -2522,6 +2542,33 @@ pub fn tessellate_display_list(
                     EdgeKind::Step, &mut verts,
                 );
             }
+            Prim::Field { rect, radii, depth, split, .. } => {
+                // Legacy approximation: the two-box form `Prim::Field`
+                // replaced — the well to the seam a step down, the run past it
+                // the Trough arm's down-then-up stack. The banded machinery
+                // has no blended outline, and the legacy path exists only for
+                // A/B comparison.
+                let all = (true, true, true, true);
+                let lw = (*split - rect.x).max(0.0);
+                push_bevel_edge_vertices_banded(
+                    rect.x, rect.y, lw, rect.height, (radii.0, 0.0, 0.0, radii.3), *depth,
+                    sw, sh, [0.0; 4], no, -1.0, default_bevel_bands(*depth), all,
+                    EdgeKind::Step, &mut verts,
+                );
+                let (rx, rw) = (*split, (rect.x + rect.width - *split).max(0.0));
+                let half = *depth * 0.5;
+                push_bevel_edge_vertices_banded(
+                    rx, rect.y, rw, rect.height, (0.0, radii.1, radii.2, 0.0), half,
+                    sw, sh, [0.0; 4], no, -1.0, default_bevel_bands(half), all,
+                    EdgeKind::Step, &mut verts,
+                );
+                let ir = (radii.1 - half).max(0.0);
+                push_bevel_edge_vertices_banded(
+                    rx + half, rect.y + half, rw - *depth, rect.height - *depth, (0.0, ir, ir, 0.0), half,
+                    sw, sh, [0.0; 4], no, 1.0, default_bevel_bands(half), all,
+                    EdgeKind::Step, &mut verts,
+                );
+            }
             Prim::Arc { cx, cy, radius, thickness, start: sa, end: ea, color } => {
                 push_arc_background_vertices(*cx, *cy, *radius, *thickness, *sa, *ea, sw, sh, *color, segs(*radius), no, &mut verts);
             }
diff --git a/src/layout.rs b/src/layout.rs
index 2cd165a..faefeed 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -4342,6 +4342,27 @@ pub fn render_widget<T: WidgetHost + 'static>(pc: &mut dyn RenderTarget, w: &mut
                     pending_face = Some((rect, radii, fill));
                 }
             }
+            // A flat host has no blended outline: the two-box form
+            // `Prim::Field` replaced — the well to the seam, the run's flush
+            // plate past it.
+            Prim::Field { rect, radii, depth, split, tint } => {
+                pc.relief_carve(&ReliefCarve {
+                    kind: CarveKind::Recess { tint },
+                    x: rect.x,
+                    y: rect.y,
+                    w: (split - rect.x).max(0.0),
+                    h: rect.height,
+                    radii: (radii.0, 0.0, 0.0, radii.3),
+                    depth,
+                    edges: (true, true, true, true),
+                });
+                let r = radii.1.max(radii.2);
+                let (rx, rw) = (split, (rect.x + rect.width - split).max(0.0));
+                match tint {
+                    Some(t) => pc.inset_plate_tinted([0.0; 4], rx, rect.y, rw, rect.height, r, depth, t),
+                    None => pc.inset_plate([0.0; 4], rx, rect.y, rw, rect.height, r, depth),
+                }
+            }
             Prim::Trough { rect, radii, depth, tint, .. } => {
                 let face = pending_face.take().map(|(_, _, fill)| fill).unwrap_or([0.0; 4]);
                 let (r1, r2, r3, r4) = radii;
diff --git a/src/scene/paint.rs b/src/scene/paint.rs
index 85e620a..0cb92e8 100644
--- a/src/scene/paint.rs
+++ b/src/scene/paint.rs
@@ -580,6 +580,28 @@ pub enum Prim {
     /// `tint` lights the rim like [`Prim::Recess`]'s — the focus treatment of a
     /// flush control plate (`PaintCtx::control_plate`).
     Trough { rect: Rect, radii: Radii, depth: f32, edges: (bool, bool, bool, bool), tint: Option<[f32; 3]> },
+    /// A sunken well that ends in a FLUSH run: one field, one outer contour.
+    /// Left of `split` (an x in the same space as `rect`) it is a
+    /// [`Prim::Recess`] — the interior one step down; right of it, a
+    /// [`Prim::Trough`] — a valley on the outline and the interior back at
+    /// the surface's level, as a flush control plate's. A parameter pane's
+    /// text row with its completion picker, a spinbox's value and its -/+ run.
+    ///
+    /// Why one prim and not the two it replaces, side by side: each of those
+    /// shades its OWN box, so at the seam the outline breaks — each box
+    /// turns its own square corner there, and the strong line of the edge
+    /// jumps from the recess's outer rim to the trough's inner lip. Here the
+    /// outline is evaluated once (the whole field, its own radii), its wall
+    /// blends from the step to the valley across a wall's width about the
+    /// seam — the two agree on the outer half, falling from the surface to
+    /// half the step, and part on the inner half — and the seam is the
+    /// well's floor rising to the run's face: a step wall along `split`,
+    /// fading to nothing where it meets the outer wall, the one place the
+    /// two sides stand at the same height.
+    ///
+    /// SDF path only; the legacy banded tessellation and flat hosts draw the
+    /// two-box form it replaced. `tint` lights it like a recess's.
+    Field { rect: Rect, radii: Radii, depth: f32, split: f32, tint: Option<[f32; 3]> },
     /// The window's glass slab: a rounded fill plus a rolled, lit edge around its whole
     /// perimeter, drawn at full size. Distinct from `Bevel`, which insets its fill by
     /// `depth` — a plate must fill the window exactly, or the compositor's rounded window
@@ -1086,6 +1108,7 @@ impl PaintCtx {
                 Some(t) => self.trough_tinted(rect, radii, depth, t),
                 None => self.trough_edges(rect, radii, depth, edges),
             },
+            Prim::Field { rect, radii, depth, split, tint } => self.field(rect, radii, depth, split, tint),
             Prim::Plate { rect, radii, material, depth, shape } => {
                 self.plate_shaped(rect, radii, &material, depth, shape)
             }
@@ -1527,6 +1550,14 @@ impl PaintCtx {
         self.push(Prim::Trough { rect, radii, depth, edges, tint: Some(tint) });
     }
 
+    /// A sunken well ending in a flush run at `split` — see [`Prim::Field`].
+    /// `tint` lights its rim (the focus and hover treatment).
+    pub fn field(&mut self, rect: Rect, radii: Radii, depth: f32, split: f32, tint: Option<[f32; 3]>) {
+        let split = split + self.offset.0;
+        let rect = self.apply_offset(rect);
+        self.push(Prim::Field { rect, radii, depth, split, tint });
+    }
+
     /// Raise a rim along `rect`'s boundary — see `Prim::Ridge`. `depth` is the
     /// full width of the bump (it straddles the outline by ±depth/2).
     pub fn ridge(&mut self, rect: Rect, radii: Radii, depth: f32) {
diff --git a/src/vk/shader2d.wgsl b/src/vk/shader2d.wgsl
index ca9ac2e..1f5efd7 100644
--- a/src/vk/shader2d.wgsl
+++ b/src/vk/shader2d.wgsl
@@ -157,6 +157,7 @@ const MODE_DROPLET_SCRIM: i32 = 12; // flat feathered fill of the droplet silhou
 const MODE_LATTICE: i32 = 13;     // periodic well field: nearest-cell carve, one evaluation
 const MODE_UNION: i32 = 14;       // union of feature boxes carved/raised as one wall
 const MODE_GROUT: i32 = 15;       // flat colour outside a periodic field of rounded cells
+const MODE_FIELD: i32 = 16;       // sunken well ending in a flush run: one outline (Prim::Field)
 // Fillet modes rejoin the shared free-carve path as their flat equivalents.
 const FILLET_TO_STEP: i32 = 4;    // 6 -> RECESS, 7 -> BOSS
 
@@ -868,6 +869,10 @@ fn plate_shade(frag: vec2f, vcol: vec4f) -> vec4f {
         fd = best;
         fgd = bgrad;
         eff = select(MODE_RECESS, MODE_BOSS, rrect_clip.p_radii.x > 0.5);
+    } else if (mode == MODE_FIELD) {
+        // The whole field's outline (p_rect / p_radii) as it stands; the
+        // profile branch below blends its wall and adds the seam.
+        eff = MODE_FIELD;
     } else if (mode == MODE_FILLET_DOWN || mode == MODE_FILLET_UP) {
         eff = mode - FILLET_TO_STEP;
         let c = frag - rrect_clip.p_rect.xy;
@@ -885,7 +890,39 @@ fn plate_shade(frag: vec2f, vcol: vec4f) -> vec4f {
     let cd = select(RECESS_DEPTH, window_info.relief_meta.x / t, window_info.relief_meta.x > 0.0);
     var slope = 0.0;
     var curv = 0.0;
-    if (eff == MODE_RIDGE || eff == MODE_TROUGH) {
+    // A second wall's slope and curvature, summed with the outline's: the
+    // seam of a MODE_FIELD.
+    var sv_seam = vec2f(0.0);
+    var curv_seam = 0.0;
+    if (eff == MODE_FIELD) {
+        // A well (the interior one step DOWN) left of the seam at
+        // p_host.x, a flush run (a valley on the outline, the interior back
+        // at the surface) right of it — one outline. The run's valley is
+        // NOT MODE_TROUGH's: that one fits its whole fall and rise into the
+        // wall's width, so its outer half is a compressed copy of a step and
+        // the edge read differently either side of the seam. Here the
+        // valley's outer half IS the well's — the same fall, the same
+        // shoulder — and its inner half that fall mirrored back up to the
+        // face. So the outline's outer half is one profile all the way round
+        // the field, and only the inner half, which the run's face really
+        // does rise from, blends across one wall width about the seam.
+        let split = rrect_clip.p_host.x;
+        let wf = smoothstep(split - 0.5 * t, split + 0.5 * t, frag.x);
+        let s_rec = -cd * carve_slope(u);
+        let c_rec = rrect_clip.p_mat.w * sin(u * TAU);
+        let s_tr = select(s_rec, cd * carve_slope(1.0 - u), u > 0.5);
+        let c_tr = rrect_clip.p_mat.w * sin(min(u, 1.0 - u) * TAU);
+        slope = mix(s_rec, s_tr, wf);
+        curv = mix(c_rec, c_tr, wf);
+        // The seam: the well's floor rising to the run's face — the well's
+        // own right wall, a step along x = split. Full inside the field,
+        // gone at the outline, where the outer wall stands both sides at
+        // half the step and there is nothing between them to climb.
+        let us = clamp((split - frag.x) / t + 0.5, 0.0, 1.0);
+        let fade = clamp(d / (0.5 * t), 0.0, 1.0);
+        sv_seam = vec2f(1.0, 0.0) * (-cd * carve_slope(us)) * fade;
+        curv_seam = rrect_clip.p_mat.w * sin(us * TAU) * fade;
+    } else if (eff == MODE_RIDGE || eff == MODE_TROUGH) {
         // Ridge bump: the carve profile mirrored about the boundary (rising
         // outer half, falling inner half), amplitude halved so the wall tilt
         // matches a step's despite the doubled profile rate. MODE_TROUGH is the
@@ -916,15 +953,16 @@ fn plate_shade(frag: vec2f, vcol: vec4f) -> vec4f {
         // boss.
         curv = -dir * rrect_clip.p_mat.w * sin(u * TAU);
     }
-    let sv = fgd * slope;
+    let sv = fgd * slope + sv_seam;
     let n = normalize(vec3f(sv, 1.0));
     let diff = PLATE_AMBIENT + (1.0 - PLATE_AMBIENT) * max(dot(n, l), 0.0);
     let spec = roll_spec(sv);
     // Fade the carve out across the host plate's perimeter roll (see p_host).
+    // A field's p_host carries its seam instead, and it fades against nothing.
     let hb = rrect_clip.p_host;
     let host_d = min(hb.z - abs(frag.x - hb.x), hb.w - abs(frag.y - hb.y));
-    let att = clamp(host_d / t, 0.0, 1.0) * wedge;
-    let v = (diff / flat_shade - 1.0 + curv + spec) * strength * att;
+    let att = select(clamp(host_d / t, 0.0, 1.0), 1.0, eff == MODE_FIELD) * wedge;
+    let v = (diff / flat_shade - 1.0 + curv + curv_seam + spec) * strength * att;
     // p_spec_tint.w = 1 marks a tinted carve — the FOCUS treatment. The
     // relief is the unfocused carve's, term for term; only its colours
     // change: the light composites in the accent instead of white and the
diff --git a/src/widget/container/parameters_bg.rs b/src/widget/container/parameters_bg.rs
index 4cca83f..18d43f2 100644
--- a/src/widget/container/parameters_bg.rs
+++ b/src/widget/container/parameters_bg.rs
@@ -1775,12 +1775,12 @@ impl ParametersBg {
                     if w > 0.0 && h > 0.0 {
                         let r = crate::layout::spinbox_corner_radius();
                         let depth = crate::layout::bevel_width().min((h - ty) * 0.2);
-                        match sb.inner().relief_parts(band) {
-                            Some((_, Some(((run, _, _, _), _)))) => {
-                                out.push((x, y + ty, run.x - x, h - ty, (r, 0.0, 0.0, r), depth, false, all));
+                        // With its -/+ run the control is a field
+                        // ([`Self::fields`]); without, a plain well.
+                        if let Some(rel) = sb.inner().relief_parts(band) {
+                            if rel.run.is_none() {
+                                out.push((x, y + ty, w, h - ty, r4(r), depth, false, all));
                             }
-                            Some((_, None)) => out.push((x, y + ty, w, h - ty, r4(r), depth, false, all)),
-                            None => {}
                         }
                     }
                 }
@@ -1815,9 +1815,12 @@ impl ParametersBg {
                 let depth = crate::layout::bevel_width().min((h - ty) * 0.2);
                 // A text box joined to its picker is square at the seam,
                 // as the box's own carve is (`TextBox::well`).
-                let joined = is_text_row(&p.2) && self.texts[i].as_ref().is_some_and(|t| t.inner().joined_right);
-                let radii = if joined { (radius, 0.0, 0.0, radius) } else { r4(radius) };
-                out.push((x, y + ty, ww, h - ty, radii, depth, raised, all));
+                // A text box joined to its picker is half of a field
+                // ([`Self::fields`]), drawn there.
+                if is_text_row(&p.2) && self.texts[i].as_ref().is_some_and(|t| t.inner().joined_right) {
+                    continue;
+                }
+                out.push((x, y + ty, ww, h - ty, r4(radius), depth, raised, all));
             }
         }
         out
@@ -1849,29 +1852,12 @@ impl ParametersBg {
             if hidden[i] {
                 continue;
             }
-            if p.2.starts_with("textpick") {
-                if let Some(d) = &self.choices[i] {
-                    // The picker's plate is the field's right end: its ring
-                    // all round, as a dropdown trigger's, square at the seam.
-                    let (bx, by, bw, bh) = d.rect();
-                    if bw > 0.0 && bh > 0.0 {
-                        let depth = crate::layout::bevel_width().min(bh * 0.2);
-                        let r = crate::layout::textbox_corner_radius();
-                        out.push((bx, by, bw, bh, (0.0, r, r, 0.0), depth, (true, true, true, true)));
-                    }
-                }
-            } else if p.2.starts_with("spinbox") {
-                if let Some(sb) = &self.spinboxes[i] {
-                    let (x, y, w, h) = sb.rect();
-                    let ty = sb.label_strip();
-                    let band = Rect { x, y: y + ty, width: w, height: h - ty };
-                    if let Some((_, Some(((run, radii, rd, edges), _)))) =
-                        sb.inner().relief_parts(band)
-                    {
-                        out.push((run.x, run.y, run.width, run.height, radii, rd, edges));
-                    }
-                }
-            } else if p.2.starts_with("choice") {
+            // A textpick row's picker and a spinbox's -/+ run are the flush
+            // ends of fields ([`Self::fields`]), not troughs of their own.
+            if p.2.starts_with("textpick") || p.2.starts_with("spinbox") {
+                continue;
+            }
+            if p.2.starts_with("choice") {
                 // The dropdown trigger: the widget's own raised paint is one
                 // `inset_plate` on its content band — the same ring here, on
                 // the same band (top-label band excluded),
@@ -1894,6 +1880,54 @@ impl ParametersBg {
         out
     }
 
+    /// The rows that are ONE field ([`crate::scene::paint::Prim::Field`]):
+    /// a sunken well ending in a flush run, one outline round both —
+    /// `(x, y, w, h, radii, depth, split)`, drawn AFTER [`Self::troughs`].
+    /// A textpick row is its text box's well and its picker; a spinbox, its
+    /// value's well and its -/+ run. On the pane's own rule for wells and
+    /// troughs: the content band as the outline, the wall straddling it.
+    #[allow(clippy::type_complexity)]
+    pub fn fields(&self) -> Vec<(f32, f32, f32, f32, (f32, f32, f32, f32), f32, f32)> {
+        if !self.visible || !crate::layout::control_relief() {
+            return Vec::new();
+        }
+        let mut out = Vec::new();
+        let hidden = self.hidden_rows();
+        for (i, p) in self.display_params.iter().enumerate() {
+            if hidden[i] {
+                continue;
+            }
+            if p.2.starts_with("textpick") {
+                if let (Some(tb), Some(d)) = (&self.texts[i], &self.choices[i]) {
+                    if !tb.inner().joined_right {
+                        continue;
+                    }
+                    let (x, y, w, h) = tb.rect();
+                    let (dx, _, dw, _) = d.rect();
+                    let ty = tb.label_strip();
+                    if w > 0.0 && h - ty > 0.0 && dw > 0.0 {
+                        let depth = crate::layout::bevel_width().min((h - ty) * 0.2);
+                        let r = crate::layout::textbox_corner_radius();
+                        out.push((x, y + ty, dx + dw - x, h - ty, (r, r, r, r), depth, dx));
+                    }
+                }
+            } else if p.2.starts_with("spinbox") {
+                if let Some(sb) = &self.spinboxes[i] {
+                    let (x, y, w, h) = sb.rect();
+                    let ty = sb.label_strip();
+                    let band = Rect { x, y: y + ty, width: w, height: h - ty };
+                    if let Some(rel) = sb.inner().relief_parts(band) {
+                        if let Some((split, _)) = rel.run {
+                            let r = rel.radius;
+                            out.push((band.x, band.y, band.width, band.height, (r, r, r, r), rel.depth, split));
+                        }
+                    }
+                }
+            }
+        }
+        out
+    }
+
     /// The engraved seams companion — `(a, b, width, depth, host)` for
     /// [`crate::scene::paint::PaintCtx::groove`], drawn AFTER [`Self::troughs`]
     /// (a groove engraves the surface the trough's control face provides).
@@ -1914,10 +1948,10 @@ impl ParametersBg {
                 let (x, y, w, h) = sb.rect();
                 let ty = sb.label_strip();
                 let band = Rect { x, y: y + ty, width: w, height: h - ty };
-                if let Some((_, Some(((_, _, rd, _), (sa, sb2, sw, host))))) =
-                    sb.inner().relief_parts(band)
-                {
-                    out.push((sa, sb2, sw, rd, host));
+                if let Some(rel) = sb.inner().relief_parts(band) {
+                    if let Some((_, (sa, sb2, sw, host))) = rel.run {
+                        out.push((sa, sb2, sw, rel.depth, host));
+                    }
                 }
             }
         }
@@ -2109,6 +2143,11 @@ impl Paint for ParametersBg {
                 ctx.trough_edges(rect, radii, td, tedges);
             }
         }
+        for (fx, fy, fw, fh, radii, fd, split) in self.fields() {
+            let rect = Rect { x: fx, y: fy, width: fw, height: fh };
+            let tint = hovered(fx, fy, fw, fh).then_some(Self::HOVER_TINT);
+            ctx.field(rect, radii, fd, split, tint);
+        }
         for (ga, gb, gw, gd, ghost) in self.grooves() {
             ctx.groove(ga, gb, gw, gd, ghost);
         }
@@ -3874,12 +3913,18 @@ mod tests {
         assert_eq!((dy, dy + dh), (ty + label, ty + th), "the picker spans the field's band, edge to edge");
         assert!(p.texts[0].as_ref().unwrap().inner().joined_right, "the box is square at the seam");
         assert!(!p.texts[1].as_ref().unwrap().inner().joined_right, "a plain text row is not");
-        let (_, radii, _, _) = p.texts[0].as_ref().unwrap().inner().well().expect("a well");
-        assert_eq!((radii.1, radii.2), (0.0, 0.0), "the well's right corners are square");
-        assert!(radii.0 > 0.0 && radii.3 > 0.0);
+        // ONE field: one outline round the box and the picker, its seam at
+        // the picker — not a well and a trough each turning its own corner
+        // there.
         let rr = crate::layout::textbox_corner_radius();
-        let ring = p.inner().troughs().into_iter().find(|t| t.0 == dx).expect("the picker's ring");
-        assert_eq!(ring.4, (0.0, rr, rr, 0.0), "square at the seam, the field's radius outside");
+        let fields = p.inner().fields();
+        assert_eq!(fields.len(), 1, "{fields:?}");
+        let (fx, fy, fw, fh, fradii, _, split) = fields[0];
+        assert_eq!((fx, fy, fx + fw, fy + fh), (tx, dy, dx + dw, dy + dh), "the field spans box and picker");
+        assert_eq!(fradii, (rr, rr, rr, rr), "its own radius at all four corners");
+        assert_eq!(split, dx, "the seam is where the picker begins");
+        assert!(p.inner().troughs().iter().all(|t| t.0 != dx), "the picker draws no ring of its own");
+        assert!(p.inner().reliefs().iter().all(|r| !(r.0 == tx && r.2 == tw)), "nor the box a well of its own");
 
         // Both text variants lay out at the same row height.
         assert_eq!(p.inner().row_height(0), p.inner().row_height(1));
@@ -4667,7 +4712,10 @@ mod tests {
         };
         // A carve's rect and tint, whatever its kind.
         let carve = |prim: &Prim| match prim {
-            Prim::Recess { rect, tint, .. } | Prim::Boss { rect, tint, .. } | Prim::Trough { rect, tint, .. } => Some((*rect, *tint)),
+            Prim::Recess { rect, tint, .. }
+            | Prim::Boss { rect, tint, .. }
+            | Prim::Trough { rect, tint, .. }
+            | Prim::Field { rect, tint, .. } => Some((*rect, *tint)),
             _ => None,
         };
         let at_rest = prims(&p, &ctx);
diff --git a/src/widget/input/spinbox.rs b/src/widget/input/spinbox.rs
index ff4083f..578f88f 100644
--- a/src/widget/input/spinbox.rs
+++ b/src/widget/input/spinbox.rs
@@ -39,6 +39,20 @@ pub struct Spinbox {
 }
 
 /// The zone geometry shared by paint and input, derived from the content rect.
+/// A spinbox's relief: one field, its well and its -/+ run — see
+/// [`Spinbox::relief_parts`].
+#[derive(Clone, Copy, Debug)]
+pub struct SpinRelief {
+    /// The field's outline, as the host was handed it.
+    pub rect: Rect,
+    pub radius: f32,
+    pub depth: f32,
+    /// Where the -/+ run begins (an x), and the engraved seam dividing - from
+    /// + as `(top, bottom, width, host)` — `None` when the button zone has no
+    /// area, and the field is the well alone.
+    pub run: Option<(f32, ((f32, f32), (f32, f32), f32, Rect))>,
+}
+
 struct SpinGeom {
     x: f32,
     y: f32,
@@ -131,36 +145,24 @@ impl Spinbox {
     /// own `paint` and flat hosts (`ParametersBg`) that must re-emit carves
     /// (their rounded-quad bridge keeps only flat prims — the slider's
     /// `track_relief` precedent). Faces are transparent in this style; the
-    /// relief IS the chrome:
-    /// - the value sits in a recessed well (the TextBox language) that ends
-    ///   at the -/+ run, square there,
-    /// - the -/+ pair is ONE flush control plate, the control's right end:
-    ///   it reaches the control's outer edge on the top, right and bottom as
-    ///   a dropdown trigger's plate does, its ring all round, square where it
-    ///   meets the well and the control's radius outside — so the two read as
-    ///   one field with a seam between them, as a parameter pane's text row
-    ///   and its picker do. Until 2026-10-01 the run was nested INSIDE a
-    ///   full-width well, its face stopping at the base of the well's wall,
-    ///   so it never reached the edge a dropdown's does,
+    /// relief IS the chrome, and it is ONE field ([`crate::scene::paint::Prim::Field`]):
+    /// - the value sits in a sunken well (the TextBox language),
+    /// - the -/+ pair is a flush run, the field's right end — out to the
+    ///   control's edge on the top, right and bottom as a dropdown trigger's
+    ///   plate is — and the field's one outline runs round both, its wall
+    ///   blending from the well's step to the run's valley at the seam, so
+    ///   the edge does not break there. Until 2026-10-01 the run was nested
+    ///   inside a full-width well (its face stopping at the base of the
+    ///   well's wall), and for a few hours after that the well and the run
+    ///   were two carves side by side, each turning its own corner at the
+    ///   seam,
     /// - the two buttons divide by an engraved seam, not a wall pair — the
     ///   breadcrumb run's segment language at miniature scale.
     ///
-    /// Returns the well `(rect, radii, depth)` — carved, radii top-left
-    /// clockwise — plus the run
-    /// `(rect, radii, depth, trough edges)` and seam `(top, bottom, width,
-    /// host)` when the button zone is non-degenerate. `None` when the control
+    /// The field's outline is `rect` as given — a host carves it inside or
+    /// not by its own rule, as it does every well. `None` when the control
     /// has no area. The caller gates on `control_relief`.
-    #[allow(clippy::type_complexity)]
-    pub fn relief_parts(
-        &self,
-        rect: Rect,
-    ) -> Option<(
-        (Rect, (f32, f32, f32, f32), f32),
-        Option<(
-            (Rect, (f32, f32, f32, f32), f32, (bool, bool, bool, bool)),
-            ((f32, f32), (f32, f32), f32, Rect),
-        )>,
-    )> {
+    pub fn relief_parts(&self, rect: Rect) -> Option<SpinRelief> {
         let g = self.geom(rect);
         if g.w <= 0.0 || g.h <= 0.0 {
             return None;
@@ -169,27 +171,19 @@ impl Spinbox {
         let depth = crate::layout::bevel_width().min(g.h * 0.2);
         let whole = Rect { x: g.x, y: g.y, width: g.w, height: g.h };
         if g.btn_w <= 0.0 || g.btn_h <= 0.0 {
-            let (well_rect, well_radii) = crate::layout::carve_inside(whole, (radius, radius, radius, radius), depth);
-            return Some(((well_rect, well_radii, depth), None));
+            return Some(SpinRelief { rect: whole, radius, depth, run: None });
         }
         // The run begins at the button hit zone and spans the whole band to
-        // the control's right edge; the well is what is left of it.
+        // the control's right edge.
         let split = g.split_dec + g.pad;
-        let (well_rect, well_radii) = crate::layout::carve_inside(
-            Rect { x: g.x, y: g.y, width: split - g.x, height: g.h },
-            (radius, 0.0, 0.0, radius),
-            depth,
-        );
-        let well = (well_rect, well_radii, depth);
-        let run_rect = Rect { x: split, y: g.y, width: g.x + g.w - split, height: g.h };
-        let run = (run_rect, (0.0, radius, radius, 0.0), depth, (true, true, true, true));
         // Seam floor width: the breadcrumb's SEAM_WIDTH — a hair of flat
         // floor so the crease doesn't alias into a dotted line. It sits on
         // the -/+ hit boundary, not the painted run's midpoint, and crosses
-        // the run's face between its ring's walls.
+        // the run's face between its outline's walls.
         let sx = split + g.btn_w;
-        let seam = ((sx, run_rect.y + depth), (sx, run_rect.y + run_rect.height - depth), 0.75, run_rect);
-        Some((well, Some((run, seam))))
+        let host = Rect { x: split, y: g.y, width: g.x + g.w - split, height: g.h };
+        let seam = ((sx, g.y + depth), (sx, g.y + g.h - depth), 0.75, host);
+        Some(SpinRelief { rect: whole, radius, depth, run: Some((split, seam)) })
     }
 
     fn value_text(&self) -> String {
@@ -336,20 +330,20 @@ impl Paint for Spinbox {
             // chrome (see [`Self::relief_parts`]). Flat prims first — hover
             // washes and the editing cue survive a flat host's rounded-quad
             // bridge, the carves are re-emitted host-side.
-            if let Some((well, buttons)) = self.relief_parts(rect) {
-                if let Some(((run, radii, _, _), (seam_top, _, _, _))) = buttons {
+            if let Some(rel) = self.relief_parts(rect) {
+                // Carved inside the control as every well is.
+                let r = rel.radius;
+                let (field, radii) = crate::layout::carve_inside(rel.rect, (r, r, r, r), rel.depth);
+                if let Some((split, _)) = rel.run {
                     let wash = [1.0, 1.0, 1.0, 0.06];
+                    let sx = split + g.btn_w;
+                    let (top, h) = (field.y, field.height);
                     if self.hover_dec {
-                        ctx.rounded_rect(
-                            Rect { x: run.x, y: run.y, width: seam_top.0 - run.x, height: run.height },
-                            radii.0,
-                            (true, false, false, true),
-                            wash,
-                        );
+                        ctx.rounded_rect(Rect { x: split, y: top, width: sx - split, height: h }, 0.0, (false, false, false, false), wash);
                     }
                     if self.hover_inc {
                         ctx.rounded_rect(
-                            Rect { x: seam_top.0, y: run.y, width: run.x + run.width - seam_top.0, height: run.height },
+                            Rect { x: sx, y: top, width: field.x + field.width - sx, height: h },
                             radii.1,
                             (false, true, true, false),
                             wash,
@@ -370,11 +364,12 @@ impl Paint for Spinbox {
                     let cursor_y = g.y + (g.h - 14.0) / 2.0;
                     ctx.quad(Rect { x: cursor_x, y: cursor_y, width: 1.5, height: 14.0 }, [0.80, 0.80, 0.85, 1.0]);
                 }
-                let (wr, wradii, wd) = well;
-                ctx.recess(wr, wradii, wd);
-                if let Some(((run, radii, rd, edges), (sa, sb, sw, host))) = buttons {
-                    ctx.trough_edges(run, radii, rd, edges);
-                    ctx.groove(sa, sb, sw, rd, host);
+                match rel.run {
+                    Some((split, (sa, sb, sw, host))) => {
+                        ctx.field(field, radii, rel.depth, split, None);
+                        ctx.groove(sa, sb, sw, rel.depth, host);
+                    }
+                    None => ctx.recess(field, radii, rel.depth),
                 }
             }
         } else if rounded {
@@ -629,6 +624,38 @@ mod tests {
     use super::*;
     use crate::widget::{WidgetHost, UiContext};
 
+    /// The value's well and the -/+ run are ONE field: one outline round
+    /// both, its seam where the run begins — not a well and a trough side by
+    /// side, each turning its own corner there — with the engraved seam
+    /// between - and + on top.
+    #[test]
+    fn a_spinbox_is_one_field_with_its_run_at_the_right_end() {
+        use crate::scene::paint::{PaintCtx, Prim};
+        if !crate::layout::control_relief() {
+            return;
+        }
+        let sb = Spinbox::new(0, -100, 100, 1);
+        let rect = Rect { x: 10.0, y: 20.0, width: 200.0, height: 26.0 };
+        let rel = sb.inner().relief_parts(rect).expect("a relief");
+        assert_eq!(rel.rect, rect, "the outline is the control as handed over");
+        let (split, (sa, sb2, _, _)) = rel.run.expect("a -/+ run");
+        assert!(split > rect.x + rect.width * 0.5 && split < rect.x + rect.width, "the run is the right end");
+        assert!(sa.0 > split && sa.0 == sb2.0, "the -/+ seam crosses the run");
+        let mut pc = PaintCtx::new();
+        Paint::paint(sb.inner(), rect, &mut pc);
+        let prims: Vec<Prim> = pc.finish().items.into_iter().map(|i| i.prim).collect();
+        let fields: Vec<&Prim> = prims.iter().filter(|p| matches!(p, Prim::Field { .. })).collect();
+        assert_eq!(fields.len(), 1, "{prims:?}");
+        if let Prim::Field { split: s, .. } = fields[0] {
+            assert_eq!(*s, split);
+        }
+        assert!(
+            !prims.iter().any(|p| matches!(p, Prim::Recess { .. } | Prim::Trough { .. })),
+            "no well or ring of its own beside the field"
+        );
+        assert!(prims.iter().any(|p| matches!(p, Prim::Groove { .. })), "the -/+ seam");
+    }
+
     #[test]
     fn spinbox_button_zones_step_the_value() {
         let mut ctx = UiContext::new();