Making-of: JPKCom Desktop — My Desktop in the Browser, Small and Open

Making-of JPKCom Desktop: why I built my own desktop for the browser — small, fast, accessible, secure, and open source under the MIT license.

by ·

An interface like an operating system, only in the browser — to me that has always been the most beautiful shape a website can take. Windows I can drag around, a menu bar, a Dock, a terminal that actually does something: I'd wanted that for a long time, for my website and my everyday work. I was never really happy with what was out there. So I built it myself: JPKCom Desktop, open source under the MIT license since October 2026.

JPKCom Desktop is a desktop-style interface for websites — windows, menu bar, Dock, Search, terminal and apps — built from native ES modules with no framework, no build step and no runtime dependencies; it runs as static files on any web server. Here I'll show you why I built it and what keeps it small, fast, secure and accessible. The source is in the repository on GitHub (opens in a new tab); try it right now in the live demo (opens in a new tab).

Short on time? Read More order in everyday work and Small, fast, efficient. Turning it into your own site is covered step by step in my JPKCom Desktop guide.

On method: All numbers come from the repository at version 1.4.0, a test run with npm test and my own measurements during development; I measured the load times for version 1.1.0. Where I measured, I say how.

Why build my own desktop at all

The idea of a desktop in the browser is old, and I've seen many takes on it over the years. What bothered me was almost always the same: a heavy framework under the hood, a build chain to maintain, third-party code that contacts external servers while loading, windows that only work with a mouse, or a result I couldn't adapt to my site without taking it apart. That's no criticism — many are showcases and work fine as such. But I wanted a tool I use every day.

My website has had a desktop of its own for a while — at first deliberately as an alpha: a feasibility study and an internal test. With it I developed the final plan, found and fixed bugs early and refined the usability step by step. It was never meant for GitHub and open source, not least because it used commercially licensed icons that must not go into a public repository. So for the open version I set up the architecture anew.

For an open project, my criteria were clear:

  • Small. No runtime dependencies, no framework, no build step.
  • Fast. The browser loads only what it needs right now.
  • Secure. A strict Content Security Policy (CSP), no third-party code, no tracking.
  • Usable. With a keyboard, a screen reader and on a phone, in any number of languages.
  • Open. MIT license, and everything you change for your site lives in one folder.

More order in everyday work

A desktop in the browser isn't just pretty; for me it's above all a way to keep order. When I administer networks, servers and websites, I keep needing the same destinations: a router's admin page, a server panel, a web server's docs, my notes on the last change. In the desktop they sit in one place, a few keystrokes away.

Bookmarks as collections

In the desktop, bookmarks aren't a loose list but collections with groups, icons and descriptions in every language, defined in site/apps.js. Here's the bundled example collection:

site/apps.js (excerpt)
	collections: [
		{
			id: 'bookmarks', prefix: 'link', icon: 'ti-bookmarks', tint: 'indigo', sort: 'alpha', itemKind: 'link',
			name: { en: 'Bookmarks', de: 'Lesezeichen' },
			allLabel: { en: 'All bookmarks', de: 'Alle Lesezeichen' },
			// …
			groups: [
				{ id: 'servers', icon: 'ti-server', tint: 'teal',
					name: { en: 'Web servers', de: 'Webserver' },
					desc: { en: 'Documentation of the servers the desktop runs on', de: 'Dokumentation der Server, auf denen der Desktop läuft' } },
				// …
			],
			items: [
				{ slug: 'apache', group: 'servers', icon: 'ti-feather', name: 'Apache HTTP Server', url: 'https://httpd.apache.org/docs/current/',
					desc: { en: 'Documentation of the Apache web server', de: 'Dokumentation des Apache-Webservers' } },
				// …

The nice part: every entry becomes an app I can search for and pin to the Dock. The Catalog shows the collection with its groups in a sidebar, a search field and an icon grid you can drive entirely with the arrow keys. In the terminal, collections are directories I walk through with ls, cd and open.

One detail matters for admin work: by default bookmarks must be https addresses. With allowHttp you explicitly allow http:// for a collection or a single entry — exactly right for the intranet bookmark to a router or NAS without a certificate. Links nobody else should see belong in the Vault; more on that under security.

Search gets you there fast

The heart of my day is the Search. Ctrl+K (⌘+K on a Mac), or simply / on the desktop, opens it below the menu bar. It covers every app, each collection as its own group and the modules' contributions. Every word I type has to match, and a hit at the start of a name beats one in a description. Three letters like "ngi" put the nginx documentation on top; Enter opens it.

The Search even has its own address: #search= plus your words opens the desktop straight on the results, handy as a browser bookmark. For screen readers the field is a combobox, and the hit count is announced once I pause typing.

Windows instead of tab switching

The second big win is windows. In the browser I have a tab per tool but see only one at a time. In the desktop, several tools sit side by side in a single tab:

One tab per tool — or one tab for everything
 Before: one tab per tool           Now: one tab, many windows
┌──────────────────────────────┐   ┌──────────────────────────────┐
│[Router][NAS][Server][Notes]… │   │ Ξ  Menu bar     Ctrl+K  12:04│
├──────────────────────────────┤   ├──────────────────────────────┤
│                              │   │┌────────────┐┌──────────────┐│
│  exactly one is visible      │   ││ Terminal   ││ Notes        ││
│                              │   ││ $ dig …    ││ Firewall: …  ││
│  I keep the rest in my head, │   │└────────────┘└──────────────┘│
│  hunt for the right tab,     │   │┌────────────────────────────┐│
│  switch, lose my train of    │   ││ Catalog: Bookmarks         ││
│  thought …                   │   │└────────────────────────────┘│
│                              │   ├──────────────────────────────┤
│                              │   │  Dock  ◇ ◇ ◇ ◇ ◇             │
└──────────────────────────────┘   └──────────────────────────────┘

On the right is how I want to work: terminal, notes and Catalog in view at once. You can move, resize and zoom windows and snap them to a half at the screen edge. F3 or Ctrl+↑ opens the Overview with all open windows side by side. And the next day the desktop restores up to 20 windows in their old places, unless I've switched that off.

There is one limit: many router and server admin interfaces refuse to be framed by another site, and the desktop only embeds pages from another origin if you allow that in the CSP. Such bookmarks open in a new tab. But everything the desktop brings itself — terminal, editor, notes, tasks, calculator, Catalog, your site's pages in the Reader — runs side by side in windows.

Version 1.2.0 made the desktop even tidier for the way I work. Every entry of my site's collections in site/apps.js can have its own manual in every language, which man prints in the terminal. And web windows with pages outside the desktop's folder, say a wiki under /wiki/, return to their last sub-page after a reload. And since 1.3.0, apps stay in the Dock even when I rename them and keep the old id as an alias.

The architecture: a small core, everything else modules

The key insight from the feasibility study is a core principle of the architecture contract — docs/ARCHITECTURE.md, the file that pins down every interface: everything optional is a module. The core knows no concrete app, no website, no provider and no language. Modules talk through a bus, registries and services, never through another module's internals. If an optional module is missing, nothing throws.

This is how the layers stack up:

The layers of the desktop
┌──────────────────────────────────────────────────────────────┐
│ site/     config.js · apps.js · theme.css · content/ · data/ │
│           vault/ · wallpapers/ · modules/   ← yours          │
├──────────────────────────────────────────────────────────────┤
│ Apps      editor · notes · todo · calc · terminal · media ·  │
│           fortune                       (src/apps/<id>/)     │
├──────────────────────────────────────────────────────────────┤
│ Modules   reader · viewer · catalog · search · calendar ·    │
│           holidays · weather · notify · vault                │
├──────────────────────────────────────────────────────────────┤
│ Core      wm (windows) · shell (menu bar, Dock, All apps) ·  │
│ parts     panels (settings, trash, help …)                   │
├──────────────────────────────────────────────────────────────┤
│ Core      config · env · store · bus · i18n · dom · icons ·  │
│           a11y · registry · router · net · consent · …       │
└──────────────────────────────────────────────────────────────┘
   ▲  cross-talk only via bus · registries · services
   └─ never by importing another module's internals

At the bottom sit 23 core files, above them the three core parts — window manager, shell and panels — then nine optional modules and seven app packages. The rule "site/ is yours, src/ is the project" keeps updates simple: swap the project files, and your content stays untouched.

Every part describes itself with a descriptor, a plain object as default export. The example app "Hello" shows how little that takes:

site/modules/hello/index.js (excerpt)
export default {
	id: 'hello',
	kind: 'app',
	i18n: ['hello'],
	locales: 'locales/',            // the texts: locales/<lang>/hello.js next to this file
	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 }
	},
	// …
	terminal: {
		[KEY]: { run: command, help: '@hello.cmd', usage: 'hello [name]', man: '@hello.cmdMan' }
	},

	mount,
	// …
};

The descriptor registers a stored value that is backed up, resettable and validated on read, and it brings its own terminal command. If a module's setup() fails, the core rolls back whatever it had registered, so a broken module doesn't take the desktop down with it.

Small, fast, efficient

For me, "small" is a list of things that are not in the project: zero runtime dependencies, no bundler, no CDN. The desktop is native ES modules that the browser loads exactly as they sit in the repository. The package.json lists exactly two development tools, needed only by people working on the project: the Tabler icon sources and playwright-core for the browser checks. Only the 97 Tabler icons in use end up in a generated file of 17 KB.

ES modules have a price I pay on purpose: the desktop needs a web server, file:// won't do. In return every file has explicit dependencies, and no build step stands between my code and your browser. Change a file, reload, done.

But skipping the build step doesn't make it fast by itself, as a measurement before 1.1.0 showed: on a first visit the desktop loaded 167 files, 135 of them JavaScript modules, about 1.2 MB uncompressed. Worse was the depth: up to 13 levels of static imports in a row, each costing a round trip to the server.

For 1.1.0 I changed three things. First, a window's code only arrives when the window is opened for the first time:

src/apps/notes/index.js (excerpt)
export default {
	id: 'notes',
	kind: 'app',
	i18n: ['notes', 'kit'],
	windowStyles: ['notes.css'],

	app: {
		icon: 'ti-note', tint: 'orange', size: [780, 520], name: '@notes.appName', desc: '@notes.appDesc',
		load: () => import('./window.js')
	},
	// …

The small descriptor comes at startup, the window and its CSS only on the click. Editor, terminal, players, notes, tasks, calculator, Fortune, the panels' contents, Reader, image viewer and Catalog all load this way now. Second, a tool generates src/boot/preload.js, which announces the whole boot graph to the browser at once. Third, language files, site data and the site's icon sets load in parallel. Startup today looks like this:

From the request to the finished desktop
index.html
 │
 ├─► CSS: layers · tokens · base · components · site/theme.css
 ├─► site/config.js       classic: window.DESKTOP_CONFIG
 ├─► src/boot/preload.js  generated: every boot file at once
 ├─► src/boot/theme.js    color scheme before the first paint
 └─► src/boot/main.js
      │
      ├─ initEnv
      ├─ initI18n ───┐
      ├─ site data ──┼─ in parallel
      ├─ icon sets ──┘
      ├─ expose() → window.JPKDesk
      ├─ modules.loadAll: core parts → modules → apps (descriptors)
      └─ body.is-ready + 'desk:ready'
           → restore session, deep links

 First click on a Dock icon
 ──► window appears at once, with a spinner
 ──► import('./window.js') + windowStyles
 ──► mount() → 'window:ready'

Add to that a fast start in the service worker: on the next visit it answers with the code from its offline copy, checks for a new version in the background and offers a reload. Since 1.2.0, feeds and data still come fresh from the server first. I measured version 1.1.0 with the same configuration in headless Chromium, uncompressed, median of three runs:

Measurement before after
Requests and data at startup 167 / 1.15 MB 140 / 0.83 MB
HTTP/2 with 150 ms latency 2.4 s 0.94 s
HTTP/2 with 150 ms and 1.6 Mbit/s 7.8 s 5.3 s
Repeat visit with service worker, 150 ms and 1.6 Mbit/s 1.44 s 0.63 s

On the slow line the data volume dominates; a real web server with gzip or Brotli does better. Lab values, not field data — but the direction is unmistakable.

Security: a strict CSP and no third-party code

A desktop holding my notes and private links has to be tight. The foundation is a strict Content Security Policy (opens in a new tab): default-src 'self'; script-src 'self'; style-src 'self', plus object-src 'none' and frame-ancestors 'self'. Scripts and styles come only from the site's own server; the CSP forbids inline scripts, eval and style attributes.

The code itself rules out HTML from strings: I build the DOM only with a small helper called h(). It refuses keys like innerHTML, outerHTML or insertAdjacentHTML with an error and takes styles only as an object, applied through the CSSOM. In the example app:

site/modules/hello/index.js (excerpt)
const field = h('input', { type: 'text', class: 'hello-input', id: fieldId, name: 'name', maxlength: String(MAX_NAME),
	autocomplete: 'off', props: { value: data.name } });
const label = h('label', { class: 'hello-label', for: fieldId });
const button = h('button', { type: 'submit', class: 'btn btn-primary' });
const output = h('output', { class: 'hello-greeting', for: fieldId, 'aria-live': 'polite' });
const opens = h('p', { class: 'hello-opens' });
const form = h('form', { class: 'hello-form' }, label, h('div', { class: 'hello-row' }, field, button));
body.append(h('div', { class: 'hello' }, form, output, opens));

Texts then go in through textContent, never as HTML. Nowhere in the source is innerHTML used, except in the list of forbidden keys and a comment on it.

The image viewer shows how far this distrust goes: files from your device are shown only as images, never as a document of their own in the desktop's origin. Otherwise a crafted SVG could run there as a document and reach local storage: notes and the Vault's key.

With 1.2.0 I took this distrust further. Now that a site can bring its own icon sets, every icon definition passes an allowlist of simple SVG shapes and presentation attributes — no event attributes, no references, no url(): icon data never runs code. And web windows, now allowed anywhere on the site, never reach the Vault's folder or the desktop itself, not even through escaped or differently spelled paths.

With 1.3.0 the Reader keeps syntax-highlighting colours, but only from a small allowlist of plain colour values that it sets anew itself — it never applies a page's style text. And only someone who has unlocked the Vault sees its row under Reset.

Online services like weather or DNS lookups are off by default. For a request to leave the desktop, the website has to switch the service on and you have to consent. Requests to external servers go out without cookies or a referrer. There's no tracking and no analytics.

Private bookmarks go in the Vault. A script seals a JSON file with AES-256-GCM; the key comes from user name and password via PBKDF2 with 600,000 iterations by default. The file name is derived from the same credentials — so wrong credentials ask for a file that doesn't exist. The Vault is meant for private links, not for secrets.

The tools are an attack surface too, so the repository follows the same rules as my other projects: installs go through Socket Firewall Free (sfw), an .npmrc with ignore-scripts, save-exact and engine-strict, exact versions, GitHub Actions pinned to a commit SHA, and npm audit signatures in CI. The reasons are in Working Securely with Node.js and npm.

Usability and accessibility

A mouse-only desktop wouldn't be a good desktop to me, so accessibility (A11y) was in the contract from day one. The menu bar is a real menu bar with arrow-key navigation, every window a named dialog. Focus is set when a window opens and returned where it came from when it closes. Context menus open with a right-click, a long press or Shift+F10. Changes are announced through a shared live region.

Motion respects prefers-reduced-motion in CSS and JavaScript. Focus rings need at least 3:1 contrast, and if a custom accent color is too pale against the background, the desktop mixes it for focus rings and selection marks until it gets there.

Languages are a list, not a pair: German and English ship, and every further language is a folder under locales/. Plurals follow the CLDR rules, right-to-left scripts are supported, and a text that falls back to another language gets the matching lang attribute. I describe how landmarks organize an interface for screen readers in HTML Landmarks in Practice.

Open source under MIT

A tool that only runs on my machine only helps me. So JPKCom Desktop is under the MIT license: you may use, modify and redistribute it, commercially too, without me imposing conditions on your website.

So that "anyone can adapt it" isn't an empty promise, almost everything a site owner changes lives in site/ — only your icons and a few fixed lines in index.html and manifest.webmanifest come on top. The repository is a GitHub template, and the quickstart "Your own site in 10 minutes" (opens in a new tab) takes you in five steps from copy to published site. Locally you start it like this:

Run it locally
git clone https://github.com/JPKCom/jpkcom-desktop.git
cd jpkcom-desktop
sfw npm ci         # development tools only: Tabler icon sources, headless browser checks
npm run serve      # http://127.0.0.1:8080/ with the production security headers

For publishing there are tested configurations for Apache, nginx, Caddy, Ferron 2 and 3 and Static Web Server, plus a GitHub Pages workflow.

One exception matters to me: the JPK monogram and the JPKCom logo have been my personal logo since 1996. Not a registered trademark, but not MIT-licensed either — all rights stay with me. The code that draws them is MIT. The details are in the License and brand assets section of the guide. One request: keep the credit line "JPKCom Desktop by Jean Pierre Kolb".

From feasibility study to version 1.4.0

It began with four basic decisions. Tabler icons instead of the feasibility study's commercial icons. Native ES modules instead of classic scripts, although the latter would have run from file://. The MIT license, with my logo as the exception. And neutral names: "Search", "Overview", "Catalog" and "All apps", not the names of their counterparts in well-known operating systems.

I built the desktop with AI: the idea, the direction, every decision and every approval came from me; Claude Code did the implementation — from the inventory of the feasibility study through the architecture contract to code, tests and reviews. Nothing went in unchecked. How I work with AI in general is on my AI transparency page.

Then came the live demo, template, quickstart and example app. For the house theme I set one condition: the switch to 108 design tokens must not change anything visible. Comparing 268 views before and after showed zero differences in computed styles and zero pixels off. Version 1.1.0 added a faster start and a hardened supply chain, 1.2.0 manuals per entry, the site's own icon sets and web windows anywhere on the site, 1.3.0 the site's own glyphs for the desktop's symbols and code colours in the Reader, 1.4.0 a more robust offline copy and separate storage for several desktops on one site. Today 675 tests from 40 files pass, and CI checks every change for tests, languages, icons, the preload file and the manifest.

What's deliberately missing

What I left out matters as much as what's in. No framework and no bundler: both would have cost more maintenance than they bring. No account and no server side: what you store in the desktop stays in the browser on your device. No theme package system and no theme picker for visitors — light, dark and the accent color remain theirs. Instead, the house theme in site/theme.css adjusts corner radii, glass and shadows via tokens in a cascade layer of their own. That's enough for a signature look without a second product inside the product.

What comes next

The desktop on jpkc.com/desktop now runs on this project: I've updated the deliberate alpha from the feasibility study to version 1.4.0. Nothing got lost, neither my bookmarks, which now live in the encrypted Vault, nor the login for my tools. My private content sits in a site/ configuration of its own that a script puts on top of exactly one pinned release at build time — none of it reaches the public repository. Whatever proved generally useful has flowed back into the project, into versions 1.2.0 to 1.4.0. Further releases will come where real use asks for them — mine, and gladly yours.

FAQ

Do I need Node.js to run the desktop?

No. The desktop is just static files that any web server can deliver. You need Node.js 24 or newer only for development tools such as tests and the browser check. A double-click on index.html won't start it, though; locally, npm run serve is enough.

Does the desktop work on a phone?

Yes. On narrow screens it switches to a compact view where every window is a card filling the space above the Dock. Context menus open with a long press. As a progressive web app (opens in a new tab) it can also be installed and used offline.

May I use the desktop for my company or for clients?

Yes. The code is MIT-licensed and may be used commercially, modified and redistributed. Only the JPK monogram and the JPKCom logo, my personal logo, are excluded. Put your own logo into site/config.js and replace the icons in assets/icons/; step 2 of the quickstart (opens in a new tab) shows how.

How do I add an app of my own?

Copy the folder site/modules/hello/ and rename everything called "hello". Then add the app with one line in site/config.js and run npm run preload. The detailed steps are in the Write your own app section of the guide.

Conclusion

JPKCom Desktop is the tool I'd wanted for years: a desktop in the browser that brings order to my day, works with keyboard and screen reader, loads nothing from third parties and stays small enough that I understand every line. What makes me happiest is that it's open now. Try the live demo (opens in a new tab), make it your own site with "Use this template" — and if something's missing, I'd love an issue in the repository (opens in a new tab).

Further reading

Setup, customizing and publishing are covered in my JPKCom Desktop guide, from the configuration in site/config.js to the Vault and publishing. What changed from version to version is in the JPKCom Desktop changelog; the architecture contract is in docs/ARCHITECTURE.md (opens in a new tab).

I explain the foundation of the house theme in CSS @layer in Practice, and why few moving parts are also sustainable in Sustainable websites. More on load times is in Core Web Vitals & Performance, and the making-of of my SEO and GEO tool shows another tool of mine.

For your own server, see the cheat sheets on Apache, .htaccess, nginx, Caddy, Ferron and MIME types.