@hackcove/ui-react 0.10.3 → 0.10.5

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
@@ -71,7 +71,7 @@ takes the size of the text around it. Upgrading: `MIGRATING.md`, shipped in the
71
71
  - Primitives: `Card` (a bordered panel; `title` and `count` make a small heading, `titleStyle="name"` sets the title in sentence case in the primary ink for a card that is a named thing (the default `overline` is small uppercase), and `status` (a `Pill` or any node) sits right after them, where `actions` sits at the far end; `flush` drops the padding and clips the corners, for a table that runs edge to edge; `bar` moves the title, count and `actions` into a tinted strip over the body; `dashed` is an empty-state shell with centred muted text, and `dashed="filled"` the same on a surface fill with more padding; `badge` pins a `Pill` to the top-right corner over the content, on a panel or a link card, where it is part of the link's name; `icon` puts one of the `Icon` set's names above the title at `feature` size in the accent colour, decorative, on a panel or a link card but not a `bar`, `flush` or `dashed` one; `attention` draws the strong warn edge (`border-amber-600`) round a card that needs a look, with or without `tone="warn"`, and does nothing on a `dashed` card; `footer` puts a tinted strip under a hairline across the bottom, for the card's own actions or a total: it takes the padding off the root and gives it to the body, so the strip runs edge to edge and stays at the foot of a card stretched in a `CardGrid`; not on a `dashed` card or a card that is a link; `inset` is a full-bleed band (a progress bar, a chart, an image) under the title and over the children, running edge to edge of a padded card and not on a `dashed` card; a bordered sub-card inside a card is a nested `Card tone="neutral"`; `as` is `section` (default), `article` or `div`; `id`, `data-*` and `aria-*` go on the root (on a link card too: the link inside takes only its own `aria-label`, `aria-labelledby` and `aria-current`); a titled panel is named by its title through `aria-labelledby`, unless you pass your own `aria-label` or `aria-labelledby`; a Card with an `id` is an anchor target, scrolled clear of the sticky header and outlined in violet while it is the `:target`), `EpicHeader`, `Pill` (a small tinted label; `tone`, `dot`, `round` and `title`; `label` makes it a two-tone `label: value` pill, the label keeping the tint and the children, the value, on the surface; `href`/`render` make it a link and `onClick` a button; `onRemove` with `removeLabel` adds an x button, a separate button beside a link or button pill and never inside it), `groupByEpic`, `Markdown`, `Badge` (a small red count for sitting on an icon; `count` above 99 reads `99+`, `corner` pins it to the top-right of its `relative` parent; an `IconButton` takes `badge` and does this itself).
72
72
  - Data: `StatTile` (one headline figure: an uppercase `label` over a large tabular `value`, already formatted, an optional `hint` under it; `tone` is `neutral` (default), `positive` or `negative`, and `accent` adds a bar across the top in the same tone), `ProgressBar` (a thin bar with a filled share of `max` (default 100); `label` names it for a screen reader, which also hears the percentage; `tone` is `accent` (default), `positive`, `warn` or `negative`), `SplitBar` (a bar split between `a` and `b` by their shares; `names` says whose is whose to a screen reader; `toneA` (default `negative`) and `toneB` (default `positive`)), `StackedBar` (any number of `segments`, each `{ label, value, tone }`, sized by its share of the sum; `legend` draws a `Legend` under it) and `Legend` (a swatch, a `label` and an optional `detail` per item, the key to a chart's or a row stripe's colours). The three bars take `size`: `sm` 6px, `md` 8px (default), `lg` 12px.
73
73
  - Status pages: `NotFound` (404), `ErrorPage` (500 or any failure; `detail` is error text shown in a `CodeBlock`, never a stack trace, and `onRetry` adds "Try again"), `Offline` (`onRetry` too), `Maintenance` and `Forbidden` (403). Each is a whole page with default copy: `title` and `children` replace the heading and the body, `action` is the primary action, and `homeHref`, or `renderHome` for the app's own router `<Link>`, adds a "Back to home" button. With neither there is no home link.
74
- - Layout (see Layout): `AppShell`, `AppShellBrand`, `appShellRootClassFor`, `Container`, `PageHeader`, `Stack`, `Section`, `Cluster`, `ClusterGrow`, `SideColumn`, and the page layouts `ListLayout`, `DetailLayout`, `FormLayout`, `ProseLayout`, `CardGrid`, `SidebarLayout`, `FullBleedLayout`, `CenteredLayout`.
74
+ - Layout (see Layout): `AppShell`, `AppShellBrand`, `appShellRootClassFor`, `Container`, `PageHeader`, `Hero`, `Stack`, `Section`, `Cluster`, `ClusterGrow`, `SideColumn`, and the page layouts `ListLayout`, `DetailLayout`, `FormLayout`, `ProseLayout`, `CardGrid`, `SidebarLayout`, `FullBleedLayout`, `CenteredLayout`.
75
75
  - `Prose` (a `div` for long-form content, a docs page or an article, never an app screen: it sets text-base/7 and puts `Text`, `SectionHeading`, `SubsectionHeading` and the `Callout` text on the long-form scale inside it, body 16, h4 16 semibold, h3 20 and h2 24; every other role keeps its size, and a `Dialog`, `Drawer` or `EmptyState` inside it keeps the app sizes; it takes no `className`, `style` or size prop).
76
76
  - Typography roles (each pins size + weight, takes a `color` and an `as`): `Display`, `Lead`,
77
77
  `PageHeading`, `SectionHeading`, `SubsectionHeading`, `Overline`, `Text`, `Caption`. Opt-in props, none changing today's look: `SectionHeading rule`
@@ -81,7 +81,7 @@ takes the size of the text around it. Upgrading: `MIGRATING.md`, shipped in the
81
81
  text colours as a `TableCell` tone, winning over `color`; pair the colour with a word, as in "Saved."),
82
82
  `numeric` (tabular figures), `mono` (the monospace face) and `truncate` (one line, cut with an
83
83
  ellipsis, the full text in a `title`, so its content is text).
