Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 29 additions & 35 deletions packages/review-editor/components/ReviewHeaderMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import React, { useMemo } from 'react';
import React from 'react';
import {
ActionMenu,
ActionMenuDivider,
ActionMenuItem,
ActionMenuSectionLabel,
} 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';
Expand Down Expand Up @@ -44,15 +46,13 @@ export const ReviewHeaderMenu: React.FC<ReviewHeaderMenuProps> = ({
origin,
isWSL = false,
}) => {
const { theme, resolvedMode, setTheme } = useTheme();
const activeTheme = useMemo<'light' | 'dark'>(() => {
return theme === 'system' ? resolvedMode : theme;
}, [resolvedMode, theme]);
const { theme, setTheme, colorTheme } = useTheme();

const showUpdateDot = !!updateInfo?.updateAvailable && !updateInfo.dismissed;

return (
<ActionMenu
panelWidth="wide"
renderTrigger={({ isOpen, toggleMenu }) => (
<button
onClick={() => {
Expand Down Expand Up @@ -87,23 +87,30 @@ export const ReviewHeaderMenu: React.FC<ReviewHeaderMenuProps> = ({
<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">
{(['light', 'dark'] as const).map((mode) => (
<button
key={mode}
onClick={() => {
closeMenu();
setTheme(mode);
}}
className={`flex flex-1 items-center justify-center gap-1.5 rounded-md px-2 py-1 text-[11px] font-medium transition-colors ${
activeTheme === mode
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{mode === 'light' ? <SunIcon /> : <MoonIcon />}
<span className="capitalize">{mode}</span>
</button>
))}
{THEME_MODES.map(({ id, label, Icon }) => {
const available = isThemeModeAvailable(colorTheme, id);
return (
<button
key={id}
disabled={!available}
title={available ? undefined : 'Not supported by the current color theme'}
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 ${
!available
? 'cursor-not-allowed text-muted-foreground opacity-40'
: theme === id
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<Icon />
<span>{label}</span>
</button>
);
})}
</div>
</div>

Expand Down Expand Up @@ -213,19 +220,6 @@ const ExportIcon = () => (
</svg>
);

const SunIcon = () => (
<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="M12 3v2.25M12 18.75V21M3 12h2.25M18.75 12H21M5.636 5.636l1.591 1.591M16.773 16.773l1.591 1.591M5.636 18.364l1.591-1.591M16.773 7.227l1.591-1.591" />
<circle cx="12" cy="12" r="3.25" />
</svg>
);

const MoonIcon = () => (
<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="M21 12.79A9 9 0 1111.21 3c-.18.57-.21 1.19-.21 1.82A8 8 0 0019.18 13c.63 0 1.25-.03 1.82-.21z" />
</svg>
);

const FileTreeMenuIcon = () => (
<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="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
Expand Down
1 change: 1 addition & 0 deletions packages/ui/HANDOFF.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ We deliberately did **not** restructure the exports map in this PR (move-don't-r
| `components/CommentPopover` | Anchor capture + comment entry. Ask-AI UI renders only if you pass `onAskAI`. |
| `components/AnnotationPanel` | Renders from your annotation state; no fetches of its own. |
| `components/ThemeProvider` | Color-mode context. |
| `theme-modes` (`THEME_MODES`, `Mode`) | The supported Light/Dark/System catalog and mode type. `Mode` also remains exported from `components/ThemeProvider` for compatibility with existing consumers. |
| `components/ImageThumbnail` / `getImageSrc` | Routes through `imageSrcResolver`. |
| `components/AttachmentsButton` | Routes through `uploadTransport`. |
| Seam-backed hooks: `useAnnotationHighlighter`, `useAnnotationDraft`, `useCodeAnnotationDraft`, `useExternalAnnotations`, `useFileBrowser` | Their network access goes through the seams in the catalog above. |
Expand Down
53 changes: 53 additions & 0 deletions packages/ui/components/ActionMenu.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { afterEach, describe, expect, test } from 'bun:test';
import React, { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { ActionMenu } from './ActionMenu';

const hasDom = typeof document !== 'undefined';
let root: Root | null = null;
let host: HTMLElement | null = null;

async function renderMenu(panelWidth?: 'default' | 'wide'): Promise<HTMLElement> {
host = document.createElement('div');
document.body.appendChild(host);
root = createRoot(host);
await act(async () => {
root!.render(
<ActionMenu
panelWidth={panelWidth}
renderTrigger={({ toggleMenu }) => <button onClick={toggleMenu}>Open</button>}
>
{() => <div>Panel</div>}
</ActionMenu>,
);
});
const trigger = host.querySelector('button');
if (!trigger) throw new Error('ActionMenu trigger did not render');
await act(async () => trigger.click());
const panel = Array.from(host.querySelectorAll('div')).find(element =>
element.textContent === 'Panel' && element.classList.contains('absolute')
);
if (!panel) throw new Error('ActionMenu panel did not open');
return panel;
}

describe('ActionMenu panel geometry', () => {
afterEach(async () => {
if (root) await act(async () => root!.unmount());
root = null;
host?.remove();
host = null;
});

test.skipIf(!hasDom)('keeps the default width for unrelated consumers', async () => {
const panel = await renderMenu();
expect(panel.classList.contains('w-56')).toBe(true);
expect(panel.classList.contains('w-64')).toBe(false);
});

test.skipIf(!hasDom)('offers an opt-in wide panel for three-mode pickers', async () => {
const panel = await renderMenu('wide');
expect(panel.classList.contains('w-64')).toBe(true);
expect(panel.classList.contains('w-56')).toBe(false);
});
});
6 changes: 5 additions & 1 deletion packages/ui/components/ActionMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import React, { useEffect, useRef, useState } from 'react';
interface ActionMenuProps {
className?: string;
panelClassName?: string;
panelWidth?: 'default' | 'wide';
renderTrigger: (props: {
isOpen: boolean;
toggleMenu: () => void;
Expand All @@ -13,6 +14,7 @@ interface ActionMenuProps {
export const ActionMenu: React.FC<ActionMenuProps> = ({
className,
panelClassName,
panelWidth = 'default',
renderTrigger,
children,
}) => {
Expand Down Expand Up @@ -50,7 +52,9 @@ export const ActionMenu: React.FC<ActionMenuProps> = ({
})}

{isOpen && (
<div className={panelClassName ?? 'absolute top-full right-0 mt-1 w-56 rounded-lg border border-border bg-popover py-1 shadow-xl z-[70]'}>
<div
className={panelClassName ?? `absolute top-full right-0 mt-1 ${panelWidth === 'wide' ? 'w-64' : 'w-56'} rounded-lg border border-border bg-popover py-1 shadow-xl z-[70]`}
>
{children({ closeMenu: () => setIsOpen(false) })}
</div>
)}
Expand Down
37 changes: 23 additions & 14 deletions packages/ui/components/ModeToggle.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
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 } = useTheme();
const { theme, setTheme, colorTheme } = useTheme();
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);

Expand Down Expand Up @@ -48,19 +50,26 @@ export function ModeToggle() {

{isOpen && (
<div className="absolute right-0 mt-1 w-32 rounded-lg border border-border bg-popover shadow-xl z-50 overflow-hidden py-1">
{(['light', 'dark', 'system'] as const).map((t) => (
<button
key={t}
onClick={() => { setTheme(t); setIsOpen(false); }}
className={`w-full px-3 py-1.5 text-left text-xs capitalize transition-colors ${
theme === t
? 'text-primary bg-primary/10 font-medium'
: 'text-popover-foreground hover:bg-muted'
}`}
>
{t}
</button>
))}
{THEME_MODES.map(({ id, label }) => {
const available = isThemeModeAvailable(colorTheme, id);
return (
<button
key={id}
disabled={!available}
title={available ? undefined : 'Not supported by the current color theme'}
onClick={() => { setTheme(id); setIsOpen(false); }}
className={`w-full px-3 py-1.5 text-left text-xs transition-colors ${
!available
? 'cursor-not-allowed text-muted-foreground opacity-40'
: theme === id
? 'text-primary bg-primary/10 font-medium'
: 'text-popover-foreground hover:bg-muted'
}`}
>
{label}
</button>
);
})}
</div>
)}
</div>
Expand Down
48 changes: 28 additions & 20 deletions packages/ui/components/PlanHeaderMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import {
ActionMenuSectionLabel,
} from './ActionMenu';
import { useTheme } from './ThemeProvider';
import { SunIcon, MoonIcon, SystemIcon } from './icons/themeIcons';
import { THEME_MODES } from './themeModes';
import { isThemeModeAvailable } from '../utils/themeRegistry';
import { ReviewAgentsIcon } from './ReviewAgentsIcon';
import { MenuVersionSection } from './MenuVersionSection';
import { TextShimmer } from './TextShimmer';
Expand Down Expand Up @@ -58,7 +59,7 @@ export const PlanHeaderMenu: React.FC<PlanHeaderMenuProps> = ({
bearConfigured,
octarineConfigured,
}) => {
const { theme, setTheme } = useTheme();
const { theme, setTheme, colorTheme } = useTheme();

const showUpdateDot = !!updateInfo?.updateAvailable && !updateInfo.dismissed;

Expand All @@ -67,6 +68,7 @@ export const PlanHeaderMenu: React.FC<PlanHeaderMenuProps> = ({

return (
<ActionMenu
panelWidth="wide"
renderTrigger={({ isOpen, toggleMenu }) => (
<button
onClick={() => {
Expand Down Expand Up @@ -101,23 +103,30 @@ export const PlanHeaderMenu: React.FC<PlanHeaderMenuProps> = ({
<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">
{(['light', 'dark', 'system'] as const).map((mode) => (
<button
key={mode}
onClick={() => {
closeMenu();
setTheme(mode);
}}
className={`flex flex-1 items-center justify-center gap-1.5 rounded-md px-2 py-1 text-[11px] font-medium transition-colors ${
theme === mode
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{mode === 'light' ? <SunIcon /> : mode === 'dark' ? <MoonIcon /> : <SystemIcon />}
<span className="capitalize">{mode}</span>
</button>
))}
{THEME_MODES.map(({ id, label, Icon }) => {
const available = isThemeModeAvailable(colorTheme, id);
return (
<button
key={id}
disabled={!available}
title={available ? undefined : 'Not supported by the current color theme'}
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 ${
!available
? 'cursor-not-allowed text-muted-foreground opacity-40'
: theme === id
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<Icon />
<span>{label}</span>
</button>
);
})}
</div>
</div>

Expand Down Expand Up @@ -296,4 +305,3 @@ const NoteIcon = () => (
<path strokeLinecap="round" strokeLinejoin="round" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
</svg>
);

Loading