feat: show the page's skeleton on Lite pages while their language loads

This commit is contained in:
Linus Rath
2026-11-01 18:41:58 +01:00
parent 943276696b
commit 8d790b29fd
21 changed files with 852 additions and 2 deletions
+92
View File
@@ -0,0 +1,92 @@
/*
* Page skeletons (components/boot-skeleton).
*
* The server draws every variant of a skeleton; these variants show the one
* that matches the attributes the boot script set on <html> before the first
* paint (lib/boot-skeleton/boot-script.ts). Part of the first stylesheet, so
* nothing here arrives late.
*/
/* 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
the layout at every width. */
@custom-variant mv (&:where([data-interface-layout="mountain-view"] *));
/* Mail layout (effectiveMailLayout: Mountain View and simple mode are focus). */
@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"] *));
/* A message, thread, contact, settings page or admin page is open. */
@custom-variant boot-open (&:where([data-boot-pane] *));
@custom-variant boot-thread (&:where([data-boot-pane="thread"] *));
/* The Pro interface takes this page over on a desktop. */
@custom-variant boot-pro (&:where([data-boot-pro="on"] *));
/* Nothing stored that checkAuth could restore: draw the login card. */
@custom-variant boot-out (&:where([data-boot-auth="out"] *));
/* Calendar view. */
@custom-variant boot-cal-month (&:where([data-boot-calendar-view="month"] *));
@custom-variant boot-cal-week (&:where([data-boot-calendar-view="week"] *));
@custom-variant boot-cal-day (&:where([data-boot-calendar-view="day"] *));
@custom-variant boot-cal-agenda (&:where([data-boot-calendar-view="agenda"] *));
@custom-variant boot-cal-tasks (&:where([data-boot-calendar-view="tasks"] *));
@custom-variant boot-cal-collapsed (&:where([data-boot-collapsed~="calendar"] *));
/* Files. */
@custom-variant boot-files-grid (&:where([data-boot-files-view="grid"] *));
@custom-variant boot-files-details (&:where([data-boot-files-details="on"] *));
/* What the rail showed last time (bulwark-boot-hints). Without hints every
app is drawn: the rail of a server with all of them. */
@custom-variant boot-no-calendar (&:where([data-boot-rail]:not([data-boot-rail~="calendar"]) *));
@custom-variant boot-no-contacts (&:where([data-boot-rail]:not([data-boot-rail~="contacts"]) *));
@custom-variant boot-no-files (&:where([data-boot-rail]:not([data-boot-rail~="files"]) *));
@custom-variant boot-rail-admin (&:where([data-boot-rail~="admin"] *));
@custom-variant boot-rail-a11y (&:where([data-boot-rail~="a11y"] *));
@custom-variant boot-rail-quota (&:where([data-boot-rail~="quota"] *));
/* A placeholder shape. A slow opacity pulse rather than a moving shimmer:
opacity runs on the compositor, so the skeleton costs the main thread
nothing while the app's JS is still starting up. */
.sk-shape {
background-color: var(--color-muted);
animation: sk-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes sk-pulse {
50% {
opacity: 0.55;
}
}
/* Reduced motion: no pulse at all (the global rule only shortens it). */
@media (prefers-reduced-motion: reduce) {
:root:not([data-motion="full"]) .sk-shape {
animation: none;
}
}
:root[data-motion="reduce"] .sk-shape {
animation: none;
}
/* More contrast: shapes that stand out from the panels they sit on. */
@media (prefers-contrast: more) {
:root:not([data-contrast="normal"]) .sk-shape {
background-color: color-mix(in oklab, var(--color-foreground) 28%, transparent);
}
}
:root[data-contrast="more"] .sk-shape {
background-color: color-mix(in oklab, var(--color-foreground) 28%, transparent);
}
/* Forced colours drop backgrounds; keep the shapes as system grey. */
@media (forced-colors: active) {
.sk-shape {
forced-color-adjust: none;
background-color: GrayText;
animation: none;
}
}
+1
View File
@@ -3,6 +3,7 @@
@import "./theme-inline.css";
@import "./accessibility.css";
@import "./boot-skeleton.css";
/* 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
@@ -0,0 +1,59 @@
import { act } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { renderToString } from 'react-dom/server';
import { hydrateRoot } from 'react-dom/client';
import { RouteSkeleton } from '../route-skeleton';
import type { BootSurface } from '@/lib/boot-skeleton/surfaces';
/**
* Every page skeleton is rendered by the server and then hydrated by the same
* component on the client. They must produce identical markup, or React throws
* the server HTML away and the page flashes.
*/
const SURFACES: BootSurface[] = [
'mail', 'calendar', 'contacts', 'files', 'apps', 'settings', 'pro', 'admin', 'admin-login', 'login', 'setup',
];
const LABELS = { mail: 'Mail', calendar: 'Calendar', contacts: 'Contacts', files: 'Files', settings: 'Settings' };
afterEach(() => {
document.body.innerHTML = '';
vi.restoreAllMocks();
});
describe('page skeletons hydrate without a mismatch', () => {
it.each(SURFACES)('%s', async (surface) => {
const element = <RouteSkeleton surface={surface} label="Loading..." labels={LABELS} />;
const html = renderToString(element);
expect(html).toContain(`data-boot-skeleton="${surface === 'admin-login' ? 'login' : surface}"`);
const container = document.createElement('div');
container.innerHTML = html;
document.body.appendChild(container);
const errors: unknown[] = [];
const consoleError = vi.spyOn(console, 'error').mockImplementation((...args) => { errors.push(args); });
await act(async () => {
hydrateRoot(container, element, { onRecoverableError: (e) => { errors.push(e); } });
});
consoleError.mockRestore();
expect(errors).toEqual([]);
expect(container.innerHTML).toBe(html);
});
it.each(SURFACES)('%s: one status message, busy, and nothing focusable', (surface) => {
const container = document.createElement('div');
container.innerHTML = renderToString(<RouteSkeleton surface={surface} label="Loading..." labels={LABELS} />);
// A signed-in page also carries the login card for signed-out visitors,
// hidden by CSS; count per region.
for (const region of container.querySelectorAll('[data-boot-skeleton]')) {
expect(region.getAttribute('aria-busy')).toBe('true');
expect(region.querySelectorAll('[role="status"]').length).toBeLessThanOrEqual(1);
}
expect(container.querySelectorAll('[role="status"]').length).toBeGreaterThan(0);
expect(container.querySelectorAll('a[href], button, input, select, textarea, [tabindex]')).toHaveLength(0);
for (const shape of container.querySelectorAll('.sk-shape')) {
expect(shape.closest('[aria-hidden="true"]'), shape.outerHTML).not.toBeNull();
}
});
});
@@ -0,0 +1,12 @@
import { SkeletonRegion } from "./kit";
export interface AdminSkeletonProps {
/** common.loading */
label?: string;
className?: string;
}
/** Skeleton of the admin page. Stub: filled in per surface. */
export function AdminSkeleton({ label, className }: AdminSkeletonProps) {
return <SkeletonRegion surface="admin" label={label} className={className ?? "min-h-dvh bg-background"} />;
}
@@ -0,0 +1,17 @@
import { AppShellSkeleton, type RailLabels } from "./shell";
export interface CalendarSkeletonProps {
/** common.loading */
label?: string;
/** Rail labels for the phone bottom bar (sidebar.*). */
labels?: RailLabels;
}
/** Skeleton of the calendar page. Stub: filled in per surface. */
export function CalendarSkeleton({ label, labels }: CalendarSkeletonProps) {
return (
<AppShellSkeleton surface="calendar" label={label} labels={labels} railBelow="md" active="calendar">
<div className="flex-1 min-w-0" />
</AppShellSkeleton>
);
}
@@ -0,0 +1,17 @@
import { AppShellSkeleton, type RailLabels } from "./shell";
export interface ContactsSkeletonProps {
/** common.loading */
label?: string;
/** Rail labels for the phone bottom bar (sidebar.*). */
labels?: RailLabels;
}
/** Skeleton of the contacts page. Stub: filled in per surface. */
export function ContactsSkeleton({ label, labels }: ContactsSkeletonProps) {
return (
<AppShellSkeleton surface="contacts" label={label} labels={labels} railBelow="md" active="contacts">
<div className="flex-1 min-w-0" />
</AppShellSkeleton>
);
}
@@ -0,0 +1,17 @@
import { AppShellSkeleton, type RailLabels } from "./shell";
export interface FilesSkeletonProps {
/** common.loading */
label?: string;
/** Rail labels for the phone bottom bar (sidebar.*). */
labels?: RailLabels;
}
/** Skeleton of the files page. Stub: filled in per surface. */
export function FilesSkeleton({ label, labels }: FilesSkeletonProps) {
return (
<AppShellSkeleton surface="files" label={label} labels={labels} railBelow="md" active="files">
<div className="flex-1 min-w-0" />
</AppShellSkeleton>
);
}
+100
View File
@@ -0,0 +1,100 @@
/**
* The page skeleton kit.
*
* Plain markup with no hooks, no store reads and no next-intl, so the same
* component renders on the server (before any JS runs), during hydration and
* inside a loaded page's own loading state, and Lite pages without a message
* catalog can draw it too. Variants are picked by CSS from the `data-boot-*`
* attributes the boot script sets on <html> (lib/boot-skeleton/boot-script.ts);
* the classes behind them live in app/boot-skeleton.css.
*/
import { Fragment, type CSSProperties, type ReactNode } from "react";
import { cn } from "@/lib/cn";
export type SkeletonSurface =
| "mail"
| "calendar"
| "contacts"
| "files"
| "apps"
| "settings"
| "pro"
| "admin"
| "admin-login"
| "login"
| "setup";
interface RegionProps {
/** Which page this is the skeleton of; tests and the probes look for it. */
surface: SkeletonSurface;
/**
* The one polite "Loading" announcement for the whole page. Leave it out
* only where no translation is at hand (Lite before its catalog loads).
*/
label?: string;
className?: string;
style?: CSSProperties;
children?: ReactNode;
}
/**
* The outermost element of a page skeleton: marks the page busy and carries
* the single status message. Everything visual inside is aria-hidden.
*/
export function SkeletonRegion({ surface, label, className, style, children }: RegionProps) {
return (
<div data-boot-skeleton={surface} aria-busy="true" className={className} style={style}>
{label ? <span role="status" className="sr-only">{label}</span> : null}
{children}
</div>
);
}
interface ShapeProps {
className?: string;
/** Width as any CSS length, e.g. "60%" or "8rem". */
w?: string;
style?: CSSProperties;
}
/** A placeholder for a line of text: give it a height (h-3, h-4) and a width. */
export function Bar({ className, w, style }: ShapeProps) {
return (
<span
aria-hidden="true"
className={cn("sk-shape block h-3 rounded", className)}
style={w ? { ...style, width: w } : style}
/>
);
}
/** A placeholder for an avatar or a round button: give it a size (w-8 h-8). */
export function Circle({ className, style }: Omit<ShapeProps, "w">) {
return <span aria-hidden="true" className={cn("sk-shape block shrink-0 rounded-full", className)} style={style} />;
}
/** A placeholder for a box: a button, an input, a card. */
export function Block({ className, w, style }: ShapeProps) {
return (
<span
aria-hidden="true"
className={cn("sk-shape block rounded-md", className)}
style={w ? { ...style, width: w } : style}
/>
);
}
/**
* Widths for placeholder text that look like text: varied, but the same on
* every render (no randomness, so server and client markup agree).
*/
export function textWidth(i: number, min = 40, spread = 45): string {
return `${min + ((i * 37 + 11) % spread)}%`;
}
/** `count` items from `render`, keyed by index. */
export function repeat(count: number, render: (i: number) => ReactNode): ReactNode[] {
const out: ReactNode[] = [];
for (let i = 0; i < count; i++) out.push(<Fragment key={i}>{render(i)}</Fragment>);
return out;
}
@@ -0,0 +1,12 @@
import { SkeletonRegion } from "./kit";
export interface LoginSkeletonProps {
/** common.loading */
label?: string;
className?: string;
}
/** Skeleton of the login page. Stub: filled in per surface. */
export function LoginSkeleton({ label, className }: LoginSkeletonProps) {
return <SkeletonRegion surface="login" label={label} className={className ?? "min-h-dvh bg-background"} />;
}
@@ -0,0 +1,17 @@
import { AppShellSkeleton, type RailLabels } from "./shell";
export interface MailSkeletonProps {
/** common.loading */
label?: string;
/** Rail labels for the phone bottom bar (sidebar.*). */
labels?: RailLabels;
}
/** Skeleton of the mail page. Stub: filled in per surface. */
export function MailSkeleton({ label, labels }: MailSkeletonProps) {
return (
<AppShellSkeleton surface="mail" label={label} labels={labels} railBelow="lg" active="mail" mvSearch>
<div className="flex-1 min-w-0" />
</AppShellSkeleton>
);
}
@@ -0,0 +1,17 @@
import { AppShellSkeleton, type RailLabels } from "./shell";
export interface PluginPageSkeletonProps {
/** common.loading */
label?: string;
/** Rail labels for the phone bottom bar (sidebar.*). */
labels?: RailLabels;
}
/** Skeleton of the apps page. Stub: filled in per surface. */
export function PluginPageSkeleton({ label, labels }: PluginPageSkeletonProps) {
return (
<AppShellSkeleton surface="apps" label={label} labels={labels} railBelow="md">
<div className="flex-1 min-w-0" />
</AppShellSkeleton>
);
}
+12
View File
@@ -0,0 +1,12 @@
import { SkeletonRegion } from "./kit";
export interface ProSkeletonProps {
/** common.loading */
label?: string;
className?: string;
}
/** Skeleton of the pro page. Stub: filled in per surface. */
export function ProSkeleton({ label, className }: ProSkeletonProps) {
return <SkeletonRegion surface="pro" label={label} className={className ?? "min-h-dvh bg-background"} />;
}
@@ -0,0 +1,49 @@
import type { BootSurface } from "@/lib/boot-skeleton/surfaces";
import type { RailLabels } from "./shell";
import { MailSkeleton } from "./mail-skeleton";
import { CalendarSkeleton } from "./calendar-skeleton";
import { ContactsSkeleton } from "./contacts-skeleton";
import { FilesSkeleton } from "./files-skeleton";
import { PluginPageSkeleton } from "./plugin-page-skeleton";
import { SettingsSkeleton } from "./settings-skeleton";
import { AdminSkeleton } from "./admin-skeleton";
import { ProSkeleton } from "./pro-skeleton";
import { LoginSkeleton } from "./login-skeleton";
import { SetupSkeleton } from "./setup-skeleton";
interface RouteSkeletonProps {
surface: BootSurface;
label?: string;
labels?: RailLabels;
}
/**
* The skeleton of a whole page, picked by surface. Used where the page is not
* the one drawing it: a connector link (its destination), the protocol
* handlers, a Lite page waiting for its message catalog, loading.tsx.
*/
export function RouteSkeleton({ surface, label, labels }: RouteSkeletonProps) {
switch (surface) {
case "mail":
return <MailSkeleton label={label} labels={labels} />;
case "calendar":
return <CalendarSkeleton label={label} labels={labels} />;
case "contacts":
return <ContactsSkeleton label={label} labels={labels} />;
case "files":
return <FilesSkeleton label={label} labels={labels} />;
case "apps":
return <PluginPageSkeleton label={label} labels={labels} />;
case "settings":
return <SettingsSkeleton label={label} labels={labels} />;
case "pro":
return <ProSkeleton label={label} />;
case "admin":
return <AdminSkeleton label={label} />;
case "admin-login":
case "login":
return <LoginSkeleton label={label} />;
case "setup":
return <SetupSkeleton label={label} />;
}
}
@@ -0,0 +1,17 @@
import { AppShellSkeleton, type RailLabels } from "./shell";
export interface SettingsSkeletonProps {
/** common.loading */
label?: string;
/** Rail labels for the phone bottom bar (sidebar.*). */
labels?: RailLabels;
}
/** Skeleton of the settings page. Stub: filled in per surface. */
export function SettingsSkeleton({ label, labels }: SettingsSkeletonProps) {
return (
<AppShellSkeleton surface="settings" label={label} labels={labels} railBelow="lg" active="settings">
<div className="flex-1 min-w-0" />
</AppShellSkeleton>
);
}
@@ -0,0 +1,12 @@
import { SkeletonRegion } from "./kit";
export interface SetupSkeletonProps {
/** common.loading */
label?: string;
className?: string;
}
/** Skeleton of the setup page. Stub: filled in per surface. */
export function SetupSkeleton({ label, className }: SetupSkeletonProps) {
return <SkeletonRegion surface="setup" label={label} className={className ?? "min-h-dvh bg-background"} />;
}
+249
View File
@@ -0,0 +1,249 @@
/**
* The app frame every signed-in page shares, as a skeleton: the page root, the
* Mountain View top bar, the vertical rail and the phone bottom bar. Class
* lists mirror the real ones (components/layout/navigation-rail.tsx,
* components/layout/mv-top-bar.tsx and the roots of mail-app, calendar-app,
* contacts-app, files-app, settings-app), so the frame doesn't move when the
* page takes over.
*/
import type { ReactNode } from "react";
import {
Accessibility,
Bell,
CalendarEvent,
Folder,
Grid3x3,
Keyboard,
Mail,
Menu,
Settings,
Shield,
Users,
} from "@/components/icons";
import { cn } from "@/lib/cn";
import { Bar, Circle, SkeletonRegion, type SkeletonSurface } from "./kit";
import { LoginSkeleton } from "./login-skeleton";
export type RailApp = "mail" | "calendar" | "contacts" | "files" | "settings";
/** Translated rail labels for the phone bottom bar; bars are drawn where one is missing. */
export type RailLabels = Partial<Record<RailApp | "admin", string>>;
const RAIL_BORDER = { borderRight: "1px solid rgba(128, 128, 128, 0.3)" } as const;
const DIVIDER = { borderColor: "rgba(128, 128, 128, 0.3)" } as const;
/** Hidden when the last session's rail didn't show the app (bulwark-boot-hints). */
const APP_HIDE: Record<"calendar" | "contacts" | "files", string> = {
calendar: "boot-no-calendar:hidden",
contacts: "boot-no-contacts:hidden",
files: "boot-no-files:hidden",
};
const APPS = [
{ id: "mail", icon: Mail },
{ id: "calendar", icon: CalendarEvent },
{ id: "contacts", icon: Users },
{ id: "files", icon: Folder },
] as const;
/**
* The 56px vertical rail (NavigationRail collapsed). `className` decides at
* which widths it shows.
*/
export function RailSkeleton({ active, className }: { active?: RailApp; className?: string }) {
const item = (on: boolean) =>
cn(
"relative flex items-center justify-center w-10 h-10 rounded-md max-lg:min-h-[44px]",
on ? "bg-primary/10 text-primary" : "text-muted-foreground",
);
const footerItem = "flex items-center justify-center w-10 h-10 rounded-md";
return (
<div aria-hidden="true" className={cn("w-14 bg-secondary flex-col flex-shrink-0", className)} style={RAIL_BORDER}>
<div className="flex flex-col h-full items-center">
<div className="flex flex-col flex-1 min-h-0 items-center gap-1 py-3 px-1">
{APPS.map(({ id, icon: Icon }) => (
<span key={id} className={cn(item(active === id), id !== "mail" && APP_HIDE[id])}>
<Icon className={cn("w-[18px] h-[18px] flex-shrink-0", active === id && "text-primary")} />
</span>
))}
</div>
<div className="mt-auto flex flex-col items-center gap-2 pb-3 px-1 text-muted-foreground">
<span className={cn(footerItem, "hidden boot-rail-admin:flex")}>
<Shield className="w-[18px] h-[18px]" />
</span>
<span className={footerItem}>
<Bell className="w-[18px] h-[18px]" />
</span>
<span className={cn(footerItem, "hidden boot-rail-a11y:flex")}>
<Accessibility className="w-[18px] h-[18px]" />
</span>
<span className={cn(footerItem, active === "settings" && "bg-primary/10 text-primary")}>
<Settings className="w-[18px] h-[18px]" />
</span>
<div className="w-8 border-t" style={DIVIDER} />
<span className={footerItem}>
<Keyboard className="w-[18px] h-[18px]" />
</span>
<Circle className="hidden boot-rail-quota:block w-8 h-8" />
<span className="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>
</div>
);
}
/**
* The phone/tablet bottom bar (NavigationRail horizontal). `className` decides
* at which widths it shows.
*/
export function BottomBarSkeleton({
active,
labels,
className,
}: {
active?: RailApp;
labels?: RailLabels;
className?: string;
}) {
const items: { id: RailApp; icon: typeof Mail }[] = [...APPS, { id: "settings", icon: Settings }];
return (
<div
aria-hidden="true"
className={cn(
"items-center bg-background border-t border-border shrink-0 overflow-hidden pb-[calc(env(safe-area-inset-bottom)/2)]",
"mv:min-h-16 mv:border-t-0 mv:bg-secondary mv:shadow-[0_-1px_3px_rgb(0_0_0/0.08)]",
className,
)}
>
{items.map(({ id, icon: Icon }) => (
<span
key={id}
className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
active === id ? "text-primary" : "text-muted-foreground",
id !== "mail" && id !== "settings" && APP_HIDE[id],
)}
>
<span className={cn("relative", active === id && "mv:min-w-14 mv:rounded-full mv:bg-primary/15 mv:px-4 mv:py-[3px]")}>
<Icon className="w-5 h-5" />
{active === id ? (
<span className="mv:hidden absolute -bottom-1 left-1/2 -translate-x-1/2 w-4 h-0.5 rounded-full bg-primary" />
) : null}
</span>
{labels?.[id] ? (
<span className="text-[0.625rem] font-medium leading-tight truncate max-w-full">{labels[id]}</span>
) : (
<Bar className="h-2 my-px" w="2rem" />
)}
</span>
))}
</div>
);
}
/** Mountain View's 64px top bar (components/layout/mv-top-bar.tsx), desktop only. */
export function MvTopBarSkeleton({ search = true, className }: { search?: boolean; className?: string }) {
const round = "grid place-items-center w-12 h-12 rounded-full text-muted-foreground";
return (
<div aria-hidden="true" className={cn("items-center h-16 gap-2 ps-1 pe-4 shrink-0 bg-[var(--mv-canvas)]", className)}>
<div className="flex items-center gap-1 shrink-0">
<span className={round}>
<Menu className="w-5 h-5" />
</span>
<span className="flex items-center gap-2 pe-4 min-w-0">
<Bar className="h-5" w="7rem" />
</span>
</div>
{search ? (
<div className="relative flex items-center flex-1 min-w-0 max-w-[720px]">
<span className="block w-full h-[var(--mv-search-h,48px)] rounded-[var(--mv-search-radius,8px)] bg-[var(--mv-search-bg)]" />
</div>
) : null}
<div className="flex-1 min-w-0" />
<div className="flex items-center gap-1 shrink-0">
<span className={round}>
<Bell className="w-5 h-5" />
</span>
<span className={round}>
<Keyboard className="w-5 h-5" />
</span>
<span className={cn(round, "hidden boot-rail-admin:grid")}>
<Shield className="w-5 h-5" />
</span>
<span className={round}>
<Settings className="w-5 h-5" />
</span>
<span className={round}>
<Grid3x3 className="w-5 h-5" />
</span>
</div>
</div>
);
}
interface AppShellSkeletonProps {
surface: SkeletonSurface;
/** The page's own "Loading" string (common.loading). */
label?: string;
/** Rail item drawn as current. */
active?: RailApp;
/**
* Below which width the vertical rail gives way to the bottom bar: mail and
* settings switch at 64rem, calendar, contacts, files and plugin pages at 48rem.
*/
railBelow: "md" | "lg";
labels?: RailLabels;
/** Whether the Mountain View top bar holds the search field (mail does). */
mvSearch?: boolean;
/** Extra classes for the row under the top bar (Mountain View card margins etc.). */
rowClassName?: string;
children: ReactNode;
}
/**
* Page root plus frame. `children` go in the row next to the rail: the page's
* own sidebar, list and panes.
*
* When nothing is stored that could sign the visitor back in, the boot script
* marks the page `data-boot-auth="out"` and the login card is drawn instead:
* that is where the app is about to send them.
*/
export function AppShellSkeleton({
surface,
label,
active,
railBelow,
labels,
mvSearch = false,
rowClassName,
children,
}: AppShellSkeletonProps) {
return (
<>
<div className="hidden boot-out:block">
<LoginSkeleton label={label} />
</div>
<SkeletonRegion
surface={surface}
label={label}
className="flex flex-col overflow-hidden pt-[env(safe-area-inset-top)] h-dvh bg-background lg:mv:bg-[var(--mv-canvas)] boot-out:hidden"
>
<MvTopBarSkeleton search={mvSearch} className="hidden lg:mv:flex" />
<div className={cn("flex flex-1 min-h-0 overflow-hidden", rowClassName)}>
<RailSkeleton
active={active}
className={railBelow === "lg" ? "hidden lg:flex lg:mv:hidden" : "hidden md:flex lg:mv:hidden"}
/>
{children}
</div>
<BottomBarSkeleton
active={active}
labels={labels}
className={railBelow === "lg" ? "flex lg:hidden" : "flex md:hidden"}
/>
</SkeletonRegion>
</>
);
}
+17
View File
@@ -34,6 +34,7 @@ import { toUnicodeEmail } from "@/lib/idn";
import { usePluginPages, pluginPageHref, isPluginPageActive } from "@/hooks/use-plugin-pages";
import { NotificationBell } from "@/components/notifications/notification-center";
import { useBottomControls } from "@/hooks/use-bottom-controls";
import { writeRailHints, type RailHint } from "@/lib/boot-skeleton/hints";
interface NavItem {
id: string;
@@ -348,6 +349,22 @@ export function NavigationRail({
const visibleItems = navItems.filter((item) => !item.hidden);
// Leave the next page load's skeleton a note of what this rail shows
// (lib/boot-skeleton/hints.ts). Only once a session is up: before that the
// rail is still missing the apps the server offers.
const quotaShown = !!quota && quota.total > 0;
useEffect(() => {
if (!client) return;
const items: RailHint[] = [];
for (const item of navItems) if (item.id !== 'mail' && !item.hidden) items.push(item.id as RailHint);
if (isStalwartAdmin) items.push('admin');
if (showAccessibilityButton) items.push('a11y');
if (quotaShown) items.push('quota');
writeRailHints(items);
// navItems is rebuilt every render; its inputs are listed instead.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [client, supportsCalendar, calendarEnabled, supportsContacts, contactsEnabled, supportsFiles, filesEnabled, isStalwartAdmin, showAccessibilityButton, quotaShown]);
const getIsActive = (href: string, itemId: string) => {
if (activeAppId) return false;
if (onNavigate) {
@@ -5,10 +5,16 @@ import { useTranslations } from 'next-intl';
import { IntlProvider } from '../intl-provider';
import { useLocaleStore } from '@/stores/locale-store';
const nav = vi.hoisted(() => ({ pathname: null as string | null }));
vi.mock('next/navigation', async (importOriginal) => ({
...(await importOriginal<typeof import('next/navigation')>()),
usePathname: () => nav.pathname,
}));
// The Lite export leaves the catalog out of the [locale] layout, because the
// prop was baked into every exported page and pushed the Stalwart zip past its
// size limit. The provider then loads the catalog itself and must render
// nothing until it has it: English text would flash on a German page, and the
// no text until it has it (only the page's skeleton, which has none): English text would flash on a German page, and the
// prerendered HTML has to match the client's first render.
vi.unmock('next-intl');
@@ -20,12 +26,22 @@ function Cancel() {
describe('IntlProvider without server messages (Lite)', () => {
afterEach(() => {
useLocaleStore.setState({ locale: '' });
nav.pathname = null;
});
it('prerenders nothing for a non-English locale', () => {
expect(renderToString(<IntlProvider locale="de"><Cancel /></IntlProvider>)).toBe('');
});
it('prerenders the page skeleton, without any text, while the catalog is missing', () => {
nav.pathname = '/de/calendar';
const html = renderToString(<IntlProvider locale="de"><Cancel /></IntlProvider>);
expect(html).toContain('data-boot-skeleton="calendar"');
expect(html).toContain('aria-busy="true"');
expect(html).not.toContain('Cancel');
expect(html).not.toContain('role="status"');
});
it('prerenders English straight away from the bundled catalog', () => {
expect(renderToString(<IntlProvider locale="en"><Cancel /></IntlProvider>)).toContain('Cancel');
});
+12 -1
View File
@@ -2,6 +2,10 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { NextIntlClientProvider } from 'next-intl';
import { usePathname } from 'next/navigation';
import { RouteSkeleton } from '@/components/boot-skeleton/route-skeleton';
import { surfaceFromPath } from '@/lib/boot-skeleton/surfaces';
import { locales } from '@/i18n/routing';
import { useLocaleStore } from '@/stores/locale-store';
import { useSettingsStore } from '@/stores/settings-store';
import { getBrowserTimeZone, resolveTimeZone } from '@/lib/timezone';
@@ -67,6 +71,7 @@ export function IntlProvider({ locale: initialLocale, messages: initialMessages,
const timeZoneSetting = useSettingsStore((state) => state.timeZone);
const timeZone = resolveTimeZone(timeZoneSetting, browserTimeZone);
const plainLanguage = useSettingsStore((state) => state.plainLanguage);
const pathname = usePathname();
// The catalog currently in use. Seeded with the server-provided messages for
// the SSR-resolved locale, so the first render needs no async work and
@@ -159,7 +164,13 @@ export function IntlProvider({ locale: initialLocale, messages: initialMessages,
[baseMessages, plainMessages]
);
if (!catalog) return null;
if (!catalog) {
// Lite without this locale's catalog yet: no text can be drawn, but the
// page's skeleton can (it takes no translations), so the prerendered HTML
// still shows the page's shape instead of a blank screen.
const surface = pathname ? surfaceFromPath(pathname, locales) : null;
return surface ? <RouteSkeleton surface={surface} /> : null;
}
return (
<NextIntlClientProvider
@@ -0,0 +1,78 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { TARGET_NAMES, getTarget } from '@/lib/connector/registry';
import { locales } from '@/i18n/routing';
import { CONNECTOR_SURFACES, surfaceFromPath } from '../surfaces';
import { BOOT_HINTS_KEY } from '../keys';
import { resetRailHintsMemo, writeRailHints } from '../hints';
describe('connector targets', () => {
it('every target the connector can resolve has a skeleton surface', () => {
for (const name of TARGET_NAMES) expect(CONNECTOR_SURFACES[name], name).toBeTruthy();
expect(Object.keys(CONNECTOR_SURFACES).sort()).toEqual([...TARGET_NAMES].sort());
});
it('the surface matches where the target resolves to', () => {
const firstSegment: Record<string, string> = { '': 'mail' };
for (const name of TARGET_NAMES) {
const target = getTarget(name)!;
const params: Record<string, string> = {};
for (const [key, spec] of Object.entries(target.params ?? {})) {
if (!spec.required) continue;
params[key] = spec.type === 'enum' ? spec.values![0] : 'x';
}
const { path } = target.resolve(params);
const seg = path.split('/').filter(Boolean)[0] ?? '';
expect(CONNECTOR_SURFACES[name], `${name} -> ${path}`).toBe(firstSegment[seg] ?? seg);
}
});
});
describe('surfaceFromPath', () => {
it.each([
['/', 'mail'],
['/en', 'mail'],
['/de/mail/folder/inbox', 'mail'],
['/en/calendar/week/2026-11-04', 'calendar'],
['/ar/contacts/abc', 'contacts'],
['/en/files/de', 'files'],
['/en/apps/p/q', 'apps'],
['/en/settings/filters', 'settings'],
['/en/pro', 'pro'],
['/en/login', 'login'],
['/admin', 'admin'],
['/admin/logs', 'admin'],
['/admin/login', 'admin-login'],
['/setup', 'setup'],
['/connector/calendar', 'calendar'],
['/connector/admin_auth', 'admin'],
['/connector/nope', null],
['/protocol/mailto', 'mail'],
['/protocol/webcal', 'calendar'],
['/webmail/de/calendar', 'calendar'],
['/en/auth/callback', null],
['/en/plugins/oauth/callback', null],
])('%s -> %s', (path, surface) => {
expect(surfaceFromPath(path, locales)).toBe(surface);
});
});
describe('rail hints', () => {
beforeEach(() => {
localStorage.clear();
resetRailHintsMemo();
});
it('stores the rail items with mail first', () => {
writeRailHints(['calendar', 'quota']);
expect(JSON.parse(localStorage.getItem(BOOT_HINTS_KEY)!)).toEqual({ v: 1, rail: ['mail', 'calendar', 'quota'] });
});
it('skips writing the same value twice', () => {
writeRailHints(['files']);
localStorage.removeItem(BOOT_HINTS_KEY);
writeRailHints(['files']);
expect(localStorage.getItem(BOOT_HINTS_KEY)).toBeNull();
writeRailHints(['files', 'admin']);
expect(JSON.parse(localStorage.getItem(BOOT_HINTS_KEY)!).rail).toEqual(['mail', 'files', 'admin']);
});
});
+28
View File
@@ -0,0 +1,28 @@
import { BOOT_HINTS_KEY } from './keys';
/**
* What the rail showed, for the next page load's skeleton: the apps and the
* optional footer items whose presence depends on the server (capabilities,
* admin rights, quota), which nothing else keeps across a reload. The boot
* script turns it into `data-boot-rail` (lib/boot-skeleton/boot-script.ts).
*/
export type RailHint = 'calendar' | 'contacts' | 'files' | 'admin' | 'a11y' | 'quota';
let lastWritten: string | null = null;
export function writeRailHints(items: RailHint[]): void {
const rail = ['mail', ...items];
const value = JSON.stringify({ v: 1, rail });
if (value === lastWritten) return;
lastWritten = value;
try {
localStorage.setItem(BOOT_HINTS_KEY, value);
} catch {
/* storage full or blocked: the next skeleton draws every app */
}
}
/** For tests. */
export function resetRailHintsMemo(): void {
lastWritten = null;
}