Skip to content

Snapshots and the canvas

Snapshots are the substrate a rib publishes data onto; the canvas is the closed set of shapes the browser knows how to render. For the design rationale, read Snapshots and surfaces first. What follows is the contract underneath it: the SnapshotManager interface, the frame envelope, and the canvas vocabulary a view must satisfy.

The harness owns one SnapshotManager. A rib receives a namespace-scoped view of it through ctx.getSnapshotManager?.().

interface SnapshotManager {
register<T>(
key: string,
compose: () => Promise<T> | T,
opts?: { validate?: (data: unknown) => T },
): () => void; // returns an unregister handle
recompose<T>(key: string): Promise<SnapshotFrame<T> | undefined>;
latest<T>(key: string): SnapshotFrame<T> | undefined;
keys(): string[];
dispose(): Promise<void>;
}
MethodBehavior
registerRegisters a composer under a key. Throws on a duplicate key; call the returned handle before re-registering. An optional validate runs on every composed payload.
recomposeRuns the composer, validates, caches, and broadcasts a frame. Concurrent calls coalesce into one composer run. Returns undefined if the key is unregistered or the composer threw.
latestThe last cached frame for a key, or undefined. Wrapped (not bare data) so a late client can compare version.
keysThe registered keys; backs GET /api/snapshots.
disposeIdempotent teardown; closes subscriber sockets and drops cached state.

A rib’s composeBundle is registered as the composer for rib.id automatically. Additional keys are registered imperatively. Every key a rib registers must live under rib:<id> or rib:<id>:*; registering outside the namespace throws, and reading another rib’s keys returns nothing.

A frame is the versioned envelope every snapshot travels in, both in latest() and over the WebSocket.

the wire shape
{
type: "snapshot_update",
key: "rib:example:board",
version: 7, // non-negative int, increments per compose
composedAt: "2026-06-09T18:20:04Z", // ISO 8601 with offset
data: /* the payload */
}

Each successful compose increments version, replaces the cached frame, and broadcasts to subscribers. Versions let a late-joining client read the cached frame first, then subscribe and discard any frame it has already seen.

A frame is checked at three points, and every rejection preserves the last good state.

  1. At the producer. A validate registered with the key (canonically a zod parse) runs before caching or broadcast. An invalid payload is dropped and the prior frame survives.
  2. At the composer. If the composer throws, the failure is logged once and the cached frame is untouched. A flaky external system degrades a board to stale, never to broken.
  3. At the renderer. The browser parses every frame against the canvas schema before rendering. A mismatch renders a one-line note instead of a component.

For a view payload, expectView(key, kind) is the canonical producer guard: it parses data through the full canvas-view union (so the uniqueness checks the render gate runs are enforced producer-side) and asserts the expected discriminant.

A payload declares what it is. The renderer set is closed: the browser either has a renderer for the declared kind or refuses politely.

KindRenders as
markdownFormatted text.
viewA typed visualization: a table, a graph, or a board.
htmlRib-authored markup rendered in a sandboxed iframe. The frame runs with sandbox="allow-scripts" (no allow-same-origin) and a CSP whose connect-src 'none' blocks script-initiated network connections (fetch, XHR, WebSocket). Frame-originated actions (posted via keelson.action(type, payload) or a [data-canvas-action] click) arrive at the rib’s onAction stamped with origin: "canvas-html", letting the rib gate untrusted verbs.

The injected window.keelson bridge lets an HTML view opt into transient UI state, such as a task field that should survive replacement markup when a project or provider switches. It does not capture the DOM automatically.

window.keelson.saveState(state: JsonObject): void;
window.keelson.onRestore(handler: (state: JsonObject) => void): void;

Saving. Each valid save replaces the entire prior object, without merging. The root must be a plain JSON object. Nested plain objects, arrays, strings, booleans, null, and finite numbers are accepted. Undefined, functions, symbols, bigint, non-finite numbers, cycles, sparse arrays, and non-JSON objects such as Date, Map, and Set are rejected. The serialized JSON must fit within 65,536 UTF-8 bytes, including keys and JSON punctuation. Invalid or unserializable values leave the last accepted save intact; diagnostics never contain the payload. saveState({}) replaces the saved draft with an empty object.

Restoring. After a replacement document loads, the host sends the latest saved object once. The bridge buffers it if the handler registers later; an early handler receives it when it arrives. A later registration replaces an undelivered handler. Once consumed, the value is never replayed to another registration in that document. With no saved value the handler is not called; an empty object is a real saved value. Handler errors remain visible.

