Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/dwarves

dwarves-gui/src/settings.rs

//! **The Settings tab** — the common settings pane for the dwarves operator shell:
//! the active workspace, the **fat/thin** backend (embedded vs `--server` remote),
//! the theme, and vault-unlock.
//!
//! ## Reuse-first (L5)
//! There is no shared `facett-settings` crate (checked — it does not exist), so this
//! pane is COMPOSED from existing shared primitives rather than a twin:
//! * the theme control drives `facett_core::look::Theme` — the SAME serde-round-tripping
//!   theme model (light/dark presets) the whole facett suite uses; picking a preset calls
//!   `Theme::apply(ctx)` (no bespoke palette here);
//! * the active workspace mirrors the ONE selection the warehouse selector broadcasts
//!   (`{"set_workspace":…}`), so Settings never invents a second source of truth;
//! * vault-unlock is a passphrase gate over the [`crate::profile`] vault seam (names
//!   only, never values).
//!
//! [`SettingsConfig`] is a plain serde struct — it ROUND-TRIPS to/from JSON, which is
//! what the gate asserts (`state_json` out == what `update_json` set).
//!
//! ## The backend control offers only modes this BINARY can enter
//! The fat/thin radio pair used to be drawn unconditionally, so a build with no
//! embedded engine offered `Embedded (fat)` — an impossible state drawn identically
//! to a possible one. It is now driven by [`HAS_EMBEDDED_ENGINE`]:
//! * a **fat** build (`dupes-fat`, i.e. the `dwarves-solo` atom) genuinely has
//!   both — it carries the engine *and* can gRPC another dwarves server — so the
//!   toggle is a real choice and stays;
//! * a **thin** build has one mode, so the toggle is not drawn at all (not greyed,
//!   not tooltipped) and the mode is STATED instead — an absent control reads as
//!   "nobody has chosen yet", a stated one as "this build can only be thin";
//! * the seam refuses `{"backend":"embedded"}` on a thin build for the same reason,
//!   so the control surface and the render cannot disagree about what exists;
//! * and the pane says what thin COSTS ([`THIN_COSTS`]) — a fact that lived only in a
//!   `build-variants.toml` comment.

use facett_core::Facet;

/// **Does THIS binary carry the embedded engine at all?**
///
/// The embedded (fat) stack of the demo shell is the LOCAL warehouse read leg —
/// `dwarves::dupes::local`, which links arrow + parquet and opens nornir's Iceberg
/// tree on disk. It exists in this binary iff the `dupes-fat` feature was compiled
/// in, which since 2026-08-03 is exactly the `dwarves-solo` atom
/// (`.nornir/build-variants.toml`; it was `dwarves-ui-fat`, which left the matrix — a
/// UI that reads the warehouse locally IS the solo shape). The default `dwarves-ui`
/// atom declares `linkage = "thin"`, `embeds = []` and does NOT enable it.
///
/// It is a `const` rather than a `#[cfg]` scattered through the pane so that
/// "which modes exist" is decided in ONE place and can be read by the widget, the
/// control seam and `state_json` alike.
pub const HAS_EMBEDDED_ENGINE: bool = cfg!(feature = "dupes-fat");

/// **What a thin build cannot do**, verbatim from the reason the fat read exists
/// (`.nornir/build-variants.toml`). Rendered in the pane rather than left in a TOML
/// comment: choosing thin is not "the same answer, slower" — it is a *poorer* answer,
/// and the user picking it is entitled to know that before they pick.
const THIN_COSTS: &str = "the thin gRPC seam cannot carry the `embeddings` table (a \
    768-float `list<float32>` per row does not survive a stringified `scan_preview`), \
    so the 🧬 Dupes tab's SEMANTIC tier and leafwatch's semantic leg are UNAVAILABLE — \
    duplicate detection is hash-tiers-only and structurally blind to \"the same thing, \
    written differently\".";

/// Which backend the shell talks to: the **fat** embedded engine (in-process) or a
/// **thin** remote `dwarves-server`/`nornir-server` over gRPC (the `--server` mode).
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Backend {
    /// Fat: the embedded engine, no server needed.
    Embedded,
    /// Thin: a remote server over gRPC (`server_endpoint`).
    Remote,
}

impl Backend {
    fn key(self) -> &'static str {
        match self {
            Backend::Embedded => "embedded",
            Backend::Remote => "remote",
        }
    }
    fn parse(s: &str) -> Option<Self> {
        match s.trim().to_ascii_lowercase().as_str() {
            "embedded" | "fat" => Some(Backend::Embedded),
            "remote" | "thin" | "server" => Some(Backend::Remote),
            _ => None,
        }
    }
    /// **Can this binary actually ENTER this mode?** `Remote` always; `Embedded` only
    /// when the embedded engine was compiled in ([`HAS_EMBEDDED_ENGINE`]). A mode the
    /// binary cannot enter is never offered and never accepted over the seam.
    pub fn is_available(self) -> bool {
        match self {
            Backend::Embedded => HAS_EMBEDDED_ENGINE,
            Backend::Remote => true,
        }
    }
}

/// The round-tripping settings model (serde) — the pane's whole persisted state.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct SettingsConfig {
    /// Fat (embedded) vs thin (remote server).
    pub backend: Backend,
    /// The remote server endpoint used when `backend == Remote`.
    pub server_endpoint: String,
    /// The active `facett_core::look::Theme` preset name.
    pub theme: String,
    /// Whether the credential vault is currently unlocked (a session gate; NEVER a
    /// passphrase — the passphrase is never stored in the config).
    pub vault_unlocked: bool,
}

/// What an unlock attempt answers in a build WITHOUT the `vault` feature.
#[cfg_attr(feature = "vault", allow(dead_code))]
pub const NO_VAULT_NOTE: &str =
    "there is no vault in this build (feature `vault`): nothing was unlocked";

