Beamline

Docs · Updated

conventions

Part of the documentation that comes with Beamline's complete system (docs/CONVENTIONS.md in the kit). Your coding agent can do all of this for you through Beamline's MCP server: connect it.

One API shape for every component, so an agent (or a person) that has used one can use any other.

Files and imports

ui/components/<id>/
  <id>.tsx          component(s): named exports, plus a default export of the main one
  <id>.module.css   optional; styles read tokens only
  demo.tsx          default export Demo[]: every variant and state, live
  meta.json         catalog entry + spec (see below)
ui/blocks/<id>/     same shape, for composed screens
  • One import path per component: import { Button } from "@/components/button/button". Nothing re-exports another component's parts. Internal helpers live in folders starting with _ and are never imported by products.
  • Ids are kebab-case nouns (order-book, date-range-picker). Exports are PascalCase (OrderBook). Parts are prefixed with the component (DialogContent, SidebarMenuItem).
  • @/lib/utils → cn(); @/hooks/use-motion-tokens → host-local durations, eases, springs; @/hooks/* for shared hooks.

Props — one vocabulary

concern name values
visual weight variant primary · secondary · ghost · outline · danger (use the subset that makes sense)
semantic colour tone neutral · accent · success · warning · danger · info
size size sm · md · lg (default md); icon-only buttons need aria-label
row density density compact · default · comfortable
value value / defaultValue / onValueChange(value) controlled + uncontrolled, always both
open state open / defaultOpen / onOpenChange(open) same
selection selected / defaultSelected / onSelectedChange same
busy loading keeps size and focus, sets aria-busy
disabled disabled native semantics where possible
empty data emptyLabel text shown instead of an empty frame
formatting format(value) => string numbers are passed raw, formatting is a function
missing data null a null value renders as unmeasured (hatched), never 0

Callbacks are on<Thing>Change for state and on<Verb> for events (onSubmit, onRowClick). Booleans are positive (interactive, not disableInteraction).

Rendering contract

  • One root, nothing outside it. Every token and base style exists only inside a .pui root (<html class="pui"> or UIRoot), and every name the system defines starts with pui (--pui-surface, @keyframes pui-shimmer). A component never writes to :root, html, body or a global class, and never adds a keyframe or custom property without the prefix. The package build confines every remaining global rule to roots (scripts/lib/scope-css.mjs).

  • Overlays open in the root's layer. Anything portalled (dialog, sheet, popover, menu, select, tooltip, hover card, toast, a floating toolbar) reads usePortalContainer() from @/runtime/appearance. undefined means no provider; null means a managed scope whose host is pending. Keep hooks and triggers mounted, but render no portal content while the value is null. Only undefined may fall back to document.body. An initially open overlay must mount directly in its already-styled scope host; appearance updates reuse that destination. The old UIRoot component path re-exports this same runtime, never another context.

  • className and style address the documented visual root; classNames addresses documented composite parts. Component CSS modules sit in @layer components, below Tailwind utilities and normal unlayered customer rules. Overrides follow the real cascade: layers, importance and specificity matter; placing a class last is not a universal precedence rule. Read public override inputs through fallbacks instead of assigning them per instance.

  • data-slot="<part>" on every meaningful part (data-slot="order-book-row") so products can target parts without classes. State goes in data attributes: data-state="open", data-side="bid", data-trend="up".

  • Refs: React 19 ref as a prop on the documented native target. Button/Table share their visual root with it; Input's ref/native attributes/value target its input and Select's ref targets its trigger. Their visual root is the field wrapper. In 0.2 Input style moves to that wrapper; use classNames.input for native input styling. Older components that use forwardRef stay valid. Slot styling never replaces measurement refs, handlers or ARIA links.

  • Polymorphism: triggers and link-able parts accept asChild (Radix Slot). Nothing else is polymorphic; text components that need it take as.

  • Primitives: overlays, menus, and form controls are built on Radix. Do not add another headless library.

  • Icons: lucide-react only, 16px in controls (size-4), 14px in dense rows. Pass icons as elements (icon={<Plus />}), not component types.

  • Styling: tokens only (var(--pui-surface), bg-surface, text-ink-2). A hex, rgb or ms literal in a component is a bug, except inside tokens.css, looks.css and motion-tokens.ts.

  • Paint comes from a look's role, never from the element. A painted part names what it is with one role class (m-control, m-solid, m-field, m-inset, m-toggle, m-knob, m-tag, m-surface, m-overlay; DESIGN-LANGUAGE.md "Looks") and keeps only layout and its own marks. bg-surface shadow-card or bg-surface-2 shadow-btn in a className is the old way and a bug now. In a module: .track { composes: m-toggle from global; }. A mark on top of a role composes the role's own value: shadow-[var(--m-surface-edge),inset_3px_0_0_var(--pui-red)]. An element with a role lists var(--m-transition) in its transition. A part that follows a chosen item (a thumb, a ring, an underline) uses useIndicator and glideProps from @/hooks/use-indicator and wears the glide class (glide-2d on both axes), which places it and stretches it toward each new choice; its module adds only paint and shape. Type, radii and icon stroke come from tokens too (font-title, rounded-control, the heading rule in base.css), so a look restyles them; a component never names a font family or tests which look is on.

  • Motion: import from motion/react and call useMotionTokens(hostRef) at the actual control. Its reduced flag follows managed/static policy and OS preference; before ready it exposes the pure foundation snapshot. CSS transitions use --pui-motion-in / --pui-motion-out.

  • Client-only APIs (ResizeObserver, canvas, WebGL, audio) are touched in effects, so components render on the server without crashing.

  • Numbers: tabular figures; prices keep their precision (0.00482), never rounded by a component.

  • Accessibility: keyboard reachable, visible focus, roles from Radix or native elements, aria-live="polite" for values that change because of a user action (a total after a filter), never for high-frequency feeds (a price that ticks twice a second is readable on demand, not announced); labels on every icon-only control.

Managed appearance runtime

@/runtime/appearance is the single client context entry. @/runtime/preset contains pure data/types only; PresetRef = { id, visualContract: 1 } loads no React, CSS, fonts or effects. Import the selected preset CSS explicitly. UIRoot preset wins over the deprecated built-in look shorthand. Outermost defaults are declared by the runtime; nested roots inherit omitted inputs. Sparse tokens merge by key and design defaults by component/key. inherit={false} starts from library defaults. A local accent replaces an inherited explicit brand; local brand wins within the same scope. Use the dedicated brand prop, never tokens["--pui-brand"].

appearanceClassName is mirrored on the root and its independent portal host; selectors must work on each boundary. Omitting it inherits, a supplied value replaces, and "" clears. Ordinary className/style remain local. A static .pui/data-pui-preset boundary supplies DOM styling only; arbitrary descendant CSS writes neither mirror to portals nor publish renderer invalidations.

Scoped defaults use lowercase catalog IDs, for example defaults={{ button: { size: "sm" }, table: { density: "compact" } }}. Each supporting client component reads only its allowed design keys through useComponentDefaults<ButtonDefaults>("button", ["size", "variant"]); explicit instance props win. Export lightweight defaults.ts types without importing component implementations. Never put handlers, domain data, value/open/loading, selection or adapters into defaults. Pure/static entries can keep explicit props and inherited CSS without acquiring a client context dependency.

Canvas/renderer code uses useResolvedAppearance(actualHostRef, { colors, metrics, numbers }). The server and first client render are unresolved; apply values only when ready. Colors are normalized to rgba, metrics to px, and unitless inputs are returned separately. Typography includes the host, body and numeric font families. Full CSS variable names are accepted for private resolved geometry. Subscribe through the nearest descriptor and font readiness, and call refresh() for explicit external stylesheet changes. Never resolve from the first document root or observe all document style mutations. Apply engine options in place; appearance must not reset feed markers, editor history, resource controllers, selection or an open overlay's lifetime.

Selected look SVG resources and optional edge-light are explicit application setup. The runtime does not import all look initializers. Setup returns a cleanup function and shares document resources by reference count. Semantic motion CSS and JavaScript derive from ui/lib/motion-source.ts; timing values remain governed by the visual contract.

Code

  • Formatted and linted, always. pnpm format (Prettier, 120 columns, Tailwind classes in the official order) and pnpm lint (hook rules and TypeScript's recommended checks) run clean on ui/ and gallery/; pnpm format:check and pnpm lint are part of every change. A lint suppression names its rule and says why after --.
  • One statement per line, and a component reads top to bottom: props and their defaults, state, derived values, effects, handlers, then the markup.
  • State lives in the stylesheet. Utilities are for layout and spacing at the call site. A part whose className carries four or more state variants (hover:, focus-visible:, data-[…]:, aria-…:, group-…:, after:) moves to the component's .module.css, where states are selectors and every value is a token; so does any part that only exists to be styled (a resize grip, a prose body). className passed to another system component (a Button) stays utilities, since that is the documented way to override it.
  • No inline stylesheets. Never <style>{CSS}</style> or a CSS string in a component; keyframes and rules live in the module. Inline style is only for values computed at run time, passed as custom properties (style={{ "--pui-indicator-x": ${x}px }}) that the module reads.
  • Keyframes belong to the module that plays them. CSS modules rename every animation name, so a module that names a global @keyframes (pui-… in ui/styles) never plays; node scripts/layer-modules.mjs refuses it.
  • Variant tables are typed records (Record<ButtonVariant, string>), never chains of ternaries in a className.

meta.json — catalog entry and spec

{
  "name": "order-book", "title": "Order book", "category": "trading", "kind": "component",
  "description": "One sentence: what it is for.",
  "source": { "from": "Beamline", "license": "MIT", "mode": "original" },   // original | wrapper | vendored
  "dependencies": ["motion"], "tags": ["depth", "bids", "asks"],
  "whenToUse": ["…"], "notFor": ["… → use X"],
  "spec": {
    "anatomy": ["root", "header", "row (price, size, total, depth bar)", "spread"],
    "variants": { "layout": ["stacked", "side-by-side"] },
    "states": ["loading", "empty", "live", "stale"],
    "keyboard": ["↑/↓ move row focus", "Enter sends price to onPriceSelect"],
    "motion": "Changed sizes flash 90ms in / 600ms out; rows never slide.",
    "props": [{ "name": "bids", "type": "Level[]", "required": true, "description": "…" }]
  }
}

spec.variants takes either shape: the object above ({ name: [values] }) or a list of descriptive lines when a variant needs words, as the charts use (["stack: none · stacked · expanded", "brush"]). Tools print both.

scripts/gen-catalog.mjs turns every meta.json into catalog.json and CATALOG.md. Vendored and wrapped code keeps source.url and license; third-party code inside an original element is listed in source.credits (name, licence, url); both end up in NOTICE.

meta.json ships to customers, so source says only what a customer may read: Beamline, or the third-party package and its licence. Where an element came from — references observed, clean-room recreations of paid kits, merged sources, lineage notes — goes in research/provenance.json (internal, never shipped; scripts/acceptance.mjs reads it).

Categories

actions · forms · overlays · navigation · layout · feedback · display · data · charts · trading · dashboard · ai · voice · effects · blocks.

Rendered motion consumers resolve each CSS duration/easing/spring/stagger separately with useMotionTokens(hostRef). The optional no-ref form resolves the nearest managed root; static scopes require a real host ref. motionTokens is the pure, server-safe foundation table for non-DOM use. useResolvedAppearance accepts times (seconds) and easings (computed CSS timing functions), resolves conditional hosts when attached, and clears readiness after removal. No module observes the first page root. Reduced timing declarations take precedence over inline timing overrides. Reading intervals, deadlines and data pacing remain caller behavior.