# Von der Vorlage bis zur Freigabe

Nach dieser Anleitung steht Ihre App als Fassung im Kanal **Stabil** und damit im Store jeder Organisation, deren Paket sie enthält. Der Weg beginnt mit einem Befehl, der die App aus einer Vorlage anlegt, und endet mit der Bestätigung einer zweiten Person im Ops-Portal. Dazwischen sagt ein zweiter Befehl jederzeit, was noch fehlt.

> **Typ:** anleitung
> **Für wen:** Entwicklerinnen und Entwickler, die im Quellbaum der Plattform eine neue App bauen. Das SDK ist vorbereitet, aber nicht veröffentlicht. Apps von außen kommen erst mit der Öffnung für Herausgeber.
> **Voraussetzung:** ein Arbeitsstand des Quellbaums nach `pnpm install` und `pnpm build`, Node 24. Für die Freigabe zwei Betreiber-Konten mit den Rechten `platform.app-catalog.import` und `platform.app-catalog.promote`.

## Der Weg in einem Bild

```text
pnpm app:new <id> ─ Widget, Config, Formular, Texte, Bilder ─ pnpm app:check <id> ─ Tests
  └─► Pull Request mit Notiz ─ Versions-PR ─ Zug: Tag @szenera-app/<id>@<fassung>
        └─► release-app.yml: bauen, aufnehmen, packen, prüfen, signieren, ablegen, melden
              └─► Staging und Produktion: Release im Kanal Canary
                    └─► Ops-Portal: Release-Prüfung ⇒ Beta ⇒ Stabil, eine zweite Person bestätigt
```

Die Wörter dieser Seite folgen dem Glossar: Die **Fassung** ist die Nummer, die eine Organisation liest (`1.0.0`), das **Release** ist dieselbe Fassung im Katalog des Betreibers, und der **Kanal** sagt, wer sie bekommt.

## 1. Die App anlegen

```bash
pnpm app:new door-sign --name="Türschild" --name-en="Door sign"
pnpm install
pnpm --filter @szenera-app/door-sign build
```

`pnpm app:new <id>` legt `apps/<id>/` aus der Vorlage an und schreibt sonst nichts: keine Liste, keinen Eintrag in einer Datei der Plattform. Plattform, Katalog, Aufnahmen und Budgets finden die App über ihren Ordner.

- **Die Kennung** ist kebab-case und englisch (`door-sign`, nicht `tuerschild`). Sie gilt für den Ordner, das Paket `@szenera-app/<id>`, das Manifest, das eine Widget der Vorlage und das Datenbank-Schema `apps_<id>` (mit Unterstrich statt Bindestrich). Eine vergebene, eine reservierte (`core`) oder eine falsch geformte Kennung lehnt der Befehl ab, mit jedem Grund auf einmal, und legt dann nichts an.
- **Die Namen** unter `--name` (deutsch) und `--name-en` (englisch) stehen im Manifest. Ohne sie nimmt der Befehl vorerst einen Namen aus der Kennung, die Texte schreiben Sie ohnehin neu.
- **`pnpm install`** nimmt die App in den Arbeitsbereich auf. Danach steht die Lockdatei kompakt da: `pnpm exec prettier --write pnpm-lock.yaml` bringt sie in die Form des Baums, und `pnpm lizenzhinweise` schreibt die Hinweise Dritter mit dem neuen Stand der Lockdatei.
- **`pnpm --filter @szenera-app/<id> build`** baut die App. Der Seed des Katalogs liest ihr gebautes Manifest und überspringt sie bis dahin mit Hinweis.

Was die Vorlage mitbringt:

| Teil | Dateien | Was darin steht |
|---|---|---|
| Manifest | `src/manifest/index.ts` | Kennung und Fassung, Texte auf Deutsch und unter `translations` auf Englisch, Kategorie, Klasse, Store-Block, `compatibility` (Wirte, Lagen, Verhalten ohne Netz) und `apiCompatibility.rendererHost` aus `RENDERER_HOST_RANGE` |
| Widget | `src/widgets/<id>/` | `definition.ts`, `config.ts`, `config-ui.ts`, `runtime.tsx`, `authoring.tsx`, `layout.ts` |
| Einstiege | `src/runtime/`, `src/authoring/` | `registerWidgets` und `registerAuthoring` |
| Tests | `tests/`, `testing/` | Manifest, Verträglichkeit mit der Grundlinie `tests/compat-baseline.json`, Widget und Formular über zwei Render-Harnische |
| Bau | `package.json`, `tsup.config.ts`, `vite.config.*.ts` | `exports` für Manifest, Laufzeit und Authoring, die Skripte und die Deckel der Bündel unter `szenera.bundleBudgets` |
| Bilder | `icon.svg`, `thumbnails/` | Platzhalter, die `pnpm app:check` rot zeigt, bis Symbol und Aufnahmen echt sind |

