Commit Graph
16 Commits
Author SHA1 Message Date
amosblomqvist f80b1a9d2c Fix wave: scroll-lock without layout shift; every models write mirrors the stored record
Fix 1 (owner: "lock must not shift"): the settings dialog's scroll-lock no
longer toggles the content column to overflow-hidden (which hid the
scrollbar and shifted everything right). The column KEEPS overflow-y-auto —
scrollbar and widths stay exactly as they are — and while any MenuShell is
open, non-passive wheel + touchmove listeners on the column preventDefault
so it cannot scroll (owner-ruled mechanism: the scrollbar may stay,
scrolling must not happen, nothing may move). Native listeners via ref —
React attaches synthetic wheel/touch passively at the root.

Fix 2 (owner repro: xhigh pin survives a GLM swap on screen until reload):
every models-writing surface now mirrors SERVER TRUTH, never the request
body. The PUT already returns the full stored record with clamps applied —
settings-dialog write() and the composer's save() now setRecord/setModels
from the response, so a swap that rides an unsupported level shows the
clamped value in the same interaction: the row trigger, the open menu's
level list + checkmark, and the composer pill all read the stored record.

Live-verified the owner's exact repro: deepseek pinned extra high → GLM
swap via the slot menu → trigger/menu/pill showed the stored clamped level
immediately; and the lock cycle: scroll 300 → blocked while open → 300
after release, zero positional shift at engage.
2026-09-22 22:45:11 +03:00
amosblomqvist 5e92d0e73e Fix wave chunk 2: thinking levels auto-populate everywhere; legacy records show the resolved level
- thinking-default.ts: defaultOrderLevel (the ordering's first supported,
  the client twin of the server's defaultThinkingLevel) + populateLevel
  (an explicit level always rides; the WRITE clamps server-side, bd68486,
  and the cross-surface signal re-reads the clamped truth; only absence
  populates from the ordering) + THINKING_LABEL shared.
- Setup dialog (item 5): per-slot thinking selectors pre-populated with
  the default-order level per picked model, editable; disabled until the
  model pick; prefillLevelFromPin keeps a stored level as-is and populates
  legacy pins from the ordering.
- Composer + agent section (item 6): fresh model picks always write a
  level: the stored one rides, absence populates; the unset state can
  never re-enter. Item 4: records with a model but no stored level display
  the RESOLVED level (ordering's first supported) instead of a dash, in
  the composer pill and the settings slot triggers/menus.
- Item 7 verified live end-to-end: an explicit level rides into the PUT,
  the server clamps, the signal re-reads, the pill shows the clamped truth.
2026-09-22 22:05:27 +03:00
amosblomqvist e766a13eb1 Fix wave chunk 1: cross-surface settings signal, dialog scroll-lock, noneRow retired
- lh:settings-saved CustomEvent (the lh:chat-renamed pattern, owner
  ruling): every writer (composer save, SetupDialog save, the settings
  write seam — one dispatch point carrying the section) notifies after a
  successful PUT; every reader re-reads its own seam: ModelControl's
  record read, the Agent section's slots/catalog, and chat.tsx's
  configured state (clearing slots mid-session now surfaces the setup
  CTA without a reload). Self-notifications re-read idempotently.
- MenuShell now announces lh:menu-open/lh:menu-close (still placement-
  only); the settings dialog's content column locks (overflow-hidden,
  count-based) while any menu is open — wheel-scrolling with a dropdown
  down is dead; the re-place-on-scroll fallback stays for un-lockable
  surfaces.
- The settings thinking menus' 'Default' noneRow is retired (owner:
  the unset state can't re-enter once chunk 2's auto-populate lands; no
  unpin is needed).
- Live-caught regression from the MenuShell migration fixed: the
  composer's outside-click handler never learned the portaled menu body
  isn't a container descendant — menu-row pointerdowns counted as
  outside and ATE THE PICK (composer picks have been dead for pointer
  users since 221c99d); rows now carve out via [data-lh-menu], the
  settings sections' discipline.