impl Default for SettingsConfig {
    fn default() -> Self {
        Self {
            // THIN BY DEFAULT — the same rule `main.rs` already applies to the process
            // ("dwarves UI skall alltid default vara thin"). It used to default to
            // `Embedded`, which on a build with no embedded engine is a state the
            // binary cannot enter, chosen for the user before they touched anything.
            backend: Backend::Remote,
            server_endpoint: dwarves_embed::DEFAULT_ENDPOINT.to_string(),
            theme: "nordisk-dark".to_string(),
            vault_unlocked: false,
        }
    }
}

/// The Settings pane. Owns the [`SettingsConfig`] plus the mirror of the active
/// workspace + the registered-workspace list (for the workspace-select control).
pub struct SettingsPane {
    cfg: SettingsConfig,
    /// The active workspace (mirrors the selector's broadcast; `""` = none).
    active_workspace: String,
    /// The registered workspaces (the picker options — seeded from the live registry).
    workspaces: Vec<String>,
    /// A transient passphrase buffer for the vault-unlock field (never persisted).
    passphrase: String,
    /// A transient note under the vault control (e.g. a wrong-passphrase error). Never
    /// persisted, never a secret.
    vault_note: String,
    /// A transient note under the backend control — set when something asked this
    /// binary for a mode it cannot enter. Never persisted, never a secret.
    backend_note: String,
    /// A transient buffer for the Claude/Anthropic key field. Consumed by
    /// [`SettingsPane::do_save_claude_key`] and cleared immediately — NEVER persisted,
    /// NEVER rendered back, NEVER in `state_json`.
    #[cfg(feature = "claude-token")]
    claude_key: String,
    /// A transient note under the Claude control (a save/clear outcome). Never a secret.
    claude_note: String,
    /// Whether the last applied theme was actually installed on the egui ctx this run
    /// (so `ui` only re-applies on change).
    applied_theme: Option<String>,
}

impl SettingsPane {
    /// Build the pane with the default config and the registered-workspace options.
    pub fn new(workspaces: Vec<String>, active_workspace: impl Into<String>) -> Self {
        Self {
            cfg: SettingsConfig::default(),
            active_workspace: active_workspace.into(),
            workspaces,
            passphrase: String::new(),
            vault_note: String::new(),
            backend_note: String::new(),
            #[cfg(feature = "claude-token")]
            claude_key: String::new(),
            claude_note: String::new(),
            applied_theme: None,
        }
    }

    /// Show the endpoint the shell ACTUALLY reads from (what `--server` /
    /// `$DWARVES_SERVER` resolved to at start), instead of the compiled default.
    pub fn with_server_endpoint(mut self, endpoint: impl Into<String>) -> Self {
        self.cfg.server_endpoint = endpoint.into();
        self
    }

    // ── the Claude/Anthropic credential ──────────────────────────────────────
    //
    // This is WHERE THE CLAUDE TOKEN IS CONFIGURED. The key is sealed in the
    // dwarves vault by `dwarves::claude_token` (through the same
    // `VaultTokenStore` the OIDC login uses) and read back by the Claude/funnel
    // transports as `nornir_claude::resolve_credential`'s session key. The pane
    // only ever holds the value in the transient `claude_key` buffer, which is
    // cleared the moment it is handed to the vault.

    /// The credential status shown in the UI — a SOURCE label and booleans, never
    /// key material. With `claude-token` it reflects the real ladder (env →
    /// vault); without it, the env rungs only (so the control is still honest
    /// about what a default build can see).
    #[cfg(feature = "claude-token")]
    fn claude_status(&self) -> dwarves::claude_token::CredentialStatus {
        dwarves::claude_token::status()
    }

    /// Store the buffered key in the vault, then clear the buffer.
    #[cfg(feature = "claude-token")]
    fn do_save_claude_key(&mut self) {
        let store = dwarves::claude_token::default_store();
        self.claude_note = match dwarves::claude_token::save_key(&store, &self.claude_key) {
            // Deliberately no echo of the value — just that it landed.
            Ok(()) => "saved — sealed in the vault".to_string(),
            Err(e) => format!("save failed: {e}"),
        };
        self.claude_key.clear();
    }

    /// Remove the stored key from the vault.
    #[cfg(feature = "claude-token")]
    fn do_clear_claude_key(&mut self) {
        let store = dwarves::claude_token::default_store();
        self.claude_note = match dwarves::claude_token::clear_key(&store) {
            Ok(()) => "removed from the vault".to_string(),
            Err(e) => format!("remove failed: {e}"),
        };
        self.claude_key.clear();
    }