Identity and lifetime. The host uses the exact snapshot key, not the HTML content or a frame-supplied identifier. Different keys are isolated, including views owned by one rib. Inline surface regions and snapshot-backed drawers use the same key: state survives collapse/remount, reopening, and expansion into a drawer. Multiple placements of one key share the latest accepted save, but there is no live synchronization; another placement receives it on its next document load. Theme toggles neither reload the frame nor replay state.

State lives only in browser-tab memory. The host retains at most 64 view keys, evicting the least recently saved or restored key when a new save exceeds that count. A page reload loses all saved state. Inline text and direct artifact sources without a snapshot key retain isolated component-lifetime state only. No state is written to disk, browser storage, SQLite, or a snapshot. Saving and restoring invoke no rib action, workflow, or API and show no toast. Use the rib’s own storage for durable data.

Register restoration before attaching edit handlers, then save user edits rather than initialization defaults. The rib owns validation and versioning of restored fields. Optional feature detection lets markup run on older harnesses without state preservation:

<textarea id="task"></textarea>
<script>
const task = document.getElementById("task");
window.keelson.onRestore?.((state) => {
if (typeof state.task === "string") task.value = state.task;
});
task.addEventListener("input", () => {
window.keelson.saveState?.({ task: task.value });
});
</script>

The additive state channel is keelson:canvas:html:state. Its strict envelope is { channel, type: "save" | "restore", state }, with no target or view fields. The host accepts saves only from its iframe; the bridge accepts restores only from its parent. The sandbox remains exactly allow-scripts, and the existing CSP, action, theme, and sizing channels are unchanged.

A view payload carries its own view discriminant, one of three:

{ view: "table",
columns: [{ key, label? }], // keys must be unique
rows: [ { [columnKey]: Cell } ],
caption? }

A Cell is a bare scalar (string | number | boolean | null) or a wrapped { value?, tone?, badges?, href? }, where each badge is { text, tone? }. A wrapped cell must render something: a value or at least one badge. An href renders the cell’s value as an external link, but only a safe http(s):// URL becomes an anchor. Other schemes fall back to plain text.

{ view: "graph",
nodes: [{ id, label?, kind?, tone? }], // ids must be unique
edges: [{ source, target, label? }] }

kind is a generic category string, never a base-side enum. Optional tone colors the node’s left rule using the tone vocabulary.

The workhorse: an ordered stack of dashboard sections.

{ view: "board",
title?,
header?: { status?: Pill, chip?, segments?: Segment[], people?: [{ name, tone? }], defaultCollapsed? },
sections: BoardSection[] }

The header’s people is a roster peek: an identity-toned dot per person, names shown on hover of the status count. It shares CanvasPerson with a card’s people field and the seats section but is its own primitive, and it carries the same name-required rule the identity tones state. defaultCollapsed is a one-shot hint that the region head may start collapsed once the board is populated: the host collapses once on the first transition to populated, a manual toggle wins after, and emptying the board re-arms it.

Each section is discriminated on kind. Thirteen are leaf sections; columns and tabs are one-level layout wrappers that nest leaf sections side by side or one group at a time.

