Skip to content

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 read

A 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:

hcl
# 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.

hcl
# 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
}
KeyDescription
sqlThe read-only query. Required.
rolesRoles allowed to call it. Omit → admin only.
sourceRun 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).

hcl
# 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
}
hcl
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) or query (read-only SQL: first column = value, optional second column = label; source picks a non-primary database for it).
  • default must be one of the options. When a request supplies no value, the default applies, then the first option.
  • kind is single (the default and, today, the only supported value — multi is a load error).
  • type = "window" is sugar for the common time-window selector: an int variable with 7/30/90 options, default 30, 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:

hcl
# 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.

hcl
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, default gray), 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, plus warn_at / warn_color to recolor once value ≥ warn_at.
  • sparkline — inline SVG trend line. params = { field, color, width, height }; field names 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:

PropertyValue
rowThe full record object.
paramsThe 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.
valueThe 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).
js
// 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:

js
// 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.

hcl
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"  }
    }
  }
}

Released under the MIT License.