Docs

Settings UI & Form Widgets

How to build a themed settings panel and form widgets that match the Tranquil design system, and the non-obvious platform gotchas that make it hard. Companion to Theme Development. Written after building the Tranquil settings tab (the “Toast Duration” panel), where nearly every one of these bit us.

The design system is a real spec — measure it, don’t eyeball it

The source of truth for widget look is tranquil-automations/mockups/mockup.css (rendered by mockups/main-view.html) plus the shipped find bar (tranquil-browser/lib/browser-find-item.js + its theme tranquil-browser.less). Both are written against the same design tokens as the themes’ colors.less.

When matching the mockup, measure the mockup element’s computed styles (it’s a live webview you can inspect) rather than guessing px. Every value below was confirmed by measurement:

ElementSpec
Field label (.form-label)12px, weight 500, @text-secondary
Label → input gap (.form-field)6px
Input (.form-input)13px UI font, padding 7px 10px, radius 6px, 1px @border-strong, fill @app-bg, ~34px tall
Hint (.form-hint)11px, @text-muted, sits below the input
Input → hint gap6px
Between fields (.form-grid)18px
Button (.btn)radius 6px, padding 8px 14px, weight 500; .btn-primary = @accent fill + @text-inverse

Values live in colors.less; control height (34px) is literal per the mockup. Radius and padding are now available as ui-variables (@component-border-radius = 6px, @component-padding) — see the next section — though the mockup’s exact 34px height stays a literal.

Theme colours in package code: @import "ui-variables"

Package stylesheets should take their colours from the standard Atom/Pulsar ui-variables, not from hardcoded hex, color: inherit, or rgba(127,127,127,…) overlays. The business themes now ship styles/ui-variables.less, which maps the standard variable set onto each theme’s colors.less tokens. So a package that does:

@import "ui-variables";
.my-widget { color: @text-color; background: @tool-panel-background-color; }

renders correctly on both light and dark with no per-theme code. Pulsar resolves @import "ui-variables" to the active UI theme’s file — ThemeManager.getImportPaths() puts each active theme’s styles/ dir on the LESS import path (that’s why, before this file existed, packages silently fell back to the bundled light defaults in static/variables/ui-variables.less and rendered invisibly on dark).

Common variables (full contract: tranquil-client/static/variables/ui-variables.less):

NeedVariable
Body text / muted / heading@text-color / @text-color-subtle / @text-color-highlight
Editor / content surface@base-background-color
Dock / tool-panel surface@tool-panel-background-color
Panel / card surface@inset-panel-background-color
Input fill / border@input-background-color / @input-border-color
Row hover / selected@background-color-highlight / @background-color-selected
Accent (links, primary fill)@background-color-info (the theme @accent)
Status text@text-color-success / -warning / -error
Radius / padding / row height@component-border-radius / @component-padding / @component-line-height

Adding or changing a mapping: edit both tranquil-business-{dark,light}/styles/ui-variables.less. The two files are byte-identical (each imports its own colors.less, so the shared mapping yields per-theme values) — so the “only colors.less differs” rule still holds. If you need a variable that isn’t in the contract, add it to the fallback (static/variables/ui-variables.less) too, so non-business themes still resolve it.

Custom buttons — no !important needed anymore. The themes give every <button> a default background (base.less: button { background: @surface-active }) but without !important — theme sheets already win over packages at equal specificity via priority. So a custom icon/ghost button just needs normal specificity (a class or two, e.g. .my-panel .close) to override; source the colour from ui-variables (@button-background-color-hover, transparent, …). (Previously that rule used !important, which forced every package button to fight it — that’s been removed.)

When to use colors.less tokens directly instead: only inside the theme packages (their own partials and the mockups). Package code uses ui-variables; theme code uses tokens — that keeps the token→variable mapping in exactly one place.

Adding a top-level “Tranquil” tab to settings-view

settings-view exposes no service for a top-level tab, but the built-in tabs are added by SettingsView.addCorePanel(name, icon, factory). The SettingsView is a workspace pane item, so:

  1. atom.workspace.observePaneItems(item => …), duck-type it (typeof item.addCorePanel === 'function' + item.constructor.name === 'SettingsView'), and call addCorePanel('Tranquil', 'gear', factory) (guard with panelCreateCallbacks).
  2. In the factory, reuse core’s SettingsPanel: new SettingsPanel({ namespace: 'tranquil', … }) auto-renders config-bound controls for a whole atom.config namespace and — because it emits the same markup as Core/Editor — gets themed for free. Wrap it in a .panels-item shell that provides element / show() / focus() / destroy() (SettingsPanel only has destroy), mirroring core general-panel.js.
  3. Register the namespace with atom.config.setSchema('tranquil', { … }) (not package.json configSchema) so keys live under tranquil.* rather than the package name.

Two layout fixes for a mockup-matching field:

  • The heading octicon offsets the title. SettingsPanel renders .section-heading.icon.icon-<x>; the glyph pushes the title ~30px right of the field. Hide it (.section-heading.icon::before { content: none }, padding-left: 0) so the title shares the field’s left edge. (The nav tab keeps its icon.)
  • The description renders above the input, but the mockup’s hint sits below. Move it in the factory: group.appendChild(group.querySelector('.setting-description')), then style it as the hint. Scope this to a .tranquil-settings class so Core/Editor are untouched.

