Files
learnharness/components/settings/appearance.tsx
T
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

159 lines
6.8 KiB
TypeScript

"use client";
/**
* THE APPEARANCE APPLICATION (thin settings wave, 2026-09-19): the settings
* record's appearance section actually DOES something — resolved against
* the factory defaults and applied as RUNTIME CSS variables on <html>.
*
* Why variables and not className swaps: every seam the settings touch was
* built token-first (--font-prose, --lh-prose-size, --lh-ink-strong, …),
* so application is setProperty and nothing re-renders. The ONE thing the
* variables can't reach — the mode — is dark-only this wave (the light
* palette is designed in the later design wave; <html class="dark"> is
* static in app/layout.tsx).
*
* The provider is mounted at the ROOT LAYOUT level (like ActivityProvider):
* every page picks appearance up on load. The settings page writes a
* section, hands the new appearance here, and the vars move immediately.
*
* (The open-settings keyboard chord lives in components/keyboard-shortcuts.tsx
* with the other navigations — mod+, pushes /settings from anywhere.)
*/
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { FACTORY_SETTINGS, type AppearanceSettings } from "@/src/core";
/** The resolved, fully-concrete appearance view (delta + factory merged). */
export function resolveAppearance(delta: AppearanceSettings | undefined) {
const f = FACTORY_SETTINGS.appearance;
return {
mode: delta?.mode ?? f.mode,
background: delta?.background ?? f.background,
fontFamily: delta?.fontFamily ?? f.fontFamily,
proseFontSize: delta?.proseFontSize ?? f.proseFontSize,
sessionNameSerif: delta?.sessionNameSerif ?? f.sessionNameSerif,
mdRendering: {
headingWeight: delta?.mdRendering?.headingWeight ?? f.mdRendering.headingWeight,
boldColor: delta?.mdRendering?.boldColor ?? f.mdRendering.boldColor,
italicColor: delta?.mdRendering?.italicColor ?? f.mdRendering.italicColor,
},
};
}
/** One channel clamped on the dark curve: additive offsets matching today's
* hand-picked palette (#141414 → card #1c1c1c (+8) → accent #2a2a2a (+22)
* → border #434343 (+47)) — the "per-mode derivation in code" the schema
* promises, as the thinnest curve that keeps the palette's relationships. */
function raise(hex: string, add: number): string {
const n = hex.length === 4
? hex.slice(1).split("").map((c) => parseInt(c + c, 16))
: [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)];
return `#${n.map((c) => Math.min(255, c + add).toString(16).padStart(2, "0")).join("")}`;
}
/** The derived palette a chosen background implies (dark curve): background
* itself, the raised surfaces (card/popover/secondary/muted, +8), the
* accent (+22), the edges (border/input, +47), and primary-foreground
* (the inverted text on primary = the background's own color, as today). */
export function derivedPalette(background: string): Record<string, string> {
const card = raise(background, 8);
const accent = raise(background, 22);
const border = raise(background, 47);
return {
"--background": background,
"--card": card,
"--popover": card,
"--secondary": card,
"--muted": card,
"--accent": accent,
"--border": border,
"--input": border,
"--primary-foreground": background,
};
}
/** THE mapping: resolved appearance → the CSS variables to set on <html>.
* Absent entries = leave the factory stylesheet value in place. Pure —
* exported for tests. */
export function appearanceVars(a: ReturnType<typeof resolveAppearance>): Record<string, string> {
const vars: Record<string, string> = {};
if (a.background !== null) Object.assign(vars, derivedPalette(a.background));
if (a.fontFamily !== null) {
vars["--font-prose"] = `"${a.fontFamily}", var(--font-sans3), ui-sans-serif, system-ui, sans-serif`;
}
// The ONE documented reading-size variable (typography round 4): prose,
// inputs, and session titles all read it, so one setProperty moves them.
vars["--lh-prose-size"] = `${a.proseFontSize}px`;
// Session names: CMU Serif by default; the toggle swaps the title font
// to the prose family — covers chat.tsx + new-session.tsx titles with
// zero component edits (they use the .font-title utility).
if (!a.sessionNameSerif) vars["--font-title"] = "var(--font-prose)";
// Markdown rendering: heading weight + the emphasis inks (the vars
// were built "ready for the settings UI").
vars["--lh-heading-weight"] = String(a.mdRendering.headingWeight);
if (a.mdRendering.boldColor !== null) vars["--lh-ink-strong"] = a.mdRendering.boldColor;
if (a.mdRendering.italicColor !== null) vars["--lh-ink-italic"] = a.mdRendering.italicColor;
return vars;
}
interface AppearanceContextValue {
/** Resolved appearance; factory until the record arrives. */
appearance: ReturnType<typeof resolveAppearance>;
/** Called by the settings page after a successful section PUT. */
onAppearanceChange: (delta: AppearanceSettings | undefined) => void;
}
const AppearanceContext = createContext<AppearanceContextValue>({
appearance: resolveAppearance(undefined),
onAppearanceChange: () => {},
});
export function useAppearance() {
return useContext(AppearanceContext);
}
export function AppearanceProvider({ children }: { children: React.ReactNode }) {
const [delta, setDelta] = useState<AppearanceSettings | undefined>(undefined);
const appliedRef = useRef<string>("");
// The vars apply on every change — stringified as the change signal.
const vars = useMemo(() => appearanceVars(resolveAppearance(delta)), [delta]);
const key = useMemo(() => JSON.stringify(vars), [vars]);
useEffect(() => {
if (appliedRef.current === key) return;
appliedRef.current = key;
const root = document.documentElement;
for (const [name, value] of Object.entries(vars)) root.style.setProperty(name, value);
}, [key, vars]);
// One fetch per app load: the record (sanitized server-side) carries the
// delta; resolution happens here against the compiled factory.
useEffect(() => {
let alive = true;
fetch("/api/settings")
.then((r) => r.json())
.then((body: { settings?: Record<string, unknown> }) => {
if (!alive) return;
// The record arrives sanitized server-side; the appearance
// slice is unknown-shaped here, so it goes through the same
// resolve (absent/invalid fields = factory anyway).
const appearance = body.settings?.appearance;
setDelta(
appearance && typeof appearance === "object" ? (appearance as AppearanceSettings) : undefined,
);
})
.catch(() => {/* settings stay factory — not worth a crash */});
return () => {
alive = false;
};
}, []);
const onAppearanceChange = useCallback((next: AppearanceSettings | undefined) => {
setDelta(next);
}, []);
const value = useMemo(
() => ({ appearance: resolveAppearance(delta), onAppearanceChange }),
[delta, onAppearanceChange],
);
return <AppearanceContext.Provider value={value}>{children}</AppearanceContext.Provider>;
}