# 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.
