skill
Adom UI Design System
Public Made by Adomby adom
How the AI builds and tests interfaces people love to use in Adom. Ships as one installable skill that bundles focused skills (tooltips, floating-windows, toasts, responsive-ui, app-shell, ai-driven-apps, clickable-paths).
← Commit history
settings-layout: modal is the norm, backdrop dims 20-30 percent and never blurs, plus shared cog assets
John's correction (2026-08-23): most Adom apps are single-page dashboards with a cog in the
header, so the DISMISSIBLE MODAL is the common case and the full settings window is ah's
exception, not the pattern. The skill led with the window; now it leads with the modal.
Backdrop law added and the stale rgba(0,0,0,.55) corrected to .25: dim 20-30 percent, never
blur. The reason is functional, settings often change the very thing behind the dialog (theme,
font, density, layout) and the user needs to watch that happen.
assets/icons/ now carries settings-cog.svg and settings-cog-outline.svg with a usage README so
every Adom app draws the same cog. Both are currentColor at 1em so they inherit from the button.
Also folded in from ah's implementation: the category sidebar with jump-to AND scroll-spy, the
row anatomy (breadcrumb name with a teal last part, a Default hint on every row), the type
scale, the token table, the two-click Restore defaults footer, and the six saving rules.
5 files changed
+370−19
SKILL.md+1−1@@ -421,7 +421,7 @@ This whole system ships as **one installable skill**. `adom-wiki pkg install ado | Skill | Governs | |---|---| | [**input-feedback**](https://wiki.adom.inc/adom/adom-ui-design/files/skills/input-feedback/SKILL.md) | The acknowledgment doctrine: hover, mousedown/:active, and focus-visible states on EVERY interactive element - the UI answers 'yes, you just hovered me / pressed me' before the action even runs |-| [**settings-layout**](https://wiki.adom.inc/adom/adom-ui-design/files/skills/settings-layout/SKILL.md) | The ah settings surface, reusable: sectioned teal-accent cards that EXPLAIN themselves in full sentences, inline live status, Install/Update/Uninstall rows with live consoles, and the portal-modal mechanics - every Adom app's settings dialog mimics this |+| [**settings-layout**](https://wiki.adom.inc/adom/adom-ui-design/files/skills/settings-layout/SKILL.md) | The settings surface every Adom app shares: a cog in the header opening a dismissible MODAL, backdrop dimmed 20-30% and NEVER blurred (settings often change what is behind them), teal-accent cards that explain themselves, toggles not checkboxes, defaults shown on every row, two-click Restore defaults, and a category sidebar with jump-to and scroll-spy once settings outgrow a screen | | [**tooltips**](https://wiki.adom.inc/adom/adom-ui-design/files/skills/tooltips/SKILL.md) | Great desktop tooltips: 600ms reveal, the fixed-div renderer, placement that never covers what you hover, edge-aware flipping, the z-index ladder | | [**floating-windows**](https://wiki.adom.inc/adom/adom-ui-design/files/skills/floating-windows/SKILL.md) | Draggable floaty tool windows and HUDs that never get in the way | | [**toasts**](https://wiki.adom.inc/adom/adom-ui-design/files/skills/toasts/SKILL.md) | Non-blocking confirmations, and the AI's live narration channel to the user |
assets/icons/README.mdadded+34@@ -0,0 +1,34 @@+# Adom shared icons++Drop-in SVGs so the same affordance looks the same in every Adom app. Each is+`viewBox="0 0 24 24"`, sized `1em`, and painted with `fill="currentColor"`, so it inherits+the surrounding text colour and font-size. Size it with CSS (`width: 20px`) or a wrapper's+`font-size`; do NOT hardcode a colour into the file.++| File | Icon | Use for |+|---|---|---|+| `settings-cog.svg` | mdi:cog (filled) | THE settings entry point: the cog at the right end of an app header. This is the default. |+| `settings-cog-outline.svg` | mdi:cog-outline | The same affordance on a dense or light surface where the filled cog reads too heavy. Pick one per app and stay with it. |++Source: Material Design Icons (`mdi:cog`, `mdi:cog-outline`), Apache 2.0. If your app already+renders Iconify, `<Icon icon="mdi:cog" />` is the identical glyph and is preferred over+copying the file; these exist for apps with no icon runtime.++## Using it++```html+<button class="icon-btn" aria-label="Settings" title="Settings">+ <img src="/assets/icons/settings-cog.svg" alt="" width="20" height="20">+</button>+```++Inlining the SVG is better than an `<img>` when you want it to follow `currentColor` on hover:++```html+<button class="icon-btn" aria-label="Settings" title="Settings">+ <svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path fill="currentColor" d="..."/></svg>+</button>+```++The label is always "Settings", never "Preferences" or "Options". See the `settings-layout`+skill for the entry point rules and everything the cog opens.
assets/icons/settings-cog-outline.svgadded+1@@ -0,0 +1 @@+<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="M12 8a4 4 0 0 1 4 4a4 4 0 0 1-4 4a4 4 0 0 1-4-4a4 4 0 0 1 4-4m0 2a2 2 0 0 0-2 2a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2m-2 12c-.25 0-.46-.18-.5-.42l-.37-2.65c-.63-.25-1.17-.59-1.69-.99l-2.49 1.01c-.22.08-.49 0-.61-.22l-2-3.46a.493.493 0 0 1 .12-.64l2.11-1.66L4.5 12l.07-1l-2.11-1.63a.493.493 0 0 1-.12-.64l2-3.46c.12-.22.39-.31.61-.22l2.49 1c.52-.39 1.06-.73 1.69-.98l.37-2.65c.04-.24.25-.42.5-.42h4c.25 0 .46.18.5.42l.37 2.65c.63.25 1.17.59 1.69.98l2.49-1c.22-.09.49 0 .61.22l2 3.46c.13.22.07.49-.12.64L19.43 11l.07 1l-.07 1l2.11 1.63c.19.15.25.42.12.64l-2 3.46c-.12.22-.39.31-.61.22l-2.49-1c-.52.39-1.06.73-1.69.98l-.37 2.65c-.04.24-.25.42-.5.42zm1.25-18l-.37 2.61c-1.2.25-2.26.89-3.03 1.78L5.44 7.35l-.75 1.3L6.8 10.2a5.55 5.55 0 0 0 0 3.6l-2.12 1.56l.75 1.3l2.43-1.04c.77.88 1.82 1.52 3.01 1.76l.37 2.62h1.52l.37-2.61c1.19-.25 2.24-.89 3.01-1.77l2.43 1.04l.75-1.3l-2.12-1.55c.4-1.17.4-2.44 0-3.61l2.11-1.55l-.75-1.3l-2.41 1.04a5.42 5.42 0 0 0-3.03-1.77L12.75 4z"/></svg>
assets/icons/settings-cog.svgadded+1@@ -0,0 +1 @@+<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="M12 15.5A3.5 3.5 0 0 1 8.5 12A3.5 3.5 0 0 1 12 8.5a3.5 3.5 0 0 1 3.5 3.5a3.5 3.5 0 0 1-3.5 3.5m7.43-2.53c.04-.32.07-.64.07-.97s-.03-.66-.07-1l2.11-1.63c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.31-.61-.22l-2.49 1c-.52-.39-1.06-.73-1.69-.98l-.37-2.65A.506.506 0 0 0 14 2h-4c-.25 0-.46.18-.5.42l-.37 2.65c-.63.25-1.17.59-1.69.98l-2.49-1c-.22-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64L4.57 11c-.04.34-.07.67-.07 1s.03.65.07.97l-2.11 1.66c-.19.15-.25.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1.01c.52.4 1.06.74 1.69.99l.37 2.65c.04.24.25.42.5.42h4c.25 0 .46-.18.5-.42l.37-2.65c.63-.26 1.17-.59 1.69-.99l2.49 1.01c.22.08.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64z"/></svg>
skills/settings-layout/SKILL.md+333−18@@ -2,16 +2,23 @@ name: settings-layout user-invocable: true description: >-- The Adom settings surface: how ah (Adom Hydrogen) lays out its Settings, and how every other- Adom app's settings dialog should mimic it. Covers the two variants (full settings PAGE with- sidebar outline, and compact settings MODAL for a panel), the settings-card anatomy (elevated- card, 3px teal left accent, title with inline status, descriptive prose paragraph, control rows,- dividers), the descriptive-copy doctrine (every section EXPLAINS itself in full sentences, e.g.- naming the exact hooks it installs), Install/Update/Uninstall row patterns with live consoles,- and the modal mechanics (portal to body, viewport clamp, draggable header, Escape/click-outside).- Read BEFORE building any settings dialog, preferences pane, or config UI in an Adom app.+ The Adom settings surface, and the ONE look every Adom app shares. Most Adom apps are+ single-page dashboards whose settings open from a cog in the header as a DISMISSIBLE MODAL:+ that is the common case and the default this skill teaches. Covers the entry point (cog, upper+ right), the backdrop law (dim 20-30%, NEVER blur, because settings often change the very thing+ behind the modal), the four dismissal behaviors, the settings-card anatomy (elevated card, 3px+ teal left accent, title with inline status, descriptive prose, control rows, dividers), the+ descriptive-copy doctrine (every section EXPLAINS itself in full sentences), toggles not+ checkboxes, Install/Update/Uninstall rows with live consoles, the row anatomy (breadcrumb name+ with a teal last part, and a Default hint on every row), the type scale and tokens, the+ two-click Restore defaults footer, local-first optimistic saving, and what to do when settings+ outgrow one screen (a category sidebar with jump-to and scroll-spy). The full settings WINDOW is+ the rare variant, used by ah alone. Read BEFORE building any settings dialog, preferences pane,+ or config UI in an Adom app. Trigger words: settings dialog, settings modal, settings layout, preferences pane, config ui,- settings card, options dialog, app settings, settings panel, mimic ah settings.+ settings card, options dialog, app settings, settings panel, mimic ah settings, cog wheel,+ gear icon, settings backdrop, dim the background, blur the background, settings sidebar,+ jump to section, scroll spy, restore defaults, dashboard settings. --- Parent skill: **adom-ui-design**@@ -31,13 +38,76 @@ Reference implementations (both live in adom/hydrogen-desktop): - COMPACT MODAL: the AI Threads panel settings in `src/lib/components/editor/panels/PanelAiThreads.svelte` (560px portal modal, three cards). -## Pick the variant+## Pick the variant (the modal is the common case) -- **Full settings page** (an app's main Settings): left sidebar OUTLINE (section list, click- scrolls; active section highlighted) + scrolling editor column of cards + a footer note- ("Settings are stored on this machine...") with a Restore-defaults button.-- **Compact settings modal** (a panel or widget's own settings): a single centered modal,- width ~560px, max-height 70vh with internal scroll, stacked cards. No sidebar.+Most Adom apps are **single-page dashboards**. Their settings open from a **cog in the upper+right of the app header** and appear as a **dismissible modal over the dashboard**. That is the+default, and unless you have a specific reason, it is what you are building.++| Variant | Use it when | Who uses it |+|---|---|---|+| **Settings MODAL** (default) | A single-page app or dashboard. Anything with a header cog. | Nearly every Adom app |+| **Settings WINDOW** | A desktop shell where the user must drag settings aside and watch the app change underneath | ah (Adom Hydrogen) only |++The full **window** variant is rare. ah uses it because ah is a desktop shell and John needs to+drag Settings off to the side and see what it changes in the app underneath. If your app is a+dashboard in a webview or a browser tab, you do NOT want a window; you want the modal.++Both variants share everything below the shell: the same cards, rows, type scale, tokens,+toggles, footer, and save behavior. Only the container differs.++### The entry point: a cog in the upper right++The settings cog lives in the app header, at the **right end**, as an icon button with the+standard hover / press / focus-visible treatment (see the **input-feedback** sub-skill). Its+tooltip and `aria-label` are "Settings", never "Preferences" or "Options". A user who learns+where the cog is in one Adom app has learned it in all of them.++**Use the shared glyph, so every Adom cog is the same cog.** This repo ships it, alongside a+short usage note, so an app with no icon runtime can just take the file:++- [`assets/icons/settings-cog.svg`](https://wiki.adom.inc/adom/adom-ui-design/files/assets/icons/settings-cog.svg) (filled, the default)+- [`assets/icons/settings-cog-outline.svg`](https://wiki.adom.inc/adom/adom-ui-design/files/assets/icons/settings-cog-outline.svg) (for dense or light surfaces; pick one per app and stay with it)+- [`assets/icons/README.md`](https://wiki.adom.inc/adom/adom-ui-design/files/assets/icons/README.md)++Both are `viewBox="0 0 24 24"`, sized `1em`, painted `fill="currentColor"`, so they inherit+colour and size from the button. If your app already renders Iconify, `<Icon icon="mdi:cog" />`+is the identical glyph and is preferred; the files exist for apps with no icon runtime.++```html+<button class="icon-btn" aria-label="Settings" title="Settings">+ <img src="/assets/icons/settings-cog.svg" alt="" width="20" height="20">+</button>+```++## The backdrop law: DIM, NEVER BLUR++**The settings modal dims the app behind it by 20-30%, and never blurs it.**++```css+/* native <dialog> */+dialog.settings::backdrop { background: rgba(0, 0, 0, 0.25); }+/* hand-rolled overlay */+.settings-overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.25); }+```++The reason is functional, not decorative (John, 2026-08-23):++> "sometimes the settings literally update the background behind the modal and you want the+> user to see that."++A theme picker, a font picker, a density toggle, a layout switch: all of them change the very+thing sitting behind the dialog. Blur destroys that feedback, and a heavy dim degrades it. The+dim exists for exactly one job, telling the user this thing is in the foreground and modal, and+20-30% is enough to say that while leaving the app underneath legible enough to watch it change.++Rules:+- **Never** `backdrop-filter: blur(...)` on a settings backdrop.+- **Never** go past about 30%. A 50%+ scrim reads as "the app is gone" and hides live previews.+- **Never** drop the dim entirely: with no scrim the dialog reads as a floating card and users+ click straight through it.+- Keep the value identical across your app's dialogs. Inconsistent scrim depth between two+ dialogs in the same app is more noticeable than the depth itself. ## The settings-card (the atom both variants share) @@ -150,9 +220,11 @@ learned them all. All four, every time, no exceptions: if (!inside) dlg.close(); }); ```-4. **The rest of the app dims.** `dialog::backdrop { background: rgba(0,0,0,.55) }` (or an- equivalent overlay for a hand-rolled modal). The dim is what says "this is modal"; without- it the dialog reads as a floating card and users click straight through it.+4. **The rest of the app dims, 20-30%, never blurred.** `dialog::backdrop { background:+ rgba(0,0,0,.25) }` (or an equivalent overlay for a hand-rolled modal). The dim is what says+ "this is modal"; without it the dialog reads as a floating card and users click straight+ through it. See the backdrop law above for why it stays light and unblurred: the settings+ behind it are often changing the app you are covering. A "Close" button in the footer is fine as a fifth affordance, but it never replaces the X. Ralph-test all four for real - drive Escape, click the X, click outside, and read the computed@@ -220,3 +292,246 @@ acknowledgment, instant :active press, :focus-visible ring). Ralph-test the surf adom-ui-design loop: open it for real, screenshot, READ the pixels (a modal that "opened" but rendered clipped passes APIs and fails humans), and verify every status string against ground truth before shipping.++## When settings outgrow one screen: the category sidebar++A settings surface that scrolls for pages is still fine to scroll. But past roughly two+screenfuls, or two or more top-level sections, add a **category sidebar** so a user can jump+straight to what they came for. This is the ah Settings pattern and it works identically inside+a modal (make the modal wider, around 760-880px) as it does in a window.++The rule that matters: **it is ONE continuously scrolling pane, and the sidebar navigates within+it.** Settings never becomes a set of pages that replace each other. A user hunting for a setting+they cannot name scans, and swapped pages defeat scanning.++```+┌──────────────────┬────────────────────────────────────────────┐+│ 15rem sidebar │ scrolling editor pane │+│ padding: 1rem │ padding: 1rem 1.25rem │+│ ▾ Appearance │ Appearance ← h2 id │+│ Theme │ ┌─ card ────────────────────────────────┐ │+│ Fonts │ │ Theme ← h3 id │ │+│ ▾ Updates │ └───────────────────────────────────────┘ │+└──────────────────┴────────────────────────────────────────────┘+```++```css+.content { display: flex; width: 100%; height: 100%; min-height: 0; }+.side-bar { width: 15rem; flex-shrink: 0; border-right: 1px solid var(--border); overflow: auto; }+.outline { padding: 1rem; }+.editor { flex: 1; min-width: 0; padding: 1rem 1.25rem; overflow: auto; scroll-behavior: smooth; }++.section, .subsection {+ width: 100%; padding: 0.3rem 0.5rem; border-radius: 6px;+ display: flex; align-items: center; background: none; border: none;+ color: var(--text); font-family: var(--brand-body); user-select: none;+}+.section:hover, .subsection:hover {+ color: var(--accent); background: rgba(255,255,255,0.06); cursor: pointer;+}+.selected { color: var(--accent); font-weight: 700; }+.section.drop-down { gap: 0.25rem; } /* has a caret */+.section:not(.drop-down) { padding-left: 1.25rem; } /* no caret: align the text anyway */+.subsection { padding-left: 2.25rem; }+```++`min-height: 0` on the flex parent and `min-width: 0` on the editor are load-bearing. Without+them a long row refuses to scroll and stretches the container instead.++- Every entry is a `<button>`, so it is keyboard reachable by construction.+- **Parents start EXPANDED.** A collapsed tree hides that a category exists at all, and settings+ trees are small enough that there is nothing to save by collapsing.+- Indentation is the ONLY hierarchy cue. Parents with children get a caret; parents without get+ the same left padding so every label lines up.++### Jump-to++Headings carry the id the sidebar names, so both sides share one vocabulary:++```js+function jumpTo(node) {+ selectedId = node.id+ spyMuteUntil = Date.now() + 600 // let the smooth scroll finish+ document.getElementById(node.id)?.scrollIntoView()+}+```++Give subsection headings `scroll-margin-top: 0.75rem` so a jumped-to card lands with its top+edge visible instead of flush against the pane.++### Scroll-spy (do not skip this)++Highlighting only on click is a half-feature: scroll with the wheel and the sidebar lies about+where you are. John caught exactly this: "you don't update the left nav depending on how far+down i've scrolled." On every scroll frame, highlight the last heading at or above a reading+line one third down the pane, rAF-throttled, muted briefly after a click:++```js+let spyMuteUntil = 0, spyRaf = 0+function onEditorScroll(ev) {+ if (spyRaf) return+ spyRaf = requestAnimationFrame(() => {+ spyRaf = 0+ if (Date.now() < spyMuteUntil) return+ const pane = ev.target+ const line = pane.getBoundingClientRect().top + pane.clientHeight * 0.33+ let current = null+ for (const h of pane.querySelectorAll('.section-title, .subsection-title')) {+ if (h.getBoundingClientRect().top <= line) current = h.id+ else break+ }+ if (current && current !== selectedId) selectedId = current+ })+}+```++It relies on headings being in document order, costs one frame, and needs no+IntersectionObserver unless you have hundreds of sections.++## The row: label above control, and always show the default++```html+<div class="wrapper">+ <h4 class="name" title="hydrogen.updater.workspace_image">+ <span class="prefix">Updater: </span><span class="title">Workspace Image</span>+ </h4>+ <!-- the control -->+ <span class="default-hint">Default: On</span>+</div>+```++```css+.wrapper { padding: 0.5rem 0; display: flex; flex-direction: column; gap: 0.25rem; }+.name { margin: 0.125rem 0; font-family: var(--brand-heading);+ font-size: 0.9375rem; font-weight: 600; }+.prefix { color: var(--text); }+.title { color: #8fd3d4; } /* brand light teal: the part that VARIES */+.default-hint { font-family: var(--brand-body); font-size: 0.75rem; color: var(--text-2); }+```++1. **Label above the control, left aligned, stacked.** Not a two-column label/control grid: a+ grid forces every label to the width of the longest and wraps badly when the dialog narrows.+2. **The name is a breadcrumb whose LAST part is teal.** `Updater: Workspace Image` renders the+ ancestry in plain text and the setting name in `#8fd3d4`, so the eye lands on what varies.+ Strip the section prefix from the key first, or every row reads "Desktop > Desktop".+3. **Always show the default** ("Default: On", "Default: Adom Studio"). For an enum, print the+ option LABEL, never the stored slug, or the hint leaks internals and drifts from the picker.+ John asked for this directly: "put what the defaults are in the settings window."+4. **Put the full key in `title`** as a tooltip. That is how a user reports a setting precisely+ and how an AI finds it.+5. Fix initialisms your case helper mangles: `Wsl` to `WSL`, `Hd` to `HD`, `Api` to `API`.++## Type scale++Two faces, not interchangeable: `--brand-heading` (Familjen Grotesk) for titles, row names and+buttons; `--brand-body` (Satoshi) for anything that is a sentence.++| Element | Size | Weight | Colour |+|---|---|---|---|+| Section title (h2) | 1.6825rem | 800 | `var(--accent)` |+| Subsection title (h3) | 1.3125rem | 600 | `var(--accent)` |+| Row name (h4) | 0.9375rem | 600 | prefix `var(--text)`, last part `#8fd3d4` |+| Description | 0.8125rem | normal | `var(--text-2)`, line-height 1.55 |+| Default hint | 0.75rem | normal | `var(--text-2)` |+| Control label | 0.875rem | normal | `var(--text-body)` |+| Action button | 0.875rem | 600 | `#06231f` on teal |+| Footer note | 0.75rem | normal | `var(--text-2)` |++Both heading levels are `height: 2rem; display: flex; align-items: center`, which holds the+optical baseline steady regardless of descenders. Cap description paragraphs at `max-width:+60ch`: a line that runs the full width of an 880px dialog is unreadable.++## Tokens (a scheme swap must change nothing but these)++| Token | Dark value | Role |+|---|---|---|+| `--surface` | `#161b22` | the settings ground |+| `--elevated` | `#1c2128` | cards |+| `--overlay` | `#21262d` | menus, dropdowns |+| `--border` | `#30363d` | borders, dividers |+| `--text` | `#e6edf3` | emphasis, sparingly |+| `--text-body` | `#c9d1d9` | body copy |+| `--text-2` | `#8b949e` | descriptions, hints |+| `--accent` | `#00b8b1` | THE Adom teal |+| `--on-accent` | `#05221f` | text on a teal fill |+| `--red` / `--yellow` / `--green` | `#f85149` / `#d29922` / `#3fb950` | destructive / warning / success ONLY |++Ground stack: dialog on `--surface`, cards one step up on `--elevated`, menus one further on+`--overlay`. Depth reads without a single shadow. Light schemes exist, so a hardcoded dark hex+where a token exists is a bug. The two intentional literals are the light-teal row title+(`#8fd3d4`) and the dark text on teal fills.++## The footer: two-click Restore defaults++Every settings surface ends the same way:++```html+<footer class="settings-footer">+ <p class="footer-note">Settings are stored on this machine. Restoring returns every option+ above to its default.</p>+ <button class="restore-btn" class:armed={armed}>+ <Icon icon={armed ? 'mdi:alert-circle-outline' : 'mdi:restore'} height="18" />+ {armed ? 'Click again to confirm' : 'Restore defaults'}+ </button>+</footer>+```++```css+.settings-footer { margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--border);+ display: flex; flex-direction: column; gap: 0.5rem; }+.restore-btn { width: fit-content; display: inline-flex; align-items: center; gap: 0.4rem;+ padding: 0.4rem 0.85rem; background: transparent;+ border: 1px solid var(--border); border-radius: 0.375rem;+ color: var(--text-body); font-family: var(--brand-heading);+ font-size: 0.8125rem; font-weight: 600; cursor: pointer; }+.restore-btn:hover { background: var(--elevated); }+.restore-btn.armed { color: var(--red); border-color: rgba(255,107,107,0.5);+ background: rgba(255,107,107,0.08); }+```++**Two clicks, auto-disarming after 4 seconds.** First click arms, turns the button red and+relabels it; second click wipes. No confirmation modal on top of a modal: the button IS the+confirmation, and it forgets on its own if the user walks away. The note says honestly where+settings live.++## Saving: local-first, optimistic, bounded++Every rule here came from a real bug, and every one of them is invisible until it bites:++1. **Persist locally FIRST**, before any network. Machine settings are local truth, the cloud+ is a mirror.+2. **Update the store optimistically, before the network call.** A PATCH with no timeout left+ a store stale and a control visually stuck, and an `isPending` flag disabled it forever.+3. **Reflect the click in the DOM immediately.** Reverting a control to the stale value while+ waiting means a stalled round trip sticks it visually, and every later click re-sends the+ same value.+4. **Bound every network call** (5s, then abandon with a warn). Never hang settings on a round+ trip.+5. **Fire side-effect hooks for every property type**, not just booleans. Gating an `onChanged`+ hook on `type === 'boolean'` makes a dropdown look wired and do nothing.+6. If your settings surface lives in a **separate document** (a window, a cross-origin webview),+ it does not share localStorage with the app. Bridge saves explicitly, or every pick reads+ back as the previous one.++## Checklist++- [ ] Opens from a cog at the right end of the app header, labelled "Settings"+- [ ] Modal by default; a window only if the user must watch the app change underneath+- [ ] Backdrop dims 20-30%, NEVER blurred, same value across the app+- [ ] Escape closes, X closes, clicking outside closes, dim present+- [ ] Modal is portalled to body, clamped to the viewport, and opens scrolled to the top+- [ ] Sidebar with jump-to AND scroll-spy once settings pass ~2 screenfuls or 2 sections+- [ ] One continuous scroll, never pages that replace each other+- [ ] Every group is a card with the 3px teal left accent, hairlines between rows only+- [ ] Every section EXPLAINS itself in full sentences, naming mechanisms+- [ ] Row names are breadcrumbs with a teal last part, full key in `title`+- [ ] Every row shows its default, using the option LABEL for enums+- [ ] Booleans are toggles (`role="switch"`), never a native checkbox+- [ ] Long actions stream a live console; no silent multi-minute buttons+- [ ] Footer note plus two-click auto-disarming Restore defaults+- [ ] Saves are local-first, optimistic, bounded at 5s+- [ ] House scrollbar, not the OS one; radius element is not the scroller+- [ ] Hover, :active and :focus-visible on every interactive element+- [ ] No hardcoded colours where a token exists; light scheme checked+- [ ] The word is Settings, never Preferences or Options+- [ ] Zero em-dashes