The settings page.

An app may bring its own page for its settings, shown in the portal instead of the standard form: a preview of the screen, a slider, a camera layout. The settings are still the manifest's; the page proposes values and nothing is saved until the operator presses Update.

Working — on paid plans

Where it goes

your repository
my-app/
  app/
    manifest.json        the settings, as always
  gui/                   beside app/, never sent to a device
    gui.json             optional: {"settings": {"title": "Welcome screen", "height": 520}}
    icon.svg | icon.png  optional: the app's icon
    settings/
      index.html         the settings page
      settings.css, settings.js, img/…
  • Files: HTML, CSS, JS, JSON, images (svg, png, jpg, gif, webp, avif, ico) and fonts (woff, woff2, ttf, otf); at most 200 files and 5 MB. Anything else, or a symbolic link, and the portal shows the standard form with the reason under it. It never stops the app's deploy.
  • Push as usual. A commit that changes only gui/ does not update any device.
  • It shows in Setup › App settings (scope project) and on each device's Setup tab for the perDevice settings (scope device). The standard form is always one click away.
  • Every plan except Free. On Free the portal shows the standard form and says the app has a page of its own.
  • height is a first guess; the frame then follows the page (120–2000 px).

What the page can and cannot do

It runs in a sandboxed frame on a separate site, inside the portal only:

  • No network. fetch, XHR, WebSocket, outside images, fonts and scripts are refused. Bundle everything in gui/; the page cannot reach the app's files either, so copy the pictures it needs.
  • No localStorage, sessionStorage or cookies: they throw. Wrap any use in try.
  • No alert, confirm, prompt, popups, target="_blank", form submission or downloads. Draw your own messages.
  • One page. Navigating anywhere, even to another file of your own, closes it. Use sections or tabs in one document.
  • Opened in a tab of its own, it is refused.

The smallest working page

gui/settings/index.html
<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="color-scheme" content="light dark">
  <style>
    html, body { background: transparent; color: var(--arda-fg); font-family: var(--arda-font); }
    [hidden] { display: none !important; }
  </style>
  <script src="/_arda/gui.js"></script>
</head>
<body>
  <label>Shop name <input id="name"></label>
  <p id="note"></p>
  <script src="settings.js"></script>
</body>
</html>
gui/settings/settings.js
var name = document.getElementById('name');
var note = document.getElementById('note');

function draw() {
  // never overwrite a field the operator is typing in
  if (document.activeElement !== name) name.value = ArdaGUI.values.shop_name || '';
}
ArdaGUI.ready(draw);               // once the portal has said what is being edited
ArdaGUI.on('values', draw);        // after Update, or a change in the standard form

name.onchange = function () {      // propose on change, not on every keystroke
  ArdaGUI.set('shop_name', name.value.trim()).then(function (r) {
    note.textContent = r.ok
      ? 'Changed — press Update to save'
      : Object.keys(r.errors).map(function (k) { return r.errors[k]; }).join(' · ');
  });
};

ArdaGUI

ArdaGUI.ready(fn)fn(ctx) once the portal has sent what is being edited (at once if it has)
ArdaGUI.definitionsthe settings, in the manifest's words: {key, caption, help, type, perDevice, unit, options: [{value, caption}], min, max, item, fields}; a select drawn from the device's own list also carries device ("printers" or "screens"), with that device's list in options on the device scope and an empty one on the project
ArdaGUI.valuestyped: strings for text, memo, color and select; a number; a boolean; rows as an array of objects each carrying _row; null when not set
ArdaGUI.secretsper row, whether each secret field holds a value: {cameras: [{password: true}, {password: false}]}
ArdaGUI.set(key, value), set({k: v, …})a proposal; resolves {ok, errors}. A refused value is taken back out of values.
ArdaGUI.scope'project' or 'device' (only the perDevice settings arrive)
ArdaGUI.following, ArdaGUI.inherited, ArdaGUI.follow(key)device scope: which settings follow the project, the project's values, and going back to the project's value
ArdaGUI.on('values' | 'view' | 'theme', fn)values changed; the larger view opened or closed; the theme changed
ArdaGUI.expand({title}), collapse(), expandedthe larger view (the portal's panel caption already has a button for it)
ArdaGUI.resize(h), autoResize(false)the frame's height, which otherwise follows the page
  • Look like the portal: gui.js sets <html data-theme="dark|light">, the colour scheme and --arda-bg --arda-fg --arda-muted --arda-border --arda-accent --arda-font. Keep the background transparent; the portal draws the panel. No title of your own.
  • Two widths: the panel is about 500 px, the larger view about 1300. One column by default, with a @media (min-width: 980px) layout for the larger view.
  • Height follows <body>: do not give body height: 100%, or it can never shrink.
  • The hidden attribute loses to a class that sets display; the [hidden] rule above fixes that.
  • On the device scope, hide controls for settings you were not given: ArdaGUI.definitions.some(function (d) { return d.key === key; }). A proposal for a key the manifest does not declare is refused.
  • A top-level number has no range in the manifest; hold the range in your controls and clamp before you propose.

Passwords and secret fields

  • A rows field marked secret arrives as null; ArdaGUI.secrets[key][i][field] says whether that row has one. Send rows back with their _row and the secret left null to keep it; a new row has no _row and brings its own. The page may replace a secret, never read it.
  • A plain text setting holding a password is not secret: the page receives it as the standard form does. Mask the input, and never echo it in a preview.

Without the helper

gui/settings/settings.js
var port = null;
window.addEventListener('message', function (e) {
  if (port || !e.data || e.data.arda !== 'gui' || e.data.type !== 'hello' || !e.ports[0]) return;
  port = e.ports[0];
  port.onmessage = function (m) { /* m.data.type: init · values · result · view · theme */ };
  port.postMessage({ type: 'ready', v: 1 });
});
// then: port.postMessage({type: 'set', id: 1, values: {shop_name: 'High Street'}})
  • Portal → page: init {v, page, scope, project, device, definitions, values, secrets, following, inherited, readOnly, theme}, values, result {id, ok, errors}, view {expanded}, theme.
  • Page → portal: ready {v: 1}, set {id, values}, follow {id, keys}, resize {height}, expand {title?}, collapse.
  • The portal says hello with a fresh port until the page answers ready, for 5 s. Messages up to 256 KB, about 50 a second. A page without gui.js should set color-scheme itself.

On your desk

shell
$ ardaplayer -dev .
level=INFO msg="settings page: http://127.0.0.1:41729/__arda/gui/"

When gui/settings/index.html exists, developer mode (v1.1.13 and later) prints its address. The harness hosts the page the way the portal does — the same sandbox, headers, protocol and checks — and Apply puts the accepted values into the running app's Arda.config and fires 'config', in memory only; arda.dev.json is never rewritten. The harness shows the values it sends, so try the page on a copy with fake passwords.

Checked: ArdaGUI's members are from the helper's source (internal/devserve/gui.js, a byte copy of the portal's); the walls and the protocol are from the player's manual and the harness code. The calls are shaped like those of a settings page in use in the portal today. The page itself was not opened in the portal for this manual.