    /// The env-only fallback for a build without `claude-token`: the two env rungs
    /// are all such a build can consult, and it can store nothing.
    #[cfg(not(feature = "claude-token"))]
    fn claude_source_key(&self) -> &'static str {
        let set = |k: &str| {
            std::env::var(k)
                .map(|v| !v.trim().is_empty())
                .unwrap_or(false)
        };
        if set("ANTHROPIC_API_KEY") {
            "env:ANTHROPIC_API_KEY"
        } else if set("ANTHROPIC_AUTH_TOKEN") {
            "env:ANTHROPIC_AUTH_TOKEN"
        } else {
            "none"
        }
    }

    // ── cfg-agnostic status accessors (what `state_json` publishes) ──────────
    //
    // Feature-independent so the robot seam has the SAME key set in every build:
    // a scenario asserts on `claude_source` without knowing how the demo was
    // compiled. None of these can return key material.

    /// The resolved credential source as a stable wire token.
    fn claude_source_token(&self) -> &'static str {
        #[cfg(feature = "claude-token")]
        {
            self.claude_status().source.as_str()
        }
        #[cfg(not(feature = "claude-token"))]
        {
            self.claude_source_key()
        }
    }

    /// Is a key stored in the vault? Always `false` without `claude-token`
    /// (such a build has no vault-backed Claude key at all).
    fn claude_in_vault(&self) -> bool {
        #[cfg(feature = "claude-token")]
        {
            self.claude_status().in_vault
        }
        #[cfg(not(feature = "claude-token"))]
        {
            false
        }
    }

    /// Could the vault be read? `true` without `claude-token` — nothing to read,
    /// so there is no lock to report.
    fn claude_vault_readable(&self) -> bool {
        #[cfg(feature = "claude-token")]
        {
            self.claude_status().vault_readable
        }
        #[cfg(not(feature = "claude-token"))]
        {
            true
        }
    }

    /// Unlock the vault with the buffered passphrase. With the `vault` feature this
    /// drives the REAL `dwarves::vault::Session` (the same session the Profile tab's
    /// `DwarvesVault` + OIDC `VaultTokenStore` read) — a wrong passphrase is rejected and
    /// leaves the vault locked. Without the feature there is no vault: it stays locked
    /// and says why ([`NO_VAULT_NOTE`]). The passphrase is consumed here, NEVER stored
    /// in the config.
    fn do_unlock(&mut self) {
        #[cfg(feature = "vault")]
        {
            let v = dwarves::vault::ZnippyVault::default_vault();
            match dwarves::vault::Session::unlock(&v, self.passphrase.as_bytes(), 3600) {
                Ok(()) => {
                    self.cfg.vault_unlocked = true;
                    self.vault_note.clear();
                }
                Err(e) => {
                    self.cfg.vault_unlocked = false;
                    self.vault_note = format!("unlock failed: {e}");
                }
            }
        }
        // No `vault` feature ⇒ there is NO vault in this build: nothing is unlocked, and
        // the pane says so. (It used to flip the flag to "unlocked" for any passphrase.)
        #[cfg(not(feature = "vault"))]
        {
            self.cfg.vault_unlocked = false;
            self.vault_note = NO_VAULT_NOTE.to_string();
        }
        self.passphrase.clear();
    }

    /// Lock the vault — clears the real `Session` (vault feature) and the demo gate.
    fn do_lock(&mut self) {
        #[cfg(feature = "vault")]
        {
            let _ = dwarves::vault::Session::lock();
        }
        self.cfg.vault_unlocked = false;
        self.passphrase.clear();
        self.vault_note.clear();
    }

    /// The current config (for the host / tests).
    pub fn config(&self) -> &SettingsConfig {
        &self.cfg
    }

    /// The one sentence said whenever something asks a thin binary to go fat.
    fn no_embedded_reason() -> &'static str {
        "this build carries no embedded engine (`dupes-fat` was not compiled in) — \
         staying THIN; build the `dwarves-solo` atom (the UI with its server compiled in) for the fat mode"
    }

    /// **Which rung of the chain supplies the server bearer** — a SOURCE label, never
    /// the token itself. HOST-ONLY: the UI authenticates to exactly ONE server, the
    /// dwarves forge (`:7900`). It does NOT dial nornir (`:7878`) and so carries no
    /// `NORNIR_SERVER_TOKEN`; "none" is an honest answer (an open server needs no
    /// bearer).
    ///
    /// ⚠ It used to check ONLY the environment variable, so a shell whose bearer came
    /// from `~/.dwarves/token` was labelled *"none (unauthenticated)"* while the client
    /// was in fact authenticating perfectly well — a Settings pane reporting the
    /// opposite of the truth. It now asks
    /// [`dwarves_embed::ClientCredential`](dwarves_embed::ClientCredential), the ONE
    /// client resolver, which names the rung that answered.
    fn server_token_source() -> String {
        dwarves_embed::ClientCredential::resolve().provenance()
    }

    /// Apply the selected theme preset to the egui context (reused `look::Theme`), once
    /// per change. Unknown names fall back to the default theme rather than panicking.
    fn apply_theme(&mut self, ctx: &egui::Context) {
        if self.applied_theme.as_deref() == Some(self.cfg.theme.as_str()) {
            return;
        }
        let theme = facett_core::look::Theme::by_name(&self.cfg.theme).unwrap_or_default();
        theme.apply(ctx);
        self.applied_theme = Some(self.cfg.theme.clone());
    }
}

impl Facet for SettingsPane {
    fn title(&self) -> &str {
        "Settings"
    }

