Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

caelestia-tab is a browser extension, plus a small Rust helper, for people who run the caelestia desktop. It gives you:

  • a new tab whose colours and background follow caelestia’s current scheme and wallpaper, and change live when you switch;
  • bookmarks on a grid you define yourself, with images or solid scheme colours;
  • the catppuccin/userstyles site themes (134 sites), recoloured with the live scheme instead of a Catppuccin flavour;
  • the scheme on every page as --caelestia-* CSS variables, for your own Stylus styles and userscripts.

It works in Firefox and Firefox-based browsers (Floorp, Zen, LibreWolf, Waterfox). The browser’s own window frame is out of scope: CaelestiaFox already recolours that, and the two work side by side.

Everything on the new tab is a plugin, the built-in widgets included. IDEAS.md holds the plans for what comes next.

Screenshots

Each browser in one window, switched live: the dark purple scheme, then the dark red one from another wallpaper, then light mode. GitHub is themed by the Catppuccin style compiled against the scheme. The sidebars are Tree Style Tab (Firefox, Floorp) and Zen’s own (Zen, through the Zen mod). Taken 2026-09-27; Firefox and Floorp with CaelestiaFox recolouring the toolbar.

Dark purpleDark redLight purple
Firefox
Floorp
Zen

Installing

There are two parts: the helper, which reads caelestia’s files, and the extension, which the browser runs. The extension works without the helper, but then it has no scheme or wallpaper and falls back to a fixed dark red.

The helper

With Nix:

nix profile install git+https://git.hu-tao.dev/hutao/caelestia-tab
caelestia-tab install

With Cargo:

cargo install --git https://git.hu-tao.dev/hutao/caelestia-tab
caelestia-tab install

caelestia-tab install writes the native messaging manifest, caelestia_tab.json, to ~/.mozilla/native-messaging-hosts/. It also writes it to the matching directory of every fork whose home directory exists (~/.zen, ~/.librewolf, ~/.floorp, ~/.waterfox). The manifest holds the helper’s absolute path, so run install again if the binary moves. caelestia-tab uninstall removes the manifests.

On NixOS or Home Manager

A path in the Nix store changes on every update, so it’s better to let the browser wrapper link the manifest than to run install. The package ships the manifest in lib/mozilla/native-messaging-hosts/, the shape nixpkgs’ Firefox wrapper expects:

home.packages = [
  (pkgs.floorp-bin.override {
    nativeMessagingHosts = [ inputs.caelestia-tab.packages.${pkgs.system}.default ];
  })
];

The same override works on firefox, firefox-bin, librewolf and the other wrapped Firefox packages.

The extension

It isn’t on addons.mozilla.org yet. Until it is:

  • To try it, open about:debugging, choose This Firefox (or This Floorp, and so on), then Load Temporary Add-on…, and pick extension/dist/manifest.json. It stays until the browser restarts. Build it first: npm ci --prefix extension && npm run --prefix extension build.
  • To keep it, build an .xpi with web-ext build --source-dir extension/dist and install it from about:addons (the gear, then Install Add-on From File…). Release Firefox refuses unsigned add-ons. Developer Edition, Nightly, ESR and unbranded builds accept them once xpinstall.signatures.required is false in about:config. Whether a fork honours that preference depends on how it was built; see Browsers.

Zen

Zen’s window needs one more step, a script in Zen’s install directory; see Zen’s window.

Site themes

Firefox asks for “Access your data for all websites” separately in Manifest V3. Grant it in the new tab under Settings, Websites, Allow, or in about:addons, caelestia-tab, Permissions. Without it the new tab still works, but no page gets themed.

Browsers

One extension covers every Firefox-based browser. What differs is where each one looks for the helper’s manifest, and a few browser settings.

BrowserManifest directoryNotes
Firefox~/.mozilla/native-messaging-hosts/
Floorp~/.mozilla/native-messaging-hosts/Floorp keeps Firefox’s lookup, even though its profiles live elsewhere (read from its omni.ja, 2026-09-26).
Zen~/.mozilla/native-messaging-hosts/Like Floorp, though its profiles live in ~/.zen (confirmed with Zen 1.22.3b, 2026-09-27). See the new tab note below.
LibreWolf~/.librewolf/native-messaging-hosts/
Waterfox~/.waterfox/native-messaging-hosts/

caelestia-tab install writes to ~/.mozilla always, and to a fork’s own directory only if the fork’s home directory already exists. So install the browser first, then run install.

Is the helper connected?

Open Settings, Advanced on the new tab. Status shows the scheme it received, or the browser’s reason it couldn’t start the helper. The usual one is “No such native application caelestia_tab”: the manifest isn’t where the browser looks, or its path points at a binary that’s gone.

The helper’s own errors (a scheme file it can’t parse, a wallpaper it can’t read) go to the extension’s console: about:debugging, caelestia-tab, Inspect.

Zen’s new tab

Out of the box Zen opens its command bar instead of a new tab page, so the new tab never shows. Set zen.urlbar.replace-newtab to false in about:config (it defaults to true in Zen 1.22.3b).

Tree Style Tab

With Tree Style Tab installed, its sidebar follows the scheme live, with the same choices as the new tab’s background (Settings, Browser):

  • A colour (the default: primary, dimmed 86%): the colour dimmed towards the surface mixed towards black (40% in dark mode, 12% in light, where surfaces grey quickly), so it sits a step darker than the page in both modes and a high dim is a light tint.
  • The caelestia wallpaper, dimmed towards the scheme’s background and blurred as you set, with the tabs on a translucent tint of primary. X offset and Y offset (-100% to 100%, 0 centred) move it: -100 lines up its left or top edge with the sidebar’s, 100 its right or bottom. The sidebar gets a copy scaled to 900 px, since TST keeps the style in every sidebar.
  • Tree Style Tab’s own look.

The fields are the background’s: the colour shows when the source is a colour, and the blur and offsets when it’s the wallpaper. A tint set up before this, with its Strength, carries over as the same colour dimmed by 100 minus it.

It goes through TST’s own API: caelestia-tab registers with TST and hands it a stylesheet, so it needs no permission, and nothing happens without TST. The wallpaper option is unit-tested for the CSS it produces (tests/extension.test.mjs); how it looks in a real sidebar is unconfirmed.

Zen’s own window

Zen ignores the WebExtension theme API: a test extension that set every theme colour to something garish recoloured Firefox completely and changed nothing in Zen 1.22.3b (2026-09-27). So CaelestiaFox doesn’t reach Zen’s window either. caelestia-tab colours it with a Zen mod, kept live by a small autoconfig script; see Zen’s window.

Tested with

Checked on 2026-09-27, each with the extension loaded through web-ext run, the helper registered by caelestia-tab install, and live wallpaper switches through caelestia wallpaper:

BrowserVersionNew tabLive switchSite themes
Firefox156yesyesyes (GitHub, YouTube)
Floorp12.17yesyesyes (GitHub, YouTube, logged in)
Zen1.22.3byesyesyes (GitHub, YouTube)

YouTube’s cookie-consent overlay, shown to a logged-out visitor, keeps its own colours; the catppuccin style doesn’t cover it.

Chrome and Chromium

Not yet. The extension uses the browser.* namespace, and Chrome’s service worker background has no DOM, which the bundled less.js expects. Chrome support is planned in IDEAS.md.

Zen’s window

Zen ignores the WebExtension theme API, so neither caelestia-tab nor CaelestiaFox can colour its window from the extension. caelestia-tab does it with a Zen mod instead, and keeps it live with a small autoconfig script.

How it works

  1. The mod. On every scheme change, the helper writes chrome/zen-themes/caelestia-tab/chrome.css into every Zen profile it finds, and registers the mod in the profile’s zen-themes.json once. The CSS gives the window the same primary tint on a darkened surface as the Tree Style Tab sidebar. It also sets Zen’s accent (--zen-primary-color), the toolbar text colour and the colour scheme, so Zen follows light and dark mode.
  2. The reload. Zen only reloads mods when the pref zen.mods.updated-value-observer flips; that’s all its own “update mods” does. Nothing outside the browser can flip a pref in a running Zen, so zen/caelestia-tab.cfg, an autoconfig script, does it from inside. It checks the mod’s chrome.css timestamp once a second and flips the pref when it changes. It reads that one file and sets that one pref, nothing else.

Without the script the mod still applies, but only when Zen starts, or when you toggle a mod in Zen’s settings.

Profiles are found through profiles.ini in ~/.config/zen (current Zen), ~/.zen (older Zen) and ~/.var/app/app.zen_browser.zen/.zen (the Flatpak). The helper only runs while a browser with caelestia-tab is open, so that’s when the mod follows the scheme.

To turn it off, disable the caelestia-tab mod in Zen’s settings. The helper registers it once and never re-enables it.

Installing the script

Autoconfig runs from Zen’s install directory, not the profile, so it’s installed once per Zen install.

Any distribution