84
- - Controls: `Button` (`variant` is `primary` (default), `outline`, `ghost`, `link` or `dashed`, the link having no box: text with an underline on hover, for an in-line action, and the dashed one a dashed outline in muted text that fills its container, like `Card dashed`, for an "add another" row, with a leading icon as a child, ignoring `tone`; `size="sm"` is compact, `text-xs`, for table rows and toolbars, `size="lg"` is `text-base` with more padding; `tone="danger"` is red, for delete and clear, and `tone="positive"` green, for accept and approve; `disabledReason` disables it, greys it flat and makes the reason its `title`; `busy` disables it with `aria-busy` and keeps its colour, showing `busyLabel` in place of the children; `pressed` makes it a toggle with `aria-pressed`, on `outline` and `ghost` neutral when off and filled in the tone's primary colour when on, so an `outline` `size="sm"` Button with `pressed` is a filter chip; `count` puts a small number chip after the label, part of the button's name ("Splits, 3"), and a button that opens something also takes `aria-expanded`; with `href`, or `render` to draw the app's own router `<Link>`, it is a link with the same look, `external` opening a new tab), `ButtonGroup` (a wrapping row of buttons; `end` is one item pushed to the far side, `label` makes it a named `role="group"`), `IconButton` (a square button holding one icon; `label` is required and becomes its `aria-label` and `title`; `variant` is `outline` (default) or `ghost`, `tone` is `default` or `danger`, `size` matches a `Button`'s height; `badge` puts a red count on its corner, not shown at 0; with `href` or `render` it is a link, `external` opening a new tab), `Icon` (one bare `<svg>` sized to fill a box, or `name` for one of the set's own icons (`diff`, `baseline`, `review`, `ci`, `self-host`, `privacy`, `capture`, `branch`, `theme`, `speed`, `search`, `chevron-right`, `chevron-down`, `close`, `external-link`, `settings`, `bell`, `check`, `plus`: feature ideas and UI glyphs on one 24 grid, a 1.5 stroke, round caps and joins, each the idea or action, not a product): `size` `sm` 14px, `md` 16px (default), `lg` 20px or `feature` 32px; `tone` is a text tone or `inherit` (default, the text colour around it); `aria-hidden` unless `label` names it as `role="img"`), `ColourSchemeToggle` (a light/dark switch on `<html>`; `id`, `data-*` and other button props go on the button, `store={false}` skips `localStorage`; see Light and dark), `Input` (a text input; `icon` adds a leading icon inside the field; `prefix` is short text inside the border before the value, a currency symbol, on a plain field only; `type="search"` drops the browser's search look and shows a "Clear search" button while the field has a value), `Textarea` (a multi-line text input, `rows` defaults to 3), `Select` (a native `<select>` in the control look; takes `<option>` children and keeps the browser's arrow). `Input`, `Select` and `Textarea` take `size` (`md` default, `sm`: `text-xs`, less padding, for dense forms and table rows, or `lg`: `text-base`, more padding) and `width` (`full` default, the fixed `xs` 5rem, `sm` 7rem, `md` 9rem, `lg` 12rem, `fill`: grows to the room left in a flex row and can shrink to nothing, so the buttons beside it stay on the line, or `auto`: no width class, as wide as the browser makes it); a fixed width replaces `w-full`, so there is no class to collide with. `Input` also takes `align` (`start` default, or `end` to right-align a numeric value). `size` replaces the native `size` attribute, so a multi-row `<select size={4}>` is not reachable through `Select`. `Checkbox` (a real `<input type="checkbox">` with its label, drawn by the library as an empty outlined box and a filled violet one with a check mark, so it reads the same in every theme and browser; other props go on the input; `size="sm"` is `text-xs` with a smaller box and `size="lg"` `text-base` with a larger one, `align="start"` puts the box on the first line of a label that wraps, where the default `center` centres it on all of them, and with no `label` it is a bare box for a table cell, named by `aria-label`; `description` is a muted line under the label, the box's `aria-describedby`). `Switch` (an on/off setting that acts at once, usually by saving to a server: a native `<button role="switch" aria-checked>`, controlled only, with `checked` and `onChange(next)`; give it a `label` (the whole label is the click target) or, with none, an `aria-label`: one is required; `description` is a muted line under the label and its `aria-describedby`; `size` is `sm`, `md` (default) or `lg`; `busy` is the save in flight: `aria-busy`, a click or key does nothing, it keeps its colour and its focus; `disabled` is the native one, washed out; a value sent with a form stays a `Checkbox`). `RadioGroup` and `Radio` (one choice from a few options: the group has a `label`, `value`/`onChange` or `defaultValue`, `name`, `isDisabled`, `isRequired`, `size` (`sm`, `md` default, `lg`, as `Checkbox`) and an `error` shown like `Field`'s with `role="alert"`; each `Radio` has a `value`, a `label`, an optional `description` and `isDisabled`; one tab stop, the arrow keys move and choose). `Disclosure` (a summary button with a chevron and `aria-expanded` over a body that opens and closes; `summary`, `children`, `defaultOpen`; it keeps its own state and replaces `<details>`). `FileDrop` (a dashed drop zone, a `<label>` around a file input that covers it, so a click opens the dialog and a dropped file lands on the input; `children` is the prompt, `accept`, `multiple`, `name`, `disabled` and `onChange` on the input). `CopyButton` (a small text button that writes `text` to the clipboard and swaps `label` ("copy") for `copiedLabel` ("copied"); every other button prop, `disabled` included, goes on the `<button>`). `CopyLine` (one line of mono text in a box with a `CopyButton` beside it; `disabled` disables the button; other props go on the root `<div>`). `Field` (a label above a control, `htmlFor` names the control's id; `hideLabel` keeps the label for assistive tech and draws no text, for a control whose purpose is plain from what surrounds it, and makes `layout="row"` a column; a `hint` sits under it, and an `error` replaces the hint with a `role="alert"` line; `span="full"` runs it across both columns of a `FormGrid`). `FormGrid` (two columns of `Field`s from `md`, one below it), `FormRow` (an inline-edit row: `FormRowField`s side by side, an `action` after them, and one `hint` or `error` under the whole row; see Layout) and `FormRowField` (a compact label over one control, `span` 1 to 4). `Callout` (a tinted notice box; `tone` is `neutral`, `info` (default), `positive`, `warn` or `danger`; an optional `title` sits in bold above the body; `actions` puts a button or link at the end of the row; `banner` makes it a flat strip, bottom border only and no radius, for the top of a `Card flush`). `ListRow` (a full-width row that is a `<button>`, or a link with `href` or `render`, with a hover fill, a left-aligned label and `trailing` content; `divided` draws a hairline above the row, to separate rows in a `Card flush`; `expanded` makes it the header of a collapsible section: `aria-expanded` and a chevron, the caller owning the state and the section; `leading` is content before the label (an `Avatar`), and `leadingWidth="thumbnail"` gives it a fixed width so rows with and without a picture line up; `size` sets the padding (`sm` dense, `md` default, `lg`); `focused` draws the focus ring on the row the keyboard cursor is on, for a list moved with the arrow keys while focus stays on the list; `selected` marks the row in view with a violet fill and `aria-current="true"`; a link row (`href` or `render`) can hold its own link, button or control in `trailing`, which sits above the row's link and takes its own click; `static` makes it a plain `<div>`, for a row that holds its own buttons where the row would otherwise be a `<button>`, and adds `actions`, buttons after `trailing`, and `revealOnHover`, which shows them only on hover or focus inside the row and always on a touch screen; a compact bordered list is `Card flush` around `static` rows at `size="sm"`, `divided` after the first (no separate part); `active` marks the row the pointer or the keyboard is on, with the hover fill and the `revealOnHover` actions shown, for a list that follows both through the row's `onPointerEnter`, `onPointerLeave` and `onFocus`, which every row passes through). `Tabs` (underline tabs for switching a view in place: a row over a bottom rule, the current tab underlined in the accent; `items` are `{ value, label }` buttons, with `value` the current one's `value` and `onChange(value)` the caller's state, one tab stop and the arrow keys, Home and End moving between enabled tabs, or `{ label, href | render, active?, external? }` links in a `<nav>` with `aria-current="page"` on the current one (`active`, or the item whose `value` is `value`); `label` names the group; a button tab takes `disabled` and `controls`, the id of its `role="tabpanel"`, which is the caller's; `variant="pill"` fills the current tab, with no rule, for `PageHeader`'s `tabs` slot, an `AppShell` `nav` or a rail; only a pill row takes `size` and `orientation`, and `orientation="vertical"` stacks it in one column, the content of a `SidebarLayout` rail; `disabled` on the row disables every tab, e.g. until hydration). `SegmentedControl` (a joined pill group, one option filled; the same `items`, `value`, `onChange`, `disabled` and `label` as `Tabs`, as a `radiogroup` with `aria-checked` on each `radio`). On both an item takes `data-*` attributes (typed `data-${string}`) onto its button or link; on `Tabs` and `SegmentedControl` an item takes `count` (a small muted number after the label, part of its name: "Shots 12") and `title`; a `disabled` button option stays in the page as `aria-disabled`, so its `title` is still the hover tooltip and is announced as the reason, it is out of the tab order and the arrow keys, Home and End skip it. `Dialog` (a modal on the browser's `<dialog>`, opened on mount, so render it only while open: `title` is its name, `description` a line under it, `footer` the right-aligned actions; Esc, the backdrop or a `<form method="dialog">` call `onClose`; `size` is `sm` (320px, a list), `md` (440px, default, a form), `lg` (640px, a wider form or a table) or `full` (the whole screen, for media or a player: the body fills the height between the title and the footer and scrolls if taller; there is no backdrop to click, so put a close button on a `<form method="dialog">` in the `footer`)). `FieldDialog` (a `Dialog` with one text field and Cancel and submit buttons, every string passed in: `title`, `description`, `label`, `inputLabel`, `placeholder`, `hint`, `emptyReason`, `submit` and `busyLabel`; `onSubmit` gets the trimmed value and a resolved promise closes it, a rejection shows its message). `Drawer` (a modal slide-over on the right edge, on a native `<dialog>` opened on mount like `Dialog`, so render it only while open: `title` is its name, a close button sits beside it, `children` scroll between the header and an optional pinned `footer`; Esc, the scrim and the close button call `onClose`; `size` is `sm`, `md` (default) or `lg`; `closeLabel` names the close button). `Breadcrumbs` (a `<nav aria-label="Breadcrumb">` over an ordered list; `crumbs` are nodes, or `{ label, href | render, external? }` for one that links; the last is the current page and is never a link and gets `aria-current="page"`, and each crumb truncates). `Table` (a list of records in a rounded, bordered card around a full-width `<table>` that scrolls sideways on a narrow screen; `caption` names it for assistive tech and is not drawn; compose it from `TableHead`, `TableBody`, `TableFoot`, `TableRow`, `TableHeaderCell` (`align` `start`, `center` or `end`; `srLabel` is a visually hidden column name, in place of children, for a column of row actions: "Actions") and `TableCell` (`align`, `indent` of 1 to 3 to set a start-aligned cell in, for a line under a parent row, `tone` `default`, `strong` or `muted`, or the Callout tones `neutral`, `info`, `positive`, `warn` or `danger` for an amount that means something, in the tone's text colour and with no fill, so pair the colour with a sign or a word; `numeric` for tabular figures, `mono` for a small mono face, `truncate` to cap a long value's width, cut it with an ellipsis and put the full text in a `title`, so its content is text (strings and numbers); `secondary` for a second, muted line under the content; `colSpan` passes through, so a `TableCell` with `colSpan` in a row of its own is a full-width panel, an empty state or a note, centred with `align="center"`); `TableEditCell` (a `TableCell` whose value opens an editor in place: `children` is the value, drawn as a button; a click, Enter or Space swaps it for `renderEditor(close)`, a plain control in the cell and not a popover of its own, and moves the focus into it; `close` and Esc put the focus back on the value, and a press outside or Tab out of it closes it too, so the control saves on its own change or submit; `label` names the button and the editor; it takes the `TableCell` props except `truncate`); `InlineEdit` (the same edit trigger without the cell, for a `TableCell` that holds other content too: `label`, `renderEditor(close)` and `children`, with the same keyboard and focus behaviour); `TableFoot` (the `<tfoot>` for totals: its cells are strong by default and each row has a firmer line above it, in a card and in a `plain` table alike); `TableGroupRow` (a full-width group header in the body: `label` spans `colSpan` columns, the table's column count, which is required; optional `trailing` content sits at the far end, and the group's rows follow it); `tone` on a `TableRow` in the body marks the row with a stripe on its leading edge over a soft fill: `neutral`, `info`, `positive`, `warn` or `danger`, as `Callout`, or `muted`, which only fades the row (a transfer, a row that is not the point); a bold subtotal is not a tone: `emphasis` on a `TableRow` in the body gives it a `TableFoot` row's look among the other rows; `stripe` (a text tone) draws the leading stripe alone, with no fill; a row takes one of `tone`, `stripe` and `emphasis`; `variant="plain"` on `Table` drops the card border, fill and shadow, the head strip and the row hover and keeps the row dividers, for a table inside a card of its own; every table element passes `id`, `data-*` and `aria-*` through, and none takes a `className` or `style`). `Timeline` (a named `<ol>` of events; `label` is its `aria-label`, `empty` is drawn instead when there are no items) with `TimelineItem` (a `title`, an already-formatted `time`, the `children` as a note, and `tone`, a Callout tone for the marker dot, `neutral` by default). `KeyValueList` (a `<dl>` of label/value rows; each `KeyValue` takes a `label` and its value as children, the value right-aligned in tabular figures; `total` makes it the sum row, a firm rule above and strong text, and `tone` (`muted`, `positive`, `warn`, `danger`, `info`) colours the value on any row, so `total tone="warn"` is a total that does not reconcile; `align="start"` puts the values left-aligned in one column beside the labels instead, using CSS subgrid: in Chrome before 117 a row stacks its label over its value). `Avatar` (a round badge of the initials of `name`, titled with the name and `aria-hidden`; with `src` (the app's own local image path, never a remote one) it is a round, cropped `<img alt={name}>` instead, and if that image fails to load it shows the initials; `size` is `xs` (16px, for an inline mention), `sm`, `md` (default), `lg`, `xl` (56px), `2xl` (64px) or `3xl` (96px); `onClick` makes it one button named by `name`; `ring` adds a page-colour border and a negative margin for an overlapping stack; the colour is one of seven stock background classes picked by a hash of the name, remapped by each theme, and `avatarColourClass(name)` returns it to colour something else the same way; `initialsOf(name)` is the two letters). `TextLink` (an inline text link in the accent colour; `href` is required unless `render` draws the app's own router `<Link>`, `external` adds `target="_blank"` and `rel="noreferrer"`, `stretch` covers the nearest `relative` ancestor so a whole row or card is the click target, `size` (`sm`, `md` or `lg`) pins the type size where it would otherwise follow the text around it, `mono` sets the monospace face, `tone` (`warn`, `danger`, `positive`, `info` or `muted`) gives it a meaning's colour in place of the accent, every other anchor prop passes through). `MetaList` (a wrapping row of facts or links in muted small text, joined by an `aria-hidden` `separator`: `·` (default), `•` or `none`; branch · SHA · date, or a footer's Privacy · Terms; an item is text, a `Code` or a `TextLink`; `nowrap` keeps each item on one line, for links; `mono` sets the row in the monospace face, for ids and paths). `PageFade` (the page's content area, named for the View Transitions API so a route change cross-fades it while the frame around it stays put; wrap what changes between pages, once per page, or give each a `name` (a CSS identifier) when there are two; it fades when the navigation runs in a view transition, through `startPageTransition(update)` (flushes a synchronous state change inside `document.startViewTransition`) or a router's own option such as TanStack Router `viewTransition`, and otherwise, in a browser without View Transitions, or under `prefers-reduced-motion: reduce`, it swaps with no fade; it renders a small `<style>`, with no colour in it, and the div is a stacking context, so portal an overlay that must rise above the frame). `EmptyState` (what a page or list shows when it has nothing yet: a centred `title`, the `children` as the explanation under it, and `actions` in a centred row; a named `<section>`; `level` is the title's heading level, 2 (default) to 6, and does not change its look; for one muted line inside a card, `Text as="p" color="muted"`). `Steps` (a numbered `<ol>` of `Step`s, each a `title` and the `children` as a detail line, the numbers counted by CSS in a violet disc; for a "get started" list, not for progress). `Divider` (a hairline `<hr>` in the theme's line colour; spacing comes from the layout around it). `BulletList` (a `<ul>` with discs, an indent and a small gap; children are `<li>`s). `Code` (inline code: mono, on a subtle background). `CodeBlock` (a block of code, no client JS: the text is `children` (a string) or `lines` (one entry per line), `language` (`text` (default, one colour), `tsx`, `ts`, `js`, `json`, `bash`, `console`, `css`, `html` or `yaml`) colours it, `console` being a shell session where a `$ ` line is a command and the rest is output, and `copy` then copies only the commands; `label` is a caption naming the language or file; long lines wrap by default, so nothing is cut off and a command copies as one line, and `overflow="scroll"` keeps lines whole and scrolls them under a thin scrollbar in the theme's muted ink; `copy` adds a copy button beside the code, which copies it exactly as given and needs the page hydrated; other props go on the root `<figure>`). `CodeExample` (one framed example: the rendered `children` over the `code` that makes them, coloured as TSX, in one bordered box, with an optional `caption` as its last band; a wide preview or long code scrolls inside the frame under the same thin scrollbar; `copy` adds a button that copies the code; no client JS without `copy`). `Video` (a full-width, rounded `<video>` with controls that loads metadata only; `src` is required, `poster` and a WebVTT `captions` track are optional, children are the fallback). `Kbd` (a keyboard key in mono; its border takes the surrounding text colour, so it reads on a filled button too). `FilterGroup` (a `<fieldset>` with a small uppercase `legend` over a wrapped row of its children, e.g. `outline` `sm` `Button`s with `pressed`). `DateField` (see Dates).
84
+ - Controls: `Button` (`variant` is `primary` (default), `outline`, `ghost`, `link` or `dashed`, the link having no box: text with an underline on hover, for an in-line action, and the dashed one a dashed outline in muted text that fills its container, like `Card dashed`, for an "add another" row, with a leading icon as a child, ignoring `tone`; `size="sm"` is compact, `text-xs`, for table rows and toolbars, `size="lg"` is `text-base` with more padding; `tone="danger"` is red, for delete and clear, and `tone="positive"` green, for accept and approve; `disabledReason` disables it, greys it flat and makes the reason its `title`; `busy` disables it with `aria-busy` and keeps its colour, showing `busyLabel` in place of the children; `pressed` makes it a toggle with `aria-pressed`, on `outline` and `ghost` neutral when off and filled in the tone's primary colour when on, so an `outline` `size="sm"` Button with `pressed` is a filter chip; `count` puts a small number chip after the label, part of the button's name ("Splits, 3"), and a button that opens something also takes `aria-expanded`; with `href`, or `render` to draw the app's own router `<Link>`, it is a link with the same look, `external` opening a new tab), `ButtonGroup` (a wrapping row of buttons; `end` is one item pushed to the far side, `label` makes it a named `role="group"`), `IconButton` (a square button holding one icon; `label` is required and becomes its `aria-label` and `title`; `variant` is `outline` (default) or `ghost`, `tone` is `default` or `danger`, `size` matches a `Button`'s height; `badge` puts a red count on its corner, not shown at 0; with `href` or `render` it is a link, `external` opening a new tab), `Icon` (one bare `<svg>` sized to fill a box, or `name` for one of the set's own icons (`diff`, `baseline`, `review`, `ci`, `self-host`, `privacy`, `capture`, `branch`, `theme`, `speed`, `search`, `chevron-right`, `chevron-down`, `close`, `external-link`, `settings`, `bell`, `check`, `plus`, `grid`, `window`, `type`, `card`, `pointer`, `field`, `table`, `compass`, `page`, `briefcase`: feature ideas and UI glyphs on one 24 grid, a 1.5 stroke, round caps and joins, each the idea or action, not a product): `size` `sm` 14px, `md` 16px (default), `lg` 20px or `feature` 32px; `tone` is a text tone or `inherit` (default, the text colour around it); `aria-hidden` unless `label` names it as `role="img"`), `ColourSchemeToggle` (a light/dark switch on `<html>`; `id`, `data-*` and other button props go on the button, `store={false}` skips `localStorage`; see Light and dark), `Input` (a text input; `icon` adds a leading icon inside the field; `prefix` is short text inside the border before the value, a currency symbol, on a plain field only; `type="search"` drops the browser's search look and shows a "Clear search" button while the field has a value), `Textarea` (a multi-line text input, `rows` defaults to 3), `Select` (a native `<select>` in the control look; takes `<option>` children and keeps the browser's arrow). `Input`, `Select` and `Textarea` take `size` (`md` default, `sm`: `text-xs`, less padding, for dense forms and table rows, or `lg`: `text-base`, more padding) and `width` (`full` default, the fixed `xs` 5rem, `sm` 7rem, `md` 9rem, `lg` 12rem, `fill`: grows to the room left in a flex row and can shrink to nothing, so the buttons beside it stay on the line, or `auto`: no width class, as wide as the browser makes it); a fixed width replaces `w-full`, so there is no class to collide with. `Input` also takes `align` (`start` default, or `end` to right-align a numeric value). `size` replaces the native `size` attribute, so a multi-row `<select size={4}>` is not reachable through `Select`. `Checkbox` (a real `<input type="checkbox">` with its label, drawn by the library as an empty outlined box and a filled violet one with a check mark, so it reads the same in every theme and browser; other props go on the input; `size="sm"` is `text-xs` with a smaller box and `size="lg"` `text-base` with a larger one, `align="start"` puts the box on the first line of a label that wraps, where the default `center` centres it on all of them, and with no `label` it is a bare box for a table cell, named by `aria-label`; `description` is a muted line under the label, the box's `aria-describedby`). `Switch` (an on/off setting that acts at once, usually by saving to a server: a native `<button role="switch" aria-checked>`, controlled only, with `checked` and `onChange(next)`; give it a `label` (the whole label is the click target) or, with none, an `aria-label`: one is required; `description` is a muted line under the label and its `aria-describedby`; `size` is `sm`, `md` (default) or `lg`; `busy` is the save in flight: `aria-busy`, a click or key does nothing, it keeps its colour and its focus; `disabled` is the native one, washed out; a value sent with a form stays a `Checkbox`). `RadioGroup` and `Radio` (one choice from a few options: the group has a `label`, `value`/`onChange` or `defaultValue`, `name`, `isDisabled`, `isRequired`, `size` (`sm`, `md` default, `lg`, as `Checkbox`) and an `error` shown like `Field`'s with `role="alert"`; each `Radio` has a `value`, a `label`, an optional `description` and `isDisabled`; one tab stop, the arrow keys move and choose). `Disclosure` (a summary button with a chevron and `aria-expanded` over a body that opens and closes; `summary`, `children`, `defaultOpen`; it keeps its own state and replaces `<details>`). `FileDrop` (a dashed drop zone, a `<label>` around a file input that covers it, so a click opens the dialog and a dropped file lands on the input; `children` is the prompt, `accept`, `multiple`, `name`, `disabled` and `onChange` on the input). `CopyButton` (a small text button that writes `text` to the clipboard and shows `copiedLabel` ("copied") in place of `label` ("copy") for two seconds, by a CSS animation, not a timer; its accessible name stays `label` and a status region beside it announces `copiedLabel` on each copy; every other button prop, `disabled` included, goes on the `<button>`). `CopyLine` (one line of mono text in a box with a `CopyButton` beside it; `disabled` disables the button; other props go on the root `<div>`). `Field` (a label above a control, `htmlFor` names the control's id; `hideLabel` keeps the label for assistive tech and draws no text, for a control whose purpose is plain from what surrounds it, and makes `layout="row"` a column; a `hint` sits under it, and an `error` replaces the hint with a `role="alert"` line; `span="full"` runs it across both columns of a `FormGrid`). `FormGrid` (two columns of `Field`s from `md`, one below it), `FormRow` (an inline-edit row: `FormRowField`s side by side, an `action` after them, and one `hint` or `error` under the whole row; see Layout) and `FormRowField` (a compact label over one control, `span` 1 to 4). `Callout` (a tinted notice box; `tone` is `neutral`, `info` (default), `positive`, `warn` or `danger`; an optional `title` sits in bold above the body; `actions` puts a button or link at the end of the row; `banner` makes it a flat strip, bottom border only and no radius, for the top of a `Card flush`). `ListRow` (a full-width row that is a `<button>`, or a link with `href` or `render`, with a hover fill, a left-aligned label and `trailing` content; `divided` draws a hairline above the row, to separate rows in a `Card flush`; `expanded` makes it the header of a collapsible section: `aria-expanded` and a chevron, the caller owning the state and the section; `leading` is content before the label (an `Avatar`), and `leadingWidth="thumbnail"` gives it a fixed width so rows with and without a picture line up; `size` sets the padding (`sm` dense, `md` default, `lg`); `focused` draws the focus ring on the row the keyboard cursor is on, for a list moved with the arrow keys while focus stays on the list; `selected` marks the row in view with a violet fill and `aria-current="true"`; a link row (`href` or `render`) can hold its own link, button or control in `trailing`, which sits above the row's link and takes its own click; `static` makes it a plain `<div>`, for a row that holds its own buttons where the row would otherwise be a `<button>`, and adds `actions`, buttons after `trailing`, and `revealOnHover`, which shows them only on hover or focus inside the row and always on a touch screen; a compact bordered list is `Card flush` around `static` rows at `size="sm"`, `divided` after the first (no separate part); `active` marks the row the pointer or the keyboard is on, with the hover fill and the `revealOnHover` actions shown, for a list that follows both through the row's `onPointerEnter`, `onPointerLeave` and `onFocus`, which every row passes through). `Tabs` (underline tabs for switching a view in place: a row over a bottom rule, the current tab underlined in the accent; `items` are `{ value, label }` buttons, with `value` the current one's `value` and `onChange(value)` the caller's state, one tab stop and the arrow keys, Home and End moving between enabled tabs, or `{ label, href | render, active?, external? }` links in a `<nav>` with `aria-current="page"` on the current one (`active`, or the item whose `value` is `value`); `label` names the group; a button tab takes `disabled` and `controls`, the id of its `role="tabpanel"`, which is the caller's; `variant="pill"` fills the current tab, with no rule, for `PageHeader`'s `tabs` slot, an `AppShell` `nav` or a rail; only a pill row takes `size` and `orientation`, and `orientation="vertical"` stacks it in one column, the content of a `SidebarLayout` rail; `disabled` on the row disables every tab, e.g. until hydration). `SegmentedControl` (a joined pill group, one option filled; the same `items`, `value`, `onChange`, `disabled` and `label` as `Tabs`, as a `radiogroup` with `aria-checked` on each `radio`). On both an item takes `data-*` attributes (typed `data-${string}`) onto its button or link; on `Tabs` and `SegmentedControl` an item takes `count` (a small muted number after the label, part of its name: "Shots 12") and `title`; a `disabled` button option stays in the page as `aria-disabled`, so its `title` is still the hover tooltip and is announced as the reason, it is out of the tab order and the arrow keys, Home and End skip it. `Dialog` (a modal on the browser's `<dialog>`, opened on mount, so render it only while open: `title` is its name, `description` a line under it, `footer` the right-aligned actions; Esc, the backdrop or a `<form method="dialog">` call `onClose`; `size` is `sm` (320px, a list), `md` (440px, default, a form), `lg` (640px, a wider form or a table) or `full` (the whole screen, for media or a player: the body fills the height between the title and the footer and scrolls if taller; there is no backdrop to click, so put a close button on a `<form method="dialog">` in the `footer`)). `FieldDialog` (a `Dialog` with one text field and Cancel and submit buttons, every string passed in: `title`, `description`, `label`, `inputLabel`, `placeholder`, `hint`, `emptyReason`, `submit` and `busyLabel`; `onSubmit` gets the trimmed value and a resolved promise closes it, a rejection shows its message). `Drawer` (a modal slide-over on the right edge, on a native `<dialog>` opened on mount like `Dialog`, so render it only while open: `title` is its name, a close button sits beside it, `children` scroll between the header and an optional pinned `footer`; Esc, the scrim and the close button call `onClose`; `size` is `sm`, `md` (default) or `lg`; `closeLabel` names the close button). `Breadcrumbs` (a `<nav aria-label="Breadcrumb">` over an ordered list; `crumbs` are nodes, or `{ label, href | render, external? }` for one that links; the last is the current page and is never a link and gets `aria-current="page"`, and each crumb truncates). `Table` (a list of records in a rounded, bordered card around a full-width `<table>` that scrolls sideways on a narrow screen; `caption` names it for assistive tech and is not drawn; compose it from `TableHead`, `TableBody`, `TableFoot`, `TableRow`, `TableHeaderCell` (`align` `start`, `center` or `end`; `srLabel` is a visually hidden column name, in place of children, for a column of row actions: "Actions") and `TableCell` (`align`, `indent` of 1 to 3 to set a start-aligned cell in, for a line under a parent row, `tone` `default`, `strong` or `muted`, or the Callout tones `neutral`, `info`, `positive`, `warn` or `danger` for an amount that means something, in the tone's text colour and with no fill, so pair the colour with a sign or a word; `numeric` for tabular figures, `mono` for a small mono face, `truncate` to cap a long value's width, cut it with an ellipsis and put the full text in a `title`, so its content is text (strings and numbers); `secondary` for a second, muted line under the content; `colSpan` passes through, so a `TableCell` with `colSpan` in a row of its own is a full-width panel, an empty state or a note, centred with `align="center"`); `TableEditCell` (a `TableCell` whose value opens an editor in place: `children` is the value, drawn as a button; a click, Enter or Space swaps it for `renderEditor(close)`, a plain control in the cell and not a popover of its own, and moves the focus into it; `close` and Esc put the focus back on the value, and a press outside or Tab out of it closes it too, so the control saves on its own change or submit; `label` names the button and the editor; it takes the `TableCell` props except `truncate`); `InlineEdit` (the same edit trigger without the cell, for a `TableCell` that holds other content too: `label`, `renderEditor(close)` and `children`, with the same keyboard and focus behaviour); `TableFoot` (the `<tfoot>` for totals: its cells are strong by default and each row has a firmer line above it, in a card and in a `plain` table alike); `TableGroupRow` (a full-width group header in the body: `label` spans `colSpan` columns, the table's column count, which is required; optional `trailing` content sits at the far end, and the group's rows follow it); `tone` on a `TableRow` in the body marks the row with a stripe on its leading edge over a soft fill: `neutral`, `info`, `positive`, `warn` or `danger`, as `Callout`, or `muted`, which only fades the row (a transfer, a row that is not the point); a bold subtotal is not a tone: `emphasis` on a `TableRow` in the body gives it a `TableFoot` row's look among the other rows; `stripe` (a text tone) draws the leading stripe alone, with no fill; a row takes one of `tone`, `stripe` and `emphasis`; `variant="plain"` on `Table` drops the card border, fill and shadow, the head strip and the row hover and keeps the row dividers, for a table inside a card of its own; every table element passes `id`, `data-*` and `aria-*` through, and none takes a `className` or `style`). `Timeline` (a named `<ol>` of events; `label` is its `aria-label`, `empty` is drawn instead when there are no items) with `TimelineItem` (a `title`, an already-formatted `time`, the `children` as a note, and `tone`, a Callout tone for the marker dot, `neutral` by default). `KeyValueList` (a `<dl>` of label/value rows; each `KeyValue` takes a `label` and its value as children, the value right-aligned in tabular figures; `total` makes it the sum row, a firm rule above and strong text, and `tone` (`muted`, `positive`, `warn`, `danger`, `info`) colours the value on any row, so `total tone="warn"` is a total that does not reconcile; `align="start"` puts the values left-aligned in one column beside the labels instead, using CSS subgrid: in Chrome before 117 a row stacks its label over its value). `Avatar` (a round badge of the initials of `name`, titled with the name and `aria-hidden`; with `src` (the app's own local image path, never a remote one) it is a round, cropped `<img alt={name}>` instead, and if that image fails to load it shows the initials; `size` is `xs` (16px, for an inline mention), `sm`, `md` (default), `lg`, `xl` (56px), `2xl` (64px) or `3xl` (96px); `onClick` makes it one button named by `name`; `ring` adds a page-colour border and a negative margin for an overlapping stack; the colour is one of seven stock background classes picked by a hash of the name, remapped by each theme, and `avatarColourClass(name)` returns it to colour something else the same way; `initialsOf(name)` is the two letters). `TextLink` (an inline text link in the accent colour; `href` is required unless `render` draws the app's own router `<Link>`, `external` adds `target="_blank"` and `rel="noreferrer"`, `stretch` covers the nearest `relative` ancestor so a whole row or card is the click target, `size` (`sm`, `md` or `lg`) pins the type size where it would otherwise follow the text around it, `mono` sets the monospace face, `tone` (`warn`, `danger`, `positive`, `info` or `muted`) gives it a meaning's colour in place of the accent, every other anchor prop passes through). `MetaList` (a wrapping row of facts or links in muted small text, joined by an `aria-hidden` `separator`: `·` (default), `•` or `none`; branch · SHA · date, or a footer's Privacy · Terms; an item is text, a `Code` or a `TextLink`; `nowrap` keeps each item on one line, for links; `mono` sets the row in the monospace face, for ids and paths). `PageFade` (the page's content area, named for the View Transitions API so a route change cross-fades it while the frame around it stays put; wrap what changes between pages, once per page, or give each a `name` (a CSS identifier) when there are two; it fades when the navigation runs in a view transition, through `startPageTransition(update)` (flushes a synchronous state change inside `document.startViewTransition`) or a router's own option such as TanStack Router `viewTransition`, and otherwise, in a browser without View Transitions, or under `prefers-reduced-motion: reduce`, it swaps with no fade; it renders a small `<style>`, with no colour in it, and the div is a stacking context, so portal an overlay that must rise above the frame). `NavigationProgress` (a thin indeterminate accent bar fixed along the top edge of the viewport, over the header, for a route change under way; `active` is the router's pending flag (TanStack Router: `useRouterState({ select: (s) => s.status === "pending" })`), `label` its accessible name (default "Loading page"); it draws the bar while `active` is true and nothing otherwise, with the bar invisible for its first 150ms so a fast navigation shows nothing; render it once per app, beside the page frame, and pair it with `PageFade`; a still bar under `prefers-reduced-motion: reduce`; it adds nothing to the layout and has no router dependency). `EmptyState` (what a page or list shows when it has nothing yet: a centred `title`, the `children` as the explanation under it, and `actions` in a centred row; a named `<section>`; `level` is the title's heading level, 2 (default) to 6, and does not change its look; for one muted line inside a card, `Text as="p" color="muted"`). `Steps` (a numbered `<ol>` of `Step`s, each a `title` and the `children` as a detail line, the numbers counted by CSS in a violet disc; for a "get started" list, not for progress). `Divider` (a hairline `<hr>` in the theme's line colour; spacing comes from the layout around it). `BulletList` (a `<ul>` with discs, an indent and a small gap; children are `<li>`s). `Code` (inline code: mono, on a subtle background). `CodeBlock` (a block of code, no client JS: the text is `children` (a string) or `lines` (one entry per line), `language` (`text` (default, one colour), `tsx`, `ts`, `js`, `json`, `bash`, `console`, `css`, `html` or `yaml`) colours it, `console` being a shell session where a `$ ` line is a command and the rest is output, and `copy` then copies only the commands; `label` is a caption naming the language or file; long lines wrap by default, so nothing is cut off and a command copies as one line, and `overflow="scroll"` keeps lines whole and scrolls them under a thin scrollbar in the theme's muted ink; `copy` adds a copy button beside the code, which copies it exactly as given and needs the page hydrated; other props go on the root `<figure>`). `CodeExample` (one framed example: the rendered `children` over the `code` that makes them, coloured as TSX, in one bordered box, with an optional `caption` as its last band; a wide preview or long code scrolls inside the frame under the same thin scrollbar; `copy` adds a button that copies the code; no client JS without `copy`). `Video` (a full-width, rounded `<video>` with controls that loads metadata only; `src` is required, `poster` and a WebVTT `captions` track are optional, children are the fallback). `Kbd` (a keyboard key in mono; its border takes the surrounding text colour, so it reads on a filled button too). `FilterGroup` (a `<fieldset>` with a small uppercase `legend` over a wrapped row of its children, e.g. `outline` `sm` `Button`s with `pressed`). `DateField` (see Dates).
85
85
  - Dates: `toISODate`, `fromISODate`, and the `CalendarDate` type.
86
86
 
87
87
  ## Dates
@@ -207,9 +207,18 @@ import { AppShell, PageHeader, Section, Stack, Tabs, appShellRootClassFor } from
207
207
  - **`Container`** is the only place the content width (`max-w-6xl`) and side padding
208
208
  (`px-4 sm:px-6`) are defined. **Pages never set their own width or outer padding**: no `max-w-*`,
209
209
  no `mx-auto`, no `px-*` on a page. AppShell already gives them the edge.
210
- - **`PageHeader`** is the top of every page: optional `breadcrumbs`, the `title` (a `PageHeading`),
210
+ - **`PageHeader`** is the top of every page but a landing page (that one opens with a `Hero`): optional `breadcrumbs`, the `title` (a `PageHeading`),
211
211
  `subtitle`, `actions` (right of the title from `sm`, under it on a phone) and `tabs` under it. Its
212
212
  fixed bottom margin puts every page's first block at the same y.
213
+ - **`Hero`** is a landing page's top block, once per page and only there; every other page opens
214
+ with a `PageHeader`. It takes an `overline` (the small mono label), the `title` (the page's `h1`,
215
+ at `Display` size) and `children`: a `Lead`, and the page's first actions in a `Cluster` if it has
216
+ any (a primary `Button` and an `outline` or `ghost` one in the default tone, or a `TextLink`: the
217
+ text each theme's contrast check holds over the glow). Behind the text drift three soft ribbons of the theme's glow, in its own colours and strength
218
+ (faint on a paper theme), anchored to the text column and clipped to it, so a phone never scrolls
219
+ sideways. The glow is `aria-hidden`, stands still under `prefers-reduced-motion: reduce`, and is
220
+ markup plus one small `<style>` with no colour in it, so it needs no script. It takes no colour,
221
+ size or styling prop.
213
222
  - **`Stack`** spaces blocks in a column: `gap="xs"` (`gap-2`) between lines of one text block (a
214
223
  heading, the line under it), `sm` (`gap-4`) inside a group, `md` (`gap-6`, the default) between
215
224
  blocks, `lg` (`gap-8`) between groups, `xl` (`gap-12`, `sm:gap-16`) between a page's top-level blocks. Use it instead of `space-y-*`. `width` caps the column:
@@ -320,6 +329,15 @@ except the last four.
320
329
  </AppShell>
321
330
  ```
322
331
 
332
+ ## Copy on a static page
333
+
334
+ `CopyButton` copies from a React click handler, so on a page rendered to static HTML and never
335
+ hydrated (an Astro page whose body is not an island) it draws and does nothing. Inline `copyScript`
336
+ once, as you do `colourSchemeScript`, and every copy button works there, `CopyLine` and a `CodeBlock`
337
+ or `CodeExample` with `copy` included. It is one click listener on `document`; in Astro, use
338
+ `<script is:inline set:html={copyScript} />`. A hydrated button and the script share each click and
339
+ copy it once between them, so the script is safe on a hydrated page too.
340
+
323
341
  ## Light and dark
324
342
 
325
343
  A dark theme does its colour work under `.dark`; this only decides whether `.dark` is on `<html>`.
@@ -425,7 +443,7 @@ A theme remaps every utility in `palette.json`; a component uses no colour utili
425
443
 
426
444
  ## Develop
427
445
 
428
- From the workspace root: `bunx nx run-many -t typecheck lint test build -p ui-react` and
429
- `bunx nx pack-check ui-react` (the tarball is `dist/` plus `package.json`, `README.md`, `LICENSE`; it also typechecks the tarball in a
446
+ From the workspace root: `bunx moon run ui-react:typecheck ui-react:lint ui-react:test ui-react:build` and
447
+ `bunx moon run ui-react:pack-check` (the tarball is `dist/` plus `package.json`, `README.md`, `LICENSE`; it also typechecks the tarball in a
430
448
  consumer app: `scripts/consumer-types.ts`).
431
449
  The stories live in `apps/storybook`.
@@ -13,7 +13,7 @@ type CodeBlockSource = {
13
13
  };
14
14
  /**
15
15
  * A block of code: mono, in a bordered inset box. No client JS unless `copy` adds a copy button
16
- * beside the code, which copies it exactly as given and needs the page hydrated. `language` colours
16
+ * beside the code, which copies it exactly as given. `language` colours
17
17
  * it: `tsx`, `ts`, `js`, `json`, `bash`, `console`, `css`, `html` or `yaml`. `console` is a shell
18
18
  * session: a line starting with `$ ` is a command (coloured as bash, its `$ ` dimmed and not
19
19
  * selectable) and any other line is output; with `copy` only the commands are copied, without the
@@ -37,7 +37,7 @@ export declare function CodeBlock({ label, language, overflow, copy, children, l
37
37
  overflow?: "wrap" | "scroll";
38
38
  /**
39
39
  * A copy button beside the code, which copies it exactly as given (a `console` block copies only
40
- * its commands). It is a client component: leave it off on a page that is rendered to static HTML without hydration.
40
+ * its commands). On a page rendered to static HTML without hydration, inline `copyScript` and it works there too.
41
41
  * @default false
42
42
  */
43
43
  copy?: boolean;
package/dist/CodeBlock.js CHANGED
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { CodeBody } from "./CodeBody.js";
3
3
  /**
4
4
  * A block of code: mono, in a bordered inset box. No client JS unless `copy` adds a copy button
5
- * beside the code, which copies it exactly as given and needs the page hydrated. `language` colours
5
+ * beside the code, which copies it exactly as given. `language` colours
6
6
  * it: `tsx`, `ts`, `js`, `json`, `bash`, `console`, `css`, `html` or `yaml`. `console` is a shell
7
7
  * session: a line starting with `$ ` is a command (coloured as bash, its `$ ` dimmed and not
8
8
  * selectable) and any other line is output; with `copy` only the commands are copied, without the
@@ -14,5 +14,5 @@ import { CodeBody } from "./CodeBody.js";
14
14
  * smaller box.
15
15
  */
16
16
  export function CodeBlock({ label, language = "text", overflow = "wrap", copy = false, children, lines, ...rest }) {
17
- return (_jsxs("figure", { ...rest, className: "overflow-hidden rounded-lg border border-slate-200 bg-slate-50 font-mono text-[12.5px] text-slate-900", children: [label && (_jsx("figcaption", { className: "border-b border-slate-200 px-3 py-1.5 text-xs text-slate-500", children: label })), _jsx(CodeBody, { code: lines ? lines.join("\n") : children, language: language, wrap: overflow === "wrap", copy: copy })] }));
17
+ return (_jsxs("figure", { ...rest, className: "overflow-hidden rounded-lg border border-slate-200 bg-slate-50 font-mono text-[12.5px] leading-normal text-slate-900", children: [label && (_jsx("figcaption", { className: "border-b border-slate-200 px-3 py-1.5 text-xs text-slate-500", children: label })), _jsx(CodeBody, { code: lines ? lines.join("\n") : children, language: language, wrap: overflow === "wrap", copy: copy })] }));
18
18
  }
@@ -7,7 +7,8 @@ import { type CodeLanguage } from "./highlight.js";
7
7
  * long lines at whitespace only (a flag with hyphens stays whole; a word wider than the box still
8
8
  * breaks inside itself); without it they scroll sideways under a themed thin scrollbar. The button's column has
9
9
  * a fixed width, so "copy" turning into "copied" never rewraps the code beside it. The button needs
10
- * client JS, which is why it is opt-in: a statically rendered page would show a dead one.
10
+ * client JS, which is why it is opt-in: a page rendered to static HTML needs `copyScript` inlined
11
+ * or the button is dead.
11
12
  */
12
13
  export declare function CodeBody({ code, language, wrap, copy, }: {
13
14
  code: string;
package/dist/CodeBody.js CHANGED
@@ -10,8 +10,9 @@ import { consoleCommands } from "./highlight.js";
10
10
  * long lines at whitespace only (a flag with hyphens stays whole; a word wider than the box still
11
11
  * breaks inside itself); without it they scroll sideways under a themed thin scrollbar. The button's column has
12
12
  * a fixed width, so "copy" turning into "copied" never rewraps the code beside it. The button needs
13
- * client JS, which is why it is opt-in: a statically rendered page would show a dead one.
13
+ * client JS, which is why it is opt-in: a page rendered to static HTML needs `copyScript` inlined
14
+ * or the button is dead.
14
15
  */
15
16
  export function CodeBody({ code, language, wrap, copy, }) {
16
- return (_jsxs("div", { className: cx("flex items-start", copy && "gap-1 pr-1.5"), children: [_jsx("div", { className: cx("min-w-0 flex-1", !wrap && `overflow-x-auto ${themedScrollbar}`), children: _jsx("pre", { className: cx("px-3 py-2.5 font-mono text-[12.5px] text-slate-900", wrap ? "break-words whitespace-pre-wrap" : "w-max min-w-full whitespace-pre"), children: _jsx(CodeText, { code: code, language: language, breakAtSpaces: wrap }) }) }), copy && (_jsx("div", { className: "flex w-14 shrink-0 justify-end pt-2", children: _jsx(CopyButton, { text: language === "console" ? consoleCommands(code) : code }) }))] }));
17
+ return (_jsxs("div", { className: cx("flex items-start", copy && "gap-1 pr-1.5"), children: [_jsx("div", { className: cx("min-w-0 flex-1", !wrap && `overflow-x-auto ${themedScrollbar}`), children: _jsx("pre", { className: cx("px-3 py-2.5 font-mono text-[12.5px] leading-normal text-slate-900", wrap ? "break-words whitespace-pre-wrap" : "w-max min-w-full whitespace-pre"), children: _jsx(CodeText, { code: code, language: language, breakAtSpaces: wrap }) }) }), copy && (_jsx("div", { className: "flex w-14 shrink-0 justify-end pt-2", children: _jsx(CopyButton, { text: language === "console" ? consoleCommands(code) : code }) }))] }));
17
18
  }
@@ -7,7 +7,8 @@ import type { Passthrough } from "./props.js";
7
7
  * it is coloured as TSX. The preview is its own height, never padded to match a neighbour, down to
8
8
  * a shared minimum so two frames side by side start alike. A wide preview or a long line of code
9
9
  * scrolls inside the frame, under a thin themed scrollbar, instead of widening the page or
10
- * wrapping.
10
+ * wrapping. Inside a `Prose` the preview stays on the app scale, so a demo looks as the component
11
+ * does anywhere else; the caption is outside it.
11
12
  */
12
13
  export declare function CodeExample({ code, caption, copy, children, ...rest }: {
13
14
  /** The source shown under the preview, newlines kept. */
@@ -15,8 +16,8 @@ export declare function CodeExample({ code, caption, copy, children, ...rest }:
15
16
  /** One note on the example, under the code. */
16
17
  caption?: ReactNode;
17
18
  /**
18
- * A copy button beside the code. It is a client component: leave it off on a page that is
19
- * rendered to static HTML without hydration.
19
+ * A copy button beside the code. On a page rendered to static HTML without hydration, inline
20
+ * `copyScript` and it works there too.
20
21
  * @default false
21
22
  */
22
23
  copy?: boolean;
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { CodeBody } from "./CodeBody.js";
3
3
  import { themedScrollbar } from "./cx.js";
4
+ import { AppScale } from "./typography.js";
4
5
  /**
5
6
  * One framed example: the rendered `children` on top and the `code` that makes them attached
6
7
  * underneath, in a single bordered box with one divider between. With `copy`, a copy
@@ -8,8 +9,9 @@ import { themedScrollbar } from "./cx.js";
8
9
  * it is coloured as TSX. The preview is its own height, never padded to match a neighbour, down to
9
10
  * a shared minimum so two frames side by side start alike. A wide preview or a long line of code
10
11
  * scrolls inside the frame, under a thin themed scrollbar, instead of widening the page or
11
- * wrapping.
12
+ * wrapping. Inside a `Prose` the preview stays on the app scale, so a demo looks as the component
13
+ * does anywhere else; the caption is outside it.
12
14
  */
13
15
  export function CodeExample({ code, caption, copy = false, children, ...rest }) {
14
- return (_jsxs("figure", { ...rest, className: "overflow-hidden rounded-lg border border-slate-200 bg-white", children: [_jsx("div", { className: `min-h-24 overflow-x-auto p-5 ${themedScrollbar}`, children: _jsx("div", { className: "text-slate-900", children: children }) }), _jsx("div", { className: "border-t border-slate-200 bg-slate-50", children: _jsx(CodeBody, { code: code, language: "tsx", wrap: false, copy: copy }) }), caption && (_jsx("figcaption", { className: "border-t border-slate-200 px-3 py-2.5 text-sm/5 text-slate-500", children: caption }))] }));
16
+ return (_jsxs("figure", { ...rest, className: "overflow-hidden rounded-lg border border-slate-200 bg-white", children: [_jsx("div", { className: `min-h-24 overflow-x-auto p-5 ${themedScrollbar}`, children: _jsx("div", { className: "text-slate-900", children: _jsx(AppScale, { children: children }) }) }), _jsx("div", { className: "border-t border-slate-200 bg-slate-50", children: _jsx(CodeBody, { code: code, language: "tsx", wrap: false, copy: copy }) }), caption && (_jsx("figcaption", { className: "border-t border-slate-200 px-3 py-2.5 text-sm/5 text-slate-500", children: caption }))] }));
15
17
  }
@@ -1,16 +1,16 @@
1
1
  import { type ComponentPropsWithoutRef, type ReactElement } from "react";
2
2
  import type { Unstyled } from "./props.js";
3
- /** Writes `text` to the clipboard and swaps the label to `copiedLabel` for two seconds. Defaults to `type="button"`. A refused or missing clipboard leaves the label on `label`. Every other button prop, `disabled` included, goes on the `<button>`. */
3
+ /** Writes `text` to the clipboard and shows `copiedLabel` for two seconds, then `label` again. Defaults to `type="button"`. Its accessible name stays `label`; a polite status region beside it announces `copiedLabel` on each copy. A refused or missing clipboard leaves it on `label`. Every other button prop, `disabled` included, goes on the `<button>`. On a page rendered to static HTML without hydration, inline `copyScript` and the button works there too. */
4
4
  export declare function CopyButton({ text, label, copiedLabel, onClick, ...rest }: {
5
5
  /** What goes on the clipboard. */
6
6
  text: string;
7
7
  /**
8
- * The button's text before a copy.
8
+ * The button's text, and its accessible name.
9
9
  * @default copy
10
10
  */
11
11
  label?: string;
12
12
  /**
13
- * The button's text once the copy has gone through.
13
+ * The text shown for two seconds once the copy has gone through, and announced.
14
14
  * @default copied
15
15
  */
16
16
  copiedLabel?: string;
@@ -1,19 +1,39 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
2
- import { useEffect, useState, } from "react";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useRef, useState, } from "react";
3
3
  import { focusRing } from "./cx.js";
4
4
  const BASE = `shrink-0 rounded px-1.5 py-0.5 font-sans text-xs text-slate-400 hover:bg-slate-100 disabled:pointer-events-none disabled:opacity-50 ${focusRing}`;
5
- /** How long the copied state stays before the label goes back, so a second copy is seen too. */
6
- const COPIED_MS = 2000;
7
- /** Writes `text` to the clipboard and swaps the label to `copiedLabel` for two seconds. Defaults to `type="button"`. A refused or missing clipboard leaves the label on `label`. Every other button prop, `disabled` included, goes on the `<button>`. */
5
+ /** Both labels share one grid cell, centred, so the button is as wide as the longer and never resizes. */
6
+ const CELL = "col-start-1 row-start-1";
7
+ // The copied state is CSS, not a timer: a copy sets `data-copied` on the button, and for two seconds
8
+ // an animation hides `label` and shows `copiedLabel`; when it ends, the resting styles are back.
9
+ // `visibility` is discrete, so each keyframe holds one value for the whole run. A second copy starts
10
+ // the run again: React remounts both labels, `copyScript` re-sets the attribute after a reflow. The
11
+ // run's own end clears `data-copied`, so a button hidden and shown again later does not replay it.
12
+ const css = `[data-copied] [data-copy-idle]{animation:hc-copy-idle 2s}[data-copied] [data-copy-done]{animation:hc-copy-done 2s}@keyframes hc-copy-idle{from,to{visibility:hidden}}@keyframes hc-copy-done{from,to{visibility:visible}}`;
13
+ /** Writes `text` to the clipboard and shows `copiedLabel` for two seconds, then `label` again. Defaults to `type="button"`. Its accessible name stays `label`; a polite status region beside it announces `copiedLabel` on each copy. A refused or missing clipboard leaves it on `label`. Every other button prop, `disabled` included, goes on the `<button>`. On a page rendered to static HTML without hydration, inline `copyScript` and the button works there too. */
8
14
  export function CopyButton({ text, label = "copy", copiedLabel = "copied", onClick, ...rest }) {
15
+ /** How many copies went through: the key that restarts the labels. */
16
+ const [copies, setCopies] = useState(0);
17
+ /** Set by a copy, cleared when the copied label's animation ends. */
9
18
  const [copied, setCopied] = useState(false);
10
- useEffect(() => {
11
- const timer = copied ? setTimeout(() => setCopied(false), COPIED_MS) : undefined;
12
- return () => clearTimeout(timer);
13
- }, [copied]);
14
- return (_jsx("button", { type: "button", ...rest, className: BASE, onClick: (event) => {
15
- onClick?.(event);
16
- // The rejection is swallowed on purpose: a denied write is not an error the page can act on.
17
- navigator.clipboard?.writeText(text).then(() => setCopied(true), () => { });
18
- }, children: copied ? copiedLabel : label }));
19
+ const status = useRef(null);
20
+ return (_jsxs(_Fragment, { children: [_jsx("style", { children: css }), _jsx("button", { type: "button", "aria-label": label, ...rest, "data-copy-text": text, "data-copy-copied": copiedLabel, "data-copied": copied ? "" : undefined,
21
+ // `copyScript` may have set `data-copied` before this button hydrated; React leaves it be.
22
+ suppressHydrationWarning: true, className: BASE, onClick: (event) => {
23
+ onClick?.(event);
24
+ // `copyScript` hears this same click on `document`. Whichever of the two runs first copies
25
+ // and flags the event, so the other skips it.
26
+ const native = event.nativeEvent;
27
+ if (native.copyHandled)
28
+ return;
29
+ native.copyHandled = true;
30
+ // The rejection is swallowed on purpose: a denied write is not an error the page can act on.
31
+ navigator.clipboard?.writeText(text).then(() => {
32
+ setCopies((n) => n + 1);
33
+ setCopied(true);
34
+ // A new text node each time, so a second copy is announced too. Set here, not
35
+ // rendered, as `copyScript` does, so a region it filled before hydration still matches.
36
+ status.current?.replaceChildren(copiedLabel);
37
+ }, () => { });
38
+ }, children: _jsxs("span", { className: "grid justify-items-center", onAnimationEnd: () => setCopied(false), children: [_jsx("span", { "data-copy-idle": "", className: CELL, children: label }), _jsx("span", { "data-copy-done": "", "aria-hidden": "true", className: `invisible ${CELL}`, children: copiedLabel })] }, copies) }), _jsx("span", { ref: status, role: "status", "data-copy-status": "", className: "sr-only", suppressHydrationWarning: true, children: "" })] }));
19
39
  }
package/dist/CopyLine.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { CopyButton } from "./CopyButton.js";
3
- const BOX = "flex items-center justify-between gap-3 rounded-lg border border-slate-200 bg-white px-2.5 py-2 font-mono text-[12.5px] text-slate-900";
3
+ const BOX = "flex items-center justify-between gap-3 rounded-lg border border-slate-200 bg-white px-2.5 py-2 font-mono text-[12.5px] leading-normal text-slate-900";
4
4
  /** One line of mono text in a box, with a `CopyButton`: a command or a token to paste elsewhere. `disabled` disables the button, e.g. until hydration. Every other div prop goes on the root `<div>`, so `data-testid` lands there; the `<code>` holds only `text`. */
5
5
  export function CopyLine({ text, disabled, ...rest }) {
6
6
  return (_jsxs("div", { ...rest, className: BOX, children: [_jsx("code", { className: "min-w-0 break-words", children: text }), _jsx(CopyButton, { text: text, disabled: disabled })] }));
package/dist/Dialog.js CHANGED
@@ -5,7 +5,7 @@ import { useModal } from "./modal.js";
5
5
  import { scrollStopProps, TABBABLE, useScrollStop } from "./scrollStop.js";
6
6
  import { AppScale, HeaderText, SectionHeading } from "./typography.js";
7
7
  /** Shared by every size: the surface, its colours and the fade (and the scrim's) in and out. */
8
- const BASE = "bg-white p-0 text-slate-900 opacity-0 transition-[opacity,translate,overlay,display] transition-discrete duration-200 open:opacity-100 starting:open:opacity-0 backdrop:bg-slate-900/35 backdrop:opacity-0 backdrop:transition-[opacity,overlay,display] backdrop:transition-discrete backdrop:duration-200 open:backdrop:opacity-100 starting:open:backdrop:opacity-0 motion-reduce:transition-none motion-reduce:backdrop:transition-none";
8
+ const BASE = "bg-white p-0 text-slate-900 whitespace-normal opacity-0 transition-[opacity,translate,overlay,display] transition-discrete duration-200 open:opacity-100 starting:open:opacity-0 backdrop:bg-slate-900/35 backdrop:opacity-0 backdrop:transition-[opacity,overlay,display] backdrop:transition-discrete backdrop:duration-200 open:backdrop:opacity-100 starting:open:backdrop:opacity-0 motion-reduce:transition-none motion-reduce:backdrop:transition-none";
9
9
  /** A floating panel under the top edge: it also slides down 8px as it fades in. */
10
10
  const WINDOW = "mx-auto mt-28 max-w-[calc(100vw-2rem)] overflow-visible rounded-2xl border border-slate-200 shadow-2xl -translate-y-2 open:translate-y-0 starting:open:-translate-y-2";
11
11
  const WIDTH = { sm: "w-80", md: "w-110", lg: "w-160" };
package/dist/Hero.d.ts ADDED
@@ -0,0 +1,23 @@
1
+ import type { ReactElement, ReactNode } from "react";
2
+ import type { Passthrough } from "./props.js";
3
+ /** The one keyframes name the glow drifts on; a consumer's own CSS never needs it. */
4
+ export declare const HERO_DRIFT = "hc-hero-tide";
5
+ export type HeroProps = {
6
+ /** The small label above the title, set in the monospace face. */
7
+ overline?: ReactNode;
8
+ /** The page's `h1`, at display size. */
9
+ title: ReactNode;
10
+ /** What follows the title: a `Lead`, and the page's first actions if it has any. */
11
+ children?: ReactNode;
12
+ };
13
+ /**
14
+ * A landing page's top block: an overline, the display title (the page's `h1`) and the lede under
15
+ * it, over a soft glow of three tilted ribbons that drift slowly behind the text. The glow is always
16
+ * there and its colours and strength are the theme's (the palette's `glow` role), so a paper theme
17
+ * keeps it faint. It is anchored to the text's own column (`max-w-3xl`, at the parent's left edge),
18
+ * never the viewport, and clipped to it, so nothing scrolls sideways on a phone. It is
19
+ * `aria-hidden`, and the text sits above it. Under `prefers-reduced-motion: reduce` it stands still.
20
+ * Pure markup and one inline stylesheet: it works rendered on the server with no script.
21
+ * One per page, on a landing page; every other page opens with a `PageHeader`.
22
+ */
23
+ export declare function Hero({ overline, title, children, ...rest }: HeroProps & Passthrough): ReactElement;
package/dist/Hero.js ADDED
@@ -0,0 +1,28 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Display, Overline } from "./typography.js";
3
+ /** The one keyframes name the glow drifts on; a consumer's own CSS never needs it. */
4
+ export const HERO_DRIFT = "hc-hero-tide";
5
+ // What Tailwind classes cannot say: the soft fade at the layer's four edges (two masks, intersected,
6
+ // so no ribbon ends in a hard line where the layer clips it) and the drift. The drift is a slow
7
+ // sideways sway and stretch, like light on water, each ribbon on its own period so the three never
8
+ // line up; it runs only under `prefers-reduced-motion: no-preference`, so a visitor who asks for less
9
+ // motion sees the ribbons standing at their resting place. It sets no colour (the mask's black and
10
+ // transparent only set how much shows): the theme paints the ribbons.
11
+ const css = `[data-hero-glow]{mask-image:linear-gradient(to bottom,transparent,black 22%,black 78%,transparent),linear-gradient(to right,transparent,black 8%,black 92%,transparent);mask-composite:intersect}
12
+ @media (prefers-reduced-motion:no-preference){[data-hero-glow] span{animation:${HERO_DRIFT} 41s ease-in-out -7s infinite alternate}[data-hero-glow] span:nth-child(2){animation-duration:53s;animation-delay:-29s}[data-hero-glow] span:nth-child(3){animation-duration:67s;animation-delay:-43s;animation-direction:alternate-reverse}}
13
+ @keyframes ${HERO_DRIFT}{0%{translate:-7% 0;scale:1 1}50%{translate:5% 10%;scale:1.14 .86}100%{translate:12% -6%;scale:.92 1.1}}`;
14
+ /** Every ribbon: an ellipse, blurred soft. Its place, size, tilt and colour follow. */
15
+ const RIBBON = "absolute rounded-[50%] blur-[56px]";
16
+ /**
17
+ * A landing page's top block: an overline, the display title (the page's `h1`) and the lede under
18
+ * it, over a soft glow of three tilted ribbons that drift slowly behind the text. The glow is always
19
+ * there and its colours and strength are the theme's (the palette's `glow` role), so a paper theme
20
+ * keeps it faint. It is anchored to the text's own column (`max-w-3xl`, at the parent's left edge),
21
+ * never the viewport, and clipped to it, so nothing scrolls sideways on a phone. It is
22
+ * `aria-hidden`, and the text sits above it. Under `prefers-reduced-motion: reduce` it stands still.
23
+ * Pure markup and one inline stylesheet: it works rendered on the server with no script.
24
+ * One per page, on a landing page; every other page opens with a `PageHeader`.
25
+ */
26
+ export function Hero({ overline, title, children, ...rest }) {
27
+ return (_jsxs("div", { ...rest, className: "relative isolate max-w-3xl", children: [_jsx("style", { children: css }), _jsxs("div", { "aria-hidden": "true", "data-hero-glow": "", className: "pointer-events-none absolute inset-x-0 -inset-y-14 -z-10 overflow-hidden", children: [_jsx("span", { className: `${RIBBON} top-[14%] left-[-6%] h-[34%] w-[78%] -rotate-[7deg] bg-indigo-400/25` }), _jsx("span", { className: `${RIBBON} top-[34%] left-[28%] h-[30%] w-[70%] rotate-[4deg] bg-teal-400/25` }), _jsx("span", { className: `${RIBBON} top-[56%] left-[6%] h-[26%] w-[58%] -rotate-[2deg] bg-rose-400/25` })] }), _jsxs("div", { className: "relative flex flex-col gap-4", children: [_jsxs("header", { children: [overline != null && overline !== false && _jsx(Overline, { mono: true, children: overline }), _jsx(Display, { children: title })] }), children] })] }));
28
+ }
package/dist/Icon.d.ts CHANGED
@@ -17,7 +17,8 @@ type IconProps = Passthrough & {
17
17
  * picks one of the library's own icons, all drawn on one grid with one stroke: feature ideas
18
18
  * (`diff`, `baseline`, `review`, `ci`, `self-host`, `privacy`, `capture`, `branch`, `theme`,
19
19
  * `speed`, `search`) and UI glyphs (`chevron-right`, `chevron-down`, `close`, `external-link`,
20
- * `settings`, `bell`, `check`, `plus`);
20
+ * `settings`, `bell`, `check`, `plus`) and the ideas the docs sections stand for (`grid`, `window`,
21
+ * `type`, `card`, `pointer`, `field`, `table`, `compass`, `page`, `briefcase`);
21
22
  * without a name, a bare `<svg>` child fills the box. It is decorative (`aria-hidden`) by
22
23
  * default; a `label` makes it an image with that name, for an icon that carries meaning no text
23
24
  * beside it says.
package/dist/Icon.js CHANGED
@@ -14,7 +14,8 @@ const BOX = {
14
14
  * picks one of the library's own icons, all drawn on one grid with one stroke: feature ideas
15
15
  * (`diff`, `baseline`, `review`, `ci`, `self-host`, `privacy`, `capture`, `branch`, `theme`,
16
16
  * `speed`, `search`) and UI glyphs (`chevron-right`, `chevron-down`, `close`, `external-link`,
17
- * `settings`, `bell`, `check`, `plus`);
17
+ * `settings`, `bell`, `check`, `plus`) and the ideas the docs sections stand for (`grid`, `window`,
18
+ * `type`, `card`, `pointer`, `field`, `table`, `compass`, `page`, `briefcase`);
18
19
  * without a name, a bare `<svg>` child fills the box. It is decorative (`aria-hidden`) by
19
20
  * default; a `label` makes it an image with that name, for an icon that carries meaning no text
20
21
  * beside it says.
@@ -0,0 +1,20 @@
1
+ import type { ReactElement } from "react";
2
+ import type { Passthrough } from "./props.js";
3
+ /**
4
+ * A thin indeterminate bar along the top edge of the viewport, in the accent tone, for a route
5
+ * change that is under way and has no known length. Pass `active` from the router's pending state
6
+ * and render it once per app, beside the page frame. It draws exactly what `active` says: the bar
7
+ * while it is true and nothing while it is false. The stylesheet holds it invisible for the first
8
+ * 150ms, so a fast navigation shows nothing. It is fixed to the viewport and overlays the top of
9
+ * the header, so nothing on the page moves. Under `prefers-reduced-motion: reduce` it is a still
10
+ * bar.
11
+ */
12
+ export declare function NavigationProgress({ active, label, ...rest }: {
13
+ /** True while a navigation is pending, e.g. from the router's state. */
14
+ active: boolean;
15
+ /**
16
+ * The bar's accessible name.
17
+ * @default Loading page
18
+ */
19
+ label?: string;
20
+ } & Passthrough): ReactElement | null;
@@ -0,0 +1,26 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { BAR_FILL } from "./ProgressBar.js";
3
+ // The one place the bar's motion lives: a stylesheet, as Tailwind classes cannot declare
4
+ // `@keyframes`. Nothing here is a timer in script: the 150ms wait is the opacity animation's own
5
+ // delay (backwards fill, so the bar is invisible until it ends), and a navigation that finishes
6
+ // first removes the bar before it ever showed. A segment slides across the bar; under reduced
7
+ // motion it is a still, full-width bar. No colour in it, which the bar's own class carries.
8
+ const css = `@keyframes hc-navigation-progress-in{from{opacity:0}to{opacity:1}}
9
+ @keyframes hc-navigation-progress{from{transform:translateX(-100%)}to{transform:translateX(250%)}}
10
+ [data-navigation-progress]{animation:hc-navigation-progress-in .01s linear 150ms backwards}
11
+ [data-navigation-progress-bar]{width:40%;animation:hc-navigation-progress 1.4s ease-in-out infinite}
12
+ @media (prefers-reduced-motion:reduce){[data-navigation-progress-bar]{width:100%;animation:none}}`;
13
+ /**
14
+ * A thin indeterminate bar along the top edge of the viewport, in the accent tone, for a route
15
+ * change that is under way and has no known length. Pass `active` from the router's pending state
16
+ * and render it once per app, beside the page frame. It draws exactly what `active` says: the bar
17
+ * while it is true and nothing while it is false. The stylesheet holds it invisible for the first
18
+ * 150ms, so a fast navigation shows nothing. It is fixed to the viewport and overlays the top of
19
+ * the header, so nothing on the page moves. Under `prefers-reduced-motion: reduce` it is a still
20
+ * bar.
21
+ */
22
+ export function NavigationProgress({ active, label = "Loading page", ...rest }) {
23
+ if (!active)
24
+ return null;
25
+ return (_jsxs(_Fragment, { children: [_jsx("style", { children: css }), _jsx("div", { ...rest, role: "progressbar", "aria-label": label, "data-navigation-progress": "", className: "pointer-events-none fixed inset-x-0 top-0 z-50 h-[3px] overflow-hidden", children: _jsx("div", { "data-navigation-progress-bar": "", className: `h-full ${BAR_FILL.accent}` }) })] }));
26
+ }
@@ -1,4 +1,4 @@
1
- import type { ReactElement, ReactNode } from "react";
1
+ import { type ReactElement, type ReactNode } from "react";
2
2
  import type { Passthrough } from "./props.js";
3
3
  /**
4
4
  * A `rail` of links beside the `children` canvas, in the Container the shell already gives every
@@ -10,7 +10,10 @@ import type { Passthrough } from "./props.js";
10
10
  * above the canvas and nothing sticks. A long rail sets `railLabel`:
11
11
  * below `md` it then folds behind a disclosure of that name, closed, so the page comes first on a
12
12
  * phone, and from `md` it is the same rail; `railLabel` also names the rail's `<aside>` landmark. Upgrading from 0.2: the rail no longer has a fill, a
13
- * border or a fixed height, and it no longer sits beside the canvas on a phone.
13
+ * border or a fixed height, and it no longer sits beside the canvas on a phone. Once the page has
14
+ * loaded, a rail that scrolls shows its current link (the one with `aria-current`) in view, and the
15
+ * folded disclosure reads `railLabel` and the current link's name ("All components: Button"); both
16
+ * need the layout to hydrate.
14
17
  */
15
18
  export declare function SidebarLayout({ rail, railLabel, children, ...rest }: {
16
19
  /** The links beside the canvas, usually a `Tabs variant="pill" orientation="vertical"`. */
@@ -1,9 +1,28 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from "react";
2
3
  import { cx } from "./cx.js";
3
4
  import { FoldedColumn } from "./foldedColumn.js";
4
5
  import { useScrollEdges } from "./scrollEdges.js";
5
6
  import { scrollStopProps, useScrollStop } from "./scrollStop.js";
6
- import { STICKY_ASIDE_MD, STICKY_FADE_MD, STICKY_SCROLL_MD } from "./stickyColumn.js";
7
+ import { STICKY_ASIDE_MD, STICKY_BAR_MD, STICKY_FADE_MD, STICKY_INK_MD, STICKY_SCROLL_MD, } from "./stickyColumn.js";
8
+ /** The rail's current link: the one `Tabs` marks with `aria-current`. */
9
+ const CURRENT = '[aria-current]:not([aria-current="false"])';
10
+ /** The room the scroller's fade takes at each edge (`STICKY_FADE_MD`'s 1.5rem), in px. */
11
+ const FADE = 24;
12
+ /**
13
+ * Scrolls `scroller` (only it, never the page, which `scrollIntoView` would also move) so `item`
14
+ * sits in the middle of its visible part. Left alone when it is already clear of the faded edges, or
15
+ * when the scroller does not scroll (below `md`, or a rail that fits).
16
+ */
17
+ function scrollToCentre(scroller, item) {
18
+ if (scroller.scrollHeight <= scroller.clientHeight)
19
+ return;
20
+ const box = scroller.getBoundingClientRect();
21
+ const at = item.getBoundingClientRect();
22
+ if (at.top >= box.top + FADE && at.bottom <= box.bottom - FADE)
23
+ return;
24
+ scroller.scrollTop += at.top + at.height / 2 - (box.top + box.height / 2);
25
+ }
7
26
  /**
8
27
  * A `rail` of links beside the `children` canvas, in the Container the shell already gives every
9
28
  * page (a plain `<AppShell>`, no `bleed`): the rail starts at the header logo's x and the canvas
@@ -14,11 +33,40 @@ import { STICKY_ASIDE_MD, STICKY_FADE_MD, STICKY_SCROLL_MD } from "./stickyColum
14
33
  * above the canvas and nothing sticks. A long rail sets `railLabel`:
15
34
  * below `md` it then folds behind a disclosure of that name, closed, so the page comes first on a
16
35
  * phone, and from `md` it is the same rail; `railLabel` also names the rail's `<aside>` landmark. Upgrading from 0.2: the rail no longer has a fill, a
17
- * border or a fixed height, and it no longer sits beside the canvas on a phone.
36
+ * border or a fixed height, and it no longer sits beside the canvas on a phone. Once the page has
37
+ * loaded, a rail that scrolls shows its current link (the one with `aria-current`) in view, and the
38
+ * folded disclosure reads `railLabel` and the current link's name ("All components: Button"); both
39
+ * need the layout to hydrate.
18
40
  */
19
41
  export function SidebarLayout({ rail, railLabel, children, ...rest }) {
20
42
  const { element, attach, stop } = useScrollStop();
21
43
  useScrollEdges(element, "y");
44
+ const [current, setCurrent] = useState(undefined);
45
+ const scrolledTo = useRef(null);
46
+ // The current link is scrolled into view once, and its name goes on the folded disclosure. It is
47
+ // read when the layout mounts and again whenever the rail's links change (a consumer's router
48
+ // moving `aria-current` to another link).
49
+ useEffect(() => {
50
+ if (!element)
51
+ return undefined;
52
+ const sync = () => {
53
+ const item = element.querySelector(CURRENT);
54
+ setCurrent(item?.textContent?.trim() || undefined);
55
+ if (item && item !== scrolledTo.current)
56
+ scrollToCentre(element, item);
57
+ scrolledTo.current = item;
58
+ };
59
+ queueMicrotask(sync);
60
+ const change = new MutationObserver(sync);
61
+ change.observe(element, {
62
+ childList: true,
63
+ subtree: true,
64
+ characterData: true,
65
+ attributes: true,
66
+ attributeFilter: ["aria-current"],
67
+ });
68
+ return () => change.disconnect();
69
+ }, [element]);
22
70
  const { props, ring } = scrollStopProps(stop, { label: railLabel });
23
- return (_jsxs("div", { ...rest, className: "flex flex-col gap-8 md:flex-row md:gap-10", children: [_jsx("aside", { className: cx("w-full shrink-0 md:w-56", STICKY_ASIDE_MD), "aria-label": railLabel, children: _jsx("div", { ref: attach, ...props, className: cx(STICKY_SCROLL_MD, STICKY_FADE_MD, ring), children: railLabel === undefined ? (rail) : (_jsx(FoldedColumn, { from: "md", label: railLabel, children: rail })) }) }), _jsx("div", { className: "min-w-0 flex-1", children: children })] }));
71
+ return (_jsxs("div", { ...rest, className: "flex flex-col gap-8 md:flex-row md:gap-10", children: [_jsx("aside", { className: cx("w-full shrink-0 md:w-56", STICKY_ASIDE_MD), "aria-label": railLabel, children: _jsx("div", { ref: attach, ...props, className: cx(STICKY_SCROLL_MD, STICKY_BAR_MD, STICKY_FADE_MD, ring), children: _jsx("div", { className: STICKY_INK_MD, children: railLabel === undefined ? (rail) : (_jsx(FoldedColumn, { from: "md", label: current === undefined ? railLabel : `${railLabel}: ${current}`, children: rail })) }) }) }), _jsx("div", { className: "min-w-0 flex-1", children: children })] }));
24
72
  }