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 ( +