sudo caelestia-tab install-zen            # looks in /opt/zen, /opt/zen-browser, /usr/lib/zen, …
sudo caelestia-tab install-zen /path/to/zen

This writes defaults/pref/caelestia-tab.js and caelestia-tab.cfg next to Zen’s binary, then restart Zen once. The directory is the one that holds the zen binary and a defaults/pref folder. A package upgrade can remove the files, so run it again after one.

Limits:

  • One autoconfig per install. If something else already sets general.config.filename (a userChrome.js loader, a distribution’s policies), the two collide. Merge caelestia-tab.cfg into the other one instead.
  • Flatpak and AppImage installs are read-only, so the script can’t be added. The mod still applies when Zen starts.

Nix

The flake’s lib.wrapZen adds the script to a wrapped Zen package:

home.packages = [
  (inputs.caelestia-tab.lib.wrapZen (
    inputs.zen-browser.packages.${pkgs.system}.beta.override {
      nativeMessagingHosts = [ inputs.caelestia-tab.packages.${pkgs.system}.default ];
    }
  ))
];

It also fixes two things in zen-browser-flake’s wrapper that stop autoconfig from running at all. The wrapper symlinks Zen’s binary, so Zen runs from the unwrapped package’s directory, which has no autoconfig: wrapZen copies the binary, as nixpkgs’ own Firefox wrapper does. And it turns off the autoconfig sandbox, which would otherwise leave the script with prefs only.

Why autoconfig is safe enough here

An autoconfig script runs with the browser’s full privileges, which is why Firefox sandboxes it by default and why caelestia-tab keeps this one small: a timer, one stat, one pref. Read it before installing it: zen/caelestia-tab.cfg.

The page and its tabs

The page is fixed: a bar along the top with the menu button, the menu’s tabs and the toolbar; the clock under it; and the bookmarks at the bottom. Open the menu and its tab covers the clock (see The new tab). Everything is edited from the page: turn on the pen and pick a part (the toolbar, the clock, the bookmarks, or the open tab) for its settings in the side panel.

Toolbar

The bookmarks’ +, the pen and the settings button, each of which can be turned off, and which side of the bar it sits on (the menu’s tabs take the other). Without the settings button, Ctrl+, still opens settings.

Clock and date

The time and the date, each with its own separator, size (in pt) and font. 12- or 24-hour, a leading zero on the hour, seconds, four date styles, and the glass behind it can be turned off.

Bookmarks

Tiles on a CSS grid you define, or in even rows, each with a line under it in the colour that goes with the tile’s, one of your own, or none. See The new tab.

The menu’s tabs

Open or closed, and the tab it’s on, stay as you left them, in every new tab. Tabs of your own come after these (see Writing a menu tab).

  • GitHub: each of your searches (one per line, Label: query) as a column of compact cards, and your recent activity (pushes, pull requests, reviews, comments …) where a line’s query is @activity, as in Recent activity: @activity. A repeated query is one column, and a line with a label but no query yet is skipped. A push reads Pushed 16bdf70 to main and links to that commit. With Private activity off, every column is public repositories only: each search is sent with is:public, and the activity column is GitHub’s own list of your public events (so it’s your last public ones, not your last 30 filtered down to nothing). The helper keeps both twins of every search for an hour and both lists of events, so flipping it back and forth shows the other at once and doesn’t ask GitHub again. The refresh button spins until the helper answers, and gives up after 15 seconds if nothing changed (while rate-limited, say). Beside it, when the helper last fetched them, as Updated at 22:31, Updated at yesterday, 22:31 or Updated at 26-09-2026 22:31: the words and the three formats are settings.
  • Media: a tab per player at the top (four a row, two on a narrow page, the last row’s sharing the whole width), then the one picked, or the one playing: its cover, title, album and artist, year and controls, and the lyrics beside them when there’s room (these on a card tinted with the primary container), under them when there isn’t. The cover’s size and its column’s width are settings. Timed lyrics follow the song and a line clicked plays from there; scroll them yourself and they stop following until you’ve stopped with the current line in view. Lyrics without times say so.

The helper’s data

The tabs read what the helper gathers from app.data[topic], and ask for it with tell({ topic, command, … }). A tab of your own can too:

  • media: every MPRIS player on the session bus (the Spotify app, mpv, Firefox’s own media …), with its title, artist, album, year, cover, length and position, and the controls: PlayPause, Play, Pause, Next, Previous and SetPosition, and nothing else on the bus. MPRIS doesn’t announce the position as it moves, so each player carries the position with the time it was read (at) and its rate, for a tab to move it on itself. Checked with Firefox’s media and the Spotify app.
  • lyrics: a track’s lyrics from LRCLIB, after a tab sends { command: "get", artist, title, album, seconds }, timed (synced: [{ ms, text }]) when LRCLIB has them, plain otherwise, or none. The artist, title and album go to lrclib.net, and only when a tab asks, and once a track: lyrics found are kept in ~/.cache/caelestia-tab/lyrics/. Some players send lyrics themselves (lyrics on the player).
  • github: GitHub searches, and your recent events when asked, after a tab sends { command: "queries", widget: id, queries, activity, private } (its own list, replacing its last one). The tab adds is:public to its queries itself; private: false asks for publicActivity rather than activity. results holds every search kept, asked for now or in the last hour, each with its ETag. The helper fetches them every five minutes, on { command: "refresh" } and when a search is new, side by side, and otherwise answers from what it last fetched, so opening a tab doesn’t search again: GitHub allows 30 searches a minute. An unchanged result comes back as a 304. Rate-limited, it keeps what it had, says so (error, limitedUntil) and waits for GitHub’s reset. Every open tab reads the same results from storage, so ten open tabs make no more requests than one.

GitHub’s token

No setup, if you use gh or git with GitHub already. The helper takes the first token it finds:

  1. The github secret alias (below).
  2. GH_TOKEN or GITHUB_TOKEN, in the browser’s environment.
  3. gh auth token, if you’re logged in with the GitHub CLI.
  4. git’s credential helper for github.com, with prompts turned off, so a helper that would ask (a terminal prompt, Git Credential Manager’s window) answers nothing instead of popping up.

The token never leaves the helper: the extension gets the search results and where the token came from, nothing else. Checked on 2026-09-27 with a gh login; the other three are unconfirmed.

Secrets

Secrets live outside the browser and are named by alias in ~/.config/caelestia-tab/secrets.toml. The file holds no values, only where each one is:

[github]
sops = "~/secrets/secrets.yaml"   # decrypted with `sops -d`
key = "github.token"              # nested keys, dot-separated; tokens.0 indexes a list

[weather]
command = "pass show weather"     # the first line of its output

[other]
env = "OTHER_TOKEN"               # or: file = "~/.config/other/token"

A plugin asks for an alias when it needs the value; the helper resolves it then and keeps the value in memory only. No page, snapshot or storage.local ever sees one. For SOPS, sops has to be on the browser’s PATH and able to reach its key (age, GPG …) the way it does from a terminal.

Writing a menu tab

The page itself is fixed (the bar, the clock, the bookmarks); what you add to it is a tab in the menu. A tab is a Svelte component that also exports tab: its label, a Nerd Font glyph for its button, its defaults, and the fields its settings take. With the pen on, the open tab’s chip opens a form drawn from those fields in the side panel, and what the user sets arrives as the component’s settings prop. The Media tab (extension/src/components/tabs/CtMedia.svelte) is a complete example.

<script module lang="ts">
  import type { TabInfo } from "$ct/fields.ts";

  export const tab: TabInfo = {
    label: "Hello",                       // its button on the menu's bar
    glyph: "\uf256",                      // nf-fa-hand_paper_o
    defaults: { who: "world", loud: false },
    fields: [                             // the form the pen opens for it
      { key: "who", label: "Greet", type: "text" },
      { key: "loud", label: "Shout", type: "checkbox" },
    ],
  };
</script>

<script lang="ts">
  let { settings }: { settings: any } = $props();
</script>

<p class="ct-hello rounded-2xl bg-glass p-4 text-on-surface">
  Hello, {settings.loud ? settings.who.toUpperCase() : settings.who}
</p>

Put it in your components folder (see Your own components) and build: it’s a tab after caelestia-tab’s own (GitHub, Media), in the order of the file names. The clock, the toolbar and the bookmarks take the same fields (their widget export), which is how the pen edits them too.

What a tab gets

  • settings: this tab’s settings, defaults filled in with what’s saved, under menu.tabs.<ComponentName>. It’s live state: assign to it (settings.who = "you") and the change is saved, and every open tab shows it. There’s no save call.
  • The whole app state, getContext<App>("ct") (see The new tab): the helper’s data in app.data, tell() for a command to a helper plugin, and edit(app, title, component, props) from store.svelte.ts for an editor of your own in the side panel. The editor gets an onclose prop. There are no pop-ups: an editor changes the settings directly, so the page shows it live.

The tab fills the panel under the bar and scrolls when it’s taller; a h-full root can divide the height itself, as Media does.

