Skip to content

Theme engine architecture

This page documents the runtime theme engine in the Carbon desktop app. It pairs with Authoring (how to ship a pack) and theme.yaml reference (field-by-field spec).

App startup / Settings → ThemeManager.loadTheme(themeId)
│
├─ Resolve id (built-in list + disk merge; built-in wins on id clash)
├─ Disk only: ThemesApi.ReadStylesheet / ReadJavascript
├─ compileDiskThemeStylesheet() (Dart Sass or passthrough .css)
├─ applyThemeStyle() → #carbon-custom-theme, data-theme, carbon:theme-changed
├─ applyThemeIcons() or resetThemeIcons()
├─ Tauri set_backdrop (unless JS enabled + layer === backdrop)
└─ themeHostService.syncJavascriptTheme() → one sandbox iframe (srcdoc)

Only one JavaScript theme iframe runs at a time. Selecting another theme sends destroy, tears down timers, and mounts the new bundle.

Built-in Disk (Skeleton/Themes/<id>/)
Registration defaultThemes in core/src/scripts/theme.ts ThemeManager.DiscoverAll()
Stylesheet Vite ?inline at app build configuration.source via ThemesApi.ReadStylesheet
JavaScript dist/theme.js via ?raw import javascript.entry via ThemesApi.ReadJavascript
Manifest on disk N/A (embedded in bundle) theme.yaml or manifest.json
Id collision Wins Skipped if id matches a built-in

Folder name = theme id. Carbon creates Skeleton/Themes at runtime if missing.

Store Key / API Notes
Backend AppSettings.GetStyle() / SetStyle(themeId) When XPUI backend is ready
Browser localStorage carbon-theme-id Fallback
Migration carbon-canvas → slipstream Legacy id
Unknown id Falls back to default-dark On load

ThemeManager.init() merges disk themes, loads the saved id, and calls themeHostService.ensureInitialized(). If CSS was applied before the bridge listened, resyncJavascriptTheme() re-mounts JS for the active theme.

Type Role
ThemeManifestFiles Resolves theme.yaml vs manifest.json
ThemeYamlParser Parses YAML/JSON into ThemeManifest
ThemeManifestSchema Allow-lists fields, validates javascript.layer, safe paths, 256 KiB JS cap
ThemeManager Scans directories, reads stylesheet / JS / assets
ThemesApi (XPUI) ListDiskThemes, ReadStylesheet, ReadJavascript, ReadAsset

Allowed root keys: manifestVersion, metadata, configuration, javascript.

javascript.layer must be backdrop or overlay when set.

C# javascript fields persisted from disk: enabled, entry, layer only. The desktop UI also reads interactive from bundled manifest.json in the repo; that flag is not in ThemeJavascriptSettings yet, so YAML-only disk themes cannot rely on interactive round-tripping through the backend.

configuration.platformFallback is allowed in JSON schema validation but is not applied by the current loader, treat as reserved.

  • configuration.source and javascript.entry must be relative, no .., must stay inside the theme directory.
  • JavaScript reads enforce 256 KiB (ThemeManifestSchema.MaxJavascriptBytes).
  1. ThemeManager.applyThemeStyle(css): writes <style id="carbon-custom-theme"> and re-appends it to <head> so --crbn-* overrides bundled defaults.
  2. document.documentElement.dataset.theme: active theme id.
  3. window event carbon:theme-changed: { detail: { themeId } }.
  4. Disk SCSS: sass.compileString in themeDiskStyles.ts on the single file returned by the backend (no @import resolution for disk partials).
  5. Preview colors: manifest configuration.colors or regex extraction from CSS in createTheme().

On Tauri, invoke('set_backdrop', { class, theme }) runs after two requestAnimationFrame callbacks so the transparent webview has painted.

