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

---

# Button · Oymyakon DS 3

> The primary action control: a hug-width pill assembled from one customButton base — a label column
> between two swappable side slots.

**Version:** 3.3.13 · **Status:** Draft · **Figma:** `[Button] 3.3` — `L-Button` (node `8216:7013`),
`M-Button` (node `8216:7170`), `S-Button` (node `8216:7337`), `Main CTA` (node `8216:7327`)

---

## 1. Description

Button commits a person to an action — submitting a form, confirming a choice, accepting an offer,
opening the next step of a flow. It is the most visible control in the system, so the `Style` axis
carries the weight of the action rather than its wording: `Primary` for the one action a screen is
built around, `Secondary` and `Ghost` for the alternatives beside it, `Error` for a destructive
choice.

The component is a label-first control. Text is the required part; StartSlot and EndSlot are opt-in
and carry meaning that the label alone would need extra words for. A control whose entire content
is an icon is Icon Button, a separate component.

Three sizes (`L`, `M`, `S`) cover in-page actions. `Main CTA` is a fourth, separate component set
for the single screen-level action pinned at the bottom of a flow — it drops the `Style` axis and
runs full-bleed.

All four are assembled globally from a single unified **customButton** — the base `.button`
component with every slot configurable (node `5:2397`). The named sizes are locked configurations of
customButton, the same relationship [Cell](https://super-dollop-pzmo65r.pages.github.io/cell.md) has to CustomCell and
[Card](https://super-dollop-pzmo65r.pages.github.io/card.md) to customCard: the base stays open, the presets fix their values so a
product team consumes them as-is.

---

## 2. Anatomy

Button is built entirely out of slots, named with the DS-wide **StartSlot / StartText / EndSlot**
semantics. Each one is an `INSTANCE_SWAP` slot that holds a default component and accepts another in
its place — the two side slots default to an IconContainer, the two StartText rows default to text.
Text is the default content of a row, not the row itself.

The base — **customButton**, all four slots configurable:

```
Button (customButton)   .button                    hug x s56 — required
├── StartSlot           .button__start-slot        swap slot, default IconContainer — optional, on by default
├── StartText           .button__start-text        row column, s2 gap — required
│   ├── Title row       .button__title-row         swap slot, default Heading 4 — required
│   └── Subtitle row    .button__subtitle-row      swap slot, default Compact Body — optional, on by default
└── EndSlot             .button__end-slot          swap slot, default IconContainer — optional, on by default
```

The locked presets replace the open row column with a per-size StartText whose rows carry text
rather than free slots, and turn both side slots off by default:

```
Button                  .button .button--l         hug x fixed height per size
├── StartSlot           .button__start-slot        optional, off by default
├── StartText           .button__start-text        .TextContainer (L / M / S) — required
│   ├── Title row       .button__title-row         single line of text — required
│   └── Subtitle row    .button__subtitle-row      text — optional, off by default
└── EndSlot             .button__end-slot          optional, off by default
```

| Element | Class | Required | Notes |
|---|---|---|---|
| Root | `.button` | required | Horizontal row, radius `s20`; the focusable and clickable element |
| StartSlot | `.button__start-slot` | optional | Figma `🔴StartSlot` (node `5:455`) — swap slot, default `s24` [IconContainer](https://github.com/inDriver/oymyakon-ds/blob/main/specs/primitives/icon-container.md). On by default in the base, off in the presets |
| StartText | `.button__start-text` | required | Base: Figma `🟡CustomContainer` (node `8216:8091`) — vertical, `s2` gap, hug, exposes `2nd-container` as a boolean, on by default. Presets: Figma `TextContainer (L)` (node `2083:1601`), `(M)` (node `2083:2398`), `(S)` (node `5411:6477`) with `s8` horizontal padding |
| Title row | `.button__title-row` | required | Figma `C-Slot` (node `8216:8089`) — a hug slot with one `INSTANCE_SWAP` property; default Heading 4. Locked to text in the presets |
| Subtitle row | `.button__subtitle-row` | optional | Base: the same `C-Slot`, default Compact Body, on by default. Presets: a text row behind a `Show Subtitle` boolean, off by default — available on `L`, `M` and `S`, absent on `Main CTA` |
| EndSlot | `.button__end-slot` | optional | Figma `🔵EndSlot` (node `5:453`), mirrors StartSlot |

A slot is invisible on its own — no fill, border or shadow — and only sets the size and alignment of
whatever sits inside it, the contract [`slot.md`](https://super-dollop-pzmo65r.pages.github.io/slot.md) defines and
[Cell](https://super-dollop-pzmo65r.pages.github.io/cell.md) and [Card](https://super-dollop-pzmo65r.pages.github.io/card.md) follow. So a row can carry a
[Tag](https://super-dollop-pzmo65r.pages.github.io/tag.md), an [Indicator](https://super-dollop-pzmo65r.pages.github.io/indicator.md), a price beside its struck-through
original, or any other DS component, and the button stays a button.

`Main CTA` carries no side slots — its anatomy is the root plus a single title row.

The row column is the clearest line between the two configurations. Both offer two rows, but only
customButton leaves them **open** — its rows are `INSTANCE_SWAP` slots, so a component-bearing label
stays inside the design system without detaching in Figma or in code. The presets keep the second
row as plain text behind a `Show Subtitle` boolean, off by default, which is what keeps a group of
buttons optically even until an instance asks for the extra line.

---

## 3. Variants and sizes

| Parameter | Values |
|---|---|
| Size | `L` · `M` · `S` (separate component sets) |
| Style | `Primary` · `Secondary` · `AlwaysLight` · `Ghost` · `Inverse` · `Error` (default `Primary`) |
| State | `Standard` · `Disabled` · `Skeleton` (default `Standard`) — `L` / `M` / `S` / `Main CTA` |
| Loading | `Off` · `On` (default `Off`) — `L` / `M` / `S` / `Main CTA` |
| RTL | `Off` · `On` (default `Off`) — `L` / `M` / `S` only |
| StartSlot | boolean — on by default in customButton, off in the presets; absent on `Main CTA` |
| EndSlot | boolean — on by default in customButton, off in the presets; absent on `Main CTA` |
| Subtitle row | boolean — `2nd-container` on the base, on by default; `Show SubTitle` on `L` / `M` / `S`, off by default. An exposed property of the nested TextContainer instance, not of the Button set |
| Config | `CustomButton` — the base's single variant, and its only non-slot property |
| Slot content | `INSTANCE_SWAP` per slot — any DS component; defaults per §2 |

Geometry — width hugs the content, height is fixed:

| Configuration | Height | Horizontal padding | Vertical padding | Gap | Radius | Class |
|---|---|---|---|---|---|---|
| customButton — base | `s56` | `s16` | `s8` | `s12` | `s20` | `.button` |
| `L` | `s56` | `s12` | `s8` | `s4` | `s20` | `.button--l` |
| `M` | `s48` | `s12` | 0 | `s4` | `s20` | `.button--m` |
| `S` | `s40` | `s12` | 0 | `s4` | `s20` | `.button--s` |
| `Main CTA` | `s64` | `s16` | 0 | 0 | `s20` | `.button--cta` |

The base runs a wider `s16` padding and a `s12` gap because its slots are on by default and its
label column can hold two rows; the presets tighten the padding to `s12` and the gap to `s4` for a
single row. The
radius holds at `s20` everywhere, so the shape reads as a pill at `s40` and as a rounded rectangle
at `s64`. `Main CTA` fills the width of its container by default rather than hugging its label — a
default, not a lock: an instance may be set to hug.

StartSlot and EndSlot carry a `s24` IconContainer by default in the base and in `L` / `M` / `S`;
`Main CTA` has neither.

**What each configuration locks**

| Configuration | Open | Locked |
|---|---|---|
| customButton | slot visibility, the content of all four slots, both rows | radius, the `Style` colour contract — and every axis besides: the set carries `Config` and the two slot booleans and nothing else, so a base instance has no `Style`, `State`, `Loading` or `RTL` to set |
| `L` / `M` / `S` | icon slot visibility and content, label text, subtitle visibility, `Style`, `State`, `Loading`, `RTL` | height, padding, gap, per-size text style, and the rows being text rather than free slots |
| `Main CTA` | label text, `State`, `Loading`, width (fill by default, hug available) | everything else — no icon slots, no `Style`, no `RTL` |

A team reaching for a shape the presets do not cover configures customButton rather than detaching
an instance, which is what keeps the result inside the design system in both Figma and code.

**Out of scope for this spec** — Icon Button is a separate roadmap entry with its own contract.

---

## 4. States

| State | Description |
|---|---|
| Standard | Rest. Fill and label follow the `Style` axis (§6) |
| Pressed | Held. Scales to 95% via `pushButton` (§5). It is a motion step, not a variant — `[Button] 3.3` has no Pressed member on the `State` axis and no pressed fill token, so the scale is the whole of it |
| Disabled | Non-interactive. Fill collapses to `Surface/OnWhite` and the label to `TextAndIcon/Disabled` for every `Style` |
| Loading | StartText, StartSlot and EndSlot give way to a [Loader](#5-animation-and-behavior). The button keeps its height and takes the same fixed width as its Skeleton — `s72` on `L` and `M`, `s64` on `S` — while `Main CTA` keeps its full width. The Loader steps with the size: `s32` on `Main CTA`, `s24` on `L`, `s16` on `M` and `S` |
| Skeleton | Placeholder while content resolves: the shape alone, no label or icons, filled by [Skeleton](https://super-dollop-pzmo65r.pages.github.io/skeleton.md). Width is fixed rather than hugging — `s72` on `L` and `M`, `s64` on `S` — since there is no label left to hug |

`Loading` is an axis of its own in Figma rather than a member of `State`, so it combines with
`Standard` and `Disabled` — a button can be loading while disabled. `Skeleton` pairs only with
`Loading=Off`.

Disabled is expressed by a token swap, not by `opacity`, which keeps the label's contrast
predictable on every surface.

---

## 5. Animation and behavior

Motion follows [`motion-rules.md`](https://super-dollop-pzmo65r.pages.github.io/motion.md) § 6.1, which names Button
as the consumer of `pushButton` — scale and colour moving together on press.

| Event | Pattern | Token | CSS |
|---|---|---|---|
| Press | `pushButton` | `--component-push-button-press` | `transform` (100% → 95%), `background-color` |
| Release | `pushButton` | `--component-push-button-release` | `transform` (95% → 100%), `background-color` |
| Standard ↔ Disabled | `Transforming/State/Default` | `--transforming-state-default` | `background-color`, `color` — consumed with its `motion-rules.md` value as a fallback, since `tokens/generated/` has no motion CSS |
| Loading Off ↔ On | `Transforming/State/Default` | `--transforming-state-default` | `opacity` |
| Loader, while loading | `Patterns/Spin` | `--pattern-spin` | `animation` — 800ms, linear, infinite |

```css
.button {
  transition: transform var(--component-push-button-release),
              background-color var(--component-push-button-release),
              color var(--transforming-state-default, 200ms cubic-bezier(0.4, 0, 0.2, 1));
}
.button:active {
  transform: scale(0.95);
  transition: transform var(--component-push-button-press),
              background-color var(--component-push-button-press);
}

@media (prefers-reduced-motion: reduce) {
  :root {
    --component-push-button-press: 0ms linear;
    --component-push-button-release: 0ms linear;
    --transforming-state-default: 0ms linear;
  }
}
```

Only `transform`, `background-color`, `color` and `opacity` participate, so the press stays on the
compositor. Reduced motion zeroes the tokens and the press becomes an instant colour change, with
the 95% scale step dropped.

The spinner rotates on `Patterns/Spin`, the pattern `motion-rules.md` defines for loading
indicators; reduced motion resolves `--pattern-spin` to `none`, leaving a static glyph.

The spinner is not Button's own drawing — it is an instance of **Loader**, a component in its own
right (Figma `[Loader] 3.1`, node `2046:7916`, six sizes × eight styles). Button consumes
`Style=AlwaysDark` at `M (24)` on `L` / `M` / `S` and `L (32)` on `Main CTA`, so the arc lands on
`var(--text-and-icon-always-dark)` — the same token as the label it replaces.

Loader has no Markdown spec and no `shared.css` build in this repo yet, so it is not part of the DS
here until it gets one. The Button page draws the arc geometry from the Figma component (its ring is
20% of the diameter at every size, so one path scales) and does not reproduce the conic fade of the
track. When Loader ships its own spec, Button consumes it instead.

---

## 6. Color tokens

One token name per role; Light/Dark substitution happens by name.

| Style | Class | Fill — Standard | Label and icons — Standard |
|---|---|---|---|
| Primary | `.button--primary` | `var(--background-brand)` | `var(--text-and-icon-always-dark)` |
| Secondary | `.button--secondary` | `var(--surface-on-white)` | `var(--text-and-icon-primary)` |
| AlwaysLight | `.button--always-light` | `var(--background-always-light)` | `var(--text-and-icon-always-dark)` |
| Ghost | `.button--ghost` | none — transparent | `var(--text-and-icon-primary)` |
| Inverse | `.button--inverse` | `var(--background-inverse-primary)` | `var(--text-and-icon-inverse-primary)` |
| Error | `.button--error` | `var(--surface-on-white)` | `var(--text-and-icon-error)` |

The size class and the style class combine — `.button.button--l.button--primary`. `Main CTA` takes
`.button--cta` alone, since its colour is fixed.

| Element | Token — Disabled |
|---|---|
| Fill, every `Style` | `var(--surface-on-white)` |
| Label and icons, every `Style` | `var(--text-and-icon-disabled)` |

No `Style` carries a border — the fill alone separates the button from its surface, and `Ghost`
relies on the label. `Error` reads as a tinted label on a neutral fill rather than a red block,
which keeps a destructive action legible next to a `Primary` one without competing with it.

`AlwaysLight` and `Inverse` hold their value across themes by design: `AlwaysLight` stays light on
imagery and dark surfaces, `Inverse` flips against the current background. `TextAndIcon/Brand` does
not appear here — the brand colour is the `Primary` fill, and its label is `AlwaysDark`.

---

## 7. Typography

In customButton these are the **default** contents of the row slots, replaceable per instance. In
the presets each row is locked to one style.

| Configuration | Title row | Subtitle row |
|---|---|---|
| customButton | Heading 4 — Suisse Intl Semibold 17 / 20 | Compact Body — Suisse Intl Book 14 / 16 |
| `L` | Heading 4 — Suisse Intl Semibold 17 / 20 | Compact Body — Suisse Intl Book 14 / 16 |
| `M` | Main Body — Suisse Intl Book 16 / 20 | Compact Body — Suisse Intl Book 14 / 16 |
| `S` | Main Body — Suisse Intl Book 16 / 20 | **Caption** — Suisse Intl Book 12 / 16 |
| `Main CTA` | Heading 2 — PP Agrandir Bold 24 / 28 | — no subtitle |

Scaling follows the style: `cap130` on `Main CTA` (Heading 2), `full` everywhere else.

The subtitle steps down from its title rather than being one style everywhere. On `L` and `M` that
step is Compact Body; on `S` the title is already Main Body, so Compact Body would barely differ and
the subtitle drops to Caption — the DS floor at 12, and the reason `S` is the last size that can
carry two rows at all.

`L` steps up to a heading style for emphasis while staying in Suisse Intl. `Main CTA` is the one
configuration that reaches for PP Agrandir, consistent with the typeface split — the large-heading
face is reserved for Promo Heading, H1 and H2.

A row holds a single line. Text that would wrap indicates the label is carrying explanatory copy
that belongs outside the button, or a case for the subtitle row.

> **Figma naming note:** in `.TextContainer (S)` the title instance is named `CompactBody` but
> carries the `Body/Main Body` style at 16. The rendered style is the contract; the instance name is
> stale.

Each row's values are the ones its text token resolves to, so an implementation sets family, size,
weight and line-height together from `var(--text-{group}-{name}-*)` rather than from the numbers in
this table.

---

## 8. Spacing

On **customButton** every number in this section is a **default**: a design request may change any
of them on an instance, provided the replacement is a token from the SP scale rather than a raw
value. On the presets none of them moves. A preset exists so that nothing is changed — not the
padding, not the gap, not the height, not the radius, not the text style — and a shape the presets
do not cover is built from the base instead of by overriding one of them.

| Property | Token | Value |
|---|---|---|
| Height — `L` | `var(--sp-s56)` | 56 |
| Height — `M` | `var(--sp-s48)` | 48 |
| Height — `S` | `var(--sp-s40)` | 40 |
| Height — `Main CTA` | `var(--sp-s64)` | 64 |
| Radius, all sizes | `var(--sp-s20)` | 20 |
| Horizontal padding — `L` / `M` / `S` | `var(--sp-s12)` | 12 |
| Horizontal padding — `Main CTA` | `var(--sp-s16)` | 16 |
| Vertical padding — `L` | `var(--sp-s8)` | 8 |
| Gap — StartSlot / StartText / EndSlot | `var(--sp-s4)` | 4 in the presets, `s12` in the base |
| StartSlot / EndSlot outer padding, toward the button edge — presets | `var(--sp-s4)` | 4; 0 in the base |
| StartText horizontal padding — presets | `var(--sp-s8)` | 8; 0 in the base |
| StartText row gap — base | `var(--sp-s2)` | 2 — between the title and subtitle rows |
| IconContainer in StartSlot / EndSlot | `var(--sp-s24)` | 24 x 24 |

**Optical balance — why a side ends up at 16 or 20.** An icon carries its own air; a label does
not. Left at the same padding, the side that ends in text reads tighter than the side that ends in
an icon, and a button with one slot on looks off-centre. The presets solve it with the two inner
paddings above rather than with a second padding token, so the `s12` root padding resolves to:

| Side ends in | Effective padding | Made of |
|---|---|---|
| An icon (StartSlot / EndSlot on) | **16** | `s12` root + `s4` slot outer padding |
| A label (no slot on that side) | **20** | `s12` root + `s8` StartText padding |

`Main CTA` has no side slots, so its label always sits at `s16`. The base needs neither correction:
it balances with `s16` root padding and a `s12` gap, and both its side slots sit at 16.

Height is fixed per size and the content is centred, so the vertical padding on `L` sets the
label's minimum breathing room rather than driving the height. Width hugs the content on `L`, `M`
and `S`; `Main CTA` fills its container.

Every value moves with the SP scale modes (100% / 130% / 150%), which lifts `S` to a `s40` target
at 100% and higher beyond it.

---

## 9. Usage context

**When to use**

- The action a screen is built around — submit, confirm, accept, continue.
- An alternative beside that action, in `Secondary` or `Ghost`.
- A destructive choice, in `Error`.
- The single screen-level action pinned to the bottom of a flow, as `Main CTA` — with nothing under it.

**When not to use**

- Navigation to another screen without committing to anything — that is a link, which carries an
  underline (see [`typography-rules.md`](https://super-dollop-pzmo65r.pages.github.io/typography.md)).
- A control whose whole content is an icon — that is Icon Button.
- A selectable filter or removable token — that is [Tag](https://super-dollop-pzmo65r.pages.github.io/tag.md).
- One option out of a fixed set shown side by side — that is
  [Segmented Control](https://super-dollop-pzmo65r.pages.github.io/segmented-control.md).
- A row in a list that opens a detail view — that is [Cell](https://super-dollop-pzmo65r.pages.github.io/cell.md).

Two `Primary` buttons in one view compete for the same attention; the second action reads more
clearly as `Secondary` or `Ghost`.

`Main CTA` is the bottom of a screen, not the top of a stack. A second button placed under it makes
two screen-level commitments out of one, and the one that is actually pinned to the bottom is no
longer the `Main CTA`. A skip or an alternative belongs above it, or in the flow, or nowhere.

**Related components** — [IconContainer](https://github.com/inDriver/oymyakon-ds/blob/main/specs/primitives/icon-container.md) (StartSlot, EndSlot),
[Slot](https://super-dollop-pzmo65r.pages.github.io/slot.md), [Skeleton](https://super-dollop-pzmo65r.pages.github.io/skeleton.md) (the `Skeleton` state),
[Card](https://super-dollop-pzmo65r.pages.github.io/card.md) and [Cell](https://super-dollop-pzmo65r.pages.github.io/cell.md) (frequent hosts), Icon Button.

---

## 10. Accessibility

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

The root is the interactive element, which keeps one focus stop per button and one target for
pointer and assistive technology alike. At 100% SP scale the `S` height of `s40` sits below the
`s48` recommended by WCAG 2.5.5, so `S` suits dense contexts where the surrounding layout adds
spacing, and `M` upward meets the target on its own.

`Loading` and `Disabled` both remove the action, and each is announced rather than shown by colour
alone: `Loading` exposes a busy state, `Disabled` a disabled one. Because the label is replaced
while loading, the accessible name is preserved separately so the button does not lose its identity
mid-request.

`Ghost` carries no fill, so its label does the whole job of marking the control — worth checking
against the surface it sits on.

---

## 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` | `button` |
| `data-ds-preset` | `custom-button` (base) · `l` · `m` · `s` · `main-cta` |
| `data-ds-variant` | the `Style` value in lower case; absent on `main-cta` |
| Coverage unit | yes |
| Tap target model | root — the whole button is the only target, whatever the slots hold |
| Actions | `tap` |
| Internal targets | none |
| Emits value | no — `data-ds-state` reports status, not a user value |

A component swapped into a slot keeps its own `data-ds-component`, so it is counted for DS coverage,
but it registers no tap of its own — the button's root remains the single action target.

```html
<button class="button button--l button--primary"
        data-ds-component="button"
        data-ds-component-id="checkout.summary.confirm"
        data-ds-preset="l"
        data-ds-variant="primary"
        data-ds-state="standard"
        data-ds-action="tap">
  <span class="button__start-text">
    <span class="button__title-row">Accept</span>
  </span>
</button>
```

With both side slots on, each icon is marked as part of the button rather than as its own target,
since neither is separately actionable:

```html
<button class="button button--m button--secondary"
        data-ds-component="button"
        data-ds-component-id="trip.details.share"
        data-ds-preset="m"
        data-ds-variant="secondary"
        data-ds-state="standard"
        data-ds-action="tap">
  <span class="button__start-slot" aria-hidden="true"><!-- IconContainer s24 --></span>
  <span class="button__start-text">
    <span class="button__title-row">Share</span>
  </span>
  <span class="button__end-slot" aria-hidden="true"><!-- IconContainer s24 --></span>
</button>
```

The base configuration carries both rows, and a row holds whatever component it was swapped to:

```html
<button class="button"
        data-ds-component="button"
        data-ds-component-id="checkout.fare.pay"
        data-ds-preset="custom-button"
        data-ds-state="standard"
        data-ds-action="tap">
  <span class="button__start-text">
    <span class="button__title-row">Pay</span>
    <span class="button__subtitle-row"><!-- swapped: Tag, Indicator, price row, … --></span>
  </span>
</button>
```

`data-ds-variant` carries the `Style` value in lower case. `data-ds-state` follows the live state —
`standard`, `disabled`, `loading`, `skeleton` — and is updated together with the matching ARIA
attribute, so the analytics value and the accessible value stay in step.

---

## Changelog

| Version | Date | Change |
|---|---|---|
| 3.3.13 | 2026-08-03 | The focus ring specified in `button-a11y.md` § Focus is now built: `.button:focus-visible` takes an `s2` outline in `TextAndIcon/Primary` at radius `s20`, offset inward the way Checkbox does it so a fill-width `Main CTA` cannot push the ring past its container. It had been specified and never implemented. Accessibility section built on the page, on real `<button>` elements. |
| 3.3.12 | 2026-08-03 | §9 records that **nothing sits under `Main CTA`**. It is the bottom of a screen, not the top of a stack: a second button beneath it makes two screen-level commitments out of one, and whatever is pinned lowest stops being the Main CTA. Shown on the page as the third Usage counter-example. |
| 3.3.11 | 2026-08-03 | §5 matched to the build: the label colour now actually transitions on `Standard ↔ Disabled`. The `.button` rule had listed only `transform` and `background-color`, so the `color` row of the motion table described something that did not happen. `--transforming-state-default` is consumed with its `motion-rules.md` value as a fallback and zeroed under reduced motion — the DS has no generated motion CSS, so the token has no declaration site. Notes that `Loading Off ↔ On` swaps markup rather than crossfading opacity in the web build. |
| 3.3.10 | 2026-08-03 | Scopes the "every number is a default" note added in 3.3.8 to **customButton only**. On `L` / `M` / `S` / `Main CTA` nothing moves: a preset exists so that padding, gap, height, radius and text style are not changed, and a shape they do not cover is built from the base. |
| 3.3.9 | 2026-08-03 | `Main CTA` fills its container **by default**, not always — an instance may be set to hug. §3 had stated full-bleed width as a lock. |
| 3.3.8 | 2026-08-03 | Axis availability read off the Figma sets rather than assumed. **The base carries `Config` and its two slot booleans and nothing else** — no `Style`, `State`, `Loading` or `RTL`, which §3 had listed as if they applied everywhere. `Main CTA` has `State` and `Loading` but **no `RTL`**. The subtitle toggle is spelled `Show SubTitle` and is an exposed property of the nested TextContainer instance, not of the Button set. Also fixes a §3 sentence that had the preset padding and gap the wrong way round, and notes in §8 that every spacing number is a default. Web: the title typography moves from the root onto `.button__title-row`, matching how the subtitle already worked; rendering is unchanged. |
| 3.3.7 | 2026-08-03 | §8 opens with the note that every spacing number — padding, gap, radius, height — is a **default** a design request may change, the constraint being that the replacement is an SP-scale token rather than a raw value. Recorded while building the Layout section of the site page, where the same note now sits in grey under each geometry line. |
| 3.3.6 | 2026-07-31 | §7 rebuilt around the two rows now that the presets are known to carry a subtitle. **The subtitle style steps down from its title rather than being one style everywhere**: Compact Body on the base, `L` and `M`, but **Caption** on `S`, where the title is already Main Body. 3.3.4 had left the web build on Compact Body for every size. `Main CTA` has no subtitle. Notes a Figma naming oddity: in `.TextContainer (S)` the title instance is named `CompactBody` while carrying `Body/Main Body` at 16 — the rendered style is the contract. |
| 3.3.5 | 2026-07-31 | §8 records the **optical balance** of the presets: a side ending in an icon sits at an effective 16 (`s12` root + `s4` slot outer padding), a side ending in a label at 20 (`s12` root + `s8` StartText padding), so a button with one slot on still reads centred. Both inner paddings exist in Figma and were missing from the web build, which put every side at a flat 12. Also corrects the §8 row that described the slot padding as sitting toward StartText — it sits toward the button edge. |
| 3.3.4 | 2026-07-31 | **The subtitle is not base-only.** `.TextContainer (L)`, `(M)` and `(S)` each hold a `↳Title` and a `↳Subtitle` behind a `Show Subtitle` boolean (default off), so all three hug sizes can carry a second row; `Main CTA` still cannot. 3.3.0 claimed the presets locked the row out. What actually separates the base is that its rows are `INSTANCE_SWAP` slots while the presets' rows are plain text — openness, not row count. |
| 3.3.3 | 2026-07-31 | Three corrections found while building the States matrix. **Loader size steps with the button** — `s32` on `Main CTA`, `s24` on `L`, `s16` on `M` and `S`; 3.3.2 claimed `s24` for all three hug sizes. **Loading width is fixed, not hugging** — `s72` on `L` and `M`, `s64` on `S`, the same widths as Skeleton, which the spec had not recorded either. **Pressed is a motion step, not a variant** — `[Button] 3.3` has no Pressed member on the `State` axis and the DS has no pressed fill token (`--color-pressed-state` from the `pushButton` sample does not exist), so the earlier "the fill shifts" was unfounded; the 95% scale is the whole of it. |
| 3.3.2 | 2026-07-31 | The spinner is named for what it is: an instance of **Loader** (Figma `[Loader] 3.1`, node `2046:7916` — six sizes × eight styles), consumed at `Style=AlwaysDark`, `M (24)` on `L`/`M`/`S` and `L (32)` on `Main CTA`. 3.3.1 treated it as an anonymous glyph and the page drew `icons/outlined/progress/progress.svg`, a different asset. Loader still has no spec or web build in this repo, so Button borrows its arc geometry and skips the conic fade until Loader ships. |
| 3.3.1 | 2026-07-31 | Loading corrected against Figma while building the page: only `L` / `M` / `S` hug the spinner at `s24` — `Main CTA` keeps its full 359 width and takes a `s32` spinner. §5 gains the spinner row: rotation is `Patterns/Spin` (`--pattern-spin`, 800ms infinite, `none` under reduced motion), which `motion-rules.md` already defines for loading indicators; the earlier note claiming the loader had no motion contract was wrong. Web build draws the glyph from `icons/outlined/progress/progress.svg`. |
| 3.3.0 | 2026-07-31 | Initial spec, aligned to Figma `[Button] 3.3`. All configurations are assembled from one base — **customButton** (node `5:2397`) — with the named sizes as its locked configurations, the same base/preset relationship Cell has to CustomCell and Card to customCard. Slots landed on the DS-wide **StartSlot / StartText / EndSlot** semantics; each is an `INSTANCE_SWAP` slot whose text or IconContainer is a *default*, replaceable by any DS component. StartText carries two `C-Slot` rows (title required, subtitle on by default in the base, locked out of the presets). Sizes `L` `s56` / `M` `s48` / `S` `s40` / `Main CTA` `s64`, six `Style` values, `State` x `Loading` x `RTL` axes, `pushButton` motion. Web implementation (`shared.css` classes, preview page, `capabilities.json`) pending; the loader inside `Loading` has no spec of its own yet. |

---

# Button — Accessibility

**Component:** Button
**Version:** 3.3.0
**Spec:** [`button.md`](https://super-dollop-pzmo65r.pages.github.io/button.md)

---

## Role and ARIA

| Attribute | Value | Where |
|---|---|---|
| `role` | `"button"` | Native on `<button>`; a custom element needs it explicitly |
| `aria-disabled` | `"true"` | The root, in the `Disabled` state |
| `aria-busy` | `"true"` | The root, in the `Loading` state |
| `aria-label` | The action's text | The root, only while the visible label is replaced by the spinner |
| `aria-hidden` | `"true"` | StartSlot / EndSlot holding a decorative icon — see the note below for slots carrying meaning |

A native `<button>` carries the role, the focus behaviour and both activation keys on its own, which
is why it is the DS markup. A `<div>` or `<span>` standing in for it needs `role="button"`,
`tabindex="0"` and its own key handling.

The `Loading` state removes the label from the accessible tree, so the accessible name is held in
`aria-label` for the duration of the request. Without it the button announces as an unnamed busy
control, and a person who focuses it mid-request loses track of what they pressed.

Every slot in Button is an `INSTANCE_SWAP` slot (see [`button.md`](https://super-dollop-pzmo65r.pages.github.io/button.md) §2), so what a slot
contributes to the accessible name depends on what was put in it:

| Slot content | Treatment |
|---|---|
| The default IconContainer beside a label that already names the action | `aria-hidden="true"` — exposing it would repeat the label |
| A component carrying information the label omits — a [Tag](https://super-dollop-pzmo65r.pages.github.io/tag.md), an [Indicator](https://super-dollop-pzmo65r.pages.github.io/indicator.md), a price | Exposed, and its text joins the button's accessible name in reading order |
| A subtitle row in the base configuration | Exposed — it is part of the label, read after the title row |

The button stays a single accessibility element in every case: content inside the slots is merged
into its name rather than becoming a separate focus stop, which is what keeps one stop per button.
An icon that is the *only* carrier of meaning indicates the label is missing a word.

`aria-disabled` marks the state while keeping the control discoverable. A hard `disabled` attribute
also works and additionally drops the button from the tab order — the choice depends on whether the
action should stay findable while unavailable.

---

## Focus

The root is the focusable element. It is also the only one — no slot and no row takes focus,
which keeps one stop per button.

| Requirement | Implementation |
|---|---|
| Reachable by Tab | `<button>` is focusable natively |
| Visible focus ring | `:focus-visible` — `s2` outline in `var(--text-and-icon-primary)`, radius `s20` to follow the pill |
| Disabled | `aria-disabled` keeps it in the tab order; `disabled` removes it |
| Loading | Focus is retained; the control stays focusable while `aria-busy` is set |

Focus is preserved across a `Loading` transition. Moving focus elsewhere when a request starts
would strand a keyboard or screen-reader user, since the button they activated is the anchor for
whatever the response reports.

`Ghost` has no fill, so its focus ring is the only shape on the surface — worth checking on both
Light and Dark backgrounds.

---

## Keyboard

| Key | Behaviour |
|---|---|
| `Tab` / `Shift+Tab` | Moves focus to and from the button |
| `Enter` | Activates |
| `Space` | Activates |

Both `Enter` and `Space` activate a button, which is the platform behaviour a native `<button>`
provides and a custom element reproduces. This differs from
[Checkbox](https://super-dollop-pzmo65r.pages.github.io/checkbox.md), where `Space` toggles and `Enter` does nothing.

In the `Disabled` and `Loading` states the activation keys have no effect, and repeated presses
while loading do not queue a second action.

---

## Contrast

Every pairing in §6 of the spec resolves through semantic tokens, so Light and Dark are covered by
one name. The pairings worth verifying when a `Style` or a surface changes:

| Pairing | Note |
|---|---|
| `Background/Brand` + `TextAndIcon/AlwaysDark` | The `Primary` combination; the brand fill takes a dark label, not a brand-coloured one |
| `Surface/OnWhite` + `TextAndIcon/Error` | `Error` carries the destructive meaning in the label, so its contrast does the whole job |
| transparent + `TextAndIcon/Primary` | `Ghost` inherits whatever sits behind it — the contrast depends on the host surface |
| `Surface/OnWhite` + `TextAndIcon/Disabled` | The lowest-contrast pairing in the component by intent; it stays above the 3:1 floor for disabled controls |

`AlwaysLight` and `Inverse` hold fixed values across themes, so a screen that switches theme
changes what sits behind them — their contrast is a property of the surface, not of the token pair.

No state is signalled by colour alone. `Disabled` is announced, `Loading` replaces the label with a
spinner and sets `aria-busy`, and `Skeleton` carries no label to misread.

---

## Android · TalkBack

`[Label]` — the button's visible text (for example *Accept*, *Share*, *Delete*).

### Tap target · the button

| Attribute | Description |
|---|---|
| **Voiced preview** | "[Label], Button, Double-tap to activate." |
| **Label** | [Label] |
| **Value** | — |
| **Trait** | Button |
| **Hint** | Double-tap to activate. |

### Edge states

- **Disabled:** *"[Label], Button, Disabled."* The Hint is dropped because the element is not actionable.
- **Loading:** *"[Label], Button, Busy."* The label comes from `aria-label` while the spinner occupies the slot the text used, so the announcement does not change mid-request.
- **Loading and disabled:** both states are appended — *"[Label], Button, Disabled, Busy."*
- **Skeleton:** not exposed. The placeholder is removed from the accessibility tree so a person is not offered a control that does not exist yet.
- **Result of the action:** the outcome of a request is announced by the region that changes, not by the button — the button reverts to its `Standard` announcement once `aria-busy` clears.

---

## iOS · VoiceOver

### Tap target · the button

| Attribute | Description |
|---|---|
| **Voiced preview** | "[Label], Button. Double-tap to activate." |
| **Label** | [Label] |
| **Value** | — |
| **Trait** | Button |
| **Hint** | Double-tap to activate. |

### Edge states

- **Dimmed:** `accessibilityTraits.notEnabled` — VoiceOver appends "Dimmed"; the control stays in the rotor so its state is discoverable.
- **Loading:** the label is preserved and the busy state is exposed; the spinner itself is not a separate element.
- **Skeleton:** `accessibilityElementsHidden` — the placeholder is not reachable.
- **Main CTA:** the same single element, announced identically; its full-bleed width changes nothing about the announcement.

---

## Target size

| Size | Height | Against WCAG 2.5.5 |
|---|---|---|
| `L` | `s56` | Above the 48 the DS uses |
| `M` | `s48` | Meets it |
| `S` | `s40` | Below 48 — suited to dense contexts where the layout adds separation around it |
| `Main CTA` | `s64` | Above it |

`S` clears the 24 x 24 absolute minimum but not the recommended 44 x 44 for pointer input, so it
fits a toolbar or a compact row rather than a primary action. The SP scale modes lift every height,
which brings `S` to 52 at 130% and 60 at 150%.

---

## RTL

Button opts into RTL through its own `RTL` axis (`Off` by default), so the mirroring is part of the
component contract rather than a page-level concern.

| Element | RTL=On |
|---|---|
| Slot order | StartSlot and EndSlot swap sides — start follows the reading direction |
| Label alignment | Follows the text direction |
| Directional icons | Chevron, arrow and back glyphs flip; other icons keep their orientation (see [`icon-container.md`](https://github.com/inDriver/oymyakon-ds/blob/main/specs/primitives/icon-container.md) § 3.5) |
| Padding | The `s12` horizontal padding is symmetric, so it needs no mirroring |

The press animation is unaffected: `pushButton` scales from the centre.

---

## Testing checklist

- [ ] Tab reaches the button; the focus ring is visible against both Light and Dark backgrounds
- [ ] Both `Enter` and `Space` activate
- [ ] `Ghost` stays legible and its focus ring visible on every surface it is placed on
- [ ] `Loading` sets `aria-busy` and preserves the accessible name via `aria-label`
- [ ] Focus is not moved away when a request starts
- [ ] Repeated activation while loading does not fire a second action
- [ ] `Disabled` does not respond to `Enter` or `Space`
- [ ] `Skeleton` is absent from the accessibility tree
- [ ] A decorative icon in StartSlot / EndSlot is `aria-hidden` and adds nothing to the announcement
- [ ] A slot swapped to a meaning-bearing component is exposed and read in visual order
- [ ] The subtitle row, when on, is read after the title row as part of one accessible name
- [ ] TalkBack announces label + "Button", with "Disabled" / "Busy" appended in those states
- [ ] VoiceOver announces label + Button trait, with "Dimmed" appended when disabled
- [ ] `S` is used only where the surrounding layout provides separation
- [ ] RTL=On swaps the slots and flips directional icons only
- [ ] `data-ds-state` matches the ARIA state after every transition

---

## Machine contract — `specs/components/button/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": "button",
  "name": "Button",
  "version": "3.3.13",
  "description": "The primary action control — a hug-width pill assembled from one customButton base: a label column between two swappable side slots. Four locked configurations (L, M, S, Main CTA), six styles.",
  "files": {
    "spec": "specs/components/button/button.md",
    "a11y": "specs/components/button/button-a11y.md",
    "preview": "src/button.njk",
    "css": "src/shared/shared.css"
  },
  "figma": {
    "library": "🕹️ Oymyakon 3.26.0 (components)",
    "fileKey": "7vdl5YkZFDWvh9QvSmydsH",
    "componentSets": {
      "customButton": {
        "key": "76fc5167a9003fe2811b7709ddc2c491f1f12327",
        "note": "The base. Carries Config=CustomButton plus the two slot booleans (both default true) and nothing else — no Style, State, Loading or RTL."
      },
      "L-Button": { "key": "5517887087ce5c5ce1a8f42e3dd37c64bd22af3f" },
      "M-Button": { "key": "2423458e70edcb8a2afb23efddf85009ec580903" },
      "S-Button": { "key": "e3d3b79f26f8dcae673ff32bedec0bb25f5e8ec8" },
      "Main CTA": {
        "key": "c90e015b5831d6ab3ee3a595d6afd1d5f94fdcbf",
        "note": "State and Loading only — no Style, no RTL, no side slots."
      }
    },
    "capturedAt": "2026-08-03"
  },
  "root": { "class": "button", "dataDsComponent": "button" },
  "anatomy": {
    "startSlot": {
      "class": "button__start-slot",
      "optional": true,
      "notes": "Figma 🔴Slot#1 — an INSTANCE_SWAP slot sized s24, IconContainer by default. On by default in the base, off in the presets, absent on Main CTA. On the presets it carries s4 of outer padding, which is what turns the s12 root padding into an effective 16."
    },
    "startText": {
      "class": "button__start-text",
      "notes": "The label column — vertical, s2 row gap, centred. Base: Figma 🟡CustomContainer, rows are free slots. Presets: TextContainer (L/M/S) with s8 horizontal padding, rows locked to text."
    },
    "titleRow": {
      "class": "button__title-row",
      "notes": "Required. Heading 4 on the base and L, Main Body on M and S, Heading 2 on Main CTA. A swap slot in the base (default text), plain text in the presets. One line, truncates to an ellipsis."
    },
    "subtitleRow": {
      "class": "button__subtitle-row",
      "optional": true,
      "notes": "Compact Body on the base, L and M; Caption on S, where the title is already Main Body. On by default in the base, off in the presets, absent on Main CTA. Truncates independently of the title."
    },
    "endSlot": {
      "class": "button__end-slot",
      "optional": true,
      "notes": "Figma 🔵Slot#2 — mirrors startSlot, s4 outer padding on the presets."
    },
    "spinner": {
      "class": "button__spinner",
      "optional": true,
      "onlyWhen": { "loading": [true] },
      "notes": "An instance of Loader at Style=AlwaysDark. Size steps with the button: s32 on Main CTA, s24 on L, s16 on M and S. Rotates with Patterns/Spin (--pattern-spin)."
    }
  },
  "axes": {
    "preset": {
      "title": "Preset",
      "type": "enum",
      "values": ["custom-button", "l", "m", "s", "main-cta"],
      "default": "custom-button",
      "css": { "mechanism": "custom-button = bare .button (both slots on, rows open); presets add .button--l / .button--m / .button--s / .button--cta plus the matching data-ds-preset" },
      "figma": {
        "kind": "component-set",
        "values": { "custom-button": "customButton", "l": "L-Button", "m": "M-Button", "s": "S-Button", "main-cta": "Main CTA" }
      },
      "constraints": [
        "The presets are locked configurations — height, padding, gap and per-size text style cannot be changed inside them; for freedom configure the base.",
        "A team reaching for a shape the presets do not cover configures customButton rather than detaching an instance."
      ]
    },
    "style": {
      "title": "Style",
      "type": "enum",
      "values": ["primary", "secondary", "always-light", "ghost", "inverse", "error"],
      "default": "primary",
      "css": { "modifierTemplate": ".button--{value}" },
      "figma": {
        "kind": "variant-property",
        "property": "Style",
        "values": { "primary": "Primary", "secondary": "Secondary", "always-light": "AlwaysLight", "ghost": "Ghost", "inverse": "Inverse", "error": "Error" }
      },
      "constraints": [
        "Available on L / M / S only. Main CTA has no Style property — its colour is fixed. The base has none either; its fill is baked and any DS colour token may replace it on an instance.",
        "No Style carries a border — the fill alone separates the button from its surface, and Ghost relies on the label.",
        "AlwaysLight and Inverse hold their value across themes by design."
      ]
    },
    "state": {
      "title": "State",
      "type": "enum",
      "values": ["standard", "disabled", "skeleton"],
      "default": "standard",
      "css": { "mechanism": "standard = no modifier; disabled = .button--disabled; skeleton = .button--skeleton wrapping a Skeleton instance" },
      "figma": {
        "kind": "variant-property",
        "property": "State",
        "values": { "standard": "Standard", "disabled": "Disabled", "skeleton": "Skeleton" }
      },
      "constraints": [
        "Available on L / M / S / Main CTA. The base has no State property.",
        "Disabled is a token swap — Surface/OnWhite fill and TextAndIcon/Disabled label for every Style — never opacity.",
        "Skeleton pairs only with loading:false."
      ]
    },
    "loading": {
      "title": "Loading",
      "type": "boolean",
      "default": false,
      "css": { "modifier": ".button--loading" },
      "figma": { "kind": "variant-property", "property": "Loading", "values": { "false": "Off", "true": "On" } },
      "constraints": [
        "Available on L / M / S / Main CTA. The base has no Loading property.",
        "An axis of its own, so it combines with state:standard and state:disabled — a button can be loading while disabled.",
        "The label and both side slots give way to the Loader; the button takes a fixed width of var(--sp-s72) on L and M, var(--sp-s64) on S, while Main CTA keeps its full width.",
        "The accessible name moves to aria-label for the duration, and aria-busy=\"true\" goes on the root."
      ]
    },
    "startSlot": {
      "title": "Start slot",
      "type": "boolean",
      "default": false,
      "css": { "mechanism": "render/omit .button__start-slot — an INSTANCE_SWAP slot, s24 IconContainer by default" },
      "figma": { "kind": "variant-property", "property": "Slot#1", "notes": "Boolean on the set; the content is swapped through the nested instance's swap property." },
      "constraints": [
        "customButton defaults BOTH slots on; L / M / S default both off; Main CTA has no slots at all.",
        "A slot is invisible on its own — no fill, border or shadow — and only sizes and aligns what sits inside it."
      ],
      "notes": "The default recorded here is the presets' (false). Reading the base, the Figma default is true."
    },
    "endSlot": {
      "title": "End slot",
      "type": "boolean",
      "default": false,
      "css": { "mechanism": "render/omit .button__end-slot — mirrors the start slot" },
      "figma": { "kind": "variant-property", "property": "Slot#2" },
      "constraints": ["Same availability and defaults as startSlot."],
      "notes": "The default recorded here is the presets' (false). Reading the base, the Figma default is true."
    },
    "subtitle": {
      "title": "Subtitle row",
      "type": "boolean",
      "default": false,
      "css": { "mechanism": "render/omit .button__subtitle-row inside .button__start-text" },
      "figma": {
        "kind": "variant-property",
        "property": "Show SubTitle",
        "notes": "An exposed property of the nested TextContainer instance, not of the Button set. On the base the equivalent is the 2nd-container boolean, default true."
      },
      "constraints": [
        "Available on the base and on L / M / S. Main CTA has one row only.",
        "The subtitle steps down from its title: Compact Body on the base, L and M; Caption on S, the DS floor at 12."
      ],
      "notes": "The default recorded here is the presets' (false). On the base the second row is on by default."
    },
    "width": {
      "title": "Width",
      "type": "enum",
      "values": ["hug", "fill"],
      "default": "hug",
      "css": { "mechanism": "hug = inline-flex sizing to the content; fill = the instance stretches in its container. .button--cta is width:100% and is always fill" },
      "figma": { "kind": "layout" },
      "constraints": [
        "Main CTA is the only size that fills by default — it is the single screen-level action pinned at the bottom of a flow — but fill is a default, not a lock. Nothing sits under it: a button placed beneath makes two screen-level commitments out of one.",
        "Loading and Skeleton override the width with a fixed value on L / M / S."
      ]
    },
    "rtl": {
      "title": "RTL",
      "type": "boolean",
      "default": false,
      "css": { "mechanism": "dir=\"rtl\" on the root — the flex row reverses, no extra CSS" },
      "figma": { "kind": "variant-property", "property": "RTL", "values": { "false": "Off", "true": "On" } },
      "constraints": [
        "Available on L / M / S only. Main CTA and the base have no RTL property.",
        "Only directional icons flip (chevron / arrow / back); utility icons keep their orientation."
      ]
    },
    "slotContent": {
      "title": "Slot content",
      "type": "enum",
      "values": ["default", "component"],
      "default": "default",
      "customizable": "any DS component, in any of the four slots — StartSlot, both StartText rows, EndSlot",
      "css": { "mechanism": "place the nested component's own root class inside the slot; it keeps its own default-state classes and its own data-ds-component" },
      "figma": { "kind": "variant-property", "property": "swap", "notes": "Every named part is an INSTANCE_SWAP slot. Defaults: IconContainer in the side slots, text in the rows." },
      "constraints": [
        "Open in the base only — the presets lock their rows to text, keeping a group of buttons optically even.",
        "A control whose entire content is an icon is Icon Button, a separate component."
      ]
    }
  },
  "states": {
    "root": ["standard", "pressed", "disabled", "skeleton"]
  },
  "constraints": [
    "Height is fixed per configuration — base and L var(--sp-s56), M var(--sp-s48), S var(--sp-s40), Main CTA var(--sp-s64) — and the content centres on both axes.",
    "Radius is var(--sp-s20) on every size, so the shape reads as a pill at 40 and as a rounded rectangle at 64.",
    "Horizontal padding var(--sp-s16) on the base and Main CTA, var(--sp-s12) on the presets; inter-part gap var(--sp-s12) on the base, var(--sp-s4) on the presets.",
    "Optical balance is a preset behaviour: the s4 slot padding and the s8 StartText padding turn the s12 root padding into an effective 16 to a slot and 20 to a label. The base does not compensate — it sits at 16 on both sides.",
    "On customButton every spacing number is a default a design request may change, the replacement being an SP-scale token and never a raw value. On the presets nothing moves — a preset exists so that padding, gap, height, radius and text style are not changed.",
    "Pressed is a motion step, not a variant — 95% scale via pushButton (--component-push-button-press / -release). There is no pressed fill token and no Pressed member on the State axis.",
    "The title row is required; a row holds one line and truncates to an ellipsis. Text that would wrap belongs outside the button.",
    "Colours come from semantic tokens only; raw px / em / rem / hex prohibited.",
    "A component swapped into a slot keeps its own data-ds-component and counts for coverage, but registers no tap of its own — the root stays the single action target.",
    "The DS markup is a native <button>; a <span> or <div> standing in for it needs role=\"button\", tabindex=\"0\" and its own key handling."
  ],
  "analytics": {
    "dataDsComponent": "button",
    "action": "tap",
    "targets": [],
    "notes": "data-ds-preset is custom-button | l | m | s | main-cta; data-ds-variant is the Style value in lower case and is absent on the base and on Main CTA. data-ds-state reports status, not a user value — the component emits no value. Disabled buttons keep coverage and emit no taps."
  },
  "rtl": {
    "supported": true,
    "notes": "Default off, opt-in per instance on L / M / S. dir=\"rtl\" on the root reverses the slot order; Main CTA and the base have no RTL property in Figma."
  }
}
```