Fields

extension/src/fields.ts lists the field types: checkbox, text, url, number, textarea, select, range, colour (a scheme colour or a fixed one), font (a font-family, with the installed fonts offered), image (a URL or an upload), glyph (a Nerd Font glyph, with suggestions for the words its words(values) returns) and presets (buttons that set several keys at once). Any field can take when: (values) => boolean to show only when it applies, like a colour only when the source is a colour. CtForm draws them, bound to an object, and works inside your own components too.

Colours and style

The Ct* components use Tailwind utilities only, and the scheme is Tailwind’s palette: bg-primary, text-on-surface, border-outline-variant, bg-glass for the frosted panels. They follow the scheme live. Outside Tailwind the same colours are var(--caelestia-primary) and so on. scheme.ts exports COLOURS (the colours offered in pickers), cssColour(token), onColour(token) (the text colour for a background colour) and complement(token) (the accent that goes with it, as under a bookmark).

Font sizes are in pt, everything else in rem. Give the root element a ct-<name> class, so custom CSS can find it, and square the corners that meet a window edge with in-[.ct-edge-bottom]:rounded-b-none and its siblings.

Your own components

Yours go in ~/.config/caelestia-tab/components/ (or wherever CAELESTIA_TAB_COMPONENTS points), and the next build takes them in:

npm run --prefix extension build

Every .svelte file there is compiled with ours. One that exports tab is a menu tab; the rest are components your tabs import (import Card from "./Card.svelte"). They reach ours through $ct: $ct/fields.ts, $ct/store.svelte.ts (tell, edit, the App type), $ct/components/CtIcon.svelte, $ct/components/CtMarquee.svelte and so on. Style them however you like: Tailwind’s classes and the scheme’s colours work in them (the build scans the folder), and so do <style>, lang="scss" and plain CSS.

A tab whose component isn’t in a build, because it was built without your folder, keeps its settings: it just isn’t on the bar.

A build without that folder is a build without your components, so an extension built elsewhere (or signed for a store) won’t have them. They need a build of your own, which an unsigned install accepts on Firefox Developer Edition, Nightly and forks with signing off (see Installing).

The Ct prefix is reserved

Components whose names start with Ct are the project’s, and the build refuses a file of yours named that way, since it would stand in for one of ours.

Components that need data from outside the browser

A web page can’t read files or keep secrets, and the stores reject extensions that run downloaded code. So anything that reads the machine (files, D-Bus, passwords) belongs in the helper as a data plugin: implement the Plugin trait in src/plugins/, and list it in plugins::all(). Its value lands in storage.local under its topic, where a component can read it and watch for changes. See The helper.

Theming your own sites

Every page gets the live scheme as CSS custom properties on :root, so your own Stylus styles and userscripts can follow caelestia as well as the bundled themes do.

The names are caelestia’s colour names in kebab case: surfaceContainerHigh becomes --caelestia-surface-container-high. The Material colours are all there (primary, on-primary, primary-container, surface, outline and so on), and so are the Catppuccin names (base, mantle, text, mauve …), the terminal colours (term0 to term15) and --caelestia-mode, which is dark or light.

A Stylus style

@-moz-document domain("example.com") {
  body {
    background: var(--caelestia-surface);
    color: var(--caelestia-on-surface);
  }
  a {
    color: var(--caelestia-primary);
  }
  /* Blends work too, and follow the scheme live. */
  .card {
    background: color-mix(in srgb, var(--caelestia-primary) 12%, var(--caelestia-surface));
  }
}

Switch the caelestia scheme and the page changes without a reload.

A userscript

The variables are on document.documentElement. When the scheme changes, a caelestia-scheme event fires on document:

function apply() {
  const primary = getComputedStyle(document.documentElement).getPropertyValue("--caelestia-primary").trim();
  // use it
}
apply();
document.addEventListener("caelestia-scheme", apply);

A site of your own

For a site no bundled style covers: Settings, Websites, type its name in the filter, and press Enter (or pick Add “…”, a site of your own under the list). It opens with three fields: the domains it’s on, one per line; Also on pages matching, a CSS selector for pages it’s on wherever they’re hosted; and your CSS, which gets the scheme as var(--caelestia-*) like any page. It can be turned off with its checkbox, like the bundled ones, and removed from its own section.

Overriding a bundled site style

The bundled styles are Catppuccin’s, applied as upstream wrote them. To go further, open Settings, Websites and click a site’s name. Each has three fields, all empty by default:

  • Also on: more domains for the style, one per line. docs.example.org covers its subdomains too.
  • Also on pages matching: a CSS selector. Once a page has loaded, the style applies to it if the selector matches anything, wherever the page is hosted. This is for kinds of site, like documentation generators, that live on many domains.
  • Your CSS: applied after the style, on every page the style applies to. It can use the --caelestia-* variables, and wins over the style, since it comes later.

The page picks up a change on its next scheme switch or reload. Extra domains and selectors take the style’s rules for all its pages at once: the path filters some styles have (GitHub’s leaves out github.com/home, for example) don’t apply to them.

Recipes

mdBook, on any domain. Catppuccin’s mdBook style lists only a few well-known books. Every mdBook page has a theme picker, so under mdBook, set Also on pages matching to:

#mdbook-theme-list, #theme-list

