Docs

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:

FamilyPackagesStructure
Defaulttranquil-theme-light / tranquil-theme-darkSeparate partials per package; each has its own colors.less with different variable names (@bg-* in light, @primary-* in dark).
Businesstranquil-business-light / tranquil-business-darkByte-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.less must resolve first; base.less imports it, so partials imported after base.less get the vars/mixins in ambient scope).
  • styles/colors.lessthe 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.js getStyleSheetPriority).
  • Theme packages → 1 (src/theme-package.js).
  • StyleManager inserts 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 :root custom properties (e.g. the terminal’s --terminal-*) works without !important — same :root specificity, 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, …) from colors.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. Use color: inherit (pick up the theme’s text color from the surrounding element) plus opacity and 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’s static/ base, not the package.
  • static/index.html’s CSP is default-src * atom://* with no font-src and no data: for fonts → a data-URI @font-face is blocked, and * doesn’t cover data:.

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-face url("fonts/…") — no atom://, 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 from document.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 reads documentElement) from the palette; the terminal re-applies on atom.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 (in colors.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-scrollbar elements.
  • 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 (notifications pkg). 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). Its tranquil-browser.less swaps palettes via .theme-<name> blocks (pure CSS, no JS). SVG icons are tinted with CSS filter. Structural changes (height/padding/gap) must be mirrored across every theme’s browser block — the two default themes each keep their own tranquil-browser.less with different var names; missing one leaves them mismatched.

Keeping variants in sync

  • Business themes: every partial except colors.less is byte-identical across the two packages. After editing a partial in tranquil-business-light, copy it to -dark:
    cp styles/<partial>.less ../tranquil-business-dark/styles/<partial>.less
    Add new tokens/mixins to both colors.less (same names, different values). Verify parity with diff -q. colors.less is 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

  1. Compile each variant standalone — catches missing tokens/mixins and bad LESS:
    npx lessc --no-color ../tranquil-business-light/index.less >/dev/null
    npx lessc --no-color ../tranquil-business-dark/index.less  >/dev/null
    (For a single partial that uses ui-variables, stub the vars or compile via index.less.)
  2. Reload the window (ctrl-alt-cmd-L) — theme LESS recompiles on reload; dev packages load from source.
  3. 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 global fetch), so connect Puppeteer via the WebSocket endpoint, not browserURL:
    WS=$(curl -s http://localhost:9222/json/version | node -e '…webSocketDebuggerUrl…')
    # puppeteer.connect({ browserWSEndpoint: WS })
    Then getComputedStyle(el) and walk el.parentElement to 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.
  4. 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 @…-color ui-variable in a business theme? → wrong palette; use colors.less tokens or color: inherit.
  • Loading a font from a theme? → atom://, never data:; -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? → cp it to the other variant; only colors.less differs.
  • New token/mixin? → add to both colors.less.
  • Structural browser-toolbar change? → mirror across every theme’s browser block.
  • Verified? → lessc compile + reload + (optionally) CDP computed-style check, in light and dark.