From 62bd1eb0ed454d848fff3b983e4c2c5955cb8dc4 Mon Sep 17 00:00:00 2001 From: SyahrulBhudiF Date: Fri, 14 Aug 2026 22:42:27 +0700 Subject: [PATCH 01/10] feat(ui): add per-surface typography customization --- apps/pi-extension/server/serverAnnotate.ts | 9 +- apps/pi-extension/server/serverPlan.ts | 9 +- apps/pi-extension/server/serverReview.ts | 9 +- bun.lock | 60 +++++++- packages/core/config-types.test.ts | 32 ++++ packages/core/config-types.ts | 47 ++++++ packages/editor/App.tsx | 1 + packages/review-editor/App.tsx | 21 +-- .../components/DiffHunkPreview.tsx | 2 +- .../review-editor/hooks/usePierreTheme.ts | 2 +- packages/review-editor/index.css | 84 +++++------ packages/server/annotate.ts | 9 +- packages/server/config.ts | 1 + packages/server/index.ts | 9 +- packages/server/review.ts | 9 +- packages/shared/config.ts | 17 ++- packages/shared/config.typography.test.ts | 30 ++++ packages/ui/components/Settings.tsx | 50 +------ packages/ui/components/ThemeProvider.tsx | 19 +++ packages/ui/components/ThemeTab.tsx | 137 +++++++++++++++++- packages/ui/config/settings.ts | 21 ++- packages/ui/theme.css | 23 ++- packages/ui/utils/typography.test.ts | 40 +++++ packages/ui/utils/typography.ts | 90 ++++++++++++ 24 files changed, 609 insertions(+), 122 deletions(-) create mode 100644 packages/core/config-types.test.ts create mode 100644 packages/shared/config.typography.test.ts create mode 100644 packages/ui/utils/typography.test.ts create mode 100644 packages/ui/utils/typography.ts diff --git a/apps/pi-extension/server/serverAnnotate.ts b/apps/pi-extension/server/serverAnnotate.ts index 6e3c76cba..383c92e0b 100644 --- a/apps/pi-extension/server/serverAnnotate.ts +++ b/apps/pi-extension/server/serverAnnotate.ts @@ -8,7 +8,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, loadConfig, resolveAIEnabled, resolveSharingEnabled, resolveAnnotateHistory, type PromptRuntime } from "../generated/config.ts"; +import { saveConfig, detectGitUser, getServerConfig, loadConfig, parseTypographyConfig, resolveAIEnabled, resolveSharingEnabled, resolveAnnotateHistory, type PromptRuntime } from "../generated/config.ts"; import { getAnnotateFileFeedbackTemplate, getAnnotateMessageFeedbackTemplate } from "../generated/prompts.ts"; import { disabledSourceSave, type SourceSaveRequest } from "../generated/source-save.ts"; import { getAnnotateReferenceRootPaths } from "../generated/annotate-reference-roots-node.ts"; @@ -679,11 +679,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; conventionalComments?: boolean }; + const body = (await parseBody(req)) as { displayName?: string; diffOptions?: Record; theme?: Record; typography?: 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 (body.conventionalComments !== undefined) toSave.conventionalComments = body.conventionalComments; if (Object.keys(toSave).length > 0) saveConfig(toSave as Parameters[0]); json(res, { ok: true }); diff --git a/apps/pi-extension/server/serverPlan.ts b/apps/pi-extension/server/serverPlan.ts index 05ccf4740..bd6db2de8 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 { readImprovementHook, getImprovementHookExpectedPath } from "../generated/improvement-hooks.ts"; import { composeImproveContext } from "../generated/pfm-reminder.ts"; import { detectProjectName, getRepoInfo } from "./project.ts"; @@ -257,11 +257,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; conventionalComments?: boolean; conventionalLabels?: unknown[] | null; pfmReminder?: boolean }; + const body = (await parseBody(req)) as { displayName?: string; diffOptions?: Record; theme?: Record; typography?: Record; 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 (body.conventionalComments !== undefined) toSave.conventionalComments = body.conventionalComments; if (body.conventionalLabels !== undefined) toSave.conventionalLabels = body.conventionalLabels; if (body.pfmReminder !== undefined) toSave.pfmReminder = body.pfmReminder; diff --git a/apps/pi-extension/server/serverReview.ts b/apps/pi-extension/server/serverReview.ts index 51ddcdcbe..cc0cb6cac 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 } from "../generated/config.ts"; +import { loadConfig, saveConfig, detectGitUser, getServerConfig, parseReviewAnalysisConfig, parseTypographyConfig, resolveAIEnabled, resolveSharingEnabled, resolveCursorSandbox, resolveGuideHistory } from "../generated/config.ts"; export type { DiffOption, @@ -2729,11 +2729,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; reviewAnalysis?: Record; conventionalComments?: boolean }; + const body = (await parseBody(req)) as { displayName?: string; diffOptions?: Record; theme?: Record; typography?: Record; 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 (body.reviewAnalysis !== undefined) { const reviewAnalysis = parseReviewAnalysisConfig(body.reviewAnalysis); if (!reviewAnalysis) return json(res, { error: "Invalid analysis settings" }, 400); diff --git a/bun.lock b/bun.lock index 6428a4bff..361343159 100644 --- a/bun.lock +++ b/bun.lock @@ -62,7 +62,7 @@ }, "apps/opencode-plugin": { "name": "@plannotator/opencode", - "version": "0.27.2", + "version": "0.27.3", "devDependencies": { "@opencode-ai/plugin": "0.0.0-next-16775", "@plannotator/server": "workspace:*", @@ -79,7 +79,7 @@ }, "apps/pi-extension": { "name": "@plannotator/pi-extension", - "version": "0.27.2", + "version": "0.27.3", "dependencies": { "@joplin/turndown-plugin-gfm": "^1.0.64", "@pierre/diffs": "1.3.2", @@ -217,7 +217,7 @@ }, "packages/server": { "name": "@plannotator/server", - "version": "0.27.2", + "version": "0.27.3", "dependencies": { "@pierre/diffs": "1.3.2", "@plannotator/ai": "workspace:*", @@ -2679,6 +2679,8 @@ "@astrojs/internal-helpers/shiki/@shikijs/types": ["@shikijs/types@4.4.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-PFYitV4vpDr/iPCIhnHp+Q4ftic5N5VeNJ3KQ1O8gn3h2ar8qgwMAXF7tq4m1CWaMS60fV4VqF6vfnWH4F7vqQ=="], + "@astrojs/react/vite/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], + "@astrojs/react/vite/lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], "@astrojs/react/vite/picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], @@ -2933,6 +2935,58 @@ "wrangler/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="], + "@astrojs/react/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], + + "@astrojs/react/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], + + "@astrojs/react/vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], + + "@astrojs/react/vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], + + "@astrojs/react/vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], + + "@astrojs/react/vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], + + "@astrojs/react/vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], + + "@astrojs/react/vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], + + "@astrojs/react/vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], + + "@astrojs/react/vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], + + "@astrojs/react/vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], + + "@astrojs/react/vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], + + "@astrojs/react/vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], + + "@astrojs/react/vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], + + "@astrojs/react/vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], + + "@astrojs/react/vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], + + "@astrojs/react/vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], + + "@astrojs/react/vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], + + "@astrojs/react/vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], + + "@astrojs/react/vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], + + "@astrojs/react/vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], + + "@astrojs/react/vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], + + "@astrojs/react/vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], + + "@astrojs/react/vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], + + "@astrojs/react/vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], + + "@astrojs/react/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], + "@astrojs/react/vite/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], "@astrojs/react/vite/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="], 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..129cfef80 100644 --- a/packages/core/config-types.ts +++ b/packages/core/config-types.ts @@ -12,6 +12,53 @@ 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; +export const MONO_TYPOGRAPHY_CATALOG_IDS = ['jetbrains-mono', 'fira-code', 'ibm-plex-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/editor/App.tsx b/packages/editor/App.tsx index 3a5a05856..6229167f1 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -4775,6 +4775,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 +380,19 @@ 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 + 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 && diffFontFamily) loadDiffFont(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]); + + useEffect(() => loadFont(typography.review?.mono), [typography.review?.mono]); const reviewSidebar = useSidebar(false, 'annotations'); const [isFileTreeOpen, setIsFileTreeOpen] = useState(true); @@ -2892,7 +2892,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. @@ -3012,7 +3012,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, @@ -3535,6 +3535,7 @@ const ReviewApp: React.FC = () => { {isSwitchingPRScope && }
span { flex: none; padding-top: 0.125rem; - font-family: var(--font-mono); + font-family: var(--pn-mono-font); font-variant-numeric: tabular-nums; } .call-flow-languages-popover ul { @@ -1369,7 +1369,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); font-size: 0.6875rem; line-height: 1; cursor: pointer; @@ -1404,7 +1404,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); font-size: 0.75rem; outline: none; } @@ -1430,7 +1430,7 @@ diffs-container { min-width: 0; overflow: hidden; color: var(--foreground); - font-family: var(--font-sans); + font-family: var(--pn-display-font); font-size: 0.75rem; font-weight: 600; text-overflow: ellipsis; @@ -1459,7 +1459,7 @@ diffs-container { padding: 0 0.5rem; background: transparent; color: var(--muted-foreground); - font-family: var(--font-sans); + font-family: var(--pn-display-font); font-size: 0.625rem; font-weight: 550; cursor: pointer; @@ -1556,7 +1556,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); font-size: 0.5625rem; font-weight: 600; text-overflow: ellipsis; @@ -1602,7 +1602,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); font-size: 0.6875rem; font-weight: 600; cursor: pointer; @@ -1632,7 +1632,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); font-size: 0.75rem; font-weight: 600; cursor: pointer; @@ -1662,7 +1662,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); font-size: 0.75rem; } .call-flow-install-stages li { @@ -1765,7 +1765,7 @@ diffs-container { /* Individual label tag — monospace, tight, code-native */ .cc-tag { - font-family: var(--font-mono); + font-family: var(--pn-mono-font); font-size: 0.5625rem; font-weight: 500; line-height: 1; @@ -1842,7 +1842,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); font-size: 0.5625rem; font-weight: 500; letter-spacing: 0.01em; @@ -1920,7 +1920,7 @@ diffs-container { display: inline-flex; align-items: center; gap: 0.25rem; - font-family: var(--font-mono); + font-family: var(--pn-mono-font); font-size: 0.5625rem; font-weight: 600; letter-spacing: 0.01em; @@ -2016,7 +2016,7 @@ diffs-container { /* Export modal code blocks */ .export-code-block { - font-family: var(--font-mono); + font-family: var(--pn-mono-font); font-size: 0.75rem; background: var(--muted); border-radius: var(--radius-sm); @@ -2029,7 +2029,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); font-size: 0.6875rem; line-height: 1.6; color: var(--foreground); @@ -2091,7 +2091,7 @@ diffs-container { } .suggestion-block-code code { - font-family: var(--font-mono); + font-family: var(--pn-mono-font); background: transparent !important; padding: 0 !important; } @@ -2116,12 +2116,12 @@ diffs-container { /* Suggestion modal original code pane */ .suggestion-modal-original { - font-family: var(--font-mono); + font-family: var(--pn-mono-font); background: var(--code-bg); } .suggestion-modal-original code { - font-family: var(--font-mono); + font-family: var(--pn-mono-font); background: transparent !important; padding: 0 !important; } @@ -2132,7 +2132,7 @@ diffs-container { /* Suggestion diff (original vs suggested) */ .suggestion-diff { - font-family: var(--font-mono); + font-family: var(--pn-mono-font); font-size: 0.6875rem; line-height: 1.5; overflow-x: auto; @@ -2233,7 +2233,7 @@ diffs-container { display: inline-flex; align-items: center; gap: 0.25rem; - font-family: var(--font-mono); + font-family: var(--pn-mono-font); font-size: 0.625rem; background: var(--muted); color: var(--muted-foreground); @@ -2345,7 +2345,7 @@ diffs-container { .ai-markdown ol { list-style: decimal; } .ai-markdown code { - font-family: var(--font-mono); + font-family: var(--pn-mono-font); font-size: 0.6875rem; background: var(--muted); padding: 0.125rem 0.25rem; @@ -2498,7 +2498,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) !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 bf8d110cf..909ced245 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, loadConfig, resolveAIEnabled, resolveAnnotateHistory } from "./config"; +import { saveConfig, detectGitUser, getServerConfig, loadConfig, parseTypographyConfig, resolveAIEnabled, resolveAnnotateHistory } from "./config"; import { existsSync } from "fs"; import { dirname, resolve as resolvePath } from "path"; import { isWithinDirectory } from "@plannotator/shared/html-assets-node"; @@ -642,11 +642,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; conventionalComments?: boolean; conventionalLabels?: unknown[] | null }; + const body = (await req.json()) as { displayName?: string; diffOptions?: Record; theme?: Record; typography?: 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 (body.conventionalComments !== undefined) toSave.conventionalComments = body.conventionalComments; if (body.conventionalLabels !== undefined) toSave.conventionalLabels = body.conventionalLabels; if (Object.keys(toSave).length > 0) saveConfig(toSave as Parameters[0]); diff --git a/packages/server/config.ts b/packages/server/config.ts index d7d1e717b..91a0c9356 100644 --- a/packages/server/config.ts +++ b/packages/server/config.ts @@ -8,6 +8,7 @@ export { resolveCursorSandbox, resolveGuideHistory, parseReviewAnalysisConfig, + parseTypographyConfig, type PlannotatorConfig, type DiffOptions, } from "@plannotator/shared/config"; diff --git a/packages/server/index.ts b/packages/server/index.ts index 71b082b91..bd6f3cbc1 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 { readImprovementHook, getImprovementHookExpectedPath } from "@plannotator/shared/improvement-hooks"; import { composeImproveContext } from "@plannotator/shared/pfm-reminder"; import { handleImage, handleUpload, handleAgents, handleServerReady, handleDraftSave, handleDraftLoad, handleDraftDelete, handleApiNotFound, handleFavicon, handleReferenceSkills, handleReferenceSkillContent, handleSaveNotes, readDraftGenerationFromBody, type OpencodeClient } from "./shared-handlers"; @@ -321,11 +321,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; conventionalComments?: boolean; conventionalLabels?: unknown[] | null; pfmReminder?: boolean }; + const body = (await req.json()) as { displayName?: string; diffOptions?: Record; theme?: Record; typography?: Record; 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 (body.conventionalComments !== undefined) toSave.conventionalComments = body.conventionalComments; if (body.conventionalLabels !== undefined) toSave.conventionalLabels = body.conventionalLabels; if (body.pfmReminder !== undefined) toSave.pfmReminder = body.pfmReminder; diff --git a/packages/server/review.ts b/packages/server/review.ts index 662a9c6a3..297230aa6 100644 --- a/packages/server/review.ts +++ b/packages/server/review.ts @@ -99,7 +99,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 { 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 { PR_CONTEXT_HEARTBEAT_COMMENT, @@ -2813,11 +2813,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; reviewAnalysis?: Record; conventionalComments?: boolean; conventionalLabels?: unknown[] | null }; + const body = (await req.json()) as { displayName?: string; diffOptions?: Record; theme?: Record; typography?: Record; 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 (body.reviewAnalysis !== undefined) { const reviewAnalysis = parseReviewAnalysisConfig(body.reviewAnalysis); if (!reviewAnalysis) { diff --git a/packages/shared/config.ts b/packages/shared/config.ts index 9745d4b75..ac6634991 100644 --- a/packages/shared/config.ts +++ b/packages/shared/config.ts @@ -10,8 +10,9 @@ import { getPlannotatorDataDir } from "./data-dir"; import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs"; import { execSync } from "child_process"; -import type { DefaultDiffType, DiffLineBgIntensity, DiffOptions, ThemeConfig } from '@plannotator/core/config-types'; -export type { DefaultDiffType, DiffLineBgIntensity, DiffOptions, ThemeConfig }; +import { parseTypographyConfig, type DefaultDiffType, type DiffLineBgIntensity, type DiffOptions, type ThemeConfig, type TypographyConfig } from '@plannotator/core/config-types'; +export { parseTypographyConfig }; +export type { DefaultDiffType, DiffLineBgIntensity, DiffOptions, ThemeConfig, TypographyConfig }; /** Single conventional comment label entry stored in config.json */ export interface CCLabelConfig { @@ -87,6 +88,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. */ @@ -278,6 +280,13 @@ 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. + const currentTypography = parseTypographyConfig(current.typography); + const partialTypography = parseTypographyConfig(partial.typography); + const mergedTypography = partial.typography === undefined || !partialTypography.ok + ? (currentTypography.ok ? currentTypography.value : undefined) + : partialTypography.value; const mergedReviewAnalysis = (current.reviewAnalysis || partial.reviewAnalysis) ? { ...current.reviewAnalysis, ...partial.reviewAnalysis } : undefined; @@ -287,6 +296,7 @@ export function saveConfig(partial: Partial): void { ...partial, diffOptions: mergedDiffOptions, theme: mergedTheme, + typography: mergedTypography, reviewAnalysis: mergedReviewAnalysis, prompts: mergedPrompts, }; @@ -318,16 +328,19 @@ export function getServerConfig(gitUser: string | null): { displayName?: string; diffOptions?: DiffOptions; theme?: ThemeConfig; + typography?: TypographyConfig; reviewAnalysis: NonNullable; gitUser?: string; conventionalComments?: boolean; conventionalLabels?: CCLabelConfig[] | null; } { 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 }), // These values gate server-side work, so always make the resolved defaults // explicit. The client must not revive a stale cookie that disagrees with // the server when the config leaves either optional leaf unset. diff --git a/packages/shared/config.typography.test.ts b/packages/shared/config.typography.test.ts new file mode 100644 index 000000000..370da0dce --- /dev/null +++ b/packages/shared/config.typography.test.ts @@ -0,0 +1,30 @@ +import { afterEach, expect, test } from 'bun:test'; +import { mkdtempSync, rmSync } from 'fs'; +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' } } }); +}); diff --git a/packages/ui/components/Settings.tsx b/packages/ui/components/Settings.tsx index 000969dd8..6d86248dd 100644 --- a/packages/ui/components/Settings.tsx +++ b/packages/ui/components/Settings.tsx @@ -3,7 +3,7 @@ import { createPortal } from 'react-dom'; 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'; @@ -105,19 +105,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' }, @@ -436,14 +423,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 */} @@ -459,33 +440,6 @@ const ReviewDisplayTab: React.FC<{ isCompactTouchLayout?: boolean }> = ({ isComp />
-
- - {/* Font Family */} -
-
-
Code Font
-
Font family for diff code lines
-
- - {diffFontFamily && ( -
- Preview: const x = fn(42); -
- )} -
@@ -1412,7 +1366,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 afeff7076..be01ba678 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 { storage } from '../utils/storage'; import { BUILT_IN_THEMES, @@ -122,6 +123,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; @@ -163,6 +165,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 db9888a63..08c3d3a62 100644 --- a/packages/ui/components/ThemeTab.tsx +++ b/packages/ui/components/ThemeTab.tsx @@ -2,16 +2,25 @@ import React, { useEffect, useState } from 'react'; import { useTheme } from './ThemeProvider'; import { THEME_MODES } from './themeModes'; import { themesForHalf, type ThemeHalf } from '../utils/themeRegistry'; +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'; 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 SyntaxLinesIcon: React.FC<{ className?: string }> = ({ className }) => ( @@ -19,7 +28,7 @@ const SyntaxLinesIcon: React.FC<{ className?: string }> = ({ className }) => ( ); -export const ThemeTab: React.FC = ({ onPreview, compact }) => { +export const ThemeTab: React.FC = ({ onPreview, compact, typographySurface: forcedTypographySurface }) => { const { mode, setMode, @@ -32,6 +41,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]); @@ -180,6 +191,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 091b9da8b..bad65d28f 100644 --- a/packages/ui/config/settings.ts +++ b/packages/ui/config/settings.ts @@ -9,7 +9,7 @@ * Add new settings here. Cookie-only settings omit serverKey. */ -import type { DiffLineBgIntensity } from '@plannotator/core/config-types'; +import { parseTypographyConfig, type DiffLineBgIntensity, type TypographyConfig } from '@plannotator/core/config-types'; import { storage } from '../utils/storage'; import { generateIdentity } from '../utils/generateIdentity'; import { @@ -132,6 +132,25 @@ export const SETTINGS = { toServer: (v: ThemePair) => ({ theme: { mode: v.mode, light: v.light, dark: v.dark } }), }, + 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..8830db8e1 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); +} +[data-pn-surface] :is(code, kbd, pre, samp) { + font-family: var(--pn-mono-font); +} + /* 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/utils/typography.test.ts b/packages/ui/utils/typography.test.ts new file mode 100644 index 000000000..29d7fcd29 --- /dev/null +++ b/packages/ui/utils/typography.test.ts @@ -0,0 +1,40 @@ +import { afterEach, describe, expect, test } from 'bun:test'; + +import { FONT_CATALOG, getFontLoadStatus, loadCatalogFont } from './typography'; + +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'); + }); +}); diff --git a/packages/ui/utils/typography.ts b/packages/ui/utils/typography.ts new file mode 100644 index 000000000..cec4d3213 --- /dev/null +++ b/packages/ui/utils/typography.ts @@ -0,0 +1,90 @@ +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}`; +} + +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' }, +] 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'; + +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; + const fail = () => { + statuses.set(font.id, 'error'); + loads.delete(font.id); + link?.remove(); + return 'error' 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) { + statuses.set(font.id, 'ready'); + resolve('ready'); + return; + } + void fontSet.load(`1em ${font.family}`).then( + () => { statuses.set(font.id, 'ready'); resolve('ready'); }, + () => resolve(fail()), + ); + }; + link.onerror = () => resolve(fail()); + 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); +} From 65c59e819e557f893f91061a855239a12202add0 Mon Sep 17 00:00:00 2001 From: SyahrulBhudiF Date: Tue, 18 Aug 2026 18:24:03 +0700 Subject: [PATCH 02/10] chore(guides-show): refresh viewer asset manifest --- packages/core/guide-viewer-manifest.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/guide-viewer-manifest.ts b/packages/core/guide-viewer-manifest.ts index 145201506..032c58bb9 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.CpDlIFcA.js", - css: "viewer.DgwM0Ujf.css", - jsIntegrity: "sha384-AL8vNhcGQZd8DuYJQfpqGrNTVWBusWhMZEyeki9HMOj6ryXhrvqzPj7EuR7DOt9I", - cssIntegrity: "sha384-7WoUPritW0qvClDPhC0nVxkMLTziN0ZKhSwJ604m6q4OSqTQ5NV+KuN2cT4d+nAU", + js: "viewer.C4G-XTIH.js", + css: "viewer.D5C12YTr.css", + jsIntegrity: "sha384-4P2327MM9Zs8WDO5AKfa8Rb42GmKmTeJTu5AFGfnCmO5kIOeA7mhxvYrlXwHn0Te", + cssIntegrity: "sha384-IipKYSubPTS20Nnoja07qxxoO6J57dq5Gbdq6bxJbr+wsEnNT8HXqLv+CvxhJJD7", langs: { "astro": "chunks/astro.BykyiR6i.js", "c": "chunks/c.BIGW1oBm.js", From 7fe14ddfbe2fa381b9ed516258bf86558af35e5c Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 20 Aug 2026 12:58:33 -0700 Subject: [PATCH 03/10] revert(guides-show): drop the viewer manifest bump from this PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The committed JS hash (viewer.C4G-XTIH.js) does not reproduce from any build of this tree, so shipping it would pin every exported guide and every hosted share page to an asset that does not exist under guides.show /v1/ — and /v1/ is add-only, so nothing would ever publish it after the fact. Regenerating the manifest is a maintainer release step (build:viewer + sync:manifest, verified against what is actually uploaded), not part of a typography feature. Restoring main's manifest also removes this branch's only merge conflict with main. --- packages/core/guide-viewer-manifest.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/guide-viewer-manifest.ts b/packages/core/guide-viewer-manifest.ts index 032c58bb9..93f88b8e3 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.C4G-XTIH.js", - css: "viewer.D5C12YTr.css", - jsIntegrity: "sha384-4P2327MM9Zs8WDO5AKfa8Rb42GmKmTeJTu5AFGfnCmO5kIOeA7mhxvYrlXwHn0Te", - cssIntegrity: "sha384-IipKYSubPTS20Nnoja07qxxoO6J57dq5Gbdq6bxJbr+wsEnNT8HXqLv+CvxhJJD7", + js: "viewer.CpDlIFcA.js", + css: "viewer.BdruF6Mj.css", + jsIntegrity: "sha384-AL8vNhcGQZd8DuYJQfpqGrNTVWBusWhMZEyeki9HMOj6ryXhrvqzPj7EuR7DOt9I", + cssIntegrity: "sha384-9i0z0HV8a5Hr0SAQt0+pUfQE96MTbGaCWtZlSzhk+HKIHXsqrGi16HQA4mlEWRvx", langs: { "astro": "chunks/astro.BykyiR6i.js", "c": "chunks/c.BIGW1oBm.js", From d220c09417710d959cb8a9ac0fdbff842d28b96f Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 20 Aug 2026 12:58:40 -0700 Subject: [PATCH 04/10] fix(ui): keep monospace outside the surface subtree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --pn-display-font / --pn-mono-font are defined on [data-pn-surface], so they only resolve inside that subtree — and an unresolvable var makes the whole declaration invalid rather than inherited. Plenty of the UI renders outside it: the review annotation toolbar and the Settings dialog portal to document.body, Base UI popovers mount at the body with no container, and the external line-annotation composer is a sibling of the surface div. Every bare reference there lost monospace at DEFAULT settings, with no typography configured at all. The same stylesheet is bundled into the guides.show viewer, so it shipped there too. Gives all 44 references the palette token as their fallback, the form theme.css already used once for the shortcut key cap, and adds a test that greps both stylesheets so a new bare reference cannot come back. --- packages/review-editor/index.css | 84 +++++++++++----------- packages/ui/theme.css | 4 +- packages/ui/typography.cssFallback.test.ts | 43 +++++++++++ 3 files changed, 87 insertions(+), 44 deletions(-) create mode 100644 packages/ui/typography.cssFallback.test.ts diff --git a/packages/review-editor/index.css b/packages/review-editor/index.css index b94442ed2..afa832426 100644 --- a/packages/review-editor/index.css +++ b/packages/review-editor/index.css @@ -174,7 +174,7 @@ diffs-container { /* Force the app's sans font — without this the card inherits its surroundings, which inside the diff is MONOSPACE, making inline comment prose hard to read and inconsistent with the sidebar. Code spans opt back into mono explicitly. */ - font-family: var(--pn-display-font); + font-family: var(--pn-display-font, var(--font-sans)); margin: 0.5rem 0.25rem; cursor: pointer; /* Soft two-layer shadow (close contact + ambient lift) for a card that reads @@ -233,7 +233,7 @@ diffs-container { /* Inline markdown in review comments */ .review-comment-body .inline-code, .review-comment-markdown .inline-code { - font-family: var(--pn-mono-font); + font-family: var(--pn-mono-font, var(--font-mono)); font-size: 0.85em; background: var(--muted); padding: 0.1em 0.35em; @@ -242,7 +242,7 @@ diffs-container { .review-comment-body .inline-code-block, .review-comment-markdown .inline-code-block { - font-family: var(--pn-mono-font); + font-family: var(--pn-mono-font, var(--font-mono)); font-size: 0.75rem; line-height: 1.5; background: var(--code-bg); @@ -256,7 +256,7 @@ diffs-container { .review-comment-body .inline-code-block code, .review-comment-markdown .inline-code-block code { - font-family: var(--pn-mono-font); + font-family: var(--pn-mono-font, var(--font-mono)); } .light .review-comment-body .inline-code-block, @@ -301,7 +301,7 @@ diffs-container { max-width: 56rem; margin: 0 auto; padding: 1.5rem; - font-family: var(--pn-mono-font); + font-family: var(--pn-mono-font, var(--font-mono)); font-size: 0.8125rem; line-height: 1.55; font-variant-numeric: tabular-nums; @@ -457,7 +457,7 @@ diffs-container { border-radius: var(--radius-sm); background: transparent; color: var(--muted-foreground); - font-family: var(--pn-mono-font); + font-family: var(--pn-mono-font, var(--font-mono)); font-size: 0.6875rem; line-height: 1; cursor: pointer; @@ -513,7 +513,7 @@ diffs-container { color: var(--popover-foreground); border: 1px solid var(--border); border-radius: var(--radius-lg); - font-family: var(--pn-mono-font); + font-family: var(--pn-mono-font, var(--font-mono)); font-size: 0.8125rem; line-height: 1.55; font-variant-numeric: tabular-nums; @@ -552,7 +552,7 @@ diffs-container { min-height: 100%; margin: 0 auto; padding: 1.5rem clamp(1rem, 3vw, 2rem) 3rem; - font-family: var(--pn-mono-font); + font-family: var(--pn-mono-font, var(--font-mono)); font-size: 0.75rem; line-height: 1.45; font-variant-numeric: tabular-nums; @@ -573,7 +573,7 @@ diffs-container { .call-flow-empty-kicker { margin-bottom: 0.25rem; color: var(--primary); - font-family: var(--pn-display-font); + font-family: var(--pn-display-font, var(--font-sans)); font-size: 0.625rem; font-weight: 600; letter-spacing: 0.1em; @@ -582,7 +582,7 @@ diffs-container { .call-flow-header h2 { margin: 0; - font-family: var(--pn-display-font); + font-family: var(--pn-display-font, var(--font-sans)); font-size: 1rem; font-weight: 600; letter-spacing: -0.015em; @@ -646,7 +646,7 @@ diffs-container { align-items: center; gap: 0.375rem; padding: 0 0.5rem; - font-family: var(--pn-display-font); + font-family: var(--pn-display-font, var(--font-sans)); font-size: 0.6875rem; font-weight: 550; white-space: nowrap; @@ -654,7 +654,7 @@ diffs-container { .call-flow-languages-trigger-count { min-width: 2.5ch; color: var(--primary); - font-family: var(--pn-mono-font); + font-family: var(--pn-mono-font, var(--font-mono)); font-size: 0.625rem; font-variant-numeric: tabular-nums; text-align: right; @@ -713,7 +713,7 @@ diffs-container { justify-content: space-between; gap: 0.625rem; color: var(--muted-foreground); - font-family: var(--pn-display-font); + font-family: var(--pn-display-font, var(--font-sans)); font-size: 0.625rem; } .call-flow-context-summary { margin-right: auto; } @@ -792,7 +792,7 @@ diffs-container { min-width: 0; overflow: hidden; color: var(--foreground); - font-family: var(--pn-display-font); + font-family: var(--pn-display-font, var(--font-sans)); font-weight: 550; text-overflow: ellipsis; white-space: nowrap; @@ -852,7 +852,7 @@ diffs-container { } .call-flow-file-boundary-label { color: var(--muted-foreground); - font-family: var(--pn-display-font); + font-family: var(--pn-display-font, var(--font-sans)); font-size: 0.5625rem; font-weight: 650; letter-spacing: 0.08em; @@ -1046,7 +1046,7 @@ diffs-container { padding: clamp(1.75rem, 7vh, 3.5rem) 0.75rem 3rem; color: var(--muted-foreground); /* Prose, not code: the shell defaults to the mono diff face. */ - font-family: var(--pn-display-font); + font-family: var(--pn-display-font, var(--font-sans)); } .call-flow-empty .call-flow-empty-kicker { margin-bottom: 0; @@ -1057,7 +1057,7 @@ diffs-container { } .call-flow-empty strong { color: var(--foreground); - font-family: var(--pn-display-font); + font-family: var(--pn-display-font, var(--font-sans)); font-size: 1.0625rem; font-weight: 600; line-height: 1.3; @@ -1079,7 +1079,7 @@ diffs-container { border-bottom: 1px solid var(--border); } .call-flow-loading div { display: grid; gap: 0.125rem; } -.call-flow-loading strong { font-family: var(--pn-display-font); font-size: 0.875rem; } +.call-flow-loading strong { font-family: var(--pn-display-font, var(--font-sans)); font-size: 0.875rem; } .call-flow-loading span:last-child { color: var(--muted-foreground); font-size: 0.6875rem; } .call-flow-spinner { width: 1rem; @@ -1108,7 +1108,7 @@ diffs-container { padding: 0.1875rem 0.5rem; border-bottom: 1px solid oklch(from var(--border) l c h / 0.6); color: var(--muted-foreground); - font-family: var(--pn-display-font); + font-family: var(--pn-display-font, var(--font-sans)); font-size: 0.6875rem; } .call-flow-raw-hint { @@ -1181,7 +1181,7 @@ diffs-container { .call-flow-search-count { min-width: 3.5rem; color: var(--muted-foreground); - font-family: var(--pn-mono-font); + font-family: var(--pn-mono-font, var(--font-mono)); font-size: 0.625rem; font-variant-numeric: tabular-nums; text-align: center; @@ -1311,7 +1311,7 @@ diffs-container { border-radius: var(--radius-lg); background: var(--popover); color: var(--popover-foreground); - font-family: var(--pn-display-font); + font-family: var(--pn-display-font, var(--font-sans)); font-size: 0.6875rem; outline: none; } @@ -1329,7 +1329,7 @@ diffs-container { .call-flow-languages-popover-header > span { flex: none; padding-top: 0.125rem; - font-family: var(--pn-mono-font); + 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(--pn-mono-font); + 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(--pn-mono-font); + 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(--pn-display-font); + 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(--pn-display-font); + 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(--pn-display-font); + 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(--pn-display-font); + 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(--pn-display-font); + 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(--pn-display-font); + 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(--pn-mono-font); + 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(--pn-mono-font); + 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(--pn-mono-font); + 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(--pn-mono-font); + 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(--pn-mono-font); + 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(--pn-mono-font); + 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(--pn-mono-font); + font-family: var(--pn-mono-font, var(--font-mono)); background: var(--code-bg); } .suggestion-modal-original code { - font-family: var(--pn-mono-font); + 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(--pn-mono-font); + 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(--pn-mono-font); + 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(--pn-mono-font); + 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(--pn-mono-font) !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/ui/theme.css b/packages/ui/theme.css index 8830db8e1..adeba8696 100644 --- a/packages/ui/theme.css +++ b/packages/ui/theme.css @@ -727,10 +727,10 @@ body { --pn-mono-font: var(--pn-review-mono-font, var(--font-mono)); } [data-pn-surface] { - font-family: var(--pn-display-font); + font-family: var(--pn-display-font, var(--font-sans)); } [data-pn-surface] :is(code, kbd, pre, samp) { - font-family: var(--pn-mono-font); + font-family: var(--pn-mono-font, var(--font-mono)); } /* Safari extends the page canvas beneath its floating browser controls. The 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)'); + } + } + }); +}); From 405957ac90c1d2c4bdbc80f992c6d02a3308e7d7 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 20 Aug 2026 12:58:54 -0700 Subject: [PATCH 05/10] fix(review): restore the generic monospace fallback in diff CSS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dropping ", monospace" from the injected font-family means a user whose chosen face is unavailable falls through to the surrounding proportional font, and the diff pane is column-aligned — that is broken output, not a cosmetic downgrade. It reaches legacy diffFontFamily users and the read-only guides.show viewer, which still passes a bare family name. monoFontStack() handles both shapes: it quotes a bare family, leaves an existing CSS stack alone, and appends the generic only when the stack does not already end in one (a generic keyword is never quoted, since '"monospace"' is a family name). --- packages/review-editor/components/DiffHunkPreview.tsx | 8 ++++++-- packages/review-editor/hooks/usePierreTheme.ts | 9 +++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/review-editor/components/DiffHunkPreview.tsx b/packages/review-editor/components/DiffHunkPreview.tsx index 896c31d5d..0543347f4 100644 --- a/packages/review-editor/components/DiffHunkPreview.tsx +++ b/packages/review-editor/components/DiffHunkPreview.tsx @@ -4,6 +4,7 @@ import { getSingularPatch } from '@pierre/diffs'; import type { DiffLineBgIntensity } from '@plannotator/shared/config'; import { useTheme } from '@plannotator/ui/components/ThemeProvider'; import { useConfigValue } from '@plannotator/ui/config'; +import { monoFontStack } from '@plannotator/ui/utils/typography'; import { useReviewState } from '../dock/ReviewStateContext'; import { resolveSyntaxTheme, buildLineBgOverrides } from '../hooks/usePierreTheme'; @@ -31,9 +32,12 @@ function buildPierreCSS( const fg = styles.getPropertyValue('--foreground').trim(); if (!bg || !fg) return ''; - const fontCSS = (fontFamily || fontSize) ? ` + // See usePierreTheme: the generic monospace fallback keeps columns aligned + // when the chosen family is unavailable. + const monoStack = monoFontStack(fontFamily); + const fontCSS = (monoStack || fontSize) ? ` pre, code, [data-line-content], [data-column-number] { - ${fontFamily ? `font-family: ${fontFamily} !important;` : ''} + ${monoStack ? `font-family: ${monoStack} !important;` : ''} ${fontSize ? `font-size: ${fontSize} !important; line-height: 1.5 !important;` : ''} }` : ''; diff --git a/packages/review-editor/hooks/usePierreTheme.ts b/packages/review-editor/hooks/usePierreTheme.ts index 0caa35b77..4815e7e8e 100644 --- a/packages/review-editor/hooks/usePierreTheme.ts +++ b/packages/review-editor/hooks/usePierreTheme.ts @@ -10,6 +10,7 @@ import { useConfigValue } from '@plannotator/ui/config'; * the import path the review editor has always used. */ import { resolveSyntaxTheme } from '@plannotator/ui/utils/syntaxTheme'; +import { monoFontStack } from '@plannotator/ui/utils/typography'; export { SHIKI_THEME_MAP, resolveSyntaxTheme } from '@plannotator/ui/utils/syntaxTheme'; export interface PierreTheme { @@ -241,9 +242,13 @@ export function usePierreTheme(options?: { const primary = styles.getPropertyValue('--primary').trim(); if (!bg || !fg) return; - const fontCSS = fontFamily || fontSize ? ` + // Always keep a generic monospace behind the chosen face: the diff pane + // is column-aligned, so a family that fails to load must not fall through + // to the surrounding proportional font. + const monoStack = monoFontStack(fontFamily); + const fontCSS = monoStack || fontSize ? ` pre, code, [data-line-content], [data-column-number] { - ${fontFamily ? `font-family: ${fontFamily} !important;` : ''} + ${monoStack ? `font-family: ${monoStack} !important;` : ''} ${fontSize ? `font-size: ${fontSize} !important; line-height: 1.5 !important;` : ''} }` : ''; From 8be7034e6c4a328ab78fb4b4519bc920ac8eef07 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 20 Aug 2026 12:58:54 -0700 Subject: [PATCH 06/10] fix(ui): migrate diffFontFamily into typography and unify the catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Code Font picker is gone but diffOptions.fontFamily stayed live on disk with no UI, and six of its nine faces (Hack, Inconsolata, Red Hat Mono, Roboto Mono, Source Code Pro, Atkinson Hyperlegible Mono) had no counterpart in the new catalog, so anyone who had picked one could neither see it nor choose it again. Folds all nine into FONT_CATALOG (ids mirrored in core's allowlist, kept in step by a test) and deletes utils/diffFonts.ts, so there is now ONE stylesheet injector and one URL per family — the old and new catalogs overlapped on four families at different weight ranges, which would have fetched the same face twice. migrateLegacyDiffFont() runs once, right after configStore.init(): it seeds typography.review.mono from the legacy value (catalog entry when the family matches, else a custom stack so a hand-edited name is not lost) and then clears the legacy key. Clearing is what makes it idempotent — without it, a user who later picked "Theme default" would read as "not migrated yet" and have the old font resurrected on the next reload. After it runs, typography is the only source of truth. Also adds a 10s timeout to the catalog loader (a stylesheet that neither loads nor errors left Settings saying "Loading font..." forever) and braces the loadFont effect, which was returning a Promise where React expects a cleanup function. --- packages/core/config-types.ts | 6 +- packages/review-editor/App.tsx | 15 +++- packages/ui/utils/diffFonts.ts | 33 -------- packages/ui/utils/typography.test.ts | 111 ++++++++++++++++++++++++++- packages/ui/utils/typography.ts | 108 +++++++++++++++++++++++++- 5 files changed, 231 insertions(+), 42 deletions(-) delete mode 100644 packages/ui/utils/diffFonts.ts diff --git a/packages/core/config-types.ts b/packages/core/config-types.ts index 129cfef80..4d6dfa3c2 100644 --- a/packages/core/config-types.ts +++ b/packages/core/config-types.ts @@ -15,7 +15,11 @@ export interface ThemeConfig { 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; -export const MONO_TYPOGRAPHY_CATALOG_IDS = ['jetbrains-mono', 'fira-code', 'ibm-plex-mono'] 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]; diff --git a/packages/review-editor/App.tsx b/packages/review-editor/App.tsx index 4d25b805c..7de3f6022 100644 --- a/packages/review-editor/App.tsx +++ b/packages/review-editor/App.tsx @@ -17,8 +17,7 @@ import { getPlatformLabel, getMRLabel, getMRNumberLabel, getDisplayRepo } from ' import type { SemanticDiffAdvert } from '@plannotator/shared/semantic-diff-types'; import type { CallFlowAdvert, CallFlowNode } from '@plannotator/shared/call-flow-types'; import { configStore, useConfigValue, setReviewPanelView } from '@plannotator/ui/config'; -import { loadDiffFont } from '@plannotator/ui/utils/diffFonts'; -import { loadFont, resolveFontFamily } from '@plannotator/ui/utils/typography'; +import { legacyDiffFontSelection, loadFont, migrateLegacyDiffFont, resolveFontFamily } from '@plannotator/ui/utils/typography'; import { getAgentSwitchSettings, getEffectiveAgentName } from '@plannotator/ui/utils/agentSwitch'; import { useAIProviderConfig } from '@plannotator/ui/hooks/useAIProviderConfig'; import { useAIProviderActivation } from '@plannotator/ui/hooks/useAIProviderActivation'; @@ -380,10 +379,13 @@ const ReviewApp: React.FC = () => { // choice even though the visual result applies to plan/document surfaces. const gridEnabled = useConfigValue('gridEnabled'); + // 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 (!typography.review?.mono && diffFontFamily) loadDiffFont(diffFontFamily); + if (!typography.review?.mono) void loadFont(legacyDiffFontSelection(diffFontFamily)); if (diffFontSize) { document.documentElement.style.setProperty('--diff-font-size-override', diffFontSize); } else { @@ -392,7 +394,9 @@ const ReviewApp: React.FC = () => { document.documentElement.style.setProperty('--diffs-tab-size', String(diffTabSize)); }, [diffFontFamily, typography.review?.mono, diffFontSize, diffTabSize]); - useEffect(() => loadFont(typography.review?.mono), [typography.review?.mono]); + // 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); 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 index 29d7fcd29..d1b15491a 100644 --- a/packages/ui/utils/typography.test.ts +++ b/packages/ui/utils/typography.test.ts @@ -1,6 +1,12 @@ import { afterEach, describe, expect, test } from 'bun:test'; -import { FONT_CATALOG, getFontLoadStatus, loadCatalogFont } from './typography'; +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'; @@ -38,3 +44,106 @@ describe.if(hasDom)('catalog font loader', () => { 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 index cec4d3213..244d2186b 100644 --- a/packages/ui/utils/typography.ts +++ b/packages/ui/utils/typography.ts @@ -9,6 +9,13 @@ interface FontCatalogEntryBase { 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' }, @@ -16,6 +23,12 @@ export const FONT_CATALOG = [ { 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]; @@ -24,6 +37,9 @@ export type DisplayFontId = 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(); @@ -51,12 +67,19 @@ export function loadCatalogFont(id: FontCatalogId | undefined): Promise | 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'; @@ -65,16 +88,20 @@ export function loadCatalogFont(id: FontCatalogId | undefined): Promise { const fontSet = document.fonts; if (!fontSet?.load) { - statuses.set(font.id, 'ready'); - resolve('ready'); + resolve(succeed()); return; } void fontSet.load(`1em ${font.family}`).then( - () => { statuses.set(font.id, 'ready'); resolve('ready'); }, + () => 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); @@ -88,3 +115,78 @@ export function loadFont(selection: FontSelection | undefined): Promise 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; +} From 748b166962cba061e21f34f91b00f82b841abb59 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 20 Aug 2026 12:59:04 -0700 Subject: [PATCH 07/10] fix(config): do not delete an unparsable typography block on unrelated writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit saveConfig runs for every setting, so when the value on disk failed to parse (a hand-edited typo, or a profile written by a newer build) currentTypography.ok was false and the key was dropped — the next theme toggle silently deleted the user's whole typography block. Preserve the raw value instead. Readers already validate before use, so a bad block stays inert and visible to be corrected rather than vanishing. Adds the test for that direction; the existing one only covered an invalid INCOMING value. --- packages/shared/config.ts | 12 +++++++++--- packages/shared/config.typography.test.ts | 21 ++++++++++++++++++++- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/packages/shared/config.ts b/packages/shared/config.ts index 0f08431e3..a791808f0 100644 --- a/packages/shared/config.ts +++ b/packages/shared/config.ts @@ -297,11 +297,17 @@ export function saveConfig(partial: Partial): void { : 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 - ? (currentTypography.ok ? currentTypography.value : undefined) - : partialTypography.value; + 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; diff --git a/packages/shared/config.typography.test.ts b/packages/shared/config.typography.test.ts index 370da0dce..229b39b04 100644 --- a/packages/shared/config.typography.test.ts +++ b/packages/shared/config.typography.test.ts @@ -1,5 +1,6 @@ import { afterEach, expect, test } from 'bun:test'; -import { mkdtempSync, rmSync } from 'fs'; +import { mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { join } from 'path'; import { tmpdir } from 'os'; import { loadConfig, saveConfig } from './config'; @@ -28,3 +29,21 @@ test('invalid typography cannot erase saved preferences', () => { 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); +}); From 3fecef4df7d4be639f5d33a14238a5afa44ef7e9 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 20 Aug 2026 12:59:04 -0700 Subject: [PATCH 08/10] docs: document the typography config key Adds it to the config-only settings section: the per-surface shape, catalog ids vs custom CSS stacks (and which are CDN-delivered), the all-or-nothing validation and what an unparsable block does, snapshot replace semantics, and the diffOptions.fontFamily migration. CLAUDE.md is a symlink to AGENTS.md, so one edit covers both. --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index 88321fde9..75805a62a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -171,6 +171,7 @@ claude --plugin-dir ./apps/hook - `markdownExtensions` (array of strings, default none) — extra file extensions the **annotate** path treats as markdown, e.g. `{ "markdownExtensions": [".livemd"] }` for Livebook notebooks (#1307). A listed extension is accepted everywhere `.md` is on that path: CLI target resolution (`plannotator annotate notes.livemd`), folder discovery and the file browser, `/api/doc` plus relative and wiki-link navigation between sibling docs, the 2MB `MAX_ANNOTATABLE_FILE_BYTES` cap, and per-file version history. Listed extensions render as **markdown** (frontmatter stripped), never as raw HTML, and they only widen the set: nothing built in is removed. Entries must start with a dot and be free of path separators, globs, and whitespace (`".livemd"`, not `"livemd"` or `"*.livemd"`); invalid entries are dropped silently, built-in extensions are deduplicated, and the dotenv family can never be registered: `.env` itself plus any entry ending in `.env` or starting with `.env.` (such as `.prod.env` or `.env.local`) is denied, because annotate copies file contents into the data dir (the same reason `.env` is excluded from the built-in set). The value is read from `config.json` once per process. Predicates stay pure in `packages/core/annotatable.ts`, which is browser-safe and cannot read config; the node-side resolver that threads the normalized list into them is `packages/shared/markdown-extensions.ts` (vendored to Pi), and the annotate `/api/plan` payload ships the same list to the renderer so it can linkify links to sibling documents. Not applied to plan write (`ALLOWED_PLAN_EXTENSIONS` in `apps/pi-extension/tool-scope.ts`) or to Edit Mode source save (`SOURCE_SAVE_FILE_REGEX` in `packages/core/source-save.ts`), which keep their own narrower allowlists. - `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. From 045c334ef6266528f8d65cd267f0cc0e63de7537 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 20 Aug 2026 13:01:50 -0700 Subject: [PATCH 09/10] chore(guides-show): regenerate viewer manifest for the typography changes Regenerated with a frozen-lockfile environment; two consecutive builds reproduce these hashes byte-identically. Replaces the original PR's pin, which was built in a drifted local environment and did not reproduce. The new viewer assets still need the release-time upload to guides.show/v1/ before this feature's exports are live. --- packages/core/guide-viewer-manifest.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/guide-viewer-manifest.ts b/packages/core/guide-viewer-manifest.ts index 93f88b8e3..01da7c3a1 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.CpDlIFcA.js", - css: "viewer.BdruF6Mj.css", - jsIntegrity: "sha384-AL8vNhcGQZd8DuYJQfpqGrNTVWBusWhMZEyeki9HMOj6ryXhrvqzPj7EuR7DOt9I", - cssIntegrity: "sha384-9i0z0HV8a5Hr0SAQt0+pUfQE96MTbGaCWtZlSzhk+HKIHXsqrGi16HQA4mlEWRvx", + js: "viewer.DqYZCPiA.js", + css: "viewer.DZJud-bb.css", + jsIntegrity: "sha384-9BHAV4y8TtHv06lWXh/eFWoF+YdEj9Y3t3FytXqCpuEx3dnN5g7zouUFB1B9e5+S", + cssIntegrity: "sha384-1iQPkwKzQcIjvCoL3ten6LLCnQ39n9AXDsGgaBi1y00fU/ZTlfVjS+BJSG2igEkd", langs: { "astro": "chunks/astro.BykyiR6i.js", "c": "chunks/c.BIGW1oBm.js", From 2b59f47541c155b00c2d095f9eac5feecec669c3 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 20 Aug 2026 18:01:12 -0700 Subject: [PATCH 10/10] ci: nudge checks for the maintainer follow-up commits