Beamline

Docs · Updated

Customise Beamline

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

Everything visual reads design tokens (CSS custom properties) from the stylesheet. Change a token and every component that uses it follows; no component hard-codes a colour, radius, shadow or duration.

Managed appearance in 0.2

Use the release's canonical runtime and import selected CSS explicitly. A preset reference is pure data; passing it never imports other looks, fonts or effects. The component UIRoot path remains a compatibility re-export of the same runtime.

import { UIRoot } from "@beamline/runtime";
import prism from "@beamline/look-prism";
import "./styles/beamline/beamline.css"; // once, in the app's entry: the selection's one stylesheet

<UIRoot preset={prism} theme="dark" brand="oklch(0.7 0.15 150)">
  {children}
</UIRoot>

A compiled install writes beamline.css with foundation, the selected looks and every selected component in order (INSTALL). A full-page root also takes className="look-page" and the viewport's height; an embedded root paints nothing behind its children. For editable source, the corresponding runtime entry is @/runtime/appearance; selected preset styles live under ui/styles/presets/. The deprecated look shorthand still selects a built-in ID, but preset wins when both are supplied.

Nested roots inherit omitted inputs. tokens merges sparse public token overrides by key, and defaults merges by component and allowed design prop. inherit={false} drops managed ancestor choices before applying local inputs. A local accent replaces an inherited explicit brand; brand wins over accent in the same scope. Use the dedicated brand prop instead of putting --pui-brand in tokens.

<UIRoot
  tokens={{ "--pui-radius-control": "12px" }}
  defaults={{ button: { size: "sm" }, table: { density: "compact" } }}
>
  {children}
</UIRoot>

Default keys are lowercase catalog IDs. Only supported presentation props belong there: handlers, adapters, data, value/open/loading and selection remain application state. Explicit instance props win, including controls inside blocks. Each supporting component exports a lightweight ./defaults type.

appearanceClassName applies to both the region and its independent portal host. Omit it to inherit, replace it with a supplied class string, or use "" to clear it. Its selectors must work on each boundary without requiring an ancestor. Ordinary className and style stay local. Managed portals wait for their host and keep the same destination while appearance changes, so an open task retains its focus and draft.

A static .pui/data-pui-preset wrapper provides DOM styling only. Raw descendant CSS writes do not mirror to portals or invalidate canvas renderers. Managed renderer integrations use useResolvedAppearance(hostRef, request) and its explicit refresh() when an external stylesheet changes.

Custom styles, by hand or with an agent

If your bundle includes the optional local studio, start it with Node 20+:

node /path/to/release/tools/beamline.mjs studio

Open the localhost address it prints. This is the gallery's Foundations pages, style builder and selected live components, compiled into the bundle; it needs no app dependency installation or Beamline service. Use --port 5191 for a stable local address. Stop with Ctrl+C. Your saved styles and settings live in that browser at that address; export the editable file to keep them independently of browser storage. The studio is separate from the component archives your app installs. Release maintainers include it with build-bundle.mjs --studio; bundles without it keep the same CLI style commands.

The local studio's Export downloads an editable .style.json and a complete .preset.css. Both carry the name, base look, pins and saved foundation parameters. Start from any approved look. A pin keeps that radius fixed when roundness changes; removing a Blueprint pin stays removed in both the preview and export. Zero is a valid radius.

The matching bundle's CLI uses the same style operations and validates against that release's preset contract:

node /path/to/release/tools/beamline.mjs style create calm.style.json --base prism --name "Warm and quiet" --id acme/calm
node /path/to/release/tools/beamline.mjs style set calm.style.json --token --pui-radius-control --value 12px
node /path/to/release/tools/beamline.mjs style parameter calm.style.json radiusScale 1.5
node /path/to/release/tools/beamline.mjs style parameter calm.style.json motionScale 2
node /path/to/release/tools/beamline.mjs style parameter calm.style.json brand '#c57736'
node /path/to/release/tools/beamline.mjs style validate calm.style.json --release /path/to/release/release.json
node /path/to/release/tools/beamline.mjs style export calm.style.json --release /path/to/release/release.json --out calm.preset.css
node /path/to/release/tools/beamline.mjs style add calm.style.json --app /path/to/app --root-file src/app.tsx

style add also accepts a studio-exported .preset.css. It requires the base look already installed from the app's accepted release. Name the file containing the intended single UIRoot imported from @beamline/runtime or @beamline/ui/ui-root (a named import alias is supported). It adds one appearance import and one props spread while preserving the root's children, event handlers, existing imports and client directive. The appearance owns preset, look and every prop it writes (brand, scale parameters, motion, density, tokens): those props are removed from the root, reported with their previous source, and replaced by the spread where the first one stood, so the root still type-checks. A look import only they used is removed too. Props you put after the spread later stay and win. If the app has another wrapper, apply the generated appearance props in that wrapper as part of the agent's normal integration work.

