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:
@@ -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", () => {
|
||||
const html = renderToStaticMarkup(<AppearanceSection appearance={undefined} onSectionChange={() => {}} />);
|
||||
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"));
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user