Skip to content

Surfaces

Checked against 2994ef5 on 2026-11-10 by Capy (review pass: Subagent 124 and fable). The files it covers are unchanged since.

Some places an app shows up aren’t the app: a home screen widget, a Live Activity on the Lock Screen and in the Dynamic Island, an ongoing notification, a CarPlay screen. They take only native views, lay text out themselves, and often run in another process that runs no niso. niso lets a page draw them in HTML anyway: an element with a data-niso-surface attribute is a surface, and niso sends it to the platform as a template each time it changes.

<div data-niso-surface="widget.small" style="width: 170px; height: 170px">
<p>Stopwatch</p>
<time data-timer-start="1762700000000">0:00</time>
</div>
Name Apple platforms Android
widget.small, widget.medium Home screen widget of that size class Glance home screen widget
activity Live Activity on the Lock Screen, and the expanded Dynamic Island unless island.expanded exists Ongoing notification
island.expanded The expanded Dynamic Island
island.leading, island.trailing, island.minimal The compact and minimal Dynamic Island
carplay.list CarPlay’s list template

An activity surface that exists is a running activity: render it to start the Live Activity or notification, remove it (or display: none) to end it. When two surfaces share a name, the first in tree order wins. The hosts read the names in SurfacePublisher.swift and the Android host’s surface code; Android Auto and watch complications are planned.

A surface is laid out with the page at the size its own CSS gives it, but it’s never drawn in the page and takes no taps: the UA stylesheet makes every [data-niso-surface] position: fixed at the viewport’s top left with pointer-events: none. So it can sit anywhere in the page’s markup and use the page’s state, styles and components.

After each frame the host asks for the surfaces whose template changed (niso_render_surfaces) and hands them to the target: on Apple platforms through an App Group the widget extension reads, on Android to the Glance widget and the notification, and to CarPlay’s scene.

So a surface changes only while the app runs: the page has to be running to change a template. Once the app is gone, a widget or Live Activity keeps its last template, and only its self-updating timers and progress bars move. niso has no background refresh or widget timeline from the page, and no host takes pushed templates yet (below).

A template is the surface’s box tree reduced to what native template views take, in a small binary format (Template format):

  • Boxes with background, border, corner radius, opacity and clipping. Boxes that draw nothing are flattened away.
  • Text as strings with their font, weight, alignment and color, which the target’s own text view lays out. Templates are the one place niso doesn’t lay text out itself, because templates accept nothing else. A box that clips its overflow keeps to the lines that fit, ending the rest with an ellipsis.
  • Timers and progress bars (below).
  • Images, from <img>: the src names an image the target bundles or has stored.
  • Buttons, from <button>: a target that can act on them (Android’s ongoing notification shows them as its actions, CarPlay as list rows) reports the user’s choice with niso_dom_activate, which clicks the button in the page. The iOS widgets and Live Activity run no page, so they leave buttons out.

A widget can’t run the page’s script every second. So a <time> element with a data-timer-start (counting up from) or data-timer-end (counting down to) attribute, in milliseconds since the Unix epoch, becomes the platform’s self-updating timer: SwiftUI’s Text(date, style: .timer) on Apple platforms, a Chronometer on Android. The template doesn’t change while it runs, so the app doesn’t need to push updates.

<time data-timer-start={origin}>{clock(elapsed)}</time>

The text inside is what a browser shows, and what the surface shows when the timer is paused: a <time> without these attributes is plain text. A timer must be a box of its own (a block or a flex item); inline, its text is part of its paragraph’s.

A <progress> can run by itself too: with data-timer-start and data-timer-end it fills from empty at the start to full at the end; with data-timer-start and data-timer-period (in milliseconds) it fills every period and starts over.

CarPlay shows only templates, allowed per app category; niso’s Apple host is an audio app. A page declares its CarPlay screen as a carplay.list surface: its first text outside a button is the list’s title, and each <button> in it is a row, its first text the row’s text and its second the detail. Choosing a row clicks the button in the page and shows Now Playing, from the page’s media session. examples/radio declares its stations this way.

examples/timer is a stopwatch whose widgets, Live Activity and ongoing notification are surfaces at the bottom of its Svelte component. The small widget:

examples/timer/App.svelte
<div class="widget small" data-niso-surface="widget.small">
<div class="head"><span class="dot" class:on={running}></span><span>Stopwatch</span></div>
{#if running}
<time class="big" data-timer-start={origin}>{clock(elapsed)}</time>
{:else}
<time class="big">{clock(elapsed)}</time>
{/if}
<progress value={elapsed % 60000} max="60000" data-timer-start={running ? origin : undefined}
data-timer-period={running ? 60000 : undefined}></progress>
<p class="foot">{laps.length ? laps.length + (laps.length === 1 ? ' lap' : ' laps') : running ? 'Running' : active ? 'Paused' : 'Ready'}</p>
</div>

examples/timer/App.svelte, line 82

Add a home screen widget and Add a Live Activity go through it step by step.

niso_render_template returns the template of any element as the document lays it out, so a server can render widget content on a headless DOM and send it through the platform’s push mechanism in the same format. No host takes pushed templates yet.

Widgets decode a template the app rendered, and the two can be different builds. The format has a version: a decoder rejects a template of another version and shows nothing rather than guess. Within a version, newer cores only append fields and add kinds, and a decoder skips what it doesn’t know.