Build v3 Widget IR pages with composition, actions, keyboard shortcuts, scheduling, time, and CRM helpers.
widget.dsl lets a JavaScript host describe a page as serializable Widget IR while the React application owns rendering and interaction behavior. This tutorial uses the same builder-lambda style as the checked golden examples, so each snippet can be moved into a jsverb route after the host selects the widget.dsl runtime module.
A v3 page is the composition boundary: widget.page creates the page wrapper, a section builder supplies labels and view slots, and domain helpers create the leaf Widget IR. Prefer a typed helper whenever one exists because it fixes the component contract in one place; use widget.raw.component only for a component without a typed v3 helper.
const widget = require("widget.dsl")
const page = widget.page("Workshop overview", (p) =>
p.id("workshop-overview")
.section("Welcome", (s) =>
s.caption("A server-produced page rendered by React.").view(
widget.ui.card({ title: "AI engineering workshop" },
widget.ui.caption("Two days, hands-on."),
widget.ui.button("Request a workshop", widget.act.navigate("/pages/lead"), {
variant: "primary",
}),
),
),
),
)
A page builder returns a handle. Route handlers return page.toPage() when they need a plain page object, while page-aware xgoja helpers may accept the handle directly.
Page shortcuts are serializable command accelerators for workflows that are not naturally table-shaped. Each binding gives the command a stable id, a logical key, a visible help label, and an ordinary action. Reuse the same action object in the shortcut and visible button so keyboard and pointer activation cannot drift.
const accept = widget.act.server("triage.accept")
const reject = widget.act.server("triage.reject")
const skip = widget.act.server("triage.skip")
const page = widget.page("Triage", (page) =>
page
.shortcuts((keys) =>
keys
.bind("accept", "y", accept, { label: "Yes" })
.bind("reject", "n", reject, { label: "No" })
.bind("skip", "s", skip, { label: "Skip" }),
)
.section("Current job", (section) =>
section.view(widget.ui.inline(
widget.ui.button("Yes", accept, { variant: "success" }),
widget.ui.button("No", reject, { variant: "danger" }),
widget.ui.button("Skip", skip),
)),
),
)
Use separate modifier values rather than embedding them in the key string:
page.shortcuts((keys) =>
keys.bind("save", "s", widget.act.server("record.save"), {
label: "Save",
modifiers: ["Control"],
}),
)
The default React app displays generated shortcut help and a persisted enable/disable control. It suppresses page commands in editable fields, dialogs, IME composition, repeated keydown events, and nested keyboard scopes. Keep table row commands on table.command(...); page shortcuts should not replace component-owned keyboard interaction.
The executable version is pkg/widgetdsl/testdata/v3/examples/43-page-shortcuts.js, with stable output checked by its golden JSON fixture.
Bindings defer a value lookup until the user interacts with a rendered widget. This keeps host data serializable and prevents a page from guessing which grid cell or board card the user will select.
const poll = {
title: "Office hours",
options: [{ id: "mon-9", label: "Monday 09:00" }],
responses: [{ id: "ana", name: "Ana", availability: { "mon-9": "available" } }],
}
const pollView = widget.schedule.availabilityPoll(poll, (b) =>
b.editableRow("ana").onToggle(
widget.schedule.intent.toggleAvailability(
widget.bind.context("row.id"),
widget.bind.context("column.id"),
),
),
)
MatrixGrid provides row, a serializable column, rowKey, colId, and value to the action context. schedule.intent.toggleAvailability emits an event action, so the browser listener receives the resolved identifiers in event.detail. A server action instead sends resolved values to /api/widget/actions/{name} in payload.
Scheduling helpers lower poll-shaped data to MatrixGrid, and time helpers lower calendar data to MonthGrid or TimeGrid. The helpers keep the page script focused on domain data and interaction intent instead of renderer-specific cell specifications.
const week = widget.time.week([
{
id: "kickoff",
title: "Workshop kickoff",
startISO: "2026-07-14T09:00:00Z",
endISO: "2026-07-14T10:00:00Z",
styleKey: "event",
},
], (b) =>
b.range(widget.time.range.week("2026-07-14"))
.hours(8, 18)
.onSelect(widget.time.intent.selectEvent(widget.bind.context("block.id"))),
)
Use widget.schedule.availabilityPoll, pollSummary, and bookingPicker for table-like availability data. Use widget.time.month and widget.time.week for dates and timed blocks; TimeGrid deliberately does not model all-day blocks.
CRM builders are opaque only while defining a field schema or pipeline. Deals, activities, task rows, and summaries remain plain JavaScript records, which lets a SQLite-backed jsverb load and return them without a separate client model.
const fields = widget.crm.fields("Workshop opportunity", (f) =>
f.text("organization", { label: "Organization", group: "Customer" })
.email("buyerEmail", { label: "Buyer email", group: "Customer" })
.currency("amount", { label: "Expected value", group: "Commercial", unit: "USD" }),
)
const pipeline = widget.crm.pipeline("Workshop sales", (p) =>
p.stage("lead", "New lead", { colorKey: "lead" })
.stage("proposal", "Proposal", { colorKey: "proposal" })
.stage("won", "Won / scheduled", { colorKey: "won" }),
)
const pipelineView = widget.crm.pipelineBoard(pipeline, deals, (b) =>
b.summaries(stageSummaries)
.onMove(widget.crm.intent.moveDeal("${cardId}", "${to}"))
.onOpen(widget.crm.intent.openDeal("${cardId}")),
)
const recordView = widget.crm.recordFields(deals[0].fields, fields, (b) =>
b.mode("edit")
.onChange(widget.crm.intent.updateField("deal-acme", "${key}", "${value}")),
)
BoardEngine supplies cardId for selection and cardId, from, to, and beforeId for moves. CRM intent placeholders become typed action payload bindings; they are not ordinary string interpolation. widget.crm.funnel(pipeline, stageSummaries) also accepts sparse summaries and displays missing stages as zero.
An xgoja host selects the provider module at build time and returns Widget IR from an API route. The SPA asset handler must exclude /api so it does not return index.html in place of page JSON.
runtime:
modules:
- provider: rag-widget-site
name: widget.dsl
as: widget.dsl
app.get("/api/widget/pages/pipeline", (_req, res) => {
res.json(widget.page("Pipeline", (p) =>
p.section("Opportunities", (s) => s.view(pipelineView)),
).toPage())
})
The workshop CRM vertical slice in examples/xgoja/workshop-crm-site/ demonstrates lead creation, pipeline movement, availability selection, and workshop-run scheduling with SQLite persistence.
Most authoring failures occur at the boundary between build-time module selection, server action contracts, and the SPA fallback. Match the symptom below to its boundary before changing the Widget IR itself.
| Problem | Cause | Solution |
|---|---|---|
Cannot find module "widget.dsl" | The build specification does not select the provider runtime module. | Add the runtime.modules entry shown above and rebuild the xgoja binary. |
| A grid action receives an accessor object rather than an id. | An accessor was placed in ordinary application data rather than an action payload or detail. | Use widget.bind.context(...) only inside an action contract. |
| An event listener gets no action values. | The listener reads payload, but browser event actions dispatch values in CustomEvent.detail. | Read event.detail; reserve payload for server actions. |
page.shortcuts is not a function | The generated host uses an older widget.dsl provider. | Rebuild the xgoja host against the updated rag-widget-site provider. |
| A shortcut does not run while focus is in a table or form. | Component keyboard scopes and editable controls intentionally take precedence. | Use table.command(...) for row actions and keep page shortcuts outside editing contexts. |
| A single-character shortcut is active but no disable control is visible. | A custom host installed behavior without the default help/preference UI. | Use RagEvaluationSiteApp or provide equivalent usePageShortcuts help and preference integration. |
| A pipeline stage is empty or has an invalid width. | The host did not provide a summary for that stage. | Pass sparse summaries safely; widget.crm.funnel defaults missing counts to zero. |
The browser receives index.html from an API route. | The SPA fallback catches /api. | Configure spaFromAssetsModule with excludePrefixes: ["/api"]. |
These related entries separate host configuration, API discovery, and executable examples so a reader can move from a first page to a complete application without relying on ticket-local notes.
widget-dsl-getting-started — select the provider and write a first page.widget-dsl-v3-api-reference — descriptor-derived namespace inventory.widget-dsl-js-api-reference — action contracts and legacy migration details.widget-dsl-spa-bundling — bundle provider help and browser assets.pkg/widgetdsl/testdata/v3/examples — executable golden examples, including page shortcuts, scheduling, time, and CRM.examples/xgoja/workshop-crm-site — SQLite-backed CRM host.