Files
backnotprop__plannotator/scripts/convert-themes.ts
Michael Ramos e1bd27aae4 feat: custom theme system with 18 built-in themes (#294)
* feat: custom theme system with 15 built-in themes

Consolidate CSS theming into a single source of truth (packages/ui/theme.css)
and introduce a multi-theme architecture where each theme defines both dark
and light mode variants. Users can pick a color palette (theme) and separately
toggle dark/light mode within it.

- Extract shared color tokens, Tailwind bridge, and base styles into packages/ui/theme.css
- Replace hardcoded oklch values with token references (oklch from var syntax)
- Fix 3 light-mode bugs in review-editor diff colors
- Create 15 built-in themes: Plannotator (default), Claude+, Soft Pop, Adwaita,
  Caffeine, Cyberdyne, Cyberfunk, Doom 64, Dracula, Gruvbox, PaulMillr,
  Quantum Rose, Solar Dusk, Terminal, Tinacious
- Expand ThemeProvider to manage colorTheme + mode independently
- Add Theme tab to Settings with mode toggle, search, and swatch grid
- Dark-only themes (Dracula, Terminal, etc.) suppress light class to prevent
  broken styling; light-only themes (Tinacious) force it
- Cookie persistence: plannotator-color-theme for palette, existing key for mode
- Include theme conversion script for FinSitter theme adaptation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: refine themes, add Catppuccin/Rosé Pine/Monokai Pro/Synthwave 84/Tokyo Night

Theme curation:
- Replace Cyberdyne with Synthwave '84 (from robb0wen/synthwave-vscode)
- Replace Cyberfunk with Catppuccin (Mocha dark + Latte light), Rosé Pine
  (dark + Dawn light), Monokai Pro (dark only), Tokyo Night (Storm + Day)
- Rewrite Gruvbox from canonical source (morhetz/gruvbox)
- Rewrite Adwaita from canonical VS Code theme (piousdeer/vscode-adwaita)
- Rewrite PaulMillr from Ghostty canonical palette

Theme fixes:
- Fix faded Send Feedback button on Solar Dusk, Quantum Rose, Caffeine
  (accent colors were too dark/invisible at 15% opacity)
- Fix Dracula/Terminal/PaulMillr/Tinacious light mode breakage — dark-only
  themes now suppress .light class via modeSupport in ThemeProvider
- Fix code block backgrounds — use --code-bg token instead of --muted
- Add faint green grid overlay for Terminal theme
- Alphabetize theme registry (Plannotator first)

Code review diff theming:
- Pass theme colors into @pierre/diffs shadow DOM via unsafeCSS prop
- Dynamic themeType based on resolved mode

18 built-in themes: Plannotator, Absolutely, Adwaita, Caffeine, Catppuccin,
Doom 64, Dracula, Gruvbox, Monokai Pro, PaulMillr, Quantum Rose, Rosé Pine,
Soft Pop, Solar Dusk, Synthwave '84, Terminal, Tinacious, Tokyo Night

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: increase settings modal height for theme grid visibility

Remove 340px cap on theme grid, bump content area from 70vh to 85vh
so all themes are visible without scrolling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: memoize ThemeProvider context, deduplicate Mode type, clean up DiffViewer

From /simplify review:
- Export Mode type from ThemeProvider, import in ThemeTab (was duplicated)
- Add resolvedMode to context — consumers no longer re-query matchMedia
- Memoize context value with useMemo, setters with useCallback (prevents
  unnecessary re-renders of all useTheme consumers)
- Consolidate DiffViewer's two pierre state vars into single object
- Use resolvedMode from context in DiffViewer instead of classList check
- Format crammed single-line extended tokens in 4 theme CSS files

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: prevent FOUC, fix system mode stale read, restore marketing theme class

P1: Marketing site inline script now sets theme-{name} class on <html>
    before first paint (reads plannotator-color-theme cookie). Without this,
    CSS tokens under .theme-* selectors were never active.

P2: System mode effect now re-reads matchMedia.matches immediately when
    entering system mode, not just on future changes. Fixes stale resolvedMode
    when OS preference changed while pinned to explicit dark/light.

P3: ThemeProvider applies theme class synchronously during render (not in
    a passive useEffect) to prevent flash of unstyled content on hard refresh.
    Also extracted resolveThemeClasses to module scope to avoid useCallback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add Quick Copy button, fix import icon, comment out agent badge

- Add Quick Copy button to annotation panel footer (splits horizontally
  with existing Quick Share). Copies annotations wrapped with the deny
  preamble so output is paste-ready for agent sessions.
- Export Modal annotations copy also wraps with deny preamble
- Extract wrapFeedbackForAgent() utility in parser.ts as single source
  of truth for the preamble text
- Fix desktop import icon to match mobile (arrow-into-document, not download)
- Comment out code review agent badge — unreliable across multiple harnesses

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 21:26:51 -07:00

133 lines
5.1 KiB
TypeScript

/**
* Converts FinSitter theme CSS files into Plannotator theme format.
*
* FinSitter: .theme-gruvbox-dark { ... } and .theme-gruvbox-light { ... } as separate files
* Plannotator: .theme-gruvbox { ... (dark) } and .theme-gruvbox.light { ... } in one file
*
* Also injects defaults for Plannotator-specific tokens (success, warning, code-bg, focus-highlight).
*/
import { readFileSync, writeFileSync, readdirSync } from 'fs';
import { join } from 'path';
const FINSITTER_DIR = '/Users/ramos/mrxtek/finsitter/reactronite/src/app/styles/themes';
const OUTPUT_DIR = join(import.meta.dir, '../packages/ui/themes');
// Themes to include, grouped by merged name
const THEME_MAP: Record<string, { dark?: string; light?: string }> = {
'adwaita': { dark: 'adwaita-dark.css', light: 'adwaita.css' },
'bold-tech': { dark: 'bold-tech-dark.css', light: 'bold-tech-light.css' },
'caffeine': { dark: 'caffine-dark.css', light: 'caffine-light.css' },
'cobalt2': { dark: 'cobalt2.css' },
'cyberdyne': { dark: 'cyberdyne.css' },
'cyberfunk': { dark: 'cyberfunk-dark.css', light: 'cyberfunk-light.css' },
'doom-64': { dark: 'doom-64-dark.css', light: 'doom-64-light.css' },
'dracula': { dark: 'dracula.css' },
'gruvbox': { dark: 'gruvbox-dark.css', light: 'gruvbox-light.css' },
'ir-black': { dark: 'ir-black.css' },
'nord': { dark: 'nord.css' },
'paulmillr': { dark: 'paulmillr.css' },
'quantum-rose': { dark: 'quantum-rose-dark.css', light: 'quantum-rose-light.css' },
'solarized': { dark: 'solarized-dark.css', light: 'solarized-light.css' },
'solar-dusk': { dark: 'solar-dusk-dark.css', light: 'solar-dusk-light.css' },
'terminal': { dark: 'terminal.css' },
'tinacious': { light: 'tinacious-light.css' },
};
// Plannotator-specific token defaults
const PLANNOTATOR_DEFAULTS_DARK = `
--success: oklch(0.72 0.17 150);
--success-foreground: oklch(0.15 0.02 260);
--warning: oklch(0.75 0.15 85);
--warning-foreground: oklch(0.20 0.02 260);
--code-bg: oklch(0.13 0.015 260);
--focus-highlight: oklch(0.70 0.20 200);`;
const PLANNOTATOR_DEFAULTS_LIGHT = `
--success: oklch(0.45 0.20 150);
--success-foreground: oklch(1 0 0);
--warning: oklch(0.55 0.18 85);
--warning-foreground: oklch(0.18 0.02 260);
--code-bg: oklch(0.96 0.005 260);
--focus-highlight: oklch(0.70 0.20 200);`;
function extractTokens(css: string): string {
// Extract just the CSS variable declarations from inside the selector block
const match = css.match(/\{([\s\S]*)\}/);
if (!match) return '';
return match[1].trim();
}
function hasToken(tokens: string, name: string): boolean {
return tokens.includes(`--${name}:`);
}
function addMissingDefaults(tokens: string, defaults: string): string {
const lines = defaults.trim().split('\n');
const missing: string[] = [];
for (const line of lines) {
const match = line.match(/--([^:]+):/);
if (match && !hasToken(tokens, match[1].trim())) {
missing.push(line);
}
}
if (missing.length === 0) return tokens;
return tokens + '\n\n /* Plannotator extended tokens */' + missing.join('');
}
function convertTheme(name: string, config: { dark?: string; light?: string }): string {
const lines: string[] = [];
if (config.dark) {
const darkCss = readFileSync(join(FINSITTER_DIR, config.dark), 'utf-8');
let darkTokens = extractTokens(darkCss);
darkTokens = addMissingDefaults(darkTokens, PLANNOTATOR_DEFAULTS_DARK);
lines.push(`.theme-${name} {`);
lines.push(` ${darkTokens}`);
lines.push(`}`);
}
if (config.light) {
const lightCss = readFileSync(join(FINSITTER_DIR, config.light), 'utf-8');
let lightTokens = extractTokens(lightCss);
lightTokens = addMissingDefaults(lightTokens, PLANNOTATOR_DEFAULTS_LIGHT);
if (config.dark) {
lines.push('');
lines.push(`.theme-${name}.light {`);
} else {
// Light-only theme: light tokens go into .light, generate dark from light with note
lines.push(`/* Dark mode: uses light colors as base (light-only source theme) */`);
lines.push(`.theme-${name} {`);
lines.push(` ${lightTokens}`);
lines.push(`}`);
lines.push('');
lines.push(`.theme-${name}.light {`);
}
lines.push(` ${lightTokens}`);
lines.push(`}`);
} else if (config.dark) {
// Dark-only: duplicate dark tokens for light with a note
const darkCss = readFileSync(join(FINSITTER_DIR, config.dark), 'utf-8');
let darkTokens = extractTokens(darkCss);
darkTokens = addMissingDefaults(darkTokens, PLANNOTATOR_DEFAULTS_LIGHT);
lines.push('');
lines.push(`/* Light mode: uses dark colors as base (dark-only source theme) */`);
lines.push(`.theme-${name}.light {`);
lines.push(` ${darkTokens}`);
lines.push(`}`);
}
return lines.join('\n');
}
// Convert all themes
for (const [name, config] of Object.entries(THEME_MAP)) {
const output = convertTheme(name, config);
const outPath = join(OUTPUT_DIR, `${name}.css`);
writeFileSync(outPath, output + '\n');
console.log(` Converted: ${name}.css`);
}
console.log(`\nDone! ${Object.keys(THEME_MAP).length} themes converted.`);