Skip to content
Open
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
4 changes: 2 additions & 2 deletions packages/core/guide-viewer-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ import type { GuideViewerAssets } from "./guide-format";

export const GUIDE_VIEWER_MANIFEST: Omit<GuideViewerAssets, "baseUrl"> = {
js: "viewer.CTfggrYt.js",
css: "viewer.BdruF6Mj.css",
css: "viewer.bodgv3Hj.css",
jsIntegrity: "sha384-It85Hkx0/d1Xme4SJjt3shHybLPGucRF/OODzE84mbOmGH8fK66eFNzgFRXe2W4Z",
cssIntegrity: "sha384-9i0z0HV8a5Hr0SAQt0+pUfQE96MTbGaCWtZlSzhk+HKIHXsqrGi16HQA4mlEWRvx",
cssIntegrity: "sha384-QdcBsIL0BoPsyZoTpe07CMpF0oKoBI3udITgij6RMlwBTW3Qp9SVR2na165Fit+H",
langs: {
"astro": "chunks/astro.BykyiR6i.js",
"c": "chunks/c.BIGW1oBm.js",
Expand Down
5 changes: 5 additions & 0 deletions packages/editor/components/AppHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,8 @@ export const AppHeader = React.memo<AppHeaderProps>(({
bearConfigured,
octarineConfigured,
}) => {
const settingsReturnFocusRef = React.useRef<HTMLButtonElement>(null);

return (
<header
data-app-header="true"
Expand Down Expand Up @@ -456,10 +458,13 @@ export const AppHeader = React.memo<AppHeaderProps>(({
onExternalClose={onCloseSettings}
gitUser={gitUser}
agentTerminalAvailable={agentTerminalAvailable}
isCompactTouchLayout={compactTouchLayout}
returnFocusRef={settingsReturnFocusRef}
/>
</div>

<PlanHeaderMenu
triggerRef={settingsReturnFocusRef}
appVersion={appVersion}
updateInfo={updateInfo}
origin={origin}
Expand Down
3 changes: 3 additions & 0 deletions packages/review-editor/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,7 @@ const ReviewApp: React.FC = () => {
const [showExportModal, setShowExportModal] = useState(false);
const [showWorktreeDialog, setShowWorktreeDialog] = useState(false);
const [openSettingsMenu, setOpenSettingsMenu] = useState(false);
const settingsReturnFocusRef = useRef<HTMLButtonElement>(null);
const [showNoAnnotationsDialog, setShowNoAnnotationsDialog] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const diffStyle = useConfigValue('diffStyle');
Expand Down Expand Up @@ -4034,6 +4035,7 @@ const ReviewApp: React.FC = () => {
<div className="w-px h-5 bg-border/50 mx-1 hidden lg:block" />

<ReviewHeaderMenu
triggerRef={settingsReturnFocusRef}
onOpenSettings={() => setOpenSettingsMenu(true)}
onOpenReviewSetup={sectionsCapable ? () => { reviewSetupIsFirstRun.current = false; setShowReviewSetup(true); } : undefined}
onOpenExport={() => setShowExportModal(true)}
Expand Down Expand Up @@ -4534,6 +4536,7 @@ const ReviewApp: React.FC = () => {
// Display tab hides the Split/Unified control rather than writing
// the desktop preference from a phone.
isCompactTouchLayout={isCompactTouchLayout}
returnFocusRef={settingsReturnFocusRef}
/>
</div>

Expand Down
3 changes: 3 additions & 0 deletions packages/review-editor/components/ReviewHeaderMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export interface CompactReviewAction {
}

interface ReviewHeaderMenuProps {
triggerRef?: React.Ref<HTMLButtonElement>;
onOpenSettings: () => void;
onOpenReviewSetup?: () => void;
onOpenExport: () => void;
Expand All @@ -56,6 +57,7 @@ interface ReviewHeaderMenuProps {
}

export const ReviewHeaderMenu: React.FC<ReviewHeaderMenuProps> = ({
triggerRef,
onOpenSettings,
onOpenReviewSetup,
onOpenExport,
Expand Down Expand Up @@ -90,6 +92,7 @@ export const ReviewHeaderMenu: React.FC<ReviewHeaderMenuProps> = ({
}
renderTrigger={({ isOpen, toggleMenu }) => (
<button
ref={triggerRef}
data-pn-touch-target
data-pn-touch-target-icon
onClick={() => {
Expand Down
3 changes: 3 additions & 0 deletions packages/ui/components/PlanHeaderMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type { UpdateInfo } from '../hooks/useUpdateCheck';
import type { Origin } from '@plannotator/core/agents';

interface PlanHeaderMenuProps {
triggerRef?: React.Ref<HTMLButtonElement>;
appVersion: string;
updateInfo?: UpdateInfo | null;
origin?: Origin | null;
Expand Down Expand Up @@ -48,6 +49,7 @@ export interface CompactPlanAction {
}

export const PlanHeaderMenu: React.FC<PlanHeaderMenuProps> = ({
triggerRef,
appVersion,
updateInfo,
origin,
Expand Down Expand Up @@ -88,6 +90,7 @@ export const PlanHeaderMenu: React.FC<PlanHeaderMenuProps> = ({
}
renderTrigger={({ isOpen, toggleMenu }) => (
<button
ref={triggerRef}
id={compactTouchLayout ? 'pn-compact-plan-options-trigger' : undefined}
data-pn-touch-target={compactTouchLayout || undefined}
data-pn-touch-target-icon={compactTouchLayout || undefined}
Expand Down
6 changes: 4 additions & 2 deletions packages/ui/components/Settings.compactDisplay.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,10 @@ async function openDisplayTab(isCompactTouchLayout: boolean): Promise<void> {
);
});

const displayTab = Array.from(document.querySelectorAll<HTMLButtonElement>('button'))
.find((button) => button.textContent?.trim() === 'Editor');
const displayTab = isCompactTouchLayout
? document.querySelector<HTMLButtonElement>('[data-pn-settings-section="display"]')
: Array.from(document.querySelectorAll<HTMLButtonElement>('button'))
.find((button) => button.textContent?.trim() === 'Editor');
if (!displayTab) throw new Error('review display tab did not render');
await act(async () => displayTab.click());
}
Expand Down
153 changes: 153 additions & 0 deletions packages/ui/components/Settings.mobile.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { afterEach, describe, expect, test } from 'bun:test';
import React, { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { Settings } from './Settings';

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

const expectedSections = {
plan: ['general', 'theme', 'display', 'saving', 'labels', 'vim', 'shortcuts', 'hooks', 'files', 'obsidian', 'bear', 'octarine'],
review: ['general', 'theme', 'git', 'display', 'analysis', 'comments', 'ai', 'shortcuts', 'files'],
annotate: ['general', 'theme', 'vim', 'shortcuts', 'files'],
} as const;

async function nextFrame(): Promise<void> {
await act(async () => {
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
});
}

async function mountSettings(
mode: 'plan' | 'review' | 'annotate',
options: {
compact?: boolean;
returnFocusRef?: React.RefObject<HTMLButtonElement | null>;
} = {},
): Promise<void> {
host = document.createElement('div');
document.body.appendChild(host);
root = createRoot(host);
await act(async () => {
root?.render(
<Settings
taterMode={false}
onTaterModeChange={() => {}}
mode={mode}
externalOpen
isCompactTouchLayout={options.compact ?? true}
returnFocusRef={options.returnFocusRef}
aiProviders={mode === 'review'
? [{ id: 'test', name: 'Test provider', capabilities: {} }]
: []
}
/>,
);
});
await nextFrame();
}

function requireElement<T extends Element>(selector: string): T {
const element = document.querySelector<T>(selector);
if (!element) throw new Error(`Expected element: ${selector}`);
return element;
}

afterEach(async () => {
if (root) await act(async () => root?.unmount());
root = null;
host?.remove();
host = null;
if (hasDom) {
document.body.replaceChildren();
window.localStorage.clear();
}
});

describe.if(hasDom)('compact touch Settings', () => {
for (const mode of ['plan', 'review', 'annotate'] as const) {
test(`${mode} exposes every section through the mobile information architecture`, async () => {
await mountSettings(mode);

const dialog = requireElement<HTMLElement>('[data-pn-settings-layout="compact"]');
expect(dialog.getAttribute('data-pn-settings-screen')).toBe('sections');
expect(dialog.closest('.pn-visible-viewport-stage')).not.toBeNull();
expect(document.querySelectorAll('[data-pn-settings-scroll-owner]')).toHaveLength(1);

const sectionIds = Array.from(
document.querySelectorAll<HTMLElement>('[data-pn-settings-section]'),
(element) => element.dataset.pnSettingsSection,
);
expect(sectionIds).toEqual([...expectedSections[mode]]);

for (const sectionId of expectedSections[mode]) {
const section = requireElement<HTMLButtonElement>(`[data-pn-settings-section="${sectionId}"]`);
await act(async () => section.click());
await nextFrame();

expect(dialog.getAttribute('data-pn-settings-screen')).toBe('detail');
expect(requireElement('[data-pn-settings-section-content]').getAttribute('data-pn-settings-section-content')).toBe(sectionId);
expect(document.querySelector('[aria-label="Close settings"]')).toBeNull();

const back = requireElement<HTMLButtonElement>('[aria-label="Back to Settings"]');
await act(async () => back.click());
await nextFrame();
expect(dialog.getAttribute('data-pn-settings-screen')).toBe('sections');
const activeElement = document.activeElement;
expect(activeElement instanceof HTMLElement ? activeElement.dataset.pnSettingsSection : undefined).toBe(sectionId);
}
});
}

test('focus starts on Close, stays contained, Escape closes, and focus returns', async () => {
const returnButton = document.createElement('button');
returnButton.textContent = 'Options';
document.body.appendChild(returnButton);
returnButton.focus();
const returnFocusRef = { current: returnButton };

await mountSettings('plan', { returnFocusRef });

const close = requireElement<HTMLButtonElement>('[aria-label="Close settings"]');
expect(document.activeElement).toBe(close);
expect(document.activeElement?.tagName).not.toBe('INPUT');

const lastSection = requireElement<HTMLButtonElement>('[data-pn-settings-section="octarine"]');
lastSection.focus();
await act(async () => {
lastSection.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }));
});
expect(document.activeElement).toBe(close);

await act(async () => {
close.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
});
await nextFrame();
expect(document.querySelector('[role="dialog"]')).toBeNull();
expect(document.activeElement).toBe(returnButton);
});
});

describe.if(hasDom)('desktop Settings control', () => {
test('keeps the existing centered desktop dialog and tab composition', async () => {
await mountSettings('plan', { compact: false });

const dialog = requireElement<HTMLElement>('[data-pn-settings-layout="desktop"]');
expect(dialog.classList.contains('max-w-2xl')).toBe(true);
expect(dialog.classList.contains('max-h-[85vh]')).toBe(true);
expect(document.querySelector('[data-pn-settings-section]')).toBeNull();
expect(document.querySelector('.pn-visible-viewport-stage')).toBeNull();
expect(document.body.textContent).toContain('Your Identity');
});
});

test('compact CSS enforces touch targets, 16px editing controls, and reduced motion', async () => {
const css = await Bun.file(new URL('../theme.css', import.meta.url)).text();
expect(css).toContain("[data-pn-settings-layout='compact'] button:not([role='switch'])");
expect(css).toContain('min-block-size: var(--pn-touch-target)');
expect(css).toContain("[data-pn-settings-layout='compact'] [role='switch']::before");
expect(css).toContain('font-size: 1rem !important');
expect(css).toContain('@media (prefers-reduced-motion: reduce)');
expect(css).toContain('transition-duration: 0.01ms !important');
});
Loading