- Item 4 (resolved-level display for legacy records) deferred to chunk 2
  per owner ruling — the payload omits the level for legacy no-level
  records and DEFAULT_THINKING_ORDER was server-only (now in the core
  barrel via bd68486, exactly the seam chunk 2 needs).
2026-09-22 21:55:33 +03:00
amosblomqvist 5ab52f07f6 BYOK universal gate rendered honestly: mid-chat setup flow, refusal-aware cards
- existing sessions: a send that lands the 503 models_not_configured envelope
  renders as the SETUP FLOW, never the error banner — the record refetch drops
  the refused orphan (the gate refuses before the write-ahead persist), the
  draft goes home to the composer, and Save auto-replays it (owner ruling);
  parseModelsRefusal reads the envelope from the transport's verbatim error
  text (exported pure, tested)
- persistent 'Set up your models' CTA above the chat composer while
  slotsConfigured is false (server-read seed via app/chat/[id]/page.tsx) —
  legacy per-chat overrides may still send with the pill present; the server
  gate stays the truth
- SetupDialog prefill + 'needed' marks (owner ruling): pinned tiers open with
  their pick (matched in the federated catalog — lapsed keys don't prefill),
  unpinned tiers carry the mark; Save still requires all three;
  prefillFromSlots exported pure, tested
- refused jobs read as INFO: dispatch card shows a quiet 'needs model setup'
  line + 'Set up models' deep-link (ChatActionsContext.openSetup); dock blobs'
  activity reads 'needs model setup' instead of 'failed'; real failures keep
  the red tone; isModelsRefusal added to the envelope's home module
- agent-section ModelsBody.slots type honest to the nullable contract
2026-09-22 18:12:57 +03:00
amosblomqvist 8675b19bef Fix wave chunk B: shared MenuShell for model menus, setup CTA on /chat/new
- components/menu-shell.tsx: one portaled, fixed-anchored, viewport-capped
  (60vh pattern) menu shell with site-preferred side + honest-fit auto-flip;
  computeMenuPlacement exported pure, placement-only (sites keep their own
  open/close/Esc contracts); re-places on capture-phase scroll/resize
- migrated: settings slot menus (agent-section), keys Add-provider,
  composer model+thinking (open up, right-aligned), SetupDialog pickers
  (portaled out of the overflow-hidden dialog frame — no clipping by
  construction); graph popovers + stats-bar summary stay bespoke (owner)
- /chat/new: the setup CTA above the draft blob, mirroring home's
- tests: menu-shell-pure (geometry/cap/flip/alignment), DraftShell CTA
2026-09-22 16:56:44 +03:00
amosblomqvist f6626f1703 Fix wave chunk A: no Default option, composer = strong slot, catalog revalidation
Item 1 — no Default in the slot selectors (owner, fix wave): a
"default" model is not a thing in this app. Picking Default used to
delete the slot and re-arm the first-run gate — incoherent. Now the
model menus carry NO clear row at all (a slot holds a pick or reads
"—"), the thinking menus carry a "—" row (menu-none) that clears only
the thinking pin, and the hint line is the roster only — the resolved
factory pin is never named. Master's amendment confirmed: the
factory-pin hint is SUPERSEDED entirely (Platform removes the pins).

Item 2 — the composer control IS the strong slot (owner: "one and the
same"): ModelControl (model-picker.tsx) reads the raw models section
(GET /api/settings) and the federated catalog, and writes
PUT /api/settings/models via composeSlotModel — the same seam the
settings Agent section and SetupDialog use. Props retired: chatId,
settings, onSaved, fallbackModelId, noDefaultModel — the control is
self-sufficient and identical on every surface (composer, home dock,
/chat/new). The per-chat model PUT is gone from the UI; legacy
per-chat overrides keep resolving server-side. StatsBar/DockOverlay
plumbing slimmed accordingly; chat.tsx drops its per-chat settings
state and initialSettings prop.

Item 3 — the composer catalog cache no longer freezes per page load:
fetchCatalog(force) invalidates, and every model-menu open revalidates
in the background (stale-while-revalidate — cached list shows
instantly, the fresh federation swaps in). Owner's repro verified live:
a key saved mid-session widens the menu without a reload.

Item 4 — stale doc comments fixed: model-catalog.ts's "OpenRouter
catalog"/"multi-provider future" (Platform's file — mechanical,
flagged for review) and model-picker's retired "config-pinned"/
per-chat-PUT narrative.

Live-verified on 3001: unpicked slots read "—" everywhere; model
menus have no clear row; the "—" thinking row clears the pin but
keeps the model; composer pick writes strong and preserves cheap/
vision siblings; thinking pill writes strong.thinkingLevel; groq key
added mid-session appears in the open menu without reload. Caught in
live verification: the record fetch was mapping the WHOLE settings
record into the models section (picks never wrote) — fixed to read
body.settings.models.

Gates: typecheck, lint, 807/807, build.
2026-09-22 16:40:31 +03:00
amosblomqvist 57dd25993c Research tool row grays on the registry's own search signal (Teacher flag)
The teacher registry drops the research tool when no search engine is
live (chat-handler's searchAvailable, 2026-09-20) — the settings row
now grays on the SAME /api/search signal, so the row and the actual
registry availability can never disagree (Teacher-stream flag,
2026-09-22, routed by master).

- researchToolUnavailable (pure): research grays exactly when the live
  status says unavailable; a loading/failed fetch grays nothing; the
  other tools never touch the search port.
- The row: whole-row dim (graph-view graying) + disabled toggle + the
  status reason as the hint — the same line the search-engine row
  under it speaks from.

Two live-caught fixes while here:
- The reason line refreshed IN the pick handler raced the section PUT
  (the hint could contradict the trigger it sits under — caught with a
  Brave pin: trigger 'Brave', hint still 'tavily (auto)'). The refresh
  now keys on the agent prop's engine pin, so it reads post-write
  state.
- The engine menu options render from a map without React keys
  (console warning) — keyed.

Live-verified on 3001: no engine key → research dimmed with reason,
siblings untouched; Tavily key saved + section reopened → row alive
('search engine: tavily (auto)'); Brave pinned without its key → both
rows gray with the honest missing-key reason; Automatic restored.
Gates: typecheck, lint, 803/803, build.
2026-09-22 14:04:15 +03:00
amosblomqvist 48fbd988f1 Wave-5 chunk B: three model slots, search-engine row, first-run setup flow
Chunk B (owner-designed, model-slots wave) on the merged engine surface
(main 1d66a17):

AGENT SECTION — SLOTS ONLY (owner ruling)
- The six per-role model rows are GONE; three slot rows instead:
  Strong (Prober + Teacher), Cheap (five workhorse roles), Vision
  (visual maker + PDF reader), roster + resolved-default in every hint.
- Slot dropdowns share the app's menu language over the FEDERATED
  catalog (/api/models = keyed providers only, fresh per call — saving
  a key in Keys immediately widens the picker); the vision slot
  filters to image-capable models (Model.input); the menu's highlight
  now matches provider+id (ids can collide across providers).
- Slots write the new models section (PUT /api/settings/models) through
  the dialog's write seam; per-role overrides stay schema-only.
- ModelMenuList rows read 'provider · id' — federation makes the
  provider bookkeeping-relevant. Usage stamps render provider-qualified
  (prettyModel(qualified): 'Glm 5.3 Flash · openrouter') in the stats
  bar's turn lines.

SEARCH ENGINE (search wave: no default, owner)
- A row in Tools: Tavily/Brave via the shared-menu look, Automatic =
  the pin removed (auto-activation). The live reason line comes from
  /api/search; no engine key at all grays the whole row + disables the
  trigger (a keyless choice would only block auto-activation).

FIRST-RUN SETUP FLOW (owner design A: composer CTA + send interception)
- Chats cannot start until all three slots are picked — no defaults
  for 33 providers (slotsConfigured: all three slot models required).
- The graph view's new-session dock (home) shows a 'Set up your models'
  CTA while unconfigured; submitting a topic opens the SetupDialog WITH
  THE DRAFT PRESERVED; Save stores the models section and the queued
  message sends. Same interception on /chat/new sends.
- STAGED BY DEPENDENCY (owner: keys first, models after): no keyed
  provider → stage 1 deep-links the settings dialog on Keys (open now
  takes a section); a saved key reopens at stage 2 on the next submit.
- Stage 2: Strong → Cheap → Vision pickers (federated, vision-filtered,
  all three required). Closable without configuring — browsing stays
  possible; the gate is the send, not the dialog. Layered Esc: an open
  slot menu eats Esc before the dialog.
- Two closure bugs caught live: onDone→start/send captured the stale
  pre-save 'configured' (the flow now forces the replayed send); menu-Esc
  used to close the whole dialog.

Settings-dialog provider: open(section?) lands on a section (stage 1
deep-link). Tests: composeSlotModel/composeSearchEngine pure, slot SSR
replacing role rows, slotsConfigured/setupStage first-run gates,
provider-qualified prettyModel. Gates: typecheck, lint, 792/792, build.
2026-09-22 13:54:29 +03:00
amosblomqvist 21d288e9f9 Keys section polish (wave-5 chunk A, owner design): the minimal key-value table
The owner called the old section messy; the redesign is theirs:

- MINIMAL TABLE: provider label left, masked status right (…tail ·
  chars · env), description hints GONE (they took too much space),
  the 'Get a key ↗' link a tiny muted guest on curated rows. Rows
  read as clean key-value pairs.
- ENV-OWNED ROWS ARE READ-ONLY (owner ruling): a real environment
  variable owns the value — the row dims (graph-view graying) and no
  paste field renders, because a paste box on a key the UI cannot
  make live would mislead. Clicking does nothing.
- OPENROUTER UN-PINNED (owner ruling): one provider among 33 — a row
  only when stored, otherwise it's an option in the Add dropdown like
  everyone else. No synthesis, no lifeline description.
- ROW INTERACTION (owner choice): clicking a writable row unfolds a
  paste field + Save (+ Delete for a file-sourced value) beneath it;
  success folds it back to rest; Esc folds without closing the dialog
  (claimed at document CAPTURE phase — the only stopPropagation that
  outruns both React handlers and the dialog's window listener; the
  React-level attempt leaked and closed the dialog, caught live).
- ADD-PROVIDER = the table's last row, in the app's shared dropdown
  language: new ProviderMenuList (fuzzy search + arrow-walk + Enter
  over the 33 pi-ai providers, label + envVar rows) in the shared
  model-menu.tsx, portaled and fixed-positioned like the role menus.
  Picking one unfolds a paste row; Save stores, Cancel/Esc folds.
- Tools & services (Deepgram + the Google pair) keep their own group,
  same table look, same unfold interaction.
- Unknown auth.json vars stay read-only dimmed rows.

Tests: classifyKeys re-pinned (openrouter rides the normal provider
classification; addable drops stored keys), keyStatusLine minimal
('not set' for absent, ' · env' marker for environment-owned). Gates:
typecheck, lint, 759/759, build.

Live-verified: add→save→replace→delete round-trips, fold-on-save,
Esc layering both flows, env-owned rows read-only, addable count
31→30→31.
2026-09-22 00:16:23 +03:00
amosblomqvist f1373d5de5 feat(search): Tavily + Brave behind a search-provider port; Google CSE retires
Search port + model slots wave, chunk A (owner design, 2026-09-20):
- core/search.ts: SearchHit + SearchProvider port + SearchStatus —
  pure, browser-safe; adapters arrive as injected dependencies.
- server/search.ts: Tavily + Brave adapters (wire shapes VERIFIED
  against docs.tavily.com's OpenAPI + Brave's dashboard docs — Tavily
  auth is the Bearer header, not the legacy body api_key; Brave is
  X-Subscription-Token) + engine resolution per owner decisions: a key
  ALONE activates its engine (no engine choice needed), explicit
  agent.search.engine pins the preference (both keys: Tavily first),
  a pinned engine with a missing key is honestly unavailable — no
  silent fallback, no default engine.
- Curated keys catalog: GOOGLE_SEARCH_API_KEY/GOOGLE_CSE_ID retire
  (CSE closed to new customers, dies Jan 2027 — stored values become
  preserved unknown vars); TAVILY_API_KEY (tvly- shape) + BRAVE_API_KEY
  (length guard) join. UI keys-section TOOL_IDS now derives from the
  catalog — cannot drift.
- GET /api/search: {available, engine, reason} — the honest status for
  the settings gray-out; researcher-disable semantics ride the same
  signal (Teacher stream rewires the tool onto the port).
- agent.search settings field (schema + sanitizer + strict parser);
  web.ts's WebSearchResult is now the core SearchHit (re-exported).
2026-09-22 00:02:07 +03:00
amosblomqvist bd66b31853 Keys section (auth/provider-keys wave): BYOK keys in the settings dialog
Owner-approved design (2026-09-20): a Keys section right after General,
two groups over the merged auth API (GET /api/keys masked statuses,
PUT/DELETE /api/keys/:id, immediate effect, shape-gated 400s):

- Model providers: OpenRouter pinned as an always-visible row (the
  app's lifeline — synthesized when absent so first boot shows it),
  stored provider keys render as they appear, and an Add-provider
  dropdown lists every pi-ai provider that takes an API key. The
  dropdown reads GET /api/keys' optional providers array — the
  Platform interlude (master-routed) derives it from pi-ai's own
  env-key table; until it lands the group shows a pending note
  (rolePins pattern — absent field, no drift, no guessed routes).
- Tools & services: the curated rows — Deepgram (dictation) and the
  Google pair (search key + CSE ID) — with description hints and docs
  links (external links route through the Electron shell to the real
  browser).
- Rows: always-visible paste inputs (owner choice; no reveal —
  plaintext never returns from the store), masked status line
  (…tail · chars · environment|saved), the shadowedByEnv explanation
  when a real env var owns the name, per-row inline 400 messages, Save
  on Enter/button, Delete when present (env-owned keys honestly keep
  their env status after a file delete).
- Unknown auth.json vars surface as read-only dimmed rows — preserved
  by the store, not editable through the curated surface.

classifyKeys/keyStatusLine pure exports drive the two-group
projection; tests: SSR loading state, first-boot synthesis, provider
row classification, addable filtering, unknown-var read-only status
line formatting. Nav grows to six sections (Keys second).

Live-verified: nav order, structure, bad-paste 400s inline, save
round-trip with env-shadow explanation, delete round-trip, env-owned
OpenRouter status stability. Gates: typecheck, lint, 748/748, build.
2026-09-19 22:53:15 +03:00
amosblomqvist fddbacd330 Settings-app refinement round 3 (owner review): toggle polish, nav spacing, portal dropdowns, Default-named hints
- Toggle (owner bug report): the ON disc sat flush against the pill's
  right edge — translate-x-4.5 left only a 1px sliver; now translate-x-4
  for a 2px inner inset both states (off: 3px left inset; on: 3px right
  inset — measured live, symmetric).
- Toggle colors (owner ask): ON is a gray track (muted-foreground) with
  a WHITE disc (foreground) — the full-white track was too loud; OFF
  stays the muted track with the gray disc.
- Nav spacing (owner ask): gap-1 between the section nav buttons so the
  hover highlights no longer touch.
- Role dropdowns (owner ask): the menus were clipped by the dialog's
  overflow frame — they now portal to document.body with fixed
  positioning against their trigger (AnchoredMenu), right-aligned,
  repositioned on scroll/resize, so they flow over the whole screen
  outside the settings dialog. Verified: a bottom-row menu extends
  137px past the dialog's bottom edge; search + pick + Default-clear
  work through the portal; Esc and inside-dialog clicks close the menu
  but not the dialog.
- Thinking trigger (owner ask): fixed w-20 — the width the Default
  state needs — so picking levels no longer shifts the row; the model
  trigger takes the same width as a minimum (its values vary in length).
- Role hints (owner ask): ALWAYS visible and naming the actual default —
  'Default: <model>'. The resolved per-role pins are not client-side
  yet: master routed rolePins to the Platform stream (GET /api/models
  extension, standalone commit); until that merge the rows read the
  'config pin' fallback and light up on rebase. rolePins also lets the
  thinking list use the pinned model's real supportedLevels when it's
  in the catalog.

Also: staging repair commit 270db53 — the round-1 dialog rewiring
(sidebar row, chord, redirect, keyframes) was listed in 27d4f33's
message but never staged; committed as catch-up.

Live-verified on the worktree dev server. Gates: typecheck, lint,
701/701.
2026-09-19 22:29:17 +03:00
amosblomqvist fd63e4a575 Settings-app refinement round 2 (owner review): toggle fix, shared model dropdown, groups, graph-view graying, pruned hints
- Toggle knob (bug report): the knob span had no left anchor, so it sat
  at its inline static position — right-edge in off, overflowing in on.
  Now left-0 + the intended 2px/18px translate; verified live: 3px from
  left when off, 1px right inset both states.
- Role model/thinking dropdowns (owner ask): the EXACT chat model
  selector dropdown, not a lookalike — the fuzzy-search + arrow-walk +
  Enter machine moved from model-picker.tsx into the shared
  components/settings/model-menu.tsx (ModelMenuList/ThinkingMenuList),
  ModelControl now renders the same bodies, and the agent roles open
  them downward with a pinned Default row (clears the override). The
  collapsed trigger keeps the thin select look (owner: 'the collapsed
  look is good'). Esc claims and closes only the menu (the dialog
  stays); a backdrop click is left to the dialog's own close.
- Sub-groups (owner ask): SettingsGroup — Models / Tools / System
  prompts inside Agent, each with a separator + quiet label; section
  headings more pronounced (text-sm, full-foreground).
- Graying (owner ask): graph-view render-panel convention — the WHOLE
  row dims (opacity-40) and the control locks; the row owns the dim so
  no double-opacity. Applied to the graph ladder, the mode row, and
  prompt rows without an override file. The 'needs X on' prose is gone.
- Hints pruned (owner ask): mechanics-explaining lines removed — the
  ladder mention, 'App-level behavior', 'Applied live as CSS
  variables', per-row explanations. Only the keyboard storage note
  stays, reworded 'Stored on this device' (desktop app — localStorage
  is the Chromium profile in Application Support, not 'this browser').

Live-verified: knob geometry both states + PUT round-trip, menu
search/pick/clear through the record, thinking set/clear, Esc layering,
ladder dim 0.4 with siblings full, pruned phrases absent. Gates:
typecheck, lint, 701/701.
2026-09-19 22:29:17 +03:00
amosblomqvist 5e21561997 Settings-dialog round 1b: un-double the section inset
The dialog's content wrapper added px-6 py-5 ON TOP of SettingsSection's
own padding (48px horizontal, 81px top — measured live). The wrapper is
now bare; the section component owns its rhythm, and the loading/error
states carry their own padding. Section box now spans the scroll column
(61px from the dialog top = the 60px header band), text inset 24px.

Gates: typecheck, lint, 701/701.
2026-09-19 22:29:17 +03:00
amosblomqvist 77527a9766 Settings-app polish round 1: the thin page graduates into the designed settings dialog
Owner decisions this wave: a centered modal over everything (not a page
— a settings column in the main area next to the session list would read
as two sidebars), left section nav in the dialog, modal-only (the
/settings URL redirects to /chat/new), light mode deferred to its own
wave (the mode control stays honestly disabled). An actual OS window is
an Electron-round feature — the sections are surface-agnostic (API +
localStorage only), so the shell re-mounts there with near-zero churn.

- components/settings/settings-dialog.tsx: SettingsDialogProvider at
  the AppShell level (portal-rendered dialog, z-50 over the dock/graph
  chrome). ~920×620 centered frame, 60px header with the quiet CMU
  title + ✕, w-44 text-only nav (session-list mono language, active =
  bg-accent, no icons), one scrolling content column mounting one
  section at a time. Escape / backdrop / ✕ all close. 150ms fade+2%
  settle scale (settings-pop), reduced-motion honored. The record +
  single write seam moved here from the thin page: one record instance
  per dialog, section switches never refetch, appearance writes still
  restyle the app LIVE behind the dialog.
- Entry points rewired: the sidebar Settings row is a button calling
  open(); the mod+, chord opens the dialog instead of routing.
- app/settings/page.tsx: redirect to /chat/new (nothing links into a
  dead page).
- SettingsDialogBody exported without the portal — the SSR-testable
  frame (portals render nothing under renderToStaticMarkup).

Live-verified: exact centering, all three close paths, chord open,
appearance prose-size restyles the chat behind the open dialog
(16→20px), section switching retains the record, keyboard section
renders all 21 rows, no console errors. Gates: typecheck, lint,
701/701 (5 new dialog tests), build.
2026-09-19 22:29:17 +03:00
amosblomqvist c32915b232 Thin working settings UI (owner-approved plan): /settings + live appearance + keyboard rebinding
The validation surface for the settings foundation — deliberately thin,
real read/write against the delta record, polish is a later wave.

Mount (owner decisions): dedicated page, single scroll; entry = sidebar
bottom row + ⌘/Ctrl+, (open-settings in the registry); chat defaults NOT
shown (they live on /chat/new); mode dark-only (light palette = design
wave); final tool list = research/demo/visual/pdf (master-adjudicated).

Sections (PUT /api/settings/[section], replace-whole per section):
- General: auto-update toggle.
- Agent: six background-role model+thinking selects (Default = config
  pin; a stored id stays VISIBLE even when the catalog hasn't loaded or
  dropped it — a select showing Default over a live override would lie),
  the four tool toggles, prompt override switches (file-gated) with
  source/override text editors over the segment routes.
- Appearance: applied LIVE as runtime CSS variables by an
  AppearanceProvider at the root layout — proseFontSize → the one
  reading-size var, fontFamily → --font-prose, background → --background
  + the derived raised/accent/edge palette (dark curve, today's exact
  spacing: +8/+22/+47 per channel, clamped), sessionNameSerif →
  --font-title (titles flip with ZERO chat.tsx edits), headingWeight →
  new --lh-heading-weight token, bold/italic colors → the lh-ink vars.
  Mode renders honestly DISABLED.
- Graph: the ladder grays a rung when any ANCESTOR is effectively off
  (stored values persist, resolve inert — switch shows stored, graying
  shows effective).
- Keyboard: view/rebind/reset over the registry; overrides persist in
  lh:keymap and apply INSTANTLY (dispatcher reads per keydown); bare
  modifier presses are ignored while armed; displayOnly actions list but
  never rebind.

First-boot fix: {settings: null} is no-record, not fetch-failure.

CROSS-LANE (flagged for master): src/core/keyboard.ts gained the
open-settings action/binding and exports saveOverrides/loadOverrides
(one-liners; no existing pins); app/layout.tsx mounts the provider.

Live-verified: structure, record writes (general/tools/graph/appearance),
prose-size applying live (16→20px), rebind + chord navigation from
another page, ladder graying, no console errors. Gates: typecheck,
lint, 689/689 (28 new), build.
2026-09-19 18:27:37 +03:00