backnotprop / backnotprop/plannotator

[Feature Request] Appearance: light/dark theme pairs, custom user themes, and separate code/UI fonts

Open
#1,211 2 comments 1 reaction 0 assignees View on GitHub
Dominant language
TypeScript
Stars
8.7k
Forks
649
Avg merge
11h 12m
Merged PRs (30d)
109

Description

Three related gaps in the appearance system. Filing them together because they share the same files and compose into one design — happy to split into separate issues or PRs if you'd rather take them independently.

## Current state

- `ThemeProvider` (`packages/ui/components/ThemeProvider.tsx`) stores a single `colorTheme` plus a mode (`light` / `dark` / `system`). Palettes in `BUILT_IN_THEMES` (`packages/ui/utils/themeRegistry.ts`) declare `modeSupport: 'both' | 'dark-only' | 'light-only'`, and on conflict `normalizeThemeMode()` coerces **the mode**, not the palette.
- All ~45 palettes are compiled in: static `@import`s in `packages/ui/theme.css` → `.theme-` / `.theme-.light`. `availableThemes: BUILT_IN_THEMES` is the only source. (`ThemeInfo.builtIn` exists but nothing consumes it yet.)
- One typography control exists: **Settings → Review → Display → Code Font / Code Font Size** (`diffFontFamily`, `diffFontSize`), scoped to the review diff, chosen from a fixed list of 9 CDN families in `packages/ui/utils/diffFonts.ts`. `--font-sans` / `--font-mono` are hardcoded per theme file.

---

## 1. Pair a light theme and a dark theme, switched by mode

### Problem

