@ntmats/hc-ui 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -24,43 +24,43 @@ Styles are not in a cascade layer: unlayered consumer resets (HCUI's `button` ru
24
24
 
25
25
  HCUI uses `SegmentedControl` through its `ViewToggle` wrapper. During the bootstrap it compiles the source through Vite/TypeScript aliases; switch to the pinned package once published.
26
26
 
27
- `Button` is a pill with `secondary` (default), `primary`, `outline` and `danger` variants, `sm`/`md`/`lg` sizes (36/44/52px), optional `icon` and `iconEnd`, `fullWidth`, and `loading`, which keeps focus and label, shows a spinner and ignores clicks. Long labels are cut with an ellipsis. `IconButton` requires `label` (accessible name and tooltip) and comes in 36/40/44px; the 36px size keeps a 44px touch target. Pass `selected` to make it a toggle (it fills with the accent). `variant` sets the fill: `muted` (default), `surface` (the surface color with the card ring and shadow, as HCUI's theme button on phones), `outline` (the surface color with a 1px border ring, as HCUI's sort button on phones) or `ghost` (no fill; `bg-muted` and primary text on hover, as HCUI's desktop utilities, with `tone="secondary"` at rest). `shape="rounded"` gives 12px corners instead of a circle. `iconKey` keys the icon: when it changes (and on first render) the new icon turns and fades in (360ms, `ease-pop`; none under reduced motion), as HCUI's sun and moon. A toggle that keeps its look, such as a theme switch, passes `role="switch"` and `aria-checked` instead of `selected`; the theme state stays with the app. HCUI's theme button: `variant="surface"` on phones and `variant="ghost" shape="rounded" size="sm" tone="secondary"` on desktop. `Chip` is a filter/option pill; pass `selected` to make it a toggle (`aria-pressed`), omit it for a plain action. All three read the shared `rond` roundness, default to `type="button"`, forward refs, scale slightly on press, show the shared focus ring and drop motion under `prefers-reduced-motion`.
27
+ `Button` is a pill with `secondary` (default), `primary`, `outline` and `danger` variants, `sm`/`md`/`lg` sizes (36/44/52px), optional `icon` and `iconEnd`, `fullWidth`, and `loading`, which keeps focus and label, shows a spinner and ignores clicks. Long labels are cut with an ellipsis; `wrap` (opt-in, experimental) lets the label break onto more lines and the button grow instead (a single line keeps the size's height). `IconButton` requires `label` (accessible name and tooltip) and comes in 36/40/44px; the 36px size keeps a 44px touch target. Pass `selected` to make it a toggle (it fills with the accent). `variant` sets the fill: `muted` (default), `surface` (the surface color with the card ring and shadow, as HCUI's theme button on phones), `outline` (the surface color with a 1px border ring, as HCUI's sort button on phones) or `ghost` (no fill; `bg-muted` and primary text on hover, as HCUI's desktop utilities, with `tone="secondary"` at rest). `shape="rounded"` gives 12px corners instead of a circle. `iconKey` keys the icon: when it changes (and on first render) the new icon turns and fades in (360ms, `ease-pop`; none under reduced motion), as HCUI's sun and moon. A toggle that keeps its look, such as a theme switch, passes `role="switch"` and `aria-checked` instead of `selected`; the theme state stays with the app. HCUI's theme button: `variant="surface"` on phones and `variant="ghost" shape="rounded" size="sm" tone="secondary"` on desktop. `Chip` is a filter/option pill; pass `selected` to make it a toggle (`aria-pressed`), omit it for a plain action. All three read the shared `rond` roundness, default to `type="button"`, forward refs, scale slightly on press, show the shared focus ring and drop motion under `prefers-reduced-motion`.
28
28
 
29
29
  `Field` is a filled pill text input. `label` is required (`hideLabel` keeps it for screen readers), with `icon`, an `end` slot, `hint`, `error` and comfortable/compact density (44/40px). It forwards its ref and input props to the `input`; value and validation stay with the caller.
30
30
 
31
31
  `Checkbox` (experimental) keeps a native `input type="checkbox"` transparently over a drawn box, so keyboard, forms, `aria` and `<label>` behave as usual. Wrap it in a `<label>` with the visible text (the whole row becomes the target and its hover reaches the box) or pass `aria-label`. Sizes are `md` (20px, touch lists and sheets) and `sm` (16px, dense desktop lists). `indeterminate` only changes the look and the DOM property, which assistive technology reads as mixed; the value still comes from `checked`. It forwards its ref to the input; `className` goes to the wrapper and every other prop to the input. The check and dash are inline SVGs (no icon library). The 16/20px box is not a touch target on its own: give it a label row of at least 44px.
32
32
 
33
- `Dialog` (experimental) is a centered modal on the native `<dialog>` and `showModal()`: the browser puts it in the top layer, makes the rest of the page inert (Tab stays inside, clicks and scripts cannot focus what is behind) and sends Esc to the topmost dialog only, so stacked dialogs work. It is controlled: `open` shows it, and Esc, a backdrop click (both only when `dismissible`, the default) and the close button call `onClose`; the dialog stays open until the caller sets `open` to false, so `onClose` can ask first (a confirmation on top) or refuse. A press that starts inside and ends on the backdrop (selecting text) does not close it. `title` is required and names the dialog (`aria-labelledby`); `description` describes it (`aria-describedby`); `children` go in the body and `actions` in the footer. `closeLabel` adds a close `IconButton` with that accessible name (none by default; `closeIcon` replaces the inline X). Focus goes to the first `autoFocus` element, else the first focusable one, and returns to the element focused before opening; page scroll is locked while any dialog is open. Sizes: `sm` (400px, HCUI's desktop confirmation: compact title, actions on the right, `Button size="sm"` at 38px) and `md` (default, 440px, HCUI's Sheet in its desktop form: header, scrolling body, footer whose actions share the width, `shadow-modal`, blurred backdrop, entry and exit animation, none under `prefers-reduced-motion`). It renders where it is declared, so it inherits `data-hc-ds` and `data-theme` without a portal (the `::backdrop` inherits them too in current browsers). Texts come from the caller. `Dialog` stays centered at every width; the bottom sheet of phones is `Sheet`.
33
+ `Dialog` (experimental) is a centered modal on the native `<dialog>` and `showModal()`: the browser puts it in the top layer, makes the rest of the page inert (Tab stays inside, clicks and scripts cannot focus what is behind) and sends Esc to the topmost dialog only, so stacked dialogs work. It is controlled: `open` shows it, and Esc, a backdrop click (both only when `dismissible`, the default) and the close button call `onClose`; the dialog stays open until the caller sets `open` to false, so `onClose` can ask first (a confirmation on top) or refuse. A press that starts inside and ends on the backdrop (selecting text) does not close it. `title` is required and names the dialog (`aria-labelledby`); `description` describes it (`aria-describedby`); `children` go in the body and `actions` in the footer. `closeLabel` adds a close `IconButton` with that accessible name (none by default; `closeIcon` replaces the inline X). Focus goes to the first `autoFocus` element, else the first focusable one, and returns to the element focused before opening; page scroll is locked while any dialog is open. Both sizes have the panel's look: `shadow-modal`, an entry and exit animation and a backdrop that fades (none under `prefers-reduced-motion`), and no backdrop blur, since the panel is solid. Sizes: `sm` (400px, HCUI's desktop confirmation: compact title, actions on the right, `Button size="sm"` at 38px, 20px corners) and `md` (default, 440px, HCUI's Sheet in its desktop form: header, scrolling body, footer whose actions share the width, 28px corners). It renders where it is declared, so it inherits `data-hc-ds` and `data-theme` without a portal (the `::backdrop` inherits them too in current browsers). Texts come from the caller. `Dialog` stays centered at every width; the bottom sheet of phones is `Sheet`.
34
34
 
35
- `Menu` (experimental) is a menu button. `trigger` is the button that opens it: pass an element (it receives `aria-haspopup="menu"`, `aria-expanded`, `aria-controls`, a ref and the click and key handlers, composed with its own) or a function `(props, { open }) => …` that spreads the props. `label` names the menu. Children: `MenuItem` (an action; optional `icon` at the end of the row, `hint` as a second line, `danger`, `disabled`; `onSelect` runs it and closes the menu unless it calls `event.preventDefault()`, as a confirmation step does), `MenuRadioGroup` with `MenuItemRadio` (one choice such as a sort order: the chosen item is bold with a check, and choosing closes the menu), `MenuItemCheckbox` (toggles in place; `closeOnSelect` to close), `MenuSeparator` and `MenuLabel` (plain text). Keyboard: Enter, Space or ArrowDown on the trigger open it at the checked item (else the first), ArrowUp at the last; arrows wrap and skip disabled items; Home/End; typing letters jumps to the next item starting with them; Enter/Space choose; Esc closes and returns focus to the trigger; Tab closes and continues from the trigger. A press outside closes without moving focus. After an action, focus goes back to the trigger unless the action moved it. If the items change while open and focus is lost, it moves to the first item. Visual: HCUI's `.desktop-sort__menu` (200px wide, `shadow-soft`, 36px rows, `text-secondary` items, `bg-muted-2` hover, a focus ring 3px out as on HCUI's desktop; `--hc-focus-ring-offset` still overrides it). Width and height limits are the caller's (`style`, `className`). It forwards its ref to the `role="menu"` element.
35
+ `Menu` (experimental) is a menu button. `trigger` is the button that opens it: pass an element (it receives `aria-haspopup="menu"`, `aria-expanded`, `aria-controls`, a ref and the click and key handlers, composed with its own) or a function `(props, { open }) => …` that spreads the props. `label` names the menu. Children: `MenuItem` (an action; optional `icon` at the end of the row, `hint` as a second line, `danger`, `disabled`; `onSelect` runs it and closes the menu unless it calls `event.preventDefault()`, as a confirmation step does), `MenuRadioGroup` with `MenuItemRadio` (one choice such as a sort order: the chosen item is bold with a check, and choosing closes the menu), `MenuItemCheckbox` (toggles in place; `closeOnSelect` to close), `MenuSeparator` and `MenuLabel` (plain text). Keyboard: Enter, Space or ArrowDown on the trigger open it at the checked item (else the first), ArrowUp at the last; arrows wrap and skip disabled items; Home/End; typing letters jumps to the next item starting with them; Enter/Space choose; Esc closes and returns focus to the trigger; Tab closes and continues from the trigger. A press outside closes without moving focus. After an action, focus goes back to the trigger unless the action moved it. If the items change while open and focus is lost, it moves to the first item. Visual: HCUI's `.desktop-sort__menu` (200px wide, `shadow-soft`, 36px rows, `text-secondary` items, `bg-muted-2` hover, the shared 2px focus ring; `--hc-focus-ring-offset` still overrides its offset). Width and height limits are the caller's (`style`, `className`). It forwards its ref to the `role="menu"` element.
36
36
 
37
37
  `Popover` (experimental) is a non-modal dialog anchored to its `trigger` (`aria-haspopup="dialog"`), named by `label`, with free content; a function child receives `close`. Focus stays on the trigger (pass `autoFocus` to move it to the first focusable element inside) and Tab is not trapped; Esc closes and returns focus to the trigger; a press outside closes. Visual: HCUI's `.team-rules__popover` (300px, at most `min(460px, 70vh)` with its own scroll, `radius-lg`, `shadow-float`, 12px text). Styles of the content (headings, lists) are the caller's. It forwards its ref to the `role="dialog"` element.
38
38
 
39
39
  Both open below the trigger, aligned by its end, 4px away (`side="bottom|top"`, `align="start|end"`), and flip to the other side or edge when there is no room in the viewport and the trigger's clipping ancestors; the numbers come from `getBoundingClientRect`, with no positioning dependency (`placePopup` is the pure rule). Without `portal` the popup is absolutely positioned inside a wrapper around the trigger (`.hc-ui-popup-anchor`, `position: relative` at zero specificity, so `anchorClassName` can place it), inherits the theme and is cut by an `overflow` ancestor. With `portal` it renders, with fixed coordinates that follow scrolling and resizing, into the trigger's open `<dialog>` (the top layer) or `document.body`, or into the element passed; the portal container carries `data-hc-ds` and the trigger's `data-theme` (or `theme`). Esc and outside presses go to the newest popup only, so a menu opened from a popover closes alone, and Esc never reaches an enclosing `Dialog`. The cost: while a popup is open, Esc is taken on `document` in the capture phase and stopped there, so it never reaches the popup's own content either: the `onKeyDown` passed to `Menu` does not see it, and a field inside a `Popover` cannot use Esc to clear itself (give it a clear button instead). Open state can be controlled (`open`, `onOpenChange`) or not (`defaultOpen`). Neither animates, so there is no motion to reduce.
40
40
 
41
- `Meter` (experimental) is a bar of a `value` against a `max` (the minimum is 0), such as a team's points against its limit. By default it is a `role="meter"` named by `label`: `aria-valuenow` stays within 0..`max` (as ARIA requires) and `aria-valuetext` carries the real amount (`valueText`, default `"<value> / <max>"`; pass a localized sentence such as "375 of 300 points, 75 over"). The visible numbers are the caller's. Pass `decorative` instead of `label` when the numbers are already next to it: the bar is then `aria-hidden` and has no role. `tone` is `auto` (danger when `value > max`), or forced with `over` or `default`. Shape: `thickness` 3, 4 (default) or 5px; `track` `muted` (default), `border` or `none`; `cap` `round` (default) or `square`; `overflow` `clamp` (default: a value over the limit fills the bar) or `mark` (the bar scales to the larger of value and limit, and a 2px danger line marks the limit). HCUI's bars: the mobile team bar is `thickness={3} track="none" animated` placed at the bottom edge, the desktop sidebar draft uses the defaults, and the team rules bar is `thickness={5} track="border" cap="square" overflow="mark"`. `animated` transitions the width (none under `prefers-reduced-motion`). It renders only spans, so it fits inside a button; its `display` and `position` have zero specificity, so a class can place it (absolute at an edge, a grid row). It forwards its ref to the root span.
41
+ `Meter` (experimental) is a bar of a `value` against a `max` (the minimum is 0), such as a team's points against its limit. By default it is a `role="meter"` named by `label`: `aria-valuenow` stays within 0..`max` (as ARIA requires) and `aria-valuetext` carries the real amount (`valueText`, default `"<value> / <max>"`; pass a localized sentence such as "375 of 300 points, 75 over"). The visible numbers are the caller's. Pass `decorative` instead of `label` when the numbers are already next to it: the bar is then `aria-hidden` and has no role. `tone` is `auto` (danger when `value > max`), or forced with `over` or `default`. Shape: `thickness` 3, 4 (default) or 5px; `track` `muted` (default), `border` or `none`; `cap` `round` (default) or `square`; `overflow` `clamp` (default: a value over the limit fills the bar) or `mark` (the bar scales to the larger of value and limit, and a 2px line in the text color, with a 1px edge of the surface on each side, marks the limit, visible over the danger fill in both themes). HCUI's bars: the mobile team bar is `thickness={3} track="none" animated` placed at the bottom edge, the desktop sidebar draft uses the defaults, and the team rules bar is `thickness={5} track="border" cap="square" overflow="mark"`. `animated` transitions the width (none under `prefers-reduced-motion`). It renders only spans, so it fits inside a button; its `display` and `position` have zero specificity, so a class can place it (absolute at an edge, a grid row). It forwards its ref to the root span.
42
42
 
43
43
  `Sheet` (experimental) is a `Dialog` (`size="md"`) that can rise from the bottom of the screen: the same native `<dialog>`, focus, Tab trap, Esc, focus return, scroll lock, stacking, `title`, `children` and controlled `open`/`onClose` (Esc, the backdrop, the close button and the drag only ask to close). `closeLabel` is required, since every sheet shows the close button; `footer` holds the actions (they share the width); `width` is the centered panel's maximum in px (440 by default; HCUI's filters use 520). `presentation` picks the form, and the library does not guess it: `bottom` (full width at the bottom edge, top corners `radius-sheet`, a 36×5px grabber, the bottom safe area, at most the viewport height minus 32px and the top safe area, rising entry and falling exit), `center` (the `md` panel), or `auto` (default), which is `bottom` below 960px and `center` from 960px through a CSS media query (HCUI's cut). For another cut, choose `bottom` or `center` yourself. At the bottom the sheet drags on touch, as HCUI's `useSheetDrag`: down past 20% of its height, or a flick faster than 0.5px/ms over 20px, closes it and fades the backdrop with it; less springs back. Inside content that has scrolled down, a downward drag scrolls first, and only at the top moves the sheet. Gestures more sideways than vertical (a row of chips), fields and anything inside `[data-hc-sheet-nodrag]` (a map, a canvas) never drag. Up from the grabber or the header (the top 64px, or `[data-hc-sheet-handle]`) it expands to full height (`data-expanded`), and down from there returns. If `onClose` keeps it open (an action in progress), the sheet comes back after the drag. The script only reads `--hc-sheet-drag`, which the CSS sets to `on` at the bottom. Under `prefers-reduced-motion` nothing animates or glides; the drag still follows the finger. It forwards its ref to the `<dialog>`.
44
44
 
45
- `SheetOptions` is a single-choice list for inside a sheet: rows of at least 52px on a `bg-muted-2` card, with the chosen row's label in semibold and a check at the end; each option may have a `hint` (second line), an `icon` (before the text, sized by the caller) and `disabled`. `SheetChips` is the same choice as wrapping `Chip`s, the chosen one filled. Both are `radiogroup`s named by `label`, controlled by `value` and `onChange` (a `value` that matches no option leaves none chosen). Keyboard: one tab stop, at the chosen option (else the first enabled one); arrows and Home/End move the focus, skipping disabled options, without choosing; Space or Enter chooses. Choosing does not move with the arrows because a choice often closes the sheet. Section labels and spacing around them are the caller's.
45
+ `SheetOptions` is a single-choice list for inside a sheet, with one look (the `list` variant is gone): rows of at least 52px on a `bg-muted-2` card, with the chosen row's label in semibold and a check at the end; each option may have a `hint` (second line), an `icon` (before the text, sized by the caller) and `disabled`. `SheetChips` is the same choice as wrapping `Chip`s, the chosen one filled. Both are `radiogroup`s named by `label`, controlled by `value` and `onChange` (a `value` that matches no option leaves none chosen). Keyboard: one tab stop, at the chosen option (else the first enabled one); arrows and Home/End move the focus, skipping disabled options, without choosing; Space or Enter chooses. Choosing does not move with the arrows because a choice often closes the sheet. Section labels and spacing around them are the caller's.
46
46
 
47
47
  `Chip` also works as a filter (experimental). `size` is `md` (default, HCUI `chip`), `sm` (36px, 12px text: HCUI's filter pills and sort trigger) or `xs` (26px, 11px, muted fill without ring: HCUI's compact quick filters and chosen-value tags; selected turns to the contrast fill). `variant="quiet"` is the lighter rule-filter pill (border ring, secondary text; primary and bolder once `applied`). `count` puts a muted number after the label (results a quick filter keeps) and `badge` a filled badge (values a filter has applied); both show what they are given, a numeric 0 included (HCUI's quick filters show "0"), and hide only when `undefined`, `null`, `false` or `''` (so a badge that disappears with nothing applied takes `badge={n || undefined}`). A chip that opens a popup has a down chevron: it appears whenever `aria-haspopup` is set, so a `Chip` given as the `trigger` of `Menu` or `Popover` gets it with no extra prop (`chevron` forces it on or off, as the rule pills that swap the chevron for the badge). `aria-expanded="true"` and `applied` draw a 1.5px ring in the primary text color; `selected` (the accent fill) wins over both. `onClear` adds a separate remove button (×) named by `clearLabel`, which the type requires whenever `onClear` is given, so it is a second tab stop rather than a button inside a button: the chip renders as a `span` holding the main button (which takes the ref, `onClick` and `aria-pressed`; without `onClick` it is plain text, a chosen-value tag) and the remove button; `className` and `style` go on the `span`. Texts and numbers are the caller's, already formatted.
48
48
 
49
49
  `FilterBar` (experimental) lays out a row of filters: a `role="group"` named by `label`, with optional `start` (a search field, an "advanced filters" chip) and `end` (a `SortMenu`, a view toggle) on the same line and a `footer` under it (chosen-value tags, "clear all"). `layout="wrap"` wraps the filters (desktop); `layout="scroll"` keeps them on one line that scrolls sideways with a hidden scrollbar, on their own line under the slots, and bleeds into the page gutter by `--hc-filter-bar-bleed` (HCUI's phones: 16px); 4px of padding above and below, taken back by a negative margin, keep focus rings visible without changing the layout. `layout="auto"` (default) is `scroll` below 960px and `wrap` from 960px, by a CSS media query (HCUI's cut). `density="compact"` spaces filters by 4px (HCUI's desktop quick filters). `sticky` is HCUI's desktop band: sticky at `--hc-filter-bar-top`, the surface fill and 8px above and below; bleeding to the panel edges and the offset are the caller's. Which filters exist, their values, the search and persistence stay with the app. It forwards its ref to the root.
50
50
 
51
- `SortMenu` (experimental) is HCUI's desktop sort control: a `Chip` (`size="sm"` by default, `triggerClassName` for a class) showing the current option, named "`label`: option", that opens a `Menu` with a `MenuRadioGroup` of `options` (`value`, `label`, optional `hint` and `disabled`), with the `Menu` keyboard and focus. `applied` marks a choice in effect with the ring; other `Menu` props (`align`, `side`, `portal`, `open`, `onOpenChange`) pass through. On phones, compose instead: `IconButton variant="outline" size="lg"` with `aria-haspopup="dialog"` opens a `Sheet` with `SheetOptions variant="list"` (plain 48px rows divided by a line, 560/720 weights, a 20px check in the text color: HCUI's mobile sort sheet). HCUI draws the desktop focus ring 3px out and the mobile filter pills' 1px out: set `--hc-focus-ring-offset` on the bar.
51
+ `SortMenu` (experimental) is HCUI's desktop sort control: a `Chip` (`size="sm"` by default, `triggerClassName` for a class) showing the current option, named "`label`: option", that opens a `Menu` with a `MenuRadioGroup` of `options` (`value`, `label`, optional `hint` and `disabled`), with the `Menu` keyboard and focus. `applied` marks a choice in effect with the ring; other `Menu` props (`align`, `side`, `portal`, `open`, `onOpenChange`) pass through. On phones, compose instead: `IconButton variant="outline" size="lg"` with `aria-haspopup="dialog"` opens a `Sheet` with `SheetOptions` (its card look, as every sheet list).
52
52
 
53
- `Table` (experimental) is a dense data table on a semantic `<table>`: HCUI's desktop Meta table. `columns` describe it (`key`, `header`, `cell(row, index)` with free content, `align` `start`/`center`/`end`, `numeric` for end-aligned tabular figures, `width` in px or any CSS length, `tone` `muted` or `secondary` for the header and cells, `className` for the header and every cell, such as a class that hides an optional column in a narrow container, and `rowHeader` for the cell that names the row, rendered as `<th scope="row">`); `rows` and `rowKey` give the data, already filtered, formatted and in order. A `sortable` column puts its header text in a button and carries `aria-sort` (`none` until sorted); `sort` (`{ key, direction }`) marks the sorted column, which shows a 12px arrow, and `onSort(key)` reports a press: the new order (toggle, default direction) and sorting the rows are the caller's. `stickyHeader` sticks the header row to the top of the scrolling ancestor at `--hc-table-sticky-top` (HCUI: under its filter band). `onRowClick` makes the rows react to a press and puts the row header's content in a button, so the keyboard reaches each row once (its press does not also reach the row); give a `rowHeader` column, or rows can only be pressed with a pointer. `selectedKey` fills the row open elsewhere (a side panel) with `bg-muted` and sets `aria-current` on its button. Rows are 4px 8px cells with a line under each and the `bg-muted-2` hover; the header is 11px muted text on the surface fill with a line under it. Focus rings are 3px out (HCUI's desktop) unless `--hc-focus-ring-offset` is set. Empty states, pagination ("show more") and the cell content styles are the caller's. It forwards its ref to the `<table>`.
53
+ `Table` (experimental) is a dense data table on a semantic `<table>`: HCUI's desktop Meta table. `columns` describe it (`key`, `header`, `cell(row, index)` with free content, `align` `start`/`center`/`end`, `numeric` for end-aligned tabular figures, `width` in px or any CSS length, `tone` `muted` or `secondary` for the header and cells, `className` for the header and every cell, such as a class that hides an optional column in a narrow container, and `rowHeader` for the cell that names the row, rendered as `<th scope="row">`); `rows` and `rowKey` give the data, already filtered, formatted and in order. A `sortable` column puts its header text in a button and carries `aria-sort` (`none` until sorted); `sort` (`{ key, direction }`) marks the sorted column, which shows a 12px arrow, and `onSort(key)` reports a press: the new order (toggle, default direction) and sorting the rows are the caller's. `stickyHeader` sticks the header row to the top of the scrolling ancestor at `--hc-table-sticky-top` (HCUI: under its filter band). `onRowClick` makes the rows react to a press and puts the row header's content in a button, so the keyboard reaches each row once (its press does not also reach the row); give a `rowHeader` column, or rows can only be pressed with a pointer. `selectedKey` fills the row open elsewhere (a side panel) with `bg-muted` and sets `aria-current` on its button. Rows are 4px 8px cells with a line under each and the `bg-muted-2` hover; the header is 11px muted text on the surface fill with a line under it. Focus rings are the shared 2px ring unless `--hc-focus-ring-offset` is set. Empty states, pagination ("show more") and the cell content styles are the caller's. It forwards its ref to the `<table>`.
54
54
 
55
55
  `PageHeader` (experimental) is HCUI's desktop page heading: a row of at least 40px with the `title` (22px semibold on a 27.5px line, pulled up 4px to drop the font's inner space; an `h1` by default, `level` 2 or 3 change only the tag), an optional `count` in muted 13px text inside the heading after a space (hidden at 0) and `actions` at the end, 8px apart. The row wraps when it does not fit. `visuallyHidden` keeps the heading only for assistive technology, for a page whose content already shows its title. Spacing around it is the caller's. As in HCUI, the title's font is set with the `font` shorthand, which drops the `ROND` axis: this reproduces HCUI's look. It forwards its ref to the `<header>`.
