Skip to content

feat(ui): add per-surface typography customization - #1318

Open
SyahrulBhudiF wants to merge 16 commits into
backnotprop:mainfrom
SyahrulBhudiF:feat/per-surface-typography
Open

feat(ui): add per-surface typography customization#1318
SyahrulBhudiF wants to merge 16 commits into
backnotprop:mainfrom
SyahrulBhudiF:feat/per-surface-typography

Conversation

@SyahrulBhudiF

@SyahrulBhudiF SyahrulBhudiF commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Pull Request: Per-surface typography customization

Summary

Implements Issue #1275.

Plannotator users can now customize typography independently for each surface:

  • Plan
  • Annotate
  • Code Review

Each surface supports separate:

  • Display font
  • Code/monospace font

Example

The Typography settings use the same theme-card selection UI as the existing theme picker. The selected font is shown with the active theme styling and a live preview.

image

Included

  • Theme-aware font selection cards
  • Theme-default reset for each font role
  • Curated display and monospace font catalog
  • Custom local CSS font-family stack support
  • Per-surface persistence across Bun and Pi
  • Strict config and API validation
  • Safe custom font-family validation
  • Lazy font loading with retry after failures
  • Pierre-supported font configuration for Code Review
  • Positive and negative tests for parsing, persistence, and loading

Verification

Passed focused tests and type checks:

bun test packages/core/config-types.test.ts packages/shared/config.typography.test.ts
DOM_TESTS=1 bun test packages/ui/utils/typography.test.ts
bunx tsc --noEmit -p packages/core/tsconfig.json
bunx tsc --noEmit -p packages/ui/tsconfig.json
bunx tsc --noEmit -p apps/pi-extension/tsconfig.json
git diff --check

Closes #1275.


Maintainer follow-up

Pushed on top of the contributor's commits. The feature design, config durability and Pi parity were already sound; these are the review's fix list.

1. Reverted the viewer manifest bump. packages/core/guide-viewer-manifest.ts pinned viewer.C4G-XTIH.js, a hash that does not reproduce from any build of this tree. Since guides.show /v1/ is add-only, shipping it would have pointed every exported guide and hosted share page at an asset that will never exist there. Manifest regeneration is a maintainer release step, verified against what is actually uploaded. This also removed the branch's only merge conflict with main.

2. Fallbacks on every per-surface font var. --pn-display-font / --pn-mono-font are defined on [data-pn-surface], so they only resolve inside that subtree, and an unresolvable var() invalidates the whole declaration rather than inheriting. Large parts of the UI render outside that subtree: the review annotation toolbar and 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. All of those lost monospace at default settings, with no typography configured, and the same stylesheet is bundled into the guides.show viewer. All 44 references now carry the palette token as their fallback, the form the PR already used once at theme.css:1101. A new test greps both stylesheets so a bare reference cannot come back.

3. Restored the generic monospace fallback in the diff CSS. usePierreTheme.ts and DiffHunkPreview.tsx had dropped , monospace, so a legacy diffFontFamily user whose face is unavailable fell through to the surrounding proportional font. The diff pane is column-aligned, so that is broken output. A shared monoFontStack() handles both value shapes: it quotes a bare family name (still what the read-only guides.show viewer passes), leaves an existing CSS stack alone, and appends the generic only when the stack does not already end in one.

4. Migrated diffFontFamily. The PR removed the Code Font picker but left the setting live 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 chosen one could neither see it nor pick it again. All nine are now folded into FONT_CATALOG, with the ids mirrored in core's allowlist and kept in step by a test, and utils/diffFonts.ts is deleted, so there is one stylesheet injector and one URL per family. The two catalogs previously overlapped on four families at different weight ranges, which would have fetched the same face twice.

migrateLegacyDiffFont() runs once, immediately after configStore.init(). It seeds typography.review.mono from the legacy value, as a catalog entry when the family matches and otherwise as 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 chose "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 for the review face.

5. Fixed saveConfig dropping an unparsable typography. 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) the key was deleted by the next unrelated write, such as a theme toggle. The raw value is now preserved. Readers already validate before use, so a bad block stays inert and visible to be corrected. Added the test for that direction; the existing one only covered an invalid incoming value.

6. Documented the typography key in the config-only settings section of AGENTS.md (CLAUDE.md is a symlink to it, so one edit covers both): the per-surface shape, catalog ids versus custom CSS stacks and which are CDN-delivered, the all-or-nothing validation and what an unparsable block does, snapshot replace semantics, and the migration.

Also fixed two smaller things found on the way: the catalog loader now gives up after 10 seconds, so a stylesheet that neither loads nor errors no longer leaves Settings saying "Loading font..." forever, and the loadFont effect is braced, since it was returning a Promise where React expects a cleanup function.

CDN font delivery is kept as designed.

Verification

  • bun run typecheck clean.
  • bun test: 3646 pass, 15 fail, all environmental and a strict subset of the 17 failing on the PR head before these commits.
  • DOM_TESTS=1 bun test packages/ui: 1047 pass, 0 fail. DOM_TESTS=1 across packages/ui packages/core packages/shared: 1177 pass, only the 3 network-dependent live paste-service tests failing.
  • bun run --cwd apps/review build and bun run build:hook both succeed; the built review, hook and guides HTML contain zero bare --pn-mono-font / --pn-display-font references and 45 fallback-carrying ones.
  • git merge-tree against origin/main: zero conflicts.

AI-assisted (Claude) under maintainer direction.

SyahrulBhudiF and others added 11 commits August 15, 2026 18:11
…graphy

# Conflicts:
#	apps/pi-extension/server/serverAnnotate.ts
#	apps/pi-extension/server/serverPlan.ts
#	apps/pi-extension/server/serverReview.ts
#	packages/server/annotate.ts
#	packages/server/index.ts
#	packages/server/review.ts
#	packages/shared/config.ts
#	packages/ui/components/ThemeProvider.tsx
#	packages/ui/components/ThemeTab.tsx
#	packages/ui/config/settings.ts
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.
--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.
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).
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.
…d writes

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.
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.
…nges

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.
@backnotprop backnotprop reopened this Aug 20, 2026
backnotprop and others added 4 commits August 20, 2026 18:01
…ment keys

Union resolutions in the config allowlists and settings registry (both
runtimes), and the guide-viewer manifest regenerated on the combined
tree in a frozen-lockfile environment (two consecutive builds reproduce
the hashes).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add customization for plannotator

2 participants