← Commit history

settings-layout: modal is the norm, backdrop dims 20-30 percent and never blurs, plus shared cog assets

John Lauer ·fbf3658483 ·1mo ago ·parent 37ffef0
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>\ No newline at end of file
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>\ No newline at end of file
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