# An app in the new standard

A widget rarely fills the whole display in a scene: it is often a small tile, in portrait format at the edge, or a bar. The standard makes sure that it looks good in every frame, that the customer finds a suitable template on insertion, and that the settings window shows what will happen later on the display. This page leads an existing app there step by step, with the weather app as a running example.

> **Type:** guide
> **For:** Developers who convert an app of the platform to templates, formats, and the declarative form, or who build a new app this way from the start.
> **Prerequisite:** an app in the source tree that builds with `pnpm --filter @szenera-app/<id> build`, and the fields of the widget definition from [Widget, configuration, and preview](/apps-sdk/widget-and-preview).

## What the standard requires

| Building block | How to recognize it | Step |
|---|---|---|
| Templates | Every variant carries a starting size, exactly one is `primary`. | [1](#1-templates-with-size-and-primary) |
| Formats | The arrangement follows the shape and size of the frame, not a setting. | [2](#2-the-arrangement-follows-the-frame) |
| Fit report | The widget reports what is enabled but does not fit. | [3](#3-the-fit-report) |
| Surfaces, icons, building blocks | Background from the shared surfaces, icons from the shared set, lists, marquee, code, source, and empty state from the display building blocks. | [4](#4-surfaces-icons-and-building-blocks) |
| Form | Described declaratively, with fixed groups, quick fields, and texts from the schema. | [5](#5-the-form-declaratively) |
| Custom fields | Only where no field kind fits, and then as an individual field. | [6](#6-custom-fields) |
| Preview states | Rain, night, or “no data” can be toggled in the window, a widget with a source has the `empty` state, and the display never shows editor text. | [7](#7-preview-states) |
| Fit check | Every template and every edge size fits, readable and untruncated, and every state is calm on the display. | [8](#8-the-fit-check) |
| Store images | Every template in its true frame on a calm surface. | [9](#9-store-images) |
| Old configs | A seed step migrates the existing data, and the schema knows only the new fields. | [10](#10-old-configs) |
| Host contract | The bundle requires the commitment of the display software that its imports cover and goes out only after it. | [11](#11-host-contract-and-release) |

## 1. Templates with size and `primary`

The variants under `preview.variants` are the templates on insertion, the images in the store, and the “Template” tiles in the settings window, all from one source. Each carries a starting size in reference pixels of a 1920 × 1080 scene and only the part of the config that defines it. Exactly one is `primary`: a click on the app in the editor inserts it. The size is a start, not a cage, and the customer then drags the frame as they like.

From `apps/weather/src/widgets/weather/definition.ts` (abridged):

```ts
size: { min: { w: 160, h: 90 } },
preview: {
  thumbnailUrl: '/apps/weather/thumbnails/weather.png',
  variants: [
    {
      id: 'compact',
      label: 'Kompakt',
      description: 'Ort, Temperatur, Wetterlage und die Spanne des Tages auf einer kleinen Kachel.',
      size: { w: 300, h: 300 },
      config: { showHourly: false, showDaily: false },
    },
    {
      id: 'hourly',
      label: 'Mit Stunden',
      description: 'Dazu die nächsten Stunden in einer Zeile.',
      size: { w: 600, h: 300 },
      config: { showHourly: true, showDaily: false },
      primary: true,
    },
    // … daily 760 × 300, full 640 × 640, column 340 × 640, bar 1320 × 110
  ],
},
```

What works well: one template per shape that the app supports (tile, wide, tall, bar), and choosing the size so that everything it turns on fits. `size.min` is the smallest frame in which the widget still shows something useful, and every template must be at least that large. Designs that differ only in content are templates of the same widget, not second widgets.

## 2. The arrangement follows the frame

`useWidgetFormat()` from `@szenera/widget-foundation/hooks` names the frame’s shape (`band`, `wide`, `square`, `tall`), the size class (`compact` below 400 reference pixels on the short side, otherwise `regular`), and the dimensions. The app draws one arrangement per shape. Nothing is measured in the DOM: the platform knows the frame, so the arrangement is in place on the first draw. The thresholds and the calculation are covered under [Formats](/apps-sdk/widget-and-preview#formats-how-a-widget-uses-its-frame).

The weather app computes the arrangement as a pure function (`runtime/layout.ts`) and calls it from the runtime. From `apps/weather/src/widgets/weather/runtime.tsx` (abridged):

```tsx
const config = useWidgetConfig(WeatherConfigSchema);
const format = useWidgetFormat();
const fit = useFitReport();

// Stage pixels per reference pixel: the platform knows the frame's short side in both measures.
const short = Math.min(format.width, format.height);
const scale = scaleOf(format.shortSideRef > 0 && short > 0 ? short / format.shortSideRef : 1);
const layout = planWeatherLayout({
  shape: format.shape,
  width: format.width / scale.k,
  height: format.height / scale.k,
  wants: { hourly: config.showHourly, daily: config.showDaily, highLow: config.showHighLow },
});
```

Three rules that every arrangement follows:

- **All dimensions grow with the frame.** Calculation is in reference pixels, and type, spacing, and corner radius are fractions of that. That way the same template looks the same in a 4K scene as in Full HD.
- **A fixed drop-out order.** What has no room in a frame drops out in an order that the app sets once. In the weather app, place, temperature, and conditions always stay, and then days, hours, and the high and low values drop out. In the column (`tall`), the hours are only added from 1.7 times the height and therefore drop first.
- **Dragging wider never removes content.** At the same height, a wider frame never hides more than a narrower one, not even at the boundary between two shapes. A test of the arrangement records this.

Writing the arrangement as a pure function pays off: its tests run without a browser and cover every shape and every boundary.

## 3. The fit report

If the widget hides enabled content, it reports the parts using the keys of the config fields that turn them on. The editor then says beneath the preview and at the toggle why content is missing, instead of it looking like an error. The weather app passes on the list from its arrangement:

```tsx
const hiddenKey = layout.hidden.join(',');
useEffect(() => {
  fit.reportHidden(hiddenKey === '' ? [] : hiddenKey.split(','));
}, [fit, hiddenKey]);
```

The complete list is reported at every arrangement, empty as soon as everything is visible, and always, without asking where the widget runs: on the display, the message has no effect.

## 4. Surfaces, icons, and building blocks

The background comes from the shared surfaces in `@szenera/widget-foundation/widget-ui/tokens`: `light`, `dark`, `glass`, and `transparent` (`WIDGET_SURFACE_IDS`, resolved with `resolveWidgetSurface(id)`, which returns background and text color). An app may add surfaces of its own, and the weather app has `condition`, a gradient per weather condition. Custom hex colors in the form are dropped. The config field is a choice among the identifiers:

```ts
surface: z
  .enum(WEATHER_SURFACE_IDS)
  .default('condition')
  .describe(
    'Hintergrund|Wetterlage färbt die Fläche nach dem Wetter. Hell, Dunkel, Glas und Transparent bleiben gleich.',
  ),
```

Icons come from the filled set `ConditionIcon` in `@szenera/widget-foundation/widget-ui` (sun, fair, cloud, rain, snow, thunderstorm, fog, moon), not from the app’s own graphics. The design is plain: no cards in cards, no borders around tiles, the house font Inter.

No app rebuilds what several apps need. The display building blocks in `@szenera/widget-foundation/widget-ui` carry no style of their own, and they take the surface, font, and dimensions of the app:

- **`PagedList`**, as soon as a widget shows a sequence of similar entries whose number it does not determine (appointments, dishes, headlines). The list pages at a fixed interval instead of pushing the type below the minimum size.
- **`QuietState`**, whenever data is missing. On the display, the surface and at most one neutral sentence, and in the editor, the explanation with the next step.
- **`CompanionQr`**, whenever a code appears. It keeps the quiet zone, contrast, and module size, and the caption never pushes it out of its quiet zone.
- **`MarqueeBand`** for a single line in a bar that is longer than the bar. `PagedList` pages through several lines.
- **`SourceCredit`** in every widget with external data: the source as text, never switchable off, never shortened.

Purpose and details are covered under [Display building blocks](/apps-sdk/widget-and-preview#display-building-blocks). The building blocks came with version 1.2.0 of the host contract, and an app that uses them requires `^1.2.0` (step [11](#11-host-contract-and-release)).

## 5. The form, declaratively

The settings window builds its form from a description (`defineWidgetConfigUI` from `@szenera/apps-sdk/config`) that the app registers in its authoring bundle. The groups follow a fixed order so that a customer finds their way in every app: Source, Content, Appearance, Advanced (collapsed). The editor adds Template and Placement itself. From `apps/weather/src/widgets/weather/config-ui.ts` (abridged):

```ts
/** Label and help text of a field, as the schema carries them. */
const text = (field: string): ConfigUIFieldText => schemaFieldText(WeatherConfigSchema, field);

export const weatherConfigUI = defineWidgetConfigUI({
  sections: [
    { id: 'source', label: 'Quelle' },
    { id: 'content', label: 'Inhalt' },
    { id: 'appearance', label: 'Aussehen' },
    { id: 'advanced', label: 'Erweitert', collapsed: true },
  ],
  fields: {
    location: { kind: 'location', ...text('location'), section: 'source', fallback: true },
    showHourly: { kind: 'toggle', ...text('showHourly'), section: 'content' },
    surface: {
      kind: 'surface',
      ...text('surface'),
      section: 'appearance',
      options: [
        { value: 'condition', label: 'Wetterlage', swatch: SWATCHES.condition },
        { value: 'light', label: 'Hell', swatch: SWATCHES.light },
        // … dark, glass, transparent
      ],
    },
    // … showDaily, showHighLow, corners, units
  },
  quickFields: ['surface'],
});
```

- **Texts from the schema.** Label and help text appear once in `.describe('Label|Help text')` and reach the form through `schemaFieldText`. A field has the same name in the editor as in the schema that an assistant reads.
- **Quick fields.** `quickFields` names at most two compact fields that sit on the floating toolbar above the widget, the background in the weather app. The template sits there anyway.
- **Place.** The field kind `location` belongs to `placeSelection('display')` in the schema: “Display location” or “Fixed place”, and with `fallback: true`, additionally the substitute place for displays whose location has no known position, in the place field under “Display location”. Whoever reads the location declares the read area `player-location` and names it in its terms, see [Data access, permissions, and terms](/apps-sdk/data-access).
- **Surface.** The field kind `surface` shows tiles with a color swatch per surface so that you recognize it before choosing it.
- **Visibility.** `showWhen: { field, equals }` (or `in`, inverted with `not: true`) hides a field or a whole group as long as it has no effect, for example a color only for a certain surface. A hidden field keeps its value.
- **Required.** `required: true` marks a field as required. Whether a value is missing or wrong is still decided by the schema, and on an error the window disables “Apply”.

`pnpm app:check` checks every form description, for example quick fields that name no described field, have no compact field kind, or appear twice.

## 6. Custom fields

If no field kind fits, the app registers an individual field, never a whole form: that way improvements to the window reach every app without it having to catch up. The weather app brings along only the city search itself and attaches it to the path `location.place`. From `apps/weather/src/authoring/index.ts`:

```ts
export function registerAuthoring(registry: AuthoringRegistry): void {
  registry.registerConfigUI(WEATHER, weatherConfigUI);
  // The place selection works with a place as its value, and the registry knows fields in general.
  registry.registerConfigField(
    WEATHER,
    'location.place',
    CityPicker as ComponentType<ConfigFieldProps>,
  );
}
```

The field receives value, change callback, and the form context (frame, language, the organization’s displays) as props and renders only itself. Label, group, and help text still come from the description.

## 7. Preview states

`preview.states` are named sample payloads that the window offers for toggling next to “Live data”. That way you see rain, night, or “no data” without waiting for the weather:

```ts
states: [
  {
    id: 'sunny',
    label: 'Sonne',
    description: 'Klarer Himmel am Mittag.',
    mockData: WEATHER_PREVIEW_STATES.sunny,
  },
  // … rain, snow, night
],
```

The payloads sit next to the app’s demo data and follow the same schema as the real payload. A state that the display software does not know shows up in its tests rather than at the customer’s.

**A widget with a source has the `empty` state.** It shows the widget without data (`mockData: null`), and `pnpm app:check` requires it. In the weather app:

```ts
{
  id: 'empty',
  label: 'Keine Daten',
  description: 'So sieht das Widget aus, solange der Wetterdienst nichts liefert.',
  mockData: null,
},
```

**Never editor text on the display.** Empty, faulty, or unconfigured means, on the display, a calm surface and at most one neutral sentence. Whoever stands in front of the display cannot change any settings, and in the shop, instructions look like an error. The explanation with the next step belongs in the editor: the app asks `useWidgetMode()` or uses the `QuietState` building block, which separates the two. Without data, the weather app shows on the display only “Weather data temporarily unavailable”, and in the editor additionally whether a place, a location, or only the data is missing. The fit check renders every state in display mode and reports a sentence addressed to the editor (step [8](#8-the-fit-check)).

## 8. The fit check

The fit check renders every template in its frame and reports what overflows, is clipped, or is entirely hidden, lines that a truncating box cuts through the letters, texts that overlap each other, type below the minimum size, and what the widget hides in its own frame. It runs as a gate in the pull request.

```bash
pnpm --filter @szenera-app/<id> build
pnpm app:fit-check --only=<id>                       # templates, edge sizes, and states of the app
pnpm app:fit-check --try=<id>:<variante>@1000x300    # measure a size before it is in the manifest
```

For an app in the new standard, it measures more than the templates:

- **The edge-size set.** With `size.min`, every widget gets the frames at which arrangements break: the minimum size, for each shape the smallest permitted frame and the largest one in the scene, the bar as full width at minimum height, the column as full height at minimum width. Hiding is allowed there, but clipping and too-small type are not. For the weather app (minimum size 160 × 90), that makes eight frames, including the tile 161 × 131, the bar 1920 × 90, and the column 160 × 1080.
- **The minimum font size.** Content never below 12, source and as-of lines never below 10 reference pixels. An arrangement that needs space omits content or pages instead of pushing the type down. The source line carries `data-fit-meta` (the `SourceCredit` building block is marked as a whole text).
- **Whole texts.** Time, date, price, mandatory disclosures, and phone number carry `data-fit-whole`. Such a text is never shortened, neither with “…” by the box nor by a script.
- **The states on the display.** Every preview state in display mode, `empty` and `error` in every template, with no sentence addressed to the editor.

The images of a frame with a finding then lie under `scratchpad/fit-check/`. With `--try`, it is worth walking the boundaries: just below and above every shape threshold. If content deliberately runs through its box and a script moves it instead of a CSS animation, the moving element or its box carries `data-fit-scroll`. If an app clips at every reasonable size because its design is only redone in its own wave, the frame belongs in `tools/app-screenshots/fit-exceptions.mjs`, together with a one-sentence reason (“own wave”). For an app that is being converted right now, that is no way out: its templates, edge sizes, and states fit, and the weather app carries no exception.

## 9. Store images

The images in the store show every template in its true frame on a calm surface, with name and dimensions underneath and small templates enlarged. They are produced with the same renderer as the display software:

```bash
node tools/app-screenshots/run.mjs --only=<id>
```

The tool writes `apps/<id>/thumbnails/<widget>--<variant>.png` per template, a copy of the `primary` template as `<widget>.png`, and the scene example. `pnpm app:check` then checks whether every image is there and not empty. The images belong in the same commit as the templates.

## 10. Old configs

Saved scenes carry configs of earlier versions. They are not reinterpreted on reading but written once into the new form by a seed step, existing data and scene templates alike. After that, the schema knows only the new fields, and form, template recognition, API, and assistants see the same state as the display software.

- **The mapping** is a pure function with tests over every combination of the old values. In the weather app: a set place becomes the fixed place, the days are on if the old config showed days (`forecastDays` above 0 and `layout` not `current-only`), a transparent background stays `transparent`, and every other one becomes `dark`.
- **Old keys stay in the JSON.** An organization that has not yet accepted the update keeps running with the older version and reads them. The new schema silently discards unknown keys.
- **New fields have a default** so that a config without them is also valid. The app’s baseline `tests/compat-baseline.json` is updated with `pnpm app-compat --update <id>` as soon as the old fields leave the schema.
- **Scene templates and demo sources** are in the new form. A template that writes old fields creates the very case that the seed step has resolved.

## 11. Host contract and release

Formats, fit report, surfaces, and icons came into the display software with version 1.1.0 of the host contract. An app’s build writes to `dist/renderer-host.json` which names the bundle takes from the display software, and sets the commitment in the manifest (`apiCompatibility.rendererHost`) to the lowest version that these imports cover. The weather app therefore requires `^1.1.0`, while an app that uses none of the new names stays at `^1.0.0`.

A display software with an older commitment rejects a bundle that requires more, as `host-incompatible`. That determines the order of the release: first the display software with the new commitment through its channels, and only once the devices have accepted it, the app. Fire TV and web display load the display software from the cloud, and a Raspberry Pi fetches it through remote maintenance. Before the release to production comes the visual check on staging on a real device: Pi, Fire TV, and web display, light and dark, every template. If the new version reads data that the organization has not yet consented to, its update waits for that consent, and until then the older version keeps running.

## The next apps

The weather app is the first app in the standard. The order of the rest depends on the benefit for customers and on what the fit check and a look at the store images show today:

| Order | App | Why now |
|---|---|---|
| 1 | Opening hours | Every location in every industry shows them. Its presentation is manageable, and its four templates already fit. As the second app, it tests the guide with little risk before a large app follows. |
| 2 | Pharmacies on duty | For pharmacies, mandatory in the shop window and the industry’s core business. It has the two open exceptions of the fit check: in portrait, title and badge overlap with the duty number, and in the template `karte` the label of a marker disappears below the list. Like the weather app, it gains the most from the display’s location: one scene for all branches, each showing its own duty pharmacies. |
| 3 | News with brand apps and ticker | One implementation for seven apps (Tagesschau, BBC, BILD, Deutschlandfunk, NDR, SPIEGEL, RSS), so the leverage is large. The marquee line does not grow with the height of its band today, and the ticker clips the descenders of its items on the full stage. Both bars then follow the same shape `band`. |
| 4 | Menu board | Food service and bakeries, often in portrait at the counter. The templates fit, but like every app except the weather app, it lacks an arrangement per shape. |
| 5 | Calendar | The weekly grid barely scales its type and becomes unreadable in small frames. The background is transparent by default, so the frame cannot be seen in the store image. |

The clock family (Clock, Word Clock, World Clock, Countdown, Day Counter) follows afterward: it already fits in every template today and gains mainly formats, surfaces, and the declarative form.

## Related

- [Widget, configuration, and preview](/apps-sdk/widget-and-preview) — every field of the widget definition, formats, fit report, and preview states in detail.
- [From template to release](/apps-sdk/template-to-release) — the whole path of a new app, which already starts in the standard with `pnpm app:new`.
- [Data access, permissions, and terms](/apps-sdk/data-access) — the read area `player-location` and what an app discloses before installation.
- [Signing and the path into the catalog](/apps-sdk/signing-and-catalog) — channels and release of a version.