kindShapeFor
statsitems: [{ label, value | clock, sub?, tone?, delta?, spark? }]KPI tiles. A clock tile shows a relative time in place of value (see below).
segmentsitems: [{ label, n, tone? }]A summary pulse.
barsinline?, items: [{ label, value, total, tone?, segments?, trailing?, href? }]Meters and offender lists. A safe http(s):// href makes the whole bar row a link; other schemes fall back to plain text. segments: [{ label, n, tone? }] splits the fill into stacked parts, each n of total, with the label and amount on hover.
tablecolumns, rows, caption?The same table shape as the view.
cardsboxed?, grid?, columns?, items: [{ title, titleTone?, mono?, stacked?, dot?, edge?, pill?, href?, action?, selected?, bar?, fields?, actions?, footnote?, ghost?, reason? }]Rich item cards. A card may carry actions: ActionItem[] for inline per-card buttons, distinct from a standalone actions section. action: { type, payload? } makes the whole card body a select/toggle target that dispatches to the owning rib like a button, for pick benches where the card is the selection control (its own actions buttons keep working, layered above); selected marks the card chosen with a brand ring and is conveyed to assistive tech as a pressed toggle, so it reads even on an inline board with no dispatcher. stacked renders the card’s fields as a column (one per line) instead of the inline ·-joined meta row, for line-oriented readouts. grid lays the cards out side by side as an auto-fit grid instead of the stacked full-width column, for fixed-capacity rosters where the row is the bench; the host owns the responsive column count, and an open fields-form stays inside its card’s column. columns (2–6) declares a fixed bench capacity: the host lays exactly that many set-size tracks per row when the panel is wide enough (fewer when narrow) instead of stretching cards to fill the row; a producer that declares capacity typically rounds the roster up with trailing decorative ghost seats (ghost cards without actions: the host hides them from assistive tech and drops them when the panel can’t fit full capacity) so the bench reads as full rows. A ghost card renders as an open-seat placeholder (dashed border, centered body): the empty-seat affordance in a grid bench, e.g. an authoring launchpad or an unfilled casting slot; while a ghost card’s form is open it sheds the dashes for a solid brand edge. edge (a tone) draws a colored left rule that lifts the one card needing attention out of a board of look-alikes. It’s decoration only, so the card must still say why in text (a pill, a reason); ghost cards ignore it.
rowsboxed?, items: [{ icon?, glyph?, chip?, text, href?, trailing?, detail? }]A status or feed list. A row with detail (long-form plain text, up to 4,000 chars) renders as a disclosure that expands beneath the row.
actionswrap?, tabs?, items: ActionItem[]Buttons. wrap lays them out as an inline wrapping row of compact chips (a selection strip) instead of a full-width stacked column; an action whose form is open breaks to its own full-width line. tabs renders a single-select strip instead: opening one action’s form closes the others’, and the open form is a stable full-width panel beneath the whole strip (at rest none is active unless an item sets defaultOpen; clicking the active tab closes it; takes precedence over wrap, and expanded is inert). Only this layout renders an item’s subtitle: a second muted line under the tab’s label. See below.
gridcells: [{ label, href?, badge?: { text, tone? } }]A dense at-a-glance matrix. Omit badge for a cell that is a bare labelled link, so a set of href cells reads as a compact strip rather than a stack of rows.
chartyLabel?, series: [{ label, points: [{ x, y }] }] (1 to 6 series, unique labels, unique x per series)A deterministic line/timeseries plot. Series wear the fixed-order categorical palette by index, never cycled: fold a seventh series into an “other” bucket. All-numeric x scales linearly; any string x renders as ordered categories in first-appearance order. Endpoint dots always mark line ends; direct endpoint labels appear up to 4 series, a legend appears for 2+, and hovering shows a crosshair with per-series values. Single y-axis by construction; pair the chart with a table section when exact values matter.
seatsitems: [{ tone?, filled?, label? }]A fixed-capacity identity row an actor roster fills. A dashed empty seat becomes solid when filled; a label (the actor’s name) becomes the seat’s tooltip and accessible name, and an unlabelled seat is decorative (aria-hidden). Identity colour never renders without a name (see Tones).
journeyitems: [{ title, text? }]A numbered step strip (numbers derive from order) that says what will happen next. Replaces stacks of “nothing here yet” placeholder chrome on first-run surfaces.
graphtitle?, columns?: string[], nodes: [{ id, label, sublabel?, tone?, kind?, rank?, badges?: [{ text, tone? }], action?: { type, payload? }, selected? }], edges: [{ source, target, label?, tone?, dashed? }]A ranked dependency, lineage, or parent-child map. Accepts 1 to 48 nodes and at most 200 edges. Node ids must be unique, both edge ends must name nodes, selected: true requires an action, and rank must be an integer at least zero. Section, node, and edge objects reject unknown keys.
timelinetitle?, window, lanes: [{ id, label, tone?, group? }], spans: [{ lane, from, to?, tone?, hatched?, title }], marks: [{ lane, at, glyph, title }], legend?Spans on lanes over a window; a chart is for values. Accepts 1 to 12 lanes, at most 400 spans and 200 marks. All three arrays are required; spans and marks may be empty.
columnscolumns: [{ weight?, sections: LeafSection[] }]Lays leaf sections side by side. One level deep only.
tabstabs: [{ label, badge?, sections: LeafSection[] }]Shows one tab’s leaf sections at a time behind a tab strip. The first tab opens first and the host keeps the open tab across frames. badge is a short count or total beside the label. One level deep only. Older hosts reject tabs.

