Files
backnotprop__plannotator/packages/ui/components/PlanHeaderMenu.tsx
Michael Ramos 2d65c65596 feat(ui): pair a light theme and a dark theme, switched by mode (#1217)
* feat(ui): pair a light theme and a dark theme, switched by mode

ThemeProvider stored one palette plus a mode, so picking a dark-only
palette pinned the mode and greyed out the Light/System buttons. Store a
pair instead: { mode, light, dark }, resolved as pair[preferredMode], so
System flips between the two choices as the OS scheme changes.

The Settings Theme tab now assigns one half at a time. A Light/Dark
switch decides which half the grid is filling, the grid lists only the
palettes that can render that half (from the registry's modeSupport), and
a summary line names both halves with each side clickable. Every mode
button is permanently enabled: a dark-only palette simply never occupies
the light slot, so no mode coercion is left to do.

The pair round-trips through the SETTINGS registry to the `theme` key in
~/.plannotator/config.json the way diffOptions does. A user upgrading
seeds both halves from their stored single palette, and the legacy
plannotator-color-theme key keeps tracking the active palette so a
downgrade never lands on an unstyled first frame.

Addresses part 1 of #1211.

* fix(ui): make the theme pair seed local, and keep the legacy API non-destructive

Review of #1217 found a data-loss path and three published-API regressions.

Seeding: ThemeProvider handed its resolved pair to the config store through
set(), which queues a debounced POST. configStore.init() applies the server
config but never cancelled that queued write, so a single cookie-less visit
(fresh profile, incognito, cleared cookies) flushed a default pair to
~/.plannotator/config.json AFTER the real one had arrived, and the next
session restored those defaults over the user's cookies. The provider now
uses a new configStore.seed(): memory plus cookie, never the server, and
never over a value init() already applied. init() additionally retracts
queued writes for the leaves the server just spoke for, which closes the
same race for every server-synced setting rather than this one key.

Deprecated APIs: isThemeModeAvailable() and normalizeThemeMode() are back as
one-line wrappers with @deprecated notes, since packages/ui exports utils/*.

setColorTheme: assigns exactly one half and nothing else. A both-mode palette
goes to the half on screen instead of clobbering both; a mode-restricted one
goes to its half without yanking a System user to an explicit mode (render
time already resolves that). It persists through configStore.setLocal(), so
it stays cookie-only as it was before the pair, unless a host installed its
own serverSync transport.

storageKey / colorThemeStorageKey are honored on the read path, so a host's
stored pre-pair preference is migrated rather than discarded. The two halves
have no pre-pair equivalent and stay on fixed keys, documented on the props.

Tests: a fresh-mount case that pins zero POSTs (the previous helper pre-seeded
cookies, which is why this was invisible), a case that pins a real choice
still reaching config.json, direct setColorTheme cases for all three
semantics, a host-storage-keys migration case, and configStore seed/retract
unit tests. All of them fail against the code they replace.
2026-08-05 21:51:55 -07:00

300 lines
9.9 KiB
TypeScript

import React from 'react';
import {
ActionMenu,
ActionMenuDivider,
ActionMenuItem,
ActionMenuSectionLabel,
} from './ActionMenu';
import { useTheme } from './ThemeProvider';
import { THEME_MODES } from './themeModes';
import { ReviewAgentsIcon } from './ReviewAgentsIcon';
import { MenuVersionSection } from './MenuVersionSection';
import { TextShimmer } from './TextShimmer';
import type { UpdateInfo } from '../hooks/useUpdateCheck';
import type { Origin } from '@plannotator/core/agents';
interface PlanHeaderMenuProps {
appVersion: string;
updateInfo?: UpdateInfo | null;
origin?: Origin | null;
isWSL?: boolean;
onOpenSettings: () => void;
onOpenExport: () => void;
onCopyAgentInstructions: () => void;
onDownloadAnnotations: () => void;
onPrint: () => void;
onCopyShareLink: () => void;
onOpenImport: () => void;
onSaveToObsidian: () => void;
onSaveToBear: () => void;
onSaveToOctarine: () => void;
sharingEnabled: boolean;
isApiMode: boolean;
agentInstructionsEnabled: boolean;
obsidianConfigured: boolean;
bearConfigured: boolean;
octarineConfigured: boolean;
}
export const PlanHeaderMenu: React.FC<PlanHeaderMenuProps> = ({
appVersion,
updateInfo,
origin,
isWSL = false,
onOpenSettings,
onOpenExport,
onCopyAgentInstructions,
onDownloadAnnotations,
onPrint,
onCopyShareLink,
onOpenImport,
onSaveToObsidian,
onSaveToBear,
onSaveToOctarine,
sharingEnabled,
isApiMode,
agentInstructionsEnabled,
obsidianConfigured,
bearConfigured,
octarineConfigured,
}) => {
const { theme, setTheme } = useTheme();
const showUpdateDot = !!updateInfo?.updateAvailable && !updateInfo.dismissed;
const anyNotesAppConfigured =
isApiMode && (obsidianConfigured || bearConfigured || octarineConfigured);
return (
<ActionMenu
panelWidth="wide"
renderTrigger={({ isOpen, toggleMenu }) => (
<button
onClick={() => {
if (!isOpen && showUpdateDot) updateInfo?.dismiss();
toggleMenu();
}}
className={`relative flex items-center gap-1.5 p-1.5 md:px-2.5 md:py-1 rounded-md text-xs font-medium transition-colors ${
isOpen
? 'bg-muted text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
}`}
title="Options"
aria-label="Options"
aria-expanded={isOpen}
>
{isOpen ? <CloseIcon /> : <MenuIcon />}
{showUpdateDot ? (
<TextShimmer className="hidden md:inline text-xs font-medium" duration={2.5} spread={1.5}>
Options
</TextShimmer>
) : (
<span className="hidden md:inline">Options</span>
)}
{showUpdateDot && (
<span className="absolute top-0.5 right-0.5 md:-top-0.5 md:-right-0.5 w-2 h-2 rounded-full bg-primary ring-2 ring-background" />
)}
</button>
)}
>
{({ closeMenu }) => (
<>
<div className="px-3 py-2 space-y-1.5">
<ActionMenuSectionLabel>Theme</ActionMenuSectionLabel>
<div className="flex items-center gap-1 rounded-lg bg-muted/50 p-0.5">
{THEME_MODES.map(({ id, label, Icon }) => (
<button
key={id}
onClick={() => {
closeMenu();
setTheme(id);
}}
className={`flex flex-1 items-center justify-center gap-1.5 rounded-md px-2 py-1 text-[11px] font-medium transition-colors ${
theme === id
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<Icon />
<span>{label}</span>
</button>
))}
</div>
</div>
<ActionMenuDivider />
<ActionMenuItem
onClick={() => {
closeMenu();
onOpenSettings();
}}
icon={<SettingsIcon />}
label="Settings"
/>
<ActionMenuItem
onClick={() => {
closeMenu();
onOpenExport();
}}
icon={<ExportIcon />}
label="Export"
/>
{agentInstructionsEnabled && (
<ActionMenuItem
onClick={() => {
closeMenu();
onCopyAgentInstructions();
}}
icon={<ReviewAgentsIcon />}
label="Agent Instructions"
subtitle="Copy agent instructions for external annotations"
/>
)}
<ActionMenuDivider />
<ActionMenuItem
onClick={() => {
closeMenu();
onDownloadAnnotations();
}}
icon={<DownloadIcon />}
label="Download Annotations"
/>
<ActionMenuItem
onClick={() => {
closeMenu();
onPrint();
}}
icon={<PrintIcon />}
label="Print / Save as PDF"
subtitle="Choose 'Save as PDF' in the print dialog"
/>
{sharingEnabled && (
<ActionMenuItem
onClick={() => {
closeMenu();
onCopyShareLink();
}}
icon={<LinkIcon />}
label="Copy Share Link"
/>
)}
{sharingEnabled && (
<ActionMenuItem
onClick={() => {
closeMenu();
onOpenImport();
}}
icon={<ImportIcon />}
label="Import Review"
/>
)}
{anyNotesAppConfigured && (
<>
<ActionMenuDivider />
{obsidianConfigured && (
<ActionMenuItem
onClick={() => {
closeMenu();
onSaveToObsidian();
}}
icon={<NoteIcon />}
label="Save to Obsidian"
/>
)}
{bearConfigured && (
<ActionMenuItem
onClick={() => {
closeMenu();
onSaveToBear();
}}
icon={<NoteIcon />}
label="Save to Bear"
/>
)}
{octarineConfigured && (
<ActionMenuItem
onClick={() => {
closeMenu();
onSaveToOctarine();
}}
icon={<NoteIcon />}
label="Save to Octarine"
/>
)}
</>
)}
<ActionMenuDivider />
<MenuVersionSection
appVersion={appVersion}
updateInfo={updateInfo}
origin={origin}
isWSL={isWSL}
closeMenu={closeMenu}
/>
</>
)}
</ActionMenu>
);
};
const MenuIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
);
const CloseIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
);
const SettingsIcon = () => (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
);
const ExportIcon = () => (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
);
const DownloadIcon = () => (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
);
const PrintIcon = () => (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
</svg>
);
const LinkIcon = () => (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
);
const ImportIcon = () => (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 3h4a2 2 0 012 2v14a2 2 0 01-2 2h-4M10 17l5-5-5-5M15 12H3" />
</svg>
);
const NoteIcon = () => (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
</svg>
);