UI
UI and changes
UI and changes
An app's index.html loads inside an iframe at /centraid/<id>/. The iframe has three things to wire up: calling the dispatcher, subscribing to the change stream, and receiving theme + knob settings from the shell.
Calling the dispatcher
The dispatcher lives at /centraid/_tool/centraid_{describe,read,write}. From inside the iframe, use absolute paths:
const res = await fetch('/centraid/_tool/centraid_read', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ app: 'hydrate', query: 'get-today', input: {}, }),});const data = await res.json();For writes:
await fetch('/centraid/_tool/centraid_write', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ app: 'hydrate', action: 'set-cups', input: { cups: 5 }, }),});Errors come back as { isError: true, error: { code, message, path? } } envelopes. See Reference → error codes.
Subscribing to the change stream
const es = new EventSource('/centraid/<id>/_changes');es.addEventListener('change', (e) => { const { tables } = JSON.parse(e.data); if (tables.includes('hydrate_daily')) refresh();});TODO(#120) — verify the event name and payload field names (
changeevent with{ tables }body) against the runtime source. Pattern is right; exact names need confirmation.
A small cache invalidator
Common pattern: keep a map of "tables a fragment depends on", invalidate on event, re-fetch:
const dependsOn = { '#counter': ['hydrate_daily'], '#recap': ['hydrate_weekly_recaps'],}; new EventSource('_changes').addEventListener('change', (e) => { const changed = new Set(JSON.parse(e.data).tables); for (const [sel, deps] of Object.entries(dependsOn)) { if (deps.some((t) => changed.has(t))) refresh(sel); }});Receiving settings {#receiving-settings}
The desktop shell pushes theme and knob values into the iframe via postMessage. Two message types to handle:
window.addEventListener('message', (e) => { const d = e.data; if (!d) return; if (d.type === 'centraid:settings') applySettings(d); else if (d.type === 'centraid:theme') applyTheme(d.theme, d.bgL);}); function applyTheme(theme, bgL) { if (theme === 'dark' || theme === 'light') document.documentElement.dataset.theme = theme; if (bgL != null) document.documentElement.style.setProperty('--bg-l', bgL + '%');} function applySettings({ dataAttrs, cssVars }) { if (dataAttrs) for (const k in dataAttrs) document.documentElement.setAttribute('data-' + k, dataAttrs[k]); if (cssVars) for (const k in cssVars) document.documentElement.style.setProperty('--' + k, cssVars[k]);}The initial values arrive server-baked — the runtime stamps <html data-theme="…" style="--bg-l:…"> before serving index.html. The postMessage listener is for in-session changes (the user flipping a knob while the iframe is mounted).
Builder preview fallback
When previewing an app in the builder (centraid-preview://…), the runtime doesn't bake — instead, settings arrive via URL hash params:
const p = new URLSearchParams((location.hash || '').slice(1));applyTheme(p.get('theme'), p.get('bgL'));The Hydrate template's index.html shows the full inline-script bridge — copy that pattern verbatim:
<script> (function () { var h = document.documentElement; // …apply URL hash settings… // …listen for centraid:settings / centraid:theme… })();</script>See packages/app-templates/hydrate/index.html for the full bridge.
CSP
Every response carries Content-Security-Policy: default-src 'self'. Your app can load scripts, styles, fonts, and images only from /centraid/<id>/. Inline scripts receive a CSP nonce stamped by the runtime — use it on any inline <script> you ship.
TODO(#120) — confirm the nonce delivery: the Hydrate template inlines a script with no visible nonce attribute, suggesting either CSP allows unsafe-inline for these paths, or the runtime injects the nonce server-side. Worth confirming so authors know how to ship inline JS safely.
Knobs flow
When a user changes a knob in the desktop's per-app settings popover:
- Desktop writes the new value to its local settings store.
- Desktop posts
centraid:settingsto the iframe withdataAttrs/cssVarsderived from the knob value. - Your
applySettingshandler updates the DOM. - Your CSS reads from the updated data attributes / CSS vars.
The knob value is the user-visible concept; what arrives in the iframe is the rendered set of attributes and CSS vars. Map knob keys to data attrs / vars in the shell, not in the iframe.
Where to go next
- App anatomy — where the iframe loads from.
- Change stream — the source of truth for invalidations.
- app.json → knobs — declaring user-facing settings.