Eine App ohne Widget (eine, die eine Funktion des Portals aufsperrt) oder mit Server-Teil baut auf derselben Vorlage auf: `./runtime` und `./authoring` entfallen, `./backend` kommt hinzu, wie unter [Server-Teil: Resolver, Fetcher und Jobs](/de/apps-sdk/backend) beschrieben.

## 2. Widget, Config und Formular schreiben

`config.ts` ist der Werte-Vertrag des Widgets. Jedes Feld trägt `.default()`, damit eine leere Config vollständig liest, und das Schema verwirft unbekannte Felder still (kein `.strict()`), damit eine ältere Fassung die Config einer neueren liest. Für jede spätere Fassung gilt dieselbe Regel: Felder kommen nur hinzu, mit Vorgabe, und keines verschwindet oder ändert seinen Typ. Die Grundlinie hält das fest (Schritt 6).

`.describe('Beschriftung|Satz')` trägt Beschriftung und Hilfetext eines Felds. Das Formular in `config-ui.ts` liest beides mit `schemaFieldText()` aus `@szenera/widget-editor/config`, so heißt ein Feld überall gleich. Welche Steuerelemente der Editor kennt, sagt die Feldart:

| Feldart (`kind`) | Steuerelement im Editor | Wert in der Config |
|---|---|---|
| `text`, `textarea` | eine Zeile, mehrere Zeilen | Text |
| `number`, `slider` | Zahl, Schieberegler | Zahl |
| `select`, `segmented`, `checkboxGroup` | Auswahl, Umschalter, Kästchen | ein Wert, mehrere Werte aus `options` |
| `toggle` | Schalter | ja oder nein |
| `color` | Farbwähler | `#rrggbb` |
| `date` | Kalender | `isoDate()`: `JJJJ-MM-TT`, leer erlaubt |
| `time` | Uhrzeit | `timeOfDay()`: `HH:mm` |
| `dateTime` | Kalender, Uhrzeit und die Zone der Angabe | `isoDateTime()`: Zeitpunkt mit Versatz |
| `timezone` | Suche über alle Zonen mit deutschen und englischen Namen | `timezoneName()`: der Name der Zone (`Europe/Berlin`), leer für die Zone der Organisation |
| `address` | Straße, PLZ, Ort und Land als ein Feld | `postalAddress()` |
| `location` | Standort des Displays oder fester Ort aus der Stadtsuche, auf Wunsch mit Ersatzort | `placeSelection()`: `mode`, `place`, `fallback` |
| `surface` | Flächen als Kacheln mit Farbmuster (`options` mit `value`, `label`, `swatch`) | die Kennung der Fläche |
| `list` | Einträge hinzufügen, entfernen, duplizieren und per Griff oder Pfeiltasten verschieben, eingeklappt mit Kurztitel, ein Medium im Eintrag | `z.array(…)` von Texten oder kleinen Objekten |
| `asset`, `font` | Mediathek, Schriften | Kennung des Mediums oder der Schrift |

Gegliedert wird das Formular in Gruppen (`sections`) in der festen Folge Vorlage, Quelle, Inhalt, Aussehen, Erweitert und Platzierung. Eine Gruppe mit `collapsed: true` startet eingeklappt, so steht „Erweitert“ niemandem im Weg. `required: true` kennzeichnet ein Pflichtfeld, und `showWhen` mit `not: true` zeigt ein Feld bei jedem Wert außer den genannten. Höchstens zwei Felder kompakter Art (Auswahl, Umschalter, Schalter, Regler, Farbe, Fläche) nennt `quickFields`: Die bietet die Leiste am gewählten Widget direkt an. Wer die Beschreibung mit `defineWidgetConfigUI()` aus `@szenera/apps-sdk/config` anlegt, erfährt schon beim Bau, wenn eine dieser Regeln nicht stimmt.

