# JPKCom Desktop — Guide & Tips

> JPKCom Desktop turns a website into a desktop with windows, a dock and apps — template, configuration, your own apps, privacy, PWA and deployment with a strict CSP.

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

JPKCom Desktop is a web interface in the style of a classic desktop — windows, a menu bar, a dock and apps in the browser, written in plain JavaScript, for anyone who wants to present their website as a desktop. It consists of static files only: native ES modules, no build step, no runtime dependencies and a strict Content Security Policy.

## Guide

### What's included

The desktop runs on any web server, at the web root or in a sub-folder. The [live demo of the unchanged example site](https://jpkcom.github.io/jpkcom-desktop/) shows what the result looks like. This guide covers version **1.4.0**; the [changelog](https://www.jpkc.com/db/en/changelog/jpkcom-desktop/) lists what each release added.

| Area | Scope |
|---|---|
| Windows | Move, resize, zoom by double-clicking the title bar, snap to an edge or half with a preview, Overview (F3 or Ctrl+↑), windows restored on the next visit (web windows on the page they last showed, also outside the desktop's folder), deep links `#app=<id>`, `#app=<id>&path=/<path>` (web apps with `linkPaths: true`), `#search=<words>`, `#/path` |
| Around the windows | Menu bar with brand menu, site menus, Search, language switch, weather and clock (opens the calendar); dock; desktop icons; "All apps"; Search with Ctrl/⌘+K or `/`; context menus via right-click, long press or Shift+F10 |
| Apps (optional, in `apps`) | Editor, Notes, Tasks, Calculator, Terminal, audio/video player, Fortune |
| Core | Settings, Wallpaper, Backup, Trash, "About this desktop", "How it works" |
| Modules (optional, in `modules`) | Reader (your website's pages, sanitised, no iframe), Image Viewer, Catalog (collections), Search, Calendar with week numbers, public holidays (computed locally), weather (Open-Meteo or Bright Sky), notifications from a JSON Feed, a vault for encrypted private bookmarks |
| Look | Dark, light or automatic; accent colours with a contrast check; tile tints; wallpapers made of colours, gradients, pictures and generated motifs |
| Icons | Tabler (only the ones used) plus your own site icon sets, two-tone too |
| Languages | Any number; German and English are included |
| Offline | Installable as a PWA, service worker with fast start; feeds and data always come fresh from the server |

Accessibility is built in: the menu bar works with the arrow keys, windows are named dialogs, focus is managed and restored, changes are announced through a live region, and `prefers-reduced-motion` is respected.

### Requirements

- **A web server.** Any static server works. Opening `index.html` directly via `file://` does not: browsers don't load ES modules from `file://`, and `fetch()`, the service worker and stored settings need a real origin.
- **HTTPS** for the service worker (offline use and installation), the vault (Web Crypto) and "My location". Over HTTP the desktop still runs, just without those features. `http://localhost` and `http://127.0.0.1` count as secure during development.
- **Node.js 24 or newer and Git** — for the tools only. The desktop itself needs neither. The only development dependencies are the Tabler icon sources and `playwright-core` for headless checks (exact versions in `package.json`).

### Get your own copy

The guide "Your own site in 10 minutes" (`docs/quickstart.md`) walks you through five steps: get a copy; name, author and logo; content; check locally; publish. There are three ways to get the copy:

1. **From the template** (recommended): on the [repository page](https://github.com/JPKCom/jpkcom-desktop), choose **Use this template → Create a new repository** and clone your new repository.
2. **Clone directly:** `git clone https://github.com/JPKCom/jpkcom-desktop.git my-desktop`
3. **Download without Git:**

```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
```

### Run it locally

```sh
sfw npm ci         # development tools only: Tabler icon sources, browser checks
npm run serve      # http://127.0.0.1:8080/ with the production security headers
```

There is no build step: edit a file, reload the page. `npm run serve` itself needs no packages — `node tools/serve.mjs` runs without `npm ci` as well. `sfw` is [Socket Firewall Free](https://github.com/SocketDev/sfw-free), which blocks malicious packages at install time; plain `npm ci` works too.

Pass options after `--`, for example `npm run serve -- --port 3000 --host 0.0.0.0`:

| Option | Default | Effect |
|---|---|---|
| `--port` | `8080` | Port |
| `--host` | `127.0.0.1` | `0.0.0.0` makes the server reachable on the LAN |
| `--base` | `/` | Test a sub-folder, e.g. `--base /desktop/` (as on GitHub Pages) |
| `--root` | project folder | Folder to serve |
| `--connect` | — | Extra `connect-src` origins (comma-separated, `https://` only) for enabled online services |
| `--frame` | — | Extra `frame-src` origins (comma-separated, `https://` only) for `web` apps on other origins |
| `--wasm` | — | Adds `'wasm-unsafe-eval'` (Pagefind only) |
| `--geolocation` | — | `geolocation=(self)` in the Permissions-Policy (only with `services.geolocation: true`) |
| `--extra` | — | Serves single files from outside the project at `<base><url-path>`, e.g. `--extra site/config.js=/tmp/cfg.js,site/icon-sets/t.json=/tmp/t.json` — for tests and trials only; real servers have no such thing |

The test server answers GET/HEAD only, serves a folder's `index.html` (a path without a trailing slash is redirected first) and never serves dotfiles, `node_modules`, `tools` or `tests`. It sends the headers over plain HTTP, so without `upgrade-insecure-requests` and HSTS.

### The `site/` folder

The project's ground rule: **`site/` is yours, `src/` is the project.** Leave `src/` unchanged and you can update later by replacing `src/`, `locales/` and `sw.js` and then regenerating the icons and preload hints (see "Update an existing site").

| Path | Contents |
|---|---|
| `site/config.js` | Classic script that sets `window.DESKTOP_CONFIG` — every key is commented |
| `site/apps.js` | The manifest as an ES module: `apps`, `collections`, `menus`, `files` |
| `site/theme.css` | House theme (token overrides in `@layer themes`), shipped without rules |
| `site/content/<lang>/` | HTML pages for the Reader, one folder per language (one file per page), plus text manuals for the terminal (example: `manuals/writing-pages.md`) — data as far as the service worker is concerned |
| `site/data/` | Notification feeds (`feed.<lang>.json`), Fortune sayings (`fortunes/<lang>.json`) and data your own modules fetch at run time (`site/data/<module-id>/`); the service worker always fetches them from the server first |
| `site/icon-sets/` | Your own icon sets as JSON (optional, none shipped; excluded from the public repository by `.gitignore`) |
| `site/wallpapers/` | Wallpaper pictures (shipped empty) |
| `site/vault/` | Sealed vault files (none shipped) |
| `site/modules/` | Your own apps and modules, example app `hello/` |

The shipped content is an example site (About, Docs, Changelog, Imprint, Privacy, Bookmarks, Showcase) that you replace step by step.

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

Every key is optional. Missing values come from the `DEFAULTS` in `src/core/config.js`; invalid values are reported in the browser console and replaced by the default — the page never breaks. Merging follows these rules:

- **Objects** are merged key by key with the defaults.
- **Arrays and plain values** replace the default entirely — `modules: [...]` drops every module you don't list.
- **Language maps** such as `{ de: '…', en: '…' }` replace as a whole, and so does `terminal.manUrl`. Lookup falls back from the language to its base language, `defaultLang`, `'en'` and finally the first entry.
- In `theme.accents`, `theme.tints` and `services`, `null` removes a default entry.

The main areas: `brand`, `author`, `credit`; `site` (`home`, `legal`, `hosts`, `routes`, `description`); `about`; `languages` and `defaultLang`; `theme`, `wallpaper`, `iconSets` (your own icon sets) and `iconReplace` (your own glyphs for the desktop's symbols); `wm`, `session`, `dock`, `desktop`, `boot`, `power`, `ui`; `modules` and `apps` (anything left out is not even downloaded); `services`; plus own sections for modules and apps (`reader` with `keepStyles`, `styleScope` and `styleVars`, `search`, `notify` with `label`, `weather`, `vault`, `fortune` with `local` and `texts`, `terminal` …) and for offline use (`pwa`, `offline` with `legacyCaches`).

#### Name, logo and credit

For your own identity, replace `brand`:

```js
	brand: {
		name: 'My Desktop',            // document title, "About this desktop", terminal
		shortName: 'My Desktop',       // keep equal to short_name in manifest.webmanifest
		menuLabel: 'My Site',          // accessible name of the brand menu
		glyph: 'ti-device-desktop',    // a Tabler icon or one from your site icon set (e.g. 'acme-logo')
		logo: null,                    // no detailed logo
		asciiLogo: ['My Site'],        // terminal art (neofetch), one string per line
		host: null,
		themeColor: '#1c2935'
	},
```

Your name goes into `about.copyright: { holder: 'Jane Doe', since: 2026 }`. Please leave `author.name`, `author.brand`, `author.url` and `credit: true` as they are — they produce the credit line "JPKCom Desktop by Jean Pierre Kolb". `author.links` become apps in the Help menu and the Dock; keep them, replace them or set `links: []` — then also update the bookmarks that point to them (see Tips & Tricks).

A complete rebrand also covers:

1. `wallpaper.motifs` without `'author-monogram'` and `'author-emblem'`: `['author-blueprint', 'waves', 'dunes', 'aurora', 'orbit', 'horizon', 'graphite']`.
2. Your own `assets/icons/favicon.svg` and `maskable.svg`, then (after `sfw npm ci`) `npm run browsers` once and `npm run icons:pwa`. `npm test` checks the files literally: both start with `<svg` including `viewBox="0 0 512 512"` (no XML declaration, no comment before it); `maskable.svg` has exactly `<rect width="512" height="512" fill="url(#g)"/>` as its background (a gradient with the id `g`), no `rx=` anywhere, and keeps the picture inside the central 80 %. The quickstart (step 2) has a placeholder pair to copy.
3. The static lines in `manifest.webmanifest` (`name`, `short_name`, `description`, `theme_color`, `background_color`) and in `index.html` (`<html lang>`, `<title>`, `<h1 id="desk-title">`, description, `theme-color`, `apple-mobile-web-app-title`, `<noscript>` with one line per language). The `author` and `generator` meta tags stay.

### Apps, pages and collections in `site/apps.js`

Every app needs an `id` from `[a-z0-9-]` (1–64 characters) and a `kind`:

| `kind` | Opens | Needs module |
|---|---|---|
| `page` | A page from `site/content/` in the Reader | `reader` |
| `web` | A page in an iframe window (same origin or allowed via `frame-src`); optionally with `scope` and `linkPaths` | — (built in) |
| `link` | An external page in a new tab (`https://`; `http://` only with `allowHttp: true`) | — |
| `collection` | A Catalog window with groups | `catalog` |
| `image` | A picture in the Image Viewer | `viewer` |

Add a page of your own like this — the `page()` helper at the top of the file builds the path for each language:

```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')
		},
```

The page itself is plain HTML without scripts, styles or `style` attributes. The Reader takes the first `main article`, its `h1` as the title and the `.lead` paragraph; the alternate link tells it which page to show after a language switch:

```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Services | My Site</title>
<link rel="alternate" hreflang="de" href="../de/leistungen.html">
<link rel="stylesheet" href="../content.css">
</head>
<body><main><article>
<h1>Services</h1>
<p class="lead">The offer in one or two sentences.</p>
<p>Text, links, lists, tables …</p>
</article></main></body>
</html>
```

Further building blocks:

- **Override records:** to change an app a module brings, list its `id` with only the fields to change and no `kind`: `{ id: 'notes', dock: true }`.
- **Aliases:** `{ id: 'old-name', alias: 'new-name', hidden: true }` keeps old links alive after a rename. Since 1.3.0, wherever the desktop gives an app a place, an alias stands for its target: stored Dock lists and `dock.pins` resolve alias ids to the target (the Dock shows its name, icon and tint), and the desktop no longer shows a second icon. So `desktop: true` and `dock: true` belong on the app itself or its override record, not on the alias. A ring of aliases (`a → b → a`) is an error.
- **Web apps with sub-pages:** `scope` is the folder a web window may stay in when it is restored and in links — relative to the desktop (`'demos/clock/'`) or, for a folder outside it, from the site's root (`'/wiki/'`), never the desktop's root or a folder above it. Without `scope`, the start page's first folder below the desktop applies, or, for a start page elsewhere, its own folder. With `linkPaths: true`, "Copy link to this window" and `#app=<id>&path=/…` keep the sub-page; it is off by default, and for content you don't control it stays off (sandbox that instead). Example: `{ id: 'wiki', kind: 'web', icon: 'ti-book', url: '/wiki/start/', scope: '/wiki/', linkPaths: true, name: { en: 'Wiki', de: 'Wiki' } }`. Collection items take `scope` and `linkPaths` too.
- **Collections** (`collections`): every entry in `items` becomes an app `<prefix>-<slug>`; without any code, the collection gets a Catalog window, a search group and menu entries. `itemKind: 'auto'` decides per item: `http(s)://` → `link`, picture extension → `image`, otherwise `web`. The Catalog's "Overview on the web" button launches the app named in `webApp` (it wins over `webUrl`). If the overview page sits exactly at the collection's `basePath`, add a hidden web app for it — `{ id: 'tools-web', kind: 'web', hidden: true, url: 'tools/' }` — and set `webApp: 'tools-web'`; a `webUrl` equal to `basePath` opens a new tab otherwise. For an overview in the Reader, `webUrl: 'tools/index.html'` is enough.
- **Manual pages for the terminal** (`man`): `man <entry>` in the terminal prints a text manual (`.md`, `.markdown` or `.txt` on your site). `man` sits on an item (a path, `{ lang: path }` or `false`) or on the whole collection as a template with `{slug}` or `{id}` (further placeholders: `{collection}`, `{lang}`), e.g. `man: 'site/content/{lang}/manuals/{slug}.md'`. The item's own `man` comes first, then the collection's, then `terminal.manUrl` in `site/config.js` — which now also takes a `{ lang: template }` map and applies only to your site's collections, never to the vault's bookmarks. The example site shows it on the "Writing pages" item: `man: page('manuals/writing-pages.md')`, called with `man writing-pages`. A missing file is not an error: the terminal says the entry has no manual page and offers the documentation. Keep manuals under `site/content/` or `site/data/`, so the service worker treats them as data.
- **Menus** (`menus`): entries are app ids, `'-'` as a separator, `{ collection: id }`, `{ label, url }` or a submenu — submenus go one level deep only.
- **Terminal files** (`files`): files the terminal shows with `cat`, e.g. `license: 'LICENSE'`; relative same-origin paths only, `.md` is rendered as Markdown.

After every change, `npm run validate` checks the manifest against `site/config.js` — ids, kinds and the modules they need, references, URLs, icons, tints and texts for every configured language, plus the site icon sets (ids, files, reserved prefixes, definitions, size), `man` and `terminal.manUrl` (missing manual files count as warnings), `scope` and `linkPaths`, `webApp`, `webUrl`, `allLabel` and `webLabel` (also on override records of Catalog apps), `fortune.local` and `fortune.texts` (including placeholders a key does not fill), and every pair in `iconReplace`. Exit code 0 means fine, 1 errors (with `--strict` or `npm run validate:strict`, warnings too), 2 manifest or config could not be loaded. A Tabler icon the desktop does not use yet (any name `'ti-…'`/`'tif-…'`, see [Tabler Icons](https://tabler.io/icons)) needs `npm run icons` once afterwards (after `sfw npm ci`); until then it stays empty, and `npm run validate` warns about it. Icons from a site icon set need no rebuild.

### Languages

`languages: ['de', 'en']` sets the offered languages in menu order (two give a toggle, three or more a menu); `defaultLang: 'en'` applies when the browser asks for none of them. On a first visit, the order of the browser's languages counts since 1.3.0: for each entry of `navigator.languages`, the desktop tries the exact tag, then the shortened tag, then another region of the same language before moving on to the next entry — with `de-AT, en`, a de/en site starts in German. A stored choice and `?lang=` always come first.

A new language is a folder of translations:

```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
```

Translate the values in every namespace file and leave keys and `{placeholders}` untouched. Plurals are objects with the `Intl.PluralRules` categories (`other` is required). After that, every language map in `site/config.js` and `site/apps.js` needs an `fr` value — page paths included: the `page()` helper in `site/apps.js` only builds `en` and `de`, so extend it with a third parameter or write the URLs as `{ en, de, fr }`, otherwise `npm run validate` warns ("has no address for 'fr'"). The same goes for the texts in `fortune.texts` (checked by `npm run validate`) and for `man` maps of the form `{ lang: path }`. Site modules with their own texts also need a `locales/fr/` file, and `index.html` a `<p lang="fr">` line in its `<noscript>`. After an update, `npm run i18n:check` reports the new keys your own language folders still lack — with 1.2.0 these are `fortune.noSource`, `fortune.denyOnline`, `terminal.noManualPage` and `terminal.manOpen`, and 1.3.0 adds `notify.meta`. Optional: `site/content/fr/`, `site/data/fortunes/fr.json` (plus `'fr'` in `fortune.langs`) and `site/data/feed.fr.json` (plus `notify.feeds.fr`). Missing keys never break anything — they fall back to the base language, `defaultLang` and English; `debug: true` lists them in the console.

### Look and house theme

Set the mode, accent and window controls in `site/config.js`; visitors can then choose for themselves in Settings:

```js
theme: {
	default: 'dark',                                  // 'dark' | 'light' | 'auto' (follows the system)
	accent: 'blue',
	allowCustomAccent: true,
	accents: { brand: '#0f6b8f', pink: null },        // white text needs ≥ 4.5:1, null removes one
	tints: { brand: ['#3fb6e0', '#0f6b8f'] },         // tile gradient [top, bottom]; apps: tint: 'brand'
	windowControls: { side: 'left', style: 'classic' } // 'left' | 'right', 'classic' (dots) | 'minimal'
},
```

Your own accents get their name as `accent.<id>` in `locales/<lang>/settings.js`; otherwise Settings shows the id.

**Wallpapers:** `wallpaper.default` is a gradient, a colour (`{ type: 'color', color }`), a motif (`{ type: 'svg', id }`) or a picture (`{ type: 'image', id }`). Put pictures (WebP or AVIF, about 2560 × 1600) into `site/wallpapers/` and list them in `wallpaper.images`: `{ id, src: 'site/wallpapers/harbour.webp', name, credit, tone }`. `src` must be a relative or root path (CSP `img-src 'self'`); `tone: 'light'` gives the menu bar darker glass on bright pictures.

**House theme:** override corners, glass, shadows and colours as tokens in `site/theme.css`, inside the `themes` layer. It comes last in the layer order and wins over every other layer regardless of specificity. `index.html` loads the file right after the core CSS and before the first paint; again, there is no build step.

```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; }
}
```

The rules from the theming reference (`docs/theming.md`): dark values always belong on `[data-island="dark"]` as well — the menu bar, desktop icons, terminal, calculator, code blocks and boot screen stay dark in both modes. Set family tokens such as `--radius-control` on `:root`; set on `body.compact` or an island, they don't reach the part tokens — override the part tokens there instead (e.g. `--radius-win`). Shadows and rings (`--shadow-*`, `--ring-*`) always go on `:root, [data-island="dark"]`, because the islands declare their own; radius and glass tokens on `:root` reach them anyway. The accent, tile tints, the wallpaper gradient and `--anim` are set inline by JavaScript — change those in `site/config.js`, not in CSS. The shipped `site/theme.css` contains a commented-out "square and flat" example to try.

### Your own icons: site icon sets

Next to Tabler, your site can bring its own icons, for example a set you hold a license for. A site icon set is a JSON file you list in `site/config.js` — at most eight, paths relative to the installation, letters, digits and `. _ - /` only:

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

```json
{ "format": "jpkcom-desktop-icons/1",
  "name": "Acme duotone",
  "license": "Acme Icons 2.1 — commercial license",
  "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** take the form `<prefix>-<name>`: a prefix of 2–12 lower-case letters and digits, starting with a letter, not `ti`, `tif`, `wc`, `tile` or `jpk`, at most 64 characters in total. Use them in `site/apps.js`, in vault data and in `brand.glyph` like Tabler ids.
- **Kinds** (`k`): `'o'` outline (on the 24-unit grid; on another grid give your own `stroke-width` in `a`, together with `fill: 'none'`, `stroke: 'currentColor'` and round caps and joins — `a` replaces the kind's attributes), `'f'` filled, `'d'` two-tone with the secondary layer in `e2`.
- **Two-tone icons** are tuned in `site/theme.css` with `--icon-duo-opacity` (default `0.4`) and `--icon-duo-color` (default `currentColor`). Set the tokens on a container, e.g. `.tile { --icon-duo-opacity: 0.5 }` in `@layer themes` — a selector with an ancestor such as `.tile .i-duo` never reaches the copy behind `<use>`.
- **Only the icons you use** belong in the set; your converter writes the subset. `npm run validate` warns above 256 KiB; above 2 MiB or 5000 icons the desktop refuses the set and starts without it.
- **Sets never run code:** every definition passes an allowlist of SVG tags and attributes (no `g`, no `style`, no `url()`, no `var()`). `npm run icons` still builds Tabler only — set icons need no rebuild.
- **License:** the project ships no set, and `site/icon-sets/*` is in `.gitignore`. A commercially licensed set never goes into a public repository or fork. A private site repository that tracks its set deletes that line or adds `!site/icon-sets/<name>.json`. On GitHub Pages, a public copy of the template publishes no git-ignored set — so don't put a licensed set there in the first place.

#### Replace the desktop's own symbols

Since 1.3.0, the desktop can also draw its own glyphs — menu bar, window controls, Settings, Dock, weather conditions — with icons from your set, so everything appears in one style. In `site/config.js`, map each id of a desktop symbol to a target:

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

- **Keys** are ids starting with `ti-`, `tif-`, `wc-` or `tile-` (the `jpk` monogram stays), **targets** any known icon, usually from your set. Replacement takes one step, never a chain; at most 500 pairs.
- `icon()` and `symbolHref()` resolve the map in one place — the core, modules, apps and site modules follow it without a change.
- Then run `npm run icons` and `npm run validate`. The replaced Tabler icons stay in `src/icons/tabler.js` on purpose: if the set does not load, the desktop shows the original. A pair whose key or target the browser does not know is reported in the console at start and dropped.
- An id with two meanings is replaced in both places — the weather conditions sleet and hail share `ti-cloud-snow`.

### Write your own app

Your own apps live in `site/modules/<id>/`, next to your content; your code does not touch `src/` — only `npm run preload` regenerates `src/boot/preload.js` (run it again after every update that replaces `src/`). Start from the example app "Hello" in `site/modules/hello/` (`index.js`, `model.js`, `hello.css`, `locales/de/hello.js`, `locales/en/hello.js`). It shows a window, texts with a placeholder and a plural, its own CSS, a stored value with validation, backup and reset, and the terminal command `hello`.

1. Copy the folder: `site/modules/hello/` → `site/modules/<id>/`. The id starts with a letter, uses `a–z`, `0–9` and `-`, and has at most 32 characters.
2. Replace every `hello` in the folder, file names included: id, i18n namespace, `@hello.…` keys, storage key, terminal command and CSS classes. A second command with the same name is refused. A search for `hello` does not catch the display name `Hello`: rewrite the texts in `locales/<lang>/<id>.js` (`appName`, `appDesc`, greetings, `cmd`, `cmdMan`).
3. List it in `site/config.js`: `apps: [ …, { id: 'my-app', src: 'site/modules/my-app/index.js' } ]`.
4. Check it and regenerate the preload hints:

```sh
npm run icons        # only for a new Tabler icon
npm run i18n:check   # every language in locales/ needs its file in your locales/<lang>/
npm run validate
npm run preload      # rewrites src/boot/preload.js
```

Don't want the example? Delete `{ id: 'hello', src: 'site/modules/hello/index.js' }` from `apps` in `site/config.js` and the folder, then run `npm run preload` and delete `tests/site-hello.test.mjs` (or point it at your app's `model.js`).

The descriptor is the default export of `index.js`. Its core fields, using "Hello" as the example:

```js
export default {
	id: 'hello',                    // must equal the id in site/config.js
	kind: 'app',
	i18n: ['hello'],
	locales: 'locales/',            // texts: locales/<lang>/hello.js next to the 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()
};
```

For larger apps, **window code on demand** (new in 1.1.0) pays off: `app.load: () => import('./window.js')` loads the DOM, CSS (`windowStyles`) and libraries only when the window first opens. The import must be written as a literal, and the descriptor must never import the window file statically — that would put it back into the boot. Use `src/apps/calc/` as the model. The project's CSP rules apply to DOM and styles: no `innerHTML`, no `style=""`; build the DOM with `Desk.h()` and `textContent`, set styles via CSSOM, and keep CSS in `@layer apps` with tokens instead of colour literals.

Since 1.2.0, three more rules apply to site modules:

- **Icons** are built only with `Desk.icons.icon(id)`. If you need the `<use>` reference in a drawing of your own, `Desk.icons.symbolHref(id)` returns it (`'#i-<id>'` or `null`). Never write `href="#ti-…"` by hand: sprite symbols now have the DOM id `i-<icon id>`, and element ids starting with `i-` are reserved. `Desk.icons.appGlyph(app, { cls, fallback })` gives you an app's glyph without the tile.
- **Data your module reads at run time** (JSON, Markdown) goes under `site/data/<id>/` and is fetched with `Desk.net.getJson` or `getText`, never imported. It is always current; files in the module's folder are code. Change data formats compatibly: add fields rather than renaming or removing them, and use a new file name for an incompatible format.
- **An online source for the Fortune app** comes through `fortuneProviders` in the descriptor (see "Fortune: online only, your own source, your own texts").

Since 1.4.0, two more tools are available:

- **Imports with a computed name** (`` import(`./parts/${name}.js`) ``) are invisible to the service worker, because it builds the offline copy from the imports written as literals in the source (imports in comments do not count). List such files in the descriptor field `precache: ['parts/a.js', 'parts/b.js']` — literals relative to the descriptor file, `.js`/`.mjs`, `.css` or `.json`, every file the import can reach. Without the field, a file only joins the copy once it has been loaded online, and it is missing offline until then. This is how the holidays module keeps its region files available offline. The loader itself ignores the field.
- **Storage keys:** your own code that looks for keys by the prefix `<namespace>-` uses `Desk.store.names()` or `Desk.store.owns(key)` instead — the prefix alone does not tell one desktop from a second one with a longer namespace (see Tips & Tricks). Keys outside the `storage` declaration, such as a whole family of names, your module declares with `Desk.store.claim()`.

### Online services and privacy

The desktop works without tracking, analytics, cookies or third-party code. Settings, notes, tasks, the editor draft and the terminal history stay in `localStorage` and IndexedDB on the device; local files are never uploaded. Online services have two gates, both closed by default: the site must switch the service on in `services`, **and** every visitor must agree before the first request (withdrawable in Settings → Online services). Requests to other origins go out without cookies and without a referrer.

| Service | In `site/config.js` | In the server's CSP |
|---|---|---|
| Weather (Open-Meteo, worldwide) | `'weather'` in `modules`, `services.weather: true`, `weather.provider: 'open-meteo'` | `connect-src https://api.open-meteo.com` |
| Weather (Bright Sky, Germany only) | as above, `weather.provider: 'brightsky'` | `connect-src https://api.brightsky.dev` |
| "My location" for the weather | as for weather, plus `services.geolocation: true` | `Permissions-Policy: geolocation=(self)` |
| `dig`/`host` in the terminal | `services.dns: true`, `terminal.doh: { url: 'https://dns.google/resolve', name: 'dns.google' }` | `connect-src https://dns.google` |
| Fortune online | `services.fortune: true`, `fortune.remote: 'jokeapi'` or `'uselessfacts'` | `connect-src https://v2.jokeapi.dev` or `https://uselessfacts.jsph.pl` |
| Fortune, an online source from your own module | `services.fortune: true`, `fortune.remote: '<id>'` | `connect-src https://<your hosts>` |
| Pagefind full-text search | `search.pagefind: { path: 'pagefind/pagefind.js' }` | `script-src 'wasm-unsafe-eval'` (the desktop's policy and that of `pagefind-worker.js`) |

If the host is missing from the CSP, the browser blocks the request ("Refused to connect"). For Pagefind, `'wasm-unsafe-eval'` belongs in the policy whenever `search.pagefind` is set: the search compiles its WebAssembly in the worker `pagefind-worker.js` and falls back to the desktop's page when the worker fails or has not started within five seconds — a slow connection is enough for that. The shipped server configs send the desktop's policy with every file, the worker included. It only allows compiling WebAssembly, not `eval`. Test this locally with `npm run serve -- --connect https://api.open-meteo.com --wasm --geolocation`. For "My location", the desktop stores only the position rounded to about 1 km. The example site's imprint and privacy policy (`site/content/<lang>/imprint.html`, `privacy.html`) are templates — fill them in before publishing, or remove them from `site.legal` and `site/apps.js`.

Agreement to the Fortune app's online source is bound to the provider (id and hosts): if you switch providers, the desktop asks again. Visitors who agreed to the source before 1.2.0 are asked once more after the update.

#### Fortune: online only, your own source, your own texts

- **Online only:** `fortune.local: false` runs the app without built-in sayings — nothing is stored in advance, and the terminal command `fortune` is hidden. It needs a `remote`; without one, the console warns and `true` applies again.
- **Renamed:** if you give the app another name and icon through an override in `site/apps.js` (`{ id: 'fortune', name: {…}, icon: '…' }`), `fortune.texts` replaces the texts that name the app; the card automatically shows the app's logo, mark or icon. Replaceable keys: `next`, `prev`, `copy`, `copied`, `loading`, `empty`, `localError`, `noSource`, `sourceLocal`, `askTitle`, `allow`, `deny`, `denyOnline`, `service`, `serviceHint`, `cmd`, `cmdMan` — and since 1.3.0 also the consent question (`askText`, `askText2`), the sentence about the texts' language (`askLang`), the key hint (`keys`), "Learn more" (`web`), the error message (`error`) and the label under Backup and Reset (`storageLabel`). The app fills placeholders per key: `askText` knows `{provider}` and `{host}`, `askLang` `{language}`, `keys` `{space}`, `{back}` and `{next}`, `error` `{host}`. `npm run validate` reports a placeholder the key does not fill (such as `{hots}`). A replaced consent question must still name who receives the request — `{host}` or the host written out — otherwise the console and the validator warn.

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

- **Your own source:** a site module brings it declaratively — as a plain object literal, with one to eight hosts and without `requires: ['fortune']`; then run `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' })
}]
```

The [Fortune reference](https://github.com/JPKCom/jpkcom-desktop/blob/main/docs/packages/p11-fortune-site.md) has all the details.

### Private bookmarks: the vault

The vault shows encrypted bookmarks after someone types `login` in the terminal. PBKDF2-HMAC-SHA-256 derives an AES-256-GCM key and the file name from the user name and password; wrong credentials simply request a file that doesn't exist.

```sh
node tools/seal-vault.mjs --new-salt              # put the salt into site/config.js
npm run seal -- --in ~/private/bookmarks.json     # writes site/vault/<32 hex>.bin
node tools/seal-vault.mjs --list                  # sealed files and their parameters
```

Add `'vault'` to `modules` and `vault: { salt: '<your salt>', iterations: 600000 }` to `site/config.js`. The plain-text JSON (`groups` and `items`, at most 20 groups and 500 items) belongs outside the project and every web root (e.g. in `~/private/`); the tool refuses it under `site/`, in the output folder and in the web root that folder belongs to (the project by default). The server must not list `site/vault/` and must serve `.bin` files with `Cache-Control: no-cache` over HTTPS. Protection rests on password strength and PBKDF2 cost: it is meant for private links, not for secrets. Since 1.3.0, the "Private bookmarks" row under Settings → Reset appears only while the vault is unlocked or a login is kept on the device; someone who merely visits the desktop does not learn that a vault exists.

Vault data may use icons from site icon sets. An icon used only in the vault must still be in the set — and the set file is public, so it shows which icons the vault uses; prefer generic icons there. `npm run seal` reads `iconSets` below the web root of `--out`: if you seal straight into a deployment (`--out <web>/site/vault/`), the set must also be in `<web>/site/icon-sets/`. Web windows never reach the vault folder, by the way, not even with percent escapes in the path.

### Offline use and installation

With `pwa: { enabled: true }` (the default), HTTPS and the manifest link in `index.html`, the desktop registers `sw.js`; visitors install it under Settings → General or from the browser menu. With `offline.fastStart: true` it starts from the offline copy — for the desktop's **code**. A few seconds after the start, the service worker compares every code file with the server in the background, fetches a complete new copy if anything changed, and then offers a reload. Code thus changes only as a whole and never mixes; visitors see a code update one reload later.

Data, on the other hand, is always current: the service worker always fetches feeds (`notify.feeds`, also outside the desktop's folder), the Fortune files and everything under `site/data/` and `site/content/` from the server first (offline: the last copy). The background check skips them, so a new feed item shows at once and never triggers "A new version of the desktop is ready". Code, by contrast, includes `site/config.js`, `site/apps.js`, `site/theme.css`, icon sets, site modules and wallpapers. Keep a Pagefind bundle outside `site/` (e.g. `pagefind/`), otherwise every rebuild of the search index counts as a new version.

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

Since 1.4.0, the desktop announces every change to the code it runs — including a new `sw.js` or `site/config.js` that keeps the cache name (a comment, `offline.timeoutMs`, `offline.legacyCaches`). When the check finds a change, it first lets the browser compare `sw.js`: a new worker installs at once, the check stops — even in the middle of fetching the copy — and the half-fetched copy is deleted, never marked complete. That way a new worker never waits for a copy it would throw away anyway. A server error or rate limit (5xx, 408, 429) counts like a network failure, so an update is never completed with the old version of a changed file; files sent with `no-store` or `private`, on the other hand, are simply left out of the copy. And the copy repairs itself: if files are missing from it — for example because another service worker of your site deletes every cache it doesn't know — or an installation could not reach every file, the next check completes it (with `fastStart`, the default). If the server still has the same code, the missing files join quietly, without a "new version". Since 1.4.0 the region file of the public holidays (`holidays.region`) is part of the copy too, so the calendar shows holidays offline as well.

Switching `fastStart` starts a fresh offline copy. `pwa: { enabled: false }` unregisters an existing service worker on the next visit and deletes its caches — this works only while `sw.js` stays uploaded, because the browser notices the change by downloading the file again.

**Replacing an earlier service worker:** if your site had a service worker before the desktop, list its cache names in `offline.legacyCaches` — exact names or a prefix ending in `*` (at least four characters before it, at most 32 entries):

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

The desktop deletes them when the new worker takes over, again about 30 seconds later, at every start and via Settings → Reset → "Offline copies"; it never touches its own caches this way. Since 1.4.0, only the worker currently in charge deletes them: as soon as a newer one installs, waits or takes over, it stops — so if you go back to the old worker (the old files, the old `sw.js` at the same URL), its caches stay. The old worker is replaced only if `sw.js` has the same scope or sits at the old script URL inside the installation folder — otherwise serve a worker at the old URL that unregisters itself. List only names your old code created, because a prefix matches every cache of the origin. Once visitors of the old version no longer come back, remove the entries again. Only sites that replace another worker need this; the details are in [`docs/deploy.md` §11](https://github.com/JPKCom/jpkcom-desktop/blob/main/docs/deploy.md#11-offline-use-and-installation-pwa).

### Publish

The desktop runs at the web root or in any sub-folder without changing a file — every path is relative to the folder that holds `index.html`. Upload: `index.html`, `manifest.webmanifest`, `sw.js`, `assets/`, `locales/`, `site/`, `src/`, `LICENSE` and `CREDITS.md`. **Don't** upload: `node_modules/`, `tools/`, `tests/`, `docs/`, `.git/`, `package.json`, `package-lock.json` — the server configs only refuse dotfiles.

#### On GitHub Pages

1. Push your changes to the `main` branch — only pushes to `main` trigger both workflows (CI also runs on pull requests; both can be started by hand). After a direct clone, first create an empty repository on GitHub (no README, no license), then run `git remote set-url origin https://github.com/<user>/<repo>.git` and `git push -u origin main`. After a download: `git init -b main`, `git add -A`, `git commit -m "…"`, `git remote add origin https://github.com/<user>/<repo>.git` and `git push -u origin main`.
2. **Settings → Pages → Build and deployment → Source:** "GitHub Actions".
3. **Settings → Secrets and variables → Actions → Variables:** `PAGES` = `true`. Without this variable, a copy of the template silently skips the Pages job.
4. **Actions → Pages → Run workflow**, or push again. Result: `https://<user>.github.io/<repo>/`.

Because GitHub Pages cannot send headers, `.github/workflows/pages.yml` inserts the base CSP as a `<meta http-equiv>` right after the single `<meta charset="utf-8">` line in `index.html`. Without `frame-ancestors`, Permissions-Policy, HSTS and the other security headers, this is the weaker option. If you switch on online services, also add their hosts (and, where needed, `'wasm-unsafe-eval'` for Pagefind or `frame-src` origins) to the CSP line in `pages.yml`. The meta CSP covers only the desktop page itself, not the service worker or other pages on the same origin. The workflow publishes the whole checkout except `.git`, `.github`, `node_modules`, `tests` and `tools` — so unlike on your own server, also `docs/`, `package.json`, `package-lock.json` and the READMEs (none of it secret).

#### On your own server

`docs/server/` holds a ready-made config for every supported server:

| Server | File | Target | Check and reload |
|---|---|---|---|
| Apache 2.4.10+ | `apache.htaccess` | `.htaccess` in the folder that holds `index.html` | takes effect at once; `apachectl configtest` only checks the vhost (`AllowOverride`) — an error in `.htaccess` shows up as a 500 in the 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, tested with 3.0.0-rc.9) | `ferron.conf` | `/etc/ferron/ferron.conf` | `ferron validate -c /etc/ferron/ferron.conf`, then restart |
| Ferron 2 (stable line, tested with 2.8.1) | `ferron.kdl` | `/etc/ferron.kdl` | restart |
| 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` |

All you replace is the host name, root and certificate paths; for Apache nothing at all, but there the vhost needs `AllowOverride FileInfo Indexes Options=Indexes` (otherwise Apache answers with a 500). Every config sends this base policy plus Permissions-Policy, `X-Frame-Options: SAMEORIGIN`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, `Cross-Origin-Opener-Policy: same-origin` and 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
```

All configs also redirect `/desktop` to `/desktop/`, list no directories, serve `.js` as `text/javascript` and send `Cache-Control: no-cache` for HTML, JS, CSS, JSON, the manifest and `sw.js` (images, fonts and media: `public, max-age=86400`). Since 1.3.0, the nginx config also sets `expires off;`, so an `expires` from the `http { }` block cannot add a second `Cache-Control` header and `Expires` to the code; the Apache `.htaccess` switches `mod_expires` off with `ExpiresActive Off`. Changes to feeds and to files under `site/data/` and `site/content/` reach visitors at once, code changes one reload later. If a deployment brings a new icon, upload the code that knows the icon first and then the data that names it. Check a deployment with:

```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
```

### Update an existing site

In a Git copy, fetch the new release with `git remote add upstream https://github.com/JPKCom/jpkcom-desktop.git && git fetch upstream --tags` and take over the files selectively with `git checkout v1.4.0 -- src sw.js locales tools package.json package-lock.json .npmrc .github/workflows` — this also works for template copies without shared history. Without Git, download `https://github.com/JPKCom/jpkcom-desktop/archive/refs/tags/v1.4.0.zip`. Then read the "Upgrade notes" of every release you skipped in `CHANGELOG.md` — coming from 1.0.x, that includes the 1.1.0 steps (`site/theme.css`, two new lines in `index.html`, Node.js 24). Afterwards run `npm run icons`, `npm run preload`, `npm run i18n:check`, `npm run validate` and `npm test`. Only take over `tests/` if you adapt the example-site tests again afterwards.

For 1.4.0 in detail:

1. Upload the new `sw.js` too: the cache gets a new name once (new version), and visitors are offered one reload. Nothing else is needed for the fixes; `src/boot/preload.js` is unchanged.
2. `site/config.js`: the comment on `namespace` changed (a recommendation) — copy it if you keep your own file.
3. Several desktops on one origin whose namespaces overlap (`jpkdesk` and `jpkdesk-next`): update every one of them to 1.4.0 — a desktop on 1.3.0 or earlier still counts the longer namespace's keys as its own and deletes them with "Reset everything". For new installations, pick namespaces where neither is the other plus `-…` (`desk-a`, `desk-b`); a changed namespace starts with empty settings.
4. Site modules that import a file with a computed name (`` import(`./parts/${name}.js`) ``) can list those files in the new descriptor field `precache: [...]` so that they are part of the offline copy; without it they join the copy the first time they load online, as before.
5. Your own code that read every `localStorage` key starting with `<namespace>-` uses `Desk.store.names()` or `Desk.store.owns(key)`; modules with keys outside their `storage` declaration declare them with `Desk.store.claim()`.

Coming from 1.2.x, the 1.3.0 steps apply as well:

1. Upload the new `sw.js` too: the cache gets a new name once, and visitors are offered one reload.
2. Run `npm run preload` once if you keep your own `src/boot/preload.js` — its start-language prediction now follows `matchLanguage()` from `src/core/i18n.js`.
3. Optionally copy the commented entries for `iconReplace`, `notify.label` and `reader` (`keepStyles`, `styleScope`, `styleVars`) and the updated comments on `search.pagefind` and `fortune.texts` into your `site/config.js`. Without them the defaults apply.
4. Add `notify.meta` to your own language folders.
5. nginx: add `expires off;` next to `add_header Cache-Control $desk_cache_control;` in the server block. Apache (optional): take over the new `<IfModule mod_expires.c>` block with `ExpiresActive Off` from `docs/server/apache.htaccess` — `AllowOverride FileInfo Indexes Options=Indexes` already covers it.
6. Pagefind: keep `'wasm-unsafe-eval'` in the desktop's policy whenever `search.pagefind` is set, even if your bundle is served without a policy.
7. You can delete override records such as `{ id: '<alias>', desktop: false }` that worked around duplicate alias icons. The desktop rewrites stored Dock lists holding alias ids once on the first start.
8. Remove your own CSS that hid the vault row under Reset (e.g. `.set-row:has(> input[data-key="rs-vault"])`) — it would now also hide the row while the vault is unlocked. Your own code calling `storage.resetGroups()` gets only the groups shown; `{ all: true }` returns all of them.
9. Reader: code blocks with colours written as `style="color:…"` now show them; `reader.keepStyles: false` restores the old look.
10. Your own code calling `i18n.displayName(code, inLang)` now gets the name in the language `inLang`; call `displayName(code)` for the endonym.

Coming from 1.1.x, the 1.2.0 steps apply as well:

1. Upload the new `sw.js` too: the cache gets a new name once, and visitors are offered one reload. `index.html`, `manifest.webmanifest` and the workflows are unchanged.
2. Run `npm run preload` — the boot graph now includes `src/core/man.js` and `src/core/icon-sets.js`, and CI's `preload:check` needs the new file.
3. Optionally copy the new commented keys into your `site/config.js`: `iconSets`, `fortune.local` and `fortune.texts`, `offline.legacyCaches`. Without them the defaults apply and nothing changes.
4. Add `fortune.noSource`, `fortune.denyOnline`, `terminal.noManualPage` and `terminal.manOpen` to your own language folders.
5. If you set `offline.fastStart: false` only because of the feeds, you can remove it again.
6. Run-time data belongs under `site/data/`, scripts don't belong under `site/data/` or `site/content/`, and a Pagefind bundle doesn't belong under `site/`.
7. A `webUrl` equal to the collection's `basePath` now opens a new tab. To open the overview in a window, add a hidden web app and `webApp`.
8. `scope` values starting with `/` used to be ignored and now take effect.
9. Site modules that write `<use href="#ti-…">` themselves switch to `Desk.icons.icon()` or `Desk.icons.symbolHref()`.
10. `terminal.manUrl` no longer applies to the vault's bookmarks. Recommended: move `.md`/`.txt` files from `docs` to `man` and let `docs` point to the page.
11. `npm run validate:strict` may show new warnings (e.g. missing manual files or `webUrl` equal to `basePath`).
12. Visitors who agreed to the Fortune app's online source are asked once more.
13. Add `site/icon-sets/*` to your `.gitignore` — or, in a private repository that tracks its set, `!site/icon-sets/<name>.json`.

Security fixes are released only for the latest release of the latest minor version (currently 1.4.x); 1.3.x, 1.2.x, 1.1.x and 1.0.x are no longer supported.

### Develop and check

| Command | Purpose |
|---|---|
| `npm run serve` | Test server with the production headers |
| `npm test` | Unit tests (`node --test "tests/*.test.mjs"`) |
| `npm run validate` / `validate:strict` | `site/apps.js` against `site/config.js`, including site icon sets, manual pages, `scope`/`linkPaths` |
| `npm run i18n:check` | Every language against English, including `site/modules/*/locales/` |
| `npm run icons` / `icons:check` | Build or verify the icon subset `src/icons/tabler.js` (Tabler only, it doesn't build site icon sets) |
| `npm run preload` / `preload:check` | Build or verify `src/boot/preload.js` |
| `npm run icons:pwa` | Render the PNG icons from `favicon.svg` and `maskable.svg` |
| `npm run browsers` | Fetch headless Chromium for icons and browser checks |
| `npm run check:browser` | Load the desktop in a headless browser; fails on console, CSP and request errors |
| `npm run seal` | Seal a vault file; vault icons may come from site icon sets |

On Node 24, after `npm ci --ignore-scripts` and `npm audit signatures`, CI (`.github/workflows/ci.yml`) runs `npm test`, `npm run i18n:check`, `npm run icons:check`, `npm run preload:check` and `node tools/validate-manifest.mjs`. The `.npmrc` sets `ignore-scripts=true`, `save-exact=true` and `engine-strict=true`; installing commands are best run through `sfw`.

**Important for your own copy:** some tests check the shipped example site and fail as soon as you replace it — the block "The example site" at the end of `tests/p11-site.test.mjs`, in the same file the test "validator CLI: the example site has no errors and no warnings" (runs with `--strict` — fix the warnings rather than the test), "manifest: … brand defaults" in `tests/p12-deploy.test.mjs`, `tests/site-hello.test.mjs` once you remove or rename the example app `hello`, in `tests/theming.test.mjs` the check "site/theme.css … ships without rules" along with the literal guards that scan `site/theme.css` and `site/modules/`, and in `tests/hygiene.test.mjs` the test "hygiene: no file in site/icon-sets/ is tracked by git" — it fails in a private site repository that tracks its icon set, and it also checks that `.gitignore` contains `site/icon-sets/*`. Since 1.2.0 the validator test also fails on missing manual files and the other new warnings. Adapt the tests to your site or delete them, otherwise your copy's CI stays red as well.

### License and brand assets

The code is under the **MIT License** (© 2026 Jean Pierre Kolb) — including the builders that draw logos and symbols, the window-control glyphs and the neutral wallpaper motifs. **Excluded** are the JPK monogram and the JPKCom logo (© 1996–2026, all rights reserved, not a registered trademark): the glyph `jpk`, the logo `jpkcom`, the default `asciiLogo`, `assets/icons/favicon.svg` and `maskable.svg` with the PNGs rendered from them, and the motifs `author-monogram` and `author-emblem`. You may show them unchanged as the default brand, including in forks and deployments that haven't been rebranded yet. You may not use them as your own logo, for other projects or in altered form without permission. The icons are a generated subset of Tabler Icons (MIT).

## Tips & Tricks

- **Several desktops on one origin:** give each its own `namespace` (default `'jpkdesk'`, pattern `[a-z][a-z0-9-]`, at most 24 characters). It prefixes every `localStorage` key, IndexedDB name, Cache name and DOM event. Ideally, pick them so that neither is the other plus `-…` — `desk-a` and `desk-b`, not `jpkdesk` and `jpkdesk-next`. Since 1.4.0, a desktop tells its keys from those of such a longer namespace as soon as that one has stored a key it knows too (a setting, a consent), and leaves them out of "Reset everything" and its storage figures; earlier versions don't. When in doubt, the rule keeps a key rather than deleting someone else's. The namespace only prevents collisions, not access: all Pages sites of one account share `https://<user>.github.io` and can read each other's data, including a kept vault key — host only code you trust there.
- **Your content private, the project public:** your `site/` can live in a private repository, and a small build script takes exactly one pinned, tagged release of the public project and adds your `site/` — so nothing private ends up in the public repository. Try an update in a separate folder with test data first, then update the real one. That is how I updated [jpkc.com/desktop](https://www.jpkc.com/desktop/) to 1.4.0: bookmarks (now in the encrypted vault), the login for my tools, and the notes, tasks and Dock of earlier visits all stayed.
- **Same origin means full trust:** a `web` app on the desktop's origin can use stored data and a kept vault key — a sub-folder separates nothing. Framed pages of the same origin can also reach `parent.JPKDesk` and in-memory state such as an unlocked vault. Third-party HTML belongs on its own origin (allowed via `frame-src`) or gets `sandbox: 'allow-scripts'`, never combined with `allow-same-origin` — and such web apps get no `linkPaths`.
- **Update the example references:** some keys point to apps of the example site — `site.legal: ['imprint', 'privacy']`, `notify.app: 'changelog'` and `site.defaultPageApp` (default `'about'`, must point to an existing `page` app). If you delete those apps from `site/apps.js`, adjust these keys, otherwise `npm run validate` reports errors. The same goes for the alias items `jpkcom-github` and `jpkcom-mastodon` in the bookmarks collection: they point to the apps `author-github` and `author-mastodon` generated from `author.links` and must go as soon as you change or empty `author.links`. And the example feed `site/data/feed.<lang>.json` is the changelog of JPKCom Desktop — replace it with your own or remove `'notify'` from `modules`.
- **Routing without code:** `site.routes` redirects same-origin links, e.g. `{ match: '^/demo/?$', app: 'demo' }`, `{ prefix: 'downloads/', tab: true }` or `{ match: '^/docs/', page: true }`. A `page` app with a folder URL (`page('docs/')`) opens every page below it in the same window — the folder needs an `index.html`.
- **Icons as complete literals:** `npm run icons` only finds ids like `'ti-…'` or `'tif-…'` written out in full in the source; names assembled at runtime stay empty. Icons that appear only in sealed vault data go into `site/icons.json` as a JSON array. Ids from site icon sets are not affected (no rebuild); vault icons from a set must be in the set file, though.
- **Reader pages are sanitised:** scripts, styles, `style` attributes, forms, iframes and event handlers are removed; classes get the prefix `c-`. Style page content as `.reader-page .c-name` for that reason.
- **Code colours in the Reader:** the one exception for `style` attributes (since 1.3.0) is syntax highlighting. Inside `reader.styleScope` (default `pre, code`), the Reader keeps `color`, `background-color`, `font-style`, `font-weight` and `text-decoration-line` with plain colour values, drops colours below 2:1 contrast and sets everything itself through CSSOM — it never applies the page's style text. `reader.keepStyles: false` switches this off. For two-theme highlighters such as Shiki, `reader.styleVars: '--shiki-'` also keeps their custom properties; you then switch to the dark variant with an `!important` rule in a stylesheet of your site, for text and background together (example in the 1.3.0 upgrade notes in the [changelog](https://www.jpkc.com/db/en/changelog/jpkcom-desktop/)).
- **Fortune files:** `site/data/fortunes/<lang>.json` holds `lang`, `by`, `categories` and `items` — plain text only, at most 1000 characters per saying, the same category ids in every language. Leave languages without a file out of `fortune.langs` and there is no 404. With `fortune.local: false` you need no files at all, and `fortune.texts` adapts the texts of a renamed app.
- **Notification feeds:** JSON Feed 1.1 per language on the same origin; every item needs a title, a `date_published` in the past and a same-origin URL. Since 1.3.0, with `notify.app: null` every banner shows the tile and name of the app its article opens in; `notify.label` (a text or `{ lang: text }`, at most 60 characters) puts a name in front of it, such as "News · Blog". A fixed `notify.app` looks as before.
- **Never long cache times for code:** ES modules import each other by name. A long `Expires` or `max-age` from the host or a CDN on `.js`, `.css`, `.json`, `.html` or `sw.js` mixes old and new files and breaks the desktop. In nginx, `expires off;` prevents this, in Apache `ExpiresActive Off` — both are in the shipped configs.
- **Service worker at the web root:** there it sees every page of the site. Mark personal or logged-in pages with `Cache-Control: no-store` or `private` so it doesn't keep them.
- **Match symptoms to causes quickly:** blank desktop with a "MIME type" error → wrong type for `.js`. Blank at `/desktop` but not at `/desktop/` → the redirect is missing (in nginx, don't add `try_files $uri $uri/`). Pagefind WebAssembly error → `'wasm-unsafe-eval'` is missing. No install offer → no HTTPS, no manifest link or `pwa.enabled: false`. "A new version of the desktop is ready" after changing a data file → the file lies outside the data folders; run-time data belongs under `site/data/` or `site/content/` (module folders, `site/apps.js`, `site/theme.css` and wallpapers are code on purpose). "A new version of the desktop is ready" after every rebuild of the search index → the Pagefind bundle lies in `site/`; move it to `pagefind/`.
- **Server quirks:** in nginx, an `add_header` inside a `location` block removes all server-level headers. In Caddy, no lines with `-` or `?` belong in the main `header` block, otherwise error responses go out without security headers. Behind a TLS proxy, switch Apache's `%{HTTPS}` conditions to `X-Forwarded-Proto`.
- **Browser check with a scenario:** `npm run check:browser -- --lang de-DE --mobile --screenshot /tmp/desk.png` checks the phone layout in German. Your own scenarios: `export default async ({ page, desk, log, assert }) => { … }`; `--route path=file` serves a local file for a check, such as a site module that is not in the project. On machines with little RAM, serialise runs with `flock /tmp/jpkcom-desktop-browser.lock node tools/browser-check.mjs …`.
- **Check a theme:** after every change, switch between light and dark, look at a dark island (menu bar, terminal), and check the phone layout (`body.compact`) and forced-colours mode. Keep `--ring-focus`, `--ring-selected`, `--shadow-control-edge` and `--shadow-pressed` visible — focus indicators and control edges need 3:1 contrast (WCAG 1.4.11).

## Further reading

- Source code on GitHub: <https://github.com/JPKCom/jpkcom-desktop>
- [Live demo on GitHub Pages](https://jpkcom.github.io/jpkcom-desktop/)
- [Your own site in 10 minutes (quickstart)](https://github.com/JPKCom/jpkcom-desktop/blob/main/docs/quickstart.md)
- [README](https://github.com/JPKCom/jpkcom-desktop/blob/main/README.md)
- References in the 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), [Architecture](https://github.com/JPKCom/jpkcom-desktop/blob/main/docs/ARCHITECTURE.md), [Server configs](https://github.com/JPKCom/jpkcom-desktop/tree/main/docs/server)
- [This project's changelog](https://www.jpkc.com/db/en/changelog/jpkcom-desktop/) and the [releases on GitHub](https://github.com/JPKCom/jpkcom-desktop/releases)
- References for 1.2.0: [Offline use and installation, replacing an earlier service worker](https://github.com/JPKCom/jpkcom-desktop/blob/main/docs/deploy.md#11-offline-use-and-installation-pwa), [Icons and site icon sets](https://github.com/JPKCom/jpkcom-desktop/blob/main/docs/ARCHITECTURE.md#13-icons), [Fortune app](https://github.com/JPKCom/jpkcom-desktop/blob/main/docs/packages/p11-fortune-site.md)
- References for 1.3.0: [configuration keys such as `iconReplace`](https://github.com/JPKCom/jpkcom-desktop/blob/main/docs/ARCHITECTURE.md#6-configuration), [Reader and code colours](https://github.com/JPKCom/jpkcom-desktop/blob/main/docs/packages/p04-content-kinds.md), [the Reader sanitiser](https://github.com/JPKCom/jpkcom-desktop/blob/main/SECURITY.md#foreign-html-the-reader-sanitiser)
- References for 1.4.0: [the descriptor field `precache`](https://github.com/JPKCom/jpkcom-desktop/blob/main/docs/ARCHITECTURE.md#8-module-descriptor), [storage and "Whose keys"](https://github.com/JPKCom/jpkcom-desktop/blob/main/docs/ARCHITECTURE.md#14-storage), [several desktops on one origin](https://github.com/JPKCom/jpkcom-desktop/blob/main/docs/deploy.md#3-at-the-web-root-or-in-a-sub-folder)
- [Working securely with Node.js and npm](https://www.jpkc.com/db/en/blog/nodejs-npm-absichern/) — Socket Firewall, `.npmrc` and `npm ci`, as the project uses them
- [CSS @layer in practice](https://www.jpkc.com/db/en/blog/css-cascade-layers/) — the foundation of the house theme
- [Sustainable websites](https://www.jpkc.com/db/en/blog/nachhaltige-websites/) — static sites and small web servers such as static-web-server
- Web server cheat sheets: [Apache](https://www.jpkc.com/db/en/cheatsheets/web-servers/apache/), [nginx](https://www.jpkc.com/db/en/cheatsheets/web-servers/nginx/), [Caddy](https://www.jpkc.com/db/en/cheatsheets/web-servers/caddy/), [Ferron](https://www.jpkc.com/db/en/cheatsheets/web-servers/ferron/)
- [Content Security Policy (Wikipedia)](https://en.wikipedia.org/wiki/Content_Security_Policy), [Progressive web app (Wikipedia)](https://en.wikipedia.org/wiki/Progressive_web_app)
- [Tabler Icons](https://tabler.io/icons), [Pagefind](https://pagefind.app/), [JSON Feed](https://www.jsonfeed.org/)

