perf: draw the mail skeleton's rows with less markup

This commit is contained in:
Linus Rath
2026-11-02 19:42:40 +01:00
parent 78ebb62e40
commit 90044b9d5a
4 changed files with 120 additions and 77 deletions
+55 -1
View File
@@ -95,8 +95,9 @@
/* mail */
/* List density (the density setting, data-boot-density): extra-compact rows
(boot-density-xc, the same variant contacts uses: drop one copy on merging)
have no avatar, compact and extra-compact ones no preview. */
@custom-variant boot-xc (&:where([data-boot-density="extra-compact"] *));
@custom-variant boot-density-xc (&:where([data-boot-density="extra-compact"] *));
@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"] *));
@@ -104,3 +105,56 @@
@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);
}
}
@@ -47,7 +47,7 @@ describe('mail skeleton', () => {
it('draws every shape for the boot page and lets the boot attributes pick', () => {
const rows = html(<MailListRowsSkeleton />);
expect(rows.querySelector('.boot-xc\\:hidden')).not.toBeNull();
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();
});
+64 -71
View File
@@ -32,7 +32,6 @@ import {
Search,
Settings,
Square,
Star,
Users,
} from "@/components/icons";
import { cn } from "@/lib/cn";
@@ -75,7 +74,7 @@ function TextLine({ className, w, bar = "h-3" }: { className?: string; w: string
export function FolderRowsSkeleton({ count = 7 }: { count?: number }) {
return (
// On the sidebar's panel colour, which dark themes share with the shapes.
<div aria-hidden="true" className="dark:[&_.sk-shape]:bg-foreground/10">
<div aria-hidden="true" className="sk-lift-dark">
{repeat(count, (i) => (
<div
className={cn(
@@ -118,7 +117,7 @@ function SidebarSkeleton({ embedded }: { embedded: boolean }) {
className={cn(
"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.
"dark:[&_.sk-shape]:bg-foreground/10",
"sk-lift-dark",
!embedded && "lg:mv:bg-[var(--mv-canvas)] lg:mv:border-transparent",
)}
>
@@ -172,7 +171,7 @@ interface RowOptions {
/** Classes for the avatar, or null where the density has none. */
function avatarClass(o: RowOptions): string | null {
if (o.density === undefined) return "boot-xc:hidden";
if (o.density === undefined) return "boot-density-xc:hidden";
return o.density === "extra-compact" ? null : "";
}
@@ -182,37 +181,49 @@ function previewClass(o: RowOptions): string | null {
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 (
<span className={cn("sk-ml", className)}>
<span className="sk-shape" style={{ width: w }} />
</span>
);
}
/** 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 (
<div className="border-b border-border max-md:mv:border-b-transparent">
<div className="bg-background" style={{ minHeight: "var(--list-item-height)" }}>
<div
className="px-3 flex items-start"
style={{ gap: "var(--density-item-gap)", paddingBlock: "var(--density-item-py)" }}
>
{avatar !== null && (
<span className={cn("flex flex-shrink-0 items-center self-stretch", avatar)}>
<Circle className="w-10 h-10" />
<div className="sk-mrow max-md:mv:border-b-transparent">
<div className="sk-mcell sk-mcell-min">
{avatar !== null && (
<span className={cn("sk-mav", avatar)}>
<span className="sk-shape block rounded-full w-10 h-10" />
</span>
)}
<span className="block flex-1 min-w-0">
<span className="flex items-center justify-between gap-2 mb-1">
<L className="text-sm flex-1" w={textWidth(i, 30, 30)} />
<L className="text-xs w-10" w="100%" />
</span>
<L className="mb-1 text-sm" w={textWidth(i + 3, 55, 35)} />
{preview !== null && (
<span className={cn("block", preview)}>
{/* Mountain View's phone rows keep the preview to one line, beside a 32px star. */}
<L className="text-sm leading-relaxed max-md:mv:h-8" w="95%" />
<L className="text-sm leading-relaxed max-md:mv:hidden" w={textWidth(i + 5, 45, 40)} />
</span>
)}
<span className="block flex-1 min-w-0">
<span className="flex items-center justify-between gap-2 mb-1">
<TextLine className="text-sm flex-1" w={textWidth(i, 30, 30)} />
<TextLine className="text-xs w-10 flex-shrink-0" w="100%" />
</span>
<TextLine className="mb-1 text-sm" w={textWidth(i + 3, 55, 35)} />
{preview !== null && (
<span className={cn("block", preview)}>
<TextLine className="text-sm leading-relaxed" w="95%" />
{/* Mountain View's phone rows keep the preview to one line. */}
<TextLine className="text-sm leading-relaxed max-md:mv:hidden" w={textWidth(i + 5, 45, 40)} />
</span>
)}
</span>
</div>
</span>
</div>
</div>
);
@@ -222,25 +233,18 @@ function StackedRow({ i, o }: { i: number; o: RowOptions }) {
function OneLineRow({ i, o }: { i: number; o: RowOptions }) {
const avatar = avatarClass(o);
return (
<div className="border-b border-border">
<div className="bg-background">
<div
className="px-3 flex items-center"
style={{ gap: "var(--density-item-gap)", paddingBlock: "var(--density-item-py)" }}
>
{avatar !== null && (
<span className={cn("flex flex-shrink-0 items-center self-stretch", avatar)}>
<Circle className="w-8 h-8" />
</span>
)}
<span className="flex flex-1 min-w-0 items-center justify-between gap-3">
<span className="flex min-w-0 flex-1 items-center gap-3">
<TextLine className="w-32 shrink-0 text-sm lg:w-40" w={textWidth(i, 50, 40)} />
<TextLine className="min-w-0 flex-1 text-sm" w={textWidth(i + 2, 35, 45)} />
</span>
<TextLine className="w-10 shrink-0 text-xs" w="100%" />
<div className="sk-mrow">
<div className="sk-mcell items-center">
{avatar !== null && (
<span className={cn("sk-mav", avatar)}>
<span className="sk-shape block rounded-full w-8 h-8" />
</span>
</div>
)}
<span className="flex flex-1 min-w-0 gap-3">
<L className="w-32 lg:w-40 text-sm" w={textWidth(i, 50, 40)} />
<L className="flex-1 text-sm" w={textWidth(i + 2, 35, 45)} />
<L className="w-10 text-xs self-center" w="100%" />
</span>
</div>
</div>
);
@@ -249,29 +253,18 @@ function OneLineRow({ i, o }: { i: number; o: RowOptions }) {
/** Mountain View's row on a tablet or desktop: check box and star, then one 40px line. */
function MvRow({ i }: { i: number }) {
return (
<div className="border-b border-border">
<div className="bg-[var(--mv-row-read,var(--color-background))]">
<div className="px-3 flex items-center min-h-10" style={{ gap: "var(--density-item-gap)" }}>
<span className="flex flex-shrink-0 items-start self-stretch text-muted-foreground">
<span className="-ms-3 ps-3 pe-0.5 flex h-10 items-center">
<span className="p-1">
<Square className="w-[18px] h-[18px]" />
</span>
</span>
<span className="flex h-10 items-center">
<span className="p-1">
<Star className="w-[18px] h-[18px] text-muted-foreground/60" />
</span>
</span>
</span>
<span className="flex flex-1 min-w-0 min-h-10 items-center justify-between gap-3">
<span className="flex min-w-0 flex-1 items-center gap-3">
<TextLine className="w-32 shrink-0 text-sm lg:w-40" w={textWidth(i, 45, 40)} />
<TextLine className="min-w-0 flex-1 text-sm" w={textWidth(i + 2, 35, 45)} />
</span>
<TextLine className="w-10 shrink-0 text-xs" w="100%" />
</span>
</div>
<div className="sk-mrow">
<div className="sk-mcell items-center py-0 min-h-10">
{/* The check box and the star, 26px each (an 18px icon in p-1). */}
<span className="flex flex-shrink-0 items-center gap-[2px]">
<span className="block w-3.5 h-3.5 m-[6px] rounded-[3px] border-2 border-muted-foreground/70" />
<span className="sk-shape block w-3.5 h-3.5 m-[6px] rounded-full" />
</span>
<span className="flex flex-1 min-w-0 gap-3">
<L className="w-32 lg:w-40 text-sm" w={textWidth(i, 45, 40)} />
<L className="flex-1 text-sm" w={textWidth(i + 2, 35, 45)} />
<L className="w-10 text-xs self-center" w="100%" />
</span>
</div>
</div>
);
@@ -375,7 +368,7 @@ function MobileHeaderSkeleton() {
"border-b border-border bg-background px-4",
"mv:mx-2 mv:mt-2 mv:mb-1 mv:rounded-full mv:border-0 mv:bg-muted mv:px-2 mv:shadow-sm",
// Mountain View's pill is the shapes' own colour: lift them off it.
"mv:[&_.sk-shape]:bg-foreground/10",
"sk-lift-mv",
)}
>
<span className="flex items-center gap-2 min-w-0 flex-shrink-0">
@@ -384,7 +377,7 @@ function MobileHeaderSkeleton() {
</span>
<TextLine className="max-md:hidden w-28 font-semibold text-lg" w="100%" bar="h-4" />
</span>
<span className="md:hidden flex-1 min-w-0 h-10 flex items-center gap-2 rounded-full pe-1 mx-1 bg-muted ps-3 mv:mx-0 mv:bg-transparent mv:ps-2 text-muted-foreground [&_.sk-shape]:bg-foreground/10">
<span className="md:hidden flex-1 min-w-0 h-10 flex items-center gap-2 rounded-full pe-1 mx-1 bg-muted ps-3 mv:mx-0 mv:bg-transparent mv:ps-2 text-muted-foreground sk-lift">
<Search className="h-4 w-4 flex-shrink-0" />
<TextLine className="flex-1 text-sm" w="7rem" />
</span>
-4
View File
@@ -182,10 +182,6 @@ export function MvTopBarSkeleton({ search = true, className }: { search?: boolea
<span className={round}>
<Grid3x3 className="w-5 h-5" />
</span>
{/* The account switcher. */}
<span className="ms-1 flex items-center justify-center w-10 h-10 pointer-coarse:w-11 pointer-coarse:h-11">
<Circle className="w-8 h-8" />
</span>
</div>
</div>
);