(#mdbook-theme-list in mdBook 0.5, #theme-list before it.)

claude.ai’s current design. Claude moved to a new set of --cds-* variables, and Catppuccin’s Claude style still sets the old ones, so it changes nothing. Paste this into Claude’s Your CSS:

/* A site override for claude.ai (Settings → Websites → Claude → Your CSS).
   Claude moved to a --cds-* design system; catppuccin's Claude style still
   sets the older --bg-* variables, which Claude no longer reads. Claude
   derives almost everything from a neutral scale, the surfaces and an accent
   role, so this sets those, on every element Claude declares them on, and
   !important beats Claude's own there. Checked against claude.ai on
   2026-09-27. */
:root,
.cds-root,
.cds-dark-scope,
[data-mode] {
  --cds-neutral-0: color-mix(in srgb, var(--caelestia-on-surface) 0.0%, var(--caelestia-surface)) !important;
  --cds-neutral-10: color-mix(in srgb, var(--caelestia-on-surface) 1.1%, var(--caelestia-surface)) !important;
  --cds-neutral-20: color-mix(in srgb, var(--caelestia-on-surface) 2.2%, var(--caelestia-surface)) !important;
  --cds-neutral-30: color-mix(in srgb, var(--caelestia-on-surface) 3.3%, var(--caelestia-surface)) !important;
  --cds-neutral-40: color-mix(in srgb, var(--caelestia-on-surface) 4.4%, var(--caelestia-surface)) !important;
  --cds-neutral-50: color-mix(in srgb, var(--caelestia-on-surface) 5.6%, var(--caelestia-surface)) !important;
  --cds-neutral-60: color-mix(in srgb, var(--caelestia-on-surface) 6.7%, var(--caelestia-surface)) !important;
  --cds-neutral-70: color-mix(in srgb, var(--caelestia-on-surface) 7.8%, var(--caelestia-surface)) !important;
  --cds-neutral-80: color-mix(in srgb, var(--caelestia-on-surface) 8.9%, var(--caelestia-surface)) !important;
  --cds-neutral-90: color-mix(in srgb, var(--caelestia-on-surface) 10.0%, var(--caelestia-surface)) !important;
  --cds-neutral-100: color-mix(in srgb, var(--caelestia-on-surface) 11.1%, var(--caelestia-surface)) !important;
  --cds-neutral-150: color-mix(in srgb, var(--caelestia-on-surface) 16.7%, var(--caelestia-surface)) !important;
  --cds-neutral-200: color-mix(in srgb, var(--caelestia-on-surface) 22.2%, var(--caelestia-surface)) !important;
  --cds-neutral-250: color-mix(in srgb, var(--caelestia-on-surface) 27.8%, var(--caelestia-surface)) !important;
  --cds-neutral-300: color-mix(in srgb, var(--caelestia-on-surface) 33.3%, var(--caelestia-surface)) !important;
  --cds-neutral-350: color-mix(in srgb, var(--caelestia-on-surface) 38.9%, var(--caelestia-surface)) !important;
  --cds-neutral-400: color-mix(in srgb, var(--caelestia-on-surface) 44.4%, var(--caelestia-surface)) !important;
  --cds-neutral-450: color-mix(in srgb, var(--caelestia-on-surface) 50.0%, var(--caelestia-surface)) !important;
  --cds-neutral-500: color-mix(in srgb, var(--caelestia-on-surface) 55.6%, var(--caelestia-surface)) !important;
  --cds-neutral-550: color-mix(in srgb, var(--caelestia-on-surface) 61.1%, var(--caelestia-surface)) !important;
  --cds-neutral-600: color-mix(in srgb, var(--caelestia-on-surface) 66.7%, var(--caelestia-surface)) !important;
  --cds-neutral-650: color-mix(in srgb, var(--caelestia-on-surface) 72.2%, var(--caelestia-surface)) !important;
  --cds-neutral-700: color-mix(in srgb, var(--caelestia-on-surface) 77.8%, var(--caelestia-surface)) !important;
  --cds-neutral-750: color-mix(in srgb, var(--caelestia-on-surface) 83.3%, var(--caelestia-surface)) !important;
  --cds-neutral-800: color-mix(in srgb, var(--caelestia-on-surface) 88.9%, var(--caelestia-surface)) !important;
  --cds-neutral-810: color-mix(in srgb, var(--caelestia-on-surface) 90.0%, var(--caelestia-surface)) !important;
  --cds-neutral-820: color-mix(in srgb, var(--caelestia-on-surface) 91.1%, var(--caelestia-surface)) !important;
  --cds-neutral-830: color-mix(in srgb, var(--caelestia-on-surface) 92.2%, var(--caelestia-surface)) !important;
  --cds-neutral-840: color-mix(in srgb, var(--caelestia-on-surface) 93.3%, var(--caelestia-surface)) !important;
  --cds-neutral-850: color-mix(in srgb, var(--caelestia-on-surface) 94.4%, var(--caelestia-surface)) !important;
  --cds-neutral-860: color-mix(in srgb, var(--caelestia-on-surface) 95.6%, var(--caelestia-surface)) !important;
  --cds-neutral-870: color-mix(in srgb, var(--caelestia-on-surface) 96.7%, var(--caelestia-surface)) !important;
  --cds-neutral-880: color-mix(in srgb, var(--caelestia-on-surface) 97.8%, var(--caelestia-surface)) !important;
  --cds-neutral-890: color-mix(in srgb, var(--caelestia-on-surface) 98.9%, var(--caelestia-surface)) !important;
  --cds-neutral-900: color-mix(in srgb, var(--caelestia-on-surface) 100.0%, var(--caelestia-surface)) !important;
  --cds-surface-0: var(--caelestia-surface) !important;
  --cds-surface-1: var(--caelestia-surface-container-low) !important;
  --cds-surface-2: var(--caelestia-surface-container) !important;
  --cds-surface-3: var(--caelestia-surface-container-high) !important;
  --cds-text-primary: var(--caelestia-on-surface) !important;
  --cds-text-secondary: var(--caelestia-on-surface-variant) !important;
  --cds-text-muted: var(--caelestia-outline) !important;
  --cds-role-accent-fill: var(--caelestia-primary) !important;
  --cds-role-accent-fill-hover: color-mix(in srgb, var(--caelestia-primary) 85%, var(--caelestia-on-surface)) !important;
  --cds-fill-accent: var(--caelestia-primary) !important;
  --cds-text-accent: var(--caelestia-primary) !important;
  --cds-bg-accent: color-mix(in srgb, var(--caelestia-primary) 20%, transparent) !important;
}

Limits

  • Pages the browser won’t let extensions touch (about: pages, the add-ons site) get nothing.
  • The variables need the site-theme permission (Settings, Websites, Allow). Turning Theme websites off keeps the variables and drops only the bundled themes.

Development

Everything runs from the dev shell, which carries Rust, mdBook, web-ext and Node:

nix develop
sh scripts/setup-hooks.sh   # once per clone: the pre-commit hook

nix develop .#ci is the same shell without rust-analyzer and the UI tests’ browsers, half the download. CI uses it for every job but the UI tests and the release (which builds old tags, from before it), since each job starts with an empty store; the Rust job also keeps cargo’s registry and target/ in the Actions cache.

The layout

.
├── src/                    # the helper (Rust)
│   ├── main.rs             # the CLI: install, uninstall, manifest, or run as the host
│   ├── host.rs             # native messaging: framing, parts, file watching
│   ├── install.rs          # where each browser looks for the manifest
│   ├── zen.rs              # the Zen mod, and install-zen
│   └── plugins/            # data plugins: scheme, wallpaper
├── extension/              # the extension: Svelte 5 and TypeScript, built with Vite
│   ├── newtab.html         # the new tab's page; Vite's entry
│   ├── src/
│   │   ├── newtab.ts       # mounts App with the state from store.svelte.ts
│   │   ├── store.svelte.ts # settings, scheme and wallpaper; saving and syncing
│   │   ├── app.css         # the only global CSS: scheme variables, font, Tailwind
│   │   ├── components/     # Ct* components; widgets/ holds the widgets
│   │   ├── fields.ts       # the settings API: field types, WidgetInfo
│   │   ├── widgets.ts      # finds every widget component
│   │   ├── layout.ts       # even rows, window-edge detection
│   │   ├── background.ts   # helper connection, site theme injection
│   │   ├── render.ts       # the new tab for svelte/server
│   │   ├── scheme.ts       # the scheme as CSS variables, colour tokens
│   │   ├── userstyles.ts   # compile a style, split and match @-moz-document
│   │   ├── treestyletab.ts # the Tree Style Tab sidebar
│   │   └── glyphs.ts       # Nerd Font glyph suggestions and search
│   ├── public/             # copied into dist/ as they are
│   │   ├── manifest.json
│   │   ├── content.js      # per page: asks for its theme, keeps what it got
│   │   ├── userstyles/     # vendored catppuccin/userstyles + index.json
│   │   └── vendor/         # vendored less.js and the Nerd Fonts symbols
│   └── dist/               # the built extension (not committed)
├── zen/                    # Zen's autoconfig: the pref file and the watcher script
├── scripts/vendor-userstyles.mjs   # refreshes extension/public/userstyles and less.js
├── scripts/vendor-nerd-fonts.sh    # refreshes extension/public/vendor/nerd-fonts
├── tests/                  # node --test for userstyles, glyphs and layout, and the Firefox tests
└── docs/                   # this handbook

Checks

The pre-commit hook and CI run the same things:

cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test
node --test tests/*.test.mjs
npm ci --prefix extension          # once, and when package-lock.json changes
npm run --prefix extension check   # svelte-check: TypeScript and Svelte
npm run --prefix extension build   # extension/dist
web-ext lint --source-dir extension/dist --self-hosted

node --test runs the TypeScript sources directly; Node strips the types.

And, slower, the whole path in a real headless Firefox. The pre-push hook runs it; CI doesn’t, because the runner has no home directory and nixpkgs’ Firefox wrapper won’t build without one:

cargo build
nix shell nixpkgs#firefox nixpkgs#geckodriver -c node tests/e2e-firefox.mjs

It runs Firefox under a throwaway HOME and XDG_STATE_HOME, so it never touches your browser or your caelestia state. It checks that the new tab override loads, the helper’s scheme and wallpaper arrive, a scheme file renamed into place reaches the open tab live, and a matching page gets the variables and its compiled Catppuccin style.

How fast a new tab is, the same way, with your wallpaper, scheme and settings (copied into the throwaway HOME):

nix shell nixpkgs#firefox nixpkgs#geckodriver -c node tests/newtab-speed.mjs

It opens a few new tabs and prints, for each, when the page’s script ran, its state loaded, it mounted, the wallpaper was ready and it first painted, in ms from navigation. The page sets those as performance marks (ct-start, ct-state, ct-mounted, ct-wallpaper), so the same numbers come from any browser’s console on a new tab:

Object.fromEntries([...performance.getEntriesByType("mark"), ...performance.getEntriesByType("paint")].map((e) => [e.name, Math.round(e.startTime)]))

The UI has Playwright tests (extension/tests/newtab.spec.ts), run by the pre-push hook and CI:

npm run --prefix extension build && npm run --prefix extension test

They drive the built new tab like a user: the pen opening a widget in the side panel and editing it live, a bookmark’s tile and the panel’s title following its name, glyph suggestions following the name, adding and removing a widget, fields that only show when they apply, Escape, and edits surviving a reload. Any uncaught error on the page fails a test. A new interaction gets a test.

Playwright can’t install an add-on into Firefox (it does extensions in Chromium only), so these load dist/newtab.html as a page, served by vite preview, with the extension API stood in by extension/tests/browser-shim.js: storage in localStorage, no helper. What needs the real extension (the helper, storage between pages, site injection) is the e2e test’s. The browsers come from nixpkgs (PLAYWRIGHT_BROWSERS_PATH in the dev shell), and @playwright/test is pinned to the same version; bump both together.

To check the site styles themselves against the live sites, which takes about ten minutes and needs the network, run this by hand after vendoring a new catppuccin/userstyles revision:

nix shell nixpkgs#firefox nixpkgs#geckodriver -c node tests/sites-firefox.mjs          # all of them
nix shell nixpkgs#firefox nixpkgs#geckodriver -c node tests/sites-firefox.mjs github   # just these

It prints each site as themed or not and lists the ones to look at by hand; Site themes says why a failure there isn’t always a broken style.

web-ext lint warns about new Function and innerHTML in vendor/less.min.js, which are less.js’s JavaScript-evaluation and plugin features that no bundled style uses, and about innerHTML in newtab.js and render.js, which is how Svelte’s runtime builds DOM from its compiled templates. Warnings don’t fail the build, errors do.

Running it in a browser

cargo build && ./target/debug/caelestia-tab install   # or wherever your target dir is
npx --prefix extension vite build --watch &            # rebuilds dist/ on every save
web-ext run --source-dir extension/dist --firefox=floorp   # any Firefox-based binary

web-ext run starts a throwaway profile with the extension loaded, and reloads it when dist/ changes. install points the manifest at the debug binary, so run it again after switching back to an installed build.

The helper can be driven by hand as well. It writes length-prefixed JSON to stdout and exits when stdin closes:

(sleep 1) | caelestia-tab | head -c 300
XDG_STATE_HOME=/tmp/fake caelestia-tab   # point it at a fake caelestia state

Refreshing the Catppuccin styles

node scripts/vendor-userstyles.mjs

It fetches catppuccin/userstyles at the commit pinned in the script (REV), compiles each style once to find its URL rules, and rewrites extension/public/userstyles/ and extension/public/vendor/. Bump REV to update, run it, then run node --test tests/*.test.mjs. A style that stops compiling is skipped with a warning, not dropped silently.

Refreshing the Nerd Fonts symbols

sh scripts/vendor-nerd-fonts.sh

It downloads the symbols-only font of the release pinned in the script (VERSION), converts it to WOFF2 (about 1 MB instead of 2.4), and writes a name-to-codepoint index of every glyph for the editor’s suggestions.

The handbook

mdbook serve docs    # live preview
mdbook build docs    # what the pages workflow does

The pages workflow publishes main to https://pages.hu-tao.dev/hutao/caelestia-tab/docs/.

Releasing

Date the [Unreleased] section of CHANGELOG.md, bump version in extension/public/manifest.json and Cargo.toml (and Cargo.lock with it), commit, and tag it v<version>. Pushing the tag runs .forgejo/workflows/release.yml, which builds the zips below and publishes them as the tag’s release on Forgejo, with the version’s CHANGELOG.md section as its notes. The run’s log is public and prints the checksums, the notes and each file it uploads, so a reviewer can match the release to the run that built it. If that run fails, fix the workflow on main and run it by hand (Actions, Release, or fj actions dispatch) with the tag: it builds from the tag, so the tag stays where it is. To build them yourself, with the tag checked out:

nix develop -c scripts/release-zip.sh <version>

It builds the extension without anyone’s own components (a normal build takes in ~/.config/caelestia-tab/components), leaves out render.js, which nothing loads yet, and writes release/caelestia-tab-v<version>.zip, the source zip and SHA256SUMS. The zips are byte for byte the same on every run and every machine with the same lockfile: every file takes the tagged commit’s time, and they’re zipped sorted, since web-ext build stamps entries with the time it zips and adds them in any order. For 0.1.0 (built before the v in the names; its release carries them renamed):

ad84678028036c27ec41c98405a5950a8f892dce8670e53fd0ba19f53662b781  caelestia-tab-v0.1.0.zip
2a114cd8d37bc8c0dc590029ee5373a4cca42c3516143368ec02acd484b588f3  caelestia-tab-v0.1.0-source.zip

AMO asks for the source of bundled, minified code: the second zip, built as with the script (Node 24, npm 11). Its linter warns about vendor/less.min.js (less.js, unmodified, from scripts/vendor-userstyles.mjs), the innerHTML in Svelte’s runtime and the page’s <style>s, and data_collection_permissions being newer than the minimum Firefox; none is an error.

How the pieces talk

caelestia CLI ──writes──▶ ~/.local/state/caelestia/{scheme.json, wallpaper/path.txt}
                                   │ inotify
                                   ▼
                         caelestia-tab (helper)
                                   │ native messaging (stdio)
                                   ▼
                         background.ts ──writes──▶ storage.local
                                   ▲                  │ onChanged
                     "theme?"      │                  ├──▶ the new tab (every open one)
                     insertCSS     │                  └──▶ content.js (every page)
                                   └───────────────────────┘
  • The helper is the only part that reads the machine. It sends each data plugin’s value when it starts and again when a watched file changes, if the value differs from what it last sent.
  • The background page writes those values into storage.local as they arrive, and does nothing else with them. It also compiles site themes on request.
  • The new tab and the content scripts read storage.local and listen for changes. They never talk to the helper, and a background page that has been suspended and restarted loses nothing they need.

Storage is the one channel, for the helper’s data and for settings alike. That makes every open new tab stay in step with every other: a bookmark edited in one appears in the rest, the same way a scheme switch does.

Why storage and not messages

Firefox suspends a Manifest V3 background page when it’s idle. Anything held only in its memory, or sent only as a message to it, is lost when that happens. storage.local persists, and it wakes every listener on change, so a new tab opened while the background sleeps still has the last scheme and wallpaper at once.

An open new tab holds a runtime.connect port to the background, which keeps the background, and with it the helper, running while there’s a tab that wants live updates.

Why the helper and not the extension

A browser extension can’t read files from disk, and neither store accepts one that runs code it didn’t ship. So the helper does the reading, and the extension gets data, never code.

The helper

caelestia-tab is a native messaging host: the browser starts it when the extension calls connectNative("caelestia_tab"), and talks to it over stdin and stdout.

Data plugins

The helper runs on tokio. Each data plugin implements plugins::Plugin, an async_trait:

#![allow(unused)]
fn main() {
#[async_trait]
pub trait Plugin: Send + Sync {
    fn topic(&self) -> &'static str;                             // the storage.local key its value goes to
    async fn read(&self) -> io::Result<Value>;                   // the current value
    fn watches(&self) -> Vec<PathBuf> { … }                      // files whose changes mean "read again"
    fn start(&self, wake: Wake) {}                               // spawns a timer or signal task of its own
    async fn command(&self, message: &Value) -> io::Result<()>   // a message from the extension
    fn changed(&self, value: &Value) {}                          // after a new value was sent
}
}

Use tokio’s fs and process and the async clients (reqwest, zbus) in a plugin; something that only blocks, like secrets::get, goes through spawn_blocking.

These ship today:

TopicReadsValueUpdates
scheme$XDG_STATE_HOME/caelestia/scheme.jsonthe file as caelestia wrote it: name, flavour, mode, variant, colours (hex without #)the file changes
wallpaper$XDG_STATE_HOME/caelestia/wallpaper/path.txt, then the image it names{ path, url }, where url is a data: URL of the imagethe file changes
fontsfc-list : familythe installed font families, sortedonce, at start
githubGitHub’s search API, with the token from the chain in The page and its tabs{ results: { query: { total, items } }, activity, publicActivity, at, auth, error, limitedUntil }, at being when it last fetched (ms); results is every search asked for now or in the last hour; activity is your events with private repositories’ included, publicActivity the public ones only, each fetched when a tab asks for itevery 5 min, on refresh, and for a new search or list
lyricsLRCLIB, for the track a tab asks about, once: found lyrics are kept in $XDG_CACHE_HOME/caelestia-tab/lyrics/{ key, synced, plain, none }a get command
savedSettings$XDG_CONFIG_HOME/caelestia-tab/settings.json{ settings, mtime }; a save command writes itthe file changes
mediaevery MPRIS player on the session bus{ players: [{ player, identity, status, title, artist, art, length, position, at, … }] }a player’s properties change, it seeks, or a player comes or goes

XDG_STATE_HOME defaults to ~/.local/state, as it does for caelestia.

To add one: a new file in src/plugins/, an entry in plugins::all(), and the extension reads storage.local[topic]. A plugin that needs a secret asks secrets::get(alias) (src/secrets.rs); the value stays in the helper.

Commands

The extension can send a plugin a message, { topic, command, … }: a widget calls tell() (store.svelte.ts), the background passes it to the helper over the native messaging port, and the host hands it to that topic’s plugin’s command. The plugin’s value is read again straight after, so the effect shows without waiting. The GitHub widget sends its searches this way; the media widget, its controls. A plugin decides what it accepts: media takes only the player methods it names, so the extension can’t reach anything else on the bus through it.

A plugin can also implement changed(&value), which runs after a new value was sent. The scheme plugin uses it to write the Zen mod (src/zen.rs; see Zen’s window).

Waking

Every plugin has a task of its own and a queue of jobs: a command to run, or just a read. A watched file’s event, a command, or wake.refresh() from the task start spawned (github’s timer, media’s D-Bus signals) each queue one. A plugin runs its jobs in order, and jobs that piled up while it was busy are answered with a single read. Plugins don’t wait for each other: a media command is answered in milliseconds while GitHub’s searches (which run side by side) take seconds. One task writes to stdout, and sends a value only when it differs from the last one sent for that topic.

Watching

The helper watches each watched file’s parent directory, not the file itself. caelestia replaces files by renaming a new one over them, and a watch on the old file would never see a change. Watching the directory also covers a file that doesn’t exist yet.

On any event naming a watched file, the plugin’s value is read again and sent only if it differs from the last one sent. That makes duplicate events, and writes that leave the content as it was, free. A half-written file fails to parse, is logged, and the write that finishes it sends the value.

The wire format

Each message is a 4-byte native-endian length followed by that much UTF-8 JSON, the browser’s native messaging framing:

{ "topic": "scheme", "value": { … } }

Browsers refuse a single message from the host over 1 MiB, and a wallpaper is usually bigger. A message over 512 KiB is sent as parts instead:

{ "part": 0, "parts": 4, "data": "{\"topic\":\"wallpaper\",\"val" }

The parts arrive in order; background.ts concatenates their data and parses the result as one message. host::tests checks that parts rejoin exactly, including a multi-byte character straddling a boundary.

Lifetime

The helper exits when stdin closes, which is how the browser says the extension disconnected. It keeps no state between runs, so each start sends everything again.

The manifest

caelestia-tab manifest prints it; install writes it for each browser (see Browsers). The host name uses an underscore because native messaging doesn’t allow hyphens in it, and allowed_extensions must match the extension’s gecko.id, caelestia-tab@hu-tao.dev.

The new tab

The new tab is a Svelte 5 app (extension/src/), built with Vite into extension/dist/. App.svelte is the root; everything under it is a Ct* component, styled with Tailwind utilities and nothing else (see Styling).

State

store.svelte.ts holds the whole state as one $state object, handed to every component through Svelte’s context (getContext("ct")):

{
  settings,     // what's saved, below
  scheme,       // the helper's scheme, or null
  helperError,  // why there's no helper, or null
  wallpaper,    // a blob: URL of the caelestia wallpaper, or null
  editing,      // the pen button
  panel,        // the settings panel is open
  focus,        // the editor the panel shows instead of its tabs, or null
}

The settings are one object under storage.local.settings:

{  font: "",                                        // the page's font-family; empty for the default
  panel: "CtSettings",                             // the component that draws settings
  background: { source: "wallpaper" | "colour" | "none", colour, dim, blur },  clock: { … }, toolbar: { side, … }, bookmarks: { items, … },  // each part's settings
  menu: { open, tab, tabs: { CtGitHub: { … }, CtMedia: { … }, … } },
  sites: { enabled, off: [styleId, …], accent, overrides },
  treeStyleTab: { source: "tint" | "wallpaper" | "none", colour, strength, dim, blur },
  css: "",                                         // custom CSS for the new tab
}

Each part’s settings, and each menu tab’s, are filled in from its component’s defaults when settings load, so a component that gains a setting needs no migration. Settings from when the page was a grid of widgets (layout, widgets) keep each part’s settings and drop the placement; the menu widget’s become the menu’s. Settings saved before the Svelte rewrite name a plugin (clock, bookmarks), which is mapped to the component.

Settings are local to the device: storage.local, not storage.sync, and, with the helper, ~/.config/caelestia-tab/settings.json too. The browser’s copy is lost when a temporary add-on is removed, which a browser restart does; the file isn’t, and can live in a dotfiles repo. The background saves every change to the file a second after it’s made (and, when there’s no file yet, the settings it has as soon as the helper connects), and takes the file’s settings when storage has none (a restart, a fresh install) or when the file changed after the last change in the browser (a hand edit), so an older file never rolls back newer edits. Settings, Advanced, All settings shows the whole object as JSON as well.

The page

The page is fixed (CtLayout): the menu’s section above the bookmarks. The section (CtMenu) has a bar along its top, with the menu button and the menu’s tabs on one side and the toolbar (CtToolbar: the bookmarks’ +, the pen, settings) on the other, and under the bar the clock. Opened, the menu is a panel over the whole section, growing out of the menu button (and shrinking back into it), with the open tab under the bar; the clock isn’t drawn while it’s covered. Switching tabs slides the new one in from the side its button is on. Whether the menu is open, and on which tab, is saved, so a new tab opens as the last one was left.

The toolbar’s side puts it on the right (the tabs on the left), the left (the tabs on the right, mirrored so the menu button stays at the edge) or in the middle (the tabs on the left). Ctrl+, opens settings without it.

font is the page’s font, inherited by every part unless it sets its own (the clock’s time and date each can). A font field autocompletes from the installed fonts, each shown in its own face, which the helper lists with fc-list, since a web page can’t; without the helper (or with one from before the fonts plugin) it takes a name typed in. Any text is accepted, a whole font-family list included, and the next font down always follows it: the page’s default after the page font, the page font after a part’s. So a name that isn’t a font, half-typed say, changes nothing. Font sizes are in pt; everything else is in rem.

Edit mode

The pen turns on edit mode, and each part shown gets CtEditOverlay: an outline and a chip with its name, which opens its fields in the side panel (CtPartEditor). The parts are the toolbar, the clock (while the menu is closed), the bookmarks and the open tab. Nothing covered can be edited, since nothing covered is drawn. The bookmarks also do more in edit mode through their editing prop: each tile’s controls.

Saving and syncing

Components change app.settings directly; nothing calls a save. An effect watches the settings and writes them to storage whenever they change, and every open tab’s storage.onChanged listener takes other tabs’ writes.

A tab also hears its own writes come back, and not always in order: a slider writes on every step, and an early step’s echo can arrive after a later step was written. Taken for another tab’s change, it would roll the value back. So each tab remembers what it wrote and ignores those echoes (pending in store.svelte.ts).

A scheme change touches nothing but a <style> of --caelestia-* variables in the head; everything styled with them follows.

The wallpaper arrives as a data: URL, which the store turns into a blob URL once, so the page’s style doesn’t carry a copy of the image as text.

Settings

The settings are a panel docked to the window’s right edge, not a modal: the page moves over to stay in view beside it, so a change shows as it’s made. Every editor opens in the same panel, never in a pop-up: a part’s settings from the pen, a bookmark from its edit button or the +. edit(app, title, component, props) puts one there (app.focus), with a back arrow to the settings, and Escape steps back. Editors change the real settings, not a copy, so the page shows each change as it’s made and there’s nothing to save. A new bookmark is added before its editor opens, so it’s on the page while it’s filled in.

A part’s form is drawn from its component’s fields (see Writing a menu tab): the settings panel knows nothing about clocks or bookmarks. Settings itself has no per-part section: its General tab holds the page’s font, and everything about one part is edited from the page with the pen. The panel is a component named in settings (panel), so it can be replaced.

Styling

The page’s only global CSS is src/app.css: the --caelestia-* variables with fallbacks for before the helper answers, the bundled symbols font, form controls inheriting the page’s font, and Tailwind’s utilities without its reset. Tailwind’s colours are the scheme’s (bg-primary, text-on-surface, border-outline-variant …), declared with @theme inline, so every utility follows the live scheme. Tailwind’s own palette is left out.

The Ct* components use Tailwind classes only. A value only known at run time (a setting, the wallpaper) goes in as a CSS variable read by a class: style:--dim={…} with opacity-(--dim).

Each component also carries a stable ct-* class (ct-tile, ct-clock, ct-bookmarks …) for your custom CSS to target.

The bookmarks’ wrapper gets ct-edge-top, -bottom, -left and -right while it touches that edge of the window (edges in layout.ts, rechecked on resize and zoom), and squares its corners there with in-[.ct-edge-bottom]:rounded-b-none.

Bookmarks

CtBookmarks lays tiles out on a CSS grid whose grid-template-columns, grid-auto-rows, gap and grid-auto-flow are settings, typed as CSS. The Tiles and List presets only fill those fields in. A tile takes span <width> and span <height>, or any grid-column and grid-row value, which override the spans. A value the browser can’t parse is dropped, so a half-typed setting leaves the grid as it was.

With Even rows on (the Tiles default), the columns come from the narrowest tile width instead: as many as fit, then as few as still need that many rows, so five tiles that don’t fit on one row go 3 and 2, never 4 and 1 (evenColumns in layout.ts, tested in tests/extension.test.mjs).

A tile shows its image, or its colour when it has none: a scheme colour, which follows the scheme, or a fixed one from the browser’s colour picker. Text on a scheme colour uses its “on” colour (onPrimary on primary); on a fixed one, black or white, whichever reads better. The line under a tile takes the accent that goes with its colour (complement in scheme.ts: tertiary under primary, a container’s own colour under the container …), a colour of its own, or none, set per bookmark on its Look tab. Uploaded images are scaled to 512 px and stored in settings as WebP data: URLs, which is why the extension asks for unlimitedStorage.

A tile’s mark is either up to a few letters (an emoji works) or a Nerd Font glyph, from the vendored symbols-only font. The glyph field is a search over every glyph, showing all of them until you type; above them, it suggests glyphs from the bookmark’s address and name. The host’s labels, without www and the TLD, and the name’s words are matched against the 11 000 glyph names: a whole-word match (fa-github) ranks above a word inside a longer name (dev-githubactions). glyphs.ts does the matching.

In edit mode each tile also gets a bar under its content to move it earlier or later, drag it (HTML drag and drop, dropping on another tile’s position), edit it, or remove it.

Clock

CtClock shows the time and the date, each with its own separator, size (in pt) and font. The hour can be 12- or 24-hour, with or without a leading zero; the date comes in four styles. Its sizes shrink with a narrow window instead of overflowing it. It renders no time until it’s mounted, since a copy of the page rendered ahead of time can be hours old.

Site themes

The extension ships the 134 styles of catppuccin/userstyles (MIT, its licence in extension/userstyles/LICENSE) unchanged, and compiles them against the live caelestia scheme instead of a Catppuccin flavour.

Why compile, not rewrite to CSS variables

The styles are LESS, and they do a lot with concrete colours at compile time: fade(), darken(), mix(), red(), hue(), colours escaped into inline SVG data URLs, and CSS filter chains per colour. A var() can’t be faded at compile time or put inside a data URL, so a one-off conversion to variables would break most of them. Compiling each style with the scheme’s real colours keeps every one of them working. All 134 compile, in about 27 ms each (measured 2026-09-27).

How a page gets themed

  1. content.js runs at document_start in every frame and asks the background for its theme, handing over the CSS it applied last time (none, at first).
  2. The background builds the CSS: the scheme’s --caelestia-* variables, plus every enabled style whose URL rules match the page.
  3. A style is compiled the first time a matching page asks, then cached until the scheme or the accent changes.
  4. The background removes the old CSS from that frame and inserts the new with scripting.insertCSS, and the content script keeps what it got.
  5. When scheme or settings change in storage, every content script asks again. That’s the live switch.

The content script, not the background, remembers what was injected into its frame. The background can be suspended between two switches, and a restarted background has no record of what it inserted; removeCSS needs the exact CSS that was inserted.

insertCSS rather than a <style> element, because a page’s Content-Security-Policy can block inline styles but not an extension’s inserted sheet.

Overrides

The styles are applied as upstream wrote them; anything beyond that is the user’s, in settings.sites.overrides[id] (see Theming your own sites): domains, when (a CSS selector) and css. The extension ships no fixes of its own; recipes live in the docs.

  • domains are extra domain rules. A style matched through one, or through when, contributes all its @-moz-document bodies (cssFor(…, all)), because its own rules don’t name that page.
  • when needs the parsed document, which document_start doesn’t have. The content script asks once at document_start as always, then again at DOMContentLoaded, sending the ids whose selector matches as detected. So a page themed only through when shows the unthemed page until its DOM is parsed.
  • css goes after the style’s CSS, so it wins at equal specificity.

Sites of the user’s own are settings.sites.custom ({ id, name }, ids custom-…), with an override under the same id. They have no style, so their domains and when are the only ways they match, and their css is all they add, after every bundled style’s.

The palette swap

userstyles.ts libFor() rewrites the vendored lib.less before compiling:

  • The @catppuccin map gets the scheme’s colours under both @latte and @mocha. caelestia’s scheme carries all 26 Catppuccin colour names.
  • @accent becomes a scheme colour, primary by default (Settings, Websites, Accent).
  • lightFlavor and darkFlavor are both set to latte when the scheme is light and mocha when it’s dark. The flavour then only decides the styles’ if(@flavor = latte, …) branches, which pick between colours made for a light or a dark palette.

@-moz-document

Stylus understands @-moz-document; web pages don’t. So after compiling, the extension splits the CSS into its @-moz-document blocks and injects only the bodies of the blocks whose rules match the page, with Stylus’s semantics (domain matches subdomains, regexp must match the whole URL). Some styles build their rules with LESS (syncthing’s come from an @var), so the vendoring script reads them from a compiled copy into index.json; that tells the background which styles a page wants before it compiles any.

Checking the styles

tests/sites-firefox.mjs loads a page for every style in a headless Firefox with the extension, logged out, and reports whether the background behind the middle of the page became a scheme colour. It’s slow and depends on the live sites, so it’s run by hand (see Development). A failure is a lead, not a verdict: a landing page that differs from the logged-in site, a page whose middle is an image, or a style that only covers some pages all show up as failures.

Known gaps

  • The @<colour>-filter values are Catppuccin’s own precomputed filter chains, so the icons a few styles tint with a CSS filter come out in Catppuccin’s shade rather than the scheme’s. Fixing it needs a solver that finds a filter chain for an arbitrary colour.
  • A style’s own options (@var checkboxes like YouTube’s logo) are fixed at their defaults. There’s no UI for them yet.

Known hazards

Things that look wrong, or look safe, and aren’t. Dated where they were learned the hard way.

  • The native host name can’t contain a hyphen. It’s caelestia_tab, while the binary is caelestia-tab. Both install.rs and background.ts spell it; change both or neither.
  • allowed_extensions must match gecko.id exactly. A mismatch looks like a missing helper: “No such native application”.
  • Watch directories, not files (see The helper). caelestia renames new files into place, which a file watch misses.
  • One native message can’t exceed 1 MiB. Anything bigger is sent in parts; don’t add a plugin that bypasses host::frames.
  • element.dataset = {…} throws in the new tab (ES modules are strict and dataset has no setter). Set node.dataset.x after creating the node. Caught in review of the Websites tab, 2026-09-27.
  • less.js’s browser build needs a document. It reads document.currentScript when loaded, so it can’t run in Node or a Chrome service worker. Firefox’s background page has one, and it compiles there (checked with the e2e test). The vendoring script uses the npm build of the same version instead (2026-09-27).
  • @-moz-document preludes can contain { inside a regexp(), e.g. pinterest’s [a-z]{2}. Parse the rules one by one; don’t cut the prelude at the first brace (2026-09-27).
  • A regexp rule’s value is a CSS string. "\\." in the style is \. to the regex, and a bare \. is just .. userstyles.ts unescapes it the CSS way before building the RegExp.
  • WebDriver won’t navigate to moz-extension:// URLs. To reach the new tab in a test, start geckodriver with --allow-system-access and call BrowserCommands.openTab() from the chrome context, which also exercises the override itself. See tests/e2e-firefox.mjs (2026-09-27).
  • A leftover geckodriver answers for the next test run. An interrupted run can leave one listening, with the old run’s HOME and state, and the next run silently drives it and watches the wrong files. The e2e test refuses to start if its port is taken (2026-09-27).
  • Port 8384 may be a real Syncthing. Syncthing’s style matches it, which makes it tempting as a test site; the e2e test uses InvokeAI’s 9090 instead (2026-09-27).
  • Some upstream styles have rules that never match. InvokeAI’s nests its :root rules inside :root, compiling to :root :root. Check the compiled CSS before blaming the injection (2026-09-27).
  • --start-url about:newtab shows the browser’s own new tab, not the extension’s. The override applies to tabs opened as new tabs (Ctrl+T), so open one to test it (2026-09-27).
  • A fresh Zen profile shows a welcome screen and no new tab page. Pass --pref zen.welcome-screen.seen=true --pref zen.urlbar.replace-newtab=false to web-ext run (2026-09-27).
  • Testing against a real profile: web-ext run --firefox-profile <dir> runs a temporary copy, next to the running browser, and deletes it on exit. The copy restores the session and extensions like Tab Session Manager, so the start URL may open behind other tabs (2026-09-27).
  • TST paints its sidebar with --browser-background first. It’s set inline from the browser theme, so overriding only --tabbar-bg changes nothing whenever a theme is active; treestyletab.ts overrides both, with !important (2026-09-27).
  • A starting TST sidebar drops registrations. It replaces its list of extensions with a snapshot it asked TST’s background for, so a register-self that lands in between is lost. background.ts re-sends once, 5 seconds after the first registration, and on every sidebar-show (2026-09-27).
  • TST sends ready only to extensions it already knows. The first registration has to be retried until TST answers, not wait for ready.
  • Check TST’s sidebar in a test by opening it as a tab: moz-extension://<uuid>/sidebar/sidebar.html, with the UUID pinned through extensions.webextensions.uuids. The real sidebar runs in another process, and the chrome context can’t read its document (2026-09-27).
  • Zen ignores browser.theme. Neither caelestia-tab nor CaelestiaFox can colour Zen’s window through it; src/zen.rs writes a Zen mod instead (2026-09-27).
  • Zen sets its background gradient inline on #zen-browser-background. A :root override loses to it there, because custom properties inherit and the element’s own value wins; the mod targets that element too, and the -old variables Zen crossfades from (2026-09-27).
  • Zen only reloads mods when zen.mods.updated-value-observer flips. Rewriting the CSS alone changes nothing until a restart (2026-09-27).
  • Autoconfig has three traps. zen-browser-flake’s wrapper symlinks the binary, so the wrapper’s mozilla.cfg never runs; the default sandbox gives the script pref() and nothing else (no Components, no Services); and the script runs before a profile exists, so anything touching the profile waits for final-ui-startup. lib.wrapZen and zen/caelestia-tab.cfg handle all three (2026-09-27).
  • Firefox caches a temporary add-on’s files. Reloading the page after editing the extension’s CSS shows the old CSS; restart web-ext run (started with --no-reload) to see the change (2026-09-27).
  • The Forgejo runner has no home directory. Building nixpkgs’ Firefox there fails with “home directory /homeless-shelter exists”, so the end-to-end test runs from the pre-push hook instead of CI (2026-09-27).
  • The runner’s job image is not a stdenv. It carries nix, node, bash, coreutils, findutils, grep, sed, tar, gzip, xz, curl, git and which (the vps repo’s modules/runner/ci-image.nix), and no awk, diff, make, patch, bzip2, unzip or jq. A run: step that works in your shell fails with exit 127 there; run anything off that list through nix develop -c. The release workflow’s first run died on awk this way (2026-09-28).
  • The Chrome DevTools MCP can’t find Chrome on NixOS; it looks in /opt/google/chrome. To look at the new tab outside a browser, serve extension/ with a stub browser object and screenshot it with google-chrome-stable --headless=new --screenshot (2026-09-27).
  • hidden loses to any display rule. An element with display: flex from a class stays visible with the hidden attribute set, which is why the Filter sites box did nothing. newtab.css has a global [hidden] { display: none !important } (2026-09-27).
  • A catppuccin style can compile, match and still change nothing. The site moved on and the style sets variables it no longer reads. claude.ai’s is the example: it sets --bg-*, Claude reads --cds-* (2026-09-27). Only looking at the page shows it; tests/sites-firefox.mjs does that for every style.
  • A style’s domain list is not everywhere its site kind lives. mdBook’s lists a handful of Rust books, so an mdBook anywhere else is unthemed. That’s what an override’s Also on pages matching selector is for (2026-09-27).
  • Svelte 5 throws when a template changes state. Creating a settings entry on the fly while rendering a list (a ??= inside {#each}) stops the whole render with state_unsafe_mutation, and the panel silently keeps showing the previous tab. Create state in an event handler instead (the Websites list does it on ontoggle) (2026-09-27).
  • Unlayered CSS beats every Tailwind class. Tailwind’s utilities live in @layer utilities; a plain button { font: inherit } outside any layer wins over font-glyph on a button, which showed every glyph as a box. app.css keeps its rules in @layer base, declared before utilities (2026-09-27).
  • vite build --ssr pulls in node:async_hooks through svelte/server’s async rendering. It’s only loaded for async renders and a failed load is caught, so the SSR build marks it external rather than polyfilling it (2026-09-27).
  • playerctld is on the bus as an MPRIS player too. It stands in for whichever player is active, so listing every org.mpris.MediaPlayer2.* name shows that player twice. The media plugin skips it (2026-09-27).
  • The helper’s GitHub token comes from the browser’s environment. gh reads its login from $HOME, so a browser started with another HOME (the e2e tests’ throwaway one, a Flatpak) finds no token. GH_TOKEN in that environment works (2026-09-27).
  • A watched file’s directory has to exist when the helper starts. The host watches directories, and a watch on one that isn’t there yet fails, so a file created there later is never seen. The settings plugin creates ~/.config/caelestia-tab/ at start for that reason (2026-09-27).
  • A widget spanning an auto grid row sizes that row. The menu spans the toolbar’s row and the clock’s, and its content grew the toolbar’s row, pushing the bookmarks off the screen whatever the middle row said. A widget that should fill its area and scroll takes h-0 min-h-full, so its content doesn’t count towards the rows; a row that should shrink is minmax(0, 1fr), not 1fr (2026-09-27).
  • A running browser keeps its old helper after a rebuild. Nix’s browser wrappers link the helper’s manifest into ~/.mozilla/native-messaging-hosts when the browser starts, so a browser started before the rebuild still starts the old helper, and a plugin added since answers “a command for no plugin” or never sends. Restart the browser (2026-09-27). And check where the link points: one made by hand (ln -s to fix a stale one) stays as it is, and the wrapper leaves it alone, so a restart kept the old helper running; readlink -f it against the system’s store path.
  • Reading a watched file raises an event about it. inotify reports opens and closes too, which notify passes on as EventKind::Access. Reading the scheme again on those read it again, forever: 94,000 reads in 8 seconds, and every other plugin’s value, a media command’s included, queued behind them, arriving tens of seconds late with no error anywhere. The host ignores access events (2026-09-27).
  • Spotify sends mpris:trackid as a string. MPRIS says an object path, and reading it as one gave Spotify no track, so SetPosition (which needs the track) could never be sent and a lyric click did nothing, silently. The media plugin takes either (2026-09-27).
  • An implicit grid column is auto, and grows to its content. A grid with no grid-template-columns sizes its one column to the widest child, so a long line (a marquee’s) pushed the Media tab’s cover column past its cell instead of scrolling inside it. Give such grids grid-cols-[minmax(0,1fr)] (2026-09-27).
  • An effect that reads a derived object re-runs on every update. Each helper value is a new object, so an effect meant for “a new song” that read p?.track through p ran on every position update and ended the lyrics’ hold each time. Derive the primitive first (const track = $derived(p?.track)) and read that (2026-09-27).
  • A glyph is a button’s accessible name. A Nerd Font glyph is a private-use character, and as a button’s only text it becomes its name, which no label or locator matches. Mark the glyph aria-hidden="true" and name the button (2026-09-27).
  • The helper’s settings file has its keys sorted. serde_json’s maps are ordered, so the file comes back with the same settings as storage in a different order, and a JSON.stringify comparison called them changed. The background then wrote the file’s copy back, the new tab replaced app.settings with it, and every open editor went on editing the old objects: a bookmark’s colour changed once, then never again until a reload. Compare with stable() (json.ts), and give an editor a function that finds what it edits in app.settings each time (App.focus.props) (2026-09-27).
  • GitHub allows 30 searches a minute, and a tab mounts often. The GitHub tab sent its searches on every mount (each new tab, each switch back to it), and the helper searched on every message: three searches each time, and a 403 within minutes. The helper fetches on its timer, on refresh and for a new search only, and answers from what it has otherwise (2026-09-27).
  • A transition on a component’s root doesn’t play when the parent’s {#if} shows it. Svelte 5 transitions are local by default: they play only for their own block. The settings panel, shown by App’s {#if}, needs transition:fly|global (2026-09-27).
  • GitHub’s events are the last 30 of every repository, private ones first when you’re busy there. Filtering them for public in the tab left nothing: all 30 were one private repository’s. Public-only is GitHub’s own list, users/{login}/events/public, fetched and cached beside the other (2026-09-28).
  • GitHub’s push events no longer carry size or commits, only head and before. Code reading size fell back to a bare Pushed to main; the tab shows head’s short SHA and links to that commit (2026-09-28).
  • The private toggle swaps every search for another query. Dropping a search’s results as soon as nothing asks for it meant each flip searched again, against GitHub’s 30 a minute. The helper keeps a search an hour after it was last asked for, and sends all it keeps (2026-09-28).
  • The helper only sends what changed, so a command can get no answer. A refresh while rate-limited fetches nothing and sends nothing. The tab’s refresh spinner stops on the next value or after 15 seconds; anything else that waits on an answer needs the same way out (2026-09-28).
  • A Playwright test passes on data you made up. The private toggle’s first test seeded events with a public flag and passed, while the real tab, on the real list, showed nothing. For helper data, check what the helper really sends (drive the binary over native messaging, or the e2e harness with GH_TOKEN set) before writing the fixture (2026-09-28).
  • /rate_limit’s core.used stayed at 0 for gh’s token through requests that should count, so it can’t show whether the helper fetched. The helper’s at moves on every fetch: watch that instead (2026-09-28).
  • The e2e harness’s js() returns "ERR …" rather than throwing. A script that throws (an element not found) reads as a result, and a step that didn’t happen looks like it did. Compare what it returns (2026-09-28).
  • The browser runs the helper it started with. After a rebuild, an open browser keeps the old helper, and a new tab that expects newer data waits for what never comes. The GitHub tab checks for the keys it needs and asks for a newer helper instead (2026-09-27).
  • The e2e test’s js() returns an error instead of throwing. A script that fails comes back as the string "ERR …", so a setup step whose result isn’t checked fails silently and the assertion after it looks like the feature’s fault. Assert on a setup step’s result (null when it returned nothing). Storage there holds only what’s been changed, too: no sites until something set them (2026-09-27).
  • The release zips only build on Linux. The flake has no macOS shell, and outside it scripts/release-zip.sh stops at BSD touch, which doesn’t take -d @<epoch>. On a Mac, build in a nixos/nix container, and docker cp the checkout in: Docker Desktop doesn’t share /private/tmp, so a mount of it comes up empty (“could not find a flake.nix”). An aarch64 container gives the same checksums as the x86_64 runner (2026-09-29).