Condition Mica
javascript.enabled && layer === 'backdrop' Skipped (canvas visible behind glass)
javascript.enabled && layer === 'overlay' Can still apply per configuration.backdrop
window.__carbonDeferBackdrop set during shell boot Deferred until activateShellBackdrop()

Backdrop type values are normalized (mica-alt → mica_alt) before the Tauri call.

IS_MOCK skips the JS host. Disk themes require the real backend (loadDiskThemeStylesheet throws if backend is not ready).

  • #carbon-theme-host: position: fixed; inset: 0; pointer-events: none; z-index: 0, first child of #app (below .app-container).
  • Iframe: sandbox="allow-scripts" only (no allow-same-origin), credentialless, referrerpolicy="no-referrer", srcdoc from buildThemeShellHtml().
javascript.layer Iframe z-index Colloquial name
backdrop (default) 0 Behind UI
overlay 9000 “Frontdrop” / in front of shell
Constant Value On failure
READY_TIMEOUT_MS 5 s Teardown + toast “failed to start”
WATCHDOG_INTERVAL_MS 15 s Ping iframe
PING_TIMEOUT_MS 2 s Two missed pongs → teardown + toast
DESTROY_GRACE_MS 500 ms Delay before removing iframe DOM

Security: a second load on the iframe (navigation) tears down the theme and shows a security toast.

When javascript.interactive is true on the frontend manifest:

  • Host listens for pointerdown / move / up / cancel on window.
  • Forwards app events: { kind: 'pointer', phase, x, y, buttons, pressure }.
  • Iframe stays pointer-events: none so the app remains clickable.
Payload When
{ type: 'theme-activated', themeId } After iframe ready and init
{ type: 'theme-deactivated', themeId } Before teardown on theme switch
Pointer object When interactive: true

buildThemeShellHtml(themeSource, themeTokensCss):

  1. Runs validateThemeJavascript again at shell build time.
  2. Injects CSP (no connect-src, no workers, no nested frame-src, inline scripts only).
  3. Injects wrapper (CarbonTheme + internal postMessage bridge), then your bundle.

The wrapper creates ctx.canvas and ctx.root, wires host message types to ctx.events, and posts ready after CarbonTheme.mount runs.

Optional themeTokensCss: snapshot of --crbn-* on :root injected as <style id="carbon-theme-tokens"> inside the iframe for first-frame token reads.

Direction source field Allowed type values
Host → iframe carbon-theme-host init, resize, pause, resume, destroy, tokens, event, ping
Iframe → host carbon-theme ready, pong only

Origin must be opaque sandbox ("null"). Theme source must not call postMessage; the static guard blocks it in your bundle.

  • settings: width, height, dpr, reducedMotion, paused, seed
  • seed: also drives ctx.random (mulberry32)
  • tokens: all computed --crbn-* from the live shell

resize updates settings and resizes the default 2D canvas with a DPR transform.

Runs when registering built-ins, loading disk JS, and building srcdoc.

  • Max 256 KiB
  • Single entry: CarbonTheme.mount(function (ctx) { ... });
  • Forbidden APIs after comment/string strip: full table in reference

Failure → toast “JavaScript theme blocked for security” or “failed to start”; no iframe.

File Responsibility
core/src/scripts/theme.ts Registry, ThemeManager, Mica, CSS inject, disk merge
core/src/scripts/themeDiskStyles.ts API reads + Sass compile
core/src/scripts/themeBootstrap.ts Iframe HTML, CSP, CarbonTheme wrapper, layer z-index
core/src/scripts/themeJavascriptGuard.ts Static validation
core/src/services/themeHostService.ts Mount/teardown, watchdog, pointer bridge
core/src/services/themeBridgeService.ts postMessage routing
core/src/stores/icons.ts applyThemeIcons / resetThemeIcons
carbon/ThemeManager.cs Disk discovery and file reads
Page Link
Overview Themes
Authoring Create a theme pack
Engine architecture This page
Manifest reference theme.yaml
Built-in catalog Shipped themes