Pages, queries & custom widgets
The mental model in one line: a page is a folder with a screen.hcl, and that file's contents pick one of two flavors — a table (the folder name is a database table and cartapel renders full CRUD) or a page (panel { } blocks: a grid of panels, no JavaScript). Tables are covered in Tables & lists; this page covers panel pages plus the plumbing they share — named queries (read-only SQL), template variables, and custom field widgets. Everything lives inside the config bundle as plain files; there is no separate build step, no npm, and no core changes.
admin/
screens/
overview/ # a group folder (_group.hcl → "Overview")
_group.hcl
orders/ # flavor 1: a table (screen.hcl configures CRUD)
screen.hcl
fleet/ # flavor 2: a page of panels
screen.hcl # panel { } blocks
queries.hcl # queries its panels readA page's slug is its folder name and its group is the enclosing group folder — both folder-derived, so screen.hcl carries neither (a stray slug or group is rejected). A page folder placed directly under the config root is ungrouped.
The page is served at {base}/p/<group>/<slug> — the example above is /admin/p/overview/fleet. An ungrouped page is at {base}/p/<slug>. The sidebar links there for you; you only need this when writing a link by hand.
Pages
A page is a folder with a screen.hcl carrying panel { } blocks — the same panel schema as the dashboard, plus an optional columns grid count. There is no JavaScript anywhere in a page:
# admin/screens/overview/fleet/screen.hcl
label = "Fleet"
icon = "bot"
columns = 3
panel {
type = "stat"
label = "Running bots"
sql = "SELECT count(*) AS v FROM bots WHERE status = 'running'"
}
panel {
type = "chart"
label = "Signals per day"
chart = "bar"
sql = "SELECT date_trunc('day', ts) AS t, count(*) AS v FROM bot_signals GROUP BY 1 ORDER BY 1"
}Panel-level roles filter individual panels; the page-level roles gates the whole page. Template variables work in panel SQL exactly as they do on the dashboard.
Pages sit in the sidebar under their group's heading, groups in the order the _group.hcl order says, and within a group in the page's own order (lower first, ties by label; unset = 0, so an ordered page precedes the alphabetical rest only if you give it a negative number or give the others positive ones). That is how a group reads top-down — "is anything wrong?" before the detail.
Each panel reads from one origin — inline sql, a named query, a configured table, or a source (an HTTP endpoint, a directory or a bucket). See where a panel reads from.
Coming from a module?
Page modules were removed in 0.9.0 — a module = … in a bundle is now a load error naming its replacement. A page that embedded a configured table becomes panel { type = "table", table = "<slug>" }; one that fetched a named query becomes panel { query = "<name>" }; one that read an HTTP source becomes panel { source = "<alias>" }.
How assets are served
Show
cartapel serves any file under the config directory at /static/<path-relative-to-config>, behind the session, so a custom widget's JS and your image assets sit right next to the HCL that references them. Serving is path-confined (directory traversal and out-of-tree symlinks are rejected) and extension-allowlisted: js, mjs, ts, tsx, css, svg, png, webp, jpg, jpeg, gif, ico. Config and secret material (.hcl, .toml, .env, .ini, dotfiles) is never served.
Named queries
Show
A queries.hcl file declares read-only SQL that pages and widgets can call. It can live in any folder under the config root — put it next to the page that uses it. Every queries.hcl in the bundle merges into one flat /query/<name> namespace; a name defined twice is a loud load error.
# admin/screens/overview/ops/queries.hcl
query "ops_fleet" {
sql = "SELECT status, count(*) AS n FROM orders GROUP BY status ORDER BY n DESC"
roles = ["support"] # omit → admin only
}
query "ops_revenue" {
sql = "SELECT date_trunc('day', placed_at) AS t, coalesce(sum(total),0) AS usd FROM orders WHERE placed_at > now() - interval '14 days' GROUP BY 1 ORDER BY 1"
roles = ["support"]
}
query "warehouse_skus" {
sql = "SELECT sku, on_hand FROM inventory"
source = "warehouse" # a non-primary source alias, not the primary DB
}| Key | Description |
|---|---|
sql | The read-only query. Required. |
roles | Roles allowed to call it. Omit → admin only. |
source | Run against a non-primary Postgres source alias. |
Every named query runs in a READ ONLY transaction with an 8-second statement timeout and a 1 000-row cap. A page calls one with useQuery(api, "ops_fleet") (or this.api.get("query/ops_fleet") from a raw custom element) and gets back { rows: [...] }.
Template variables
Show
A variables.hcl declares URL-backed parameters that queries interpolate with . Like queries, they merge globally from any folder. A value never string-concatenates into SQL — it is a bound parameter (an ident-typed value, which names a column/table, is regex-validated ^[A-Za-z0-9_]+$ and inlined).
# admin/screens/overview/pulse/variables.hcl
variable "window" {
label = "Window"
type = "int" # text (default) | int | float | ident
options = ["7", "30", "90"]
default = "30"
roles = ["support"] # omit → in scope for everyone
}query "pulse_orders" {
sql = "SELECT date_trunc('day', placed_at) AS t, count(*) AS n FROM orders WHERE placed_at > now() - {{window}} * interval '1 day' GROUP BY 1 ORDER BY 1"
}The rules, all enforced at load time:
- Set exactly one of
options(a static list) orquery(read-only SQL: first column = value, optional second column = label;sourcepicks a non-primary database for it). defaultmust be one of theoptions. When a request supplies no value, the default applies, then the first option.kindissingle(the default and, today, the only supported value —multiis a load error).type = "window"is sugar for the common time-window selector: anintvariable with7/30/90options, default30, label "Window" — each part overridable by declaring it yourself.
At request time, a supplied value outside a static options list — or, for an ident variable, outside its query's value set — is a hard 400, never a silent fallback. State lives in the URL (?v_window=90), so a parameterized page is shareable by link:
# admin/screens/overview/pulse/screen.hcl — v_* params are folded in automatically
panel {
type = "chart"
label = "Orders per day"
chart = "bar"
query = "pulse_orders"
}VarBar renders one selector per in-scope variable; changing it re-runs every useQuery/useSource/useTable on the page.
Custom widgets
Show
A custom field widget is a web component you reference from a table config as widget = "custom:<name>". The component's source lives at config/widgets/<name>.js and is served from /static/config/widgets/<name>.js.
field "equity" {
widget = "custom:sparkline"
params = { field = "equity_curve", color = "blue" }
}Bundled widgets
The demo bundle ships three drop-in widgets under config/widgets/ — copy the files into your own bundle and reference them as custom:<name>:
statuspill— a colored pill from a value → tone mapping.params:field(column to read, defaults to the cell value),map({ "<value>": "<tone>" | { label, tone } }with tone ∈green|red|blue|gray|orange|violet|yellow),fallback(tone when no key matches, defaultgray),labels(optional value → label overrides). Booleans and numbers match by their string form ("true","3").minibar— a tiny horizontal magnitude bar + number.params:field,max(full scale, default 100),width(px),color,suffix, pluswarn_at/warn_colorto recolor oncevalue ≥ warn_at.sparkline— inline SVG trend line.params = { field, color, width, height };fieldnames a column holding a JSON array of numbers.
All three read the panel's CSS variables, so they track the active theme.
Authoring one
Define a custom element named sx-widget-<name>. cartapel sets properties on it; re-render whenever a property is assigned:
| Property | Value |
|---|---|
row | The full record object. |
params | The field's params map from config. |
api | { get(path), post(path, body) } — bound to the panel base path, sends the session cookie and CSRF header for you. |
value | The field's own current value. Set on every render, editable or not. |
onChange | (value) => void. Only set when the field is being edited — a plain read-only cell (list or detail display) never gets it. Its presence is how a widget tells the two contexts apart, since the same element renders in both: if (this.onChange) { … }. Call it to report a new value; cartapel doesn't save until the surrounding form does (same as every built-in field). |
// admin/config/widgets/sparkline.js
class Sparkline extends HTMLElement {
set row(v) { this._row = v; this.render() }
set params(v) { this._params = v; this.render() }
render() {
const series = this._row?.[this._params?.field] ?? []
this.innerHTML = `<svg>…</svg>` // draw the trend line
}
}
customElements.define('sx-widget-sparkline', Sparkline)An editable widget checks onChange before deciding whether to render inputs at all — a list cell and a read-only detail field get row/params/value like any other, just never a way to write back:
// admin/config/widgets/richtext.js — sketch, not the sanitized real thing
class RichText extends HTMLElement {
set value(v) { this._value = v; this.render() }
set onChange(fn) { this._onChange = fn; this.render() }
render() {
if (!this._onChange) {
this.innerHTML = this._value ?? '' // read-only: just show it
return
}
if (this.isContentEditable) return // already live-editing, don't reset the cursor
this.contentEditable = 'true'
this.innerHTML = this._value ?? ''
this.oninput = () => this._onChange(this.innerHTML)
}
}
customElements.define('sx-widget-richtext', RichText)A custom:<name> widget renders in both the list cell and the detail field. An unknown custom widget falls back to the raw value — never a crash.
field "has_subscription" {
label = "Subscribed"
widget = "custom:statuspill"
sql = "EXISTS (SELECT 1 FROM subscriptions s WHERE s.customer_id = t.id AND s.status = 'active')"
params = {
field = "has_subscription"
map = {
"true" = { label = "active", tone = "green" }
"false" = { label = "none", tone = "gray" }
}
}
}
