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 purple | Dark red | Light 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 pickextension/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
.xpiwithweb-ext build --source-dir extension/distand install it fromabout:addons(the gear, then Install Add-on From File…). Release Firefox refuses unsigned add-ons. Developer Edition, Nightly, ESR and unbranded builds accept them oncexpinstall.signatures.requiredisfalseinabout: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.
| Browser | Manifest directory | Notes |
|---|---|---|
| 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:
| Browser | Version | New tab | Live switch | Site themes |
|---|---|---|---|---|
| Firefox | 156 | yes | yes | yes (GitHub, YouTube) |
| Floorp | 12.17 | yes | yes | yes (GitHub, YouTube, logged in) |
| Zen | 1.22.3b | yes | yes | yes (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
- The mod. On every scheme change, the helper writes
chrome/zen-themes/caelestia-tab/chrome.cssinto every Zen profile it finds, and registers the mod in the profile’szen-themes.jsononce. 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. - The reload. Zen only reloads mods when the pref
zen.mods.updated-value-observerflips; that’s all its own “update mods” does. Nothing outside the browser can flip a pref in a running Zen, sozen/caelestia-tab.cfg, an autoconfig script, does it from inside. It checks the mod’schrome.csstimestamp 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. Mergecaelestia-tab.cfginto 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 inRecent 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 withis: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,PreviousandSetPosition, 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 itsrate, 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, ornone. 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 (lyricson 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 addsis:publicto its queries itself;private: falseasks forpublicActivityrather thanactivity.resultsholds 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 a304. 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:
- The
githubsecret alias (below). GH_TOKENorGITHUB_TOKEN, in the browser’s environment.gh auth token, if you’re logged in with the GitHub CLI.- 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,defaultsfilled in with what’s saved, undermenu.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 inapp.data,tell()for a command to a helper plugin, andedit(app, title, component, props)fromstore.svelte.tsfor an editor of your own in the side panel. The editor gets anoncloseprop. 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.orgcovers 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.localas they arrive, and does nothing else with them. It also compiles site themes on request. - The new tab and the content scripts read
storage.localand 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:
| Topic | Reads | Value | Updates |
|---|---|---|---|
scheme | $XDG_STATE_HOME/caelestia/scheme.json | the 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 image | the file changes |
fonts | fc-list : family | the installed font families, sorted | once, at start |
github | GitHub’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 it | every 5 min, on refresh, and for a new search or list |
lyrics | LRCLIB, 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 it | the file changes |
media | every 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
content.jsruns atdocument_startin every frame and asks the background for its theme, handing over the CSS it applied last time (none, at first).- The background builds the CSS: the scheme’s
--caelestia-*variables, plus every enabled style whose URL rules match the page. - A style is compiled the first time a matching page asks, then cached until the scheme or the accent changes.
- The background removes the old CSS from that frame and inserts the new
with
scripting.insertCSS, and the content script keeps what it got. - When
schemeorsettingschange 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.
domainsare extradomainrules. A style matched through one, or throughwhen, contributes all its@-moz-documentbodies (cssFor(…, all)), because its own rules don’t name that page.whenneeds the parsed document, whichdocument_startdoesn’t have. The content script asks once atdocument_startas always, then again atDOMContentLoaded, sending the ids whose selector matches asdetected. So a page themed only throughwhenshows the unthemed page until its DOM is parsed.cssgoes 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
@catppuccinmap gets the scheme’s colours under both@latteand@mocha. caelestia’s scheme carries all 26 Catppuccin colour names. @accentbecomes a scheme colour,primaryby default (Settings, Websites, Accent).lightFlavoranddarkFlavorare both set tolattewhen the scheme is light andmochawhen 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>-filtervalues 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 (
@varcheckboxes 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 iscaelestia-tab. Bothinstall.rsandbackground.tsspell it; change both or neither. allowed_extensionsmust matchgecko.idexactly. 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 anddatasethas no setter). Setnode.dataset.xafter creating the node. Caught in review of the Websites tab, 2026-09-27.- less.js’s browser build needs a
document. It readsdocument.currentScriptwhen 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-documentpreludes can contain{inside aregexp(), 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.tsunescapes it the CSS way before building theRegExp. - WebDriver won’t navigate to
moz-extension://URLs. To reach the new tab in a test, start geckodriver with--allow-system-accessand callBrowserCommands.openTab()from the chrome context, which also exercises the override itself. Seetests/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
HOMEand 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
:rootrules inside:root, compiling to:root :root. Check the compiled CSS before blaming the injection (2026-09-27). --start-url about:newtabshows 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=falsetoweb-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-backgroundfirst. It’s set inline from the browser theme, so overriding only--tabbar-bgchanges nothing whenever a theme is active;treestyletab.tsoverrides 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-selfthat lands in between is lost.background.tsre-sends once, 5 seconds after the first registration, and on everysidebar-show(2026-09-27). - TST sends
readyonly to extensions it already knows. The first registration has to be retried until TST answers, not wait forready. - Check TST’s sidebar in a test by opening it as a tab:
moz-extension://<uuid>/sidebar/sidebar.html, with the UUID pinned throughextensions.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.rswrites a Zen mod instead (2026-09-27). - Zen sets its background gradient inline on
#zen-browser-background. A:rootoverride loses to it there, because custom properties inherit and the element’s own value wins; the mod targets that element too, and the-oldvariables Zen crossfades from (2026-09-27). - Zen only reloads mods when
zen.mods.updated-value-observerflips. 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.cfgnever runs; the default sandbox gives the scriptpref()and nothing else (noComponents, noServices); and the script runs before a profile exists, so anything touching the profile waits forfinal-ui-startup.lib.wrapZenandzen/caelestia-tab.cfghandle 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. Arun:step that works in your shell fails with exit 127 there; run anything off that list throughnix 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, serveextension/with a stubbrowserobject and screenshot it withgoogle-chrome-stable --headless=new --screenshot(2026-09-27). hiddenloses to anydisplayrule. An element withdisplay: flexfrom a class stays visible with thehiddenattribute set, which is why the Filter sites box did nothing.newtab.csshas 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.mjsdoes 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 withstate_unsafe_mutation, and the panel silently keeps showing the previous tab. Create state in an event handler instead (the Websites list does it onontoggle) (2026-09-27). - Unlayered CSS beats every Tailwind class. Tailwind’s utilities live in
@layer utilities; a plainbutton { font: inherit }outside any layer wins overfont-glyphon a button, which showed every glyph as a box.app.csskeeps its rules in@layer base, declared beforeutilities(2026-09-27). vite build --ssrpulls innode:async_hooksthrough 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).playerctldis on the bus as an MPRIS player too. It stands in for whichever player is active, so listing everyorg.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.
ghreads its login from$HOME, so a browser started with anotherHOME(the e2e tests’ throwaway one, a Flatpak) finds no token.GH_TOKENin 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
autogrid 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 takesh-0 min-h-full, so its content doesn’t count towards the rows; a row that should shrink isminmax(0, 1fr), not1fr(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-hostswhen 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 -sto fix a stale one) stays as it is, and the wrapper leaves it alone, so a restart kept the old helper running;readlink -fit 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:trackidas a string. MPRIS says an object path, and reading it as one gave Spotify no track, soSetPosition(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 nogrid-template-columnssizes 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 gridsgrid-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?.trackthroughpran 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.stringifycomparison called them changed. The background then wrote the file’s copy back, the new tab replacedapp.settingswith it, and every open editor went on editing the old objects: a bookmark’s colour changed once, then never again until a reload. Compare withstable()(json.ts), and give an editor a function that finds what it edits inapp.settingseach 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}, needstransition: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
publicin 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
sizeorcommits, onlyheadandbefore. Code readingsizefell back to a bare Pushed to main; the tab showshead’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
publicflag 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 withGH_TOKENset) before writing the fixture (2026-09-28). /rate_limit’score.usedstayed at 0 forgh’s token through requests that should count, so it can’t show whether the helper fetched. The helper’satmoves 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 (nullwhen it returned nothing). Storage there holds only what’s been changed, too: nositesuntil 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.shstops at BSDtouch, which doesn’t take-d @<epoch>. On a Mac, build in anixos/nixcontainer, anddocker cpthe 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).