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 =
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user