diff --git a/app/(main)/protocol/mailto/page.tsx b/app/(main)/protocol/mailto/page.tsx index f2797d699..bef4cbd4c 100644 --- a/app/(main)/protocol/mailto/page.tsx +++ b/app/(main)/protocol/mailto/page.tsx @@ -3,6 +3,14 @@ import { MailtoProtocolClient } from "@/components/protocol/mailto-protocol-clie export default async function MailtoProtocolPage() { const t = await getTranslations("protocol_handlers"); + const tSidebar = await getTranslations("sidebar"); + const labels = { + mail: tSidebar("mail"), + calendar: tSidebar("calendar"), + contacts: tSidebar("contacts"), + files: tSidebar("files"), + settings: tSidebar("settings"), + }; - return ; + return ; } diff --git a/app/boot-skeleton.css b/app/boot-skeleton.css index 921ede914..bbd1d03d3 100644 --- a/app/boot-skeleton.css +++ b/app/boot-skeleton.css @@ -351,5 +351,69 @@ @media (width < 48rem) { .sk-cal-cols { --sk-col: 120px; + +/* mail */ + +/* List density (the density setting, data-boot-density): extra-compact rows + (boot-density-xc, defined with contacts above) have no avatar, compact and + extra-compact ones no preview. */ +@custom-variant boot-dense (&:where([data-boot-density="compact"] *, [data-boot-density="extra-compact"] *)); +/* Message previews turned off (showPreview). */ +@custom-variant boot-no-preview (&:where([data-boot-preview="off"] *)); +/* The default interface layout, the opposite of mv: (both can't be told apart by order). */ +@custom-variant boot-std (&:where(:root:not([data-interface-layout="mountain-view"]) *)); +/* Mountain View's side panel shows only with calendar or contacts. */ +@custom-variant boot-no-mv-side (&:where([data-boot-rail]:not([data-boot-rail~="calendar"]):not([data-boot-rail~="contacts"]) *)); + +/* Message list rows (MailListRowsSkeleton), in short classes because a + skeleton draws dozens of them. Geometry as ThreadListItem's: a 1px rule, + the cell's density padding and gap, its minimum height on stacked rows. + In the components layer, so utilities on the same element still win. */ +@layer components { + .sk-mrow { + border-bottom: 1px solid var(--color-border); + background-color: var(--color-background); + } + .sk-mcell { + display: flex; + align-items: flex-start; + gap: var(--density-item-gap); + padding: var(--density-item-py) 0.75rem; + } + .sk-mcell-min { + min-height: var(--list-item-height); + } + /* The avatar's column: centred on the row. */ + .sk-mav { + display: flex; + flex-shrink: 0; + align-self: stretch; + align-items: center; + } + /* A line of text as tall as the real one (font size and leading from its + classes), with its bar centred on it. */ + .sk-ml { + position: relative; + display: block; + height: 1lh; + } + .sk-ml > .sk-shape { + position: absolute; + inset-block: 0; + inset-inline-start: 0; + height: 0.75rem; + margin-block: auto; + border-radius: 0.25rem; + } +} + +/* Panels drawn in the shapes' own colour (the sidebar in dark themes, the + Mountain View phone header): lift the shapes off them. Below the contrast + rules' specificity, and not in forced colours. */ +@media not (forced-colors: active) { + :where(.dark) .sk-lift-dark .sk-shape, + :where([data-interface-layout="mountain-view"]) .sk-lift-mv .sk-shape, + .sk-lift .sk-shape { + background-color: color-mix(in oklab, var(--color-foreground) 10%, transparent); } } diff --git a/components/boot-skeleton/__tests__/mail-skeleton.test.tsx b/components/boot-skeleton/__tests__/mail-skeleton.test.tsx new file mode 100644 index 000000000..784665b8c --- /dev/null +++ b/components/boot-skeleton/__tests__/mail-skeleton.test.tsx @@ -0,0 +1,54 @@ +import { renderToString } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; +import { MailListRowsSkeleton, MailSkeleton } from '../mail-skeleton'; + +function html(element: React.ReactElement): HTMLElement { + const container = document.createElement('div'); + container.innerHTML = renderToString(element); + return container; +} + +describe('mail skeleton', () => { + // The app hides its bottom bar while a message is open on a phone; the + // skeleton of /mail/message/ must not draw one either. + it('hides the phone bottom bar when a message or conversation is open', () => { + const page = html(); + // The bar's items are its 64px-basis tabs; it is the last such row on the page. + const tabs = page.querySelectorAll('.basis-\\[64px\\]'); + 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'); + }); + + it('draws only the body inside a Pro pane: no frame, no sidebar header', () => { + const pane = html(); + const region = pane.querySelector('[data-boot-skeleton="mail"]')!; + expect(region.className).toContain('h-full'); + expect(region.className).not.toContain('h-dvh'); + expect(pane.querySelector('.boot-out\\:block')).toBeNull(); + expect(pane.querySelector('.lg\\:mv\\:hidden.border-b')).toBeNull(); + }); + + it.each([ + ['extra-compact', 0, 0], + ['compact', 16, 0], + ['regular', 16, 16], + ] as const)('draws %s stacked rows with %i avatars and %i previews', (density, avatars, previews) => { + const rows = html(); + expect(rows.querySelectorAll('.w-10.h-10').length).toBe(avatars); + expect(rows.querySelectorAll('.leading-relaxed').length).toBe(previews * 2); + }); + + it('leaves the preview out when previews are turned off', () => { + const rows = html(); + expect(rows.querySelectorAll('.leading-relaxed').length).toBe(0); + }); + + it('draws every shape for the boot page and lets the boot attributes pick', () => { + const rows = html(); + expect(rows.querySelector('.boot-density-xc\\:hidden')).not.toBeNull(); + expect(rows.querySelector('.boot-dense\\:hidden')).not.toBeNull(); + expect(rows.querySelector('.md\\:mv\\:block')).not.toBeNull(); + }); +}); diff --git a/components/boot-skeleton/mail-skeleton.tsx b/components/boot-skeleton/mail-skeleton.tsx index f71310a14..ae430177e 100644 --- a/components/boot-skeleton/mail-skeleton.tsx +++ b/components/boot-skeleton/mail-skeleton.tsx @@ -1,3 +1,42 @@ +/** + * 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, + * 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. + * + * The pieces with a `density`/`shape` prop are shared with the loaded app's + * own loading states (the list's skeleton rows, the sidebar before the + * folders arrive, the viewer while a message loads). Without those props they + * pick their variant by CSS from the boot attributes, which is what the page + * skeleton needs; with them they draw exactly the one the app asks for. + */ +import type { ReactNode } from "react"; +import { + ArrowLeft, + CalendarEvent, + ChevronLeft, + ChevronRight, + Forward, + Reply, + ReplyAll, + ChevronDown, + ChevronsLeft, + Filter, + Mail, + Menu, + MoreVertical, + Pencil, + RefreshCw, + Search, + Settings, + Square, + Users, +} from "@/components/icons"; +import { cn } from "@/lib/cn"; +import type { Density } from "@/lib/density"; +import { Bar, Block, Circle, SkeletonRegion, repeat, textWidth } from "./kit"; import { AppShellSkeleton, type RailLabels } from "./shell"; export interface MailSkeletonProps { @@ -7,19 +46,642 @@ export interface MailSkeletonProps { labels?: RailLabels; } +/** Folder row indent: the row's own start padding and the expand chevron's slot (sidebar.tsx). */ +export const FOLDER_ROW_PX_BASE = 8; +export const FOLDER_CHEVRON_SLOT = 20; + +/** + * One line of placeholder text that is exactly as tall as a line of the real + * text (h-lh: one line-height of its own font size and leading), with the bar + * centred on it. `className` carries the font size and leading. + */ +function TextLine({ className, w, bar = "h-3" }: { className?: string; w: string; bar?: string }) { + return ( + + ); +} + +// --- Sidebar ----------------------------------------------------------------- + +/** + * Folder rows as SidebarRow draws them (sidebar.tsx): text-sm, the density's + * block padding, a 2px leading stripe, the chevron slot and a 16px icon. + * Under Mountain View the rows are 32px pills with 20px icons, as + * globals.css makes the real ones. + */ +export function FolderRowsSkeleton({ count = 7 }: { count?: number }) { + return ( + // On the sidebar's panel colour, which dark themes share with the shapes. + + ); +} + +/** The "Folders" section header (SidebarSectionHeader, first). */ +function FolderSectionHeaderSkeleton({ className }: { className?: string }) { + return ( + + ); +} + +function SidebarSkeleton({ embedded }: { embedded: boolean }) { + return ( +
+ {/* Header: collapse button and account switcher. Hidden in a Pro pane and under Mountain View. */} + {!embedded && ( +
+
+ + + + + + + + + + + +
+
+ )} + {/* Mountain View: the Compose button heads the navigation. */} + {!embedded && ( +
+ + + + +
+ )} +
+ {/* Mountain View lists the folders without a heading. */} + + +
+
+ ); +} + +// --- Message list ------------------------------------------------------------ + +/** How the list draws its rows (useListRowShape, plus Mountain View's own rows). */ +export type MailRowShape = "stacked" | "one-line" | "mv"; + +interface RowOptions { + /** Live: the density setting. Left out, the boot attributes decide by CSS. */ + density?: Density; + /** Live: whether rows show a preview (showPreview). */ + preview?: boolean; +} + +/** Classes for the avatar, or null where the density has none. */ +function avatarClass(o: RowOptions): string | null { + if (o.density === undefined) return "boot-density-xc:hidden"; + return o.density === "extra-compact" ? null : ""; +} + +/** Classes for the two preview lines, or null where the row has none. */ +function previewClass(o: RowOptions): string | null { + if (o.density === undefined) return "boot-dense:hidden boot-no-preview:hidden"; + return o.preview === false || o.density === "compact" || o.density === "extra-compact" ? null : ""; +} + +/* + * The rows below are drawn dozens of times, so they use the short classes in + * app/boot-skeleton.css (mail section) for what every row repeats: sk-mrow + * (the row and its rule), sk-mcell (the cell: the density's padding and gap, + * and its minimum height), sk-mav (the avatar's column) and sk-ml (a line of + * text, with its bar). + */ + +/** One line of row text, as tall as the real line; `w` is the bar's width. */ +function L({ className, w }: { className?: string; w: string }) { + return ( + + + + ); +} + +/** A stacked row (ThreadListItem on phones and in the split list). */ +function StackedRow({ i, o }: { i: number; o: RowOptions }) { + const avatar = avatarClass(o); + const preview = previewClass(o); + return ( +
+
+ {avatar !== null && ( + + + + )} + + + + + + + {preview !== null && ( + + {/* Mountain View's phone rows keep the preview to one line, beside a 32px star. */} + + + + )} + +
+
+ ); +} + +/** A one-line row (the focused list and the bottom reading pane on a desktop). */ +function OneLineRow({ i, o }: { i: number; o: RowOptions }) { + const avatar = avatarClass(o); + return ( +
+
+ {avatar !== null && ( + + + + )} + + + + + +
+
+ ); +} + +/** Mountain View's row on a tablet or desktop: check box and star, then one 40px line. */ +function MvRow({ i }: { i: number }) { + return ( +
+
+ {/* The check box and the star, 26px each (an 18px icon in p-1). */} + + + + + + + + + +
+
+ ); +} + +/** Enough rows of each shape to fill a tall viewport. */ +const ROWS: Record = { stacked: 16, "one-line": 32, mv: 22 }; + +/** + * Message list rows. With `shape`, the rows the loaded list is about to draw + * (EmailList's loading state); without, every shape, shown by CSS from the + * boot attributes (one-line where useListRowShape says so, Mountain View's + * rows from a tablet up). + */ +export function MailListRowsSkeleton({ + shape, + density, + preview, + className, +}: { shape?: MailRowShape; className?: string } & RowOptions) { + const o: RowOptions = { density, preview }; + if (shape) { + return ( + + ); + } + return ( + + ); +} + +/** The list header row: select toggle, search field, folder menu (mail-app.tsx). */ +function ListToolbarSkeleton({ embedded }: { embedded: boolean }) { + const icon = "flex items-center justify-center h-8 w-8 shrink-0 text-muted-foreground"; + const round = "grid place-items-center w-10 h-10 rounded-full shrink-0 text-muted-foreground"; + return ( +
+
+
+ + + + + + + + + / + + + + + + + + + +
+
+ {/* Mountain View's list toolbar (MvListToolbar). */} + {!embedded && ( +
+ + + + + + + + + + + +
+ )} +
+ ); +} + +/** + * The phone and tablet header (MobileHeader): menu, the search pill on + * phones or the folder name on tablets, the ⋮ menu and the account. + */ +function MobileHeaderSkeleton() { + return ( +
+ + + + + + + + + + + + + + + + + + +
+ ); +} + +// --- Reading pane -------------------------------------------------------------- + +/** + * The viewer while a message loads (EmailViewer's loading state draws this + * same component): the toolbar, the subject block and the sender block of the + * default reader, then the message body's tinted sheet. + */ +export function MailMessageSkeleton({ className }: { className?: string }) { + const button = "h-8 w-20 rounded-md"; + return ( + + ); +} + +/** + * A conversation on a phone while it loads (ThreadConversationView): the + * header with back, subject and count, then the message cards. + */ +export function MailConversationSkeleton({ className }: { className?: string }) { + return ( + + ); +} + +/** The empty reading pane: nothing open (EmailViewer without an email). */ +function EmptyReaderSkeleton({ className }: { className?: string }) { + return ( + + ); +} + +/** The reader's frame (EmailViewer's shell()). */ +function ReaderFrame({ children }: { children: ReactNode }) { + return ( +
+
{children}
+
+ ); +} + +/** Mountain View's side panel rail (MvSidePanel): calendar and contacts. */ +function MvSidePanelSkeleton() { + return ( +