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:
| Element | Spec |
|---|---|
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 gap | 6px |
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):
| Need | Variable |
|---|---|
| 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:
atom.workspace.observePaneItems(item => …), duck-type it (typeof item.addCorePanel === 'function'+item.constructor.name === 'SettingsView'), and calladdCorePanel('Tranquil', 'gear', factory)(guard withpanelCreateCallbacks).- In the factory, reuse core’s
SettingsPanel:new SettingsPanel({ namespace: 'tranquil', … })auto-renders config-bound controls for a wholeatom.confignamespace and — because it emits the same markup as Core/Editor — gets themed for free. Wrap it in a.panels-itemshell that provideselement/show()/focus()/destroy()(SettingsPanel only hasdestroy), mirroring coregeneral-panel.js. - Register the namespace with
atom.config.setSchema('tranquil', { … })(not package.jsonconfigSchema) so keys live undertranquil.*rather than the package name.
Two layout fixes for a mockup-matching field:
- The heading octicon offsets the title.
SettingsPanelrenders.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-settingsclass 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) — forceopacity: 1for a solid field. - It renders in the monospace editor font at 14px;
font-family: inherit; font-size: 13pxmakes 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/::afterpseudo-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
setSchemachanges only take effect on a fullWindow: Reload(Cmd/Ctrl-Alt-L) — which re-requires the module and re-runsactivate(). 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
onDidAddNotificationhandler can override everything.tranquil-browserhad one that force-dismissed everyinfotoast 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 foronDidAddNotification. - 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’snotify()helpers add their ownsetTimeout(dismiss, …).
Gotcha checklist
- Matching the mockup? Measure its computed styles; don’t eyeball px.
- Checkbox/tick marks →
background-imageSVG, not::before/::afteron the input. - Native
<input>→ addnative-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
onDidAddNotificationdismiss 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., notinherit/rgba()/hex. - Custom
<button>→ colour from ui-variables; a class-scoped selector overrides the theme’s defaultbuttonbackground (no!importantneeded). - New ui-variable mapping → edit both
tranquil-business-{dark,light}/styles/ui-variables.less(byte-identical) + thestatic/variables/ui-variables.lessfallback. - New surface styling → apply identically to both business themes; only
colors.lessdiffers.