Picking a dark-only palette (Kanagawa Wave, Vesper, Nord, Dracula, One Dark Pro, Synthwave '84) or a light-only one (One Light, Snazzy Light, Kanagawa Lotus, Tinacious) effectively **disables System mode** — the mode gets pinned to whatever that palette supports, and `ThemeTab.tsx` renders the Light/Dark/System buttons disabled via `isThemeModeAvailable()`.

So there's no way to express the thing most editors do by default:

> Kanagawa Wave at night, Kanagawa Lotus during the day.
> Rosé Pine at night, Rosé Pine Dawn during the day.

You either give up automatic switching, or restrict yourself to the `modeSupport: 'both'` palettes and accept whatever light variant that palette happens to ship.

### Proposal

1. **Store a pair, not a single id.** Persist `{ mode, lightTheme, darkTheme }` and resolve the active palette as `pair[preferredMode]` — `preferredMode` already exists in `ThemeProvider` and already resolves `system` against `prefers-color-scheme`.
2. **Segment the picker.** Group the theme grid into **Light themes** / **Dark themes** using the existing `modeSupport` field (`both` palettes appear in each group with that group's swatches, which `resolveThemeMode()` already computes). A Light/Dark segmented control at the top decides which half of the pair you're assigning.
3. **Show both assignments** — a compact `Light: Rosé Pine Dawn · Dark: Rosé Pine` summary row, each clickable.
4. **Explicit modes still work.** `mode: light` / `mode: dark` pin which half is used; `mode: system` is what makes the pair valuable.
5. **Syntax highlighting needs no work.** `resolveSyntaxTheme()` and `SHIKI_THEME_MAP` (`packages/review-editor/hooks/usePierreTheme.ts`) are already keyed on `(colorTheme, mode)` — resolving the palette earlier is enough.
6. **Persist to `~/.plannotator/config.json`.** Add the pair to the `SETTINGS` registry in `packages/ui/config/settings.ts` with a `serverKey` (e.g. `theme: { mode, light, dark }`), the same way `diffOptions` round-trips today, so the choice survives across sessions and hosts instead of living only in browser storage.
7. **Migration.** Seed both halves from the existing `plannotator-color-theme` value: if it's a `both` palette, use it for light and dark; if it's mode-restricted, assign it to the half it supports and default the other half (e.g. `plannotator`). Keep writing the legacy key so a downgrade doesn't land on an unstyled first frame.

### Side benefit

Mode coercion and the disabled mode buttons disappear — a dark-only palette simply never appears in the light slot, which is more honest than a greyed-out "Light" button with a tooltip.

---

## 2. Load a user-defined custom theme

### Problem

Adding a palette today means forking and rebuilding. The built-in list is genuinely great, but "my company's palette" or "my exact terminal colours" has no path.

### Proposal

**Theme source — a file the user (or their agent) writes**

`~/.plannotator/themes/.json`, mirroring the token set the CSS files already use:

```jsonc
{
"id": "acme-dark",
"name": "Acme",
"modeSupport": "both",
"syntaxTheme": { "dark": "tokyo-night", "light": "vitesse-light" }, // existing Shiki theme names
"radius": "0.625rem",
"fontSans": "system-ui, sans-serif",
"fontMono": "'Fira Code', ui-monospace, monospace",
"dark": { "background": "#1a1b26", "foreground": "#c0caf5", "primary": "#7aa2f7", "...": "..." },
"light": { "background": "#e1e2e7", "foreground": "#3760bf", "primary": "#2e7de9", "...": "..." }
}
```

Token names map 1:1 to the existing CSS variables: `background`, `foreground`, `card`, `popover`, `primary`, `secondary`, `muted`, `accent`, `destructive`, `border`, `input`, `ring`, `success`, `warning` (plus their `-foreground` pairs), `code-bg`, `focus-highlight`, `radius`, `font-sans`, `font-mono`. JSON rather than raw CSS so it can be validated, previewed, and safely serialised.

**Server**

`GET /api/themes` reads the directory, validates, and returns `ThemeInfo[]` plus a generated CSS block scoped to `.theme-` / `.theme-.light`. The UI injects one `` tag — `applyThemeClasses()` in `ThemeProvider` needs no change, since the class mechanism is identical.

**Validation / safety**

Emit **only** allowlisted token names, and only values matching a strict colour/length grammar (hex, `rgb()`, `oklch()`, `hsl()`, rem/px for `radius`, a font-family list for the font tokens). Drop and report anything else — no `url()`, no `@import`, no raw declarations. Worth being strict given Plannotator serves a local origin (see #956).

**UI**

- A **Custom** section in the theme picker.
- **Import theme…** (file picker or paste JSON) that validates and live-previews before saving.
- Per-theme **Reveal file** / **Delete**.

**Agent authoring**

Ship `docs/custom-themes.md` with the full token contract and one complete worked example, plus a **"Copy prompt for your agent"** button in the theme picker. The flow becomes: copy prompt → paste into Claude Code / Codex → *"make me a theme matching my terminal colours"* → agent writes `~/.plannotator/themes/mine.json` → it appears in the picker. Given Plannotator's audience this is the natural authoring path, and the strict validator above means malformed agent output fails loudly instead of half-applying.

This composes with §1 — a custom theme declaring both a `light` and a `dark` block slots straight into the pair.

---

## 3. Separate font settings for code and for UI/prose text

Related to #851 and PR #855, which cover a custom family name for the **diff** font. Two gaps remain on top of that.

### Problem

**a. The code font only reaches the review diff.** Markdown code blocks and inline code in the plan and annotate surfaces render with `--font-mono`, which is hardcoded per theme file (`packages/ui/themes/*.css`, mostly `'Fira Code', ui-monospace, monospace`) and isn't user-settable. So "set my code font" half-works depending on which surface you're looking at.

**b. There is no interface/prose font setting at all.** `--font-sans` is likewise baked into each theme (`system-ui, sans-serif` in most). Annotating a 2,000-word plan is a reading task, not a code task, and there's no way to change the face — whether the motivation is preference or legibility (e.g. Atkinson Hyperlegible for dyslexia or low vision).

### Proposal

1. **Two settings, one place.** A **Typography** group with **Code font** (family + size) and **Interface font** (family + size). Put it with Theme/Appearance rather than under Review → Display, since it applies to every surface, not just reviews.
2. **Apply through the existing tokens.** Set `--font-mono` / `--font-sans` overrides on `documentElement` so plan markdown, the annotations panel, comment popovers, the sidebar and the diff all pick them up with no per-component changes. Keep `--diff-font-override` as the diff-specific override so the diff can still differ from other code if someone wants that.
3. **Font sources, tiered:**
- **Built-in list** — extend `diffFonts.ts` with sans options (Inter, Atkinson Hyperlegible, IBM Plex Sans, Source Sans 3, system default).
- **Any locally-installed family by name** — exactly what PR #855 builds (`fontDetect.ts` + the Custom… input). Ideally that lands first and the same control is reused for both fields.
- *(Optional, later)* a local font file embedded as a base64 `@font-face` in config, for families that aren't installed system-wide.
4. **Keep the offline story explicit.** Built-in fonts load from Google Fonts / jsDelivr — currently the only network egress from this picker. A "system fonts only" posture should stay possible for air-gapped setups (related to #1163).
5. **Per-theme defaults still win when unset**, exactly as `diffFontFamily: ''` does today — a theme that ships a deliberate `--font-mono` keeps it until the user overrides.

---

Happy to implement all three. I'd start with §1 since §2 depends on its pair format, and §3 is cleanest stacked on #855. Let me know if you'd prefer these as separate issues, and whether you'd take PRs.

Contributor guide

Open the contributing guide

Research direction

Start by reading packages/ui/components/ThemeProvider.tsx, packages/ui/utils/themeRegistry.ts, packages/ui/config/settings.ts, and packages/ui/utils/diffFonts.ts to separate the theme-pair, custom-theme, and typography work. Then trace /api/themes, packages/ui/theme.css, and packages/review-editor/hooks/usePierreTheme.ts. Done means the chosen scopes are implemented, custom themes are validated and exposed, settings persist, and the documented UI behavior works across themes and surfaces.

Written by the indexing model from the issue text.

Assessment

Tech stack
css, typescript
Domain
backend, documentation, frontend
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
38/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.