From ad613013247edd130b953df27755ac244f05e68a Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Fri, 25 Sep 2026 20:02:19 +0300 Subject: [PATCH] Appearance wave: searchable reading-font dropdown + emphasis inks default to text color MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/globals.css | 14 +- components/settings/appearance-section.tsx | 192 +++++++++++++++++++-- components/settings/model-menu.tsx | 166 ++++++++++++++++++ electron/main.cjs | 3 +- tests/font-menu.test.tsx | 151 ++++++++++++++++ tests/render-settings.test.tsx | 8 +- 6 files changed, 514 insertions(+), 20 deletions(-) create mode 100644 tests/font-menu.test.tsx diff --git a/app/globals.css b/app/globals.css index 36c7407..5e801bf 100644 --- a/app/globals.css +++ b/app/globals.css @@ -38,9 +38,12 @@ --border: #434343; --input: #434343; --ring: #8d8d8d; - /* Emphasis coloring in teaching prose — ready for the settings UI. */ - --lh-ink-strong: #ee928c; - --lh-ink-italic: #6fa9d6; + /* Emphasis inks default to the REGULAR TEXT COLOR (owner ruling, + appearance wave 2026-09-26 — the old #ee928c/#6fa9d6 factory + 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 var()-linked: @theme inline inlines literals into utilities without emitting runtime vars — so this is the one documented duplication: @@ -73,8 +76,9 @@ --border: #434343; --input: #434343; --ring: #8d8d8d; - --lh-ink-strong: #ee928c; - --lh-ink-italic: #6fa9d6; + /* Same emphasis ruling as the block above: unset = text color. */ + --lh-ink-strong: var(--foreground); + --lh-ink-italic: var(--foreground); /* THE READING SIZE = the ladder's 16px rung (--text-lg). Can't be var()-linked: @theme inline inlines literals into utilities without emitting runtime vars — so this is the one documented duplication: diff --git a/components/settings/appearance-section.tsx b/components/settings/appearance-section.tsx index 64ff031..eea91e9 100644 --- a/components/settings/appearance-section.tsx +++ b/components/settings/appearance-section.tsx @@ -14,12 +14,75 @@ * 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 * 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 { MenuShell } from "@/components/menu-shell"; +import { Hint } from "@/components/ui/hint"; import { useAppearance } from "./appearance.tsx"; +import { FontMenuList } from "./model-menu"; 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>; + +/** 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({ appearance, onSectionChange, @@ -32,6 +95,73 @@ export function AppearanceSection({ const { appearance: resolved } = useAppearance(); 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(undefined); + 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. + 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 ( <> @@ -54,16 +184,51 @@ export function AppearanceSection({ - onSectionChange({ ...appearance, fontFamily: v === "" ? null : v })} - /> + + + {/* 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 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. */} + onSectionChange({ ...appearance, mdRendering: { ...md, boldColor: v === "" ? null : v } })} /> - + onSectionChange({ ...appearance, mdRendering: { ...md, italicColor: v === "" ? null : v } })} diff --git a/components/settings/model-menu.tsx b/components/settings/model-menu.tsx index 4c1ec86..17cec41 100644 --- a/components/settings/model-menu.tsx +++ b/components/settings/model-menu.tsx @@ -8,6 +8,10 @@ * opens UP from the bottom bar, the settings rows open DOWN inside the * 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 * field — ↑/↓ walk the filtered results (clamped, no wrap), Enter picks * 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 { + 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(null); + const [custom, setCustom] = useState(""); + const searchRef = useRef(null); + const listRef = useRef(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 ( + <> + { + 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 &&
Loading fonts…
} + {families && results.length === 0 && ( +
No fonts match — type a family below.
+ )} +
    + {families && + results.map((family, i) => { + const isDefault = family === null; + const effective = isDefault ? effectiveFamily === null : family === effectiveFamily; + return ( +
  • + +
  • + ); + })} +
+ {/* 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/electron/main.cjs b/electron/main.cjs index 08f261f..4345bce 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -228,7 +228,8 @@ function createWindow(url) { // 2026-09-19; before this grant every copy silently rejected). Still // loopback-origin only — Electron's default is auto-approve-everything, // 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) => origin === allowedOrigin() && allowedPermissions.has(permission); session.defaultSession.setPermissionRequestHandler((_wc, permission, callback, details) => { diff --git a/tests/font-menu.test.tsx b/tests/font-menu.test.tsx new file mode 100644 index 0000000..43a204a --- /dev/null +++ b/tests/font-menu.test.tsx @@ -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( + {}} />, + ); + assert.ok(html.includes('data-testid="font-option-default"'), "the default row"); + assert.ok(html.includes("default"), "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( + {}} />, + ); + const defaultRow = unset.split('data-testid="font-option-default"')[1]?.split("")[0] ?? ""; + assert.ok(defaultRow.includes("bg-muted/60"), "unset → the default row is the effective one"); + + const menlo = renderToStaticMarkup( + {}} />, + ); + const menloRow = menlo.split('data-testid="font-option-Menlo"')[1]?.split("")[0] ?? ""; + const georgiaRow = menlo.split('data-testid="font-option-Georgia"')[1]?.split("")[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( + {}} />, + ); + 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( + {}} />, + ); + 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( + {}} />, + ); + 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( + {}} />, + ); + assert.equal((html.match(/Empty = same as text/g) ?? []).length, 2, "bold + italic rows"); + assert.ok(!html.includes("#ee928c")); + assert.ok(!html.includes("#6fa9d6")); + }); +}); diff --git a/tests/render-settings.test.tsx b/tests/render-settings.test.tsx index 54c903f..e15337e 100644 --- a/tests/render-settings.test.tsx +++ b/tests/render-settings.test.tsx @@ -206,8 +206,12 @@ describe("AppearanceSection (thin SSR)", () => { it("hex inputs carry the factory placeholders; prose size resolves against the factory", () => { const html = renderToStaticMarkup( {}} />); assert.ok(html.includes(`placeholder="#141414"`)); - assert.ok(html.includes(`placeholder="Source Sans 3"`)); - assert.ok(html.includes(`placeholder="#ee928c"`)); + // The reading font is the searchable dropdown now (font-menu + // 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")); });