    fn ui(&mut self, ui: &mut egui::Ui) {
        self.apply_theme(ui.ctx());

        ui.heading("Settings");
        ui.separator();

        // ── Workspace ────────────────────────────────────────────────────────────
        ui.label("Active workspace (set from the top-bar selector):");
        ui.horizontal(|ui| {
            let shown = if self.active_workspace.is_empty() {
                "⚠ none — pick one in the header".to_string()
            } else {
                self.active_workspace.clone()
            };
            ui.monospace(shown);
        });
        if !self.workspaces.is_empty() {
            ui.label(format!("registered: {}", self.workspaces.join(", ")));
        }
        ui.separator();

        // ── Backend: fat (embedded) vs thin (remote) ─────────────────────────────
        //
        // A CHOICE only where a choice exists. A fat build genuinely has both — it
        // carries the embedded engine AND can gRPC against another dwarves server, so
        // "fat" means *carries the engine*, not *must use it*, and the toggle is real.
        // A thin build has exactly one mode, so the toggle is NOT drawn — not greyed,
        // not behind a tooltip, gone. What is drawn instead is the mode as a STATED
        // FACT, because an absent control reads as "nobody has chosen yet" while a
        // stated one reads as "this build can only be thin".
        ui.label("Backend:");
        if HAS_EMBEDDED_ENGINE {
            ui.weak("FAT build — the embedded engine is compiled in; both modes are reachable.");
            ui.horizontal(|ui| {
                ui.radio_value(&mut self.cfg.backend, Backend::Embedded, "Embedded (fat)");
                ui.radio_value(
                    &mut self.cfg.backend,
                    Backend::Remote,
                    "Remote (thin · --server)",
                );
            });
        } else {
            ui.horizontal(|ui| {
                ui.strong("THIN");
                ui.label("— this build carries no embedded engine");
            });
            ui.weak(
                "not a setting: the embedded warehouse read leg (`dupes-fat`) was not \
                 compiled into this binary, so `Embedded (fat)` is not a mode it can \
                 enter. Build the `dwarves-solo` atom to get the choice.",
            );
        }
        // WHAT THIN COSTS — surfaced wherever thin is what you are running, because
        // the price is a *poorer answer*, not a slower one.
        if self.cfg.backend == Backend::Remote {
            ui.colored_label(egui::Color32::from_rgb(0xe0, 0xa8, 0x00), THIN_COSTS);
        }
        if !self.backend_note.is_empty() {
            ui.colored_label(
                egui::Color32::from_rgb(0xd9, 0x53, 0x4f),
                &self.backend_note,
            );
        }
        // Thin is not *whether* but *which*: endpoint, token, workspace. The endpoint
        // is the one the panes read from — fixed when the UI starts, so it is SHOWN, not
        // edited (the field used to hold the compiled default and accept edits that
        // changed nothing). The token is reported by SOURCE only (never a value) and
        // the workspace is the ONE selection mirrored from the header, above.
        if self.cfg.backend == Backend::Remote {
            ui.horizontal(|ui| {
                ui.label("server:");
                ui.monospace(&self.cfg.server_endpoint);
                ui.weak("(set at start: --server / $DWARVES_SERVER)");
            });
            ui.horizontal(|ui| {
                ui.label("token:");
                ui.monospace(Self::server_token_source());
            });
            ui.horizontal(|ui| {
                ui.label("workspace:");
                ui.monospace(if self.active_workspace.is_empty() {
                    "⚠ none — pick one in the header"
                } else {
                    self.active_workspace.as_str()
                });
            });
        }
        ui.separator();

        // ── Theme (reused facett_core::look presets) ─────────────────────────────
        ui.label("Theme:");
        egui::ComboBox::from_id_salt("dwarves_settings_theme")
            .selected_text(self.cfg.theme.clone())
            .show_ui(ui, |ui| {
                for name in facett_core::look::Theme::preset_names() {
                    ui.selectable_value(&mut self.cfg.theme, name.clone(), name);
                }
            });
        ui.separator();

        // ── Vault unlock (passphrase gate; names only, never values) ─────────────
        ui.label("Vault:");
        ui.horizontal(|ui| {
            if self.cfg.vault_unlocked {
                ui.label("🔓 unlocked");
                if ui.button("Lock").clicked() {
                    self.do_lock();
                }
            } else {
                ui.add(
                    egui::TextEdit::singleline(&mut self.passphrase)
                        .password(true)
                        .hint_text("passphrase"),
                );
                if ui
                    .add_enabled(!self.passphrase.is_empty(), egui::Button::new("Unlock"))
                    .clicked()
                {
                    // The passphrase gates the real session; it is NEVER stored.
                    self.do_unlock();
                }
            }
        });
        if !self.vault_note.is_empty() {
            ui.colored_label(egui::Color32::from_rgb(0xd9, 0x53, 0x4f), &self.vault_note);
        }
        ui.separator();

        // ── Claude / Anthropic credential (WHERE THE TOKEN IS CONFIGURED) ────────
        //
        // Rendered in EVERY build so the setting is discoverable — a default build
        // shows the resolved source and says what it would take to store one, rather
        // than leaving the user hunting for a config that appears to not exist.
        ui.label("Claude (Anthropic) API key:");
        #[cfg(feature = "claude-token")]
        {
            let st = self.claude_status();
            ui.horizontal(|ui| {
                let (dot, colour) = if st.is_configured() {
                    ("●", egui::Color32::from_rgb(0x4c, 0xaf, 0x50))
                } else {
                    ("○", egui::Color32::from_rgb(0xd9, 0x53, 0x4f))
                };
                ui.colored_label(colour, dot);
                ui.label(st.source.describe());
            });
            if !st.vault_readable {
                ui.colored_label(
                    egui::Color32::from_rgb(0xe0, 0xa8, 0x00),
                    "vault is locked — unlock it above to read or store a key",
                );
            }
            ui.horizontal(|ui| {
                // The value is masked on entry and never rendered back.
                ui.add(
                    egui::TextEdit::singleline(&mut self.claude_key)
                        .password(true)
                        .hint_text("sk-ant-…"),
                );
                if ui
                    .add_enabled(
                        !self.claude_key.trim().is_empty(),
                        egui::Button::new("Save"),
                    )
                    .clicked()
                {
                    self.do_save_claude_key();
                }
                if st.in_vault && ui.button("Remove").clicked() {
                    self.do_clear_claude_key();
                }
            });
            if st.source == dwarves::claude_token::CredentialSource::Vault {
                ui.weak("stored in the vault as the `anthropic` secret");
            } else if st.in_vault {
                ui.weak("a key is stored here, but an environment variable takes precedence");
            }
        }
        #[cfg(not(feature = "claude-token"))]
        {
            ui.horizontal(|ui| {
                let src = self.claude_source_key();
                let configured = src != "none";
                let (dot, colour) = if configured {
                    ("●", egui::Color32::from_rgb(0x4c, 0xaf, 0x50))
                } else {
                    ("○", egui::Color32::from_rgb(0xd9, 0x53, 0x4f))
                };
                ui.colored_label(colour, dot);
                ui.label(if configured {
                    src
                } else {
                    "no Claude credential configured"
                });
            });
            ui.weak(
                "this build reads the key from ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN only — \
                 rebuild with `--features claude-token` to store one in the vault",
            );
        }
        if !self.claude_note.is_empty() {
            ui.weak(&self.claude_note);
        }
    }