56
56
 
57
57
  `PanelHeader` (experimental) is the header of a panel or a section. `variant="bar"` (default) is HCUI's desktop context panel header: 52px, 8px 12px 8px 16px, a line under it, 13px text. It shows either a `trail` (steps of a breadcrumb in a `nav` named by `trailLabel`: earlier steps with `onSelect` are buttons that go back, steps without it are plain text, the last step is the heading, with `aria-current="location"`, all cut with an ellipsis, 14px chevrons between them) or a `title` with an optional `eyebrow` (HCUI's `eyebrow`: 11px uppercase, wide and muted) above and `subtitle` (muted) under it. `actions` go at the end, then, with `onClose`, a 32px round close button named by `closeLabel` (`closeTitle` for a tooltip that says more, such as the Esc shortcut; `closeIcon` replaces the inline X). `variant="section"` is HCUI's section heading (`section-title`: 17px, 680 weight, 90% width), with an `icon` in a 36px muted circle before it, the `subtitle` under it or, with `subtitleInline`, on the same baseline and cut with an ellipsis, and `actions` at the end; its 12px gap has zero specificity, so a class sets the row's layout (HCUI's desktop home: 8px and at least 44px tall). The title is an `h2` by default (`level`). Texts come from the caller. It forwards its ref to the `<header>`.
58
58
 
59
- `ListRow` (experimental) is HCUI's `list-row`: a full-width button row of at least 56px with 8px 16px of padding, a 12px gap between its children, primary text, a translucent veil of the text color on hover (4%, only with a mouse) and press (7%), and the focus ring drawn inside (`--hc-focus-ring-offset`, -2px by default) with 20px corners. The base rule has zero specificity, so each list sets its height, padding, corners, font and dividers with its own class, as HCUI's lists do. Content and semantics (`role="option"` with `aria-selected`, `aria-pressed`) are the caller's. It forwards its ref to the button.
59
+ `ListRow` (experimental) is HCUI's `list-row`: a full-width button row of at least 56px with 8px 16px of padding, a 12px gap between its children, primary text, a translucent veil of the text color on hover (4%, only with a mouse) and press (7%), and the focus ring drawn inside (`--hc-focus-ring-offset`, minus `focus-ring-width` by default) with 20px corners. The base rule has zero specificity, so each list sets its height, padding, corners, font and dividers with its own class, as HCUI's lists do. Content and semantics (`role="option"` with `aria-selected`, `aria-pressed`) are the caller's. It forwards its ref to the button.
60
60
 
61
- `CommandPalette` (experimental) is HCUI's desktop ⌘K palette: a search field over grouped results, in a modal `<dialog>` opened with `showModal()` (focus in the field, Esc and a backdrop click call `onClose`, focus returns to the trigger, page scroll locked). It is presentation and keyboard only: `query` and `onQueryChange` drive the field, and `groups` are the results the caller already searched, filtered and capped (`heading`, the uppercase title that names a group, and `items` with `key`, `label`, `detail`, an `icon` slot in a 32px box, `onSelect`, an optional `onAltSelect` and `altHint`). The field is a combobox (ARIA 1.2) over a listbox; focus never leaves it: arrows (wrapping), Home and End move the active item through `aria-activedescendant` and scroll it into view, Enter calls `onSelect`, ⌘/Ctrl+Enter calls `onAltSelect` (else `onSelect`), the pointer activates the item under it and a click selects without taking focus. A new query starts at the first item. Choosing does not close it; close it in `onSelect`. Opening on ⌘K is the caller's shortcut. Texts come from props: `label` (the dialog and the list; `inputLabel` for the field), `placeholder`, `selectHint` and `altHint` (keys on the active item), `hints` (the footer: `{ keys, label }`), `loading` (text at the end of the field; the list is `aria-busy`) and `empty` (a message at the top of the list). It looks like HCUI's: 640px wide, at most 560px tall, 12vh from the top, a 1px border, `shadow-soft`, no blur, no animation. The backdrop is `bg-overlay` unless `--hc-command-palette-backdrop` is set (HCUI: `.palette::backdrop { --hc-command-palette-backdrop: rgb(0 0 0 / 28%); }`), and the list's overflow and padding have zero specificity, so `listClassName` can reserve the scrollbar gutter. The search field keeps the browser's placeholder color, as in HCUI. It forwards its ref to the `<dialog>`.
61
+ `CommandPalette` (experimental) is HCUI's desktop ⌘K palette: a search field over grouped results, in a modal `<dialog>` opened with `showModal()` (focus in the field, Esc and a backdrop click call `onClose`, focus returns to the trigger, page scroll locked). It is presentation and keyboard only: `query` and `onQueryChange` drive the field, and `groups` are the results the caller already searched, filtered and capped (`heading`, the uppercase title that names a group, and `items` with `key`, `label`, `detail`, an `icon` slot in a 32px box, `onSelect`, an optional `onAltSelect` and `altHint`). The field is a combobox (ARIA 1.2) over a listbox; focus never leaves it: arrows (wrapping), Home and End move the active item through `aria-activedescendant` and scroll it into view, Enter calls `onSelect`, ⌘/Ctrl+Enter calls `onAltSelect` (else `onSelect`), the pointer activates the item under it and a click selects without taking focus. A new query starts at the first item. Choosing does not close it; close it in `onSelect`. Opening on ⌘K is the caller's shortcut. Texts come from props: `label` (the dialog and the list; `inputLabel` for the field), `placeholder`, `selectHint` and `altHint` (keys on the active item), `hints` (the footer: `{ keys, label }`), `loading` (text at the end of the field; the list is `aria-busy`) and `empty` (a message at the top of the list). It looks like HCUI's: 640px wide, at most 560px tall, 12vh from the top, a 1px border, `shadow-soft`, no blur, no animation. The backdrop is the `color-bg-scrim` token (28% black, HCUI's) unless `--hc-command-palette-backdrop` is set, and the list's overflow and padding have zero specificity, so `listClassName` can reserve the scrollbar gutter. The search field keeps the browser's placeholder color, as in HCUI. It forwards its ref to the `<dialog>`.
62
62
 
