# Button

Built from the Figma-derived specification. Appearance is three axes —
`intention` × `context` × `emphasis` — bound straight to the token groups.
Geometry never varies: padding, the 40px minimums and the 1px border are
identical on every combination and every state.

Live at `/button-demo` — every combination, every state, on all three surfaces.

## Provenance

Every value comes from `github.com/samiamdesigns/pd-xrpl-developer-docs`:
`components/button.md` (the axes and the reasoning), `components/button.json`
(values resolved per mode), `components/button-examples.md` (the acceptance
checklist), plus `accessibility/focus-indicators.md`,
`implementation/icons-that-inherit-colour.md` and
`implementation/font-stacks.md`.

The colour matrix in `Button.scss` §2 was generated rather than transcribed and
verified back against `button.json`: all 270 values, both modes, no RGB
mismatch.

## API

```tsx
<Button>Get started</Button>

<Button intention="neutral" emphasis="standard">Learn more</Button>
<Button context="on-saturated">On a green block</Button>
<Button href="/docs" target="_blank">Read the docs</Button>
<Button iconEnd={<XrplArrowInternalLinkIcon />}>Read more</Button>
<Button loading>Submitting…</Button>
```

| Prop | Values | Default |
|  --- | --- | --- |
| `intention` | `brand` · `neutral` | `brand` |
| `context` | `on-theme` · `on-inverse` · `on-saturated` | `on-theme` |
| `emphasis` | `strong` · `standard` · `subtle` | `strong` |
| `loading` | boolean — `aria-busy`, activation suppressed, indicator shown | `false` |
| `inactive` | boolean — `aria-disabled`, **stays in the tab order** | `false` |
| `disabled` | boolean — native `disabled`, leaves the tab order | `false` |
| `href` / `target` | renders an `<a>` | — |
| `iconStart` / `iconEnd` | decorative, `aria-hidden` — either slot omitted renders no icon and reserves no space | — |


### `context` is the axis to get right

Each context is measured against a different backdrop, so choosing the wrong one
is the likeliest way to produce a button that looks plausible and fails
contrast. It also drives the focus-ring colour, and **nothing will tell you if
that is wrong** — a button that takes `context="on-inverse"` for its paint and
leaves the ring at its default renders perfectly.

### `neutral` + `on-saturated` does not compile

There are no tokens for it. The props are a discriminated union, so that
combination is a type error rather than a runtime surprise.

### Two `strong` buttons must not share a container

Emphasis is what says which action is primary. No checker catches this — a row
of identical `strong` buttons is contrast-clean and still wrong.

## States

Six states, three appearances. `hover`/`pressed`/`loading` resolve
byte-identically and `inactive` == `rest`, in all 15 combinations, in both
modes.

| Appearance | States |
|  --- | --- |
| Resting | `rest`, `inactive` |
| Engaged | `hover`, `pressed`, `loading` |
| Disabled | `disabled` — its own group |


They stay separate code paths, because what distinguishes them is behaviour:

|  | ARIA | tab order | activates |
|  --- | --- | --- | --- |
| `loading` | `aria-busy` | in | no |
| `inactive` | `aria-disabled` | **in** | no |
| `disabled` | native `disabled` | **out** | no |


Collapsing `inactive` into `disabled` removes it from the tab order, and a
screen-reader user can then no longer find it.

**`disabled` is chosen by context alone** — not by intention, not by emphasis.
Emphasis only selects the shape: `strong` keeps fill and border, `standard`
drops the fill, `subtle` drops both.

## Anchor paint

`href` renders an `<a>`, so every bare `a` rule on the site applies to it —
Bootstrap's, the theme's, and any container styling its own anchors.
`Button.scss` §5 out-specifies them with a doubled class,
`a.bds-btn.bds-btn:link` and the other four link pseudo-classes.

**Do not collapse those into `:is()`.** PurgeCSS drops a rule when the argument
to `:is()` or `:where()` is a pseudo-class list or a `:not()` chain, so the tidy
form compiles, works in `realm develop`, and vanishes from the production bundle
— leaving anchor buttons painted as links. A plain selector argument is fine,
which is why `:where(html.dark)` in §4 survives. §5 is also deliberately
unlayered, since everything above it sits in `@layer bds-btn.*`.

## Not from the token set

- **The background rise** — a `::before` scaling from `bottom center`. The spec
says hover swaps the whole triplet; the rise is only *how* the fill arrives.
- **The animated arrow**, with the motion inside the icon's own viewBox so the
button's geometry never changes on hover.
- Motion values: 150ms, `cubic-bezier(0.98, 0.12, 0.12, 0.98)`.


## Deliberate deviations from the spec

| Deviation | Why |
|  --- | --- |
| `intention` is optional | `button.md`'s printed union marks it required, which would reject `<Button>Get started</Button>` — the zero-prop default its own examples show. `button.json` records `"default": "brand"`. **Reported upstream.** |
| Loader glyph is `LoaderIcon` | The spec's glyph is an explicit placeholder and asks us to pick one and report back. **This is the choice to report.** |
| The loader slows under reduced motion rather than stopping | Freezing it removes the only signal that anything is happening. `aria-busy` covers assistive technology; nothing covers a sighted user watching a motionless spinner. |
| Alpha comes from `_colors.scss`, not `button.json` | The JSON rounds alpha to 2dp; `_colors.scss` carries the generated 8-digit Radix value the tokens are built from. 57 of 270 values differ by up to 1.2/255 of alpha. No RGB channel differs anywhere. |


## Where the colours come from

**Every colour in the palette is a variable from `styles/_colors.scss`** — not
one hex literal in the map. `_colors.scss` already carries the generated Radix
scales the tokens are built from, so binding the variables is shorter and truer
than copying numbers, and a palette change propagates on its own.

The pairs double as documentation: `($sage-12, $sage-dark-12)` reads as "step 12
of the neutral scale, per mode", and the on-inverse groups visibly swap them.

Three exceptions, commented in the file: the focus ring's `#111111` and
`#000000` come from `mode-color.focus-indicator.*`, a token family with no
counterpart in any scale the site carries. White binds `$white`.

To change a colour, edit the map in §2 — one `@each` loop emits all 30 rules.
To re-derive it, re-resolve `button.json`; if `button.md` and `button.json`
disagree, **neither wins**, re-resolve from the token set.

## Traps

- **`line-height: 1` is a ratio, not `1px`.** `button.md` reversed this on
2026-08-17; converting it is the documented trap.
- **`on-inverse` is not a mode-swap of its base group.** 104 of 108 values
survive that shortcut and 4 border values do not, so it is emitted literally.
- **PurgeCSS.** The group/emphasis/context classes are composed from props at
runtime and emitted from a Sass loop, so they appear as literals nowhere.
`postcss.config.cjs` safelists `/^bds-btn/` and `/^bds-icon/`; without them
the production build silently strips the component.
- **`:where(html.dark)` in §4 is load-bearing.** A bare `html.dark` selector
outranks the state swaps and leaves disabled buttons with their resting fill
in dark mode only.