Fix wave: dropdown keyboard/focus, appearance resets, inline key editors, content-sized slider

- 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.
This commit is contained in:
amosblomqvist
2026-09-23 00:57:28 +03:00
parent b18294948d
commit 22266973f3
6 changed files with 224 additions and 51 deletions
+83 -43
View File
@@ -114,6 +114,7 @@ function KeyTableRow({
onToggle,
onSave,
onDelete,
onCancel,
testid,
}: {
label: string;
@@ -124,6 +125,9 @@ function KeyTableRow({
onToggle: () => void;
onSave: (value: string) => Promise<string | null>;
onDelete?: () => Promise<void>;
/** Folds the editor (owner, fix wave: the way OUT of a paste prompt
* must be visible — Cancel beside Save/Delete, not only Esc). */
onCancel: () => void;
testid: string;
}) {
const envOwned = status.source === "env";
@@ -141,6 +145,68 @@ function KeyTableRow({
setBusy(false);
}
// INLINE editing (owner, fix wave: "type the keys in line — more
// table-like"): the expanded row becomes the editor itself — label
// left, the paste field filling the middle, Save | Cancel (| Delete)
// right — instead of a second line beneath the row.
if (expanded && !envOwned) {
return (
<div data-testid={testid}>
<div className="flex items-center gap-2 rounded-sm px-1 py-1.5" data-testid={`${testid}-editor`}>
<span className="shrink-0 font-ui text-xs text-foreground">{label}</span>
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void save();
}}
placeholder="Paste key…"
aria-label={`${label} key`}
data-testid={`${testid}-input`}
disabled={busy}
autoFocus
className="h-7 min-w-0 flex-1 rounded-sm border border-border bg-background px-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50"
/>
<button
type="button"
onClick={() => void save()}
disabled={busy || !value.trim()}
data-testid={`${testid}-save`}
className="shrink-0 text-xs text-foreground underline-offset-2 hover:underline disabled:text-muted-foreground/40 disabled:no-underline"
>
Save
</button>
<button
type="button"
onClick={onCancel}
disabled={busy}
data-testid={`${testid}-cancel`}
className="shrink-0 text-xs text-muted-foreground/70 underline-offset-2 hover:text-foreground hover:underline"
>
Cancel
</button>
{status.present && status.source === "file" && onDelete && (
<button
type="button"
onClick={() => void onDelete()}
disabled={busy}
data-testid={`${testid}-delete`}
className="shrink-0 text-xs text-muted-foreground/70 underline-offset-2 hover:text-red-500 hover:underline"
>
Delete
</button>
)}
</div>
{error && (
<p role="alert" data-testid={`${testid}-error`} className="px-1 text-xs text-red-500">
{error}
</p>
)}
</div>
);
}
return (
<div data-testid={testid} className={envOwned ? "opacity-40" : undefined}>
<button
@@ -170,43 +236,6 @@ function KeyTableRow({
</span>
</span>
</button>
{expanded && !envOwned && (
<div className="mt-0.5 mb-1 flex items-center gap-2 pl-6" data-testid={`${testid}-editor`}>
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void save();
}}
placeholder="Paste key…"
aria-label={`${label} key`}
data-testid={`${testid}-input`}
disabled={busy}
className="h-7 w-64 rounded-sm border border-border bg-background px-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50"
/>
<button
type="button"
onClick={() => void save()}
disabled={busy || !value.trim()}
data-testid={`${testid}-save`}
className="text-xs text-foreground underline-offset-2 hover:underline disabled:text-muted-foreground/40 disabled:no-underline"
>
Save
</button>
{status.present && status.source === "file" && onDelete && (
<button
type="button"
onClick={() => void onDelete()}
disabled={busy}
data-testid={`${testid}-delete`}
className="text-xs text-muted-foreground/70 underline-offset-2 hover:text-red-500 hover:underline"
>
Delete
</button>
)}
</div>
)}
{error && (
<p role="alert" data-testid={`${testid}-error`} className="pl-6 text-xs text-red-500">
{error}
@@ -269,7 +298,15 @@ function AddProviderRow({
preferredSide="down"
menuClass="flex w-72 flex-col rounded-xl border border-border bg-card p-2 text-xs shadow-xl"
>
<ProviderMenuList providers={addable} onPick={onPick} />
{/* Close on pick (owner, fix wave): a chosen provider ends
the dropdown — the paste row is the next step. */}
<ProviderMenuList
providers={addable}
onPick={(p) => {
setOpen(false);
onPick(p);
}}
/>
</MenuShell>
)}
</div>
@@ -367,6 +404,7 @@ export function KeysSection() {
if (!err) setOpenRow(null); // fold on success — rest state
return err;
}}
onCancel={() => setOpenRow(null)}
onDelete={() => deleteKey(status.id)}
testid={`key-${status.id.toLowerCase()}`}
/>
@@ -381,8 +419,8 @@ export function KeysSection() {
/>
)}
{adding && (
<div className="mt-0.5 mb-1 flex items-center gap-2 pl-6" data-testid={`key-${adding.id}-editor`}>
<span className="font-ui text-xs text-foreground">{adding.label}</span>
<div className="flex items-center gap-2 rounded-sm px-1 py-1.5" data-testid={`key-${adding.id}-editor`}>
<span className="shrink-0 font-ui text-xs text-foreground">{adding.label}</span>
<AddProviderEditor provider={adding} onSave={(v) => void saveNewProvider(adding, v)} onCancel={() => setAdding(null)} error={addError} />
</div>
)}
@@ -402,6 +440,7 @@ export function KeysSection() {
if (!err) setOpenRow(null);
return err;
}}
onCancel={() => setOpenRow(null)}
onDelete={() => deleteKey(status.id)}
testid={`key-${status.id.toLowerCase()}`}
/>
@@ -452,14 +491,15 @@ function AddProviderEditor({
placeholder="Paste key…"
aria-label={`${provider.label} key`}
data-testid={`key-${provider.id}-input`}
className="h-7 w-64 rounded-sm border border-border bg-background px-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50"
autoFocus
className="h-7 min-w-0 flex-1 rounded-sm border border-border bg-background px-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50"
/>
<button
type="button"
onClick={() => value.trim() && onSave(value)}
disabled={!value.trim()}
data-testid={`key-${provider.id}-save`}
className="text-xs text-foreground underline-offset-2 hover:underline disabled:text-muted-foreground/40 disabled:no-underline"
className="shrink-0 text-xs text-foreground underline-offset-2 hover:underline disabled:text-muted-foreground/40 disabled:no-underline"
>
Save
</button>
@@ -467,7 +507,7 @@ function AddProviderEditor({
type="button"
onClick={onCancel}
data-testid={`key-${provider.id}-cancel`}
className="text-xs text-muted-foreground/70 underline-offset-2 hover:text-foreground hover:underline"
className="shrink-0 text-xs text-muted-foreground/70 underline-offset-2 hover:text-foreground hover:underline"
>
Cancel
</button>