@hackcove/ui-react 0.10.1 → 0.10.2

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
@@ -72,6 +72,7 @@ takes the size of the text around it. Upgrading: `MIGRATING.md`, shipped in the
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
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`.
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).
75
76
  - Typography roles (each pins size + weight, takes a `color` and an `as`): `Display`, `Lead`,
76
77
  `PageHeading`, `SectionHeading`, `SubsectionHeading`, `Overline`, `Text`, `Caption`. Opt-in props, none changing today's look: `SectionHeading rule`
77
78
  (a hairline under it, with its spacing), `SubsectionHeading size` (`sm`, `md` default, `lg`) and `truncate` (one line with an ellipsis),
@@ -80,7 +81,7 @@ takes the size of the text around it. Upgrading: `MIGRATING.md`, shipped in the
80
81
  text colours as a `TableCell` tone, winning over `color`; pair the colour with a word, as in "Saved."),
81
82
  `numeric` (tabular figures), `mono` (the monospace face) and `truncate` (one line, cut with an
82
83
  ellipsis, the full text in a `title`, so its content is text).
83
- - 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 native checkbox with its label; 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` (`bash`, `css`, `tsx`, `ts` or `json`) colours it and `text`, the default, leaves it in one colour; `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`: 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 native checkbox with its label; 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
85
  - Dates: `toISODate`, `fromISODate`, and the `CalendarDate` type.
85
86
 
86
87
  ## Dates
package/dist/Callout.d.ts CHANGED
@@ -5,7 +5,8 @@ export type CalloutTone = "neutral" | "info" | "positive" | "warn" | "danger";
5
5
  /** The soft fill and border of each tone; a toned `Card` uses the same. */
6
6
  export declare const TONE_SURFACE: Record<CalloutTone, string>;
7
7
  /**
8
- * A tinted notice box, with an optional bold title above its body. `actions` puts a button or link
8
+ * A tinted notice box, with an optional bold title above its body. Its text is text-sm (text-base/7
9
+ * inside a `Prose`, boxed or `banner`). `actions` puts a button or link
9
10
  * at the end of the row; `banner` makes it a flat strip (bottom border only, no radius) for the
10
11
  * top of a `Card flush` and nowhere else: a padded card or a padded `FullBleedLayout` column
11
12
  * would inset it. `align="center"` centres the text. In a page's column the notice is a plain Callout, first in the column's `Stack`. With `actions` the box is `relative`, so a `TextLink
package/dist/Callout.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { cx } from "./cx.js";
3
+ import { useProse } from "./typography.js";
3
4
  /** The soft fill and border of each tone; a toned `Card` uses the same. */
4
5
  export const TONE_SURFACE = {
5
6
  neutral: "border-slate-200 bg-slate-50",
@@ -16,13 +17,15 @@ const TONE_TEXT = {
16
17
  danger: "text-red-800",
17
18
  };
18
19
  /**
19
- * A tinted notice box, with an optional bold title above its body. `actions` puts a button or link
20
+ * A tinted notice box, with an optional bold title above its body. Its text is text-sm (text-base/7
21
+ * inside a `Prose`, boxed or `banner`). `actions` puts a button or link
20
22
  * at the end of the row; `banner` makes it a flat strip (bottom border only, no radius) for the
21
23
  * top of a `Card flush` and nowhere else: a padded card or a padded `FullBleedLayout` column
22
24
  * would inset it. `align="center"` centres the text. In a page's column the notice is a plain Callout, first in the column's `Stack`. With `actions` the box is `relative`, so a `TextLink
23
25
  * stretch` there makes the whole callout its click target, like a link in a table row.
24
26
  */
25
27
  export function Callout({ tone = "info", title, actions, banner = false, align = "start", children, ...rest }) {
28
+ const size = useProse() ? "text-base/7" : "text-sm";
26
29
  const content = title ? (_jsxs(_Fragment, { children: [_jsx("p", { className: "font-semibold", children: title }), _jsx("div", { className: "mt-1", children: children })] })) : (children);
27
- return (_jsx("div", { className: cx(Boolean(actions) && "relative", banner ? "border-b px-5 py-3 text-sm" : "rounded-xl border p-4 text-sm", TONE_SURFACE[tone], TONE_TEXT[tone], align === "center" && "text-center"), ...rest, children: actions ? (_jsxs("div", { className: "flex items-center justify-between gap-3", children: [_jsx("div", { className: "min-w-0", children: content }), _jsx("div", { className: "flex shrink-0 items-center gap-2", children: actions })] })) : (content) }));
30
+ return (_jsx("div", { className: cx(Boolean(actions) && "relative", banner ? "border-b px-5 py-3" : "rounded-xl border p-4", size, TONE_SURFACE[tone], TONE_TEXT[tone], align === "center" && "text-center"), ...rest, children: actions ? (_jsxs("div", { className: "flex items-center justify-between gap-3", children: [_jsx("div", { className: "min-w-0", children: content }), _jsx("div", { className: "flex shrink-0 items-center gap-2", children: actions })] })) : (content) }));
28
31
  }
@@ -13,9 +13,12 @@ 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 it, and `label` names the language or file (`bash`,
17
- * `css`) in a caption above the code. `text` (the default) leaves the code in one colour, for a
18
- * block that is not a language. Give the text as `children` or as `lines`. Long lines wrap, so
16
+ * beside the code, which copies it exactly as given and needs the page hydrated. `language` colours
17
+ * it: `tsx`, `ts`, `js`, `json`, `bash`, `console`, `css`, `html` or `yaml`. `console` is a shell
18
+ * session: a line starting with `$ ` is a command (coloured as bash, its `$ ` dimmed and not
19
+ * selectable) and any other line is output; with `copy` only the commands are copied, without the
20
+ * `$ `. `label` names the language or file (`bash`, `css`) in a caption above the code. `text` (the
21
+ * default) leaves the code in one colour, for a block that is not a language. Give the text as `children` or as `lines`. Long lines wrap, so
19
22
  * nothing is cut off and a command stays one copyable line; `overflow="scroll"` keeps lines whole
20
23
  * and scrolls them sideways under a thin themed scrollbar, for code whose layout matters. Every
21
24
  * other figure prop goes on the root `<figure>`. For one short line to copy, `CopyLine` is the
@@ -33,8 +36,8 @@ export declare function CodeBlock({ label, language, overflow, copy, children, l
33
36
  */
34
37
  overflow?: "wrap" | "scroll";
35
38
  /**
36
- * A copy button beside the code, which copies it exactly as given. It is a client component:
37
- * leave it off on a page that is rendered to static HTML without hydration.
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.
38
41
  * @default false
39
42
  */
40
43
  copy?: boolean;
package/dist/CodeBlock.js CHANGED
@@ -2,9 +2,12 @@ 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 it, and `label` names the language or file (`bash`,
6
- * `css`) in a caption above the code. `text` (the default) leaves the code in one colour, for a
7
- * block that is not a language. Give the text as `children` or as `lines`. Long lines wrap, so
5
+ * beside the code, which copies it exactly as given and needs the page hydrated. `language` colours
6
+ * it: `tsx`, `ts`, `js`, `json`, `bash`, `console`, `css`, `html` or `yaml`. `console` is a shell
7
+ * session: a line starting with `$ ` is a command (coloured as bash, its `$ ` dimmed and not
8
+ * selectable) and any other line is output; with `copy` only the commands are copied, without the
9
+ * `$ `. `label` names the language or file (`bash`, `css`) in a caption above the code. `text` (the
10
+ * default) leaves the code in one colour, for a block that is not a language. Give the text as `children` or as `lines`. Long lines wrap, so
8
11
  * nothing is cut off and a command stays one copyable line; `overflow="scroll"` keeps lines whole
9
12
  * and scrolls them sideways under a thin themed scrollbar, for code whose layout matters. Every
10
13
  * other figure prop goes on the root `<figure>`. For one short line to copy, `CopyLine` is the
@@ -1,8 +1,9 @@
1
1
  import type { ReactElement } from "react";
2
- import type { CodeLanguage } from "./highlight.js";
2
+ import { type CodeLanguage } from "./highlight.js";
3
3
  /**
4
4
  * The code of a `CodeBlock` or a `CodeExample`: the `<pre>` and, with `copy`, a `CopyButton` beside
5
- * it that copies `code` exactly as given (wrapping is CSS, so it adds no characters). `wrap` wraps
5
+ * it that copies `code` exactly as given (wrapping is CSS, so it adds no characters), except in a
6
+ * `console` block, where it copies only the commands (see `consoleCommands`). `wrap` wraps
6
7
  * long lines at whitespace only (a flag with hyphens stays whole; a word wider than the box still
7
8
  * breaks inside itself); without it they scroll sideways under a themed thin scrollbar. The button's column has
8
9
  * a fixed width, so "copy" turning into "copied" never rewraps the code beside it. The button needs
package/dist/CodeBody.js CHANGED
@@ -2,14 +2,16 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { CodeText } from "./CodeText.js";
3
3
  import { CopyButton } from "./CopyButton.js";
4
4
  import { cx, themedScrollbar } from "./cx.js";
5
+ import { consoleCommands } from "./highlight.js";
5
6
  /**
6
7
  * The code of a `CodeBlock` or a `CodeExample`: the `<pre>` and, with `copy`, a `CopyButton` beside
7
- * it that copies `code` exactly as given (wrapping is CSS, so it adds no characters). `wrap` wraps
8
+ * it that copies `code` exactly as given (wrapping is CSS, so it adds no characters), except in a
9
+ * `console` block, where it copies only the commands (see `consoleCommands`). `wrap` wraps
8
10
  * long lines at whitespace only (a flag with hyphens stays whole; a word wider than the box still
9
11
  * breaks inside itself); without it they scroll sideways under a themed thin scrollbar. The button's column has
10
12
  * a fixed width, so "copy" turning into "copied" never rewraps the code beside it. The button needs
11
13
  * client JS, which is why it is opt-in: a statically rendered page would show a dead one.
12
14
  */
13
15
  export function CodeBody({ code, language, wrap, copy, }) {
14
- 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: code }) }))] }));
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 }) }))] }));
15
17
  }