    fn state_json(&self) -> serde_json::Value {
        serde_json::json!({
            "kind": "settings",
            "backend": self.cfg.backend.key(),
            "server_endpoint": self.cfg.server_endpoint,
            "theme": self.cfg.theme,
            // Which modes this BINARY can enter — the same fact the widget draws from,
            // so a robot can assert that a thin build never offers the fat mode.
            "has_embedded_engine": HAS_EMBEDDED_ENGINE,
            "backend_choices": if HAS_EMBEDDED_ENGINE {
                vec!["embedded", "remote"]
            } else {
                vec!["remote"]
            },
            "backend_note": self.backend_note,
            "server_token_source": Self::server_token_source(),
            "vault_unlocked": self.cfg.vault_unlocked,
            "vault_note": self.vault_note,
            "active_workspace": self.active_workspace,
            "workspaces": self.workspaces,
            // The Claude credential, as OBSERVABLE STATUS ONLY. The key itself is
            // never here, never in `config`, and therefore never in a snapshot or a
            // persisted state file — `claude_key` is a transient buffer cleared on
            // save. What rides is a source label + booleans.
            "claude_source": self.claude_source_token(),
            "claude_configured": self.claude_source_token() != "none",
            "claude_in_vault": self.claude_in_vault(),
            "claude_vault_readable": self.claude_vault_readable(),
            "claude_note": self.claude_note,
            // The full round-tripping config (what a persist layer serializes).
            "config": self.cfg,
        })
    }

