diff --git a/app/(main)/[locale]/pro/__tests__/pro-gate.test.tsx b/app/(main)/[locale]/pro/__tests__/pro-gate.test.tsx index 799b73fd5..45e72f0f8 100644 --- a/app/(main)/[locale]/pro/__tests__/pro-gate.test.tsx +++ b/app/(main)/[locale]/pro/__tests__/pro-gate.test.tsx @@ -9,8 +9,8 @@ vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => (key === 'common.loading' ? 'Loading…' : key), })); -const device = vi.hoisted(() => ({ isMobile: false, isTablet: false, isDesktop: true })); -vi.mock('@/hooks/use-media-query', () => ({ useDeviceDetection: () => device, atLeast: (px: number) => `(width >= ${px}px)` })); +const device = vi.hoisted(() => ({ isMobile: false, isCompact: false, isDesktop: true })); +vi.mock('@/hooks/use-media-query', () => ({ useDeviceDetection: () => device, atLeast: (px: number) => `(width >= ${px}px)`, PHONE_BELOW: 600 })); const routerReplace = vi.hoisted(() => vi.fn()); vi.mock('@/i18n/navigation', () => ({ useRouter: () => ({ replace: routerReplace }) })); @@ -72,7 +72,7 @@ beforeEach(() => { auth.state.client = null; auth.state.isLoading = false; useSettingsStore.setState({ proInterface: true }); - Object.assign(device, { isMobile: false, isTablet: false, isDesktop: true }); + Object.assign(device, { isMobile: false, isCompact: false, isDesktop: true }); Object.defineProperty(window, 'location', { configurable: true, value: { ...window.location, replace, pathname: '/pro', search: '' }, @@ -98,13 +98,25 @@ describe('Pro page gate', () => { it('keeps the skeleton up, not a blank page, below desktop width', async () => { auth.state.isAuthenticated = true; auth.state.client = {}; - Object.assign(device, { isMobile: false, isTablet: true, isDesktop: false }); + Object.assign(device, { isMobile: true, isCompact: false, isDesktop: false }); const { container } = render(); await act(async () => {}); expect(container.querySelector('[data-boot-skeleton="pro"]')).not.toBeNull(); expect(container.querySelector('[data-testid="mail-app"]')).toBeNull(); }); + // A half-screen window or a laptop at 150 % is a compact desktop, which + // keeps the Pro shell (usability test 2026-11-04, mail-28). + it('runs on a compact desktop', async () => { + auth.state.isAuthenticated = true; + auth.state.client = {}; + Object.assign(device, { isMobile: false, isCompact: true, isDesktop: true }); + const { container } = render(); + await act(async () => {}); + expect(container.querySelector('[data-pro-shell] [data-testid="mail-app"]')).not.toBeNull(); + expect(routerReplace).not.toHaveBeenCalled(); + }); + it('marks the loaded shell so a skeleton inside a pane keeps its own frame', async () => { auth.state.isAuthenticated = true; auth.state.client = {}; @@ -126,7 +138,7 @@ describe('Pro page gate', () => { beforeEach(() => { auth.state.isAuthenticated = true; auth.state.client = {}; - Object.assign(device, { isMobile: false, isTablet: true, isDesktop: false }); + Object.assign(device, { isMobile: true, isCompact: false, isDesktop: false }); const compose = { id: 'c1', kind: 'compose' as const, labelKey: '', closeable: true, paneId: 'main' as const, composeData: { sessionId: 1, mode: 'compose' as const, title: 'x', initialData: { subject: 'Typed', mode: 'compose' } as never } }; useProTabStore.setState({ tabs: [{ id: 'home-mail', kind: 'mail', labelKey: 'mail', closeable: false, paneId: 'main' }, compose], @@ -151,6 +163,16 @@ describe('Pro page gate', () => { expect(routerReplace).not.toHaveBeenCalled(); expect(takeHandedOffComposeDraft()).toBeNull(); }); + + it('stays in a window as wide as a compact desktop (800px)', async () => { + window.matchMedia = vi.fn((query: string) => { + const px = Number(/\(width >= (\d+)px\)/.exec(query)?.[1] ?? NaN); + return { matches: 800 >= px, addEventListener: () => {}, removeEventListener: () => {} }; + }) as never; + render(); + await act(async () => {}); + expect(routerReplace).not.toHaveBeenCalled(); + }); }); it('moves a tab dropped on a surface that stops every drop', async () => { diff --git a/app/(main)/[locale]/pro/page.tsx b/app/(main)/[locale]/pro/page.tsx index 7e175c448..d6ccf7434 100644 --- a/app/(main)/[locale]/pro/page.tsx +++ b/app/(main)/[locale]/pro/page.tsx @@ -10,7 +10,7 @@ import { useSidebarApps } from "@/hooks/use-sidebar-apps"; import { useAuthStore, redirectToLogin } from "@/stores/auth-store"; import { useEmailStore } from "@/stores/email-store"; import { useSettingsStore } from "@/stores/settings-store"; -import { atLeast, useDeviceDetection } from "@/hooks/use-media-query"; +import { atLeast, PHONE_BELOW, useDeviceDetection } from "@/hooks/use-media-query"; import { EmbeddedContext } from "@/hooks/use-is-embedded"; import { OrgAnnouncement } from "@/components/org/org-announcement"; import { PaneSizeContext } from "@/hooks/use-pane-size"; @@ -49,7 +49,8 @@ import { ProComposeTabBody } from "@/components/pro/pro-compose-tab-body"; import { ProEmailTabBody } from "@/components/pro/pro-email-tab-body"; import { ProFolderTabBody } from "@/components/pro/pro-folder-tab-body"; -const DESKTOP_MIN_WIDTH = 1024; +// Pro runs wherever the desktop layout does, the compact one included. +const DESKTOP_MIN_WIDTH = PHONE_BELOW; // Each surface renders the same component the standard routes do. Deep links // reach them through the handoff ProInterfaceRedirect parks (#733), not as @@ -195,7 +196,7 @@ function Pane({ paneId, tabs, activeTabId, loadedTabIds, onPaneFocus, isFocused export default function ProHome() { const t = useTranslations(); const { label: skeletonLabel } = useSkeletonLabels(); - const { isMobile, isTablet, isDesktop } = useDeviceDetection(); + const { isMobile, isDesktop } = useDeviceDetection(); const router = useRouter(); const [initialCheckDone, setInitialCheckDone] = useState( @@ -263,7 +264,7 @@ export default function ProHome() { // switching Pro off from Settings lands back in Settings rather than // on the mail list. Read imperatively: the focused tab must not retrigger // this effect, it only matters at the moment a precondition flips. - if (isMobile || isTablet || !proInterface) { + if (isMobile || !proInterface) { const { tabs: allTabs, focusedPaneId: pane, activeTabId, activeSplitTabId } = useProTabStore.getState(); const focusedId = pane === 'split' ? activeSplitTabId : activeTabId; const kind = allTabs.find((tab) => tab.id === focusedId)?.kind; @@ -284,7 +285,7 @@ export default function ProHome() { if (kind === 'compose' && focusedId) handOffComposeTab(focusedId); router.replace(surface); } - }, [initialCheckDone, isMobile, isTablet, proInterface, router]); + }, [initialCheckDone, isMobile, proInterface, router]); const mainTabs = useMemo(() => tabs.filter((t) => t.paneId === 'main'), [tabs]); const splitTabs = useMemo(() => tabs.filter((t) => t.paneId === 'split'), [tabs]); diff --git a/app/boot-skeleton.css b/app/boot-skeleton.css index 3de963440..cc8f7aff4 100644 --- a/app/boot-skeleton.css +++ b/app/boot-skeleton.css @@ -8,7 +8,7 @@ */ /* Mountain View shell. Its geometry only applies on desktop, so pair it with - lg: in markup (lg:mv:hidden), except the phone bottom bar, which follows + desk: in markup (desk:mv:hidden), except the phone bottom bar, which follows the layout at every width. */ @custom-variant mv (&:where([data-interface-layout="mountain-view"] *)); @@ -16,6 +16,19 @@ @custom-variant boot-split (&:where([data-boot-mail-layout="split"] *)); @custom-variant boot-horizontal (&:where([data-boot-mail-layout="horizontal"] *)); @custom-variant boot-focus (&:where([data-boot-mail-layout="focus"] *)); +/* One pane at a time: the focused layout, and the split layout in a window + too narrow for the list and the reader side by side (SPLIT_PANES_MIN, + useDrawnMailLayout). The rows keep the split layout's shape (boot-focus). */ +@custom-variant boot-onepane { + &:where([data-boot-mail-layout="focus"] *) { + @slot; + } + @media (width < 52.5rem) { + &:where([data-boot-mail-layout="split"] *) { + @slot; + } + } +} /* A message, thread, contact, settings page or admin page is open. */ @custom-variant boot-open (&:where([data-boot-pane] *)); diff --git a/app/globals.css b/app/globals.css index a7e851e20..191548f9c 100644 --- a/app/globals.css +++ b/app/globals.css @@ -8,6 +8,27 @@ /* The app's font stack replaces Tailwind's default --font-sans, so the font-sans utility and body use the same stack, and a theme's typography.fontSans (compiled to --font-sans on :root) restyles both. */ +/* The layout breakpoint (PHONE_BELOW in hooks/use-media-query.ts): below + 37.5rem (600px) the phone layout, from there up the desktop layout, compact + up to lg. `desk:` and `max-desk:` decide what is drawn (rail or bottom bar, + sidebar in the flow or as a drawer, panes side by side or one at a time); + md: and lg: stay for spacing and type sizes. */ +@theme { + --breakpoint-desk: 37.5rem; +} + +/* Rows and buttons sized for a finger (44px): on phones, and on touch screens + below the full desktop, which get the compact desktop layout. A mouse in a + narrow window keeps the desktop's row heights. */ +@custom-variant tap { + @media (width < 37.5rem) { + @slot; + } + @media (width < 64rem) and (pointer: coarse) { + @slot; + } +} + @theme { --font-sans: -apple-system, @@ -90,13 +111,13 @@ --density-card-p: 16px; --density-sidebar-py: 4px; - /* Minimum height of a tappable list row below the lg breakpoint, where - * folder rows use max-lg:min-h-[44px]. Plugin frames get it as a variable - * because their own viewport is the frame, not the page. */ + /* Minimum height of a tappable list row where folder rows use + * tap:min-h-[44px]. Plugin frames get it as a variable because their own + * viewport is the frame, not the page. */ --touch-row-min-h: 0px; } -@media (width < 64rem) { +@media (width < 37.5rem), (width < 64rem) and (pointer: coarse) { :root { --touch-row-min-h: 44px; } @@ -1522,9 +1543,9 @@ html[data-window-controls-left] [data-window-rail] { /* The collapsed rail's hover peek. Only on desktop: below that breakpoint the sidebar is already a drawer, opened by a tap rather than by a pointer that - does not exist. The width beats Tailwind's own `lg:w-full` on specificity, + does not exist. The width beats Tailwind's own `desk:w-full` on specificity, which is what keeps the floating panel from filling the 72px column. */ -@media (min-width: 1024px) { +@media (width >= 37.5rem) { [data-interface-layout="mountain-view"] [data-mv-rail-peek] { width: var(--mv-rail-peek-w); } diff --git a/components/admin/__tests__/admin-shell-resize.test.tsx b/components/admin/__tests__/admin-shell-resize.test.tsx index 98086ec5c..f106be54c 100644 --- a/components/admin/__tests__/admin-shell-resize.test.tsx +++ b/components/admin/__tests__/admin-shell-resize.test.tsx @@ -21,7 +21,7 @@ describe('AdminShell across the desktop breakpoint', () => { let desktop = initial; const listeners = new Set<() => void>(); window.matchMedia = vi.fn((query: string) => ({ - get matches() { return desktop && query === '(width >= 64rem)'; }, + get matches() { return desktop && query === '(width >= 37.5rem)'; }, media: query, addEventListener: (_: string, cb: () => void) => listeners.add(cb), removeEventListener: (_: string, cb: () => void) => listeners.delete(cb), diff --git a/components/admin/__tests__/admin-shell.test.tsx b/components/admin/__tests__/admin-shell.test.tsx index 6696a5445..9123db707 100644 --- a/components/admin/__tests__/admin-shell.test.tsx +++ b/components/admin/__tests__/admin-shell.test.tsx @@ -16,7 +16,7 @@ import { useUpdateStore } from '@/stores/update-store'; function setDesktop(desktop: boolean) { window.matchMedia = vi.fn((query: string) => ({ - matches: desktop && query === '(width >= 64rem)', + matches: desktop && query === '(width >= 37.5rem)', media: query, addEventListener: () => {}, removeEventListener: () => {}, diff --git a/components/admin/page-header.tsx b/components/admin/page-header.tsx index ed367948e..19ebde211 100644 --- a/components/admin/page-header.tsx +++ b/components/admin/page-header.tsx @@ -29,7 +29,7 @@ export function AdminPageHeader({ title, description, actions, back, leading, cl {back.label} @@ -39,7 +39,7 @@ export function AdminPageHeader({ title, description, actions, back, leading, cl
{leading}
-

{title}

+

{title}

{description &&

{description}

}
diff --git a/components/boot-skeleton/__tests__/auth-skeletons.test.tsx b/components/boot-skeleton/__tests__/auth-skeletons.test.tsx index fbea8e9e9..d7fb267c7 100644 --- a/components/boot-skeleton/__tests__/auth-skeletons.test.tsx +++ b/components/boot-skeleton/__tests__/auth-skeletons.test.tsx @@ -61,7 +61,7 @@ describe('admin skeleton', () => { it('shows the phone list at /admin and the page below it', () => { const index = dom(renderToString()); const detail = dom(renderToString()); - const phone = (c: HTMLElement) => c.querySelector('.lg\\:hidden')!; + const phone = (c: HTMLElement) => c.querySelector('.desk\\:hidden')!; // Index: the list (with its chevrons) is shown, the page column hidden. expect(phone(index).querySelector('.p-4')!.className).toContain('hidden'); expect(phone(detail).querySelector('.p-4')!.className).not.toContain('hidden'); diff --git a/components/boot-skeleton/__tests__/mail-skeleton.test.tsx b/components/boot-skeleton/__tests__/mail-skeleton.test.tsx index 05734374d..fac240fb4 100644 --- a/components/boot-skeleton/__tests__/mail-skeleton.test.tsx +++ b/components/boot-skeleton/__tests__/mail-skeleton.test.tsx @@ -18,7 +18,7 @@ describe('mail skeleton', () => { const bar = tabs[tabs.length - 1]?.parentElement as HTMLElement; expect(bar.querySelector('svg')).not.toBeNull(); expect(bar).toBeTruthy(); - expect(bar.className.split(' ')).toContain('max-md:boot-open:hidden'); + expect(bar.className.split(' ')).toContain('max-desk:boot-open:hidden'); }); it('draws only the body inside a Pro pane: no frame, no sidebar header', () => { @@ -52,6 +52,6 @@ describe('mail skeleton', () => { expect(rows.querySelector('.boot-density-xc\\:hidden')).not.toBeNull(); expect(rows.querySelector('.boot-dense\\:hidden')).not.toBeNull(); expect(rows.querySelector('.boot-one-preview-line\\:hidden')).not.toBeNull(); - expect(rows.querySelector('.md\\:mv\\:block')).not.toBeNull(); + expect(rows.querySelector('.desk\\:mv\\:block')).not.toBeNull(); }); }); diff --git a/components/boot-skeleton/__tests__/pro-skeleton.test.tsx b/components/boot-skeleton/__tests__/pro-skeleton.test.tsx index 8d032a377..a0743f855 100644 --- a/components/boot-skeleton/__tests__/pro-skeleton.test.tsx +++ b/components/boot-skeleton/__tests__/pro-skeleton.test.tsx @@ -21,7 +21,7 @@ const classes = (el: Element | null | undefined) => (el?.getAttribute('class') ? describe('AppShellSkeleton under the Pro interface', () => { const page = () => html( - +
, ); diff --git a/components/boot-skeleton/admin-skeleton.tsx b/components/boot-skeleton/admin-skeleton.tsx index 0db2ff49b..4bf42ab71 100644 --- a/components/boot-skeleton/admin-skeleton.tsx +++ b/components/boot-skeleton/admin-skeleton.tsx @@ -93,12 +93,12 @@ export function AdminContentSkeleton({ page, status }: { page?: AdminPageId | nu
{def && page !== undefined ? ( <> - {def.label} + {def.label} {def.description} ) : ( <> - + )} @@ -174,7 +174,7 @@ export function AdminSkeleton({ label, page, backLabel, searchLabel, labels, cla className={cn("h-dvh bg-background pt-[var(--app-top-inset)]", className)} > {/* Desktop */} -
+
{/* Phone and tablet */} -
+
{embedded ? null : ( - + )}
-
+
{embedded ? null : ( -
+
@@ -257,7 +257,6 @@ export function ContactsSkeleton({ label, labels }: ContactsSkeletonProps) { surface="contacts" label={label} labels={labels} - railBelow="md" active="contacts" mvMenu={false} rowClassName={MV_CARD_ROW} diff --git a/components/boot-skeleton/files-skeleton.tsx b/components/boot-skeleton/files-skeleton.tsx index 6802ccd98..f73f936d3 100644 --- a/components/boot-skeleton/files-skeleton.tsx +++ b/components/boot-skeleton/files-skeleton.tsx @@ -221,7 +221,7 @@ function FilesHeaderSkeleton({ picker }: { picker: boolean }) { - +
)}
@@ -349,7 +349,6 @@ export function FilesSkeleton({ label, labels }: FilesSkeletonProps) { surface="files" label={label} labels={labels} - railBelow="md" active="files" mvMenu={false} rowClassName={MV_CARD_ROW} diff --git a/components/boot-skeleton/mail-skeleton.tsx b/components/boot-skeleton/mail-skeleton.tsx index 0fbbe323f..07c634645 100644 --- a/components/boot-skeleton/mail-skeleton.tsx +++ b/components/boot-skeleton/mail-skeleton.tsx @@ -1,7 +1,7 @@ /** * The mail page as a skeleton: folder sidebar, message list and reading pane, - * in each layout the app has (split, horizontal, focus, Mountain View, tablet, - * phone). Class lists follow the real components (components/mail/mail-app.tsx, + * in each layout the app has (split, horizontal, focus, Mountain View, the + * compact desktop, phone). Class lists follow the real components (components/mail/mail-app.tsx, * components/layout/sidebar.tsx, components/layout/mobile-header.tsx, * components/email/thread-list-item.tsx, components/email/email-viewer.tsx), * so the page fills in over it without anything moving. @@ -23,6 +23,7 @@ import { ReplyAll, ChevronDown, ChevronsLeft, + ChevronsRight, Filter, Mail, Menu, @@ -78,17 +79,17 @@ export function FolderRowsSkeleton({ count = 7 }: { count?: number }) { {repeat(count, (i) => (
- - + + - +
))} @@ -118,17 +119,19 @@ function SidebarSkeleton({ embedded }: { embedded: boolean }) { "relative flex flex-col h-full border-e overflow-hidden bg-secondary border-border", // Dark themes draw the panel in the shapes' own colour: lift them off it. "sk-lift-dark", - !embedded && "lg:mv:bg-[var(--mv-canvas)] lg:mv:border-transparent", + !embedded && "desk:mv:bg-[var(--mv-canvas)] desk:mv:border-transparent", )} > {/* Header: collapse button and account switcher. Hidden in a Pro pane and under Mountain View. */} {!embedded && ( -
-
- - +
+
+ + + {/* A compact desktop folds the sidebar to its icons (sidebarFolded). */} + - + @@ -137,20 +140,24 @@ function SidebarSkeleton({ embedded }: { embedded: boolean }) {
+ {/* The folded sidebar's Compose button. */} +
+ +
)} {/* Mountain View: the Compose button heads the navigation. */} {!embedded && ( -
+
- +
)}
{/* Mountain View lists the folders without a heading. */} - +
@@ -210,7 +217,7 @@ function StackedRow({ i, o }: { i: number; o: RowOptions }) { const preview = previewClass(o); const secondLine = secondPreviewLineClass(o); return ( -
+
{avatar !== null && ( @@ -226,9 +233,9 @@ function StackedRow({ i, o }: { i: number; o: RowOptions }) { {preview !== null && ( {/* Mountain View's phone rows keep the preview to one line, beside a 32px star. */} - + {secondLine !== null && ( - + )} )} @@ -306,13 +313,13 @@ export function MailListRowsSkeleton({ } return (