Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

ADR-0090: A ring is a track and a dial is a grab

  • Status: Accepted
  • Date: 2026-08-18
  • Relates to: docs/design-system.md §3, docs/core-widgets.md §3, extends ADR-0089

Context

ADR-0089 built knob to the letter of §3: 270° arc indicator, vertical drag at 200px per range, wheel, keyboard, detents. Put in front of someone, two things were wrong with it that no assertion could have said.

It read as a gauge, not a knob. §3 asks for an “arc indicator” and core-widgets.md §3 for a rotary control, and between them they say what the value is and never say which way the thing is pointing. An arc alone tells you how full something is. Nothing on the dial turned, so nothing about it suggested you could turn it.

The ring did nothing. A slider’s track is clickable — press anywhere and the thumb comes to you. A knob’s ring is the same 270° of travel drawn round a circle, and clicking it was inert: the only way to reach a value was to grab and drag.

Decision

The dial carries a pointer. Box.Mark gains a POINTER kind — a radial line at the mark’s start angle, running 0.35 → 0.78 of the box’s radius. A proportion rather than a length, for the reason the tick and the dash are proportions: the same drawing has to be right on §3’s 32px knob and its 48px one. It stops short of the centre because a line through the middle of a dial reads as a diameter rather than as a direction, and short of the rim so it does not join the ring outside it — two strokes meeting is a join, and a join says the two are one thing.

It is a mark on knob-dial, not a part of its own, which is the first time that has been the right answer since CheckMark went the other way. A part is a node because two things must be styled or moved independently (ADR-0073); the pointer is neither — one colour, one angle, and the angle is a painter argument rather than a transform.

Clicking the ring positions the value; clicking the dial does not. The ring is a track and behaves like one. The dial is the thing you grab, and a press that jumped before the drag started would move the value out from under the gesture about to set it.

The boundary between them is localPart(), not a constant. The control has to know where the dial ends, and it cannot: a widget has no idea how it was laid out, and the inset is the stylesheet’s (knob-arc { padding: 5px }), so an application that restyled it would move a boundary this file had hard-coded. ADR-0080 already answered exactly this question — the router measures PointerEvent.local() against a named part — so knob names knob-dial and “outside the dial” is hypot(dx, dy) > radius, derived from the geometry that was actually painted. The drag is unaffected: it reads dragY(), which is the window’s.

The jump fires on CLICKED, not on PRESSED, and that is what makes it compose with the drag. A press is the first event of both gestures and cannot know which one it is. The router synthesizes CLICKED only when the press and the release landed on the same node (ADR-0058), and the remaining ambiguity — a drag that ended where it began — is settled by an 8px slop, which is Toggle’s answer to the identical question.

A click in the 90° gap resolves to the nearer end. The gap at the bottom is where a user clicks to mean all the way down or all the way up, and a gap that refused every click would make the bottom of the control dead.

Alternatives considered

Jump on the press, like a slider does. It is the obvious symmetry and it fights the anchor: the router reads gestureAnchor() before dispatching the press (ADR-0089), so a drag that began with a jump would continue from the value the jump replaced — the knob would snap to the click and then snap back as soon as the pointer moved.

Let a press anywhere jump, dial included. One rule instead of two, and it makes the dial unusable as a grab: every drag would start by throwing the value to wherever the finger landed.

Pick the dial boundary as a fraction of the radius — the shipped inset puts it at 11/16 = 0.6875, so 0.7 would work today. Rejected because it is a constant that silently agrees with a stylesheet: an application that changed knob-arc { padding } would get a band that no longer matched its own drawing, and nothing would say so.

Make the pointer a knob-pointer part rotated by a transform. Consistent with how the checkbox’s mark was promoted to a node. Rejected because the rotation is about the dial’s centre and the part would sit at the dial’s top, so transform-origin would have to be expressed as a percentage of the child’s box — arithmetic that has to be redone for every diameter, to make a node that nothing needs to style separately.

Build the circular drag instead. Still the right answer to a different question, and still deferred for ADR-0089’s reason: it needs the accumulated angle, a second piece of gesture state, to decide what crossing the gap means. Click-to-position gets most of the benefit — reaching a distant value without a long drag — for none of that.

Consequences

Box.Mark has a kind that is not a shape but a direction, and sweep is meaningless for it. Documented on the constant rather than enforced; a POINTER built with a sweep is not an error, because a record that validated the irrelevance of one component for one kind would be a worse thing to read.

A click on the ring is subject to detents, where a wheel line is not. The jump goes through the same detented() the drag does, so a knob with detents snaps on a click exactly as it does under a finger. The rule that keeps the two apart is what the gesture is: a click and a drag both say “put it there”, and “there” is what detents adjust; a wheel line says “one more”, and a step that snapped would make some clicks of the wheel do nothing.

knob now names a localPart(), so its local() is the dial’s box and not its own. Nothing else in the control reads local(), but anything added later will get the dial unless it says otherwise — the same trap slider documents.

Three goldens moved and one scene grew a pointer. knob-travel.png is the image that says the pointer and the arc agree: at every value the line points at the end of the arc, and a travel that started at nine o’clock instead of seven-thirty would be wrong by 22.5° everywhere — which is not enough to notice in a picture, and is why KnobTest asserts that nine o’clock is a sixth of the way round rather than a quarter.