Appearance wave: searchable reading-font dropdown + emphasis inks default to text color
Two owner rulings (2026-09-26):
READING FONT — the free-text CommitInput is retired; a searchable
dropdown of the user's INSTALLED fonts over model-menu's exact
fuzzy + arrow-walk + Enter machine (FontMenuList in model-menu.tsx,
fed by window.queryLocalFonts in the desktop shell):
- The DEFAULT row (Source Sans 3) is a fuzzy CANDIDATE, not a ghost:
fontMenuResults (pure, pinned) matches it on empty/source/sans/
default queries only, so an unfiltered list opens with the default
first and a searched list drops it honestly — Enter can never pick
a row that isn't on screen. Picking it writes fontFamily null
(empty = Source Sans 3 semantics preserved).
- The enumeration is CLICK-bound (queryLocalFonts requires user
activation — it cannot be a mount effect), deduped to unique
families, the factory family excluded (the DEFAULT row is it),
cached for the section's life; a failed enumeration falls to the
curated list rather than an empty picker.
- Dev-browser fallback is honest, never fabricated: queryLocalFonts
absent → a small curated list (the app's own knowns + ubiquitous
macOS families) with the footer 'Common families — your installed
list needs the desktop app'. The manual escape lives inside the
menu: a custom-family input + Set commits any family name the
enumeration didn't surface (the old free-text input's heir).
- composeFontFamily (pure, pinned): null/blank → null, else verbatim.
- The trigger gets a Hint (tooltip wave convention); the row's
empty-state hint keeps reading 'Empty = Source Sans 3'.
⚠ electron/main.cjs — one line, FLAGGED FOR PLATFORM: 'local-fonts'
joined the allowedPermissions set (the Local Font Access grant the
picker needs in the desktop shell; the loopback-origin discipline is
untouched).
EMPHASIS INKS — the factory #ee928c/#6fa9d6 are gone: both globals.css
theme blocks now default --lh-ink-strong/--lh-ink-italic to
var(--foreground) — unset emphasis renders the regular text color.
Explicit hex overrides ride unchanged (the appearance seam was already
null-honest); the rows' empty-state labels now say 'same as text'.
Tests (+16, 983 total): fontMenuResults default-row candidacy +
filtering, composeFontFamily null/blank/verbatim, the curated roster
(no factory-family duplicate), FontMenuList SSR shape (default row,
families, effective highlight, custom escape, loading state), the
trigger-replaces-input pin, and the globals.css factory pin (no old
hexes in the declarations; var(--foreground) ×2 in both blocks).
Live-verified on :3001 (dev browser — the curated-fallback path):
dropdown opens with the default row + curated list + honest footer;
search filters ('serif' → CMU Serif only); picking Georgia lands
record + --font-prose live; the default row unsets (var cleared);
the custom input commits Baskerville verbatim; Esc closes the menu
WITHOUT closing the dialog. Ink: strong/em computed = rgb(230,230,230)
(the text color) at factory; #ff0000 through the dialog renders red
and clears back cleanly. The desktop queryLocalFonts path cannot run
in this environment — the seam is click-bound + cached with the
failure fallback, and needs one desktop-shell smoke.
This commit is contained in:
+9
-5
@@ -38,9 +38,12 @@
|
|||||||
--border: #434343;
|
--border: #434343;
|
||||||
--input: #434343;
|
--input: #434343;
|
||||||
--ring: #8d8d8d;
|
--ring: #8d8d8d;
|
||||||
/* Emphasis coloring in teaching prose — ready for the settings UI. */
|
/* Emphasis inks default to the REGULAR TEXT COLOR (owner ruling,
|
||||||
--lh-ink-strong: #ee928c;
|
appearance wave 2026-09-26 — the old #ee928c/#6fa9d6 factory
|
||||||
--lh-ink-italic: #6fa9d6;
|
emphasis colors are gone). Explicit overrides land via the
|
||||||
|
appearance settings vars. */
|
||||||
|
--lh-ink-strong: var(--foreground);
|
||||||
|
--lh-ink-italic: var(--foreground);
|
||||||
/* THE READING SIZE = the ladder's 16px rung (--text-lg). Can't be
|
/* THE READING SIZE = the ladder's 16px rung (--text-lg). Can't be
|
||||||
var()-linked: @theme inline inlines literals into utilities without
|
var()-linked: @theme inline inlines literals into utilities without
|
||||||
emitting runtime vars — so this is the one documented duplication:
|
emitting runtime vars — so this is the one documented duplication:
|
||||||
@@ -73,8 +76,9 @@
|
|||||||
--border: #434343;
|
--border: #434343;
|
||||||
--input: #434343;
|
--input: #434343;
|
||||||
--ring: #8d8d8d;
|
--ring: #8d8d8d;
|
||||||
--lh-ink-strong: #ee928c;
|
/* Same emphasis ruling as the block above: unset = text color. */
|
||||||
--lh-ink-italic: #6fa9d6;
|
--lh-ink-strong: var(--foreground);
|
||||||
|
--lh-ink-italic: var(--foreground);
|
||||||
/* THE READING SIZE = the ladder's 16px rung (--text-lg). Can't be
|
/* THE READING SIZE = the ladder's 16px rung (--text-lg). Can't be
|
||||||
var()-linked: @theme inline inlines literals into utilities without
|
var()-linked: @theme inline inlines literals into utilities without
|
||||||
emitting runtime vars — so this is the one documented duplication:
|
emitting runtime vars — so this is the one documented duplication:
|
||||||
|
|||||||
@@ -14,12 +14,75 @@
|
|||||||
* Graph graying (owner, 2026-09-20) follows the graph viewer's render-panel
|
* Graph graying (owner, 2026-09-20) follows the graph viewer's render-panel
|
||||||
* convention: the whole row dims when a parent is off (stored values
|
* convention: the whole row dims when a parent is off (stored values
|
||||||
* persist but resolve inert — the switch shows stored, the dim effective).
|
* persist but resolve inert — the switch shows stored, the dim effective).
|
||||||
|
*
|
||||||
|
* Reading font (appearance wave, 2026-09-26, owner ruling): a searchable
|
||||||
|
* dropdown of the user's INSTALLED fonts (FontMenuList over model-menu's
|
||||||
|
* machine) fed by window.queryLocalFonts() in the desktop shell — the
|
||||||
|
* enumeration is click-bound (the API requires user activation), deduped
|
||||||
|
* to unique families, the factory family excluded (the DEFAULT row is
|
||||||
|
* it). In a plain dev browser the API is absent → a small curated list
|
||||||
|
* + a footer that says exactly that — never a fabricated system list.
|
||||||
|
* The custom-family input inside the menu is the manual escape path.
|
||||||
|
* Empty/unset keeps meaning Source Sans 3.
|
||||||
|
*
|
||||||
|
* Emphasis inks (same wave): the factory tokens default to the regular
|
||||||
|
* text color now (globals.css); the rows' empty-state labels say "same
|
||||||
|
* as text".
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
import type { AppearanceSettings, GraphSettings } from "@/src/core";
|
import type { AppearanceSettings, GraphSettings } from "@/src/core";
|
||||||
|
import { MenuShell } from "@/components/menu-shell";
|
||||||
|
import { Hint } from "@/components/ui/hint";
|
||||||
import { useAppearance } from "./appearance.tsx";
|
import { useAppearance } from "./appearance.tsx";
|
||||||
|
import { FontMenuList } from "./model-menu";
|
||||||
import { CommitInput, SettingsRow, ThinSelect, Toggle } from "./controls.tsx";
|
import { CommitInput, SettingsRow, ThinSelect, Toggle } from "./controls.tsx";
|
||||||
|
|
||||||
|
/** The factory reading family — the DEFAULT row's identity (unset =
|
||||||
|
* this). Exported for tests. */
|
||||||
|
export const DEFAULT_READING_FONT = "Source Sans 3";
|
||||||
|
|
||||||
|
/** The dev-browser fallback list (queryLocalFonts is absent there):
|
||||||
|
* the app's own knowns plus a handful of ubiquitous macOS families,
|
||||||
|
* honestly LABELED as common — never presented as the system's real
|
||||||
|
* list. The factory family is absent: the DEFAULT row is it. Exported
|
||||||
|
* for tests. */
|
||||||
|
export const CURATED_FALLBACK_FONTS: readonly string[] = [
|
||||||
|
"Arial",
|
||||||
|
"CMU Serif",
|
||||||
|
"Courier New",
|
||||||
|
"Georgia",
|
||||||
|
"Helvetica",
|
||||||
|
"Helvetica Neue",
|
||||||
|
"Menlo",
|
||||||
|
"Times New Roman",
|
||||||
|
"Trebuchet MS",
|
||||||
|
"Verdana",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** window.queryLocalFonts' shape as this section consumes it (family is
|
||||||
|
* the only field used; the API returns one FontData per FACE — the
|
||||||
|
* caller dedupes). */
|
||||||
|
type LocalFontsApi = () => Promise<Array<{ family: string }>>;
|
||||||
|
|
||||||
|
/** The Local Font Access seam — read lazily (SSR has no window, and
|
||||||
|
* render-time access would evaluate the API on the server). */
|
||||||
|
function localFontsApi(): LocalFontsApi | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
return (window as unknown as { queryLocalFonts?: LocalFontsApi }).queryLocalFonts ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compose the reading-font pick: null (or blank) = the factory
|
||||||
|
* Source Sans 3; otherwise the family verbatim (trimmed — the
|
||||||
|
* sanitizer trims again server-side). Pure — exported for tests. */
|
||||||
|
export function composeFontFamily(
|
||||||
|
appearance: AppearanceSettings | undefined,
|
||||||
|
family: string | null,
|
||||||
|
): AppearanceSettings {
|
||||||
|
const value = family?.trim() ?? "";
|
||||||
|
return { ...appearance, fontFamily: value === "" ? null : value };
|
||||||
|
}
|
||||||
|
|
||||||
export function AppearanceSection({
|
export function AppearanceSection({
|
||||||
appearance,
|
appearance,
|
||||||
onSectionChange,
|
onSectionChange,
|
||||||
@@ -32,6 +95,73 @@ export function AppearanceSection({
|
|||||||
const { appearance: resolved } = useAppearance();
|
const { appearance: resolved } = useAppearance();
|
||||||
const md = appearance?.mdRendering;
|
const md = appearance?.mdRendering;
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
const [fontMenuOpen, setFontMenuOpen] = useState(false);
|
||||||
|
const [localFonts, setLocalFonts] = useState<string[] | null | undefined>(undefined);
|
||||||
|
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.
|
||||||
|
const openFontMenu = () => {
|
||||||
|
setFontMenuOpen((open) => {
|
||||||
|
if (!open && localFonts === undefined) {
|
||||||
|
const api = localFontsApi();
|
||||||
|
if (api === null) {
|
||||||
|
setLocalFonts(null);
|
||||||
|
} 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);
|
||||||
|
})
|
||||||
|
.catch(() => setLocalFonts(null));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return !open;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickFont = (family: string | null) => {
|
||||||
|
setFontMenuOpen(false);
|
||||||
|
onSectionChange(composeFontFamily(appearance, family));
|
||||||
|
};
|
||||||
|
|
||||||
|
// The dropdown contract (agent-section's, mirrored): any click
|
||||||
|
// outside the open menu — or an Esc — closes it. Esc is CLAIMED with
|
||||||
|
// stopPropagation so the settings dialog behind does NOT also close.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!fontMenuOpen) return;
|
||||||
|
const onPointerDown = (e: PointerEvent) => {
|
||||||
|
const target = e.target as Node;
|
||||||
|
if (menuAnchorRef.current?.contains(target)) return;
|
||||||
|
if (target instanceof Element && target.closest("[data-lh-menu]")) return;
|
||||||
|
if (target instanceof Element && target.closest('[role="dialog"]')) {
|
||||||
|
setFontMenuOpen(false);
|
||||||
|
}
|
||||||
|
// else: outside the dialog — the dialog's own close handles it.
|
||||||
|
};
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.stopPropagation();
|
||||||
|
setFontMenuOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("pointerdown", onPointerDown);
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("pointerdown", onPointerDown);
|
||||||
|
document.removeEventListener("keydown", onKeyDown);
|
||||||
|
};
|
||||||
|
}, [fontMenuOpen]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SettingsRow label="Mode" hint="Coming soon" testid="row-mode" dimmed>
|
<SettingsRow label="Mode" hint="Coming soon" testid="row-mode" dimmed>
|
||||||
@@ -54,16 +184,51 @@ export function AppearanceSection({
|
|||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
label="Reading font"
|
label="Reading font"
|
||||||
hint="Empty = Source Sans 3"
|
hint={`Empty = ${DEFAULT_READING_FONT}`}
|
||||||
testid="row-font"
|
testid="row-font"
|
||||||
>
|
>
|
||||||
<CommitInput
|
<Hint label="Choose from your installed fonts" side="left">
|
||||||
value={appearance?.fontFamily ?? ""}
|
<span ref={fontMenuOpen ? menuAnchorRef : undefined} className="relative flex items-center gap-2">
|
||||||
placeholder="Source Sans 3"
|
{/* The MenuTrigger look (agent-section's collapsed
|
||||||
ariaLabel="font family"
|
select — it lives unexported there, so the
|
||||||
testid="input-font"
|
classes ride inline; the searchable machine is
|
||||||
onCommit={(v) => onSectionChange({ ...appearance, fontFamily: v === "" ? null : v })}
|
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"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
</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
|
||||||
@@ -116,19 +281,22 @@ export function AppearanceSection({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
<SettingsRow label="Bold color" hint="Empty = #ee928c" testid="row-bold-color">
|
{/* The emphasis inks default to the REGULAR TEXT COLOR now
|
||||||
|
(appearance wave, 2026-09-26) — the empty-state labels say
|
||||||
|
so; explicit hex overrides ride unchanged. */}
|
||||||
|
<SettingsRow label="Bold color" hint="Empty = same as text" testid="row-bold-color">
|
||||||
<CommitInput
|
<CommitInput
|
||||||
value={md?.boldColor ?? ""}
|
value={md?.boldColor ?? ""}
|
||||||
placeholder="#ee928c"
|
placeholder="same as text"
|
||||||
ariaLabel="bold color"
|
ariaLabel="bold color"
|
||||||
testid="input-bold-color"
|
testid="input-bold-color"
|
||||||
onCommit={(v) => onSectionChange({ ...appearance, mdRendering: { ...md, boldColor: v === "" ? null : v } })}
|
onCommit={(v) => onSectionChange({ ...appearance, mdRendering: { ...md, boldColor: v === "" ? null : v } })}
|
||||||
/>
|
/>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
<SettingsRow label="Italic color" hint="Empty = #6fa9d6" testid="row-italic-color">
|
<SettingsRow label="Italic color" hint="Empty = same as text" testid="row-italic-color">
|
||||||
<CommitInput
|
<CommitInput
|
||||||
value={md?.italicColor ?? ""}
|
value={md?.italicColor ?? ""}
|
||||||
placeholder="#6fa9d6"
|
placeholder="same as text"
|
||||||
ariaLabel="italic color"
|
ariaLabel="italic color"
|
||||||
testid="input-italic-color"
|
testid="input-italic-color"
|
||||||
onCommit={(v) => onSectionChange({ ...appearance, mdRendering: { ...md, italicColor: v === "" ? null : v } })}
|
onCommit={(v) => onSectionChange({ ...appearance, mdRendering: { ...md, italicColor: v === "" ? null : v } })}
|
||||||
|
|||||||
@@ -8,6 +8,10 @@
|
|||||||
* opens UP from the bottom bar, the settings rows open DOWN inside the
|
* opens UP from the bottom bar, the settings rows open DOWN inside the
|
||||||
* dialog).
|
* dialog).
|
||||||
*
|
*
|
||||||
|
* The appearance wave (2026-09-26) added the reading-font picker over the
|
||||||
|
* same machine: FontMenuList (families instead of models, plus the
|
||||||
|
* always-offerable DEFAULT row and a custom-family escape input).
|
||||||
|
*
|
||||||
* Keyboard machine (owner polish, 2026-09-17): focus STAYS in the search
|
* Keyboard machine (owner polish, 2026-09-17): focus STAYS in the search
|
||||||
* field — ↑/↓ walk the filtered results (clamped, no wrap), Enter picks
|
* field — ↑/↓ walk the filtered results (clamped, no wrap), Enter picks
|
||||||
* the walked row (or the first result when nothing is walked).
|
* the walked row (or the first result when nothing is walked).
|
||||||
@@ -271,3 +275,165 @@ export function ProviderMenuList({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The reading-font dropdown's row set — the SAME fuzzy + arrow-walk +
|
||||||
|
* Enter machine over font families (appearance wave, 2026-09-26). The
|
||||||
|
* DEFAULT row (null) is a candidate like any family: it matches empty
|
||||||
|
* and "source"/"sans"/"default" queries, so an unfiltered list opens
|
||||||
|
* with the default first and a searched list drops it honestly — Enter
|
||||||
|
* can never pick a row that isn't on screen. Pure — exported for
|
||||||
|
* tests. */
|
||||||
|
export function fontMenuResults(query: string, families: string[]): Array<string | null> {
|
||||||
|
const scored = families
|
||||||
|
.map((f) => ({ f, s: fuzzyScore(query, f) }))
|
||||||
|
.filter((r) => r.s !== null)
|
||||||
|
.sort((a, b) => b.s! - a.s! || a.f.localeCompare(b.f))
|
||||||
|
.map((r) => r.f);
|
||||||
|
// The default row's searchable identity: the factory family + the
|
||||||
|
// word "default" (the suffix it renders with).
|
||||||
|
const defaultScore = fuzzyScore(query, "Source Sans 3", "default");
|
||||||
|
if (query.trim() === "" || defaultScore !== null) return [null, ...scored];
|
||||||
|
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. */
|
||||||
|
export function FontMenuList({
|
||||||
|
families,
|
||||||
|
effectiveFamily,
|
||||||
|
onPick,
|
||||||
|
footer,
|
||||||
|
}: {
|
||||||
|
/** Enumerated font families (already deduped/sorted by the caller);
|
||||||
|
* undefined while the enumeration runs. */
|
||||||
|
families: string[] | undefined;
|
||||||
|
/** The stored override (null = the factory Source Sans 3). */
|
||||||
|
effectiveFamily: string | null;
|
||||||
|
/** Receives the pick: a family string, or null for the default. */
|
||||||
|
onPick: (family: string | null) => void;
|
||||||
|
/** Quiet footer note under the list. */
|
||||||
|
footer?: string;
|
||||||
|
}) {
|
||||||
|
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);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
searchRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (menuIdx === null) return;
|
||||||
|
listRef.current?.querySelector(`[data-idx="${menuIdx}"]`)?.scrollIntoView({ block: "nearest" });
|
||||||
|
}, [menuIdx]);
|
||||||
|
|
||||||
|
const results = fontMenuResults(query, families ?? []);
|
||||||
|
|
||||||
|
const commitCustom = () => {
|
||||||
|
const value = custom.trim();
|
||||||
|
if (value === "") return;
|
||||||
|
onPick(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
ref={searchRef}
|
||||||
|
data-testid="font-search"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => {
|
||||||
|
setQuery(e.target.value);
|
||||||
|
setMenuIdx(null); // new query → the walk restarts on Enter/↓
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (results.length === 0) return;
|
||||||
|
setMenuIdx((i) =>
|
||||||
|
e.key === "ArrowDown"
|
||||||
|
? Math.min((i ?? -1) + 1, results.length - 1)
|
||||||
|
: Math.max((i ?? results.length) - 1, 0),
|
||||||
|
);
|
||||||
|
} else if (e.key === "Enter") {
|
||||||
|
const chosen = results[menuIdx ?? 0];
|
||||||
|
if (chosen !== undefined) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onPick(chosen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Search fonts…"
|
||||||
|
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) => {
|
||||||
|
const isDefault = family === null;
|
||||||
|
const effective = isDefault ? effectiveFamily === null : family === effectiveFamily;
|
||||||
|
return (
|
||||||
|
<li key={family ?? "__default"}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid={isDefault ? "font-option-default" : `font-option-${family.replace(/[^a-z0-9-]/gi, "_")}`}
|
||||||
|
data-idx={i}
|
||||||
|
onClick={() => onPick(family)}
|
||||||
|
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 ${
|
||||||
|
i === menuIdx ? "bg-accent/40 ring-1 ring-inset ring-border" : ""
|
||||||
|
}${effective && i !== menuIdx ? " bg-muted/60" : ""}`}
|
||||||
|
>
|
||||||
|
<span className="truncate text-card-foreground">{family ?? "Source Sans 3"}</span>
|
||||||
|
<span className="shrink-0 text-muted-foreground">{isDefault ? "default" : effective && <span aria-hidden>✓</span>}</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>}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
+2
-1
@@ -228,7 +228,8 @@ function createWindow(url) {
|
|||||||
// 2026-09-19; before this grant every copy silently rejected). Still
|
// 2026-09-19; before this grant every copy silently rejected). Still
|
||||||
// loopback-origin only — Electron's default is auto-approve-everything,
|
// loopback-origin only — Electron's default is auto-approve-everything,
|
||||||
// and we do not ship that.
|
// and we do not ship that.
|
||||||
const allowedPermissions = new Set(["media", "clipboard-sanitized-write"]);
|
// "local-fonts": the appearance section's reading-font picker (2026-09-26).
|
||||||
|
const allowedPermissions = new Set(["media", "clipboard-sanitized-write", "local-fonts"]);
|
||||||
const fromOwnOrigin = (origin, permission) =>
|
const fromOwnOrigin = (origin, permission) =>
|
||||||
origin === allowedOrigin() && allowedPermissions.has(permission);
|
origin === allowedOrigin() && allowedPermissions.has(permission);
|
||||||
session.defaultSession.setPermissionRequestHandler((_wc, permission, callback, details) => {
|
session.defaultSession.setPermissionRequestHandler((_wc, permission, callback, details) => {
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* The appearance wave's font dropdown (2026-09-26): the pure row-set
|
||||||
|
* (fontMenuResults — the DEFAULT row is a fuzzy candidate, so Enter can
|
||||||
|
* never pick a row that isn't on screen), the pick composer (null = the
|
||||||
|
* factory Source Sans 3), the curated dev-browser fallback roster, the
|
||||||
|
* FontMenuList SSR shape, and the emphasis-ink factory ruling (globals.css
|
||||||
|
* no longer ships #ee928c/#6fa9d6 — unset inks are the text color).
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 {
|
||||||
|
CURATED_FALLBACK_FONTS,
|
||||||
|
DEFAULT_READING_FONT,
|
||||||
|
AppearanceSection,
|
||||||
|
composeFontFamily,
|
||||||
|
} from "../components/settings/appearance-section";
|
||||||
|
|
||||||
|
describe("fontMenuResults — the DEFAULT row is a candidate, never a ghost pick", () => {
|
||||||
|
const families = ["Arial", "CMU Serif", "Georgia", "Helvetica Neue", "Menlo"];
|
||||||
|
|
||||||
|
it("empty query → the DEFAULT row first, then every family", () => {
|
||||||
|
assert.deepEqual(fontMenuResults("", families), [null, "Arial", "CMU Serif", "Georgia", "Helvetica Neue", "Menlo"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the default row matches its own identity (source/sans/default), not everything", () => {
|
||||||
|
assert.deepEqual(fontMenuResults("georgia", families), ["Georgia"]);
|
||||||
|
assert.deepEqual(fontMenuResults("menlo", families), ["Menlo"]);
|
||||||
|
// the default row is reachable by search…
|
||||||
|
assert.deepEqual(fontMenuResults("source", families)[0], null);
|
||||||
|
assert.ok(fontMenuResults("default", families).includes(null));
|
||||||
|
// …and drops out of unrelated queries
|
||||||
|
assert.ok(!fontMenuResults("georgia", families).includes(null));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a query matching nothing yields an empty list (the custom input is the escape)", () => {
|
||||||
|
assert.deepEqual(fontMenuResults("zzzz", families), []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("composeFontFamily — null/blank = the factory family, otherwise verbatim", () => {
|
||||||
|
it("null → fontFamily null (Source Sans 3 semantics preserved)", () => {
|
||||||
|
assert.equal(composeFontFamily({ background: "#101010" }, null).fontFamily, null);
|
||||||
|
});
|
||||||
|
it("blank/whitespace → null too", () => {
|
||||||
|
assert.equal(composeFontFamily(undefined, " ").fontFamily, null);
|
||||||
|
});
|
||||||
|
it("a family commits verbatim (trimmed)", () => {
|
||||||
|
assert.equal(composeFontFamily(undefined, " Georgia ").fontFamily, "Georgia");
|
||||||
|
});
|
||||||
|
it("the rest of the section rides along", () => {
|
||||||
|
const next = composeFontFamily({ background: "#101010" }, "Menlo");
|
||||||
|
assert.equal(next.background, "#101010");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the curated dev-browser fallback", () => {
|
||||||
|
it("is the app's knowns + ubiquitous macOS families — and never a fabricated system list member for the factory family", () => {
|
||||||
|
assert.ok(CURATED_FALLBACK_FONTS.includes("CMU Serif"));
|
||||||
|
assert.ok(CURATED_FALLBACK_FONTS.includes("Georgia"));
|
||||||
|
// the factory family lives ONLY as the DEFAULT row — never a
|
||||||
|
// duplicated family row
|
||||||
|
assert.ok(!CURATED_FALLBACK_FONTS.includes(DEFAULT_READING_FONT));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("FontMenuList (thin SSR)", () => {
|
||||||
|
it("renders the DEFAULT row, the families, and the custom escape", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<FontMenuList families={["Georgia", "Menlo"]} effectiveFamily={null} onPick={() => {}} />,
|
||||||
|
);
|
||||||
|
assert.ok(html.includes('data-testid="font-option-default"'), "the default row");
|
||||||
|
assert.ok(html.includes("default</span>"), "the default row carries its mark");
|
||||||
|
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");
|
||||||
|
assert.ok(html.includes("Search fonts…"), "the search placeholder");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the effective family's row carries the quiet highlight; the default row does when unset", () => {
|
||||||
|
const unset = renderToStaticMarkup(
|
||||||
|
<FontMenuList families={["Georgia", "Menlo"]} effectiveFamily={null} onPick={() => {}} />,
|
||||||
|
);
|
||||||
|
const defaultRow = unset.split('data-testid="font-option-default"')[1]?.split("</button>")[0] ?? "";
|
||||||
|
assert.ok(defaultRow.includes("bg-muted/60"), "unset → the default row is the effective one");
|
||||||
|
|
||||||
|
const menlo = renderToStaticMarkup(
|
||||||
|
<FontMenuList families={["Georgia", "Menlo"]} effectiveFamily="Menlo" onPick={() => {}} />,
|
||||||
|
);
|
||||||
|
const menloRow = menlo.split('data-testid="font-option-Menlo"')[1]?.split("</button>")[0] ?? "";
|
||||||
|
const georgiaRow = menlo.split('data-testid="font-option-Georgia"')[1]?.split("</button>")[0] ?? "";
|
||||||
|
assert.ok(menloRow.includes("bg-muted/60"), "the stored family highlights");
|
||||||
|
assert.ok(!georgiaRow.includes("bg-muted/60"), "others do not");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("undefined families = the enumerating state (Loading fonts…), not an empty picker", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<FontMenuList families={undefined} effectiveFamily={null} onPick={() => {}} />,
|
||||||
|
);
|
||||||
|
assert.ok(html.includes("Loading fonts…"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the appearance section's font control (thin SSR)", () => {
|
||||||
|
it("the reading-font row renders the dropdown TRIGGER — the old free-text input is gone", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<AppearanceSection appearance={undefined} onSectionChange={() => {}} />,
|
||||||
|
);
|
||||||
|
assert.ok(html.includes('data-testid="font-trigger"'), "the trigger");
|
||||||
|
assert.ok(html.includes(DEFAULT_READING_FONT), "unset shows the factory family");
|
||||||
|
assert.ok(html.includes('aria-expanded="false"'), "the collapsed state");
|
||||||
|
// the old CommitInput is retired
|
||||||
|
assert.ok(!html.includes('data-testid="input-font"'));
|
||||||
|
assert.ok(!html.includes('placeholder="Source Sans 3"'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a stored family shows in the trigger (the record never lies)", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<AppearanceSection appearance={{ fontFamily: "Georgia" }} onSectionChange={() => {}} />,
|
||||||
|
);
|
||||||
|
assert.ok(html.includes("Georgia"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the emphasis-ink factory ruling (globals.css)", () => {
|
||||||
|
it("the old emphasis hexes are GONE from the ink token declarations", () => {
|
||||||
|
const css = fs.readFileSync("app/globals.css", "utf-8");
|
||||||
|
assert.ok(!css.includes("--lh-ink-strong: #ee928c"), "bold factory is not the old orange");
|
||||||
|
assert.ok(!css.includes("--lh-ink-italic: #6fa9d6"), "italic factory is not the old blue");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("both blocks default the inks to the regular text color", () => {
|
||||||
|
const css = fs.readFileSync("app/globals.css", "utf-8");
|
||||||
|
assert.equal((css.match(/--lh-ink-strong: var\(--foreground\)/g) ?? []).length, 2, "both theme blocks");
|
||||||
|
assert.equal((css.match(/--lh-ink-italic: var\(--foreground\)/g) ?? []).length, 2, "both theme blocks");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the appearance rows' empty-state labels say 'same as text'", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<AppearanceSection appearance={undefined} onSectionChange={() => {}} />,
|
||||||
|
);
|
||||||
|
assert.equal((html.match(/Empty = same as text/g) ?? []).length, 2, "bold + italic rows");
|
||||||
|
assert.ok(!html.includes("#ee928c"));
|
||||||
|
assert.ok(!html.includes("#6fa9d6"));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -206,8 +206,12 @@ describe("AppearanceSection (thin SSR)", () => {
|
|||||||
it("hex inputs carry the factory placeholders; prose size resolves against the factory", () => {
|
it("hex inputs carry the factory placeholders; prose size resolves against the factory", () => {
|
||||||
const html = renderToStaticMarkup(<AppearanceSection appearance={undefined} onSectionChange={() => {}} />);
|
const html = renderToStaticMarkup(<AppearanceSection appearance={undefined} onSectionChange={() => {}} />);
|
||||||
assert.ok(html.includes(`placeholder="#141414"`));
|
assert.ok(html.includes(`placeholder="#141414"`));
|
||||||
assert.ok(html.includes(`placeholder="Source Sans 3"`));
|
// The reading font is the searchable dropdown now (font-menu
|
||||||
assert.ok(html.includes(`placeholder="#ee928c"`));
|
// wave): its trigger shows the factory family; the emphasis
|
||||||
|
// inks' empty state is "same as text" (the old hexes died with
|
||||||
|
// the factory token change).
|
||||||
|
assert.ok(html.includes('data-testid="font-trigger"'));
|
||||||
|
assert.equal((html.match(/Empty = same as text/g) ?? []).length, 2);
|
||||||
assert.ok(html.includes("16px"));
|
assert.ok(html.includes("16px"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user