Graph layout. Nodes occupy left-to-right rank columns and keep input order within each column. columns[rank] supplies the heading; unused ranks are compacted away. Explicit ranks are honored. Unranked nodes derive their rank from the longest dependency path from a source, including explicitly ranked predecessors. Cycles are accepted: remaining unranked nodes use the deepest processed predecessor plus one, or zero if none exists. Self-loops are not drawn; backward and same-column edges use a simple curve that may cross nodes. Forward edges that skip columns route through node gaps. Edge label is a tooltip, not text drawn on the curve.

Graph interaction. Hover or keyboard focus lights the node’s upstream and downstream chain and dims unrelated nodes and edges. With neither active, selected nodes keep their chains lit. A node with an action is a native button: click or Enter dispatches { type, payload? } to the owning rib without an origin stamp, just like a card action. It is disabled without a dispatcher or while pending. A reply { effect: "open-canvas", key, placement: "side" } opens an inspector without hiding the map. Other nodes remain keyboard-focusable for chain highlighting.

Graph fallback. Below 720px of section width, columns stack, the SVG edge layer is omitted, and each dependent node lists the source labels it waits on. The host remeasures edges on resize. Prefer this section over an html region for node-link maps: the host owns layout, theme, highlighting, the narrow fallback, and trusted action dispatch. Keep html for bespoke drawings, and prefer journey or table when relationships are not the story. For larger graphs, publish the relevant slice and put “showing N of M” in title.

Graph sections require a harness that supports this board-section kind. An older harness may support the top-level view: "graph" while rejecting a graph inside a board; check support before a rib adopts the section.

Timeline contract. window is exactly { from, to } or { from, clock: { until } }. All timestamps are ISO 8601 with an offset. The window end must be after its start; a closed span’s end may equal, but never precede, its start. Lane ids must be unique. Every span and mark must reference a declared lane. Ids, labels, and item titles are nonempty; a mark’s glyph is one Unicode code point, including supplementary-plane characters. Section, window, lane, span, and mark objects reject unknown keys. Publish through expectView(key, "board") for these checks at either the board’s top level or inside columns. @keelson/shared exports CanvasTimelineSection, CanvasTimelineWindow, CanvasTimelineLane, CanvasTimelineSpan, and CanvasTimelineMark.

const activity: CanvasTimelineSection = {
kind: "timeline",
title: "Activity window",
window: {
from: "2026-10-05T12:00:00Z",
clock: { until: "2026-10-05T13:00:00Z" },
},
lanes: [
{ id: "prepare", label: "Prepare", tone: "id-blue", group: "Work" },
{ id: "review", label: "Review", tone: "id-teal", group: "Checks" },
],
spans: [
{
lane: "prepare", title: "Prepare inputs",
from: "2026-10-05T12:00:00Z", to: "2026-10-05T12:12:00Z",
},
{ lane: "review", title: "Review in progress", from: "2026-10-05T12:15:00Z" },
],
marks: [
{ lane: "prepare", at: "2026-10-05T12:12:00Z", glyph: "*", title: "Inputs ready" },
],
legend: "Dashed outline: open-ended. Marks: checkpoints.",
};

Timeline rendering. Lanes keep input order; adjacent groups are divided. Lane tones are id-blue, id-amber, id-teal, id-rose, id-olive, brand, neutral, or info. A span’s tone override accepts the full tone vocabulary, otherwise it inherits the lane tone, then neutral. Labels stay in readable ink. hatched adds a hatch; omitted to adds a dashed open-ended outline. These treatments can coexist. Marks expose their titles as tooltips.

A clock window uses the host’s shared 30-second clock, without new frames. until is a fixed upper bound, not a sliding window: running spans extend to the lesser of now and until, and the now rule disappears outside the window. Future-starting open spans wait until their start. Fixed windows have no now rule, and open spans extend to to.

Ticks, window captions, item descriptions and tooltips, and narrow-list times render in the viewer’s local timezone. Short-window ticks include dates when the window crosses local midnight. Repeated local tick labels include UTC offsets to distinguish instants across a fall-back DST change. Caption and item timestamps keep full local calendar dates and millisecond precision. The caption names the zone once, using Intl.DateTimeFormat with timeZoneName: "short" at the window’s start, including windows spanning a day or a DST change. Inputs remain offset-aware ISO instants; tick and item placement stays instant-based across DST changes.

Data outside the window is valid: the plot clips intersecting spans and omits fully outside spans and marks. Zero-duration spans get a small visible width. Overlapping spans draw in source order and may overpaint. Below 720px of section width, the plot becomes lane lists containing every span and mark, including items outside the window, with times and explicit open-ended/hatched status. Publish a bounded slice when the full history exceeds the caps. Older hosts reject the timeline section kind; check support before adoption.

