diff --git a/README.md b/README.md
index c857ea8..305c0d8 100644
--- a/README.md
+++ b/README.md
@@ -69,7 +69,10 @@ no database, no data leaving your machine except the requests you send to Ollama
IndexedDB log, and each activity gets a light **SM-2 schedule** — correct answers come back after
1 day, then 3, then interval × ease; wrong ones after ten minutes. The Library shows a **Smart
review** card with the due count, a **Mistakes** count (activities whose latest answer was wrong —
- they clear themselves once answered correctly) and today's **daily goal** progress. A **Progress**
+ they clear themselves once answered correctly) and today's **daily goal** progress. Tapping
+ either one shows a **loading screen instantly** (the link reports its own pending state, the
+ route has a loading boundary, and the payload is prefetched), so even a slow first request never
+ looks like a dead tap. A **Progress**
tab has the day streak, a five-week calendar, accuracy per subject and per deck and
the weakest topics. **Exam mode** (its own tab) takes one or more decks, a question count and an
optional timer, hides hints and shows a per-deck/per-topic breakdown at the end. **Daily
@@ -1044,6 +1047,24 @@ Things I chose while building, with the reasoning:
Back from every tab and the ⋯ sheet) plus the wizard suite (92 checks, including the
loading-lock and abort-on-leave cases) and the other standing suites.
+44. **Smart Review must never look like a dead tap.** The reported symptom was a tap on *Review
+ due* sitting for many seconds with no feedback. The flow itself is local and fast (the counts
+ are already hydrated on the library; measurements show <1.5s even at 6× CPU throttle with
+ 3,000 answers), so the visible fault was the missing feedback: `/review` is a dynamic route
+ (search params), and until its payload arrived the screen simply didn't change. Three layers
+ now cover it: the card links **prefetch** their payload; the link's own **`useLinkStatus`**
+ pending state renders a full-screen loading screen (`LinkLoadingScreen`) the instant the tap
+ happens — measured at **58 ms** even with the route request held open; and `loading.tsx`
+ boundaries cover the render/stream phase (review, progress and exam). The client screen's own
+ bare skeleton was replaced with the same loading screen. While in there, health probes got a
+ per-key 30-second cache plus in-flight sharing (`use-health.ts`): every page mounts its own
+ gate and each probe can sit for seconds while Ollama answers, so a session now fires **one**
+ probe instead of one per navigation (verified). Also fixed a Czech typo on the exam heading
+ (“Zkouškové režim” → “Zkouškový režim”). Verified by the review-loading suite (15 checks:
+ stalled-request loading screen, session arrival after release, the normal fast path, the
+ mistakes path, end-to-end grading, and the probe cache) and the rebuilt navigation suite
+ (78 checks).
+
36. **The mobile declutter pass reshapes, it doesn't fork.** Phones (≤639px) get a 3-step deck
flow and a 2-step homework flow that share the exact state and handlers with the desktop
cards (one `UploadScreen` / one `HomeworkInput`, two layouts switched by `useIsPhone()`);
diff --git a/app/exam/loading.tsx b/app/exam/loading.tsx
new file mode 100644
index 0000000..0765d5a
--- /dev/null
+++ b/app/exam/loading.tsx
@@ -0,0 +1,6 @@
+import { RouteLoading } from "@/components/RouteLoading";
+
+/** Shown the instant the exam route starts loading. */
+export default function ExamLoading() {
+ return ;
+}
diff --git a/app/review/loading.tsx b/app/review/loading.tsx
new file mode 100644
index 0000000..fdf8767
--- /dev/null
+++ b/app/review/loading.tsx
@@ -0,0 +1,6 @@
+import { RouteLoading } from "@/components/RouteLoading";
+
+/** Shown the instant the review route starts loading. */
+export default function ReviewLoading() {
+ return ;
+}
diff --git a/app/stats/loading.tsx b/app/stats/loading.tsx
new file mode 100644
index 0000000..c79d5e2
--- /dev/null
+++ b/app/stats/loading.tsx
@@ -0,0 +1,6 @@
+import { RouteLoading } from "@/components/RouteLoading";
+
+/** Shown the instant the progress route starts loading. */
+export default function StatsLoading() {
+ return ;
+}
diff --git a/components/LibraryScreen.tsx b/components/LibraryScreen.tsx
index 604f0da..713a4a4 100644
--- a/components/LibraryScreen.tsx
+++ b/components/LibraryScreen.tsx
@@ -8,6 +8,7 @@ import { DeckCard } from "@/components/DeckCard";
import { FolderBar } from "@/components/FolderBar";
import { HomeworkCard } from "@/components/HomeworkCard";
import { EllipsisIcon, FolderIcon, UploadIcon } from "@/components/icons";
+import { LinkLoadingScreen } from "@/components/LinkLoadingScreen";
import { SegmentedPill } from "@/components/SegmentedPill";
import { Button, EmptyState, ProgressBar } from "@/components/ui";
import { createFolder, folderStore, renameFolder } from "@/lib/folders";
@@ -306,6 +307,7 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
diff --git a/components/LinkLoadingScreen.tsx b/components/LinkLoadingScreen.tsx
new file mode 100644
index 0000000..c3d1a51
--- /dev/null
+++ b/components/LinkLoadingScreen.tsx
@@ -0,0 +1,23 @@
+"use client";
+
+import { useLinkStatus } from "next/link";
+
+import { RouteLoading } from "@/components/RouteLoading";
+
+/**
+ * A full-screen loading screen that appears the instant the surrounding
+ * `` starts navigating — before any response arrives — so a slow route
+ * never looks like a dead tap. Renders nothing while idle and disappears with
+ * the page that owns it.
+ */
+export function LinkLoadingScreen({ messageKey }: { messageKey: string }) {
+ const { pending } = useLinkStatus();
+
+ if (!pending) return null;
+
+ return (
+
+
+
+ );
+}
diff --git a/components/ReviewClient.tsx b/components/ReviewClient.tsx
index 28f2892..86b4e04 100644
--- a/components/ReviewClient.tsx
+++ b/components/ReviewClient.tsx
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { useRouter } from "next/navigation";
import { SessionFeed, type SessionItem } from "@/components/SessionFeed";
+import { RouteLoading } from "@/components/RouteLoading";
import { Button } from "@/components/ui";
import { haptic } from "@/lib/haptics";
import { ensureLearningHydrated, learningStore, recordAnswer } from "@/lib/history";
@@ -53,14 +54,7 @@ export function ReviewClient({ mistakes, deckId }: { mistakes: boolean; deckId?:
: t("review.title");
if (!ready) {
- return (
-
-
-
-
-
-
- );
+ return ;
}
if (!session) {
diff --git a/components/RouteLoading.tsx b/components/RouteLoading.tsx
new file mode 100644
index 0000000..ad6d8aa
--- /dev/null
+++ b/components/RouteLoading.tsx
@@ -0,0 +1,31 @@
+"use client";
+
+import { Spinner } from "@/components/ui";
+import { useI18n } from "@/lib/i18n";
+
+/**
+ * Route-level loading screen (used by `loading.tsx` boundaries and by client
+ * screens that wait for local data). Rendered the moment a navigation starts,
+ * so a slow request never looks like a dead tap.
+ */
+export function RouteLoading({ messageKey }: { messageKey: string }) {
+ const { t } = useI18n();
+
+ return (
+