> ## Oymyakon DS index
> Fetch the full index at: https://super-dollop-pzmo65r.pages.github.io/llms.txt
> HTML preview: https://super-dollop-pzmo65r.pages.github.io/pinwheel-picker.html
> Source: specs/components/pinwheel-picker/pinwheel-picker.md, specs/components/pinwheel-picker/pinwheel-picker-a11y.md, specs/components/pinwheel-picker/capabilities.json

---

# PinwheelPicker · Oymyakon DS 3

> A row of one to four vertical wheels, each scrolled to one value under a shared selection band —
> the date, time and option picker that lives inside a BottomSheet.

**Version:** 3.0.2 · **Status:** Ready · **Figma:** `[PinwheelPicker] 3.0` (main components file `7vdl5YkZFDWvh9QvSmydsH`, page `36495:5025` — moved from the working copy `cziIEoDBlYTZlYd51yDJ56`, node-ids unchanged, keys new: master `e2508094…`, inner set `42252a4f…`. Master `36501:14323`; inner set `PinwheelPicker` `36811:3672` — `RTL: off | on`, variants `36501:12390` / `36811:3708`; `PinWheel` set `36772:1714`; `PinWheelUnit` set `36501:12134`; `PinWheelSeparator` `36807:1655`; Specification frame `36812:4927`)

---

## 1. Description

PinwheelPicker lets the user pick one value per wheel by scrolling — a date, a time, a time
interval, a day/month/year triple, or any short list of options (a terminal, a hall). Each wheel
shows three rows: the selected value in the middle, on the selection band, and one neighbour
above and below it. The wheels share one band, so a multi-part value (`Today · 00 · 05`) reads as
one line.

The picker is a content block: it carries no title and no confirm action. In every Figma preset it
sits inside a BottomSheet between a NavBar or SectionHeader and a ButtonGroup; the sheet owns
the title ("Choose time"), the confirm button ("Done", "Next", "Confirm pickup point") and the
dismiss.

Each value is in one of three states: **Selected** (on the band), **Unselected** (available, off
the band) and **Expired** (unavailable — a past date or time that is shown for context and cannot
be selected).

The Figma presets stage seven configurations: Date · Time · Date + Time · Day + Month + Year ·
Time + Interval · Date and Time step by step (two sheets in sequence) · PUDO (pickup point —
two wheels of long, start-aligned labels).

### Platform implementations

| Platform | Entry point | Version | Notes |
|---|---|---|---|
| iOS | — | — | Not shipped — this spec is the handoff contract |
| Android | `DsPinwheelPicker` (`core.compose.ds.v3.pinwheel_picker`) | 3.0 | `columns = persistentListOf(...)` — one `Column.Wheel` per wheel (`items`, `selectedIndex`, the accessible `label`, `align`), up to four; `Item(text, enabled = State != Expired)`. No `enabled` parameter and no skeleton (the spec has neither state); RTL from the layout direction. In the Android bridge since 2026-11, bound in the manifest 2026-11-12 |
| Flutter | — | — | Not shipped |
| Web | `src/shared/shared.css` (`.pinwheel-picker`) | 3.0.0 | Reference implementation; the behaviour (settle, Expired correction, keyboard, band height) is the script of the preview page `src/pinwheel-picker.njk`, the SegmentedControl precedent |

### Figma coverage

First written web-first (2026-10-19), then carried into Figma the same day:

| Item | In Figma |
|---|---|
| The band — name and sizing | `SelectionBand` (owner); sizing in the inner PinwheelPicker's description — Figma cannot stretch it to the row's height, so it draws a fixed 60 |
| Separator | `PinWheelSeparator` (TEXT property `Text`); wheel slots 02–04 swap `PinWheel` ↔ `PinWheelSeparator` (`PinWheel 0N Swap`); the Time + Interval preset uses it |
| Focused | `Focused` boolean on the `PinWheel` set |
| RTL | `RTL: off | on` on the inner `PinwheelPicker` set (the owner's structure, 2026-10-19) |
| Expired semantics, motion, haptics, reduced motion, loop | The "Behaviour & motion" note on the page and the component descriptions — motion is documented, not prototyped |
| Specification | Frame `36812:4927` in the site style (the DestinationButton / HeaderAlert donor): Cover and the seven sections, real instances |

Two items stay *Proposed* — written down in Figma, with no property of their own and not built
on the web: the looping wheel (§ 3, prose only) and haptics (§ 5).

---

## 2. Anatomy

```
PinwheelPicker (.pinwheel-picker)              padding s8 / s24, fills the host width
├── Selection band (.pinwheel-picker__selection)   behind the wheels; inset s8 from each side,
│                                                   height = the selected row, radius s96, Surface/OnWhite
└── Container (.pinwheel-picker__container)    the wheels' row — at most 393 wide, at least s48, centred
    └── column × 1–4
        ├── Wheel (.pinwheel-picker__wheel)          the focusable spinbutton, three rows tall (s180)
        │   └── scroller (.pinwheel-picker__scroller)  scrolls with snap-to-centre; s60 block padding
        │       └── Unit × N (.pinwheel-picker__unit)    one value — min height s60, padding s0 / s8
        │           └── Label (.pinwheel-picker__label)  Main Body, up to two lines, ellipsis
        └── Separator (.pinwheel-picker__separator)  optional, static — "until", ":", "–"
```

| Part | Required | Description |
|---|---|---|
| **Root** | Required | A horizontal row; padding `s8` top/bottom, `s24` left/right; no gap between columns. Fills the host's width (360 · 393 · 440 in the presets) |
| **Container** | Required | Holds the wheels and separators (Figma `container`). Fills the root's content box up to a maximum of **393** and centres in it; its minimum is one wheel, `s48`. 393 is the owner's value (2026-10-19) and the one non-token number in the component: a **wide-screen rule** — on phones the wheel row (312 · 345 · 392 at 360 · 393 · 440) never reaches it; it stops the wheels on tablets and in landscape (hosts wider than 441). It is a width, so it does not scale with the SP 130% / 150% modes (owner); the web carries it as `--pinwheel-picker-container-max` |
| **Selection band** | Required | One band for all wheels, behind them, on the root — not limited by the Container. Figma `SelectionBand` (the owner's rename of the former remote `.PinWheel / Hover`). Stretches across all the available width of the component with `s8` left and right — past the Container's 393 on a wide host — and is as tall as the selected row — it grows when a selected value wraps to two lines or the text scales. Radius `s96` from the SP **150%** scale, so the pill grows with the text scale |
| **Wheel** | 1–4 | A vertical list of Units, `s180` tall (three rows). Minimum width `s48`; no fixed maximum — the wheels share the Container's width (less the separators) equally, so one wheel's width depends on the available width and the number of wheels. Figma: `PinWheel` set, `Align` axis. On the web the wheel is the focusable element and its `__scroller` child does the scrolling, so the focus ring can sit still on the band while the values move |
| **Unit** | Required, one per value | Minimum height `s60`, padding `s0` vertical / `s8` horizontal. Figma: `PinWheelUnit` set, `State` axis |
| **Label** | Required | The value's text — Main Body, up to **two lines**, an ellipsis past that. **Centred by default** (`Align=center`); a wheel can switch to left alignment (`Align=left`) — see "Text alignment" below |
| **Separator** | Optional, between wheels | A static word or symbol on the band's row — "until" in Time + Interval. Main Body, `TextAndIcon/Primary`, hugs its text with `s8` side padding. Does not scroll, takes no focus. Figma `PinWheelSeparator` (TEXT property `Text`), placed by swapping a wheel slot 02–04 (`PinWheel 0N Swap`) |

Wheel and separator count: up to **four columns** in total (Figma: `PinWheel 02/03/04` booleans,
the first wheel is always on). Time + Interval uses all four: hour · "until" · hour · AM/PM.

### Text alignment

The text inside a wheel is **centred by default** (`Align=center`). A wheel can align its text to the
**left edge** instead (`Align=left`): every Unit's label starts at the Unit's `s8` side padding, and
a two-line value wraps from that edge. Alignment is set per wheel, so one picker can mix both. Left
alignment is for long, word-like values that read better from a fixed edge (PUDO: "Domestic
departure"); numbers and short words stay centred, so they sit under the middle of the band.
Under RTL the left alignment becomes start alignment — the right edge.

### RTL layout

Opt-in (DS default: off). Figma `RTL: off | on` on the inner `PinwheelPicker` set. Under `dir="rtl"` the column order mirrors — the first wheel
sits on the right — and `Align=left` becomes start-aligned, which is the right edge. Digits inside
a value keep their own direction. Which wheel comes first in a date or time (day–month–year, or
year–month–day) is the host's locale formatting, not a component setting.

---

## 3. Variants and sizes

| Axis | Values | Default | Web mechanism |
|---|---|---|---|
| **Wheels** | `1` · `2` · `3` · `4` | `4` (Figma, all booleans on) | The number of `.pinwheel-picker__wheel` children (separators count towards the four columns) |
| **Wheel: Align** | `center` · `left` | `center` | `.pinwheel-picker__wheel--start` (`left` in Figma = start; mirrors under RTL) |
| **Unit: State** | `selected` · `unselected` · `expired` | Figma default `Expired`; on a live wheel exactly one Unit is `selected` | `.pinwheel-picker__unit--selected`, `.pinwheel-picker__unit--expired`; no modifier = Unselected |
| **Separator** | off · on (per gap) | off | Presence of `.pinwheel-picker__separator` between two wheels; Figma: swap a wheel slot 02–04 to `PinWheelSeparator` |
| **RTL** | off · on | off | `dir="rtl"`; Figma `RTL: off \| on` on the inner `PinwheelPicker` set |

- **One size.** Rows are `s60`, the wheel is `s180`; the component has no S/M/L.
- **A wheel stops at its first and last value** — the first can reach the band, so an empty row
  shows above "Today" in the presets. *Proposed, not an axis:* a looping wheel for cyclic values
  (after `59` comes `00`); neither Figma nor the web build has it.
- Text is **centred by default**; `Align=left` aligns it to the left edge, for long labels (PUDO:
  "Domestic departure"). Numbers and short words stay centred. Set per wheel.
- The Subtitle and BottomSlot (icon + caption) of the first PinWheelUnit draft were removed from
  the set before 3.0 and are not part of this contract.

---

## 4. States

### Unit states

| State | Figma | Colour | Meaning |
|---|---|---|---|
| **Selected** | `State=Selected` | `TextAndIcon/Primary` | The wheel's current value — on the band. Exactly one per wheel |
| **Unselected** | `State=Unselected` | `TextAndIcon/Secondary` | An available value off the band |
| **Expired** | `State=Expired` | `TextAndIcon/Disabled` | An unavailable value, shown for context — past (a date or time already gone) or excluded by another wheel (an end time not after the start). It cannot become Selected: a scroll that ends on it settles on the nearest available value, and keyboard and assistive-technology steps skip it |

State follows the value, not the position: an Expired value may sit anywhere in the list. The
Figma presets stage none (every value in them is available); the preview page shows Expired in a
past-time scenario — a time sheet opened at 1:40 AM, where the hours 12 (12 AM) and 1 are past; in
the Time + Interval sheet the start times up to 1:30 are past and the end times up to the selected
start are excluded.

### Component states

| State | What shows |
|---|---|
| **Standard** | The band behind the selected row; the wheels at rest |
| **Focused** | Figma `Focused` boolean on `PinWheel`. A wheel holds keyboard or accessibility focus: an `s2` inside outline in `TextAndIcon/Primary` around that wheel's part of the band, radius `s96`. Pointer and touch input do not show it |

- **No Disabled state.** Neither Figma nor this spec has a disabled picker. When nothing can be
  picked, the host does not open the sheet; when some values cannot be picked, they are Expired.
- **No Skeleton.** The sheet opens with its values already known.
- Scrolling is not a state: the colour of a value crossing the band is motion (§ 5, "Unit enters
  / leaves the band").

---

## 5. Animation and behavior

Motion follows [`motion-rules.md`](https://super-dollop-pzmo65r.pages.github.io/motion.md). Figma documents it in the
"Behaviour & motion" note on the page (not prototyped).

| Event | Token | Behaviour |
|---|---|---|
| Drag and fling | — (platform scroll physics) | Native scrolling with inertia: iOS `UIScrollView` deceleration, Android fling, the browser's scroller. The system overscroll is kept (`motion-rules.md` § 0.4) |
| Snap | — (platform snap) | When the wheel comes to rest, the nearest available Unit centres on the band (iOS `UIPickerView` behaviour, Android snap helper, CSS `scroll-snap-align: center`) |
| Unit enters / leaves the band | `var(--pattern-color)` | The label colour cross-fades between Selected and Unselected / Expired, 200 ms |
| Expired correction | — (platform animated scroll) | A wheel that comes to rest on an Expired value scrolls to the nearest available one. Platforms time it to Position Medium's 400 ms and curve where their scroll API takes a duration; the web uses the browser's smooth scroll |
| Value set by the host | — (platform animated scroll) | A dependent wheel that changes its range (Today → Tomorrow re-enables earlier hours) scrolls to its new value — the same animated scroll as the correction |
| Band height change | — (none) | The band takes the selected row's height at once when a two-line value arrives on it. Height is a layout property and is not animated (`motion-rules.md`: width / height cause reflow) |
| Haptic | — | *Proposed.* One selection tick per Unit that crosses the band — iOS `UISelectionFeedbackGenerator`, Android `HapticFeedbackConstants.CLOCK_TICK`. Respects the system haptics setting. The web has no equivalent |

- **Reduced motion.** The tokens zero themselves under `prefers-reduced-motion: reduce`, so
  colour changes and corrections land at once. Programmatic scrolls jump instead of glide (the web
  passes `behavior: "auto"`). Finger scrolling is the user's own motion and stays as it is.
- The value changes when a wheel **comes to rest**, not on every row it passes; the host receives
  one change per settle.
- Scrolling is not a translate pattern: Position Medium names the duration and curve the
  platforms aim for, not a token the component applies. The browser's smooth scroll has its own
  duration and curve, which CSS cannot set — a known difference on the web.

---

## 6. Color tokens

| Element | Token | Notes |
|---|---|---|
| Selection band | `var(--surface-on-white)` | Figma `Surface/OnWhite` |
| Selected label | `var(--text-and-icon-primary)` | |
| Unselected label | `var(--text-and-icon-secondary)` | Changed from `Disabled` in the Figma fix of 2026-10-19 |
| Expired label | `var(--text-and-icon-disabled)` | Changed from `Secondary` in the Figma fix of 2026-10-19 |
| Separator | `var(--text-and-icon-primary)` | It reads with the selected values |
| Focus ring | `var(--text-and-icon-primary)` | Stroke `s2`, inside |
| Host surface | The BottomSheet's own background (`var(--background-primary)`) | Not part of the component — the picker has no fill of its own |

**Theme flag (2026-10-19).** In the Night theme `TextAndIcon/Disabled` (Grey 300) is lighter than
`TextAndIcon/Secondary` (Grey 400). On the sheet, Expired measures ≈ 9.8:1 and Unselected ≈ 7.9:1,
so the unavailable value reads stronger than the available one, the reverse of Day (≈ 2.2:1 and
≈ 5.3:1). This affects every DS pair of these two tokens, not only this component — a DS-wide item.
The spec keeps the tokens as Figma binds them, and colour as the only Expired cue is accepted (owner, 2026-11-12).

---

## 7. Typography

| Element | Style | Tokens |
|---|---|---|
| Unit label | Body / Main Body | `var(--text-body-main-body-*)` — up to two lines, ellipsis |
| Separator | Body / Main Body | `var(--text-body-main-body-*)` — one line |

Main Body scales with the SP scale in **full** mode (H4 and below). The `s60` row minimum is on
the 100% scale. At 150% a two-line value outgrows the row: the row and the band grow with it.

---

## 8. Spacing

| Property | Token | Value @100% | Notes |
|---|---|---|---|
| Root padding, top / bottom | `var(--sp-s8)` | 8 | |
| Root padding, left / right | `var(--sp-s24)` | 24 | |
| Column gap | `var(--sp-s0)` | 0 | The wheels touch |
| Container maximum width | `--pinwheel-picker-container-max` | 393 | The owner's wide-screen rule — binds only on hosts wider than 441 (393 + the `s24` sides); a width, not on the SP scale, so it does not scale with 130% / 150%; the Container centres in the root |
| Container minimum width | `var(--sp-s48)` | 48 | One wheel |
| Wheel height | `var(--sp-s180)` | 180 | Three rows |
| Wheel minimum width | `var(--sp-s48)` | 48 | Maximum: the Container's width shared by the wheels — e.g. 104 at 360 with three wheels, 131 at the 393 cap |
| Unit minimum height | `var(--sp-s60)` | 60 | |
| Unit padding | `var(--sp-s0)` / `var(--sp-s8)` | 0 / 8 | top-bottom / left-right |
| Band inset, left / right | `var(--sp-s8)` | 8 | From the component's edges. Figma draws it as a fixed 8 with a Stretch constraint |
| Band height | — | the selected row | Figma draws a fixed 60. The contract is the row's height, at least `s60` |
| Band radius | `var(--sp-s96)` @ SP 150% | 96 | Larger than half the band height at every scale, so the ends stay round |
| Separator padding, left / right | `var(--sp-s8)` | 8 | |
| Component height | — | 196 | `s8` + `s180` + `s8`; grows only when the selected row does |

Width: the picker fills its host. The wheels sit in the Container — up to 393, centred — and share
its width, less the separators, equally. The band ignores the Container: it spans the whole
component minus `s8` a side.

---

## 9. Usage context

### When to use

- Choosing a date, a time, or both, inside a BottomSheet — Date, Time, Date + Time.
- Choosing a time window — Time + Interval.
- A long date such as a birth date — Day + Month + Year.
- Choosing one of a short, ordered set of options where scanning beats reading a list — PUDO
  terminals and halls.
- Two-step selection: a date sheet whose "Next" opens a time sheet ("Date and Time step by step").

### Placement in a BottomSheet

The picker sits full width between the sheet's header and its actions; the sheet adds no side
padding of its own — the picker carries its `s24`.

| Part | Content (from the Figma presets) |
|---|---|
| Header | NavBar — `s48`, a centred Heading 4 title (+ an optional Compact Body subtitle), close, an optional back arrow — for dates and times; SectionHeader — a Heading 2 title + close — when the title names a place (PUDO) |
| Picker | PinwheelPicker, 1–4 wheels |
| Actions | ButtonGroup — `s8` padding, `s4` between buttons, Button L at full width: the Primary confirm (Done, Next, Confirm pickup point) and, when needed, a Secondary (Skip, Pickup directions with `pin-map`) |

Date and time step by step are two sheets: the date sheet's Next opens the time sheet, whose back
arrow returns and whose subtitle repeats the picked date ("Today, 3 September").

### When not to use

- Fewer than four options that all fit on screen — SegmentedControl, Chips or RadioButton.
- Long or searchable lists (countries, addresses) — a list of Cells.
- Free text or exact numbers — TextField.
- Values that are mostly unavailable — filter them out instead of showing a wheel of Expired rows.

### Related components

- BottomSheet — the host; owns the title, the confirm action and the dismiss.
- NavBar / SectionHeader — the sheet's header above the picker.
- ButtonGroup — the confirm action below it.
- [SegmentedControl](https://super-dollop-pzmo65r.pages.github.io/segmented-control.md) — for two or three visible options.

---

## 10. Accessibility

Brief summary. Full spec: [`pinwheel-picker-a11y.md`](https://super-dollop-pzmo65r.pages.github.io/pinwheel-picker.md).

- **Each wheel is one adjustable element.** iOS `.adjustable` (swipe up / down), Android scroll
  forward / backward actions, web `role="spinbutton"`. The label names what the wheel sets ("Hour",
  "Minute", "Terminal"); the value is the selected label.
- **Expired values are skipped** by adjust actions and arrow keys, so assistive technology cannot
  land on a value the user cannot pick.
- **The separator is not an element.** Its word goes into the neighbouring wheels' labels ("Start
  time", "End time").
- **The band is decoration.** The selection lives in the wheel's value.
- The web keyboard: `Up` / `Down` step to the next / previous available value (Up increases, per
  the APG), `Page Up` / `Page Down` five, `Home` / `End` the first / last available value. `Tab`
  moves between wheels — one stop each; the inner scroller is `tabindex="-1"`.
- **Host changes are announced.** A wheel the host moves while the user is elsewhere (Today
  re-expires the past hours) is announced: a polite live region on the wheel (Android
  `accessibilityLiveRegion` / Compose `liveRegion = Polite`; web `role="status"`), an iOS
  announcement. `announceForAccessibility` is deprecated in Android 16.
- **Reduced motion**: corrections and host changes jump instead of glide; the announcements stay.
- **Forced colours**: Expired falls to `GrayText`, the band gets an `s2` `Highlight` border and the
  focus ring an explicit `CanvasText`, so it never matches the band.

---

## 11. Analytics and coverage contract

Contract per [`prototype-analytics-and-coverage.md`](https://github.com/inDriver/oymyakon-ds/blob/main/docs/prototype-analytics-and-coverage.md).

| Field | Value |
|---|---|
| `data-ds-component` | `pinwheel-picker` |
| Coverage unit | yes |
| Tap target model | Multiple targets — each wheel |
| Actions | `select` on a wheel, fired once when it comes to rest on a new value |
| Internal targets | One per wheel: `data-ds-target="wheel-<id>"` (`wheel-day`, `wheel-hour`, `wheel-minute`) — the Rating precedent |
| Emits value | `data-ds-value` = the id of the selected value (`today`, `05`, `terminal-2`), updated when the wheel comes to rest. Dates and times of an order are not personal data on their own, but a birth date is: Day + Month + Year wheels carry no `data-ds-value` |

Attribute model: `data-ds-variant` = the number of wheels (`1`…`4`); `data-ds-preset` = the Figma
preset when one is used (`date`, `time`, `date-time`, `day-month-year`, `time-interval`, `pudo`).

```html
<div class="pinwheel-picker" role="group" aria-label="Pickup time"
     data-ds-component="pinwheel-picker" data-ds-component-id="pickup-time"
     data-ds-variant="3" data-ds-preset="date-time">
  <span class="pinwheel-picker__selection" aria-hidden="true"></span>
  <div class="pinwheel-picker__container">
  <div class="pinwheel-picker__wheel" role="spinbutton" tabindex="0"
       aria-label="Day" aria-valuetext="Today"
       aria-valuenow="0" aria-valuemin="0" aria-valuemax="6"
       data-ds-action="select" data-ds-target="wheel-day" data-ds-value="today">
    <div class="pinwheel-picker__scroller" tabindex="-1">
      <div class="pinwheel-picker__unit pinwheel-picker__unit--selected" aria-hidden="true" data-value="today">
        <span class="pinwheel-picker__label">Today</span>
      </div>
      <div class="pinwheel-picker__unit" aria-hidden="true" data-value="tomorrow">
        <span class="pinwheel-picker__label">Tomorrow</span>
      </div>
    </div>
  </div>
  <!-- hour and minute wheels follow the same shape -->
  </div>
</div>
```

---

## Changelog

| Version | Date | Change |
|---|---|---|
| 3.0.2 | 2026-11-12 | Owner ruling: colour as the only Expired cue is accepted (an Expired value is skipped by scroll, keys and assistive technology); the Night Disabled / Secondary pair is the DS-wide item. Status Draft → Ready (owner). |
| 3.0.1 | 2026-11-12 | The Figma set moved into the main components file (page `36495:5025`, the node-ids unchanged, new keys — master `e2508094…`, inner set `42252a4f…`; verified in the published library and the file). Android ships `DsPinwheelPicker` 3.0 — found by the manifest sync of 2026-11-12, which binds both nodes; iOS does not ship it yet. |
| 3.0.0 | 2026-10-19 | New component, from the Figma `[PinwheelPicker] 3.0` (master `36501:14323`; inner set `PinwheelPicker` `36811:3672` with `RTL: off \| on`; PinWheel `Align` + `Focused`; PinWheelUnit `State`; `PinWheelSeparator` + slot swaps 02–04; seven BottomSheet presets; Specification frame `36812:4927`) in the components-library working copy `cziIEoDBlYTZlYd51yDJ56`. 1–4 wheels in a centred container (max 393 — a wide-screen width rule, not on the SP scale — min `s48`) under one `SelectionBand` (Surface/OnWhite, radius `s96` @ SP 150%, full width minus `s8`, the selected row's height); units Selected (Primary) / Unselected (Secondary) / Expired (Disabled — unavailable: past, or excluded by another wheel; no snap point, skipped by keys and assistive technology). Owner decisions, 2026-10-19: the name `PinwheelPicker`, the Expired / Unselected semantics and colours, the Separator element, the Focused state, the band's sizing, the container rule, RTL on the inner set, `SelectionBand`. Motion: platform scroll and snap, `--pattern-color`; corrections on the platform's animated scroll; the band height is not animated. Loop and haptics stay *Proposed* (documented only). Flagged: in Night, Disabled is lighter than Secondary, so Expired outshines Unselected (a DS-wide token pair). Reviewed by spec-reviewer, a11y-reviewer and PR #21 (JohnyGoode): one tab stop per wheel (the scroller is `tabindex="-1"` and hands focus to the wheel), a forced-colours fallback, host changes announced through a polite live region, Up = increase per the APG, analytics on `wheel-<id>` targets with the selected value |

---

# PinwheelPicker — Accessibility

Each wheel is one adjustable element: the screen reader lands on the wheel, reads what it sets and
its current value, and steps the value up or down without scrolling by hand. The picker as a whole
is a group named by the sheet's title. Three rules hold on every platform:

- **The label names the wheel, the value is the selected label.** "Hour, 2", not "2".
- **Expired values are not reachable.** Adjust actions and arrow keys skip them, so a value the
  user cannot pick is never offered. They still render, as context for sighted users.
- **One announcement per settle.** The value is read when the wheel comes to rest, not for every
  row that passes the band.

Label rule: the host gives each wheel a short noun — "Day", "Hour", "Minute", "Period",
"Terminal", "Hall". A separator's word goes into the labels of the wheels around it: Time +
Interval reads "Start time" and "End time", and "until" is not an element of its own. A wheel
without a label is an implementation bug: "2" alone does not say what was picked.

## Android · TalkBack

| Element | Label | Value | Trait | Hint |
|---|---|---|---|---|
| Picker | The sheet title ("Choose time") as the group's `contentDescription` | — | A traversal group (Compose: `semantics { isTraversalGroup = true; contentDescription = … }`); TalkBack reads the name when focus first enters a wheel inside it | — |
| Wheel | The wheel noun ("Hour") | The selected label ("2") as `stateDescription`; `RangeInfo` (int, 0 … the available-value count − 1, current = the selected index) | `ACTION_SCROLL_FORWARD` / `ACTION_SCROLL_BACKWARD` + `ACTION_SET_PROGRESS` — TalkBack offers them as "Scroll forward / backward" in the actions menu and on the volume keys while the wheel has focus | — (TalkBack speaks the available actions itself; no custom hint) |
| Unit (any state) | — | — | Not an element — the wheel speaks for its Units | — |
| Separator | — | — | Not an element — folded into the wheels' labels | — |
| Selection band | — | — | Decorative — `importantForAccessibility="no"` | — |

- Scroll forward / backward moves to the next or previous **available** value and fires the
  wheel's change, as a finger settle would.
- The wheel announces its new value through `stateDescription` after each step — once, at rest.
- A looping wheel (*Proposed*) has no first or last value; the actions never run out.

### Edge states

- **Expired**: skipped by both actions; TalkBack never focuses or reads an Expired value.
- **All values but one Expired**: both actions are removed — there is nowhere to step; the value
  stays and is read as the wheel's value.
- **First / last value** of a non-looping wheel: the action in that direction is removed, so
  TalkBack does not offer a step that does nothing.
- **Disabled**: the component has no disabled state (see the spec § 4).
- **RTL**: the wheels mirror visually; traversal follows the reading order, so the first wheel is
  still read first.
- **Reduced motion / animations off**: corrections land at once; the announcement is unchanged.
- **Host-moved value**: when the host moves a wheel the user is not on (picking Today re-expires
  the past hours), focus stays put. The wheel carries a polite live region
  (`accessibilityLiveRegion = ACCESSIBILITY_LIVE_REGION_POLITE`; Compose
  `semantics { liveRegion = LiveRegionMode.Polite }`), so TalkBack reads its new value — "Hour,
  14". `announceForAccessibility` is deprecated in Android 16 (API 36) and is not used.
- **Dimmed / Error / Skeleton**: none (spec § 4).

---

## iOS · VoiceOver

| Element | Label | Value | Trait | Hint |
|---|---|---|---|---|
| Picker | The sheet title as the container's label | — | `accessibilityContainerType = .semanticGroup` | — |
| Wheel | The wheel noun ("Hour") | The selected label ("2") | `.adjustable` — swipe up / down calls `accessibilityIncrement()` / `accessibilityDecrement()` | "Swipe up or down with one finger to adjust the value" (system) |
| Unit (any state) | — | — | Hidden — the wheel is the element | — |
| Separator | — | — | Hidden — folded into the wheels' labels | — |
| Selection band | — | — | Hidden | — |

- Increment and decrement step to the next and previous **available** value and fire the change.
- VoiceOver reads the new value after each step (`UIAccessibility.post(.announcement)` is not
  needed — the adjustable value change speaks itself).
- `UIPickerView` gives this behaviour out of the box. A custom wheel reproduces it, including the
  Expired skip, which `UIPickerView` does not do by itself.

### Edge states

- **Expired**: skipped by increment / decrement; never read.
- **First / last value** of a non-looping wheel: the step in that direction does nothing and
  VoiceOver plays its boundary sound.
- **Disabled**: none (spec § 4).
- **Dynamic Type**: the labels scale (Main Body, full scaling); a value of two lines grows its row
  and the band. A value longer than two lines is truncated on screen but read in full.
- **RTL**: the wheels mirror; VoiceOver reads them in reading order.
- **Reduced Motion**: corrections and host-set values jump instead of glide; the value is read
  the same way.
- **Host-moved value**: the host posts `UIAccessibility.post(notification: .announcement,
  argument: "Hour changed to 14")` — the moved wheel does not have focus, so nothing else speaks.
- **Dimmed / Error / Skeleton**: none (spec § 4).

---

## Web preview (reference)

- **Each wheel is a `role="spinbutton"`** with `aria-label` (the wheel noun), `aria-valuetext`
  (the selected label), and `aria-valuenow` / `aria-valuemin` / `aria-valuemax` as the index range
  of the **available** values (Expired values do not count). `aria-valuetext` is what screen readers
  speak; the numbers only give the position. The script sets all five once the wheel is positioned;
  display-only specimens carry none of them, and no `role` either.

  ```html
  <div class="pinwheel-picker" role="group" aria-label="Choose time">
    <span class="pinwheel-picker__selection" aria-hidden="true"></span>
    <div class="pinwheel-picker__container">
    <div class="pinwheel-picker__wheel" role="spinbutton" tabindex="0"
         aria-label="Hour" aria-valuetext="2" aria-valuenow="0"
         aria-valuemin="0" aria-valuemax="10">
      <div class="pinwheel-picker__scroller" tabindex="-1">
        <div class="pinwheel-picker__unit pinwheel-picker__unit--expired" aria-hidden="true">…</div>
        …
      </div>
    </div>
    <span class="pinwheel-picker__separator" aria-hidden="true">until</span>
    </div>
  </div>
  ```

- **Units are `aria-hidden`.** They are the wheel's visual content; the spinbutton's value is the
  only thing announced. Without this, the browse cursor would read every row of every wheel.
- **Keyboard**: `Tab` / `Shift+Tab` move between wheels (one tab stop per wheel). `Up` / `Down`
  step to the next / previous available value — **Up increases**, per the APG spinbutton pattern
  and iOS increment (WebKit sends VoiceOver's increment as `ArrowUp`). `Page Up` / `Page Down`
  jump five, `Home` / `End` go to the first / last available value. Each key press scrolls the
  wheel to the value and fires the change at once. Keys with Alt, Ctrl or Cmd pass through to the
  browser.
- **Implementation trap — the scroller swallows the arrows.** A focused scroll container scrolls
  by itself on `Up` / `Down`, by pixels, ignoring the Expired skip. The key handler calls
  `preventDefault()` and moves by values.
- **Implementation trap — the scroller takes the click focus.** With `tabindex="-1"` the scroller
  is click-focusable and the nearest focusable ancestor of a unit, so a pointer click would focus
  a nameless element and the wheel's `:focus-visible` ring would never show for the arrow keys
  that follow. The scroller hands focus to its wheel (`focus` → `wheel.focus()`).
- **Reduced motion**: under `prefers-reduced-motion: reduce` programmatic scrolls use
  `behavior: "auto"` — they jump instead of glide; the change and the announcement are the same.
- **Implementation trap — the scroller is a second tab stop.** Chrome makes a scroll container
  with no focusable children keyboard-focusable. The scroller carries `tabindex="-1"`, or every
  wheel gets a second, unnamed stop whose browser ring replaces the band ring.
- **Implementation trap — host changes are silent.** A wheel moved by the host (not by the user)
  changes `aria-valuetext` without focus, so nothing is read. The host writes the change into a
  polite live region (`role="status"`) — "Hour changed to 14".
- **Implementation trap — the settle fires too often.** `scroll` events arrive for every pixel.
  The change is fired once the wheel has stopped (`scrollend`, with a timer fallback), and only
  when the value differs.
- **The group** is `role="group"` with the sheet title as its `aria-label`, so a screen reader
  entering the first wheel hears "Choose time, group, Hour, 2, spin button".
- **Separator and band are `aria-hidden`.**
- **Focus ring** (Figma `Focused` on PinWheel): `:focus-visible` on a wheel draws an `s2` inside outline in
  `--text-and-icon-primary` at radius `s96` around that wheel's part of the band. Pointer focus
  does not show it.
- **Display-only specimens** on the preview page are `aria-hidden` whole, and their wheels carry
  no `role` and no `tabindex` — a spinbutton that does not respond to keys is worse than none.
- **Forced colours** (Windows High Contrast): the mode drops the band fill and folds every label
  colour into `CanvasText`, so Expired would look pickable. Under `@media (forced-colors: active)`
  Expired takes `GrayText`, Selected `CanvasText`, the band an `s2` `Highlight` border and the
  focus ring an explicit `CanvasText` (the UA could otherwise paint it the band's colour) — the
  system colour keywords, the only values the mode honours (an owner-pending colour-rule
  exception). Colour stays the only Expired cue
  otherwise (WCAG 1.4.1 relies on the skip: an Expired value cannot be reached or picked); a
  non-colour cue such as a strikethrough was considered and not taken — colour alone is accepted (owner, 2026-11-12).

### Colour and contrast

Measured from the primitives, on the sheet (`--background-primary`) or the band
(`--surface-on-white`):

| Pairing | Tokens | Day | Night | Requirement |
|---|---|---|---|---|
| Selected label on the band | `--text-and-icon-primary` on `--surface-on-white` | ≈ 15.7:1 | ≈ 13.2:1 | ≥ 4.5:1 — passes |
| Unselected label on the sheet | `--text-and-icon-secondary` on `--background-primary` | ≈ 5.3:1 | ≈ 7.9:1 | ≥ 4.5:1 — passes |
| Expired label on the sheet | `--text-and-icon-disabled` on `--background-primary` | ≈ 2.2:1 | ≈ 9.8:1 | None — an inactive value is exempt (WCAG 1.4.3) |
| Band on the sheet | `--surface-on-white` on `--background-primary` | ≈ 1.1:1 | ≈ 1.3:1 | Not relied on — see below |
| Focus ring | `--text-and-icon-primary` on `--surface-on-white` | ≈ 15.7:1 | ≈ 13.2:1 | ≥ 3:1 — passes |

- **The band is not the only selection cue.** It is nearly invisible against the sheet (≈ 1.1:1
  Day). The selected value is also the middle row and the only one in Primary, so selection does
  not depend on seeing the band.
- **Flag — Night inverts Expired and Unselected.** In Night, Disabled (Grey 300) is lighter than
  Secondary (Grey 400), so the unavailable value is the brighter one. Nothing fails a contrast
  minimum, but the visual ranking is reversed. Accepted for the component (owner, 2026-11-12); the pair is the
  DS-wide Night `TextAndIcon/Disabled` / `Secondary` item, recorded in the spec § 6.

---

## Machine contract — `specs/components/pinwheel-picker/capabilities.json`

Axes, allowed values, defaults, constraints and CSS/Figma bindings. Read this instead of guessing what the component can do.

```json
{
  "$schema": "../component-capabilities.schema.json",
  "id": "pinwheel-picker",
  "name": "PinwheelPicker",
  "version": "3.0.2",
  "description": "One to four vertical wheels under a shared selection band — each scrolled to one value (Selected, Unselected or the unavailable Expired); the date, time and option picker that sits inside a BottomSheet.",
  "files": {
    "spec": "specs/components/pinwheel-picker/pinwheel-picker.md",
    "a11y": "specs/components/pinwheel-picker/pinwheel-picker-a11y.md",
    "preview": "src/pinwheel-picker.njk",
    "css": "src/shared/shared.css"
  },
  "figma": {
    "library": "🕹️ Oymyakon (components) — main file, page 36495:5025 (moved from the working copy cziIEoDBlYTZlYd51yDJ56: node-ids unchanged, keys new)",
    "fileKey": "7vdl5YkZFDWvh9QvSmydsH",
    "componentSets": {
      "[PinwheelPicker] 3.0": {
        "key": "e2508094692883548604ddab6bc2bb8682a0e177",
        "note": "The master (36501:14323), a single component exposing its inner picker's properties."
      },
      "PinwheelPicker": {
        "key": "42252a4fef90c84bdaca638c4b241a080b888901",
        "note": "The inner set (36811:3672): RTL off (36501:12390) | on (36811:3708); PinWheel 02–04 visibility + PinWheel 0N Swap (PinWheel ↔ PinWheelSeparator), container (min s48, max 393), SelectionBand."
      },
      "PinWheel": {
        "key": "129c70f70514c3cb08f3a1ef5405ff6fb78fe62f",
        "note": "36772:1714 — Align center | left, Focused boolean."
      },
      "PinWheelUnit": {
        "key": "d4dc4f4a764f3fdaedd5bb3d0a3dd513879741dc",
        "note": "36501:12134 — State Expired | Selected | Unselected."
      },
      "PinWheelSeparator": {
        "key": "f2e0bfd3de86906bf7432e81475c5991b9484d9f",
        "note": "36807:1655 — TEXT property Text (default \"until\")."
      }
    },
    "capturedAt": "2026-11-12"
  },
  "root": {
    "class": "pinwheel-picker",
    "dataDsComponent": "pinwheel-picker"
  },
  "anatomy": {
    "selection": {
      "class": "pinwheel-picker__selection",
      "count": 1,
      "notes": "The band behind every wheel: Surface/OnWhite, radius s96 @ SP 150%, stretching across all the available width with s8 left and right (not capped by the container), as tall as the selected row (--pinwheel-picker-band, measured by the script). Figma SelectionBand."
    },
    "container": {
      "class": "pinwheel-picker__container",
      "notes": "The wheels' row (Figma container): fills the root's content box up to --pinwheel-picker-container-max (393, the owner's value, not on the SP scale), at least s48, centred."
    },
    "wheel": {
      "class": "pinwheel-picker__wheel",
      "count": "1–4",
      "notes": "The focusable spinbutton, s180 tall; minimum s48, no fixed maximum — the wheels share the container's width (less the separators) equally."
    },
    "scroller": {
      "class": "pinwheel-picker__scroller",
      "notes": "The wheel's scrolling child: snap-to-centre, s60 block padding so the first and last value reach the band."
    },
    "unit": {
      "class": "pinwheel-picker__unit",
      "notes": "One value: at least s60 tall, s8 side padding."
    },
    "label": {
      "class": "pinwheel-picker__label",
      "notes": "Main Body, up to two lines, an ellipsis past that."
    },
    "separator": {
      "class": "pinwheel-picker__separator",
      "optional": true,
      "notes": "A static word on the band's row (\"until\"); Main Body in TextAndIcon/Primary, s8 side padding; no scroll, no focus, aria-hidden. Figma PinWheelSeparator, placed by a slot swap (PinWheel 0N Swap)."
    }
  },
  "axes": {
    "wheels": {
      "title": "Wheels",
      "type": "number",
      "min": 1,
      "max": 4,
      "step": 1,
      "default": 4,
      "css": {
        "mechanism": "the number of .pinwheel-picker__wheel children; separators count towards the four columns"
      },
      "figma": {
        "kind": "variant-property",
        "property": "PinWheel 02",
        "notes": "One of three BOOLEAN properties on the inner PinwheelPicker — PinWheel 02, PinWheel 03, PinWheel 04 (the first wheel is always on); the master exposes them."
      },
      "constraints": [
        "Wheels plus separators fill at most four columns."
      ]
    },
    "align": {
      "title": "Wheel: Align",
      "type": "enum",
      "values": [
        "center",
        "start"
      ],
      "default": "center",
      "css": {
        "modifier": ".pinwheel-picker__wheel--start",
        "mechanism": "per wheel; center is the bare wheel"
      },
      "figma": {
        "kind": "variant-property",
        "property": "Align",
        "values": {
          "center": "center",
          "start": "left"
        }
      },
      "notes": "Figma's left is start-aligned and mirrors under RTL. Start is for long labels (PUDO)."
    },
    "unitState": {
      "title": "Unit: State",
      "type": "enum",
      "values": [
        "selected",
        "unselected",
        "expired"
      ],
      "default": "unselected",
      "css": {
        "mechanism": "selected → .pinwheel-picker__unit--selected, expired → .pinwheel-picker__unit--expired; unselected is the bare unit"
      },
      "figma": {
        "kind": "variant-property",
        "property": "State",
        "values": {
          "selected": "Selected",
          "unselected": "Unselected",
          "expired": "Expired"
        }
      },
      "constraints": [
        "Exactly one unit per wheel is selected.",
        "An expired unit is never selected: it has no snap point, and arrow keys and assistive technology skip it."
      ],
      "notes": "The runtime default for a value off the band is unselected; the Figma set's default variant is Expired — an instance always sets State explicitly."
    },
    "separator": {
      "title": "Separator",
      "type": "text",
      "default": "",
      "css": {
        "mechanism": "presence of .pinwheel-picker__separator between two wheels; its text is the word"
      },
      "figma": {
        "kind": "variant-property",
        "property": "PinWheel 02 Swap",
        "notes": "One of three INSTANCE_SWAP properties on the inner PinwheelPicker — PinWheel 02 Swap, PinWheel 03 Swap, PinWheel 04 Swap: PinWheel ↔ PinWheelSeparator; the separator's word is its TEXT property Text."
      }
    },
    "rtl": {
      "title": "RTL",
      "type": "boolean",
      "default": false,
      "css": {
        "mechanism": "dir=\"rtl\": the column order mirrors; start-aligned labels move to the right"
      },
      "figma": {
        "kind": "variant-property",
        "property": "RTL",
        "values": {
          "false": "off",
          "true": "on"
        },
        "notes": "On the inner PinwheelPicker set; the master exposes it."
      }
    }
  },
  "states": {
    "default": [
      "standard",
      "focused"
    ]
  },
  "analytics": {
    "dataDsComponent": "pinwheel-picker",
    "action": "select",
    "targets": [
      "wheel-<id>"
    ],
    "valueAttr": "data-ds-value",
    "notes": "Coverage unit. One target per wheel: data-ds-target=\"wheel-<id>\" (wheel-day, wheel-hour — the Rating precedent); select fires once when a wheel comes to rest on a new value; data-ds-value = the id of the selected value, updated on settle. Birth-date wheels (Day + Month + Year) carry no data-ds-value. data-ds-variant = the number of wheels; data-ds-preset = date | time | date-time | day-month-year | time-interval | pudo."
  },
  "rtl": {
    "supported": true,
    "notes": "Opt-in: the columns mirror and Align=left becomes start (right); digits keep their direction; the order of day, month and year is the host's locale formatting. Figma: RTL off | on on the inner PinwheelPicker set."
  },
  "constraints": [
    "A content block of a BottomSheet: no title, no confirm action, no fill of its own — the sheet owns them.",
    "No Disabled and no Skeleton state; unavailable values are Expired.",
    "The wheels container is at most 393 wide (--pinwheel-picker-container-max — the one non-token value; a wide-screen width rule that binds only on hosts wider than 441 and does not scale with the SP modes, owner 2026-10-19) and at least one wheel (s48); the band is not capped by it.",
    "One size: s60 rows, s180 wheel; the band and the rows grow only with a two-line value or the text scale.",
    "The value changes when a wheel comes to rest — one change per settle, not per row passed.",
    "The separator is not an accessible element; its word belongs in the neighbouring wheels' labels.",
    "The Figma set lives in a working copy of the components library (cziIEoDBlYTZlYd51yDJ56), not the main file the manifest binds — no manifest binding until it lands there (the DestinationButton precedent).",
    "A wheel stops at its first and last value; a looping wheel is Proposed prose only — no axis, not in Figma, not on the web.",
    "The band height is not animated — it takes the selected row's height at once (motion-rules: no width / height animation)."
  ]
}
```