Das Einstellungs-Fenster baut sich aus dieser Beschreibung: `registerAuthoring` trägt sie mit `registerConfigUI` ein. Ein Bedienelement, das keine Feldart abbildet (etwa eine Stadtsuche), kommt als einzelnes Feld dazu (`registerConfigField`), nicht als ganzes Formular. Wie das Formular Felder, Kontext und Passt-Bericht verbindet, steht unter [Widgets konfigurieren](/de/apps-sdk/configure-widgets#das-formular-im-editor).

Die Werte-Typen aus `@szenera/apps-sdk/config` hängen nur an `zod`, der Bau nimmt sie ins Bündel der App. Wie das Widget seine Fläche zeichnet, Platzierungen und Ansichten anlegt, steht unter [Widget, Konfiguration und Vorschau](/de/apps-sdk/widget-and-preview).

## 3. Die Texte schreiben

Die Texte stehen auf Deutsch im Manifest, jede weitere Sprache als Block unter `translations`, der nur Texte trägt. Englisch ist Pflicht, und `defineAppManifest` lehnt einen unvollständigen Block mit dem Pfad des fehlenden Texts ab. Dieselben Regeln gelten für jeden Text, den eine Organisation liest, im Store, im Install-Dialog und im Editor: die Sie-Form, kein Semikolon, höchstens ein Gedankenstrich je Text, keine Wörter aus dem Inneren der Plattform und keine Branche in einer App für alle Branchen.

Zwei Angaben ordnen die App ein: `category` bestimmt den Abschnitt im Store und in der Widget-Palette, `tier` die **Klasse** (`standard` in jedem Paket, `pro` ab dem Paket Pro, `addon` eigens gebucht). Der Manifest-Test der App hält beide fest, damit eine verschobene Einordnung nicht still durchläuft. Eine Klasse außer `standard` braucht zudem ihren Eintrag in der Verkaufsliste `EXPECTED_TIERS` in `services/api/tests/app-tier-classification-sweep.test.ts`, etwa `'door-sign': 'pro'`: Dort steht, was verkauft wird, und ein Test der Schnittstelle hält jede App dagegen. Fehlt der Eintrag, nennt ihn `pnpm app:check` in der Zeile **Manifest**. Eine App mit fremder Quelle oder Pflichtangaben trägt dazu einen Block `legal` ([Datenzugriff, Rechte und Bedingungen](/de/apps-sdk/data-access)).

## 4. Symbol und Aufnahmen

Das Symbol `icon.svg` zeichnen Sie selbst. Solange das Symbol der Vorlage mit seiner Marke `data-placeholder` dort liegt, bleibt die Zeile **Store-Bilder** rot.

Die Aufnahmen entstehen mit dem Werkzeug, das auch die CI benutzt:

```bash
node tools/app-screenshots/run.mjs --build --only=door-sign
```

Es nimmt jede Ansicht unter `preview.variants` auf, die Hauptansicht ein zweites Mal unter dem Namen aus `thumbnailUrl` und das Szenenbeispiel, die Hauptansicht in einer typischen Szene. Braucht eine Aufnahme mehr, eine Akzentfarbe der Umgebung oder eine feste Teil-Config, steht es in der `package.json` unter `szenera.screenshots`.

## 5. Mit `pnpm app:check` prüfen

```bash
pnpm app:check door-sign            # aus den Quellen, ohne Bau
pnpm app:check door-sign --built    # dazu Budgets und Host-Zusage gegen den Bau
```

Der Befehl zeigt je Prüfung eine Zeile, mit jedem Befund darunter. Lokal und in der CI ist es derselbe Befehl, die Prüfung vor dem Bau fährt `pnpm app:check --all` über alle Apps.

| Zeile | Was sie prüft |
|---|---|
| Manifest | Das Manifest lädt und erfüllt den Vertrag. Ordner, Paket und Manifest tragen dieselbe Kennung, Paket und Manifest dieselbe Fassung, jeder Teilpfad unter `exports` hat seine Quelle, das Schema heißt `apps_<id>`. Eine Klasse außer `standard` steht in der Verkaufsliste (Schritt 3). |
| Übersetzungen | Deutsch und Englisch sind da. |
| Kompatibilität | Wirte, Lagen und Verhalten ohne Netz stehen im Manifest. |
| Store-Bilder | Symbol, jede Ansicht, die Hauptansicht und das Szenenbeispiel liegen da, je ein PNG in den Maßen seiner Lage und nicht leer. |
| Texte | Jeder Text aus Manifest, Übersetzung und Formular folgt der Sprachregel, und jedes Feld heißt im Formular wie im Schema. |
| Formular | Jede Formular-Beschreibung in `src/widgets/<ordner>/config-ui.ts` hält die Regeln, die kein Typ ausdrückt (`widgetConfigUIIssues`): höchstens zwei Schnellfelder kompakter Art, jede Gruppe einmal und jedes Feld in einer, die es gibt, jede Fläche zur Wahl einmal. |
| Zustände | Jedes Widget, das Daten von außen holt, erklärt den Vorschau-Zustand `empty` (`{ id: 'empty', mockData: null }` oder die leere Nutzlast), damit das Display die ruhige Fläche zeigt. Eine Ausnahme bis zur eigenen Welle steht mit Begründung in `tools/app-kit/src/check/state-exceptions.ts`. |
| Grundlinie | Test und Grundlinie liegen da, die Config hält sie ein, und die Grundlinie ist aktuell: Ein Feld, das nicht darin steht, schützt sie nicht. |
| Budgets | Ohne Bau: Die Deckel sind lesbar. Mit `--built`: jedes Bündel unter seinem Deckel. |
| Host-Zusage | Mit `--built`: Das Laufzeit-Bündel bezieht nur zugesagte Namen, und das gebaute Manifest trägt den Bereich, der aus den Importen folgt. |
| Verdrahtung | Was außerhalb des Ordners für die App stimmen muss. |
| Handbuch | Die App hat eine Seite im Handbuch. |
| Notiz | `CHANGELOG.md` nennt die Fassung aus `package.json` mit einem Abschnitt `## <fassung>`. Aus ihm liest der Lieferweg die Notizen des Artefakts, ohne ihn packt er die Fassung nicht. Den Abschnitt schreibt der Versions-PR aus der Notiz (Schritt 7), eine neue App bringt ihn aus der Vorlage mit. |

Eine Zeile mit `–` ist nicht geprüft, sie zählt nicht als bestanden: Budgets und Host-Zusage brauchen den Bau, und den prüft ohnehin der Bau selbst. Ein Bündel über seinem Deckel bricht `vite build`, ein Import außerhalb der Zusage ebenso.

Eine frische App aus der Vorlage zeigt drei Zeilen rot, und jede verschwindet mit einem Handgriff:

- **Store-Bilder:** ein eigenes Symbol und die Aufnahmen aus Schritt 4.
- **Verdrahtung:** `"@szenera-app/<id>": "workspace:*"` in `services/public-site/package.json` und, für eine App mit Widget, in `services/scene-snapshot/package.json`, danach `pnpm install`. Der Bau beider Pakete liest die gebauten Apps, und erst die Abhängigkeit sagt ihm die Reihenfolge. Danach `pnpm codegen:public-app-catalog`, das den Katalog der öffentlichen Seite neu schreibt.
- **Handbuch:** eine Seite `docs/de/handbook/apps/<id>.md` vom Typ `app`. Die öffentliche Seite baut nur, wenn jede App eine hat.

## 6. Testen

```bash
pnpm --filter @szenera-app/door-sign typecheck
pnpm --filter @szenera-app/door-sign test
```

Die Tests laufen gegen die Quellen, ohne Bau. `tests/manifest.test.ts` hält Vertrag, Kategorie, Bedingungen, Wirte und Registry, `tests/compat.test.ts` die Grundlinie und den Host-Bereich, die Tests unter `tests/widgets/` Darstellung und Formular. Ändern Sie die Config, schreibt `pnpm app-compat --update door-sign` das Neue in die Grundlinie. Bricht die Änderung die Regel aus Schritt 2, schreibt der Befehl nichts, und der Test nennt das Feld.

## 7. Der Pull Request

Der PR trägt eine **Notiz**: `pnpm changeset` nennt die App, und eine Notiz für eine App braucht den Abschnitt `## Kunde` mit dem Satz, den eine Organisation zu dieser Fassung liest. Ohne Notiz bekommt die App keine Fassung. Die Prüfung des PR fährt `pnpm app:check --all` mit der übrigen Statik, und in der vollen Tiefe baut, nimmt auf, packt und prüft sie jede berührte App so, wie der Lieferweg es tut. Signiert und abgelegt wird im PR nichts.

Nach dem Merge hebt der Versions-PR die Fassung aus der Notiz, und der Zug setzt den Tag `@szenera-app/<id>@<fassung>`.

## 8. Vom Tag in den Katalog

Zum Tag baut `release-app.yml` die Fassung genau einmal: Bau, Aufnahmen, das Artefakt `<id>-<fassung>.szapp`, die Prüfung, die Signatur, die Ablage in der Registry. Dann meldet der Lauf die Fassung Staging und Produktion, beide holen dasselbe Artefakt und legen das Release im Kanal **Canary** an. Dieselbe Fassung trägt damit überall dieselben Bytes. Was an einem Release liegt und wie die Kanäle wirken, steht unter [Signieren und der Weg in den Katalog](/de/apps-sdk/signing-and-catalog).

## 9. Prüfen und freigeben im Ops-Portal

1. Öffnen Sie im Ops-Portal **Apps**, die Zeile der App und den Reiter **Releases**. Die neue Fassung steht in **Canary**, **Prüfung öffnen** führt zur Release-Prüfung.
2. Die Prüfung zeigt links den Store-Eintrag, wie die Organisationen ihn sehen werden, rechts die Zustimmungsfolge, den Vergleich mit der Vorgängerin, „Neu in dieser Fassung“, die Verbreitung und die Prüfliste. Fehlt der Text für die Organisationen, tragen Sie ihn hier ein.
3. **Nach Beta hochstufen …** dürfen Sie allein. In **Beta** bekommen die Fassung die Organisationen, die sich für Vorabfassungen eingetragen haben.
4. Nach einigen Tagen in Beta: **Nach Stabil hochstufen …** mit einer Begründung. Das legt eine Anforderung an, die Fassung bleibt so lange in Beta.
5. Eine zweite Person öffnet die Prüfung derselben Fassung und bestätigt. Erst damit steht die Fassung in **Stabil**, und alle Organisationen, deren Paket die App enthält, finden sie im Store.

## Ergebnis prüfen

- `pnpm app:check <id> --built` zeigt jede Zeile mit `✓`.
- Im Ops-Portal steht die Fassung im Reiter **Releases** in **Stabil**, und der Verlauf nennt bei der Freigabe beide Personen.
- Im Store einer Organisation mit passendem Paket steht die App unter ihrer Kategorie, mit Symbol, Aufnahmen und Szenenbeispiel aus dem Artefakt.

## Wenn etwas nicht klappt

| Was Sie sehen | Was dahintersteht und was hilft |
|---|---|
| `app:new` legt nichts an | Die Meldung nennt jeden Grund: eine vergebene oder reservierte Kennung, eine Form außerhalb von kebab-case, ein Name mit Anführungszeichen. Den Grund beheben und den Befehl wiederholen. |
| Zeile **Manifest**: „das Manifest lässt sich nicht laden“ | `defineAppManifest` hat abgelehnt, der Text danach nennt das Feld, eine unvollständige Übersetzung etwa mit ihrem Pfad. |
| Zeile **Manifest**: „EXPECTED_TIERS … nennt … nicht“ | Die App trägt eine Klasse außer `standard`, und die Verkaufsliste kennt sie nicht. Den Eintrag aus dem Befund in `services/api/tests/app-tier-classification-sweep.test.ts` übernehmen (Schritt 3). |
| Zeile **Notiz**: „CHANGELOG.md nennt die Fassung … nicht“ | Die Fassung in `package.json` ist gehoben, ohne dass der Versions-PR sie geschrieben hat. Fassungen hebt nur die Notiz über den Versions-PR, `package.json` und `CHANGELOG.md` ändern sich dann gemeinsam. |
| Zeile **Grundlinie**: „nicht aktuell“ | Die Config hat ein Feld, das die Grundlinie nicht kennt. `pnpm app-compat --update <id>` schreibt es fest. |
| Zeile **Grundlinie**: „Das neue Feld hat keine Vorgabe“ | Ein neues Feld ohne `.default()`. Eine Config einer früheren Fassung kennt es nicht und liest dann nicht mehr. |
| Zeile **Store-Bilder**: „zu klein“ | Ein Platzhalter der Vorlage oder ein leeres Bild. Die Aufnahme aus Schritt 4 ersetzt ihn. |
| `vite build` bricht am Deckel | Das Bündel ist größer als `szenera.bundleBudgets`. Erst schlanker bauen, sonst den Deckel mit Grund im Feld `note` heben. |
| `vite build` bricht an der Host-Zusage | Das Laufzeit-Bündel bezieht einen Namen, den die Anzeige nicht zusagt. Den Namen vermeiden oder die Zusage mit einer neuen Fassung des Host-Vertrags erweitern, eine Änderung an der Plattform. |

In der Nacht beweist `pnpm app:probe`, dass dieser Weg hält: Die Probe legt eine App aus der Vorlage an, fährt ihre Tests und ihren Bau, fragt Seed, Aufnahmen und Budgets, ob sie die App sehen, und erwartet von `pnpm app:check` genau die drei roten Zeilen einer frischen App.
