import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";

import * as ActionTilesStories from "./ActionTiles.stories";

<Meta of={ActionTilesStories} />

# ActionTiles

A set of **actions** drawn as tiles — a bordered card carrying a colour bar, an icon beside
its name, and a line of help, in a grid that gains columns with its container.

This is [`RadioGroup`](/docs/components-controls-radiogroup--docs)'s `itemShape="tile"` with
the radio taken away. The 2026-08-25 record that made the choice tile a `RadioGroup` shape
named this case under *"What this deliberately does not cover"*: mux-magic's "Pick a tool"
tiles are `<a href>` and points-market's are `<Link>`, and *"a link is not a radio, and
giving one `aria-checked` would be worse than the paint it replaced"*.

<Canvas of={ActionTilesStories.Playground} />

## Props

<Controls />

## The colour is the point, and it is not decoration

The first version of this component was neutral, and the owner's verdict on it was *"very
boring, not colorful"*. Three apps had already answered the question themselves — mux-magic,
Gallery Downloader and points-market all coloured their tiles by hand — so the paint is now
the library's and every one of them can stop.

A tile wears a **bar down its leading edge**, its icon takes the same hue, and the box hovers
in it. The hue comes from the ten-wide
[categorical palette](/docs/foundations-colour--docs), which is why a set of five needs no
colour props at all.

It is walked in **`CATEGORICAL_SEQUENCE`**, not `1..10`. The ring itself is hue-ordered
because a swatch picker wants a spectrum to scan, and that is exactly what makes walking it in
order the worst assignment for a set: ring neighbours are 34–41° apart, the tightest pair the
palette has, so a two-tile set got red and orange every time. The sequence visits the same ten
and holds every neighbour 105° or more away.

It starts on **Teal, then Purple**, and that start is a decision rather than a by-product of
the spacing. Spacing alone put Red first, which made Red and Lime the pair every two-tile set
in the fleet drew — the owner rejected red beside green on sight. Red keeps its place in the
palette; it just sits between Indigo and Blue now and never neighbours Lime or Green at any
position, the wrap included.

<Canvas of={ActionTilesStories.AutoHues} />

The bar is `Card`'s accent-edge pseudo-element rather than a second copy of one, so a tile and
a [`Card`](/docs/components-layout-card--docs) on the same page draw the same bar. That also
buys the corner: a border is painted on the border box and cannot follow a curve, which is why
all three hand-rolled versions in the fleet ran past the radius and stopped square.

Because it is an **overlay**, it occupies nothing in the box model — so the tile widens its
leading padding to make room. Without that the bar draws straight through the first letter of
the name, and nothing reports it: a pseudo-element is not in the DOM, so no query can miss it
and axe has nothing to say about a glyph with a stripe on it.

`categorical` and not `intent`, for the reason `cardAccentEdge.ts` gives: an intent is a
*claim* — `danger` says what happens if you press the thing — and "Builder" beside "Jobs" is
making no claim about either. Ten numbered hues say only "these are different from each
other", which is what a tile set means.

### Naming a hue, and turning them off

An item's `categorical` overrides the one its position would have given it, and the rest go on
walking the palette from their own positions — so naming one does not force naming all of
them. Worth doing when the colour belongs to the **thing** rather than to the list.

<Canvas of={ActionTilesStories.NamedHues} />

`accent="none"` is the paint this component shipped with, kept as an opt-out. Reach for it
when the tiles sit inside something already carrying a colour of its own, where a second
palette is noise rather than information.

<Canvas of={ActionTilesStories.Neutral} />

## Which of the two you want

Ask what a press does.

- It **records a value** the user will then submit, or that filters what is below it —
  `RadioGroup itemShape="tile"`. The set announces "3 of 6", the arrow keys move the choice,
  and one tile stays selected.
- It **goes somewhere or starts something** — this. Nothing stays selected, because nothing
  was chosen: the page moved on.

A first step that opens the next step is the second kind. QueuePilot's "Queue type" chooser
— *Picks* or *Rules*, and the editor for the one you press opens immediately — is the case
this component was built for; that same app's Tonight activity filter stays a `RadioGroup`,
because it holds a value the Go button below it reads.

<Canvas of={ActionTilesStories.BesideTheRadioTile} />

## Why this is not a `Button` with a `className`

A `Button` is sized by `h-(--control-height-md)` and carries **no block padding at all**. It
is a one-line control by contract, which is what keeps every control on a form row the same
height at every density.

So an app that wants a wrapping two-line card reaches for `height: auto` — and that deletes
the height and leaves `padding: 0` down the block axis. The title sits flush against the top
border and the description flush against the bottom one. Nothing reports it: the class really
is in the DOM, TypeScript never reads the CSS, and unstyled-but-present markup passes axe.

That shipped in QueuePilot's queue-type chooser and is what this component exists to have
made impossible. `ActionTiles.test.tsx` reads the padding off `getComputedStyle` in a real
browser for the same reason.

<Canvas of={ActionTilesStories.AllStates} />

## The box is shared, and asserted rather than trusted