The default destination is src/styles/beamline, outside the managed component source. Change it with --styles-to. This directory contains the editable source, complete preset CSS, appearance.ts with the saved UIRoot props, and beamline.css, the app's one Beamline stylesheet, which now also imports the custom preset (appearance.ts imports it, so the style works even before the entry does). Repeating the same command adds nothing twice. Editing the original source and applying again updates only files that still match the previous application; a customer edit is preserved and named. Source and package upgrades leave this customer-owned directory alone.

Use style pin <file> --token --pui-radius-control --reason "Part of our identity" or style unpin <file> --token --pui-radius-control. style modifier <file> motion 1.25 composes with the app's speed; reduced/none still win. style set and style parameter accept --reset. style set refuses a token the base look does not declare and names the closest ones; it checks against --release, the bundle's own release, or a source checkout's presets. Invalid fields/tokens are named by validation. Edit the JSON source, then export CSS. The CLI is local and uses no model, hosted editor or new service.

For direct integration, import the selected base look's stylesheet plus your custom stylesheet and use the canonical reference (custom IDs do not go in the deprecated built-in look prop):

<UIRoot preset={{ id: "acme/calm", visualContract: 1, base: "prism" }} radiusScale={1.5} motionScale={2}>
  {children}
</UIRoot>

The static-scope CSS includes suggested numeric/focus/brand parameters; UIRoot props also carry density and motion policy to stable portals. A custom preset's fonts must be selected/installed explicitly.

An example's Use with my agent instruction and downloaded choice include its custom style, active foundation parameters and explicit token overrides. Shared preview links reproduce those settings without using the recipient's saved appearance. A preview does not replace their saved studio library.

Brand colour (one change)

:root { --pui-brand: oklch(0.7 0.15 150); }   /* or the brand prop on UIRoot */

Accent, accent text, hover and pressed states, focus rings, selection tints, glows and the first chart series all derive from --pui-brand. Text on a brand fill (a primary accent button, a selected day, a checked box) picks its own ink from the brand's lightness, so any brand keeps at least 4.5:1 without a second token. Status fills work the same way through --pui-on-red, --pui-on-green, --pui-on-orange and --pui-on-blue (text-pui-on-red … with @beamline/ui/tailwind.css; text-on-red … in the source). OKLCH keeps lightness predictable: keep L between 0.55 and 0.75 for a brand that also reads as text on both themes. Six presets swap only the brand: data-accent="neutral|violet|green|amber|orange|rose" on <html>.

The brand engine also exposes seven lightness steps and six chroma steps of the brand (--pui-brand-l-14 … --pui-brand-l-86, --pui-brand-c-0 … --pui-brand-c-22) for brand-tinted illustrations and fills.

Theme

Dark is the default and the primary design target: class="pui dark" (or just pui) on <html>, or <UIRoot theme="dark">. Light: class="pui light" or theme="light"; data-theme="dark|light" works too. A region can switch theme for its own subtree (a UIRoot inside another). Both themes define every token, so components never branch on the theme. ThemeSwitch (@beamline/ui/theme-switch) is a ready control.

Look

Every element has a core (size, layout, states, keyboard, motion) and a look: the whole style on top of it, meaning the fonts, icon weight, corner radii, the page behind everything and the paint of every surface. The look is chosen like the theme and independently of it, and changing it on the root restyles the whole app:

<html class="pui dark" data-look="metal">          <!-- the whole app -->
<UIRoot theme="light" look="glass">…</UIRoot>      <!-- a region, its menus and dialogs included -->
<section data-look="blueprint">…</section>         <!-- any subtree -->
<Button variant="accent" look="glow">Upgrade</Button>  <!-- one button -->

prism is the default: cut crystal in a dark room (a white room in light). Parts are drawn by their cut edges, which catch light from the top left; the main action is white light with near-black words; colour appears only where attention is (a line of split light under the field being written in, the chosen tab and the pointed-at main action) and in charts, which keep a natural spectrum. Set brand and the main action, focus ring and highlights take your colour instead of white. soft is clean surfaces tinted with a trace of your brand's hue, hairline rims, short shadows, Geist headings. blueprint is the drafting table for dense tools, drawn as CAD: cyan wireframe frames with corner ticks, square corners, mono micro-labels. glass is a liquid lens: clear glass with thick bright rims over a faint grid and soft pools of your brand's light. metal is black anodised metal with diamond-cut edges (anodised aluminium in light) with wide Archivo headings and monospaced figures. glow keeps a calm night and puts light in one place: a faint haze of your brand's spectrum at the top, and soft spectral light under the main buttons. pop is a sticker sheet: a warm page, ink outlines, a hard lip under every control, Gabarito and Figtree. mono is monochrome: controls are ink and your brand colour appears only in the data it leads. ink is a pen-and-ink drawing in two inks. A look never changes the size scale (control heights, spacing), so switching it never re-flows a layout beyond text widths following its fonts. The look names are in LOOKS from @beamline/ui/lib/look.

