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 type (identifier vs string).
Rule. A widget changes how a module renders, never what it means. The model is the source of truth and the diagram derives from it — so optimize your
.archfor an accurate, complete description and let the renderer worry about looks. Reach for a widget only to present that truth more clearly, never to compensate for a model that says the wrong thing.
Configuring the default widget
Section titled “Configuring the default widget”The default arch-module is configurable through widget.* fields — and widget.* entries are ordinary fields: structured data on the module. That’s the whole mechanism. Fields are what drive widget controls; you tune the card by setting data, not by drawing. The full configuration surface:
| Field | Effect |
|---|---|
widget.icon | Icon id (one of the stdlib icons: service, database, browser, cluster, etc.) |
widget.color | Accent colour — a palette name (crimson, info), a literal CSS colour, or a cast (colorize(@@team)) |
widget.bg | Background colour or CSS background-image value |
widget.subheader | Text under the name — a literal, or a getter reading a field/aspect (@@team) |
widget.footer | Text in the footer band — literal or getter |
widget.label1, widget.label2 | Values rendered as key: value chips in the body — usually getters, whose key labels the chip |
widget.width, widget.height | Card size in model pixels (default 220 × 140) |
These fields take a getter when they should read a value off the element: @@team on the aspect axis, @repo.url on the field axis (Chapter 9). The sigil is what marks a read — a bare word is a literal, so subheader: team prints the word “team”. Getters resolve per element, after propagation, so one line on a type reads each descendant’s own value.
Note. Surfacing an aspect as a chip here is just chrome — a static readout printed on the card. It is not the same thing as an aspect overlay. Infrastructure planes — a message broker, a network zone, logs, metrics — live on their own plane and are revealed on demand as a toggleable overlay across the board, derived from the aspects elements share, not baked into any one widget. A widget prints an aspect’s value; toggling the aspect layout shows who shares it. See Chapter 9 and Chapter 8.
The stdlib bundles a cascade * group at every type type so authors get sensible defaults out of the box. For example, the stdlib’s service cascades two optional membership aspects (team, domain) and a widget config that reads them for display:
export type module service { "A network-addressable backend service — the default building block of a microservice architecture" aspect team aspect domain cascade * widget: arch-module { icon: service color: info subheader: @@team footer: @@domain }}Neither aspect is required — a service that hasn’t recorded a team or domain yet is still valid. Making ownership mandatory is an opt-in policy check, not a required field — governance enforces it separately via a policy block (see Chapter 28). Every service instance inherits the widget group. To tweak a single leaf, override that leaf on the instance:
service Orders { widget.color: green}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
Section titled “Building a custom widget”Rule. A plain module with a plain interface is the default — typing a module is sugar. A custom widget is one of the few things that genuinely earns a custom type (alongside custom fields, custom requirements, and subtyping). Define a type to give a family of modules a bespoke renderer; don’t invent a behaviorless type just to wrap a plain module. See Chapter 15.
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.
Widget tag namespacing
Section titled “Widget tag namespacing”Custom elements live in one global registry, so two packages that both
register arch-card would silently collide. To keep tags safe to
author, each package has an implicit namespace derived from its name,
and a bare widget reference is auto-prefixed with it:
| Package | Bare ref | Resolves to |
|---|---|---|
arch.c4 | widget: boundary | arch-c4-boundary |
myorg.widgets | widget: card | myorg-widgets-card |
The rule is intentionally simple:
- A bare, single-token name (no hyphen) gets the declaring package’s prefix prepended.
- A name that contains a hyphen is taken as a full tag and used
verbatim — that’s how you reference a builtin (
arch-module) or another package’s widget (arch-ui-card).
So you ship customElements.define("arch-c4-boundary", …) in your
widgets.js and write the short widget: boundary in .arch; the
resolver bridges the two. Anonymous packages (no manifest) have no
prefix, so their bare names are left as-is.
What the widget receives
Section titled “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, aspects, surfaces, 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
Section titled “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, aspects }. 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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “Inline templates”When you want a one-off custom look without a JS sidecar, set widget: to a string literal:
service Cart { aspect { team: "shop"; 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'>{{typeName}} · {{aspects.team}}</div> <div class='text-xs text-purple-400/80'>{{aspects.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
Section titled “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
Section titled “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 |
{{typeName}} (or {{type}} / {{kindName}}) | Type name |
{{description}} | Joined description entries |
{{<dotted.field>}} | Field by path |
{{aspects.<dotted>}} | Aspect 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
Section titled “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
Section titled “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 { aspect { team: "shop"; 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-label path="team" label="team"></archui-label> <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
Section titled “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-type defaults can be wholesale-replaced or surgically tweaked:
type module service { cascade * widget: arch-module { icon: service color: info subheader: @@team footer: @@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: green // keeps icon: service, subheader: @@team, etc.}Edge widgets
Section titled “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 type type level:
type interface command { cascade widget: arch-edge cascade widget.color: info}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
Section titled “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 |