> ## 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/numeric-stepper.html
> Source: specs/components/numeric-stepper/numeric-stepper.md, specs/components/numeric-stepper/numeric-stepper-a11y.md, specs/components/numeric-stepper/capabilities.json

---

# NumericStepper · Oymyakon DS 3

> A value between a "−" and a "+" button — the fare the passenger offers, or a count — changed a
> step at a time, or typed in through the button in the middle.

**Version:** 3.2.3 · **Status:** Ready · **Figma:** `[NumericStepper] 3.2` (main components file `7vdl5YkZFDWvh9QvSmydsH`, page `18033:1471`, set `18379:2714`, key `26e079e2…`; private StartText set `18379:608`; Specification frame `18465:1511`; update frames `Update 3.1` `33509:3224` and `Update 3.2` `34461:118287`)

---

## 1. Description

NumericStepper increases or decreases a value by a fixed step with the "−" and "+" buttons on its
sides. The button in the middle shows the value and opens direct entry — a secondary screen or a
bottom sheet where the value is typed. Its main use is the fare the passenger offers for a ride
("COL$ 120,000"); the same component also holds a count, such as passengers (owner, 2026-10-21).

The value field changes its look with the **Fare State**: **Empty** asks for a value ("Tap to
enter"), **Standard** shows it, three **tones** colour the field with a soft glow and an edge —
**Green** (Positive), **Orange** (Warning), **Red** (Error) — and **Skeleton** holds the place while
the data loads. The tones carry no meaning of their own: the screen decides what a Positive, Warning
or Error value is and says it in its own text (owner, 2026-10-21).

The step size, the minimum and the maximum belong to the product. At a boundary the host disables
the "−" or "+" button; each side button's state is set on its own.

### Platform implementations

| Platform | Entry point | Version | Notes |
|---|---|---|---|
| iOS | `DsNumericStepper` (DS3) | Figma 3.0 axis | `style`: `.recommended` / `.min` / `.max` / `.error` / `.static` / `.empty(placeholder:)`; `canDecrement` / `canIncrement`, `leftSquircleState` / `rightSquircleState`, `subtitle`, `trailingIcon` (the chevron), `minimumTextScaleFactor`, `onDecrement` / `onIncrement` / `onTap`; Skeleton = `.empty(placeholder: "")` + `.isLoading(true)` (manifest bindings, 2026-10-21) |
| Android | `DsNumericStepper` (`core.compose.ds.v3.numeric_stepper`) | Figma 3.0 axis | `style`: `Neutral` / `Positive` / `Warning` / `Error`; `minusEnabled` / `plusEnabled`, `isSkeleton`, `onMinusClick` / `onPlusClick` / `onMainClick`; the centre is a content lambda (`PriceText`, `TapToEnter`) (manifest bindings, 2026-10-21) |
| Flutter | — | — | Not shipped |
| Web | `src/shared/shared.css` (`.numeric-stepper`) | 3.2.0 | Reference implementation, this repo; preview page `src/numeric-stepper.njk` |

Both mobile builds still carry the Figma 3.0 Fare State values. The 3.1 update renamed them —
Rec → Default (Standard here), Min → Orange, Max → Green, Error → Red — and removed Static; on the
platforms the colour is the `style` and the side buttons' availability is separate data, which is
the 3.1 model.

---

## 2. Anatomy

```
NumericStepper (.numeric-stepper)            fill × hug · s8 padding · s4 gap
├── Decrease (.numeric-stepper__step)         Squircle L, Type Icon — "minus" s24
├── Counter (.numeric-stepper__field)         customButton · fill × s56 · radius s20 · no side padding
│   ├── Spacer (.numeric-stepper__spacer)     s24 × s24 · empty · with the chevron only
│   ├── Text (.numeric-stepper__text)         fill · centred
│   │   ├── Title (.numeric-stepper__title)   one line · Heading 3
│   │   │   ├── Currency (.numeric-stepper__currency)   optional · before or after the value
│   │   │   └── Value (.numeric-stepper__value)
│   │   ├── Placeholder (.numeric-stepper__placeholder) Empty only · Heading 4 · "Tap to enter"
│   │   └── Subtitle (.text-row.numeric-stepper__subtitle)  TextRow, Expanded · optional, off by default
│   │       ├── Start slots (.text-row__slot-start-1/2)  optional · IconContainer s16
│   │       ├── Text (.text-row__text)                   Compact Body
│   │       └── End slots (.text-row__slot-end-1/2)      optional · IconContainer s16
│   └── Chevron (.numeric-stepper__chevron)   IconContainer s24 · "chevron-right-small"
└── Increase (.numeric-stepper__step)         Squircle L, Type Icon — "add" s24
```

| Part | Required | Description |
|---|---|---|
| **Container** | Required | A horizontal auto-layout: fills the host's width, hugs its height; padding `s8` on all sides, gap `s4`; no fill of its own |
| **Decrease** | Required | A [Squircle](https://super-dollop-pzmo65r.pages.github.io/squircle.md) L (`s56`, radius `s20`), Type Icon, with the "minus" icon (s24). Inherits the Squircle's states — Standard, Disabled, Skeleton. Here the Squircle is a **button** — an interactive host the Squircle spec does not cover (it describes a non-interactive container): `<button class="squircle squircle--l numeric-stepper__step">`, with the stepper's press overlay, focus ring and `aria-disabled` at a limit; the Squircle itself is unchanged |
| **Counter** | Required | The customButton between the side buttons: fills the remaining width, height `var(--sp-130-s56)`, radius `s20`, `Surface/OnWhite` fill. Opens direct entry |
| **Title** | Required (not in Empty) | One line: the value with its currency, `Heading/Heading 3`, `TextAndIcon/Primary` (`TextAndIcon/Error` in Red); currency and value split by a hair space (`s2`) |
| **Currency** | Optional | Before the value (prefix, Figma `Currency = Left`) or after it (suffix, `Currency = Right`). A count has none (owner, 2026-10-21) |
| **Placeholder** | Empty only | "Tap to enter" in `Heading/Heading 4`, `TextAndIcon/Secondary`, in place of the title |
| **Subtitle** | Optional, off by default | A second line under the title — a [TextRow](https://super-dollop-pzmo65r.pages.github.io/text-row.md) in Expanded mode, one line, centred: `Body/Compact Body`, `TextAndIcon/Secondary`. Its start and end slots hold up to two icons on each side — an IconContainer `s16` (Modify = Whole container), `s4` from the text (the TextRow's gap). A mono icon takes the subtitle colour; a colour icon keeps its own. The Figma examples: the previous fare struck through; "+COL$120" with the colour wallet icon in the end slot |
| **Spacer** | With the chevron | An empty `s24` × `s24` box at the Counter's start — the chevron's width, so the text centres between the two. The Figma `s24` spacer instance; the Chevron property shows and hides both together |
| **Chevron** | Optional, on by default | An [IconContainer](https://super-dollop-pzmo65r.pages.github.io/icon-container.md) s24 with "chevron-right-small" in `TextAndIcon/Secondary`, at the Counter's end; the spacer balances it at the start |
| **Increase** | Required | A Squircle L, Type Icon, with the "add" icon (s24) — the same as Decrease |

- The Counter and the side buttons are three separate buttons — three tap targets.
- Without the chevron the spacer goes too: the text is centred across the whole Counter.
- The Subtitle is the Figma `Title•` text configuration (Mode Expanded, Multiline Off, A-slot / B-slot off by default, each side able to "Add one") — the repo's TextRow.

### RTL layout

`RTL = On` mirrors the row: "+" on the left, "−" on the right, the chevron at the Counter's left
edge, pointing left, and the spacer at its right edge. The title keeps its own order — the currency stays where the Currency setting
puts it — and the "−" / "+" icons are symmetric. On the web the root's direction (`dir="rtl"` on it
or an ancestor) mirrors the row, the spacer and the chevron; the title is isolated, so a value keeps
its order in either direction.

---

## 3. Variants and sizes

One size. The variability is the Fare State and the parts that switch on and off.

| Axis | Values | Default | Web mechanism |
|---|---|---|---|
| **Fare State** | `standard` · `empty` · `green` · `orange` · `red` · `skeleton` | `standard` (Figma: Default) | The bare `.numeric-stepper` is Standard; `.numeric-stepper--green` / `--orange` / `--red` paint the Counter's tone; `.numeric-stepper--skeleton`; Empty is the placeholder element in place of the title |
| **Decrease / Increase** | `standard` · `disabled` | per Fare State (table below) | `.squircle--disabled` + `aria-disabled="true"` on that button |
| **Chevron** | `true` · `false` | `true` | `.numeric-stepper--no-chevron` drops the chevron and its spacer |
| **Currency** | `prefix` · `suffix` · none | `prefix` | The order of `.numeric-stepper__currency` and `.numeric-stepper__value` in the title; none for a count |
| **Subtitle** | `false` · `true` | `false` | The `.text-row.text-row--expanded.numeric-stepper__subtitle` element; its icons are the TextRow's slot elements, only those in use |
| **RTL** | `false` · `true` | `false` | `dir="rtl"` on the root or an ancestor |

The Figma Fare State also sets the side buttons' default states. They are defaults — the host
changes each side button on its own, as the boundaries of the value require.

| Fare State | Decrease | Increase | Counter |
|---|---|---|---|
| **Standard** | Standard | Standard | `Surface/OnWhite` |
| **Empty** | Disabled | Disabled | `Surface/OnWhite`, "Tap to enter" |
| **Green** | Standard | Disabled | Green tone |
| **Orange** | Disabled | Standard | Orange tone |
| **Red** | Disabled | Disabled | Red tone, the value in `TextAndIcon/Error` |
| **Skeleton** | Skeleton | Skeleton | `Skeleton/OnWhite` |

---

## 4. States

| State | What shows |
|---|---|
| **Standard** | The value in the Counter on `Surface/OnWhite`; both side buttons Standard |
| **Empty** | "Tap to enter" in `TextAndIcon/Secondary`; no value; both side buttons Disabled by default |
| **Green** | The Positive tone: a green glow from the Counter's bottom edge and a green edge along it |
| **Orange** | The Warning tone: the same glow and edge in orange |
| **Red** | The Error tone: the same glow and edge in red, and the value in `TextAndIcon/Error` |
| **Skeleton** | The side buttons and the Counter as `Skeleton/OnWhite` shapes with the DS shimmer; no icons, no text |

Interaction on each of the three buttons, not on the Fare State axis:

| Interaction | What shows |
|---|---|
| **Pressed** | The DS pressed overlay (`--shadow-pressed`) over the pressed button's fill — `pushHighlight` (owner, 2026-10-21) |
| **Focused** (web) | An `s2` ring in `TextAndIcon/Primary` inside the focused button's edge, radius `s20` |
| **Disabled** (a side button) | The icon in `TextAndIcon/Disabled`, the fill unchanged; no press, no action |

- The tone is the Counter's alone: the side buttons never take the glow.
- A disabled side button keeps its place and its fill — the row never reflows at a boundary.
- The Figma frame calls the first state "Default"; the DS name is **Standard**.

---

## 5. Animation and behavior

Reference: [`motion-rules.md`](https://super-dollop-pzmo65r.pages.github.io/motion.md).

| Event | Pattern | Token | Notes |
|---|---|---|---|
| Press / release (each button) | `pushHighlight` | `var(--component-push-highlight)` | The pressed overlay fades in and out — no scale. A disabled side button and the Skeleton do not react |
| Skeleton shimmer | `Patterns/Shimmer` | `var(--pattern-shimmer)` | The DS sweep on every shape — the Squircle Skeleton on the side buttons, the same sweep on the Counter; reversed in RTL |
| Value change | — | — | The new value replaces the old in the same frame — no count-up, no width tween |
| Fare State change | — | — | The tone, the placeholder and the side buttons' states swap in the same frame; the Figma set stages no transition |
| Skeleton → content | — | — | The host swaps the skeleton for the content; the stepper declares no transition of its own |

- A tap on "−" or "+" changes the value by the product's step; a tap on the Counter opens direct entry.
- Reduced motion: `--component-push-highlight` and `--pattern-shimmer` are zeroed at the token —
  the overlay appears without a fade and the skeleton holds still; no override of its own.

---

## 6. Color tokens

| Element | Token | Notes |
|---|---|---|
| Side button fill | `var(--surface-on-white)` | The Squircle's own fill |
| Side button icon | `var(--text-and-icon-primary)` | `var(--text-and-icon-disabled)` when Disabled |
| Counter fill | `var(--surface-on-white)` | |
| Title (currency + value) | `var(--text-and-icon-primary)` | `var(--text-and-icon-error)` in Red. A default — an instance may re-point it to any DS text token |
| Placeholder | `var(--text-and-icon-secondary)` | Empty |
| Subtitle text | `var(--text-and-icon-secondary)` | A default — any DS text token |
| Subtitle icons | `var(--text-and-icon-secondary)` for a mono icon | The subtitle colour (`currentColor`); a colour or double-colour icon keeps its own colours |
| Chevron | `var(--text-and-icon-secondary)` | |
| Tone glow | `var(--pastel-green1)` · `var(--pastel-orange1)` · `var(--pastel-red2)` | A radial gradient over the fill, centred on the bottom edge, from the tone at 60% to transparent |
| Tone edge | `var(--border-success)` · `var(--border-warning)` · `var(--border-error)` | A 1dp inside stroke, a radial gradient centred on the bottom edge — the token at the centre, transparent by 32% of the radius |
| Pressed overlay | `var(--shadow-pressed)` | Over the pressed button's fill, added on top of the surface — the effect style `State/PressedStateOverlay`, the one press token |
| Focus ring | `var(--text-and-icon-primary)` | Web |
| Skeleton fill | `var(--skeleton-on-white)` | |
| Skeleton wave | `var(--skeleton-wave)` | The shimmer's highlight |

The colour contract is three custom properties on the root: `--numeric-stepper-title` (the title,
`var(--text-and-icon-primary)`; Red sets `var(--text-and-icon-error)`; an instance may re-point it),
`--numeric-stepper-tone` (the glow's Pastel token) and `--numeric-stepper-edge` (the edge's Border
token) — both `transparent` until a tone sets them.

Tone geometry (Figma, converted from the paints' gradient transforms):

| Layer | Centre | Radius — horizontal × vertical | Stops |
|---|---|---|---|
| Glow (fill, 60% opacity) | bottom centre (50%, 100%) | 93.6% × 100% of the Counter | 0% tone → 100% transparent |
| Edge (1dp inside stroke) | bottom centre (50%, 100%) | 166% × 100% of the Counter | 0% border token → 32% transparent |

- Contrast on `Surface/OnWhite`: Title ≈ 15.7:1 Day / ≈ 13.2:1 Night; Placeholder and Subtitle ≈
  4.6:1 / ≈ 6.1:1; the Red value ≈ 3.5:1 / ≈ 3.8:1, and ≈ 3.1:1 / ≈ 3.5:1 over the strongest part of
  the glow — large text (Heading 3, bold 20), above the 3:1 floor. On the Green and Orange glow the
  Title stays above 11:1.
- `TextAndIcon/Disabled` on a disabled side button is below the contrast floor on purpose — a disabled
  control is exempt.
- The glow's 60% is a paint opacity in Figma, over a Pastel token that is itself an alpha token in
  Night — an opacity on top of an alpha token. The DS has no gradient token for it; the `color-mix`
  below is accepted until one exists (owner, 2026-11-12). The web mixes the tone at 60% into the gradient's first stop (`color-mix`) rather than
  dimming a layer.

---

## 7. Typography

| Element | Style | Tokens | Scaling |
|---|---|---|---|
| Title — currency and value | Heading/Heading 3 — 20/28, Suisse Intl Bold | `var(--text-heading-heading3-*)` | `cap130` (Figma binds `SP/130% ratio`) |
| Placeholder | Heading/Heading 4 — 17/20, Suisse Intl Semibold | `var(--text-heading-heading4-*)` | `full` (Figma binds `SP/150% ratio`) |
| Subtitle | Body/Compact Body — 14/16, Suisse Intl Book | `var(--text-body-compact-body-*)` | `full` (`SP/150% ratio`) |

- The Title is one line. iOS shrinks it to fit (`minimumTextScaleFactor`); the web keeps the size and
  clips the end of an over-long line with an ellipsis — Figma stages no overflow.
- The value is formatted by the product (thousands separators, currency symbol); the component does
  not format it.

---

## 8. Spacing

Each value takes the family Figma binds. The container's padding and gap, the side buttons (box,
radius, icon) and the radii are `SP/100% ratio` — `var(--sp-100-sN)`, fixed in every mode; the Counter's
height and vertical padding are `SP/130% ratio` — `var(--sp-130-sN)`, 72.8 high at 130% and 150%. The
side buttons are Squircle L instances held at 100%: the frame's `SP/100%` binding wins over the Squircle
L 130% cap (owner, 2026-11-12), so above 100% the Counter stands taller than the buttons beside it.

| Property | Token | @100% | Notes |
|---|---|---|---|
| Container width | — | fills the host | |
| Container height | — | hugs — 72 | The s56 buttons plus the s8 padding; 88.8 at 130% and 150%, where the Counter is the tallest part |
| Container padding | `var(--sp-100-s8)` | 8 | All sides; fixed |
| Gap — side button ↔ Counter | `var(--sp-100-s4)` | 4 | Fixed |
| Side button | `var(--sp-100-s56)` | 56 × 56 | Squircle L held at 100% — Figma binds SP/100% (owner, 2026-11-12) |
| Side button radius | `var(--sp-100-s20)` | 20 | Fixed |
| Side button icon | `var(--sp-100-s24)` | 24 | Fixed, with the box; Squircle L padding `s16` |
| Counter width | — | fills | The width between the side buttons |
| Counter height | `var(--sp-130-s56)` | 56 | SP/130% — 72.8 at SP 130 and 150; a two-line text (Title + Subtitle, 44) centres in it |
| Counter padding — top / bottom | `var(--sp-130-s8)` | 8 | SP/130% — 10.4 at 130% and 150% |
| Counter padding — start / end | `var(--sp-s0)` | 0 | The spacer and the chevron sit flush at the ends |
| Spacer | `var(--sp-s24)` | 24 × 24 | The chevron's width, so the text stays centred; gone without the chevron |
| Counter radius | `var(--sp-100-s20)` | 20 | Fixed |
| Gap — spacer ↔ text ↔ chevron | `var(--sp-s0)` | 0 | |
| Gap — currency ↔ value | `var(--sp-s2)` | 2 | The hair space |
| Gap — Title ↔ Subtitle | `var(--sp-s0)` | 0 | |
| Chevron | `var(--sp-s24)` | 24 | IconContainer, "Icon only" |
| Subtitle icons | `var(--sp-s16)` | 16 | IconContainer in the TextRow's slots, up to two on each side |
| Gap — subtitle text ↔ icon | `var(--sp-s4)` | 4 | The TextRow's gap |
| Tone edge | 1dp | 1 | Inside — adds nothing to the size; no SP token, the Tag / Squircle border precedent |

---

## 9. Usage context

### When to use NumericStepper

- The fare the passenger offers on the ride request screen: the recommended fare to start from, the
  step buttons to move it, the Counter to type it — "Your fare", "Recommended fare".
- A count the user adjusts in steps near a known value — passengers, seats.
- Empty, when there is no value yet and the person is asked to enter one ("Tap to enter").
- A tone when the screen judges the value — Green, Orange or Red — with the screen's own text saying
  why.

### When not to use

- For free-form numeric input with no sensible step — a text field.
- For a choice from a short fixed list — a segmented control or a list of cells.
- With the tone as the only signal: the colour does not say why; the screen's text does.
- To show a value that cannot change — plain text.

### Related components

- [Squircle](https://super-dollop-pzmo65r.pages.github.io/squircle.md) — the side buttons are Squircle L, Type Icon.
- [Button](https://super-dollop-pzmo65r.pages.github.io/button.md) — the Counter is a customButton.
- [TextRow](https://super-dollop-pzmo65r.pages.github.io/text-row.md) — the Subtitle, in Expanded mode with its side slots.
- [TariffCell](https://super-dollop-pzmo65r.pages.github.io/tariff-cell.md) — the tariff card organism around it holds a NumericStepper (out of TariffCell's scope).

---

## 10. Accessibility

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

- **Three focusable buttons**, in order: Decrease, Counter, Increase. "−" and "+" are announced by
  their meaning — "Decrease [Item]" / "Increase [Item]" — never as symbols.
- **[Item] and [value] come from the screen**: a fare — "Fare, 120,000 Colombian pesos" (the
  currency read as words); a count — "Passengers, 3".
- **The new value is announced right after a step** — the person does not move focus to hear it; a
  step that lands on a limit adds "maximum" / "minimum", and a tap on the disabled button repeats it.
- **Direct entry**: titled "[Item]"; on dismiss the focus returns to the Counter, which reads the
  committed value once.
- **Empty**: the Counter reads "[Item], Not set"; the visible "Tap to enter" is not read.
- **Subtitle**: its text is read with the Counter, after the value ("…, was 200,000 Colombian pesos");
  its icons are decorative unless they carry meaning the text does not — then the screen gives it a word.
- **Disabled side button** (a boundary): TalkBack appends "Disabled", VoiceOver "Dimmed"; on the web
  `aria-disabled`, so focus is not lost when "+" reaches the maximum under the person's finger.
- **Tones** are not announced as colours: the screen's message about the value is appended to the
  Counter's value on mobile, linked with `aria-describedby` on the web.
- **Skeleton**: not announced, not focusable — the loading container announces once.
- Touch targets: s56 × s56 side buttons and an s56-high Counter — above the 48 minimum.
- Text scaling: Title caps at 130%, Placeholder and Subtitle scale to 150%; the Counter's height grows
  with the SP mode up to 130% (72.8); the side buttons hold at 56.

---

## 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` | `numeric-stepper` |
| Coverage unit | yes — one root, one DS component instance |
| Tap target model | multiple targets — three internal buttons |
| Actions | `decrement`, `edit`, `increment` |
| Internal targets | `decrease-button`, `counter-button`, `increase-button` |
| Emits value | yes — `data-ds-value` on the root is the current value; the product updates it after every step, so a tap event reads the value the tap produced |

Attribute model: `data-ds-state` = the Fare State (`standard`, `empty`, `green`, `orange`, `red`,
`skeleton`); no `data-ds-preset` — one size, no presets. A disabled side button keeps its target
attribution and emits no tap. RTL is the reading direction, not a state.

```html
<p id="fare-hint">Recommended fare</p>   <!-- the screen's own message; not part of the component -->

<div class="numeric-stepper"
     data-ds-component="numeric-stepper" data-ds-component-id="ride-request.fare"
     data-ds-state="standard" data-ds-value="120000">
  <span id="fare-value" hidden>120,000 Colombian pesos</span>
  <button type="button" class="squircle squircle--l numeric-stepper__step" aria-label="Decrease fare"
          aria-describedby="fare-value"
          data-ds-action="decrement" data-ds-target="decrease-button">
    <span class="squircle__slot"><svg …>minus</svg></span>
  </button>
  <button type="button" class="numeric-stepper__field" aria-describedby="fare-hint"
          data-ds-action="edit" data-ds-target="counter-button">
    <span class="visually-hidden">Fare, 120,000 Colombian pesos</span>
    <span class="numeric-stepper__spacer" aria-hidden="true"></span>
    <span class="numeric-stepper__text" aria-hidden="true">
      <span class="numeric-stepper__title">
        <span class="numeric-stepper__currency">COL$</span><span class="numeric-stepper__value">120,000</span>
      </span>
    </span>
    <span class="numeric-stepper__chevron" aria-hidden="true"><svg …>chevron-right-small</svg></span>
  </button>
  <button type="button" class="squircle squircle--l numeric-stepper__step" aria-label="Increase fare"
          aria-describedby="fare-value"
          data-ds-action="increment" data-ds-target="increase-button">
    <span class="squircle__slot"><svg …>add</svg></span>
  </button>
  <span class="visually-hidden" aria-live="polite"></span>
</div>
```

The Counter's spoken text is the visually hidden span — the label and the value with the currency in
words; the visible title is hidden from assistive tech. The side buttons take the value as their
description from the `hidden` span, which the product rewrites with the value. The polite live region receives the new value
after a step. At a boundary the side button takes `class="… squircle--disabled"` and
`aria-disabled="true"` — not `disabled`, which would drop the focus.

---

## Changelog

| Version | Date | Change |
|---|---|---|
| 3.2.3 | 2026-11-12 | Owner rulings, and Status Draft → Ready (owner). **The side buttons are held at 100%** — box, radius and icon on `var(--sp-100-sN)`, as the frame binds them (`SP/100%`), over the Squircle L 130% cap; above 100% the Counter (72.8) stands taller than them. The container's padding and gap and the radii move onto `SP/100%` too, the Counter's vertical padding onto `SP/130%` — the families Figma binds (§ 8 no longer claims the repo lacks a fixed set). The subtitle over the Red glow (≈ 4.1–4.4:1 Day) keeps the Figma colours; the glow's `color-mix` stays until a gradient token exists. |
| 3.2.2 | 2026-11-04 | Counter height capped at 130% — `var(--sp-130-s56)`, the Figma `SP/130% ratio` binding — built now that Squircle 3.5 caps the L side buttons at 130%: at SP 150 the buttons hold at 72.8 and a full-scaling Counter would have stood at 84. §8 deviation note narrowed: the side buttons follow Squircle L (130% cap, where this frame binds `SP/100%` — a Figma disagreement between the two components, flagged to the owner); the radii and container padding still scale with the mode. |
| 3.2.1 | 2026-10-30 | § 6: the pressed overlay is named as the one press token, the effect style `State/PressedStateOverlay` = `var(--shadow-pressed)` — it pointed at `Surface/PressedOverlay`, which is not used on the web (flagged in color-rules 3.5.2). No change to the build. |
| 3.2.0 | 2026-10-21 | Initial spec, from the Figma `[NumericStepper] 3.2` set in the main components file (page `18033:1471`, set `18379:2714`), its Specification frame (`18465:1511`) and the `Update 3.1` / `Update 3.2` frames. Two Squircle L side buttons ("minus" / "add") around a customButton Counter (fill × `s56`, radius `s20`, `Surface/OnWhite`); container padding `s8`, gap `s4`. Fare State Standard (Figma Default) · Empty ("Tap to enter", Heading 4, `TextAndIcon/Secondary`) · Green / Orange / Red (the Positive / Warning / Error tones: a radial glow of `Pastel/Green1` / `Pastel/Orange1` / `Pastel/Red2` at 60% and a 1dp radial edge of `Border/Success` / `Warning` / `Error`, both centred on the bottom edge; Red paints the value `TextAndIcon/Error`) · Skeleton; the Fare State sets the side buttons' default states, the host changes each on its own. Title = currency + value in Heading 3 split by a hair space (`s2`), the currency before or after the value, none for a count; optional Subtitle — a TextRow in Expanded mode (Compact Body, `TextAndIcon/Secondary`, off by default) with up to two `s16` icons on each side in its slots, a colour icon keeping its own colours; optional chevron (on by default) balanced by an empty `s24` spacer at the Counter's start, so the text stays centred. RTL mirrors the row and the chevron, the title keeps its order. Press = `pushHighlight` (the pressed overlay, no scale). A11y spec reworked for the generic value — a fare or a count — with Empty, Skeleton, the tones and the post-step announcement. Owner rulings (2026-10-21): its own sidebar page; the tones are Positive / Warning / Error with no business meaning of their own; the component also holds a count; press feedback `pushHighlight`. Figma flags: the frame calls the first state "Default" (kept — owner, 2026-10-21); its Layout calls the horizontal paddings "vertical"; "Suiqrcle" typo (not urgent — owner); Empty's Heading 4 is bound to the "(Don't use) s17" size token; the Counter binds `SP/130% ratio`, the side buttons `SP/100%`; the glow's paint opacity sits on an alpha token in Night. Both mobile builds ship the Figma 3.0 axis (Rec / Min / Max / Error / Static). Spec-reviewer (0 critical, 1 high, 4 medium) and a11y-reviewer (0 critical, 2 high, 7 medium) passes folded in before release: the value lives on the root only (`data-ds-value`); the nested TextRow carries no `data-ds-*`; Standard first on the page; the Squircle-as-button exception recorded; a limit is said ("…, maximum" / "minimum", repeated on a tap at the limit); the web side buttons take the spoken value as their description from a `hidden` span; direct entry returns the focus to the Counter; iOS hints in Apple's idiom (none on −/+, "Opens [item] entry" on the Counter); TalkBack reads the step from the focused button's state change, the Counter is not a live region (to verify on device); the skeleton's "Loading" is a status node without `aria-busy`. Flagged for the owner: the Subtitle over the Red glow ≈ 4.1–4.4:1 (below 4.5); the Usage example's `TextAndIcon/Success` message ≈ 3.8:1 on `Background/Primary`; the gradient geometry has no DS token. Figma updated by the agent on the owner's ask (2026-10-21, not published): the `[NumericStepper] 3.2` set gets its description; the Specification frame's Accessibility block is rewritten to the a11y spec (generic `[Item]` / `[value]`, the step's hint without "by one", "Dimmed" on iOS, Apple-style iOS hints, a new Edge states point per platform), its two canvases re-pointed from a deleted `NumericStepper/Static/Off` instance to `[NumericStepper] 3.2` Default (Android "+" / iOS "−" disabled), and the frame grown 13273 → 15453 to fit. |

---

# NumericStepper — Accessibility

NumericStepper has **three tap targets**, each its own focusable button with its own announcement:
**Decrease** ("−"), the **Counter** in the middle, and **Increase** ("+"). The Counter is not
read-only text — it opens direct entry, a secondary screen or a bottom sheet where the value is
typed. "−" and "+" are announced by their meaning — "Decrease" / "Increase" — never as symbols.

**One component, two kinds of value** (owner, 2026-10-21). The same stepper holds a fare or a count;
the screen supplies the words:

- `[Item]` — what the value is, from the screen: *Fare*, *Passengers*, *Seats*. With no domain label
  the product falls back to "Quantity".
- `[value]` — the current value **as it is spoken**: a fare with its currency in words — "120,000
  Colombian pesos", never "C O L dollar 120 000" — or a count, "3". The product's formatter supplies
  the spoken form next to the visible one ("COL$ 120,000").

| Use | Visible | Decrease | Counter | Increase |
|---|---|---|---|---|
| Fare | COL$ 120,000 | "Decrease fare, 120,000 Colombian pesos" | "Fare, 120,000 Colombian pesos" | "Increase fare, 120,000 Colombian pesos" |
| Count | 3 | "Decrease passengers, 3" | "Passengers, 3" | "Increase passengers, 3" |

**The step is the product's.** A tap moves the value by the step the product sets — a fare moves by a
currency step, a count by one — so the hint names the action, not the amount: "Double-tap to
decrease", not "… by one".

**The new value is announced right after a step**, while focus stays on "−" or "+": the person never
has to move to the Counter to hear what the tap did. **A limit is said, not only shown**: a step that
lands on the minimum or the maximum adds the product's limit word — "130,000 Colombian pesos,
maximum" — and a tap on a disabled side button repeats that phrase, so a press is never silent.

**Direct entry.** The Counter opens a sheet or a screen titled "[Item]"; its input is labelled
"[Item]". On dismiss the focus returns to the Counter, and its name — "[Item], [value]" — reads the
committed value once; no live announcement on top of it.

**Voice control.** The visible text is not in the accessible name word for word ("COL$" is read
"Colombian pesos"), but the number is — "Tap 120,000" works. iOS adds
`accessibilityInputLabels(["[Item]", visible value])` to the Counter; on the web and Android the
spoken name carries the visible number.

---

## Android · TalkBack

| Element | Label | Value | Trait | Hint |
|---|---|---|---|---|
| Decrease | "Decrease [Item]" — `contentDescription` | `[value]` — `stateDescription` | Button — `Role.Button` | "Double-tap to decrease." — `onClick(label = "decrease")` |
| Counter | "[Item]" — `contentDescription` | `[value]`, or "Not set" in Empty — `stateDescription` | Button — `Role.Button` | "Double-tap to edit." — `onClick(label = "edit")` |
| Increase | "Increase [Item]" — `contentDescription` | `[value]` — `stateDescription` | Button — `Role.Button` | "Double-tap to increase." — `onClick(label = "increase")` |
| Icons, spacer, chevron, tone glow and edge | — | — | Decorative — not nodes | — |

Voiced previews:

- Decrease: "Decrease fare, 120,000 Colombian pesos, Button, Double-tap to decrease."
- Counter: "Fare, 120,000 Colombian pesos, Button, Double-tap to edit."
- Increase: "Increase passengers, 3, Button, Double-tap to increase."

### Edge states

- **After a step:** each step button's `stateDescription` follows the value, and TalkBack speaks a
  changed state of the focused node — the new value is read while focus stays on the step button.
  The Counter is **not** a live region: with both, the value would be read twice. (Verify on device;
  if a build does not voice the state change, the fallback is a polite live region on the Counter
  with the step buttons' `stateDescription` left unchanged until the next focus.)
- **At a limit:** the step that lands on it reads "[value], maximum" (or "minimum") — the product's
  words; a double-tap on the now disabled button re-reads that phrase.
- **Boundary — a disabled side button:** `enabled = false` (`disabled()` semantics). TalkBack appends
  "Disabled" and drops the hint: "Increase fare, 999,000 Colombian pesos, Button, Disabled." A
  double-tap does nothing. The Counter stays enabled at a boundary — it still opens direct entry.
- **Empty:** the Counter reads "[Item], Not set, Button, Double-tap to edit."; the visible "Tap to
  enter" is not read — it is a touch instruction. Both side buttons are Disabled by default.
- **Subtitle:** its text joins the Counter's `stateDescription` after the value, in words — "Fare,
  120,000 Colombian pesos, was 200,000 Colombian pesos". Its slot icons are decorative; an icon that
  carries meaning the text does not gets a word in that phrase from the screen.
- **Tones (Green / Orange / Red):** the colour is never announced and never the only signal. The
  screen's own message about the value — the text near the stepper — is appended to the value: the
  Counter's `stateDescription` on Android, its `accessibilityValue` on iOS, `aria-describedby` on the
  web. A step that changes the tone announces the new message with the new value.
- **Skeleton:** not announced, not focusable — `clearAndSetSemantics {}` on the shapes; the loading
  container announces once, politely. When the value lands, it is read in place, not announced on its
  own.
- **RTL:** the row mirrors ("+" left, "−" right); the semantic order stays Decrease, Counter,
  Increase, and TalkBack follows the reading direction, so the button on the right — Decrease — is
  first. The labels are semantic, not positional, and do not change.
- **Text scaling:** the Title caps at 130%; the Placeholder and the Subtitle scale to 150%, the DS
  maximum.

---

## iOS · VoiceOver

| Element | Label | Value | Trait | Hint |
|---|---|---|---|---|
| Decrease | "Decrease [Item]" — `accessibilityLabel` | `[value]` — `accessibilityValue` | `.button` | — the label says it; no hint |
| Counter | "[Item]" — `accessibilityLabel` | `[value]`, or "Not set" in Empty — `accessibilityValue` | `.button` | "Opens [item] entry." — `accessibilityHint` |
| Increase | "Increase [Item]" — `accessibilityLabel` | `[value]` — `accessibilityValue` | `.button` | — the label says it; no hint |
| Icons, spacer, chevron, tone glow and edge | — | — | Decorative — `accessibilityHidden(true)` | — |

Voiced previews:

- Decrease: "Decrease fare, 120,000 Colombian pesos, Button."
- Counter: "Fare, 120,000 Colombian pesos, Button. Opens fare entry."
- Increase: "Increase passengers, 3, Button."

Apple's hint idiom — a verb phrase that describes the result, never the gesture — so it holds for
Switch Control and Voice Control too. TalkBack's "Double-tap to …" is generated by the system from the
`onClick` label; iOS reads `accessibilityHint` verbatim.

### Edge states

- **After a step:** the new value is posted as an announcement (`UIAccessibility.post(notification:
  .announcement, argument:)`; SwiftUI `AccessibilityNotification.Announcement`) while focus stays on
  the step button; every element's `accessibilityValue` follows the value.
- **Boundary — a dimmed side button:** `.notEnabled` — VoiceOver appends "Dimmed": "Decrease fare,
  110,000 Colombian pesos, Dimmed, Button." It stays in navigation; a double-tap does nothing but
  re-posts the limit phrase. The Counter stays enabled.
- **At a limit:** the announcement after the step that lands on it is "[value], maximum" (or
  "minimum").
- **Empty:** the Counter reads "[Item], Not set, Button. Opens [item] entry."; the visible "Tap to
  enter" is not read. Both side buttons are Dimmed by default.
- **Subtitle:** as on Android — its text joins the Counter's `accessibilityValue` after the value; the
  slot icons are decorative unless the screen gives them a word.
- **Tones:** as on Android — the screen's message is appended to the Counter's `accessibilityValue`,
  after the value, and a step that changes the tone announces the new message.
- **Skeleton:** `accessibilityHidden(true)` on the shapes (`.isLoading(true)` alone does not hide
  them); the loading container announces once.
- **RTL / Dynamic Type:** as on Android.

---

## Web preview (reference)

- **Three `<button type="button">` elements** in DOM order Decrease, Counter, Increase. The side
  buttons carry `aria-label` ("Decrease fare") and `aria-describedby` pointing at a `hidden` span
  that holds the spoken value ("120,000 Colombian pesos") — a hidden node still feeds a description,
  and it is never read on its own in browse mode. The Counter's spoken text is a `.visually-hidden`
  span ("Fare, 120,000 Colombian pesos") and its visible title, subtitle, spacer, chevron and icons
  are `aria-hidden="true"`. A subtitle's text goes into the spoken span after the value, in words
  ("…, was 200,000 Colombian pesos").
- **After a step:** a `.visually-hidden` `aria-live="polite"` region inside the root receives the new
  spoken value (and the tone message, if it changed). The Counter itself is not the live region. The
  region holds only the latest value — a held Enter or Space repeats the step, and the last value
  wins.
- **At a limit:** the step that lands on it writes "[value], maximum" (or "minimum"); a click on the
  disabled button empties the region and writes the phrase again, so a repeated message is re-read.
- **Boundary — the trap:** a side button that reaches its limit takes `aria-disabled="true"` and
  `.squircle--disabled`, and its click handler returns early. Not `disabled`: a `disabled` button that
  has focus — "+" pressed up to the maximum — drops the focus to the page, and the person loses their
  place. With `aria-disabled` it stays focusable and is announced as unavailable.
- **Keyboard:** Tab moves Decrease → Counter → Increase; Enter and Space activate. No arrow keys — the
  stepper is three buttons, not a `spinbutton`: the value is not edited in place, the Counter opens
  entry.
- **Focus:** an `s2` ring in `var(--text-and-icon-primary)` inside the focused button's edge, radius
  `s20` — `:focus-visible` only.
- **Tones:** the screen's message is linked to the Counter with `aria-describedby`.
- **Skeleton:** the root is `aria-hidden="true"` and `inert`, and its buttons `tabindex="-1"` (a
  fallback where `inert` is not supported) — not announced, not focusable. "Loading" is the text of a
  separate `role="status"` node without `aria-busy`; `aria-busy="true"` goes on the content region
  being loaded — a busy live region holds its own announcement back.
- **RTL:** `dir="rtl"` mirrors the row and the chevron; the DOM order — and so the Tab order and the
  reading order — stays Decrease, Counter, Increase.
- **Reduced motion:** the press overlay appears without a fade and the shimmer holds still — both
  zeroed at the token.
- **Clipped value:** an over-long visible value ends in an ellipsis; the spoken value stays whole. The
  host gives the stepper the width its longest value needs.

### Colour and contrast

| Pairing | Tokens | Measured |
|---|---|---|
| Title on the Counter | `--text-and-icon-primary` on `--surface-on-white` | ≈ 15.7:1 Day / ≈ 13.2:1 Night; above 11:1 over the Green and Orange glow |
| Red value on the Counter | `--text-and-icon-error` on `--surface-on-white` | ≈ 3.5:1 Day / ≈ 3.8:1 Night; ≈ 3.1:1 / ≈ 3.5:1 over the strongest part of the glow — large text (Heading 3, bold 20), above the 3:1 floor |
| Placeholder, Subtitle, chevron | `--text-and-icon-secondary` on `--surface-on-white` | ≈ 4.6:1 Day / ≈ 6.1:1 Night |
| Subtitle over the Red glow | `--text-and-icon-secondary` over `--pastel-red2` at 60% | ≈ 4.1–4.4:1 Day — **below 4.5:1** for Compact Body (14, regular); Night, Green and Orange pass. Owner ruling 2026-11-12: the Figma colours stay; the gap is accepted |
| Focus ring | `--text-and-icon-primary` on `--surface-on-white` | ≈ 15.7:1 Day / ≈ 13.2:1 Night — WCAG 2.4.11 |
| Side button icon | `--text-and-icon-primary` on `--surface-on-white` | ≈ 15.7:1 Day / ≈ 13.2:1 Night |
| Disabled icon | `--text-and-icon-disabled` on `--surface-on-white` | ≈ 1.9:1 Day — below the floor on purpose; a disabled control is exempt, and the disabled state is announced, never shown by colour alone |
| Tone glow and edge | `--pastel-*` / `--border-success` · `--warning` · `--error` | Decorative — the meaning is the screen's text, not the colour |

### Touch targets

The side buttons are s56 × s56 and the Counter is s56 high — above the 48 minimum on every platform. The side buttons hold at 56 in every mode (`SP/100%`, owner 2026-11-12); the Counter grows with the SP mode to 72.8 at 130% and holds there.
The s4 gap between them is not a dead zone a finger falls into: each target already exceeds the
minimum on its own.

---

## References

**TalkBack (Android)**
- Turn on TalkBack — https://support.google.com/accessibility/android/answer/6007100
- Use TalkBack gestures — https://support.google.com/accessibility/android/answer/6151827
- Customize Android settings for TalkBack — https://support.google.com/accessibility/android/answer/6283655

**VoiceOver (iOS)**
- Operate iPhone when VoiceOver is on — https://support.apple.com/en-kz/guide/iphone/iph3e2e2329/ios
- Use VoiceOver gestures on iPhone — https://support.apple.com/en-kz/guide/iphone/iph3e2e2281/26/ios/26
- Control VoiceOver using the rotor on iPhone — https://support.apple.com/en-kz/guide/iphone/iph3e2e3a6d/26/ios/26
- Accessibility features for vision on iPhone — https://support.apple.com/en-kz/guide/iphone/iph732dc028d/26/ios/26

---

## Machine contract — `specs/components/numeric-stepper/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": "numeric-stepper",
  "name": "NumericStepper",
  "version": "3.2.3",
  "description": "A value between a \"−\" and a \"+\" Squircle L button — the fare the passenger offers, or a count — with a Counter in the middle that shows it and opens direct entry. Fare State Standard · Empty (\"Tap to enter\") · Green / Orange / Red (Positive / Warning / Error tones: a radial glow + 1dp edge on the Counter) · Skeleton; each side button's availability is the host's.",
  "files": {
    "spec": "specs/components/numeric-stepper/numeric-stepper.md",
    "a11y": "specs/components/numeric-stepper/numeric-stepper-a11y.md",
    "preview": "src/numeric-stepper.njk",
    "css": "src/shared/shared.css"
  },
  "figma": {
    "library": "🕹️ Oymyakon (components) — main file",
    "fileKey": "7vdl5YkZFDWvh9QvSmydsH",
    "componentSets": {
      "[NumericStepper] 3.2": {
        "key": "26e079e285fc7510d0f497685cb002cc18ec8055",
        "note": "Set 18379:2714 on page 18033:1471 — Fare State (Empty, Skeleton, Default, Green, Orange, Red) × RTL (Off, On) + the Chevron boolean; the Counter's text is the private StartText set 18379:608 (Currency Left / Right / Empty × RTL + the Subtitle boolean). Specification frame 18465:1511; Update 3.1 33509:3224, Update 3.2 34461:118287."
      }
    },
    "capturedAt": "2026-10-21"
  },
  "root": {
    "class": "numeric-stepper",
    "dataDsComponent": "numeric-stepper"
  },
  "anatomy": {
    "decrease": {
      "class": "numeric-stepper__step",
      "notes": "A <button> wearing squircle squircle--l (Type Icon) with the \"minus\" icon s24 in .squircle__slot; aria-label \"Decrease [Item]\"; data-ds-action=decrement, data-ds-target=decrease-button. Disabled = .squircle--disabled + aria-disabled=\"true\"; Skeleton = .squircle--skeleton."
    },
    "counter": {
      "class": "numeric-stepper__field",
      "notes": "The customButton between the side buttons — fill × s56 (SP/130% — var(--sp-130-s56)), radius s20, Surface/OnWhite; opens direct entry. Its spoken text is a .visually-hidden span (\"Fare, 120,000 Colombian pesos\"); the visible text and the chevron are aria-hidden. data-ds-action=edit, data-ds-target=counter-button."
    },
    "text": {
      "class": "numeric-stepper__text",
      "notes": "Fill, centred column: Title (or Placeholder) over the optional Subtitle, gap s0."
    },
    "title": {
      "class": "numeric-stepper__title",
      "onlyWhen": { "fareState": ["standard", "green", "orange", "red"] },
      "notes": "One line, Heading 3, --numeric-stepper-title (TextAndIcon/Primary; TextAndIcon/Error in Red); isolated left-to-right; currency and value split by s2."
    },
    "currency": {
      "class": "numeric-stepper__currency",
      "optional": true,
      "notes": "Before (prefix) or after (suffix) the value — the DOM order; none for a count (owner, 2026-10-21)."
    },
    "value": {
      "class": "numeric-stepper__value",
      "notes": "The formatted value from the product; an over-long line ends in an ellipsis on the web (iOS shrinks it via minimumTextScaleFactor)."
    },
    "placeholder": {
      "class": "numeric-stepper__placeholder",
      "onlyWhen": { "fareState": ["empty"] },
      "notes": "\"Tap to enter\" — Heading 4, TextAndIcon/Secondary, in place of the title; not read by screen readers (the Counter reads \"[Item], Not set\")."
    },
    "spacer": {
      "class": "numeric-stepper__spacer",
      "onlyWhen": { "chevron": [true] },
      "notes": "An empty s24 × s24 box at the Counter's start (the Figma s24 spacer instance) — the chevron's width, so the text centres between the two; toggled with the chevron by the same property; aria-hidden."
    },
    "subtitle": {
      "class": "numeric-stepper__subtitle",
      "optional": true,
      "notes": "Off by default. A TextRow (text-row.md) in Expanded mode on the same element (.text-row.text-row--expanded.numeric-stepper__subtitle — an internal part, no data-ds-* of its own, the Cell / Card precedent): one line, Compact Body, TextAndIcon/Secondary, centred. Its start / end slots (.text-row__slot-start-1/2, .text-row__slot-end-1/2 — only those in use in the markup) hold up to two icons on each side: IconContainer s16 (Modify = Whole container), s4 from the text; a mono icon takes the subtitle colour, a colour icon keeps its own. Figma examples: the previous fare struck through; \"+COL$120\" with wallet-color in the end slot. Read with the Counter after the value."
    },
    "chevron": {
      "class": "numeric-stepper__chevron",
      "onlyWhen": { "chevron": [true] },
      "notes": "IconContainer s24, chevron-right-small in TextAndIcon/Secondary at the Counter's end; mirrored in RTL."
    }
  },
  "axes": {
    "fareState": {
      "title": "Fare State",
      "type": "enum",
      "values": ["standard", "empty", "green", "orange", "red", "skeleton"],
      "default": "standard",
      "css": {
        "mechanism": "standard is the bare .numeric-stepper; .numeric-stepper--green / --orange / --red set the Counter's tone (--numeric-stepper-tone glow + --numeric-stepper-edge 1dp edge; red also --numeric-stepper-title → TextAndIcon/Error); .numeric-stepper--skeleton; empty = .numeric-stepper__placeholder in place of the title, no modifier"
      },
      "figma": {
        "kind": "variant-property",
        "property": "Fare State",
        "values": { "standard": "Default", "empty": "Empty", "green": "Green", "orange": "Orange", "red": "Red", "skeleton": "Skeleton" },
        "notes": "Renamed in 3.1 (Rec → Default, Min → Orange, Max → Green, Error → Red; Static removed). Both platform bridges still carry the 3.0 values."
      },
      "constraints": [
        "The tones are Positive (green) / Warning (orange) / Error (red) with no business meaning of their own — the screen says why in its own text (owner, 2026-10-21); never the only signal.",
        "The Fare State sets the side buttons' DEFAULT states (Standard both · Empty both disabled · Green − standard, + disabled · Orange − disabled, + standard · Red both disabled · Skeleton both skeleton); the host changes each side button on its own.",
        "The tone paints the Counter only — the side buttons never take the glow.",
        "Skeleton: aria-hidden + inert on the root; the Counter is empty; the loading container announces once."
      ]
    },
    "decreaseState": {
      "title": "Decrease",
      "type": "enum",
      "values": ["standard", "disabled"],
      "default": "standard",
      "css": { "mechanism": ".squircle--disabled + aria-disabled=\"true\" on the decrease button (never the disabled attribute — it drops the focus at a boundary)" },
      "figma": { "kind": "none", "notes": "The StartSlot Squircle instance's own State (Default / Disabled); the set stages it per Fare State, the instance overrides it." }
    },
    "increaseState": {
      "title": "Increase",
      "type": "enum",
      "values": ["standard", "disabled"],
      "default": "standard",
      "css": { "mechanism": ".squircle--disabled + aria-disabled=\"true\" on the increase button" },
      "figma": { "kind": "none", "notes": "The EndSlot Squircle instance's own State (Default / Disabled)." }
    },
    "chevron": {
      "title": "Chevron",
      "type": "boolean",
      "default": true,
      "css": { "modifier": ".numeric-stepper--no-chevron", "mechanism": "the modifier applies when false: the chevron and its balancing s24 spacer go together" },
      "figma": { "kind": "variant-property", "property": "Chevron", "notes": "A BOOLEAN component property (Chevron#28250:0) — it toggles both the s24 spacer and the IconContainer." }
    },
    "currency": {
      "title": "Currency",
      "type": "enum",
      "values": ["prefix", "suffix", "none"],
      "default": "prefix",
      "css": { "mechanism": "the order of .numeric-stepper__currency and .numeric-stepper__value inside the title; none = no currency element (a count)" },
      "figma": { "kind": "variant-property", "property": "Currency", "values": { "prefix": "Left", "suffix": "Right", "none": "—" }, "notes": "On the private StartText set; its third value, Empty, is the Fare State's placeholder. A value without a currency is not staged in Figma — owner ruling 2026-10-21 (the stepper also holds a count)." }
    },
    "subtitle": {
      "title": "Subtitle",
      "type": "boolean",
      "default": false,
      "css": { "mechanism": "the .numeric-stepper__subtitle element present or absent" },
      "figma": { "kind": "variant-property", "property": "Subtitle", "notes": "A BOOLEAN on the private StartText set (Subtitle#19459:0), hidden by default since 3.2." }
    },
    "rtl": {
      "title": "RTL",
      "type": "boolean",
      "default": false,
      "css": { "mechanism": "dir=\"rtl\" on the root or an ancestor: the row mirrors (+ left, − right), the spacer and the chevron mirror (the chevron glyph via :dir(rtl)), the skeleton sweep reverses; the title stays left-to-right" },
      "figma": { "kind": "variant-property", "property": "RTL", "values": { "false": "Off", "true": "On" } }
    },
    "titleColor": {
      "title": "Title colour",
      "type": "token",
      "tokens": ["--text-and-icon-primary", "--text-and-icon-error"],
      "default": "--text-and-icon-primary",
      "customizable": "any TextAndIcon/* token (Figma: \"Can be changed to any color from DS\")",
      "css": { "customProperty": "--numeric-stepper-title" },
      "figma": { "kind": "none", "notes": "An instance override of the StartText title colour; Red sets TextAndIcon/Error." }
    }
  },
  "states": {
    "default": ["standard", "empty", "green", "orange", "red", "skeleton"],
    "interaction": ["standard", "pressed", "focused", "disabled"]
  },
  "analytics": {
    "dataDsComponent": "numeric-stepper",
    "actions": ["decrement", "edit", "increment"],
    "targets": ["decrease-button", "counter-button", "increase-button"],
    "valueAttr": "data-ds-value",
    "notes": "Coverage unit, three internal targets. data-ds-state = the Fare State; no data-ds-preset (one size). data-ds-value on the root = the current value, updated by the product after every step. A disabled side button keeps its target attribution and emits no tap."
  },
  "rtl": {
    "supported": true,
    "notes": "The row mirrors (+ on the left, − on the right) and the chevron points left at the Counter's left edge; the title keeps its own order (the currency side is the Currency setting); the − / + icons are symmetric. DOM, Tab and reading order stay Decrease, Counter, Increase."
  },
  "constraints": [
    "One size: container fill × hug, padding s8, gap s4; side buttons Squircle L held at 100% — s56, radius s20, icon s24 on var(--sp-100-sN) (Figma SP/100%, owner 2026-11-12); Counter fill × s56 (SP/130% — var(--sp-130-s56)), radius s20, padding s8 top/bottom and s0 at the sides; its row = s24 spacer + text (fill) + s24 chevron, the spacer and the chevron toggled together.",
    "Three separate buttons — three tap targets, never one control; not a spinbutton, no arrow keys.",
    "Press = pushHighlight: the --shadow-pressed overlay over the pressed button's fill via --component-push-highlight; no scale. No other motion except the skeleton shimmer.",
    "A side button at a boundary is .squircle--disabled + aria-disabled=\"true\" — never the disabled attribute; the Counter stays enabled at a boundary and in Empty.",
    "The title is one line in Heading 3 (cap130); the value is formatted by the product, the component does not format it; its spoken form carries the currency in words.",
    "The new value is announced right after a step (web: the root's polite live region; iOS: an announcement; Android: the focused step button's stateDescription change — the Counter is not a live region); a step landing on a limit adds \"maximum\" / \"minimum\", and a tap on the disabled button repeats it.",
    "The tone glow (Pastel at 60%, radial from the bottom centre) and edge (Border/* 1dp, radial, gone by 32%) are Figma paints with no DS gradient token — flagged for the owner.",
    "Both mobile builds ship the Figma 3.0 Fare State values (Rec / Min / Max / Error / Static)."
  ]
}
```
