@ntmats/hc-ui 0.4.0 → 0.6.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,56 @@ 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
+ Options for HCUI's remaining local pieces (unreleased, experimental; opt-in, except the visible changes listed in the CHANGELOG):
54
+
55
+ - `Button`: `variant="ghost"` (no fill, only the hover and press veil) and `tone="danger"` (danger text on `secondary`, `outline` and `ghost`), e.g. `variant="ghost" tone="danger"` for a text-only destructive action. As a popup trigger (`aria-haspopup`) that is open (`aria-expanded="true"`), `secondary`, `outline` and `ghost` draw the 1.5px ring in the text color, as `Chip`.
56
+ - `IconButton`: `size="xs"` (28px, keeping a 44px touch target) and `tone="muted"` (text-muted). A `ghost` popup trigger keeps its hover fill while open.
57
+ - `Chip`: `variant="muted"` (gray fill without ring; with `onClear` at `md`/`sm`, the main button fills the height and the × is 26px, 5px from the end), `badgePlacement="corner"` (the badge on the top right corner, brand fill, a 2px ring of `--hc-chip-badge-ring`, default `bg-page`), a chip with an `icon` and no label is a circle, and `informative` (disabled but not dimmed).
58
+ - `SheetChips`: `size`, passed to every `Chip`.
59
+ - `Dialog` and `Sheet`: `bodyClassName` (the scrolling body's class). `Sheet header={false}` drops the header (no title, no close button) for content with its own heading and floating close; `aria-label` names the dialog (required by the type), and the body has no padding. The entry animations no longer leave a transform on the dialog, so fixed popups portaled into it are not clipped; a touch inside a portaled popup (`.hc-ui-portal`) does not drag the sheet.
60
+ - `portalContainerFor(node)`: the open `<dialog>` around `node`, else `document.body` (null on the server). Use it for the app's own tips, so they show above a modal `Dialog` or `Sheet` and receive touches instead of staying behind the top layer; `Menu` and `Popover` with `portal` already do this.
61
+ - `Surface`: `as` (`div`, `section`, `article`, `aside`, `ul`, `ol`, `li`) and `tone="muted"` (the `bg-muted-2` card: 20px corners, `shadow-card`).
62
+ - `FilterBar`: `bleed` (negative margin of `--hc-filter-bar-bleed-start`/`-end`, default `--hc-filter-bar-bleed`, given back as padding) and `rowClassName`; the row is `position: relative`, so an absolute popup placed in it is anchored to it.
63
+ - `Dialog size="sm"` no longer forces 38px buttons: they keep their own size.
64
+ - `SegmentedControl`: `optionMinWidth` (px).
65
+
66
+ `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
67
 
55
68
  `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
69
 
57
70
  `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
71
 
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.
72
+ `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
73
 
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>`.
74
+ `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
75
 
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.
76
+ 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
77
 
65
78
  `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
79
 
@@ -68,7 +81,7 @@ License: `UNLICENSED` (HCUI and Arena only). Component state and styles still ne
68
81
 
69
82
  ## HeroClix game pieces (experimental)
70
83
 
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/`.
84
+ 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
85
 
73
86
  `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
87
 
@@ -76,10 +89,10 @@ The game pieces ship in `@hc/ui` itself (decision of 2026-11-06). They only draw
76
89
 
77
90
  `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
91
 
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>`.
92
+ `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
93
 
81
94
  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
95
 
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`.
96
+ `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
97
 
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.
98
+ `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.