diff --git a/components/settings/appearance-section.tsx b/components/settings/appearance-section.tsx index eea91e9..b661e83 100644 --- a/components/settings/appearance-section.tsx +++ b/components/settings/appearance-section.tsx @@ -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(undefined); + const [enumEmpty, setEnumEmpty] = useState(false); const menuAnchorRef = useRef(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" > - - - {/* The MenuTrigger look (agent-section's collapsed - select — it lives unexported there, so the - classes ride inline; the searchable machine is - the shared part). */} - - {fontMenuOpen && ( - + {/* 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 = ( + + ); + return fontMenuOpen ? ( + triggerButton + ) : ( + + {triggerButton} + + ); + })()} + {fontMenuOpen && ( + + + + )} + '" 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 ''" 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(null); - const [custom, setCustom] = useState(""); const searchRef = useRef(null); const listRef = useRef(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 = 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 &&
Loading fonts…
} - {families && results.length === 0 && ( -
No fonts match — type a family below.
- )}
    {families && results.map((family, i) => { @@ -399,40 +404,23 @@ export function FontMenuList({ ); })} + {families && customFamily !== null && ( +
  • + +
  • + )}
- {/* 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. */} -
- 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" - /> - -
{footer &&
{footer}
} ); diff --git a/tests/font-menu.test.tsx b/tests/font-menu.test.tsx index 43a204a..be1b645 100644 --- a/tests/font-menu.test.tsx +++ b/tests/font-menu.test.tsx @@ -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 ''" 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); + }); +});