-
-
-
- {t("page_error_title")}
-
-
- {t("page_error_description")}
-
-
+
+
+
+
+
{title}
+ {description &&
{description}
}
+
+ {homeLabel && (
+ // A full load: whatever broke starts over.
+
+ {homeLabel}
+
+ )}
+
- {t("try_again")}
+ {retryLabel}
);
}
-/**
- * Sidebar error fallback - matches sidebar width (256px).
- */
+/** Full-page error fallback, also used around each app (AppErrorBoundary). */
+export function PageErrorFallback({ resetError, t }: FallbackProps) {
+ return (
+
+ );
+}
+
+/** Mail's folder sidebar (256px wide). */
export function SidebarErrorFallback({ resetError, t }: FallbackProps) {
return (
-
-
-
- {t("sidebar_error")}
-
-
-
- {t("reload")}
-
-
+
);
}
-/**
- * Email list error fallback - matches email list panel width (384px).
- */
+/** Mail's message list. */
export function EmailListErrorFallback({ resetError, t }: FallbackProps) {
- return (
-
-
-
- {t("email_list_error")}
-
-
-
- {t("reload_emails")}
-
-
- );
+ return ;
}
-/**
- * Email viewer error fallback - fills remaining space (flex-1).
- */
+/** Mail's reading pane. */
export function EmailViewerErrorFallback({ resetError, t }: FallbackProps) {
return (
-
-
-
-
-
- {t("viewer_error_title")}
-
-
- {t("viewer_error_description")}
-
-
-
- {t("try_again")}
-
-
+
);
}
-/**
- * Email composer modal error fallback.
- */
+/** The composer. */
export function ComposerErrorFallback({ resetError, t }: FallbackProps) {
- return (
-
-
-
- {t("composer_error")}
-
-
- {t("retry")}
-
-
- );
+ return ;
}
-/**
- * Settings page error fallback.
- */
+/** Settings. */
export function SettingsErrorFallback({ resetError, t }: FallbackProps) {
return (
-
-
-
- {t("settings_error_title")}
-
-
- {t("settings_error_description")}
-
-
-
- {t("reload_settings")}
-
-
+
);
}
diff --git a/components/error/index.ts b/components/error/index.ts
index 2b70083be..fef3b8dcf 100644
--- a/components/error/index.ts
+++ b/components/error/index.ts
@@ -1,6 +1,8 @@
export { ErrorBoundary } from "./error-boundary";
+export { AppErrorBoundary } from "./app-error-boundary";
export type { FallbackProps } from "./error-boundary";
export {
+ ErrorState,
PageErrorFallback,
SidebarErrorFallback,
EmailListErrorFallback,
diff --git a/components/error/not-found-view.tsx b/components/error/not-found-view.tsx
new file mode 100644
index 000000000..aa42c06b4
--- /dev/null
+++ b/components/error/not-found-view.tsx
@@ -0,0 +1,71 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { SearchX } from "@/components/icons";
+import { useAuthStore } from "@/stores/auth-store";
+import { getPathPrefix } from "@/lib/browser-navigation";
+
+/** Whether the current URL is an admin page, which has its own sign-in. */
+function isAdminPath(): boolean {
+ if (typeof window === "undefined") return false;
+ const adminBase = `${getPathPrefix()}/admin`;
+ const path = window.location.pathname;
+ return path === adminBase || path.startsWith(`${adminBase}/`);
+}
+
+/**
+ * Who is looking at a missing page: "checking" until the stored session has
+ * been restored, then signed in or not. Deciding before that sent a signed-in
+ * user with a mistyped URL to the login page.
+ */
+export function useNotFoundAuth(): "checking" | "signed-in" | "signed-out" {
+ const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
+ const [checked, setChecked] = useState(() => useAuthStore.getState().isAuthenticated);
+
+ useEffect(() => {
+ if (useAuthStore.getState().isAuthenticated) {
+ setChecked(true);
+ return;
+ }
+ let cancelled = false;
+ void useAuthStore.getState().checkAuth().finally(() => { if (!cancelled) setChecked(true); });
+ return () => { cancelled = true; };
+ }, []);
+
+ useEffect(() => {
+ if (!checked || isAuthenticated || isAdminPath()) return;
+ window.location.href = `${getPathPrefix()}/login`;
+ }, [checked, isAuthenticated]);
+
+ if (isAuthenticated) return "signed-in";
+ return checked ? "signed-out" : "checking";
+}
+
+export interface NotFoundViewProps {
+ title: string;
+ description: string;
+ homeLabel: string;
+}
+
+/** The 404 page: what happened and the way back. Signed out, it shows only on admin paths. */
+export function NotFoundView({ title, description, homeLabel }: NotFoundViewProps) {
+ const auth = useNotFoundAuth();
+ if (auth !== "signed-in" && !isAdminPath()) return null;
+
+ const prefix = typeof window !== "undefined" ? getPathPrefix() : "";
+ return (
+
+ );
+}
diff --git a/components/files/files-header.tsx b/components/files/files-header.tsx
index 426b80f94..1c865fcdf 100644
--- a/components/files/files-header.tsx
+++ b/components/files/files-header.tsx
@@ -11,6 +11,7 @@ import { Button } from "@/components/ui/button";
import { IconButton } from "@/components/ui/icon-button";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
import { useContextMenu } from "@/hooks/use-context-menu";
+import { PhoneHeaderActions } from "@/components/layout/phone-header-actions";
import { cn } from "@/lib/utils";
import { anchorEvent, isInternalDrag, readDraggedNames } from "@/components/files/file-item-parts";
import { PluginActions } from "@/components/plugins/plugin-actions";
@@ -366,6 +367,7 @@ export function FilesHeader({
)}
+
)}
diff --git a/components/layout/__tests__/account-switcher-remove.test.tsx b/components/layout/__tests__/account-switcher-remove.test.tsx
new file mode 100644
index 000000000..f287edb60
--- /dev/null
+++ b/components/layout/__tests__/account-switcher-remove.test.tsx
@@ -0,0 +1,59 @@
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+// Removing an account from the popover: the app's confirm dialog instead of
+// the browser's, a control a touch screen can reach, and sign-out rows in
+// the menu's plain text colour.
+
+const { AccountSwitcher } = await import('../account-switcher');
+const { useAccountStore } = await import('@/stores/account-store');
+const { useAuthStore } = await import('@/stores/auth-store');
+const { useSettingsStore } = await import('@/stores/settings-store');
+
+function account(id: string) {
+ return {
+ id, label: `${id}@example.com`, email: `${id}@example.com`, username: `${id}@example.com`,
+ displayName: id.toUpperCase(), serverUrl: 'https://mail.example.com', avatarColor: '#336699',
+ cookieSlot: 0, isConnected: true, isDefault: id === 'a',
+ };
+}
+
+const removeAccount = vi.fn();
+
+beforeEach(() => {
+ removeAccount.mockClear();
+ useAccountStore.setState({ accounts: [account('a'), account('b')] } as never);
+ useAuthStore.setState({ activeAccountId: 'a', removeAccount } as never);
+ useSettingsStore.setState({ interfaceLayout: 'default' } as never);
+});
+
+describe('removing an account from the popover', () => {
+ it('asks with the app dialog, not the browser, and removes on confirm', async () => {
+ const browserConfirm = vi.spyOn(window, 'confirm').mockReturnValue(true);
+ render(
);
+ fireEvent.click(screen.getByTestId('account-switcher'));
+ fireEvent.click(screen.getByRole('menuitem', { name: 'remove_account' }));
+
+ expect(browserConfirm).not.toHaveBeenCalled();
+ const dialog = screen.getByRole('alertdialog', { name: 'remove_account_title' });
+ expect(removeAccount).not.toHaveBeenCalled();
+ await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'remove_account' })); });
+ expect(removeAccount).toHaveBeenCalledWith('b');
+ expect(dialog).not.toBeInTheDocument();
+ browserConfirm.mockRestore();
+ });
+
+ it('keeps the remove control visible on a touch screen', () => {
+ render(
);
+ fireEvent.click(screen.getByTestId('account-switcher'));
+ expect(screen.getByRole('menuitem', { name: 'remove_account' }).className).toContain('pointer-coarse:opacity-100');
+ });
+
+ it('draws "sign out of all accounts" like the other rows, start-aligned', () => {
+ render(
);
+ fireEvent.click(screen.getByTestId('account-switcher'));
+ const all = screen.getByRole('menuitem', { name: 'sign_out_all' });
+ expect(all.className).not.toContain('text-destructive');
+ expect(all.className).toContain('text-start');
+ });
+});
diff --git a/components/layout/__tests__/mv-top-bar-cluster.test.tsx b/components/layout/__tests__/mv-top-bar-cluster.test.tsx
new file mode 100644
index 000000000..ad7854147
--- /dev/null
+++ b/components/layout/__tests__/mv-top-bar-cluster.test.tsx
@@ -0,0 +1,67 @@
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+// Mountain View's right-hand cluster is the same in every app: the keyboard
+// shortcuts button and the avatar's "All inboxes" row work outside mail too.
+
+const nav = vi.hoisted(() => ({ push: vi.fn() }));
+const switcher = vi.hoisted(() => ({ onSelectAllInboxes: undefined as undefined | (() => void) }));
+
+vi.mock('../account-switcher', () => ({
+ AccountSwitcher: (props: { onSelectAllInboxes?: () => void }) => {
+ switcher.onSelectAllInboxes = props.onSelectAllInboxes;
+ return null;
+ },
+}));
+vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) }));
+vi.mock('@/hooks/use-stalwart-admin', () => ({ useStalwartAdmin: () => false }));
+vi.mock('@/i18n/navigation', () => ({
+ useRouter: () => ({ push: nav.push, back: vi.fn(), replace: vi.fn() }),
+ usePathname: () => '/calendar',
+ Link: ({ children }: { children: React.ReactNode }) => children,
+}));
+
+const { MvTopBar } = await import('../mv-top-bar');
+const { useSettingsStore } = await import('@/stores/settings-store');
+const { useAccountStore } = await import('@/stores/account-store');
+const { usePolicyStore } = await import('@/stores/policy-store');
+const { DEFAULT_POLICY } = await import('@/lib/admin/types');
+const appCommands = await import('@/lib/app-commands');
+
+const twoAccounts = [
+ { id: 'a', isConnected: true },
+ { id: 'b', isConnected: true },
+];
+
+beforeEach(() => {
+ nav.push.mockClear();
+ switcher.onSelectAllInboxes = undefined;
+ usePolicyStore.setState({ policy: { ...DEFAULT_POLICY, features: { ...DEFAULT_POLICY.features, unifiedCrossAccountEnabled: true } } });
+ useSettingsStore.setState({ enableUnifiedMailbox: true, unifiedCrossAccount: true } as never);
+ useAccountStore.setState({ accounts: twoAccounts } as never);
+});
+
+describe('Mountain View top bar outside mail', () => {
+ it('opens the keyboard shortcuts without a handler from the app', () => {
+ const opened = vi.fn();
+ window.addEventListener(appCommands.KEYBOARD_SHORTCUTS_OPEN_EVENT, opened);
+ render(
);
+ fireEvent.click(screen.getByRole('button', { name: 'keyboard_shortcuts' }));
+ expect(opened).toHaveBeenCalled();
+ window.removeEventListener(appCommands.KEYBOARD_SHORTCUTS_OPEN_EVENT, opened);
+ });
+
+ it('offers "All inboxes" and opens mail on it', () => {
+ render(
);
+ expect(switcher.onSelectAllInboxes).toBeTypeOf('function');
+ act(() => switcher.onSelectAllInboxes!());
+ expect(nav.push).toHaveBeenCalledWith('/');
+ expect(appCommands.peekParkedAppCommand()).toBe('all-inboxes');
+ });
+
+ it('leaves "All inboxes" out when the merged inbox cannot be built', () => {
+ useAccountStore.setState({ accounts: [twoAccounts[0]] } as never);
+ render(
);
+ expect(switcher.onSelectAllInboxes).toBeUndefined();
+ });
+});
diff --git a/components/layout/__tests__/navigation-rail-storage.test.tsx b/components/layout/__tests__/navigation-rail-storage.test.tsx
new file mode 100644
index 000000000..60d7fe9b7
--- /dev/null
+++ b/components/layout/__tests__/navigation-rail-storage.test.tsx
@@ -0,0 +1,38 @@
+import React from 'react';
+import { fireEvent, render, screen } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+// The rail's storage ring opens a popover that Escape closes, like every
+// other popover in the rail.
+
+vi.mock('../account-switcher', () => ({ AccountSwitcher: () => null }));
+vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
+vi.mock('@/components/keyboard-shortcuts-modal', () => ({ KeyboardShortcutsModal: () => null }));
+vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) }));
+vi.mock('@/hooks/use-stalwart-admin', () => ({ useStalwartAdmin: () => false }));
+vi.mock('@/i18n/navigation', () => ({
+ useRouter: () => ({ push: vi.fn(), back: vi.fn(), replace: vi.fn() }),
+ usePathname: () => '/',
+ Link: ({ children, href, ...rest }: { children: React.ReactNode; href: string }) =>
{children} ,
+}));
+
+const { NavigationRail } = await import('../navigation-rail');
+const { useUpdateStore } = await import('@/stores/update-store');
+
+beforeEach(() => {
+ useUpdateStore.setState({ startPolling: vi.fn() } as never);
+});
+
+describe('storage popover', () => {
+ it('closes on Escape and gives the focus back to the ring', () => {
+ render(
);
+ const ring = screen.getByRole('button', { name: 'storage' });
+ fireEvent.click(ring);
+ expect(ring).toHaveAttribute('aria-expanded', 'true');
+ expect(screen.getByText('storage_total')).toBeInTheDocument();
+
+ fireEvent.keyDown(document, { key: 'Escape' });
+ expect(screen.queryByText('storage_total')).toBeNull();
+ expect(ring).toHaveFocus();
+ });
+});
diff --git a/components/layout/__tests__/phone-header-actions.test.tsx b/components/layout/__tests__/phone-header-actions.test.tsx
new file mode 100644
index 000000000..5b5be97f5
--- /dev/null
+++ b/components/layout/__tests__/phone-header-actions.test.tsx
@@ -0,0 +1,107 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+// The phone header cluster (⋮ with notifications and the command palette,
+// then the account avatar) that every app shows at phone width.
+
+const media = vi.hoisted(() => ({ mobile: true, desktop: false }));
+vi.mock('@/hooks/use-media-query', async (importOriginal) => ({
+ ...(await importOriginal
()),
+ useIsMobile: () => media.mobile,
+ useIsDesktop: () => media.desktop,
+}));
+
+const { PhoneHeaderActions, PhoneAppHeader } = await import('../phone-header-actions');
+const { ContactList } = await import('@/components/contacts/contact-list');
+const { ContextMenuItem } = await import('@/components/ui/context-menu');
+
+beforeEach(() => {
+ media.mobile = true;
+ media.desktop = false;
+});
+
+describe('PhoneHeaderActions', () => {
+ it('shows the menu and the account avatar on a phone', () => {
+ render( );
+ expect(screen.getByRole('button', { name: 'switch_account' })).toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button', { name: 'more' }));
+ // Notifications and the command palette (both "title" as keys in tests).
+ expect(screen.getAllByRole('menuitem').map((m) => m.textContent)).toEqual(['title', 'title']);
+ });
+
+ it('opens the command palette from the menu', () => {
+ const opened = vi.fn();
+ window.addEventListener('bulwark:command-palette', opened);
+ render( );
+ fireEvent.click(screen.getByRole('button', { name: 'more' }));
+ fireEvent.click(screen.getAllByRole('menuitem')[1]);
+ expect(opened).toHaveBeenCalled();
+ window.removeEventListener('bulwark:command-palette', opened);
+ });
+
+ it('lists the app entries first and closes the menu when one is picked', () => {
+ const run = vi.fn();
+ render(
+ { close(); run(); }} />}
+ />
+ );
+ fireEvent.click(screen.getByRole('button', { name: 'more' }));
+ const items = screen.getAllByRole('menuitem');
+ expect(items[0]).toHaveTextContent('Import');
+ fireEvent.click(items[0]);
+ expect(run).toHaveBeenCalled();
+ expect(screen.queryByRole('menu')).toBeNull();
+ });
+
+ it('draws nothing where the rail holds these controls', () => {
+ media.desktop = true;
+ media.mobile = false;
+ const { container } = render( );
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('draws nothing on a tablet for an app that keeps the rail there', () => {
+ media.mobile = false;
+ const { container } = render( );
+ expect(container).toBeEmptyDOMElement();
+ render( );
+ expect(screen.getByRole('button', { name: 'more' })).toBeInTheDocument();
+ });
+});
+
+describe('PhoneAppHeader', () => {
+ it('gives a screen without a header a title and the cluster', () => {
+ render( );
+ expect(screen.getByRole('heading', { name: 'Kanban' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'more' })).toBeInTheDocument();
+ });
+});
+
+describe('apps use it', () => {
+ it('puts the cluster in the Contacts list header on a phone', () => {
+ render(
+ ()}
+ onToggleSelection={vi.fn()}
+ onSelectRangeContacts={vi.fn()}
+ onSelectAll={vi.fn()}
+ onClearSelection={vi.fn()}
+ onBulkDelete={vi.fn()}
+ onBulkAddToGroup={vi.fn()}
+ onBulkExport={vi.fn()}
+ onEditContact={vi.fn()}
+ onDeleteContact={vi.fn()}
+ onAddContactToGroup={vi.fn()}
+ />
+ );
+ expect(screen.getByRole('button', { name: 'more' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'switch_account' })).toBeInTheDocument();
+ });
+});
diff --git a/components/layout/__tests__/rtl-icon-flips.test.ts b/components/layout/__tests__/rtl-icon-flips.test.ts
new file mode 100644
index 000000000..b9edb4a77
--- /dev/null
+++ b/components/layout/__tests__/rtl-icon-flips.test.ts
@@ -0,0 +1,42 @@
+import { readdirSync, readFileSync, statSync } from 'node:fs';
+import { join } from 'node:path';
+import { describe, expect, it } from 'vitest';
+
+// globals.css already mirrors chevron and arrow icons under [dir=rtl]. An
+// extra rtl:rotate-180 or rtl:-scale-x-100 on top cancels that out, and the
+// icon points the left-to-right way again (R11). Checked in the shell's own
+// folders.
+
+const ROOTS = [
+ 'components/layout',
+ 'components/plugins',
+ 'components/tour',
+ 'components/notifications',
+ 'components/command-palette',
+ 'components/global-search',
+ 'components/auth',
+ 'components/error',
+ 'app/(main)/[locale]/login',
+];
+
+function sources(dir: string): string[] {
+ return readdirSync(dir).flatMap((name) => {
+ const path = join(dir, name);
+ if (name === '__tests__' || name === 'node_modules') return [];
+ if (statSync(path).isDirectory()) return sources(path);
+ return /\.tsx?$/.test(name) ? [path] : [];
+ });
+}
+
+describe('directional icons in right-to-left languages', () => {
+ it('rely on the global mirror only', () => {
+ const offenders = ROOTS.flatMap(sources).flatMap((file) =>
+ readFileSync(file, 'utf8')
+ .split('\n')
+ .map((line, i) => ({ line, at: `${file}:${i + 1}` }))
+ .filter(({ line }) => /<(Chevron|Arrow)\w*[^>]*\brtl:(rotate-180|-scale-x-100)/.test(line))
+ .map(({ at }) => at),
+ );
+ expect(offenders).toEqual([]);
+ });
+});
diff --git a/components/layout/__tests__/shell-strings.test.tsx b/components/layout/__tests__/shell-strings.test.tsx
new file mode 100644
index 000000000..43d921dc7
--- /dev/null
+++ b/components/layout/__tests__/shell-strings.test.tsx
@@ -0,0 +1,55 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+// Shell labels come from the translations (keys in tests), and the admin
+// entry says that an update is waiting instead of a dot nobody hears.
+
+vi.mock('../account-switcher', () => ({ AccountSwitcher: () => null }));
+vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
+vi.mock('@/components/keyboard-shortcuts-modal', () => ({ KeyboardShortcutsModal: () => null }));
+vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null }));
+vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) }));
+vi.mock('@/hooks/use-stalwart-admin', () => ({ useStalwartAdmin: () => true }));
+vi.mock('@/i18n/navigation', () => ({
+ useRouter: () => ({ push: vi.fn(), back: vi.fn(), replace: vi.fn() }),
+ usePathname: () => '/',
+ Link: ({ children, href, ...rest }: { children: React.ReactNode; href: string }) => {children} ,
+}));
+
+const { NavigationRail } = await import('../navigation-rail');
+const { MvTopBar } = await import('../mv-top-bar');
+const { ResizeHandle } = await import('../resize-handle');
+const { InlineAppView } = await import('../inline-app-view');
+const { useUpdateStore } = await import('@/stores/update-store');
+
+beforeEach(() => {
+ useUpdateStore.setState({
+ startPolling: vi.fn(),
+ status: { updateAvailable: true, severity: 'security', current: process.env.NEXT_PUBLIC_APP_VERSION ?? '', latest: '9.9.9' },
+ } as never);
+});
+
+describe('shell labels', () => {
+ it('names the admin entry with the waiting update, in the rail, the tab bar and Mountain View', () => {
+ const { unmount } = render( );
+ expect(screen.getByRole('link', { name: 'admin, update_important' })).toBeInTheDocument();
+ unmount();
+ const bar = render( );
+ expect(screen.getByRole('link', { name: /update_important/ })).toBeInTheDocument();
+ bar.unmount();
+ render( );
+ expect(screen.getByRole('link', { name: 'admin, update_important' })).toBeInTheDocument();
+ });
+
+ it('translates the resize handle and the inline app close button', () => {
+ render(
+ <>
+ {}} />
+ {}} />
+ >
+ );
+ expect(screen.getByRole('separator', { name: 'resize' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'close' })).toBeInTheDocument();
+ });
+});
diff --git a/components/layout/__tests__/sidebar-apps-modal.test.tsx b/components/layout/__tests__/sidebar-apps-modal.test.tsx
new file mode 100644
index 000000000..b39e09c6d
--- /dev/null
+++ b/components/layout/__tests__/sidebar-apps-modal.test.tsx
@@ -0,0 +1,40 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+
+// The "Apps" modal on the shared dialog shell: a named close button, a
+// backdrop that closes it, Escape from anywhere inside, named row buttons.
+
+vi.mock('@/hooks/use-resolved-sidebar-apps', () => ({ useManagedSidebarApps: () => [] }));
+
+const { SidebarAppsModal } = await import('../sidebar-apps-modal');
+const { useSettingsStore } = await import('@/stores/settings-store');
+
+describe('apps modal', () => {
+ it('closes from its named X and from the backdrop', () => {
+ const onClose = vi.fn();
+ render( );
+ fireEvent.click(screen.getByRole('button', { name: 'close' }));
+ expect(onClose).toHaveBeenCalledTimes(1);
+
+ const backdrop = screen.getByRole('dialog').parentElement!;
+ fireEvent.mouseDown(backdrop);
+ fireEvent.click(backdrop);
+ expect(onClose).toHaveBeenCalledTimes(2);
+ });
+
+ it('closes on Escape after a click on its heading', () => {
+ const onClose = vi.fn();
+ render( );
+ const heading = screen.getByRole('heading', { name: 'modal_title' });
+ fireEvent.mouseDown(heading);
+ fireEvent.keyDown(heading, { key: 'Escape' });
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ it('names the edit and delete buttons of each app', () => {
+ useSettingsStore.setState({ sidebarApps: [{ id: 'a', name: 'Wiki', url: 'https://wiki.example', icon: 'Globe', openMode: 'tab' }] } as never);
+ render( );
+ expect(screen.getByRole('button', { name: 'edit_app' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'delete' })).toBeInTheDocument();
+ });
+});
diff --git a/components/layout/__tests__/sidebar-vacation-banner.test.tsx b/components/layout/__tests__/sidebar-vacation-banner.test.tsx
new file mode 100644
index 000000000..1d2c8ff25
--- /dev/null
+++ b/components/layout/__tests__/sidebar-vacation-banner.test.tsx
@@ -0,0 +1,37 @@
+import React from 'react';
+import { fireEvent, render, screen } from '@testing-library/react';
+import { describe, it, expect, vi } from 'vitest';
+
+// "Vacation responder is active" opens Settings > Out of Office, not
+// whichever settings page was open last.
+
+const nav = vi.hoisted(() => ({ push: vi.fn() }));
+vi.mock('@/i18n/navigation', () => ({
+ useRouter: () => ({ push: nav.push, back: vi.fn(), replace: vi.fn() }),
+ usePathname: () => '/',
+ Link: ({ children }: { children: React.ReactNode }) => children,
+}));
+vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
+vi.mock('@/components/tour/tour-provider', () => ({
+ useTour: () => ({ startTour: vi.fn(), resetTourCompletion: vi.fn(), activeTour: null, endTour: vi.fn(), registerStep: vi.fn() }),
+}));
+vi.mock('@/contexts/drag-drop-context', () => ({ useDragDropContext: () => ({ isDragging: false }) }));
+vi.mock('@/hooks/use-mailbox-drop', () => ({
+ useMailboxDrop: () => ({ dropHandlers: {}, isValidDropTarget: false, isInvalidDropTarget: false }),
+}));
+vi.mock('@/hooks/use-tag-drop', () => ({ useTagDrop: () => ({ dropHandlers: {}, isValidDropTarget: false }) }));
+vi.mock('../account-switcher', () => ({ AccountSwitcher: () => null }));
+
+const { Sidebar } = await import('../sidebar');
+const { useVacationStore } = await import('@/stores/vacation-store');
+const { buildSettingsPath } = await import('@/lib/deep-links');
+
+describe('vacation banner', () => {
+ it('opens the Out of Office settings', () => {
+ useVacationStore.setState({ isEnabled: true, isSupported: true } as never);
+ render( );
+ fireEvent.click(screen.getByRole('button', { name: /vacation_active/ }));
+ expect(nav.push).toHaveBeenCalledWith(buildSettingsPath('vacation'));
+ expect(buildSettingsPath('vacation')).not.toBe('/settings');
+ });
+});
diff --git a/components/layout/__tests__/touch-targets.test.tsx b/components/layout/__tests__/touch-targets.test.tsx
new file mode 100644
index 000000000..3c05903e7
--- /dev/null
+++ b/components/layout/__tests__/touch-targets.test.tsx
@@ -0,0 +1,66 @@
+import { render, screen, fireEvent } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+
+// Phone touch targets in the shell: every control gets the 44px hit area
+// Buttons have on a touch screen (R1).
+
+vi.mock('@/hooks/use-media-query', async (importOriginal) => ({
+ ...(await importOriginal()),
+ useDeviceDetection: () => ({ isMobile: true, isTablet: false, isDesktop: false }),
+}));
+vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ startTour: vi.fn() }) }));
+vi.mock('@/i18n/navigation', () => ({ useRouter: () => ({ push: vi.fn() }), usePathname: () => '/' }));
+
+const { NotificationPanel } = await import('@/components/notifications/notification-center');
+const { WelcomeBanner } = await import('@/components/ui/welcome-banner');
+const { ToastItem } = await import('@/components/ui/toast');
+const { AccountSwitcher } = await import('../account-switcher');
+const { useSettingsStore } = await import('@/stores/settings-store');
+const { useAccountStore } = await import('@/stores/account-store');
+const { useAuthStore } = await import('@/stores/auth-store');
+
+const HIT = 'pointer-coarse:after:min-h-11';
+
+describe('touch targets', () => {
+ it('notification sheet header buttons', () => {
+ render( );
+ for (const name of ['mark_all_read', 'clear', 'close']) {
+ expect(screen.getByRole('button', { name }).className).toContain(HIT);
+ }
+ });
+
+ it('welcome banner buttons', () => {
+ useSettingsStore.setState({ onboardingCompleted: false });
+ render( );
+ for (const name of ['dismiss', 'start_tour', 'settings', 'got_it']) {
+ expect(screen.getByRole('button', { name: new RegExp(name) }).className).toContain(HIT);
+ }
+ });
+
+ it('toast action and close', () => {
+ render(
+
+ );
+ expect(screen.getByRole('button', { name: 'Undo' }).className).toContain(HIT);
+ expect(screen.getByRole('button', { name: 'close' }).className).toContain(HIT);
+ });
+
+ it('account avatar and popover rows', () => {
+ useAccountStore.setState({ accounts: [
+ { id: 'a', label: 'a', email: 'a@x', username: 'a@x', serverUrl: 'https://x', isConnected: true, isDefault: true, cookieSlot: 0 },
+ { id: 'b', label: 'b', email: 'b@x', username: 'b@x', serverUrl: 'https://x', isConnected: true, isDefault: false, cookieSlot: 1 },
+ ] } as never);
+ useAuthStore.setState({ activeAccountId: 'b' } as never);
+ useSettingsStore.setState({ interfaceLayout: 'default' } as never);
+ render( );
+ const avatar = screen.getByTestId('account-switcher');
+ expect(avatar.className).toContain('pointer-coarse:h-11');
+ fireEvent.click(avatar);
+ for (const name of ['add_account', 'set_as_default', /sign_out_of/, 'sign_out_all']) {
+ expect(screen.getByRole('menuitem', { name }).className).toContain('pointer-coarse:min-h-11');
+ }
+ });
+});
diff --git a/components/layout/account-switcher.tsx b/components/layout/account-switcher.tsx
index 7fd7da489..3ca375d16 100644
--- a/components/layout/account-switcher.tsx
+++ b/components/layout/account-switcher.tsx
@@ -16,6 +16,9 @@ import { cn, formatFileSize } from "@/lib/utils";
import { useRouter } from "@/i18n/navigation";
import { Avatar } from "@/components/ui/avatar";
import { useMenuNavigation } from "@/hooks/use-menu-navigation";
+import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
+import { ConfirmDialog } from "@/components/ui/confirm-dialog";
+import { IconButton } from "@/components/ui/icon-button";
import { toUnicodeDomain, toUnicodeEmail } from "@/lib/idn";
interface AccountSwitcherProps {
@@ -103,6 +106,7 @@ export function AccountSwitcher({
const logout = useAuthStore((s) => s.logout);
const removeAccount = useAuthStore((s) => s.removeAccount);
const logoutAll = useAuthStore((s) => s.logoutAll);
+ const { dialogProps: confirmDialogProps, confirm } = useConfirmDialog();
const updatePosition = useCallback(() => {
if (!buttonRef.current) return;
@@ -190,11 +194,18 @@ export function AccountSwitcher({
router.push(`/login?mode=add-account` as never);
};
- const handleRemove = (e: React.MouseEvent, account: AccountEntry) => {
+ const handleRemove = async (e: React.MouseEvent, account: AccountEntry) => {
e.stopPropagation();
const label = toUnicodeEmail(account.email || account.username);
- if (!window.confirm(t("remove_account_confirm", { account: label }))) return;
- removeAccount(account.id);
+ // The popover closes first: the dialog is the only thing asking now.
+ setOpen(false);
+ const ok = await confirm({
+ title: t("remove_account_title"),
+ message: t("remove_account_confirm", { account: label }),
+ confirmText: t("remove_account"),
+ variant: "destructive",
+ });
+ if (ok) removeAccount(account.id);
};
const handleLogout = () => {
@@ -273,7 +284,7 @@ export function AccountSwitcher({
"flex items-center gap-2 transition-colors",
mvPopover ? "rounded-full" : "rounded-md",
variant !== "expanded"
- ? "justify-center w-10 h-10 hover:bg-muted"
+ ? "justify-center w-10 h-10 pointer-coarse:w-11 pointer-coarse:h-11 hover:bg-muted"
: "w-full px-2 py-1.5 hover:bg-muted text-start min-w-0",
className
)}
@@ -403,7 +414,7 @@ export function AccountSwitcher({
className={cn(
"w-full flex items-start gap-3 px-3 py-2.5 text-start transition-colors",
isActive ? "bg-accent/50" : "hover:bg-muted",
- (!isActive && !account.isDefault) ? (isDraggable ? "pe-14" : "pe-8") : (isDraggable && "pe-7")
+ (!isActive && !account.isDefault) ? (isDraggable ? "pe-16" : "pe-10") : (isDraggable && "pe-7")
)}
role="menuitem"
disabled={isActive}
@@ -411,7 +422,7 @@ export function AccountSwitcher({
{isActive && (
-
+
)}
@@ -449,22 +460,24 @@ export function AccountSwitcher({
{isDraggable && (
)}
{!isActive && !account.isDefault && (
-
handleRemove(e, account)}
+ // Shown on hover and focus, and always on a touch screen (R7).
+ void handleRemove(e, account)}
role="menuitem"
- aria-label={t("remove_account")}
- title={t("remove_account")}
- className="absolute end-1.5 top-1/2 -translate-y-1/2 p-1 rounded-md text-muted-foreground/60 opacity-0 transition-opacity group-hover/acct:opacity-100 hover:bg-destructive/10 hover:text-destructive focus:opacity-100 focus:outline-none focus:ring-1 focus:ring-destructive"
+ label={t("remove_account")}
+ className="absolute end-1.5 top-1/2 -translate-y-1/2 text-muted-foreground opacity-0 transition-opacity group-hover/acct:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100 hover:bg-destructive/10 hover:text-destructive"
>
-
-
+
+
)}
);
@@ -544,7 +557,7 @@ export function AccountSwitcher({
@@ -559,7 +572,7 @@ export function AccountSwitcher({
{activeAccount && !activeAccount.isDefault && accounts.length > 1 && (
handleSetDefault(activeAccount.id)}
- className={cn("w-full flex items-center gap-2 py-2 text-sm text-foreground hover:bg-muted transition-colors", mvPopover ? "px-4" : "px-3")}
+ className={cn("w-full flex items-center gap-2 py-2 pointer-coarse:min-h-11 text-sm text-start text-foreground hover:bg-muted transition-colors", mvPopover ? "px-4" : "px-3")}
role="menuitem"
>
@@ -568,7 +581,7 @@ export function AccountSwitcher({
)}
@@ -577,7 +590,7 @@ export function AccountSwitcher({
{!mvPopover && accounts.length > 1 && (
@@ -588,6 +601,7 @@ export function AccountSwitcher({
,
document.body
)}
+
>
);
}
diff --git a/components/layout/inline-app-view.tsx b/components/layout/inline-app-view.tsx
index 368d49c55..c74296236 100644
--- a/components/layout/inline-app-view.tsx
+++ b/components/layout/inline-app-view.tsx
@@ -1,6 +1,8 @@
'use client';
+import { useTranslations } from 'next-intl';
import { X } from '@/components/icons';
+import { IconButton } from '@/components/ui/icon-button';
import { cn } from '@/lib/utils';
import type { InlineAppState } from '@/hooks/use-sidebar-apps';
@@ -12,6 +14,7 @@ interface InlineAppViewProps {
}
export function InlineAppView({ apps, activeAppId, onClose, className }: InlineAppViewProps) {
+ const tCommon = useTranslations('common');
const activeApp = apps.find((a) => a.id === activeAppId);
return (
@@ -19,13 +22,9 @@ export function InlineAppView({ apps, activeAppId, onClose, className }: InlineA
{/* Header bar */}
{activeApp?.name}
-
-
-
+
+
+
{/* Iframes - active one visible, rest hidden but alive */}
diff --git a/components/layout/mobile-header.tsx b/components/layout/mobile-header.tsx
index 654c47625..bc0a1a33a 100644
--- a/components/layout/mobile-header.tsx
+++ b/components/layout/mobile-header.tsx
@@ -1,8 +1,8 @@
"use client";
-import { useCallback, useEffect, useRef, useState } from "react";
-import { Menu, ArrowLeft, Plus, Search, X, MoreVertical, Terminal, Bell } from "@/components/icons";
+import { Menu, ArrowLeft, Plus, Search, X } from "@/components/icons";
import { Button } from "@/components/ui/button";
+import { IconButton } from "@/components/ui/icon-button";
import { useUIStore } from "@/stores/ui-store";
import { useShallow } from "zustand/react/shallow";
import { useSettingsStore } from "@/stores/settings-store";
@@ -10,84 +10,7 @@ import { useIsDesktop } from "@/hooks/use-media-query";
import { cn } from "@/lib/utils";
import { useTranslations } from "next-intl";
import { AccountSwitcher } from "@/components/layout/account-switcher";
-import { PluginActions } from "@/components/plugins/plugin-actions";
-import { useMenuNavigation } from "@/hooks/use-menu-navigation";
-import { ContextMenuItem } from "@/components/ui/context-menu";
-import { openCommandPalette } from "@/lib/app-commands";
-import { NotificationSheet, formatBadgeCount, useUnreadNotificationCount } from "@/components/notifications/notification-center";
-
-/** Plugin buttons in the top bar are not about any one item. */
-const TOP_BAR_CONTEXT: Record
= {};
-
-/**
- * The phone header's overflow menu: the notifications (the desktop bell), the
- * command palette (no Ctrl+K on a phone) and what the desktop top bar shows
- * as buttons (plugin top-bar-actions) and has no room for here.
- */
-function MobileHeaderMenu() {
- const t = useTranslations('top_bar');
- const tPalette = useTranslations('command_palette');
- const tNotifications = useTranslations('notification_center');
- const unread = useUnreadNotificationCount();
- const [showNotifications, setShowNotifications] = useState(false);
- const [open, setOpen] = useState(false);
- const triggerRef = useRef(null);
- const close = useCallback(() => setOpen(false), []);
- const { menuRef, onKeyDown } = useMenuNavigation({ open, onClose: close, triggerRef });
-
- useEffect(() => {
- if (!open) return;
- const onPointerDown = (e: MouseEvent) => {
- if (triggerRef.current?.contains(e.target as Node) || menuRef.current?.contains(e.target as Node)) return;
- setOpen(false);
- };
- document.addEventListener('mousedown', onPointerDown);
- return () => document.removeEventListener('mousedown', onPointerDown);
- }, [open, menuRef]);
-
- return (
-
-
setOpen((v) => !v)}
- className={cn("relative h-11 w-11", open && "bg-accent")}
- aria-label={unread > 0 ? `${t('more')} (${tNotifications('unread_count', { count: unread })})` : t('more')}
- aria-haspopup="menu"
- aria-expanded={open}
- >
-
- {unread > 0 && (
-
- )}
-
- {open && (
-
-
0 ? formatBadgeCount(unread) : undefined}
- onClick={() => { close(); setShowNotifications(true); }}
- />
- { close(); openCommandPalette(); }}
- />
-
-
- )}
- {showNotifications &&
setShowNotifications(false)} />}
-
- );
-}
+import { PhoneHeaderMenu } from "@/components/layout/phone-header-actions";
interface MobileHeaderProps {
title: string;
@@ -209,14 +132,13 @@ export function MobileHeader({
{searchPlaceholder || title}
{searchActive && onClearSearch && (
-
-
-
+
+
)}
)}
@@ -246,7 +168,7 @@ export function MobileHeader({
)}
-
+
{/* Which account you are reading is otherwise only visible after
opening the drawer. Last in this group so it sits on the far
diff --git a/components/layout/mv-top-bar.tsx b/components/layout/mv-top-bar.tsx
index 0a666d1cc..9694e6763 100644
--- a/components/layout/mv-top-bar.tsx
+++ b/components/layout/mv-top-bar.tsx
@@ -6,7 +6,7 @@ import {
Folder,
Grid3x3,
Users,
- HelpCircle,
+ Keyboard,
Mail,
Menu,
Puzzle,
@@ -17,7 +17,7 @@ import {
} from "@/components/icons";
import type { AppIcon as AppIconType } from "@/components/icons";
import { useTranslations } from "next-intl";
-import { Link, usePathname } from "@/i18n/navigation";
+import { Link, usePathname, useRouter } from "@/i18n/navigation";
import { useAuthStore } from "@/stores/auth-store";
import { useShallow } from "zustand/react/shallow";
import { useCalendarStore } from "@/stores/calendar-store";
@@ -31,6 +31,9 @@ import { getPathPrefix, withBasePath } from "@/lib/browser-navigation";
import { useStalwartAdmin } from "@/hooks/use-stalwart-admin";
import { useUpdateStore, selectHasUpdate } from "@/stores/update-store";
import { cn } from "@/lib/utils";
+import { openKeyboardShortcuts, parkAppCommand, requestAppCommand } from "@/lib/app-commands";
+import { useSettingsStore } from "@/stores/settings-store";
+import { useAccountStore } from "@/stores/account-store";
import { AccountSwitcher } from "@/components/layout/account-switcher";
import { SearchBox, type ContactSearchField } from "@/components/search/search-box";
import { PluginActions } from "@/components/plugins/plugin-actions";
@@ -76,11 +79,23 @@ interface MvTopBarProps {
onInlineApp?: (appId: string, url: string, name: string) => void;
onCloseInlineApp?: () => void;
activeAppId?: string | null;
- /** Opens the merged across-accounts inbox from the account popover. */
+ /**
+ * Opens the merged across-accounts inbox from the account popover. Outside
+ * mail the bar opens mail on that inbox itself, when it can be built.
+ */
onSelectAllInboxes?: () => void;
allInboxesSelected?: boolean;
}
+/** Whether the merged inbox can be built: the terms the mail sidebar's unified section uses. */
+function useAllInboxesReachable(): boolean {
+ const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox);
+ const unifiedCrossAccount = useSettingsStore((s) => s.unifiedCrossAccount);
+ const gate = usePolicyStore((s) => s.isFeatureEnabled("unifiedCrossAccountEnabled"));
+ const connected = useAccountStore((s) => s.accounts.filter((a) => a.isConnected).length);
+ return enableUnifiedMailbox && unifiedCrossAccount && gate && connected > 1;
+}
+
/**
* Mountain View's global header: one 64px row that owns the hamburger, the
* brand, the search field and the account cluster. It only renders on
@@ -102,7 +117,20 @@ export function MvTopBar({
}: MvTopBarProps) {
const t = useTranslations("sidebar");
const tSearch = useTranslations("advanced_search");
+ const tShell = useTranslations("shell");
const pathname = usePathname();
+ const router = useRouter();
+ const allInboxesReachable = useAllInboxesReachable();
+ // Every app gets the same cluster: shortcuts and "All inboxes" work from
+ // Calendar, Contacts, Files and Settings too, not only from mail.
+ const showShortcuts = onShowShortcuts ?? openKeyboardShortcuts;
+ const selectAllInboxes = onSelectAllInboxes ?? (allInboxesReachable
+ ? () => {
+ if (requestAppCommand("all-inboxes")) return;
+ parkAppCommand("all-inboxes");
+ router.push("/");
+ }
+ : undefined);
const { appName, appLogoLightUrl, appLogoDarkUrl } = useConfig();
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const toggleSidebarCollapsed = useUIStore((s) => s.toggleSidebarCollapsed);
@@ -246,18 +274,15 @@ export function MvTopBar({
- {onShowShortcuts && (
-
- {/* Gmail's help button, which is where it keeps the shortcuts too. */}
-
-
- )}
+
+
+
{/* Admin (Stalwart admins), as in the navigation rail this bar replaces.
A hard navigation because /admin lives outside the [locale] tree. */}
{isStalwartAdmin && (
@@ -265,7 +290,7 @@ export function MvTopBar({
href={`${getPathPrefix()}/admin`}
className="relative grid place-items-center w-12 h-12 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
title={t("admin")}
- aria-label={t("admin")}
+ aria-label={hasUpdate ? `${t("admin")}, ${tShell(updateImportant ? "update_important" : "update_available")}` : t("admin")}
>
{hasUpdate && (
@@ -274,7 +299,7 @@ export function MvTopBar({
"absolute top-3 end-3 w-2 h-2 rounded-full ring-2 ring-background",
updateImportant ? "bg-red-500" : "bg-amber-500",
)}
- aria-label={updateImportant ? "Important update available" : "Update available"}
+ aria-hidden="true"
/>
)}
@@ -407,7 +432,7 @@ export function MvTopBar({
diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx
index 55abf84b6..c3c0af96b 100644
--- a/components/layout/navigation-rail.tsx
+++ b/components/layout/navigation-rail.tsx
@@ -120,8 +120,17 @@ function StorageQuotaCircle({ quota, usagePercent }: { quota: StorageQuota; usag
) return;
setOpen(false);
};
+ const handleEscape = (e: KeyboardEvent) => {
+ if (e.key !== "Escape") return;
+ setOpen(false);
+ buttonRef.current?.focus();
+ };
document.addEventListener("mousedown", handleClick);
- return () => document.removeEventListener("mousedown", handleClick);
+ document.addEventListener("keydown", handleEscape);
+ return () => {
+ document.removeEventListener("mousedown", handleClick);
+ document.removeEventListener("keydown", handleEscape);
+ };
}, [open]);
// Usage can legitimately exceed the quota (e.g. limit lowered after the fact)
@@ -140,6 +149,7 @@ function StorageQuotaCircle({ quota, usagePercent }: { quota: StorageQuota; usag
onClick={() => setOpen(!open)}
className="relative w-8 h-8 flex items-center justify-center rounded-full hover:bg-muted transition-colors cursor-pointer"
aria-label={t("storage")}
+ aria-expanded={open}
>
@@ -243,6 +253,9 @@ export function NavigationRail({
const startUpdatePolling = useUpdateStore((s) => s.startPolling);
useEffect(() => { startUpdatePolling(); }, [startUpdatePolling]);
const updateImportant = updateSeverity === 'security' || updateSeverity === 'deprecated';
+ const tShell = useTranslations("shell");
+ // Said with the admin entry; its dot alone is not announced.
+ const updateLabel = hasUpdate ? tShell(updateImportant ? "update_important" : "update_available") : null;
// Account list for rail
const accounts = useAccountStore((s) => s.accounts);
@@ -371,6 +384,7 @@ export function NavigationRail({
key={item.id}
href={item.href}
onClick={handleNavClick(item.id as 'mail' | 'calendar' | 'contacts' | 'files' | 'settings')}
+ data-tour={`nav-${item.id}`}
className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
"transition-colors duration-150",
@@ -386,7 +400,7 @@ export function NavigationRail({
)}>
{item.badge != null && item.badge > 0 && (
-
+
{item.badge > 99 ? "99+" : item.badge}
)}
@@ -487,14 +501,15 @@ export function NavigationRail({
{hasUpdate && (
)}
{t("admin") || "Admin"}
+ {updateLabel && {`, ${updateLabel}`} }
)}
@@ -601,7 +616,7 @@ export function NavigationRail({
{item.badge != null && item.badge > 0 && (
{item.badge > 99 ? "99+" : item.badge}
@@ -718,16 +733,17 @@ export function NavigationRail({
adminActive ? "bg-primary/10 text-primary" : "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
title={t("admin") || "Admin"}
+ aria-label={updateLabel ? `${t("admin") || "Admin"}, ${updateLabel}` : undefined}
aria-current={adminActive ? "page" : undefined}
>
{hasUpdate && (
)}
@@ -816,7 +832,7 @@ export function NavigationRail({
fallbackColor={account.avatarColor}
/>
{isActive && (
-
+
)}
@@ -868,7 +884,7 @@ export function NavigationRail({
{accounts.length > 1 && (
{ setLogoutMenuOpen(false); logoutAll(); }}
- className="w-full flex items-center gap-2 px-3 py-2.5 text-sm text-destructive hover:bg-muted transition-colors"
+ className="w-full flex items-center gap-2 px-3 py-2.5 text-sm text-start text-foreground hover:bg-muted transition-colors"
role="menuitem"
>
diff --git a/components/layout/phone-header-actions.tsx b/components/layout/phone-header-actions.tsx
new file mode 100644
index 000000000..fee8315dc
--- /dev/null
+++ b/components/layout/phone-header-actions.tsx
@@ -0,0 +1,174 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
+import { useTranslations } from "next-intl";
+import { MoreVertical, Terminal, Bell } from "@/components/icons";
+import { IconButton } from "@/components/ui/icon-button";
+import { ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
+import { AccountSwitcher } from "@/components/layout/account-switcher";
+import { PluginActions } from "@/components/plugins/plugin-actions";
+import { useMenuNavigation } from "@/hooks/use-menu-navigation";
+import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query";
+import { openCommandPalette } from "@/lib/app-commands";
+import { cn } from "@/lib/utils";
+import {
+ NotificationSheet,
+ formatBadgeCount,
+ useUnreadNotificationCount,
+} from "@/components/notifications/notification-center";
+
+/** Plugin buttons in the top bar are not about any one item. */
+const TOP_BAR_CONTEXT: Record = {};
+
+export interface PhoneHeaderMenuProps {
+ /**
+ * The app's own entries, listed above the shell's. They get the function
+ * that closes the menu, to call when one is picked.
+ */
+ menuItems?: (close: () => void) => ReactNode;
+}
+
+/**
+ * The phone header's overflow menu: the app's own entries if it has any, the
+ * notifications (the desktop bell), the command palette (no Ctrl+K on a
+ * phone) and what the desktop top bar shows as buttons (plugin
+ * top-bar-actions) and has no room for here.
+ */
+export function PhoneHeaderMenu({ menuItems }: PhoneHeaderMenuProps) {
+ const t = useTranslations("top_bar");
+ const tPalette = useTranslations("command_palette");
+ const tNotifications = useTranslations("notification_center");
+ const unread = useUnreadNotificationCount();
+ const [showNotifications, setShowNotifications] = useState(false);
+ const [open, setOpen] = useState(false);
+ const triggerRef = useRef(null);
+ const close = useCallback(() => setOpen(false), []);
+ const { menuRef, onKeyDown } = useMenuNavigation({ open, onClose: close, triggerRef });
+
+ useEffect(() => {
+ if (!open) return;
+ const onPointerDown = (e: MouseEvent) => {
+ if (triggerRef.current?.contains(e.target as Node) || menuRef.current?.contains(e.target as Node)) return;
+ setOpen(false);
+ };
+ document.addEventListener("mousedown", onPointerDown);
+ return () => document.removeEventListener("mousedown", onPointerDown);
+ }, [open, menuRef]);
+
+ const appItems = menuItems?.(close);
+
+ return (
+
+
setOpen((v) => !v)}
+ className={cn(open && "bg-accent")}
+ label={unread > 0 ? `${t("more")} (${tNotifications("unread_count", { count: unread })})` : t("more")}
+ title=""
+ aria-haspopup="menu"
+ aria-expanded={open}
+ >
+
+ {unread > 0 && (
+
+ )}
+
+ {open && (
+
+ {appItems && (
+ <>
+ {appItems}
+
+ >
+ )}
+
0 ? formatBadgeCount(unread) : undefined}
+ onClick={() => { close(); setShowNotifications(true); }}
+ />
+ { close(); openCommandPalette(); }}
+ />
+
+
+ )}
+ {showNotifications &&
setShowNotifications(false)} />}
+
+ );
+}
+
+export interface PhoneHeaderActionsProps extends PhoneHeaderMenuProps {
+ /**
+ * Below which width the app shows the phone shell (bottom tab bar, no
+ * rail): "md" for apps that keep the rail on a tablet, "lg" for those that
+ * use the phone shell up to the desktop. Above it nothing is drawn, as the
+ * rail or the Mountain View top bar holds these controls there.
+ */
+ below?: "md" | "lg";
+ className?: string;
+ /** Mountain View: opens the merged across-accounts inbox from the avatar's popover. */
+ onSelectAllInboxes?: () => void;
+ allInboxesSelected?: boolean;
+}
+
+/**
+ * The cluster at the end of every app's phone header (answer 12 of the
+ * 2026-10-27 usability test): a ⋮ with notifications and the command palette,
+ * then the account avatar. Drop it in as the last child of the header row.
+ */
+export function PhoneHeaderActions({
+ below = "lg",
+ className,
+ menuItems,
+ onSelectAllInboxes,
+ allInboxesSelected = false,
+}: PhoneHeaderActionsProps) {
+ const isMobile = useIsMobile();
+ const isDesktop = useIsDesktop();
+ const shown = below === "md" ? isMobile : !isDesktop;
+ if (!shown) return null;
+
+ return (
+
+
+ {/* Which account you are in is otherwise only visible in Settings.
+ Last, so it sits on the far edge, mirrored under RTL by the flex row. */}
+
+
+ );
+}
+
+export interface PhoneAppHeaderProps extends PhoneHeaderActionsProps {
+ title: string;
+ /** Controls drawn before the title, such as a back or menu button. */
+ leading?: ReactNode;
+}
+
+/** A whole phone header for a screen that has none of its own: title, then the shared cluster. */
+export function PhoneAppHeader({ title, leading, ...actions }: PhoneAppHeaderProps) {
+ const isMobile = useIsMobile();
+ const isDesktop = useIsDesktop();
+ const shown = (actions.below ?? "lg") === "md" ? isMobile : !isDesktop;
+ if (!shown) return null;
+ return (
+
+ {leading}
+ {title}
+
+
+ );
+}
diff --git a/components/layout/resize-handle.tsx b/components/layout/resize-handle.tsx
index 81915c038..33e853d90 100644
--- a/components/layout/resize-handle.tsx
+++ b/components/layout/resize-handle.tsx
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
+import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils";
interface ResizeHandleProps {
@@ -17,6 +18,7 @@ interface ResizeHandleProps {
const KEYBOARD_STEP = 10;
export function ResizeHandle({ onResizeStart, onResize, onResizeEnd, onDoubleClick, orientation = "vertical", className, testId }: ResizeHandleProps) {
+ const t = useTranslations("shell");
const handleRef = useRef(null);
// Id of the pointer that started the drag, or null when idle. Only that
// pointer may move or end the drag, so a second finger or button can't
@@ -120,7 +122,7 @@ export function ResizeHandle({ onResizeStart, onResize, onResizeEnd, onDoubleCli
ref={handleRef}
role="separator"
aria-orientation={isHorizontal ? "horizontal" : "vertical"}
- aria-label="Resize"
+ aria-label={t("resize")}
data-testid={testId}
tabIndex={0}
onPointerDown={handlePointerDown}
diff --git a/components/layout/sidebar-apps-modal.tsx b/components/layout/sidebar-apps-modal.tsx
index cd1c0b0fa..a9d8e7043 100644
--- a/components/layout/sidebar-apps-modal.tsx
+++ b/components/layout/sidebar-apps-modal.tsx
@@ -2,15 +2,16 @@
import { useState, useCallback } from 'react';
import { useTranslations } from 'next-intl';
-import { X, Plus, Pencil, Trash2, ExternalLink, PanelRight, Lock } from '@/components/icons';
+import { Plus, Pencil, Trash2, ExternalLink, PanelRight, Lock } from '@/components/icons';
import { iconForName } from '@/components/icons';
import { cn, generateUUID } from '@/lib/utils';
import { Button } from '@/components/ui/button';
+import { IconButton } from '@/components/ui/icon-button';
import { Input } from '@/components/ui/input';
import { IconPicker } from './icon-picker';
import { useSettingsStore, type SidebarApp } from '@/stores/settings-store';
import { useManagedSidebarApps } from '@/hooks/use-resolved-sidebar-apps';
-import { useFocusTrap } from '@/hooks/use-focus-trap';
+import { Dialog } from '@/components/ui/dialog';
import { useConfirmDialog } from '@/hooks/use-confirm-dialog';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
@@ -240,18 +241,17 @@ export function SidebarAppsModal({ isOpen, onClose }: SidebarAppsModalProps) {
const [isCreating, setIsCreating] = useState(false);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
- const modalRef = useFocusTrap({
- isActive: isOpen,
- onEscape: () => {
- if (isCreating || editingId) {
- setIsCreating(false);
- setEditingId(null);
- } else {
- onClose();
- }
- },
- restoreFocus: true,
- });
+ // Escape, the X and an outside click leave an open app form first, then
+ // the dialog; a click outside is ignored while a form is open.
+ const editing = isCreating || !!editingId;
+ const handleClose = useCallback(() => {
+ if (editing) {
+ setIsCreating(false);
+ setEditingId(null);
+ } else {
+ onClose();
+ }
+ }, [editing, onClose]);
const handleCreate = useCallback((data: SidebarAppFormData) => {
const id = `app-${generateUUID()}`;
@@ -275,36 +275,17 @@ export function SidebarAppsModal({ isOpen, onClose }: SidebarAppsModalProps) {
removeSidebarApp(app.id);
}, [removeSidebarApp, confirmDialog, t]);
- if (!isOpen) return null;
-
return (
-
-
+
- {/* Header */}
-
-
-
-
-
-
-
- {/* Content */}
-
{/* Create form */}
@@ -368,22 +349,21 @@ export function SidebarAppsModal({ isOpen, onClose }: SidebarAppsModalProps) {
{app.openMode === 'inline' ? t('inline_badge') : t('tab_badge')}
-
setEditingId(app.id)}
disabled={!!editingId || isCreating}
>
-
-
-
+
+
handleDelete(app)}
disabled={!!editingId || isCreating}
+ className="text-destructive"
>
-
-
+
+
);
@@ -397,10 +377,9 @@ export function SidebarAppsModal({ isOpen, onClose }: SidebarAppsModalProps) {
)}
-
-