Native inputs & widgets — the platform gotchas

These are the ones that cost the most time.

<input> does NOT render ::before/::after in Chromium

A pseudo-element checkmark (border-triangle, glyph, content: "✓") on input[type=checkbox] silently never paints — and getComputedStyle(input, '::after') still reports your content, so it looks applied but isn’t. Draw checkbox marks with a background-image inline-SVG instead (backgrounds do paint on inputs):

input[type="checkbox"]:checked {
  background-color: @accent;
  background-image: url("data:image/svg+xml,%3Csvg …stroke='@{check}'…/%3E");
}

Encode the stroke color’s #%23 (replace("@{text-inverse}","#","%23")) and build the URI as a variable, then url("@{var}") — interpolating avoids the \"-escaping that breaks url().

Also suppress any theme/base ::before mark on the box (&::before { content: none }) or it combines with yours into a garbled shape.

Native editing keys need native-key-bindings

Backspace, Delete, Cmd/Ctrl+A (select-all), copy/paste, and arrows are swallowed by Atom’s keymap in workspace inputs. Add the native-key-bindings class to any native <input>/<textarea> so those keys reach the field.

Native controls need a color-scheme

The number-input spin buttons (and other native controls) render with the platform default color-scheme (light), so on a dark input the arrows are near-invisible. Add a per-theme token (@color-scheme: dark / light in each colors.less) and set color-scheme: @color-scheme on the control; ::-webkit-inner-spin-button { opacity: 1 } keeps the steppers always visible.

atom-text-editor[mini] quirks (if you keep the core editor)

  • Core fades it to opacity: .75 (back to 1 on focus) — force opacity: 1 for a solid field.
  • It renders in the monospace editor font at 14px; font-family: inherit; font-size: 13px makes it match the mockup’s UI-font input.
  • In a flex row it collapses (flex-basis doesn’t hold it) — give the column an explicit width.
  • We ultimately replaced it with a native <input type="number"> to show seconds with steppers while storing ms behind the scenes — cleaner than fighting the editor.

settings-view clamps integer fields on every keystroke

SettingsPanel’s change handler reads atom.config.getSchema(name) live and, if the typed value is < minimum / > maximum, rewrites the field — so you can’t type a value that passes through an out-of-range prefix (e.g. any digit < 1000). If you want free typing, omit minimum/maximum from the schema and enforce bounds where the value is consumed. Because getSchema returns the live object, you can also delete schema.minimum at panel-create time to fix it without a config re-registration.

Note atom.config.setSchema does Object.assign — re-registering a namespace replaces the leaf property (so dropping min/max in the definition works on reload), but only on a full reload (see below).

Reload behavior — know what actually reloads (this is the big one)

The single biggest source of “I fixed it but nothing changed”:

  • themePackage.reloadStylesheets() / the LESS file watcher updates the CSSOM and computed styles, but does not reliably repaint existing ::before/::after pseudo-elements, and does not re-run any JavaScript. Property changes (colors, margins, fonts) show up; structural pseudo-element changes may not until a real reflow.
  • JS, package activation, and setSchema changes only take effect on a full Window: Reload (Cmd/Ctrl-Alt-L) — which re-requires the module and re-runs activate(). A dev package’s edited source is not re-required by a stylesheet reload.
  • So: after a style change, a stylesheet reload usually suffices; after any JS/schema/DOM- factory change, do a full window reload. When debugging remotely via CDP, remember reloadStylesheets() ≠ package reload.

Notifications behaviors

  • A stray global onDidAddNotification handler can override everything. tranquil-browser had one that force-dismissed every info toast after a hard-coded 1000ms (registered once per browser tab). It silently capped all info-toast durations regardless of the notify() helpers or any per-toast timer. If toast timing seems ignored, grep for onDidAddNotification.
  • Identical notifications de-dupe. The notifications view shows a count badge instead of a fresh toast when the message string matches a visible one — so a “preview” button that always posts the same message reuses the old toast (and its old timing). Make each message unique (embed the value) and dismiss the prior one.
  • Only non-dismissable toasts auto-hide (via notifications.defaultTimeout). Dismissable ones stay until clicked, so Tranquil’s notify() helpers add their own setTimeout(dismiss, …).

Gotcha checklist

  • Matching the mockup? Measure its computed styles; don’t eyeball px.
  • Checkbox/tick marks → background-image SVG, not ::before/::after on the input.
  • Native <input> → add native-key-bindings.
  • Native control on a dark surface → set color-scheme.
  • Integer setting field rewriting as you type → drop schema minimum/maximum.
  • Changed JS / schema / DOM factory → do a full Window: Reload, not just a style reload.
  • Toast timing ignored → check for a global onDidAddNotification dismiss handler.
  • Repeated identical toasts → make the message unique and dismiss the prior one.
  • Colours in a package stylesheet → @import "ui-variables" and use @text-color / @tool-panel-background-color / etc., not inherit/rgba()/hex.
  • Custom <button> → colour from ui-variables; a class-scoped selector overrides the theme’s default button background (no !important needed).
  • New ui-variable mapping → edit both tranquil-business-{dark,light}/styles/ui-variables.less (byte-identical) + the static/variables/ui-variables.less fallback.
  • New surface styling → apply identically to both business themes; only colors.less differs.