Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
62bd1eb
feat(ui): add per-surface typography customization
SyahrulBhudiF Aug 14, 2026
73ac0a3
Merge branch 'main' into feat/per-surface-typography
SyahrulBhudiF Aug 15, 2026
535a6e5
Merge remote-tracking branch 'origin/main' into feat/per-surface-typo…
SyahrulBhudiF Aug 18, 2026
425f2ee
Merge remote-tracking branch 'origin/feat/per-surface-typography' int…
SyahrulBhudiF Aug 18, 2026
65c59e8
chore(guides-show): refresh viewer asset manifest
SyahrulBhudiF Aug 18, 2026
7fe14dd
revert(guides-show): drop the viewer manifest bump from this PR
backnotprop Aug 20, 2026
d220c09
fix(ui): keep monospace outside the surface subtree
backnotprop Aug 20, 2026
405957a
fix(review): restore the generic monospace fallback in diff CSS
backnotprop Aug 20, 2026
8be7034
fix(ui): migrate diffFontFamily into typography and unify the catalog
backnotprop Aug 20, 2026
748b166
fix(config): do not delete an unparsable typography block on unrelate…
backnotprop Aug 20, 2026
3fecef4
docs: document the typography config key
backnotprop Aug 20, 2026
045c334
chore(guides-show): regenerate viewer manifest for the typography cha…
backnotprop Aug 20, 2026
2b59f47
ci: nudge checks for the maintainer follow-up commits
backnotprop Aug 21, 2026
a8fac0c
Merge origin/main: compose typography config with the Agent TUI place…
backnotprop Aug 21, 2026
e7d5206
Merge origin/main into feat/per-surface-typography
SyahrulBhudiF Aug 22, 2026
73335d2
Merge branch 'main' into feat/per-surface-typography
SyahrulBhudiF Aug 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ claude --plugin-dir ./apps/hook
- `agentTerminalDefaultAgent` (string agent id, e.g. `"claude"` or `"codex"`, default `""` meaning no recorded choice): which agent the annotate-mode Agent TUI preselects when the panel opens (#1050). Validation is `typeof === "string"` only, with no enum and no check against installed agents, so an unknown or currently unavailable id is inert rather than an error: `resolveAnnotateAgentId` uses the saved id only when it appears among the available agents and otherwise takes the first available one (`packages/ui/utils/annotateAgentTerminal.ts:45-54`). It is written only by the "save as default" checkbox in the terminal's agent picker (`packages/editor/components/AnnotateAgentTerminalPanel.tsx:257`); there is no Settings control for it. An empty string deletes the cookie and reads as unset, though the server allowlist will still write `""` into `config.json`, where it is then ignored.
- Precedence for both agent-terminal keys follows the settings registry (`packages/ui/config/settings.ts`) and its resolver (`packages/ui/config/configStore.ts:3-5`): **server config file > cookie > built-in default**. `config.json` is the durable, cross-browser store; the cookie (`plannotator-annotate-agent-terminal-side`, `plannotator-annotate-agent-terminal-default`) is the browser-local fallback. There is no one-time cookie-to-config migration: those two cookie names were deliberately kept unchanged so a pre-registry cookie stays readable, and its value only reaches `config.json` if the user changes the setting again. The sync runs one direction at startup, with `init()` stamping a valid config value back into the cookie (`packages/ui/config/configStore.ts:157-161`). Neither key has an env-var equivalent, and only the annotate servers allowlist them on `POST /api/config` (`packages/server/annotate.ts:726-727`, mirrored in `apps/pi-extension/server/serverAnnotate.ts:690-691`), so setting them has no effect on plan or review sessions.
- `pfmReminder` (`true` / `false`, default `false`) — when enabled, a Plannotator Flavored Markdown reminder is injected at plan-time describing the renderer's extensions (code-file links, callouts, tables, diagrams, task lists, hex swatches, wiki-links). Lets the planning agent enrich plans with PFM features without having to discover them. Composes cleanly with the compound-skill improvement hook. Supported across all three runtimes: Claude Code (`improve-context` PreToolUse hook in `apps/hook/server/index.ts`), OpenCode (`experimental.chat.system.transform` in `apps/opencode-plugin/index.ts`), and Pi (`before_agent_start` in `apps/pi-extension/index.ts`).
- `typography` (object, default none) — per-surface font overrides, keyed `plan` / `annotate` / `review`, each holding an optional `display` (reading and interface text) and `mono` (code, diffs, shortcuts) selection of the shape `{ "family": string, "source": "catalog" | "custom" }`. Normally set from Settings -> Theme -> Typography rather than by hand; the key is listed here because there is no env var for it. A `catalog` selection's `family` is a catalog **id**, not a CSS name (`inter`, `atkinson-hyperlegible`, `ibm-plex-sans` for display; `jetbrains-mono`, `fira-code`, `ibm-plex-mono`, `hack`, `inconsolata`, `red-hat-mono`, `roboto-mono`, `source-code-pro`, `atkinson-hyperlegible-mono` for mono) and is delivered from a CDN stylesheet on demand; a `custom` selection's `family` is a literal CSS font-family stack (max 240 chars, no `{`, `}` or `;`) resolved from locally installed fonts, with no network fetch. The catalog is `FONT_CATALOG` in `packages/ui/utils/typography.ts` and its ids are mirrored for validation in `packages/core/config-types.ts`; a role may only take an id declared for that role. Validation is strict and all-or-nothing (`parseTypographyConfig`): one bad entry makes the whole key unparsable, in which case it is IGNORED at read time but preserved on disk, so a typo disables the overrides without silently deleting the block on the next unrelated settings write. Unset roles inherit the active palette's `--font-sans` / `--font-mono`, and every override is scoped to its surface's `[data-pn-surface]` subtree, so the plan and review apps can carry different faces in the same session. A saved profile is a complete snapshot: writing `typography` REPLACES the stored value rather than deep-merging, which is what makes clearing a role durable. Supersedes the retired `diffOptions.fontFamily` Code Font picker; an existing `diffOptions.fontFamily` is migrated once into `typography.review.mono` (matching catalog entry, else a custom stack) and then cleared.

**Legacy:** `SSH_TTY` and `SSH_CONNECTION` are still detected when `PLANNOTATOR_REMOTE` is unset. Set `PLANNOTATOR_REMOTE=1` / `true` to force remote mode or `0` / `false` to force local mode.

Expand Down
9 changes: 7 additions & 2 deletions apps/pi-extension/server/serverAnnotate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { contentHash, deleteDraft } from "../generated/draft.ts";
import { getPlanVersion, getVersionCount, listVersions } from "../generated/storage.ts";
import { computeAnnotateHistory, deriveAnnotateHistorySlug, persistAnnotateSubmission, type AnnotateHistoryResult } from "../generated/annotate-history.ts";
import { htmlDiff } from "../generated/html-diff.ts";
import { saveConfig, detectGitUser, getServerConfig, isAgentTerminalSide, loadConfig, resolveAIEnabled, resolveSharingEnabled, resolveAnnotateHistory, type PromptRuntime } from "../generated/config.ts";
import { saveConfig, detectGitUser, getServerConfig, isAgentTerminalSide, loadConfig, parseTypographyConfig, resolveAIEnabled, resolveSharingEnabled, resolveAnnotateHistory, type PromptRuntime } from "../generated/config.ts";
import { isFaviconStyle, type FaviconStyle } from "../generated/favicon.ts";
import { getAnnotateFileFeedbackTemplate, getAnnotateMessageFeedbackTemplate } from "../generated/prompts.ts";
import { disabledSourceSave, type SourceSaveRequest } from "../generated/source-save.ts";
Expand Down Expand Up @@ -762,11 +762,16 @@ export async function startAnnotateServer(options: {
handleShareHtml(res, url);
} else if (url.pathname === "/api/config" && req.method === "POST") {
try {
const body = (await parseBody(req)) as { displayName?: string; diffOptions?: Record<string, unknown>; theme?: Record<string, unknown>; favicon?: FaviconStyle; conventionalComments?: boolean; agentTerminalSide?: unknown; agentTerminalDefaultAgent?: unknown };
const body = (await parseBody(req)) as { displayName?: string; diffOptions?: Record<string, unknown>; theme?: Record<string, unknown>; typography?: Record<string, unknown>; favicon?: FaviconStyle; conventionalComments?: boolean; agentTerminalSide?: unknown; agentTerminalDefaultAgent?: unknown };
const toSave: Record<string, unknown> = {};
if (body.displayName !== undefined) toSave.displayName = body.displayName;
if (body.diffOptions !== undefined) toSave.diffOptions = body.diffOptions;
if (body.theme !== undefined) toSave.theme = body.theme;
if (body.typography !== undefined) {
const typography = parseTypographyConfig(body.typography);
if (!typography.ok) return json(res, { error: "Invalid typography" }, 400);
toSave.typography = typography.value;
}
if (isFaviconStyle(body.favicon)) toSave.favicon = body.favicon;
if (body.conventionalComments !== undefined) toSave.conventionalComments = body.conventionalComments;
if (isAgentTerminalSide(body.agentTerminalSide)) toSave.agentTerminalSide = body.agentTerminalSide;
Expand Down
9 changes: 7 additions & 2 deletions apps/pi-extension/server/serverPlan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import {
} from "./integrations.ts";
import { buildAdvertisedUrl, listenOnPort } from "./network.ts";

import { loadConfig, saveConfig, detectGitUser, getServerConfig, resolveAIEnabled, resolveSharingEnabled } from "../generated/config.ts";
import { loadConfig, saveConfig, detectGitUser, getServerConfig, parseTypographyConfig, resolveAIEnabled, resolveSharingEnabled } from "../generated/config.ts";
import { isFaviconStyle, type FaviconStyle } from "../generated/favicon.ts";
import { readImprovementHook, getImprovementHookExpectedPath } from "../generated/improvement-hooks.ts";
import { composeImproveContext } from "../generated/pfm-reminder.ts";
Expand Down Expand Up @@ -258,11 +258,16 @@ export async function startPlanReviewServer(options: {
});
} else if (url.pathname === "/api/config" && req.method === "POST") {
try {
const body = (await parseBody(req)) as { displayName?: string; diffOptions?: Record<string, unknown>; theme?: Record<string, unknown>; favicon?: FaviconStyle; conventionalComments?: boolean; conventionalLabels?: unknown[] | null; pfmReminder?: boolean };
const body = (await parseBody(req)) as { displayName?: string; diffOptions?: Record<string, unknown>; theme?: Record<string, unknown>; typography?: Record<string, unknown>; favicon?: FaviconStyle; conventionalComments?: boolean; conventionalLabels?: unknown[] | null; pfmReminder?: boolean };
const toSave: Record<string, unknown> = {};
if (body.displayName !== undefined) toSave.displayName = body.displayName;
if (body.diffOptions !== undefined) toSave.diffOptions = body.diffOptions;
if (body.theme !== undefined) toSave.theme = body.theme;
if (body.typography !== undefined) {
const typography = parseTypographyConfig(body.typography);
if (!typography.ok) return json(res, { error: "Invalid typography" }, 400);
toSave.typography = typography.value;
}
if (isFaviconStyle(body.favicon)) toSave.favicon = body.favicon;
if (body.conventionalComments !== undefined) toSave.conventionalComments = body.conventionalComments;
if (body.conventionalLabels !== undefined) toSave.conventionalLabels = body.conventionalLabels;
Expand Down
9 changes: 7 additions & 2 deletions apps/pi-extension/server/serverReview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { basename, resolve as resolvePath } from "node:path";

import { SingleFlight } from "../generated/single-flight.ts";
import { contentHash, deleteDraft } from "../generated/draft.ts";
import { loadConfig, saveConfig, detectGitUser, getServerConfig, parseReviewAnalysisConfig, resolveAIEnabled, resolveSharingEnabled, resolveCursorSandbox, resolveGuideHistory, resolveGuideShareUrl } from "../generated/config.ts";
import { loadConfig, saveConfig, detectGitUser, getServerConfig, parseReviewAnalysisConfig, parseTypographyConfig, resolveAIEnabled, resolveSharingEnabled, resolveCursorSandbox, resolveGuideHistory, resolveGuideShareUrl } from "../generated/config.ts";
import { isFaviconStyle, type FaviconStyle } from "../generated/favicon.ts";

export type {
Expand Down Expand Up @@ -3002,11 +3002,16 @@ export async function startReviewServer(options: {
}
} else if (url.pathname === "/api/config" && req.method === "POST") {
try {
const body = (await parseBody(req)) as { displayName?: string; diffOptions?: Record<string, unknown>; theme?: Record<string, unknown>; favicon?: FaviconStyle; reviewAnalysis?: Record<string, unknown>; conventionalComments?: boolean };
const body = (await parseBody(req)) as { displayName?: string; diffOptions?: Record<string, unknown>; theme?: Record<string, unknown>; typography?: Record<string, unknown>; favicon?: FaviconStyle; reviewAnalysis?: Record<string, unknown>; conventionalComments?: boolean };
const toSave: Record<string, unknown> = {};
if (body.displayName !== undefined) toSave.displayName = body.displayName;
if (body.diffOptions !== undefined) toSave.diffOptions = body.diffOptions;
if (body.theme !== undefined) toSave.theme = body.theme;
if (body.typography !== undefined) {
const typography = parseTypographyConfig(body.typography);
if (!typography.ok) return json(res, { error: "Invalid typography" }, 400);
toSave.typography = typography.value;
}
if (isFaviconStyle(body.favicon)) toSave.favicon = body.favicon;
if (body.reviewAnalysis !== undefined) {
const reviewAnalysis = parseReviewAnalysisConfig(body.reviewAnalysis);
Expand Down
32 changes: 32 additions & 0 deletions packages/core/config-types.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
51 changes: 51 additions & 0 deletions packages/core/config-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,57 @@ export interface ThemeConfig {
dark?: string;
}

export type TypographySurface = 'plan' | 'annotate' | 'review';
export type TypographyRole = 'display' | 'mono';
export const DISPLAY_TYPOGRAPHY_CATALOG_IDS = ['inter', 'atkinson-hyperlegible', 'ibm-plex-sans'] as const;
/** Kept in step with FONT_CATALOG in packages/ui/utils/typography.ts (asserted by its test). */
export const MONO_TYPOGRAPHY_CATALOG_IDS = [
'jetbrains-mono', 'fira-code', 'ibm-plex-mono', 'hack', 'inconsolata',
'red-hat-mono', 'roboto-mono', 'source-code-pro', 'atkinson-hyperlegible-mono',
] as const;
export const TYPOGRAPHY_CATALOG_IDS = [...DISPLAY_TYPOGRAPHY_CATALOG_IDS, ...MONO_TYPOGRAPHY_CATALOG_IDS] as const;
export type TypographyCatalogId = typeof TYPOGRAPHY_CATALOG_IDS[number];

export interface FontSelection {
/** A trusted catalog id or a validated CSS font-family stack. */
family: string;
source: 'catalog' | 'custom';
}

export type SurfaceTypography = Partial<Record<TypographyRole, FontSelection>>;
export type TypographyConfig = Partial<Record<TypographySurface, SurfaceTypography>>;

export type TypographyParseResult =
| { ok: true; value: TypographyConfig }
| { ok: false };

const TYPOGRAPHY_SURFACES = new Set<TypographySurface>(['plan', 'annotate', 'review']);
const TYPOGRAPHY_ROLES = new Set<TypographyRole>(['display', 'mono']);
const DISPLAY_TYPOGRAPHY_CATALOG = new Set<string>(DISPLAY_TYPOGRAPHY_CATALOG_IDS);
const MONO_TYPOGRAPHY_CATALOG = new Set<string>(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<string, unknown>)) {
if (!TYPOGRAPHY_ROLES.has(role as TypographyRole) || !selection || typeof selection !== 'object' || Array.isArray(selection)) return { ok: false };
const { family, source } = selection as Record<string, unknown>;
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';
Expand Down
8 changes: 4 additions & 4 deletions packages/core/guide-viewer-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@
import type { GuideViewerAssets } from "./guide-format";

export const GUIDE_VIEWER_MANIFEST: Omit<GuideViewerAssets, "baseUrl"> = {
js: "viewer.CTfggrYt.js",
css: "viewer.BdruF6Mj.css",
jsIntegrity: "sha384-It85Hkx0/d1Xme4SJjt3shHybLPGucRF/OODzE84mbOmGH8fK66eFNzgFRXe2W4Z",
cssIntegrity: "sha384-9i0z0HV8a5Hr0SAQt0+pUfQE96MTbGaCWtZlSzhk+HKIHXsqrGi16HQA4mlEWRvx",
js: "viewer.BFaoWWQ_.js",
css: "viewer.VlGbzmRQ.css",
jsIntegrity: "sha384-3YSCHmWdMO220r262prqJx1bbN/aDgULkNBij5aDNCH7xvJjV1MdI4NtmVsQa2XH",
cssIntegrity: "sha384-oHCBQ5EbgjN6PfNTxnJ04+j4C8pUbdedvhz4wvvjXXBrNLnuUtVsD2W8QsBS5b94",
langs: {
"astro": "chunks/astro.BykyiR6i.js",
"c": "chunks/c.BIGW1oBm.js",
Expand Down
1 change: 1 addition & 0 deletions packages/editor/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5010,6 +5010,7 @@ const App: React.FC = () => {
<TooltipProvider delayDuration={900} skipDelayDuration={200} disableHoverableContent>
<div
data-print-region="root"
data-pn-surface={annotateMode ? 'annotate' : 'plan'}
data-pn-browser-canvas={browserCanvas}
data-pn-compact-touch-layout={usesDocumentScroll ? 'true' : undefined}
data-pn-document-scroll={usesDocumentScroll ? 'true' : undefined}
Expand Down
Loading