diff --git a/src/app/globals.css b/src/app/globals.css
index 192d890..cdd3e96 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -32,6 +32,43 @@ html {
animation: fadeInUp 0.5s ease-out forwards;
}
+/* ─── Loading placeholders ─── */
+
+/* A light band sweeping left→right, in the spirit of the reveal animations
+ below — quieter than a pulsing opacity blink, and it reads as "typesetting
+ in progress" rather than "widget buffering". */
+@keyframes skeletonSweep {
+ from { background-position: 150% 0; }
+ to { background-position: -150% 0; }
+}
+
+/* Placeholder for a line of body text — light grey from the charcoal ramp,
+ one step lighter than --color-border-light so placeholders stay quieter
+ than the rules and frames around them. */
+.skeleton-bar {
+ background-color: var(--color-charcoal-200);
+ background-image: linear-gradient(
+ 90deg,
+ transparent 20%,
+ var(--color-charcoal-100) 50%,
+ transparent 80%
+ );
+ background-size: 200% 100%;
+ background-repeat: no-repeat;
+ animation: skeletonSweep 2s ease-in-out infinite;
+}
+
+/* Heavier weight, for headings and other display type. */
+.skeleton-bar-strong {
+ background-color: var(--color-charcoal-300);
+}
+
+/* Staggers the sweep down a stack so it travels through the block instead of
+ every line flashing in unison. */
+.skeleton-delay-1 { animation-delay: 0.15s; }
+.skeleton-delay-2 { animation-delay: 0.3s; }
+.skeleton-delay-3 { animation-delay: 0.45s; }
+
/* Draw stroke forward (dashoffset 1 → 0) */
@keyframes drawIn {
from { stroke-dashoffset: 1; }
diff --git a/src/app/memos/[slug]/MemoSkeleton.tsx b/src/app/memos/[slug]/MemoSkeleton.tsx
new file mode 100644
index 0000000..48dfc5d
--- /dev/null
+++ b/src/app/memos/[slug]/MemoSkeleton.tsx
@@ -0,0 +1,153 @@
+// The placeholder a memo route shows while its content streams in.
+//
+// It mirrors the real layout in page.tsx (hero → signpost → key messages →
+// body) at the same widths and spacing, so swapping in the real memo doesn't
+// shift anything on screen.
+//
+// Chrome that's identical on every memo — the Key Messages frame and its
+// eyebrow, the numbered indices, the Signpost's rail and Share block — is
+// rendered for real rather than faked as grey blocks. Only the parts that
+// differ per memo are placeholders, which keeps the page recognisably a memo
+// while it loads instead of a generic loading card.
+
+const KEY_MESSAGES_FILL = {
+ // The fills the real Key Messages boxes use (see page.tsx). Hardcoded there
+ // too — they predate the linen ramp.
+ default: "bg-[#f0e5dc]",
+ toronto: "bg-[#d7e4f3]",
+} as const;
+
+const DELAYS = ["", "skeleton-delay-1", "skeleton-delay-2", "skeleton-delay-3"];
+
+// One placeholder line. `i` staggers the sweep down a stack.
+function Line({
+ className,
+ strong = false,
+ i = 0,
+}: {
+ className: string;
+ strong?: boolean;
+ i?: number;
+}) {
+ return (
+
+ );
+}
+
+export function MemoSkeleton({
+ brand = "default",
+ showBackLink = false,
+}: {
+ brand?: keyof typeof KEY_MESSAGES_FILL;
+ showBackLink?: boolean;
+}) {
+ return (
+
+
Loading memo…
+
+
+ {/* Hero — mirrors MemoHero: title, then author frame beside name/date.
+ Title lines are display-weight; the author frame uses the same
+ border-light square MemoHero shows before its photo loads. */}
+
+ {showBackLink &&
}
+
+
+
+
+
+
+
+
+
+
+ {/* Signpost — the desktop-only rail. Its accent top rule, Share
+ eyebrow, share buttons and dotted track are all real. */}
+
+
+
+
+ Share
+
+
+ {[0, 1, 2].map((i) => (
+
+ ))}
+
+
+
+
+ {/* The rail's 2px track, matching Track's geometry. */}
+
+
+ {["w-full", "w-4/5", "w-11/12", "w-3/4", "w-5/6"].map(
+ (w, i) => (
+
+ ),
+ )}
+
+
+
+
+
+
+ {/* Key Messages — real frame, real eyebrow, real numerals. */}
+
+
Key Messages
+ {["w-full", "w-11/12", "w-4/5"].map((w, i) => (
+
+
+ {String(i + 1).padStart(2, "0")}
+
+
+
+
+
+
+ ))}
+
+
+ {/* Body — paragraphs broken by section headings, on the same
+ ~1.4 line-height rhythm as type-body prose. */}
+
+ {[0, 1, 2].map((section) => (
+
+ {section > 0 && (
+
+ )}
+ {["w-full", "w-full", "w-11/12", "w-full", "w-2/3"].map(
+ (w, i) => (
+
+ ),
+ )}
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/src/app/memos/[slug]/loading.tsx b/src/app/memos/[slug]/loading.tsx
new file mode 100644
index 0000000..0e1195a
--- /dev/null
+++ b/src/app/memos/[slug]/loading.tsx
@@ -0,0 +1,9 @@
+import { MemoSkeleton } from "./MemoSkeleton";
+
+// This route reads cookies (draft preview + viewer state), so it renders
+// dynamically on every request and its prefetch payload is empty. Without a
+// loading boundary a click leaves the previous page on screen for the whole
+// round trip, which reads as a dead click.
+export default function Loading() {
+ return ;
+}
diff --git a/src/app/memos/[slug]/page.tsx b/src/app/memos/[slug]/page.tsx
index e7f4b2d..c5c2554 100644
--- a/src/app/memos/[slug]/page.tsx
+++ b/src/app/memos/[slug]/page.tsx
@@ -80,10 +80,16 @@ export default async function MemoDetailPage({
// engagement UI's signed-in / postal-code-ready states.
const viewer = await getCurrentUser();
- let memo;
- try {
- memo = await fetchMemo(slug);
- } catch {
+ // The memo and the list backing "related memos" don't depend on each other,
+ // so they go out together rather than one after the other. Both must start
+ // after primeAdminPreviewToken, which seeds the token apiFetch reads.
+ // Related memos are decorative — if that list fails the memo still renders.
+ const [memo, allMemos] = await Promise.all([
+ fetchMemo(slug).catch(() => null),
+ fetchMemos().catch(() => []),
+ ]);
+
+ if (!memo) {
notFound();
}
@@ -142,7 +148,6 @@ export default async function MemoDetailPage({
generateBreadcrumbSchema(`/memos/${memo.slug}`, memo.title, configData.siteUrl)
);
- const allMemos = await fetchMemos();
const sameCategory = allMemos.filter(
(m) => m.slug !== memo.slug && memo.category && m.category === memo.category,
);
diff --git a/src/app/toronto/memos/[slug]/loading.tsx b/src/app/toronto/memos/[slug]/loading.tsx
new file mode 100644
index 0000000..3d95546
--- /dev/null
+++ b/src/app/toronto/memos/[slug]/loading.tsx
@@ -0,0 +1,5 @@
+import { MemoSkeleton } from "@/app/memos/[slug]/MemoSkeleton";
+
+export default function Loading() {
+ return ;
+}
diff --git a/src/app/toronto/memos/[slug]/page.tsx b/src/app/toronto/memos/[slug]/page.tsx
index 7cd380e..320084e 100644
--- a/src/app/toronto/memos/[slug]/page.tsx
+++ b/src/app/toronto/memos/[slug]/page.tsx
@@ -72,10 +72,16 @@ export default async function TorontoMemoDetailPage({
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
- let memo;
- try {
- memo = await fetchMemo(slug, { publication: PUBLICATION });
- } catch {
+
+ // The memo and the list backing "related memos" don't depend on each other,
+ // so they go out together rather than one after the other. Related memos are
+ // decorative — if that list fails the memo still renders.
+ const [memo, allMemos] = await Promise.all([
+ fetchMemo(slug, { publication: PUBLICATION }).catch(() => null),
+ fetchMemos({ publication: PUBLICATION }).catch(() => []),
+ ]);
+
+ if (!memo) {
notFound();
}
@@ -129,7 +135,6 @@ export default async function TorontoMemoDetailPage({
)
);
- const allMemos = await fetchMemos({ publication: PUBLICATION });
const sameCategory = allMemos.filter(
(m) => m.slug !== memo.slug && memo.category && m.category === memo.category,
);
diff --git a/src/lib/api/memos.ts b/src/lib/api/memos.ts
index 9965a30..06f1cbf 100644
--- a/src/lib/api/memos.ts
+++ b/src/lib/api/memos.ts
@@ -69,29 +69,37 @@ export async function fetchMemos(params?: {
type MemoRow = YFMemo & { key_messages?: unknown[] };
- const all: MemoRow[] = [];
- let page = 1;
+ const fetchPage = (page: number) =>
+ apiFetch>("/memos", {
+ params: { ...queryParams, page: String(page) },
+ revalidate: 60,
+ });
- while (true) {
- queryParams.page = String(page);
- const res = await apiFetch>(
- "/memos",
- { params: queryParams, revalidate: 60 }
- );
- all.push(...res.data);
- if (page >= res.pagination.pages) break;
- page++;
- }
+ // Author titles don't depend on the memo pages, so this goes out alongside
+ // them rather than waiting for pagination to finish. Failing to resolve
+ // titles isn't fatal — memos still render without them.
+ const teamPromise = apiFetch>("/team", {
+ revalidate: 3600,
+ }).catch(() => null);
+
+ // Page 1 tells us how many pages there are; the rest are then fetched
+ // concurrently instead of one round trip at a time.
+ const first = await fetchPage(1);
+ const rest = await Promise.all(
+ Array.from({ length: Math.max(0, first.pagination.pages - 1) }, (_, i) =>
+ fetchPage(i + 2),
+ ),
+ );
+
+ const all: MemoRow[] = [first, ...rest].flatMap((res) => res.data);
const authorTitles = new Map();
- try {
- const team = await apiFetch>("/team", {
- revalidate: 3600,
- });
+ const team = await teamPromise;
+ if (team) {
for (const t of team.data) {
authorTitles.set(t.slug, t.title ?? null);
}
- } catch {}
+ }
return all.map((m) => mapMemo(m, authorTitles));
}