Card rims can catch light as the pointer comes near. This is an explicit optional feature: import setup from the release's @beamline/effect-edge-light entry (editable source: @/runtime/features/edge-light) and call it once in an application effect, returning its cleanup. UIRoot does not start it or import every look initializer. It does nothing while the pointer is still, on touch screens, or with reduced motion.

Looks that use SVG resources declare a separate runtime entry: @beamline/look-glow/runtime, @beamline/look-mono/runtime or @beamline/look-ink/runtime. Import only the selected setup and return its cleanup from an application effect. The setup shares resources within the document; the preset's root entry stays pure.

Your own parts can wear the same paint with the role classes m-control, m-solid (set --m-color), m-field, m-inset, m-toggle, m-knob, m-tag, m-surface and m-overlay, the look's page with look-page, a sidebar with look-chrome (frosted in glass) and a meter track with meter (segmented in metal). A new look implements visual contract 1 as complete CSS. Built-in authored recipes live in ui/styles/preset-source.css; looks.css is the explicit aggregate. The generator flattens inheritance into each selected stylesheet and validates its declared contract. External IDs need no runtime registry or edit to the built-in Look type.

Other tokens worth knowing

change token
corner radius family --pui-radius-chip 6 · --pui-radius-control 9 · --pui-radius-card 12 · --pui-radius-panel 14
surfaces --pui-canvas, --pui-surface, --pui-surface-2, --pui-inset, --pui-field
text --pui-ink (primary), --pui-ink-2 (body), --pui-ink-3 (meta only)
trading direction colours --pui-up, --pui-down (separate from success/danger so red-up markets can swap them)
chart series --pui-series-1 … --pui-series-8 (series 1 follows the brand)
motion --pui-motion-in (90 ms), --pui-motion-out (180 ms), easing --pui-ease-default
type faces --pui-font-body (Inter), --pui-font-display (Geist), --pui-font-code (Geist Mono)

The full list, drawn and explained, is in docs/DESIGN-LANGUAGE.md and on the gallery's Foundations pages (previews/gallery/index.html, complete bundle).

Per-component changes

  • className, style and classNames.root address the documented visual root. Component CSS sits in @layer components, below normal unlayered customer CSS and Tailwind utilities; importance, layers and specificity still govern the real cascade. Putting a class last does not guarantee that it wins.

  • Input's 0.2 style target is its field wrapper, matching className. Use classNames.input for the native input. Its ref, native attributes and value still target the input. Select keeps its ref on the trigger; classNames.trigger styles that trigger. Styling does not replace measurement refs, keyboard behavior or accessibility relationships.

  • Every meaningful part carries data-slot="<part>" (for example data-slot="order-book-row") and state in data attributes (data-state="open", data-side="bid"), so you can style parts without forking:

    [data-slot="order-book-row"][data-side="bid"] { font-weight: 500; }
  • Variants and sizes use one vocabulary everywhere: variant (primary · secondary · outline · ghost · danger), size (sm · md · lg), tone (neutral · accent · success · warning · danger · info), density (compact · default · comfortable).

Fonts

@beamline/ui/fonts.css serves Inter, Geist and Geist Mono, plus Archivo (metal), Gabarito and Figtree (pop) and Instrument Serif (ink), from your own app (variable fonts, SIL Open Font License 1.1, licence texts in dist/fonts/); browsers download only the faces and character sets a page uses, so a look's faces load only when that look is on. To use other faces, skip that import and set --pui-font-inter, --pui-font-geist and --pui-font-mono-face to your families.

Motion and effects

Motion follows the reader's prefers-reduced-motion: durations become instant and decorative loops stop, while live data and progress keep updating. Managed motion="auto" follows that preference, "reduced" removes decorative displacement, and "none" removes decorative transitions. Meaningful loading/status feedback and live data remain. Button retains its Motion label, loading and press behavior; that dependency is part of the selected Button artifact. The old Button metal effect was removed; the accepted Metal look is selected through the preset, with no replacement MetalFx effect.

Right-to-left

Use UIRoot dir="rtl" when the region includes portals; its direction reaches both the region and its stable portal host. Direction (@beamline/ui/direction) remains available for a local direction provider. Components use logical properties, and directional icons flip.

Editing the source

With the source copy (INSTALL.md, path B) you can change any component directly. Keep the conventions in docs/CONVENTIONS.md (props vocabulary, data-slot, tokens only) so your edits stay consistent with the rest.