RotarySelector
Instrument, wave 2 (plan). Follows the anatomy template and the conventions.
1. Purpose and native element
Section titled “1. Purpose and native element”Picks one option from a short list by turning, as on a mixer’s input selector or a synth’s waveform switch: a filter
type, a source, a mode. Renders a native <select> that covers a printed dial, with one hard detent per <option>.
There is no WAI-ARIA pattern to follow beyond the native one: the <select> has the role (combobox, a listbox in
the browser’s popup), the value, the keys, type-ahead and form participation; Strata adds the round pointer behaviour
(section 8), the knob that turns to the chosen option, and the options’ labels printed round it.
Decision (the plan left it open): a <select>, not a radio group. The plan lists the RotarySelector as a <select>
whose “native popup stays in the overlay host, as Select does today”, and a radio group presented as a selector is the
RadioGroup’s selector form (a later phase). A <select> is one tab stop with one value, submits as one
form field and names its options by text, which is what a hardware selector is. Use it for two to about eight options;
for a long list use Select or Combobox, and for a continuous value use Knob.
2. Object
Section titled “2. Object”A machined knob on a printed faceplate, as the Knob: a skirt, a cap on it with an engraved pointer, and the
positions printed on the face round it, each with a tick and its label. The pointer does not sweep freely: it stands on
one position at a time, and a change turns it to the next on spring.detent (design language section 12).
| Part | Shape | Height at 50% | Moves |
|---|---|---|---|
| Dial (the native box) | The root’s width (at most 2 × (the skirt’s radius + 12 px + 5 em), 208 px at the medium size) and 2 × (that radius + 1.5 em) high | 0 | No |
| Scale | One mark per option: a tick at r + 2 to r + 8 (1.6 × 6 px) and the label printed upright from r + 12, on the side its angle faces, up to 6 em wide (wraps) | 0 | No |
| Skirt, cap, pointer | As the Knob’s: Ø size.rotary (48, 64 or 80), the cap 0.8 of it, the pointer 2.2 px wide |
As the Knob’s | The pointer turns to the selected position |
| Legend | The select’s label, printed under the dial; when the dial is narrow, a numbered list of the options above it | 0 | No |
- Positions. Option i of n sits at fraction i / (n − 1) of the sweep (
knob.sweep, 270° by default, or thesweepprop) from the first at −135° to the last at +135°, 0° at the top and clockwise; a lone option sits at the top. The pointer’s angle is--strata-rotary-value× the sweep − sweep / 2. A disabled option keeps its place and is printed at 45%; nothing selects it. The selected option’s print iscolour.text, the otherscolour.muted. - Only the pointer turns, as on the Knob: the skirt and the cap keep their boxes at every position, so their faces never change size, and every part lies inside the dial at every position.
- At the Instrument level (
data-strata-level="instrument") the selector is the Knob’s object, built bygpu/instrument.ts(knobObject) from the laid-out skirt and cap, with the select’s box asboundsandtthe selected position’s place along the sweep. The detent spring (spring.detent, 900 / 42, fixed 4 ms steps, instant under reduced motion) turns the pointer to the chosen position as uniform updates: no capture and no hit-geometry update, however many positions it passes. The printed positions stay on the face.
3. Faces and materials
Section titled “3. Faces and materials”As the Knob: the skirt and the cap are knob (satin); the pointer and the ticks are
printed; the labels are muted, the selected one text; the legend is text. The pointer keeps at least 3:1 against the
cap in every theme. data-strata-material on the selector overrides the skirt’s and the cap’s material.
4. Elevation per state
Section titled “4. Elevation per state”As the Knob, with value change meaning a new position: the pointer turns to it and the
selected print changes (captured content, so fresh pixels), and at the Instrument level the parts turn on the detent spring.
Selected and busy do not apply. Invalid draws a 2 px danger ring on the skirt.
5. Relief primitives
Section titled “5. Relief primitives”As the Knob: two raised faces, the skirt and the cap, both round, the cap inside the skirt (two slabs at the Objects
level). The <select> carries no data-strata-face (a select with one is drawn as a Select’s key); the skirt and the cap take its
hover and disabled state.
6. Tokens it reads
Section titled “6. Tokens it reads”| Token | Prop |
|---|---|
size.rotary (48, 64, 80, --strata-rotary-size) |
size |
knob.sweep (270), knob.dragPx (200), knob.fine (0.1), knob.tapSlop (4) |
sweep (per selector, --strata-knob-sweep) |
colour.knob, colour.edge, colour.text, colour.muted, colour.accent (focus), colour.danger (invalid) |
— |
elevation.raised, state.*, interior.max, bevel.*, shadow.*, light.azimuth |
— |
material.roles.knob |
material |
font.size.caption (labels), font.size.small (legend), font.weight.strong, space.1 |
— |
focus.width, focus.offset, motion.fast, spring.detent |
— |
feedback.tick.*, feedback.sound, feedback.haptics |
— |
level |
level |
size.rotary is this page’s (the Knob’s sizes, †, provisional). Props: label (the legend), options ({ value, label, disabled? } in order round the dial), value / defaultValue (an option’s value; or v-model), sweep, id, name,
form, required, invalid, disabled, and in React selectRef (Vue exposes root and select). Inside a Field it takes
the Field’s id, description, invalid and disabled state.
7. DOM structure and overlay needs
Section titled “7. DOM structure and overlay needs”<div class="ug-rotary" style="--strata-rotary-value: 0.5;"> <div class="ug-rotary__dial"> <span class="ug-rotary__scale" aria-hidden="true"> <span class="ug-rotary__mark" data-strata-value="lp" data-strata-selected="false" style="--strata-rotary-at: 0;"><span class="ug-rotary__tick"></span><span class="ug-rotary__text">LP</span></span> <span class="ug-rotary__mark" data-strata-value="hp" data-strata-selected="true" style="--strata-rotary-at: 0.5;"><span class="ug-rotary__tick"></span><span class="ug-rotary__text">HP</span></span> <span class="ug-rotary__mark" data-strata-value="bp" data-strata-selected="false" data-strata-disabled="true" style="--strata-rotary-at: 1;"><span class="ug-rotary__tick"></span><span class="ug-rotary__text">BP</span></span> </span> <span class="ug-rotary__skirt" data-strata-face="raised" aria-hidden="true"> <span class="ug-rotary__cap" data-strata-face="raised"><span class="ug-rotary__pointer"></span></span> </span> <select class="ug-rotary__select" id="filter" name="filter"> <option value="lp">LP</option> <option value="hp" selected>HP</option> <option value="bp" disabled>BP</option> </select> </div> <ol class="ug-rotary__legend" aria-hidden="true"> <li data-strata-selected="false">LP</li> <li data-strata-selected="true">HP</li> <li data-strata-selected="false">BP</li> </ol> <label class="ug-rotary__label" for="filter">Filter</label></div>- The
.ug-rotaryroot is the boundary and a size container: React and Vue refs,classandstylereach it.--strata-rotary-value(0–1) turns the pointer; asweepprop adds--strata-knob-sweep. The behaviour keeps--strata-rotary-valueand everydata-strata-selectedin step with the selected option; callsyncStrataRotarySelector(select)after settingvalueorselectedIndexfrom script. - The select is transparent (
appearance: none,color: transparent,touch-action: none) and covers the whole dial, above the drawn parts, which take no pointer events: every press lands on the original control. Its options take the page’s colours in the native popup. - A mark is an overlay at its angle: its tick (
__tick) and its label (__text), both decoration. The legend (ol, decoration, hidden unless the dial is narrow) repeats the labels in order; the select’s own options are what a screen reader reads. - The legend line is a
<label for>printed under the dial. No overlay: the native popup is the browser’s.
8. Keyboard and screen reader
Section titled “8. Keyboard and screen reader”| Input | Action |
|---|---|
| Up, Left / Down, Right | Previous / next option (native; the select changes without opening) |
| Home, End, a typed letter | First, last, the next option beginning with it (native) |
| Alt+Down, F4, Space | Opens the native popup, which stays the browser’s |
| Vertical drag (mouse, pen or touch) | knob.dragPx (200) per full sweep, up moves to the next option; × knob.fine (0.1) with Shift; snaps to the nearest enabled option; a press moving under knob.tapSlop (4) is a tap |
| Tap on the ring or a printed label | Selects the option nearest the pressed angle (0° at the top, clockwise over the sweep; the bottom gap goes to the nearer end) |
| Tap on the skirt or cap | Steps to the next enabled option on the upper half, the previous on the lower half (the single-pointer alternative, WCAG 2.5.7) |
| Wheel over the dial | One option per wheel notch (touchpads add up to one); the page does not scroll. Ctrl+wheel zooms as usual |
| Tab, Shift+Tab | Move focus (native order) |
- Role
combobox(the native select, named by the legend), with the selected option as its value; the popup’s options have roleoption. The printed marks, the legend and the dial’s parts arearia-hidden. - A press with the pointer never opens the popup (
pointerdownis cancelled, as the Knob does for the range); the press focuses the select. Every change goes throughselectedIndex, theninputandchangefire on the select once per option passed to, as a native choice does, so forms and bindings see native events. The behaviour isattachStrataRotarySelectorsfrom@underglass/ui/dom(one set of delegated listeners per document, among them the blocking document-level wheel listener);attachStrataand the React, Vue and Astro components attach it. disableduses the native attribute, which removes the selector from the tab order and from the pointer behaviour; a disabled<option>is skipped by every gesture and by the keys.
9. CSS presentation
Section titled “9. CSS presentation”The Knob’s recipe on the skirt and the cap, and the positions printed by sin() and cos() from --strata-rotary-at: ticks
rotated about the dial’s centre, labels placed on one radius (r + 12) with the anchor following the side, so a label on the right
starts at its point and a label on the left ends at it, and the top and bottom ones centre on it. In face mode the faces are
flat colour.knob and the GPU paints the relief. The dial is at least 108 px high, far above target.min (24).
- Reflow: the root is as wide as its dial, at most 208 px at medium, and never wider than its container, so it fits a 320 px
viewport. Below 104 px + 7 em of width (a narrow container, or text at 200%: the labels would not fit round the knob) the
positions are printed as numbers on the dial and the labels move to the numbered legend under it (
@container); nothing is lost and nothing overflows. The radius and the dial grow with the caption size. forced-colors: active:Canvasskirt and cap with a 1 pxCanvasTextborder, the pointer, ticks, labels and legendCanvasText, focus aHighlightring,GrayTextwhen disabled.prefers-contrast: more: the pointer and the labels takecolour.text, and the skirt and cap anedgeborder.prefers-reduced-motion: reduce: the detent settle is off; the pointer turns at once.- Feedback (design language section 20): a selector plays
tickas a change moves the pointer to another position (one per change, however many positions it passes; the Knob’s rule). Sound and haptics are both off by default (feedback.sound,feedback.haptics, orattachStrataFeedback), so nothing plays unless the application or a scope turns them on.
10. Tests
Section titled “10. Tests”- Plain DOM (
ui-rotary-selector.test.mjs, real select): ring clicks on printed labels select their options; cap taps step to the next enabled option and back; drag with and without Shift; the wheel steps and the page does not scroll; native keys and type-ahead; a disabled option is never selected by any gesture;inputandchangefire once per option; the form submits the value; touch taps and a touch drag (Playwright touch emulation); 320 and 390 px viewports (no horizontal overflow, targets at least 24 px, print inside the viewport); 200% text (labels or the numbered legend: nothing overlaps or leaves the dial); axe in light, dark and face mode; forced colours; reduced motion. - GPU (
ui-rotary-selector.test.mjs, DPR 1 and 2, Relief, Objects and Instrument): GPU stays active through every change; at every option position the selector’s parts and nothing else change pixels, and every pixel they draw lies inside the dial; native hits over the dial land on the select (0 mismatches); a real change changes pixels inside the dial only; the detent snap adds 0 captures and 0 hit-geometry updates at the Instrument level while frames are moving; the accessibility snapshot is the same in DOM and GPU modes. - Feedback: off by default; with haptics on, one pulse per change by the visitor’s own gesture, none by script.
- Bindings (
ui-rotary-selector-bindings.test.mjs,ui-rotary-selector-astro.test.mjs): React, Vue and Astro render the markup above; controlled and uncontrolled values,onValueChange/v-model, the root ref,classandstyle, a Field. - Unit (
strata-rotary-push.unit.test.mjs): the position and angle maths and the markup model; the selector’s object (the Knob’s) validated by core’svalidateInstrumentSceneand contained. - Not yet: a parity budget between the Instrument and Objects or CSS renderings, a low-end GPU, and the manual screen-reader script for a combobox whose positions are printed (VAL-002 in open issues). Automation cannot hear the native popup, which is the browser’s. Selectors of more than about eight options, or with labels longer than about eight characters, crowd the dial: use Select.