Building widgets

Patterns & gotchas

Practical conventions for widgets that stay calm, correct, and responsive.

A few conventions keep widgets robust with live data and native SDK requests.

Render defensively

onData hands you whatever JSON was last pushed — it may be partial, or a metric may be unavailable on some Macs. Default everything and bail early:

window.PREEN.onData((d) => {
  d = d || {};
  if (d.available === false) return;       // source explicitly has nothing
  setText("cpu", typeof d.cpu === "number" ? Math.round(d.cpu) + "%" : "—");
});

Show a neutral placeholder (—) before the first payload, not a spinner — the widget mounts before any data arrives.

Re-render, don’t accumulate

Each onData call carries the full payload, not a diff. Treat your render function as a pure projection of the latest data; don’t append or mutate history-derived state unless you keep it yourself.

Size in vmin, reflow with the orientation classes

Size elements in vmin (the short edge) — font-size: 5vmin, not 5vw — so they stay the same physical size in portrait and landscape. vw/vh sizes balloon or collapse when the phone rotates; vmin is constant.

Layout is the part that should change on rotation, and that’s pure CSS: the runtime keeps preen-landscape / preen-portrait in sync on <html> and <body> for you.

.stats { display: flex; flex-direction: column; gap: 9vmin; }
.preen-landscape .stats { flex-direction: row; }   /* reflow, don't resize */

Never add your own resize or orientationchange listeners — the runtime handles both, and the CSS orientation media feature is unreliable inside the widget’s web view. Inside a .preen-landscape rule, vh equals vmin (height is the short edge), so either works there.

Optimistic UI for actions

Interactive widgets feel laggy if they wait for a round trip to reflect a tap. Apply the change locally on tap, then reconcile when fresh data confirms it:

function setVolume(v) {
  vol = v; render();                 // optimistic
  expectVol = v; volPending = true;  // remember what we asked for
  window.PREEN.trigger("volset", { vol: v });
}
window.PREEN.onData((d) => {
  if (volPending && Math.abs(d.vol - expectVol) <= 1) volPending = false;
  if (!volPending) vol = d.vol;      // trust the source once it agrees
  render();
});

Haptics: one tick per step, never per frame

window.PREEN.haptic is cheap, but a dial or slider should tick when the value changes, not on every pointermove. Accumulate movement and fire "selection" once per detent; use an impact ("light") for the press or release. The phone drops anything faster than it can play and anything sent while no finger is down, so a stray extra call costs nothing — but a tick per frame feels like a buzz, not a dial.

const STEP = 12;                        // degrees per detent
let acc = 0;
function onTurn(deltaDeg) {
  acc += deltaDeg;
  while (Math.abs(acc) >= STEP) {
    const s = Math.sign(acc); acc -= s * STEP;
    value += s;
    window.PREEN?.haptic("selection");  // once per detent
  }
  render();
}

On iPad (PREEN.hapticsAvailable === false) give the detent a visual cue instead; the preen-haptics class lets you do that in CSS alone. See Haptics.

Keep it self-contained

No external scripts, stylesheets, fonts, or images (the CSP blocks them). Inline everything; use system fonts or data: URIs. If a widget renders blank, an external asset request blocked by CSP is the usual cause.

Don’t poll or fetch

The native app streams the feed and runs a backup poll; your widget renders what onData gives it. The current iOS app does not use refreshMs to time these deliveries. A snapshot may arrive repeatedly, so rendering the same value again should be harmless. Local timers and animation are fine; direct network requests from the page are blocked.

For requests to the Mac, use PREEN.db for state and PREEN.trigger for approved capabilities. There is no arbitrary endpoint proxy in the widget SDK.