# Widget, configuration, and preview

`defineWidget` builds the widget definition that sits under `widgets` in the manifest: configuration schema, sizes, and the preview data from which the catalog, thumbnails, and the editor’s templates arise. The tables list every field, and the clock example shows them in context.

> **Type:** reference
> **For:** Developers who define a widget or maintain the preview variants of an app.
> **Prerequisite:** the `@szenera/apps-sdk` SDK from the platform’s source tree (not publicly released). Zod for the configuration schema.

## The fields of the widget definition

| Field | Required | Format | Meaning |
|---|---|---|---|
| `id` | yes | Lowercase letters and digits with hyphens, 2–100 characters | The widget type. Under this key the runtime and authoring bundles register their components. |
| `appId` | yes | App identifier | Must equal the manifest’s `id` (cross-field rule at load time). |
| `name` | yes | 1–60 characters | Name in the editor palette. |
| `description` | yes | max. 300 characters | Short text in palette and store. |
| `configSchema` | yes | Zod schema, every field with `.default()` and `.describe()` | The contract of the widget configuration. `defineWidget` derives from it the JSON Schema that the catalog serves — see [Field texts and examples](#field-texts-and-examples). |
| `noResolver` | default `false` | boolean | `true`: purely static, renders solely from the configuration. `false`: requires a `@WidgetResolver` in the backend, and if it is missing, the app does not load. |
| `size.min` | no | `{ w, h }` in whole reference pixels of a 1920 × 1080 scene | The smallest frame in which the widget still shows something useful. The editor does not let the handles be dragged below it. Every template must be at least this large. |
| `preview.thumbnailUrl` | yes | Path | Thumbnail in editor palette and tile. |
| `preview.defaultConfig` | no | Configuration | Starting values of newly placed instances, and at the same time the basis of every preview variant. |
| `preview.mockData` | no | Payload or configuration | Sample data for the preview. For static widgets, a representative configuration. |
| `preview.variants` | default `[]` | see below | The presentations that the widget supports, which with `size` are also the templates in the editor. |
| `preview.states` | no | see below, at most 12 | Named sample payloads for the preview in the settings window. |
| `preload` | no | boolean | `true`: The display software preloads the widget hidden before its scene comes up. `useWidgetActivity()` then reports the phase `preload`, and the widget loads but plays nothing. For widgets with external content (page, video, stream), because otherwise an empty area would show after the crossfade. |

Every field of the configuration carries a default so that an empty configuration parses completely and the editor renders without prefilling. A widget resolver in the backend parses `ctx.config` again with the same schema, so the schema lives on both sides.

## Field texts and examples

`defineWidget` translates the Zod schema **once**, when the manifest loads, into a JSON Schema document according to Draft 2020-12. That is what `GET /v1/widget-catalog` serves and what a third-party client checks its configuration against. You control two things deliberately here:

**The field texts.** JSON Schema knows two texts per field, `title` as a short heading and `description` as a sentence to go with it, while Zod knows only one. Both therefore go into ONE `.describe()`, separated by a vertical bar:

```ts
timezone: z
  .string()
  .max(64)
  .default('')
  .describe('Zeitzone|IANA-Zeitzone wie „Europe/Berlin". Leer heißt: die Zeitzone der Organisation.'),
```

Without the bar, the whole text becomes the `description`, and the field gets no heading. A field without `.describe()` appears in the catalog with no explanation, which leaves it practically unlabeled for anyone who works without the editor.

**The examples.** The SDK fills `examples` itself: the `preview.defaultConfig` and, for each preview variant, the same configuration with the variant’s partial configuration laid over it. Every example is checked against the Zod schema, and if one breaks its own rule, **the app build fails** and names the field. A widget without `preview.defaultConfig` falls back to `preview.mockData`, and if it has neither, it cannot be built.

What the document ends up saying is covered under [Configure widgets](/apps-sdk/configure-widgets).

## Preview variants

`preview.variants` is the only source for what an app can show: the store gallery renders its views from it, the image generator derives from it which thumbnails it captures, and the editor offers the variants that have a size as templates. A layout option that is not listed here does not exist in the store.

| Field | Required | Format | Meaning |
|---|---|---|---|
| `id` | yes | `^[a-z][a-z0-9-]{1,31}$`, unique per widget | Also the name suffix of the image: `/apps/<appId>/thumbnails/<widgetId>--<variantId>.png`. |
| `label` | yes | 1–40 characters | Caption of the image (“Cards”, “Next large”). |
| `description` | no | max. 160 characters | One sentence on what the presentation is good for. |
| `config` | no | Partial configuration | The difference from `preview.defaultConfig`. A widget with no choices declares exactly one variant without `config`. |
| `size` | no | `{ w, h }` in whole reference pixels of a 1920 × 1080 scene | The template’s starting size: when the editor inserts the variant, the widget gets this frame. Without `size`, the variant is only an image. |
| `aspect` | default `16:9`, derived with `size` | `16:9` · `9:16` | Display orientation of the capture. Landscape is the usual case. With `size` it follows the size (landscape as long as the frame is at least as wide as it is tall), `defineWidget` sets it, and a contradicting entry breaks the build. |
| `primary` | default `false` | boolean | Exactly one variant per widget that has variants. It becomes the thumbnail in palette and tile and the template that a click inserts. The contract rejects zero or two canonical variants. |

### Sizes on variants

Each variant gets a `size` that suits its purpose: a clock as a tile (`600 × 300`), a news band as a bar (`1920 × 120`), a notice board in portrait (`600 × 800`), a map view wide (`1280 × 720`). The size is the frame with which a click places the template into a 1920 × 1080 scene. On a different stage, the editor converts it via the short side. A variant for portrait displays gets a tall size instead of `aspect: '9:16'`, and the orientation follows it. No template is taller than 1080 reference pixels, so a portrait frame is a column in the scene and not a whole display.

The thumbnail of a variant with a size shows the widget 1 : 1 in this frame, centered on a calm light surface, with name and dimensions underneath. The image stays 1920 × 1080, or 1080 × 1920 for a tall size. A variant without a size fills the stage of its orientation.

**The fit check** renders every variant of every app in its frame and fails if something overflows there, a text is clipped or too small, or the widget hides enabled content through the fit report. It runs as a gate in the pull request and locally like this:

```bash
pnpm --filter @szenera-app/<id> build
pnpm app:fit-check --only=<id>                       # every variant, every edge size, every state
pnpm app:fit-check --try=<id>:<variante>@600x300     # measure a size before it is in the manifest
```

The check measures the ink of a text, not its line box. The rules in detail:

| Rule | Finding |
|---|---|
| Overflow | An element that draws itself (image, surface, line) goes beyond the frame. |
| Clipped or hidden | A text extends out of the frame or out of a box that clips its overflow, or disappears behind it entirely (the last lines of a list with a fixed height). |
| Cut through | A box that explicitly truncates (`text-overflow: ellipsis`, `line-clamp`) may end a whole last line with “…” and omit the lines after it. If it cuts a line through the letters at the top or bottom, its height does not match its lines. |
| Stay whole | A text in an element with `data-fit-whole` (time, date, price, mandatory disclosures, phone number, source credit) is never shortened, neither by the box nor with an ellipsis in the text. |
| Minimum font size | Content below 12, and source and as-of lines (`data-fit-meta`) below 10 reference pixels of a stage with a short side of 1080, in every shape. The display building blocks adhere to the same limits (`MIN_CONTENT_FONT_REF`, `MIN_META_FONT_REF`). |
| Text over text | Two texts overlap each other. |
| Editor text on the display | Only in display mode (preview states): a sentence that addresses the editor or names technology (“in the settings”, “enter”, “widget”, “error”), or, in a calm state, more than one short neutral sentence. |
| Fit report | In its template, the widget hides enabled content. |

A widget with `size.min` is in the new standard and additionally gets the **edge-size set**: the minimum size itself, for each shape the smallest frame that `size.min` allows and the largest one in the reference scene, bar and column at the most extreme aspect ratio (full width at minimum height, full height at minimum width). Rendering uses the default config, which turns everything on. There the widget may hide content, but it must not clip text or use a font that is too small. The errors in all reports sat at these edges, not in the templates.

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` (the marquee band sets it itself). A layer that is drawn only for measuring carries `data-fit-measure` and does not count. The images of a frame with a finding then lie under `scratchpad/fit-check/`. If an app that is not yet in the new standard clips at every reasonable size, the frame belongs in the exception list `tools/app-screenshots/fit-exceptions.mjs`, together with a one-sentence reason (“own wave”). There it stays visible in the report, and if it fits one day, the check turns red until the entry is removed. An app in the new standard carries no exception.

## Preview states

`preview.states` shows in the settings window how the widget looks in a specific situation without that situation actually occurring: rain, night, no data, stale. The editor offers the states as toggles next to “Live data”.

| Field | Required | Format | Meaning |
|---|---|---|---|
| `id` | yes | `^[a-z][a-z0-9-]{1,31}$`, unique per widget | Identifier of the state. |
| `label` | yes | 1–40 characters | Caption on the toggle (“Rain”, “Night”). |
| `description` | no | max. 160 characters | One sentence on what the state shows. |
| `mockData` | yes | Payload or configuration | The state’s sample data, in the same form as `preview.mockData`. |

There is no schema for the payload. `defineWidget` therefore checks the shape: plain JSON data (no function, no date, no number without a value) and the same kind as `preview.mockData` (object, list, or single value). The caption is translated under `translations.<language>.widgets.<widget>.states.<state>.label`, like that of a variant.

**The `empty` state is required** for every widget with a source (`noResolver: false`), and `pnpm app:check` demands it in the `Zustände` line. It shows the widget without data, for a data widget with `mockData: null` (no payload, as before the first fetch or after a failed one). Apps that redraw their empty state only in their own wave are listed in `tools/app-kit/src/check/state-exceptions.ts` until then.

**Never editor text on the display.** Empty, faulty, or unconfigured means, on the display, a calm surface and at most one neutral sentence, while the explanation with the next step lives in the editor (`useWidgetMode()`, and the `QuietState` building block separates the two). The fit check therefore renders every state in **display mode**, as a display shows it: in the frame of the `primary` template, and the calm states `empty` and `error` in every template. A sentence addressed to the editor, or more than twelve words in a calm state, is a finding there.

## Templates in the editor

In the scene editor, a variant with `size` is a template. Its content is `preview.defaultConfig` with the variant’s partial configuration laid flat over it (`{ ...defaultConfig, ...variant.config }`), exactly as in the image generator. The editor therefore inserts what the store shows, in exactly this way.

| Action | What happens |
|---|---|
| Click on the tile in the app area | The canonical template (`primary`) lands centered on the stage at its starting size. If a slot of the layout template is free, the widget fills it with the template whose shape fits the slot best: the tall template in a column, the bar in a bar. |
| Arrow on the tile | “Insert <Widget>” shows all templates as live thumbnails in the true aspect ratio, with name, shape, and size. A click inserts exactly that one. |
| Hovering over the tile | The preview shows the canonical template in its frame, not filling the stage. |
| Dragging onto the stage | Starting size of the canonical template around the drop point, with the free slot under the pointer highlighted. |
| Toolbar on the selected widget | “Template” separates what fits the frame (same shape, only the content changes) from what sets a new frame (centered on the old center point). In a slot, only the content changes. |
| Settings window | Tiles per template, the active one marked, “customized” as soon as the content deviates. Resetting returns to the active template. A selected template sets the frame only when you apply it, together with the config in one step. |

Three rules that an app should know when designing its templates:

- **The starting size is stated against the 1920 × 1080 reference.** On a different stage, the editor converts it via the short side (a 4K scene gets double) and limits it to the stage without losing the aspect ratio.
- **The content of a template is the keys of its partial configuration.** When the customer switches the template, all keys that any template sets return to their default, and the new partial configuration goes on top. What no template sets (a chosen place, the surface) stays. Templates that differ only in size may carry the same content.
- **The editor recognizes which template a widget carries from its frame and content.** The instance does not remember its template. The shape of the frame counts first, and within it the template whose content the config carries unchanged, otherwise the one with the closest aspect ratio.

`size.min` limits the handles on the stage: the widget cannot be dragged smaller than the minimum size (converted to the stage via the short side). While resizing, the editor shows the shape and dimensions of the frame (“Tall · 440 × 780”), and the Shift key locks the aspect ratio.

## One widget per app, templates instead of second widgets

No app carries two widgets. The app is the unit that an organization installs and removes again, and a bundle of unrelated widgets could not be evaluated. Anything that needs several presentations is solved through the preview variants and a configuration field:

- **A variant with `size` is a template.** It fixes the frame’s starting size and, with its partial configuration, the content the customer gets on insertion.
- **How the content looks inside the frame is decided by frame and configuration.** The widget reads the frame’s shape through `useWidgetFormat()`, and the customer’s choice sits in a configuration field such as `composition` for the clock.

A widget with `noResolver: false` is also resolved without a player, for example for the preview in the editor. The resolver then receives `playerId: null` and has to handle that case.

## Formats: how a widget uses its frame

A widget rarely fills the whole display in a scene. It is often small, in portrait, or a bar. It therefore arranges itself according to the frame the scene gives it, using one calculation for all apps. The hooks live in `@szenera/widget-foundation/hooks`:

| Hook | Returns | Purpose |
|---|---|---|
| `useWidgetFormat(options?)` | `{ shape, size, aspect, shortSideRef, width, height }` | Shape and size class of the frame. The app draws one arrangement per shape. |
| `useWidgetGeometry()` | `{ x, y, width, height, stage: { width, height } }` | The resolved box in stage pixels and the stage, without DOM measurement. |
| `useFitReport()` | `{ reportHidden(parts) }` | Tells the editor which enabled parts are currently missing in the frame. |

**The shape** follows the aspect ratio width : height:

| Shape | Aspect ratio | Typical place |
|---|---|---|
| `band` | from 3.2 : 1 | Bar at the edge of the scene |
| `wide` | 1.3 to 3.2 | Horizontal box |
| `square` | 0.78 to 1.3 | Tile |
| `tall` | below 0.78 | Column |

When dragging the handles in the editor, the shape changes only once the ratio is 4% past the threshold. That keeps a widget from jumping between two arrangements with every pointer movement.

**The size** measures the short side of the frame in reference pixels of a stage with a short side of 1080: `short side × 1080 / short side of the stage`. Below 400 reference pixels, the frame is `compact`, otherwise `regular`. A 4K scene has twice as many pixels, so its widget is not larger: 600 pixels on a stage 2160 high are 300 reference pixels. If you want finer steps, pass your own steps, for example `useWidgetFormat({ sizes: [{ size: 'tiny', from: 0 }, { size: 'small', from: 200 }, { size: 'large', from: 600 }] })`. Custom thresholds (`thresholds`) work too but are the exception: the same frame size should mean the same thing in every app.

**The fit report.** If a widget hides enabled content because the frame is too small, it reports the parts using the keys of the config fields that turn them on, with the complete list at every arrangement and an empty one as soon as everything is visible:

```tsx
const { shape, size } = useWidgetFormat();
const { reportHidden } = useFitReport();
const drawsDaily = config.showDaily && size === 'regular' && shape !== 'band';
useEffect(() => {
  reportHidden(config.showDaily && !drawsDaily ? ['showDaily'] : []);
}, [reportHidden, config.showDaily, drawsDaily]);
```

From it, the editor shows “Hidden in this frame: Next days.” beneath the preview, using the field’s label from the form description, and beneath the toggle a sentence on why it currently has no effect. A part without a matching field appears with its key. The fit check fails a template that hides something at its own size. On the display and in thumbnails, the message has no effect, so a widget always reports without asking where it runs. The `SceneRenderer` collects the report in `editor` mode and passes it to the host through `onFitReport(widgetInstanceId, hiddenParts)`.

The same calculation without React is available as `classifyWidgetFormat({ width, height }, { canvas, previous?, thresholds?, sizes? })` in `@szenera/widget-foundation/widget-ui/tokens`, for the capture tool, tests, and server. `useElementSize` remains for inner areas whose dimensions only the layout determines.

## Display building blocks

What several apps need lives once in `@szenera/widget-foundation/widget-ui`. The building blocks carry no style of their own, and they take the surface, font, and dimensions of the app. They arrived in the display software with version 1.2.0 of the host contract, so an app that uses one of them requires `^1.2.0` (see [Host contract and release](/apps-sdk/new-app-standard#11-host-contract-and-release)).

| Building block | Purpose | When to use |
|---|---|---|
| `PagedList` | Entries on pages instead of shrinking type: the list keeps to the minimum font size, distributes what does not fit beyond that across pages at a fixed ten-second interval with a page indicator “1/3”, and shows the first page with `motion: frozen`. Each entry is measured in an invisible measuring layer. | As soon as a widget shows a sequence of similar entries whose number the app does not determine: appointments, dishes, headlines, pharmacies on duty. The app arranges a fixed number of parts itself. |
| `QuietState` | On the display, the surface and at most one neutral sentence, and in the editor, the explanation with the next step. | Whenever a widget has no data: source empty, fetch failed, required field missing. The `empty` state shows exactly this building block. |
| `CompanionQr` | A code with a caption that scans on displays: quiet zone of four modules, a code area that is never transparent, module contrast, whole pixels per module, error correction M. The caption never pushes the code out of its quiet zone. | Whenever an app shows a code, as the target of a link, a route, or a guest Wi-Fi. |
| `MarqueeBand` | The marquee band: a seam between end and start, speed by width in reference pixels, edge feathering, descenders that are not clipped, and motion only with `motion: animated`. It carries `data-fit-scroll` as long as it runs. | For a single line in a bar that is longer than the bar. `PagedList` pages through several lines stacked on top of each other. |
| `SourceCredit` | The source credit for third-party data, as text in the app’s font, never switchable off, never shortened (`data-fit-whole`), never below 10 reference pixels. | In every widget with data from outside, in every shape, and with mixed sources on every item. A third-party logo or a house color is never part of it. |
| `ExternalFrame` | The managed frame for external content: a page via its address, or an embed code. A block that keeps the device and the display software from being embedded, sandbox levels (`site`, `code`, `scripted-code`) without forms, windows, and navigation, permissions only from a fixed list, view, crop, and slow scrolling via transform, an arrangement of the app’s own (`arrange`), double-buffered reloading on an interval or on a trigger (`reloadKey`), on failure the last state slightly blurred with a notice, and a rebuild as soon as the network is back. When frozen, it shows `still`. | For every external page, every embed code, and every player in a frame, never a `<iframe>` of your own. Apps that talk to their content (a player) get the visible frame through `onFrame`. |

## The two bundles

| Bundle | Entry point | What it registers |
|---|---|---|
| runtime (player) | `registerWidgets(registry)` | Per widget type the render component, loaded lazily: `registry.register('clock', lazy(() => import('../widgets/clock/runtime')))`. |
| authoring (editor) | `registerAuthoring(registry)` | Per widget type the configuration form: `registry.registerConfigForm('clock', ClockConfigForm)`. |

The renderer loads the runtime bundle from the device and has it enter its widgets into the registry. If the import fails, it reports `widget.bundle.load-failed` instead of aborting the scene. The portal’s scene editor loads the authoring bundle.

## Example: the clock (abridged)

```ts
import { defineWidget } from '@szenera/apps-sdk/widget';
import { type ClockConfig, ClockConfigSchema } from './config';

const DEFAULT_CONFIG: ClockConfig = {
  variant: 'digital',
  composition: 'stapel',
  timeFormat: 'HH:mm',
  showDate: true,
  timezone: '',
  theme: 'redaktion',
  accentColor: '#0f766e',
  // … further fields, each with a default in the schema …
};

export const clockWidget = defineWidget({
  id: 'clock',
  appId: 'clock',
  name: 'Uhrzeit',
  description: 'Digitale Uhr oder analoges Zifferblatt, mit optionalem Datum und eigener Zeitzone.',
  configSchema: ClockConfigSchema,
  noResolver: true,
  preview: {
    thumbnailUrl: '/apps/clock/thumbnails/clock.png',
    defaultConfig: DEFAULT_CONFIG,
    mockData: DEFAULT_CONFIG,
    variants: [
      { id: 'digital', size: { w: 600, h: 300 }, label: 'Digital', description: 'Große Ziffern, die sich der Fläche anpassen, darunter wahlweise das Datum.', primary: true },
      { id: 'band', size: { w: 480, h: 300 }, label: 'Band', config: { composition: 'band', theme: 'kachel', dateWeekday: 'short' } },
      { id: 'analog', size: { w: 560, h: 480 }, label: 'Analog', config: { variant: 'analog', theme: 'kachel', dialNumerals: 'quarters' } },
      { id: 'hochkant', size: { w: 440, h: 720 }, label: 'Hochkant', description: 'Dieselbe Uhr auf einem stehenden Display.' },
    ],
  },
});
```

The configuration schema for it is a Zod object in which every field carries a default and a text, for example `variant: z.enum(['digital', 'analog']).default('digital').describe('Variante|„digital" ist die Zahlen-Uhr, „analog" ein gezeichnetes Zifferblatt.')`. The schema silently discards unknown keys so that older saved scenes continue to parse.

## Related

- [The manifest](/apps-sdk/manifest) — where the widget definition is entered.
- [Backend: resolvers, fetchers, and jobs](/apps-sdk/backend) — the resolver that `noResolver: false` requires.
- [The example apps](/apps-sdk/example-apps) — category, class, and terms of each app.
- [Signing and the path into the catalog](/apps-sdk/signing-and-catalog#the-two-catalogs-across-all-apps) — how the finished definition comes back out through `GET /v1/widget-catalog`.
- [Configure widgets](/apps-sdk/configure-widgets) — how a client reads the generated schema and checks its configuration against it.
