From 044647ff3440cc9dc92858d8eb7e276d747afc09 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 09:07:06 +0100 Subject: [PATCH] fix: keep the desktop layout in a half-screen window and at 150 % zoom A window snapped to half the screen (640-800 px) or a 1366x768 laptop at 150 % (911 px) got the phone and tablet layout: bottom tab bar, folders in a drawer, Settings as a phone list with a Back arrow. The layout switched at the 64rem (1024 px) breakpoint. Now the phone layout starts below 600 px. From 600 to 1024 px the app is a compact desktop: app rail at the side, folder sidebar folded to its icons (the existing collapsed state, opened per session with its toggle), Settings with its two panes and a narrower page list. Below 840 px the split layout opens a message in the list's place, as the focused layout does. This holds in the browser and the desktop app, in the Pro shell (which now runs from 600 px) and under Mountain View. - `hooks/use-media-query.ts`: classifyWidth, PHONE_BELOW 600, FULL_DESKTOP 1024; the tablet class (`isTablet`) is gone, the store has `isCompact` instead. Plugins still see "tablet" for it. - `app/globals.css`: a `desk:` breakpoint at 37.5rem for the layout classes, and a `tap:` variant for 44px rows on phones and touch screens below 1024 px (was every window below 1024 px). - Touch screens below 1024 px keep swiping list rows (was phones only). - The boot skeletons, boot script, mail app, sidebar, reader, admin shell and calendar, contacts and files follow the new widths. Tests changed with the behaviour: the tablet cases in pro-gate, mail-app-mv-composer-move, mail-app-compose-handoff, mail-app-thread-expand-mark-read, mobile-tour-end and thread-list-item-columns now run on a phone or a compact desktop, as those widths no longer exist as a tablet. mail-app-short-window-tab-bar is replaced by mail-app-compact-layout: a phone on its side is a compact desktop with the rail at the side, so the short-window tab bar rule is gone. Class and media-query assertions follow desk:/tap: and 37.5rem; the 32px-text test uses 1100 px, since 1440 px at 32px is now a compact desktop. --- .../[locale]/pro/__tests__/pro-gate.test.tsx | 32 ++- app/(main)/[locale]/pro/page.tsx | 11 +- app/boot-skeleton.css | 15 +- app/globals.css | 33 ++- .../__tests__/admin-shell-resize.test.tsx | 2 +- .../admin/__tests__/admin-shell.test.tsx | 2 +- components/admin/page-header.tsx | 4 +- .../__tests__/auth-skeletons.test.tsx | 2 +- .../__tests__/mail-skeleton.test.tsx | 4 +- .../__tests__/pro-skeleton.test.tsx | 2 +- components/boot-skeleton/admin-skeleton.tsx | 8 +- .../boot-skeleton/calendar-skeleton.tsx | 9 +- .../boot-skeleton/contacts-skeleton.tsx | 13 +- components/boot-skeleton/files-skeleton.tsx | 3 +- components/boot-skeleton/mail-skeleton.tsx | 123 ++++++------ .../boot-skeleton/plugin-page-skeleton.tsx | 4 +- .../boot-skeleton/settings-skeleton.tsx | 11 +- components/boot-skeleton/shell.tsx | 28 +-- .../calendar-app-boot-skeleton.test.tsx | 1 + .../calendar/__tests__/calendar-app.test.tsx | 4 +- .../__tests__/calendar-side-panel.test.tsx | 4 +- components/calendar/calendar-app.tsx | 7 +- components/contacts/contacts-app.tsx | 7 +- .../__tests__/list-row-preview-lines.test.tsx | 2 +- .../__tests__/list-row-received-time.test.tsx | 2 +- .../mail-list-plugin-surfaces.test.tsx | 2 +- .../__tests__/plugin-row-badges.test.tsx | 4 +- .../thread-list-item-account-dot.test.tsx | 2 +- .../thread-list-item-columns.test.tsx | 12 +- ...hread-list-item-conversation-pane.test.tsx | 2 +- .../thread-list-item-source-folder.test.tsx | 4 +- .../email/__tests__/thread-list-item.test.tsx | 45 ++++- components/email/email-viewer.tsx | 34 ++-- components/email/thread-conversation-view.tsx | 6 +- components/email/thread-list-item.tsx | 17 +- components/files/file-browser.tsx | 7 +- components/global-search/search-facets.tsx | 2 +- .../layout/__tests__/sidebar-compose.test.tsx | 2 +- .../sidebar-rail-peek-hover.test.tsx | 2 +- components/layout/mobile-header.tsx | 2 +- components/layout/navigation-rail.tsx | 10 +- components/layout/sidebar.tsx | 28 ++- .../__tests__/mail-app-boot-skeleton.test.tsx | 6 +- ...t.tsx => mail-app-compact-layout.test.tsx} | 108 ++++++---- .../mail-app-compose-handoff.test.tsx | 11 +- .../mail/__tests__/mail-app-list-ux.test.tsx | 8 +- .../mail-app-mv-composer-move.test.tsx | 26 +-- .../mail-app-thread-expand-mark-read.test.tsx | 11 +- .../mail/__tests__/mobile-send-view.test.tsx | 4 +- .../mail/__tests__/mobile-tour-end.test.tsx | 21 +- components/mail/mail-app.tsx | 190 ++++++------------ .../settings-phone-deep-link.test.tsx | 3 +- components/settings/settings-app.tsx | 6 +- components/tour/__tests__/tour.test.tsx | 2 +- hooks/__tests__/plugin-ui-hooks.test.tsx | 6 +- .../__tests__/use-email-drag-routing.test.tsx | 2 +- .../use-media-query-breakpoints.test.ts | 93 ++++++++- hooks/use-effective-mail-layout.ts | 15 ++ hooks/use-list-row-shape.ts | 8 +- hooks/use-media-query.ts | 91 ++++++--- hooks/use-return-to-list.ts | 7 +- hooks/use-split-panes-fit.ts | 33 +++ lib/__tests__/plugin-ui-kit.test.tsx | 9 +- lib/__tests__/return-to-list.test.ts | 3 +- .../__tests__/boot-script.test.ts | 2 +- lib/boot-skeleton/boot-script.ts | 3 +- lib/return-to-list.ts | 9 +- stores/__tests__/ui-store.test.ts | 51 ++++- stores/ui-store.ts | 50 +++-- 69 files changed, 805 insertions(+), 487 deletions(-) rename components/mail/__tests__/{mail-app-short-window-tab-bar.test.tsx => mail-app-compact-layout.test.tsx} (58%) create mode 100644 hooks/use-split-panes-fit.ts 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 (