Theme Development
How Tranquil’s UI themes are built, and the non-obvious quirks that bite you. Read this before touching any theme package. Companion docs: theme-refresh.md (the original reference refresh), business-theme-and-mockups.md (the business themes + mockups), file-type-icons.md (icon colors).
The theme landscape
Tranquil ships two families of owned UI themes, each with a light and dark variant:
| Family | Packages | Structure |
|---|---|---|
| Default | tranquil-theme-light / tranquil-theme-dark | Separate partials per package; each has its own colors.less with different variable names (@bg-* in light, @primary-* in dark). |
| Business | tranquil-business-light / tranquil-business-dark | Byte-identical partials shared via @{theme-scope} interpolation; only colors.less differs, using the same semantic names. |
Plus two supporting owned packages that are theme-aware:
tranquil-theme-icons— file-type icon glyphs + per-UI-theme colors.tranquil-browser— its toolbar adapts to the active UI theme via.theme-*selectors.
Going forward, prefer the business-theme structure (shared partials, one colors.less per
variant). It’s the reason light/dark can’t drift apart. See Keeping variants in sync.
All palettes derive from the reference app (../reference/src/index.css). Fonts: Inter (UI/body), IBM Plex Mono (code).
Anatomy of a business UI theme
package.json—"theme": "ui",link:../deps, deserializers if any.index.less— imports every partial in order (colors.lessmust resolve first;base.lessimports it, so partials imported afterbase.lessget the vars/mixins in ambient scope).styles/colors.less— the only file that differs light↔dark. Defines@theme-scope(the interpolation target, e.g.~'.theme-tranquil-business-light'), all semantic tokens, and shared LESS mixins (e.g..themed-scrollbar()).styles/*.less— partials scoped under@{theme-scope}, byte-identical across the two packages. A few are intentionally global (see below).
Every rule is normally written as:
@{theme-scope} {
.some-thing { color: @text-primary; }
} @{theme-scope} interpolates to .theme-tranquil-business-light / -dark, so the rules only
apply when that theme is active (Pulsar adds theme-<package-name> to atom-workspace).
Quirk 1 — Where the theme class lives: atom-workspace, not <body>
Pulsar applies theme-tranquil-business-dark (etc.) to the atom-workspace element, which
is a child of <body>. This matters enormously:
- Anything rendered inside the workspace (editors, docks, panels, modal panels, editor
overlays like autocomplete) is a descendant of the theme element →
@{theme-scope} …reaches it. Scope these. - Anything appended to
<body>is outside the theme element →@{theme-scope} …never matches it. These must be global rules (unscoped). Because the theme stylesheet only loads when the theme is active, a global rule is still theme-safe.
Tooltips are the classic trap. atom.tooltips.add appends to <body> (src/tooltip.js,
container 'body'). A scoped @{theme-scope} .tooltip silently does nothing, and one-dark-ui’s
global .tooltip rule (bright accent-blue background) wins instead. Fix: write tooltip rules global and body-anchored to out-specify one-dark-ui:
// popovers.less — global, NOT under @{theme-scope}
body .tooltip .tooltip-inner { background: @popover-bg; color: @text-primary; … }
body .tooltip.top .tooltip-arrow { border-top-color: @popover-bg; } When in doubt, inspect the live element’s parent chain (see Verifying).
Quirk 2 — Stylesheet priority: themes win, so no !important
You almost never need !important in a theme. Pulsar assigns stylesheet priority:
- Regular packages → 0 (
src/package.jsgetStyleSheetPriority). - Theme packages → 1 (
src/theme-package.js). StyleManagerinserts higher-priority sheets later in the document (src/style-manager.js), so theme rules come after package rules and win on equal specificity.
On top of that, scoping under @{theme-scope} adds a class, so theme rules usually out-specify un-scoped package rules anyway. Practical consequences:
- Overriding a package’s
:rootcustom properties (e.g. the terminal’s--terminal-*) works without!important— same:rootspecificity, theme loads later. - Overriding a package rule that carries its own classes (e.g.
atom-notifications atom-notification.warning .content) means matching its specificity with your scoped selector — count the classes/elements, don’t reach for!important. - If you’re fighting another theme (priority 1 too, e.g. a syntax theme, or one-dark-ui at
<body>level), decide the winner by specificity — add an element/class (body .tooltip …) rather than!important.
Quirk 3 — ui-variables are NOT the theme’s palette
@import "ui-variables" in a package’s LESS resolves to whatever ui-variables the active UI
theme exports — and the business themes don’t export a ui-variables.less. So on a
business theme, @text-color, @text-color-subtle, @background-color-highlight, etc. fall
back to light-theme defaults (e.g. #333), which render invisibly on dark.
Rules of thumb:
- Inside a theme partial, use the theme’s own semantic tokens (
@text-primary,@popover-bg, …) fromcolors.less, never ui-variables. - In an owned non-theme package that injects chrome (e.g. the pane-controls in
tranquil-automations), don’t hardcode ui-variable colors. Usecolor: inherit(pick up the theme’s text color from the surrounding element) plusopacityand neutral translucent backgrounds (rgba(127,127,127,.18)) — theme-agnostic and correct in both light and dark. (This was the bug behind “the pane-control icons disappeared on dark.“)
Quirk 4 — Fonts: bundle Inter via atom://, not data:
The app has no system/bundled Inter; every theme sets font-family: Inter, … but silently
fell back to SF. Loading a font from a theme is constrained by two facts:
- Theme stylesheets are injected into the main document, so a relative
url()resolves against the app’sstatic/base, not the package. static/index.html’s CSP isdefault-src * atom://*with nofont-srcand nodata:for fonts → a data-URI@font-faceis blocked, and*doesn’t coverdata:.
The CSP does allow atom://*, and Pulsar’s atom-protocol-handler resolves atom://<pkg>/<path> from ~/.tranquil/dev/packages (dev) and RESOURCE_PATH/node_modules (production). So bundle the Inter variable latin woff2 in the package and load it in a fonts.less (imported first):
@font-face {
font-family: 'Inter';
font-weight: 100 900; /* variable — covers all weights */
src: url("atom://tranquil-business-light/resources/fonts/Inter-Variable-latin.woff2") format('woff2');
} Also set -webkit-font-smoothing: auto (not antialiased) in base.less: grayscale
smoothing renders Inter thin/narrow on non-retina (1×) displays; auto gives subpixel
antialiasing at 1× and still resolves to grayscale at 2×.
Webviews are different. A webview’s document base is its own folder, so in-app mockups (
tranquil-automations/mockups/) can use a plain relative@font-faceurl("fonts/…")— noatom://, no CSP issue. That constraint only applies to theme stylesheets in the main document.
Quirk 5 — Theming third-party surfaces (don’t edit the package)
Never edit node_modules/ or Pulsar core packages. Theme these surfaces from the theme package
instead:
- Terminal (
pulsar-edit/terminal). Its default “Stylesheet” color mode reads--terminal-*custom properties fromdocument.documentElement; by default they’re emitted from the active syntax theme, so a light UI theme still shows a dark terminal. Override the--terminal-*vars at:root(unscoped — the terminal readsdocumentElement) from the palette; the terminal re-applies onatom.themes.onDidChangeActiveThemes/atom.styles.onDidAddStyleElement, so it flips live. No!important(theme priority). See the terminal note in business-theme-and-mockups.md. Deliberate: Pulsar convention ties terminal colors to the syntax theme; we drive them from the UI theme so they follow the light/dark toggle. - Scrollbars. Use the
.themed-scrollbar()mixin (incolors.less) per scroll-container (tree-view, terminal.xterm-viewport, settings.config-menu/.panels) — never a universal*rule, which would disturb the editor’s own.vertical-scrollbarelements. - Tooltips / popovers. Tooltips → global (Quirk 1). Autocomplete (
autocomplete-suggestion-list.select-list.popover-list) and select-list popovers render inside the workspace → scope them. Both use@popover-bg+ border +@shadow-popover. - Notifications (
notificationspkg). It colors each type via a.notification()mixin using ui-variables → saturated blocks that clash. Override to a neutral card (@panel-bg, border, shadow) with a palette accent stripe/icon per type, matching the package’s selector specificity (atom-notifications atom-notification.<type>.icon:before). - File-type icons (
tranquil-theme-icons). Colors are applied via a.icon-colors(…)mixin invoked once per UI-theme class (.theme-tranquil-business-dark { .icon-colors(…) }). A new UI theme needs its own invocation or icons render monochrome. See file-type-icons.md. - Browser toolbar (
tranquil-browser). Itstranquil-browser.lessswaps palettes via.theme-<name>blocks (pure CSS, no JS). SVG icons are tinted with CSSfilter. Structural changes (height/padding/gap) must be mirrored across every theme’s browser block — the two default themes each keep their owntranquil-browser.lesswith different var names; missing one leaves them mismatched.
Keeping variants in sync
- Business themes: every partial except
colors.lessis byte-identical across the two packages. After editing a partial intranquil-business-light, copy it to-dark:
Add new tokens/mixins to bothcp styles/<partial>.less ../tranquil-business-dark/styles/<partial>.lesscolors.less(same names, different values). Verify parity withdiff -q.colors.lessis the only file that should ever differ. - Default themes (
tranquil-theme-*): partials are not shared and use different variable names per package, so a change in one must be hand-ported to the other (e.g. the browser toolbar height bug — light was 24px, dark 48px). This is exactly the friction the business structure removes.
Verifying a theme change
- Compile each variant standalone — catches missing tokens/mixins and bad LESS:
(For a single partial that usesnpx lessc --no-color ../tranquil-business-light/index.less >/dev/null npx lessc --no-color ../tranquil-business-dark/index.less >/dev/nullui-variables, stub the vars or compile viaindex.less.) - Reload the window (
ctrl-alt-cmd-L) — theme LESS recompiles on reload; dev packages load from source. - Inspect computed styles / find the winning rule via the remote-debugging port (the app is
launched with
--remote-debugging-port=9222,src/main-process/start.js). The local Node is 16 (no globalfetch), so connect Puppeteer via the WebSocket endpoint, notbrowserURL:
ThenWS=$(curl -s http://localhost:9222/json/version | node -e '…webSocketDebuggerUrl…') # puppeteer.connect({ browserWSEndpoint: WS })getComputedStyle(el)and walkel.parentElementto confirm scoped-vs-global reach (this is how the blue-tooltip and invisible-icon bugs were diagnosed). For surfaces that only appear on interaction (tooltips, notifications), you can compile the partial to CSS, inject it as a<style>, trigger the element (atom.tooltips.add,atom.notifications.addInfo), and screenshot — a fast preview without a full reload. - Switch Settings ▸ Themes between light and dark and confirm both.
Gotcha checklist
- Body-level element (tooltip, some overlays)? → global rule, not
@{theme-scope}. - Reaching for
!important? → almost certainly unnecessary; fix specificity instead. - Using an
@…-colorui-variable in a business theme? → wrong palette; usecolors.lesstokens orcolor: inherit. - Loading a font from a theme? →
atom://, neverdata:;-webkit-font-smoothing: auto. - Third-party surface (terminal/notifications/scrollbars/icons)? → theme it from the theme package via its documented hook; don’t edit the package.
- Edited a business-theme partial? →
cpit to the other variant; onlycolors.lessdiffers. - New token/mixin? → add to both
colors.less. - Structural browser-toolbar change? → mirror across every theme’s browser block.
- Verified? →
lessccompile + reload + (optionally) CDP computed-style check, in light and dark.