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:
@@ -97,32 +97,49 @@ export function AppearanceSection({
|
|||||||
|
|
||||||
// The font dropdown's open state + its enumeration cache. undefined =
|
// The font dropdown's open state + its enumeration cache. undefined =
|
||||||
// not enumerated yet (desktop: click-bound queryLocalFonts pending);
|
// not enumerated yet (desktop: click-bound queryLocalFonts pending);
|
||||||
// null = the API is absent (dev browser → the curated fallback, footed
|
// null = enumeration UNAVAILABLE (the API is absent in a plain
|
||||||
// honestly); a list = the deduped families.
|
// 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 [fontMenuOpen, setFontMenuOpen] = useState(false);
|
||||||
const [localFonts, setLocalFonts] = useState<string[] | null | undefined>(undefined);
|
const [localFonts, setLocalFonts] = useState<string[] | null | undefined>(undefined);
|
||||||
|
const [enumEmpty, setEnumEmpty] = useState(false);
|
||||||
const menuAnchorRef = useRef<HTMLSpanElement>(null);
|
const menuAnchorRef = useRef<HTMLSpanElement>(null);
|
||||||
|
|
||||||
// The enumeration runs INSIDE the trigger click — queryLocalFonts
|
// The enumeration runs INSIDE the trigger click — queryLocalFonts
|
||||||
// requires user activation, so it cannot be a mount effect. First
|
// requires user activation, so it cannot be a mount effect. First
|
||||||
// open only: the families are cached for the section's life (font
|
// open only: the families are cached for the section's life (font
|
||||||
// installs mid-dialog are not a case); a failed enumeration falls
|
// installs mid-dialog are not a case); ANY unavailable enumeration
|
||||||
// back to the curated list rather than an empty picker.
|
// (absent / rejected / empty) falls back to the curated list rather
|
||||||
|
// than an empty picker.
|
||||||
const openFontMenu = () => {
|
const openFontMenu = () => {
|
||||||
setFontMenuOpen((open) => {
|
setFontMenuOpen((open) => {
|
||||||
if (!open && localFonts === undefined) {
|
if (!open && localFonts === undefined) {
|
||||||
const api = localFontsApi();
|
const api = localFontsApi();
|
||||||
if (api === null) {
|
if (api === null) {
|
||||||
setLocalFonts(null);
|
setLocalFonts(null);
|
||||||
|
setEnumEmpty(false);
|
||||||
} else {
|
} else {
|
||||||
void api()
|
void api()
|
||||||
.then((fonts) => {
|
.then((fonts) => {
|
||||||
const families = [...new Set(fonts.map((f) => f.family))]
|
const families = [...new Set(fonts.map((f) => f.family))]
|
||||||
.filter((f) => f !== DEFAULT_READING_FONT)
|
.filter((f) => f !== DEFAULT_READING_FONT)
|
||||||
.sort((a, b) => a.localeCompare(b));
|
.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;
|
return !open;
|
||||||
@@ -187,48 +204,63 @@ export function AppearanceSection({
|
|||||||
hint={`Empty = ${DEFAULT_READING_FONT}`}
|
hint={`Empty = ${DEFAULT_READING_FONT}`}
|
||||||
testid="row-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">
|
||||||
<span ref={fontMenuOpen ? menuAnchorRef : undefined} className="relative flex items-center gap-2">
|
{/* The MenuTrigger look (agent-section's collapsed
|
||||||
{/* The MenuTrigger look (agent-section's collapsed
|
select — it lives unexported there, so the
|
||||||
select — it lives unexported there, so the
|
classes ride inline; the searchable machine is
|
||||||
classes ride inline; the searchable machine is
|
the shared part). The tooltip rides the CLOSED
|
||||||
the shared part). */}
|
button ONLY (owner ruling 2026-09-26: never while
|
||||||
<button
|
the dropdown is out — it popped over the open
|
||||||
type="button"
|
menu when the wrap covered the whole anchor
|
||||||
aria-label="Reading font"
|
span). */}
|
||||||
aria-expanded={fontMenuOpen}
|
{(() => {
|
||||||
data-testid="font-trigger"
|
const triggerButton = (
|
||||||
onClick={openFontMenu}
|
<button
|
||||||
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"
|
type="button"
|
||||||
>
|
aria-label="Reading font"
|
||||||
<span className="min-w-0 flex-1 truncate">{appearance?.fontFamily || DEFAULT_READING_FONT}</span>
|
aria-expanded={fontMenuOpen}
|
||||||
<span aria-hidden className="text-muted-foreground">
|
data-testid="font-trigger"
|
||||||
▾
|
onClick={openFontMenu}
|
||||||
</span>
|
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"
|
||||||
</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"
|
|
||||||
>
|
>
|
||||||
<FontMenuList
|
<span className="min-w-0 flex-1 truncate">{appearance?.fontFamily || DEFAULT_READING_FONT}</span>
|
||||||
families={localFonts === undefined ? undefined : (localFonts ?? [...CURATED_FALLBACK_FONTS])}
|
<span aria-hidden className="text-muted-foreground">
|
||||||
effectiveFamily={appearance?.fontFamily ?? null}
|
▾
|
||||||
onPick={pickFont}
|
</span>
|
||||||
footer={
|
</button>
|
||||||
localFonts === undefined
|
);
|
||||||
? undefined
|
return fontMenuOpen ? (
|
||||||
: localFonts === null
|
triggerButton
|
||||||
? "Common families — your installed list needs the desktop app"
|
) : (
|
||||||
: "Your installed fonts"
|
<Hint label="Choose from your installed fonts" side="left">
|
||||||
}
|
{triggerButton}
|
||||||
/>
|
</Hint>
|
||||||
</MenuShell>
|
);
|
||||||
)}
|
})()}
|
||||||
</span>
|
{fontMenuOpen && (
|
||||||
</Hint>
|
<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>
|
||||||
<SettingsRow label="Reading font size" hint={`${resolved.proseFontSize}px`} testid="row-prose-size">
|
<SettingsRow label="Reading font size" hint={`${resolved.proseFontSize}px`} testid="row-prose-size">
|
||||||
<CommitInput
|
<CommitInput
|
||||||
|
|||||||
@@ -296,12 +296,23 @@ export function fontMenuResults(query: string, families: string[]): Array<string
|
|||||||
return scored;
|
return scored;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The reading-font dropdown body — search + list + the custom-family
|
/** The custom-family action row's decision (pure, 2026-09-26): a typed
|
||||||
* escape (the old free-text input lives here as the last resort: any
|
* query that matches NO family becomes the "Use '<query>'" custom pick —
|
||||||
* family name the enumeration didn't surface). `families ===
|
* a matching family is the better pick, so the action row only exists
|
||||||
* undefined` is the enumerating state (desktop's queryLocalFonts); the
|
* when there is nothing else to pick. Exported for tests. */
|
||||||
* curated fallback list arrives as plain families with a footer that
|
export function customFamilyFor(query: string, families: string[]): string | null {
|
||||||
* says so honestly. */
|
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({
|
export function FontMenuList({
|
||||||
families,
|
families,
|
||||||
effectiveFamily,
|
effectiveFamily,
|
||||||
@@ -320,7 +331,6 @@ export function FontMenuList({
|
|||||||
}) {
|
}) {
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [menuIdx, setMenuIdx] = useState<number | null>(null);
|
const [menuIdx, setMenuIdx] = useState<number | null>(null);
|
||||||
const [custom, setCustom] = useState("");
|
|
||||||
const searchRef = useRef<HTMLInputElement>(null);
|
const searchRef = useRef<HTMLInputElement>(null);
|
||||||
const listRef = useRef<HTMLUListElement>(null);
|
const listRef = useRef<HTMLUListElement>(null);
|
||||||
|
|
||||||
@@ -334,12 +344,10 @@ export function FontMenuList({
|
|||||||
}, [menuIdx]);
|
}, [menuIdx]);
|
||||||
|
|
||||||
const results = fontMenuResults(query, families ?? []);
|
const results = fontMenuResults(query, families ?? []);
|
||||||
|
// The custom-family action row: only when a typed query matches
|
||||||
const commitCustom = () => {
|
// nothing (a matching family is the better pick). Enter picks it.
|
||||||
const value = custom.trim();
|
const customFamily = customFamilyFor(query, families ?? []);
|
||||||
if (value === "") return;
|
const walkable: Array<string | null> = customFamily !== null ? ["__custom__"] : results;
|
||||||
onPick(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -355,18 +363,18 @@ export function FontMenuList({
|
|||||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (results.length === 0) return;
|
if (walkable.length === 0) return;
|
||||||
setMenuIdx((i) =>
|
setMenuIdx((i) =>
|
||||||
e.key === "ArrowDown"
|
e.key === "ArrowDown"
|
||||||
? Math.min((i ?? -1) + 1, results.length - 1)
|
? Math.min((i ?? -1) + 1, walkable.length - 1)
|
||||||
: Math.max((i ?? results.length) - 1, 0),
|
: Math.max((i ?? walkable.length) - 1, 0),
|
||||||
);
|
);
|
||||||
} else if (e.key === "Enter") {
|
} else if (e.key === "Enter") {
|
||||||
const chosen = results[menuIdx ?? 0];
|
const chosen = walkable[menuIdx ?? 0];
|
||||||
if (chosen !== undefined) {
|
if (chosen !== undefined) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
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"
|
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 && <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">
|
<ul ref={listRef} data-testid="font-list" className="flex max-h-72 flex-col overflow-y-auto px-1">
|
||||||
{families &&
|
{families &&
|
||||||
results.map((family, i) => {
|
results.map((family, i) => {
|
||||||
@@ -399,40 +404,23 @@ export function FontMenuList({
|
|||||||
</li>
|
</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>
|
</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>}
|
{footer && <div className="px-2 pt-1.5 text-xs text-muted-foreground">{footer}</div>}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import assert from "node:assert/strict";
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
import { FontMenuList, fontMenuResults } from "../components/settings/model-menu";
|
import { FontMenuList, customFamilyFor, fontMenuResults } from "../components/settings/model-menu";
|
||||||
import {
|
import {
|
||||||
CURATED_FALLBACK_FONTS,
|
CURATED_FALLBACK_FONTS,
|
||||||
DEFAULT_READING_FONT,
|
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-Georgia"'), "a family row");
|
||||||
assert.ok(html.includes('data-testid="font-option-Menlo"'), "another 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-search"'), "the search field");
|
||||||
assert.ok(html.includes('data-testid="font-custom-input"'), "the custom-family escape");
|
// ONE field (owner ask 2026-09-26: "why two?") — the custom-family
|
||||||
assert.ok(html.includes('data-testid="font-custom-set"'), "the Set button");
|
// 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");
|
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"));
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user