63
- Focus rings read `--hc-focus-ring-offset` (default 2px). Set it to `-2px` on a control inside an `overflow: hidden` group so the ring is not cut.
63
+ Every focus ring is the same: `focus-ring-width` (2px) wide, in `focus-ring`, drawn `focus-ring-width` out. There are no 1px or 3px rings. The offset reads `--hc-focus-ring-offset`; set it to `calc(-1 * var(--hc-focus-ring-width))` (-2px) on a control inside an `overflow: hidden` group so the ring is not cut.
64
64
 
65
65
  `Skeleton` is a static shape that pulses, hidden from assistive technology; the enclosing region announces loading once. Its base rule has zero specificity (`:where`), so the consumer sizes each shape with `width`/`height` or its own class.
66
66
 
@@ -68,7 +68,7 @@ License: `UNLICENSED` (HCUI and Arena only). Component state and styles still ne
68
68
 
69
69
  ## HeroClix game pieces (experimental)
70
70
 
71
- The game pieces ship in `@hc/ui` itself (decision of 2026-11-06). They only draw: the caller (HCUI's adapters) reads the unit, applies the rules, picks colors and contrast, builds the image addresses (the official symbols stay on the image server and never enter the package) and passes localized labels. They never fetch, translate or import an icon library. They have the props of HCUI's `src/heroclix-ui/`.
71
+ The game pieces ship in `@hc/ui` itself (decision of 2026-11-06). They only draw: the caller (HCUI's adapters) reads the unit, applies the rules, picks colors and contrast, builds the image addresses (the official symbols stay on the image server and never enter the package) and passes localized labels. They never fetch, translate or import an icon library. They have the props of HCUI's `src/heroclix-ui/`. Their constants, helpers and types carry the piece in the name (`PHYSICAL_CARD_*`, `physicalCard*`, `MAP_*`, `map*`, `Map*`), so they sit next to the consumer's own names without aliasing.
72
72
 
73
73
  `MiniDialView` draws the dial as small squares: one column per click (`columns`, each with `click` and the four `cells`: `color`, `round`, `special`), one row per value. Without a color a cell is the dial paper (`color-dial-paper`, white in both themes); a special power is the paper with a 1px `color-dial-outline` ring. `currentClick` outlines that click in the danger color and fades the earlier ones (in play). It is a `role="img"` named by `label`. The cell size is `--hc-mini-dial-cell` (7px), set on the dial or any ancestor (HCUI: 6px in tables, 5px in the narrow list); the 5px top margin has zero specificity.
74
74
 
@@ -76,10 +76,10 @@ The game pieces ship in `@hc/ui` itself (decision of 2026-11-06). They only draw
76
76
 
77
77
  `AvatarView` is the piece's portrait: it tries `sources` in order (a thumbnail, then the original) and, when none loads, shows `fallbackSrc` with `data-fallback` and the muted fill, calling `onFallback`. The image cannot be dragged, selected or opened in a context menu; it loads lazily. `shape`: `round` (default; the caller's class sizes and crops it), `card` (one-shot: the width ÷ 1.42, corners 13.6% / 19.4%), `square` (maps: square, 21% corners, cover) and `square-fit` (terrain markers: the whole image on the muted fill). The shapes beat the caller's class by specificity, as HCUI's `!important` rules did; display, `max-width` and `box-sizing` defaults have zero specificity. `size` (px) sizes it without a class, round when `round`. `SetIconView` tries `sources` the same way and renders nothing when none loads; it is decorative (`aria-hidden`), with an optional `title`. Both use `useImageChain(sources)`, exported for other image chains (`src`, `index`, `next`).
78
78
 
79
- `CombatDialView` is the combat dial: the legend (range, target bolts and four symbols: `glyph` ones are `StatGlyphView`, others the piece's own combat symbol), one column per click (`columns`: `click`, `label`, `selectLabel`, `dimmed`, `startColor` and the four resolved `cells`: `value`, `label`, `background`, `color`, `round`, `outlined`, `tip`) and the KO column (`labels.koCell`), plus a header with `labels.heading`, the KO mark and the point value chips (`points`, `selectedPointIndex`, `onSelectPoint`; one value shows a disabled chip, none with a `valueLabel` hides the row). Without `onClickChange` it is static: no click is marked, cells without a tip are `role="img"`, and only cells with a power react. With it, the number of each click is a toggle button (`aria-pressed`) and every cell a button that also sets the click. The tip is controlled: the view decides when to open and close it (a mouse hover opens a passive tip described by the cell; a press, touch or Enter opens it as a dialog; leaving closes the passive one) and reports through `onTipChange`; the caller keeps `tip`, closes it when the piece changes and draws it in `tipSlot` with the `tipId` it gave. When the point value changes, the dial centers `focusClick` in its own track (never scrolling the page; instantly under reduced motion). Cells are 40px with 8px gaps; a cell with a tip grows its hit area to about 48px. `presentation="printed"` is the physical card's dial: no header, 20px cells in rows of 12 clicks, condensed numbers, red click numbers and KO with a paper outline (`color-dial-number-printed`), the white legend for the card's dark window and a red circle on the current click in play. `--hc-dial-bleed` (and `--hc-dial-bleed-end`) is the container's padding, so the tracks scroll to the panel's edge; `--hc-dial-surface` is the fill behind the sticky legend (the surface by default). The tracks hide their scrollbar, except on a desktop with a mouse (960px and up), where they draw a thin 8px one whose thumb shows while the pointer is over the track or focus is inside it. Point chips shrink on press and transitions stop under `prefers-reduced-motion`. It forwards its ref to the `<section>`.
79
+ `CombatDialView` is the combat dial: the legend (range, target bolts and four symbols: `glyph` ones are `StatGlyphView`, others the piece's own combat symbol), one column per click (`columns`: `click`, `label`, `selectLabel`, `dimmed`, `startColor` and the four resolved `cells`: `value`, `label`, `background`, `color`, `round`, `outlined`, `tip`) and the KO column (`labels.koCell`), plus a header with `labels.heading`, the KO mark and the point value chips (`points`, `selectedPointIndex`, `onSelectPoint`; one value shows a disabled chip, none with a `valueLabel` hides the row). Without `onClickChange` it is static: no click is marked, cells without a tip are `role="img"`, and only cells with a power react. With it, the number of each click is a toggle button (`aria-pressed`) and every cell a button that also sets the click. The tip is controlled: the view decides when to open and close it (a mouse hover opens a passive tip described by the cell; a press, touch or Enter opens it as a dialog; leaving closes the passive one) and reports through `onTipChange`; the caller keeps `tip`, closes it when the piece changes and draws it in `tipSlot` with the `tipId` it gave. When the point value changes, the dial centers `focusClick` in its own track (never scrolling the page; instantly under reduced motion). Cells are 40px with 8px gaps; a cell with a tip grows its hit area to about 48px. `density="compact"` (experimental; `comfortable` is the default) is for dense lists: 34px cells and rows with 6px gaps, 13px numbers, 18px special icons and tighter point chips (HCUI's compact dial of the unit view on phones); the printed presentation ignores it. `presentation="printed"` is the physical card's dial: no header, 20px cells in rows of 12 clicks, condensed numbers, red click numbers and KO with a paper outline (`color-dial-number-printed`), the white legend for the card's dark window and a red circle on the current click in play. `--hc-dial-bleed` (and `--hc-dial-bleed-end`) is the container's padding, so the tracks scroll to the panel's edge; `--hc-dial-surface` is the fill behind the sticky legend (the surface by default). The tracks hide their scrollbar, except on a desktop with a mouse (960px and up), where they draw a thin 8px one whose thumb shows while the pointer is over the track or focus is inside it. Point chips shrink on press and transitions stop under `prefers-reduced-motion`. It forwards its ref to the `<section>`.
80
80
 
81
81
  Fixed colors that stay the same in both themes are HeroClix tokens: `color-dial-paper`, `color-dial-outline`, `color-dial-outline-printed`, `color-dial-number-printed`, `color-dial-legend-printed`, `color-symbol-outline` (`-soft`, `-strong`), `color-symbol-light`, and the filters `filter-symbol-light` and `filter-symbol-muted` that turn the black official symbols white or gray. Colors of powers come from the caller (HCUI's powers dictionary), so they are inline styles.
82
82
 
83
- `PhysicalCardView` is the printed card in HCUnits' format (375×525 px, absolute positions), scaled to its container's width: front and back side by side while each keeps about 86% of its size, otherwise one under the other, never above 1.2×. With `fitHeight` the front also fits the visible height of the scrolling area found by `fitHeightWithin`. Everything comes resolved: the backgrounds (`images.front`, `images.back`, `images.legacy`, `layout.rarityImage`, `layout.dialImage`: addresses the app serves; HCUI's are in its `public/card/` and do not ship here), the title (its type shrinks from 28px to fit), header lines, team abilities (buttons that open a tip, with the shield the caller passes in `teamAbilityIcon`), the collection number and set symbol, the token (`portrait` drawn by the caller with the class the card gives, `terrain` art or an `object` ring in its color), the bystander's token replica, keyphrases, the rules (already translated, with an optional `icon`; tapping a paragraph calls `onCopy(copyText)`), improved abilities, the printed dial as a node (`dial.node`, usually `CombatDialView` with `presentation="printed"`), the terrain dial, points (buttons that call `onSelectPoint`, an object's line or nothing) and the black back bar. Rules that do not fit the front move to the backs, two columns each, one block at a time (`nextDistribution`); the split starts over when `contentKey` changes. Under a mouse the card tilts up to 3° toward the pointer, with parallax and a shine (nearly still over the dial); on a phone only the focus view moves, by the motion sensor (`requestMotionAccess` asks iOS on the first touch). Holding the card with a finger for 450 ms calls `onRequestFocus`; the caller opens `PhysicalCardFocusView` (a modal `<dialog>` over a dimmed, blurred backdrop; Esc and a tap outside, once the opening finger is lifted, call `onClose`) with a second card with `focus`, which shows one side at a time and draws `focusActions({ nextSide, backs, flip })` under it (the library's `Button` `outline` there gets the surface fill). Tips are controlled as in the dial: hover with a mouse opens a passive tip described by the control, a press or Enter opens it as a dialog, leaving closes the passive one; the caller keeps `tip` and draws it in `tipSlot`. Paper, ink, shine and grain follow the theme (`color-card-*`, `filter-card-background`, `blend-card-*`, `opacity-card-grain*`); the printed black and white, the team-up band and the terrain dial are fixed (`color-card-print-*`, `color-card-team-up`, `color-card-terrain-panel*`). Letters are upright (`--hc-rond: 0` on the card, which its printed dial reads too). Motion only with a fine pointer or in the phone's focus view, and none under `prefers-reduced-motion`.
83
+ `PhysicalCardView` is the printed card in HCUnits' format (375×525 px, absolute positions), scaled to its container's width: front and back side by side while each keeps about 86% of its size, otherwise one under the other, never above 1.2×. With `fitHeight` the front also fits the visible height of the scrolling area found by `fitHeightWithin`. Everything comes resolved: the backgrounds (`images.front`, `images.back`, `images.legacy`, `layout.rarityImage`, `layout.dialImage`: addresses the app serves; HCUI's are in its `public/card/` and do not ship here), the title (its type shrinks from 28px to fit), header lines, team abilities (buttons that open a tip, with the shield the caller passes in `teamAbilityIcon`), the collection number and set symbol, the token (`portrait` drawn by the caller with the class the card gives, `terrain` art or an `object` ring in its color), the bystander's token replica, keyphrases, the rules (already translated, with an optional `icon`; tapping a paragraph calls `onCopy(copyText)`), improved abilities, the printed dial as a node (`dial.node`, usually `CombatDialView` with `presentation="printed"`), the terrain dial, points (buttons that call `onSelectPoint`, an object's line or nothing) and the black back bar. Rules that do not fit the front move to the backs, two columns each, one block at a time (`nextPhysicalCardDistribution`); the split starts over when `contentKey` changes. Under a mouse the card tilts up to 3° toward the pointer, with parallax and a shine (nearly still over the dial); on a phone only the focus view moves, by the motion sensor (`requestPhysicalCardMotionAccess` asks iOS on the first touch). Holding the card with a finger for 450 ms calls `onRequestFocus`; the caller opens `PhysicalCardFocusView` (a modal `<dialog>` over a dimmed, blurred backdrop; Esc and a tap outside, once the opening finger is lifted, call `onClose`) with a second card with `focus`, which shows one side at a time and draws `focusActions({ nextSide, backs, flip })` under it (the library's `Button` `outline` there gets the surface fill). Tips are controlled as in the dial: hover with a mouse opens a passive tip described by the control, a press or Enter opens it as a dialog, leaving closes the passive one; the caller keeps `tip` and draws it in `tipSlot`. Paper, ink, shine and grain follow the theme (`color-card-*`, `filter-card-background`, `blend-card-*`, `opacity-card-grain*`); the printed black and white, the team-up band and the terrain dial are fixed (`color-card-print-*`, `color-card-team-up`, `color-card-terrain-panel*`). Letters are upright (`--hc-rond: 0` on the card, which its printed dial reads too). Motion only with a fine pointer or in the phone's focus view, and none under `prefers-reduced-motion`.
84
84
 
85
- `MapBoardView` draws a map already fetched (`map`: the hcmaps JSON, `HCMapJson`) with the hcmaps engine (`HCMap`) in a canvas named by `canvasLabel`, square-cornered and white in both themes (`color-map-board`), and shades the inspected square or draws the inspected edge (`selection`, from `inspectMap`; `color-map-selection*`). `onInspect(x, y, clientX, clientY)` gets the pointer in squares: mouse and pen while passing over, touch on press; `onLeave` when the mouse leaves. Other attributes (role, `tabIndex`, `onKeyDown`, `aria-*`) go to the container, so the caller adds the keyboard (HCUI: arrows move one square, Esc closes). `MapBoardPlaceholder` is the empty grid while it loads, a busy status whose `label` is visually hidden by the library. `MapTooltipView` is fixed next to `anchor` without leaving the screen (portal it out of a clipping container): the title, a wall's color, and for each square (one, or two on an edge) its coordinate, terrain and elevation swatches with the caller's names, the environment (a node, e.g. `MapEnvironmentView`, or `environmentNote`) and notes. `MapSwatch` is the board's square in a legend; `MapLegendView` lists them (the 3D model's legend) with children after; `MapEnvironmentView` shows indoor/outdoor with the caller's icons (black line icons, turned light in the dark theme by `filter-map-environment-icon`) and names. `renderMapThumbnail(json, height)` returns a PNG data URL. The engines are plain functions with no DOM beyond a canvas context: `inspectMap` (square, edge or wall at a point, in squares), `mapSquare`, `mapMaxLevel`, `elevationLevel`, `squareCoordinate`; `buildMapScene` (the 3D model's heights, walls, ramps and ziplines), the orthographic camera (`makeProjection`, `pickScene`, `zoomCamera`, `clampPan`) and `drawMapScene` (Canvas 2D). The 3D model's component, its gestures and its icons stay in the app.
85
+ `MapBoardView` draws a map already fetched (`map`: the hcmaps JSON, `HCMapJson`) with the hcmaps engine (`HCMap`) in a canvas named by `canvasLabel`, square-cornered and white in both themes (`color-map-board`), and shades the inspected square or draws the inspected edge (`selection`, from `inspectMap`; `color-map-selection*`). `onInspect(x, y, clientX, clientY)` gets the pointer in squares: mouse and pen while passing over, touch on press; `onLeave` when the mouse leaves. Other attributes (role, `tabIndex`, `onKeyDown`, `aria-*`) go to the container, so the caller adds the keyboard (HCUI: arrows move one square, Esc closes). `MapBoardPlaceholder` is the empty grid while it loads, a busy status whose `label` is visually hidden by the library. `MapTooltipView` is fixed next to `anchor` without leaving the screen (portal it out of a clipping container): the title, a wall's color, and for each square (one, or two on an edge) its coordinate, terrain and elevation swatches with the caller's names, the environment (a node, e.g. `MapEnvironmentView`, or `environmentNote`) and notes. `MapSwatch` is the board's square in a legend; `MapLegendView` lists them (the 3D model's legend) with children after; `MapEnvironmentView` shows indoor/outdoor with the caller's icons (black line icons, turned light in the dark theme by `filter-map-environment-icon`) and names. `renderMapThumbnail(json, height)` returns a PNG data URL. The engines are plain functions with no DOM beyond a canvas context: `inspectMap` (square, edge or wall at a point, in squares), `mapSquare`, `mapMaxLevel`, `mapElevationLevel`, `mapSquareCoordinate`; `buildMapScene` (the 3D model's heights, walls, ramps and ziplines), the orthographic camera (`makeMapProjection`, `pickMapScene`, `zoomMapCamera`, `clampMapPan`) and `drawMapScene` (Canvas 2D). The 3D model's component, its gestures and its icons stay in the app.
package/dist/index.d.ts CHANGED
@@ -46,6 +46,11 @@ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
46
46
  fullWidth?: boolean;
47
47
  /** Busy state: keeps focus and label, shows a spinner and ignores clicks. */
48
48
  loading?: boolean;
49
+ /**
50
+ * Opt-in: a long label wraps onto more lines (the button grows taller) instead of
51
+ * being cut with an ellipsis, which stays the default.
52
+ */
53
+ wrap?: boolean;
49
54
  }
