Settings screen #9

Closed
opened 2026-08-01 22:10:27 -04:00 by lyssieth · 1 comment
Owner

The settings surface, per docs/designs/draft/frontend.md. Both blockers landed long ago: the token core (#5) and the config loader's IPC surface (#3).

Status: every checklist item is implemented. frontend/src/lib/Settings.svelte is an inline panel mounted from App.svelte covering Appearance / Providers / Models / Default model / Backups, with full CRUD over providers and models; the provider sections carry #10's work. The first pass was reviewed from a screenshot; the editors added since are verified by build, typecheck, lint, and tests only.

Checklist

  • Settings scaffold: inline progressive disclosure over modals (modals are a last resort) — an <aside> panel beside the chat column; sections are plain <section> blocks, no nested modals
  • Theme section: system / light / dark, written through set_config (#3); takes effect without restart
  • Provider section placeholder — filled by the provider-configuration UI issue; landed as the full Providers + Models + Default-model sections (#10), not a placeholder
  • Full keyboard navigation and visible focus everywhere
  • Structure leaves room to grow (the provider is a setting, not an identity; more sections will follow) — five independent sections under one scroll container; Backups was added after the fact without restructuring
  • Edit an existing provider — every field but the slug
  • Edit an existing model — every field but the key

How the theme item was resolved

Config.ui.theme already existed in the schema (crates/sermones-core/src/config.rs, Theme::System | Light | Dark) and was generated into TS, but nothing on the frontend read or wrote it — the preference lived in localStorage and the only control was a two-way light/dark toggle. Now:

  • Config.ui.theme is the source of truth. theme.ts gained preferenceFromConfig / configTheme for the mapping (an absent field means System); App.svelte adopts the config's value on boot and after a backup restore, overriding the cache.
  • localStorage is demoted to a first-paint cache. The config arrives asynchronously, and main.ts still calls initTheme() before mount so the first paint is not a flash of the wrong scheme. Where the two disagree, the config wins and the cache is rewritten.
  • Three-way control. Settings → Appearance is a segmented radio group (System / Light / Dark). Radios, not buttons: arrow keys walk the group and the whole group is one tab stop. The sidebar's one-click toggle stays, flipping the effective theme — which necessarily commits to an explicit override, since there is no "opposite of system".
  • Applied live, persisted through set_config. Both affordances apply locally first, so the change lands without a round-trip and without a restart. Write failures surface on error_raised like every other fire-and-forget slot (docs/architecture/errors.md).
  • The OS is tracked while on system. Styling needed no listener (tokens.scss follows the media query), but the shell subscribes to prefers-color-scheme anyway so the sidebar's toggle icon does not go stale when the desktop flips mid-session.

One interaction worth recording: theme is the only control in the panel that does not wait for Save, so a draft saved afterwards could have rolled it back. Two guards — onSettingsSave merges the shell's current preference into the config it sends, and the panel's draft no longer tracks the config prop, so a theme write mid-edit cannot wipe unsaved provider/model edits either. The draft is seeded once at mount (the panel is unmounted while closed, so that is "when the user opened it"); a backup restore bumps a configRevision key that remounts the panel, which is the one case where the draft must be re-seeded.

How the keyboard / focus item was resolved

  • aria-modal dropped. The panel claimed role="dialog" aria-modal="true" with no focus trap, no focus-on-open, and no Escape handler — promising a screen reader three things it did not do. It is now an <aside aria-labelledby>, which is what it visually is, and matches this issue's "modals are a last resort".
  • Focus moves in and comes back. The panel takes focus on open; the shell records the opener and restores focus to it on close, so keyboard users are not dumped at the top of the document. Escape closes.
  • Every control is ringed. The old .link buttons (Close, Remove, Restore, the theme toggle) declared background: none; border: 0 and no :focus-visible. They are now primitives.interactive-based icon and secondary buttons, so the ring comes from the shared state matrix. The bare Default-model <select> was unstyled entirely — it now uses primitives.input like the rest.
  • Placeholder-as-label is gone. The add-rows were unlabelled inputs whose placeholder was the only hint — invisible to a screen reader once typing starts. They are <label>-wrapped fields in <form> elements now, which also makes Enter submit the row.
  • A shared visually-hidden mixin landed in primitives.scss for the group legend and the default-model label (MessageView's hand-rolled .sr-only now uses it too).

Editing existing providers and models

Added after the first pass: the panel could add and remove, but not change anything, so fixing a typo'd base URL or renaming a model meant remove-and-re-add — which for a model in use also drops the conversations' selection.

  • Rows expand in place. A pencil toggle on each row opens an editor beneath it (aria-expanded / aria-controls), one at a time, so a section stays a list you can scan. Providers expose display name, kind, base URL, and API key; models expose wire name, display name, context window, max output tokens, and temperature. Edits write straight into the draft — the panel's Save is the commit, Close is the discard, same as add and remove.
  • Keys stay immutable, and the editor says so. A provider's slug and a model's ModelKey are map keys, and the slug is also embedded in every ModelKey ("<slug>|<model_id>"), in each conversation's selected_model, and in the frozen model attribution on every assistant message already in the tree. Renaming one is a cascade across live conversation history, not a settings edit. Each editor states the fixed key with "remove and re-add to rename" rather than leaving the user to wonder.
  • Save now validates the whole draft. Base URL present on every provider; API key present on the kinds that require one; every model resolvable to a provider and carrying a wire name; active_model present in the list. This closes the hole noted in #10set_config stores the config wholesale and ConfigStore::save only serialises, so the panel is the sole enforcement point for ProviderRecord::new's invariant. It also fixes a live bug: the add-form only demanded an API key for OpenAI, but ProviderKind::requires_api_key is true for OpenRouter too.
  • Optional fields empty out properly. A cleared display name or API key normalises to absent rather than "", which the renderers would otherwise show as a blank label.

Two things from the screenshot came along with it:

  • Add-forms moved behind a disclosure button. Two always-open forms dominated the panel; with row editors on top of them it would have been unreadable. Each section now shows a dashed "Add provider" / "Add model" trigger at rest.
  • Placeholders dimmed to --text-disabled. At --text-secondary they read as filled-in values — local and https://api.openai.com/v1 in an empty add-form looked like a configured provider. Scoped to this panel; the composer's placeholder is unchanged.

Follow-ups this did not cover

  • Visual review of the editors and the disclosure-triggered add-forms — the first pass was reviewed from a screenshot, this round has not been.
  • Renaming a slug or model key, which needs the cascade across conversation history described above. Worth its own issue if it comes up.
  • A Tooltip primitive; title= is still the only hint on icon buttons (pre-existing, tracked in docs/components/frontend-ui.md).

Refs

  • frontend/src/lib/Settings.svelte — the panel
  • frontend/src/lib/theme.ts + theme.test.ts — preference ⇄ config mapping, OS resolution
  • frontend/src/App.svelte — theme adoption, save merge, focus restore, restore-remount
  • frontend/src/styles/primitives.scssvisually-hidden, the shared state matrix
  • docs/components/frontend-ui.md — "Theme" and "Settings panel" sections
  • docs/designs/palette.md — decision 5, the override seam
  • #10 — provider configuration (its UI lives in this panel; connection test still open)
The settings surface, per `docs/designs/draft/frontend.md`. Both blockers landed long ago: the token core (#5) and the config loader's IPC surface (#3). **Status:** every checklist item is implemented. `frontend/src/lib/Settings.svelte` is an inline panel mounted from `App.svelte` covering Appearance / Providers / Models / Default model / Backups, with full CRUD over providers and models; the provider sections carry #10's work. The first pass was reviewed from a screenshot; the editors added since are verified by build, typecheck, lint, and tests only. ## Checklist - [x] Settings scaffold: inline progressive disclosure over modals (modals are a last resort) — an `<aside>` panel beside the chat column; sections are plain `<section>` blocks, no nested modals - [x] Theme section: system / light / dark, written through `set_config` (#3); takes effect without restart - [x] Provider section placeholder — filled by the provider-configuration UI issue; landed as the full Providers + Models + Default-model sections (#10), not a placeholder - [x] Full keyboard navigation and visible focus everywhere - [x] Structure leaves room to grow (the provider is a setting, not an identity; more sections will follow) — five independent sections under one scroll container; Backups was added after the fact without restructuring - [x] Edit an existing provider — every field but the slug - [x] Edit an existing model — every field but the key ## How the theme item was resolved `Config.ui.theme` already existed in the schema (`crates/sermones-core/src/config.rs`, `Theme::System | Light | Dark`) and was generated into TS, but nothing on the frontend read or wrote it — the preference lived in `localStorage` and the only control was a two-way light/dark toggle. Now: - **`Config.ui.theme` is the source of truth.** `theme.ts` gained `preferenceFromConfig` / `configTheme` for the mapping (an absent field means `System`); `App.svelte` adopts the config's value on boot and after a backup restore, overriding the cache. - **`localStorage` is demoted to a first-paint cache.** The config arrives asynchronously, and `main.ts` still calls `initTheme()` before mount so the first paint is not a flash of the wrong scheme. Where the two disagree, the config wins and the cache is rewritten. - **Three-way control.** Settings → Appearance is a segmented radio group (System / Light / Dark). Radios, not buttons: arrow keys walk the group and the whole group is one tab stop. The sidebar's one-click toggle stays, flipping the *effective* theme — which necessarily commits to an explicit override, since there is no "opposite of system". - **Applied live, persisted through `set_config`.** Both affordances apply locally first, so the change lands without a round-trip and without a restart. Write failures surface on `error_raised` like every other fire-and-forget slot (`docs/architecture/errors.md`). - **The OS is tracked while on `system`.** Styling needed no listener (`tokens.scss` follows the media query), but the shell subscribes to `prefers-color-scheme` anyway so the sidebar's toggle icon does not go stale when the desktop flips mid-session. One interaction worth recording: theme is the only control in the panel that does not wait for Save, so a draft saved afterwards could have rolled it back. Two guards — `onSettingsSave` merges the shell's current preference into the config it sends, and the panel's `draft` no longer tracks the `config` prop, so a theme write mid-edit cannot wipe unsaved provider/model edits either. The draft is seeded once at mount (the panel is unmounted while closed, so that is "when the user opened it"); a backup restore bumps a `configRevision` key that remounts the panel, which is the one case where the draft must be re-seeded. ## How the keyboard / focus item was resolved - **`aria-modal` dropped.** The panel claimed `role="dialog" aria-modal="true"` with no focus trap, no focus-on-open, and no Escape handler — promising a screen reader three things it did not do. It is now an `<aside aria-labelledby>`, which is what it visually is, and matches this issue's "modals are a last resort". - **Focus moves in and comes back.** The panel takes focus on open; the shell records the opener and restores focus to it on close, so keyboard users are not dumped at the top of the document. Escape closes. - **Every control is ringed.** The old `.link` buttons (Close, Remove, Restore, the theme toggle) declared `background: none; border: 0` and no `:focus-visible`. They are now `primitives.interactive`-based icon and secondary buttons, so the ring comes from the shared state matrix. The bare Default-model `<select>` was unstyled entirely — it now uses `primitives.input` like the rest. - **Placeholder-as-label is gone.** The add-rows were unlabelled inputs whose placeholder was the only hint — invisible to a screen reader once typing starts. They are `<label>`-wrapped fields in `<form>` elements now, which also makes Enter submit the row. - **A shared `visually-hidden` mixin** landed in `primitives.scss` for the group legend and the default-model label (`MessageView`'s hand-rolled `.sr-only` now uses it too). ## Editing existing providers and models Added after the first pass: the panel could add and remove, but not change anything, so fixing a typo'd base URL or renaming a model meant remove-and-re-add — which for a model in use also drops the conversations' selection. - **Rows expand in place.** A pencil toggle on each row opens an editor beneath it (`aria-expanded` / `aria-controls`), one at a time, so a section stays a list you can scan. Providers expose display name, kind, base URL, and API key; models expose wire name, display name, context window, max output tokens, and temperature. Edits write straight into the draft — the panel's Save is the commit, Close is the discard, same as add and remove. - **Keys stay immutable, and the editor says so.** A provider's slug and a model's `ModelKey` are map keys, and the slug is *also* embedded in every `ModelKey` (`"<slug>|<model_id>"`), in each conversation's `selected_model`, and in the frozen `model` attribution on every assistant message already in the tree. Renaming one is a cascade across live conversation history, not a settings edit. Each editor states the fixed key with "remove and re-add to rename" rather than leaving the user to wonder. - **Save now validates the whole draft.** Base URL present on every provider; API key present on the kinds that require one; every model resolvable to a provider and carrying a wire name; `active_model` present in the list. This closes the hole noted in #10 — `set_config` stores the config wholesale and `ConfigStore::save` only serialises, so the panel is the sole enforcement point for `ProviderRecord::new`'s invariant. It also fixes a live bug: the add-form only demanded an API key for `OpenAI`, but `ProviderKind::requires_api_key` is true for `OpenRouter` too. - **Optional fields empty out properly.** A cleared display name or API key normalises to *absent* rather than `""`, which the renderers would otherwise show as a blank label. Two things from the screenshot came along with it: - **Add-forms moved behind a disclosure button.** Two always-open forms dominated the panel; with row editors on top of them it would have been unreadable. Each section now shows a dashed "Add provider" / "Add model" trigger at rest. - **Placeholders dimmed to `--text-disabled`.** At `--text-secondary` they read as filled-in values — `local` and `https://api.openai.com/v1` in an empty add-form looked like a configured provider. Scoped to this panel; the composer's placeholder is unchanged. ## Follow-ups this did not cover - Visual review of the editors and the disclosure-triggered add-forms — the first pass was reviewed from a screenshot, this round has not been. - Renaming a slug or model key, which needs the cascade across conversation history described above. Worth its own issue if it comes up. - A `Tooltip` primitive; `title=` is still the only hint on icon buttons (pre-existing, tracked in `docs/components/frontend-ui.md`). ## Refs - `frontend/src/lib/Settings.svelte` — the panel - `frontend/src/lib/theme.ts` + `theme.test.ts` — preference ⇄ config mapping, OS resolution - `frontend/src/App.svelte` — theme adoption, save merge, focus restore, restore-remount - `frontend/src/styles/primitives.scss` — `visually-hidden`, the shared state matrix - `docs/components/frontend-ui.md` — "Theme" and "Settings panel" sections - `docs/designs/palette.md` — decision 5, the override seam - #10 — provider configuration (its UI lives in this panel; connection test still open)
lyssieth added this to the v1 milestone 2026-08-01 22:10:56 -04:00
Author
Owner

Landed in 608f09e.

All seven checklist items are done: the scaffold, the theme section (now backed by Config.ui.theme rather than localStorage), the provider section, full keyboard navigation and visible focus, room to grow, and editing for existing providers and models.

Reviewed on the desktop across two screenshot rounds — the add-forms moved behind disclosure buttons, placeholders were dimmed so they stop reading as filled-in values, backups became collapsible with their own bounded scroll area, and list_config_backups now projects newest-first.

Two things deliberately left out, both tracked elsewhere:

  • The provider connection test stays on #10 — it needs a new IPC slot and cannot be exercised without a reachable endpoint.
  • Renaming a slug or model key, which is a cascade across ModelKey, each conversation's selected_model, and the frozen model attribution on assistant messages already in the tree. Worth its own issue if it ever comes up; remove-and-re-add covers it for now.
Landed in 608f09e. All seven checklist items are done: the scaffold, the theme section (now backed by `Config.ui.theme` rather than `localStorage`), the provider section, full keyboard navigation and visible focus, room to grow, and editing for existing providers and models. Reviewed on the desktop across two screenshot rounds — the add-forms moved behind disclosure buttons, placeholders were dimmed so they stop reading as filled-in values, backups became collapsible with their own bounded scroll area, and `list_config_backups` now projects newest-first. Two things deliberately left out, both tracked elsewhere: - The provider connection test stays on #10 — it needs a new IPC slot and cannot be exercised without a reachable endpoint. - Renaming a slug or model key, which is a cascade across `ModelKey`, each conversation's `selected_model`, and the frozen `model` attribution on assistant messages already in the tree. Worth its own issue if it ever comes up; remove-and-re-add covers it for now.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Reference
lyssieth/sermones#9
No description provided.