Docs menuPackaging
On this page4 sections
Packaging a shell
How to build a shell into one module and ship it. Part of How a shell works.
Bundling
domicile ./src/index.ts builds a shell for you
(Distributing), so you need none of this to run one. To ship
a built module, build it yourself with Vite. A complete project:
my-desktop/ package.json vite.config.ts src/ index.tsx (or index.ts) style.css{ "name": "my-desktop", "private": true, "type": "module", "scripts": { "build": "vite build" }, "dependencies": { "@domicile-desktop/react": "next", "react": "^19", "react-dom": "^19" }, "devDependencies": { "@types/react": "^19", "vite": "^8.2.2" }}// src/index.tsx/// <reference types="@domicile-desktop/react/elements" />/// <reference types="vite/client" />import "./style.css";import { mount } from "@domicile-desktop/react/mount";import { useWindows } from "@domicile-desktop/react/windows";/** Every window side by side. */const Desktop = () => ( <main> {useWindows() .filter((window) => window.parent === undefined) .map((window) => ( <app app-id={window.appId} key={window.appId} /> ))} </main>);export const Shell = mount(Desktop);// vite.config.tsimport { defineConfig, type Plugin } from "vite";// Domicile's document has no <link> for a stylesheet, so the CSS Vite// extracts goes into the module as a <style>.const cssInTheModule = (): Plugin => ({ enforce: "post", generateBundle(_options, bundle) { const css = Object.keys(bundle).filter((name) => name.endsWith(".css")); const entry = Object.values(bundle).find( (output) => output.type === "chunk" && output.isEntry, ); if (entry?.type !== "chunk") { return; } for (const name of css) { const asset = bundle[name]; if (asset?.type === "asset") { entry.code = `document.head.append(Object.assign(document.createElement("style"), { textContent: ${JSON.stringify(String(asset.source))} }));\n${entry.code}`; delete bundle[name]; } } }, name: "css-in-the-module",});export default defineConfig({ base: "./", build: { outDir: "dist", rollupOptions: { input: "src/index.tsx", // src/index.ts in JavaScript output: { entryFileNames: "shell.js" }, preserveEntrySignatures: "exports-only", }, }, plugins: [cssInTheModule()],});/* src/style.css */main { display: flex; gap: 8px; height: 100vh; padding: 8px; box-sizing: border-box;}app { flex: 1; border-radius: 8px;}bun installbun run builddomicile ./dist/shell.jsoutDir is up to you. The rest is required, and each fails silently without
it:
input: a script, not an HTML file, so no HTML document is emitted.entryFileNames: fixed. Vite hashes it by default, and the path is what users pass todomicile.preserveEntrySignatures: "exports-only": keeps theShellexport. An app build drops it, along with all code only it reaches.base: "./": makes emitted URLs relative to Domicile's document.cssInTheModule: Vite extracts imported CSS into a separate file for a<link>, and Domicile's document has none. This repo's own version is@domicile-desktop/component-library/vite-shell.
domicile's builder sets all of these itself, and resolves
@domicile-desktop/* and React from the running Domicile. A standalone build
bundles its own copies.
Other rules:
- Dependencies: the bundle must contain everything. Nothing resolves at
run time and there is no
node_modules. Vite's browser build does this by default. - Theme flash: nothing paints before
Shellreturns, anddomicile.themecan benullfor a few milliseconds. Either paint in a default theme and switch on the firstthemechanged, or paint in the last theme you saw (manganese does this) and correct on the firstthemechanged.
Scripts
Domicile serves its document with Content-Security-Policy: script-src
'self', so only files under the module's directory run. Markup from outside
(a notification body, window title or file name put in innerHTML) cannot
run script with the desktop.
Refused:
- inline
<script>elements andon…attributes javascript:URLseval,new Functionand stringsetTimeoutblob:anddata:scripts and workers- WebAssembly
Vite's build emits none of these. A library that probes for eval and falls
back, such as Zod, logs one violation to the console. Styles are not
restricted, so inlined CSS and style attributes work.
Distributing
A shell is a directory containing a module:
my-desktop/ dist/ shell.jsShip it any way (tarball, git checkout, Nix derivation) and pass the module path:
domicile ./my-desktop/dist/shell.js- Pass the module, not the directory. A directory is refused.
- Files next to the module are served. Nothing above its directory is.
domicile can also build a shell from source:
domicile ./my-desktop/src/index.ts # an entry filedomicile my-cool-shell # an npm packagedomicile github:me/my-cool-shell # a repositorydomicile @domicile-desktop/manganese # Domicile's own, prebuiltdomicile load-shelland a JSON config'sshelltake the same forms. Relative paths resolve from the working directory, or from the config file.- A
.jswith no bare imports is a bundle and is served as is. Any other.js,.tsor.tsxis an entry and is built. - An entry's packages go in the nearest
package.jsonandbun.lockabove it, created next to it if there are none. @domicile-desktop/*and React always come from the running Domicile.- The build transpiles and bundles only. For other tools (Tailwind, Panda,
PostCSS), add a
vite.configto the project: see Build config. - A package with a prebuilt module declares it in
package.jsonas"domicile": { "shell": "dist/shell.js" }and is served as is. Otherwise it is built from its entry. - Builds are cached under
$XDG_CACHE_HOME/domicile/shells, keyed on the entry's local imports, the lockfile and Domicile's install. A cache hit builds nothing. - On first start, a build that takes over 500 ms runs behind a splash
(
shell-splash). Nothing else runs in its place, so a desktop never shows a shell the user did not choose. - A failed first build is printed, or shown on the splash, and the desktop
does not start. A failed
load-shellprints the error and keeps the running shell.
Build config
domicile builds an entry with Vite, transpiling and bundling only. Packages
ship their CSS prebuilt: manganese's covers all of its packages, and the
component library's components each load the library's own.
To add a tool, put a vite.config in the entry's project (the directory with
its package.json, or the entry's own directory). For Tailwind:
// vite.config.tsimport tailwindcss from "@tailwindcss/vite";import { defineConfig } from "vite";export default defineConfig({ plugins: [tailwindcss()] });/* style.css, imported by the entry */@import "tailwindcss";Panda with manganese's tokens
css() from @domicile-desktop/manganese/css only names classes. To get
rules for your own calls, run Panda with manganese's config:
bun add -d @pandacss/dev@1 @domicile-desktop/manganese// panda.config.tsimport manganese from "@domicile-desktop/manganese/panda.config";import { defineConfig } from "@pandacss/dev";export default defineConfig({ ...manganese, importMap: "@domicile-desktop/manganese", include: ["./*.tsx"], outdir: "styled-system", preflight: false,});// vite.config.tsimport panda from "@pandacss/dev/postcss";import { defineConfig } from "vite";export default defineConfig({ css: { postcss: { plugins: [panda({ cwd: import.meta.dirname })] } },});/* style.css, imported by the entry */@layer reset, base, tokens, recipes, utilities;- Install the manganese version your Domicile runs. Its config sets the class name hashing, so a different version can name classes differently.
includelists your files; keepnode_modulesout of it.- The builder's tests build this setup.
What the builder adds
- The config's plugins and settings apply.
domicilesets the output (Bundling) and resolves@domicile-desktop/*and React from the running Domicile, over the config's own settings. - Packages the config imports are installed like the entry's.
- The cache key covers the config and the files it imports. A file a tool
reads by name, such as
panda.config.ts, is not covered: edit the entry to rebuild.
Users of your shell never run domicile-compositor and do not need a config
file.