diff --git a/apps/marketing/src/content/docs/getting-started/ui-settings.md b/apps/marketing/src/content/docs/getting-started/ui-settings.md index 34d8f921b..d9887d29f 100644 --- a/apps/marketing/src/content/docs/getting-started/ui-settings.md +++ b/apps/marketing/src/content/docs/getting-started/ui-settings.md @@ -14,6 +14,10 @@ Open settings with the **gear icon** in the header. The dialog has three tabs: * The sun/moon toggle in the header switches between **Dark**, **Light**, and **System** themes. System follows your OS preference and updates automatically. Dark is the default. +The **Theme** tab in Settings assigns a palette to each half of a pair: one theme for light mode, one for dark mode. A Light/Dark switch above the grid decides which half you are assigning, and the grid then lists only the palettes that can render it (Kanagawa Wave appears under Dark, Kanagawa Lotus under Light, and a palette that ships both variants appears under each with that mode's colors). The summary line above the grid always names both halves, and clicking either side jumps the grid to it. + +With **System** selected, your two choices swap as your OS switches between light and dark. All three mode buttons stay available whatever you pick, because a dark-only palette simply never occupies the light half. The pair is saved to `~/.plannotator/config.json` under `theme`, so it carries across sessions and hosts. + ## General ### Identity diff --git a/apps/pi-extension/server/serverAnnotate.ts b/apps/pi-extension/server/serverAnnotate.ts index 32437ccc8..b570b60ac 100644 --- a/apps/pi-extension/server/serverAnnotate.ts +++ b/apps/pi-extension/server/serverAnnotate.ts @@ -618,10 +618,11 @@ 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; conventionalComments?: boolean }; + const body = (await parseBody(req)) as { displayName?: string; diffOptions?: Record; theme?: 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.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 a0e342e00..5963e0672 100644 --- a/apps/pi-extension/server/serverPlan.ts +++ b/apps/pi-extension/server/serverPlan.ts @@ -255,10 +255,11 @@ 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; conventionalComments?: boolean; conventionalLabels?: unknown[] | null; pfmReminder?: boolean }; + const body = (await parseBody(req)) as { displayName?: string; diffOptions?: Record; theme?: 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.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 5bec6bba5..6ad1ef2cb 100644 --- a/apps/pi-extension/server/serverReview.ts +++ b/apps/pi-extension/server/serverReview.ts @@ -2476,10 +2476,11 @@ 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; conventionalComments?: boolean }; + const body = (await parseBody(req)) as { displayName?: string; diffOptions?: Record; theme?: 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.conventionalComments !== undefined) toSave.conventionalComments = body.conventionalComments; if (Object.keys(toSave).length > 0) saveConfig(toSave as Parameters[0]); json(res, { ok: true }); diff --git a/packages/core/config-types.ts b/packages/core/config-types.ts index 66372dd4d..f674c942a 100644 --- a/packages/core/config-types.ts +++ b/packages/core/config-types.ts @@ -1,6 +1,17 @@ export type DefaultDiffType = 'since-base' | 'uncommitted' | 'unstaged' | 'staged' | 'merge-base' | 'all'; export type DiffLineBgIntensity = 'subtle' | 'normal' | 'strong'; +/** + * The user's appearance choice: a palette for the light half, a palette for + * the dark half, and which of them the mode selects. `system` follows the OS, + * so the two halves swap with `prefers-color-scheme`. + */ +export interface ThemeConfig { + mode?: 'light' | 'dark' | 'system'; + light?: string; + dark?: string; +} + export interface DiffOptions { diffStyle?: 'split' | 'unified'; overflow?: 'scroll' | 'wrap'; diff --git a/packages/review-editor/components/ReviewHeaderMenu.tsx b/packages/review-editor/components/ReviewHeaderMenu.tsx index cb5c2361a..476762f39 100644 --- a/packages/review-editor/components/ReviewHeaderMenu.tsx +++ b/packages/review-editor/components/ReviewHeaderMenu.tsx @@ -7,7 +7,6 @@ import { } from '@plannotator/ui/components/ActionMenu'; import { useTheme } from '@plannotator/ui/components/ThemeProvider'; import { THEME_MODES } from '@plannotator/ui/components/themeModes'; -import { isThemeModeAvailable } from '@plannotator/ui/utils/themeRegistry'; import { MenuVersionSection } from '@plannotator/ui/components/MenuVersionSection'; import { ReviewAgentsIcon } from '@plannotator/ui/components/ReviewAgentsIcon'; import { TextShimmer } from '@plannotator/ui/components/TextShimmer'; @@ -46,7 +45,7 @@ export const ReviewHeaderMenu: React.FC = ({ origin, isWSL = false, }) => { - const { theme, setTheme, colorTheme } = useTheme(); + const { theme, setTheme } = useTheme(); const showUpdateDot = !!updateInfo?.updateAvailable && !updateInfo.dismissed; @@ -87,30 +86,23 @@ export const ReviewHeaderMenu: React.FC = ({
Theme
- {THEME_MODES.map(({ id, label, Icon }) => { - const available = isThemeModeAvailable(colorTheme, id); - return ( - - ); - })} + {THEME_MODES.map(({ id, label, Icon }) => ( + + ))}
diff --git a/packages/server/annotate.ts b/packages/server/annotate.ts index 1d2a228d7..bd4c90e67 100644 --- a/packages/server/annotate.ts +++ b/packages/server/annotate.ts @@ -562,10 +562,11 @@ 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; conventionalComments?: boolean; conventionalLabels?: unknown[] | null }; + const body = (await req.json()) as { displayName?: string; diffOptions?: Record; theme?: 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.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/goal-setup.ts b/packages/server/goal-setup.ts index 840d2f3b6..fc4624a92 100644 --- a/packages/server/goal-setup.ts +++ b/packages/server/goal-setup.ts @@ -132,6 +132,7 @@ export async function startGoalSetupServer( const body = (await req.json()) as { displayName?: string; diffOptions?: Record; + theme?: Record; conventionalComments?: boolean; conventionalLabels?: unknown[] | null; }; @@ -142,6 +143,9 @@ export async function startGoalSetupServer( if (body.diffOptions !== undefined) { toSave.diffOptions = body.diffOptions; } + if (body.theme !== undefined) { + toSave.theme = body.theme; + } if (body.conventionalComments !== undefined) { toSave.conventionalComments = body.conventionalComments; } diff --git a/packages/server/index.ts b/packages/server/index.ts index f96d7294a..11e75dffa 100644 --- a/packages/server/index.ts +++ b/packages/server/index.ts @@ -321,10 +321,11 @@ 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; conventionalComments?: boolean; conventionalLabels?: unknown[] | null; pfmReminder?: boolean }; + const body = (await req.json()) as { displayName?: string; diffOptions?: Record; theme?: 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.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 8f3ef981f..b9da52572 100644 --- a/packages/server/review.ts +++ b/packages/server/review.ts @@ -2543,10 +2543,11 @@ 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; conventionalComments?: boolean; conventionalLabels?: unknown[] | null }; + const body = (await req.json()) as { displayName?: string; diffOptions?: Record; theme?: 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.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/shared/config.ts b/packages/shared/config.ts index c1d5c5561..95fa4c085 100644 --- a/packages/shared/config.ts +++ b/packages/shared/config.ts @@ -10,8 +10,8 @@ import { getPlannotatorDataDir } from "./data-dir"; import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs"; import { execSync } from "child_process"; -import type { DefaultDiffType, DiffLineBgIntensity, DiffOptions } from '@plannotator/core/config-types'; -export type { DefaultDiffType, DiffLineBgIntensity, DiffOptions }; +import type { DefaultDiffType, DiffLineBgIntensity, DiffOptions, ThemeConfig } from '@plannotator/core/config-types'; +export type { DefaultDiffType, DiffLineBgIntensity, DiffOptions, ThemeConfig }; /** Single conventional comment label entry stored in config.json */ export interface CCLabelConfig { @@ -87,6 +87,13 @@ export function mergePromptConfig( export interface PlannotatorConfig { displayName?: string; diffOptions?: DiffOptions; + /** + * Appearance: which mode, plus the palette assigned to each half of the + * light/dark pair. Written by the UI through POST /api/config, so a choice + * made in one session is picked up by the next one (each hook invocation + * runs on its own random port). + */ + theme?: ThemeConfig; prompts?: PromptConfig; conventionalComments?: boolean; /** null = explicitly cleared (use defaults), undefined = not set */ @@ -215,11 +222,15 @@ export function saveConfig(partial: Partial): void { const mergedDiffOptions = (current.diffOptions || partial.diffOptions) ? { ...current.diffOptions, ...partial.diffOptions } : undefined; + const mergedTheme = (current.theme || partial.theme) + ? { ...current.theme, ...partial.theme } + : undefined; const mergedPrompts = mergePromptConfig(current.prompts, partial.prompts); const merged = { ...current, ...partial, diffOptions: mergedDiffOptions, + theme: mergedTheme, prompts: mergedPrompts, }; mkdirSync(CONFIG_DIR, { recursive: true }); @@ -249,6 +260,7 @@ export function detectGitUser(): string | null { export function getServerConfig(gitUser: string | null): { displayName?: string; diffOptions?: DiffOptions; + theme?: ThemeConfig; gitUser?: string; conventionalComments?: boolean; conventionalLabels?: CCLabelConfig[] | null; @@ -257,6 +269,7 @@ export function getServerConfig(gitUser: string | null): { return { displayName: cfg.displayName, diffOptions: cfg.diffOptions, + ...(cfg.theme !== undefined && { theme: cfg.theme }), gitUser: gitUser ?? undefined, ...(cfg.conventionalComments !== undefined && { conventionalComments: cfg.conventionalComments }), ...(cfg.conventionalLabels !== undefined && { conventionalLabels: cfg.conventionalLabels }), diff --git a/packages/ui/components/ModeToggle.tsx b/packages/ui/components/ModeToggle.tsx index e35baeae8..1f0a7ecee 100644 --- a/packages/ui/components/ModeToggle.tsx +++ b/packages/ui/components/ModeToggle.tsx @@ -1,10 +1,9 @@ import React, { useState, useRef, useEffect } from 'react'; import { useTheme } from './ThemeProvider'; import { THEME_MODES } from './themeModes'; -import { isThemeModeAvailable } from '../utils/themeRegistry'; export function ModeToggle() { - const { theme, setTheme, colorTheme } = useTheme(); + const { theme, setTheme } = useTheme(); const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(null); @@ -50,26 +49,19 @@ export function ModeToggle() { {isOpen && (
- {THEME_MODES.map(({ id, label }) => { - const available = isThemeModeAvailable(colorTheme, id); - return ( - - ); - })} + {THEME_MODES.map(({ id, label }) => ( + + ))}
)} diff --git a/packages/ui/components/PlanHeaderMenu.tsx b/packages/ui/components/PlanHeaderMenu.tsx index 2abb6bc10..eb526a8b1 100644 --- a/packages/ui/components/PlanHeaderMenu.tsx +++ b/packages/ui/components/PlanHeaderMenu.tsx @@ -7,7 +7,6 @@ import { } from './ActionMenu'; import { useTheme } from './ThemeProvider'; import { THEME_MODES } from './themeModes'; -import { isThemeModeAvailable } from '../utils/themeRegistry'; import { ReviewAgentsIcon } from './ReviewAgentsIcon'; import { MenuVersionSection } from './MenuVersionSection'; import { TextShimmer } from './TextShimmer'; @@ -59,7 +58,7 @@ export const PlanHeaderMenu: React.FC = ({ bearConfigured, octarineConfigured, }) => { - const { theme, setTheme, colorTheme } = useTheme(); + const { theme, setTheme } = useTheme(); const showUpdateDot = !!updateInfo?.updateAvailable && !updateInfo.dismissed; @@ -103,30 +102,23 @@ export const PlanHeaderMenu: React.FC = ({
Theme
- {THEME_MODES.map(({ id, label, Icon }) => { - const available = isThemeModeAvailable(colorTheme, id); - return ( - - ); - })} + {THEME_MODES.map(({ id, label, Icon }) => ( + + ))}
diff --git a/packages/ui/components/ThemeProvider.test.tsx b/packages/ui/components/ThemeProvider.test.tsx index 075f0790d..43bf9c1c7 100644 --- a/packages/ui/components/ThemeProvider.test.tsx +++ b/packages/ui/components/ThemeProvider.test.tsx @@ -1,8 +1,17 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import React, { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; +import { configStore } from '../config/configStore'; import { resetStorageBackend, setStorageBackend } from '../utils/storage'; -import { BUILT_IN_THEMES } from '../utils/themeRegistry'; +import { + BUILT_IN_THEMES, + DEFAULT_COLOR_THEME, + normalizeThemePair, + resetDefaultThemePair, + seedThemePair, + themesForHalf, + themeSupportsHalf, +} from '../utils/themeRegistry'; import { THEME_MODES, isThemeMode, parseThemeMode } from './themeModes'; import { ThemeProvider, useTheme } from './ThemeProvider'; import { ThemeTab } from './ThemeTab'; @@ -14,6 +23,32 @@ let host: HTMLElement | null = null; let currentTheme: ReturnType | null = null; let stored = new Map(); let originalMatchMediaDescriptor: PropertyDescriptor | undefined; +let originalFetch: typeof globalThis.fetch | null = null; + +/** Capture every request the default server-sync transport would make. */ +function captureConfigPosts(): string[] { + const posts: string[] = []; + originalFetch = globalThis.fetch; + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : String(input); + if (url.includes('/api/config')) posts.push(String(init?.body ?? '')); + return Promise.resolve({ ok: true, json: async () => ({}) } as Response); + }) as typeof globalThis.fetch; + return posts; +} + +/** Let the store's 300ms server-sync debounce fire (or prove it never does). */ +async function afterServerSyncDebounce(): Promise { + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 400)); + }); +} + +/** Flush writes an earlier test queued, so only this test's posts are counted. */ +async function drainPendingServerSync(posts: string[]): Promise { + await afterServerSyncDebounce(); + posts.length = 0; +} function Probe() { currentTheme = useTheme(); @@ -25,6 +60,43 @@ function themeState(): ReturnType { return currentTheme; } +/** Every palette card in the grid — the only buttons carrying color swatches. */ +function paletteButtons(): HTMLButtonElement[] { + return Array.from(host!.querySelectorAll('button')).filter(button => + button.querySelector('.rounded-full') + ); +} + +function paletteNames(): string[] { + return paletteButtons().map(button => button.textContent?.trim() ?? ''); +} + +function palette(name: string): HTMLButtonElement { + const found = paletteButtons().find(button => button.textContent?.trim() === name); + if (!found) throw new Error(`palette "${name}" is not in the grid`); + return found; +} + +function button(label: string): HTMLButtonElement { + const found = Array.from(host!.querySelectorAll('button')).find( + candidate => candidate.textContent?.trim() === label + ); + if (!found) throw new Error(`button "${label}" did not render`); + return found; +} + +function summaryButton(prefix: string): HTMLButtonElement { + const found = Array.from(host!.querySelectorAll('button')).find(candidate => + candidate.textContent?.trim().startsWith(prefix) + ); + if (!found) throw new Error(`summary button "${prefix}" did not render`); + return found; +} + +function clickButton(target: HTMLButtonElement): void { + target.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); +} + function installMatchMedia(initialMatches: boolean) { let matches = initialMatches; const listeners = new Set<(event: MediaQueryListEvent) => void>(); @@ -67,6 +139,15 @@ function installMatchMedia(initialMatches: boolean) { } async function mountTheme(children?: React.ReactNode): Promise { + // The config store is a process-wide singleton: re-read it from the storage + // backend this test installed so seeded cookies (not a previous test's + // values) decide the pair. + configStore.loadFromBackend(); + await mountThemeFresh(children); +} + +/** Mount WITHOUT pre-seeding the store, i.e. what a cookie-less visit hits. */ +async function mountThemeFresh(children?: React.ReactNode): Promise { host = document.createElement('div'); document.body.appendChild(host); root = createRoot(host); @@ -111,6 +192,80 @@ describe('theme registry', () => { expect(theme.syntaxHighlighting).toBe(true); expect(theme.colors.dark.background).not.toBe(theme.colors.light.background); }); + + test('offers each palette only for the half it can render', () => { + const light = themesForHalf(BUILT_IN_THEMES, 'light').map(({ id }) => id); + const dark = themesForHalf(BUILT_IN_THEMES, 'dark').map(({ id }) => id); + + expect(light).toContain('kanagawa-lotus'); + expect(light).not.toContain('kanagawa-wave'); + expect(dark).toContain('kanagawa-wave'); + expect(dark).not.toContain('kanagawa-lotus'); + + // A `both` palette — the colorblind theme included — belongs to each half. + for (const id of ['rose-pine', 'colorblind']) { + expect(light).toContain(id); + expect(dark).toContain(id); + expect(themeSupportsHalf(id, 'light')).toBe(true); + expect(themeSupportsHalf(id, 'dark')).toBe(true); + } + }); + + test('seeds both halves from the single palette older releases stored', () => { + // A palette that renders both modes takes over the whole pair. + expect(seedThemePair('rose-pine', 'system')).toEqual({ + mode: 'system', + light: 'rose-pine', + dark: 'rose-pine', + }); + + // A mode-restricted one keeps its half; the other half falls back. + expect(seedThemePair('kanagawa-wave', 'dark')).toEqual({ + mode: 'dark', + light: DEFAULT_COLOR_THEME, + dark: 'kanagawa-wave', + }); + expect(seedThemePair('kanagawa-lotus', 'light')).toEqual({ + mode: 'light', + light: 'kanagawa-lotus', + dark: DEFAULT_COLOR_THEME, + }); + + // Nothing stored, or a palette this build does not ship. + expect(seedThemePair(null, 'system')).toEqual({ + mode: 'system', + light: DEFAULT_COLOR_THEME, + dark: DEFAULT_COLOR_THEME, + }); + expect(seedThemePair('gone-in-this-build', 'dark')).toEqual({ + mode: 'dark', + light: DEFAULT_COLOR_THEME, + dark: DEFAULT_COLOR_THEME, + }); + }); + + test('repairs a pair whose halves hold unusable palettes', () => { + const fallback = { mode: 'system', light: 'rose-pine', dark: 'kanagawa-wave' } as const; + + expect(normalizeThemePair({ mode: 'light', light: 'tinacious', dark: 'vesper' }, fallback)).toEqual({ + mode: 'light', + light: 'tinacious', + dark: 'vesper', + }); + + // A dark-only palette can never occupy the light half, and vice versa. + expect(normalizeThemePair({ mode: 'sepia', light: 'vesper', dark: 'tinacious' }, fallback)).toEqual({ + mode: 'system', + light: 'rose-pine', + dark: 'kanagawa-wave', + }); + + expect(normalizeThemePair(undefined)).toEqual({ + mode: 'dark', + light: DEFAULT_COLOR_THEME, + dark: DEFAULT_COLOR_THEME, + }); + }); }); describe('ThemeProvider', () => { @@ -141,6 +296,11 @@ describe('ThemeProvider', () => { } originalMatchMediaDescriptor = undefined; } + configStore.resetServerSync(); + if (originalFetch) { + globalThis.fetch = originalFetch; + originalFetch = null; + } resetStorageBackend(); }); @@ -168,78 +328,320 @@ describe('ThemeProvider', () => { expect(themeState().resolvedMode).toBe('dark'); }); - test.skipIf(!hasDom)('keeps System coherent and normalizes explicit modes for constrained palettes', async () => { + test.skipIf(!hasDom)('flips between the two halves of the pair when the OS scheme changes', async () => { stored.set('plannotator-theme', 'system'); - stored.set('plannotator-color-theme', 'andromeeda'); + stored.set('plannotator-light-theme', 'kanagawa-lotus'); + stored.set('plannotator-dark-theme', 'kanagawa-wave'); const media = installMatchMedia(true); await mountTheme(); - expect(themeState().mode).toBe('system'); - expect(themeState().preferredMode).toBe('light'); - expect(themeState().resolvedMode).toBe('dark'); - expect(document.documentElement.classList.contains('theme-andromeeda')).toBe(true); - expect(document.documentElement.classList.contains('light')).toBe(false); + expect(themeState().lightTheme).toBe('kanagawa-lotus'); + expect(themeState().darkTheme).toBe('kanagawa-wave'); + expect(themeState().colorTheme).toBe('kanagawa-lotus'); + expect(themeState().resolvedMode).toBe('light'); + expect(document.documentElement.classList.contains('theme-kanagawa-lotus')).toBe(true); + expect(document.documentElement.classList.contains('light')).toBe(true); await act(async () => media.setMatches(false)); - expect(themeState().mode).toBe('system'); - expect(themeState().preferredMode).toBe('dark'); + expect(themeState().colorTheme).toBe('kanagawa-wave'); expect(themeState().resolvedMode).toBe('dark'); + expect(document.documentElement.classList.contains('theme-kanagawa-wave')).toBe(true); + expect(document.documentElement.classList.contains('light')).toBe(false); - await act(async () => themeState().setColorTheme('kanagawa-lotus')); + // Older releases read the single-palette key, so it keeps tracking the + // palette actually on screen — a downgrade never lands unstyled. + expect(stored.get('plannotator-color-theme')).toBe('kanagawa-wave'); + }); + + test.skipIf(!hasDom)('migrates a stored dark-only palette into the dark half only', async () => { + stored.set('plannotator-theme', 'system'); + stored.set('plannotator-color-theme', 'kanagawa-wave'); + installMatchMedia(true); + + await mountTheme(); + expect(themeState().darkTheme).toBe('kanagawa-wave'); + expect(themeState().lightTheme).toBe(DEFAULT_COLOR_THEME); + // The OS is light, so the migrated pair renders its light half — the mode + // is no longer coerced to keep a dark-only palette on screen. expect(themeState().mode).toBe('system'); + expect(themeState().preferredMode).toBe('light'); expect(themeState().resolvedMode).toBe('light'); - expect(stored.get('plannotator-theme')).toBe('system'); + expect(themeState().colorTheme).toBe(DEFAULT_COLOR_THEME); - await act(async () => themeState().setMode('dark')); + // The migrated pair is persisted on arrival — the legacy key it was + // derived from is immediately overwritten with the active palette. + expect(stored.get('plannotator-light-theme')).toBe(DEFAULT_COLOR_THEME); + expect(stored.get('plannotator-dark-theme')).toBe('kanagawa-wave'); + expect(stored.get('plannotator-color-theme')).toBe(DEFAULT_COLOR_THEME); + }); + + test.skipIf(!hasDom)('keeps every mode selectable while a dark-only palette owns the dark half', async () => { + stored.set('plannotator-theme', 'dark'); + stored.set('plannotator-light-theme', 'rose-pine'); + stored.set('plannotator-dark-theme', 'dracula'); + installMatchMedia(false); + + await mountTheme(); + expect(themeState().colorTheme).toBe('dracula'); + + const modeButtons = Array.from(host!.querySelectorAll('button')).filter(button => + ['Light', 'Dark', 'System'].includes(button.textContent?.trim() ?? '') + ); + expect(modeButtons.length).toBe(3); + expect(modeButtons.some(button => button.disabled)).toBe(false); + + await act(async () => themeState().setMode('light')); expect(themeState().mode).toBe('light'); + expect(themeState().colorTheme).toBe('rose-pine'); expect(themeState().resolvedMode).toBe('light'); expect(stored.get('plannotator-theme')).toBe('light'); + expect(stored.get('plannotator-dark-theme')).toBe('dracula'); + }); + + test.skipIf(!hasDom)('repairs invalid persisted values before exposing state', async () => { + stored.set('plannotator-theme', 'sepia'); + stored.set('plannotator-light-theme', 'dracula'); + stored.set('plannotator-dark-theme', 'gone-in-this-build'); + stored.set('plannotator-color-theme', 'andromeeda'); + installMatchMedia(true); - await act(async () => themeState().setColorTheme('andromeeda')); + await mountTheme(); expect(themeState().mode).toBe('dark'); + expect(themeState().lightTheme).toBe(DEFAULT_COLOR_THEME); + expect(themeState().darkTheme).toBe('andromeeda'); expect(themeState().resolvedMode).toBe('dark'); expect(stored.get('plannotator-theme')).toBe('dark'); + }); + + test.skipIf(!hasDom)('assigns one half at a time from its own grid', async () => { + stored.set('plannotator-theme', 'light'); + stored.set('plannotator-light-theme', DEFAULT_COLOR_THEME); + stored.set('plannotator-dark-theme', DEFAULT_COLOR_THEME); + installMatchMedia(true); + + await mountTheme(); + + // The grid opens on the half the user is actually looking at. + expect(paletteNames()).toContain('Kanagawa Lotus'); + expect(paletteNames()).not.toContain('Kanagawa Wave'); + + await act(async () => clickButton(palette('Tinacious'))); + expect(themeState().lightTheme).toBe('tinacious'); + expect(themeState().colorTheme).toBe('tinacious'); + + const swatches = palette('Tinacious').querySelectorAll('.rounded-full'); + const tinacious = BUILT_IN_THEMES.find(theme => theme.id === 'tinacious'); + if (!tinacious) throw new Error('Tinacious palette is not registered'); + expect(swatches[3]?.style.backgroundColor).toBe(tinacious.colors.light.background); + + // Assigning the other half leaves the visible palette alone. + await act(async () => clickButton(button('Dark theme'))); + expect(paletteNames()).toContain('Kanagawa Wave'); + expect(paletteNames()).not.toContain('Kanagawa Lotus'); + + await act(async () => clickButton(palette('Dracula'))); + expect(themeState().darkTheme).toBe('dracula'); + expect(themeState().mode).toBe('light'); + expect(themeState().colorTheme).toBe('tinacious'); + // The summary names both halves and jumps the grid back to the light one. + expect(host!.textContent).toContain('Tinacious'); + expect(host!.textContent).toContain('Dracula'); + await act(async () => clickButton(summaryButton('Light:'))); + expect(paletteNames()).toContain('Kanagawa Lotus'); + }); + + test.skipIf(!hasDom)('honors a host\'s own storage keys when migrating to a pair', async () => { + stored.set('host-mode', 'system'); + stored.set('host-palette', 'kanagawa-wave'); + installMatchMedia(false); + + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); await act(async () => { - themeState().setColorTheme('plannotator'); - themeState().setMode('light'); - themeState().setColorTheme('andromeeda'); - themeState().setMode('light'); + root!.render( + + + , + ); }); - expect(themeState().mode).toBe('dark'); - expect(themeState().resolvedMode).toBe('dark'); - expect(stored.get('plannotator-theme')).toBe('dark'); + + // The host's stored preference is migrated, not discarded. + expect(themeState().mode).toBe('system'); + expect(themeState().darkTheme).toBe('kanagawa-wave'); + expect(themeState().colorTheme).toBe('kanagawa-wave'); + // And the mirror keeps writing the host's keys, not Plannotator's. + expect(stored.get('host-mode')).toBe('system'); + expect(stored.get('host-palette')).toBe('kanagawa-wave'); }); +}); - test.skipIf(!hasDom)('repairs invalid persisted modes before exposing state', async () => { - stored.set('plannotator-theme', 'sepia'); - stored.set('plannotator-color-theme', 'andromeeda'); - installMatchMedia(true); +describe('ThemeProvider server write-back', () => { + let posts: string[] = []; + + beforeEach(() => { + if (hasDom) { + originalMatchMediaDescriptor = Object.getOwnPropertyDescriptor(window, 'matchMedia'); + } + stored = new Map(); + setStorageBackend({ + getItem: key => stored.get(key) ?? null, + setItem: (key, value) => { + stored.set(key, value); + }, + removeItem: key => { + stored.delete(key); + }, + }); + posts = captureConfigPosts(); + }); + + afterEach(async () => { + if (hasDom) { + await unmountTheme(); + document.documentElement.className = ''; + if (originalMatchMediaDescriptor) { + Object.defineProperty(window, 'matchMedia', originalMatchMediaDescriptor); + } else { + Reflect.deleteProperty(window, 'matchMedia'); + } + originalMatchMediaDescriptor = undefined; + } + configStore.resetServerSync(); + if (originalFetch) { + globalThis.fetch = originalFetch; + originalFetch = null; + } + resetStorageBackend(); + }); + + // A cookie-less visit (fresh profile, incognito, cleared cookies) must not + // write anything to ~/.plannotator/config.json: that POST would land after + // the server config arrives and reset the user's real theme to defaults. + test.skipIf(!hasDom)('posts nothing when mounting with no stored preference', async () => { + // Drain anything an earlier test's debounce still had in flight. + await drainPendingServerSync(posts); + installMatchMedia(false); + + await mountThemeFresh(); + await afterServerSyncDebounce(); + + expect(posts).toEqual([]); + // The pair still resolved and was persisted locally. + expect(themeState().colorTheme).toBe(DEFAULT_COLOR_THEME); + expect(stored.get('plannotator-light-theme')).toBe(DEFAULT_COLOR_THEME); + }); + + test.skipIf(!hasDom)('posts a real choice, so the pair still reaches config.json', async () => { + await drainPendingServerSync(posts); + installMatchMedia(false); + + await mountThemeFresh(); + await act(async () => themeState().setHalfTheme('dark', 'vesper')); + await afterServerSyncDebounce(); + + expect(posts.length).toBe(1); + expect(JSON.parse(posts[0]!)).toEqual({ + theme: { mode: 'dark', light: DEFAULT_COLOR_THEME, dark: 'vesper' }, + }); + }); + + // The legacy single-palette API was cookie-only before pairs existed, and a + // host that never installed a serverSync transport has no endpoint to post to. + test.skipIf(!hasDom)('keeps the legacy setColorTheme cookie-only', async () => { + await drainPendingServerSync(posts); + installMatchMedia(false); + + await mountThemeFresh(); + await act(async () => themeState().setColorTheme('vesper')); + await afterServerSyncDebounce(); + + expect(posts).toEqual([]); + expect(themeState().darkTheme).toBe('vesper'); + expect(stored.get('plannotator-dark-theme')).toBe('vesper'); + }); +}); + +describe('ThemeProvider legacy setColorTheme', () => { + beforeEach(() => { + if (hasDom) { + originalMatchMediaDescriptor = Object.getOwnPropertyDescriptor(window, 'matchMedia'); + } + stored = new Map(); + setStorageBackend({ + getItem: key => stored.get(key) ?? null, + setItem: (key, value) => { + stored.set(key, value); + }, + removeItem: key => { + stored.delete(key); + }, + }); + }); + + afterEach(async () => { + if (hasDom) { + await unmountTheme(); + document.documentElement.className = ''; + if (originalMatchMediaDescriptor) { + Object.defineProperty(window, 'matchMedia', originalMatchMediaDescriptor); + } else { + Reflect.deleteProperty(window, 'matchMedia'); + } + originalMatchMediaDescriptor = undefined; + } + configStore.resetServerSync(); + resetStorageBackend(); + }); + + test.skipIf(!hasDom)('assigns a both-mode palette to the half on screen only', async () => { + stored.set('plannotator-theme', 'dark'); + stored.set('plannotator-light-theme', 'one-light'); + stored.set('plannotator-dark-theme', 'vesper'); + installMatchMedia(false); await mountTheme(); + await act(async () => themeState().setColorTheme('gruvbox')); + + expect(themeState().darkTheme).toBe('gruvbox'); + // The other half keeps the user's assignment. + expect(themeState().lightTheme).toBe('one-light'); expect(themeState().mode).toBe('dark'); - expect(themeState().resolvedMode).toBe('dark'); - expect(stored.get('plannotator-theme')).toBe('dark'); }); - test.skipIf(!hasDom)('previews a constrained palette using the mode it actually renders', async () => { + test.skipIf(!hasDom)('assigns a mode-restricted palette without moving the mode', async () => { stored.set('plannotator-theme', 'system'); - stored.set('plannotator-color-theme', 'tinacious'); - installMatchMedia(false); + stored.set('plannotator-light-theme', 'one-light'); + stored.set('plannotator-dark-theme', 'nord'); + const media = installMatchMedia(true); - await mountTheme(); - expect(themeState().preferredMode).toBe('dark'); - expect(themeState().resolvedMode).toBe('light'); + await mountTheme(); + expect(themeState().colorTheme).toBe('one-light'); - const paletteButton = Array.from(host!.querySelectorAll('button')).find(button => - button.textContent?.includes('Tinacious') - ); - if (!paletteButton) throw new Error('Tinacious palette preview did not render'); - const swatches = paletteButton.querySelectorAll('.rounded-full'); - const palette = BUILT_IN_THEMES.find(theme => theme.id === 'tinacious'); - if (!palette) throw new Error('Tinacious palette is not registered'); + await act(async () => themeState().setColorTheme('vesper')); + expect(themeState().darkTheme).toBe('vesper'); + expect(themeState().lightTheme).toBe('one-light'); + // Still System: a dark-only palette does not yank the user out of it. + expect(themeState().mode).toBe('system'); + expect(themeState().colorTheme).toBe('one-light'); + + // And it is what renders as soon as the OS goes dark. + await act(async () => media.setMatches(false)); + expect(themeState().colorTheme).toBe('vesper'); + }); - expect(swatches[3]?.style.backgroundColor).toBe(palette.colors.light.background); - expect(swatches[3]?.style.backgroundColor).not.toBe(palette.colors.dark.background); + test.skipIf(!hasDom)('assigns a light-only palette to the light half from dark mode', async () => { + stored.set('plannotator-theme', 'dark'); + stored.set('plannotator-light-theme', 'one-light'); + stored.set('plannotator-dark-theme', 'nord'); + installMatchMedia(false); + + await mountTheme(); + await act(async () => themeState().setColorTheme('kanagawa-lotus')); + + expect(themeState().lightTheme).toBe('kanagawa-lotus'); + expect(themeState().darkTheme).toBe('nord'); + expect(themeState().mode).toBe('dark'); }); }); diff --git a/packages/ui/components/ThemeProvider.tsx b/packages/ui/components/ThemeProvider.tsx index 210993471..afeff7076 100644 --- a/packages/ui/components/ThemeProvider.tsx +++ b/packages/ui/components/ThemeProvider.tsx @@ -1,12 +1,21 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { configStore } from '../config/configStore'; +import { readThemePairCookies, writeThemePairCookies } from '../config/settings'; +import { useConfigValue } from '../config/useConfig'; import { storage } from '../utils/storage'; import { BUILT_IN_THEMES, - normalizeThemeMode, + getUnsupportedMode, + resolvePairTheme, resolveThemeMode, + seedThemePair, + setDefaultThemePair, + themeSupportsHalf, + type ThemeHalf, type ThemeInfo, + type ThemePair, } from '../utils/themeRegistry'; -import { parseThemeMode, type Mode } from './themeModes'; +import type { Mode } from './themeModes'; // Kept here because published consumers already import Mode from ThemeProvider. export type { Mode } from './themeModes'; @@ -19,9 +28,13 @@ type ThemeProviderState = { setMode: (mode: Mode) => void; preferredMode: 'dark' | 'light'; resolvedMode: 'dark' | 'light'; - // Color theme (palette) + // Color theme (the palette the current mode renders — pair[preferredMode]) colorTheme: string; setColorTheme: (theme: string) => void; + // The pair itself: one palette per half, assignable independently + lightTheme: string; + darkTheme: string; + setHalfTheme: (half: ThemeHalf, theme: string) => void; availableThemes: ThemeInfo[]; }; @@ -34,6 +47,9 @@ const ThemeProviderContext = createContext({ resolvedMode: 'dark', colorTheme: 'plannotator', setColorTheme: () => null, + lightTheme: 'plannotator', + darkTheme: 'plannotator', + setHalfTheme: () => null, availableThemes: BUILT_IN_THEMES, }); @@ -63,7 +79,20 @@ interface ThemeProviderProps { children: React.ReactNode; defaultTheme?: Mode; defaultColorTheme?: string; + /** + * Where the mode is stored. Read when resolving the initial pair and written + * by the legacy mirror, so a host's already-stored preference survives the + * upgrade to pairs. + */ storageKey?: string; + /** + * Where the single pre-pair palette is stored. Read as the migration source + * for both halves and kept in sync with the palette on screen. + * + * Note: the two halves themselves are a new concept with no pre-existing + * host data, so they always live under `plannotator-light-theme` / + * `plannotator-dark-theme`; these props rename only the two legacy values. + */ colorThemeStorageKey?: string; } @@ -74,24 +103,54 @@ export function ThemeProvider({ storageKey = 'plannotator-theme', colorThemeStorageKey = 'plannotator-color-theme', }: ThemeProviderProps) { - const [colorTheme, setColorThemeState] = useState( - () => storage.getItem(colorThemeStorageKey) || defaultColorTheme + const legacyKeys = useMemo( + () => ({ mode: storageKey, colorTheme: colorThemeStorageKey }), + [storageKey, colorThemeStorageKey], ); - const [mode, setModeState] = useState(() => { - const storedMode = parseThemeMode(storage.getItem(storageKey), defaultTheme); - return normalizeThemeMode(colorTheme, storedMode); + // Resolve the pair this provider starts on from ITS OWN storage keys: what + // the user persisted (migrating a host's pre-pair values), else these props. + // The config store is a singleton that may already have resolved a default of + // its own, so storage is asked directly rather than trusting that value. + const [initialPair] = useState(() => { + const resolved = readThemePairCookies(legacyKeys) + ?? seedThemePair(defaultColorTheme, defaultTheme); + setDefaultThemePair(resolved); + return resolved; }); - const colorThemeRef = useRef(colorTheme); - const modeRef = useRef(mode); + const pendingSeed = useRef(initialPair); + const [, setSeedApplied] = useState(false); + + const storePair = useConfigValue('themePair'); + const pair = pendingSeed.current ?? storePair; + const mode = pair.mode; + + // Hand the resolved pair to the store as a SEED, not a user choice: seeding + // writes memory + cookies only. Routing it through set() would queue a + // server write of a value nobody picked, which (flushing after the server + // config arrives) would overwrite the user's real ~/.plannotator/config.json + // theme from any cookie-less visit. + useEffect(() => { + const seed = pendingSeed.current; + if (!seed) return; + pendingSeed.current = null; + configStore.seed('themePair', seed); + setSeedApplied(true); + }, []); const [systemIsLight, setSystemIsLight] = useState(getSystemIsLight); - // Keep the OS-resolved preference separate from the mode the palette can render. + // Keep the OS-resolved preference separate from the half it selects. const preferredMode: 'dark' | 'light' = mode === 'system' ? (systemIsLight ? 'light' : 'dark') : mode; + const colorTheme = resolvePairTheme(pair, preferredMode); const resolvedMode = resolveThemeMode(colorTheme, preferredMode); + // Read by the legacy setColorTheme, which must target the half on screen + // without re-creating its callback on every mode change. + const preferredModeRef = useRef(preferredMode); + preferredModeRef.current = preferredMode; + // [P3 fix] Apply theme class synchronously during initialization to prevent // flash of unstyled content. CSS tokens live under .theme-* selectors, so // without this the first frame has no valid --background/--foreground. @@ -128,28 +187,48 @@ export function ThemeProvider({ }, [mode]); const setMode = useCallback((newMode: Mode) => { - const normalizedMode = normalizeThemeMode(colorThemeRef.current, newMode); - modeRef.current = normalizedMode; - storage.setItem(storageKey, normalizedMode); - setModeState(normalizedMode); - }, [storageKey]); + configStore.set('themePair', { ...configStore.get('themePair'), mode: newMode }); + }, []); + /** Assign one palette to one half of the pair. */ + const setHalfTheme = useCallback((half: ThemeHalf, newTheme: string) => { + if (!themeSupportsHalf(newTheme, half)) return; + configStore.set('themePair', { ...configStore.get('themePair'), [half]: newTheme }); + }, []); + + /** + * Legacy single-palette API, kept for published consumers. It assigns exactly + * ONE half and changes nothing else: + * + * - a palette that renders both modes goes to the half currently on screen, + * leaving the other half's assignment alone; + * - a mode-restricted palette goes to the half it supports without touching + * the mode, because render-time resolution (`resolveThemeMode`) already + * keeps a System user on a palette that can be drawn. + * + * Persistence stays cookie-only unless a host installed a serverSync + * transport, matching the side effects this API had before the pair existed. + */ const setColorTheme = useCallback((newTheme: string) => { - const normalizedMode = normalizeThemeMode(newTheme, modeRef.current); - colorThemeRef.current = newTheme; - storage.setItem(colorThemeStorageKey, newTheme); - if (normalizedMode !== modeRef.current) { - modeRef.current = normalizedMode; - storage.setItem(storageKey, normalizedMode); - setModeState(normalizedMode); - } - setColorThemeState(newTheme); - }, [colorThemeStorageKey, storageKey]); + const current = configStore.get('themePair'); + const unsupported = getUnsupportedMode(newTheme); + const half: ThemeHalf = unsupported + ? (unsupported === 'light' ? 'dark' : 'light') + : preferredModeRef.current; + configStore.setLocal('themePair', { ...current, [half]: newTheme }); + }, []); - // Repair invalid or incompatible values left by older versions at the boundary. + // Mirror the resolved choice onto the keys older releases read, so a + // downgrade lands on the user's palette instead of an unstyled first frame. + // The pair itself is written first: a pair migrated from the legacy + // single-palette key is derived, and the mirror below overwrites the key it + // was derived from. useEffect(() => { - if (storage.getItem(storageKey) !== mode) storage.setItem(storageKey, mode); - }, [mode, storageKey]); + writeThemePairCookies(pair, legacyKeys); + if (storage.getItem(colorThemeStorageKey) !== colorTheme) { + storage.setItem(colorThemeStorageKey, colorTheme); + } + }, [pair, colorTheme, colorThemeStorageKey, legacyKeys]); const value = useMemo(() => ({ theme: mode, @@ -160,8 +239,11 @@ export function ThemeProvider({ resolvedMode, colorTheme, setColorTheme, + lightTheme: pair.light, + darkTheme: pair.dark, + setHalfTheme, availableThemes: BUILT_IN_THEMES, - }), [mode, preferredMode, resolvedMode, colorTheme, setMode, setColorTheme]); + }), [mode, preferredMode, resolvedMode, colorTheme, pair.light, pair.dark, setMode, setColorTheme, setHalfTheme]); return ( diff --git a/packages/ui/components/ThemeTab.tsx b/packages/ui/components/ThemeTab.tsx index 7152905b8..db9888a63 100644 --- a/packages/ui/components/ThemeTab.tsx +++ b/packages/ui/components/ThemeTab.tsx @@ -1,110 +1,159 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { useTheme } from './ThemeProvider'; import { THEME_MODES } from './themeModes'; -import { isThemeModeAvailable, resolveThemeMode } from '../utils/themeRegistry'; +import { themesForHalf, type ThemeHalf } from '../utils/themeRegistry'; interface ThemeTabProps { onPreview?: () => void; compact?: boolean; } +const HALVES: { id: ThemeHalf; label: string }[] = [ + { id: 'light', label: 'Light' }, + { id: 'dark', label: 'Dark' }, +]; + +const SyntaxLinesIcon: React.FC<{ className?: string }> = ({ className }) => ( + + + +); + export const ThemeTab: React.FC = ({ onPreview, compact }) => { const { mode, setMode, - colorTheme, - setColorTheme, + lightTheme, + darkTheme, + setHalfTheme, availableThemes, preferredMode, } = useTheme(); + // 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 [half, setHalf] = useState(preferredMode); + useEffect(() => setHalf(preferredMode), [preferredMode]); + + const pair: Record = { light: lightTheme, dark: darkTheme }; + const themes = themesForHalf(availableThemes, half); + const nameOf = (id: string) => availableThemes.find(theme => theme.id === id)?.name ?? id; + + const summary = ( +
+ {HALVES.map(({ id, label }, index) => ( + + {index > 0 && ·} + + + ))} +
+ ); + return (
{/* Mode */}
{!compact && }
- {THEME_MODES.map(({ id, label, Icon }) => { - const available = isThemeModeAvailable(colorTheme, id); - return ( - - ); - })} + {THEME_MODES.map(({ id, label, Icon }) => ( + + ))}
- {compact && ( - - - - - syntax match - + {!compact && ( +

+ System follows your OS and switches between the two themes below. +

)} + {compact &&
{summary}
}
- {/* Theme */} -
+ {/* Theme pair */} +
{!compact && ( -
- -
- - - - - = matched syntax colors - - {onPreview && ( - - )} + <> +
+ +
+ + + = matched syntax colors + + {onPreview && ( + + )} +
-
+ {summary} + )} + + {/* Which half the grid assigns to */} +
+ Assigning +
+ {HALVES.map(({ id, label }) => ( + + ))} +
+
+
- {availableThemes.map(theme => { - const isSelected = colorTheme === theme.id; - const previewMode = resolveThemeMode(theme.id, preferredMode); - const colors = theme.colors[previewMode]; - const modeUnavailable = !isThemeModeAvailable(theme.id, preferredMode); + {themes.map(theme => { + const isSelected = pair[half] === theme.id; + const colors = theme.colors[half]; return (