Files

152 lines
7.2 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.