diff --git a/AGENTS.md b/AGENTS.md index a0fa8efaa..31119639d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -175,6 +175,7 @@ claude --plugin-dir ./apps/hook - `agentTerminalDefaultAgent` (string agent id, e.g. `"claude"` or `"codex"`, default `""` meaning no recorded choice): which agent the annotate-mode Agent TUI preselects when the panel opens (#1050). Validation is `typeof === "string"` only, with no enum and no check against installed agents, so an unknown or currently unavailable id is inert rather than an error: `resolveAnnotateAgentId` uses the saved id only when it appears among the available agents and otherwise takes the first available one (`packages/ui/utils/annotateAgentTerminal.ts:45-54`). It is written only by the "save as default" checkbox in the terminal's agent picker (`packages/editor/components/AnnotateAgentTerminalPanel.tsx:257`); there is no Settings control for it. An empty string deletes the cookie and reads as unset, though the server allowlist will still write `""` into `config.json`, where it is then ignored. - Precedence for both agent-terminal keys follows the settings registry (`packages/ui/config/settings.ts`) and its resolver (`packages/ui/config/configStore.ts:3-5`): **server config file > cookie > built-in default**. `config.json` is the durable, cross-browser store; the cookie (`plannotator-annotate-agent-terminal-side`, `plannotator-annotate-agent-terminal-default`) is the browser-local fallback. There is no one-time cookie-to-config migration: those two cookie names were deliberately kept unchanged so a pre-registry cookie stays readable, and its value only reaches `config.json` if the user changes the setting again. The sync runs one direction at startup, with `init()` stamping a valid config value back into the cookie (`packages/ui/config/configStore.ts:157-161`). Neither key has an env-var equivalent, and only the annotate servers allowlist them on `POST /api/config` (`packages/server/annotate.ts:726-727`, mirrored in `apps/pi-extension/server/serverAnnotate.ts:690-691`), so setting them has no effect on plan or review sessions. - `pfmReminder` (`true` / `false`, default `false`) — when enabled, a Plannotator Flavored Markdown reminder is injected at plan-time describing the renderer's extensions (code-file links, callouts, tables, diagrams, task lists, hex swatches, wiki-links). Lets the planning agent enrich plans with PFM features without having to discover them. Composes cleanly with the compound-skill improvement hook. Supported across all three runtimes: Claude Code (`improve-context` PreToolUse hook in `apps/hook/server/index.ts`), OpenCode (`experimental.chat.system.transform` in `apps/opencode-plugin/index.ts`), and Pi (`before_agent_start` in `apps/pi-extension/index.ts`). +- `typography` (object, default none) — per-surface font overrides, keyed `plan` / `annotate` / `review`, each holding an optional `display` (reading and interface text) and `mono` (code, diffs, shortcuts) selection of the shape `{ "family": string, "source": "catalog" | "custom" }`. Normally set from Settings -> Theme -> Typography rather than by hand; the key is listed here because there is no env var for it. A `catalog` selection's `family` is a catalog **id**, not a CSS name (`inter`, `atkinson-hyperlegible`, `ibm-plex-sans` for display; `jetbrains-mono`, `fira-code`, `ibm-plex-mono`, `hack`, `inconsolata`, `red-hat-mono`, `roboto-mono`, `source-code-pro`, `atkinson-hyperlegible-mono` for mono) and is delivered from a CDN stylesheet on demand; a `custom` selection's `family` is a literal CSS font-family stack (max 240 chars, no `{`, `}` or `;`) resolved from locally installed fonts, with no network fetch. The catalog is `FONT_CATALOG` in `packages/ui/utils/typography.ts` and its ids are mirrored for validation in `packages/core/config-types.ts`; a role may only take an id declared for that role. Validation is strict and all-or-nothing (`parseTypographyConfig`): one bad entry makes the whole key unparsable, in which case it is IGNORED at read time but preserved on disk, so a typo disables the overrides without silently deleting the block on the next unrelated settings write. Unset roles inherit the active palette's `--font-sans` / `--font-mono`, and every override is scoped to its surface's `[data-pn-surface]` subtree, so the plan and review apps can carry different faces in the same session. A saved profile is a complete snapshot: writing `typography` REPLACES the stored value rather than deep-merging, which is what makes clearing a role durable. Supersedes the retired `diffOptions.fontFamily` Code Font picker; an existing `diffOptions.fontFamily` is migrated once into `typography.review.mono` (matching catalog entry, else a custom stack) and then cleared. **Legacy:** `SSH_TTY` and `SSH_CONNECTION` are still detected when `PLANNOTATOR_REMOTE` is unset. Set `PLANNOTATOR_REMOTE=1` / `true` to force remote mode or `0` / `false` to force local mode. diff --git a/apps/pi-extension/server/serverAnnotate.ts b/apps/pi-extension/server/serverAnnotate.ts index 600453f5d..bb26e091f 100644 --- a/apps/pi-extension/server/serverAnnotate.ts +++ b/apps/pi-extension/server/serverAnnotate.ts @@ -17,7 +17,7 @@ import { contentHash, deleteDraft } from "../generated/draft.ts"; import { getPlanVersion, getVersionCount, listVersions } from "../generated/storage.ts"; import { computeAnnotateHistory, deriveAnnotateHistorySlug, persistAnnotateSubmission, type AnnotateHistoryResult } from "../generated/annotate-history.ts"; import { htmlDiff } from "../generated/html-diff.ts"; -import { saveConfig, detectGitUser, getServerConfig, isAgentTerminalSide, loadConfig, resolveAIEnabled, resolveSharingEnabled, resolveAnnotateHistory, type PromptRuntime } from "../generated/config.ts"; +import { saveConfig, detectGitUser, getServerConfig, isAgentTerminalSide, loadConfig, parseTypographyConfig, resolveAIEnabled, resolveSharingEnabled, resolveAnnotateHistory, type PromptRuntime } from "../generated/config.ts"; import { isFaviconStyle, type FaviconStyle } from "../generated/favicon.ts"; import { getAnnotateFileFeedbackTemplate, getAnnotateMessageFeedbackTemplate } from "../generated/prompts.ts"; import { disabledSourceSave, type SourceSaveRequest } from "../generated/source-save.ts"; @@ -762,11 +762,16 @@ export async function startAnnotateServer(options: { handleShareHtml(res, url); } else if (url.pathname === "/api/config" && req.method === "POST") { try { - const body = (await parseBody(req)) as { displayName?: string; diffOptions?: Record; theme?: Record; favicon?: FaviconStyle; conventionalComments?: boolean; agentTerminalSide?: unknown; agentTerminalDefaultAgent?: unknown }; + const body = (await parseBody(req)) as { displayName?: string; diffOptions?: Record; theme?: Record; typography?: Record; favicon?: FaviconStyle; conventionalComments?: boolean; agentTerminalSide?: unknown; agentTerminalDefaultAgent?: unknown }; const toSave: Record = {}; if (body.displayName !== undefined) toSave.displayName = body.displayName; if (body.diffOptions !== undefined) toSave.diffOptions = body.diffOptions; if (body.theme !== undefined) toSave.theme = body.theme; + if (body.typography !== undefined) { + const typography = parseTypographyConfig(body.typography); + if (!typography.ok) return json(res, { error: "Invalid typography" }, 400); + toSave.typography = typography.value; + } if (isFaviconStyle(body.favicon)) toSave.favicon = body.favicon; if (body.conventionalComments !== undefined) toSave.conventionalComments = body.conventionalComments; if (isAgentTerminalSide(body.agentTerminalSide)) toSave.agentTerminalSide = body.agentTerminalSide; diff --git a/apps/pi-extension/server/serverPlan.ts b/apps/pi-extension/server/serverPlan.ts index 37b2bff35..f29b26385 100644 --- a/apps/pi-extension/server/serverPlan.ts +++ b/apps/pi-extension/server/serverPlan.ts @@ -41,7 +41,7 @@ import { } from "./integrations.ts"; import { buildAdvertisedUrl, listenOnPort } from "./network.ts"; -import { loadConfig, saveConfig, detectGitUser, getServerConfig, resolveAIEnabled, resolveSharingEnabled } from "../generated/config.ts"; +import { loadConfig, saveConfig, detectGitUser, getServerConfig, parseTypographyConfig, resolveAIEnabled, resolveSharingEnabled } from "../generated/config.ts"; import { isFaviconStyle, type FaviconStyle } from "../generated/favicon.ts"; import { readImprovementHook, getImprovementHookExpectedPath } from "../generated/improvement-hooks.ts"; import { composeImproveContext } from "../generated/pfm-reminder.ts"; @@ -258,11 +258,16 @@ export async function startPlanReviewServer(options: { }); } else if (url.pathname === "/api/config" && req.method === "POST") { try { - const body = (await parseBody(req)) as { displayName?: string; diffOptions?: Record; theme?: Record; favicon?: FaviconStyle; conventionalComments?: boolean; conventionalLabels?: unknown[] | null; pfmReminder?: boolean }; + const body = (await parseBody(req)) as { displayName?: string; diffOptions?: Record; theme?: Record; typography?: Record; favicon?: FaviconStyle; conventionalComments?: boolean; conventionalLabels?: unknown[] | null; pfmReminder?: boolean }; const toSave: Record = {}; if (body.displayName !== undefined) toSave.displayName = body.displayName; if (body.diffOptions !== undefined) toSave.diffOptions = body.diffOptions; if (body.theme !== undefined) toSave.theme = body.theme; + if (body.typography !== undefined) { + const typography = parseTypographyConfig(body.typography); + if (!typography.ok) return json(res, { error: "Invalid typography" }, 400); + toSave.typography = typography.value; + } if (isFaviconStyle(body.favicon)) toSave.favicon = body.favicon; if (body.conventionalComments !== undefined) toSave.conventionalComments = body.conventionalComments; if (body.conventionalLabels !== undefined) toSave.conventionalLabels = body.conventionalLabels; diff --git a/apps/pi-extension/server/serverReview.ts b/apps/pi-extension/server/serverReview.ts index cff0a3696..c0acaa4b1 100644 --- a/apps/pi-extension/server/serverReview.ts +++ b/apps/pi-extension/server/serverReview.ts @@ -6,7 +6,7 @@ import { basename, resolve as resolvePath } from "node:path"; import { SingleFlight } from "../generated/single-flight.ts"; import { contentHash, deleteDraft } from "../generated/draft.ts"; -import { loadConfig, saveConfig, detectGitUser, getServerConfig, parseReviewAnalysisConfig, resolveAIEnabled, resolveSharingEnabled, resolveCursorSandbox, resolveGuideHistory, resolveGuideShareUrl } from "../generated/config.ts"; +import { loadConfig, saveConfig, detectGitUser, getServerConfig, parseReviewAnalysisConfig, parseTypographyConfig, resolveAIEnabled, resolveSharingEnabled, resolveCursorSandbox, resolveGuideHistory, resolveGuideShareUrl } from "../generated/config.ts"; import { isFaviconStyle, type FaviconStyle } from "../generated/favicon.ts"; export type { @@ -3002,11 +3002,16 @@ export async function startReviewServer(options: { } } else if (url.pathname === "/api/config" && req.method === "POST") { try { - const body = (await parseBody(req)) as { displayName?: string; diffOptions?: Record; theme?: Record; favicon?: FaviconStyle; reviewAnalysis?: Record; conventionalComments?: boolean }; + const body = (await parseBody(req)) as { displayName?: string; diffOptions?: Record; theme?: Record; typography?: Record; favicon?: FaviconStyle; reviewAnalysis?: Record; conventionalComments?: boolean }; const toSave: Record = {}; if (body.displayName !== undefined) toSave.displayName = body.displayName; if (body.diffOptions !== undefined) toSave.diffOptions = body.diffOptions; if (body.theme !== undefined) toSave.theme = body.theme; + if (body.typography !== undefined) { + const typography = parseTypographyConfig(body.typography); + if (!typography.ok) return json(res, { error: "Invalid typography" }, 400); + toSave.typography = typography.value; + } if (isFaviconStyle(body.favicon)) toSave.favicon = body.favicon; if (body.reviewAnalysis !== undefined) { const reviewAnalysis = parseReviewAnalysisConfig(body.reviewAnalysis); diff --git a/packages/core/config-types.test.ts b/packages/core/config-types.test.ts new file mode 100644 index 000000000..2b239c9fa --- /dev/null +++ b/packages/core/config-types.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from 'bun:test'; +import { parseTypographyConfig } from './config-types'; + +describe('parseTypographyConfig', () => { + test('accepts an explicit empty profile for reset', () => { + expect(parseTypographyConfig({})).toEqual({ ok: true, value: {} }); + }); + + test('accepts valid role-specific catalog and custom selections', () => { + expect(parseTypographyConfig({ + plan: { display: { source: 'catalog', family: 'inter' } }, + review: { mono: { source: 'custom', family: '"Berkeley Mono", monospace' } }, + })).toEqual({ + ok: true, + value: { + plan: { display: { source: 'catalog', family: 'inter' } }, + review: { mono: { source: 'custom', family: '"Berkeley Mono", monospace' } }, + }, + }); + }); + + test('rejects malformed, unsafe, and role-incompatible input without partial acceptance', () => { + for (const value of [ + null, + { plan: null }, + { plan: { display: { source: 'catalog', family: 'fira-code' } } }, + { review: { mono: { source: 'catalog', family: 'inter' } } }, + { review: { mono: { source: 'custom', family: 'x; color: red' } } }, + { unknown: { display: { source: 'catalog', family: 'inter' } } }, + ]) expect(parseTypographyConfig(value).ok).toBe(false); + }); +}); diff --git a/packages/core/config-types.ts b/packages/core/config-types.ts index f674c942a..4d6dfa3c2 100644 --- a/packages/core/config-types.ts +++ b/packages/core/config-types.ts @@ -12,6 +12,57 @@ export interface ThemeConfig { dark?: string; } +export type TypographySurface = 'plan' | 'annotate' | 'review'; +export type TypographyRole = 'display' | 'mono'; +export const DISPLAY_TYPOGRAPHY_CATALOG_IDS = ['inter', 'atkinson-hyperlegible', 'ibm-plex-sans'] as const; +/** Kept in step with FONT_CATALOG in packages/ui/utils/typography.ts (asserted by its test). */ +export const MONO_TYPOGRAPHY_CATALOG_IDS = [ + 'jetbrains-mono', 'fira-code', 'ibm-plex-mono', 'hack', 'inconsolata', + 'red-hat-mono', 'roboto-mono', 'source-code-pro', 'atkinson-hyperlegible-mono', +] as const; +export const TYPOGRAPHY_CATALOG_IDS = [...DISPLAY_TYPOGRAPHY_CATALOG_IDS, ...MONO_TYPOGRAPHY_CATALOG_IDS] as const; +export type TypographyCatalogId = typeof TYPOGRAPHY_CATALOG_IDS[number]; + +export interface FontSelection { + /** A trusted catalog id or a validated CSS font-family stack. */ + family: string; + source: 'catalog' | 'custom'; +} + +export type SurfaceTypography = Partial>; +export type TypographyConfig = Partial>; + +export type TypographyParseResult = + | { ok: true; value: TypographyConfig } + | { ok: false }; + +const TYPOGRAPHY_SURFACES = new Set(['plan', 'annotate', 'review']); +const TYPOGRAPHY_ROLES = new Set(['display', 'mono']); +const DISPLAY_TYPOGRAPHY_CATALOG = new Set(DISPLAY_TYPOGRAPHY_CATALOG_IDS); +const MONO_TYPOGRAPHY_CATALOG = new Set(MONO_TYPOGRAPHY_CATALOG_IDS); + +/** Strict trust boundary for typography from disk, cookies, and APIs. */ +export function parseTypographyConfig(value: unknown): TypographyParseResult { + if (!value || typeof value !== 'object' || Array.isArray(value)) return { ok: false }; + const parsed: TypographyConfig = {}; + for (const [surface, roles] of Object.entries(value)) { + if (!TYPOGRAPHY_SURFACES.has(surface as TypographySurface) || !roles || typeof roles !== 'object' || Array.isArray(roles)) return { ok: false }; + const next: SurfaceTypography = {}; + for (const [role, selection] of Object.entries(roles as Record)) { + if (!TYPOGRAPHY_ROLES.has(role as TypographyRole) || !selection || typeof selection !== 'object' || Array.isArray(selection)) return { ok: false }; + const { family, source } = selection as Record; + const valid = typeof family === 'string' && typeof source === 'string' && ( + (source === 'catalog' && (role === 'display' ? DISPLAY_TYPOGRAPHY_CATALOG : MONO_TYPOGRAPHY_CATALOG).has(family)) || + (source === 'custom' && family.length > 0 && family.length <= 240 && !/[{};]/.test(family)) + ); + if (!valid) return { ok: false }; + next[role as TypographyRole] = { family, source: source as FontSelection['source'] }; + } + parsed[surface as TypographySurface] = next; + } + return { ok: true, value: parsed }; +} + export interface DiffOptions { diffStyle?: 'split' | 'unified'; overflow?: 'scroll' | 'wrap'; diff --git a/packages/core/guide-viewer-manifest.ts b/packages/core/guide-viewer-manifest.ts index a3446bd79..29aae8bd3 100644 --- a/packages/core/guide-viewer-manifest.ts +++ b/packages/core/guide-viewer-manifest.ts @@ -5,10 +5,10 @@ import type { GuideViewerAssets } from "./guide-format"; export const GUIDE_VIEWER_MANIFEST: Omit = { - js: "viewer.CTfggrYt.js", - css: "viewer.BdruF6Mj.css", - jsIntegrity: "sha384-It85Hkx0/d1Xme4SJjt3shHybLPGucRF/OODzE84mbOmGH8fK66eFNzgFRXe2W4Z", - cssIntegrity: "sha384-9i0z0HV8a5Hr0SAQt0+pUfQE96MTbGaCWtZlSzhk+HKIHXsqrGi16HQA4mlEWRvx", + js: "viewer.BFaoWWQ_.js", + css: "viewer.VlGbzmRQ.css", + jsIntegrity: "sha384-3YSCHmWdMO220r262prqJx1bbN/aDgULkNBij5aDNCH7xvJjV1MdI4NtmVsQa2XH", + cssIntegrity: "sha384-oHCBQ5EbgjN6PfNTxnJ04+j4C8pUbdedvhz4wvvjXXBrNLnuUtVsD2W8QsBS5b94", langs: { "astro": "chunks/astro.BykyiR6i.js", "c": "chunks/c.BIGW1oBm.js", diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index d7ec25258..a7ac0f072 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -5010,6 +5010,7 @@ const App: React.FC = () => {
{ const diffHideWhitespace = useConfigValue('diffHideWhitespace'); const diffExpandUnchanged = useConfigValue('diffExpandUnchanged'); const diffFontFamily = useConfigValue('diffFontFamily'); + const typography = useConfigValue('typography'); const diffFontSize = useConfigValue('diffFontSize'); const diffTabSize = useConfigValue('diffTabSize'); const reviewShowViewedControls = useConfigValue('reviewShowViewedControls'); @@ -378,21 +379,24 @@ const ReviewApp: React.FC = () => { // choice even though the visual result applies to plan/document surfaces. const gridEnabled = useConfigValue('gridEnabled'); - // Load custom diff font and override --font-mono for surrounding review elements + // A pre-migration session (or the read-only viewer, which has no typography + // plumbing) can still be carrying the legacy value; migrateLegacyDiffFont + // normally retires it before first paint. + const reviewMono = resolveFontFamily(typography.review?.mono) ?? diffFontFamily; + useEffect(() => { - if (diffFontFamily) { - loadDiffFont(diffFontFamily); - document.documentElement.style.setProperty('--diff-font-override', `'${diffFontFamily}', monospace`); - } else { - document.documentElement.style.removeProperty('--diff-font-override'); - } + if (!typography.review?.mono) void loadFont(legacyDiffFontSelection(diffFontFamily)); if (diffFontSize) { document.documentElement.style.setProperty('--diff-font-size-override', diffFontSize); } else { document.documentElement.style.removeProperty('--diff-font-size-override'); } document.documentElement.style.setProperty('--diffs-tab-size', String(diffTabSize)); - }, [diffFontFamily, diffFontSize, diffTabSize]); + }, [diffFontFamily, typography.review?.mono, diffFontSize, diffTabSize]); + + // Braces matter: React reads an effect's return value as its cleanup + // function, and a Promise is not callable. + useEffect(() => { void loadFont(typography.review?.mono); }, [typography.review?.mono]); const reviewSidebar = useSidebar(false, 'annotations'); const [isFileTreeOpen, setIsFileTreeOpen] = useState(true); @@ -1720,6 +1724,9 @@ const ReviewApp: React.FC = () => { apiModeRef.current = true; // Initialize config store with server-provided values (config file > cookie > default) configStore.init(data.serverConfig); + // The Code Font picker is gone; fold any value it left behind into + // typography.review.mono before anything reads the review face. + migrateLegacyDiffFont(configStore); // gitUser drives the "Use git name" button in Settings; stays undefined (button hidden) when unavailable setGitUser(data.serverConfig?.gitUser); setSnapshotId(data.snapshotId); @@ -2915,7 +2922,7 @@ const ReviewApp: React.FC = () => { disableLineNumbers: !diffShowLineNumbers, disableBackground: !diffShowBackground, expandUnchanged: diffExpandUnchanged, - fontFamily: diffFontFamily || undefined, + fontFamily: reviewMono || undefined, fontSize: diffFontSize || undefined, // Only propagate base for modes where it affects old/new content. Avoids // needless file-content re-fetches when switching to uncommitted/staged/etc. @@ -3038,7 +3045,7 @@ const ReviewApp: React.FC = () => { }), [ files, diffData?.rawPatch, activeFileIndex, guideOpen, effectiveDiffStyle, handleDiffStyleChange, isCompactTouchLayout, diffOverflow, diffIndicators, diffLineDiffType, diffShowLineNumbers, diffShowBackground, - diffExpandUnchanged, diffFontFamily, diffFontSize, activeDiffBase, committedBase, feedbackDiffContext, prReviewScopeLabel, prDiffScope, agentCwd, canUseLiveWorkspaceActions, + diffExpandUnchanged, reviewMono, diffFontSize, activeDiffBase, committedBase, feedbackDiffContext, prReviewScopeLabel, prDiffScope, agentCwd, canUseLiveWorkspaceActions, allAnnotations, externalAnnotations, visibleDescriptionAnnotations, selectedDescriptionAnnotationId, handleAddDescriptionAnnotation, handleSelectDescriptionAnnotation, handleDeleteDescriptionAnnotation, handleAskAIForDescription, @@ -3562,6 +3569,7 @@ const ReviewApp: React.FC = () => { {isSwitchingPRScope && }
span { flex: none; padding-top: 0.125rem; - font-family: var(--font-mono); + font-family: var(--pn-mono-font, var(--font-mono)); font-variant-numeric: tabular-nums; } .call-flow-languages-popover ul { @@ -1370,7 +1370,7 @@ diffs-container { border-radius: var(--radius-sm); background: transparent; color: var(--muted-foreground); - font-family: var(--font-mono); + font-family: var(--pn-mono-font, var(--font-mono)); font-size: 0.6875rem; line-height: 1; cursor: pointer; @@ -1409,7 +1409,7 @@ diffs-container { border-radius: var(--radius-lg); background: var(--popover); color: var(--popover-foreground); - font-family: var(--diff-font-override, var(--font-mono)); + font-family: var(--pn-mono-font, var(--font-mono)); font-size: 0.75rem; outline: none; } @@ -1435,7 +1435,7 @@ diffs-container { min-width: 0; overflow: hidden; color: var(--foreground); - font-family: var(--font-sans); + font-family: var(--pn-display-font, var(--font-sans)); font-size: 0.75rem; font-weight: 600; text-overflow: ellipsis; @@ -1464,7 +1464,7 @@ diffs-container { padding: 0 0.5rem; background: transparent; color: var(--muted-foreground); - font-family: var(--font-sans); + font-family: var(--pn-display-font, var(--font-sans)); font-size: 0.625rem; font-weight: 550; cursor: pointer; @@ -1561,7 +1561,7 @@ diffs-container { border-bottom: 1px solid oklch(from var(--border) l c h / 0.45); background: var(--popover); color: var(--muted-foreground); - font-family: var(--font-sans); + font-family: var(--pn-display-font, var(--font-sans)); font-size: 0.5625rem; font-weight: 600; text-overflow: ellipsis; @@ -1607,7 +1607,7 @@ diffs-container { border-radius: var(--radius-sm); background: var(--background); color: var(--foreground); - font-family: var(--font-sans); + font-family: var(--pn-display-font, var(--font-sans)); font-size: 0.6875rem; font-weight: 600; cursor: pointer; @@ -1637,7 +1637,7 @@ diffs-container { border-radius: var(--radius-sm); background: var(--primary); color: var(--primary-foreground); - font-family: var(--font-sans); + font-family: var(--pn-display-font, var(--font-sans)); font-size: 0.75rem; font-weight: 600; cursor: pointer; @@ -1667,7 +1667,7 @@ diffs-container { margin: 0.75rem 0 0.25rem; padding: 0; list-style: none; - font-family: var(--font-sans); + font-family: var(--pn-display-font, var(--font-sans)); font-size: 0.75rem; } .call-flow-install-stages li { @@ -1770,7 +1770,7 @@ diffs-container { /* Individual label tag — monospace, tight, code-native */ .cc-tag { - font-family: var(--font-mono); + font-family: var(--pn-mono-font, var(--font-mono)); font-size: 0.5625rem; font-weight: 500; line-height: 1; @@ -1847,7 +1847,7 @@ diffs-container { gap: 0.3125rem; margin-left: 0.25rem; padding: 0.125rem 0.375rem 0.125rem 0.25rem; - font-family: var(--font-mono); + font-family: var(--pn-mono-font, var(--font-mono)); font-size: 0.5625rem; font-weight: 500; letter-spacing: 0.01em; @@ -1925,7 +1925,7 @@ diffs-container { display: inline-flex; align-items: center; gap: 0.25rem; - font-family: var(--font-mono); + font-family: var(--pn-mono-font, var(--font-mono)); font-size: 0.5625rem; font-weight: 600; letter-spacing: 0.01em; @@ -2021,7 +2021,7 @@ diffs-container { /* Export modal code blocks */ .export-code-block { - font-family: var(--font-mono); + font-family: var(--pn-mono-font, var(--font-mono)); font-size: 0.75rem; background: var(--muted); border-radius: var(--radius-sm); @@ -2034,7 +2034,7 @@ diffs-container { /* Suggested code input - code editor style */ .suggested-code-input { width: 100%; - font-family: var(--font-mono); + font-family: var(--pn-mono-font, var(--font-mono)); font-size: 0.6875rem; line-height: 1.6; color: var(--foreground); @@ -2096,7 +2096,7 @@ diffs-container { } .suggestion-block-code code { - font-family: var(--font-mono); + font-family: var(--pn-mono-font, var(--font-mono)); background: transparent !important; padding: 0 !important; } @@ -2121,12 +2121,12 @@ diffs-container { /* Suggestion modal original code pane */ .suggestion-modal-original { - font-family: var(--font-mono); + font-family: var(--pn-mono-font, var(--font-mono)); background: var(--code-bg); } .suggestion-modal-original code { - font-family: var(--font-mono); + font-family: var(--pn-mono-font, var(--font-mono)); background: transparent !important; padding: 0 !important; } @@ -2137,7 +2137,7 @@ diffs-container { /* Suggestion diff (original vs suggested) */ .suggestion-diff { - font-family: var(--font-mono); + font-family: var(--pn-mono-font, var(--font-mono)); font-size: 0.6875rem; line-height: 1.5; overflow-x: auto; @@ -2238,7 +2238,7 @@ diffs-container { display: inline-flex; align-items: center; gap: 0.25rem; - font-family: var(--font-mono); + font-family: var(--pn-mono-font, var(--font-mono)); font-size: 0.625rem; background: var(--muted); color: var(--muted-foreground); @@ -2350,7 +2350,7 @@ diffs-container { .ai-markdown ol { list-style: decimal; } .ai-markdown code { - font-family: var(--font-mono); + font-family: var(--pn-mono-font, var(--font-mono)); font-size: 0.6875rem; background: var(--muted); padding: 0.125rem 0.25rem; @@ -2503,7 +2503,7 @@ diffs-container { .suggestion-modal-original code, .suggestion-diff, .ai-markdown code { - font-family: var(--diff-font-override, var(--font-mono)) !important; + font-family: var(--pn-mono-font, var(--font-mono)) !important; } /* Font size override — only takes effect when --diff-font-size-override is set on :root */ diff --git a/packages/server/annotate.ts b/packages/server/annotate.ts index 4290a4a89..062aeb84f 100644 --- a/packages/server/annotate.ts +++ b/packages/server/annotate.ts @@ -43,7 +43,7 @@ import { type AnnotateClientLeaseStreamSession, } from "@plannotator/shared/annotate-client-lease"; import { createAnnotateDecisionSettler } from "@plannotator/shared/annotate-decision"; -import { saveConfig, detectGitUser, getServerConfig, isAgentTerminalSide, loadConfig, resolveAIEnabled, resolveAnnotateHistory } from "./config"; +import { saveConfig, detectGitUser, getServerConfig, isAgentTerminalSide, loadConfig, parseTypographyConfig, resolveAIEnabled, resolveAnnotateHistory } from "./config"; import { isFaviconStyle, type FaviconStyle } from "@plannotator/shared/favicon"; import { existsSync } from "fs"; import { dirname, resolve as resolvePath } from "path"; @@ -767,11 +767,16 @@ export async function startAnnotateServer( // API: Update user config (write-back to ~/.plannotator/config.json) if (url.pathname === "/api/config" && req.method === "POST") { try { - const body = (await req.json()) as { displayName?: string; diffOptions?: Record; theme?: Record; favicon?: FaviconStyle; conventionalComments?: boolean; conventionalLabels?: unknown[] | null; agentTerminalSide?: unknown; agentTerminalDefaultAgent?: unknown }; + const body = (await req.json()) as { displayName?: string; diffOptions?: Record; theme?: Record; typography?: Record; favicon?: FaviconStyle; conventionalComments?: boolean; conventionalLabels?: unknown[] | null; agentTerminalSide?: unknown; agentTerminalDefaultAgent?: unknown }; const toSave: Record = {}; if (body.displayName !== undefined) toSave.displayName = body.displayName; if (body.diffOptions !== undefined) toSave.diffOptions = body.diffOptions; if (body.theme !== undefined) toSave.theme = body.theme; + if (body.typography !== undefined) { + const typography = parseTypographyConfig(body.typography); + if (!typography.ok) return Response.json({ error: "Invalid typography" }, { status: 400 }); + toSave.typography = typography.value; + } if (isFaviconStyle(body.favicon)) toSave.favicon = body.favicon; if (body.conventionalComments !== undefined) toSave.conventionalComments = body.conventionalComments; if (body.conventionalLabels !== undefined) toSave.conventionalLabels = body.conventionalLabels; diff --git a/packages/server/config.ts b/packages/server/config.ts index 9adffb015..7f1328c59 100644 --- a/packages/server/config.ts +++ b/packages/server/config.ts @@ -8,6 +8,7 @@ export { resolveCursorSandbox, resolveGuideHistory, parseReviewAnalysisConfig, + parseTypographyConfig, isAgentTerminalSide, type PlannotatorConfig, type DiffOptions, diff --git a/packages/server/index.ts b/packages/server/index.ts index 0adb25c9a..4fef749b4 100644 --- a/packages/server/index.ts +++ b/packages/server/index.ts @@ -41,7 +41,7 @@ import { } from "./storage"; import { getRepoInfo } from "./repo"; import { detectProjectName } from "./project"; -import { loadConfig, saveConfig, detectGitUser, getServerConfig, resolveAIEnabled } from "./config"; +import { loadConfig, saveConfig, detectGitUser, getServerConfig, parseTypographyConfig, resolveAIEnabled } from "./config"; import { isFaviconStyle, type FaviconStyle } from "@plannotator/shared/favicon"; import { readImprovementHook, getImprovementHookExpectedPath } from "@plannotator/shared/improvement-hooks"; import { composeImproveContext } from "@plannotator/shared/pfm-reminder"; @@ -322,11 +322,16 @@ export async function startPlannotatorServer( // API: Update user config (write-back to ~/.plannotator/config.json) if (url.pathname === "/api/config" && req.method === "POST") { try { - const body = (await req.json()) as { displayName?: string; diffOptions?: Record; theme?: Record; favicon?: FaviconStyle; conventionalComments?: boolean; conventionalLabels?: unknown[] | null; pfmReminder?: boolean }; + const body = (await req.json()) as { displayName?: string; diffOptions?: Record; theme?: Record; typography?: Record; favicon?: FaviconStyle; conventionalComments?: boolean; conventionalLabels?: unknown[] | null; pfmReminder?: boolean }; const toSave: Record = {}; if (body.displayName !== undefined) toSave.displayName = body.displayName; if (body.diffOptions !== undefined) toSave.diffOptions = body.diffOptions; if (body.theme !== undefined) toSave.theme = body.theme; + if (body.typography !== undefined) { + const typography = parseTypographyConfig(body.typography); + if (!typography.ok) return Response.json({ error: "Invalid typography" }, { status: 400 }); + toSave.typography = typography.value; + } if (isFaviconStyle(body.favicon)) toSave.favicon = body.favicon; if (body.conventionalComments !== undefined) toSave.conventionalComments = body.conventionalComments; if (body.conventionalLabels !== undefined) toSave.conventionalLabels = body.conventionalLabels; diff --git a/packages/server/review.ts b/packages/server/review.ts index f504a23de..79ca5ee42 100644 --- a/packages/server/review.ts +++ b/packages/server/review.ts @@ -112,7 +112,7 @@ import { extractMarkerNonce, type MarkerEngineId, } from "./marker-review"; -import { loadConfig, saveConfig, detectGitUser, getServerConfig, parseReviewAnalysisConfig, resolveAIEnabled, resolveCursorSandbox, resolveGuideHistory } from "./config"; +import { loadConfig, saveConfig, detectGitUser, getServerConfig, parseReviewAnalysisConfig, parseTypographyConfig, resolveAIEnabled, resolveCursorSandbox, resolveGuideHistory } from "./config"; import { isFaviconStyle, type FaviconStyle } from "@plannotator/shared/favicon"; import { type PRMetadata, type PRRef, type PRReviewFileComment, type PRStackTree, type PRListItem, fetchPR, fetchPRFileContent, fetchPRContext, submitPRReview, fetchPRViewedFiles, markPRFilesViewed, fetchPRStack, fetchPRList, getPRUser, parsePRUrl, prRefFromMetadata, isSameProject, getDisplayRepo, getMRLabel, getMRNumberLabel, prCommandRuntime } from "./pr"; import { @@ -3068,11 +3068,16 @@ export async function startReviewServer( // API: Update user config (write-back to ~/.plannotator/config.json) if (url.pathname === "/api/config" && req.method === "POST") { try { - const body = (await req.json()) as { displayName?: string; diffOptions?: Record; theme?: Record; favicon?: FaviconStyle; reviewAnalysis?: Record; conventionalComments?: boolean; conventionalLabels?: unknown[] | null }; + const body = (await req.json()) as { displayName?: string; diffOptions?: Record; theme?: Record; typography?: Record; favicon?: FaviconStyle; reviewAnalysis?: Record; conventionalComments?: boolean; conventionalLabels?: unknown[] | null }; const toSave: Record = {}; if (body.displayName !== undefined) toSave.displayName = body.displayName; if (body.diffOptions !== undefined) toSave.diffOptions = body.diffOptions; if (body.theme !== undefined) toSave.theme = body.theme; + if (body.typography !== undefined) { + const typography = parseTypographyConfig(body.typography); + if (!typography.ok) return Response.json({ error: "Invalid typography" }, { status: 400 }); + toSave.typography = typography.value; + } if (isFaviconStyle(body.favicon)) toSave.favicon = body.favicon; if (body.reviewAnalysis !== undefined) { const reviewAnalysis = parseReviewAnalysisConfig(body.reviewAnalysis); diff --git a/packages/shared/config.ts b/packages/shared/config.ts index 225e1429e..2cad25a7f 100644 --- a/packages/shared/config.ts +++ b/packages/shared/config.ts @@ -22,10 +22,11 @@ import { } from "fs"; import { execSync } from "child_process"; -import type { DefaultDiffType, DiffLineBgIntensity, DiffOptions, ThemeConfig } from '@plannotator/core/config-types'; +import { parseTypographyConfig, type DefaultDiffType, type DiffLineBgIntensity, type DiffOptions, type ThemeConfig, type TypographyConfig } from '@plannotator/core/config-types'; import { isFaviconStyle, type FaviconStyle } from './favicon'; import { isAnnotateAgentTerminalSide, type AnnotateAgentTerminalSide } from './agent-terminal'; -export type { DefaultDiffType, DiffLineBgIntensity, DiffOptions, ThemeConfig, FaviconStyle }; +export { parseTypographyConfig }; +export type { DefaultDiffType, DiffLineBgIntensity, DiffOptions, ThemeConfig, TypographyConfig, FaviconStyle }; /** Single conventional comment label entry stored in config.json */ export interface CCLabelConfig { @@ -102,6 +103,7 @@ export function mergePromptConfig( export interface PlannotatorConfig { displayName?: string; diffOptions?: DiffOptions; + typography?: TypographyConfig; /** Optional analysis layers used by code review. */ reviewAnalysis?: { /** Named-entity semantic diff. Enabled by default for backwards compatibility. */ @@ -494,6 +496,19 @@ export function saveConfig(partial: Partial): void { const mergedTheme = (current.theme || partial.theme) ? { ...current.theme, ...partial.theme } : undefined; + // A typography update is a complete profile snapshot. Replacing it makes + // Reset durable instead of deep-merging deleted roles back from disk. + // When the incoming value is absent or invalid we keep what is on disk — + // including a value that does not parse. saveConfig is called for every + // unrelated setting, so dropping an unparsable key here would silently + // delete a hand-edited typography block on the next theme toggle instead + // of leaving it there to be fixed. Readers already ignore it (both + // getServerConfig and the client validate before use). + const currentTypography = parseTypographyConfig(current.typography); + const partialTypography = parseTypographyConfig(partial.typography); + const mergedTypography = partial.typography !== undefined && partialTypography.ok + ? partialTypography.value + : (currentTypography.ok ? currentTypography.value : current.typography); const mergedReviewAnalysis = (current.reviewAnalysis || partial.reviewAnalysis) ? { ...current.reviewAnalysis, ...partial.reviewAnalysis } : undefined; @@ -503,6 +518,7 @@ export function saveConfig(partial: Partial): void { ...partial, diffOptions: mergedDiffOptions, theme: mergedTheme, + typography: mergedTypography, reviewAnalysis: mergedReviewAnalysis, prompts: mergedPrompts, }; @@ -535,6 +551,7 @@ export function getServerConfig(gitUser: string | null): { displayName?: string; diffOptions?: DiffOptions; theme?: ThemeConfig; + typography?: TypographyConfig; favicon?: FaviconStyle; reviewAnalysis: NonNullable; gitUser?: string; @@ -544,10 +561,12 @@ export function getServerConfig(gitUser: string | null): { agentTerminalDefaultAgent?: string; } { const cfg = loadConfig(); + const typography = parseTypographyConfig(cfg.typography); return { displayName: cfg.displayName, diffOptions: cfg.diffOptions, ...(cfg.theme !== undefined && { theme: cfg.theme }), + ...(typography.ok && { typography: typography.value }), ...(isFaviconStyle(cfg.favicon) && { favicon: cfg.favicon }), // These values gate server-side work, so always make the resolved defaults // explicit. The client must not revive a stale cookie that disagrees with diff --git a/packages/shared/config.typography.test.ts b/packages/shared/config.typography.test.ts new file mode 100644 index 000000000..229b39b04 --- /dev/null +++ b/packages/shared/config.typography.test.ts @@ -0,0 +1,49 @@ +import { afterEach, expect, test } from 'bun:test'; +import { mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { loadConfig, saveConfig } from './config'; + +const previousDataDir = process.env.PLANNOTATOR_DATA_DIR; +let dataDir = ''; + +afterEach(() => { + if (previousDataDir === undefined) delete process.env.PLANNOTATOR_DATA_DIR; + else process.env.PLANNOTATOR_DATA_DIR = previousDataDir; + if (dataDir) rmSync(dataDir, { recursive: true, force: true }); + dataDir = ''; +}); + +test('typography reset replaces the saved profile', () => { + dataDir = mkdtempSync(`${tmpdir()}/plannotator-typography-`); + process.env.PLANNOTATOR_DATA_DIR = dataDir; + saveConfig({ typography: { review: { mono: { source: 'catalog', family: 'fira-code' } } } }); + saveConfig({ typography: {} }); + expect(loadConfig().typography).toEqual({}); +}); + +test('invalid typography cannot erase saved preferences', () => { + dataDir = mkdtempSync(`${tmpdir()}/plannotator-typography-`); + process.env.PLANNOTATOR_DATA_DIR = dataDir; + saveConfig({ typography: { plan: { display: { source: 'catalog', family: 'inter' } } } }); + saveConfig({ typography: { plan: { display: { source: 'catalog', family: 'fira-code' } } } as never }); + expect(loadConfig().typography).toEqual({ plan: { display: { source: 'catalog', family: 'inter' } } }); +}); + +// The other direction: the value ON DISK is the unparsable one (a hand edit, +// or a profile written by a newer build). saveConfig runs for every unrelated +// setting, so if it dropped what it could not parse, the next theme toggle +// would silently delete the user's typography block instead of leaving it +// there to be corrected. +test('an unparsable typography block on disk survives unrelated config writes', () => { + dataDir = mkdtempSync(`${tmpdir()}/plannotator-typography-`); + process.env.PLANNOTATOR_DATA_DIR = dataDir; + const handEdited = { plan: { dispaly: { source: 'catalog', family: 'inter' } } }; + writeFileSync(join(dataDir, 'config.json'), JSON.stringify({ typography: handEdited })); + + saveConfig({ displayName: 'someone' }); + + const after = loadConfig(); + expect(after.displayName).toBe('someone'); + expect(after.typography).toEqual(handEdited as never); +}); diff --git a/packages/ui/components/Settings.tsx b/packages/ui/components/Settings.tsx index 4a0e133cc..6519184bb 100644 --- a/packages/ui/components/Settings.tsx +++ b/packages/ui/components/Settings.tsx @@ -4,7 +4,7 @@ import type { AnnotateAgentTerminalSide } from '@plannotator/core/agent-terminal import type { Origin } from '@plannotator/core/agents'; import type { DiffLineBgIntensity } from '@plannotator/core/config-types'; import { configStore, useConfigValue, setReviewPanelView, setReviewDefaultDiffType } from '../config'; -import { loadDiffFont } from '../utils/diffFonts'; + import { TaterSpritePullup } from './TaterSpritePullup'; import { getIdentity, regenerateIdentity, setCustomIdentity, isIdentityEditable } from '../utils/identity'; import { GitUser } from '../icons/GitUser'; @@ -111,19 +111,6 @@ interface SettingsProps { // --- Review-mode Display tab (diff display options) --- -const DIFF_FONT_OPTIONS = [ - { value: '', label: 'Theme Default' }, - { value: 'Fira Code', label: 'Fira Code' }, - { value: 'Hack', label: 'Hack' }, - { value: 'IBM Plex Mono', label: 'IBM Plex Mono' }, - { value: 'Inconsolata', label: 'Inconsolata' }, - { value: 'JetBrains Mono', label: 'JetBrains Mono' }, - { value: 'Red Hat Mono', label: 'Red Hat Mono' }, - { value: 'Roboto Mono', label: 'Roboto Mono' }, - { value: 'Source Code Pro', label: 'Source Code Pro' }, - { value: 'Atkinson Hyperlegible Mono', label: 'Atkinson Hyperlegible' }, -]; - export const DIFF_STYLE_OPTIONS = [ { value: 'split' as const, label: 'Split' }, { value: 'unified' as const, label: 'Unified' }, @@ -448,14 +435,8 @@ const ReviewDisplayTab: React.FC<{ isCompactTouchLayout?: boolean }> = ({ isComp const diffHideWhitespace = useConfigValue('diffHideWhitespace'); const editSuggestions = useConfigValue('editSuggestions'); const diffExpandUnchanged = useConfigValue('diffExpandUnchanged'); - const diffFontFamily = useConfigValue('diffFontFamily'); const diffFontSize = useConfigValue('diffFontSize'); - // Load font for the preview swatch - useEffect(() => { - if (diffFontFamily) loadDiffFont(diffFontFamily); - }, [diffFontFamily]); - return ( <> {/* Experimental: edit code to author suggestions */} @@ -471,33 +452,6 @@ const ReviewDisplayTab: React.FC<{ isCompactTouchLayout?: boolean }> = ({ isComp />
-
- - {/* Font Family */} -
-
-
Code Font
-
Font family for diff code lines
-
- - {diffFontFamily && ( -
- Preview: const x = fn(42); -
- )} -
@@ -1456,7 +1410,7 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange )} {/* === THEME TAB === */} - {activeTab === 'theme' && { setShowDialog(false); setThemePreview(true); }} />} + {activeTab === 'theme' && { setShowDialog(false); setThemePreview(true); }} />} {/* === GIT TAB === */} {activeTab === 'git' && mode === 'review' && ( diff --git a/packages/ui/components/ThemeProvider.tsx b/packages/ui/components/ThemeProvider.tsx index 090022dab..3dc5373fe 100644 --- a/packages/ui/components/ThemeProvider.tsx +++ b/packages/ui/components/ThemeProvider.tsx @@ -2,6 +2,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use import { configStore } from '../config/configStore'; import { readThemePairCookies, writeThemePairCookies } from '../config/settings'; import { useConfigValue } from '../config/useConfig'; +import { loadFont, resolveFontFamily } from '../utils/typography'; import { faviconDataUrl } from '@plannotator/core/favicon'; import { storage } from '../utils/storage'; import { @@ -143,6 +144,7 @@ export function ThemeProvider({ const [, setSeedApplied] = useState(false); const storePair = useConfigValue('themePair'); + const typography = useConfigValue('typography'); const pair = pendingSeed.current ?? storePair; const mode = pair.mode; const faviconStyle = useConfigValue('faviconStyle'); @@ -204,6 +206,23 @@ export function ThemeProvider({ applyThemeClasses(colorTheme, resolvedMode); }, [resolvedMode, colorTheme]); + // Typography overrides are scoped by each app root. Keep palette tokens + // untouched: they remain the inheritance fallback for unset roles. + useEffect(() => { + const root = document.documentElement; + for (const surface of ['plan', 'annotate', 'review'] as const) { + const selection = typography[surface]; + for (const role of ['display', 'mono'] as const) { + const font = selection?.[role]; + void loadFont(font); + const value = resolveFontFamily(font); + const property = `--pn-${surface}-${role}-font`; + if (value) root.style.setProperty(property, value); + else root.style.removeProperty(property); + } + } + }, [typography]); + // Enable color transitions after mount settles — prevents the global * // transition rule from firing during initial load. useEffect(() => { diff --git a/packages/ui/components/ThemeTab.tsx b/packages/ui/components/ThemeTab.tsx index 67c4764ac..d125d4ad1 100644 --- a/packages/ui/components/ThemeTab.tsx +++ b/packages/ui/components/ThemeTab.tsx @@ -2,19 +2,26 @@ import React, { useEffect, useState } from 'react'; import { useTheme } from './ThemeProvider'; import { THEME_MODES } from './themeModes'; import { themesForHalf, type ThemeHalf } from '../utils/themeRegistry'; -import { configStore } from '../config/configStore'; -import { useConfigValue } from '../config/useConfig'; +import { configStore, useConfigValue } from '../config'; +import { FONT_CATALOG, getFontLoadStatus, isSafeCustomFontFamily, loadFont, resolveFontFamily, type FontCatalogRole, type FontLoadStatus } from '../utils/typography'; +import type { FontSelection, TypographyRole, TypographySurface } from '@plannotator/core/config-types'; import { faviconDataUrl, type FaviconStyle } from '@plannotator/core/favicon'; interface ThemeTabProps { onPreview?: () => void; compact?: boolean; + typographySurface?: TypographySurface; } const HALVES: { id: ThemeHalf; label: string }[] = [ { id: 'light', label: 'Light' }, { id: 'dark', label: 'Dark' }, ]; +const TYPOGRAPHY_SURFACES: { id: TypographySurface; label: string }[] = [ + { id: 'plan', label: 'Plan' }, + { id: 'annotate', label: 'Annotate' }, + { id: 'review', label: 'Review' }, +]; const FAVICON_STYLES: { id: FaviconStyle; label: string }[] = [ { id: 'totman', label: 'Totman' }, @@ -56,7 +63,7 @@ const FaviconStyleControl: React.FC<{ selected: FaviconStyle }> = ({ selected })
); -export const ThemeTab: React.FC = ({ onPreview, compact }) => { +export const ThemeTab: React.FC = ({ onPreview, compact, typographySurface: forcedTypographySurface }) => { const { mode, setMode, @@ -71,6 +78,8 @@ export const ThemeTab: React.FC = ({ onPreview, compact }) => { // Which half the grid assigns to. Follows the mode you are actually seeing, // so opening Settings in dark mode edits the dark half first. + const typography = useConfigValue('typography'); + const [typographySurface, setTypographySurface] = useState(forcedTypographySurface ?? 'plan'); const [half, setHalf] = useState(preferredMode); useEffect(() => setHalf(preferredMode), [preferredMode]); @@ -231,6 +240,130 @@ export const ThemeTab: React.FC = ({ onPreview, compact }) => { })}
+ + {!compact && ( + + )} ); }; + +function TypographySettings({ surface, setSurface, typography, showSurfacePicker }: { + surface: TypographySurface; + setSurface: (surface: TypographySurface) => void; + typography: ReturnType>; + showSurfacePicker: boolean; +}) { + const setRole = (role: TypographyRole, selection: FontSelection | undefined) => { + const current = configStore.get('typography'); + const nextSurface = { ...current[surface], ...(selection ? { [role]: selection } : {}) }; + if (!selection) delete nextSurface[role]; + configStore.set('typography', { ...current, [surface]: nextSurface }); + }; + return ( +
+
+
+ +

Set the reading and code face for this surface.

+
+ {surface} +
+ {showSurfacePicker &&
+ {TYPOGRAPHY_SURFACES.map(item => )} +
} + + +
+ ); +} + +function FontChoice({ selected, onClick, label, preview, detail, family }: { + selected: boolean; + onClick: () => void; + label: string; + preview: string; + detail: string; + family?: string; +}) { + return ( + + ); +} + +function FontControl({ label, role, selection, onChange }: { + label: string; + role: TypographyRole; + selection: FontSelection | undefined; + onChange: (role: TypographyRole, selection: FontSelection | undefined) => void; +}) { + const [custom, setCustom] = useState(selection?.source === 'custom' ? selection.family ?? '' : ''); + const [editingCustom, setEditingCustom] = useState(selection?.source === 'custom'); + const [customError, setCustomError] = useState(null); + const [status, setStatus] = useState(() => selection?.source === 'catalog' ? getFontLoadStatus(selection.family as never) : 'idle'); + useEffect(() => { + setCustom(selection?.source === 'custom' ? selection.family ?? '' : ''); + setEditingCustom(selection?.source === 'custom'); + setCustomError(null); + }, [selection]); + const fonts = FONT_CATALOG.filter(font => (font.roles as readonly FontCatalogRole[]).includes(role as FontCatalogRole)); + useEffect(() => { + let active = true; + void loadFont(selection).then(next => { if (active) setStatus(next); }); + setStatus(selection?.source === 'catalog' ? getFontLoadStatus(selection.family as never) : 'idle'); + return () => { active = false; }; + }, [selection?.family, selection?.source]); + const preview = resolveFontFamily(selection); + const isSelected = (id: string) => selection?.source === 'catalog' && selection.family === id; + const choose = (font: typeof fonts[number]) => { + setEditingCustom(false); + onChange(role, { family: font.id, source: 'catalog' }); + }; + return ( +
+
+
+

{label}

+

{role === 'mono' ? 'Code, diffs, and shortcuts' : 'Reading and interface text'}

+
+ {selection && } +
+
+ { setEditingCustom(false); onChange(role, undefined); }} label="Theme default" preview="Aa" detail="Follow palette" /> + {fonts.map(font => choose(font)} label={font.label} preview="Aa" detail="Font family" family={font.family} />)} + setEditingCustom(open => !open)} label="Custom local" preview="+" detail="CSS stack" /> +
+ {editingCustom && ( + <> + { setCustom(event.target.value); setCustomError(null); }} onBlur={() => { + const value = custom.trim(); + if (!value) { onChange(role, undefined); return; } + if (!isSafeCustomFontFamily(value)) { setCustomError('Use a font-family stack without braces or semicolons.'); return; } + onChange(role, { family: value, source: 'custom' }); + }} placeholder={'e.g. "Berkeley Mono", monospace'} className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-primary focus:ring-2 focus:ring-primary/20" /> + {customError &&

{customError}

} + + )} +
+ {role === 'mono' ? 'const font = "preview";' : 'The quick brown fox jumps over the lazy dog.'} +
+ {selection?.source === 'catalog' &&

{status === 'loading' ? 'Loading font…' : status === 'error' ? 'Could not load font; using fallback.' : status === 'ready' ? 'Loaded' : 'Waiting to load'}

} +
+ ); +} diff --git a/packages/ui/config/settings.ts b/packages/ui/config/settings.ts index 8a292c673..de342b536 100644 --- a/packages/ui/config/settings.ts +++ b/packages/ui/config/settings.ts @@ -9,11 +9,11 @@ * Add new settings here. Cookie-only settings omit serverKey. */ +import { parseTypographyConfig, type DiffLineBgIntensity, type TypographyConfig } from '@plannotator/core/config-types'; import { isAnnotateAgentTerminalSide, type AnnotateAgentTerminalSide, } from '@plannotator/core/agent-terminal'; -import type { DiffLineBgIntensity } from '@plannotator/core/config-types'; import { isFaviconStyle, type FaviconStyle } from '@plannotator/core/favicon'; import { storage } from '../utils/storage'; import { generateIdentity } from '../utils/generateIdentity'; @@ -151,6 +151,25 @@ export const SETTINGS = { toServer: (v: FaviconStyle) => ({ favicon: v }), }, + typography: { + defaultValue: {} as TypographyConfig, + fromCookie: () => { + const raw = storage.getItem('plannotator-typography'); + if (!raw) return undefined; + try { + const parsed = parseTypographyConfig(JSON.parse(raw)); + return parsed.ok ? parsed.value : undefined; + } catch { return undefined; } + }, + toCookie: (value: TypographyConfig) => storage.setItem('plannotator-typography', JSON.stringify(value)), + serverKey: 'typography', + fromServer: (sc: Record) => { + const parsed = parseTypographyConfig(sc.typography); + return parsed.ok ? parsed.value : undefined; + }, + toServer: (value: TypographyConfig) => ({ typography: value }), + }, + gridEnabled: { // Default ON: plans open in the classic grid / floating-card look. The UI 2.0 // flat look is offered as an opt-in via the look-and-feel chooser dialog. diff --git a/packages/ui/theme.css b/packages/ui/theme.css index 023f4974a..adeba8696 100644 --- a/packages/ui/theme.css +++ b/packages/ui/theme.css @@ -712,6 +712,27 @@ body { font-feature-settings: "ss01", "ss02", "cv01"; } +/* Typography overrides belong to the active app surface, never the palette + * tokens. Unset values inherit from the selected palette. */ +[data-pn-surface='plan'] { + --pn-display-font: var(--pn-plan-display-font, var(--font-sans)); + --pn-mono-font: var(--pn-plan-mono-font, var(--font-mono)); +} +[data-pn-surface='annotate'] { + --pn-display-font: var(--pn-annotate-display-font, var(--font-sans)); + --pn-mono-font: var(--pn-annotate-mono-font, var(--font-mono)); +} +[data-pn-surface='review'] { + --pn-display-font: var(--pn-review-display-font, var(--font-sans)); + --pn-mono-font: var(--pn-review-mono-font, var(--font-mono)); +} +[data-pn-surface] { + font-family: var(--pn-display-font, var(--font-sans)); +} +[data-pn-surface] :is(code, kbd, pre, samp) { + font-family: var(--pn-mono-font, var(--font-mono)); +} + /* Safari extends the page canvas beneath its floating browser controls. The * plan document is a card-colored nested scroller, so leaving the outer canvas * on `--background` produces an opaque dark band around those controls. Match @@ -1077,7 +1098,7 @@ html:has([data-pn-compact-touch-layout='true']) border: 1px solid oklch(from var(--border) l c h / 0.5); background: oklch(from var(--muted) l c h / 0.6); color: var(--foreground); - font-family: var(--font-mono); + font-family: var(--pn-mono-font, var(--font-mono)); font-size: 0.625rem; line-height: 1rem; } diff --git a/packages/ui/typography.cssFallback.test.ts b/packages/ui/typography.cssFallback.test.ts new file mode 100644 index 000000000..d58916ec9 --- /dev/null +++ b/packages/ui/typography.cssFallback.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'fs'; +import { join } from 'path'; + +/** + * `--pn-display-font` / `--pn-mono-font` are DEFINED on `[data-pn-surface]` + * (theme.css), so they only resolve inside that subtree. Large parts of the UI + * render outside it: the review annotation toolbar and the Settings dialog + * portal to document.body, Base UI popovers mount at the body without a + * container, and the external line-annotation composer is a sibling of the + * surface div. A bare `var(--pn-mono-font)` in any of those loses monospace + * entirely — at DEFAULT settings, with no typography configured — because an + * unresolvable var makes the whole declaration invalid rather than inherited. + * The same CSS is bundled into the guides.show viewer, so it ships there too. + * + * Every reference must therefore carry the palette token as its fallback. + */ +const FILES = [ + join(import.meta.dir, 'theme.css'), + join(import.meta.dir, '..', 'review-editor', 'index.css'), +]; + +const BARE = /var\(\s*--pn-(?:display|mono)-font\s*\)/g; + +describe('per-surface font vars always carry a palette fallback', () => { + for (const file of FILES) { + test(file.split('/').slice(-2).join('/'), () => { + const css = readFileSync(file, 'utf8'); + // Sanity: this guard is worthless if the vars are not used here at all. + expect(css).toContain('--pn-mono-font'); + expect([...css.matchAll(BARE)].map(m => m[0])).toEqual([]); + }); + } + + test('every use resolves to --font-mono or --font-sans when unset', () => { + for (const file of FILES) { + const css = readFileSync(file, 'utf8'); + for (const [, role, fallback] of css.matchAll(/var\(\s*--pn-(display|mono)-font\s*,([^)]*\))/g)) { + expect(fallback!.trim()).toBe(role === 'mono' ? 'var(--font-mono)' : 'var(--font-sans)'); + } + } + }); +}); diff --git a/packages/ui/utils/diffFonts.ts b/packages/ui/utils/diffFonts.ts deleted file mode 100644 index a3612257d..000000000 --- a/packages/ui/utils/diffFonts.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Dynamic font loading for code review diff viewer. - * - * Injects Google Fonts / CDN stylesheet links on demand when the user - * selects a custom diff font. Each font is loaded at most once. - */ - -const FONT_URLS: Record = { - 'Red Hat Mono': 'https://fonts.googleapis.com/css2?family=Red+Hat+Mono:wght@300..700&display=swap', - 'Fira Code': 'https://fonts.googleapis.com/css2?family=Fira+Code:wght@300..700&display=swap', - 'Atkinson Hyperlegible Mono': 'https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible+Mono:wght@200..700&display=swap', - 'Source Code Pro': 'https://fonts.googleapis.com/css2?family=Source+Code+Pro:wght@300..700&display=swap', - 'JetBrains Mono': 'https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300..700&display=swap', - 'IBM Plex Mono': 'https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@300..700&display=swap', - 'Inconsolata': 'https://fonts.googleapis.com/css2?family=Inconsolata:wght@300..700&display=swap', - 'Roboto Mono': 'https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@300..700&display=swap', - 'Hack': 'https://cdn.jsdelivr.net/npm/hack-font@3/build/web/hack.css', -}; - -const loaded = new Set(); - -export function loadDiffFont(fontFamily: string): void { - if (!fontFamily || loaded.has(fontFamily)) return; - const url = FONT_URLS[fontFamily]; - if (!url) return; - - const link = document.createElement('link'); - link.rel = 'stylesheet'; - link.href = url; - link.dataset.diffFont = fontFamily; - document.head.appendChild(link); - loaded.add(fontFamily); -} diff --git a/packages/ui/utils/typography.test.ts b/packages/ui/utils/typography.test.ts new file mode 100644 index 000000000..d1b15491a --- /dev/null +++ b/packages/ui/utils/typography.test.ts @@ -0,0 +1,149 @@ +import { afterEach, describe, expect, test } from 'bun:test'; + +import { + FONT_CATALOG, getFontLoadStatus, legacyDiffFontSelection, loadCatalogFont, + migrateLegacyDiffFont, monoFontStack, type FontCatalogRole, +} from './typography'; +import { + DISPLAY_TYPOGRAPHY_CATALOG_IDS, MONO_TYPOGRAPHY_CATALOG_IDS, parseTypographyConfig, +} from '@plannotator/core/config-types'; + +const hasDom = typeof document !== 'undefined'; + +describe.if(hasDom)('catalog font loader', () => { + afterEach(() => { + document.querySelectorAll('link[data-plannotator-font]').forEach(link => link.remove()); + }); + + test('loads trusted catalog URLs once and reports readiness', async () => { + for (const font of FONT_CATALOG) expect(font.stylesheet).toMatch(/^https:\/\//); + + const first = loadCatalogFont('inter'); + const second = loadCatalogFont('inter'); + const link = document.querySelector('link[data-plannotator-font="inter"]'); + + expect(first).toBe(second); + expect(document.querySelectorAll('link[data-plannotator-font]').length).toBe(1); + expect(getFontLoadStatus('inter')).toBe('loading'); + + link!.dispatchEvent(new Event('load')); + expect(await first).toBe('ready'); + expect(getFontLoadStatus('inter')).toBe('ready'); + }); + + test('allows retry after a failed stylesheet load', async () => { + const first = loadCatalogFont('fira-code'); + const firstLink = document.querySelector('link[data-plannotator-font="fira-code"]')!; + firstLink.dispatchEvent(new Event('error')); + expect(await first).toBe('error'); + + const retry = loadCatalogFont('fira-code'); + const retryLink = document.querySelector('link[data-plannotator-font="fira-code"]')!; + expect(retry).not.toBe(first); + retryLink.dispatchEvent(new Event('load')); + expect(await retry).toBe('ready'); + }); +}); + +describe('catalog integrity', () => { + // The trust boundary (parseTypographyConfig) validates catalog ids against a + // list in @plannotator/core, which cannot import this file. A drift between + // the two means either a font the picker offers is rejected on save, or an id + // the parser trusts resolves to no family at all. + test('catalog ids match the core allowlist, per role', () => { + const ids = (role: FontCatalogRole) => + FONT_CATALOG.filter(f => (f.roles as readonly FontCatalogRole[]).includes(role)).map(f => f.id).sort(); + expect(ids('display')).toEqual([...DISPLAY_TYPOGRAPHY_CATALOG_IDS].sort()); + expect(ids('mono')).toEqual([...MONO_TYPOGRAPHY_CATALOG_IDS].sort()); + }); + + test('one stylesheet URL per family, so no face loads twice at two weight ranges', () => { + const urls = FONT_CATALOG.map(f => f.stylesheet); + expect(new Set(urls).size).toBe(urls.length); + expect(new Set(FONT_CATALOG.map(f => f.family)).size).toBe(FONT_CATALOG.length); + }); + + test('every mono family ends in a generic monospace fallback', () => { + for (const font of FONT_CATALOG) { + if ((font.roles as readonly FontCatalogRole[]).includes('mono')) { + expect(font.family.endsWith(', monospace')).toBe(true); + } + } + }); +}); + +describe('monoFontStack', () => { + test('quotes a bare family and appends the generic', () => { + expect(monoFontStack('JetBrains Mono')).toBe("'JetBrains Mono', monospace"); + }); + + test('leaves an existing stack alone but still guarantees a generic', () => { + expect(monoFontStack('"Berkeley Mono", monospace')).toBe('"Berkeley Mono", monospace'); + expect(monoFontStack('"Berkeley Mono", Consolas')).toBe('"Berkeley Mono", Consolas, monospace'); + expect(monoFontStack('ui-monospace')).toBe('ui-monospace'); + }); + + test('is empty for empty input', () => { + expect(monoFontStack(undefined)).toBeUndefined(); + expect(monoFontStack(' ')).toBeUndefined(); + }); +}); + +describe('legacy diffFontFamily migration', () => { + function fakeStore(values: { diffFontFamily?: string; typography?: unknown }) { + const state: Record = { diffFontFamily: '', typography: {}, ...values }; + return { + state, + get: (key: 'diffFontFamily' | 'typography') => state[key], + set: (key: 'diffFontFamily' | 'typography', value: never) => { state[key] = value; }, + }; + } + + test('every family the retired picker offered still maps to a catalog entry', () => { + for (const legacy of [ + 'Fira Code', 'Hack', 'IBM Plex Mono', 'Inconsolata', 'JetBrains Mono', + 'Red Hat Mono', 'Roboto Mono', 'Source Code Pro', 'Atkinson Hyperlegible Mono', + ]) { + const selection = legacyDiffFontSelection(legacy); + expect(selection?.source).toBe('catalog'); + expect(parseTypographyConfig({ review: { mono: selection } }).ok).toBe(true); + } + }); + + test('seeds review.mono from the legacy value and retires the legacy key', () => { + const store = fakeStore({ diffFontFamily: 'Hack' }); + expect(migrateLegacyDiffFont(store)).toBe(true); + expect(store.state.typography).toEqual({ review: { mono: { family: 'hack', source: 'catalog' } } }); + expect(store.state.diffFontFamily).toBe(''); + }); + + test('a hand-edited family outside the catalog survives as a custom stack', () => { + const store = fakeStore({ diffFontFamily: 'Berkeley Mono' }); + migrateLegacyDiffFont(store); + expect(store.state.typography).toEqual({ + review: { mono: { family: "'Berkeley Mono', monospace", source: 'custom' } }, + }); + }); + + test('never overwrites a typography choice the user already made', () => { + const chosen = { review: { mono: { family: 'fira-code', source: 'catalog' } } }; + const store = fakeStore({ diffFontFamily: 'Hack', typography: chosen }); + migrateLegacyDiffFont(store); + expect(store.state.typography).toEqual(chosen); + expect(store.state.diffFontFamily).toBe(''); + }); + + test('is one-time: clearing the seeded font does not resurrect it', () => { + const store = fakeStore({ diffFontFamily: 'Hack' }); + migrateLegacyDiffFont(store); + store.state.typography = {}; // user picks "Theme default" + expect(migrateLegacyDiffFont(store)).toBe(false); + expect(store.state.typography).toEqual({}); + }); + + test('does nothing when there was never a legacy value', () => { + const store = fakeStore({}); + expect(migrateLegacyDiffFont(store)).toBe(false); + expect(store.state.typography).toEqual({}); + }); +}); diff --git a/packages/ui/utils/typography.ts b/packages/ui/utils/typography.ts new file mode 100644 index 000000000..244d2186b --- /dev/null +++ b/packages/ui/utils/typography.ts @@ -0,0 +1,192 @@ +import type { FontSelection } from '@plannotator/core/config-types'; + +export type FontCatalogRole = 'display' | 'mono'; + +interface FontCatalogEntryBase { + label: string; + family: string; + roles: readonly FontCatalogRole[]; + stylesheet?: `https://${string}`; +} + +/** + * The single font catalog. The six mono entries below `ibm-plex-mono` came from + * the retired Code Font picker (`utils/diffFonts.ts`): folding them in here is + * what keeps a reviewer who had picked Hack or Inconsolata from finding their + * font unreachable, and keeps ONE stylesheet URL per family so a face is never + * fetched twice at two different weight ranges. + */ +export const FONT_CATALOG = [ + { id: 'inter', label: 'Inter', family: 'Inter, sans-serif', roles: ['display'], stylesheet: 'https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap' }, + { id: 'atkinson-hyperlegible', label: 'Atkinson Hyperlegible', family: '"Atkinson Hyperlegible", sans-serif', roles: ['display'], stylesheet: 'https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible:wght@400;700&display=swap' }, + { id: 'ibm-plex-sans', label: 'IBM Plex Sans', family: '"IBM Plex Sans", sans-serif', roles: ['display'], stylesheet: 'https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&display=swap' }, + { id: 'jetbrains-mono', label: 'JetBrains Mono', family: '"JetBrains Mono", monospace', roles: ['mono'], stylesheet: 'https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@100..800&display=swap' }, + { id: 'fira-code', label: 'Fira Code', family: '"Fira Code", monospace', roles: ['mono'], stylesheet: 'https://fonts.googleapis.com/css2?family=Fira+Code:wght@300..700&display=swap' }, + { id: 'ibm-plex-mono', label: 'IBM Plex Mono', family: '"IBM Plex Mono", monospace', roles: ['mono'], stylesheet: 'https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&display=swap' }, + { id: 'hack', label: 'Hack', family: 'Hack, monospace', roles: ['mono'], stylesheet: 'https://cdn.jsdelivr.net/npm/hack-font@3/build/web/hack.css' }, + { id: 'inconsolata', label: 'Inconsolata', family: 'Inconsolata, monospace', roles: ['mono'], stylesheet: 'https://fonts.googleapis.com/css2?family=Inconsolata:wght@300..700&display=swap' }, + { id: 'red-hat-mono', label: 'Red Hat Mono', family: '"Red Hat Mono", monospace', roles: ['mono'], stylesheet: 'https://fonts.googleapis.com/css2?family=Red+Hat+Mono:wght@300..700&display=swap' }, + { id: 'roboto-mono', label: 'Roboto Mono', family: '"Roboto Mono", monospace', roles: ['mono'], stylesheet: 'https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@300..700&display=swap' }, + { id: 'source-code-pro', label: 'Source Code Pro', family: '"Source Code Pro", monospace', roles: ['mono'], stylesheet: 'https://fonts.googleapis.com/css2?family=Source+Code+Pro:wght@300..700&display=swap' }, + { id: 'atkinson-hyperlegible-mono', label: 'Atkinson Hyperlegible Mono', family: '"Atkinson Hyperlegible Mono", monospace', roles: ['mono'], stylesheet: 'https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible+Mono:wght@200..700&display=swap' }, +] as const satisfies readonly (FontCatalogEntryBase & { id: string })[]; + +export type FontCatalogEntry = (typeof FONT_CATALOG)[number]; +export type FontCatalogId = FontCatalogEntry['id']; +export type DisplayFontId = Extract['id']; +export type MonoFontId = Extract['id']; +export type FontLoadStatus = 'idle' | 'loading' | 'ready' | 'error'; + +/** How long a catalog stylesheet may hang before the loader gives up. */ +export const FONT_LOAD_TIMEOUT_MS = 10_000; + +const byId = new Map(FONT_CATALOG.map(font => [font.id, font])); +const loads = new Map>(); +const statuses = new Map(); + +export function fontForId(id: string | undefined): FontCatalogEntry | undefined { + return id ? byId.get(id as FontCatalogId) : undefined; +} + +export function resolveFontFamily(selection: FontSelection | undefined): string | undefined { + if (!selection?.family) return undefined; + return selection.source === 'catalog' ? fontForId(selection.family)?.family : selection.family; +} + +export function getFontLoadStatus(id: FontCatalogId): FontLoadStatus { + return statuses.get(id) ?? 'idle'; +} + +/** Loads a trusted catalog stylesheet once and resolves when its font face is usable. */ +export function loadCatalogFont(id: FontCatalogId | undefined): Promise { + const font = id && fontForId(id); + if (!font?.stylesheet) return Promise.resolve('idle'); + const cached = loads.get(font.id); + if (cached) return cached; + if (typeof document === 'undefined') return Promise.resolve('idle'); + + statuses.set(font.id, 'loading'); + let link: HTMLLinkElement | undefined; + let timer: ReturnType | undefined; + const fail = () => { + if (timer !== undefined) clearTimeout(timer); + statuses.set(font.id, 'error'); + loads.delete(font.id); + link?.remove(); + return 'error' as const; + }; + const succeed = () => { + if (timer !== undefined) clearTimeout(timer); + statuses.set(font.id, 'ready'); + return 'ready' as const; + }; + const load = new Promise((resolve) => { + link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = font.stylesheet!; + link.dataset.plannotatorFont = font.id; + link.onload = () => { + const fontSet = document.fonts; + if (!fontSet?.load) { + resolve(succeed()); + return; + } + void fontSet.load(`1em ${font.family}`).then( + () => resolve(succeed()), + () => resolve(fail()), + ); + }; + link.onerror = () => resolve(fail()); + // A stylesheet that neither loads nor errors (a CDN that accepts the + // connection and then stalls) would otherwise leave the settings panel + // saying "Loading font…" forever. Settle as a failure and let the retry + // path — fail() drops the memo — try again. + timer = setTimeout(() => resolve(fail()), FONT_LOAD_TIMEOUT_MS); + document.head.appendChild(link); + }); + loads.set(font.id, load); + return load; +} + +export function loadFont(selection: FontSelection | undefined): Promise { + return selection?.source === 'catalog' ? loadCatalogFont(selection.family as FontCatalogId) : Promise.resolve('idle'); +} + +export function isSafeCustomFontFamily(value: string): boolean { + return value.length > 0 && value.length <= 240 && !/[{};]/.test(value); +} + +/** + * Make a value safe to drop into `font-family: …` for the diff pane. + * + * The diff renderer addresses code by column, so a face that fails to load has + * to fall back to SOME monospace or the columns stop lining up. Callers pass + * either a bare family name (the legacy `diffFontFamily` cookie, still in use + * by the read-only guides.show viewer) or a full CSS stack (everything the + * typography catalog and the custom input produce), so quote the bare form and + * only append the generic when the stack does not already end in one. + */ +export function monoFontStack(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + if (!trimmed) return undefined; + // A generic keyword must stay unquoted: '"monospace"' is a family NAME. + const endsGeneric = /^(?:monospace|ui-monospace)$/.test(trimmed.split(',').pop()!.trim()); + const stack = /[,'"]/.test(trimmed) || endsGeneric ? trimmed : `'${trimmed}'`; + return endsGeneric ? stack : `${stack}, monospace`; +} + +/** Legacy `diffFontFamily` values, by the exact strings the old picker wrote. */ +const LEGACY_DIFF_FONT_IDS: Record = { + 'Fira Code': 'fira-code', + 'Hack': 'hack', + 'IBM Plex Mono': 'ibm-plex-mono', + 'Inconsolata': 'inconsolata', + 'JetBrains Mono': 'jetbrains-mono', + 'Red Hat Mono': 'red-hat-mono', + 'Roboto Mono': 'roboto-mono', + 'Source Code Pro': 'source-code-pro', + 'Atkinson Hyperlegible Mono': 'atkinson-hyperlegible-mono', +}; + +/** + * Translate a legacy `diffFontFamily` value into a typography selection. + * Families the old picker offered become catalog entries; anything else a user + * hand-edited into config.json survives as a custom stack rather than vanishing. + */ +export function legacyDiffFontSelection(family: string | undefined): FontSelection | undefined { + const trimmed = family?.trim(); + if (!trimmed) return undefined; + const id = LEGACY_DIFF_FONT_IDS[trimmed]; + if (id) return { family: id, source: 'catalog' }; + return isSafeCustomFontFamily(trimmed) ? { family: monoFontStack(trimmed)!, source: 'custom' } : undefined; +} + +/** + * One-time migration off the retired Code Font picker. + * + * The picker is gone but `diffOptions.fontFamily` is still on disk for anyone + * who used it, so seed `typography.review.mono` from it once and then clear the + * legacy key. Clearing is what makes this idempotent: without it, a user who + * later chose "Theme default" would have their old font resurrected on the next + * reload, because "no review.mono" would read as "not migrated yet" again. + * After this runs, typography is the only source of truth for the review face. + */ +export function migrateLegacyDiffFont(store: { + get: (key: 'diffFontFamily' | 'typography') => unknown; + set: (key: 'diffFontFamily' | 'typography', value: never) => void; +}): boolean { + const legacy = store.get('diffFontFamily'); + if (typeof legacy !== 'string' || !legacy.trim()) return false; + const typography = (store.get('typography') ?? {}) as Record>; + if (!typography.review?.mono) { + const selection = legacyDiffFontSelection(legacy); + if (selection) { + store.set('typography', { + ...typography, + review: { ...typography.review, mono: selection }, + } as never); + } + } + store.set('diffFontFamily', '' as never); + return true; +}