preact-components
Skip to content
Everything
preact-components

Everything

preact-components

Preact components, Tailwind styles, icons and signal helpers for Deno apps — every one running live on this page, with the code next to it.

196 live cards · 119 icons · 9 packages

General instructions

What @spy4x/preact-theme provides, and the page conventions the components assume.

Every class below is a Tailwind 4 utility emitted by preset.css. Import tokens.css before it to get the palette these utilities read; the presets fall back to the library's own values if you skip it.

Put .theme-base on <body> to opt into the document-level font, colour and canvas — nothing is applied to the host page by importing the preset. Use .page-layout for the standard page width.

The ui/ primitives in this catalogue deliberately do not use these classes: they inline their own utilities so they render without the token layer. Reach for a component first and a class second.

Colour atoms

.text-primary.bg-primary.border-primary.rounded-primary.text-muted.bg-canvas.bg-surface.border-subtle.border-control.bg-danger.bg-warning.bg-success.text-danger.text-warning.text-success

Type

.h1.h2.h3.h4.h5.link.page-layout.list-ul.theme-base

Buttons

.btn.btn-primary.btn-primary-outline.btn-danger.btn-danger-outline.btn-warning.btn-warning-outline.btn-success.btn-success-outline.btn-icon.btn-link.btn-input-icon.btn-disabled

Forms

.input.select.textarea.label.checkbox.radio

Surfaces

.card.card-header.card-body.card-footer.scrollbar

Data display

.num.kpi.kpi-label.kpi-value.bar

Map

.map-marker.status-on.status-off.status-unknown

Removed, and not coming back

  • .h6 — use `text-base font-medium`
  • .btn-sm — use `h-9 px-4` on the button
  • .power-anomaly — application-specific wording, not part of this library — colour a marker with `status-on` / `status-off` / `status-unknown` instead

@spy4x/preact-ui

UI

The controls and surfaces an app is assembled from: buttons and badges, tables and meters, fields and pickers, dialogs, toasts and the empty and error states.

Badges

Every palette entry, filled and outlined.

<Badge />

Status pill. Static: takes `text`, renders a `span`. `color` defaults to `purple`, `type` to `filled`.

filled (default)redorangegreengraybluepurplepurpleNav
outlineredorangegreengraybluepurplepurpleNav
Usage
<Badge text="paid" color="green" />
<Badge text="draft" color="gray" type="outline" />

<StatusMark />

