# JPKCom Desktop — Anleitung & Tipps

> JPKCom Desktop macht aus einer Website einen Desktop mit Fenstern, Dock und Apps — Vorlage, Konfiguration, eigene Apps, Datenschutz, PWA und Deployment mit strenger CSP.

Source: https://www.jpkc.com/db/guides/jpkcom-desktop/

JPKCom Desktop ist eine Weboberfläche im Stil eines klassischen Desktops — Fenster, Menüleiste, Dock und Apps im Browser, geschrieben in reinem JavaScript, für alle, die ihre Website als Desktop präsentieren wollen. Es besteht nur aus statischen Dateien: native ES-Module, kein Build-Schritt, keine Laufzeit-Abhängigkeiten und eine strenge Content Security Policy.

## Anleitung

### Was drin steckt

Der Desktop liegt auf einem beliebigen Webserver, in der Wurzel oder in einem Unterordner. Wie das Ergebnis aussieht, zeigt die [Live-Demo der unveränderten Beispiel-Website](https://jpkcom.github.io/jpkcom-desktop/). Diese Anleitung bezieht sich auf Version **1.4.0**; was in welchem Release dazukam, steht im [Changelog](https://www.jpkc.com/db/changelog/jpkcom-desktop/).

| Bereich | Umfang |
|---|---|
| Fenster | Verschieben, Größe ändern, Zoom per Doppelklick auf die Titelleiste, Andocken an Rand oder Hälfte mit Vorschau, Fensterübersicht (F3 oder Strg+↑), Fenster wiederherstellen beim nächsten Besuch (Webfenster auf der zuletzt gezeigten Seite, auch außerhalb des Desktop-Ordners), Deep-Links `#app=<id>`, `#app=<id>&path=/<pfad>` (Web-Apps mit `linkPaths: true`), `#search=<wörter>`, `#/pfad` |
| Rund um die Fenster | Menüleiste mit Markenmenü, Seitenmenüs, Suche, Sprachwahl, Wetter und Uhr (öffnet den Kalender); Dock; Symbole auf dem Schreibtisch; „Alle Apps“; Suche mit Strg/⌘+K oder `/`; Kontextmenüs per Rechtsklick, langem Druck oder Umschalt+F10 |
| Apps (optional, in `apps`) | Editor, Notizen, Aufgaben, Rechner, Terminal, Audio-/Videoplayer, Glückskeks |
| Kern | Einstellungen, Hintergrund, Sicherung, Papierkorb, „Über diesen Desktop“, „Bedienung“ |
| Module (optional, in `modules`) | Reader (Seiten deiner Website, bereinigt, ohne iframe), Bildbetrachter, Katalog (Sammlungen), Suche, Kalender mit Kalenderwochen, Feiertage (lokal berechnet), Wetter (Open-Meteo oder Bright Sky), Mitteilungen aus einem JSON Feed, Tresor für verschlüsselte private Lesezeichen |
| Darstellung | Dunkel, hell oder automatisch; Akzentfarben mit Kontrastprüfung; Kachelfarben; Hintergründe aus Farben, Verläufen, Bildern und erzeugten Motiven |
| Icons | Tabler (nur die benutzten) plus eigene Site-Icon-Sets, auch zweifarbig |
| Sprachen | Beliebig viele; Deutsch und Englisch liegen bei |
| Offline | Installierbar als PWA, Service Worker mit Schnellstart; Feeds und Daten kommen immer frisch vom Server |

Barrierefreiheit ist eingebaut: Die Menüleiste geht mit den Pfeiltasten, Fenster sind benannte Dialoge, der Fokus wird geführt und zurückgegeben, Änderungen werden über eine Live-Region angesagt, und `prefers-reduced-motion` wird respektiert.

### Voraussetzungen

- **Ein Webserver.** Jeder statische Server funktioniert. `index.html` direkt per `file://` zu öffnen klappt nicht: Browser laden ES-Module nicht von `file://`, und `fetch()`, Service Worker und gespeicherte Einstellungen brauchen einen echten Origin.
- **HTTPS** für Service Worker (Offline und Installation), Tresor (Web Crypto) und „Mein Standort“. Über HTTP läuft der Desktop trotzdem, nur ohne diese Funktionen. `http://localhost` und `http://127.0.0.1` gelten bei der Entwicklung als sicher.
- **Node.js 24 oder neuer und Git** — nur für die Werkzeuge. Der Desktop selbst braucht beides nicht. Entwicklungsabhängigkeiten sind nur die Tabler-Icon-Quellen und `playwright-core` für Headless-Checks (exakte Versionen in `package.json`).

### Eine eigene Kopie anlegen

Der Leitfaden „Deine eigene Website in 10 Minuten“ (`docs/quickstart.de.md`) führt in fünf Schritten durch: Kopie holen, Name/Autor/Logo, Inhalte, lokal prüfen, veröffentlichen. Für die Kopie gibt es drei Wege:

1. **Mit der Vorlage** (empfohlen): Auf der [Repository-Seite](https://github.com/JPKCom/jpkcom-desktop) **Use this template → Create a new repository** wählen und das neue Repository klonen.
2. **Direkt klonen:** `git clone https://github.com/JPKCom/jpkcom-desktop.git my-desktop`
3. **Ohne Git herunterladen:**

```sh
curl -fsSL -o jpkcom-desktop.zip https://github.com/JPKCom/jpkcom-desktop/archive/refs/heads/main.zip
unzip jpkcom-desktop.zip
cd jpkcom-desktop-main
```

### Lokal starten

```sh
sfw npm ci         # nur Entwicklungswerkzeuge: Tabler-Icon-Quellen, Browser-Checks
npm run serve      # http://127.0.0.1:8080/ mit den Sicherheits-Headern der Produktion
```

Einen Build-Schritt gibt es nicht: Datei ändern, Seite neu laden. `npm run serve` selbst braucht keine Pakete — `node tools/serve.mjs` läuft auch ohne `npm ci`. `sfw` ist [Socket Firewall Free](https://github.com/SocketDev/sfw-free) und blockiert bösartige Pakete schon bei der Installation; ein einfaches `npm ci` geht auch.

Optionen gibst du nach `--` weiter, etwa `npm run serve -- --port 3000 --host 0.0.0.0`:

| Option | Standard | Wirkung |
|---|---|---|
| `--port` | `8080` | Port |
| `--host` | `127.0.0.1` | `0.0.0.0` macht den Server im LAN erreichbar |
| `--base` | `/` | Unterordner testen, z. B. `--base /desktop/` (wie auf GitHub Pages) |
| `--root` | Projektordner | Auszuliefernder Ordner |
| `--connect` | — | Zusätzliche `connect-src`-Origins (kommagetrennt, nur `https://`) für eingeschaltete Online-Dienste |
| `--frame` | — | Zusätzliche `frame-src`-Origins (kommagetrennt, nur `https://`) für `web`-Apps auf anderen Origins |
| `--wasm` | — | Ergänzt `'wasm-unsafe-eval'` (nur für Pagefind) |
| `--geolocation` | — | `geolocation=(self)` in der Permissions-Policy (nur mit `services.geolocation: true`) |
| `--extra` | — | Liefert einzelne Dateien von außerhalb des Projekts unter `<base><url-pfad>` aus, z. B. `--extra site/config.js=/tmp/cfg.js,site/icon-sets/t.json=/tmp/t.json` — nur für Tests und Versuche, auf echten Servern gibt es das nicht |

Der Testserver beantwortet nur GET/HEAD, liefert für Ordner die `index.html` (ohne Schrägstrich am Ende erst nach einer Weiterleitung) und gibt Dotfiles, `node_modules`, `tools` und `tests` nie aus. Er sendet die Header über HTTP, also ohne `upgrade-insecure-requests` und HSTS.

### Der Ordner `site/`

Die Grundregel des Projekts: **`site/` gehört dir, `src/` ist das Projekt.** Lässt du `src/` unverändert, aktualisierst du später durch Ersetzen von `src/`, `locales/` und `sw.js` und erzeugst danach Icons und Vorlade-Hinweise neu (siehe „Eine bestehende Website aktualisieren“).

| Pfad | Inhalt |
|---|---|
| `site/config.js` | Klassisches Skript, setzt `window.DESKTOP_CONFIG` — jeder Schlüssel ist kommentiert |
| `site/apps.js` | Manifest als ES-Modul: `apps`, `collections`, `menus`, `files` |
| `site/theme.css` | Haus-Theme (Token-Overrides in `@layer themes`), wird ohne Regeln ausgeliefert |
| `site/content/<lang>/` | HTML-Seiten für den Reader, ein Ordner je Sprache (eine Datei je Seite), dazu Text-Handbücher fürs Terminal (Beispiel: `manuals/writing-pages.md`) — für den Service Worker sind das Daten |
| `site/data/` | Mitteilungs-Feeds (`feed.<lang>.json`), Glückskeks-Sprüche (`fortunes/<lang>.json`) und Daten, die eigene Module zur Laufzeit holen (`site/data/<modul-id>/`); der Service Worker holt sie immer zuerst vom Server |
| `site/icon-sets/` | Eigene Icon-Sets als JSON (optional, keins mitgeliefert; im öffentlichen Repository per `.gitignore` ausgeschlossen) |
| `site/wallpapers/` | Hintergrundbilder (leer ausgeliefert) |
| `site/vault/` | Versiegelte Tresor-Dateien (keine ausgeliefert) |
| `site/modules/` | Eigene Apps und Module, Beispiel-App `hello/` |

Die mitgelieferten Inhalte sind eine Beispiel-Website (Über, Handbuch, Versionshinweise, Impressum, Datenschutz, Lesezeichen, Schaufenster), die du nach und nach ersetzt.

### Konfiguration in `site/config.js`

Jeder Schlüssel ist optional. Fehlende Werte kommen aus den `DEFAULTS` in `src/core/config.js`; ungültige Werte meldet die Browser-Konsole und ersetzt sie durch den Standard — die Seite bricht nie. Beim Zusammenführen gilt:

- **Objekte** werden Schlüssel für Schlüssel mit den Standards gemischt.
- **Arrays und einfache Werte** ersetzen den Standard komplett — `modules: [...]` lässt jedes nicht genannte Modul weg.
- **Sprach-Maps** wie `{ de: '…', en: '…' }` ersetzen als Ganzes, ebenso `terminal.manUrl`. Beim Nachschlagen fällt der Wert von der Sprache auf die Basissprache, `defaultLang`, `'en'` und schließlich den ersten Eintrag zurück.
- In `theme.accents`, `theme.tints` und `services` entfernt `null` einen Standard-Eintrag.

Die wichtigsten Bereiche: `brand`, `author`, `credit`; `site` (`home`, `legal`, `hosts`, `routes`, `description`); `about`; `languages` und `defaultLang`; `theme`, `wallpaper`, `iconSets` (eigene Icon-Sets) und `iconReplace` (eigene Glyphen für die Symbole des Desktops); `wm`, `session`, `dock`, `desktop`, `boot`, `power`, `ui`; `modules` und `apps` (was fehlt, wird nicht einmal heruntergeladen); `services`; dazu eigene Abschnitte für Module und Apps (`reader` mit `keepStyles`, `styleScope` und `styleVars`, `search`, `notify` mit `label`, `weather`, `vault`, `fortune` mit `local` und `texts`, `terminal` …) und für den Offline-Betrieb (`pwa`, `offline` mit `legacyCaches`).

#### Name, Logo und Credit

Für deinen eigenen Auftritt ersetzt du `brand`:

```js
	brand: {
		name: 'My Desktop',            // Dokumenttitel, „Über diesen Desktop“, Terminal
		shortName: 'My Desktop',       // gleich short_name in manifest.webmanifest
		menuLabel: 'My Site',          // zugänglicher Name des Markenmenüs
		glyph: 'ti-device-desktop',    // Tabler-Icon oder Icon aus deinem Site-Icon-Set (z. B. 'acme-logo')
		logo: null,                    // kein ausführliches Logo
		asciiLogo: ['My Site'],        // Terminal-Bild (neofetch), ein String pro Zeile
		host: null,
		themeColor: '#1c2935'
	},
```

Dein Name kommt in `about.copyright: { holder: 'Erika Mustermann', since: 2026 }`. `author.name`, `author.brand`, `author.url` und `credit: true` bleiben bitte, wie sie sind — sie erzeugen die Credit-Zeile „JPKCom Desktop by Jean Pierre Kolb“. Aus `author.links` werden Apps im Hilfe-Menü und im Dock; du kannst sie behalten, ersetzen oder `links: []` setzen — dann ziehst du die Lesezeichen mit, die auf sie verweisen (siehe Tipps & Tricks).

Zum vollständigen Rebranding gehören außerdem:

1. `wallpaper.motifs` ohne `'author-monogram'` und `'author-emblem'`: `['author-blueprint', 'waves', 'dunes', 'aurora', 'orbit', 'horizon', 'graphite']`.
2. Eigene `assets/icons/favicon.svg` und `maskable.svg`, danach (nach `sfw npm ci`) einmal `npm run browsers` und dann `npm run icons:pwa`. `npm test` prüft die Dateien wörtlich: Beide beginnen mit `<svg` samt `viewBox="0 0 512 512"` (keine XML-Deklaration, kein Kommentar davor); `maskable.svg` hat als Hintergrund genau `<rect width="512" height="512" fill="url(#g)"/>` (Verlauf mit der ID `g`), nirgends `rx=` und hält das Motiv in den mittleren 80 %. Ein Platzhalter-Paar zum Kopieren steht im Schnellstart (Schritt 2).
3. Die festen Zeilen in `manifest.webmanifest` (`name`, `short_name`, `description`, `theme_color`, `background_color`) und in `index.html` (`<html lang>`, `<title>`, `<h1 id="desk-title">`, Beschreibung, `theme-color`, `apple-mobile-web-app-title`, `<noscript>` mit einer Zeile je Sprache). Die Meta-Tags `author` und `generator` bleiben.

### Apps, Seiten und Sammlungen in `site/apps.js`

Jede App braucht eine `id` aus `[a-z0-9-]` (1–64 Zeichen) und eine Art (`kind`):

| `kind` | Öffnet | Braucht Modul |
|---|---|---|
| `page` | Eine Seite aus `site/content/` im Reader | `reader` |
| `web` | Eine Seite im iframe-Fenster (gleicher Origin oder per `frame-src` erlaubt); optional mit `scope` und `linkPaths` | — (eingebaut) |
| `link` | Eine externe Seite im neuen Tab (`https://`, `http://` nur mit `allowHttp: true`) | — |
| `collection` | Ein Katalog-Fenster mit Gruppen | `catalog` |
| `image` | Ein Bild im Bildbetrachter | `viewer` |

Eine eigene Seite trägst du so ein — die Hilfsfunktion `page()` oben in der Datei baut den Pfad für jede Sprache:

```js
		{
			id: 'services', kind: 'page', icon: 'ti-file-text', tint: 'blue', desktop: true, dock: true,
			name: { en: 'Services', de: 'Leistungen' },
			url: page('services.html', 'leistungen.html')
		},
```

Die Seite selbst ist schlichtes HTML ohne Skripte, Styles oder `style`-Attribute. Der Reader nimmt das erste `main article`, dessen `h1` als Titel und den Absatz `.lead`; der Alternate-Link sagt ihm, welche Seite er nach einem Sprachwechsel zeigt:

```html
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Leistungen | My Site</title>
<link rel="alternate" hreflang="en" href="../en/services.html">
<link rel="stylesheet" href="../content.css">
</head>
<body><main><article>
<h1>Leistungen</h1>
<p class="lead">Das Angebot in ein, zwei Sätzen.</p>
<p>Text, Links, Listen, Tabellen …</p>
</article></main></body>
</html>
```

Weitere Bausteine:

- **Override-Einträge:** Eine App, die ein Modul mitbringt, änderst du mit ihrer `id` und nur den gewünschten Feldern, ohne `kind`: `{ id: 'notes', dock: true }`.
- **Aliase:** `{ id: 'alter-name', alias: 'neuer-name', hidden: true }` hält alte Links nach einer Umbenennung am Leben. Seit 1.3.0 steht ein Alias überall dort, wo der Desktop einer App einen Platz gibt, für sein Ziel: Gemerkte Dock-Listen und `dock.pins` lösen Alias-IDs auf das Ziel auf (das Dock zeigt dessen Name, Icon und Farbe), und auf dem Schreibtisch erscheint kein zweites Symbol mehr. `desktop: true` und `dock: true` gehören deshalb an die App selbst oder an ihren Override-Eintrag, nicht an den Alias. Ein Ring aus Aliasen (`a → b → a`) ist ein Fehler.
- **Web-Apps mit Unterseiten:** `scope` ist der Ordner, in dem ein Webfenster beim Wiederherstellen und in Links bleiben darf — relativ zum Desktop (`'demos/clock/'`) oder, für einen Ordner außerhalb, ab der Wurzel der Website (`'/wiki/'`), nie die Wurzel des Desktops oder ein Ordner darüber. Ohne `scope` gilt der erste Ordner der Startseite unterhalb des Desktops bzw. bei einer Startseite außerhalb deren eigener Ordner. Mit `linkPaths: true` behalten „Link zu diesem Fenster kopieren“ und `#app=<id>&path=/…` die Unterseite; standardmäßig ist das aus, und für Inhalte, die du nicht selbst kontrollierst, bleibt es aus (die gehören in eine Sandbox). Beispiel: `{ id: 'wiki', kind: 'web', icon: 'ti-book', url: '/wiki/start/', scope: '/wiki/', linkPaths: true, name: { en: 'Wiki', de: 'Wiki' } }`. Einträge einer Sammlung kennen `scope` und `linkPaths` ebenso.
- **Sammlungen** (`collections`): Jeder Eintrag in `items` wird zur App `<prefix>-<slug>`; die Sammlung bekommt ohne Code ein Katalog-Fenster, eine Suchgruppe und Menüeinträge. `itemKind: 'auto'` entscheidet pro Eintrag: `http(s)://` → `link`, Bildendung → `image`, sonst `web`. Der Knopf „Übersicht im Web“ des Katalogs startet die App aus `webApp` (sie hat Vorrang vor `webUrl`). Liegt die Übersichtsseite genau unter dem `basePath` der Sammlung, legst du dafür eine versteckte Web-App an — `{ id: 'tools-web', kind: 'web', hidden: true, url: 'tools/' }` — und setzt `webApp: 'tools-web'`; ein `webUrl`, das dem `basePath` gleicht, öffnet sonst einen neuen Tab. Für eine Übersicht im Reader genügt `webUrl: 'tools/index.html'`.
- **Handbuchseiten fürs Terminal** (`man`): `man <eintrag>` im Terminal zeigt ein Text-Handbuch (`.md`, `.markdown` oder `.txt` auf deiner Website). `man` steht an einem Eintrag (ein Pfad, `{ lang: pfad }` oder `false`) oder an der ganzen Sammlung als Vorlage mit `{slug}` oder `{id}` (weitere Platzhalter: `{collection}`, `{lang}`), z. B. `man: 'site/content/{lang}/manuals/{slug}.md'`. Es gilt zuerst das `man` des Eintrags, dann das der Sammlung, dann `terminal.manUrl` in `site/config.js` — das nimmt jetzt auch eine Map `{ lang: vorlage }` und gilt nur für die Sammlungen deiner Website, nie für die Lesezeichen des Tresors. Die Beispiel-Website zeigt es am Eintrag „Seiten schreiben“: `man: page('manuals/writing-pages.md')`, aufgerufen mit `man writing-pages`. Fehlt eine Datei, ist das kein Fehler: Das Terminal meldet, dass der Eintrag keine Handbuchseite hat, und bietet die Dokumentation an. Handbücher legst du am besten unter `site/content/` oder `site/data/` ab, dann behandelt der Service Worker sie als Daten.
- **Menüs** (`menus`): Einträge sind App-IDs, `'-'` als Trenner, `{ collection: id }`, `{ label, url }` oder ein Untermenü — Untermenüs gehen nur eine Ebene tief.
- **Terminal-Dateien** (`files`): Dateien, die `cat` im Terminal zeigt, z. B. `license: 'LICENSE'`; nur relative Pfade auf demselben Origin, `.md` wird als Markdown dargestellt.

Nach jeder Änderung prüft `npm run validate` das Manifest gegen `site/config.js` — IDs, Arten und nötige Module, Verweise, URLs, Icons, Kachelfarben und Texte für jede konfigurierte Sprache, außerdem die Site-Icon-Sets (IDs, Dateien, reservierte Präfixe, Definitionen, Größe), `man` und `terminal.manUrl` (fehlende Handbuchdateien zählen als Warnungen), `scope` und `linkPaths`, `webApp`, `webUrl`, `allLabel` und `webLabel` (auch an Override-Einträgen von Katalog-Apps) sowie `fortune.local` und `fortune.texts` (auch Platzhalter, die ein Schlüssel nicht kennt) und jedes Paar in `iconReplace`. Exit-Code 0 heißt in Ordnung, 1 Fehler (mit `--strict` bzw. `npm run validate:strict` auch Warnungen), 2 Manifest oder Konfiguration nicht ladbar. Ein Tabler-Icon, das der Desktop noch nicht verwendet (jeder Name `'ti-…'`/`'tif-…'`, siehe [Tabler Icons](https://tabler.io/icons)), braucht danach einmal `npm run icons` (nach `sfw npm ci`); bis dahin bleibt es leer, und `npm run validate` warnt. Icons aus einem Site-Icon-Set brauchen keinen Neubau.

### Sprachen

`languages: ['de', 'en']` legt die angebotenen Sprachen in Menü-Reihenfolge fest (zwei ergeben einen Umschalter, drei oder mehr ein Menü); `defaultLang: 'en'` greift, wenn der Browser keine davon verlangt. Beim ersten Besuch zählt seit 1.3.0 die Reihenfolge der Browsersprachen: Für jeden Eintrag von `navigator.languages` sucht der Desktop erst den exakten Tag, dann den gekürzten, dann eine andere Region derselben Sprache, bevor er zum nächsten Eintrag geht — mit `de-AT, en` startet eine de/en-Website also auf Deutsch. Eine gespeicherte Wahl und `?lang=` gehen immer vor.

Eine neue Sprache ist ein Ordner mit Übersetzungen:

```sh
cp -r locales/en locales/fr
# locales/fr/_meta.js: export default { name: 'Français', intl: 'fr-FR', dir: 'ltr', yes: '^(o|oui|y|yes)$' };
# site/config.js: languages: ['de', 'en', 'fr']
npm run i18n:check -- fr
npm run validate
```

Übersetze die Werte jeder Namespace-Datei und lass Schlüssel und `{platzhalter}` stehen. Plurale sind Objekte mit den Kategorien von `Intl.PluralRules` (`other` ist Pflicht). Danach brauchen alle Sprach-Maps in `site/config.js` und `site/apps.js` einen `fr`-Wert — auch die Seitenpfade: Die Hilfsfunktion `page()` in `site/apps.js` baut nur `en` und `de`, also erweiterst du sie um einen dritten Parameter oder schreibst die URLs als `{ en, de, fr }`, sonst warnt `npm run validate` („has no address for 'fr'“). Dasselbe gilt für die Texte in `fortune.texts` (prüft `npm run validate`) und für `man`-Maps der Form `{ lang: pfad }`. Außerdem brauchen Site-Module mit eigenen Texten eine `locales/fr/`-Datei, und `index.html` eine `<p lang="fr">`-Zeile im `<noscript>`. Nach einem Update meldet `npm run i18n:check` die neuen Schlüssel, die deinen eigenen Sprachordnern noch fehlen — mit 1.2.0 sind das `fortune.noSource`, `fortune.denyOnline`, `terminal.noManualPage` und `terminal.manOpen`, mit 1.3.0 kommt `notify.meta` dazu. Optional: `site/content/fr/`, `site/data/fortunes/fr.json` (plus `'fr'` in `fortune.langs`) und `site/data/feed.fr.json` (plus `notify.feeds.fr`). Fehlende Schlüssel brechen nie etwas — sie fallen auf Basissprache, `defaultLang` und Englisch zurück; `debug: true` listet sie in der Konsole.

### Erscheinungsbild und Haus-Theme

Modus, Akzent und Fensterknöpfe stellst du in `site/config.js` ein; Besucherinnen und Besucher können danach in den Einstellungen selbst wählen:

```js
theme: {
	default: 'dark',                                  // 'dark' | 'light' | 'auto' (folgt dem System)
	accent: 'blue',
	allowCustomAccent: true,
	accents: { brand: '#0f6b8f', pink: null },        // weißer Text braucht ≥ 4.5:1, null entfernt
	tints: { brand: ['#3fb6e0', '#0f6b8f'] },         // Kachelverlauf [oben, unten]; Apps: tint: 'brand'
	windowControls: { side: 'left', style: 'classic' } // 'left' | 'right', 'classic' (Punkte) | 'minimal'
},
```

Eigene Akzente bekommen ihren Namen als `accent.<id>` in `locales/<lang>/settings.js`, sonst zeigen die Einstellungen die ID.

**Hintergründe:** `wallpaper.default` ist ein Verlauf, eine Farbe (`{ type: 'color', color }`), ein Motiv (`{ type: 'svg', id }`) oder ein Bild (`{ type: 'image', id }`). Bilder (WebP oder AVIF, etwa 2560 × 1600) legst du in `site/wallpapers/` und trägst sie in `wallpaper.images` ein: `{ id, src: 'site/wallpapers/hafen.webp', name, credit, tone }`. `src` muss ein relativer oder Wurzelpfad sein (CSP `img-src 'self'`); `tone: 'light'` gibt der Menüleiste bei hellen Bildern dunkleres Glas.

**Haus-Theme:** Rundungen, Glas, Schatten und Farben überschreibst du als Tokens in `site/theme.css`, im Layer `themes`. Der steht in der Layer-Reihenfolge ganz hinten und gewinnt unabhängig von der Spezifität gegen alle anderen. `index.html` lädt die Datei direkt nach dem Kern-CSS und vor dem ersten Paint; einen Build-Schritt gibt es auch hier nicht.

```css
@layer themes {
	:root { --radius-control: 2px; --radius-panel: 4px; --radius-win: 4px; }
	body.compact { --radius-win: 4px; }
	:root[data-theme="light"] { --win-bg: #fbfaf7; }
	:root[data-theme="dark"], [data-island="dark"] { --win-bg: #1b2430; }
}
```

Die Regeln aus der Theming-Referenz (`docs/theming.md`): Dunkle Werte gehören immer auch auf `[data-island="dark"]` — Menüleiste, Desktop-Symbole, Terminal, Rechner, Code-Blöcke und Startbild bleiben in beiden Modi dunkel. Familien-Tokens wie `--radius-control` setzt du auf `:root`; auf `body.compact` oder einer Insel erreichen sie die Teil-Tokens nicht — dort überschreibst du stattdessen die Teil-Tokens (z. B. `--radius-win`). Schatten und Ringe (`--shadow-*`, `--ring-*`) gehören immer auf `:root, [data-island="dark"]`, weil die Inseln eigene deklarieren; Rundungs- und Glas-Tokens auf `:root` erreichen sie ohnehin. Akzent, Kachelfarben, Verlauf des Hintergrunds und `--anim` setzt der Desktop inline per JavaScript — die änderst du in `site/config.js`, nicht im CSS. Die mitgelieferte `site/theme.css` enthält ein auskommentiertes Beispiel „eckig und flach“ zum Ausprobieren.

### Eigene Icons: Site-Icon-Sets

Neben Tabler kann deine Website eigene Icons mitbringen, etwa ein Set, für das du eine Lizenz hast. Ein Site-Icon-Set ist eine JSON-Datei, die du in `site/config.js` einträgst — höchstens acht, Pfade relativ zur Installation, nur Buchstaben, Ziffern und `. _ - /`:

```js
iconSets: ['site/icon-sets/duotone.json'],
```

```json
{ "format": "jpkcom-desktop-icons/1",
  "name": "Acme duotone",
  "license": "Acme Icons 2.1 — kommerzielle Lizenz",
  "icons": {
    "acme-rocket": { "k": "d", "vb": "0 0 512 512", "e": ["M…"], "e2": ["M…"] },
    "acme-logo":   { "k": "f", "vb": "0 0 448 512", "e": ["M…"] } } }
```

- **IDs** haben die Form `<präfix>-<name>`: ein Präfix aus 2–12 Kleinbuchstaben und Ziffern, beginnend mit einem Buchstaben, nicht `ti`, `tif`, `wc`, `tile` oder `jpk`, insgesamt höchstens 64 Zeichen. In `site/apps.js`, in Tresor-Daten und in `brand.glyph` verwendest du sie wie Tabler-IDs.
- **Arten** (`k`): `'o'` Outline (im 24er-Raster; auf einem anderen Raster gibst du in `a` eine eigene `stroke-width` an, zusammen mit `fill: 'none'`, `stroke: 'currentColor'` und runden Linienenden und -ecken — `a` ersetzt die Attribute der Art), `'f'` gefüllt, `'d'` zweifarbig mit der zweiten Ebene in `e2`.
- **Zweifarbige Icons** stimmst du in `site/theme.css` mit `--icon-duo-opacity` (Standard `0.4`) und `--icon-duo-color` (Standard `currentColor`) ab. Die Tokens setzt du auf einen Container, etwa `.tile { --icon-duo-opacity: 0.5 }` in `@layer themes` — ein Selektor mit Vorfahren wie `.tile .i-duo` erreicht die Kopie hinter `<use>` nie.
- **Nur die benutzten Icons** gehören ins Set; die Teilmenge schreibt dein Konverter. `npm run validate` warnt ab 256 KiB; über 2 MiB oder 5000 Icons lehnt der Desktop das Set ab und startet ohne es.
- **Sets führen keinen Code aus:** Jede Definition läuft durch eine Allowlist für SVG-Tags und -Attribute (kein `g`, kein `style`, kein `url()`, kein `var()`). `npm run icons` baut weiterhin nur Tabler — für Set-Icons brauchst du keinen Neubau.
- **Lizenz:** Das Projekt liefert kein Set mit, und `site/icon-sets/*` steht in der `.gitignore`. Ein kommerziell lizenziertes Set gehört nie in ein öffentliches Repository oder einen Fork. Ein privates Site-Repository, das sein Set mit versioniert, löscht diese Zeile oder ergänzt `!site/icon-sets/<name>.json`. Auf GitHub Pages veröffentlicht eine öffentliche Kopie der Vorlage kein ignoriertes Set — ein lizenziertes Set legst du dort also gar nicht erst ab.

#### Die Symbole des Desktops ersetzen

Seit 1.3.0 zeichnet der Desktop auch seine eigenen Glyphen — Menüleiste, Fensterknöpfe, Einstellungen, Dock, Wetterlagen — auf Wunsch mit Icons aus deinem Set, damit alles in einem Stil erscheint. Dafür ordnest du in `site/config.js` jeder ID eines Desktop-Symbols ein Ziel zu:

```js
iconReplace: { 'ti-settings': 'acme-cog', 'ti-sun': 'acme-sun', 'wc-close': 'acme-xmark' },
```

- **Schlüssel** sind IDs mit `ti-`, `tif-`, `wc-` oder `tile-` (das Monogramm `jpk` bleibt), **Ziele** jedes bekannte Icon, meist aus deinem Set. Ersetzt wird in einem Schritt, nie verkettet; höchstens 500 Paare.
- `icon()` und `symbolHref()` lösen die Zuordnung an einer Stelle auf — Core, Module, Apps und Site-Module folgen ihr ohne Änderung.
- Danach `npm run icons` und `npm run validate`. Die ersetzten Tabler-Icons bleiben absichtlich in `src/icons/tabler.js`: Lädt das Set nicht, zeigt der Desktop das Original. Ein Paar, dessen Schlüssel oder Ziel der Browser nicht kennt, meldet die Konsole beim Start und lässt es weg.
- Eine ID mit zwei Bedeutungen wird an beiden Stellen ersetzt — die Wetterlagen Schneeregen und Hagel teilen sich `ti-cloud-snow`.

### Eine eigene App schreiben

Eigene Apps leben in `site/modules/<id>/` neben deinen Inhalten; dein Code berührt `src/` nicht — nur `npm run preload` erzeugt `src/boot/preload.js` neu (nach jedem Update, das `src/` ersetzt, erneut ausführen). Ausgangspunkt ist die Beispiel-App „Hallo“ in `site/modules/hello/` (`index.js`, `model.js`, `hello.css`, `locales/de/hello.js`, `locales/en/hello.js`). Sie zeigt ein Fenster, Texte mit Platzhalter und Plural, eigenes CSS, einen gespeicherten Wert mit Validierung, Sicherung und Zurücksetzen sowie das Terminal-Kommando `hello`.

1. Ordner kopieren: `site/modules/hello/` → `site/modules/<id>/`. Die ID beginnt mit einem Buchstaben, besteht aus `a–z`, `0–9` und `-` und hat höchstens 32 Zeichen.
2. Jedes `hello` im Ordner ersetzen, auch in Dateinamen: ID, i18n-Namespace, `@hello.…`-Schlüssel, Speicherschlüssel, Terminal-Kommando und CSS-Klassen. Ein zweites Kommando gleichen Namens wird abgelehnt. Den Anzeigenamen `Hallo`/`Hello` erfasst die Suche nach `hello` nicht: Schreib die Texte in `locales/<lang>/<id>.js` neu (`appName`, `appDesc`, Grüße, `cmd`, `cmdMan`).
3. In `site/config.js` eintragen: `apps: [ …, { id: 'my-app', src: 'site/modules/my-app/index.js' } ]`.
4. Prüfen und Vorlade-Hinweise neu erzeugen:

```sh
npm run icons        # nur bei einem neuen Tabler-Icon
npm run i18n:check   # jede Sprache aus locales/ braucht ihre Datei in deinem locales/<lang>/
npm run validate
npm run preload      # schreibt src/boot/preload.js neu
```

Beispiel nicht gewünscht? `{ id: 'hello', src: 'site/modules/hello/index.js' }` aus `apps` in `site/config.js` und den Ordner löschen, danach `npm run preload` ausführen und `tests/site-hello.test.mjs` löschen (oder auf das `model.js` deiner App umstellen).

Der Deskriptor ist der Default-Export von `index.js`. Die Kernfelder am Beispiel „Hallo“:

```js
export default {
	id: 'hello',                    // muss gleich der ID in site/config.js sein
	kind: 'app',
	i18n: ['hello'],
	locales: 'locales/',            // Texte: locales/<lang>/hello.js neben dem Code
	styles: ['hello.css'],
	app: { icon: 'ti-mood-smile', tint: 'green', size: [420, 360], name: '@hello.appName', desc: '@hello.appDesc' },
	storage: { [KEY]: { type: 'json', backup: true, reset: KEY, label: '@hello.appName', validate: clean } },
	resetGroups: [{ id: KEY, label: '@hello.appName', hint: '@hello.resetHint', order: 60 }],
	terminal: { [KEY]: { run: command, help: '@hello.cmd', usage: 'hello [name]', man: '@hello.cmdMan' } },
	mount,
	focus: win => win.state[KEY].field.focus({ preventScroll: true }),
	relabel: win => win.state[KEY].draw(),
	unmount: win => win.state[KEY].off()
};
```

Für größere Apps lohnt **Fenstercode bei Bedarf** (neu in 1.1.0): `app.load: () => import('./window.js')` lädt DOM, CSS (`windowStyles`) und Bibliotheken erst beim ersten Öffnen. Der Import muss als Literal dastehen, und der Deskriptor darf die Fenster-Datei nie statisch importieren — sonst landet sie wieder im Start. Vorbild ist `src/apps/calc/`. Für DOM und Styles gelten die CSP-Regeln des Projekts: kein `innerHTML`, kein `style=""`; DOM baust du mit `Desk.h()` und `textContent`, Styles per CSSOM, CSS liegt in `@layer apps` mit Tokens statt Farbliteralen.

Seit 1.2.0 kommen drei Regeln für Site-Module dazu:

- **Icons** baust du nur mit `Desk.icons.icon(id)`. Brauchst du die `<use>`-Referenz in einer eigenen Grafik, liefert `Desk.icons.symbolHref(id)` sie (`'#i-<id>'` oder `null`). Schreib nie `href="#ti-…"` von Hand: Sprite-Symbole haben jetzt die DOM-ID `i-<icon-id>`, und Element-IDs mit `i-` am Anfang sind reserviert. `Desk.icons.appGlyph(app, { cls, fallback })` gibt dir das Icon einer App ohne Kachel.
- **Daten, die dein Modul zur Laufzeit liest** (JSON, Markdown), gehören nach `site/data/<id>/` und kommen per `Desk.net.getJson` bzw. `getText`, nie per Import. Sie sind immer aktuell; Dateien im Modulordner sind Code. Ändere Datenformate verträglich: Felder ergänzen statt umbenennen oder entfernen, für ein unverträgliches Format ein neuer Dateiname.
- **Eine Online-Quelle für den Glückskeks** bringst du über `fortuneProviders` im Deskriptor mit (siehe „Glückskeks: nur online, eigene Quelle, eigene Texte“).

Seit 1.4.0 kommen zwei Werkzeuge dazu:

- **Importe mit berechnetem Namen** (`` import(`./parts/${name}.js`) ``) sieht der Service Worker nicht, denn er baut die Offline-Kopie aus den Importen, die als Literal im Quelltext stehen (Importe in Kommentaren zählen nicht). Solche Dateien listest du im Deskriptorfeld `precache: ['parts/a.js', 'parts/b.js']` auf — Literale relativ zur Deskriptor-Datei, `.js`/`.mjs`, `.css` oder `.json`, jede Datei, die der Import erreichen kann. Ohne das Feld kommt eine Datei erst in die Kopie, wenn sie einmal online geladen wurde, und fehlt offline bis dahin. So hält das Feiertagsmodul seine Regionsdateien offline bereit. Der Loader selbst ignoriert das Feld.
- **Speicherschlüssel:** Eigener Code, der Schlüssel über das Präfix `<namespace>-` sucht, nimmt `Desk.store.names()` oder `Desk.store.owns(key)` — das Präfix allein trennt einen Desktop nicht von einem zweiten mit längerem Namespace (siehe Tipps & Tricks). Schlüssel außerhalb der `storage`-Deklaration, etwa eine ganze Familie von Namen, meldet dein Modul mit `Desk.store.claim()` an.

### Online-Dienste und Datenschutz

Der Desktop kommt ohne Tracking, Analytics, Cookies und Code von Dritten aus. Einstellungen, Notizen, Aufgaben, Editor-Entwurf und Terminal-Verlauf bleiben in `localStorage` und IndexedDB auf dem Gerät; lokale Dateien werden nie hochgeladen. Online-Dienste haben zwei Schranken, beide standardmäßig zu: Die Website muss den Dienst in `services` einschalten, **und** jede Besucherin und jeder Besucher muss vor der ersten Anfrage zustimmen (widerrufbar unter Einstellungen → Online-Dienste). Anfragen an andere Origins gehen ohne Cookies und ohne Referrer raus.

| Dienst | In `site/config.js` | In die CSP des Servers |
|---|---|---|
| Wetter (Open-Meteo, weltweit) | `'weather'` in `modules`, `services.weather: true`, `weather.provider: 'open-meteo'` | `connect-src https://api.open-meteo.com` |
| Wetter (Bright Sky, nur Deutschland) | wie oben, `weather.provider: 'brightsky'` | `connect-src https://api.brightsky.dev` |
| „Mein Standort“ fürs Wetter | wie Wetter, zusätzlich `services.geolocation: true` | `Permissions-Policy: geolocation=(self)` |
| `dig`/`host` im Terminal | `services.dns: true`, `terminal.doh: { url: 'https://dns.google/resolve', name: 'dns.google' }` | `connect-src https://dns.google` |
| Glückskeks online | `services.fortune: true`, `fortune.remote: 'jokeapi'` oder `'uselessfacts'` | `connect-src https://v2.jokeapi.dev` bzw. `https://uselessfacts.jsph.pl` |
| Glückskeks, Online-Quelle aus deinem eigenen Modul | `services.fortune: true`, `fortune.remote: '<id>'` | `connect-src https://<deine Hosts>` |
| Pagefind-Volltextsuche | `search.pagefind: { path: 'pagefind/pagefind.js' }` | `script-src 'wasm-unsafe-eval'` (Policy des Desktops und von `pagefind-worker.js`) |

Fehlt der Host in der CSP, blockiert der Browser die Anfrage („Refused to connect“). Bei Pagefind gehört `'wasm-unsafe-eval'` in die Policy, sobald `search.pagefind` gesetzt ist: Die Suche kompiliert ihr WebAssembly im Worker `pagefind-worker.js` und weicht auf die Seite des Desktops aus, wenn der Worker scheitert oder nicht binnen fünf Sekunden startet — dafür genügt schon eine langsame Verbindung. Die mitgelieferten Server-Konfigurationen senden die Policy des Desktops mit jeder Datei, also auch mit dem Worker. Erlaubt ist damit nur das Kompilieren von WebAssembly, kein `eval`. Lokal testest du das mit `npm run serve -- --connect https://api.open-meteo.com --wasm --geolocation`. Beim Standort speichert der Desktop nur die auf etwa 1 km gerundete Position. Impressum und Datenschutzerklärung der Beispiel-Website (`site/content/<lang>/imprint.html`, `privacy.html`) sind Vorlagen — füll sie vor dem Veröffentlichen aus oder entferne sie aus `site.legal` und `site/apps.js`.

Die Zustimmung zur Online-Quelle des Glückskekses ist an den Anbieter gebunden (ID und Hosts): Wechselst du ihn, fragt der Desktop erneut. Besucherinnen und Besucher, die der Quelle vor 1.2.0 zugestimmt haben, fragt er nach dem Update einmal neu.

#### Glückskeks: nur online, eigene Quelle, eigene Texte

- **Nur online:** `fortune.local: false` betreibt die App ohne eingebaute Sprüche — nichts wird vorab gespeichert, und das Terminal-Kommando `fortune` ist ausgeblendet. Das braucht ein `remote`; fehlt es, warnt die Konsole und es gilt wieder `true`.
- **Umbenannt:** Gibst du der App per Override in `site/apps.js` einen anderen Namen und ein anderes Icon (`{ id: 'fortune', name: {…}, icon: '…' }`), ersetzt `fortune.texts` die Texte, die die App beim Namen nennen; die Karte zeigt automatisch Logo, Zeichen oder Icon der App. Ersetzbare Schlüssel: `next`, `prev`, `copy`, `copied`, `loading`, `empty`, `localError`, `noSource`, `sourceLocal`, `askTitle`, `allow`, `deny`, `denyOnline`, `service`, `serviceHint`, `cmd`, `cmdMan` — seit 1.3.0 außerdem die Zustimmungsfrage (`askText`, `askText2`), der Satz zur Sprache der Texte (`askLang`), der Tastenhinweis (`keys`), „Mehr erfahren“ (`web`), die Fehlermeldung (`error`) und die Bezeichnung unter Sicherung und Zurücksetzen (`storageLabel`). Platzhalter füllt die App je Schlüssel: `askText` kennt `{provider}` und `{host}`, `askLang` `{language}`, `keys` `{space}`, `{back}` und `{next}`, `error` `{host}`. Einen Platzhalter, den der Schlüssel nicht kennt (etwa `{hots}`), meldet `npm run validate`. Eine ersetzte Zustimmungsfrage muss weiterhin nennen, wer die Anfrage bekommt — `{host}` oder den Host ausgeschrieben —, sonst warnen Konsole und Validator.

```js
fortune: { remote: 'example', local: false, texts: { next: { en: 'Next fact', de: 'Nächster Fakt' } } },
```

- **Eigene Quelle:** Ein Site-Modul bringt sie deklarativ mit — als schlichtes Objekt-Literal, mit einem bis acht Hosts und ohne `requires: ['fortune']`; danach `npm run preload`:

```js
fortuneProviders: [{
	id: 'example', name: 'Example facts', hosts: ['api.example.org'],
	url: ({ cat }) => `https://api.example.org/random${cat ? `?category=${encodeURIComponent(cat)}` : ''}`,
	parse: (j, { plain }) => ({ text: plain(j.text), lang: 'en' })
}]
```

Alle Details stehen in der [Glückskeks-Referenz](https://github.com/JPKCom/jpkcom-desktop/blob/main/docs/packages/p11-fortune-site.md).

### Private Lesezeichen: der Tresor

Der Tresor zeigt verschlüsselte Lesezeichen, nachdem jemand `login` im Terminal eingibt. Aus Benutzername und Passwort leitet PBKDF2-HMAC-SHA-256 einen AES-256-GCM-Schlüssel und den Dateinamen ab; falsche Zugangsdaten fragen schlicht eine nicht vorhandene Datei an.

```sh
node tools/seal-vault.mjs --new-salt              # Salt in site/config.js eintragen
npm run seal -- --in ~/private/bookmarks.json     # erzeugt site/vault/<32 hex>.bin
node tools/seal-vault.mjs --list                  # versiegelte Dateien und Parameter
```

Dazu `'vault'` in `modules` und `vault: { salt: '<dein Salt>', iterations: 600000 }` in `site/config.js`. Die Klartext-JSON (`groups` und `items`, höchstens 20 Gruppen und 500 Einträge) gehört außerhalb des Projekts und jedes Web-Roots (z. B. nach `~/private/`); das Werkzeug verweigert sie unter `site/`, im Zielordner und im zugehörigen Web-Root (standardmäßig das Projekt). Der Server darf `site/vault/` nicht auflisten und muss `.bin` mit `Cache-Control: no-cache` über HTTPS ausliefern. Der Schutz hängt an Passwortstärke und PBKDF2-Kosten: gedacht für private Links, nicht für Geheimnisse. Unter Einstellungen → Zurücksetzen erscheint die Zeile „Private Lesezeichen“ seit 1.3.0 nur, solange der Tresor entsperrt ist oder ein Login auf dem Gerät gemerkt ist; wer den Desktop nur besucht, erfährt also nicht, dass es einen Tresor gibt.

Tresor-Daten dürfen Icons aus Site-Icon-Sets verwenden. Ein Icon, das nur im Tresor vorkommt, muss trotzdem im Set stehen — und die Set-Datei ist öffentlich, sie verrät also, welche Icons der Tresor nutzt; nimm dort lieber neutrale Icons. `npm run seal` liest `iconSets` unterhalb des Web-Roots von `--out`: Versiegelst du direkt in ein Deployment (`--out <web>/site/vault/`), muss das Set auch in `<web>/site/icon-sets/` liegen. Webfenster erreichen den Tresor-Ordner übrigens nie, auch nicht über Prozent-Kodierung im Pfad.

### Offline und Installation

Mit `pwa: { enabled: true }` (Standard), HTTPS und dem Manifest-Link in `index.html` registriert der Desktop `sw.js`; installieren lässt er sich unter Einstellungen → Allgemein oder über das Browser-Menü. Mit `offline.fastStart: true` startet er aus der Offline-Kopie — das gilt für den **Code** des Desktops. Einige Sekunden nach dem Start vergleicht der Service Worker im Hintergrund jede Code-Datei mit dem Server, lädt bei Änderungen eine vollständige neue Kopie und bietet dann an, die Seite neu zu laden. Code wechselt so nur als Ganzes und mischt sich nie; ein Code-Update sehen Besucherinnen und Besucher erst einen Reload später.

Daten dagegen sind immer aktuell: Feeds (`notify.feeds`, auch außerhalb des Desktop-Ordners), die Glückskeks-Dateien und alles unter `site/data/` und `site/content/` holt der Service Worker immer zuerst vom Server (offline: die letzte Kopie). Die Prüfung im Hintergrund überspringt sie, ein neuer Feed-Eintrag erscheint also sofort und löst nie „Eine neue Version des Desktops ist bereit“ aus. Zum Code zählen dagegen `site/config.js`, `site/apps.js`, `site/theme.css`, Icon-Sets, Site-Module und Hintergrundbilder. Ein Pagefind-Bundle legst du außerhalb von `site/` ab (z. B. `pagefind/`), sonst gilt jeder Neubau des Suchindex als neue Version.

```js
pwa: { enabled: true },
offline: { maxPages: 80, timeoutMs: 4000, fastStart: true, legacyCaches: [] }   // fastStart: false = Netzwerk zuerst
```

Seit 1.4.0 kündigt der Desktop jede Änderung am Code an, die er ausführt — auch ein neues `sw.js` oder `site/config.js`, das den Cache-Namen behält (ein Kommentar, `offline.timeoutMs`, `offline.legacyCaches`). Findet die Prüfung eine Änderung, lässt sie den Browser zuerst `sw.js` vergleichen: Ein neuer Worker installiert sich sofort, die Prüfung hört auf — auch mitten im Laden der Kopie —, und die halb geladene Kopie wird gelöscht, nie als vollständig markiert. So wartet ein neuer Worker nie auf eine Kopie, die er ohnehin verwerfen würde. Antwortet der Server mit einem Serverfehler oder einem Rate-Limit (5xx, 408, 429), zählt das wie ein Netzwerkausfall, sodass nie ein Update mit der alten Fassung einer geänderten Datei fertig wird; Dateien mit `no-store` oder `private` dagegen fehlen einfach in der Kopie. Und die Kopie ergänzt sich selbst: Fehlen ihr Dateien — etwa weil ein anderer Service Worker deiner Website jeden Cache löscht, den er nicht kennt — oder erreichte eine Installation nicht jede Datei, vervollständigt sie die nächste Prüfung (mit `fastStart`, dem Standard). Hat der Server noch denselben Code, kommen die fehlenden Dateien still hinzu, ohne „neue Version“. Auch die Regionsdatei der Feiertage (`holidays.region`) gehört seit 1.4.0 zur Kopie, der Kalender zeigt Feiertage also auch offline.

Wechselst du `fastStart`, beginnt eine frische Offline-Kopie. `pwa: { enabled: false }` meldet einen vorhandenen Service Worker beim nächsten Besuch ab und löscht seine Caches — das klappt nur, solange `sw.js` hochgeladen bleibt, denn der Browser bemerkt die Änderung, indem er die Datei neu lädt.

**Einen früheren Service Worker ablösen:** Hatte deine Website schon vor dem Desktop einen Service Worker, trägst du dessen Cache-Namen in `offline.legacyCaches` ein — exakte Namen oder ein Präfix mit `*` am Ende (davor mindestens vier Zeichen, höchstens 32 Einträge):

```js
offline: { legacyCaches: ['oldsite-shell-*', 'oldsite-pages'] }
```

Der Desktop löscht sie, wenn der neue Worker übernimmt, etwa 30 Sekunden später noch einmal, bei jedem Start und über Einstellungen → Zurücksetzen → „Offline-Kopien“; die eigenen Caches des Desktops rührt er dabei nie an. Seit 1.4.0 löscht nur der Worker, der gerade zuständig ist: Sobald ein neuerer installiert wird, wartet oder übernimmt, hört er damit auf — gehst du zurück zum alten Worker (alte Dateien, altes `sw.js` unter derselben URL), bleiben dessen Caches also stehen. Ersetzt wird der alte Worker nur, wenn `sw.js` denselben Scope hat oder unter der alten Skript-URL im Installationsordner liegt — sonst stellst du unter der alten URL einen Worker bereit, der sich selbst abmeldet. Trag nur Namen ein, die dein alter Code angelegt hat, denn ein Präfix trifft jeden Cache des Origins. Sobald keine Besucherinnen und Besucher der alten Version mehr zurückkommen, nimmst du die Einträge wieder heraus. Nötig ist das nur für Websites, die einen anderen Worker ablösen; die Einzelheiten stehen in [`docs/deploy.md` §11](https://github.com/JPKCom/jpkcom-desktop/blob/main/docs/deploy.md#11-offline-use-and-installation-pwa).

### Veröffentlichen

Der Desktop läuft in der Wurzel oder in jedem Unterordner, ohne dass du eine Datei änderst — alle Pfade sind relativ zum Ordner mit der `index.html`. Hochladen: `index.html`, `manifest.webmanifest`, `sw.js`, `assets/`, `locales/`, `site/`, `src/`, `LICENSE` und `CREDITS.md`. **Nicht** hochladen: `node_modules/`, `tools/`, `tests/`, `docs/`, `.git/`, `package.json`, `package-lock.json` — die Server-Konfigurationen sperren nur Dotfiles.

#### Auf GitHub Pages

1. Änderungen auf den Branch `main` pushen — beide Workflows reagieren nur auf Pushes nach `main` (CI zusätzlich auf Pull Requests; beide lassen sich von Hand starten). Nach einem direkten Klon legst du zuerst ein leeres Repository auf GitHub an (ohne README, ohne Lizenz), dann `git remote set-url origin https://github.com/<user>/<repo>.git` und `git push -u origin main`. Nach einem Download: `git init -b main`, `git add -A`, `git commit -m "…"`, `git remote add origin https://github.com/<user>/<repo>.git` und `git push -u origin main`.
2. **Settings → Pages → Build and deployment → Source:** „GitHub Actions“.
3. **Settings → Secrets and variables → Actions → Variables:** `PAGES` = `true`. Ohne die Variable überspringt eine Kopie der Vorlage den Pages-Job stillschweigend.
4. **Actions → Pages → Run workflow** oder erneut pushen. Ergebnis: `https://<user>.github.io/<repo>/`.

Weil GitHub Pages keine Header senden kann, setzt `.github/workflows/pages.yml` die Basis-CSP als `<meta http-equiv>` direkt hinter die einzige Zeile `<meta charset="utf-8">` in `index.html`. Ohne `frame-ancestors`, Permissions-Policy, HSTS und die übrigen Sicherheits-Header ist das der schwächere Weg. Schaltest du Online-Dienste ein, trägst du ihre Hosts (und ggf. `'wasm-unsafe-eval'` für Pagefind bzw. `frame-src`-Origins) zusätzlich in die CSP-Zeile von `pages.yml` ein. Die Meta-CSP schützt nur die Desktop-Seite selbst, nicht den Service Worker oder andere Seiten auf demselben Origin. Der Workflow veröffentlicht den ganzen Checkout außer `.git`, `.github`, `node_modules`, `tests` und `tools` — anders als beim eigenen Server also auch `docs/`, `package.json`, `package-lock.json` und die READMEs (nichts davon ist geheim).

#### Auf dem eigenen Server

Für jeden unterstützten Server liegt eine fertige Konfiguration in `docs/server/`:

| Server | Datei | Ziel | Prüfen und neu laden |
|---|---|---|---|
| Apache 2.4.10+ | `apache.htaccess` | `.htaccess` im Ordner der `index.html` | wirkt sofort; `apachectl configtest` prüft nur die vhost-Konfiguration (`AllowOverride`), ein Fehler in der `.htaccess` zeigt sich als 500 im Error-Log |
| nginx 1.19+ | `nginx.conf` | `/etc/nginx/conf.d/desktop.conf` | `nginx -t && nginx -s reload` |
| Caddy 2.7+ | `Caddyfile` | `/etc/caddy/Caddyfile` | `caddy validate --config /etc/caddy/Caddyfile && systemctl reload caddy` |
| Ferron 3 (Release Candidate, geprüft mit 3.0.0-rc.9) | `ferron.conf` | `/etc/ferron/ferron.conf` | `ferron validate -c /etc/ferron/ferron.conf`, dann neu starten |
| Ferron 2 (stabile Linie, geprüft mit 2.8.1) | `ferron.kdl` | `/etc/ferron.kdl` | neu starten |
| static-web-server 2.x | `static-web-server.toml` | `/etc/static-web-server/config.toml` | `static-web-server --config-file /etc/static-web-server/config.toml` |

Anzupassen sind jeweils nur Hostname, Root und Zertifikatspfade; bei Apache gar nichts, dort braucht der vhost aber `AllowOverride FileInfo Indexes Options=Indexes` (sonst antwortet Apache mit 500). Jede Konfiguration sendet diese Basis-Policy und dazu Permissions-Policy, `X-Frame-Options: SAMEORIGIN`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, `Cross-Origin-Opener-Policy: same-origin` und HSTS:

```text
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self'; connect-src 'self' blob:; frame-src 'self'; worker-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; upgrade-insecure-requests
```

Außerdem leiten alle Konfigurationen `/desktop` auf `/desktop/` weiter, listen keine Verzeichnisse, liefern `.js` als `text/javascript` und senden `Cache-Control: no-cache` für HTML, JS, CSS, JSON, Manifest und `sw.js` (Bilder, Schriften und Medien: `public, max-age=86400`). Seit 1.3.0 setzt die nginx-Konfiguration dazu `expires off;`, damit ein `expires` aus dem `http { }`-Block keinen zweiten `Cache-Control`-Header samt `Expires` an den Code hängt; die Apache-`.htaccess` schaltet `mod_expires` mit `ExpiresActive Off` ab. Änderungen an Feeds und an Dateien unter `site/data/` und `site/content/` erreichen Besucherinnen und Besucher sofort, Code-Änderungen einen Reload später. Bringt ein Deployment ein neues Icon mit, lädst du deshalb erst den Code hoch, der das Icon kennt, und dann die Daten, die es nennen. Ein Deployment prüfst du mit:

```sh
curl -sI https://example.org/desktop/ | grep -iE '^(content-security|permissions|cache-control|x-|strict|referrer|cross-origin)'
curl -sI https://example.org/desktop | grep -iE '^(HTTP|location)'
curl -sI https://example.org/desktop/src/boot/main.js | grep -i '^content-type'
curl -s -o /dev/null -w '%{http_code}\n' https://example.org/desktop/site/vault/
curl -s -o /dev/null -w '%{http_code}\n' https://example.org/desktop/.git/config
```

### Eine bestehende Website aktualisieren

Den neuen Stand holst du in einer Git-Kopie mit `git remote add upstream https://github.com/JPKCom/jpkcom-desktop.git && git fetch upstream --tags` und übernimmst die Dateien gezielt per `git checkout v1.4.0 -- src sw.js locales tools package.json package-lock.json .npmrc .github/workflows` — das klappt auch bei Kopien aus der Vorlage ohne gemeinsame Historie. Ohne Git lädst du `https://github.com/JPKCom/jpkcom-desktop/archive/refs/tags/v1.4.0.zip` herunter. Lies danach die „Upgrade notes“ jedes übersprungenen Release in `CHANGELOG.md` — kommst du von 1.0.x, gehören die Schritte für 1.1.0 dazu (`site/theme.css`, zwei neue Zeilen in `index.html`, Node.js 24). Anschließend `npm run icons`, `npm run preload`, `npm run i18n:check`, `npm run validate` und `npm test` ausführen. `tests/` übernimmst du nur, wenn du die Tests der Beispiel-Website danach erneut anpasst.

Für 1.4.0 im Einzelnen:

1. Das neue `sw.js` mit hochladen: Der Cache bekommt einmalig einen neuen Namen (neue Version), Besucherinnen und Besuchern wird ein Reload angeboten. Mehr braucht es für die Korrekturen nicht; `src/boot/preload.js` ist unverändert.
2. `site/config.js`: Der Kommentar zu `namespace` hat sich geändert (eine Empfehlung) — übernimm ihn, wenn du eine eigene Datei pflegst.
3. Mehrere Desktops auf einem Origin, deren Namespaces sich überschneiden (`jpkdesk` und `jpkdesk-next`): Bring jeden davon auf 1.4.0 — ein Desktop mit 1.3.0 oder älter zählt die Schlüssel des längeren Namespace noch zu seinen eigenen und löscht sie mit „Alles zurücksetzen“. Für neue Installationen wählst du Namespaces, bei denen keiner der andere plus `-…` ist (`desk-a`, `desk-b`); ein geänderter Namespace startet mit leeren Einstellungen.
4. Site-Module, die eine Datei mit berechnetem Namen importieren (`` import(`./parts/${name}.js`) ``), können diese Dateien im neuen Deskriptorfeld `precache: [...]` aufführen, damit sie zur Offline-Kopie gehören; ohne das Feld kommen sie wie bisher beim ersten Laden online hinein.
5. Eigener Code, der jeden `localStorage`-Schlüssel mit `<namespace>-` am Anfang gelesen hat, nimmt `Desk.store.names()` oder `Desk.store.owns(key)`; Module mit Schlüsseln außerhalb ihrer `storage`-Deklaration melden sie mit `Desk.store.claim()` an.

Kommst du von 1.2.x, gilt zusätzlich für 1.3.0:

1. Das neue `sw.js` mit hochladen: Der Cache bekommt einmalig einen neuen Namen, Besucherinnen und Besuchern wird ein Reload angeboten.
2. `npm run preload` einmal ausführen, wenn du ein eigenes `src/boot/preload.js` pflegst — die Vorhersage der Startsprache darin folgt jetzt `matchLanguage()` aus `src/core/i18n.js`.
3. Optional die kommentierten Einträge für `iconReplace`, `notify.label` und `reader` (`keepStyles`, `styleScope`, `styleVars`) sowie die überarbeiteten Kommentare zu `search.pagefind` und `fortune.texts` in dein `site/config.js` übernehmen. Ohne sie gelten die Standards.
4. In eigenen Sprachordnern `notify.meta` ergänzen.
5. nginx: `expires off;` neben `add_header Cache-Control $desk_cache_control;` in den Server-Block setzen. Apache (optional): den neuen Block `<IfModule mod_expires.c>` mit `ExpiresActive Off` aus `docs/server/apache.htaccess` übernehmen — `AllowOverride FileInfo Indexes Options=Indexes` deckt ihn bereits ab.
6. Pagefind: `'wasm-unsafe-eval'` in der Policy des Desktops lassen, solange `search.pagefind` gesetzt ist, auch wenn dein Bundle ohne Policy ausgeliefert wird.
7. Override-Einträge wie `{ id: '<alias>', desktop: false }`, die doppelte Symbole von Aliasen verhindern sollten, kannst du löschen. Gemerkte Dock-Listen mit Alias-IDs schreibt der Desktop beim ersten Start einmal um.
8. Eigenes CSS, das die Tresor-Zeile unter Zurücksetzen versteckt hat (etwa `.set-row:has(> input[data-key="rs-vault"])`), entfernen — es würde die Zeile jetzt auch im entsperrten Zustand verbergen. Eigener Code mit `storage.resetGroups()` bekommt nur noch die angezeigten Gruppen; `{ all: true }` liefert alle.
9. Reader: Code-Blöcke mit Farben als `style="color:…"` zeigen diese jetzt; `reader.keepStyles: false` stellt den alten Stand her.
10. Eigener Code mit `i18n.displayName(code, inLang)` bekommt den Namen jetzt in der Sprache `inLang`; für die Eigenbezeichnung rufst du `displayName(code)` auf.

Kommst du von 1.1.x, gilt zusätzlich für 1.2.0:

1. Das neue `sw.js` mit hochladen: Der Cache bekommt einmalig einen neuen Namen, Besucherinnen und Besuchern wird ein Reload angeboten. `index.html`, `manifest.webmanifest` und die Workflows haben sich nicht geändert.
2. `npm run preload` ausführen — der Boot-Graph enthält jetzt `src/core/man.js` und `src/core/icon-sets.js`, und `preload:check` in der CI braucht die neue Datei.
3. Optional die neuen, kommentierten Schlüssel in dein `site/config.js` übernehmen: `iconSets`, `fortune.local` und `fortune.texts`, `offline.legacyCaches`. Ohne sie gelten die Standards, und nichts ändert sich.
4. In eigenen Sprachordnern `fortune.noSource`, `fortune.denyOnline`, `terminal.noManualPage` und `terminal.manOpen` ergänzen.
5. Hast du `offline.fastStart: false` nur wegen der Feeds gesetzt, kannst du das wieder entfernen.
6. Laufzeitdaten gehören nach `site/data/`, Skripte nicht nach `site/data/` oder `site/content/`, ein Pagefind-Bundle nicht nach `site/`.
7. Ein `webUrl`, das dem `basePath` der Sammlung gleicht, öffnet jetzt einen neuen Tab. Soll die Übersicht in einem Fenster aufgehen, ergänzt du eine versteckte Web-App und `webApp`.
8. `scope`-Werte, die mit `/` beginnen, wurden bisher ignoriert und wirken jetzt.
9. Site-Module, die `<use href="#ti-…">` selbst schreiben, stellst du auf `Desk.icons.icon()` oder `Desk.icons.symbolHref()` um.
10. `terminal.manUrl` gilt nicht mehr für die Lesezeichen des Tresors. Empfohlen: `.md`/`.txt` aus `docs` nach `man` verschieben und `docs` auf die Seite zeigen lassen.
11. `npm run validate:strict` zeigt womöglich neue Warnungen (z. B. fehlende Handbuchdateien oder `webUrl` gleich `basePath`).
12. Besucherinnen und Besucher, die der Online-Quelle des Glückskekses zugestimmt haben, werden einmal neu gefragt.
13. `site/icon-sets/*` in deine `.gitignore` aufnehmen — oder in einem privaten Repository, das sein Set mit versioniert, `!site/icon-sets/<name>.json`.

Sicherheitskorrekturen gibt es nur für das jeweils neueste Release der neuesten Minor-Version (derzeit 1.4.x); 1.3.x, 1.2.x, 1.1.x und 1.0.x werden nicht mehr unterstützt.

### Entwickeln und prüfen

| Befehl | Zweck |
|---|---|
| `npm run serve` | Testserver mit Produktions-Headern |
| `npm test` | Unit-Tests (`node --test "tests/*.test.mjs"`) |
| `npm run validate` / `validate:strict` | `site/apps.js` gegen `site/config.js`, inklusive Site-Icon-Sets, Handbuchseiten, `scope`/`linkPaths` |
| `npm run i18n:check` | Alle Sprachen gegen Englisch, inklusive `site/modules/*/locales/` |
| `npm run icons` / `icons:check` | Icon-Teilmenge `src/icons/tabler.js` erzeugen bzw. prüfen (nur Tabler, Site-Icon-Sets baut es nicht) |
| `npm run preload` / `preload:check` | `src/boot/preload.js` erzeugen bzw. prüfen |
| `npm run icons:pwa` | PNG-Icons aus `favicon.svg` und `maskable.svg` rendern |
| `npm run browsers` | Headless-Chromium für Icons und Browser-Checks holen |
| `npm run check:browser` | Desktop im Headless-Browser laden; scheitert an Konsolen-, CSP- und Request-Fehlern |
| `npm run seal` | Tresor-Datei versiegeln; Tresor-Icons dürfen aus Site-Icon-Sets stammen |

Die CI (`.github/workflows/ci.yml`) führt auf Node 24 nach `npm ci --ignore-scripts` und `npm audit signatures` aus: `npm test`, `npm run i18n:check`, `npm run icons:check`, `npm run preload:check` und `node tools/validate-manifest.mjs`. Die `.npmrc` setzt `ignore-scripts=true`, `save-exact=true` und `engine-strict=true`; installierende Befehle laufen am besten über `sfw`.

**Wichtig für eigene Kopien:** Einige Tests prüfen die mitgelieferte Beispiel-Website und schlagen fehl, sobald du sie ersetzt — der Block „The example site“ am Ende von `tests/p11-site.test.mjs`, in derselben Datei der Test „validator CLI: the example site has no errors and no warnings“ (läuft mit `--strict` — behebe lieber die Warnungen als den Test), „manifest: … brand defaults“ in `tests/p12-deploy.test.mjs`, `tests/site-hello.test.mjs`, sobald du die Beispiel-App `hello` entfernst oder umbenennst, in `tests/theming.test.mjs` die Prüfung „site/theme.css … ships without rules“ samt den Literal-Wächtern über `site/theme.css` und `site/modules/` und in `tests/hygiene.test.mjs` der Test „hygiene: no file in site/icon-sets/ is tracked by git“ — er schlägt in einem privaten Site-Repository fehl, das sein Icon-Set mit versioniert, und prüft außerdem, dass die `.gitignore` `site/icon-sets/*` enthält. Der Validator-Test scheitert seit 1.2.0 auch an fehlenden Handbuchdateien und den übrigen neuen Warnungen. Pass die Tests an deine Website an oder lösche sie, sonst bleibt auch die CI deiner Kopie rot.

### Lizenz und Brand-Assets

Der Code steht unter der **MIT-Lizenz** (© 2026 Jean Pierre Kolb) — einschließlich der Bausteine, die Logos und Symbole zeichnen, der Fensterknopf-Glyphen und der neutralen Hintergrundmotive. **Ausgenommen** sind das JPK-Monogramm und das JPKCom-Logo (© 1996–2026, alle Rechte vorbehalten, keine eingetragene Marke): die Glyphe `jpk`, das Logo `jpkcom`, das Standard-`asciiLogo`, `assets/icons/favicon.svg` und `maskable.svg` samt der daraus gerenderten PNGs sowie die Motive `author-monogram` und `author-emblem`. Unverändert als Standard-Marke zeigen darfst du sie, auch in Forks und Deployments, die noch nicht umgestaltet sind. Als eigenes Logo, für andere Projekte oder verändert verwenden darfst du sie ohne Erlaubnis nicht. Die Icons sind eine erzeugte Teilmenge der Tabler Icons (MIT).

## Tipps & Tricks

- **Mehrere Desktops auf einem Origin:** Gib jedem einen eigenen `namespace` (Standard `'jpkdesk'`, Muster `[a-z][a-z0-9-]`, höchstens 24 Zeichen). Er ist das Präfix jedes `localStorage`-Schlüssels, IndexedDB-Namens, Cache-Namens und DOM-Events. Wähl sie am besten so, dass keiner der andere plus `-…` ist — `desk-a` und `desk-b`, nicht `jpkdesk` und `jpkdesk-next`. Seit 1.4.0 trennt ein Desktop seine Schlüssel von denen eines solchen längeren Namespace, sobald der einen Schlüssel gespeichert hat, den er selbst auch kennt (eine Einstellung, eine Zustimmung), und lässt sie bei „Alles zurücksetzen“ und in der Speicheranzeige aus; ältere Versionen tun das nicht. Im Zweifel behält die Regel einen Schlüssel lieber, als einen fremden zu löschen. Der Namespace verhindert nur Kollisionen, keinen Zugriff: Alle Pages-Websites eines Kontos teilen sich `https://<user>.github.io` und können gegenseitig ihre Daten lesen, auch einen gemerkten Tresor-Schlüssel — dort hostest du nur vertrauenswürdigen Code.
- **Eigene Inhalte privat, Projekt öffentlich:** Dein `site/` kann in einem privaten Repository liegen, und ein kleines Build-Skript nimmt genau ein festgelegtes, getaggtes Release des öffentlichen Projekts und legt dein `site/` dazu — so landet nichts Privates im öffentlichen Repository. Ein Update probierst du zuerst in einem eigenen Ordner mit Testdaten aus und aktualisierst danach den echten. So habe ich [jpkc.com/desktop](https://www.jpkc.com/desktop/) auf 1.4.0 aktualisiert: Lesezeichen (jetzt im verschlüsselten Tresor), der Zugang zu meinen Werkzeugen sowie Notizen, Aufgaben und Dock früherer Besuche sind geblieben.
- **Gleicher Origin heißt volles Vertrauen:** Eine `web`-App auf dem Origin des Desktops kann gespeicherte Daten und einen gemerkten Tresor-Schlüssel nutzen — auch ein Unterordner trennt nichts. Gerahmte Seiten desselben Origins erreichen außerdem `parent.JPKDesk` und Zustände im Speicher, etwa einen entsperrten Tresor. Fremdes HTML gehört auf einen eigenen Origin (per `frame-src` erlaubt) oder bekommt `sandbox: 'allow-scripts'`, nie zusammen mit `allow-same-origin` — und solche Web-Apps bekommen kein `linkPaths`.
- **Beispiel-Verweise mitziehen:** Einige Schlüssel verweisen auf Apps der Beispiel-Website — `site.legal: ['imprint', 'privacy']`, `notify.app: 'changelog'` und `site.defaultPageApp` (Standard `'about'`, muss auf eine vorhandene `page`-App zeigen). Löschst du diese Apps aus `site/apps.js`, passe die Schlüssel an, sonst meldet `npm run validate` Fehler. Dasselbe gilt für die Alias-Einträge `jpkcom-github` und `jpkcom-mastodon` der Lesezeichen-Sammlung: Sie zeigen auf die aus `author.links` erzeugten Apps `author-github` und `author-mastodon` und müssen weg, sobald du `author.links` änderst oder leerst. Und der Beispiel-Feed `site/data/feed.<lang>.json` ist der Changelog von JPKCom Desktop — ersetze ihn durch deinen eigenen oder nimm `'notify'` aus `modules`.
- **Routing ohne Code:** `site.routes` leitet Links auf demselben Origin um, z. B. `{ match: '^/demo/?$', app: 'demo' }`, `{ prefix: 'downloads/', tab: true }` oder `{ match: '^/docs/', page: true }`. Eine `page`-App mit Ordner-URL (`page('docs/')`) öffnet jede Seite darunter im selben Fenster — der Ordner braucht eine `index.html`.
- **Icons als vollständige Literale:** `npm run icons` findet nur IDs wie `'ti-…'` oder `'tif-…'`, die vollständig im Quelltext stehen; zur Laufzeit zusammengesetzte Namen bleiben leer. Icons, die nur in versiegelten Tresor-Daten vorkommen, kommen als JSON-Array in `site/icons.json`. IDs aus Site-Icon-Sets betrifft das nicht (kein Neubau); Tresor-Icons aus einem Set müssen aber in der Set-Datei stehen.
- **Reader-Seiten sind bereinigt:** Skripte, Styles, `style`-Attribute, Formulare, iframes und Event-Handler fliegen raus; Klassen bekommen das Präfix `c-`. Eigene Stile für Seiteninhalte schreibst du deshalb als `.reader-page .c-name`.
- **Code-Farben im Reader:** Die eine Ausnahme bei `style`-Attributen (seit 1.3.0) betrifft Syntax-Highlighting. Innerhalb von `reader.styleScope` (Standard `pre, code`) behält der Reader `color`, `background-color`, `font-style`, `font-weight` und `text-decoration-line` mit einfachen Farbwerten, verwirft Farben unter 2:1 Kontrast und setzt alles selbst per CSSOM — den Style-Text der Seite übernimmt er nie. `reader.keepStyles: false` schaltet das ab. Für Highlighter mit zwei Themes wie Shiki behält `reader.styleVars: '--shiki-'` auch deren Custom Properties; die dunkle Variante schaltest du dann mit einer `!important`-Regel in einem Stylesheet deiner Website um, für Text und Hintergrund gemeinsam (Beispiel in den Upgrade-Hinweisen zu 1.3.0 im [Changelog](https://www.jpkc.com/db/changelog/jpkcom-desktop/)).
- **Glückskeks-Dateien:** `site/data/fortunes/<lang>.json` enthält `lang`, `by`, `categories` und `items` — nur reiner Text, höchstens 1000 Zeichen pro Spruch, in jeder Sprache dieselben Kategorie-IDs. Sprachen ohne Datei lässt du aus `fortune.langs` weg, dann gibt es keine 404. Mit `fortune.local: false` brauchst du gar keine Dateien, und `fortune.texts` passt die Texte einer umbenannten App an.
- **Mitteilungs-Feeds:** JSON Feed 1.1 je Sprache auf demselben Origin; jeder Eintrag braucht einen Titel, ein `date_published` in der Vergangenheit und eine URL auf demselben Origin. Mit `notify.app: null` zeigt jedes Banner seit 1.3.0 Kachel und Namen der App, in der sein Artikel aufgeht; `notify.label` (Text oder `{ lang: text }`, höchstens 60 Zeichen) stellt dem einen Namen voran, etwa „News · Blog“. Ein fester `notify.app` sieht aus wie bisher.
- **Nie lange Cache-Zeiten für Code:** ES-Module importieren sich gegenseitig über ihren Namen. Ein langes `Expires` oder `max-age` von Host oder CDN auf `.js`, `.css`, `.json`, `.html` oder `sw.js` mischt alte und neue Dateien und zerlegt den Desktop. Bei nginx verhindert das `expires off;`, bei Apache `ExpiresActive Off` — beides steht in den mitgelieferten Konfigurationen.
- **Service Worker in der Wurzel:** Dort sieht er jede Seite der Website. Persönliche oder angemeldete Seiten markierst du mit `Cache-Control: no-store` oder `private`, damit er sie nicht aufbewahrt.
- **Fehlerbilder schnell zuordnen:** Leerer Desktop mit „MIME type“-Fehler → falscher Typ für `.js`. Leer unter `/desktop`, aber nicht unter `/desktop/` → Weiterleitung fehlt (bei nginx kein `try_files $uri $uri/` ergänzen). Pagefind-WebAssembly-Fehler → `'wasm-unsafe-eval'` fehlt. Kein Installationsangebot → kein HTTPS, kein Manifest-Link oder `pwa.enabled: false`. „Eine neue Version des Desktops ist bereit“ nach der Änderung einer Datendatei → die Datei liegt außerhalb der Datenordner; Laufzeitdaten gehören nach `site/data/` oder `site/content/` (Modulordner, `site/apps.js`, `site/theme.css` und Hintergrundbilder sind absichtlich Code). „Eine neue Version des Desktops ist bereit“ nach jedem Neubau des Suchindex → das Pagefind-Bundle liegt in `site/`; verschieb es nach `pagefind/`.
- **Server-Eigenheiten:** Bei nginx entfernt ein `add_header` innerhalb eines `location`-Blocks alle Header der Server-Ebene. Bei Caddy gehören keine Zeilen mit `-` oder `?` in den Haupt-`header`-Block, sonst gehen Fehlerantworten ohne Sicherheits-Header raus. Hinter einem TLS-Proxy stellst du bei Apache die `%{HTTPS}`-Bedingungen auf `X-Forwarded-Proto` um.
- **Browser-Check mit Szenario:** `npm run check:browser -- --lang de-DE --mobile --screenshot /tmp/desk.png` prüft die Smartphone-Ansicht auf Deutsch. Eigene Szenarien: `export default async ({ page, desk, log, assert }) => { … }`; `--route pfad=datei` liefert für einen Check eine lokale Datei aus, etwa ein Site-Modul, das nicht im Projekt liegt. Auf Rechnern mit wenig RAM serialisierst du Läufe mit `flock /tmp/jpkcom-desktop-browser.lock node tools/browser-check.mjs …`.
- **Theme prüfen:** Nach jeder Änderung hell und dunkel umschalten, eine dunkle Insel (Menüleiste, Terminal) ansehen, die Smartphone-Ansicht (`body.compact`) und den Modus mit erzwungenen Farben kontrollieren. `--ring-focus`, `--ring-selected`, `--shadow-control-edge` und `--shadow-pressed` bleiben sichtbar — Fokus und Bedienelement-Kanten brauchen 3:1 Kontrast (WCAG 1.4.11).

## Weiterführende Informationen

- Quellcode auf GitHub: <https://github.com/JPKCom/jpkcom-desktop>
- [Live-Demo auf GitHub Pages](https://jpkcom.github.io/jpkcom-desktop/)
- [Deine eigene Website in 10 Minuten (Schnellstart)](https://github.com/JPKCom/jpkcom-desktop/blob/main/docs/quickstart.de.md)
- [README auf Deutsch](https://github.com/JPKCom/jpkcom-desktop/blob/main/README.de.md)
- Englischsprachige Referenzen im Repository: [Deployment](https://github.com/JPKCom/jpkcom-desktop/blob/main/docs/deploy.md), [Theming](https://github.com/JPKCom/jpkcom-desktop/blob/main/docs/theming.md), [Architektur](https://github.com/JPKCom/jpkcom-desktop/blob/main/docs/ARCHITECTURE.md), [Server-Konfigurationen](https://github.com/JPKCom/jpkcom-desktop/tree/main/docs/server)
- [Changelog dieses Projekts](https://www.jpkc.com/db/changelog/jpkcom-desktop/) und die [Releases auf GitHub](https://github.com/JPKCom/jpkcom-desktop/releases)
- Englischsprachige Referenzen zu 1.2.0: [Offline und Installation, frühere Service Worker ablösen](https://github.com/JPKCom/jpkcom-desktop/blob/main/docs/deploy.md#11-offline-use-and-installation-pwa), [Icons und Site-Icon-Sets](https://github.com/JPKCom/jpkcom-desktop/blob/main/docs/ARCHITECTURE.md#13-icons), [Glückskeks-App](https://github.com/JPKCom/jpkcom-desktop/blob/main/docs/packages/p11-fortune-site.md)
- Englischsprachige Referenzen zu 1.3.0: [Konfigurationsschlüssel wie `iconReplace`](https://github.com/JPKCom/jpkcom-desktop/blob/main/docs/ARCHITECTURE.md#6-configuration), [Reader und Code-Farben](https://github.com/JPKCom/jpkcom-desktop/blob/main/docs/packages/p04-content-kinds.md), [Bereinigung im Reader](https://github.com/JPKCom/jpkcom-desktop/blob/main/SECURITY.md#foreign-html-the-reader-sanitiser)
- Englischsprachige Referenzen zu 1.4.0: [Deskriptorfeld `precache`](https://github.com/JPKCom/jpkcom-desktop/blob/main/docs/ARCHITECTURE.md#8-module-descriptor), [Speicher und „Whose keys“](https://github.com/JPKCom/jpkcom-desktop/blob/main/docs/ARCHITECTURE.md#14-storage), [Mehrere Desktops auf einem Origin](https://github.com/JPKCom/jpkcom-desktop/blob/main/docs/deploy.md#3-at-the-web-root-or-in-a-sub-folder)
- [Sicheres Arbeiten mit Node.js und npm](https://www.jpkc.com/db/blog/nodejs-npm-absichern/) — Socket Firewall, `.npmrc` und `npm ci`, wie sie das Projekt nutzt
- [CSS @layer in der Praxis](https://www.jpkc.com/db/blog/css-cascade-layers/) — die Grundlage des Haus-Themes
- [Nachhaltige Websites](https://www.jpkc.com/db/blog/nachhaltige-websites/) — statische Seiten und kleine Webserver wie static-web-server
- Cheat-Sheets zu den Webservern: [Apache](https://www.jpkc.com/db/cheatsheets/web-servers/apache/), [nginx](https://www.jpkc.com/db/cheatsheets/web-servers/nginx/), [Caddy](https://www.jpkc.com/db/cheatsheets/web-servers/caddy/), [Ferron](https://www.jpkc.com/db/cheatsheets/web-servers/ferron/)
- [Content Security Policy (Wikipedia)](https://de.wikipedia.org/wiki/Content_Security_Policy), [Progressive Web App (Wikipedia)](https://de.wikipedia.org/wiki/Progressive_Web_App)
- [Tabler Icons](https://tabler.io/icons), [Pagefind](https://pagefind.app/), [JSON Feed](https://www.jsonfeed.org/)

