- MenuShell: after placement, focus lands in the search when the menu has one, else the body (tabIndex -1) — the lists' mount-time focus ran during the hidden measuring pass and silently failed, so no dropdown ever opened focused. The body owns the arrow machine for searchless menus: ArrowDown/Up cycle options with wrap (first/last entry from the body, native-select convention), Home/End, Enter activates, preventDefault so arrows never scroll the page. - Appearance: the CSS-var applier only ever setProperty'd — a later write omitting a var left the stale inline value forever (empty background never reset, serif toggle-back looked dead). appliedNamesRef tracks what was set; staleVarNames (pure, exported) returns what to removeProperty. - CommitInput: controlled — after a commit the field snaps to the stored record, so the clamp on a -5 prose size is visible (12), Esc reverts. - Keys: Add-provider dropdown closes on pick; the paste row is inline (label | input | Save | Cancel); existing-row editors go inline with an explicit Cancel beside Save/Delete — folding is visible, not Esc-only. - Graph context: readout is content-sized (tabular-nums) — no fixed-width dead gap when "off"; the dictation strip fills whatever space remains. Gates: tsc clean, 866/866 tests (new staleVarNames suite), lint 0, build ok. Live-verified via Playwright: dropdown focus+arrow cycles+wrap+Enter pick, background set→empty reset, serif round-trip, visible clamp to 12, provider pick closes menu, inline editor + Cancel + Esc fold, slider width 270→277px.
212 lines
5.8 KiB
TypeScript
212 lines
5.8 KiB
TypeScript
"use client";
|
|
/**
|
|
* Thin settings primitives (settings-app wave): the control kit the
|
|
* settings dialog needs. Sections → groups → rows; everything SSRs clean
|
|
* (render tests pin the sections' markup).
|
|
*
|
|
* The dependency graying (owner, 2026-09-20) follows the graph viewer's
|
|
* render-panel convention: the WHOLE row dims (opacity-40, label and
|
|
* control together) and the control itself locks — the row owns the dim.
|
|
*/
|
|
|
|
import { useState } from "react";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
/** A section block: pronounced mono heading + content. */
|
|
export function SettingsSection({
|
|
title,
|
|
hint,
|
|
children,
|
|
testid,
|
|
}: {
|
|
title: string;
|
|
/** One quiet line under the header — only where it earns its place. */
|
|
hint?: string;
|
|
children: React.ReactNode;
|
|
testid?: string;
|
|
}) {
|
|
return (
|
|
<section data-testid={testid} className="px-6 py-5">
|
|
<h2 className="text-sm uppercase tracking-widest text-foreground">{title}</h2>
|
|
{hint && <p className="mt-1 text-xs text-muted-foreground">{hint}</p>}
|
|
<div className="mt-4 flex flex-col gap-3">{children}</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
/** A labeled sub-group inside a section (owner, 2026-09-20: clear
|
|
* separation — Models / Tools / Prompts read as three blocks). */
|
|
export function SettingsGroup({
|
|
label,
|
|
children,
|
|
testid,
|
|
}: {
|
|
label: string;
|
|
children: React.ReactNode;
|
|
testid?: string;
|
|
}) {
|
|
return (
|
|
<div data-testid={testid} className="mt-5 border-t border-border/60 pt-4 first:mt-0 first:border-t-0 first:pt-0">
|
|
<p className="mb-2 text-xs uppercase tracking-widest text-muted-foreground">{label}</p>
|
|
<div className="flex flex-col gap-2">{children}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** One label + one control. Label left (with optional muted hint), control
|
|
* right — the settings row shape. */
|
|
export function SettingsRow({
|
|
label,
|
|
hint,
|
|
children,
|
|
testid,
|
|
dimmed,
|
|
}: {
|
|
label: string;
|
|
hint?: string;
|
|
children: React.ReactNode;
|
|
testid?: string;
|
|
/** Dependency graying, graph-view style: the whole row dims when the
|
|
* control is inert (parent off, no file yet, …). */
|
|
dimmed?: boolean;
|
|
}) {
|
|
return (
|
|
<div data-testid={testid} className={cn("flex items-center justify-between gap-4 py-1", dimmed && "opacity-40")}>
|
|
<span className="min-w-0">
|
|
<span className="block font-prose text-(length:--lh-prose-size)">{label}</span>
|
|
{hint && <span className="block text-xs text-muted-foreground/70">{hint}</span>}
|
|
</span>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** The on/off switch — a plain button in the switch role. */
|
|
export function Toggle({
|
|
checked,
|
|
disabled,
|
|
onChange,
|
|
label,
|
|
}: {
|
|
checked: boolean;
|
|
disabled?: boolean;
|
|
onChange: (next: boolean) => void;
|
|
/** Accessible name for the switch role. */
|
|
label: string;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
role="switch"
|
|
aria-checked={checked}
|
|
aria-label={label}
|
|
disabled={disabled}
|
|
data-testid={`toggle-${label.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`}
|
|
onClick={() => onChange(!checked)}
|
|
className={cn(
|
|
"relative h-4.5 w-8 shrink-0 rounded-full border border-border transition-colors",
|
|
// Owner (2026-09-20): on is a gray track with a WHITE disc (a full-white
|
|
// track was too loud); off stays the muted track.
|
|
checked ? "bg-muted-foreground" : "bg-muted",
|
|
disabled && "cursor-not-allowed",
|
|
)}
|
|
>
|
|
<span
|
|
className={cn(
|
|
"absolute left-0 top-0.5 size-3 rounded-full transition-transform",
|
|
// 2px inset from each inner edge, both states (owner round: the on
|
|
// disc used to touch the pill's right edge).
|
|
checked ? "translate-x-4 bg-foreground" : "translate-x-0.5 bg-muted-foreground",
|
|
)}
|
|
/>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
/** A thin select — native element, options as given. */
|
|
export function ThinSelect({
|
|
value,
|
|
onChange,
|
|
children,
|
|
ariaLabel,
|
|
testid,
|
|
disabled,
|
|
}: {
|
|
value: string;
|
|
onChange: (next: string) => void;
|
|
children: React.ReactNode;
|
|
ariaLabel: string;
|
|
testid?: string;
|
|
disabled?: boolean;
|
|
}) {
|
|
return (
|
|
<select
|
|
value={value}
|
|
aria-label={ariaLabel}
|
|
data-testid={testid}
|
|
disabled={disabled}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
className={cn(
|
|
"h-7 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground outline-none",
|
|
disabled && "cursor-not-allowed",
|
|
)}
|
|
>
|
|
{children}
|
|
</select>
|
|
);
|
|
}
|
|
|
|
/** A thin hex/text input committing on blur + Enter (settings pace, not
|
|
* typing pace — every commit is a section PUT). */
|
|
export function CommitInput({
|
|
value,
|
|
placeholder,
|
|
onCommit,
|
|
ariaLabel,
|
|
testid,
|
|
type = "text",
|
|
}: {
|
|
/** "" = unset (the factory default). Mirrors the STORED record —
|
|
* after a clamped/normalized commit this flows back and the field
|
|
* snaps to the stored truth (owner repro: a negative size kept
|
|
* displaying "-5" while 12 was stored). */
|
|
value: string;
|
|
placeholder?: string;
|
|
onCommit: (next: string) => void;
|
|
ariaLabel: string;
|
|
testid?: string;
|
|
type?: "text" | "number";
|
|
}) {
|
|
// The draft exists only while editing; a blur shows the record's value
|
|
// again — commit-on-blur/Enter (owner ruling), the field honest about
|
|
// what landed.
|
|
const [draft, setDraft] = useState(value);
|
|
const [editing, setEditing] = useState(false);
|
|
return (
|
|
<input
|
|
type={type}
|
|
value={editing ? draft : value}
|
|
placeholder={placeholder}
|
|
aria-label={ariaLabel}
|
|
data-testid={testid}
|
|
onChange={(e) => {
|
|
setEditing(true);
|
|
setDraft(e.target.value);
|
|
}}
|
|
onBlur={() => {
|
|
if (!editing) return;
|
|
setEditing(false);
|
|
const next = draft.trim();
|
|
if (next !== value) onCommit(next);
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") e.currentTarget.blur();
|
|
if (e.key === "Escape") {
|
|
setEditing(false);
|
|
e.currentTarget.blur();
|
|
}
|
|
}}
|
|
className="h-7 w-40 rounded-sm border border-border bg-background px-2 font-ui text-xs text-foreground outline-none placeholder:text-muted-foreground/60"
|
|
/>
|
|
);
|
|
} |