A shape paired with a word (#257): `ready`/`beta`/`wip`/`paused`/`archived`/`known-issue`. The shape is decorative (`aria-hidden`) and told apart without colour — six distinct silhouettes — while `label` carries the real meaning as text. A new component beside `Badge`, not a change to it.

ReadyBetaWIPPausedArchivedKnown issue
Usage
<StatusMark status="ready" />
<StatusMark status="known-issue" label="Flaky on Safari" />

<CiStatusPill />

A CI status string mapped to a coloured pill, extending `Badge`'s palette. Presentational only — fetching the actual CI state is the consuming site's job. A status this component does not recognise (anything but `passing`/`failing`/`running`) falls back to a neutral pill instead of throwing.

PassingFailingRunningqueued
Usage
<CiStatusPill status="passing" />
<CiStatusPill status="queued" />  {/* unrecognised → neutral pill, its own text */}

Buttons

Variants × sizes, plus the components that wrap a button around a side effect (clipboard, geolocation, a CSV download).

<Button />

Native button with the library's variant and size vocabulary. All other button attributes pass through; `type` defaults to `button`.

sm
md
lg
clicked 0 times
Usage
<Button variant="primary" size="md" onClick={save}>Save</Button>
<Button variant="danger" disabled>Delete</Button>

<CopyButton />

Copies `textToCopy`, icon-only without `title`. The optional `copy` port replaces the browser clipboard.

port received: nothing yet

Usage
<CopyButton textToCopy={invoice.id} />
<CopyButton textToCopy={invoice.id} title="Copy id" copy={app.clipboard.copy} />

<GeoButton />

Asks the browser for the current position and routes it to `onLocation`; failures become an `onError` message.

onLocation / onError: not asked yet

Usage
<GeoButton
  onLocation={(position) => map.center.set(position)}
  onError={(message) => app.toast.error({ body: message })}
/>

<ExportButton />

Downloads `rows` — or the result of `getRows`, called on click — as an RFC 4180 CSV file, UTF-8 with a byte-order mark. A cell that reads like a spreadsheet formula is guarded with a leading `'`.

Usage
<ExportButton
  columns={[{ key: "id", header: "ID" }, { key: "name", header: "Name" }]}
  getRows={() => api.attendees.list()}
  fileName="attendees.csv"
/>

Display

Everything that presents rather than collects: the page title, headings, the table shell, the meters, the card parts, tabs, pagination and the avatar family.

<PageTitle />

Page heading carrying the library's `h1` typography. `class` replaces the default utilities entirely, so spacing is the caller's call.

Transactions

Nested detail with an overridden scale

Usage
<PageTitle>Transactions</PageTitle>
<PageTitle class="mb-2 text-xl">Nested detail</PageTitle>

<ConfidenceMeter />

Horizontal meter for a `0…100` score, banded into low / medium / high. Width is inline, so it is correct before hydration.

12%12 — low
Low confidence
55%55 — medium
Medium confidence
88%88 — high
High confidence
0%-20 → 0
Low confidence
100%140 → 100
High confidence
Usage
<ConfidenceMeter value={88} label="match" />

<MoneyDisplay />

Renders an amount in a currency's smallest unit through `Intl.NumberFormat` — `amount={12345}` is €123.45 for `EUR`, ¥12,345 for `JPY` (no minor unit), and three decimals for `KWD`, each asked from `Intl` rather than assumed. `colorNegative` colours a negative amount red; every other style is the caller's own `class`.

€123.45 — two decimals

¥12,345 — none, the yen has no minor unit

KWD 12.345 — three, the Kuwaiti dinar's own count

-€45.99 — colorNegative

Usage
<MoneyDisplay amount={12345} currency="EUR" />
<MoneyDisplay amount={-4599} currency="EUR" colorNegative />

<Table />

Table shell with header, body and optional footer slots. The primitive owns dividers, hover and horizontal scroll; the caller owns every cell.

DateMerchantStatusAmount
2026-02-01Coffee & Copending-450
2026-02-02Salary transfercleared450000
2026-02-03Amazon purchasepending-12999
Net436551
Usage
<Table
  headerSlot={<th scope="col">Merchant</th>}
  bodySlots={rows.map((row) => <td>{row.merchant}</td>)}
  footerSlot={<tr>…</tr>}
/>

<DataTable />

`Table`'s markup joined to `table-state`'s sort rules: a sortable, optionally paged table with no sort or page state of its own — the caller owns `sort` and `paging.page`, so either can live in a signal or a URL parameter. Every sortable header is a real button; `aria-sort` carries the state, the chevron beside it is decorative. `caption` is required — it is the table's name, and only the caller knows it.

sort: none

Invoices
2026-02-01Coffee & Co-450
2026-02-02Salary transfer450000
2026-02-03Amazon purchase-12999
Usage
<DataTable
  caption="Invoices"
  columns={[
    { key: "date", header: "Date", sortable: true },
    { key: "merchant", header: "Merchant", sortable: true },
    { key: "amount", header: "Amount", sortable: true, align: "right" },
  ]}
  rows={invoices}
  rowKey={(row) => row.id}
  sort={sort.value}
  onSortChange={(next) => sort.value = next}
  paging={{ page: page.value, pageSize: 3, onChange: (next) => page.value = next }}
/>

<Card />

Card surface carrying the preset's `card` utility and nothing else — no padding or width opinion of its own. Header, body and footer are optional children, and a caller's `class` is merged after the preset's, so a later utility wins.

Card
default

Header, body and footer are each optional.

Raw header markup

children win

A header with no title or action renders its children instead.

Ada Lovelace
Email
ada@example.com
Role
Administrator
Invoices
3
Usage
<Card>
  <CardHeader title="Invoices" action={<Button size="sm">New</Button>} />
  <CardBody>…</CardBody>
  <CardFooter><Button variant="outline" size="sm">Dismiss</Button></CardFooter>
</Card>

<CardHeader />

Card header in one of two mutually exclusive modes: `title` plus an optional right-aligned `action` slot, or raw `children`, which replace both. Mixing them is a type error rather than markup that silently drops half the header.

Card
default

Header, body and footer are each optional.

Raw header markup

children win

A header with no title or action renders its children instead.

Ada Lovelace
Email
ada@example.com
Role
Administrator
Invoices
3
Usage
<CardHeader title="Invoices" action={<Button size="sm">New</Button>} />

<CardHeader>
  <h4>Raw header markup</h4>
  <span>children win over title/action</span>
</CardHeader>

<CardBody />

The content region of a card: the `card-body` utility and a slot, nothing more. There is no padding prop — a caller that wants different geometry passes utilities through `class`.

Card
default

Header, body and footer are each optional.

Raw header markup

children win

A header with no title or action renders its children instead.

Ada Lovelace
Email
ada@example.com
Role
Administrator
Invoices
3
Usage
<CardBody>
  <p>Header, body and footer are each optional.</p>
</CardBody>

<CardFooter />

Divider-separated action row at the bottom of a card, normally holding a row of buttons. Like the header it is a slot, so a table footer or a caption goes in it just as well.

Card
default

Header, body and footer are each optional.

Raw header markup

children win

A header with no title or action renders its children instead.

Ada Lovelace
Email
ada@example.com
Role
Administrator
Invoices
3
Usage
<CardFooter>
  <div class="flex justify-end gap-2">
    <Button variant="outline" size="sm">Dismiss</Button>
    <Button size="sm">Open</Button>
  </div>
</CardFooter>

<FactCard />

Key/value facts as a real `<dl>`, composed from `Card`/`CardHeader`/`CardBody` (#257) rather than a fourth card primitive. `title`/`action` are optional — omitting `title` leaves the header out entirely.

Antonshubin.com
Stack
Deno + Hono + Fresh
Hosting
Hetzner, one Compose stack
Status
In production
Usage
<FactCard
  title="Antonshubin.com"
  facts={[
    { key: "Stack", value: "Deno + Hono + Fresh" },
    { key: "Status", value: <StatusMark status="ready" /> },
  ]}
/>

<MarginNote />

A short aside with an optional source link or a `checked on` date, rendered as a real `<time>`. It floats beside its paragraph in a column at least 30rem wide and sits inline in a narrower one, whatever the screen size — a CSS container query, so the column carries Tailwind's `@container` class. At most window widths this card's column is narrower than 30rem, so the note sits inline; in a window about 600 to 720px wide the column is wider than 30rem and the note floats.

The library ships a component-testing harness that drives a real browser over the DevTools protocol, so behaviour behind an effect, a key press or a timer is proven in the browser rather than assumed from a string render. Every claim in the catalogue that depends on a real event is backed by one of those checks.

Usage
<div class="@container flow-root">
  <MarginNote sourceHref={benchmarkUrl} sourceLabel="Benchmark" checkedOn="2026-09-01">
    Cold start under 50ms on a shared vCPU.
  </MarginNote>
  <p>The paragraph the note sits beside.</p>
</div>

<InstallBox />

A code snippet box with a copy button, built on `CopyButton` rather than a second clipboard implementation. The command is real, selectable text — not a background image.

deno add jsr:@spy4x/preact-ui
Usage
<InstallBox command="deno add jsr:@spy4x/preact-ui" />

<Progress />

Determinate progress bar, captioned or not; with no reading it renders a bare track and omits `aria-valuenow` rather than claiming `0`. Width is inline so the server render is already correct, and a reading outside `0…max` is clamped by `clampProgress`.

primary (default)68%
success68%
warning68%
danger68%
Determinate42%

42 of 100 — 42%

Clamped100%

140 of 100 → the track is full, not overflowing

Out of range0%

-20 of 100 → 0%, still measurable

Fractional33%

1 of 3 — floored to 33%, never 34%

Indeterminate

no aria-valuenow, no fill

Usage
<Progress id="upload" label="Upload" value={42} tone="success" />

// No reading yet: the honest indeterminate DOM, not a bar at zero.
<Progress value={undefined} />

<Tabs />

Controlled tablist and panels: `active` in, `onChange` out, no state of its own. Every panel is rendered with the inactive ones `hidden` by default, so each `aria-controls` resolves; ids are derived from `TabItem.id` rather than generated. Arrow-key navigation is browser-only — its decision table is `nextTabIndex`, unit-tested in the component.

horizontal (default) — ArrowLeft / ArrowRight · active: guide-tab-overview

Totals for the current period.
the buttons drive the same `onChange`, which is what a click on a tab does

vertical — ArrowUp / ArrowDown

Name, email, avatar.
Usage
<Tabs
  tabs={[
    { id: "overview", label: "Overview", content: <Overview /> },
    { id: "detail", label: "Detail", content: <Detail />, disabled: true },
  ]}
  active={view.value}
  onChange={(id) => view.value = id}
  orientation="vertical"
  label="Report views"
/>

<Pagination />

Page numbers with the long runs collapsed, plus previous/next. Controlled: `page` is rendered (clamped) and every request leaves through `onChange`. From two pages up, a control that cannot act stays where it is and carries `aria-disabled` — unmounting it, or disabling it natively, would take focus off the button a keyboard user is pressing. `pageCount={0}` renders nothing and `pageCount={1}` renders the one page with no controls, since neither can lose anybody their place. Every page number's name comes from `pageLabel`, which defaults to `Page N`.

pageCount=5 · page 1 — Previous is disabled on page 1 and Next on the last page, and neither leaves the page, so a keyboard user keeps the control they were pressing: 1 2 3 4 5

last page asked for through onChange: none yet — a disabled control asks for nothing at all, which is the only way to tell it from one whose request the component clamped back to the page it was already on.

pageCount=24 · page 12 — the ends are always shown, the current page keeps two neighbours on each side, and a run nobody needs becomes an ellipsis, which never hides a single page: 1 … 10 11 12 13 14 … 24

pageCount=0 · page 1 — an empty result set renders nothing rendered

Nothing above this line: `pageCount=0` returns `null`, so a list that found no rows needs no special case at the call site.

Usage
<Pagination page={page.value} pageCount={24} onChange={(page) => page.value = page} />

// An empty result set needs no special case at the call site.
<Pagination page={1} pageCount={0} onChange={() => {}} />

<Avatar />

Round image with two fallbacks: the initials of `name`, then `IconUser` when the name yields no initial. `src` is used while it is set and has not errored, and a failure is remembered per URL — changing `src` clears it, so a dead URL can be replaced with a live one. `alt=""` makes the box decorative.

AL

xs

AL

sm

AL

md (default)

AL

lg

Ada Lovelace

image — `src` set and not failed

GH

initials — from `name`, two at most

icon — no usable initial in the name

WX

initials — one per CJK character

`alt=""` — decorative, hidden from assistive tech

Usage
<Avatar name="Ada Lovelace" size="lg" />
<Avatar name="Ada Lovelace" src={user.avatarUrl} />
<Avatar name="😀" />                        {/* no usable initial → the icon face */}
<Avatar src={user.avatarUrl} alt="" />       {/* named elsewhere in the row */}

<AvatarGroup />

Overlapping stack of avatars with a `+N` chip for the rest. `max` decides how many fit and the chip counts the remainder, and it is absent when nothing overflows. The group is one labelled object — `role="group"`, members decorative — whose name from `label` carries the total.

`label="Reviewers"` · 3 members — no chip, because nothing overflows

`max=4` · 7 members — the chip counts the rest, and the group is named “Project members (7)”

`size="sm"` · no `label` — the accessible name falls back to “Avatars (7)”

Usage
<AvatarGroup items={members} label="Project members" max={4} size="sm" />

<CopyableText />

Monospace value with an integrated copy control, plus a polite live region that announces the copy — `CopyButton` confirms by swapping its glyph, which reaches neither a screen reader nor an `aria-label`. `copiedForMs` (1500 default) is the only state it holds; the clipboard write is the `copy` port.

0192f7c1-4d5e-7a8b-9c0d-1e2f3a4b5c6d

the port received: nothing yet — copying needs a click

a-very-long-identifier-that-does-not-fit-in-the-column-it-lives-in

`truncate` clips the line only: the element keeps the whole value as its text, so its accessible name is the full string, and the title tooltip shows what the ellipsis ate.

BTC-USD-4h-2026-02

`CopyableTextBody` with `copied=` — the stateless half, whose props are assertable without a click.

Usage
<CopyableText text={invoice.id} />
<CopyableText
  text={reference}
  truncate
  copyLabel="Copy reference"
  copiedLabel="Reference copied"
  copy={app.clipboard.copy}
/>

<CopyableTextBody />

The stateless body of `CopyableText`: the same value, copy control and live region with `copied` in and `onCopy` out, which is what makes the props it hands down — the copy port above all — assertable without a render that holds state.

0192f7c1-4d5e-7a8b-9c0d-1e2f3a4b5c6d

the port received: nothing yet — copying needs a click

a-very-long-identifier-that-does-not-fit-in-the-column-it-lives-in

`truncate` clips the line only: the element keeps the whole value as its text, so its accessible name is the full string, and the title tooltip shows what the ellipsis ate.

BTC-USD-4h-2026-02

`CopyableTextBody` with `copied=` — the stateless half, whose props are assertable without a click.

Usage
<CopyableTextBody
  text={seriesKey}
  copied={copied.value}
  onCopy={() => copied.value = true}
  copyLabel="Copy series key"
/>

<Tooltip />

Supplementary hint revealed by hover and by keyboard focus, anchored with CSS only — no measurement, no scroll or resize listener. Escape dismisses a hint without moving focus, and the pointer can travel onto the hint and rest there, so it can be read under magnification. `label` is the trigger's own accessible name, carried by a `<button>` wrapper that may actually hold one, and the tooltip text is only ever its description; `focusable={false}` makes the wrapper a `role="group"` instead, leaving the single tab stop to the caller's own control.

top (default)

right

bottom

left

An interactive trigger keeps its own tab stop, so the wrapper drops its own

`focusable={false}` — with a <button> inside, a second tab stop for one control is a keyboard trap rather than a convenience.

The one hint here that reveals itself. Point at it and it stays up while the pointer is on it, because the gap to it is the surface's own padding rather than dead space; press Escape and it goes without your focus moving, and comes back on the next hover or focus.

Usage
<Tooltip content="Supplements the trigger" label="Total revenue" placement="right">
  <span>Revenue</span>
</Tooltip>

// Interactive children own the tab stop already:
<Tooltip content="Archive the invoice" label="Archive" focusable={false}>
  <button type="button">Archive</button>
</Tooltip>

<ImageGallery />

A strip of thumbnails — real `<button>` elements, named by each image's `alt` — that opens `Lightbox` on the one pressed. `images` is `{ src, alt, thumbSrc? }`; `thumbSrc` is the thumbnail's own source and defaults to `src` when left out. An image whose `alt` is empty after trimming is dropped before render, not in the strip and not in the lightbox's sequence, and this is silent — `alt` is already required by the type, so an empty one only reaches this component through a caller that bypassed it.

5 images passed in, 4 shown — the one whose alt is blank after trimming is dropped from the strip and from the lightbox's sequence, not rendered with a placeholder name.

Usage
<ImageGallery
  images={[
    { src: hero, alt: "A hero shot" },
    { src: team, alt: "The team", thumbSrc: teamThumb },
  ]}
/>

<Lightbox />

The dialog `ImageGallery` and `system/image-lightbox.tsx`'s content mode both open: the current image, its `alt` as a caption, labelled previous/next, a "3 of 8" counter announced through a live region that is present whether the dialog is open or not. `images`, `index`, `open`, `onClose` and `onIndexChange` are all the caller's own state — built on a native `<dialog>` rather than `ui/Modal`, so a listener can be attached to the dialog itself for Left and Right; see the component's own doc for what about `Modal` did not fit. Escape and a backdrop click close it natively, and focus returns to whatever had it before the dialog opened.

Left and Right page through the three images while the dialog is open; Escape closes it and returns focus to whichever button opened it. The fourth button opens a lightbox whose one image has no description — nothing happens, because describedImages leaves it nothing to show.

Usage
<Lightbox
  images={images}
  index={index}
  open={open}
  onClose={() => setOpen(false)}
  onIndexChange={setIndex}
/>

Feedback

What a page shows while it is busy, empty or broken: spinners and skeletons, the error and empty states, the toast, and the two dialogs. The overlay-style ones are pinned inside a box here.

<ErrorState />

Inline error banner. Renders nothing for an empty, `null` or `undefined` message, so a possibly-empty value can be passed straight through.

The block above is empty on purpose — message="" returns null.

Usage
<ErrorState message={error.value} />

<EmptyState />

Placeholder for a list, table or search that produced no rows. Every string is a prop, and the component owns layout and nothing else; an instance with no slot at all renders `null`. It is `role="status"`, so an empty collection is announced politely rather than raising an alert.

No invoices yet

Invoices appear here once a customer is billed.

No filters applied

The third instance passes no slot at all and renders nothing — an empty state cannot invent copy, and the guide ships no product sentence to fall back on.

Usage
<EmptyState
  icon={<IconFolder class="size-5" />}
  title="No invoices yet"
  description="Invoices appear here once a customer is billed."
  action={<Button size="sm">New invoice</Button>}
/>

<LoadingSpinner />

Inline spinner in a polite live region. `label` is the visible caption; without one only a screen-reader “Loading” remains.

small

medium (default)

large

Loading
Usage
<LoadingSpinner size="lg" label="Loading transactions…" />

<LoadingSkeleton />

Placeholder layout shown while a result loads. The whole tree is `aria-hidden`, so a screen reader hears the caller's status message instead of empty boxes.

Usage
<LoadingSkeleton rows={2} />

<SkeletonText />

Paragraph-shaped placeholder: `lines` bars, each at its own width from `widths`, which cycles when it is shorter than `lines`. With no `widths` every line is full width, and the bars carry the same `text-sm` line box the real paragraph does.

full lines (no widths prop)

widths=undefined — a shorter list cycles, so three lines read 100 / 100 / 100

explicit percentages

widths=[100,85,60] — a shorter list cycles, so three lines read 100 / 85 / 60

a cycled pattern

widths=[90,40] — a shorter list cycles, so three lines read 90 / 40 / 90

the full keyword

widths=["full",70] — a shorter list cycles, so three lines read 100 / 70 / 100

Usage
<SkeletonText lines={3} widths={[100, 85, 60]} />

// The ragged right edge belongs to the copy, so a paragraph with no width list is all-full:
<SkeletonText lines={2} />

<SkeletonTable />

Table-shaped placeholder mirroring a real `Table`'s boxes: the same wrapper, the same row heights, one `grid-template-columns` on the header and every row. `widths` are relative weights, a description of the shape rather than a mirror of `table-auto`'s content sizing, and `reserveHeight` follows the real table's `min-h-[300px]`.

`rows=4 columns=3` — 4 × 3 = 12 placeholder cells, each row 53px tall

`widths=[3,1,2]` — 2 × 3 = 6 placeholder cells, each row 53px tall: the weights are the caller's description of the split, not a mirror of it, because the real `Table` is `table-auto` and sizes from content

`reserveHeight=` — for a real table that has dropped `min-h-[300px]`, which is what an empty result set renders

`rows=1 columns=0` — nothing to render, so no header and no cells

Usage
<SkeletonTable rows={4} columns={3} />

<SkeletonTable widths={[3, 1, 2]} rows={2} />

// An empty result set: the real table dropped its height reservation too.
<SkeletonTable rows={0} columns={0} reserveHeight={false} />

<SkeletonCards />

Card-grid placeholder: `columns × rows` boxes on the grid a card grid is written with, each carrying the real `Card`'s surface utilities and `lines` text bars inside it. The column count stays a prop because the shipped utilities only express two and three columns.

`columns=2 rows=1 lines=2` — a two-up grid of one row

`columns=3 rows=2 lines=3` — the 3 × 2 grid, three bars per card

`lines=0` — the card boxes with no copy reserved inside them

Usage
<SkeletonCards columns={3} rows={2} lines={2} />

// A two-up grid overrides the shipped three-column default through class:
<SkeletonCards columns={2} lines={3} class="lg:grid-cols-2" />

<SkeletonStatus />

The live region that announces a load the skeletons are silent about: `role="status"`, polite, `sr-only`, so one instance can cover a table, a grid and a paragraph. A blank or absent label renders `null` rather than an empty region.

Loading the invoice list…

Only the first instance is in the DOM: `role="status"`, `aria-live="polite"` and `sr-only`, which is why a sighted reader sees the placeholder and nothing else. The second passes whitespace and renders nothing.

Usage
<SkeletonStatus label="Loading the invoice list…" />
<SkeletonTable rows={4} columns={3} />

<LoadingScreen />

Full-viewport loading overlay: `message` plus an optional second line.

Loading

Loading…

Please wait…

Usage
<LoadingScreen message="Syncing" description="This can take a minute." />

<Modal />

Dialog on the platform's `<dialog>`, opened by mounting it through `showModal()` — the `open` attribute is deliberately never rendered, because `<dialog open>` is the non-modal state and `showModal()` throws on it. `open` is either caller-owned or seeded by `defaultOpen`, every close leaves through `onClose` — always the one from the latest render, so an inline handler reads the state the parent has now — and `title` supplies the accessible name unless `ariaLabel` does. `ariaDescribedBy` points at the element holding the body, and `role="alertdialog"` is for a panel that has to be answered.

nothing is mounted until a trigger is pressed: closed

step 1; the close port last read nothing yet

Usage
<Modal
  open={open.value}
  onClose={() => open.value = false}
  title="Rename the list"
  cancelLabel="Close"
  tone="danger"
  footer={<Button onClick={save}>Save</Button>}
>
  <p>Modal body.</p>
</Modal>

<ConfirmDialog />

Confirmation panel: a title, one question as `message` or richer `children`, and two labelled actions. It announces itself as an `alertdialog` and points `aria-describedby` at its question, so a screen reader reads what is about to happen and not just two verbs. `confirmLabel` and `cancelLabel` default to English and a caller's own verbs override them. Nothing closes itself: `onConfirm` and `onCancel` are ports, and only the caller moves its own flag.

outcome: nothing confirmed yet

Usage
<ConfirmDialog
  title="Delete invoice INV-0007?"
  message="This cannot be undone."
  confirmLabel="Delete"
  cancelLabel="Keep it"
  tone="danger"
  onConfirm={() => deleteInvoice()}
  onCancel={() => confirming.value = false}
/>

<Toastr />

Stack of transient notifications. The caller owns the stack: it arrives as `toasts` and removal is the `onDismiss` port, which the per-toast auto-dismiss timer also calls. **This component owns every dismiss timer**, including for toasts that came out of `createToastStore` — that store holds the list and schedules nothing, because the side that can see a pointer resting on a toast is the side that should be timing it. The stack is in the document at all times, empty included — a named region marked `aria-live="polite"`, because an area created together with its first message is commonly not announced at all; empty it has no children and no height, so it costs a landmark rather than layout. An error toast carries `role="alert"` and interrupts, every other variant `role="status"`. The timer pauses while the pointer is over the stack or focus is inside it and resumes with the time it had left, so the dismiss control is reachable rather than a race; `duration: 0` means keep this toast until somebody dismisses it, and raising a toast's `duration` while it is on screen refills the budget, which is how a caller extends one. Every string is a prop with an English default — `label`, `dismissLabel`, and `ToastItem.dismissLabel` for one toast — and `data-e2e` is rendered only when `dataE2E` is passed.

The store is holding 0 toast(s) — the same number the live area below shows, because the component removes through the store rather than beside it. A toast that names no delay of its own stays 5000 ms, which is the component's default and the only auto-dismiss default in the library.

Nothing pushed yet. The live area is already in the box below, empty and zero pixels tall — that is what lets a screen reader announce a toast that arrives later.

Put the pointer over the stack, or tab into it, and every timer stops; each one picks up the time it had left when you leave, rather than starting over — including these, which came out of the store. Raising a toast's `duration` is the one thing that refills its budget, which is what the extend control does. The error toast is a `role="alert"`, so it interrupts a screen reader; the rest are `role="status"` inside a polite region.

Usage
<Toastr
  toasts={app.toast.list.value}
  onDismiss={(id) => app.toast.remove(String(id))}
  label="Benachrichtigungen"
  dismissLabel="Ausblenden"
/>

// One toast naming its own control, which is worth doing when several are on screen at once:
{ id: "upload", type: "error", body: "Upload failed", dismissLabel: "Dismiss the upload error" }

Inputs

The controlled controls ui/ owns: switches, the dropdown's trigger-panel pair, and the composite pickers built from them. The Fields section below is the text-and-form half of the same story.

<ToggleSwitch />

Two-state switch, controlled. Renders `value`, reports the intended value through `onToggle`; persistence belongs to the caller.

archived: off
notifications: on
disabled
Usage
<ToggleSwitch
  value={archived.value}
  onToggle={(next) => archived.value = next}
  label="Show archived"
/>

<OnOffButtons />

Segmented ON/OFF pair. `value` of `undefined` leaves both halves unselected; `amount` annotates them with counts.

value: undefined
with counts and custom labels
Usage
<OnOffButtons
  value={showActive.value}
  amount={{ on: 128, off: 14 }}
  onSwitch={(on) => showActive.value = on}
/>

<Dropdown />

Trigger plus a real menu. Opening it moves focus to the first item; Arrow Down and Arrow Up walk the items with Home and End at the ends; Escape closes it and gives the trigger its focus back; and it closes as soon as focus leaves, which one Tab press does because the items are out of the tab order. Outside-click still dismisses it. The trigger's accessible name is required and comes in two shapes, because one `aria-label` cannot serve both: `triggerLabel` names an icon trigger, and `triggerNamedByContent` declares that a trigger's own visible text is its name so nothing is written over it. Open/closed is local state, so the panel is `hidden` in the server render, and every `document` access sits in an effect or a handler.

Default icon trigger

Custom trigger

`vertical="up"` — last table rows

`horizontal="left"`

Usage
<Dropdown
  trigger={<IconEllipsisVertical />}
  triggerLabel="Row actions"
  menuLabel="Row actions"
  vertical="up"
>
  <DropdownItem href={editHref}>Edit</DropdownItem>
  <DropdownItem onClick={archive}>Archive</DropdownItem>
</Dropdown>

<DropdownItem />

One item of a `Dropdown`'s menu: a link with `href` and a `<button>` without one. It exists because `role="menuitem"` cannot be applied from outside — `Dropdown` receives its children already rendered, and a menu whose children carry no role is, to a screen reader, a menu with nothing in it. The item is out of the tab order, because inside a menu the arrow keys move between items and Tab leaves altogether. A disabled item is skipped by those keys rather than landed on.

Usage
<DropdownItem href="/regions/1/edit">Edit</DropdownItem>
<DropdownItem class="text-red-600" onClick={archive}>Archive</DropdownItem>
<DropdownItem disabled onClick={archive}>Archive</DropdownItem>

<ToggleField />

Labelled row for a `ToggleSwitch`: label and switch on one line, `description` and `error` under them. It deliberately does not render through `Field` — a switch is a `<button>`, which is not a labelable element, so there is no `for` to write: `aria-labelledby` names the switch and the label carries the click the browser will not perform. The two message ids are the ones `Field` uses, `${id}-error` and `${id}-hint`.

Hides the row from everyone without the administrator role.

Send an email when a run fails.

This caller discards the value, which is what a read-only setting looks like: the row stays live in the markup.

The control itself is disabled, and the label is dimmed with it.

Your role cannot change this setting.

archived: off · admin only: on · notifications: on

The last three rows are `aria-describedby` targets: `error` renders a polite live region and `description` a hint, both wired from the same required id — the `-error` and `-hint` suffixes, the ids `Field` would have used. Empty or whitespace-only text renders but is not referred to.

Usage
<ToggleField
  id="show-archived"
  label="Show archived"
  value={archived.value}
  onToggle={(next) => archived.value = next}
  description="Hides the row from the list"
  required
/>

<FileInput />

File picker on a real `<input type="file">`, hidden with `sr-only` so keyboard, screen-reader and plain-form-post behaviour all stay native; the drop zone is a `<div>` around it, not a second label. `accept` and `maxSize` refusals go through `onReject` and an always-present live region; `previews` (default on) shows and revokes an image thumbnail per file. Does not upload.

Images only, up to 2 MB

Choose filesor drag and drop

PNG or JPEG, up to 2 MB each

chosen: none

refused: none

Disabled

Choose filesor drag and drop

Inside a Field

Choose filesor drag and drop

Up to 2 MB each

`Field` renders the one visible label; `FileInput` itself gets no `label`, `hint` or `error` here, so it renders none of its own.

Single file only (drop or pick more than one)

Choose filesor drag and drop

refused: none

A plain form post

Choose filesor drag and drop

No `onSubmit`: whatever this form posts is the browser's own multipart body, built from the native input `FileInput` wraps.

Usage
<FileInput
  id="attachments"
  label="Attachments"
  hint="PNG or JPEG, up to 2 MB each"
  accept="image/png,image/jpeg"
  maxSize={2 * 1024 * 1024}
  multiple
  onFiles={(files) => chosen.value = files}
  onReject={(reasons) => refused.value = reasons}
/>

<Combobox />

Searchable single-select: the ARIA combobox pattern by hand, with `role="listbox"` options and focus never leaving the input — the highlight is announced through `aria-activedescendant`, moved by the keyboard alone and followed by the list, which scrolls to keep it on screen. Items may be any `T` once `getLabel` says what to render and match; `filter` replaces the built-in substring match, and a controlled `query` with `onQueryChange` is the server-side shape. Every string it shows defaults to English and takes an override: `placeholder`, `emptyMessage`, `countMessage` and `clearLabel`. One `role="status"` region is rendered with the field and never taken away, empty until the field has been used, and the answers arrive inside it: how many options the query left, or the empty message when it left none. The empty message waits until the field is open or has a query in it, so a field nobody has touched — one whose options are still loading, say — never claims there are no matches.

String items

selected BTC

Object items

nothing selected

`renderOption` replaces the option's content, not its <li>: the `role="option"`, `aria-selected` and the highlight stay the component's.

Controlled query

controlled query: (empty) · nothing selected

`filter` replaces the built-in substring match: this one is a prefix match, the kind a server-side search would write.

Longer than the popup

nothing selected

Nothing to offer yet

no options yet

An empty `items` list. Closed and untouched its live region is there and empty; open it and the empty message answers the question that opening it asked. Arm the button, open the field, and watch the options land underneath it: the message goes, and the first row is highlighted, so `Enter` picks something without an arrow key first.

What is announced, and when

nothing selected

The `role="status"` region under this field is in the page from the first render, empty. Type, and the count of what is left arrives inside that same element — visually hidden, because the rows themselves are the sighted answer. Type something that matches nothing and the empty message takes its place. Clear the field and it goes quiet again.

The popup is in the markup at all times and marked `hidden` while closed, so what is rendered here is the closed state: `role="combobox"` with `aria-expanded="false"`, `aria-controls` pointing at the `role="listbox"`, and every option already present. Opening it, the `aria-activedescendant` highlight and the key map are browser-only — the key map's decision table is exported from the component and unit-tested there.

Usage
<Combobox
  items={cities}
  value={city.value}
  onChange={(next) => city.value = next}
  getLabel={(city) => `${city.name} — ${city.country}`}
  isItemDisabled={(city) => city.retired === true}
  placeholder="Search a city…"
  emptyMessage={(query) => `No city matches “${query}”`}
/>

<DateRangePicker />

Preset menu plus a custom from/to panel over `rangeForPreset`. The value is controlled, every string in the panel defaults to English and can be overridden one key at a time, and `timeZone` is required because the server's zone is not the visitor's. The clock is either injected through `now` or read inside a click handler, never during render, so the first render is deterministic. The panel is a `role="group"` rather than a menu, because it contains form controls. Focus follows the panel: opening moves it to the pressed preset, and Escape, a preset, Apply and Cancel all hand it back to the trigger. `withTime` swaps the two date fields for `datetime-local` ones, carries a timed `DateTimeRange` instead of a `DateRange`, and adds two presets built for a time of day — `"last-hour"` and `"last-24-hours"`, both computed over `rangeForTimePreset`.

range: 2026-02-09 → 2026-02-15

Every preset resolves against an injected instant, now=2026-02-15T12:00:00.000Z in Europe/Paris, so the numbers below are the same on every build:

  • Last 7 days: last-7-days → 2026-02-09 … 2026-02-15
  • This month: this-month → 2026-02-01 … 2026-02-28
  • This year: this-year → 2026-01-01 … 2026-12-31
  • Custom…: custom → 2026-02-09 … 2026-02-15

Without `now` the component reads the clock inside the click handler, never during render, so an app that wants "today" gets it and the first render stays deterministic.

range: none — the trigger reads “All time”

A draft the caller would refuse reads incomplete — Apply stays disabled — the same rule the Apply button follows, which is why the reversed pair cannot be committed.

Only placeholder is passed here. “Date range”, “From”, “To”, “Apply” and “Cancel” inside the panel are the component's own English defaults, so a caller with nothing to translate writes no copy at all.

range: none

Last hour and Last 24 hours are real elapsed time, not calendar walking: each subtracts exactly one or twenty-four hours from now=2026-02-15T12:00:00.000Z before reading the wall clock in Europe/Paris — the maths that keeps them correct across a clock change.

Usage
<DateRangePicker
  range={range.value}
  onChange={(next) => range.value = next}
  timeZone="Europe/Paris"
  presets={[
    { preset: "last-7-days", label: "Last 7 days" },
    { preset: "custom", label: "Custom…" },
  ]}
  selectedPreset="last-7-days"
/>

// Every label is English by default; override only the ones you need to.
<DateRangePicker {...props} labels={{ placeholder: "All time" }} />

// withTime: datetime-local fields, a DateTimeRange, and two sub-day presets — no presets prop.
<DateRangePicker
  withTime
  range={timedRange.value}
  onChange={(next) => timedRange.value = next}
  timeZone="Europe/Paris"
/>

Fields

The controlled form primitives: Field owns the label wiring and the messages, and Input/Textarea/Select/Checkbox/Radio are the native elements with the preset's class on them.

<Field />

Label, control, error and hint of one field row, and the owner of the `id`/`for` wiring. `suffix` puts the label under the control, `error` marks the control `aria-invalid`, and a caller's own `aria-describedby` is kept alongside the messages `Field` adds.

Enter a valid address

Work address only

Hides the row from the list

Notification method

name: Ada Lovelace · email: not-an-address · role: admin · archived: off · channel: email · query: (empty)

Usage
<Field id="email" label="Email" required error={emailError} hint="Work address only">
  <Input value={email.value} onInput={(event) => email.value = event.currentTarget.value} />
</Field>

// The label under the control, and a control that is its own label — the opt-out keeps the label
// from pointing a for at something that cannot carry one:
<Field id="archived" label="Archived" suffix labelFor={false}>
  <Checkbox checked={archived.value} />
</Field>

<Input />

Native `<input>` with `.input`. Every native attribute passes through; `value` in, `onInput` out, and no draft state inside the component.

(empty)

Usage
<Input
  type="email"
  name="email"
  value={email.value}
  placeholder="you@example.com"
  required
  onInput={(event) => email.value = event.currentTarget.value}
/>

<Textarea />

Native `<textarea>` with `.textarea`. Same contract as `Input`, on the multi-line box.

0 characters

Usage
<Textarea rows={4} value={notes.value} onInput={(event) => notes.value = event.currentTarget.value} />

<Select />

Native `<select>` with `.select`, taking its options as data. The selection comes from `value`, so a value no option carries renders blank instead of mislabelling the first entry.

role: editor

Usage
<Select
  value={role.value}
  onChange={(event) => role.value = event.currentTarget.value}
  options={[
    { value: "admin", label: "Administrator" },
    { value: "editor", label: "Editor" },
  ]}
  placeholder="Choose a role"
/>

<Checkbox />

Native `<input type="checkbox">` with `.checkbox`, wrapped in its own label so the box and the text share one hit area. `checked` in, `onChange` out.

archived: off

Usage
<Checkbox
  checked={archived.value}
  onChange={(event) => archived.value = event.currentTarget.checked}
>
  Show archived
</Checkbox>

<Radio />

One native `<input type="radio">` with `.radio` inside its label. Give it a `name` — the platform groups on it, including arrow-key navigation.

Usage
<Radio name="channel" value="email" checked={channel.value === "email"}>Email</Radio>

<RadioGroup />

`<fieldset>` + `<legend>` over radios that share one `name`, so the legend names the group and the browser keeps the roving tab stop and its arrow keys. No role, no key handler, no `aria-checked`. `onChange` receives the picked value first, because a change event fires on the radio rather than on the fieldset.

Notification method

channel: email

Usage
<RadioGroup
  legend="Notification method"
  name="channel"
  value={channel.value}
  onChange={(value) => channel.value = value}
  options={[
    { value: "email", label: "Email" },
    { value: "sms", label: "Phone (SMS)" },
    { value: "push", label: "Push notification", disabled: true },
  ]}
/>

<InputButton />

An input with a trailing `.btn-input-icon` button positioned inside it. Its own export rather than a slot on `Input`: it is a layout with two wrappers and reserved right padding, and `Input` stays a bare native element. The button is a sibling, so clicking it never activates the input.

query: (empty)

Usage
<InputButton
  type="search"
  icon={<IconSearch class="size-4" />}
  iconLabel="Search"
  placeholder="Search users"
  value={query.value}
  onInput={(event) => query.value = event.currentTarget.value}
  onClick={() => query.value = ""}
/>

<MoneyInput />

Text field for an amount in a currency's smallest unit: `value`/`onChange` carry the integer, `inputmode="decimal"` brings up the numeric keypad, and typing understands `locale`'s own decimal mark — `"12,5"` with `locale="de"` becomes `1250`. Text that will not parse, or a parsed amount outside `min`/`max`, leaves `value` unchanged and announces a message; the typed text itself is never what a plain form post carries — pass `name` for a hidden field that posts the integer instead.

amount: 1999

submits: 0, posted: (none)

Usage
<Field id="price" label="Price">
  <MoneyInput
    value={amount.value}
    currency="EUR"
    locale="de"
    name="price"
    onChange={(value) => amount.value = value}
  />
</Field>

Enhanced forms

Whole forms built on the Fields primitives above, that post on their own before a script has run and stay on the page once one has: EnhancedForm is the building block, NewsletterForm and ContactForm are the two shapes built on it.

<EnhancedForm />

A real `<form action method>` that posts on its own before hydration, or whenever `onSubmit` is left out — `method` defaults to `"post"` unconditionally, so a pre-hydration submit never puts a field's value in the address bar. Once hydrated, a submit calls `onSubmit(formData)` instead and the page stays: `children` sits inside a disabled `<fieldset>` while the promise is outstanding (or the `sending` slot replaces it, when given), `done`/`failed` replace it on settlement, and the always-present `role="status"` region announces the same three states. A second click, or a `form.requestSubmit()`, while a submit is outstanding does nothing — a ref checked synchronously catches what the disabled fieldset has not repainted yet. The sending state always ends: on success, on a rejection or a synchronous throw, and on a `pageshow` with `persisted: true`, which is what a promise abandoned in the back/forward cache would otherwise leave stuck forever.

submits: 0

Usage
<EnhancedForm
  action="/api/subscribe"
  onSubmit={async (data) => api.subscribe(data.get("email"))}
  done={<p>Thanks.</p>}
>
  <Field id="email" label="Email">
    <Input type="email" name="email" required />
  </Field>
  <Button type="submit">Subscribe</Button>
</EnhancedForm>

<NewsletterForm />

One email field on `EnhancedForm`: `onSubmit={(email) => …}` reads the one field for you, `done` replaces the field with a thank-you message, and a rejected submit — no `failed` slot of its own — leaves the field and button re-enabled so the visitor can just try again without retyping their address. `honeypot` adds an off-screen field simple bots fill in; a submit that carries a value there resolves as if it had succeeded and never reaches `onSubmit`.

subscribes: 0

subscribes: 0

subscribes: 0

subscribes: 0

subscribes: 0

Usage
<NewsletterForm
  action="/api/subscribe"
  onSubmit={(email) => api.subscribe(email)}
  honeypot
/>

<ContactForm />

Name, email and message on `EnhancedForm`, with the same retry-on-failure default `NewsletterForm` has. `onSubmit={({ name, email, message }) => …}` reads all three fields for you. The same `honeypot` prop as `NewsletterForm`.

leads: 0

leads: 0

Usage
<ContactForm
  action="/api/lead"
  onSubmit={({ name, email, message }) => api.sendLead({ name, email, message })}
  honeypot
/>

Helpers

The functions and constants ui/ exports beside its components, each run on this page.

formatBytes()

A byte count as a short size label, in binary units, with one decimal only when it is not whole.

Output
[
  "512 B",
  "1.5 KB",
  "5 MB"
]
Usage
import { formatBytes } from "@spy4x/preact-ui"

[formatBytes(512), formatBytes(1536), formatBytes(5 * 1024 ** 2)]

buttonClasses()

The classes a `Button` wears, for a link or any other element that has to look like one.

Output
"inline-flex items-center justify-center gap-2 rounded-md font-medium transition-colors cursor-pointer focus-visible:ring-2 focus-visible:ring-purple-900 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50 border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700 px-2.5 py-1.5 text-xs w-full"
Usage
import { buttonClasses } from "@spy4x/preact-ui/button"

buttonClasses("outline", "sm", "w-full")

Calendar date arithmetic

Day and month steps on `YYYY-MM-DD` strings in fixed UTC days, so no step drifts across a daylight-saving change and an impossible date throws; a month step lands on the 1st of the target month.

Output
{
  "roundTrip": "2026-03-31",
  "weekLater": "2026-04-07",
  "monthBefore": "2026-02-01",
  "same": true
}
Usage
import { addDays, formatIsoDate, isSameDay, parseIsoDate, shiftMonth } from "@spy4x/preact-ui"

const ms = parseIsoDate("2026-03-31")
;({
  roundTrip: formatIsoDate(ms),
  weekLater: addDays("2026-03-31", 7),
  monthBefore: shiftMonth("2026-03-31", -1),
  same: isSameDay("2026-03-31", formatIsoDate(ms)),
})

Month, quarter and year bounds

The first and last day of the month, quarter and year a date falls in.

Output
{
  "month": [
    "2028-02-01",
    "2028-02-29"
  ],
  "quarter": [
    "2028-01-01",
    "2028-03-31"
  ],
  "year": [
    "2028-01-01",
    "2028-12-31"
  ]
}
Usage
import {
  endOfMonth, endOfQuarter, endOfYear, startOfMonth, startOfQuarter, startOfYear,
} from "@spy4x/preact-ui"

const day = "2028-02-10"
;({
  month: [startOfMonth(day), endOfMonth(day)],
  quarter: [startOfQuarter(day), endOfQuarter(day)],
  year: [startOfYear(day), endOfYear(day)],
})

Date range presets

Resolves a preset such as `last-7-days` into an inclusive date range from an injected `now` and zone, and finds the preset a range matches.

Output
{
  "presets": 15,
  "today": "2026-03-18",
  "week": {
    "from": "2026-03-12",
    "to": "2026-03-18"
  },
  "matches": "last-7-days",
  "reversedIsValid": false
}
Usage
import {
  calendarDateInZone, dateRangePresets, isValidDateRange, presetForRange, rangeForPreset,
} from "@spy4x/preact-ui"

const now = new Date("2026-03-18T10:30:00Z")
const timeZone = "Europe/Paris"
const week = rangeForPreset("last-7-days", { now, timeZone })
;({
  presets: dateRangePresets.length,
  today: calendarDateInZone(now, timeZone),
  week,
  matches: presetForRange(week, { now, timeZone }),
  reversedIsValid: isValidDateRange({ from: week.to, to: week.from }),
})

Time range presets

The sub-day counterpart of the date presets: the last hour or the last 24 hours as wall-clock times in a zone.

Output
{
  "presets": [
    "last-hour",
    "last-24-hours"
  ],
  "hour": {
    "from": "2026-03-18T10:30",
    "to": "2026-03-18T11:30"
  },
  "matches": "last-hour",
  "valid": false
}
Usage
import {
  isValidDateTimeRange, presetForTimeRange, rangeForTimePreset, timeRangePresets,
} from "@spy4x/preact-ui"

const now = new Date("2026-03-18T10:30:00Z")
const timeZone = "Europe/Paris"
const hour = rangeForTimePreset("last-hour", { now, timeZone })
;({
  presets: timeRangePresets,
  hour,
  matches: presetForTimeRange(hour, { now, timeZone }),
  valid: isValidDateTimeRange({ from: "2026-02-31T10:00", to: "2026-03-01T10:00" }),
})

Combobox search

How a combobox matches what was typed: accents and case are folded away before a substring test.

Output
{
  "folded": "sao paulo",
  "label": "42",
  "one": true,
  "many": [
    "São Paulo",
    "Sapporo"
  ]
}
Usage
import { defaultGetLabel, filterItems, fold, matchesQuery } from "@spy4x/preact-ui"

const cities = ["São Paulo", "Sapporo", "Zürich"]
;({
  folded: fold("São Paulo"),
  label: defaultGetLabel(42),
  one: matchesQuery("Zürich", "zur"),
  many: filterItems(cities, "sa"),
})

Combobox keys

Turns a key press into the combobox's next state: which option is active, whether the list is open, and what Enter selects.

Output
{
  "key": "ArrowDown",
  "down": {
    "activeIndex": 2,
    "isOpen": true,
    "handled": true
  },
  "unknown": "<undefined>",
  "enter": {
    "state": {
      "activeIndex": -1,
      "isOpen": false
    },
    "preventDefault": true,
    "select": 1
  }
}
Usage
import { comboboxKey, comboboxKeyAction, nextComboboxState } from "@spy4x/preact-ui"

const key = comboboxKey({ key: "ArrowDown", altKey: false })
const open = { activeIndex: 1, isOpen: true }
;({
  key,
  down: key && nextComboboxState(open, key, 3),
  unknown: comboboxKey({ key: "a", altKey: false }),
  enter: comboboxKeyAction("Enter", open, 3),
})

Combobox ids and naming

The ids that tie a combobox's input to its list and its active option, and which attribute gives it an accessible name.

Output
{
  "listbox": "city-listbox",
  "option": "city-option-2",
  "active": "city-option-2",
  "closed": "<undefined>",
  "named": "aria-label",
  "unnamed": null
}
Usage
import {
  activeDescendant, comboboxListboxId, comboboxOptionId, naming,
} from "@spy4x/preact-ui"

;({
  listbox: comboboxListboxId("city"),
  option: comboboxOptionId("city", 2),
  active: activeDescendant("city", { activeIndex: 2, isOpen: true }),
  closed: activeDescendant("city", { activeIndex: 2, isOpen: false }),
  named: naming({ ariaLabel: "City" }),
  unnamed: naming({}),
})

Combobox opening and closing

The state a combobox opens in, which option it lands on when some are disabled, what the list shows when nothing matches, and when focus has left it.

Output
{
  "opening": {
    "activeIndex": 2,
    "isOpen": true
  },
  "typing": {
    "activeIndex": 1,
    "isOpen": true
  },
  "firstUsable": 1,
  "empty": {
    "options": [],
    "emptyMessage": "No matches"
  },
  "leaves": {
    "toAnotherElement": true,
    "toNowhere": false
  }
}
Usage
import {
  leavesCombobox, listboxContent, openingState, selectableIndex, typingState,
} from "@spy4x/preact-ui"

const sizes = ["Small", "Medium", "Large"]
const soldOut = (size: string) => size === "Small"
const input = { isSameNode: (node: unknown) => node === input }
;({
  opening: openingState(sizes, 2, sizes, soldOut),
  typing: typingState(sizes, soldOut),
  firstUsable: selectableIndex(sizes, 0, soldOut),
  empty: listboxContent([], "xl"),
  leaves: {
    toAnotherElement: leavesCombobox(input, {} as Node),
    toNowhere: leavesCombobox(input, null),
  },
})

nextMenuIndex()

The menu item a `Dropdown` moves to on an arrow, Home or End key, wrapping at both ends; `undefined` for any other key.

Output
[
  0,
  2,
  2,
  "<undefined>"
]
Usage
import { nextMenuIndex } from "@spy4x/preact-ui"

[nextMenuIndex("ArrowDown", 2, 3), nextMenuIndex("ArrowUp", 0, 3), nextMenuIndex("End", 0, 3), nextMenuIndex("x", 0, 3)]

nextTabIndex()

The tab an arrow key moves to, following the tab list's orientation and skipping disabled tabs.

Output
{
  "right": 2,
  "down": 2,
  "wrongAxis": "<undefined>"
}
Usage
import { nextTabIndex } from "@spy4x/preact-ui"

const disabled = [false, true, false]
;({
  right: nextTabIndex("ArrowRight", 0, 3, "horizontal", disabled),
  down: nextTabIndex("ArrowDown", 0, 3, "vertical", disabled),
  wrongAxis: nextTabIndex("ArrowDown", 0, 3, "horizontal", disabled),
})

Backdrop clicks

Whether a click landed on a dialog's dimmed backdrop rather than its content, and whether that click should close it.

Output
{
  "outside": true,
  "inside": false,
  "byDefault": true,
  "refused": false
}
Usage
import { backdropClickDismisses, isBackdropClick } from "@spy4x/preact-ui"
import { backdropDismissesByDefault } from "@spy4x/preact-ui/modal"

const dialog = {}
const rect = { left: 100, top: 100, right: 500, bottom: 400 }
const click = { target: dialog, clientX: 20, clientY: 20 } as MouseEvent
;({
  outside: isBackdropClick({ target: dialog, dialog }, rect, 20, 20),
  inside: isBackdropClick({ target: dialog, dialog }, rect, 300, 250),
  byDefault: backdropDismissesByDefault,
  refused: backdropClickDismisses(click, rect, dialog, false),
})

Scroll lock

Stops the page scrolling behind an open dialog and pads the body by the scrollbar's width, so nothing slides sideways; shown on plain objects standing in for the document.

Output
{
  "padding": 15,
  "locked": {
    "html": "hidden",
    "body": {
      "overflow": "hidden",
      "paddingRight": "15px"
    }
  },
  "released": {
    "overflow": "",
    "paddingRight": ""
  }
}
Usage
import {
  applyScrollLock, clientWidthWithoutScrollbar, scrollLockPadding,
} from "@spy4x/preact-ui"

const html = {
  style: { overflow: "" },
  get clientWidth() { return this.style.overflow === "hidden" ? 1000 : 985 },
}
const body = { style: { overflow: "", paddingRight: "" } }
const padding = scrollLockPadding(html.clientWidth, clientWidthWithoutScrollbar(html))
const lock = applyScrollLock({ scrollingElement: html, body }, padding)
const locked = { html: html.style.overflow, body: { ...body.style } }
lock.release()
;({ padding, locked, released: body.style })

Escape closes a dialog

The key that asks a `Modal` to close, and the check its key handler makes.

Output
{
  "key": "Escape",
  "open": true,
  "closed": false,
  "other": false
}
Usage
import { DISMISS_KEY, isDismissKey } from "@spy4x/preact-ui"

;({
  key: DISMISS_KEY,
  open: isDismissKey({ key: "Escape" }, true),
  closed: isDismissKey({ key: "Escape" }, false),
  other: isDismissKey({ key: "Enter" }, true),
})

Escape on each browser

Where the browser honours `closedby`, a `Modal` asks its close port before closing on Escape; where it does not, the browser closes the dialog and the port is only told. These helpers pick and wire that plan.

Output
{
  "withClosedBy": {
    "strategy": {
      "listensForKeydown": true,
      "listensForCancel": false,
      "refusalHolds": true
    },
    "log": [
      "add keydown",
      "remove keydown"
    ]
  },
  "withoutClosedBy": {
    "strategy": {
      "listensForKeydown": false,
      "listensForCancel": true,
      "refusalHolds": false
    },
    "log": [
      "add cancel",
      "remove cancel",
      "open false"
    ]
  }
}
Usage
import {
  bindEscapeClose, escapeCloseStrategy, platformCloseHandler, supportsClosedBy,
} from "@spy4x/preact-ui/modal"

// A browser's dialog prototype: one that knows `closedby` has a `closedBy` property.
function plan(dialogPrototype: object) {
  const log: string[] = []
  const dialog = {
    addEventListener: (type: string) => log.push("add " + type),
    removeEventListener: (type: string) => log.push("remove " + type),
  }
  const strategy = escapeCloseStrategy(supportsClosedBy(dialogPrototype))
  const unbind = bindEscapeClose(dialog, strategy, { keydown: () => {}, cancel: () => {} })
  unbind()
  platformCloseHandler({
    refusalHolds: strategy.refusalHolds,
    onClose: () => false,
    settleOpen: (open) => log.push("open " + open),
  })()
  return { strategy, log }
}
;({ withClosedBy: plan({ closedBy: "none" }), withoutClosedBy: plan({}) })

Focus after a dialog closes

Whether focus goes back to the element that opened a dialog, and the move itself; `dialogHeldFocus` reports where focus was, for a caller to log.

Output
{
  "held": true,
  "retarget": true,
  "noTrigger": false,
  "moved": true,
  "log": [
    "trigger focused"
  ]
}
Usage
import { dialogHeldFocus, restoreFocus, shouldRetargetFocus } from "@spy4x/preact-ui"

const log: string[] = []
const trigger = { focus: () => log.push("trigger focused") }
const field = {}
const dialog = { contains: (element: unknown) => element === field }
;({
  held: dialogHeldFocus(dialog, field),
  retarget: shouldRetargetFocus(trigger),
  noTrigger: shouldRetargetFocus(null),
  moved: restoreFocus(trigger),
  log,
})

dialogTitleId()

The id of a dialog's title element, which the dialog names itself by through `aria-labelledby`.

Output
"modal-title-P0-1"
Usage
import { dialogTitleId } from "@spy4x/preact-ui"

dialogTitleId("P0-1")

Confirm dialog labels

The English button labels a `ConfirmDialog` falls back to, and the rule that a blank label falls back too.

Output
[
  "Delete",
  "Confirm",
  "Cancel"
]
Usage
import { CANCEL_LABEL, CONFIRM_LABEL, labelOr } from "@spy4x/preact-ui"

[labelOr("Delete", CONFIRM_LABEL), labelOr("   ", CONFIRM_LABEL), labelOr(undefined, CANCEL_LABEL)]

Confirm dialog body and tone

Whether a dialog body says anything a screen reader can announce, and which button variant a tone asks for.

Output
{
  "blank": false,
  "asked": true,
  "danger": "danger",
  "plain": "primary"
}
Usage
import { confirmVariant, hasQuestion } from "@spy4x/preact-ui"

;({
  blank: hasQuestion(["", "  "]),
  asked: hasQuestion("Delete this file?"),
  danger: confirmVariant("danger"),
  plain: confirmVariant("default"),
})

Avatar faces

An avatar shows its image, else the name's initials, else a generic icon; a failed image stays failed until its address changes.

Output
{
  "initials": [
    "AL",
    "JP",
    "王小"
  ],
  "image": "image",
  "broken": "initials",
  "nameless": "icon",
  "sameSrc": true,
  "newSrc": false
}
Usage
import { avatarFace, initials } from "@spy4x/preact-ui"
import { failedAfterSrcChange } from "@spy4x/preact-ui/avatar"

;({
  initials: [initials("Ada Lovelace"), initials("John Paul Smith"), initials("王小明")],
  image: avatarFace({ src: "/a.png", name: "Ada" }),
  broken: avatarFace({ src: "/a.png", failed: true, name: "Ada" }),
  nameless: avatarFace({}),
  sameSrc: failedAfterSrcChange("/a.png", "/a.png", true),
  newSrc: failedAfterSrcChange("/a.png", "/b.png", true),
})

Avatar groups

How many avatars a group shows before a `+N` badge, and the group's accessible name.

Output
{
  "split": {
    "visible": 3,
    "overflow": 4
  },
  "label": "Reviewers (7)",
  "fallback": "Avatars (7)"
}
Usage
import { groupLabel, groupSplit } from "@spy4x/preact-ui"

;({ split: groupSplit(7, 3), label: groupLabel("Reviewers", 7), fallback: groupLabel(null, 7) })

Progress values

Clamps a progress value into its range, then turns the fraction into a bar width and a whole-percent label that never rounds up to 100% early.

Output
{
  "over": {
    "value": 100,
    "fraction": 1
  },
  "fraction": 0.9993,
  "width": 99.9,
  "label": "99%"
}
Usage
import { clampProgress, formatProgressPercent, progressWidthPercent } from "@spy4x/preact-ui"

const { fraction } = clampProgress(59.96, 60)
;({
  over: clampProgress(130),
  fraction,
  width: progressWidthPercent(fraction),
  label: formatProgressPercent(fraction ?? 0),
})

clampConfidence()

A confidence score clamped to 0–100 and sorted into the low, medium or high tier the meter colours by.

Output
[
  {
    "value": 42,
    "tier": "low"
  },
  {
    "value": 80,
    "tier": "high"
  },
  {
    "value": 100,
    "tier": "high"
  },
  {
    "value": null,
    "tier": null
  }
]
Usage
import { clampConfidence } from "@spy4x/preact-ui"

[clampConfidence(42), clampConfidence(80), clampConfidence(140), clampConfidence(null)]

normalizeCiStatus()

Reads a build status from any source into one of the four states the pill shows.

Output
[
  "passing",
  "running",
  "unknown"
]
Usage
import { normalizeCiStatus } from "@spy4x/preact-ui"

[normalizeCiStatus(" Passing "), normalizeCiStatus("RUNNING"), normalizeCiStatus("cancelled")]

Skeleton metrics

The sizes, in rem, a loading skeleton copies from the real text and table it stands in for, so the page does not jump when the content arrives.

Output
{
  "lineHeight": 1.25,
  "line": 1.25,
  "bar": 1,
  "row": 3.3125,
  "header": 2.78125
}
Usage
import {
  SKELETON_METRICS, tableHeaderHeightRem, tableRowHeightRem,
} from "@spy4x/preact-ui"
import { barHeightRem, lineBoxRem } from "@spy4x/preact-ui/skeletons"

;({
  lineHeight: SKELETON_METRICS.lineHeightRem,
  line: lineBoxRem(),
  bar: barHeightRem(),
  row: tableRowHeightRem(),
  header: tableHeaderHeightRem(),
})

Skeleton geometry

How many placeholder lines, rows and cells a skeleton draws, and how wide each one is.

Output
{
  "count": [
    3,
    2,
    0
  ],
  "columns": [
    50,
    25,
    25
  ],
  "text": {
    "lines": 3,
    "linePercents": [
      100,
      60,
      100
    ]
  },
  "table": {
    "rows": 2,
    "columns": 2,
    "cells": 4,
    "rowHeightRem": 3.3125,
    "columnWidths": [
      {
        "index": 0,
        "percent": 66.6667
      },
      {
        "index": 1,
        "percent": 33.3333
      }
    ]
  }
}
Usage
import {
  columnWidthPercents, skeletonCount, tableGeometry, textGeometry,
} from "@spy4x/preact-ui"

;({
  count: [skeletonCount(undefined, 3), skeletonCount(2.7, 3), skeletonCount(-1, 3)],
  columns: columnWidthPercents([2, 1, 1]),
  text: textGeometry(3, ["full", 60]),
  table: tableGeometry({ rows: 2, widths: [2, 1] }),
})

skeletonStatusRole()

The ARIA role a skeleton carries, so a screen reader hears that something is loading.

Output
"status"
Usage
import { skeletonStatusRole } from "@spy4x/preact-ui"

skeletonStatusRole()

Accepting files

Splits the files a person chose into the ones a `FileInput` keeps and the ones it refuses, with the reason for each refusal.

Output
{
  "pdf": true,
  "accepted": [
    "photo.png"
  ],
  "rejected": [
    [
      "notes.txt",
      "wrong-type"
    ],
    [
      "poster.png",
      "too-large"
    ]
  ]
}
Usage
import { matchesAccept } from "@spy4x/preact-ui"
import { classifyFiles } from "@spy4x/preact-ui/file-input"

const photo = new File(["12345"], "photo.png", { type: "image/png" })
const notes = new File(["1"], "notes.txt", { type: "text/plain" })
const poster = new File(["1234567890"], "poster.png", { type: "image/png" })
const { accepted, rejected } = classifyFiles([photo, notes, poster], {
  accept: "image/*",
  maxSize: 8,
})
;({
  pdf: matchesAccept(new File([], "report.PDF"), ".pdf"),
  accepted: accepted.map((file) => file.name),
  rejected: rejected.map(({ file, reason }) => [file.name, reason]),
})

File input labels

Fills in the English default for every file input message the caller did not override.

Output
[
  "notes.txt is not an image",
  "poster.png is larger than 1 KB",
  "Remove photo.png"
]
Usage
import { resolveLabels } from "@spy4x/preact-ui/file-input"

const labels = resolveLabels({ wrongType: (name) => name + " is not an image" })
;[labels.wrongType("notes.txt"), labels.tooLarge("poster.png", 1024), labels.removeFile("photo.png")]

Honeypot field

A hidden field people never see and bots fill in; the server drops a submission whose field is not empty.

Output
{
  "name": "hp-field",
  "hidden": "true",
  "person": false,
  "bot": true
}
Usage
import { HONEYPOT_FIELD_NAME, honeypotField, honeypotFilled } from "@spy4x/preact-ui"

const field = honeypotField(HONEYPOT_FIELD_NAME, "Leave this empty")
const person = new FormData()
const bot = new FormData()
bot.set(HONEYPOT_FIELD_NAME, "https://example.com")
;({
  name: HONEYPOT_FIELD_NAME,
  hidden: field.props["aria-hidden"],
  person: honeypotFilled(person),
  bot: honeypotFilled(bot),
})

enhancedFormMessage()

The status line an `EnhancedForm` announces while it sends, after it succeeds and after it fails.

Output
[
  "",
  "Sending…",
  "Thanks, we got it.",
  "Please try again."
]
Usage
import { enhancedFormMessage } from "@spy4x/preact-ui/enhanced-form"

const labels = { sending: "Sending…", done: "Thanks, we got it.", failed: "Please try again." }
;(["idle", "sending", "done", "failed"] as const).map((status) => enhancedFormMessage(status, labels))

labelTarget()

Which element a `Field`'s label points at: its own control, another id, or none when the control names itself.

Output
[
  "email",
  "email-input",
  "<undefined>"
]
Usage
import { labelTarget } from "@spy4x/preact-ui/field"

[labelTarget(undefined, "email"), labelTarget("email-input", "email"), labelTarget(false, "email")]

Money input

What a `MoneyInput` shows for an amount in minor units, and what it makes of the text a person types: a value, or a message.

Output
{
  "shown": "1234,56",
  "typed": {
    "value": 1250,
    "message": "<undefined>"
  },
  "invalid": {
    "message": "Enter an amount"
  },
  "tooMuch": {
    "message": "Enter an amount between 0,00 € and 1.000,00 €"
  }
}
Usage
import { resolveMoneyInputEdit } from "@spy4x/preact-ui"
import { editableText } from "@spy4x/preact-ui/money-input"

const bounds = { min: 0, max: 100000 }
;({
  shown: editableText(123456, "EUR", "de-DE"),
  typed: resolveMoneyInputEdit("12,50", "EUR", "de-DE", bounds, "Enter an amount"),
  invalid: resolveMoneyInputEdit("twelve", "EUR", "de-DE", bounds, "Enter an amount"),
  tooMuch: resolveMoneyInputEdit("5000", "EUR", "de-DE", bounds, "Enter an amount"),
})

copyToClipboard()

Copies text through the caller's port when one is given, else through the browser clipboard; here a port records what it was handed.

Output
[
  "deno add jsr:@std/path"
]
Usage
import { copyToClipboard } from "@spy4x/preact-ui/copy-button"

const copied: string[] = []
copyToClipboard("deno add jsr:@std/path", (text) => {
  copied.push(text)
})
copied

requestGeolocation()

Asks for the device's position and hands back plain coordinates or an error message; here a stand-in answers instead of the browser.

Output
[
  {
    "latitude": 48.8584,
    "longitude": 2.2945
  }
]
Usage
import { requestGeolocation } from "@spy4x/preact-ui/geo-button"

const results: unknown[] = []
const stub = {
  getCurrentPosition: (found: (position: { coords: { latitude: number; longitude: number } }) => void) =>
    found({ coords: { latitude: 48.8584, longitude: 2.2945 } }),
} as unknown as Geolocation
requestGeolocation((position) => results.push(position), (message) => results.push(message), stub)
results

pageRange()

The page numbers a `Pagination` shows around the current page, with gaps where pages are left out.

Output
[
  1,
  "…",
  4,
  5,
  6,
  7,
  8,
  "…",
  20
]
Usage
import { pageRange } from "@spy4x/preact-ui"

pageRange(6, 20).map((item) => "page" in item ? item.page : "…")

rowKeyAttribute

The attribute a `DataTable` writes each row's key into, so a script or a test can find one row.

Output
"tr[data-row-key=\"42\"]"
Usage
import { rowKeyAttribute } from "@spy4x/preact-ui"

"tr[" + rowKeyAttribute + '="42"]'

thumbnailKey()

A stable key for each gallery thumbnail, which stays unique when the same image appears twice.

Output
[
  "/a.jpg#0",
  "/b.jpg#0",
  "/a.jpg#1"
]
Usage
import { thumbnailKey } from "@spy4x/preact-ui/image-gallery"

const images = [{ src: "/a.jpg" }, { src: "/b.jpg" }, { src: "/a.jpg" }]
images.map((_, index) => thumbnailKey(images, index))

Lightbox navigation

Which image a lightbox moves to, wrapping at both ends, the counter it reads out, and the images it shows at all — only those with a description.

Output
{
  "shown": 2,
  "back": 1,
  "counter": "2 of 2"
}
Usage
import { counterText, describedImages, wrapIndex } from "@spy4x/preact-ui"

const images = [
  { src: "/1.jpg", alt: "Harbour at dawn" },
  { src: "/2.jpg", alt: "" },
  { src: "/3.jpg", alt: "Market street" },
]
const shown = describedImages(images)
;({
  shown: shown.length,
  back: wrapIndex(0, shown.length, -1),
  counter: counterText(2, shown.length),
})

Toast duration

How long a toast stays before it dismisses itself: its own duration, else the default; `0` keeps it until closed.

Output
{
  "defaultToastDuration": 5000,
  "unset": 5000,
  "sticky": 0,
  "short": 2000
}
Usage
import { defaultToastDuration, resolveDuration } from "@spy4x/preact-ui"

;({
  defaultToastDuration,
  unset: resolveDuration({}),
  sticky: resolveDuration({ duration: 0 }),
  short: resolveDuration({ duration: 2000 }),
})

@spy4x/preact-system

System

Application chrome and platform integration: heads, the service-worker prompt, the dual-mode calendar. Everything is live; the two platform-integration cards say on the card what they demonstrate and what they leave to a browser.

System

Application chrome and platform integration: heads, the service-worker prompt, the dual-mode calendar. Everything is live; the two platform-integration cards say on the card what they demonstrate and what they leave to a browser.

<AuthForm />

Sign-in, sign-up and a one-time-code step, drawn from `mode`, `step`, `busy` and `error` and reported through `onSignIn`/`onSignUp`/`onOneTimeCode`/`onModeChange` — it makes no network call and stores no credential outside the input it came from. **Submitted values are read from `FormData` inside the submit handler, never from controlled state**, so a password sits nowhere but its own input's `value` until the one moment it is needed, and it reaches the callback exactly as typed, untrimmed. **`action` is the no-JavaScript path**: before hydration, or whenever the matching callback is left out, the form posts natively. **`method` is always `"post"`, unconditionally — there is no `method` prop** — because a card with callbacks and no `action`, the ordinary shape of a hydrated app, would otherwise have no `method` attribute at all, and a browser reads that as GET: a submit in the gap before hydration, or with scripts off, would put the password in the address bar. **A busy submit calls no callback**, including one started with `form.requestSubmit()`, which bypasses the disabled submit button the way a person cannot. **The one-time-code step moves the focus to its field**, and no other render does. **`error` is a string or `{ message, field? }`**: every error reaches the always-present `role="alert"` region — present before anything goes wrong, exactly like `SWUpdater`'s — and a named field also gets the message and `aria-invalid` through its own `Field` (read twice by some screen readers; accepted, see `system/README.md`). The show/hide password control is a real `type="button"` with `aria-pressed`, and every visible string has an English default and a `labels` override.

sign-ins: 0, sign-ups: 0, codes: 0

Usage
<AuthForm
  mode={mode}
  step={step}
  onModeChange={setMode}
  onSignIn={({ login, password }) => auth.signIn(login, password)}
  onSignUp={({ login, password }) => auth.signUp(login, password)}
  onOneTimeCode={(code) => auth.verify(code)}
  busy={pending}
  error={error} // string, or { message, field: "login" | "password" | "code" }
  action="/auth/sign-in"
/>

<Calendar />

Six-week month grid. **Dual-mode**: with no `onSelectDate` every cell is an `<a href>` and a month arrow with nothing to show is a `<span>` rather than a dead link; supplying the callback turns the cells into `<button>`. `today` and `timeZone` are props, so a render can be pinned — this card passes `2026-03-10` and `UTC` and reads no clock, and a zone the platform cannot resolve falls back to UTC instead of throwing. A date missing from `availableByDate` has no availability and a `0` has none left: both are greyed out, the none-left day is also struck through, and each carries its own accessible label. Cells also show today, past dates, dates outside the window, and a low-availability dot at or below `lowAvailabilityThreshold`. **The whole grid is one Tab stop** once hydrated: the arrow keys step a day and a week, Home and End go to the ends of the week, Page Up and Page Down ask `onSelectMonth` for the neighbouring month, and why a day cannot be picked is the cell's own accessible name plus the hint under the grid rather than a `title` nobody can hover. **A month is asked for, never taken**, and the reader keeps their place whatever the owner answers: an owner that draws the month lands them on the same day number in it, an owner that leaves `monthAnchor` where it was — clamping to an allowed range, say — leaves them on the day they pressed from rather than on the grid container, and an owner that draws the month a render or more later, as anything that fetches first does, still lands them on that same day number once it arrives. The fourth card below refuses every month change and the fifth answers a second late; both count what they were asked, because "the month did not change" is otherwise indistinguishable from a key press that never arrived. **The week is the locale's**: both the column order and the header text come from `Intl`, so the third card below moves the columns under the same dates as it changes language.

March 2026

Mon
Tue
Wed
Thu
Fri
Sat
Sun
01
02
03
04
05
06
07
08
09
10
13
15
16
17
19
20
21
22
23
24
25
26
27
28
29
30
31

March 2026

Mon
Tue
Wed
Thu
Fri
Sat
Sun
01
02
03
04
05
06
07
08
09
10
13
14
15
16
17
19
20
21
22
23
24
25
26
27
28
29
30
31

onSelectDate: nothing picked · onSelectMonth: 2026-03-01

Tab once to reach the grid, then the arrow keys, Home, End, Page Up and Page Down move inside it.

March 2026

Mon
Tue
Wed
Thu
Fri
Sat
Sun
01
02
03
04
05
06
07
08
09
10
13
14
15
16
17
19
20
21
22
23
24
25
26
27
28
29
30
31

locale: en-GB

March 2026

Mon
Tue
Wed
Thu
Fri
Sat
Sun
01
02
03
04
05
06
07
08
09
10
12
13
14
15
16
17
18
20
21
22
23
24
26
27
28
29
30
31

This owner refuses every month change. Last month asked for: nothing yet; requests refused: 0.

Focus a day, then press Page Down: the count rises, the grid stays on March, and the focus stays on the day you were on.

March 2026

Mon
Tue
Wed
Thu
Fri
Sat
Sun
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
20
21
22
23
24
25
26
27
28
29
30
31

This owner answers late, by timer. Last month asked for: nothing yet; answers drawn: 0; showing: 2026-03-01.

Focus a day and press Page Down: the month changes a moment later and the focus follows it to the same day number, the way it would if the owner had answered at once.

Usage
<Calendar
  monthAnchor="2026-03-01"
  minDate="2026-03-01"
  maxDate="2026-04-30"
  today="2026-03-10"
  timeZone="UTC"
  availableByDate={{ "2026-03-12": 3, "2026-03-13": 0 }}
  selectedDate="2026-03-12"
  onSelectDate={(date) => picked.value = date}
/>

<SEOHead />

The page-head tag set as a fragment, plus the JSON-LD `@graph` that mirrors it: title, description, canonical and robots first, then the Twitter card, then Open Graph, then a `BreadcrumbList` built from the crumbs the caller stated. Optional tags are omitted rather than emitted empty, and `<` is escaped in the script body so a description containing `</script>` cannot close the element it is embedded in. **The canonical address is cleaned before it is published**: this card is built from an address ending in `#reviews`, and the tag set below carries that address without it, because a fragment names a position inside a page rather than a page — and because `…#reviews#breadcrumb` is an identifier nothing can match. The usage block above is written the way a route should write it, which is why it carries no fragment to begin with. A user name and password are dropped the same way, and an address that is not an `http`/`https` page — `javascript:alert(1)`, or a relative path — throws rather than being printed, the way an impossible month anchor does: a canonical address is the route's own arithmetic. **Crumbs are a prop, never a guess.** Reading them out of the path assumed every segment is a page, so `/products/widgets/12` used to publish a crumb named `12`. **This card shows `seoHeadTags`, the exported data the component maps over, not the component itself**: rendering `<SEOHead />` here would splice a second `<title>` into this document's body, and a browser reads the first `<title>` anywhere in a document as `document.title` — which would rename every deep link in the host app. The tag set below is real and complete; where it goes is the host's head pipeline, and this guide has none.

17 tags, in document order — the exact array <SEOHead /> maps over

[
  {
    "tag": "title",
    "attrs": {},
    "text": "Blue widget — Acme"
  },
  {
    "tag": "meta",
    "attrs": {
      "name": "description",
      "content": "Specifications, prices and reviews for the blue widget."
    }
  },
  {
    "tag": "link",
    "attrs": {
      "rel": "canonical",
      "href": "https://example.com/products/widgets/12"
    }
  },
  {
    "tag": "meta",
    "attrs": {
      "name": "robots",
      "content": "index, follow"
    }
  },
  {
    "tag": "meta",
    "attrs": {
      "name": "twitter:card",
      "content": "summary_large_image"
    }
  },
  {
    "tag": "meta",
    "attrs": {
      "name": "twitter:site",
      "content": "@acme"
    }
  },
  {
    "tag": "meta",
    "attrs": {
      "name": "twitter:title",
      "content": "Blue widget — Acme"
    }
  },
  {
    "tag": "meta",
    "attrs": {
      "name": "twitter:description",
      "content": "Specifications, prices and reviews for the blue widget."
    }
  },
  {
    "tag": "meta",
    "attrs": {
      "name": "twitter:image",
      "content": "https://example.com/og/widgets.png"
    }
  },
  {
    "tag": "meta",
    "attrs": {
      "property": "og:type",
      "content": "article"
    }
  },
  {
    "tag": "meta",
    "attrs": {
      "property": "og:title",
      "content": "Blue widget — Acme"
    }
  },
  {
    "tag": "meta",
    "attrs": {
      "property": "og:description",
      "content": "Specifications, prices and reviews for the blue widget."
    }
  },
  {
    "tag": "meta",
    "attrs": {
      "property": "og:url",
      "content": "https://example.com/products/widgets/12"
    }
  },
  {
    "tag": "meta",
    "attrs": {
      "property": "og:image",
      "content": "https://example.com/og/widgets.png"
    }
  },
  {
    "tag": "meta",
    "attrs": {
      "property": "og:site_name",
      "content": "Acme"
    }
  },
  {
    "tag": "meta",
    "attrs": {
      "property": "og:locale",
      "content": "en_GB"
    }
  },
  {
    "tag": "script",
    "attrs": {
      "type": "application/ld+json"
    },
    "text": "{\"@context\":\"https://schema.org\",\"@graph\":[{\"@type\":\"Organization\",\"name\":\"Acme\"},{\"@type\":\"BreadcrumbList\",\"@id\":\"https://example.com/products/widgets/12#breadcrumb\",\"itemListElement\":[{\"@type\":\"ListItem\",\"position\":1,\"name\":\"Home\",\"item\":\"https://example.com/\"},{\"@type\":\"ListItem\",\"position\":2,\"name\":\"Widgets\",\"item\":\"https://example.com/products/widgets\"},{\"@type\":\"ListItem\",\"position\":3,\"name\":\"Blue widget\",\"item\":\"https://example.com/products/widgets/12\"}]}]}"
  }
]
Usage
<SEOHead
  title="Blue widget — Acme"
  description="Specifications, prices and reviews for the blue widget."
  canonical="https://example.com/products/widgets/12"
  ogImage="https://example.com/og/widgets.png"
  siteName="Acme"
  jsonLd={[{ "@type": "Organization", name: "Acme" }]}
  crumbs={[
    { name: "Home", href: "/" },
    { name: "Widgets", href: "/products/widgets" },
    { name: "Blue widget" },
  ]}
/>

// The same tag set as data, for an app whose head is not a component tree:
const tags = seoHeadTags(head)

<Shell />

The frame every signed-in app built from `spy4x/template` needs: a header that is always in view (menu button, `brand`, `status`, user menu), a sidebar from `lg` up, and the same navigation in a `<details>`-built drawer below it — reusing `useMobilePanel`, the same hook `SiteHeader` (#139) uses, rather than a second implementation. **No router import, no app state, no hard-coded link, brand text or URL** — `navItems`, `brand`, `user` and `children` are exactly what the caller passes in. **The skip link is the first focusable element**, and it targets a `<main>` this component gives `tabindex="-1"`, so activating it moves focus there rather than only changing the address bar's hash. **The active item is decided by exact string equality**, `href === currentPath`, the same rule `SiteHeader`'s `isCurrentLink` already uses — reused here rather than re-implemented. **The user menu is `ui/`'s own `Dropdown`, named from `Avatar`**: `Avatar`'s own accessible name — the user's name — becomes the trigger's name through `triggerNamedByContent`, the same way `DateRangePicker` names its trigger. **The header and the drawer never overlap, and the header stays on top**: the drawer's overlay starts below the header's own height and sits at a lower `z-index`, which is what keeps the menu button and the user menu independently reachable whichever one is open — proved in `pages/checks/system.ts` by opening both at once and pressing Escape, which closes only the one that was actually focused. **`navItems` is redrawn from data for the sidebar and the drawer — `brand` and `status` are rendered exactly once**, in the header, for the same reason `SiteHeader`'s `actions` is: a caller's own element can only ever be mounted in one place.

Skip to content
Acme
Connected

The page goes here.

Usage
<Shell
  brand={<Logo />}
  currentPath={url.pathname}
  navItems={[
    { name: "Dashboard", href: "/dashboard", Icon: IconHome },
    { name: "Docs", href: "/docs", Icon: IconBookOpen },
    { name: "Settings", children: [
      { name: "Billing", href: "/settings/billing" },
      { name: "Team", href: "/settings/team", counter: unreadInvites },
    ] },
  ]}
  user={session ? { name: session.name, email: session.email } : null}
  userMenuItems={[
    { label: "Your profile", href: "/profile" },
    { label: "Sign out", onClick: () => auth.signOut() },
  ]}
  status={<ConnectionIndicator />}
>
  <PageContent />
</Shell>

<SiteHeader />

A public-site top bar: `brand` on the left; `links` on the right from `lg` up, and in a `<details>` disclosure this card's menu button opens below it; an optional `actions` slot and the menu button always in view, beside whichever form `links` is currently taking. **Every link, and every word of `brand`, is a prop** — this component writes no `href`, no label and no brand text of its own, which the card below proves: the only two addresses on the page are `links`' own. **`aria-current="page"` marks the one link whose `href` equals `currentPath`** — exact string equality, so a caller whose routes want prefix matching normalises the comparison itself before handing either one in. **`links` is reachable with no JavaScript at all**: a click on `<summary>` opens and closes the native `<details>` disclosure with nothing running, so every link is there before hydration and with scripts off — proved in `pages/checks/system.ts` by disabling script execution and pressing the button, and by holding the island bundle back so a menu opened before it loads still catches up correctly once it does. **`links` is redrawn from data, not duplicated as markup — `actions` is rendered once, because a caller's own element can only ever be mounted in one place.** **The panel overlays the page instead of pushing the bar down**, positioned against the `<header>` rather than sitting in the row beside the button, so opening it moves nothing else. What JavaScript adds, through the `useMobilePanel` hook `system/README.md` names, is Escape closing the panel and returning focus to the button, a client-side navigation on a panel link closing the panel *without* returning focus (it is moving to the new page, not back to the button), and `aria-expanded` tracking the disclosure's own state — which Chromium already exposes on the accessibility tree natively, `aria-expanded` or not. The menu button keeps one fixed accessible name rather than a pair that swaps with the state, so the state is never announced twice and never goes stale for as long as no script has run. The icon swap between the two glyphs in the button costs no script at all, because `group-open:` is a Tailwind variant compiled from the `<details>` element's own `[open]` attribute. **The desktop row and the mobile panel never coexist in the accessibility tree** — one is always `display:none` — so Tab never reaches a link twice at one viewport width. Every string beyond `links` and `brand` — the menu button's name, the shared `<nav>` label — has an English default and a `labels` override. This hook is shared with the app shell's side navigation (#135), which opens and closes the same way.

Usage
<SiteHeader
  brand={<Logo />}
  currentPath={url.pathname}
  links={[
    { label: "Product", href: "/product" },
    { label: "Pricing", href: "/pricing" },
    { label: "Docs", href: "/docs", Icon: IconBookOpen },
  ]}
  actions={<Button size="sm" onClick={() => navigate("/get-started")}>Get started</Button>}
/>

<StateInit />

A generic SSR→client hydration bridge: the server writes one JSON value into the page with `StateInit`, and the browser reads it back with `readStateInit` — which keys the value holds is the app's business, and this component knows none of them. **The escaping is `SEOHead`'s own `jsonLdText`, reused rather than reimplemented**: `<` becomes `\u003c`, which is what keeps the literal text `</script>` — or `<!--`, itself a `<` — from closing the element it is embedded in, whatever the script's own `type`. **U+2028 and U+2029 need no escaping here**, unlike the classic `window.x = {…}` shape of this pattern: this component renders `type="application/json"`, which the browser never executes, and `readStateInit` reads it back with `JSON.parse`, which has always accepted both characters inside a JSON string. This card's own sample data carries `</script>`, `<!--` and both separators, and the button below reads it back with the real function, not a copy of it — press it to see the exact value survive the round trip.

(not read yet)
Usage
// Wherever the server renders the page:
<StateInit data={{ userId: user.id, features: enabledFeatures }} />

// Anywhere on the client:
const state = readStateInit<{ userId: string; features: string[] }>()

<SWUpdater />

Registers the service worker and offers a reload once a new version is *waiting*. **Its live region is in the page from the first render, empty**, and the bar appears inside it: a region that arrives carrying its first message is commonly not announced at all, because assistive technology announces a *change* to a region it is already watching. Nothing else is always rendered — the region carries no class, no padding and no border, so an empty one paints nothing and is zero pixels tall. It is an ordinary in-flow element rather than a `fixed` one, so a `flex` or `grid` parent charges a full 16px for it whether it spaces its children with `gap-4` or with a `space-y-4` margin — neither collapses between flex or grid items — while block flow costs nothing either way; mount it outside a flex or grid container, and see `system/README.md` for the measurements. The `class` prop goes to the bar rather than the region, so no caller can give the region a box. A dismissal covers one update rather than the component, so a later version puts the message back. The container comes from `navigator.serviceWorker`, read inside the effect so a server render touches nothing but still emits the empty region. Nothing reloads until the visitor presses Reload: `controllerchange` fires in every open tab, so the listener that reloads is armed by the button and not by the registration — otherwise a first install reloads the page mid-visit and one tab's Reload reloads the tab with the half-filled form. Pressing Reload posts `{ action: "skipWaiting" }` to the waiting worker, which is a **contract**: a worker that expects a different message ignores it and the button does nothing, so the message is the `updateMessage` prop. The bar can be dismissed, and every string it shows has an English default and a prop. **This card has three parts.** The first is the contract above: the component mounted with nothing waiting, and a button that puts a message into the region already there. The second drives the pure function underneath, `watchForUpdate`, against a fake registration in all three branches. The third is the component itself against a real worker — press the button, because this site is published and nothing registers a worker on its own.

Mounted right now, with nothing waiting: the only thing it renders is an empty live region, zero pixels tall and painting nothing. Press the button to make an update ready inside that same element.

announced 0 times, reload port called 0 times

Nothing has been watched yet. Each button runs the real function against a registration it builds on the spot.

reload port called 0 times

    <SWUpdater> is not mounted: it registers a worker, so it waits for the button.

    Usage
    <SWUpdater
      scriptUrl="/sw.js"
      reload={() => globalThis.location.reload()}
      onUpdate={() => app.toast.info({ body: "Updating…" })}
      onError={(error) => app.report(error)}
    />
    
    // Only when the worker speaks another dialect — the default is { action: "skipWaiting" }:
    <SWUpdater scriptUrl="/sw.js" updateMessage={{ type: "SKIP_WAITING" }} />

    <ImageLightbox />

    Makes the images inside a container zoomable, opening `@spy4x/preact-ui`'s shared `Lightbox` — the same dialog `ImageGallery` opens on a thumbnail. Progressive enhancement in the strict sense: the server renders the page and this only adds a zoom layer after hydration, so a reader without JavaScript loses a zoom they never had. The layer is delegated to the container — one listener rather than one per image, and images arriving later still work. **A zoomable image behaves like a button**: it takes a Tab stop, carries a button's role and a name saying what it does, and opens with Enter or Space, with Space cancelled so the page does not scroll away underneath. A click or an Enter press is cancelled too, so the second image below opens the lightbox instead of following the link it sits in. **The dialog is the component's real output and it is really closed** until an image is opened. Escape closes it natively and a click on the backdrop closes it, which is only true because the image is positioned inside the dialog rather than filling it — a child that covers the dialog is a backdrop no click can reach. **Previous and next page through the container's other zoomable images**, snapshotted at the moment one opens — the two images below are what makes that a claim the card can show rather than describe. Every string it shows is a prop with an English default.

    <ImageLightbox /> renders the dialog below, then watches [data-lightbox] for clicks and key presses. Both images below are Tab stops that open it with Enter or Space; the second is wrapped in a link to example.com, which opening the lightbox does not follow.

    A placeholder imageA placeholder image inside a link

    With fallbackAlt="", an image with no description at all is not a zoom control — it stays a plain image, and the link below still works.

    (the link's target)
    Usage
    <ImageLightbox
      containerSelector="[data-lightbox]"
      fallbackAlt="Figure"
      zoomLabel="Zoom"
      onOpen={(image) => analytics.track("lightbox", image.src)}
    />

    <RailShell />

    A third page frame beside `Shell` and `SiteHeader`: a vertical rail of icon-over-label items with a pinned, visually distinct primary action from `md` up, and below `md` a bottom tab bar of at most five slots whose last slot, **More**, opens a native modal `<dialog>` with the remaining items and the primary action. **The switch is a CSS breakpoint**, so the server render carries both and nothing reads the window while rendering — resize the browser window itself to see the tab bar, since the breakpoint reads the viewport, not this card. **The overlay is a modal `<dialog>`**: More opens it with `showModal()` and focus moves inside; Escape, a backdrop click, the close button and choosing an entry all close it, and focus returns to More. **With no JavaScript** every entry with an `href` is a link, and More and the close button carry `command`/`commandfor`, so a browser that supports invoker commands still opens and closes the overlay. **Nothing covers the page**: the rail is a column in the layout and the tab bar keeps its place in the flow, padded by the bottom safe-area inset. An entry with an `href` is a plain link, so the shell works with no JavaScript; one without calls the `navigate` port with its `key`, which is what every entry in this demo does. Every colour is a theme token read through `var()` with the default palette's value as its fallback, so the shell follows whichever palette the page sets and still draws without the theme preset. Every string — the nav's name, More, the dialog's name, its close button, the skip link — has an English default and a `labels` override.

    Skip to content

    Navigated to: home

    Paragraph 1 of the page. It is here so the frame scrolls and the rail and the tab bar can be seen staying in place while it does.

    Paragraph 2 of the page. It is here so the frame scrolls and the rail and the tab bar can be seen staying in place while it does.

    Paragraph 3 of the page. It is here so the frame scrolls and the rail and the tab bar can be seen staying in place while it does.

    Paragraph 4 of the page. It is here so the frame scrolls and the rail and the tab bar can be seen staying in place while it does.

    Paragraph 5 of the page. It is here so the frame scrolls and the rail and the tab bar can be seen staying in place while it does.

    Paragraph 6 of the page. It is here so the frame scrolls and the rail and the tab bar can be seen staying in place while it does.

    Paragraph 7 of the page. It is here so the frame scrolls and the rail and the tab bar can be seen staying in place while it does.

    Paragraph 8 of the page. It is here so the frame scrolls and the rail and the tab bar can be seen staying in place while it does.

    Paragraph 9 of the page. It is here so the frame scrolls and the rail and the tab bar can be seen staying in place while it does.

    Paragraph 10 of the page. It is here so the frame scrolls and the rail and the tab bar can be seen staying in place while it does.

    Paragraph 11 of the page. It is here so the frame scrolls and the rail and the tab bar can be seen staying in place while it does.

    Paragraph 12 of the page. It is here so the frame scrolls and the rail and the tab bar can be seen staying in place while it does.

    The last line of the page.

    More

    Usage
    <RailShell
      items={[
        { key: "home", label: "Home", href: "/", Icon: IconHome },
        { key: "projects", label: "Projects", href: "/projects", Icon: IconFolder },
        // … more than five entries in all, and the phone bar grows a "More" slot
      ]}
      currentPath={location.pathname}
      primary={{ key: "compose", label: "Write", href: "/new", Icon: IconPencilSquare }}
    >
      <Page />
    </RailShell>

    Helpers

    The functions and constants system/ exports beside its components, each run on this page.

    normalizeCanonical()

    A canonical address in the one spelling a search engine should see: resolved, with dot segments removed.

    Output
    "https://example.com/guide?page=2"
    Usage
    import { normalizeCanonical } from "@spy4x/preact-system"
    
    normalizeCanonical("https://example.com/docs/../guide?page=2")

    Breadcrumbs as structured data

    `canonicalUrl` parses a page address and refuses anything that is not `http`/`https`; `breadcrumbItems` resolves the crumbs the caller states against it, and `breadcrumbListJsonLd` wraps them in a `BreadcrumbList` anchored to the page.

    Output
    {
      "canonical": "https://example.com/docs/setup",
      "items": [
        {
          "@type": "ListItem",
          "position": 1,
          "name": "Docs",
          "item": "https://example.com/docs"
        },
        {
          "@type": "ListItem",
          "position": 2,
          "name": "Setup",
          "item": "https://example.com/docs/setup"
        }
      ],
      "id": "https://example.com/docs/setup#breadcrumb"
    }
    Usage
    import {
      breadcrumbItems,
      breadcrumbListJsonLd,
      canonicalUrl,
    } from "@spy4x/preact-system"
    
    const page = "https://example.com/docs/setup#install"
    const crumbs = [{ name: "Docs", href: "/docs" }, { name: "Setup" }]
    
    canonicalUrl(page).href
    breadcrumbItems(page, crumbs)
    breadcrumbListJsonLd(page, crumbs)["@id"]

    createHeadStore()

    One page-head signal per request: `setHead` merges a page's fields over the defaults, and `resetHead` puts the defaults back before the next page.

    Output
    {
      "onPricing": "Pricing — Acme",
      "afterReset": "Acme"
    }
    Usage
    import { createHeadStore } from "@spy4x/preact-system"
    
    const { head, setHead, resetHead } = createHeadStore({
      title: "Acme",
      description: "Tools for small teams.",
      canonical: "https://example.com/",
    })
    setHead({ title: "Pricing — Acme", canonical: "https://example.com/pricing" })
    const onPricing = head.value.title
    resetHead()
    const afterReset = head.value.title

    The head tags as data

    `seoHeadTags` is every tag `SEOHead` renders, in document order, for a head pipeline that is not a component tree; `seoHeadJsonLd` is the JSON-LD graph, and `jsonLdText` serialises it for a `<script>` body with `<` escaped.

    Output
    {
      "tags": [
        "title",
        "description",
        "canonical",
        "robots",
        "twitter:card",
        "twitter:title",
        "twitter:description",
        "og:type",
        "og:title",
        "og:description",
        "og:url",
        "script"
      ],
      "script": "[{\"@type\":\"Offer\",\"name\":\"Starter\",\"description\":\"For \\u003c5 people.\"},{\"@type\":\"BreadcrumbList\",\"@id\":\"https://example.com/pricing#breadcrumb\",\"itemListElement\":[{\"@type\":\"ListItem\",\"position\":1,\"name\":\"Home\",\"item\":\"https://example.com/\"},{\"@type\":\"ListItem\",\"position\":2,\"name\":\"Pricing\",\"item\":\"https://example.com/pricing\"}]}]"
    }
    Usage
    import { jsonLdText, seoHeadJsonLd, seoHeadTags } from "@spy4x/preact-system"
    
    const head = {
      title: "Pricing — Acme",
      description: "Plans for teams of every size.",
      canonical: "https://example.com/pricing",
      jsonLd: [{ "@type": "Offer", name: "Starter", description: "For <5 people." }],
      crumbs: [{ name: "Home", href: "/" }, { name: "Pricing" }],
    }
    
    seoHeadTags(head).map(({ tag, attrs }) => attrs.name ?? attrs.property ?? attrs.rel ?? tag)
    jsonLdText(seoHeadJsonLd(head))

    Server state into the page and back

    `stateInitText` is the JSON body `StateInit` writes, with `<` escaped so `</script>` cannot end it; `readStateInit` reads it back, trusting only a `<script type="application/json">` — here from a stand-in for `document`.

    Output
    {
      "text": "{\"user\":\"Ada\",\"note\":\"\\u003c/script> is safe\"}",
      "read": {
        "user": "Ada",
        "note": "</script> is safe"
      }
    }
    Usage
    import { readStateInit, stateInitText } from "@spy4x/preact-system"
    
    const text = stateInitText({ user: "Ada", note: "</script> is safe" })
    const page = {
      getElementById: () => ({
        tagName: "SCRIPT",
        getAttribute: () => "application/json",
        textContent: text,
      }),
    }
    readStateInit("state-init", page)

    describeCalendarDay()

    The default accessible description of one calendar cell: the date as the locale writes it, then how many are left or why it cannot be picked.

    Output
    [
      "11 March 2026 — 3 available",
      "11 March 2026 — past"
    ]
    Usage
    import { describeCalendarDay } from "@spy4x/preact-system"
    
    const day = {
      date: "2026-03-11",
      label: "11 March 2026",
      inMonth: true,
      disabled: false,
      reason: null,
      availableCount: 3,
      selected: false,
      today: false,
    }
    describeCalendarDay(day)
    describeCalendarDay({ ...day, disabled: true, reason: "past", availableCount: undefined })

    The phone tab bar's split

    `tabBarSlots` decides which `RailShell` entries become tabs and which go behind "More": everything fits in `TAB_BAR_SLOTS` or fewer, otherwise four tabs and the primary action leads the overflow.

    Output
    {
      "slots": 5,
      "tabs": [
        "home",
        "inbox",
        "files",
        "reports"
      ],
      "more": [
        "new",
        "people",
        "settings"
      ]
    }
    Usage
    import { TAB_BAR_SLOTS, tabBarSlots } from "@spy4x/preact-system"
    
    const items = ["home", "inbox", "files", "reports", "people", "settings"]
      .map((key) => ({ key, label: key }))
    const { tabs, more } = tabBarSlots(items, { key: "new", label: "New" })
    
    TAB_BAR_SLOTS
    tabs.map((item) => item.key)
    more.map((item) => item.key)

    What the lightbox opens

    `zoomableAlt` picks an image's description or the fallback, `resolveImage` turns a clicked element into a lightbox image, and `collectSequence` builds the images the lightbox pages through and where the clicked one sits — here from stand-ins for `<img>` elements.

    Output
    {
      "alt": "Harbour at dawn",
      "resolved": {
        "src": "/b.jpg",
        "alt": "Image"
      },
      "sequence": {
        "images": [
          {
            "src": "/a.jpg",
            "alt": "Harbour at dawn"
          },
          {
            "src": "/b.jpg",
            "alt": "Image"
          },
          {
            "src": "/c.jpg",
            "alt": "Image"
          }
        ],
        "index": 2
      }
    }
    Usage
    import { resolveImage } from "@spy4x/preact-system"
    import { collectSequence, zoomableAlt } from "@spy4x/preact-system/image-lightbox"
    
    const image = (src, alt) => ({ matches: (s) => s === "img", src, alt })
    const images = [image("/a.jpg", "Harbour at dawn"), image("/b.jpg", null), image("/c.jpg", " ")]
    
    zoomableAlt("  Harbour at dawn ", "Image")
    resolveImage(images[1])
    collectSequence(images, images[2])

    Finding and registering the service worker

    `serviceWorkerContainer` returns `navigator.serviceWorker`, or nothing off a browser; `startUpdates` registers the worker with it and watches for an update, and the function it returns stops watching.

    Output
    {
      "offBrowser": "<undefined>",
      "registered": [
        "/sw.js (scope /app/)"
      ]
    }
    Usage
    import { serviceWorkerContainer, startUpdates } from "@spy4x/preact-system"
    
    const registered = []
    const container = Object.assign(new EventTarget(), {
      controller: null,
      register: (url, options) => {
        registered.push(`${url} (scope ${options?.scope ?? "default"})`)
        return new Promise(() => {})
      },
    })
    
    serviceWorkerContainer({})
    const stop = startUpdates(container, { scope: "/app/" })
    stop()

    Handing over to a waiting worker

    `watchForUpdate` reports once that a new worker is waiting, and `skipWaiting` tells it to take over by posting `DEFAULT_UPDATE_MESSAGE`, the message the worker has to recognise.

    Output
    {
      "posted": true,
      "log": [
        "update waiting",
        {
          "action": "skipWaiting"
        }
      ]
    }
    Usage
    import {
      DEFAULT_UPDATE_MESSAGE,
      skipWaiting,
      watchForUpdate,
    } from "@spy4x/preact-system"
    
    const log = []
    const waiting = Object.assign(new EventTarget(), {
      state: "installed",
      postMessage: (message) => log.push(message),
    })
    const registration = Object.assign(new EventTarget(), { installing: null, waiting })
    
    const stop = watchForUpdate(registration, {
      hasController: () => true,
      onUpdate: () => log.push("update waiting"),
    })
    skipWaiting(registration, DEFAULT_UPDATE_MESSAGE)
    stop()

    reloadOnControllerChange()

    Reloads once the new worker controls the page — armed when the visitor asks for the reload, never at registration, so other tabs are left alone.

    Output
    {
      "reloads": 1
    }
    Usage
    import { reloadOnControllerChange } from "@spy4x/preact-system"
    
    let reloads = 0
    const container = Object.assign(new EventTarget(), {
      controller: null,
      register: () => new Promise(() => {}),
    })
    
    const stop = reloadOnControllerChange(container, () => reloads++)
    container.dispatchEvent(new Event("controllerchange"))
    stop()
    container.dispatchEvent(new Event("controllerchange"))

    @spy4x/preact-crud

    CRUD

    The list and editor scaffolding a resource page is rebuilt from — props and slots, no entity and no store assumed. Every card drives a small in-memory store built from the structural interfaces the package declares.

    CRUD

    The list and editor scaffolding a resource page is rebuilt from — props and slots, no entity and no store assumed. Every card drives a small in-memory store built from the structural interfaces the package declares.

    <CrudList />

    A resource list from a store: title, count badge, search box, Active/Archived filter, an add link and a table whose header, cells and actions column are slots. `store` is the structural `CrudListStore` — two status slices plus a load state — so any model store satisfies it; this card drives a small in-memory one built from signals. `rows` is the other source: a plain signal, for a nested list or a collection with no soft-delete column. `match` decides what one search word matches, and words are ANDed.

    Teams3

    NameMembersActions
    Design4
    Support0
    Platform7
    Usage
    <CrudList
      title="Teams"
      store={teamStore}
      match={(row, word) => search(row.name, word)}
      query={query}
      header={<th scope="col">Name</th>}
      row={(row) => <td>{row.name}</td>}
      actions={(row) => (
        <RowActions>
          <RowAction href={`/teams/${row.id}/edit`}>Edit</RowAction>
          <RowAction danger onClick={() => archive(row.id)}>Archive</RowAction>
        </RowActions>
      )}
    />

    <RowAction />

    One item of a row's action menu. With `href` it is a link and without one a `<button>`, which is how one component covers a navigation and an operation without the caller branching. `danger` renders it red; `disabled` disables the button, and the menu's arrow keys skip it. The element itself is a `DropdownItem`, so it carries `role="menuitem"` and the menu around it can move focus to it; the spacing row it sits in is `role="none"`, which keeps it a direct child of the menu for assistive tech. `RowActions` still supplies the popup and the trigger.

    Usage
    <RowAction href={`/teams/${row.id}/edit`}>Edit</RowAction>
    <RowAction danger onClick={() => archive(row.id)}>Archive</RowAction>

    <RowActions />

    The per-row actions menu: a vertical-ellipsis `Dropdown` trigger over `RowAction` items, with the label defaulting to `Actions` and naming both the trigger and the menu. It is the piece that knows it is a popup, so the trigger, the menu label and the keyboard contract live here — opening it moves focus to the first action, the arrow keys walk them, and Escape closes it and returns focus to the trigger. The item supplies its own `role="menuitem"`, because that role cannot be applied to a child from outside.

    Usage
    <RowActions label="Team actions">
      <RowAction href="/teams/1/edit">Edit</RowAction>
      <RowAction onClick={archive}>Archive</RowAction>
    </RowActions>

    <CrudEditor />

    The add/edit form harness: it loads the row out of the store once, validates the model on every change, owns the archive toggle and the blocked-archive cascade, and renders chrome around a body the caller supplies. `children`, `notice` and `footerSlot` are functions rather than children so the caller's rows receive the model and validation signals directly — the same contract every field row in `field.tsx` takes. Save is enabled only once the form has initialised, the validation model is clean and nothing blocks the archive. `schema` is an arktype schema run through `validateSchema`; `validate` is the domain-check port that runs after it, reading signals so a check against a not-yet-loaded collection fixes itself. A rule with no field of its own — this card's schema adds one, Name must differ from Notes — is filed under the reserved `FORM_FIELD` key and shown as a live region above Save, named at all times by Save's `aria-describedby`.

    Add a team

    The schema's own rule: must differ from Name

    A notice slot sits between the field grid and the footer.

    onCreated received: nothing created yet

    the create port logged: nothing yet

    rows in the store: 4 (was 4 before the first save)

    Save is disabled until the form has initialised, the validation model is clean and nothing blocks the archive. The blank row starts with Name and Notes equal (both empty), so the schema's cross-field rule fails from the start — that rule has no field of its own to report against, so it shows as the live region above Save instead.

    Usage
    <CrudEditor
      store={teamStore}
      blank={blankTeam}
      schema={teamBaseSchema}
      entity="Team"
      cancelHref="/teams"
      archive={{}}
      onCreated={(row) => navigate(`/teams/${row.id}/edit`)}
    >
      {({ vm, vl }) => <TextField vm={vm} vl={vl} name="name" label="Name" />}
    </CrudEditor>

    <AssociationEditor />

    The editor for a row that exists only to join two entities, composed from `CrudEditor` rather than rebuilt beside it. Three differences, each a slot or a port: no validation model of its own, because a junction row has no rules; a *conflict* instead of a dependency list — the row a new one would duplicate is reported as an issue on the field the user has to change, which is what blocks Save, with the duplicate's id travelling as the issue payload — and removal instead of archiving, written into `footerSlot`. Its store reads `list.all`, removed rows included, because the duplicate worth telling the user about is often one that can be restored: that branch was unreachable in the source editors, which scanned `nonDeleted` and then branched on `deletedAt`.

    Add a supplier association

    rows the conflict port scans: 3, of which 1 were removed — so typing Ink supplier reaches the "restore it instead" branch, and Paper supplier the live-conflict branch.

    Usage
    <AssociationEditor
      store={associationStore}
      mode="add"
      blank={blankAssociation}
      entity="Supplier association"
      cancelHref="/suppliers"
      conflictField="supplierId"
      conflict={(row, all) => all.find((entry) => entry.supplierId === row.supplierId)}
      conflictMessage="That supplier is already attached."
      conflictRemovedMessage="It was attached before and removed — restore it instead."
    >
      {({ vm, vl }) => <SelectField vm={vm} vl={vl} name="supplierId" label="Supplier" options={…} />}
    </AssociationEditor>

    <DeletionValidation />

    The list of entities blocking a soft delete. A row can only be archived once everything pointing at it has been archived, and the store hands back what still points at it. An empty list renders nothing visible, which is why the healthy path looks empty — the button below switches between the two. The block scrolls itself into view on a first non-empty list, on a later list with different content, and on a list that follows an empty one (a second blocked archive attempt, after `CrudEditor`'s archive checkbox is unchecked and rechecked); a re-render that replaces one non-empty list with an equal one, without emptying in between, never moves the page. The `role="alert"` region itself is always present, empty until there is something to say.

    An empty list renders nothing visible. Re-rendered 0 times for a reason unrelated to the dependency list — the block does not move when that happens.

    Usage
    <DeletionValidation
      dependencies={[
        { kind: "Projects", values: [{ title: "Launch plan", url: "/projects/1/edit" }] },
      ]}
      model="Team"
    />

    <TextField />

    One labelled text input that reads and writes one field of a model signal. It commits on **blur**, trimmed, so a keystroke is not a store write. The row generates its own control id with `useId`, which is what stops two editors — or two rows — on one page sharing `id="name"` and stealing each other's label. `span` sets the grid cell, `hint` the helper text, and `renderIssue` replaces the default red paragraph.

    Commits 0 for an empty box

    name: Design · members: 4 · parentId: 1 · archived: off · notes: (empty)

    Usage
    <TextField vm={vm} vl={vl} name="name" label="Name" placeholder="Design" span="sm:col-span-2" />

    <NumberField />

    The same row with a numeric control. A number input reports `""` for anything the browser cannot parse — an empty box, a lone `-`, the intermediate states of `1e` — so an empty or unparsable box commits `0` rather than `NaN`, which is what would otherwise make an arktype schema reject the model on every keystroke. `commitNumber` is exported and pure for exactly that rule.

    Clear the box or type 1e, then click away

    members in the model: 4

    Usage
    <NumberField vm={vm} vl={vl} name="members" label="Members" hint="Commits 0 for an empty box" />

    <SelectField />

    One labelled select taking its options as data. The control's value is the model's, not each option's `selected` flag, so a model holding a foreign key no option carries falls back to the empty option instead of silently displaying the first entry as if it were chosen. Option values are stringified in the markup on purpose: a browser only ever reports a string, and leaving a numeric `0` on an option made it match the empty placeholder (`"" == 0`), so two options rendered selected. `SelectOption.value` keeps its type, so a numeric foreign key stays numeric in the model.

    parentId in the model: 2 — a number, because SelectOption.value keeps its type; set it to an id no option carries and the select shows the empty option instead of the first entry

    Usage
    <SelectField
      vm={vm}
      vl={vl}
      name="parentId"
      label="Parent team"
      options={[{ value: 1, label: "Design" }, { value: 2, label: "Support" }]}
      placeholder="— none —"
    />

    <CheckboxField />

    One labelled checkbox with its label after the box, so the box and the text share one hit area. `checked` is the model's boolean and `onChange` writes it straight back; the control's id is generated per row like every other field, so the label always points at its own box.

    Bound to a boolean field of the model

    archived in the model: on

    Usage
    <CheckboxField vm={vm} vl={vl} name="archived" label="Is archived?" />

    <TextareaField />

    The multi-line row. Same contract as `TextField` — commits on blur, trimmed, own control id — with `rows` defaulting to `4`.

    notes in the model: Runs the night shift.

    Usage
    <TextareaField vm={vm} vl={vl} name="notes" label="Notes" rows={3} />

    <FieldIssues />

    The issues of one field, keyed by issue type, so an application's own checks (`NOT_UNIQUE`, `LINKED_ENTITY_IS_DELETED`) sit beside the schema's and neither overwrites the other. A field with no issues renders nothing, and one whose every issue has been cleared is dropped from the model rather than left as an empty object — that is what makes `setFieldIssue(…, undefined)` an eraser. `renderIssue` replaces the default red paragraph, which the editors that link to the row a value collides with use to put a "Navigate to it" control beside the message; the issue's `payload` is the id to link at.

    Two issue types can sit on one field at once; clearing both drops the field

    the field row above renders the default message; this is the same model through a custom renderIssue:

    Usage
    <FieldIssues vl={vl} name="name" />
    
    // Or with the caller's own rendering, and the payload the issue carries:
    <FieldIssues
      vl={vl}
      name="name"
      renderIssue={(issue, type) => <p>{type}: {issue.message}</p>}
    />

    Helpers

    The functions and constants crud/ exports beside its components, each run on this page.

    timeAgo()

    How long ago a timestamp was, in words, measured from the clock; two and a half hours ago always reads the same.

    Output
    "2 hours ago"
    Usage
    import { timeAgo } from "@spy4x/preact-crud"
    
    timeAgo(Date.now() - 150 * 60_000)

    formatTimestamp()

    An absolute timestamp for a `title` attribute: the date and a 24-hour time, the time alone, or `-` for an unset column.

    Output
    [
      "15/01/2026 09:05",
      "09:05",
      "-"
    ]
    Usage
    import { formatTimestamp } from "@spy4x/preact-crud"
    
    formatTimestamp("2026-01-15T09:05:00Z", { full: true, timeZone: "UTC" })
    formatTimestamp("2026-01-15T09:05:00Z", { timeOnly: true, timeZone: "UTC" })
    formatTimestamp(null)

    The rows a list shows

    `rowsForStatus` picks the Active or Archived slice of a store, and `listRows` filters that slice by the search box, one word at a time.

    Output
    {
      "archived": [
        "Old launch notes"
      ],
      "activeLaunch": [
        "Launch plan"
      ]
    }
    Usage
    import { listRows, rowsForStatus } from "@spy4x/preact-crud"
    
    const match = (row, word) => row.name.toLowerCase().includes(word.toLowerCase())
    
    rowsForStatus(store, "archived").map((row) => row.name)
    listRows(store, "active", "launch", match).map((row) => row.name)

    editorState()

    Whether the editor's form is valid, busy, and savable: Save is live only once the row has loaded, nothing is invalid, nothing is in flight and nothing blocks the archive.

    Output
    {
      "ready": {
        "valid": true,
        "busy": false,
        "saveEnabled": true
      },
      "notLoaded": {
        "valid": false,
        "busy": false,
        "saveEnabled": false
      },
      "blocked": {
        "valid": true,
        "busy": false,
        "saveEnabled": false
      }
    }
    Usage
    import { editorState } from "@spy4x/preact-crud"
    
    const ready = { initialized: true, validation: {}, canChange: true, inProgress: false, blocked: 0 }
    
    editorState(ready)
    editorState({ ...ready, initialized: false })
    editorState({ ...ready, blocked: 2 })

    submitEditor()

    Routes a save to the store write its mode implies: an add creates, an edit updates, and an edit whose archive is blocked writes nothing. The card prints which store methods each submit reached.

    Output
    [
      "create",
      "update 1"
    ]
    Usage
    import { submitEditor } from "@spy4x/preact-crud"
    
    const calls = []
    const store = {
      create: (row) => (calls.push("create"), Promise.resolve({ error: null, result: row })),
      update: (id, row) => (calls.push(`update ${id}`), Promise.resolve({ error: null, result: row })),
    }
    const row = { id: 1, name: "Launch plan", deletedAt: null }
    
    submitEditor({ mode: "add", store, value: row })
    submitEditor({ mode: "edit", id: 1, store, value: row })
    submitEditor({ mode: "edit", id: 1, store, value: row, blocked: 2 })

    toggleArchiveState()

    The archive checkbox's next state: archiving stamps `deletedAt` with the current time and asks what still points at the row; un-archiving clears both.

    Output
    {
      "stamped": true,
      "blocked": [
        {
          "kind": "Tasks",
          "values": [
            {
              "title": "Write copy",
              "url": "/tasks/7"
            }
          ]
        }
      ],
      "unarchiving": {
        "deletedAt": null,
        "blocked": []
      }
    }
    Usage
    import { toggleArchiveState } from "@spy4x/preact-crud"
    
    const dependents = () => [{ kind: "Tasks", values: [{ title: "Write copy", url: "/tasks/7" }] }]
    
    const archiving = toggleArchiveState({ id: 1, deletedAt: null }, dependents)
    archiving.deletedAt instanceof Date
    archiving.blocked
    toggleArchiveState({ id: 3, deletedAt: "2026-01-15T09:00:00Z" }, dependents)

    Field values in and out

    `setField` writes one field into a model signal as a fresh object, `fieldText` shows a value in a control (`null` as empty, never `"null"`), and `commitNumber` reads a number box, half-typed input included.

    Output
    {
      "model": {
        "name": "Launch plan",
        "budget": 1200
      },
      "empty": "",
      "halfTyped": 0
    }
    Usage
    import { commitNumber, fieldText, setField } from "@spy4x/preact-crud"
    import { signal } from "@preact/signals"
    
    const vm = signal({ name: "Launch plan", budget: null })
    setField(vm, "budget", commitNumber(" 1200 "))
    
    vm.value
    fieldText(null)
    commitNumber("12e")

    A duplicate association as a field issue

    `conflictIssue` files a duplicate under `CONFLICT` on the field the user has to change, with the duplicate's id as payload; `isRestorable` says whether that duplicate was removed and can be brought back instead.

    Output
    {
      "issue": {
        "message": "Removed earlier — restore it instead.",
        "payload": 2
      },
      "restorable": true
    }
    Usage
    import { CONFLICT, conflictIssue, isRestorable } from "@spy4x/preact-crud"
    
    const rows = [
      { id: 1, name: "Paper supplier", deletedAt: null },
      { id: 2, name: "Ink supplier", deletedAt: "2026-02-01T00:00:00Z" },
    ]
    const input = {
      rows,
      conflict: (row, all) => all.find((entry) => entry.name === row.name),
      field: "name",
      message: "Already attached.",
      removedMessage: "Removed earlier — restore it instead.",
    }
    
    conflictIssue({ id: 0, name: "Ink supplier", deletedAt: null }, {}, input).name[CONFLICT]
    isRestorable(rows[1])

    associationActions()

    Binds an association editor's Delete and Restore buttons to the store's `delete` and `undelete`. The card prints which store method each button reached.

    Output
    [
      "delete 4",
      "undelete 4"
    ]
    Usage
    import { associationActions } from "@spy4x/preact-crud"
    
    const calls = []
    // The store's other members are left out; these two are all the actions call.
    const store = {
      delete: (id) => (calls.push(`delete ${id}`), Promise.resolve({ error: null, result: null })),
      undelete: (id) => (calls.push(`undelete ${id}`), Promise.resolve({ error: null, result: null })),
    }
    
    const { remove, restore } = associationActions(store)
    remove(4)
    restore(4)

    @spy4x/preact-charts

    Charts

    Server-rendered charts and the d3 islands. The zero-JS SVGs are live and need nothing but their data; the two islands draw in an effect, so their cards are their real server render and a browser is where the drawing happens.

    Charts

    Server-rendered charts and the d3 islands. The zero-JS SVGs are live and need nothing but their data; the two islands draw in an effect, so their cards are their real server render and a browser is where the drawing happens.

    <Bars />

    A labelled bar chart from `data` alone, rendered as a table of proportions: no d3, no client JavaScript, no width to measure.

    Orders by plan
    Starter
    41
    Team
    27
    Business
    12
    Enterprise
    6
    Usage
    <Bars data={[{ label: "Starter", value: 41 }]} title="Orders by plan" />

    <LineChart />

    Server-rendered SVG line chart over one or more series, taking its X labels from the points themselves. Axis maths lives in `scales.ts`, so a degenerate series — one point, all-equal values, a non-finite `y` — still renders instead of producing NaN coordinates. `showLegend` defaults on for a multi-series chart and off for a single one, and a `yDomain` the caller supplies is honoured exactly, which is what puts two panels on one axis.

    Orders per month

    -100102030405060JanFebMarAprMayJunOrders Jan: 41Orders Feb: 27Orders Mar: 12Orders Apr: 6Orders May: 48Orders Jun: 21Returns Jan: 28Returns Feb: 19Returns Mar: 9Returns Apr: 4Returns May: 33Returns Jun: 14
    OrdersReturns
    Usage
    <LineChart
      title="Orders per month"
      series={[{ name: "Orders", points: months.map((m) => ({ x: m.label, y: m.orders })) }]}
      yFormat={(value) => value.toFixed(0)}
      xStride={2}
    />

    <DonutChart />

    Server-rendered donut drawn as one CSS `conic-gradient`, with the centre value in a hole cut by an inset circle. The share maths is exported as `donutGeometry` and tested on its own, which is why a dataset with no positive value renders a flat empty ring instead of the invalid gradient an all-zero total would produce. A datum carrying `href` turns its legend row into a link.

    Traffic sources

    Usage
    <DonutChart
      data={[{ label: "Organic", value: 52 }, { label: "Referral", value: 24, href: "/referral" }]}
      centerValue="12.4k"
      centerLabel="sessions"
      title="Traffic sources"
    />

    <Kpi />

    One key-performance-indicator card: label, value, optional caption. Values use tabular figures so a row of them does not jitter as they change. `tone` says what the number is for — `accent` by default, plus `positive`, `warning`, `negative` and `neutral` — rather than the source's `good`/`warn`/`bad` vocabulary.

    Accent
    42
    tone="accent"
    Positive
    42
    tone="positive"
    Warning
    42
    tone="warning"
    Negative
    42
    tone="negative"
    Neutral
    42
    tone="neutral"
    Usage
    <Kpi label="Uptime" value="99.95%" sub="last 30 days" tone="positive" />

    <KpiGrid />

    Responsive grid for `Kpi` cards: `repeat(auto-fit, minmax(minWidth, 1fr))`. The column minimum is an inline style rather than a class, because a Tailwind class cannot vary with a prop. `children` may be anything that belongs in the grid, not only `Kpi` — the two grids below differ only in `minWidth`.

    Uptime
    99.95%
    last 30 days
    Errors
    3
    Pending
    18

    the same three cards at minWidth="14rem"

    Uptime
    99.95%
    last 30 days
    Errors
    3
    Pending
    18
    Usage
    <KpiGrid minWidth="12rem">
      <Kpi label="Uptime" value="99.95%" tone="positive" />
      <Kpi label="Errors" value={3} tone="negative" />
    </KpiGrid>

    <MetricPanel />

    The shell one metric panel is made of: a heading with its unit, an actions slot, an error box and the chart body as children. Presentational only — it loads nothing. The fetching half is the exported `loadMetricSeries` (and the `useMetricSeries` hook), which takes a `loadStats` port and a `scale` multiplier; that pair is what let a source application's two near-identical metric panels become one component.

    Revenue, k€

    Orders and returns

    -10.000.0010.0020.0030.0040.0050.0060.00JanFebMarAprMayJunOrders Jan: 41.00Orders Feb: 27.00Orders Mar: 12.00Orders Apr: 6.00Orders May: 48.00Orders Jun: 21.00Returns Jan: 28.00Returns Feb: 19.00Returns Mar: 9.00Returns Apr: 4.00Returns May: 33.00Returns Jun: 14.00
    OrdersReturns

    Sessions, per day

    The stats endpoint returned 502.

    Widen the date range and try again.

    No series

    No data

    Usage
    <MetricPanel
      title="Revenue"
      unit="k€"
      error={error}
      actions={<ExportButtons />}
    >
      <D3LineChart data={revenue.data} timeFrame={revenue.timeFrame} ariaLabel="Revenue, k€" />
    </MetricPanel>

    <D3LineChart />

    The interactive island: d3 v7 draws into the svg from an effect, re-renders on resize and shows a tooltip built by `tooltipFormat`. It server-renders as an empty, labelled `<svg>` because nothing touches the DOM before the effect runs. This page goes one step further and loads the component itself only when the page opens, so its server-rendered card is a placeholder, and confirming that the axes and the path appear needs a browser. `ignoreZeroes` draws zeroes as a gap with a legend note, and `referenceValue` adds a dashed target marker. Needs `d3`, an optional peer declared in `charts/deno.json` and absent from the root import map.

    Revenue, k€: drawn in the browser with d3
    Revenue with a gap for missing values: drawn in the browser with d3
    Usage
    <D3LineChart
      data={revenue.data}
      timeFrame="hours"
      referenceValue={12}
      ignoreZeroes
      tickFormat={(value) => value.toFixed(1)}
      ariaLabel="Revenue, k€"
    />

    <CompareChart />

    A `D3LineChart` plus a toggle that loads the window before the current one through a `loadStats` port — the fetch stays with the caller, which is what replaced a source application's direct call into its chart store. A failed load becomes a red box beside the second chart instead of a thrown error, and `rangePicker` is a slot for the caller's own date control. It server-renders with the toggle off, so the second chart does not exist until a browser clicks it.

    Revenue, k€: drawn in the browser with d3
    Usage
    <CompareChart
      range={range}
      data={revenue.data}
      timeFrame="hours"
      loadStats={(window) => api.stats({ ...window, kind: "revenue" })}
      onError={(message) => app.toast.error({ body: message })}
    />

    Helpers

    The scales, colours and loaders charts/ exports beside its charts, each run on this page.

    extent()

    The smallest and largest finite value of a series, or `null` when it has none: the domain a scale starts from.

    Output
    [
      [
        1,
        9
      ],
      null
    ]
    Usage
    import { extent } from "@spy4x/preact-charts"
    
    [extent([3, 9, 1, Number.NaN]), extent([])]

    paddedDomain() and niceScale()

    `paddedDomain` widens a data range so no point sits on the frame; `niceScale` pads it too, then rounds it outward to round numbers and returns the ticks to draw.

    Output
    [
      {
        "min": 4.5,
        "max": 94.5
      },
      {
        "min": 0,
        "max": 100,
        "step": 20,
        "ticks": [
          0,
          20,
          40,
          60,
          80,
          100
        ]
      }
    ]
    Usage
    import { niceScale, paddedDomain } from "@spy4x/preact-charts"
    
    [paddedDomain(12, 87), niceScale(12, 87)]

    niceStep() and ticks()

    `niceStep` picks a round distance between ticks for a span, and `ticks` lists the round values that cover a range.

    Output
    {
      "step": 20,
      "ticks": [
        0,
        20,
        40,
        60,
        80,
        100
      ]
    }
    Usage
    import { niceStep, ticks } from "@spy4x/preact-charts"
    
    ({ step: niceStep(75), ticks: ticks(0, 100, 5) })

    linearScale()

    Maps a data domain onto a pixel range; a reversed range is how a Y axis puts larger values higher up.

    Output
    [
      200,
      150,
      0
    ]
    Usage
    import { linearScale } from "@spy4x/preact-charts"
    
    const y = linearScale([0, 100], [200, 0])
    console.log([y(0), y(25), y(100)])

    xLabelStride()

    How many X labels to skip between two drawn ones, so a dense axis stays readable: 5 points draw every label, 30 draw every fourth.

    Output
    [
      1,
      4,
      5
    ]
    Usage
    import { xLabelStride } from "@spy4x/preact-charts"
    
    [xLabelStride(5), xLabelStride(30), xLabelStride(30, 6)]

    barPercent()

    A bar's width as a percentage of the longest one, clamped to 0–100, and 0 for a value or maximum that cannot be drawn.

    Output
    [
      25,
      100,
      0
    ]
    Usage
    import { barPercent } from "@spy4x/preact-charts"
    
    [barPercent(30, 120), barPercent(150, 120), barPercent(5, 0)]

    donutGeometry()

    The slice maths behind `DonutChart`: each slice's share and the `conic-gradient` that paints the ring.

    Output
    {
      "total": 1000,
      "slices": [
        "Desktop 62.0%",
        "Mobile 31.0%",
        "Tablet 7.0%"
      ],
      "gradient": "conic-gradient(#4f46e5 0% 62%, #16a34a 62% 93%, #f59e0b 93% 100%)"
    }
    Usage
    import { donutGeometry } from "@spy4x/preact-charts"
    
    const ring = donutGeometry(
      [
        { label: "Desktop", value: 620 },
        { label: "Mobile", value: 310 },
        { label: "Tablet", value: 70 },
      ],
      { colors: ["#4f46e5", "#16a34a", "#f59e0b"] },
    )
    console.log({
      total: ring.total,
      slices: ring.segments.map((slice) => `${slice.label} ${slice.percent}`),
      gradient: ring.gradient,
    })

    Series colours

    `seriesColor` picks the colour for a series by its index, wrapping around the palette; `DEFAULT_CHART_PALETTE` is the palette used when you pass none.

    Output
    {
      "paletteSize": 5,
      "first": "var(--color-primary-muted, oklch(0.558 0.288 302.321))",
      "third": "#2563eb"
    }
    Usage
    import { DEFAULT_CHART_PALETTE, seriesColor } from "@spy4x/preact-charts"
    
    ({
      paletteSize: DEFAULT_CHART_PALETTE.length,
      first: seriesColor(0),
      third: seriesColor(2, ["#2563eb", "#dc2626"]),
    })

    Default chart colours

    The colours every chart uses when the caller passes none: each reads a `theme/` token and falls back to a fixed colour, so a chart renders with or without the theme.

    Output
    "<computed in the browser: needs charts/d3-line-chart, which imports d3>"
    Usage
    import {
      DEFAULT_AXIS_COLOR,
      DEFAULT_D3_LINE_CHART_COLORS,
      DEFAULT_GRID_COLOR,
      DEFAULT_SURFACE_COLOR,
      DEFAULT_TEXT_COLOR,
      DEFAULT_TRACK_COLOR,
    } from "@spy4x/preact-charts"
    
    ({
      axis: DEFAULT_AXIS_COLOR,
      grid: DEFAULT_GRID_COLOR,
      text: DEFAULT_TEXT_COLOR,
      surface: DEFAULT_SURFACE_COLOR,
      track: DEFAULT_TRACK_COLOR,
      d3Line: DEFAULT_D3_LINE_CHART_COLORS.line,
    })

    Time labels

    `TIME_FRAMES` lists the bucket sizes a series can have, `formatTimeTick` labels an X tick to suit the bucket size, and `defaultTooltipFormat` is the text `D3LineChart` shows on hover. Both print local time.

    Output
    "<computed in the browser: needs charts/d3-line-chart, which imports d3>"
    Usage
    import { defaultTooltipFormat, formatTimeTick, TIME_FRAMES } from "@spy4x/preact-charts"
    
    const at = new Date(2026, 2, 14, 9, 30)
    console.log({
      ticks: TIME_FRAMES.map((frame) => `${frame}: ${formatTimeTick(at, frame)}`),
      tooltip: defaultTooltipFormat({ timeGroup: at, value: 42 }),
    })

    yDomainFor()

    The Y range `D3LineChart` draws: from 0 (or the smallest non-zero value with `ignoreZeroes`) to the maximum plus 20% headroom, stretched to include a reference line.

    Output
    "<computed in the browser: needs charts/d3-line-chart, which imports d3>"
    Usage
    import { yDomainFor } from "@spy4x/preact-charts"
    
    const points = [0, 8, 20, 14].map((value, hour) => ({ timeGroup: hour * 3_600_000, value }))
    console.log([
      yDomainFor(points),
      yDomainFor(points, { ignoreZeroes: true }),
      yDomainFor(points, { referenceValue: 50 }),
    ])

    assertD3Available()

    The check `D3LineChart` makes before drawing: a `d3` with no line generator throws `MISSING_D3_LINE_ERROR`, which tells the reader to add the dependency.

    Output
    "<computed in the browser: needs charts/d3-line-chart, which imports d3>"
    Usage
    import { assertD3Available, MISSING_D3_LINE_ERROR } from "@spy4x/preact-charts/d3-line-chart"
    
    assertD3Available({ line: () => {} }) // a d3 with a line generator passes
    let message = ""
    try {
      assertD3Available({})
    } catch (error) {
      message = (error as Error).message
    }
    console.log({ isTheExportedMessage: message === MISSING_D3_LINE_ERROR, message })

    previousPeriod()

    The window of the same length that ends where a range starts; `steps` walks further back.

    Output
    [
      {
        "from": "2026-03-01T00:00:00.000Z",
        "to": "2026-03-08T00:00:00.000Z"
      },
      {
        "from": "2026-02-22T00:00:00.000Z",
        "to": "2026-03-01T00:00:00.000Z"
      }
    ]
    Usage
    import { previousPeriod } from "@spy4x/preact-charts"
    
    const week = { from: new Date("2026-03-08T00:00:00Z"), to: new Date("2026-03-15T00:00:00Z") }
    console.log([previousPeriod(week), previousPeriod(week, 2)])

    Stats payloads and their loaders

    `timeSeriesPointSchema` and `chartPayloadSchema` are the arktype shape a stats endpoint returns. `loadChartPayload` asks your `loadStats` port for a range and resolves to `{ payload, error }`, with a rejected payload as the error; `loadMetricSeries` does the same and multiplies every value by `scale`. The loaders resolve after this card renders, so it prints the calls they made to the port and the verdicts they apply.

    Output
    {
      "asked": [
        "2026-03-01T00:00:00.000Z → 2026-03-02T00:00:00.000Z",
        "2026-03-01T00:00:00.000Z → 2026-03-02T00:00:00.000Z"
      ],
      "point": {
        "timeGroup": "2026-03-01T00:00:00Z",
        "value": 1200
      },
      "rejected": "timeFrame must be \"days\", \"hours\" or \"minutes\" (was \"weeks\")"
    }
    Usage
    import { chartPayloadSchema, loadChartPayload, loadMetricSeries, timeSeriesPointSchema } from "@spy4x/preact-charts"
    import { type } from "arktype"
    
    const range = { from: new Date("2026-03-01T00:00:00Z"), to: new Date("2026-03-02T00:00:00Z") }
    const asked: string[] = []
    const loadStats = (period: typeof range) => {
      asked.push(`${period.from.toISOString()} → ${period.to.toISOString()}`)
      return Promise.resolve({ data: [{ timeGroup: "2026-03-01T00:00:00Z", value: 1200 }], timeFrame: "hours" })
    }
    void loadChartPayload(loadStats, range) // resolves to { payload, error }
    void loadMetricSeries({ loadStats, range, scale: 0.001 }) // resolves to { data, timeFrame, error }
    const rejected = chartPayloadSchema({ data: [], timeFrame: "weeks" })
    console.log({
      asked,
      point: timeSeriesPointSchema({ timeGroup: "2026-03-01T00:00:00Z", value: 1200 }),
      rejected: rejected instanceof type.errors ? rejected.summary : rejected,
    })

    useMetricSeries()

    `loadMetricSeries` as a hook, for a panel that loads its own data: it loads when `enabled` is true and the range or port changes, and returns the series with `isLoading` and `reload`. This card calls it with `enabled: false`, so it prints the state a panel renders before its first load.

    Output
    {
      "data": [],
      "timeFrame": "minutes",
      "error": null,
      "isLoading": false
    }
    Usage
    import { useMetricSeries } from "@spy4x/preact-charts"
    
    // Outside the component: a new function on every render would reload on every render.
    const loadStats = () => Promise.resolve({ data: [], timeFrame: "hours" })
    
    // Inside a component:
    const { data, timeFrame, error, isLoading } = useMetricSeries({
      loadStats,
      range: { from: new Date("2026-03-01T00:00:00Z"), to: new Date("2026-03-02T00:00:00Z") },
      enabled: false,
    })
    console.log({ data, timeFrame, error, isLoading })

    useInView()

    Tells a component when its element has scrolled near the viewport, so a chart can wait to load until then. Attach `ref` to the element; `inView` turns true 200px before it shows. This card attaches the ref to nothing, so it prints the first render: `inView` is false until an element is watched.

    Output
    {
      "element": null,
      "inView": false
    }
    Usage
    import { useInView } from "@spy4x/preact-charts"
    
    // Inside a component, before <div ref={ref}> has mounted:
    const { ref, inView } = useInView<HTMLDivElement>()
    console.log({ element: ref.current, inView })

    createInViewObserver()

    The observer wiring behind `useInView`: it watches one element and reports each change, and returns `null` where there is no `IntersectionObserver`, as on a server. A recording stand-in replaces `IntersectionObserver` for the length of the call, so the output shows what the helper asked for.

    Output
    [
      {
        "options": {
          "rootMargin": "100px",
          "threshold": 0
        }
      },
      {
        "observe": "revenue-chart"
      },
      {
        "visible": true
      },
      "disconnect"
    ]
    Usage
    import { createInViewObserver } from "@spy4x/preact-charts"
    
    const log: unknown[] = []
    const scope = globalThis as Record<string, unknown>
    const original = scope.IntersectionObserver
    scope.IntersectionObserver = class {
      constructor(private report: (entries: { isIntersecting: boolean }[]) => void, options: object) {
        log.push({ options })
      }
      observe(target: { id: string }) {
        log.push({ observe: target.id })
        this.report([{ isIntersecting: true }]) // the browser reports once the element is observed
      }
      disconnect() { log.push("disconnect") }
    }
    try {
      const chart = { id: "revenue-chart" } as unknown as Element
      const handle = createInViewObserver(chart, (visible) => log.push({ visible }), { rootMargin: "100px" })
      handle?.disconnect()
    } finally {
      if (original === undefined) delete scope.IntersectionObserver
      else scope.IntersectionObserver = original
    }
    console.log(log)

    @spy4x/preact-map

    Map

    Markers on a Leaflet tile layer, plotted from plain data — each pin is the component's real keyboard and screen-reader interface — with a plain, non-interactive list of the same places beside it. The card is its own server render — a labelled empty box — until a browser mounts Leaflet into it.

    Map

    Markers on a Leaflet tile layer, plotted from plain data — each pin is the component's real keyboard and screen-reader interface — with a plain, non-interactive list of the same places beside it. The card is its own server render — a labelled empty box — until a browser mounts Leaflet into it.

    <Map />

    Markers on a Leaflet tile layer, from plain `{ id, lat, lng, label, status? }` data, plus the plain-text list of the same places beside it — the map's own pins, not the list, are the keyboard and screen-reader path (see `map/README.md`). `tileUrl` and `attribution` are both required: the application picks its own tile provider, and providers require the credit line shown. Server-renders as an empty, sized box.

    © Example tile provider

    Places

    • London depot
    • Paris warehouse
    • Berlin outpost

    onMarkerClick: none yet

    Tab reaches each pin on the map, in marker order; activating one — a click, or a real Enter or Space press while it has focus — updates the id above. The list below is a plain, non-interactive overview of the same places, not a second set of controls. A public tile provider needs a real internet connection and its own required credit line, e.g. tileUrl="https://tile.openstreetmap.org/{z}/{x}/{y}.png" with attribution="© OpenStreetMap contributors" — this card uses a local tile instead so the guide never depends on one.

    Usage
    <Map
      center={{ lat: 50, lng: 5 }}
      zoom={4}
      markers={[{ id: "depot", lat: 51.5, lng: -0.13, label: "London depot", status: "on" }]}
      onMarkerClick={(id) => select(id)}
      tileUrl="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
      attribution="© OpenStreetMap contributors"
    />

    @spy4x/preact-signals

    Signals

    State helpers built on @preact/signals: the model store, filters bound to the address bar, table state, toasts and the theme store. The package renders nothing of its own.

    Signals

    Each helper run on this page, its output printed under the code.

    Sort rules in the address

    `parseSort` reads rules out of a URL parameter, `toggleSort` advances one column through asc, desc and off, and `serializeSort` writes the rules back.

    Output
    "name:desc,size:asc"
    Usage
    import { parseSort, serializeSort, toggleSort } from "@spy4x/preact-signals"
    
    const rules = parseSort("name:asc", ["name", "size"], [])
    const next = toggleSort(toggleSort(rules, "name"), "size")
    serializeSort(next)

    sortRows() and removeSortRule()

    `sortRows` orders rows by several rules at once, numbers as numbers and empty cells last, without changing the input; `removeSortRule` takes one column out of the rules.

    Output
    {
      "sorted": [
        "archive.zip",
        "photo.jpg",
        "notes.txt",
        "draft.txt"
      ],
      "withoutSize": [
        {
          "key": "name",
          "direction": "asc"
        }
      ]
    }
    Usage
    import { removeSortRule, type SortRule, sortRows } from "@spy4x/preact-signals"
    
    const files = [
      { name: "notes.txt", size: 12 },
      { name: "photo.jpg", size: 2048 },
      { name: "draft.txt", size: null },
      { name: "archive.zip", size: 2048 },
    ]
    const rules: SortRule<"name" | "size">[] = [
      { key: "size", direction: "desc" },
      { key: "name", direction: "asc" },
    ]
    console.log({
      sorted: sortRows(files, rules).map((file) => file.name),
      withoutSize: removeSortRule(rules, "size"),
    })

    One filter and its URL parameter

    What `useUrlFilters` does for each field: `resolveFilterValue` turns a parameter into a value, falling back to the default for a missing or unreadable one; `shouldPersistFilter` says whether a value belongs in the address; `filterWrite` says what to do to the parameter.

    Output
    {
      "read": [
        3,
        1,
        1
      ],
      "persist": [
        true,
        false
      ],
      "write": [
        {
          "urlParam": "page",
          "value": "3"
        },
        {
          "urlParam": "page",
          "value": "<undefined>"
        }
      ]
    }
    Usage
    import { signal } from "@preact/signals"
    import { resolveFilterValue, shouldPersistFilter } from "@spy4x/preact-signals"
    import { filterWrite } from "@spy4x/preact-signals/use-url-filters"
    
    const page = { signal: signal(1), urlParam: "page", initialValue: 1 }
    console.log({
      read: [resolveFilterValue(page, "3"), resolveFilterValue(page, "abc"), resolveFilterValue(page, null)],
      persist: [shouldPersistFilter(page, 3), shouldPersistFilter(page, 1)],
      write: [filterWrite(page, 3), filterWrite(page, 1)],
    })

    filterSearch() and restoredAddress()

    How `useUrlFilters` rewrites the address: `filterSearch` applies the filters' writes and keeps every parameter they do not own; `restoredAddress` puts back a fragment the router dropped, or answers `undefined` when nothing needs fixing.

    Output
    {
      "search": "tab=files&status=open",
      "lostFragment": "/list?tab=files&status=open#results",
      "nothingLost": "<undefined>"
    }
    Usage
    import { filterSearch, restoredAddress } from "@spy4x/preact-signals/use-url-filters"
    
    const search = filterSearch("?tab=files&page=3", [
      { urlParam: "page" },
      { urlParam: "status", value: "open" },
    ])
    const after = { pathname: "/list", search: `?${search}`, hash: "" }
    console.log({
      search,
      lostFragment: restoredAddress("#results", { ...after, href: `https://example.com/list?${search}` }),
      nothingLost: restoredAddress("", { ...after, href: `https://example.com/list?${search}` }),
    })

    clearFilterFields()

    Resets every filter to its default inside one `batch`, so whatever watches the filters — `useUrlFilters` writing the address — reacts once rather than once per field, and a reader presses Back once to undo the clear.

    Output
    {
      "before": {
        "status": "open",
        "page": 4
      },
      "after": {
        "status": "",
        "page": 1
      }
    }
    Usage
    import { signal } from "@preact/signals"
    import { clearFilterFields } from "@spy4x/preact-signals/use-url-filters"
    
    const status = signal("open")
    const page = signal(4)
    const before = { status: status.value, page: page.value }
    clearFilterFields({
      status: { signal: status, urlParam: "status", initialValue: "" },
      page: { signal: page, urlParam: "page", initialValue: 1 },
    })
    console.log({ before, after: { status: status.value, page: page.value } })

    patchSignal()

    Replaces an object signal's value with a copy that has some fields changed, so everything reading the signal updates once.

    Output
    {
      "pageSize": 20,
      "density": "compact",
      "showArchived": false
    }
    Usage
    import { signal } from "@preact/signals"
    import { patchSignal } from "@spy4x/preact-signals"
    
    const settings = signal({ pageSize: 20, density: "comfortable", showArchived: false })
    patchSignal(settings, { density: "compact" })
    settings.value

    setMapEntry() and deleteMapEntry()

    Return a new `Map` with one entry set or removed, leaving the original alone — what a `Map` held in a signal needs, since the signal only notices a new value.

    Output
    {
      "cart": {
        "apples": 3
      },
      "added": {
        "apples": 3,
        "pears": 2
      },
      "removed": {
        "pears": 2
      }
    }
    Usage
    import { deleteMapEntry, setMapEntry } from "@spy4x/preact-signals"
    
    const cart: ReadonlyMap<string, number> = new Map([["apples", 3]])
    const added = setMapEntry(cart, "pears", 2)
    const removed = deleteMapEntry(added, "apples")
    console.log({ cart, added, removed })

    createToastStore()

    The list of toasts on screen, which `Toastr` renders: each call adds one and returns its id, and `remove` takes one off. The store runs no timers; whatever renders the toasts removes them.

    Output
    [
      {
        "id": "toast-1",
        "title": "Success",
        "body": "Settings saved",
        "type": "success",
        "duration": "<undefined>"
      },
      {
        "id": "toast-3",
        "title": "Info",
        "body": "Two new comments",
        "type": "info",
        "duration": "<undefined>"
      }
    ]
    Usage
    import { createToastStore } from "@spy4x/preact-signals"
    
    let count = 0
    const toasts = createToastStore({ nextId: () => `toast-${++count}` })
    toasts.success({ body: "Settings saved" })
    const failed = toasts.error({ title: "Upload failed", body: "The file is over 10 MB", duration: 0 })
    toasts.info({ body: "Two new comments" })
    toasts.remove(failed)
    toasts.list.value

    createThemeStore() and ThemeValue

    The light, dark or system theme preference as signals: `preference` is what the reader chose, `actual` is what gets painted. Storage, the system setting and the painting are ports, so this card hands in stand-ins and paints nothing.

    Output
    {
      "actual": [
        "light",
        "dark",
        "light",
        "light"
      ],
      "preference": "system",
      "stored": "system"
    }
    Usage
    import { createThemeStore, ThemeValue } from "@spy4x/preact-signals"
    
    const saved = new Map([["theme", ThemeValue.DARK as string]])
    const theme = createThemeStore({
      storage: { getItem: (key) => saved.get(key) ?? null, setItem: (key, value) => void saved.set(key, value) },
      media: () => ({ matches: false }), // the system asks for light
      apply: () => {}, // an app leaves this out, and the store toggles the page's `dark` class
    })
    const actual = [theme.actual.value]
    theme.attach() // reads the stored preference: dark
    actual.push(theme.actual.value)
    theme.toggle()
    actual.push(theme.actual.value)
    theme.set(ThemeValue.SYSTEM)
    actual.push(theme.actual.value)
    theme.dispose()
    console.log({ actual, preference: theme.preference.value, stored: saved.get("theme") })

    createClipboard()

    Copies text and reports the outcome through callbacks instead of throwing. `copy` resolves to `true` or `false`; with no clipboard — an insecure page, a server render — it reports `CLIPBOARD_UNAVAILABLE` at once, which is what this card prints.

    Output
    {
      "reported": [
        "Clipboard API is unavailable"
      ],
      "isTheExportedMessage": true
    }
    Usage
    import { CLIPBOARD_UNAVAILABLE, createClipboard } from "@spy4x/preact-signals"
    
    const reported: string[] = []
    const clipboard = createClipboard({ clipboard: null })
    void clipboard.copy("https://example.com/report", {
      onError: (error) => reported.push((error as Error).message),
    })
    console.log({ reported, isTheExportedMessage: reported[0] === CLIPBOARD_UNAVAILABLE })

    buildModelStore() and RemoteEvent

    A store for one REST collection, validated with arktype. `onWs` applies a feed event named by `RemoteEvent`; an invalid `create` is refused before any request. This card's `fetch` is never called, so it prints only what the store did without the network.

    Output
    {
      "active": [
        "Meeting agenda"
      ],
      "archived": [
        "Old draft"
      ],
      "createError": "Provided data doesn't seem valid. Check the form validation error messages."
    }
    Usage
    import { buildModelStore, RemoteEvent } from "@spy4x/preact-signals"
    import { type } from "arktype"
    
    const notes = buildModelStore({
      model: "note",
      endpoint: "/api/notes",
      schemas: {
        full: type({ id: "number", title: "string", deletedAt: "string | null" }),
        create: type({ title: "string > 0" }),
        update: type({ "title?": "string > 0" }),
      },
      fetch: () => Promise.reject(new Error("this example sends no requests")),
    })
    void notes.onWs([
      { id: 1, title: "Meeting agenda", deletedAt: null },
      { id: 2, title: "Old draft", deletedAt: "2026-01-05" },
    ], RemoteEvent.LIST)
    void notes.create({ title: "" })
    console.log({
      active: notes.list.nonDeleted.value.map((note) => note.title),
      archived: notes.list.deleted.value.map((note) => note.title),
      createError: notes.op.create.value.error?.message,
    })

    Filters in the address bar

    useUrlFilters from @spy4x/preact-signals binds a set of signals to the query string both ways: the links below change the address and the filters follow, the buttons change the filters and the address follows. It is a hook rather than a component, so it has no card in the catalogue.

    Arriving, reading and remounting leave the address exactly as it was — including ?size=huge, which the size field's parser refuses and the address keeps anyway. Changing a filter is what writes, and that write changes the query string and nothing else: the path, a parameter belonging to something else on the page, and this page's own #/… route all stay where they were.

    status
    (any)
    page
    1
    size
    md
    Filters → address
    Carda fresh card starts from whatever the address says

    DataTable, sorted from the address bar

    DataTable's sort prop is caller-owned state, the same as Pagination's page. Here it is a ?sort= parameter, bound through useUrlFilters: press a column header and the address changes with it.

    Orders
    Grace340
    Ada120
    Katherine75

    @spy4x/preact-theme

    Theme

    The design tokens and the classes preset.css ships: the half of the stylesheet an app applies to markup the library does not own.

    Forms

    The form classes preset.css ships, with no component wrapped around them: the controls, a label in either placement, and the input with a button inside it. The Fields section above shows the same classes through the ui/ primitives.

    Input

    .input.label.text-muted

    `.input` on a native `<input>`: full width, 48px tall, radius and border from the tokens, and a focus ring the preset paints itself. Every attribute passes through, `value` in and `onInput` out — no draft state lives in a component.

    email: (empty)

    Usage
    <input
      class="input"
      type="email"
      placeholder="you@example.com"
      value={email.value}
      onInput={(event) => email.value = event.currentTarget.value}
    />

    Select

    .select.label.text-muted

    `.select` composes `.input`, so the two line up side by side. The selection comes from `value` on the `<select>`; the option list is the platform's, painted by the `.dark option` rule in the preset.

    role: editor

    Usage
    <select class="select" value={role.value} onChange={(event) => role.value = event.currentTarget.value}>
      <option value="admin">Administrator</option>
      <option value="editor">Editor</option>
    </select>

    Textarea

    .textarea.label.text-muted

    `.textarea` is `.input` with a 6rem floor and inner padding, so a short note and a long one both look intentional. Same contract as `Input`: controlled, no wrapper.

    2 lines

    Usage
    <textarea
      class="textarea"
      rows={3}
      placeholder="Anything the next person should know"
      value={notes.value}
      onInput={(event) => notes.value = event.currentTarget.value}
    />

    Labels and their placement

    .label.input.checkbox.text-muted.text-danger

    One `.label` class in both placements: above the control for a stacked field, below it when the value matters more than the name. A required marker, a hint and an error are text — `.text-danger` plus `aria-describedby`, since the preset ships no `[aria-invalid]` styling of its own.

    Full name, as on the contract.

    Usage
    <div class="grid gap-1.5">
      <label class="label" for="name">Name <span class="text-danger">*</span></label>
      <input id="name" class="input" aria-describedby="name-hint" value={name.value} />
      <p id="name-hint" class="text-xs text-muted">As it appears on the contract.</p>
    </div>
    
    <!-- the suffix placement: the label follows the control -->
    <input id="ref" class="input" value="Ref 2024-0917" />
    <label class="label" for="ref">Reference</label>

    Checkbox

    .checkbox.label.text-muted

    `.checkbox` sets the size, radius and focus treatment; the glyph is the platform's until an app imports `@tailwindcss/forms`. The label wraps the control, so the text is part of the hit area and `checked` in / `onChange` out is the whole state.

    archived: off · email: on

    Usage
    <label class="label" for="archived">
      <input
        id="archived"
        class="checkbox"
        type="checkbox"
        checked={archived.value}
        onChange={(event) => archived.value = event.currentTarget.checked}
      />
      Show archived rows
    </label>

    Radio

    .radio.label.text-muted

    `.radio` in a `fieldset` with a `legend`: the platform keeps the roving tab stop and the arrow keys because the inputs share one `name`, and the legend names the group for a screen reader. No role, no key handler, no `aria-checked`.

    Notification method

    channel: email

    Usage
    <fieldset>
      <legend class="label">Notification method</legend>
      <label class="label" for="sms">
        <input id="sms" class="radio" type="radio" name="channel" value="sms"
          checked={channel.value === "sms"}
          onChange={() => channel.value = "sms"} />
        Phone (SMS)
      </label>
    </fieldset>

    Input with an inline button

    .btn-input-icon.input.text-muted

    `.btn-input-icon` is the square, borderless button that belongs inside a field: 36px, centred glyph, hover fill in both palettes. It is a class rather than a component — the wrapper is a `relative` box and the input reserves the right padding.

    query: (empty)

    Usage
    <div class="relative">
      <input class="input pr-12" type="search" placeholder="Search users" />
      <button class="btn-input-icon absolute inset-y-0 right-1.5 my-auto" type="button" aria-label="Search">
        <IconSearch class="size-4" />
      </button>
    </div>

    Surfaces and utilities

    The half of the stylesheet an app applies to its own markup: card surfaces, the scroll container, the type scale, KPI tiles, and every colour atom.

    Card

    .card.card-header.card-body.card-footer.link.text-muted

    The four-part surface: `.card` is the frame, and `.card-header`/`.card-body`/`.card-footer` carry their own padding and the border between them, so the parts are spacing-free in composition.

    Meter 4417

    Last read 4 minutes ago

    online

    The body is the only part with a padding floor of its own: a header and a footer already carry theirs, so a card with all three needs no spacing utilities between them.

    Usage
    <div class="card">
      <div class="card-header">
        <p class="font-medium">Meter 4417</p>
        <span class="text-xs text-muted">online</span>
      </div>
      <div class="card-body">…</div>
      <div class="card-footer">
        <a class="link" href={meterHref}>Open the meter</a>
      </div>
    </div>

    Scroll container

    .scrollbar.card.card-body.border-subtle.rounded-primary.text-muted

    `.scrollbar` turns a flex row into a horizontal scroller with a 4px thumb instead of the platform's full-height bar. It sets the overflow only — the height belongs to the content.

    JanuaryFebruaryMarchAprilMayJuneJulyAugustSeptemberOctoberNovemberDecember

    Twelve chips, one 28rem box: the row scrolls, the card does not.

    Usage
    <div class="scrollbar flex gap-3">
      {months.map((month) => (
        <span class="rounded-primary border border-subtle px-3 py-1 text-sm">{month}</span>
      ))}
    </div>

    Type scale

    .h1.h2.h3.h4.h5.link.list-ul.page-layout.text-muted

    `.h1`–`.h5` are type-scale utilities, `.list-ul` and `.link` are the two text affordances, and `.page-layout` is the standard frame: `mx-auto max-w-6xl space-y-4 lg:space-y-8`. Apply them to whatever element is semantically right.

    h1 — page title

    h2 — section

    h3 — sub-section

    h4 — card title

    h5 — field group

    • `.list-ul` is the one list rule: disc markers, inside, one step down in size.
    • `.link` underlines on its own, and drops the underline on hover.

    `.page-layout` is the horizontal frame around all of it: `mx-auto max-w-6xl space-y-4`.

    Usage
    <div class="page-layout">
      <p class="h2">Section</p>
      <ul class="list-ul">
        <li>Disc markers, inside, one step down in size.</li>
        <li><a class="link" href={reportHref}>A link that underlines itself</a></li>
      </ul>
    </div>

    KPI tiles and numbers

    .kpi.kpi-label.kpi-value.bar.num.border-subtle.text-muted

    `.kpi` is the tile, `.kpi-label` the caption above its value. `.bar` is a width-less bar — the caller sets the length — and `.num` right-aligns a cell with tabular figures so a column of numbers lines up.

    Consumed1 284
    Budget1 800
    MonthReadings
    January1 284
    February986
    Usage
    <div class="kpi">
      <span class="kpi-label">Consumed</span>
      <span class="kpi-value">1 284</span>
      <span class="bar" style="width: 72%" />
    </div>
    
    <td class="num">1 284</td>

    Colour atoms

    .text-primary.text-muted.text-danger.text-warning.text-success.bg-primary.bg-danger.bg-warning.bg-success.border-primary.border-subtle.border-control.bg-canvas.bg-surface.rounded-primary

    Every colour the preset exposes as a utility, read through `var(--color-*, fallback)`: text, fill, border and the two surfaces. `.rounded-primary` is the radius the same way, so a box and a button share one corner rounding.

    text-primarytext-mutedtext-dangertext-warningtext-success
    bg-primarybg-dangerbg-warningbg-success
    border-primaryborder-subtleborder-control
    bg-canvas — pagebg-surface — cardrounded-primary
    Usage
    <span class="text-danger">text-danger</span>
    <span class="bg-success rounded-primary px-2 py-1 text-xs text-white">bg-success</span>
    <span class="border-control border px-2 py-1 text-xs">border-control</span>
    <span class="bg-canvas border-subtle border px-2 py-1 text-xs">bg-canvas</span>

    Ink palette

    .card.card-body.text-muted.rounded-primary.border-subtle

    The ink theme (#257): an additional, opt-in dark palette, `.dark[data-theme="ink"]`. It repaints the same tokens above — the default Eirene palette is unaffected — and adds a four-step surface scale, a hairline rule colour, two text tones, and `--color-nav-active`/`--color-focus-ring`, split off `--color-primary` so focus rings and the rail indicator do not use the accent. Links, checkboxes, outline buttons and bars still do.

    Ink is dark-only: set data-theme="ink" together with .dark on <html>.

    surface-page
    surface-rail
    surface-card
    surface-active
    hairline
    text
    text-muted
    primary action
    nav-active
    focus-ring
    Usage
    <html class="dark" data-theme="ink">
      <body class="theme-base">
        <nav style="background: var(--color-surface-rail)">…</nav>
      </body>
    </html>

    Stylesheets as text

    The stylesheets exported as strings, for a build that turns them into a compiled stylesheet.

    The stylesheets as text

    `TOKENS_CSS`, `PRESET_CSS` and `INK_CSS` carry the three stylesheets as strings, for a build that cannot import a CSS file from the registry.

    Output
    [
      true,
      true,
      true
    ]
    Usage
    import { INK_CSS, PRESET_CSS, TOKENS_CSS } from "@spy4x/preact-theme"
    
    [TOKENS_CSS, PRESET_CSS, INK_CSS].map((css) => css.length > 0)

    @spy4x/preact-icons

    Icons

    Every glyph the icon package exports. Search by name, click a glyph to copy its JSX.

    Icons

    119 glyphs, read from the package. Click one to copy <IconName />.

    119 of 119 shown

    @spy4x/preact-cn

    cn

    cn() joins class names and resolves conflicting Tailwind utilities, so a caller's class wins over a component's default.

    cn

    cn() run on this page, its output printed under the code.

    cn()

    Joins class names, drops falsy ones, and lets a later Tailwind utility win over a conflicting earlier one.

    Output
    "py-1 text-sm px-4"
    Usage
    import { cn } from "@spy4x/preact-cn"
    
    cn("px-2 py-1 text-sm", false, "px-4")