    fn update_json(&mut self, msg_json: &str) {
        let Ok(v) = serde_json::from_str::<serde_json::Value>(msg_json) else {
            return;
        };
        // The selector's broadcast — track the active workspace (never a 2nd source).
        if let Some(ws) = v.get("set_workspace").and_then(|t| t.as_str()) {
            self.active_workspace = ws.to_string();
            return;
        }
        // A whole-config round-trip (persist restore). A config persisted by a FAT
        // build and reloaded by a THIN one carries `backend = embedded`, which this
        // binary cannot enter — so the restore is clamped rather than obeyed, and it
        // says so instead of silently sitting in an impossible state.
        if let Some(cfg) = v.get("config") {
            if let Ok(mut cfg) = serde_json::from_value::<SettingsConfig>(cfg.clone()) {
                if !cfg.backend.is_available() {
                    cfg.backend = Backend::Remote;
                    self.backend_note = format!(
                        "restored config asked for `embedded`; {}",
                        Self::no_embedded_reason()
                    );
                } else {
                    self.backend_note.clear();
                }
                self.cfg = cfg;
                return;
            }
        }
        // Individual controls (the same edits the widgets make). A mode this binary
        // cannot enter is REFUSED at the seam, exactly as the widget refuses to draw
        // it — the control surface and the render must not disagree about what exists.
        if let Some(b) = v
            .get("backend")
            .and_then(|t| t.as_str())
            .and_then(Backend::parse)
        {
            if b.is_available() {
                self.cfg.backend = b;
                self.backend_note.clear();
            } else {
                self.backend_note = Self::no_embedded_reason().to_string();
            }
        }
        if let Some(e) = v.get("server_endpoint").and_then(|t| t.as_str()) {
            self.cfg.server_endpoint = e.to_string();
        }
        if let Some(t) = v.get("theme").and_then(|t| t.as_str()) {
            self.cfg.theme = t.to_string();
        }
        // `vault_unlock` is the persisted config GATE (a bool restore), NOT a real
        // unlock — persist-reload sets it directly. A REAL unlock is driven headlessly
        // via `{"unlock":"<passphrase>"}` (drives `dwarves::vault::Session`), and lock
        // via `{"lock":true}`.
        if let Some(u) = v.get("vault_unlock").and_then(|t| t.as_bool()) {
            self.cfg.vault_unlocked = u;
        }
        if let Some(pw) = v.get("unlock").and_then(|t| t.as_str()) {
            self.passphrase = pw.to_string();
            self.do_unlock();
        }
        if v.get("lock").and_then(|t| t.as_bool()) == Some(true) {
            self.do_lock();
        }
        // The Claude credential: `{"claude_key":"sk-ant-…"}` stores it (the same path
        // the Save button drives), `{"clear_claude_key":true}` removes it. The value
        // is consumed straight into the vault and the buffer cleared — it is never
        // echoed back through `state_json`.
        if let Some(k) = v.get("claude_key").and_then(|t| t.as_str()) {
            #[cfg(feature = "claude-token")]
            {
                self.claude_key = k.to_string();
                self.do_save_claude_key();
            }
            #[cfg(not(feature = "claude-token"))]
            {
                let _ = k;
                self.claude_note =
                    "this build cannot store a key — rebuild with `--features claude-token`"
                        .to_string();
            }
        }
        if v.get("clear_claude_key").and_then(|t| t.as_bool()) == Some(true) {
            #[cfg(feature = "claude-token")]
            {
                self.do_clear_claude_key();
            }
            #[cfg(not(feature = "claude-token"))]
            {
                self.claude_note = "nothing stored in this build".to_string();
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// RED-when-broken (the GATE): the Settings config ROUND-TRIPS over the seam —
    /// setting backend/theme/endpoint/vault via `update_json` shows up in `state_json`,
    /// and the serialized `config` deserializes back to the same struct.
    #[test]
    fn settings_config_round_trips_over_the_seam() {
        let mut p = SettingsPane::new(vec!["nordisk".into(), "skade".into()], "nordisk");

        // Defaults: THIN/remote (the law: a UI defaults thin), dark theme, vault locked.
        let s = p.state_json();
        assert_eq!(s["kind"], "settings");
        assert_eq!(s["backend"], "remote");
        assert_eq!(s["vault_unlocked"], false);
        assert_eq!(s["active_workspace"], "nordisk");

        // Flip to thin/remote + a light theme + unlock the vault, all over the seam.
        p.update_json(r#"{"backend":"remote"}"#);
        p.update_json(r#"{"server_endpoint":"http://127.0.0.1:7900"}"#);
        p.update_json(r#"{"theme":"nordisk-light"}"#);
        p.update_json(r#"{"vault_unlock":true}"#);
        let s = p.state_json();
        assert_eq!(s["backend"], "remote");
        assert_eq!(s["server_endpoint"], "http://127.0.0.1:7900");
        assert_eq!(s["theme"], "nordisk-light");
        assert_eq!(s["vault_unlocked"], true);

        // The whole config serializes and deserializes back byte-identically.
        let cfg_json = s["config"].clone();
        let restored: SettingsConfig = serde_json::from_value(cfg_json).unwrap();
        assert_eq!(restored, *p.config());

        // A whole-config restore over the seam replaces the state (persist reload).
        let mut p2 = SettingsPane::new(vec![], "");
        p2.update_json(&serde_json::json!({ "config": restored }).to_string());
        assert_eq!(p2.state_json()["backend"], "remote");
        assert_eq!(p2.state_json()["theme"], "nordisk-light");
        assert_eq!(p2.state_json()["vault_unlocked"], true);
    }

    /// The active workspace mirrors the selector's broadcast — never a second source of
    /// truth. RED-when-broken if Settings stopped tracking `set_workspace`.
    #[test]
    fn settings_tracks_the_selected_workspace() {
        let mut p = SettingsPane::new(vec!["nordisk".into(), "skade".into()], "");
        assert_eq!(p.state_json()["active_workspace"], "");
        p.update_json(r#"{"set_workspace":"skade"}"#);
        assert_eq!(p.state_json()["active_workspace"], "skade");
    }

    /// ROBOT SCENARIO (task #69 item 4): Settings' Unlock drives the REAL
    /// `dwarves::vault::Session` — a WRONG passphrase is rejected (stays locked, surfaces
    /// the error), the RIGHT one unlocks (the session key the Profile tab's `DwarvesVault`
    /// + OIDC `VaultTokenStore` read), and Lock clears it. Only compiled with
    /// `--features vault`; driven headless over `update_json`/`state_json`.
    #[cfg(feature = "vault")]
    #[test]
    fn robot_settings_drives_real_vault_unlock() {
        use dwarves::vault::{Session, ZnippyVault};
        // The ONE shared crate-level env lock (never a per-module twin — L5).
        let _g = crate::ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());

        let dir = tempfile::tempdir().unwrap();
        let sess = dir.path().join("vault.session");
        let vpath = dir.path().join("vault.bin");
        // SAFETY: single-threaded under ENV; isolate BOTH the session and the default
        // vault path (HOME) to this dir so the real `~/.dwarves` is never touched.
        let prev_home = std::env::var("HOME").ok();
        unsafe {
            std::env::set_var("DWARVES_VAULT_SESSION", &sess);
            std::env::set_var("HOME", dir.path());
        }
        // Seed a vault at the default path (HOME/.dwarves/vault.bin).
        std::fs::create_dir_all(dir.path().join(".dwarves")).unwrap();
        let v = ZnippyVault::at(&vpath);
        let _ = &v; // the pane opens the DEFAULT vault; seed THAT one:
        let dv = ZnippyVault::default_vault();
        dv.add(b"correct-pw", "crates-io", "crates-io-token", b"cio_X")
            .unwrap();

        let mut p = SettingsPane::new(vec!["nordisk".into()], "nordisk");
        assert_eq!(p.state_json()["vault_unlocked"], false);

        // Wrong passphrase → rejected, stays locked, error surfaced.
        p.update_json(r#"{"unlock":"WRONG"}"#);
        let s = p.state_json();
        assert_eq!(
            s["vault_unlocked"], false,
            "wrong passphrase must not unlock: {s}"
        );
        assert!(s["vault_note"]
            .as_str()
            .unwrap_or_default()
            .contains("unlock failed"));
        assert!(
            Session::key_for_path(dv.path()).is_none(),
            "no session cached on a bad unlock"
        );

        // Right passphrase → the REAL session unlocks.
        p.update_json(r#"{"unlock":"correct-pw"}"#);
        let s = p.state_json();
        let unlocked = s["vault_unlocked"] == true;
        assert!(
            unlocked,
            "the correct passphrase unlocks the real vault: {s}"
        );
        assert!(
            Session::key_for_path(dv.path()).is_some(),
            "a real session key is cached"
        );

        // Lock clears the real session.
        p.update_json(r#"{"lock":true}"#);
        assert_eq!(p.state_json()["vault_unlocked"], false);
        assert!(
            Session::key_for_path(dv.path()).is_none(),
            "lock clears the real session"
        );

        unsafe {
            std::env::remove_var("DWARVES_VAULT_SESSION");
            match &prev_home {
                Some(h) => std::env::set_var("HOME", h),
                None => std::env::remove_var("HOME"),
            }
        }

        #[cfg(feature = "testmatrix")]
        nornir_testmatrix::functional_status(
            "dwarves-gui",
            "settings_drives_real_vault_unlock",
            unlocked,
            "Settings' Unlock/Lock drive the REAL dwarves::vault::Session: a wrong passphrase is rejected (stays locked + surfaces the error), the correct one caches the session key the Profile tab reads, and Lock clears it — driven headless, no network",
        );
    }

    /// **RED-when-broken: the fat/thin control never offers a mode the binary cannot
    /// enter.** Renders the REAL widget and reads the AccessKit tree, so it fails if
    /// the radio is merely disabled or hidden behind a tooltip rather than absent —
    /// and it drives the seam too, because a control surface that accepts what the
    /// widget refuses to draw is the same impossible state by another door.
    ///
    /// It asserts the case that matches how it was COMPILED: with `dupes-fat` the
    /// choice is real and both radios must be on screen; without it, `Embedded (fat)`
    /// must not be paintable at all and the pane must instead STATE the mode.
    #[test]
    fn the_backend_control_offers_only_modes_this_binary_can_enter() {
        use egui_kittest::kittest::Queryable;

        let mut p = SettingsPane::new(vec!["nordisk".into()], "nordisk");

        // The seam agrees with the binary about what exists.
        let st = p.state_json();
        assert_eq!(st["has_embedded_engine"], HAS_EMBEDDED_ENGINE);
        if HAS_EMBEDDED_ENGINE {
            assert_eq!(
                st["backend_choices"],
                serde_json::json!(["embedded", "remote"])
            );
        } else {
            assert_eq!(st["backend_choices"], serde_json::json!(["remote"]));
        }

        // Ask for the fat mode over the seam.
        p.update_json(r#"{"backend":"embedded"}"#);
        let st = p.state_json();
        if HAS_EMBEDDED_ENGINE {
            assert_eq!(
                st["backend"], "embedded",
                "a fat build really can go embedded"
            );
            assert_eq!(st["backend_note"], "");
        } else {
            assert_eq!(st["backend"], "remote", "a thin build must stay thin");
            assert!(
                st["backend_note"]
                    .as_str()
                    .unwrap_or_default()
                    .contains("no embedded engine"),
                "…and must SAY why, not silently ignore it: {st}"
            );
        }

        // A whole-config restore (e.g. persisted by a fat build) is clamped the same way.
        p.update_json(r#"{"config":{"backend":"embedded","server_endpoint":"http://x:1","theme":"nordisk-dark","vault_unlocked":false}}"#);
        assert_eq!(
            p.state_json()["backend"],
            if HAS_EMBEDDED_ENGINE {
                "embedded"
            } else {
                "remote"
            },
            "a restored config can never install a mode the binary cannot enter"
        );

        // …and now the RENDER: what is actually paintable.
        let cell = std::cell::RefCell::new(SettingsPane::new(vec!["nordisk".into()], "nordisk"));
        let mut h = egui_kittest::Harness::builder()
            .with_size(egui::vec2(1200.0, 1000.0))
            .build_ui(|ui| {
                Facet::ui(&mut *cell.borrow_mut(), ui);
            });
        h.step();
        h.step();
        let fat_radio_painted = h.query_by_label("Embedded (fat)").is_some();
        assert_eq!(
            fat_radio_painted, HAS_EMBEDDED_ENGINE,
            "the fat radio is painted iff this binary carries the embedded engine"
        );
        if !HAS_EMBEDDED_ENGINE {
            assert!(
                h.query_by_label("THIN").is_some(),
                "an absent control reads as ambiguous — the mode must be STATED"
            );
            assert!(
                h.query_by_label("— this build carries no embedded engine")
                    .is_some(),
                "…and stated in words, not as a bare badge"
            );
        }

        cell_row(
            "settings_backend_offers_only_reachable_modes",
            fat_radio_painted == HAS_EMBEDDED_ENGINE,
            "the fat/thin control is drawn from what the BINARY can do: with dupes-fat compiled in both radios paint and the seam accepts `embedded`; without it the fat radio is absent from the AccessKit tree entirely (not greyed, not tooltipped), the pane STATES `THIN — this build carries no embedded engine`, and the seam refuses `{\"backend\":\"embedded\"}` with a reason instead of installing a mode the binary cannot enter",
        );
    }

    /// **RED-when-broken: a thin build says what thin COSTS.** The price was buried in
    /// a `build-variants.toml` comment; it belongs where the person choosing can read
    /// it, because thin is not a slower answer, it is a poorer one.
    #[test]
    fn a_thin_backend_states_what_it_cannot_answer() {
        use egui_kittest::kittest::Queryable;
        let cell = std::cell::RefCell::new(SettingsPane::new(vec!["nordisk".into()], "nordisk"));
        let mut h = egui_kittest::Harness::builder()
            .with_size(egui::vec2(1200.0, 1400.0))
            .build_ui(|ui| {
                Facet::ui(&mut *cell.borrow_mut(), ui);
            });
        h.step();
        h.step();
        // The default is thin in every build, so this cost line paints in every build.
        let painted = h.query_by_label(THIN_COSTS).is_some();
        assert!(painted, "the thin build must name the tier it cannot serve");
        assert!(
            THIN_COSTS.contains("embeddings"),
            "…naming the table that cannot cross"
        );
        assert!(
            THIN_COSTS.contains("SEMANTIC"),
            "…and the tier that goes dark"
        );

        cell_row(
            "settings_thin_states_its_cost",
            painted,
            "with the thin backend selected the Settings pane paints the cost of that choice — that the gRPC seam cannot carry the `embeddings` table and the Dupes tab's SEMANTIC tier plus leafwatch's semantic leg are therefore UNAVAILABLE — instead of leaving it in a build-variants.toml comment",
        );
    }

    /// Emit a coverage-board cell (reuse edda's testmatrix — never a local harness).
    fn cell_row(name: &str, ok: bool, detail: &str) {
        #[cfg(feature = "testmatrix")]
        nornir_testmatrix::functional_status("dwarves-gui", name, ok, detail);
        let _ = (name, ok, detail);
    }

    /// Every theme the pane offers is a real reused `look::Theme` preset — the picker
    /// can't offer a name `by_name` won't resolve (RED if the preset list drifted).
    #[test]
    fn theme_picker_offers_only_resolvable_presets() {
        for name in facett_core::look::Theme::preset_names() {
            assert!(
                facett_core::look::Theme::by_name(&name).is_some(),
                "preset {name} resolves"
            );
        }
    }
}

/// **Robot-UI coverage for the Claude (Anthropic) API-key control** — the config
/// surface whose absence was the original complaint ("I didn't see the config for the
/// claude token"). Driven through the SHARED `nornir-robotui` framework (egui_kittest /
/// AccessKit — REUSE, never a twinned harness).
///
/// These live here rather than in `tests/` because `settings` is a module of the BINARY
/// target (declared in `main.rs`), not of the library — an integration test cannot reach
/// it. No crate-level feature gate: the discoverability scenario runs in EVERY build (the
/// 2026-07-21 silenced-tests trap), and only the leg that writes a real vault is gated,
/// per-test.
#[cfg(all(test, not(target_arch = "wasm32")))]
mod robotui_claude_token_tests {
    use super::*;
    use facett_core::Facet;
    use nornir_robotui::egui_kittest::Harness;
    use nornir_robotui::{status, Assert, Observable, RobotSession, Scenario, Step};
    use serde_json::json;

    /// The Settings pane as an `Observable` — the robot reads exactly what the pane
    /// renders through `Facet::state_json`, no twinned oracle.
    struct SettingsApp(SettingsPane);
    impl Observable for SettingsApp {
        fn state_json(&self) -> serde_json::Value {
            Facet::state_json(&self.0)
        }
    }

    /// **The Claude token control is REACHABLE in the DEFAULT build.** A user must be
    /// able to FIND it without knowing a build flag — that discoverability is the whole
    /// point of the change, so it is asserted on the AccessKit tree (it really paints),
    /// not merely in `state_json`.
    ///
    /// RED-when-broken: delete the control's label and the visibility step fails.
    #[test]
    fn robot_finds_the_claude_token_control_in_settings() {
        let _env = crate::ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        // Isolate the env so a developer's real ANTHROPIC_* export cannot flip this.
        // SAFETY: single-threaded under ENV_LOCK.
        unsafe {
            std::env::remove_var("ANTHROPIC_API_KEY");
            std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
        }

        let pane = SettingsPane::new(vec!["nordisk".into()], "nordisk");
        let harness = Harness::builder()
            .with_size(nornir_robotui::egui::Vec2::new(1200.0, 1000.0))
            .build_ui_state(
                |ui, app: &mut SettingsApp| Facet::ui(&mut app.0, ui),
                SettingsApp(pane),
            );
        let mut robot = RobotSession::new(harness, "dwarves-gui");
        robot.run(2); // settle the layout so every widget is in the AccessKit tree

        let scenario = Scenario::new("claude-token-control-discoverable")
            .step(
                Step::new("the Claude API-key control is labelled and on screen")
                    .assert(Assert::LabelVisible("Claude (Anthropic) API key:".into())),
            )
            .step(
                Step::new("with nothing configured the pane says so honestly").assert(
                    Assert::State {
                        pointer: "/claude_source".into(),
                        expected: json!("none"),
                    },
                ),
            )
            .step(
                Step::new("…and reports it as not configured").assert(Assert::State {
                    pointer: "/claude_configured".into(),
                    expected: json!(false),
                }),
            );

        let rows = robot.run_scenario(&scenario);
        let all_green = rows.iter().all(|r| r.status == status::PASS);
        assert!(
            all_green,
            "the Claude token control is discoverable in Settings: {:?}",
            rows.iter()
                .map(|r| (&r.test_name, &r.status, &r.message))
                .collect::<Vec<_>>()
        );

        #[cfg(feature = "testmatrix")]
        nornir_testmatrix::functional_status(
            "dwarves-gui",
            "claude_token_control_discoverable",
            all_green,
            "the Settings pane carries a labelled Claude (Anthropic) API-key control in the DEFAULT build, driven through the shared nornir-robotui framework, and reports an honest credential source when none is configured — the config surface whose absence was the original complaint",
        );
    }

    /// **Storing a key flips the reported source to the vault — and no key material ever
    /// rides the seam.** Drives the pane's real storage path with the vault redirected to
    /// a tempdir, so nothing touches `~/.dwarves/vault.bin`.
    ///
    /// RED-when-broken: drop the `claude_key` arm in `update_json` and the source never
    /// leaves `none`; echo the key into `state_json` and the secret-leak assert fails.
    #[cfg(feature = "claude-token")]
    #[test]
    fn robot_stores_a_claude_key_and_the_source_flips_to_the_vault() {
        const SENTINEL: &str = "sk-ant-robot-sentinel-value";

        let _env = crate::ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        let dir = tempfile::tempdir().unwrap();
        // SAFETY: single-threaded under ENV_LOCK; isolates the vault + credential env
        // to this tempdir for the test.
        unsafe {
            std::env::set_var("DWARVES_VAULT_FILE", dir.path().join("vault.bin"));
            std::env::set_var("DWARVES_VAULT_SESSION", dir.path().join("vault.session"));
            std::env::set_var("DWARVES_VAULT_PASSPHRASE", "robot-claude-pw");
            std::env::remove_var("ANTHROPIC_API_KEY");
            std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
        }

        let mut pane = SettingsPane::new(vec!["nordisk".into()], "nordisk");
        assert_eq!(
            Facet::state_json(&pane)["claude_source"],
            "none",
            "nothing yet"
        );

        // Store a key through the SAME path the Save button drives.
        Facet::update_json(&mut pane, &json!({ "claude_key": SENTINEL }).to_string());

        let st = Facet::state_json(&pane);
        assert_eq!(
            st["claude_source"], "vault:anthropic",
            "the vault rung now wins"
        );
        assert_eq!(st["claude_configured"], true);
        assert_eq!(st["claude_in_vault"], true);

        // THE SECRET NEVER RIDES THE SEAM — not the key, not a fragment of it.
        let blob = st.to_string();
        assert!(
            !blob.contains(SENTINEL),
            "the key must never enter state_json: {blob}"
        );
        assert!(
            !blob.contains("sk-ant"),
            "no key-shaped material in state_json: {blob}"
        );

        // …and it is genuinely sealed on disk, not merely stored.
        let on_disk = std::fs::read(dir.path().join("vault.bin")).unwrap();
        assert!(
            !on_disk
                .windows(SENTINEL.len())
                .any(|w| w == SENTINEL.as_bytes()),
            "the key must be encrypted at rest"
        );

        // Removing it returns the pane to the honest unconfigured state.
        Facet::update_json(&mut pane, &json!({ "clear_claude_key": true }).to_string());
        let st = Facet::state_json(&pane);
        assert_eq!(st["claude_source"], "none", "removed ⇒ unconfigured again");
        assert_eq!(st["claude_in_vault"], false);

        // Do not leak the redirected vault path to any later test in this process —
        // `DWARVES_VAULT_FILE` outranks the HOME isolation the journey test relies on.
        // SAFETY: still under ENV_LOCK.
        unsafe {
            std::env::remove_var("DWARVES_VAULT_FILE");
            std::env::remove_var("DWARVES_VAULT_SESSION");
            std::env::remove_var("DWARVES_VAULT_PASSPHRASE");
        }

        #[cfg(feature = "testmatrix")]
        nornir_testmatrix::functional_status(
            "dwarves-gui",
            "claude_token_stored_in_vault_from_settings",
            true,
            "a Claude API key typed into Settings is sealed in the dwarves vault through the shared VaultTokenStore, flips the reported credential source to vault:anthropic, and never appears in state_json or in the vault file as plaintext",
        );
    }
}