---
title: "Settings and the UI"
---

> Documentation Index
> Fetch the complete documentation index at: https://jasonm4130-labs.github.io/ambient/llms.txt
> Use this file to discover all available pages before exploring further.

# Settings and the UI

# Settings and the UI

Ambient's settings live in one JSON file, reachable from a CLI verb and from a
bundled web page rendered inside the app's window. This chapter covers where
that file lives, how it is resolved, how the page is built, and why five
separate binaries exist purely to check that the assembled app is what it
claims to be.

Every setting has code behind it, and nothing is stored that nothing reads. The
full key-by-key list is in [the config reference](../using/settings.md).

They live in `~/Library/Application Support/Ambient/config.json` — deliberately
not under the sessions folder, since that folder is itself a setting and a
config inside the thing it configures cannot be found before it is read.
Precedence runs **environment → CLI flag → config file → default**, so
`AMBIENT_HOME` and `--app` still win and every existing harness is unaffected.
A missing file is an ordinary first run; a corrupt one warns and falls back
rather than failing a recording.

`ambient config` prints the resolved settings and the input devices it can see;
`ambient config <key> <value>` sets one. That exists so every setting is
verifiable without a GUI.

A named input device that has gone away **warns and names the alternatives**
before falling back. Silent fallback is this project's recurring failure and
the one thing a settings layer must not reintroduce.

## The window

`src/window.rs` creates one `NSWindow` whose content view is the `WKWebView`
owned by `SettingsPane`. React renders Sessions, Settings and Welcome inside
that page. AppKit retains the menu bar, activation policy and native file
panels; the bridge forwards recording commands to the app delegate.
See [the UI implementation](ui.md) for routes, components and screenshots.

The page is embedded with `include_str!`: launched through LaunchServices the
working directory is `/`, and that is the only launch with the audio-capture
grant, so a relative path would break the one path that matters.

The bridge carries a **JSON string** each way. `WKScriptMessage::body` otherwise
arrives as an `NSDictionary` that Rust would unpick a value at a time; a string
goes straight to serde. The page never edits its own state — it sends a patch,
Rust merges it onto the file and pushes the whole config back, so the file is
the single source of truth rather than the DOM.

## Building the page — `ui/`

React 19, TypeScript, shadcn/ui and Tailwind v4, bundled by Vite through
`vite-plugin-singlefile` into one self-contained `assets/settings.html`; a
`WKWebView` loaded via `loadHTMLString` has no origin to fetch siblings from,
so nothing may stay external. Linting and formatting are oxlint and oxfmt.

```
cd ui && pnpm install
pnpm run build     # → ../assets/settings.html
pnpm run check     # tsc --noEmit
pnpm run lint      # oxlint
```

The built file is committed, so `cargo build` never needs node. `make-app.sh`
refreshes it only when `ui/node_modules` is present.

The request/response bridge is typed in TypeScript and exercised with a
`FakeBridge` in component tests. Rust dispatches shared methods through
`api::call`; clipboard, native panels and recording actions remain bridge-only.

`cargo run --bin symbolcheck` asks macOS whether each menu bar icon actually
resolves. `imageWithSystemSymbolName` returning nothing leaves the previous
icon in place, so a symbol this OS lacks would show "recording" while merely
armed — silent, and only catchable by asking. It runs in CI, and it caught
`waveform.badge.questionmark` not existing.

`cargo run --release --bin uicheck -- out.png` drives the built page in a real
`WKWebView`, pushes a config in, synthesises every click and prints what
reaches the bridge. Compiling and type-checking prove the page *says* the right
thing; only this proves it does anything.

## Why there are five checking binaries

Each of the five asserts something a compiler cannot: that what the running OS
resolves, or what the running app actually does, is what the code assumed. They
differ in which artefact.

**`src/bin/symbolcheck.rs`** asks `NSImage::imageWithSystemSymbolName` for every
menu bar symbol and exits non-zero if any is missing,
printing "`{missing} symbol(s) will silently leave the wrong icon showing`".
The silent failure it exists to catch is named in its own header: "`set_state`
only sets an image when `imageWithSystemSymbolName` returns one, so a symbol
this OS does not have leaves the previous icon in place — the menu bar would
then say 'recording' while armed, silently. That is this project's recurring
failure mode, so it gets a check rather than a hope." It caught
`waveform.badge.questionmark` not existing on this OS.

It walks `PhaseKind::ALL` rather than a list of its own, because a list here is
a second source of truth and it had already drifted: adding the `Failed` phase
gave the menu a fifth symbol, and this check went on validating four — the
silent wrong-icon failure it exists to catch, in the check itself.

**`src/bin/iconcheck.rs`** takes a built `.app` and an output path, asks
`NSWorkspace::iconForFile` what icon macOS resolves for the bundle, prints the
size and every representation, and writes the result out as a PNG. Its header
states the gap precisely: "The plist key and the file being present prove
neither that the .icns parses nor that LaunchServices picks it up." A wrong or
unparsed icon is not an error anywhere — you simply get the generic one.

**`src/bin/uicheck.rs`** loads the built page into a real `WKWebView` with
canned API replies. It selects a session, copies two export formats, injects a
recording phase, reads back the live card and Settings controls, and changes a
setting. It then scrolls 2,000 sessions, checks the rendered row count and
elapsed work, and saves a light or dark PNG. Missing responses or a failed
assertion produce a nonzero exit code.

**`src/bin/policycheck.rs`** drives the activation-policy flip and prints what
the app becomes. None of it is visible to inspection: whether the process has a
Dock icon is a property of the running process, and both guards on the
demotion are about windows that only exist at run time — `NSApp.windows()`
carries AppKit's own furniture, the status item's bar window and the carrier
behind an open menu, and a guard counting those would mean the app never
demoted at all. It opens the real `MainWindow`, closes it the way ⌘W does so
`windowWillClose:` really runs, and asserts the seven states in its header:
launch is Accessory, showing promotes, closing demotes, a modal session refuses
the demotion, another visible window refuses it too, the refresh tick's retry
demotes once that window goes away, and minimising is not closing.

**`src/bin/windowcheck.rs`** opens the real `MainWindow` with disposable
sessions and the real dispatcher. It asserts that the content view is a
`WKWebView`, loads a transcript, injects capture-worker meter values through
`render`, verifies selection survives the phase change, clicks Stop and checks
that it reaches the app delegate, then opens Settings. It opens no audio device.

`windowcheck` creates its own scratch sessions folder. Give `policycheck` one
with `AMBIENT_HOME=<scratch> cargo run --bin policycheck`.

Two worked examples of the class of bug that only a running check finds.

The menu bar's refresh timer polls twice a second, and the first version
scheduled it the obvious way. `src/menubar.rs` now registers it in the common
run loop modes instead, with the reason in the code: "Added in the common modes
rather than scheduled: `scheduledTimer…` registers only for
NSDefaultRunLoopMode, and AppKit runs the loop in event-tracking mode for as
long as a menu is open. The elapsed time therefore froze exactly while you were
looking at it, and only moved when the menu was closed and reopened." Nothing
errors; the number is simply stale in the one moment anyone reads it.

The settings capture-mode selector keeps the user's current choice separate
from `config.apps`: "Selected apps, none picked yet" and "Everything" both
have an empty list. Deriving the selector from that list would hide Add before
the user could pick their first app. Component tests exercise that transition.

See [ADR-0012](../adr/0012-ci-verifies-assembly.md).

Source: https://jasonm4130-labs.github.io/ambient/developing/settings-and-ui/index.mdx