50
55
  /** Pill button. The caller supplies the label text and handles the action. */
51
56
  export declare const Button: import("react").ForwardRefExoticComponent<ButtonProps & import("react").RefAttributes<HTMLButtonElement>>;
@@ -298,15 +303,12 @@ interface SheetChoiceProps<T extends string> extends Omit<HTMLAttributes<HTMLDiv
298
303
  }
299
304
  export interface SheetOptionsProps<T extends string> extends SheetChoiceProps<T> {
300
305
  options: readonly SheetOption<T>[];
301
- /**
302
- * `card` (default): rows on a `bg-muted-2` card, as HCUI's SheetOptions. `list`: plain
303
- * 48px rows divided by a line, the chosen one bolder with a 20px check in the text color,
304
- * as the list of HCUI's mobile sort sheet (`SortButton`).
305
- */
306
- variant?: 'card' | 'list';
307
306
  }
308
- /** Single-choice list of rows with a check (HCUI's SheetOptions), for inside a sheet. */
309
- export declare function SheetOptions<T extends string>({ label, options, value, onChange, variant, className, ...props }: SheetOptionsProps<T>): import("react/jsx-runtime").JSX.Element;
307
+ /**
308
+ * Single-choice list of rows with a check on a `bg-muted-2` card (HCUI's SheetOptions),
309
+ * for inside a sheet. It is also the phone's sort list (decision of 2026-11-07).
310
+ */
311
+ export declare function SheetOptions<T extends string>({ label, options, value, onChange, className, ...props }: SheetOptionsProps<T>): import("react/jsx-runtime").JSX.Element;
310
312
  export interface SheetChipsProps<T extends string> extends SheetChoiceProps<T> {
311
313
  options: readonly {
312
314
  value: T;
@@ -442,7 +444,7 @@ export interface SortMenuProps<T extends string> extends Omit<MenuProps, 'label'
442
444
  /**
443
445
  * Sort control: a `Chip` showing the current choice that opens a `Menu` with a
444
446
  * `MenuRadioGroup` (HCUI's `DesktopSortMenu`). Options and value come from the caller.
445
- * On phones, compose `Sheet` with `SheetOptions` behind an `IconButton` instead.
447
+ * On phones, compose `Sheet` with `SheetOptions` (its card look) behind an `IconButton` instead.
446
448
  */
447
449
  export declare function SortMenu<T extends string>({ label, value, options, onChange, applied, size, triggerClassName, ...props }: SortMenuProps<T>): import("react/jsx-runtime").JSX.Element;
448
450
  export interface PopoverProps extends PopupBaseProps, Omit<HTMLAttributes<HTMLDivElement>, 'role' | 'children'> {
@@ -864,6 +866,12 @@ export interface CombatDialViewProps {
864
866
  labels: CombatDialLabels;
865
867
  /** `printed`: the physical card's dial, without the header, in rows of 12 clicks. */
866
868
  presentation?: 'default' | 'printed';
869
+ /**
870
+ * `comfortable` (default): 40px cells. `compact`: 34px cells, 6px gaps, 13px numbers and
871
+ * tighter point chips, for dense lists (HCUI's compact dial of the unit view on phones).
872
+ * The printed presentation keeps its own measures.
873
+ */
874
+ density?: 'comfortable' | 'compact';
867
875
  /** Without it the dial is static: it neither marks nor changes the click; only cells with a tip react. */
868
876
  onClickChange?: (click: number) => void;
869
877
  onSelectPoint: (point: CombatDialPoint) => void;
@@ -881,11 +889,11 @@ export interface CombatDialViewProps {
881
889
  * the cells open the tip through `onTipChange`; the caller draws the tip.
882
890
  */
883
891
  export declare const CombatDialView: import("react").ForwardRefExoticComponent<CombatDialViewProps & import("react").RefAttributes<HTMLElement>>;
884
- export declare const CARD_WIDTH = 375;
885
- export declare const CARD_HEIGHT = 525;
886
- export declare const BACK_TABLE_HEIGHT = 440;
892
+ export declare const PHYSICAL_CARD_WIDTH = 375;
893
+ export declare const PHYSICAL_CARD_HEIGHT = 525;
894
+ export declare const PHYSICAL_CARD_BACK_TABLE_HEIGHT = 440;
887
895
  /** Position of each value of the bystander's dial: every row goes 24px down and 7px right. */
888
- export declare const BYSTANDER_DIAL_STEP: {
896
+ export declare const PHYSICAL_CARD_BYSTANDER_DIAL_STEP: {
889
897
  x: number;
890
898
  y: number;
891
899
  };
@@ -898,11 +906,11 @@ export declare const BYSTANDER_DIAL_STEP: {
898
906
  * `overflowing(table)` tells whether that column overflows with the current split.
899
907
  * Returns the next split, or `null` once it is stable.
900
908
  */
901
- export declare function nextDistribution(pages: number[], overflowing: (table: number) => boolean): number[] | null;
909
+ export declare function nextPhysicalCardDistribution(pages: number[], overflowing: (table: number) => boolean): number[] | null;
902
910
  /** How many backs the split needs (each back has two columns). There is always one. */
903
- export declare function backCount(pages: number[]): number;
911
+ export declare function physicalCardBackCount(pages: number[]): number;
904
912
  /** Cursor position on the card (0 to 1) → tilt, parallax (-1 to 1) and shine (%). */
905
- export declare function tiltAt(x: number, y: number, damping?: number): {
913
+ export declare function physicalCardTiltAt(x: number, y: number, damping?: number): {
906
914
  rotateX: number;
907
915
  rotateY: number;
908
916
  parallaxX: number;
@@ -910,16 +918,16 @@ export declare function tiltAt(x: number, y: number, damping?: number): {
910
918
  shineX: number;
911
919
  shineY: number;
912
920
  };
913
- type CardTilt = ReturnType<typeof tiltAt>;
921
+ type CardTilt = ReturnType<typeof physicalCardTiltAt>;
914
922
  /** The finished values each moving part of the card takes, ready for `style`. */
915
- export declare function tiltStyles(tilt: CardTilt): {
923
+ export declare function physicalCardTiltStyles(tilt: CardTilt): {
916
924
  card: string;
917
925
  art: string;
918
926
  header: string;
919
927
  shine: string;
920
928
  };
921
929
  /** Sensor reading relative to the reference pose → position on the card (0 to 1). */
922
- export declare function devicePosition(beta: number, gamma: number, baseline: {
930
+ export declare function physicalCardDevicePosition(beta: number, gamma: number, baseline: {
923
931
  beta: number;
924
932
  gamma: number;
925
933
  }, screenAngle?: number): {
@@ -932,7 +940,7 @@ export declare function devicePosition(beta: number, gamma: number, baseline: {
932
940
  * it; outside iOS it does nothing. A touch the browser refuses leaves the request
933
941
  * for the next one.
934
942
  */
935
- export declare function requestMotionAccess(): void;
943
+ export declare function requestPhysicalCardMotionAccess(): void;
936
944
  /** Value rows of the dial, top to bottom. */
937
945
  export declare const PHYSICAL_CARD_STATS: readonly ["speed", "attack", "defense", "damage"];
938
946
  export type PhysicalCardStat = (typeof PHYSICAL_CARD_STATS)[number];
@@ -1224,13 +1232,13 @@ export declare const PhysicalCardView: import("react").ForwardRefExoticComponent
1224
1232
  * same JSON (the piece's map) and draws it 1:1 locally, with no runtime
1225
1233
  * dependency on their scripts. Credit: hcmaps.net (original engine in JS).
1226
1234
  */
1227
- export declare const TILE_SIZE = 64;
1228
- export declare const ELEVATION_TILE_COLORS: string[];
1229
- export declare const TERRAIN_TILE_COLORS: Record<string, string>;
1230
- export declare const TERRAIN_EDGE_COLORS: Record<string, string>;
1231
- export declare const ELEVATION_EDGE_COLOR = "#FF0000";
1232
- export declare const INDOOR_EDGE_COLOR = "#FFFF00";
1233
- export declare const WALL_COLORS: Record<string, string>;
1235
+ export declare const MAP_TILE_SIZE = 64;
1236
+ export declare const MAP_ELEVATION_TILE_COLORS: string[];
1237
+ export declare const MAP_TERRAIN_TILE_COLORS: Record<string, string>;
1238
+ export declare const MAP_TERRAIN_EDGE_COLORS: Record<string, string>;
1239
+ export declare const MAP_ELEVATION_EDGE_COLOR = "#FF0000";
1240
+ export declare const MAP_INDOOR_EDGE_COLOR = "#FFFF00";
1241
+ export declare const MAP_WALL_COLORS: Record<string, string>;
1234
1242
  interface JsonLabel {
1235
1243
  text: string;
1236
1244
  color?: string;
@@ -1299,7 +1307,7 @@ export declare function mapSquare(map: HCMapJson, x: number, y: number): {
1299
1307
  };
1300
1308
  export type MapSquare = ReturnType<typeof mapSquare>;
1301
1309
  /** A text elevation exists in the hcmaps format; the 2D drawing treats it as an elevated level. */
1302
- export declare function elevationLevel(elevation: number | string): number;
1310
+ export declare function mapElevationLevel(elevation: number | string): number;
1303
1311
  export declare function mapMaxLevel(map: HCMapJson): number;
1304
1312
  export interface MapInspection {
1305
1313
  square: MapSquare;
@@ -1314,7 +1322,7 @@ export interface MapInspection {
1314
1322
  }
1315
1323
  /** Coordinates are in squares; wall tolerance is based on screen pixels by the caller. */
1316
1324
  export declare function inspectMap(map: HCMapJson, x: number, y: number, tolerance?: number): MapInspection | null;
1317
- export declare function squareCoordinate(square: Pick<MapSquare, 'x' | 'y'>): string;
1325
+ export declare function mapSquareCoordinate(square: Pick<MapSquare, 'x' | 'y'>): string;
1318
1326
  /**
1319
1327
  * The 3D model's scene, with no drawing. It uses only what the map JSON has:
1320
1328
  * terrain, elevation, environment, walls, ramps and ziplines. Blocking follows
@@ -1322,15 +1330,15 @@ export declare function squareCoordinate(square: Pick<MapSquare, 'x' | 'y'>): st
1322
1330
  * one elevation. Walls, water and obscuring have no height in the data: those
1323
1331
  * are conventions.
1324
1332
  */
1325
- export declare const LEVEL_HEIGHT = 0.62;
1326
- export declare const BASE_Z = -0.34;
1327
- export interface SceneCell extends MapSquare {
1333
+ export declare const MAP_SCENE_LEVEL_HEIGHT = 0.62;
1334
+ export declare const MAP_SCENE_BASE_Z = -0.34;
1335
+ export interface MapSceneCell extends MapSquare {
1328
1336
  level: number;
1329
1337
  /** The square's floor, without the terrain's volume. */
1330
1338
  floor: number;
1331
1339
  top: number;
1332
1340
  }
1333
- export interface SceneWall {
1341
+ export interface MapSceneWall {
1334
1342
  x0: number;
1335
1343
  y0: number;
1336
1344
  x1: number;
@@ -1339,7 +1347,7 @@ export interface SceneWall {
1339
1347
  z0: number;
1340
1348
  z1: number;
1341
1349
  }
1342
- export interface SceneRamp {
1350
+ export interface MapSceneRamp {
1343
1351
  x: number;
1344
1352
  y: number;
1345
1353
  dx: number;
@@ -1347,7 +1355,7 @@ export interface SceneRamp {
1347
1355
  from: number;
1348
1356
  to: number;
1349
1357
  }
1350
- export interface SceneLine {
1358
+ export interface MapSceneLine {
1351
1359
  x0: number;
1352
1360
  y0: number;
1353
1361
  x1: number;
@@ -1357,13 +1365,13 @@ export interface SceneLine {
1357
1365
  export interface MapScene {
1358
1366
  width: number;
1359
1367
  height: number;
1360
- cells: SceneCell[];
1361
- walls: SceneWall[];
1362
- ramps: SceneRamp[];
1363
- ziplines: SceneLine[];
1368
+ cells: MapSceneCell[];
1369
+ walls: MapSceneWall[];
1370
+ ramps: MapSceneRamp[];
1371
+ ziplines: MapSceneLine[];
1364
1372
  maxTop: number;
1365
1373
  }
1366
- export declare function sceneCell(scene: Pick<MapScene, 'width' | 'height' | 'cells'>, x: number, y: number): SceneCell | null;
1374
+ export declare function mapSceneCell(scene: Pick<MapScene, 'width' | 'height' | 'cells'>, x: number, y: number): MapSceneCell | null;
1367
1375
  export declare function buildMapScene(map: HCMapJson): MapScene;
1368
1376
  export interface MapCamera {
1369
1377
  azimuth: number;
@@ -1372,16 +1380,16 @@ export interface MapCamera {
1372
1380
  panX: number;
1373
1381
  panY: number;
1374
1382
  }
1375
- export interface Viewport {
1383
+ export interface MapViewport {
1376
1384
  width: number;
1377
1385
  height: number;
1378
1386
  }
1379
- export declare const DEFAULT_CAMERA: MapCamera;
1387
+ export declare const MAP_DEFAULT_CAMERA: MapCamera;
1380
1388
  /** 90 is the flat view from above. Below the minimum the board becomes a line. */
1381
- export declare const TILT_RANGE: readonly [6, 90];
1382
- export declare const ZOOM_RANGE: readonly [1, 6];
1383
- export declare const clamp: (value: number, min: number, max: number) => number;
1384
- export interface Projection {
1389
+ export declare const MAP_TILT_RANGE: readonly [6, 90];
1390
+ export declare const MAP_ZOOM_RANGE: readonly [1, 6];
1391
+ export declare const clampMapValue: (value: number, min: number, max: number) => number;
1392
+ export interface MapProjection {
1385
1393
  scale: number;
1386
1394
  sin: number;
1387
1395
  cos: number;
@@ -1391,16 +1399,16 @@ export interface Projection {
1391
1399
  /** From the screen to the map, on a plane at height `z`. */
1392
1400
  ground(px: number, py: number, z: number): [number, number];
1393
1401
  }
1394
- export declare function makeProjection(scene: MapScene, camera: MapCamera, viewport: Viewport): Projection;
1402
+ export declare function makeMapProjection(scene: MapScene, camera: MapCamera, viewport: MapViewport): MapProjection;
1395
1403
  /** The map's center may go as far as any of its corners reaches the middle of the screen. */
1396
- export declare function clampPan(scene: MapScene, camera: MapCamera, viewport: Viewport): MapCamera;
1404
+ export declare function clampMapPan(scene: MapScene, camera: MapCamera, viewport: MapViewport): MapCamera;
1397
1405
  /** Zooms keeping the point at `anchor` (measured from the screen's center) still. */
1398
- export declare function zoomCamera(scene: MapScene, camera: MapCamera, viewport: Viewport, factor: number, anchor?: {
1406
+ export declare function zoomMapCamera(scene: MapScene, camera: MapCamera, viewport: MapViewport, factor: number, anchor?: {
1399
1407
  x: number;
1400
1408
  y: number;
1401
1409
  }): MapCamera;
1402
1410
  /** A square's four sides: the edge's points, the neighbor and the normal. */
1403
- export declare const CELL_SIDES: readonly [{
1411
+ export declare const MAP_CELL_SIDES: readonly [{
1404
1412
  readonly ax: 0;
1405
1413
  readonly ay: 1;
1406
1414
  readonly bx: 0;
@@ -1433,7 +1441,7 @@ export declare const CELL_SIDES: readonly [{
1433
1441
  * From the screen to map coordinates, testing from front to back. Returns the
1434
1442
  * point in `inspectMap`'s terms, so the tooltip and selection match the 2D board.
1435
1443
  */
1436
- export declare function pickScene(scene: MapScene, view: Projection, px: number, py: number): {
1444
+ export declare function pickMapScene(scene: MapScene, view: MapProjection, px: number, py: number): {
1437
1445
  x: number;
1438
1446
  y: number;
1439
1447
  } | null;
@@ -1442,7 +1450,7 @@ type Point = readonly [number, number];
1442
1450
  * The target's squares inside the board and their outer outline, in map coordinates.
1443
1451
  * Corners are where the outline turns or ends: the volume's edges rise there.
1444
1452
  */
1445
- export declare function footprintOutline({ x, y, width, height, cells }: {
1453
+ export declare function mapFootprintOutline({ x, y, width, height, cells }: {
1446
1454
  x: number;
1447
1455
  y: number;
1448
1456
  width: number;
@@ -1453,7 +1461,7 @@ export declare function footprintOutline({ x, y, width, height, cells }: {
1453
1461
  edges: [Point, Point][];
1454
1462
  corners: Point[];
1455
1463
  };
1456
- export interface SceneFootprint {
1464
+ export interface MapSceneFootprint {
1457
1465
  x: number;
1458
1466
  y: number;
1459
1467
  width: number;
@@ -1463,7 +1471,7 @@ export interface SceneFootprint {
1463
1471
  /** Occupied squares (`cells[y][x]`, from the corner); without it, the whole rectangle. */
1464
1472
  cells?: readonly (readonly boolean[])[] | null;
1465
1473
  }
1466
- export interface SceneHighlight {
1474
+ export interface MapSceneHighlight {
1467
1475
  square?: {
1468
1476
  x: number;
1469
1477
  y: number;
@@ -1475,9 +1483,9 @@ export interface SceneHighlight {
1475
1483
  y1: number;
1476
1484
  };
1477
1485
  /** The target of a piece being moved: floor and volume, cut to the board. */
1478
- footprint?: SceneFootprint;
1486
+ footprint?: MapSceneFootprint;
1479
1487
  }
1480
- export declare function drawMapScene(ctx: CanvasRenderingContext2D, scene: MapScene, view: Projection, texture: HTMLCanvasElement, highlight?: SceneHighlight | null, { tufts }?: {
1488
+ export declare function drawMapScene(ctx: CanvasRenderingContext2D, scene: MapScene, view: MapProjection, texture: HTMLCanvasElement, highlight?: MapSceneHighlight | null, { tufts }?: {
1481
1489
  tufts?: boolean;
1482
1490
  }): void;
1483
1491
  /** The thumbnail's default height, in pixels. */