package/dist/CodeText.js CHANGED
@@ -4,6 +4,8 @@ import { highlight } from "./highlight.js";
4
4
  * Each kind borrows a colour family already in `palette.json`, so a theme recolours code like
5
5
  * anything else: keywords and element names read as the accent (violet), strings as positive
6
6
  * (emerald), numbers as warn (amber), attribute names as sky, calls as info (blue), comments muted.
7
+ * The `$ ` prompt of a console block is dimmer still and `select-none`, so selecting a command by
8
+ * hand does not pick it up.
7
9
  */
8
10
  const TOKEN_CLASS = {
9
11
  plain: "",
@@ -14,6 +16,7 @@ const TOKEN_CLASS = {
14
16
  string: "text-emerald-700",
15
17
  number: "text-amber-700",
16
18
  function: "text-blue-700",
19
+ prompt: "text-slate-400 select-none",
17
20
  };
18
21
  /** One coloured run: bare text for plain, a span with the kind's palette utility otherwise. */
19
22
  const colour = (kind, key, text) => kind === "plain" ? (text) : (_jsx("span", { className: TOKEN_CLASS[kind], children: text }, key));
package/dist/Dialog.js CHANGED
@@ -3,7 +3,7 @@ import { useId, useLayoutEffect, useRef, } from "react";
3
3
  import { cx } from "./cx.js";
4
4
  import { useModal } from "./modal.js";
5
5
  import { scrollStopProps, TABBABLE, useScrollStop } from "./scrollStop.js";
6
- import { HeaderText, SectionHeading } from "./typography.js";
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
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";
9
9
  /** A floating panel under the top edge: it also slides down 8px as it fades in. */
@@ -61,5 +61,5 @@ export function Dialog({ title, description, children, footer, size = "md", onCl
61
61
  const closed = useModal(dialog, onClose);
62
62
  const full = size === "full";
63
63
  const { attach, props: bodyProps, className: bodyClass, } = useScrollingBody(dialog, titleId, full);
64
- return (_jsx("dialog", { ...rest, ref: dialog, "aria-labelledby": titleId, onClose: closed, onClick: (event) => event.target === dialog.current && dialog.current?.close(), className: cx(BASE, size === "full" ? FULL : `${WINDOW} ${WIDTH[size]}`), children: _jsxs("div", { className: cx("flex flex-col p-5.5", full ? "min-h-0 flex-1" : CAP), children: [_jsx(SectionHeading, { id: titleId, children: title }), description && (_jsx("div", { className: "mt-1", children: _jsx(HeaderText, { children: description }) })), children && (_jsx("div", { ref: attach, ...bodyProps, className: bodyClass, children: children })), footer && _jsx("div", { className: "mt-5 flex shrink-0 justify-end gap-2", children: footer })] }) }));
64
+ return (_jsx(AppScale, { children: _jsx("dialog", { ...rest, ref: dialog, "aria-labelledby": titleId, onClose: closed, onClick: (event) => event.target === dialog.current && dialog.current?.close(), className: cx(BASE, size === "full" ? FULL : `${WINDOW} ${WIDTH[size]}`), children: _jsxs("div", { className: cx("flex flex-col p-5.5", full ? "min-h-0 flex-1" : CAP), children: [_jsx(SectionHeading, { id: titleId, children: title }), description && (_jsx("div", { className: "mt-1", children: _jsx(HeaderText, { children: description }) })), children && (_jsx("div", { ref: attach, ...bodyProps, className: bodyClass, children: children })), footer && _jsx("div", { className: "mt-5 flex shrink-0 justify-end gap-2", children: footer })] }) }) }));
65
65
  }
package/dist/Drawer.js CHANGED
@@ -4,7 +4,7 @@ import { cx } from "./cx.js";
4
4
  import { Icon } from "./Icon.js";
5
5
  import { IconButton } from "./IconButton.js";
6
6
  import { useModal } from "./modal.js";
7
- import { SectionHeading } from "./typography.js";
7
+ import { AppScale, SectionHeading } from "./typography.js";
8
8
  const PANEL = "fixed inset-y-0 right-0 left-auto m-0 h-dvh max-h-none max-w-full flex-col overflow-hidden border-l border-slate-200 bg-white p-0 text-slate-900 shadow-2xl translate-x-full transition-[translate,overlay,display] transition-discrete duration-200 open:flex open:translate-x-0 starting:open:translate-x-full 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
  const SIZE = { sm: "w-80", md: "w-110", lg: "w-160" };
10
10
  /**
@@ -18,5 +18,5 @@ export function Drawer({ title, children, footer, size = "md", onClose, closeLab
18
18
  const dialog = useRef(null);
19
19
  const titleId = useId();
20
20
  const closed = useModal(dialog, onClose);
21
- return (_jsxs("dialog", { ...rest, ref: dialog, "aria-labelledby": titleId, onClose: closed, onClick: (event) => event.target === dialog.current && dialog.current?.close(), className: cx(PANEL, SIZE[size]), children: [_jsxs("header", { className: "flex shrink-0 items-center justify-between gap-3 border-b border-slate-200 px-5.5 py-3.5", children: [_jsx(SectionHeading, { id: titleId, children: title }), _jsx(IconButton, { label: closeLabel, onClick: () => dialog.current?.close(), children: _jsx(Icon, { children: _jsx("svg", { viewBox: "0 0 20 20", fill: "currentColor", children: _jsx("path", { d: "M5.28 4.22a.75.75 0 0 0-1.06 1.06L8.94 10l-4.72 4.72a.75.75 0 1 0 1.06 1.06L10 11.06l4.72 4.72a.75.75 0 1 0 1.06-1.06L11.06 10l4.72-4.72a.75.75 0 0 0-1.06-1.06L10 8.94 5.28 4.22Z" }) }) }) })] }), _jsx("div", { className: "min-h-0 flex-1 overflow-y-auto px-5.5 py-4", children: children }), footer && (_jsx("footer", { className: "flex shrink-0 justify-end gap-2 border-t border-slate-200 px-5.5 py-3.5", children: footer }))] }));
21
+ return (_jsx(AppScale, { children: _jsxs("dialog", { ...rest, ref: dialog, "aria-labelledby": titleId, onClose: closed, onClick: (event) => event.target === dialog.current && dialog.current?.close(), className: cx(PANEL, SIZE[size]), children: [_jsxs("header", { className: "flex shrink-0 items-center justify-between gap-3 border-b border-slate-200 px-5.5 py-3.5", children: [_jsx(SectionHeading, { id: titleId, children: title }), _jsx(IconButton, { label: closeLabel, onClick: () => dialog.current?.close(), children: _jsx(Icon, { children: _jsx("svg", { viewBox: "0 0 20 20", fill: "currentColor", children: _jsx("path", { d: "M5.28 4.22a.75.75 0 0 0-1.06 1.06L8.94 10l-4.72 4.72a.75.75 0 1 0 1.06 1.06L10 11.06l4.72 4.72a.75.75 0 1 0 1.06-1.06L11.06 10l4.72-4.72a.75.75 0 0 0-1.06-1.06L10 8.94 5.28 4.22Z" }) }) }) })] }), _jsx("div", { className: "min-h-0 flex-1 overflow-y-auto px-5.5 py-4", children: children }), footer && (_jsx("footer", { className: "flex shrink-0 justify-end gap-2 border-t border-slate-200 px-5.5 py-3.5", children: footer }))] }) }));
22
22
  }
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useId } from "react";
3
3
  import { cx } from "./cx.js";
4
- import { SectionHeading } from "./typography.js";
4
+ import { AppScale, SectionHeading } from "./typography.js";
5
5
  /**
6
6
  * What a list or a page shows when there is nothing in it yet: a centred `title`, the `children`
7
7
  * as the explanation under it (a `Text as="p"`, then `Steps` for a "get started" list), and
@@ -11,5 +11,5 @@ import { SectionHeading } from "./typography.js";
11
11
  */
12
12
  export function EmptyState({ title, level = 2, padding = "default", children, actions, ...rest }) {
13
13
  const titleId = useId();
14
- return (_jsxs("section", { ...rest, "aria-labelledby": titleId, className: cx("mx-auto flex w-full max-w-xl flex-col items-center gap-4 text-center", padding === "inset" ? "p-8" : "py-8"), children: [_jsx(SectionHeading, { as: `h${level}`, id: titleId, children: title }), children != null && (_jsx("div", { className: "flex w-full flex-col items-center gap-5", children: children })), actions != null && _jsx("div", { className: "flex flex-wrap justify-center gap-2", children: actions })] }));
14
+ return (_jsx(AppScale, { children: _jsxs("section", { ...rest, "aria-labelledby": titleId, className: cx("mx-auto flex w-full max-w-xl flex-col items-center gap-4 text-center", padding === "inset" ? "p-8" : "py-8"), children: [_jsx(SectionHeading, { as: `h${level}`, id: titleId, children: title }), children != null && (_jsx("div", { className: "flex w-full flex-col items-center gap-5", children: children })), actions != null && _jsx("div", { className: "flex flex-wrap justify-center gap-2", children: actions })] }) }));
15
15
  }
@@ -4,13 +4,14 @@
4
4
  * highlighted code exactly as it recolours everything else.
5
5
  *
6
6
  * The grammars are deliberately shallow — enough for examples, not a compiler front end. `text`
7
- * returns the code as one plain run, so nothing is coloured where no language is named.
7
+ * returns the code as one plain run, so nothing is coloured where no language is named. The
8
+ * languages: `text`, `tsx`, `ts`, `js`, `json`, `bash`, `console` (a shell session: `$ ` commands
9
+ * scanned as bash, everything else output), `css`, `html` (with `<script>` scanned as JS and
10
+ * `<style>` as CSS) and `yaml`.
8
11
  */
9
- export type CodeLanguage = "text" | "tsx" | "ts" | "json" | "bash" | "css";
10
- export type TokenKind = "plain" | "comment" | "keyword" | "string" | "number" | "tag" | "attr" | "function";
11
- export type Token = {
12
- text: string;
13
- kind: TokenKind;
14
- };
12
+ import type { Token } from "./highlightKit.js";
13
+ export { consoleCommands } from "./highlightConsole.js";
14
+ export type { Token, TokenKind } from "./highlightKit.js";
15
+ export type CodeLanguage = "text" | "tsx" | "ts" | "js" | "json" | "bash" | "console" | "css" | "html" | "yaml";
15
16
  /** The coloured runs of `code`. The text of every token joined back together is `code`. */
16
17
  export declare function highlight(code: string, language: CodeLanguage): Token[];
package/dist/highlight.js CHANGED
@@ -4,8 +4,15 @@
4
4
  * highlighted code exactly as it recolours everything else.
5
5
  *
6
6
  * The grammars are deliberately shallow — enough for examples, not a compiler front end. `text`
7
- * returns the code as one plain run, so nothing is coloured where no language is named.
7
+ * returns the code as one plain run, so nothing is coloured where no language is named. The
8
+ * languages: `text`, `tsx`, `ts`, `js`, `json`, `bash`, `console` (a shell session: `$ ` commands
9
+ * scanned as bash, everything else output), `css`, `html` (with `<script>` scanned as JS and
10
+ * `<style>` as CSS) and `yaml`.
8
11
  */
12
+ import { scanConsole } from "./highlightConsole.js";
13
+ import { scanHtml } from "./highlightHtml.js";
14
+ import { scanYaml } from "./highlightYaml.js";
15
+ export { consoleCommands } from "./highlightConsole.js";
9
16
  /** The coloured runs of `code`. The text of every token joined back together is `code`. */
10
17
  export function highlight(code, language) {
11
18
  const tokens = [];
@@ -22,9 +29,16 @@ export function highlight(code, language) {
22
29
  emit("plain", code);
23
30
  else if (language === "bash")
24
31
  scanBash(code, emit);
32
+ else if (language === "console")
33
+ scanConsole(code, emit, scanBash);
25
34
  else if (language === "css")
26
35
  scanCss(code, emit);
27
- // JSON is JS with no code around it: keys are strings, `true`/`false`/`null` are keywords.
36
+ else if (language === "html")
37
+ scanHtml(code, emit, scanJs, scanCss);
38
+ else if (language === "yaml")
39
+ scanYaml(code, emit);
40
+ // JS, TS and TSX share one scanner. JSON is JS with no code around it: keys are strings,
41
+ // `true`/`false`/`null` are keywords.
28
42
  else
29
43
  scanJs(code, emit);
30
44
  return tokens;
@@ -0,0 +1,9 @@
1
+ import type { Emit, Scanner } from "./highlightKit.js";
2
+ /** A shell session: `$ ` is the prompt, the command after it is bash, other lines are output. */
3
+ export declare function scanConsole(code: string, emit: Emit, bash: Scanner): void;
4
+ /**
5
+ * What a console block's copy button copies: each command without its `$ `, plus the lines that
6
+ * continue it as written (the trailing `\\` and the indentation kept, so the paste runs), joined by
7
+ * newlines. Output is left out. A block with no `$ ` line has no commands, so it is copied as given.
8
+ */
9
+ export declare function consoleCommands(code: string): string;
@@ -0,0 +1,42 @@
1
+ import { splitLines } from "./highlightKit.js";
2
+ const CONSOLE_PROMPT = "$ ";
3
+ /**
4
+ * The lines of a console session, marked: a line that starts with `$ ` is a command (`prompt`), and
5
+ * so is every line after a command line that ends with `\\`, which continues it. The rest is output.
6
+ */
7
+ function consoleLines(code) {
8
+ let continued = false;
9
+ return splitLines(code).map(({ body, eol }) => {
10
+ const prompt = !continued && body.startsWith(CONSOLE_PROMPT);
11
+ const command = prompt || continued;
12
+ continued = command && body.endsWith("\\");
13
+ return { body, eol, command, prompt };
14
+ });
15
+ }
16
+ /** A shell session: `$ ` is the prompt, the command after it is bash, other lines are output. */
17
+ export function scanConsole(code, emit, bash) {
18
+ for (const { body, eol, command, prompt } of consoleLines(code)) {
19
+ if (prompt)
20
+ emit("prompt", CONSOLE_PROMPT);
21
+ const text = prompt ? body.slice(CONSOLE_PROMPT.length) : body;
22
+ if (command)
23
+ bash(text, emit);
24
+ else
25
+ emit("plain", text);
26
+ emit("plain", eol);
27
+ }
28
+ }
29
+ /**
30
+ * What a console block's copy button copies: each command without its `$ `, plus the lines that
31
+ * continue it as written (the trailing `\\` and the indentation kept, so the paste runs), joined by
32
+ * newlines. Output is left out. A block with no `$ ` line has no commands, so it is copied as given.
33
+ */
34
+ export function consoleCommands(code) {
35
+ const lines = consoleLines(code);
36
+ if (!lines.some((line) => line.prompt))
37
+ return code;
38
+ return lines
39
+ .filter((line) => line.command)
40
+ .map((line) => (line.prompt ? line.body.slice(CONSOLE_PROMPT.length) : line.body))
41
+ .join("\n");
42
+ }
@@ -0,0 +1,7 @@
1
+ import type { Emit, Scanner } from "./highlightKit.js";
2
+ /**
3
+ * HTML: comments, the doctype, tags with their attributes (a start tag may span lines), and text,
4
+ * which is plain. The content of `<script>` and `<style>` runs to the first closing tag of that
5
+ * name, wherever it falls, and is scanned with `script` (JS) or `style` (CSS).
6
+ */
7
+ export declare function scanHtml(code: string, emit: Emit, script: Scanner, style: Scanner): void;
@@ -0,0 +1,132 @@
1
+ import { sticky } from "./highlightKit.js";
2
+ const OPEN = /<(\/?)([A-Za-z][\w:-]*)/y;
3
+ const DOCTYPE = /<!doctype\b[^>]*>?/iy;
4
+ const SPACE = /\s+/y;
5
+ const ATTR = /[^\s"'<>/=]+/y;
6
+ const UNQUOTED = /[^\s>"'=<`]+/y;
7
+ /** `>` or `/>` where the tag ends. */
8
+ function tagEnd(code, at) {
9
+ if (code.startsWith("/>", at))
10
+ return "/>";
11
+ return code.charAt(at) === ">" ? ">" : undefined;
12
+ }
13
+ /** The value after an `=`: a quoted string (which may span lines) or a bare run, which stays plain. */
14
+ function takeValue(scan) {
15
+ const { code, at } = scan;
16
+ const c = code.charAt(at);
17
+ if (c === '"' || c === "'") {
18
+ const close = code.indexOf(c, at + 1);
19
+ const end = close === -1 ? code.length : close + 1;
20
+ scan.emit("string", code.slice(at, end));
21
+ scan.at = end;
22
+ return true;
23
+ }
24
+ const bare = sticky(UNQUOTED, code, at);
25
+ if (bare === undefined)
26
+ return false;
27
+ scan.emit("plain", bare);
28
+ scan.at += bare.length;
29
+ return true;
30
+ }
31
+ /** One piece of a tag's insides: space, `=`, a value, an attribute name, or a stray character. */
32
+ function tagStep(scan) {
33
+ const { code, at } = scan;
34
+ const space = sticky(SPACE, code, at);
35
+ if (space !== undefined) {
36
+ scan.emit("plain", space);
37
+ scan.at += space.length;
38
+ return;
39
+ }
40
+ if (code.charAt(at) === "=") {
41
+ scan.emit("plain", "=");
42
+ scan.afterEquals = true;
43
+ scan.at += 1;
44
+ return;
45
+ }
46
+ const wasAfterEquals = scan.afterEquals;
47
+ scan.afterEquals = false;
48
+ if (wasAfterEquals && takeValue(scan))
49
+ return;
50
+ const name = sticky(ATTR, code, at);
51
+ scan.emit(name === undefined ? "plain" : "attr", name ?? code.charAt(at));
52
+ scan.at += name?.length ?? 1;
53
+ }
54
+ /** The rest of a tag from just past its name; says where it ended and how. */
55
+ function scanTag(code, from, emit) {
56
+ const scan = { code, emit, at: from, afterEquals: false };
57
+ while (scan.at < code.length) {
58
+ const end = tagEnd(code, scan.at);
59
+ if (end !== undefined) {
60
+ emit("plain", end);
61
+ return { end: scan.at + end.length, closed: true, selfClosed: end === "/>" };
62
+ }
63
+ tagStep(scan);
64
+ }
65
+ return { end: code.length, closed: false, selfClosed: false };
66
+ }
67
+ /** A comment (which may span lines) or a doctype at `at`: where it ends and its colour. */
68
+ function special(code, at) {
69
+ if (code.startsWith("<!--", at)) {
70
+ const close = code.indexOf("-->", at + 4);
71
+ return { end: close === -1 ? code.length : close + 3, kind: "comment" };
72
+ }
73
+ const doctype = sticky(DOCTYPE, code, at);
74
+ return doctype === undefined ? undefined : { end: at + doctype.length, kind: "keyword" };
75
+ }
76
+ /** The `<script>` or `<style>` start tag whose content is another language, if this is one. */
77
+ function rawOf(name, tag, embedded) {
78
+ if (!tag.closed || tag.selfClosed)
79
+ return undefined;
80
+ if (name === "script")
81
+ return { name, scan: embedded.script };
82
+ return name === "style" ? { name, scan: embedded.style } : undefined;
83
+ }
84
+ /** The content of a `<script>` or `<style>`, up to the first closing tag of that name, wherever it falls. */
85
+ function rawContent(code, at, raw, emit) {
86
+ const closing = new RegExp(`</${raw.name}(?=[\\s/>]|$)`, "gi");
87
+ closing.lastIndex = at;
88
+ const end = closing.exec(code)?.index ?? code.length;
89
+ raw.scan(code.slice(at, end), emit);
90
+ return end;
91
+ }
92
+ /** A tag at a `<`, or a lone `<` that opens nothing. Returns the index to carry on from. */
93
+ function scanElement(code, at, emit, embedded) {
94
+ OPEN.lastIndex = at;
95
+ const open = OPEN.exec(code);
96
+ if (open === null) {
97
+ emit("plain", "<");
98
+ return at + 1;
99
+ }
100
+ const [whole, slash = "", tagName = ""] = open;
101
+ emit("plain", `<${slash}`);
102
+ emit("tag", tagName);
103
+ const tag = scanTag(code, at + whole.length, emit);
104
+ const raw = slash === "" ? rawOf(tagName.toLowerCase(), tag, embedded) : undefined;
105
+ return raw === undefined ? tag.end : rawContent(code, tag.end, raw, emit);
106
+ }
107
+ /** Markup at a `<`: a comment, a doctype or a tag. Returns the index to carry on from. */
108
+ function scanMarkup(code, at, emit, embedded) {
109
+ const found = special(code, at);
110
+ if (found === undefined)
111
+ return scanElement(code, at, emit, embedded);
112
+ emit(found.kind, code.slice(at, found.end));
113
+ return found.end;
114
+ }
115
+ /**
116
+ * HTML: comments, the doctype, tags with their attributes (a start tag may span lines), and text,
117
+ * which is plain. The content of `<script>` and `<style>` runs to the first closing tag of that
118
+ * name, wherever it falls, and is scanned with `script` (JS) or `style` (CSS).
119
+ */
120
+ export function scanHtml(code, emit, script, style) {
121
+ let at = 0;
122
+ while (at < code.length) {
123
+ if (code.charAt(at) === "<") {
124
+ at = scanMarkup(code, at, emit, { script, style });
125
+ continue;
126
+ }
127
+ const next = code.indexOf("<", at);
128
+ const end = next === -1 ? code.length : next;
129
+ emit("plain", code.slice(at, end));
130
+ at = end;
131
+ }
132
+ }
@@ -0,0 +1,16 @@
1
+ /** The pieces the scanners in `highlight*.ts` share. */
2
+ export type TokenKind = "plain" | "comment" | "keyword" | "string" | "number" | "tag" | "attr" | "function" | "prompt";
3
+ export type Token = {
4
+ text: string;
5
+ kind: TokenKind;
6
+ };
7
+ export type Emit = (kind: TokenKind, text: string) => void;
8
+ /** A scanner for a whole run of code, given to a scanner of a language that holds another. */
9
+ export type Scanner = (code: string, emit: Emit) => void;
10
+ /** The text a sticky `pattern` matches at `at`, or `undefined`. */
11
+ export declare function sticky(pattern: RegExp, code: string, at: number): string | undefined;
12
+ /** Each line of `code` without its line break, and the break (`\n`, `\r\n` or nothing at the end). */
13
+ export declare function splitLines(code: string): {
14
+ body: string;
15
+ eol: string;
16
+ }[];
@@ -0,0 +1,23 @@
1
+ /** The pieces the scanners in `highlight*.ts` share. */
2
+ /** The text a sticky `pattern` matches at `at`, or `undefined`. */
3
+ export function sticky(pattern, code, at) {
4
+ pattern.lastIndex = at;
5
+ return pattern.exec(code)?.[0];
6
+ }
7
+ /** Each line of `code` without its line break, and the break (`\n`, `\r\n` or nothing at the end). */
8
+ export function splitLines(code) {
9
+ const out = [];
10
+ let at = 0;
11
+ for (;;) {
12
+ const newline = code.indexOf("\n", at);
13
+ const end = newline === -1 ? code.length : newline;
14
+ const bodyEnd = end > at && code.charAt(end - 1) === "\r" ? end - 1 : end;
15
+ out.push({
16
+ body: code.slice(at, bodyEnd),
17
+ eol: code.slice(bodyEnd, newline === -1 ? end : end + 1),
18
+ });
19
+ if (newline === -1)
20
+ return out;
21
+ at = newline + 1;
22
+ }
23
+ }
@@ -0,0 +1,3 @@
1
+ import type { Emit } from "./highlightKit.js";
2
+ /** Line by line. The lines of a block scalar are one plain run: no comment or key is read in them. */
3
+ export declare function scanYaml(code: string, emit: Emit): void;
@@ -0,0 +1,143 @@
1
+ import { splitLines, sticky } from "./highlightKit.js";
2
+ const YAML_MARKER = /-(?: +|$)/y;
3
+ const YAML_KEYWORDS = /^(?:true|false|null|~)$/;
4
+ const YAML_NUMBER = /^-?\d[\d._]*$/;
5
+ const YAML_BLOCK_HEADER = /^[|>](?:[1-9][-+]?|[-+][1-9]?)?$/;
6
+ /**
7
+ * The index past the quoted scalar at `at` in a YAML line, or `undefined`. A single-quoted scalar
8
+ * escapes a quote by doubling it (`'it''s'`), a double-quoted one with a backslash.
9
+ */
10
+ function quotedEnd(line, at) {
11
+ const quote = line.charAt(at);
12
+ if (quote !== '"' && quote !== "'")
13
+ return undefined;
14
+ let i = at + 1;
15
+ while (i < line.length) {
16
+ const c = line.charAt(i);
17
+ if (quote === '"' && c === "\\")
18
+ i += 2;
19
+ else if (c !== quote)
20
+ i += 1;
21
+ else if (quote === "'" && line.charAt(i + 1) === "'")
22
+ i += 2;
23
+ else
24
+ return i + 1;
25
+ }
26
+ return line.length;
27
+ }
28
+ /** What follows a quoted scalar: spaces, then perhaps a comment. */
29
+ function trailing(rest, emit) {
30
+ const comment = /^(\s+)(#.*)$/.exec(rest);
31
+ if (comment === null)
32
+ emit("plain", rest);
33
+ else {
34
+ emit("plain", comment[1] ?? "");
35
+ emit("comment", comment[2] ?? "");
36
+ }
37
+ }
38
+ /** A quoted key: the closing quote must be followed by `:` and a space or the line end. */
39
+ function quotedKey(line, at) {
40
+ const end = quotedEnd(line, at);
41
+ if (end === undefined || end - at < 2 || line.charAt(end - 1) !== line.charAt(at))
42
+ return undefined;
43
+ return /^:(?:\s|$)/.test(line.slice(end)) ? { end, kind: "string" } : undefined;
44
+ }
45
+ /** An unquoted key: the text up to the first `:` that is followed by a space or the line end. */
46
+ function plainKey(line, at) {
47
+ if ("#[{|>".includes(line.charAt(at)))
48
+ return undefined;
49
+ const colon = /:(?:\s|$)/.exec(line.slice(at));
50
+ if (colon === null || colon.index === 0)
51
+ return undefined;
52
+ const text = line.slice(at, at + colon.index);
53
+ if (/(?:^|\s)#/.test(text))
54
+ return undefined;
55
+ return { end: at + text.trimEnd().length, kind: "attr" };
56
+ }
57
+ /** The mapping key at `at`: where its text ends (before the `:`) and its colour. */
58
+ function keyAt(line, at) {
59
+ const first = line.charAt(at);
60
+ if (first === "")
61
+ return undefined;
62
+ return first === '"' || first === "'" ? quotedKey(line, at) : plainKey(line, at);
63
+ }
64
+ /** The colour of an unquoted scalar read whole. */
65
+ function scalarKind(scalar) {
66
+ if (YAML_NUMBER.test(scalar))
67
+ return "number";
68
+ return YAML_KEYWORDS.test(scalar) ? "keyword" : "plain";
69
+ }
70
+ /** A value: a quoted string, or an unquoted scalar up to a comment. True when it opens a block scalar. */
71
+ function emitValue(line, from, emit) {
72
+ const value = line.slice(from);
73
+ if (value === "")
74
+ return false;
75
+ if (value.startsWith("#")) {
76
+ emit("comment", value);
77
+ return false;
78
+ }
79
+ const quoted = quotedEnd(line, from);
80
+ if (quoted !== undefined) {
81
+ emit("string", line.slice(from, quoted));
82
+ trailing(line.slice(quoted), emit);
83
+ return false;
84
+ }
85
+ const comment = /\s#/.exec(value);
86
+ const scalar = line.slice(from, comment === null ? line.length : from + comment.index).trimEnd();
87
+ emit(scalarKind(scalar), scalar);
88
+ const commentAt = comment === null ? line.length : from + comment.index + 1;
89
+ emit("plain", line.slice(from + scalar.length, commentAt));
90
+ emit("comment", line.slice(commentAt));
91
+ return YAML_BLOCK_HEADER.test(scalar);
92
+ }
93
+ /** The `- ` markers from `at`: where the content starts, and the column of the last dash. */
94
+ function emitMarkers(line, at, emit) {
95
+ let column = at;
96
+ let next = at;
97
+ for (;;) {
98
+ const marker = sticky(YAML_MARKER, line, next);
99
+ if (marker === undefined)
100
+ return { at: next, column };
101
+ column = next;
102
+ emit("plain", marker);
103
+ next += marker.length;
104
+ }
105
+ }
106
+ /**
107
+ * One YAML line. Returns the column its block scalar's lines must be indented past, when it ends
108
+ * in `|` or `>`: the key's own column, after any `- ` markers (a key-less item uses its dash's).
109
+ */
110
+ function scanLine(line, emit) {
111
+ const indent = line.length - line.trimStart().length;
112
+ emit("plain", line.slice(0, indent));
113
+ if (line.charAt(indent) === "#") {
114
+ emit("comment", line.slice(indent));
115
+ return undefined;
116
+ }
117
+ const markers = emitMarkers(line, indent, emit);
118
+ let { at, column } = markers;
119
+ const key = keyAt(line, at);
120
+ if (key !== undefined) {
121
+ column = at;
122
+ emit(key.kind, line.slice(at, key.end));
123
+ const colon = line.indexOf(":", key.end);
124
+ emit("plain", line.slice(key.end, colon + 1));
125
+ at = colon + 1;
126
+ }
127
+ const gap = line.length - at - line.slice(at).trimStart().length;
128
+ emit("plain", line.slice(at, at + gap));
129
+ return emitValue(line, at + gap, emit) ? column : undefined;
130
+ }
131
+ /** Line by line. The lines of a block scalar are one plain run: no comment or key is read in them. */
132
+ export function scanYaml(code, emit) {
133
+ let block;
134
+ for (const { body, eol } of splitLines(code)) {
135
+ const indent = body.length - body.trimStart().length;
136
+ if (block !== undefined && (body.trim() === "" || indent > block)) {
137
+ emit("plain", body + eol);
138
+ continue;
139
+ }
140
+ block = scanLine(body, emit);
141
+ emit("plain", eol);
142
+ }
143
+ }
package/dist/index.d.ts CHANGED
@@ -77,7 +77,7 @@ export { MediaTile, type MediaTileProps } from "./MediaTile.js";
77
77
  export { ScrollStrip, type ScrollStripProps } from "./ScrollStrip.js";
78
78
  export { TextLink, type TextLinkSize } from "./TextLink.js";
79
79
  export { ToolCallCard } from "./ToolCallCard.js";
80
- export { Caption, Display, Lead, Overline, PageHeading, SectionHeading, SubsectionHeading, type SubsectionHeadingSize, Text, type TextTone, type TypographyColor, type TypographyProps, } from "./typography.js";
80
+ export { Caption, Display, Lead, Overline, PageHeading, Prose, SectionHeading, SubsectionHeading, type SubsectionHeadingSize, Text, type TextTone, type TypographyColor, type TypographyProps, } from "./typography.js";
81
81
  export { DateField, type DateFieldProps } from "./DateField.js";
82
82
  export { fromISODate, toISODate } from "./isoDate.js";
83
83
  export type { CalendarDate } from "@internationalized/date";
package/dist/index.js CHANGED
@@ -73,6 +73,6 @@ export { MediaTile } from "./MediaTile.js";
73
73
  export { ScrollStrip } from "./ScrollStrip.js";
74
74
  export { TextLink } from "./TextLink.js";
75
75
  export { ToolCallCard } from "./ToolCallCard.js";
76
- export { Caption, Display, Lead, Overline, PageHeading, SectionHeading, SubsectionHeading, Text, } from "./typography.js";
76
+ export { Caption, Display, Lead, Overline, PageHeading, Prose, SectionHeading, SubsectionHeading, Text, } from "./typography.js";
77
77
  export { DateField } from "./DateField.js";
78
78
  export { fromISODate, toISODate } from "./isoDate.js";
@@ -1,4 +1,5 @@
1
1
  import { type ComponentPropsWithoutRef, type ElementType, type ReactElement, type ReactNode } from "react";
2
+ import type { Passthrough } from "./props.js";
2
3
  import type { Size } from "./size.js";
3
4
  export type TypographyColor = "default" | "secondary" | "tertiary" | "muted" | "brand";
4
5
  /**
@@ -77,8 +78,31 @@ export declare const PageHeading: {
77
78
  <E extends ElementType = "h1">(props: TypographyProps<E>): ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
78
79
  displayName: string;
79
80
  };
81
+ /** True inside a `Prose`, where the text roles take the long-form scale. Internal. */
82
+ export declare function useProse(): boolean;
80
83
  /**
81
- * Top-level section header. `h2`, text-base/6 semibold, primary text. `rule` sets
84
+ * Sets the text roles back to the app scale around what a component renders itself (a `Dialog`, a
85
+ * `Drawer`, an `EmptyState`), so one opened from a docs page looks as it does anywhere else.
86
+ * Renders no element. Internal: not exported from the package.
87
+ */
88
+ export declare function AppScale({ children }: {
89
+ children?: ReactNode;
90
+ }): ReactElement;
91
+ /**
92
+ * Long-form content, for a docs page or an article. A block (`div`) that sets text-base/7 on itself,
93
+ * so a bare `<li>` or text node inside reads at 16px, and puts the text roles inside it on the
94
+ * prose scale: `Text` text-base/7, `SectionHeading` text-2xl/8, `SubsectionHeading` `lg` text-2xl/8,
95
+ * `md` text-xl/7 and `sm` text-base/7 (a semibold line at body size), and the box of a `Callout`
96
+ * text-base/7. Every other role keeps its size. Nothing outside a `Prose` changes, and a `Dialog`,
97
+ * `Drawer` or `EmptyState` inside one is back on the app scale. An app screen never uses it: wrap the
98
+ * body of a docs page or an article, inside a `ProseLayout`, a `SidebarLayout` or anywhere else.
99
+ */
100
+ export declare function Prose({ children, ...rest }: {
101
+ children?: ReactNode;
102
+ } & Passthrough): ReactElement;
103
+ /**
104
+ * Top-level section header. `h2`, text-base/6 semibold, primary text (text-2xl/8 semibold tight
105
+ * inside a `Prose`). `rule` sets
82
106
  * a hairline under it (the `Divider` line colour) with the space either side of
83
107
  * the line built in. `description` is a muted line under the heading, outside the `<h2>`, so the
84
108
  * heading's name stays the title; the rule then sits under the description.
@@ -92,7 +116,9 @@ export declare function SectionHeading<E extends ElementType = "h2">({ rule, des
92
116
  export type SubsectionHeadingSize = Size;
93
117
  /**
94
118
  * Nested header. `h3`, text-base/6 semibold, primary text. `size` steps it down for a dense card
95
- * or up for a drawer or form-group title: `sm` (text-sm), `md` (default) or `lg`. `truncate` keeps it on
119
+ * or up for a drawer or form-group title: `sm` (text-sm), `md` (default) or `lg`. Inside a `Prose`
120
+ * `md` is text-xl/7 (the h3 of a doc), `sm` text-base/7 (the h4: body size, set apart by its weight)
121
+ * and `lg` text-2xl/8, the size of a `SectionHeading` there. `truncate` keeps it on
96
122
  * one line and cuts it with an ellipsis where its container ends, the full text as the `title`.
97
123
  */
98
124
  export declare function SubsectionHeading<E extends ElementType = "h3">({ size, ...props }: TypographyProps<E> & {
@@ -115,7 +141,8 @@ export declare const Overline: {
115
141
  /** A fixed width for a run of text: `amount` is a money figure, right-aligned, at a width that lines a column up. */
116
142
  export type TextMinWidth = "amount";
117
143
  /**
118
- * Running text. `span` (inline; `as="p"` for a paragraph), text-sm/5 normal, secondary. A block of it
144
+ * Running text. `span` (inline; `as="p"` for a paragraph), text-sm/5 normal, secondary (text-base/7
145
+ * inside a `Prose`). A block of it
119
146
  * stops at a readable line length (`max-w-[72ch]`), wherever it sits. `strong` emphasises it:
120
147
  * font-medium in the primary ink, unless `color` or `tone` says otherwise.
121
148
  * `preserveNewlines` keeps the line breaks in the text (a user-typed note); runs of spaces still collapse.
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Children, } from "react";
2
+ import { Children, createContext, useContext, } from "react";
3
3
  import { cx } from "./cx.js";
4
4
  /** Colour token → slate/violet utility, which a theme remaps. */
5
5
  const COLOR = {
@@ -83,32 +83,72 @@ export const Lead = typography("Lead", "p", "text-lg/7 font-normal sm:text-xl/8"
83
83
  * `Lead` under it and the body text after it, so the page's title reads first.
84
84
  */
85
85
  export const PageHeading = typography("PageHeading", "h1", "text-3xl/9 font-bold tracking-tight sm:text-4xl/10", "default");
86
+ // --- Prose: the long-form scale ---------------------------------------------
86
87
  /**
87
- * Top-level section header. `h2`, text-base/6 semibold, primary text. `rule` sets
88
+ * Whether the text roles below are in a `Prose`. `Text`, `SectionHeading`, `SubsectionHeading` and
89
+ * `Callout` read it and pick their prose size; outside a `Prose` it is false and nothing changes.
90
+ * Internal: not exported from the package.
91
+ */
92
+ const ProseContext = createContext(false);
93
+ /** True inside a `Prose`, where the text roles take the long-form scale. Internal. */
94
+ export function useProse() {
95
+ return useContext(ProseContext);
96
+ }
97
+ /**
98
+ * Sets the text roles back to the app scale around what a component renders itself (a `Dialog`, a
99
+ * `Drawer`, an `EmptyState`), so one opened from a docs page looks as it does anywhere else.
100
+ * Renders no element. Internal: not exported from the package.
101
+ */
102
+ export function AppScale({ children }) {
103
+ return _jsx(ProseContext.Provider, { value: false, children: children });
104
+ }
105
+ /**
106
+ * Long-form content, for a docs page or an article. A block (`div`) that sets text-base/7 on itself,
107
+ * so a bare `<li>` or text node inside reads at 16px, and puts the text roles inside it on the
108
+ * prose scale: `Text` text-base/7, `SectionHeading` text-2xl/8, `SubsectionHeading` `lg` text-2xl/8,
109
+ * `md` text-xl/7 and `sm` text-base/7 (a semibold line at body size), and the box of a `Callout`
110
+ * text-base/7. Every other role keeps its size. Nothing outside a `Prose` changes, and a `Dialog`,
111
+ * `Drawer` or `EmptyState` inside one is back on the app scale. An app screen never uses it: wrap the
112
+ * body of a docs page or an article, inside a `ProseLayout`, a `SidebarLayout` or anywhere else.
113
+ */
114
+ export function Prose({ children, ...rest }) {
115
+ return (_jsx(ProseContext.Provider, { value: true, children: _jsx("div", { ...rest, className: "text-base/7", children: children }) }));
116
+ }
117
+ const SECTION = {
118
+ app: "text-base/6 font-semibold",
119
+ prose: "text-2xl/8 font-semibold tracking-tight",
120
+ };
121
+ /**
122
+ * Top-level section header. `h2`, text-base/6 semibold, primary text (text-2xl/8 semibold tight
123
+ * inside a `Prose`). `rule` sets
88
124
  * a hairline under it (the `Divider` line colour) with the space either side of
89
125
  * the line built in. `description` is a muted line under the heading, outside the `<h2>`, so the
90
126
  * heading's name stays the title; the rule then sits under the description.
91
127
  */
92
128
  export function SectionHeading({ rule, description, ...props }) {
93
129
  const hairline = "mb-4 border-b border-slate-200/70 pb-2";
130
+ const size = useProse() ? SECTION.prose : SECTION.app;
94
131
  const hasDescription = description != null && description !== false;
95
132
  if (!hasDescription) {
96
- return pinned("h2", cx(rule && hairline, "text-base/6 font-semibold"), "default", props);
133
+ return pinned("h2", cx(rule && hairline, size), "default", props);
97
134
  }
98
- return (_jsxs("div", { className: cx("flex flex-col gap-1", rule && hairline), children: [pinned("h2", "text-base/6 font-semibold", "default", props), _jsx(Text, { as: "p", color: "tertiary", children: description })] }));
135
+ return (_jsxs("div", { className: cx("flex flex-col gap-1", rule && hairline), children: [pinned("h2", size, "default", props), _jsx(Text, { as: "p", color: "tertiary", children: description })] }));
99
136
  }
100
137
  const SUBSECTION_SIZE = {
101
- sm: "text-sm/5 font-semibold",
102
- md: "text-base/6 font-semibold",
103
- lg: "text-lg/7 font-semibold",
138
+ sm: { app: "text-sm/5 font-semibold", prose: "text-base/7 font-semibold" },
139
+ md: { app: "text-base/6 font-semibold", prose: "text-xl/7 font-semibold" },
140
+ lg: { app: "text-lg/7 font-semibold", prose: "text-2xl/8 font-semibold tracking-tight" },
104
141
  };
105
142
  /**
106
143
  * Nested header. `h3`, text-base/6 semibold, primary text. `size` steps it down for a dense card
107
- * or up for a drawer or form-group title: `sm` (text-sm), `md` (default) or `lg`. `truncate` keeps it on
144
+ * or up for a drawer or form-group title: `sm` (text-sm), `md` (default) or `lg`. Inside a `Prose`
145
+ * `md` is text-xl/7 (the h3 of a doc), `sm` text-base/7 (the h4: body size, set apart by its weight)
146
+ * and `lg` text-2xl/8, the size of a `SectionHeading` there. `truncate` keeps it on
108
147
  * one line and cuts it with an ellipsis where its container ends, the full text as the `title`.
109
148
  */
110
149
  export function SubsectionHeading({ size = "md", ...props }) {
111
- return pinned("h3", SUBSECTION_SIZE[size], "default", props);
150
+ const scale = SUBSECTION_SIZE[size];
151
+ return pinned("h3", useProse() ? scale.prose : scale.app, "default", props);
112
152
  }
113
153
  // --- Inline roles (inline by default) --------------------------------------
114
154
  /**
@@ -117,7 +157,8 @@ export function SubsectionHeading({ size = "md", ...props }) {
117
157
  */
118
158
  export const Overline = inlineRole("Overline", "span", "text-xs font-semibold uppercase tracking-wide", "tertiary");
119
159
  /**
120
- * Running text. `span` (inline; `as="p"` for a paragraph), text-sm/5 normal, secondary. A block of it
160
+ * Running text. `span` (inline; `as="p"` for a paragraph), text-sm/5 normal, secondary (text-base/7
161
+ * inside a `Prose`). A block of it
121
162
  * stops at a readable line length (`max-w-[72ch]`), wherever it sits. `strong` emphasises it:
122
163
  * font-medium in the primary ink, unless `color` or `tone` says otherwise.
123
164
  * `preserveNewlines` keeps the line breaks in the text (a user-typed note); runs of spaces still collapse.
@@ -128,7 +169,8 @@ export const Overline = inlineRole("Overline", "span", "text-xs font-semibold up
128
169
  * fixes a width and right-aligns the figure in it (a column of amounts lines up); it does not take `align`.
129
170
  */
130
171
  export function Text({ strong, preserveNewlines, minWidth, italic, breakWords, ...props }) {
131
- return pinned("span", cx(textVariant(strong, preserveNewlines, true), italic && "italic", breakWords && "break-words", minWidth === "amount" && "inline-block min-w-24 text-end"), strong ? "default" : "secondary",
172
+ const prose = useProse();
173
+ return pinned("span", cx(textVariant(strong, preserveNewlines, true, prose), italic && "italic", breakWords && "break-words", minWidth === "amount" && "inline-block min-w-24 text-end"), strong ? "default" : "secondary",
132
174
  // An amount is always in tabular figures, so it lines up; `numeric` already emits them.
133
175
  { ...props, numeric: props.numeric || minWidth === "amount" });
134
176
  }
@@ -137,10 +179,10 @@ export function Text({ strong, preserveNewlines, minWidth, italic, breakWords, .
137
179
  * it runs the width of its dialog. Internal; not exported from the package.
138
180
  */
139
181
  export function HeaderText({ children }) {
140
- return pinned("p", textVariant(false, false, false), "secondary", { children });
182
+ return pinned("p", textVariant(false, false, false, useProse()), "secondary", { children });
141
183
  }
142
- function textVariant(strong, preserveNewlines, measure) {
143
- return cx(preserveNewlines && "whitespace-pre-line", measure && "max-w-[72ch]", "text-sm/5", strong ? "font-medium" : "font-normal");
184
+ function textVariant(strong, preserveNewlines, measure, prose) {
185
+ return cx(preserveNewlines && "whitespace-pre-line", measure && "max-w-[72ch]", prose ? "text-base/7" : "text-sm/5", strong ? "font-medium" : "font-normal");
144
186
  }
145
187
  /**
146
188
  * Fine print / metadata. `span` (inline; `as="p"` for a block), text-xs/4 normal, tertiary. Takes `tone`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hackcove/ui-react",
3
- "version": "0.10.1",
3
+ "version": "0.10.2",
4
4
  "description": "Hack Cove React components: primitives, typography roles and a safe Markdown renderer, written against Tailwind v4 utilities. Pair with a theme from @hackcove/themes.",
5
5
  "keywords": [
6
6
  "components",