A · Paket (Git-Abhängigkeit)
npm install github:fgilde/AirWorksnpm klont das Repository und baut die Bibliothek bei der Installation (prepare). Benötigt Node 22.18+.
AirWorks ist ein frameworkfreier Webdesktop und eine Micro-Frontend-Shell. Fenster, Taskleiste mit Vorschau, mehrere Webtops, Ordner, Drag-and-drop, Suche, Pop-outs, Anmeldebildschirm, Rollen und Rechte sowie Persistenz sind enthalten. Du registrierst Apps.
AirWorks besteht aus einem ES-Modul (airworks.js), einem Stylesheet (airworks.css) und dem Ordner icons/. Drei Wege, dieselbe API.
npm install github:fgilde/AirWorksnpm klont das Repository und baut die Bibliothek bei der Installation (prepare). Benötigt Node 22.18+.
<link rel="stylesheet"
href="https://fgilde.github.io/AirWorks/lib/airworks.css">
<script type="module">
import { AirWorks } from
"https://fgilde.github.io/AirWorks/lib/airworks.js";
</script>Icons werden automatisch aus lib/icons/ neben dem Modul geladen.
git clone https://github.com/fgilde/AirWorks.git
cd AirWorks/src/airworks
npm install
npm run dev # Doku auf http://127.0.0.1:4173, Demo unter /webtop.html
npm run build:package # schreibt ../../package (airworks.js, airworks.css, icons, types)
assetBase auf. Standard ist der Ordner icons/ neben airworks.js; das funktioniert per CDN und ungebündeltem ESM. Bündelt ein Bundler (Vite, webpack …) AirWorks in deinen eigenen Chunk, kopiere node_modules/@fgilde/airworks/package/icons in deinen Public-Ordner und setze assetBase – wie das Aspire-Beispiel über publicDir.<air-desktop></air-desktop>
<script type="module">
import { AirWorks } from "@fgilde/airworks";
import "@fgilde/airworks/style.css";
AirWorks.configure({
brand: { title: "Contoso Desk", logo: "/img/logo.png" },
initialShortcuts: ["hello"],
initialApps: ["hello"],
});
AirWorks.registerApp({
id: "hello",
title: "Hallo",
icon: "fn-dashboard.png", // Datei im Icon-Ordner oder beliebige URL
accent: "#00a4a6",
width: 640, height: 420,
render: () => "<h1>Hallo AirWorks</h1>",
});
</script>
<air-desktop> initialisiert sich in einem Microtask; configure() und Registrierungen im selben Modul laufen also immer vorher. Ist dein Setup asynchron (z. B. await auf einen Konfigurations-Endpunkt), füge das Element danach ein: document.body.append(document.createElement("air-desktop")). Apps, Rechte und Provider lassen sich jederzeit registrieren; der Desktop aktualisiert sich sofort.
AirWorks.configure(options) führt Optionen zusammen und darf mehrfach aufgerufen werden. assetBase, storageKey, initialApps und initialShortcuts werden beim Initialisieren des Desktops gelesen.
| Option | Zweck |
|---|---|
assetBase | Basis-URL des Icon-Ordners. Standard: icons/ neben dem Modul. |
locale | "en" oder "de" (oder eigene über addMessages). Standard: Browsersprache. Die Auswahl in den Einstellungen hat Vorrang. |
serverBaseUrl | Präfix für AirWorks.request(path). |
request | Ersetzt den eingebauten fetch-Transport vollständig. |
loginHint | Text im Anmeldebildschirm, z. B. wie man sich anmeldet. Provider können einen eigenen hint mitbringen. |
requireLogin | Sobald ein Auth-Provider registriert ist, startet der Desktop im Anmeldebildschirm und bleibt bis zur Anmeldung gesperrt. Mit false sind Gäste erlaubt. |
accessProvider | (session) => { roles, permissions }, z. B. von /api/me. Läuft nach jeder Anmeldung. |
identityStore | Backend für Benutzer und Rollen; aktiviert die eingebaute App „Benutzer & Rollen“. |
brand | Dein Produkt: { title, logo, startIcon, cornerLogo, description, version }. title und logo erscheinen im Anmeldebildschirm, unter Information und im Logo-Effekt; startIcon ersetzt das Logo im Startknopf; cornerLogo ist das Bild unten rechts (false blendet es aus). |
defaults | Einstellungen für Benutzer ohne gespeichertes Profil, z. B. { wallpaper: "dark", systemColor: "#00777a", taskbarPosition: "bottom", effect: "none" }. |
wallpapers | Zusätzliche Hintergründe: [{ id, title, image }] oder [{ id, title, background: "linear-gradient(…)", foreground: "#fff" }]. |
docsUrl | Fügt dem Startmenü einen Doku-Knopf hinzu; die Doku öffnet sich als Fenster. |
webLinks | Die eingebaute App Web-Verknüpfung (Standard true). false, wenn nur eigene Apps erscheinen sollen. |
storageKey | Präfix gespeicherter Profile (Standard airworks; angemeldete Benutzer erhalten airworks.<Benutzer-ID>). |
profileStore | Speicherort der Profile (Webtops, Verknüpfungen, Einstellungen, offene Fenster). Standard: localStorage. Siehe Benutzerprofile. |
initialShortcuts / initialApps | App-IDs, die beim allerersten Start (ohne gespeicherten Workspace) auf den ersten Webtop gelegt bzw. geöffnet werden. |
Eine App ist ein einfaches Objekt. Sie braucht id, title, icon und genau eine Inhaltsquelle: render, url oder entry.
AirWorks.registerApp({
id: "orders",
title: "Aufträge",
icon: "fn-reporttable.png",
accent: "#35496b", // Farbe von Fenster, Kachel und Taskleiste
width: 900, height: 600,
requiredPermissions: ["orders.read"],
hidden: false, // true: nicht in Startmenü/Suche, per API weiterhin öffenbar
async render({ host, request, can, onClose, setTitle, session, access }) {
host.innerHTML = "Lädt …";
const orders = await request("/orders");
host.innerHTML = renderTable(orders);
const timer = setInterval(refresh, 30_000);
onClose(() => clearInterval(timer)); // Aufräumen beim Schließen
},
});
Gibt einen HTML-String oder DOM-Knoten zurück oder schreibt direkt in context.host. Darf async sein. AirWorks rendert einen Fensterinhalt nie neu – Zustand, Custom Elements und Framework-Roots (React, Vue, Svelte, Angular Elements) bleiben bis zum Schließen erhalten.
Lädt die App in einem iframe: vollständige Isolation für unabhängig deployte Micro-Frontends. Das iframe kümmert sich selbst um Authentifizierung (Single Sign-on mit demselben Identity Provider funktioniert).
URL eines ES-Moduls, das beim ersten Öffnen importiert wird. Sein Default-Export (oder exportName) ist eine Render-Funktion oder eine Teil-App-Definition.
host, appId, windowId, session, access, can(permission), request(path, init), onClose(cb), setTitle(text), setAppearance({ title, accent, icon }), intent, onIntent(cb), setState(state, title) und link (die Verknüpfung, aus der das Fenster geöffnet wurde, mit ihren data).
Jedes Fenster hat die Menüaktionen Verknüpfung erstellen/aktualisieren, Duplizieren und Im Browser öffnen (ein Pop-out, das nur diese App über ?app=<id> zeigt). Eigene Einträge ergänzt eine App mit menu: [{ id, title, icon, run(context) }]. Apps lassen sich beliebig oft öffnen; singleInstance: true erlaubt nur ein Fenster (beim nächsten Öffnen wird es fokussiert, ohne Duplizieren), wie bei den eingebauten Einstellungen. Im Pop-out (Im Browser öffnen) füllt das Fenster den Browser-Tab, ohne Verschieben, Größenänderung oder Fensterknöpfe; das Menü behält Verknüpfung aktualisieren und Im Browser öffnen für ein weiteres Pop-out. Eingebaute Apps: airworks.settings, airworks.identity, airworks.weblink und airworks.docs. AirWorks.unregisterApp(id) entfernt eine App.
Eine App meldet ihre aktuelle Position mit setState, z. B. den gewählten Bericht oder die Kennzahl, samt Titel. Verknüpfung erstellen schlägt diesen Titel vor und speichert den Zustand, Verknüpfung aktualisieren übernimmt den neuen Zustand, und Duplizieren, Im Browser öffnen sowie wiederhergestellte Fenster öffnen genau dort. Der Zustand kommt wie bei Suchtreffern als intent an.
render: ({ host, intent, onIntent, setState }) => {
const show = (ratio) => { draw(ratio); setState({ ratio }, titleOf(ratio)); };
onIntent((next) => show(next.ratio));
show(intent?.ratio ?? "cash-ratio");
}
Die App Web-Verknüpfung macht aus jeder Webseite eine App: Name, Webadresse, Farbe und Icon (hochgeladenes Bild oder das Favicon der Seite). Sie legt eine Verknüpfung an und zeigt die Seite in einem Fenster; Web-Verknüpfung bearbeiten im Fenstermenü ändert sie später. Seiten, die das Einbetten verbieten (X-Frame-Options), bleiben leer; das Fenster bietet an, sie im Browser zu öffnen. Abschalten mit webLinks: false.
// React
render: ({ host, onClose }) => {
const root = createRoot(host);
root.render(<Orders />);
onClose(() => root.unmount());
}
Die Suche in der Taskleiste findet Apps und Inhalte. Apps liefern Inhalte über search; alles andere (typischerweise dein Server) registriert einen Suchanbieter. Treffer öffnen ihre App mit einem Intent, der beschreibt, was gezeigt werden soll.
AirWorks.registerApp({
id: "orders", title: "Aufträge", icon: "fn-reporttable.png",
search: (query) => orders
.filter((order) => order.customer.toLowerCase().includes(query))
.map((order) => ({ title: order.number, description: order.customer, intent: { order: order.id } })),
render: ({ host, intent, onIntent }) => {
show(intent?.order); // aus einem Suchtreffer geöffnet
onIntent((next) => show(next.order)); // Fenster schon offen, neuer Treffer gewählt
},
});
AirWorks.registerSearchProvider({
id: "server",
search: (query, { request, signal }) => request(`/search?q=${encodeURIComponent(query)}`, { signal }),
}); // liefert [{ title, description?, icon?, appId, intent? }]
AirWorks.openApp("orders", { order: "4711" }); // derselbe Intent aus Code
Die Suche startet ab zwei Zeichen, wartet 200 ms nach der Eingabe und bricht veraltete Anfragen über signal ab. Treffer zu Apps ohne Berechtigung werden verworfen. Die Kennzahlen der Demo und die Serversuche des Aspire-Beispiels (Berichte, Benutzer) nutzen das.
Teams liefern Apps unabhängig aus. AirWorks.loadRemote(url) lädt ein JSON-Manifest; relative Pfade in entry, url und icon werden gegen die Manifest-URL aufgelöst. Apps mit entry laden erst beim ersten Öffnen.
// https://team-a.example.com/airworks/manifest.json
{
"permissions": [{ "id": "remote.hello", "title": "Hallo nutzen", "group": "Team A" }],
"apps": [
{ "id": "remote-hello", "title": "Remote Hallo", "icon": "fn-ideaportal.png",
"entry": "./hello.js", "requiredPermissions": ["remote.hello"] },
{ "id": "billing", "title": "Abrechnung", "icon": "./billing.png", "url": "./billing/index.html" }
],
"modules": ["./register-more.js"] // Module, die selbst AirWorks.* aufrufen
}
// hello.js
export default function render({ host, access, onClose }) {
return `<h2>Hallo ${access.identity?.displayName ?? "Gast"}</h2>`;
}
await AirWorks.loadRemote("https://team-a.example.com/airworks/manifest.json");
await AirWorks.loadRemote({ apps: [/* Manifest inline */] });
Die Client-only-Demo lädt remote/manifest.json genau so. Module von fremden Origins brauchen CORS-Header. Ein Manifest kann auch pro Benutzer vom Server erzeugt werden.
Webtops (bis zu 9) und Ordner sind dasselbe: seitenweise Raster. Webtops haben 5 × 3 Zellen, Ordner 4 × 3 und beliebig viele Seiten. Alle Regeln gelten für beide:
Drag-and-drop arbeitet mit Pointer-Events und einem schwebenden Abbild (Maus, Stift und Touch); während des Ziehens wird nichts neu gerendert. Fenster weichen beim Ziehen zur Seite. Fenster lassen sich an allen Kanten und Ecken in der Größe ändern und andocken: nach oben ziehen maximiert, an linken/rechten/unteren Rand für Hälften, in eine Ecke für Viertel; Strg+Pfeile wechseln zwischen diesen Positionen (zweimal oben = obere Hälfte, zweimal unten = minimieren). Außerdem:, Taskleiste an jeder Seite mit Auto-Hide, Vorschauen, Suche, Pilotansicht (alle Fenster und Webtops; Webtops hinzufügen/löschen), Hintergründe, eigene Systemfarbe und Effekte (springende Logos, Schnee, Sternenfeld, Warp, Netzwerk, Polarlicht). Verknüpfungen auf Apps ohne Berechtigung behalten ihren Platz, werden aber ausgeblendet.
Ohne Auth-Provider läuft AirWorks ganz ohne Anmeldung. Sobald ein oder mehrere Provider registriert sind, startet der Desktop im Anmeldebildschirm: Die Karte listet alle Provider, schüttelt sich bei einem Fehlversuch und schrumpft nach der Anmeldung in den Startknopf. Abmelden führt zurück zum Anmeldebildschirm. Mit requireLogin: false bleibt der Desktop für Gäste nutzbar und die Anmeldung wird über das Startmenü angeboten.
createOidcProvider registrieren (siehe unten). Die Anmeldung findet beim Identity Provider statt; AirWorks erhält die Tokens per PKCE.AirWorks.request sendet das Token automatisch mit.GET /me); dieser Endpunkt wird als accessProvider eingebunden. Dieselben Rechte an jedem Endpunkt erzwingen.identityStore: httpIdentityStore(…), profileStore: httpProfileStore(…).AirWorks.configure({
serverBaseUrl: "/api",
accessProvider: () => AirWorks.request("/me"),
identityStore: httpIdentityStore(AirWorks.request, "/identity"),
profileStore: httpProfileStore(AirWorks.request, "/profile"),
});
AirWorks.registerAuthProvider(createOidcProvider({ id: "keycloak", title: "Keycloak", authority, clientId: "airworks-web" }));
document.body.append(document.createElement("air-desktop"));
Das .NET-Aspire-Beispiel setzt genau das um, inklusive API.
import { AirWorks, createOidcProvider } from "@fgilde/airworks";
AirWorks.registerAuthProvider(createOidcProvider({
id: "keycloak",
title: "Keycloak",
authority: "https://sso.example.com/realms/airworks",
clientId: "airworks-web", // Public Client, PKCE S256
// scope: "openid profile email", // Standard
// redirectUri: location.origin + location.pathname,
// extraParams: { kc_idp_hint: "github" },
// roleClaims: ["realm_access.roles", "roles", "groups"],
}));
AirWorks.registerAuthProvider(createOidcProvider({
id: "entra", title: "Microsoft",
authority: "https://login.microsoftonline.com/<tenant>/v2.0",
clientId: "<spa-client-id>",
scope: "openid profile email api://<api-client-id>/access",
}));
createOidcProvider implementiert den Authorization-Code-Flow mit PKCE ohne Abhängigkeiten: Discovery, Redirect, Code-Tausch, Erneuerung per Refresh-Token und RP-initiierter Logout. Registriere die Seiten-URL als Redirect-URI und ihren Origin als erlaubten Web-Origin (CORS) beim Identity Provider. Tokens liegen im sessionStorage. Rollen kommen aus roleClaims; mapSession(claims, tokens) passt die Session an.
extraParams: { kc_idp_hint: "github" } hinzu; im Aspire-Beispiel steht dieser Eintrag bereits in appsettings.json.import { localAuthProvider, localIdentityStore } from "@fgilde/airworks";
const identity = localIdentityStore({ users: [...], roles: [...] });
AirWorks.registerAuthProvider(localAuthProvider(identity, { title: "Demo-Benutzer" }));
Ein Benutzerwechsler für Demos und Prototypen: Der Anmeldebildschirm fragt nur den Benutzernamen ab, Rollen kommen aus dem lokalen Identity-Store. Alles liegt im Browser und lässt sich in den Entwicklerwerkzeugen ändern. Zum Ausprobieren der Oberfläche gedacht, niemals zum Schutz von Daten. Die Live-Demo arbeitet so (Benutzer demo und admin).
AirWorks.registerAuthProvider({
id: "company", title: "Firmen-Login",
fields: [{ name: "userName", label: "userName" }, { name: "password", label: "Passwort", type: "password" }],
async login(values) { return await myLogin(values); }, // liefert eine Session
async restore() { return mySavedSession(); }, // Start, Rückkehr vom Redirect, Erneuerung
logout(session) { /* … */ },
});
// oder ohne Provider die Session selbst setzen:
await AirWorks.setSession({ token, identity: { id: "jdoe", displayName: "J. Doe" }, roles: ["user"] });
Eine Session ist { token?, identity?, roles?, permissions?, expiresAt? }. Mit expiresAt ruft AirWorks 30 Sekunden vor Ablauf restore() des Providers zur Erneuerung auf.
Webtops, Ordner, Verknüpfungen, Einstellungen und offene Fenster bilden ein Profil. Ohne Anmeldung gibt es ein Profil; mit Anmeldung hat jeder Benutzer sein eigenes (Schlüssel <storageKey>.<Benutzer-ID>), das bei der Anmeldung geladen und automatisch gespeichert wird.
AirWorks.configure({ profileStore: localProfileStore() });Pro Browser und Benutzer.
AirWorks.configure({
profileStore: httpProfileStore(AirWorks.request, "/profile"),
});
// GET /profile?key=… → Profil-JSON oder 204
// PUT /profile?key=… ← Profil-JSONFolgt dem Benutzer auf jedes Gerät. Auf dem Server den angemeldeten Benutzer verwenden, nicht den Schlüssel, um das Profil zuzuordnen.
Jedes Objekt mit load(key) und save(key, profile) eignet sich als profileStore.
Rechte sind Strings. Apps deklarieren requiredPermissions; Apps ohne sind für alle sichtbar. Registriere Rechte, damit Admins sie auswählen können (im Client, auf dem Server oder beides; die Admin-App führt beide Listen zusammen):
AirWorks.registerPermissions([
{ id: "orders.read", title: "Aufträge lesen", group: "Aufträge" },
{ id: "orders.write", title: "Aufträge bearbeiten", group: "Aufträge", description: "Anlegen und ändern" },
]);
AirWorks.can("orders.write"); // auch im App-Kontext verfügbar
AirWorks.access; // { identity, roles, permissions }
accessProvider: dessen Ergebnis (typischerweise /me deines Servers). Für echte Sicherheit empfohlen.permissions der Session plus die Rechte ihrer Rollen aus dem identityStore (Rollen der Session plus Rollen des Benutzer-Datensatzes mit gleicher ID bzw. gleichem Benutzernamen).Nach Änderungen an anderer Stelle AirWorks.refreshAccess() aufrufen; die eingebaute Admin-App tut das selbst. Fenster von Apps, für die das Recht wegfällt, schließen sich.
Konfiguriere einen identityStore und gib Administratoren airworks.identity.manage (exportiert als MANAGE_IDENTITY). Die App bearbeitet Benutzer (Rollen) und Rollen (Rechte).
localIdentityStore(seed?, key?)
// localStorage, für DemoshttpIdentityStore(AirWorks.request, "/identity")
GET /identity/users PUT/DELETE /identity/users/{id}
GET /identity/roles PUT/DELETE /identity/roles/{id}
GET /identity/permissionsOder IdentityStore (listUsers, saveUser, deleteUser, listRoles, saveRole, deleteRole, listPermissions) für ein beliebiges Backend implementieren.
AirWorks.configure({
serverBaseUrl: "/api",
accessProvider: () => AirWorks.request("/me"), // { identity?, roles, permissions }
});
const orders = await AirWorks.request("/orders"); // setzt "Authorization: Bearer <token>"
await AirWorks.request("/orders/7", { method: "PUT", body: JSON.stringify(order) }); // JSON-Content-Type wird gesetzt
request wirft bei Nicht-2xx, liefert JSON oder Text und bei 204 undefined. Über die Option request lässt sich ein eigener HTTP-Client nutzen. Serverseitig (ASP.NET Core, aus dem Beispiel):
api.MapGet("/me", (ClaimsPrincipal user, IdentityService identity) => identity.Describe(user));
api.MapGet("/reports", GetReports).RequirePermission("reports.read");
// RequirePermission: Endpoint-Filter, der Rollen des Aufrufers auf Rechte abbildet, sonst 403.
samples/AirWorks.Demo ist ein vollständig abgesichertes Setup: Aspire-AppHost, Keycloak (Realm-Import), eine ASP.NET-Core-API mit JWT-Prüfung und Rechteprüfung sowie ein Web-Host, der den AirWorks-Client (als Paket eingebunden) ausliefert und /api weiterleitet.
Mit QuickRun wird das Sample per Klick aus quickrun.yml geklont, gebaut und gestartet. Oder von Hand:
cd samples/AirWorks.Demo
dotnet run --project AirWorks.Demo.AppHost # Keycloak benötigt Docker
http://localhost:5177 öffnen und per Keycloak anmelden: demo/demo oder admin/admin (nur lokale Entwicklung).airworks-user und airworks-admin. Die API bildet Rollen auf Rechte ab (reports.read, reports.export, airworks.identity.manage) und liefert sie über /api/me.controller geben oder einer Rolle das im Client registrierte Recht notes.use – die App Notes erscheint sofort.AirWorks:Providers in AirWorks.Demo.Web/appsettings.json; dort stehen deaktivierte Beispiele für GitHub (über Keycloak Brokering) und Microsoft Entra ID./api/profile) und folgen dem Benutzer in jeden Browser.https://localhost:8080 mit dem ASP.NET-Core-Entwicklungszertifikat (dotnet dev-certs https --trust). Benutzer und Rollenzuordnungen hält das Beispiel im Speicher.const off = AirWorks.on("login", (session) => console.log(session));
AirWorks.on("logout", () => location.reload());
AirWorks.on("access-changed", ({ roles, permissions }) => {});
AirWorks.on("app-opened", ({ appId, windowId }) => {});
AirWorks.on("access-denied", ({ appId, requiredPermissions }) => {});
off();
Das Desktop-Element löst zusätzlich bubblende DOM-Events aus: airworks:login, airworks:logout, airworks:access-changed, airworks:app-opened, airworks:access-denied.
Die Shell bringt Englisch und Deutsch mit; umgeschaltet wird unter Einstellungen → Allgemein. Texte ergänzen oder überschreiben:
import { addMessages } from "@fgilde/airworks";
addMessages("fr", { close: "Fermer", settings: "Paramètres" });
AirWorks.configure({ locale: "fr" });
Fehlende Schlüssel fallen auf Englisch zurück. App-Titel übersetzt du selbst.
| Member | Beschreibung |
|---|---|
configure(options) | Siehe Optionen. |
registerApp(app), registerApps(apps), unregisterApp(id) | Apps verwalten. Doppelte IDs werfen einen Fehler. |
loadRemote(urlOrManifest) | Micro-Frontend-Manifest laden. |
openApp(id, intent?) | App öffnen (oder fokussieren); false, wenn unbekannt oder nicht erlaubt. |
setBadge(id, value?) | Zahl oder kurze Markierung auf Kacheln, Startmenü-Eintrag und Taskleistenknöpfen der App (Ungelesenes, offene Freigaben); leer oder 0 entfernt sie. |
registerSearchProvider(p), search(query) | Globale Inhaltssuche. |
registerPermission(s), can(id), access, refreshAccess() | Rechte. |
registerAuthProvider(p), login(id, values?), logout(), setSession(s), session, showLogin() | Anmeldung. |
request(path, init?) | Authentifizierter fetch. |
on(event, handler) | Abonnieren; liefert eine Abmeldefunktion. |
| Exporte | createOidcProvider, localAuthProvider, localIdentityStore, httpIdentityStore, MANAGE_IDENTITY, localProfileStore, httpProfileStore, addMessages, setLocale, AirDesktop, VERSION und alle Typen. |
Bei Einbindung per Script-Tag liegt dasselbe Objekt auf window.AirWorks.
src/airworks/src/
index.ts öffentliche API runtime.ts Apps, Optionen, Remote-Laden
desktop.ts <air-desktop> windows.ts Fenster, Taskleiste, Andocken
workspace.ts Webtops & Ordner grid.ts reines Rastermodell (npm run check)
grid-view.ts seitenweises Raster drag.ts Pointer-Drag-Engine
access.ts Sessions, Rollen oidc.ts OIDC-Provider
apps/ Einstellungen, Admin demo/ Client-only-Demo (GitHub Pages)
npm run check prüft das Rastermodell, npm run build baut Demo und Doku, npm run build:package die Bibliothek. Jeder Push auf main veröffentlicht Demo, Doku und /lib über .github/workflows/pages.yml auf GitHub Pages.