20. Widgets
A widget defines how a module renders on the board. Every module renders through one — when nothing is configured, the viewer’s built-in arch-module element takes over. This chapter covers two scenarios:
- Configuring the default — staying with
arch-moduleand tuning its appearance throughwidget.*fields. Covers the 80% case. - Building a custom widget — registering your own custom element when the default isn’t enough.
Both paths share the same widget: field surface — the parser just dispatches on value kind (identifier vs string).
Configuring the default widget
The default arch-module is configurable through widget.* fields. The full configuration surface:
| Field | Effect |
|---|---|
widget.icon | Icon id (one of the stdlib icons: service, database, browser, cluster, etc.) |
widget.color | Accent colour (CSS colour value) |
widget.bg | Background colour or CSS background-image value |
widget.subheader | Path of a field/label whose resolved value renders under the name (e.g. team, labels.domain) |
widget.footer | Path of a field/label whose resolved value renders as the footer band |
widget.label1, widget.label2 | Paths whose resolved values render as key: value chips in the body |
widget.width, widget.height | Card size in model pixels (default 220 × 140) |
The path-style fields (subheader, footer, label1, label2) accept either a bare path (team) or an explicit prefix (labels.X, fields.X). A bare path tries labels first, then fields.
The stdlib bundles a cascade * group at every kind type so authors get sensible defaults out of the box. For example, the stdlib’s service cascades:
type module service { required cascade team required labels.domain cascade * widget: arch-module { icon: service color: "#60a5fa" subheader: team footer: "labels.domain" }}Every service instance inherits the lot. To tweak a single leaf, override that leaf on the instance:
service Orders { widget.color: "#22c55e"}To replace the widget entirely (swap to a custom element), set the root:
service Orders { widget: my-orders-card}Because the parent declares cascade *, the entire group (including widget.icon, widget.color, etc.) is dropped from the inheritance — no per-leaf drop ceremony needed. The new widget starts fresh. See Chapter 18 for the cascade-group rules.
Building a custom widget
Two reasons to write a custom element:
- The default widget can’t represent your visual — you want a database cylinder, a stick-figure actor, a table-shaped renderer where each row is an interface, etc.
- You need behaviour the default doesn’t expose — animation, hover detail, embedded charts, live data fetch.
Skeleton:
class MyWidget extends HTMLElement { static get observedAttributes() { return ["name"]; }
connectedCallback() { const root = this.attachShadow({ mode: "open" }); root.innerHTML = ` <style>:host { display: block; width: 100%; height: 100%; }</style> <div class="card"></div> `; this.render(); }
attributeChangedCallback() { this.render(); }
// Custom property — the renderer assigns `el.arch = module` AFTER // connectedCallback fires. Trigger a re-render on assignment. set arch(value) { this._arch = value; this.render(); } get arch() { return this._arch; }
render() { const root = this.shadowRoot; if (!root) return; const name = this.getAttribute("name") ?? this._arch?.name ?? ""; root.querySelector(".card").textContent = name; }}customElements.define("my-widget", MyWidget);Register the element by listing its JS file in your package’s widgets: field — see Chapter 12.
Use it from a module:
service Orders { widget: my-widget}The renderer emits <my-widget name="Orders" ...> for that node.
What the widget receives
| Surface | Contents |
|---|---|
Attribute name | Instance name |
Attribute data-has-subspace | "true" when the module has children; widget can branch on this for container chrome |
Attribute data-selected | "true" when this is the selected module |
Attribute data-highlighted | "true" when in active blast radius |
Attribute data-hovered | "true" / "false" driven by the React layer (use instead of :hover to avoid Chrome’s sticky-hover quirks on GPU layers) |
Attribute data-module-id | The module’s stable id (when present) — used by the renderer’s DOM lookups |
Attribute widget.<…> props | Every widget.* prop, kebab-cased — value coerced to string |
CSS var --arch-projected-width | Projected pixel width (continuous) |
CSS var --arch-scale | Viewport scale (1 = model space) |
CSS var --arch-band-compact, --arch-band-compact-reveal | 0→1 as the node enters compact-readable zoom (structure vs reveal phase) |
CSS var --arch-band-full, --arch-band-full-reveal | 0→1 as the node enters full-detail zoom |
Property el.arch | Full resolved module: { id, name, kindName, description, fields, labels, facets, interfaces, children, sourceFile, … } |
Property el.archZoom | { projectedWidth, scale } |
Property el.archState | { selected, highlighted, hovered } |
Property el.archConnections (optional) | { incoming, outgoing } if set by the renderer |
DOM properties are set after connectedCallback. The pattern in the skeleton above (setter triggers re-render) covers this.
Node sizing — the widget owns it
The layout solver does not read widget.width / widget.height from the instance directly. It asks the widget. Widgets choose between three policies:
-
Customisable — read
props.get("width")/props.get("height")insizeForand fall back to your own default:class CustomCard extends HTMLElement {static sizeFor(props) {const w = props.get("width");const h = props.get("height");return {w: typeof w === "number" ? w : 220,h: typeof h === "number" ? h : 140,};}} -
Fixed — declare
static archWidth/static archHeight(and skipsizeFor). Instancewidget.width: 999has no effect.class Actor extends HTMLElement {static archWidth = 110;static archHeight = 140;} -
Computed — derive size from the model.
sizeForreceives(props, ctx)wherectx = { kindName, interfaces, labels }. Pure, deterministic — same inputs always return the same size, so the solver doesn’t have to re-measure.class DbTable extends HTMLElement {static archWidth = 240;static rowH = 22;static headerH = 44;static sizeFor(_props, ctx) {const rows = Math.min(ctx.interfaces.length, 32);return { h: this.headerH + rows * this.rowH };}}
Resolution order per axis: sizeFor(props, ctx) → static archWidth/Height → 0 (solver then uses its global NODE_W/NODE_H default).
Zoom-aware rendering
The renderer exposes zoom through continuous CSS variables that ramp smoothly across user wheel/pinch. Bind layout and reveal to them:
[header] { flex: 1 0 auto; min-height: 1.6em; }[body] { flex: calc(999 * var(--arch-band-compact, 0)) 0 0; min-height: 0; }[footer] { max-height: calc(1.6em * var(--arch-band-full, 0)); opacity: var(--arch-band-full-reveal, 0); }--arch-band-compact ramps 0→1 around the compact-zoom range (default 0.52–0.56 of viewport scale); --arch-band-full ramps later around 1.09–1.13. The matching *-reveal variants ramp slightly after the structure phase so layout settles before content fades in. With body grow tied to band-compact, the body collapses at dot zoom and the header absorbs the leftover space.
For font sizing, container queries are cleaner than raw band signals: declare the header as container-type: size; container-name: my-header; and use cqh/cqw units inside.
Subspace projection
When a module has children, the renderer mounts the subspace as a light-DOM child of the widget host. The widget projects it via its default <slot>:
class MyContainer extends HTMLElement { connectedCallback() { const root = this.attachShadow({ mode: "open" }); root.innerHTML = ` <style> :host { display: block; width: 100%; height: 100%; } .card { display: flex; flex-direction: column; padding: 0.6rem; height: 100%; } .body { flex: 1; position: relative; } ::slotted(*) { position: absolute; inset: 0; } </style> <div class="card"> <header>{{name}}</header> <div class="body"><slot></slot></div> </div> `; }}The <slot> position is where the subspace renders — the renderer-side NestedSpaceFit wrapper measures the slot’s actual rect via ResizeObserver and fit-contains the nested scene inside it. Widgets without a <slot> silently don’t show subspace; that’s the opt-out for leaf-only widgets.
For pixel-perfect dive-in animation, expose the body region under part="subspace" (matches the renderer’s measureItemBodyRect lookup):
<div class="body" part="subspace"><slot></slot></div>Selection and highlight
State surfaces as host attributes:
:host([data-selected="true"]) .card { box-shadow: 0 0 0 2px var(--accent), 0 0 24px var(--accent-glow);}:host([data-highlighted="true"]) .card { box-shadow: 0 0 0 2px var(--warn);}:host([data-hovered="true"]) .card { border-color: var(--accent);}Edge anchors
Mark connection points with data-anchor="<side>". The layout engine reads getBoundingClientRect():
<div class="anchor" data-anchor="in" style="position:absolute; left:-4px; top:50%"></div><div class="anchor" data-anchor="out" style="position:absolute; right:-4px; top:50%"></div>Inline templates
When you want a one-off custom look without a JS sidecar, set widget: to a string literal:
service Cart { team: shop labels { domain: shopping }
widget: " <div class='w-full h-full p-3 rounded-xl bg-arch-card border border-purple-400/50'> <div class='font-semibold text-purple-200'>{{name}}</div> <div class='text-xs text-slate-400'>{{kindName}} · {{team}}</div> <div class='text-xs text-purple-400/80'>{{labels.domain}}</div> </div> "}The parser sees the string value and routes the module through the inline-template host instead of a custom element.
Triple-quoted strings
For templates that contain double quotes (HTML attributes), use triple quotes:
service Cart { widget: """ <archui-card variant="outline" accent="#a78bfa"> <archui-header slot="header" name="{{name}}"></archui-header> </archui-card> """}Triple-quoted strings are raw — backslashes aren’t escape sequences, and ${expr}-style interpolation is deliberately not supported.
Substitution rules
Template substitution is strict and logic-free. There are no conditionals, loops, or formatters — only {{path}} lookups:
| Token | Resolves to |
|---|---|
{{name}} | Instance name |
{{kindName}} (or {{kind}}) | Kind keyword |
{{description}} | Joined description entries |
{{<dotted.field>}} | Field by path |
{{labels.<dotted>}} | Label by path |
{{{<path>}}} | Same as above but raw (skips HTML escaping) |
{{widget.<…>}} | Always empty — widget config never leaks into content |
Unknown paths render as the empty string. Double braces escape; triple braces inject raw HTML.
CSS for inline templates
Tailwind utilities used in templates are compiled server-side via UnoCSS (Tailwind-compatible). The server walks every string-form widget: template, extracts candidate class names, and exposes the compiled CSS at GET /api/widgets/css. The viewer fetches it at boot and re-fetches on workspace updates.
Custom arch-* utilities ship out of the box:
| Class | Effect |
|---|---|
bg-arch-card | Pre-tuned dark gradient for cards |
bg-arch-card-hot | Indigo-tinted variant |
shadow-arch-glow / -lg | Subtle accent glow |
shadow-arch-card | Drop shadow + inner highlight |
text-arch-{50..950} | Extended sky-blue palette |
The arch.ui primitive library
The stdlib ships a primitive library at arch.ui. Custom widgets and inline templates can compose it by tag name:
service Cart { team: shop labels { domain: shopping }
widget: """ <archui-card variant="outline" accent="#a78bfa"> <archui-header slot="header" icon="server" name="{{name}}"></archui-header> <archui-body slot="body"> <archui-tech-stack></archui-tech-stack> </archui-body> <archui-details slot="details"> <archui-field path="team" label="team"></archui-field> <archui-label path="domain" format="mono"></archui-label> </archui-details> <archui-anchor side="in"></archui-anchor> <archui-anchor side="out"></archui-anchor> </archui-card> """}Theme tokens live under --au-* CSS custom properties. Override on any host to retheme a subtree:
:host { --au-accent: #a78bfa; }Tokens are layered with CSS @layer archui.tokens, archui.project; so project tokens win over stdlib defaults regardless of import order.
Cascade
Both widget and widget.<…> are regular fields, so the propagation mechanisms from Chapter 18 apply. Use cascade * to bundle the whole config so per-kind defaults can be wholesale-replaced or surgically tweaked:
type module service { cascade * widget: arch-module { icon: service color: "#60a5fa" subheader: team footer: "labels.domain" }}
// Replace the whole bundle:type service fancy { widget: my-fancy-element // arch-module's icon / color / etc. are NOT inherited — the // cascade-group root was replaced.}
// Or override just a leaf:service Orders { widget.color: "#22c55e" // keeps icon: service, subheader: team, etc.}Edge widgets
Edges (lines on the board) can carry their own widget too. The mechanism mirrors module widgets — a widget: field on the interface chain, with widget.<…> props. Interfaces typically declare their widget at the kind type level:
type interface command { cascade widget: arch-edge cascade widget.color: "#60a5fa"}Edge widgets receive el.archEdge = { from, to, id, details } and el.archPath (the rendered SVG path string). Use the path to draw decorations along the line — labels, animated dots, glow accents.
Quick reference
| Want to … | Do this |
|---|---|
| Tweak default widget look | Set widget.<icon|color|subheader|footer|label1|label2> |
| Replace the widget entirely | Set widget: my-element (drops parent’s cascade * group) |
| Customise a single leaf, keep the rest | widget.color: … |
| Force-fix card size at the widget | Declare static archWidth / archHeight on the element, no sizeFor |
| Make card size customisable | Implement sizeFor(props) reading props.get("width")/get("height") |
| Render nested children | Declare a <slot></slot> in shadow DOM, mark part="subspace" on the wrapper |
| Skip nested children entirely | Omit the slot — light DOM is dropped, leaf-only widget |
| One-off custom look, no JS | Inline template via widget: "<html>...</html>" |
| Compose stdlib primitives | Use archui-* tags in templates |