A card field is { label?, value, tone?, href?, copyable?, copyAction? }, or { label?, people: [{ name, tone?, face?, status?, lead?, hint? }] }, which renders identity-toned names in the value slot (a dot per name, the name itself in ink), or { label?, tone?, clock }; a field carries exactly one of value / people / clock. A person with face (one or two characters) renders as an avatar in its tone with the name beside it: status busy pulses the face, waiting badges it, idle fades it, and open draws an empty seat with the name kept for assistive tech; lead rings it and hint is its hover text. A clock is { at, mode }, where at is an ISO 8601 timestamp with an offset and mode is "since" or "until": the host renders “4 min ago” or “53 min left” and re-ticks it locally every 30 seconds, so a producer needn’t republish just to keep a time current. A card may also carry a reason: { label?, text } annotation line.

A card or row bar is { value, total, label?, trailing? } for a plain fill (value: null hatches as unmeasured) or { segments, label?, trailing? } for a stage composition at item scale. label names what the meter measures and trailing carries its reading (“Turn budget used”, “18 of 80”); the host draws both with the track and makes them the meter’s accessible name and value text, so the reading doesn’t need a separate field.

Every section primitive accepts a tone, a generic visual category the renderer maps to a color, never a domain enum. The semantic core is the first four; the rest extend the ramp for grade scales, decorative lane glyphs, and hash-keyed category hues.

ToneUse
ok / warn / error / neutralThe semantic core: status.
info / cautionExtend a multi-step scale (an A–E grade chip).
brand / accentDecorative identity, distinct neighboring hues.
id-blue / id-amber / id-teal / id-rose / id-oliveReserved identity hues. Assign one per repeatedly-rendered actor at creation and persist it, never hash per render, and never seat an actor in a status hue. Always render the actor’s name alongside the colour; a sixth actor folds to neutral plus a name rather than minting a hue.

An actions section gives a board buttons. Clicking one dispatches a rib-defined verb to the rib that owns the board’s key, resolved from the key’s namespace, and reaches the rib’s onAction.

On success the host toasts <type> ✓, or the rib’s own wording when onAction returns { ok: true, data: { message } } with a non-blank string (trimmed, cut to 200 chars). While the dispatch is in flight the button is disabled and aria-busy, and shows pendingLabel when the item sets one.

ActionItem = {
type, // a rib-defined verb the base never enumerates
label,
subtitle?, // a second muted line under the label, rendered only by the tabs layout
glyph?, tone?,
destructive?, // marks the action dangerous: danger styling and a confirm
inline?, // surface a destructive card action as a visible button, not the overflow menu
confirm?: Confirm, // confirm before dispatch, destructive or not; simple or typed dialog wording
hint?, // descriptive hover tooltip (WHAT it does), any state; joins with reason when disabled
disabled?, reason?, // gate the button (dim + seal); reason is the tooltip WHY, only with disabled: true
selected?, // a toggle's ON state (brand ring + aria-pressed). Absent ≠ false: absent is a plain verb action with no pressed semantics, false is an off toggle. Only on a click-dispatching action, and each exclusion for its own reason: no fields (an expanded action draws no trigger, a solo picker's opens a popover), not destructive (a one-shot verb, and non-inline on a card it becomes a menu item), not inside a tabs strip (which owns its own active state), never a headAction (always a menu item)
expanded?, // render fields as an always-open hero form (inert without fields)
defaultOpen?, // in a tabs strip, open the first enabled, field-bearing item marked true on first render, except a solo model-picker, which opens as a popover rather than a form and never seeds; the operator's toggles win after (inert elsewhere and without fields)
submitLabel?, // the fields form's submit button text, defaulting to label (inert without fields)
submitTone?, // tone for the fields form's submit button only, defaulting to tone, for a neutral tab whose submit is the board's primary verb (inert without fields)
pendingLabel?, // button text while the dispatch is in flight ("Sending…"); absent keeps the label with a busy mark
payload?, // opaque rib-defined context, dispatched verbatim
fields?: ActionField[] // input collected before dispatch
}

