ADR-0017: One normalized form layout for the Tranquil settings tab
Status: Active · Date: 2026-07-24
References
- Settings UI Form Layout — Delivery Notes — what shipped and where
- ADR-0011: A first-class Tranquil settings tab — the tab this layout lives in; that ADR reuses core’s
SettingsPanelagainst atranquil.*namespace - Settings UI & Form Widgets — the widget specs (
.form-input, native accent checkboxes) this layout dresses the controls with - Theme Development — the business-theme conventions (no
!importantexcept where core out-specifies us) this builds on
Context
ADR-0011 gave Tranquil a real settings tab by reusing core
settings-view’s SettingsPanel against a tranquil.* config namespace. That renders controls, but
core emits different markup per control type: for a mini-editor (e.g. Toast Duration) the .control-label is nested inside .controls; for an enum <select> the label is a sibling above .controls; the .setting-description sometimes lives inside the label. The result was fields that
didn’t line up — one setting’s label sat left of its input, the next sat above it — and the misalignment
flipped depending on which control type each field used. Patching individual fields kept moving the
problem around.
Two more edges surfaced while adding an in-tab UI-theme picker (so users can switch light/dark without hunting through the Themes panel):
- A native macOS
<select>(appearance: auto) draws its visible control inset within the CSS box, ignoring ourborder-radius, so its right edge fell short of the sibling text inputs even at an identical22emwidth. (Confirmed by reading computed styles off the live renderer — the box rects were pixel-identical; only the native rendering differed.) - The picker must not modify settings-view (third-party-package rule) and must survive theme changes made elsewhere.
We want the tab to read as one coherent form, and — since settings will keep being added — we want new settings to inherit the layout without per-field CSS.
Decision
Normalize every setting to one stacked layout in the panel factory, style that single shape in the business themes, and surface a self-contained UI-theme picker at the top of the tab.
- DOM normalization (tranquil-config
createTranquilSettingsPanel). AfterSettingsPanelrenders, walk every.control-groupand hoist its.control-labelto be the first child and keep the.setting-descriptiontucked inside the label (directly under the title). Whatever per-type markup core produced, each group ends up as label (title + description) → controls → (buttons). - One layout in the theme (
settings.less, both business themes). Scope a.settings-view .tranquil-settingsblock that lays each.control-groupout as a flex column with explicitordervalues, so the vertical order is fixed regardless of source markup. Every text/number/selectfield shares one22em,box-sizing: border-boxbox, left-aligned in a full-width.controlsrow, so all fields line up on both edges; trailing buttons (e.g. “Test”) follow in the same row. Heading is15px/600 @text-primary; the description reads as a12px@text-mutedhint directly beneath it. - Flat selects. Force
appearance: noneon.settings-view select.form-controlso the box renders flat and fills its width (edges match the inputs), and draw our own chevron as a@text-secondarySVG data-URI (URL-encoding#→%23, mirroring the existing checkbox mark), with right padding to clear it.border-boxkeeps the extra padding from shifting the right edge. - In-tab UI-theme picker (self-contained). Build a
<select>in the factory that readsatom.themes.getLoadedThemes()(filtered to Tranquil’s owntranquil-business-*UI themes) and, on change, writescore.themes = [uiTheme, syntaxTheme]— preserving the active syntax theme exactly as settings-view’s own Themes panel does. It repopulates onatom.themes.onDidChangeActiveThemes(so a switch made elsewhere is reflected) and disposes that subscription on paneldestroy(). It touches no settings-view source.
Options considered
- Normalize DOM + one themed layout (chosen) — the layout lives in exactly two places (a factory
loop + one theme block); new settings inherit it automatically. Cost: we depend on core’s class names
(
.control-group/.control-label/.controls/.setting-description), which we already do for theming. - Per-field CSS patches — rejected: what actually happened first, and it whack-a-moled because each control type needs a different patch and future fields would need new ones.
- Fork/replace
SettingsPanelwith a bespoke renderer — rejected: violates the reuse win of ADR-0011 (free theming, config-bound controls) and is far more surface to maintain than a normalize pass. - Keep
appearance: autoand nudge the select width to fake alignment — rejected: the native inset isn’t a fixed number and isn’t controllable;appearance: none+ our own chevron is the real fix and makes every settings select render consistently. - Link to settings-view’s Themes panel instead of an in-tab picker — rejected: the point is to
switch theme where users already are; the picker is a few lines and reads/writes the same
core.themespair the Themes panel does.
Consequences
- New settings line up for free. Any future
tranquil.*control (input, select, checkbox-adjacent, with or without a trailing button) drops into the tab already aligned — no bespoke CSS. - All settings selects render flat and consistent, not just the theme picker, because the
appearance: none+ chevron fix lives on the sharedselect.form-controlrule. - We lean on core’s settings-view class names. If a future settings-view rewrite renamed
.control-group/.controls, the normalize pass and theme block would need updating — a known, small blast radius, and the same coupling theming already carries. - The picker is scoped to Tranquil’s own themes on purpose. It lists only
tranquil-business-*; installing a third-party UI theme won’t appear here (that’s still the full Themes panel’s job). - Layout changes are theme edits — the two business
settings.lessfiles must stay in sync (light differs only by tokens), the standing rule for browser/settings surfaces. - Re-evaluate if we ever need a genuinely two-column setting (label beside control) or grouped sub-sections — the current decision commits the whole tab to a single stacked column.