Font picker fix round: one search field, honest empty-enumeration fallback, tooltip only on the closed trigger

- FontMenuList: the separate custom-family input is gone; the search
  field absorbed its job — a no-match query renders a 'Use <query>'
  action row (customFamilyFor, pure, pinned), Enter picks it
- openFontMenu: an empty-but-successful queryLocalFonts() now falls back
  to the curated list (Electron 44 grants local-fonts and still
  enumerates zero fonts — reproduced with a hidden-Window probe); the
  footer split states WHY: 'could not be read' vs 'needs the desktop app'
- The Hint tooltip wraps the trigger button only and unmounts while the
  menu is open — it no longer pops over the dropdown
This commit is contained in:
amosblomqvist
2026-09-25 20:12:57 +03:00
parent ad61301324
commit ac1c1a2818
3 changed files with 148 additions and 104 deletions
+79 -47
View File
@@ -97,32 +97,49 @@ export function AppearanceSection({
// The font dropdown's open state + its enumeration cache. undefined =
// not enumerated yet (desktop: click-bound queryLocalFonts pending);
// null = the API is absent (dev browser → the curated fallback, footed
// honestly); a list = the deduped families.
// null = enumeration UNAVAILABLE (the API is absent in a plain
// browser, rejected, or resolved EMPTY — Electron 44's local-fonts
// grant passes and the enumeration still yields zero fonts,
// reproduced 2026-09-26) → the curated fallback; a list = the deduped
// families. enumEmpty distinguishes WHY it fell back, for the footer.
const [fontMenuOpen, setFontMenuOpen] = useState(false);
const [localFonts, setLocalFonts] = useState<string[] | null | undefined>(undefined);
const [enumEmpty, setEnumEmpty] = useState(false);
const menuAnchorRef = useRef<HTMLSpanElement>(null);
// The enumeration runs INSIDE the trigger click — queryLocalFonts
// requires user activation, so it cannot be a mount effect. First
// open only: the families are cached for the section's life (font
// installs mid-dialog are not a case); a failed enumeration falls
// back to the curated list rather than an empty picker.
// installs mid-dialog are not a case); ANY unavailable enumeration
// (absent / rejected / empty) falls back to the curated list rather
// than an empty picker.
const openFontMenu = () => {
setFontMenuOpen((open) => {
if (!open && localFonts === undefined) {
const api = localFontsApi();
if (api === null) {
setLocalFonts(null);
setEnumEmpty(false);
} else {
void api()
.then((fonts) => {
const families = [...new Set(fonts.map((f) => f.family))]
.filter((f) => f !== DEFAULT_READING_FONT)
.sort((a, b) => a.localeCompare(b));
setLocalFonts(families);
if (families.length === 0) {
// Resolved-but-empty: not a failure the catch sees —
// an honest unavailable (this Electron 44 build
// enumerates nothing even granted).
setLocalFonts(null);
setEnumEmpty(true);
} else {
setLocalFonts(families);
}
})
.catch(() => setLocalFonts(null));
.catch(() => {
setLocalFonts(null);
setEnumEmpty(false);
});
}
}
return !open;
@@ -187,48 +204,63 @@ export function AppearanceSection({
hint={`Empty = ${DEFAULT_READING_FONT}`}
testid="row-font"
>
<Hint label="Choose from your installed fonts" side="left">
<span ref={fontMenuOpen ? menuAnchorRef : undefined} className="relative flex items-center gap-2">
{/* The MenuTrigger look (agent-section's collapsed
select — it lives unexported there, so the
classes ride inline; the searchable machine is
the shared part). */}
<button
type="button"
aria-label="Reading font"
aria-expanded={fontMenuOpen}
data-testid="font-trigger"
onClick={openFontMenu}
className="flex h-7 min-w-20 items-center gap-1 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50"
>
<span className="min-w-0 flex-1 truncate">{appearance?.fontFamily || DEFAULT_READING_FONT}</span>
<span aria-hidden className="text-muted-foreground">
▾
</span>
</button>
{fontMenuOpen && (
<MenuShell
preferredSide="down"
anchorTestid="font-trigger"
testid="font-menu"
menuClass="flex w-[18rem] flex-col gap-0.5 rounded-xl border border-border bg-card p-2 text-xs shadow-xl"
<span ref={fontMenuOpen ? menuAnchorRef : undefined} className="relative flex items-center gap-2">
{/* The MenuTrigger look (agent-section's collapsed
select — it lives unexported there, so the
classes ride inline; the searchable machine is
the shared part). The tooltip rides the CLOSED
button ONLY (owner ruling 2026-09-26: never while
the dropdown is out — it popped over the open
menu when the wrap covered the whole anchor
span). */}
{(() => {
const triggerButton = (
<button
type="button"
aria-label="Reading font"
aria-expanded={fontMenuOpen}
data-testid="font-trigger"
onClick={openFontMenu}
className="flex h-7 min-w-20 items-center gap-1 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50"
>
<FontMenuList
families={localFonts === undefined ? undefined : (localFonts ?? [...CURATED_FALLBACK_FONTS])}
effectiveFamily={appearance?.fontFamily ?? null}
onPick={pickFont}
footer={
localFonts === undefined
? undefined
: localFonts === null
? "Common families — your installed list needs the desktop app"
: "Your installed fonts"
}
/>
</MenuShell>
)}
</span>
</Hint>
<span className="min-w-0 flex-1 truncate">{appearance?.fontFamily || DEFAULT_READING_FONT}</span>
<span aria-hidden className="text-muted-foreground">
▾
</span>
</button>
);
return fontMenuOpen ? (
triggerButton
) : (
<Hint label="Choose from your installed fonts" side="left">
{triggerButton}
</Hint>
);
})()}
{fontMenuOpen && (
<MenuShell
preferredSide="down"
anchorTestid="font-trigger"
testid="font-menu"
menuClass="flex w-[18rem] flex-col gap-0.5 rounded-xl border border-border bg-card p-2 text-xs shadow-xl"
>
<FontMenuList
families={localFonts === undefined ? undefined : (localFonts ?? [...CURATED_FALLBACK_FONTS])}
effectiveFamily={appearance?.fontFamily ?? null}
onPick={pickFont}
footer={
localFonts === undefined
? undefined
: localFonts === null
? enumEmpty
? "Common families — installed fonts could not be read"
: "Common families — your installed list needs the desktop app"
: "Your installed fonts"
}
/>
</MenuShell>
)}
</span>
</SettingsRow>
<SettingsRow label="Reading font size" hint={`${resolved.proseFontSize}px`} testid="row-prose-size">
<CommitInput
+42 -54
View File
@@ -296,12 +296,23 @@ export function fontMenuResults(query: string, families: string[]): Array<string
return scored;
}
/** The reading-font dropdown body — search + list + the custom-family
* escape (the old free-text input lives here as the last resort: any
* family name the enumeration didn't surface). `families ===
* undefined` is the enumerating state (desktop's queryLocalFonts); the
* curated fallback list arrives as plain families with a footer that
* says so honestly. */
/** The custom-family action row's decision (pure, 2026-09-26): a typed
* query that matches NO family becomes the "Use '<query>'" custom pick —
* a matching family is the better pick, so the action row only exists
* when there is nothing else to pick. Exported for tests. */
export function customFamilyFor(query: string, families: string[]): string | null {
const trimmed = query.trim();
if (trimmed === "") return null;
return fontMenuResults(query, families).length === 0 ? trimmed : null;
}
/** The reading-font dropdown body — ONE search field (owner ask
* 2026-09-26: "why two?"): the fuzzy machine filters the families, and
* when nothing matches, a "Use '<query>'" action row commits the typed
* text as a custom family — the old separate custom input is gone, the
* search field absorbed its job. `families === undefined` is the
* enumerating state; the curated fallback arrives as plain families with
* a footer that says so honestly. */
export function FontMenuList({
families,
effectiveFamily,
@@ -320,7 +331,6 @@ export function FontMenuList({
}) {
const [query, setQuery] = useState("");
const [menuIdx, setMenuIdx] = useState<number | null>(null);
const [custom, setCustom] = useState("");
const searchRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);
@@ -334,12 +344,10 @@ export function FontMenuList({
}, [menuIdx]);
const results = fontMenuResults(query, families ?? []);
const commitCustom = () => {
const value = custom.trim();
if (value === "") return;
onPick(value);
};
// The custom-family action row: only when a typed query matches
// nothing (a matching family is the better pick). Enter picks it.
const customFamily = customFamilyFor(query, families ?? []);
const walkable: Array<string | null> = customFamily !== null ? ["__custom__"] : results;
return (
<>
@@ -355,18 +363,18 @@ export function FontMenuList({
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
e.stopPropagation();
if (results.length === 0) return;
if (walkable.length === 0) return;
setMenuIdx((i) =>
e.key === "ArrowDown"
? Math.min((i ?? -1) + 1, results.length - 1)
: Math.max((i ?? results.length) - 1, 0),
? Math.min((i ?? -1) + 1, walkable.length - 1)
: Math.max((i ?? walkable.length) - 1, 0),
);
} else if (e.key === "Enter") {
const chosen = results[menuIdx ?? 0];
const chosen = walkable[menuIdx ?? 0];
if (chosen !== undefined) {
e.preventDefault();
e.stopPropagation();
onPick(chosen);
onPick(chosen === "__custom__" ? customFamily : chosen);
}
}
}}
@@ -374,9 +382,6 @@ export function FontMenuList({
className="lh-input mb-1.5 w-full rounded-lg border bg-background px-2 py-1.5 text-foreground outline-none focus:ring-1 focus:ring-ring"
/>
{!families && <div className="px-2 py-3 text-center text-muted-foreground">Loading fonts…</div>}
{families && results.length === 0 && (
<div className="px-2 py-3 text-center text-muted-foreground">No fonts match — type a family below.</div>
)}
<ul ref={listRef} data-testid="font-list" className="flex max-h-72 flex-col overflow-y-auto px-1">
{families &&
results.map((family, i) => {
@@ -399,40 +404,23 @@ export function FontMenuList({
</li>
);
})}
{families && customFamily !== null && (
<li>
<button
type="button"
data-testid="font-option-custom"
data-idx={0}
onClick={() => onPick(customFamily)}
className={`flex w-full items-center justify-between gap-3 rounded-lg px-2 py-1.5 text-left outline-none transition-colors hover:bg-muted ${
menuIdx === 0 ? "bg-accent/40 ring-1 ring-inset ring-border" : ""
}`}
>
<span className="truncate text-card-foreground">Use “{customFamily}”</span>
<span className="shrink-0 text-muted-foreground">custom</span>
</button>
</li>
)}
</ul>
{/* The manual escape path — the old free-text input, living
inside the dropdown as its last row. Any family name the
enumeration didn't surface commits verbatim. */}
<div className="mt-1 flex items-center gap-1.5 border-t px-1 pt-1.5">
<input
data-testid="font-custom-input"
value={custom}
onChange={(e) => setCustom(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
commitCustom();
}
}}
placeholder="Type a family name…"
aria-label="Custom font family"
className="lh-input min-w-0 flex-1 rounded-lg border bg-background px-2 py-1.5 text-foreground outline-none focus:ring-1 focus:ring-ring"
/>
<button
type="button"
data-testid="font-custom-set"
onClick={commitCustom}
disabled={custom.trim() === ""}
className={`shrink-0 rounded-lg px-2 py-1.5 text-xs underline-offset-2 ${
custom.trim() === ""
? "cursor-not-allowed text-muted-foreground/40"
: "text-foreground hover:underline"
}`}
>
Set
</button>
</div>
{footer && <div className="px-2 pt-1.5 text-xs text-muted-foreground">{footer}</div>}
</>
);
+27 -3
View File
@@ -11,7 +11,7 @@ import assert from "node:assert/strict";
import fs from "node:fs";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, it } from "node:test";
import { FontMenuList, fontMenuResults } from "../components/settings/model-menu";
import { FontMenuList, customFamilyFor, fontMenuResults } from "../components/settings/model-menu";
import {
CURATED_FALLBACK_FONTS,
DEFAULT_READING_FONT,
@@ -77,8 +77,12 @@ describe("FontMenuList (thin SSR)", () => {
assert.ok(html.includes('data-testid="font-option-Georgia"'), "a family row");
assert.ok(html.includes('data-testid="font-option-Menlo"'), "another family row");
assert.ok(html.includes('data-testid="font-search"'), "the search field");
assert.ok(html.includes('data-testid="font-custom-input"'), "the custom-family escape");
assert.ok(html.includes('data-testid="font-custom-set"'), "the Set button");
// ONE field (owner ask 2026-09-26: "why two?") — the custom-family
// input is gone; the search field absorbed its job via the
// "Use '<query>'" action row.
assert.ok(!html.includes('data-testid="font-custom-input"'), "no separate custom input");
assert.ok(!html.includes('data-testid="font-custom-set"'), "no Set button");
assert.ok(!html.includes("Type a family name"), "no second field placeholder");
assert.ok(html.includes("Search fonts…"), "the search placeholder");
});
@@ -149,3 +153,23 @@ describe("the emphasis-ink factory ruling (globals.css)", () => {
assert.ok(!html.includes("#6fa9d6"));
});
});
describe("customFamilyFor — the single-field custom escape (2026-09-26)", () => {
const families = ["Arial", "CMU Serif", "Georgia", "Menlo"];
it("a query matching nothing becomes the custom pick", () => {
assert.equal(customFamilyFor("Baskerville", families), "Baskerville");
assert.equal(customFamilyFor(" zzz ", families), "zzz");
});
it("a matching family is the better pick — no action row", () => {
assert.equal(customFamilyFor("georgia", families), null);
// the default row matches too — "source"/"sans"/"default" queries pick it
assert.equal(customFamilyFor("source", families), null);
});
it("an empty query is never a custom pick", () => {
assert.equal(customFamilyFor("", families), null);
assert.equal(customFamilyFor(" ", families), null);
});
});