diff --git a/DOCS/superpowers/specs/2026-06-14-settings-modal-design.md b/DOCS/superpowers/specs/2026-06-14-settings-modal-design.md new file mode 100644 index 0000000..8afb193 --- /dev/null +++ b/DOCS/superpowers/specs/2026-06-14-settings-modal-design.md @@ -0,0 +1,160 @@ +# Settings Modal — Design Spec + +Date: 2026-06-14 +Status: Approved (design); ready for implementation plan. + +## Goal + +A Settings modal (opened from the existing TopBar gear) that lets the user +configure the terminal font, appearance (Dark/Light + accent), the default +shell, and view/control the daemon. All settings persist in +`~/.spacesh/config.toml` via the daemon — the GUI holds no settings state — and +changes apply live to every connected client through a `config_changed` event. + +This honors the core invariant: the daemon is the single source of truth; the +GUI and CLI are thin clients. Settings are a daemon concern with a GUI view. + +## Out of scope (v1) + +- Per-workspace or per-surface overrides (global config only). +- Keybinding customization. +- Notification settings (deferred to the M5 Telegram/MAX work). +- Arbitrary user themes (only Dark + Light, each with an accent choice). + +## 1. Config model (daemon) + +Extend `crates/spaceshd/src/config.rs`'s `Config` (currently `default_shell` +only) with nested sections, all optional with sane defaults: + +```toml +default_shell = "/bin/zsh" # existing + +[terminal] +font_family = "JetBrains Mono" +font_size = 13 # clamped 10..=20 + +[appearance] +theme = "dark" # "dark" | "light" +accent = "blue" # blue | teal | purple | green | orange +``` + +- `Config` gains `terminal: TerminalConfig` and `appearance: AppearanceConfig` + structs, each `#[serde(default)]` so a partial or missing file still loads. +- Add a **write path** (config.rs currently only reads): `Config::save()` + serializes to `~/.spacesh/config.toml` (toml crate already a dependency). +- An **effective config** accessor returns resolved values (e.g. `default_shell` + goes through the existing env→toml→passwd→$SHELL→/bin/sh resolver; font/theme + fall back to defaults). This is what `GetConfig` returns. + +The daemon holds the loaded `Config` in its server state so reads don't hit disk +each time; `SetConfig` mutates it, persists, and broadcasts. + +## 2. Protocol + +`crates/spacesh-proto/src/message.rs`: + +- `Cmd::GetConfig` → `res` with the effective config (all fields resolved). +- `Cmd::SetConfig { default_shell?, font_family?, font_size?, theme?, accent? }` + — each field `Option`, `None` = no change. Validates (font_size clamp, theme + and accent enums), updates state, writes config.toml, then broadcasts. +- `Evt::ConfigChanged { config }` — full effective config, pushed to all + subscribers so every GUI applies the change live. + +Serialization stays isolated in `spacesh-proto` per the one-protocol invariant. + +## 3. Daemon handlers + +`server.rs`: + +- `GetConfig` → reply with effective config. +- `SetConfig` → apply to in-memory `Config`, `Config::save()`, broadcast + `ConfigChanged`, reply ok. On validation error reply `err("BAD_CONFIG", …)`. +- The default-shell resolver already reads config.toml; once the daemon holds + config in memory, `default_shell()` should consult the in-memory value first + (so a SetConfig change affects *new* surfaces without a disk re-read). + +## 4. Theme: runtime switching (low-churn) + +Components already use `COLORS.x` in inline styles. Keep the keys; change the +values to CSS custom-property references so no component is touched: + +- `theme.ts`: + - `COLORS.textPrimary = "var(--c-text-primary)"` (etc.) — same keys, var() + values. `STATE_COLOR` likewise maps to `var(--c-st-…)`. + - `PALETTES = { dark: {...real}, light: {...real} }` and `ACCENTS = { blue, + teal, purple, green, orange }` with real hex values. + - `applyTheme(theme, accent)` writes the resolved palette to + `document.documentElement.style` as `--c-*` variables (accent overrides the + palette's accent-derived tokens). + - `resolvePalette(theme, accent)` returns a **real-color** object for places + that can't use `var()` (xterm). +- `App.tsx` calls `applyTheme` on first config load and on every + `config_changed`. + +Light palette: define a readable light token set (bg/app/panel/elevated, +borders, text tiers, status colors tuned for light bg). Accent feeds +`--c-accent` and accent-derived tokens (e.g. focus border, search active match). + +## 5. TerminalView + +`TerminalView` must use real colors, not var(), for xterm: + +- Read font_family/font_size and theme/accent from the current config (passed as + props from App, or via a small config context). +- Build the xterm `theme` (background, foreground, cursor, selection, ANSI) from + `resolvePalette(theme, accent)`. +- On `config_changed`: apply `term.options.fontFamily/fontSize/theme` to the + live terminal and re-fit (so size changes reflow correctly). + +## 6. Settings modal (GUI) + +New `Settings.tsx`, opened from the TopBar gear (handler already present as a +no-op/mock). Modal pattern mirrors `Wizard`/`ConfirmDelete` (overlay + focus +trap + Esc). Loads via `getConfig()` on open; each control calls `setConfig` +(text inputs debounced) and relies on the `config_changed` broadcast to apply. + +Sections: + +- **Terminal** — font family dropdown (curated: JetBrains Mono + a few common + monospace families), font size 10–20. +- **Appearance** — Dark/Light segmented toggle; accent swatch row. +- **Shell** — default shell (text input/picker over the resolver; empty = + auto-detect). +- **Daemon** — read-only: version, pid, uptime, sessions/workspaces count, + socket path (from `getHealth`). Buttons **Stop** and **Restart**, each behind + an inline confirm (reuse ConfirmDelete-style confirmation or a small inline + "are you sure"). + +## 7. Daemon Stop / Restart + +- The `Shutdown` command exists; the launchd plist sets `KeepAlive=true`, and the + Tauri bridge has `ensure_daemon` (lazy spawn + poll socket) on connect. +- **Stop**: send `Shutdown`; GUI shows disconnected (existing + `spacesh:disconnected` path). +- **Restart**: send `Shutdown`, then trigger a reconnect; the bridge's + `ensure_daemon` (or launchd KeepAlive) respawns the daemon and the GUI + reattaches. Verify the bridge reconnects automatically after a server-side + disconnect; if not, add an explicit reconnect trigger invoked after Restart. + +## Implementation order + +1. Daemon config model: `TerminalConfig`/`AppearanceConfig`, `Config::save()`, + in-memory config in server state, resolver consults memory. Tests: + round-trip, partial-file defaults, save+reload. +2. Protocol: `GetConfig`, `SetConfig`, `ConfigChanged` + bridge commands + + socketBridge fns + `config_changed` wiring in App. +3. theme.ts CSS-var refactor + `applyTheme`/`resolvePalette` + light palette + + accents; App applies on load and on config_changed. +4. TerminalView: font + xterm theme from config, live re-apply on change. +5. Settings.tsx modal (Terminal/Appearance/Shell sections) wired to the gear. +6. Daemon section + Stop/Restart with confirm and reconnect verification. + +## Risks / notes + +- Light-mode xterm legibility: tune the light ANSI palette on real output. +- `config_changed` must not feedback-loop: applying a broadcast must not re-emit + SetConfig. +- Font dropdown lists families that may not be installed; curated list keeps this + low-risk, but a missing family silently falls back (acceptable). +- Restart depends on respawn (launchd KeepAlive or bridge ensure_daemon); + confirm one path works before shipping the button.