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.

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 (opens in a new tab) shows what the result looks like. This guide covers version 1.4.0; the changelog 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 (opens in a new tab), 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:
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

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 (opens in a new tab), 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:

		{
			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 (opens in a new tab)) 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:

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:

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:

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:
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.
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:
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 (opens in a new tab) 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.

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.

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):

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 (opens in a new tab).

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:

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:

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 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).
  • 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