diff --git a/apps/web/src/features/coach/components/LessonPlayer.spec.tsx b/apps/web/src/features/coach/components/LessonPlayer.spec.tsx
index 07e0f0c..21c7a04 100644
--- a/apps/web/src/features/coach/components/LessonPlayer.spec.tsx
+++ b/apps/web/src/features/coach/components/LessonPlayer.spec.tsx
@@ -88,4 +88,22 @@ describe('LessonPlayer', () => {
render()
expect(screen.getByText('Leçon introuvable')).toBeDefined()
})
+
+ it('should link the last step of an intermediate chapter to the next chapter in journey order', async () => {
+ const user = userEvent.setup()
+ render()
+ await user.click(screen.getByLabelText(/^Étape 5 :/))
+
+ const next = screen.getByRole('link', { name: 'Chapitre suivant' })
+ expect(next.getAttribute('href')).toBe('/coach/white-corners')
+ })
+
+ it('should link the last step of the final chapter back to the Coach index', async () => {
+ const user = userEvent.setup()
+ render()
+ await user.click(screen.getByLabelText(/^Étape 3 :/))
+
+ const back = screen.getByRole('link', { name: 'Retour au Coach' })
+ expect(back.getAttribute('href')).toBe('/coach')
+ })
})
diff --git a/apps/web/src/features/coach/data/illustrative.spec.ts b/apps/web/src/features/coach/data/illustrative.spec.ts
index fc98b63..3594345 100644
--- a/apps/web/src/features/coach/data/illustrative.spec.ts
+++ b/apps/web/src/features/coach/data/illustrative.spec.ts
@@ -4,6 +4,7 @@ import { describe, expect, it } from 'bun:test'
import {
secondLayerState,
+ warmMilestones,
whiteCornersState,
whiteCrossOnlyState,
yellowCrossState,
@@ -87,3 +88,13 @@ describe('production milestones are genuine, reachable partial states (SUG-1)',
expect(D_CORNERS.every(p => cornerHome(s, p))).toBe(true)
})
})
+
+describe('warmMilestones (#17 idle prewarm)', () => {
+ it('primes the milestone cache without throwing, idempotently', () => {
+ expect(() => warmMilestones()).not.toThrow()
+ // a second call is a cache hit — still safe, no recompute side effects
+ expect(() => warmMilestones()).not.toThrow()
+ // states are genuine and available once warmed
+ expect(twoLayersComplete(secondLayerState())).toBe(true)
+ })
+})
diff --git a/apps/web/src/features/coach/data/illustrative.ts b/apps/web/src/features/coach/data/illustrative.ts
index d0870e5..c06a6ab 100644
--- a/apps/web/src/features/coach/data/illustrative.ts
+++ b/apps/web/src/features/coach/data/illustrative.ts
@@ -124,6 +124,14 @@ const computeMilestones = (): Milestones => {
const milestones = (): Milestones => (cache ??= compute())
+// Eagerly fill the memoised cache. The first milestone access runs solveCube on the
+// fixed scramble (~420ms on the main thread); calling this from the Coach route during
+// idle time means the first lesson's first demo hits a warm cache instead of paying
+// that cost mid-click (#17). Idempotent — a second call is a cheap cache hit.
+export const warmMilestones = (): void => {
+ milestones()
+}
+
// CubeState milestones — the store builds a case demo as applyMoves(milestone,
// invertMoves(alg)), so it needs the state, not just the stickers.
export const whiteCrossOnlyState = (): CubeState => milestones().whiteCrossOnly
diff --git a/apps/web/src/features/coach/index.ts b/apps/web/src/features/coach/index.ts
index c7443e0..8feeb5e 100644
--- a/apps/web/src/features/coach/index.ts
+++ b/apps/web/src/features/coach/index.ts
@@ -1,4 +1,5 @@
export { CoachErrorBoundary, LessonBrowser, LessonPlayer } from './components'
+export { warmMilestones } from './data/illustrative'
export { LESSONS, getLesson } from './data/lessons'
export type { Lesson, LessonStep } from './data/types'
export { stepAlgorithmId } from './data/types'
diff --git a/apps/web/src/pages/Coach.tsx b/apps/web/src/pages/Coach.tsx
index 4d90c7e..93c0b90 100644
--- a/apps/web/src/pages/Coach.tsx
+++ b/apps/web/src/pages/Coach.tsx
@@ -1,7 +1,25 @@
-import { CoachErrorBoundary, LessonBrowser, LessonPlayer } from '~/features/coach'
+import { useEffect } from 'react'
-export const Coach = ({ lessonId }: { lessonId?: string }) => (
-
- {lessonId ? : }
-
-)
+import { CoachErrorBoundary, LessonBrowser, LessonPlayer, warmMilestones } from '~/features/coach'
+
+export const Coach = ({ lessonId }: { lessonId?: string }) => {
+ // Prewarm the solver-derived milestone cache during idle time: the first milestone
+ // access runs solveCube (~420ms) on the main thread, so doing it here — while the
+ // learner reads the first screen — means the first demo opens against a warm cache
+ // instead of freezing on the click (#17). Falls back to a short timer where
+ // requestIdleCallback is unavailable; the cleanup cancels it on unmount.
+ useEffect(() => {
+ if (typeof window.requestIdleCallback === 'function') {
+ const handle = window.requestIdleCallback(() => warmMilestones())
+ return () => window.cancelIdleCallback(handle)
+ }
+ const timer = setTimeout(warmMilestones, 200)
+ return () => clearTimeout(timer)
+ }, [])
+
+ return (
+
+ {lessonId ? : }
+
+ )
+}