`tileStyles.ts` holds the padding, the border, the radius, the surface, the type ramp, the
hint ramp, the gap and the grid; `RadioGroup` and `ActionTiles` both read it. Two tile sets
on one page have to be the same card, and "identical" spelled as two copies of one string is
a promise that survives exactly one edit — the same argument `CONTROL_BASE_CLASS` settles
between `Button` and `ButtonLink`.

The test compares **computed** styles of an action tile and a resting radio tile, not class
names. A class-name assertion passes while the paint is wrong.

There is exactly one difference, and it is asserted rather than waived: a coloured action tile
has more **leading** padding than a radio tile, by the room its bar takes. Every other side
matches, and `accent="none"` matches on all four — so "the boxes match" cannot quietly come to
mean "the boxes match apart from whatever changed last". A radio tile draws no bar at all: a
checked one says so by turning its whole border `intent-accent-solid`, and a coloured stripe
on the same box would be one card carrying two claims — *this one is selected* and *this one
is the blue one* — in the same place.

## `href` makes the tile a real anchor

Middle-click, ctrl-click, "open in a new tab", "copy link address" and the status bar all
come from the element rather than from the paint — the standing
[buttons are actions, links are navigation](/docs/guides-routing--docs) rule. A same-origin
destination goes through the injected `RouterLink`; an external one or a fragment falls back
to a plain `<a>`, because pushing `https://…` or `#credits` onto the history stack navigates
the SPA to a route that does not exist.

A set may mix links and buttons. `isDisabled` on a link drops the `href` entirely rather than
shipping a focusable anchor that silently ignores clicks, and keeps `role="link"` plus
`aria-disabled` so it is *announced* as unavailable rather than vanishing from the tree.

<Canvas of={ActionTilesStories.Links} />

## `role="group"`, and no roving tabindex

These are ordinary buttons and anchors, so the platform already owns the keyboard: Tab
reaches **each** tile, Enter and Space press a button, Enter follows a link.

A radio group borrows one tab stop and moves its choice with the arrow keys because exactly
one of its options is true at a time. Nothing here is true, so imposing that model would only
cost a keyboard user the ability to Tab to the second tile.

The group takes a required `label`, which names the set — "Queue type", "Pick a tool" — the
way a `radiogroup` is named. A set of unnamed tiles is a pile of buttons.

## A tile may lead with an icon, beside the name

The icon sits **to the left of the title**, with the hint below both — which is how all three
apps that had grown this card by hand drew it, and how the owner asked for it back: *"I like
how Mux-Magic looks today with the icon to the left of the title."*

It takes the tile's hue, so an app passes a `stroke="currentColor"` path and states no colour
of its own. Size is the app's; around 22px at `md` and 30px at `lg` reads the way those
hand-rolled cards did. The library still ships no icons.

`icon` is `aria-hidden` by construction, because the name it sits beside is inside the same
tile — an announced icon is the label read twice.

<Canvas of={ActionTilesStories.WithIcons} />

## The grid takes its columns from the container

`repeat(auto-fill, minmax(min(var(--charcuterie-tile-min-inline-size), 100%), 1fr))` — the
same constant `RadioGroup`'s tile shape uses, and each of its three parts is load-bearing:

- **`auto-fill`, not `auto-fit`.** `auto-fit` collapses the empty tracks and lets the
  survivors share the whole row, so six tiles in a 2560px container become six 420px slabs —
  the full-width-row shape in a new costume.
- **`min(…, 100%)`.** Without it the 200px floor overflows a container narrower than one
  tile, which is a horizontal scrollbar in the Narrow View.
- **A custom property, not an interpolated class.** Tailwind scans source *text* for complete
  class strings, so `` `grid-cols-[…${n}px…]` `` generates nothing, paints nothing and
  reports nothing.

### The floor sizes the CONTAINER's tracks, not the tiles

`minTileInlineSize` is the narrowest a **track** may be, and `auto-fill` lays as many of
them as fit whether or not there are tiles to put in them. So the number to pick comes from
the width of the box the set is in, divided by how many tiles you want across it — not from
how many tiles you have.

Leave it at the 200px default in a 1024px container and the grid cuts four tracks. Two
tiles take the first two, the other half of the row is empty background, and the set reads
as a layout bug.

⚠️ **Nothing reports this.** It type-checks, it lints, it passes every unit and e2e test,
and the DOM is correct — the tiles really are in a grid, and the grid really has the columns
it was asked for. Three apps shipped a half-empty row on the day they adopted this
component, and each one was caught by a screenshot. If a set has few tiles, look at it.

<Canvas of={ActionTilesStories.Responsive} />

This is **not** `useAdaptiveColumns`. That hook buys a column with **height**, for an
unbounded gallery that is going to scroll; a tile set is a bounded group inside a form section
or a first step, where the only question is how many fit across the box it was given.

## What this deliberately does not do

- **Hold a selection.** That is `RadioGroup itemShape="tile"`, and giving these tiles
  `aria-checked` would be the mistake the 2026-08-25 record refused.
- **Toggle per tile.** A multi-select tile is a checkbox group and a different record.
- **Carry artwork.** That is [`MediaTile`](/docs/components-data-mediatile--docs).

<Canvas of={ActionTilesStories.Interactive} />
