---
url: /conventions/web-components.md
---
# `@pantoken/web-components`

Shadow-DOM custom elements built over the `@pantoken/components` CSS. A `wrapper(tag, css, render,
display)` covers pure-presentational elements; behavioral elements are bespoke classes.

## Runtime graph must stay Node-free

This package ships to the browser (the docs client registers it). Anything in the runtime graph is
bundled into the client. A **value** import of a postcss-based `@cssdoc/*` package makes rolldown
inject a `createRequire`/`node:module` shim into `dist/index.mjs`, which throws `createRequire is not
a function` at app init and kills all client JS. Keep `@cssdoc/*` value imports out of `src/` —
type-only imports are fine (erased at build); value imports belong in build scripts and tests.

## Shadow CSS is co-located `.css` → `?inline`

* Shadow CSS lives in co-located `src/**/*.css` files — the lint, format, and docs source of truth.
* Elements import their co-located CSS with `?inline`; Vite+ bundles `@tsdown/css`, which returns the
  processed stylesheet as a tree-shakeable string for the shadow `<style>`.
* The CSS finalizer strips doc comments and minifies the inline string, so cssdoc records stay in the
  source `.css` files without shipping as runtime bytes.
* Use `?inline`, not `?raw`: `?inline` is the shared Vite/`@tsdown/css` contract and works in pack,
  dev, and test.

## `register()` and the tag prefix

`register(target?, { prefix })`. A prefix is **always** applied because a custom-element name must
contain a hyphen (`<icon>` is invalid). A non-empty string wins (`{prefix:"x"}` → `<x-icon>`); anything
falsy or whitespace falls back to `DEFAULT_PREFIX = "instui"` — it never drops elements. Implementation
is low-churn: internal code keeps its canonical `instui-<base>` literals, and `register` shadows the
registry with a scoped adapter that rewrites `instui-<base>` → `${prefix}-<base>`. Cross-element
nesting uses `ctx.tag("calendar")` so the prefix propagates.

`register()` also injects `foundationCss()` once into `document.head` as `<style
id="pantoken-foundation">` — a `:root` block built from `elevationDeclarations()` +
`focusOutlineDeclarations()`. Those tokens aren't in the plain token sheet, so without it a consumer
loading only the module + `@pantoken/css` gets no shadows or focus rings (custom props pierce the
shadow boundary). It's guarded on `typeof document` so the registry-only test path skips it, and
exported for SSR inlining.

## Structure

`src/index.ts` is a barrel. Elements are `src/elements/<name>.ts`, one `ElementDefinition` each, added
to `DEFINITIONS[]` and `ELEMENTS[]` in **load-bearing order** (a test asserts `ELEMENTS` matches the
`register()` define order — append new tags at the end). `register()` builds one `RegisterContext`
(`{ registry, tag, I:{prefix:"instui"}, invokerSupported, onCommand, wrapper, variantClass, iconSvg }`)
and hands it to each `def.define(ctx)`.

## The JS-behavior tier

Behavior pure CSS can't cover: tooltip, drilldown, pages, drawer-layout, an interactive calendar, and
date/datetime pickers. The consistent declarative layer is the **Invoker Commands API** — light-DOM
`<button command="…" commandfor="element-id">`:

* `instui-modal`: `--show` / `--close` / `--toggle` (the shadow `<dialog>` can't be a `commandfor`
  target, hence host-level custom commands).
* `instui-drilldown`: `--goto` (reads `data-page`) / `--back`.
* `instui-pages`: `--push` / `--back`.
* `instui-drawer-layout`: `--toggle` / `--open` / `--close`.
* `instui-popover`/`instui-tray`/`instui-context-view`: already native `[popover]`, so the built-in
  `command="toggle-popover"` works with no code.

A shared `onCommand(target, handler)` helper adds the `command` listener and, when the API is absent
(`!("command" in HTMLButtonElement.prototype)`), falls back to a click delegate reading the same
attributes. It listens on `target.getRootNode()` so it covers a light-DOM host (document) and the
calendar grid (shadow root). Date helpers use **local** dates (avoid `toISOString` UTC shift).

## Universal spacing

Every element supports `margin`/`padding` (InstUI 1–4-value shorthand) and per-side
`margin-<side>`/`padding-<side>` (physical or logical edges), each value a keyword → token or a raw
CSS length. `lib/helpers.ts` exports `resolveSpace`, `spacingValue`, and `applySpacing(host)`; a
`withSpacing(Ctor)` mixin subclasses each ctor and the scoped registry wraps **every** ctor with it in
`define`, so it's universal with zero per-element code (a per-host MutationObserver re-applies on attr
change).

## Testing

The harness has no DOM lib (happy-dom/jsdom absent), so `tests/index.test.ts` only asserts
registration + pure `iconSvg`. Verify behavior in a real browser. `.focus()` fires no focus events
when `document.hasFocus()` is false (headless) — dispatch a composed `focusin` to exercise focus-show.