An ActionField is { name, label, placeholder?, required?, multiline?, half?, segmented?, options?, modelPicker?, defaultValue?, showWhen? }. When a button declares fields, clicking it opens a small form, and the collected { name: value } map is merged into the dispatched payload. Field names must be unique within an action. options (an array of { value, label, hint? }) renders a select instead of a text input: the dispatched value is the chosen option’s value, a non-required select offers placeholder as an empty “none” option, and the option values must be unique. It is mutually exclusive with multiline. defaultValue pre-fills the control; for a select it must name an option value or "". half renders the field at half width so adjacent half fields share a two-track row (a lone half field keeps its own row). segmented renders options as a single-select segment strip instead of a select (a non-required field leads with a clear segment labelled by placeholder, dispatching "" like the select’s empty option) and is rejected without options. An option’s hint is its hover text on the segment or the select option.

showWhen ({ field, equals? }) renders the field only once the named sibling field has a non-empty value, or exactly equals when set (a workflow picker that appears after a project is chosen). A hidden field, and a hidden model picker’s providerField companion, is left out of the dispatched payload (a same-named static payload default too, though never a binding key) and skips required; a field whose controller is hidden is hidden too. The schema rejects a showWhen.field that names the field itself or a field the action doesn’t have.

modelPicker ({ providerField?, providerDefault? }) renders the host’s live provider/model catalog as a searchable picker instead of a producer-supplied choice set, so a board never hardcodes a model list. The dispatched value is the chosen model id; providerField names a companion payload key that carries the chosen model’s provider id, and providerDefault seeds that key so an untouched submit re-affirms the current provider/model pair rather than clearing it. A non-required picker offers placeholder as its clear row (dispatching "" for both keys), and defaultValue may be any model id (on- or off-catalog), so a hand-pinned model stays visible. It is mutually exclusive with multiline and options. providerField must not collide with the field’s own name, any sibling field’s name, or another modelPicker field’s providerField: every one of those lands in the same dispatched payload map, so a collision would silently overwrite a value; the schema rejects it at publish. When the picker is the action’s only field and the action is not expanded, the host skips the intermediate form: the action button opens the catalog directly and picking dispatches immediately (dismissing the popover is the cancel). An expanded action always renders its always-open form and requires submit, even with a single picker field.

inline surfaces a destructive card action as a visible, still-confirm-guarded button on the card instead of hiding it in the card’s overflow (⋯) menu. It is inert on a non-destructive action, which always renders inline.

confirm is confirmation-dialog metadata, { irreversible?, subject?, title?, body?, label?, confirmLabel?, cancelLabel? }. An action that sets confirm or destructive asks before it dispatches; with fields the dialog opens after the form submits and dispatches the collected values on confirm. An action with neither dispatches immediately. destructive alone decides the styling: a destructive action’s confirm button is danger red, a non-destructive one’s is the normal brand style, so a weighty but safe verb (apply a reviewed plan) can ask “Apply 4 changes?” without reading as a deletion or moving into a card’s overflow menu. An irreversible confirm requires a subject the operator types before confirm enables.

disabled renders the button non-interactive, dimmed and its form sealed, when a precondition the state cannot satisfy fails (a capability-gated tab whose current cast cannot run it). reason is the human explanation of why, surfaced as a tooltip, and is valid only alongside disabled: true. A producer that recomputes preconditions re-emits both on each recompose, so a state change flips the gate.

hint is a short descriptive tooltip (what the action does), surfaced on hover whether the action is enabled or disabled, so a producer can remind the operator what an unfamiliar button is for. Unlike reason it needs no disabled; on a disabled action the host shows both, the hint then the reason.

subtitle is a second muted line under the label, rendered only by the tabs layout, where a mode picker’s one-line description should read inline rather than hide in a hover tooltip. Stacked buttons and wrap chips ignore it, and hint still carries the hover explanation.

expanded renders fields as an always-open form whose submit button carries the action’s label, glyph, and tone, for a hero action whose input is the affordance. It is inert without fields.

submitLabel overrides the text on a fields form’s submit button, which otherwise carries the action’s label, so a tab named for its mode (“Debate”) can submit with a verb (“Convene”). It applies to every action form and is inert without fields.

A rib’s contributeWorkflows can bind a workflow’s structured output to one of its snapshot keys with bindSnapshotKey. When that workflow runs, its output is run through the contribution’s validate (the producer-side fail-closed gate) and republished to the key, so a board refresh is a workflow run. A surface region names that workflow in its workflow field, and cadenceMs (floored at 30s) re-runs it at that cadence while the key has a live subscriber and at six times that cadence while idle. serverRefresh: false opts the region out of the server heartbeat; its client cadence still applies while the surface is open.