From 8d790b29fdb30f47d83ed359e46c1573b98c9937 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sun, 1 Nov 2026 18:41:58 +0100
Subject: [PATCH] feat: show the page's skeleton on Lite pages while their
language loads
---
app/boot-skeleton.css | 92 +++++++
app/globals.css | 1 +
.../__tests__/hydration.test.tsx | 59 +++++
components/boot-skeleton/admin-skeleton.tsx | 12 +
.../boot-skeleton/calendar-skeleton.tsx | 17 ++
.../boot-skeleton/contacts-skeleton.tsx | 17 ++
components/boot-skeleton/files-skeleton.tsx | 17 ++
components/boot-skeleton/kit.tsx | 100 +++++++
components/boot-skeleton/login-skeleton.tsx | 12 +
components/boot-skeleton/mail-skeleton.tsx | 17 ++
.../boot-skeleton/plugin-page-skeleton.tsx | 17 ++
components/boot-skeleton/pro-skeleton.tsx | 12 +
components/boot-skeleton/route-skeleton.tsx | 49 ++++
.../boot-skeleton/settings-skeleton.tsx | 17 ++
components/boot-skeleton/setup-skeleton.tsx | 12 +
components/boot-skeleton/shell.tsx | 249 ++++++++++++++++++
components/layout/navigation-rail.tsx | 17 ++
.../__tests__/intl-provider-lite.test.tsx | 18 +-
components/providers/intl-provider.tsx | 13 +-
lib/boot-skeleton/__tests__/surfaces.test.ts | 78 ++++++
lib/boot-skeleton/hints.ts | 28 ++
21 files changed, 852 insertions(+), 2 deletions(-)
create mode 100644 app/boot-skeleton.css
create mode 100644 components/boot-skeleton/__tests__/hydration.test.tsx
create mode 100644 components/boot-skeleton/admin-skeleton.tsx
create mode 100644 components/boot-skeleton/calendar-skeleton.tsx
create mode 100644 components/boot-skeleton/contacts-skeleton.tsx
create mode 100644 components/boot-skeleton/files-skeleton.tsx
create mode 100644 components/boot-skeleton/kit.tsx
create mode 100644 components/boot-skeleton/login-skeleton.tsx
create mode 100644 components/boot-skeleton/mail-skeleton.tsx
create mode 100644 components/boot-skeleton/plugin-page-skeleton.tsx
create mode 100644 components/boot-skeleton/pro-skeleton.tsx
create mode 100644 components/boot-skeleton/route-skeleton.tsx
create mode 100644 components/boot-skeleton/settings-skeleton.tsx
create mode 100644 components/boot-skeleton/setup-skeleton.tsx
create mode 100644 components/boot-skeleton/shell.tsx
create mode 100644 lib/boot-skeleton/__tests__/surfaces.test.ts
create mode 100644 lib/boot-skeleton/hints.ts
diff --git a/app/boot-skeleton.css b/app/boot-skeleton.css
new file mode 100644
index 000000000..87664f686
--- /dev/null
+++ b/app/boot-skeleton.css
@@ -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 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;
+ }
+}
diff --git a/app/globals.css b/app/globals.css
index 34e47123a..fb405c99a 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -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
diff --git a/components/boot-skeleton/__tests__/hydration.test.tsx b/components/boot-skeleton/__tests__/hydration.test.tsx
new file mode 100644
index 000000000..30b4269cd
--- /dev/null
+++ b/components/boot-skeleton/__tests__/hydration.test.tsx
@@ -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 = ;
+ 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();
+ // 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();
+ }
+ });
+});
diff --git a/components/boot-skeleton/admin-skeleton.tsx b/components/boot-skeleton/admin-skeleton.tsx
new file mode 100644
index 000000000..5267114e7
--- /dev/null
+++ b/components/boot-skeleton/admin-skeleton.tsx
@@ -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 ;
+}
diff --git a/components/boot-skeleton/calendar-skeleton.tsx b/components/boot-skeleton/calendar-skeleton.tsx
new file mode 100644
index 000000000..bea949da2
--- /dev/null
+++ b/components/boot-skeleton/calendar-skeleton.tsx
@@ -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 (
+
+
+
+ );
+}
diff --git a/components/boot-skeleton/contacts-skeleton.tsx b/components/boot-skeleton/contacts-skeleton.tsx
new file mode 100644
index 000000000..a44360ce7
--- /dev/null
+++ b/components/boot-skeleton/contacts-skeleton.tsx
@@ -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 (
+
+
+
+ );
+}
diff --git a/components/boot-skeleton/files-skeleton.tsx b/components/boot-skeleton/files-skeleton.tsx
new file mode 100644
index 000000000..6a3b8be40
--- /dev/null
+++ b/components/boot-skeleton/files-skeleton.tsx
@@ -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 (
+
+
+
+ );
+}
diff --git a/components/boot-skeleton/kit.tsx b/components/boot-skeleton/kit.tsx
new file mode 100644
index 000000000..310ca466e
--- /dev/null
+++ b/components/boot-skeleton/kit.tsx
@@ -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 (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 (
+
+ {label ? {label} : null}
+ {children}
+
+ );
+}
+
+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 (
+
+ );
+}
+
+/** A placeholder for an avatar or a round button: give it a size (w-8 h-8). */
+export function Circle({ className, style }: Omit) {
+ return ;
+}
+
+/** A placeholder for a box: a button, an input, a card. */
+export function Block({ className, w, style }: ShapeProps) {
+ return (
+
+ );
+}
+
+/**
+ * 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({render(i)});
+ return out;
+}
diff --git a/components/boot-skeleton/login-skeleton.tsx b/components/boot-skeleton/login-skeleton.tsx
new file mode 100644
index 000000000..2a25a7a04
--- /dev/null
+++ b/components/boot-skeleton/login-skeleton.tsx
@@ -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 ;
+}
diff --git a/components/boot-skeleton/mail-skeleton.tsx b/components/boot-skeleton/mail-skeleton.tsx
new file mode 100644
index 000000000..83b33f68a
--- /dev/null
+++ b/components/boot-skeleton/mail-skeleton.tsx
@@ -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 (
+
+
+
+ );
+}
diff --git a/components/boot-skeleton/plugin-page-skeleton.tsx b/components/boot-skeleton/plugin-page-skeleton.tsx
new file mode 100644
index 000000000..116e566b1
--- /dev/null
+++ b/components/boot-skeleton/plugin-page-skeleton.tsx
@@ -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 (
+
+
+
+ );
+}
diff --git a/components/boot-skeleton/pro-skeleton.tsx b/components/boot-skeleton/pro-skeleton.tsx
new file mode 100644
index 000000000..a516c8061
--- /dev/null
+++ b/components/boot-skeleton/pro-skeleton.tsx
@@ -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 ;
+}
diff --git a/components/boot-skeleton/route-skeleton.tsx b/components/boot-skeleton/route-skeleton.tsx
new file mode 100644
index 000000000..73b6aae45
--- /dev/null
+++ b/components/boot-skeleton/route-skeleton.tsx
@@ -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 ;
+ case "calendar":
+ return ;
+ case "contacts":
+ return ;
+ case "files":
+ return ;
+ case "apps":
+ return ;
+ case "settings":
+ return ;
+ case "pro":
+ return ;
+ case "admin":
+ return ;
+ case "admin-login":
+ case "login":
+ return ;
+ case "setup":
+ return ;
+ }
+}
diff --git a/components/boot-skeleton/settings-skeleton.tsx b/components/boot-skeleton/settings-skeleton.tsx
new file mode 100644
index 000000000..dc031a375
--- /dev/null
+++ b/components/boot-skeleton/settings-skeleton.tsx
@@ -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 (
+
+
+
+ );
+}
diff --git a/components/boot-skeleton/setup-skeleton.tsx b/components/boot-skeleton/setup-skeleton.tsx
new file mode 100644
index 000000000..924535a67
--- /dev/null
+++ b/components/boot-skeleton/setup-skeleton.tsx
@@ -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 ;
+}
diff --git a/components/boot-skeleton/shell.tsx b/components/boot-skeleton/shell.tsx
new file mode 100644
index 000000000..806f9e57b
--- /dev/null
+++ b/components/boot-skeleton/shell.tsx
@@ -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>;
+
+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 (
+
+
+
+ {APPS.map(({ id, icon: Icon }) => (
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+/**
+ * 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 (
+
+ {items.map(({ id, icon: Icon }) => (
+
+
+
+ {active === id ? (
+
+ ) : null}
+
+ {labels?.[id] ? (
+ {labels[id]}
+ ) : (
+
+ )}
+
+ ))}
+
+ );
+}
+
+/** 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 (
+
+
+
+
+
+
+
+
+
+ {search ? (
+
+
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+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 (
+ <>
+
+
+
+
+
+
+
+ {children}
+
+
+
+ >
+ );
+}
diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx
index e6016a1d0..71653b1d8 100644
--- a/components/layout/navigation-rail.tsx
+++ b/components/layout/navigation-rail.tsx
@@ -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) {
diff --git a/components/providers/__tests__/intl-provider-lite.test.tsx b/components/providers/__tests__/intl-provider-lite.test.tsx
index 25b06cc7d..56a6efeb2 100644
--- a/components/providers/__tests__/intl-provider-lite.test.tsx
+++ b/components/providers/__tests__/intl-provider-lite.test.tsx
@@ -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()),
+ 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()).toBe('');
});
+ it('prerenders the page skeleton, without any text, while the catalog is missing', () => {
+ nav.pathname = '/de/calendar';
+ const html = renderToString();
+ 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()).toContain('Cancel');
});
diff --git a/components/providers/intl-provider.tsx b/components/providers/intl-provider.tsx
index 9b3b564fb..ace1f1018 100644
--- a/components/providers/intl-provider.tsx
+++ b/components/providers/intl-provider.tsx
@@ -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 ? : null;
+ }
return (
{
+ 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 = { '': 'mail' };
+ for (const name of TARGET_NAMES) {
+ const target = getTarget(name)!;
+ const params: Record = {};
+ 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']);
+ });
+});
diff --git a/lib/boot-skeleton/hints.ts b/lib/boot-skeleton/hints.ts
new file mode 100644
index 000000000..cc93fdac9
--- /dev/null
+++ b/lib/boot-skeleton/hints.ts
@@ -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;
+}