SDK

Theming & layout

The CSS variables, safe-area insets, layout helpers, and glow the runtime gives every widget.

The runtime injects brand colors, safe-area insets, layout helper classes, and a signature glow — so widgets look at home on the phone and adapt to light/dark without shipping their own theme.

Brand color variables

The --preen-* variables below are set on :root, resolved for the current appearance. The runtime also sets color-scheme and data-preen-scheme ("light" / "dark") on the document element.

Core tokens — the neutrals and the brand accent:

VariableLightDark
--preen-accent#C2143A#E0435C
--preen-bg#F0EEE6#241D15
--preen-ink#1A1915#ECE4D6
--preen-muted#6B6862#A79C8C
--preen-grain#CDC1AA#3A2D1F

The bird palette — the six tints in the flock, one per bird. Each is a warm, slightly muted hue tuned for cream in light mode and lifted for dark wood in dark mode (the same way the accent lifts). --preen-bird (below) resolves to whichever tint this device wears; the whole family is always available, so you can theme with the palette regardless of the device’s own tint.

VariableLightDark
--preen-green#2EA043#32783A
--preen-yellow#DAAA3A#EDC964
--preen-sunset#E26A37#F18A54
--preen-navy#1E3A7A#486CBA
--preen-crimson#C2143A#E0435C
--preen-violet#6A2D94#9C5CD6

--preen-crimson is the same hue as --preen-accent — the brand crimson, and the app-icon bird.

.value  { color: var(--preen-ink); }
.label  { color: var(--preen-muted); }
.accent { color: var(--preen-accent); }
.chip   { background: var(--preen-sunset); }

Your device’s bird color

Each paired phone is given its own bird tint — the color of its bird in the Mac’s device strip (and of its “active” check in the widget list). The runtime exposes that same color to widgets as --preen-bird, so a widget can theme itself to match the device it’s running on:

/* Match this device's bird; fall back to the brand green if unset. */
.hero { color: var(--preen-bird, var(--preen-green)); }

Notes:

  • Like the brand colors, it’s resolved for the current appearance (light/dark).
  • It’s optional — always provide a fallback (var(--preen-bird, …)). It’s absent when a widget is opened standalone, and from older Mac hubs.
  • Each device starts with a tint from its position in the paired-device strip (cycling a small palette so two phones read as distinct); you can override it per device on the Mac (right-click the phone → Bird color). Either way the widget’s --preen-bird follows whatever color that phone shows on the Mac.

The built-in Tap demo uses it: its bird, button, and pulse ring all take --preen-bird, so the on-screen bird matches the one it chirps on the Mac.

Layout regions & safe-area insets

The notch, calibration offset, and bottom bleed are pushed as variables — use them for padding so content clears the hardware. They re-resolve on rotation: in portrait the island is a top inset; in landscape it’s a side inset.

VariableMeaning
--preen-safe-topClears the notch / Dynamic Island + user calibration. ~0px in landscape (the island moves to a side).
--preen-safe-right / --preen-safe-leftMirrored side insets — the larger of the two OS side insets, applied to both, so a one-sided landscape island reads as balanced margin. ~0px in portrait.
--preen-safe-bottomAlways 0px — content bleeds to the bottom edge in both orientations.
--preen-display-bottomSymmetric chin (= top) for vertically-centered layouts.
--preen-screen-radiusThe device’s physical display corner radius (px). 0px on a square display or an older hub.

The region classes apply these for you. The first three are full-bleed layers (position: fixed; inset: 0, stacking in DOM order), so a widget can use one region — or stack several as siblings when it needs more than one coordinate space:

  • .preen-screen — the entire physical screen. Center a hero element here and it’s centered on the device, not the (top-inset) safe area. Content only: never paint backgrounds on it — nothing painted may cross the notch line.
  • .preen-safe — the safe area: clears the notch, mirrors side insets, bleeds to the bottom. Edge-anchored content lives here, and a background painted on it clips to the safe box (the body background shows through the notch strip).
  • .preen-display.preen-safe plus a symmetric bottom chin, for notch-safe flow layouts (full-width dashboards) that should read optically centered.
  • .preen-content — a full-bleed child wrapper (100% width/height, border-box).

More regions cover the strips .preen-display leaves over — the bands between the display box and the screen edges. Each is a fixed band along one edge (not a full-bleed layer), stacking with the others in DOM order:

  • .preen-unsafe-top — the strip above the display area (screen top → the top inset): the notch band. The notch / Dynamic Island may cover its middle, so use it for ambient decoration — a tint, a hairline, a corner ornament — never for content that must stay visible.
  • .preen-unsafe-bottom — the strip below the display area (the symmetric bottom chin). No hardware covers it — it’s “unsafe” only in that .preen-display reserves it — but treat it the same way, so display-region layouts keep their even framing.
  • .preen-unsafe-left / .preen-unsafe-right — the side bands (screen edge → the mirrored side inset). Effectively zero-width in portrait; in landscape the island lives in one of them, so the same decoration-only rule applies.

In portrait the top strip holds the island and the side strips collapse; in landscape the top strip collapses and a side strip holds it. Decorate all four and the widget is correct in both orientations.

Each strip’s outer corners meet the glass, so anything painted flush inside one should take the device’s own curve there: --preen-screen-radius on the two outer corners. One catch — the strips are usually shorter than the device radius, and CSS auto-scales radii down to fit the box, which pulls your curve squarer than the glass (the physically rounded corner then hides it). So don’t round the strip itself: make the strip an overflow: hidden clip and round a taller inner element, anchored to the strip’s outer edge, so its radius is never scaled:

