:::note Generated reference Generated from honeyframeapi 0.1.8 — do not edit by hand. :::
honeyframeapi.webapp
Webapp handles — publish a dashboard as a shareable webapp, then mint public share links from code.
Webapps are the last mile of the vibe-code loop: you've authored dbt models,
built a Dashboard, and now want to hand a
stakeholder a URL they can open without an account. A webapp is a published
asset (asset_type='webapp') whose config lays out pages, nav, filters
and card_ref blocks that point at dashboard cards.
proj = client.get_project("sales")
dash = proj.create_dashboard("Ops Overview")
dash.add_card("Visits", "kpi", "SELECT COUNT(*) AS n FROM marts.fact_orders")
app = client.publish_webapp(
"ops-overview", "Ops Overview",
config={"pages": [{"key": "home", "title": "Home",
"blocks": [{"kind": "card_ref",
"dashboard_id": dash.dashboard_id,
"card_id": dash.cards[0].card_id}]}]},
)
link = app.create_public_link(label="exec share", expires_days=30)
print(link["token"]) # shown ONCE — store it
print(link["share_url"]) # /public/apps/ops-overview?token=… (SaaS host)
Public links are token-gated and serve a PII-masked view (the same masking the
authed /public/apps route applies). The raw token is returned exactly once
by Webapp.create_public_link; later Webapp.public_links calls
return metadata only.
class Webapp
A published webapp, keyed by asset_key (e.g. 'sales360').
Webapp.reload()
reload() -> Webapp
Fetch the webapp (incl. full config) and cache it.
Webapp.title
property
title: Optional[str]
Webapp.is_public
property
is_public: bool
Webapp.config
property
config: dict
The page/nav/filter layout (call reload first if stale).
Webapp.project_id
property
project_id: Optional[int]
Webapp.execute_block()
execute_block(page_key: str, block_idx: int, params: Optional[dict] = None) -> dict
Run one page block's server-side SQL; returns {columns, rows, row_count, execution_ms, truncated, error}.
Addresses the block positionally — block_idx is its index in
config["pages"][…]["card_refs"] — and runs the SQL the config
already holds (a code block's sql, an html block's
data_sql). The query is never sent from here; params only binds
the filter values its :name placeholders reference, so this is a
read-only call a read_only PAT can make.
Errors come back in-band as the error key (with empty
rows) rather than as an exception — a query that times out or
fails still returns the envelope. truncated is True when the
result hit the 5000-row cap.
app = client.get_webapp("sales360_finance")
res = app.execute_block("cashflow", 2, {"period": "YTD"})
if res["error"]:
raise RuntimeError(res["error"])
print(res["columns"], res["row_count"])
Webapp.catalog()
catalog(*, page_key: Optional[str] = None) -> dict
Asset-scoped data catalog: which marts back this app, what they mean, and which cards use them.
GET /api/webapps/{key}/catalog, gated identically to opening the app
shell — so a viewer can read it without project.edit. Scoped
twice: to the ASSET (only marts behind cards this app embeds) and to the
CALLER (only dashboards this principal can actually execute) — a viewer
sees only their own slice, and a principal who can execute none of them
gets 200 with datasets: [], never a 403. page_key narrows to
one page of the app.
Never returns SQL or rows. Every key is always present (None / []
when unknown), so a client never has to branch on key presence to tell
"no data" from "old backend":
{asset_key, asset_id, project_id, locale, truncated,
scope: {pages, dashboards_visible, dashboards_denied, cards_visible},
freshness_card, datasets: [...], sources: [...]}
Each dataset carries {name, schema, layer, materialization, title, description, description_locale, grain, period_column, refresh, status, sumber, sumber_teknis, caveats, tags, columns, columns_suppressed, used_by}. The authored extras (grain/period_column/refresh/status/
sumber/sumber_teknis/caveats) are what
Dataset.update_settings(catalog=…) writes;
description prefers the authored Bahasa prose over the dbt
manifest's English one and says which in description_locale.
truncated goes true when caps (50 dashboards / 100 datasets / 200
columns per dataset) clipped the response — coverage is then partial and
should be reported as such.
Related, on backends new enough to serve this endpoint: every
card_ref block in config (from client.get_webapp() /
reload) also gains an optional card_description key —
the card author's "Sumber data" prose. Treat it as optional; an older
backend simply omits it, as does a card with no description.
app = client.get_webapp("sales360")
cat = app.catalog(page_key="ringkasan")
for ds in cat["datasets"]:
print(ds["name"], ds["grain"], len(ds["used_by"]))
Webapp.public_links()
public_links() -> list[dict]
List this webapp's share links (metadata only — never the token).
Webapp.create_public_link()
create_public_link(*, label: Optional[str] = None, expires_days: Optional[int] = PUBLIC_LINK_DEFAULT_DAYS) -> dict
Mint a token-gated public share link.
The raw token and a relative share_url are in the returned dict
and are shown once — store them.
The link expires after expires_days (default 30; 1..365 allowed).
expires_days=0 makes a link that never expires — opt in only when
you mean it. Any other value raises ValueError (the server would
silently make it never-expiring). None still means never but is
deprecated in favour of 0.
Webapp.revoke_public_link()
revoke_public_link(link_id: int) -> None
Revoke a share link (idempotent).
Webapp.update()
update(*, config: Optional[dict] = None, title: Optional[str] = None, description: Optional[str] = None, theme: Optional[dict] = None, change_note: str = '') -> Webapp
Overwrite the stored config/metadata (PUT /api/publish/assets/{id}).
Pass the full config you want persisted. For incremental edits
prefer add_page / set_nav, which read-modify-write for you.
Returns self (reloaded). Note: editing the draft does NOT republish —
call publish to push it live.
Webapp.publish()
publish() -> Webapp
Publish — makes it live at /apps/<key>.
The webapp viewer only serves published assets; a freshly-created webapp is a draft (visible only in the builder) until you publish it.
Webapp.unpublish()
unpublish() -> Webapp
Take the webapp offline (back to draft).
Webapp.add_page()
add_page(page: dict, *, nav: Optional[dict] = None, publish: bool = False) -> Webapp
Add (or replace, by key) a page in the config and save.
page comes from page (with blocks from card_ref /
html_block / code_card). Pass nav (from
nav_item) to also surface it in the sidebar, and publish=True
to push live in the same call.
Webapp.set_nav()
set_nav(nav: list) -> Webapp
Replace the whole sidebar nav (list of nav_item dicts).
Webapp.remove_page()
remove_page(key: str) -> Webapp
Drop a page (and its matching nav entry) by key and save.
Webapp.set_filters()
set_filters(filters: dict) -> Webapp
Replace the webapp-level filter slots (from filters).
Webapp.set_chrome()
set_chrome(chrome: dict) -> Webapp
Replace the app-level header/rail chrome flags (from chrome).
app.set_chrome(chrome(show_sidebar_search=True, show_clock=True))
Webapp.set_theme()
set_theme(theme: dict) -> Webapp
Replace the webapp theme tokens (primaryColor, sidebar*, …).
Webapp.update_theme()
update_theme(patch: dict) -> Webapp
Apply a partial theme update, validated server-side against the
theme registry (unknown keys dropped, values coerced/clamped). Unlike
set_theme (full replace) this merges onto the current theme;
set a key to None to reset it. Build patch with theme.
app.update_theme(theme(font_family="Poppins", font_scale=1.1))
Webapp.ai_theme()
ai_theme(prompt: str, *, model: Optional[str] = None) -> dict
Vibe-code the theme in natural language — the LLM proposes a theme
patch, the server validates it against the registry and applies it.
Returns {"theme": <merged>, "applied": <validated patch>, ...}.
app.ai_theme("make the app font Poppins and the text a bit bigger")
Webapp.ai_edit()
ai_edit(prompt: str, *, model: Optional[str] = None, apply: bool = False, history: Optional[list] = None) -> dict
Vibe-code the WHOLE webapp in natural language — the LLM proposes a COMPLETE new config (pages, cards, blocks, layout); the server coerces, caps page/block counts, forces every code/html SQL to a read-only SELECT, and runs the same structural validator the manual save path uses.
Returns {"config": <proposed>, "summary": [<change lines>], "prompt": …}. The /ai-edit endpoint is preview-only — it never
writes, so you can read summary (and diff config against the
current config) before committing — a config rewrite can drop a
page, so unlike ai_theme it isn't auto-applied.
Pass apply=True to persist the proposal in one call. It's saved
through the normal versioned path (the same update a manual edit
uses), so the vibe-coded change is snapshotted as a new version tagged
"AI: <prompt>" — versions lists it, diff_versions
shows what changed, and rollback undoes it. Then publish
to push live.
prop = app.ai_edit("add a revenue page with a branch bar chart")
print(prop["summary"]) # ["Added page 'Revenue' (1 card)"]
app.ai_edit("…", apply=True) # propose + versioned save
history enables a multi-turn chat: pass the prior conversation as
[{"role": "user"|"assistant", "content": str}, …] so the model can
resolve references to earlier turns ("now make it blue"). Thread it
yourself across calls — typically the user prompts plus the returned
summary lines as the assistant turns:
hist = []
r = app.ai_edit("build a 2-page sales app", apply=True, history=hist)
hist += [{"role": "user", "content": "build a 2-page sales app"},
{"role": "assistant", "content": " ".join(r["summary"])}]
app.ai_edit("now add a KPI row on page 1", apply=True, history=hist)
Webapp.versions()
versions() -> list
List the webapp's version history, newest first.
Every save — a manual builder edit OR an applied ai_edit — writes
a snapshot, so this is the single timeline you scrub through to compare
hand-edited and AI-edited states. Each entry is {id, version, published_by, change_note, created_at}; an applied ai_edit is tagged
"AI: <prompt>" in change_note so you can tell them apart.
Webapp.rollback()
rollback(version: int) -> Webapp
Restore a previous version (non-destructive — it's re-applied as a
NEW version, so you can go back and forth freely). Requires org-admin.
app.rollback(7) # revert to how the app looked at version 7
Webapp.diff_versions()
diff_versions(a: int, b: int) -> dict
Diff two versions — returns {left, right, diff, theme_diff} where
diff is a flat [{path, kind, left, right}] list (kind ∈
added / removed / changed). Use it to see exactly what an ai_edit
changed versus a hand-edited version. Requires org-admin.
app.diff_versions(7, 8)
Webapp.leaderboard()
leaderboard(*, board: Optional[str] = None, limit: int = 10, player_id: Optional[str] = None) -> dict
Read this webapp's leaderboard. Returns {board, top: [{rank, player_name, score}], you} — you is non-null only when player_id
is given (that player's rank + saved cross-device state).
app.leaderboard() # top 10 of the default board
app.leaderboard(board="weekly", limit=25)
Webapp.submit_score()
submit_score(player_id: str, score: float, *, player_name: Optional[str] = None, board: Optional[str] = None, state: Optional[dict] = None) -> dict
Submit a score (upsert this player's BEST + optional cross-device save
blob). Mostly for tests/automation — in a real game the sandboxed runtime
calls hf.submitScore(name, score) and the shell posts on the frame's
behalf. Returns the refreshed board.
app.submit_score("p1", 1200, player_name="AB")
Webapp.url
property
url: str
The authenticated browser URL for this webapp.
Webapp.public_url()
public_url(token: str, *, host: Optional[str] = None) -> str
Build the absolute anonymous URL for a share token.
The public route lives on the SaaS host (e.g.
https://apps.your-domain.com), not the API base_url the client
is pointed at — pass host to target it, otherwise base_url is
used as a best-effort default.
Functions
card_ref()
card_ref(dashboard_id: int, card_id: int, *, x: int = 0, y: int = 0, w: int = 12, h: int = 4, overrides: Optional[dict] = None) -> dict
A block embedding an existing dashboard card (the no-code path).
html_block()
html_block(html: str, *, x: int = 0, y: int = 0, w: int = 24, h: int = 4, mode: str = 'sanitized', title: Optional[str] = None, pass_filters: bool = False, hide_header: bool = False, public_hidden: bool = False, data_sql: Optional[str] = None, emits_filters: bool = False, allow_cdn: bool = False, persist_key: Optional[str] = None) -> dict
A freeform HTML/CSS/JS block — the code-webapp escape hatch.
mode='sandboxed' runs arbitrary JS in a walled-off iframe (opaque
origin: no API/JWT access, and a CSP blocks outbound network — so embed any
data in the markup; a self-contained canvas game works). 'sanitized'
strips <script> and is for safe rich text/banners. pass_filters
forwards the page's filter params into a sandboxed iframe's query string.
data_sql (sandboxed only) is the DATA BRIDGE: a read-only SELECT the
server runs (governed: read-only txn, timeout, row cap, PII-masked on
public) and posts INTO the iframe via postMessage — so embedded JS renders
REAL DWH data while staying network-fenced. The author's JS listens:
window.addEventListener('message', e => { if (e.data?.source === 'honeyframe' && e.data.type === 'data') draw(e.data.rows); }). Re-posted
on filter change; like a code block's sql it never leaves the server and is
stripped from the public config.
emits_filters=True (sandboxed only) is the INTERACTIVE bridge: the block
may post messages back UP to drive the page filters, so a custom viz can be
clickable. The author's JS posts parent.postMessage({source:'honeyframe', type:'set_filter', param:'branch_id', value:'JTN'}, '*') (or type: 'set_filters', params:{…}). The page accepts only sanitized scalar/array
values from this block's own frame, bound by name into card SQL.
public_hidden=True keeps this block's markup off anonymous (public /
share-link) viewers — the server blanks it before the config leaves the
backend. Use for internal notes that should only render for authed users.
Sandboxed HTML is otherwise author-trusted and public-visible.
allow_cdn=True (sandboxed only) opts the block into a FIXED, vetted
library-CDN allowlist (jsDelivr / unpkg / cdnjs / esm.sh): it widens the
block's CSP so a game or rich app can load an engine (Phaser, Three.js,
Pixi) or assets from those CDNs instead of inlining everything within the
100k-char budget. The iframe stays opaque-origin (no API/JWT access); since
inline JS is already allowed, this grants libraries, not new powers.
A sandboxed block can PERSIST state across reloads (a high score, game
progress) with NO server: hf.saveState(obj) stores it and
await hf.loadState() restores it on load — the shell keeps it in the
browser's localStorage, namespaced per webapp + block (this browser only; no
cross-device sync). Pass persist_key to pin a stable slot that survives
reordering the block.
There is a 100k-char per-block limit enforced server-side on save.
code_card()
code_card(sql: str, card_type: str, *, x: int = 0, y: int = 0, w: int = 12, h: int = 6, title: Optional[str] = None, card_config: Optional[dict] = None, display_options: Optional[dict] = None) -> dict
A governed SQL→chart block (read-only SELECT, runs server-side).
card_type is any DashboardCardChart type (table/bar/line/
pie/process_map/…). For process_map the SQL should return
from_activity, to_activity, transition_count (pre-aggregated edge list)
or a raw event log (case_id, activity, ts).
leaderboard_card()
leaderboard_card(*, board: Optional[str] = None, limit: Optional[int] = None, title: Optional[str] = None, x: int = 0, y: int = 0, w: int = 8, h: int = 6) -> dict
A no-code leaderboard block — renders this webapp's shared leaderboard
(top scores by board) with no SQL and no game JS. Pairs with the in-game
hf.submitScore / Webapp.submit_score; auto-scoped to this webapp.
page("home", "Home", [leaderboard_card(board="weekly", limit=10)])
form_card()
form_card(form_key: str, embed_token: str, *, title: Optional[str] = None, x: int = 0, y: int = 0, w: int = 6, h: int = 8) -> dict
A data-capture FORM block — embeds an existing published form asset so
the webapp can COLLECT submissions (surveys, feedback, "submit a reading",
data corrections). No new write path: the field schema, validation (incl.
national-ID formats), submit, CSV export, and the form→dataset pipeline all reuse
the existing forms feature.
form_key + embed_token identify the source form — list yours via the
platform's GET /api/publish/assets?asset_type=form (each row carries the
asset_key and embed_token). Both the schema fetch and the submit
resolve from the embed_token against the form's public endpoints, so the
block renders identically in the authed host and an anonymous public viewer.
page("intake", "Intake", [form_card("customer-feedback", tok)])
field()
field(name: str, label: str, type: str = 'text', *, required: bool = False, options: Optional[list] = None, validation: Optional[str] = None, placeholder: Optional[str] = None, min: Optional[float] = None, max: Optional[float] = None) -> dict
One field of an inline form. type ∈ text / textarea / number /
email / date / select / toggle / checkbox. options lists choices for a
select; validation="nik_format" enforces a 16-digit Indonesian NIK;
min/max bound a number.
field("nik", "NIK", validation="nik_format", required=True)
form()
form(form_id: str, fields: list, *, title: Optional[str] = None, success_message: Optional[str] = None, x: int = 0, y: int = 0, w: int = 6, h: int = 8) -> dict
A data-capture FORM block with INLINE fields — declare the form right here
(no separate form asset). Submissions persist to the webapp-scoped
endpoint (validated against fields, rate-limited, write-only public) and
the owner reads them via
GET /api/webapps/{key}/forms/{form_id}/submissions. Reuses the forms
feature's validation (incl. Indonesian NIK).
form_id is a stable slug (the submit-path segment + the per-submission
discriminator). fields is a list of field.
form("feedback", [
field("name", "Name", required=True),
field("rating", "Rating", "number", min=1, max=5),
field("comment", "Comments", "textarea"),
])
banner()
banner(branches_sql: str, *, title: Optional[str] = None, severity: str = 'warning', chip_limit: int = 24, x: int = 0, y: int = 0, w: int = 24, h: int = 1, locale: str = 'id') -> dict
A full-width attention banner listing entities flagged by
branches_sql — one row = one chip. Columns are name-matched:
label/name (chip text), optional detail/value
(highlighted suffix, e.g. "Margin 4%").
title defaults to the locale's wording: "id" (the 0.1.x
default, kept so existing apps render unchanged) gives "Perlu perhatian",
"en" gives "Needs attention". An explicit title always wins.
Executes via the SAME governed per-block execute path a code_card
uses — read-only SELECT, run server-side, never sent to the browser (and
stripped from a public webapp's config payload). Renders NOTHING when the
query returns zero rows or fails — never a title with no chips. There is
no static/authored fallback: a banner with no live query has nothing
honest to show.
banner("SELECT store_name AS label, 'Margin '||margin_pct||'%' AS detail "
"FROM marts.v_store_margin WHERE margin_pct < 5 ORDER BY margin_pct",
locale="en")
page_anchor()
page_anchor(label: str, block_index: int) -> dict
One pill in a page's in-page anchor row (page(..., anchors=[...])).
block_index is 0-based, matching the position of the block in the
blocks list passed to page — the same addressing the grid's
data-hf-card-index wrapper and the ?hfcard= metric-search deep
link already use. page raises if any anchor's block_index is
out of range for the blocks actually passed, so a stale anchor (a
block reordered or removed after the anchor was authored) is caught at
build time instead of shipping a pill that scrolls nowhere.
page("overview", "Overview", [hero, breakdown, kpis],
anchors=[page_anchor("Revenue", 0), page_anchor("Breakdown", 1)])
page()
page(key: str, title: str, blocks: list, *, subtitle: Optional[str] = None, footnote: Optional[str] = None, icon: Optional[str] = None, filters: Optional[dict] = None, header_meta: Optional[str] = None, header_alert: Optional[str] = None, header_alert_severity: Optional[str] = None, header_meta_sql: Optional[str] = None, header_alert_sql: Optional[str] = None, anchors: Optional[list] = None) -> dict
A webapp page. blocks is a list from card_ref / html_block
/ code_card. (Stored under the legacy key card_refs; kind
discriminates each block.)
Optional extras:
filters— page-level filter slots (fromdate_range_filter/branch_filter, assembled withfilters) merged over the webapp-level filters. Use when one page needs a different filter shape.header_meta/header_alert(+_severity) — the right-side header chips next to the title (freshness stamp, status pill).header_meta_sql/header_alert_sql— read-only SELECTs whose first cell overrides the static chip when non-null (run server-side).anchors— in-page pill nav (frompage_anchor), rendered between the filter bar and the block grid. Every entry'sblock_indexmust be <len(blocks)— raisesValueErrorotherwise, the same disciplinechromeuses to refuse a flag with nothing behind it.
nav_item()
nav_item(page_key: str, label: str, *, icon: str = 'circle', section: Optional[str] = None) -> dict
A sidebar nav entry pointing at a page key.
date_range_filter()
date_range_filter(*, start_param: str = 'period_start', end_param: str = 'period_end', timezone: str = 'Asia/Jakarta', presets: Optional[list] = None, label_param: Optional[str] = None, locale: str = 'id') -> dict
A preset-driven date-range filter writing start_param/end_param.
presets is a list of {key, label?, range} dicts; when omitted a
default set (month to date / quarter to date / year to date / last 30
days / previous month) is used, labelled in locale: "id" (the
0.1.x default, so existing apps render unchanged) or "en".
timezone is the IANA zone the ranges are computed in; its 0.1.x
default is "Asia/Jakarta" — pass your own ("UTC",
"Europe/London", …). The chosen range is bound into your code-block /
card SQL by the param names, e.g.
WHERE order_date BETWEEN :period_start AND :period_end.
branch_filter()
branch_filter(param: str, *, mode: str = 'multi', options: Optional[list] = None, options_sql: Optional[str] = None, empty_value: Optional[str] = None, chips: bool = False) -> dict
A branch / entity dropdown writing a single param.
mode is 'single' or 'multi'. Supply static options
([{"value", "label", "code"?}, …]) and/or an options_sql read-only
SELECT returning value, label (and optional code) for a data-driven
dropdown — a common pattern is to list only branches that actually have
data so the filter never offers an empty selection. Public webapps fall
back to the static options (options_sql is authed-only).
chips=True renders the selection as a removable chip row
("N01 — North ×") under the filter bar instead of the selected-count
badge. Multi-select only — the renderer ignores it in 'single'
mode (a single-select button already shows its one value), so asking for
it there raises instead of silently rendering no chips.
entity_filter()
entity_filter(param: str, *, mode: str = 'multi', options: Optional[list] = None, options_sql: Optional[str] = None, empty_value: Optional[str] = None, chips: bool = False) -> dict
An entity dropdown (store, region, team, customer segment, …) writing
param — the neutral name for branch_filter, with exactly the
same arguments and the same wire type (branch_select).
entity_filter("store_codes", options_sql=
"SELECT store_code AS value, store_name AS label FROM marts.dim_store")
pill_multi_filter()
pill_multi_filter(param: str, options: list, *, label: Optional[str] = None, mode: str = 'multi', all_label: Optional[str] = None, empty_value: Optional[str] = None, chips: bool = False) -> dict
An inline pill row (REGION [All][1][2]…) writing param — the
native form of the fixed-taxonomy rows pages have been hand-rolling as
sandboxed HTML blocks.
Publishes the same two binds as branch_filter: param (a list in
'multi' mode, a scalar in 'single') and the CSV companion
<param>_codes (each option's code, falling back to its value),
so card SQL can bind <col> = ANY(string_to_array(:regional_codes, ','))
without an expanding bind. With nothing selected both publish
empty_value when one is declared.
options are static and authored — there is no options_sql here: a
pill row is a fixed taxonomy (6 regions, 4 tiers), and anything that
needs a query belongs in branch_filter. Each entry is a
{"value", "label", "code"?} dict or a bare string (used as both value
and label). all_label renames the leading clear-the-group pill (the
renderer's default label is the Indonesian "Semua", i.e. "All");
chips=True adds this group's selection to the same removable chip row
branch_filter feeds.
pill_multi_filter("region", [{"value": 1, "label": "Region 1"}],
label="REGION", all_label="All", empty_value="ALL", chips=True)
pill_multi_filter("tier", ["A", "B", "C", "D"], label="TIER", all_label="All")
metric_search_filter()
metric_search_filter(*, label: Optional[str] = None, placeholder: Optional[str] = None) -> dict
The page-level metric-search row. Binds NO parameter — it does not narrow the data, it finds the CARD.
Hits come from the same card-title index the sidebar search uses and route
to the card's page with ?hfcard=<block index>. The row renders above the
parameter slots (find the card first, then narrow it). Without label /
placeholder the renderer shows its Indonesian defaults ("Cari Metrik",
"Find metric"; "Ketik nama metrik…", "Type a metric name…"); pass both to
label it in another language.
Use it on chromeless / collapsed-rail surfaces: an app that sets
chrome(show_sidebar_search=True) already carries this search in the
rail, and both at once puts two search boxes on one screen.
filters(search=metric_search_filter(label="Find metric",
placeholder="Type a metric name…"),
period=date_range_filter(locale="en"))
filters()
filters(**slots) -> dict
Assemble named filter slots into the keyed map a filters config
expects, dropping any None slot. Keys are logical names (period,
branch, region); values come from date_range_filter /
branch_filter / entity_filter / pill_multi_filter / metric_search_filter.
filters(period=date_range_filter(), branch=branch_filter("branch_id"))
theme()
theme(*, primary_color: Optional[str] = None, header_background: Optional[str] = None, logo: Optional[str] = None, card_color_theme: Optional[str] = None, sidebar_background: Optional[str] = None, sidebar_text: Optional[str] = None, sidebar_active_bg: Optional[str] = None, accent_text: Optional[str] = None, font_family: Optional[str] = None, font_scale: Optional[float] = None, font_url: Optional[str] = None, assistant_width: Optional[float] = None, assistant_position: Optional[str] = None, sidebar_width: Optional[float] = None, border_radius: Optional[str] = None, content_padding: Optional[float] = None) -> dict
Build a webapp theme dict for create_webapp(theme=...) or
Webapp.set_theme. Only the keys you pass are included.
Branding tokens map to the camelCase config keys the renderer honors
(primaryColor, sidebar*, accentText, …). Fonts:
font_family— CSS font-family for the whole app (cards inherit it). Use a web-safe font, the bundled'Inter Variable', or a family loaded viafont_url.font_scale— text size multiplier; the renderer clamps to 0.7–1.5.font_url— optional stylesheet URL (e.g. a Google Fonts link) loaded so a customfont_familycan render.
Assistant drawer:
assistant_width— drawer width in pixels (280–1200). Mobile always full-width.assistant_position—'right'(default) or'left'.
Layout density:
sidebar_width— expanded sidebar width in pixels (160–320). Default 256.border_radius— card corner rounding:'none'/'sm'/'md'/'lg'/'xl'.content_padding— main content area padding in pixels (0–48). Default 24.
app.update_theme(theme(font_family="Poppins", font_scale=1.1,
font_url="https://fonts.googleapis.com/css2?family=Poppins"))
app.update_theme(theme(assistant_width=700, sidebar_width=280, border_radius="xl"))
app.update_theme(theme(content_padding=12)) # tighter layout
chrome()
chrome(*, show_sidebar_search: Optional[bool] = None, show_clock: Optional[bool] = None, timezone: Optional[str] = None, show_alert_pill: Optional[bool] = None, alert_pill_sql: Optional[str] = None, alert_pill_text: Optional[str] = None, alert_pill_severity: Optional[str] = None, show_notifications: Optional[bool] = None, notifications_sql: Optional[str] = None, show_share_button: Optional[bool] = None, show_print_button: Optional[bool] = None, show_theme_toggle: Optional[bool] = None, show_user_avatar: Optional[bool] = None, show_report_cart: Optional[bool] = None, show_help_fab: Optional[bool] = None, show_assistant: Optional[bool] = None, assistant: Optional[dict] = None, fullscreen: Optional[bool] = None) -> dict
Build the app-level chrome dict for create_webapp(chrome=...) or
Webapp.set_chrome. Only the keys you pass are included — an absent
flag means the shell renders that affordance not at all.
Header / rail affordances (all off unless turned on):
show_sidebar_search— search box atop the left rail; filters nav items AND the app's card titles (metric hits that jump to the block).show_clock(+timezone) — header clock pill.timezoneis an IANA name (0.1.x defaultAsia/Jakarta; pass your own, e.g."UTC") and is read ONLY by this pill.show_alert_pill(+alert_pill_sql/alert_pill_text/alert_pill_severity) — a status pill such as "23 stores critical".show_notifications(+notifications_sql) — header bell + dropdown.show_share_button/show_print_button/show_theme_toggle/show_user_avatar/show_help_fab— header buttons and the "?" FAB.show_report_cart— the ONE affordance that is ON by default; passFalseto hide it.show_assistant(+assistant) — right-side assistant drawer.assistantis the wiring dict (agent_id,title,welcome_message,suggested_prompts,accent_color,icon,show_tabs,tabs).fullscreen— chromeless: hide BOTH rail and header so page content fills the viewport (also forced per-view by?fullscreen=1/?embed).
A flag whose content comes from a query is refused without that query, so
the app cannot ship an affordance with nothing behind it: an alert pill
needs alert_pill_sql (live) or alert_pill_text (static) or the shell
renders nothing; a bell without notifications_sql is a dead placeholder;
an assistant launcher without assistant never appears. The inverse is
refused too — content passed without its flag is config the shell never
reads.
There is no show_title flag: the webapp shell reads none (it exists only
in the dashboard page config, a different object), so passing it here would
be a silent no-op.
chrome(show_sidebar_search=True, show_clock=True, show_theme_toggle=True,
show_alert_pill=True,
alert_pill_sql="SELECT COUNT(*) || ' stores critical' "
"FROM marts.v_store_margin WHERE margin_pct < 5")
Constants
| Name | Value |
|---|---|
LOCALES | ('id', 'en') |
Verified against honeyframeapi 0.1.8.