/* A decorated top strip whose corners genuinely follow the glass. */
.notch-band { position: fixed; top: 0; left: 0; right: 0;
  height: var(--preen-safe-top); overflow: hidden; }
.notch-band::before { content: ""; position: absolute; inset: 0 0 auto 0;
  height: 100vh; background: #2a1020;
  border-radius: var(--preen-screen-radius); }

Rule of thumb: centered → preen-screen · edge-anchored → preen-safe · painted → <body> (or clipped on preen-safe).

<body class="preen-glow">     <!-- hue: base fills the screen, wash confined to the safe view -->
  <div class="preen-safe">    <!-- edge-anchored: a label hanging from the notch-safe top -->
    <div class="preen-label">Studio</div>
  </div>
  <div class="preen-screen">  <!-- hero: centered on the device -->
    <div class="preen-orb"><svg viewBox="0 0 24 24"><!-- glyph --></svg></div>
  </div>
</body>

Regions are click-transparent while their direct children stay tappable, so an upper layer never swallows taps aimed at the one below. One caveat: a full-size child of an upper layer re-blocks it, so keep overlay layers sparse.

Orientation

The runtime keeps two classes in sync on <html> and <body> — style per-orientation in pure CSS and never add your own resize / orientationchange listeners (the CSS orientation media feature is also unreliable inside the widget’s web view):

.stats { display: flex; flex-direction: column; }
.preen-landscape .stats { flex-direction: row; }

Everything above is orientation-aware for free: the inset variables re-push on rotation, and the region classes re-resolve from them. Size elements in vmin so they keep one physical size in both orientations, and use the classes only to reflow — see Patterns.

Follow the device’s curve

A frame that hugs the screen edge looks wrong with square corners on a round-cornered phone. Round it to the glass with --preen-screen-radius — the device’s real display corner radius, in px:

/* A card pinned to the screen edges, rounded to match the device. */
.frame {
  position: fixed; inset: 12px;
  /* When the frame is inset from the edge, subtract that inset so its curve
     stays concentric with the screen's — and clamp at 0 for square displays. */
  border-radius: max(0px, calc(var(--preen-screen-radius) - 12px));
}

It’s 0px on a square display or an older hub that doesn’t report a radius, so a literal fallback isn’t needed — square corners are the graceful default. Give a non-zero fallback (var(--preen-screen-radius, 44px)) only if you’re previewing the widget outside the phone and want a rounded look there too.

The glow

Add preen-glow to <body> for the signature Preen hue — a linear wash rising from the bottom into the background. Tune it with three variables:

body {
  --preen-glow-color: #C2143A;  /* the hue */
  --preen-glow-base: #000;      /* what it fades into */
  --preen-glow-intensity: 0;    /* 0 = off … 1 = full */
}

Drive --preen-glow-intensity from your data for an at-a-glance signal (e.g. the system widget ramps it with load):

window.PREEN.onData((d) => {
  const hot = Math.max(0, Math.min(1, (d.cpu - 80) * 0.05));
  document.body.style.setProperty("--preen-glow-intensity", hot.toFixed(3));
});

The style kit

A handful of themeable building blocks ship with the runtime so widgets share one visual language instead of re-styling the same idioms. Each reads the brand tokens (--preen-accent / --preen-muted) with literal fallbacks — so override --preen-accent on <body> to re-tint a whole widget at once.

ClassWhat it is
.preen-pressThe house “jelly” tap feedback — springs down on press, bounces back. Drop it on any tappable element.
.preen-labelSmall uppercase caption (section headers, device names, on/off state). Muted; add is-on to accent it.
.preen-readoutA large live number/timer — tabular digits (no jitter as it ticks) at a light weight. Set your own font-size.
.preen-faintAmbient low-contrast text (a best-time line, idle stats).
.preen-orbThe circular hero button (toggles, push-to-talk, power). Neutral dark by default; add is-on to fill it with the accent + a matching glow. Size with --preen-orb-size; nest an <svg> glyph.
<body class="preen-glow" style="--preen-accent:#7b6cff; --preen-orb-size:148px">
  <!-- a toggle: tap-springs, fills + glows when active -->
  <div class="preen-orb preen-press is-on">
    <svg viewBox="0 0 24 24"><!-- glyph --></svg>
  </div>
  <div class="preen-label is-on">Focused</div>

  <!-- a faint, ticking count-up timer -->
  <div class="preen-readout preen-faint" style="font-size:58px">12:34</div>
</body>

Toggle is-on from JS to switch state; the orb, label, and accent all follow it:

const on = !el.classList.contains("is-on");
orb.classList.toggle("is-on", on);
label.classList.toggle("is-on", on);

Display longevity (OLED)

Your widget may sit on an OLED iPhone for hours a day, mostly static — the classic burn-in scenario. The display protects itself (the whole stage drifts a few device pixels on a slow orbit, and the phone auto-sleeps after a period without interaction), but pixel drift only protects edges; what you render still decides how the panel ages. Rules of thumb:

  • Keep the background black (or near it) — dark pixels barely age, and it’s the Preen look anyway. The .preen-glow wash is fine: soft gradients have no hard edges to etch in.
  • Never paint pure #FFFFFF. Use var(--preen-ink) for your brightest text — on a black canvas it reads white, at meaningfully lower emission. OLED lifetime falls superlinearly with luminance, so every step down from white buys panel life.
  • Prefer outlines and strokes over large bright fills (a power ring, not a solid disc). The interior of a big bright shape is what pixel drift can’t protect.
  • Avoid static, maximum-brightness fine detail — thin white hairlines, sharp high-contrast borders that never move. Burn-in reads first at static edges.
  • Blue subpixels age fastest; for elements that are lit all day, warm tones (the brand palette) are kinder than saturated blues/violets.