Add cook mode with persistent cooking timers to recipe pages#657
Conversation
Implements the Phase 2 "Kitchen Experience" cooking mode from the recipe project plan, adapted from the mid-fi designs (recipe.jsx / mobile.jsx): - Full-screen cook mode overlay on recipe detail pages: one large handwritten step at a time, prev/next with big tap targets, clickable step progress bar, arrow-key navigation, swipe gestures on touch, Escape/back-button exit, and a screen wake lock for the whole session. - Desktop shows an ingredient reference panel that highlights the ingredients used in the current step (from Cooklang tokens); mobile gets the same list in a bottom sheet. - Cook mode state is mirrored to the URL (?cook=1&step=N) via the history API, so reloads restore the session and back exits cleanly. - Timers are lifted into a global store (localStorage-backed, stored as end timestamps) shared by the inline step pills, the cook-mode timer controls, and a new floating timer dock mounted in the recipes layout, so multiple timers persist across page navigation and full reloads. - Wake lock and completion-tone handling move into shared reference- counted modules; ingredient display formatting is extracted to lib/domain/recipe/ingredientDisplay.ts for reuse by cook mode. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EZ6MMqQC1fRiqdJDdRqa4K
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (8)
🚧 Files skipped from review as they are similar to previous changes (7)
📝 WalkthroughWalkthroughThis PR introduces a global cooking-timer store with localStorage persistence, wake-lock, and alert-tone side effects; a full-screen CookMode dialog for step-by-step cooking with URL-synced state; a floating draggable TimerDock; and a shared ingredient-display formatting module, integrating all of these into recipe content and refactoring InlineTimer to be store-driven. ChangesCook Mode Feature
Sequence Diagram(s)sequenceDiagram
participant User
participant RecipeContent
participant BrowserHistory
participant CookMode
participant TimerStore
User->>RecipeContent: click "Start cooking"
RecipeContent->>BrowserHistory: pushState(cook=1&step=0)
RecipeContent->>CookMode: render with steps, step, onStepChange, onExit
User->>CookMode: navigate step / start timer
CookMode->>TimerStore: startTimer(timerId)
CookMode->>RecipeContent: onStepChange(step)
RecipeContent->>BrowserHistory: replaceState(step=N)
User->>CookMode: exit
CookMode->>RecipeContent: onExit()
RecipeContent->>BrowserHistory: pushState(cook removed)
sequenceDiagram
participant TimerDock
participant TimerStore
participant WakeLock
participant AlertTone
TimerDock->>TimerStore: subscribeTimers(callback)
TimerStore->>WakeLock: retainWakeLock (when running)
TimerStore->>TimerStore: tick() every 250ms
TimerStore->>AlertTone: playAlertTone (on completion)
TimerStore-->>TimerDock: emit change
TimerDock->>TimerStore: dismissTimer / extendTimer
TimerStore->>WakeLock: releaseWakeLock (when none running)
Possibly related PRs
Suggested labels: 🚥 Pre-merge checks | ✅ 2✅ Passed checks (2 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Preview environmentSite: https://pr-657.personal-site-bu5.pages.dev |
There was a problem hiding this comment.
Code Review
This pull request introduces a full-screen "Cook Mode" feature for recipes, featuring step-by-step navigation, wake lock support, and a global, persistent cooking timer store. It also adds a floating TimerDock to display active timers across the application, refactors inline timers to sync with this global store, and includes comprehensive unit tests. The review feedback highlights two important accessibility and UX improvements for the Cook Mode overlay: increasing the touch target size of the step progress buttons for mobile users, and implementing focus trapping to prevent keyboard navigation from escaping the full-screen modal.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
Addresses review feedback on #657: - Trap Tab/Shift+Tab inside the cook-mode dialog so keyboard focus cannot escape to the page covered by the overlay. - Pad the step progress-bar buttons vertically so their touch targets are comfortably tappable while the visual bar stays thin. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EZ6MMqQC1fRiqdJDdRqa4K
Multiple running timers previously rendered as a stack of separate chips that could cover the cook-mode step content, with no way to move them, and pausing widened a chip (the "· paused" suffix). - The dock is now one compact pill by default, showing the most urgent timer (completed first, then soonest-ending) plus a "+N" badge; tapping expands it into a panel with per-timer controls. - A grip handle makes the dock draggable via pointer events (touch and mouse); the position is clamped to the viewport and persisted to localStorage. Until dragged, the default bottom-right anchor and the cook-mode footer offset still apply. - Paused state is now shown by the resume icon and a dimmed countdown instead of a text suffix, so the width no longer jumps. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EZ6MMqQC1fRiqdJDdRqa4K
Expanding the dock (or adding timers while expanded) could grow it past the screen edges when it had been dragged near one — leaving the collapse button and per-timer controls unreachable. - Re-clamp the dragged position whenever the dock's size changes (expand/collapse, timer count) and on window resize/rotation. - Cap the expanded timer list at 45vh with internal scrolling and cap the panel width to the viewport. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EZ6MMqQC1fRiqdJDdRqa4K
- Use a native <dialog> element (non-modal `open`, not showModal(), so the top layer doesn't paint over the floating timer dock) with UA styles neutralised, instead of role="dialog" on a div. - Reduce cognitive complexity: extract trapFocus/isEditableTarget from the cook-mode keydown handler, and a shared unitLabelFor helper from formatAmount's nested unit-label logic. - Extract nested ternaries into named helpers (progressSegmentColor, timerCircleBackground, dotColor as if-chains, amountText). - Avoid negated conditions where an else exists; prefer ??= over guarded assignments; prefer globalThis over window; mark component props Readonly<>. No behaviour change intended; the full browser verification suite (26 desktop/mobile checks plus the dock clamping suite) still passes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EZ6MMqQC1fRiqdJDdRqa4K
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
PR Summary by QodoAdd cook mode overlay and persistent timers for recipe pages
AI Description
Diagram
High-Level Assessment
Files changed (12)
|
Code Review by Qodo
Context used✅ Compliance rules (platform):
4 rules 1.
|
|
| Filename | Overview |
|---|---|
| ui/lib/cooking/timerStore.ts | New global timer store using useSyncExternalStore pattern; persists timers as absolute end-timestamps in localStorage with cross-tab sync. Logic is sound but __resetTimerStoreForTests omits resetting globalListenersHooked, weakening test isolation for storage/visibility event paths. |
| ui/components/recipes/cook-mode.tsx | New full-screen cook-mode overlay using a non-modal dialog open with manual focus trapping; intentional top-layer bypass for timer dock compatibility. aria-modal=true on a non-modal dialog may not restrict screen-reader virtual cursor, leaving background content reachable to AT users. |
| ui/components/recipes/recipe-content.tsx | Cook mode integration and URL-state management added cleanly; formatting helpers extracted to ingredientDisplay.ts. Step key generation uses joined token values which can collide for duplicate-text steps. |
| ui/components/recipes/timer-dock.tsx | New floating draggable timer dock; pointer-capture drag, viewport clamping on resize/expand, and localStorage position persistence are all correctly implemented. |
| ui/hooks/use-cooking-timers.ts | Thin useSyncExternalStore wrappers over the timer store. useCookingTimer subscribes every InlineTimer to the full store, causing all timer pills to re-render on every 250 ms tick regardless of their own state. |
| ui/tests/lib/cooking/timer-store.test.ts | Good coverage of countdown, pause/resume, extend, completion, and localStorage restore including expiry-while-away. Cross-tab (storage event) and visibilitychange paths are not tested because globalListenersHooked is not reset between tests. |
| ui/lib/cooking/wakeLock.ts | Reference-counted wake lock with epoch-based in-flight cancellation and automatic re-acquisition on visibility change; implementation is correct. |
| ui/lib/cooking/alertTone.ts | Shared Web Audio context that must be unlocked from a user gesture before the background tick can play the completion tone; correctly extracted from the old inline-timer. |
| ui/lib/domain/recipe/ingredientDisplay.ts | Clean extraction of ingredient formatting helpers from recipe-content.tsx for reuse in cook mode; logic is unchanged from the original. |
| ui/components/recipes/inline-timer.tsx | Simplified from self-contained local state to a thin view over the global timer store; all previous logic removed cleanly. |
| ui/app/recipes/layout.tsx | TimerDock mounted outside the isolated .recipe-surface stacking context so it paints above the cook-mode overlay; minimal, correct change. |
| ui/app/recipes/recipe-theme.css | CSS-only lift of the timer dock above the cook-mode footer when .rt-cook-mode-open is set on body; responsive breakpoints are correct. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
U([User]) --> RC[RecipeContent]
RC -->|openCookMode| HA[History API\npushState ?cook=1]
HA --> CM[CookMode overlay\nportaled to body]
CM -->|onExit| HB[history.back / replaceState]
HB -->|popstate| RC
RC --> IL[InlineTimer pill\nper step]
CM --> CT[CookModeTimer\nlarge control]
RL[RecipesLayout] --> TD[TimerDock\nfloating dock]
IL --> TS[(timerStore\nmodule singleton)]
CT --> TS
TD --> TS
TS -->|persist| LS[(localStorage\nendTimeMs)]
LS -->|loadStored on hydrate| TS
TS -->|storage event| TS
TS --> WL[wakeLock.ts\nref-counted]
TS --> AT[alertTone.ts\nshared AudioContext]
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
U([User]) --> RC[RecipeContent]
RC -->|openCookMode| HA[History API\npushState ?cook=1]
HA --> CM[CookMode overlay\nportaled to body]
CM -->|onExit| HB[history.back / replaceState]
HB -->|popstate| RC
RC --> IL[InlineTimer pill\nper step]
CM --> CT[CookModeTimer\nlarge control]
RL[RecipesLayout] --> TD[TimerDock\nfloating dock]
IL --> TS[(timerStore\nmodule singleton)]
CT --> TS
TD --> TS
TS -->|persist| LS[(localStorage\nendTimeMs)]
LS -->|loadStored on hydrate| TS
TS -->|storage event| TS
TS --> WL[wakeLock.ts\nref-counted]
TS --> AT[alertTone.ts\nshared AudioContext]
Prompt To Fix All With AI
Fix the following 4 code review issues. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 4
ui/components/recipes/cook-mode.tsx:300-312
**Non-modal `<dialog>` with `aria-modal` may not exclude background content for screen readers**
The dialog is opened with the `open` attribute (non-modal) rather than `showModal()` — an intentional choice to avoid the top-layer stacking issue. However, because `showModal()` is not used, the browser does not set `aria-modal` on the dialog's native role, and many screen readers will not restrict their virtual cursor to the dialog content even though `aria-modal="true"` is set in HTML. A user navigating with arrow keys or reading-mode shortcuts will be able to reach elements behind the overlay. The manual `trapFocus` only intercepts Tab/Shift+Tab, not screen-reader cursor movement. On VoiceOver/Safari and NVDA/Firefox this is a well-documented gap with this pattern. Adding `aria-hidden="true"` to the siblings of the portal target, or maintaining a live `inert` attribute on the background tree while the dialog is open, would close this gap.
### Issue 2 of 4
ui/hooks/use-cooking-timers.ts:6-8
**All `InlineTimer` instances re-render every 250 ms tick**
`useCookingTimer(id)` calls `useCookingTimers()` which subscribes every caller to the shared store. The store emits on every tick (every 250 ms while any timer is running) and also on every action. A recipe with N timer tokens will therefore schedule N component re-renders 4× per second — even for timers that are idle. For a long recipe this is unnecessary work. A lightweight fix is to memoize the per-timer selector so individual `InlineTimer` instances only re-render when their own timer's state changes.
### Issue 3 of 4
ui/lib/cooking/timerStore.ts:292-302
**`__resetTimerStoreForTests` does not reset `globalListenersHooked`**
After the first test that calls `subscribeTimers`, `globalListenersHooked` is set to `true` and never cleared by `__resetTimerStoreForTests`. On subsequent test runs the `storage` and `visibilitychange` listeners are not re-hooked; tests that mutate `localStorage` to verify cross-tab behaviour or depend on the visibilitychange path will silently skip those code paths, making them weaker than they appear. Adding `globalListenersHooked = false;` to the reset function would restore full isolation.
```suggestion
/** Test-only: reset module state (optionally keeping localStorage). */
export function __resetTimerStoreForTests(): void {
if (tickHandle !== null) {
clearInterval(tickHandle);
tickHandle = null;
}
releaseWakeLock(WAKE_LOCK_KEY);
timers = EMPTY_TIMERS;
hydrated = false;
globalListenersHooked = false;
listeners.clear();
}
```
### Issue 4 of 4
ui/components/recipes/recipe-content.tsx:212-214
**Step `key` can collide for two steps with identical token values**
The `key` for each `CookStep` is produced by joining every token's `.value` with `|`. Two recipe steps that happen to have the same text (e.g., two "Season to taste" steps) will produce the same key, causing a React duplicate-key warning and potential reconciliation artifacts. Using a stable positional key such as `String(stepIndex)` would be safer while still being stable across re-renders for the same recipe.
```suggestion
if (instructionTokenization?.ok === true) {
return instructionTokenization.steps.map((tokens, stepIndex) => ({
key: String(stepIndex),
```
Reviews (1): Last reviewed commit: "Address SonarQube findings on the cook m..." | Re-trigger Greptile
| className="flex min-h-0 min-w-0 flex-1 flex-col" | ||
| onTouchStart={onTouchStart} | ||
| onTouchEnd={onTouchEnd} | ||
| > | ||
| <div className="flex-1 overflow-y-auto px-5 py-6 sm:px-10 sm:py-8"> | ||
| <div className="rt-mono text-[var(--terracotta)]"> | ||
| Step {String(clampedStep + 1).padStart(2, "0")} of {steps.length} | ||
| </div> | ||
| <p | ||
| aria-live="polite" | ||
| className="rt-display mt-3 max-w-3xl text-3xl leading-[1.08] text-[var(--ink)] sm:text-4xl lg:text-5xl" | ||
| > | ||
| {current.tokens |
There was a problem hiding this comment.
Non-modal
<dialog> with aria-modal may not exclude background content for screen readers
The dialog is opened with the open attribute (non-modal) rather than showModal() — an intentional choice to avoid the top-layer stacking issue. However, because showModal() is not used, the browser does not set aria-modal on the dialog's native role, and many screen readers will not restrict their virtual cursor to the dialog content even though aria-modal="true" is set in HTML. A user navigating with arrow keys or reading-mode shortcuts will be able to reach elements behind the overlay. The manual trapFocus only intercepts Tab/Shift+Tab, not screen-reader cursor movement. On VoiceOver/Safari and NVDA/Firefox this is a well-documented gap with this pattern. Adding aria-hidden="true" to the siblings of the portal target, or maintaining a live inert attribute on the background tree while the dialog is open, would close this gap.
Prompt To Fix With AI
This is a comment left during a code review.
Path: ui/components/recipes/cook-mode.tsx
Line: 300-312
Comment:
**Non-modal `<dialog>` with `aria-modal` may not exclude background content for screen readers**
The dialog is opened with the `open` attribute (non-modal) rather than `showModal()` — an intentional choice to avoid the top-layer stacking issue. However, because `showModal()` is not used, the browser does not set `aria-modal` on the dialog's native role, and many screen readers will not restrict their virtual cursor to the dialog content even though `aria-modal="true"` is set in HTML. A user navigating with arrow keys or reading-mode shortcuts will be able to reach elements behind the overlay. The manual `trapFocus` only intercepts Tab/Shift+Tab, not screen-reader cursor movement. On VoiceOver/Safari and NVDA/Firefox this is a well-documented gap with this pattern. Adding `aria-hidden="true"` to the siblings of the portal target, or maintaining a live `inert` attribute on the background tree while the dialog is open, would close this gap.
How can I resolve this? If you propose a fix, please make it concise.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
There was a problem hiding this comment.
Fixed in d6593be — same approach as CodeRabbit's parallel note: background top-level content gets inert/aria-hidden while cook mode is mounted, with the timer dock portaled to <body> so it stays usable. Verified in a browser that the app root is inert and the dialog + dock are not.
Generated by Claude Code
| if (instructionTokenization?.ok === true) { | ||
| return instructionTokenization.steps.map((tokens, stepIndex) => ({ | ||
| key: tokens.map((token) => token.value).join("|"), |
There was a problem hiding this comment.
Step
key can collide for two steps with identical token values
The key for each CookStep is produced by joining every token's .value with |. Two recipe steps that happen to have the same text (e.g., two "Season to taste" steps) will produce the same key, causing a React duplicate-key warning and potential reconciliation artifacts. Using a stable positional key such as String(stepIndex) would be safer while still being stable across re-renders for the same recipe.
| if (instructionTokenization?.ok === true) { | |
| return instructionTokenization.steps.map((tokens, stepIndex) => ({ | |
| key: tokens.map((token) => token.value).join("|"), | |
| if (instructionTokenization?.ok === true) { | |
| return instructionTokenization.steps.map((tokens, stepIndex) => ({ | |
| key: String(stepIndex), |
Prompt To Fix With AI
This is a comment left during a code review.
Path: ui/components/recipes/recipe-content.tsx
Line: 212-214
Comment:
**Step `key` can collide for two steps with identical token values**
The `key` for each `CookStep` is produced by joining every token's `.value` with `|`. Two recipe steps that happen to have the same text (e.g., two "Season to taste" steps) will produce the same key, causing a React duplicate-key warning and potential reconciliation artifacts. Using a stable positional key such as `String(stepIndex)` would be safer while still being stable across re-renders for the same recipe.
```suggestion
if (instructionTokenization?.ok === true) {
return instructionTokenization.steps.map((tokens, stepIndex) => ({
key: String(stepIndex),
```
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
Actionable comments posted: 6
🧹 Nitpick comments (1)
ui/components/recipes/timer-dock.tsx (1)
308-315: 🔒 Security & Privacy | 🔵 Trivial | 💤 Low valueConsider encoding the dynamic slug in the
Linkhref.Based on learnings, dynamic path segments in
Linkhrefs should useencodeURIComponentas defense-in-depth, even when the value is expected to already be safe.🛡️ Suggested diff
<Link - href={`/recipes/${timer.recipeSlug}`} + href={`/recipes/${encodeURIComponent(timer.recipeSlug)}`}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@ui/components/recipes/timer-dock.tsx` around lines 308 - 315, The Link href for the recipe navigation is using a dynamic slug without encoding, so update the href construction in timer-dock.tsx to pass timer.recipeSlug through encodeURIComponent before interpolating it into the /recipes path. Keep the change localized to the Link element so the dynamic route remains safe even if timer.recipeSlug contains unexpected characters.Source: Learnings
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@ui/app/recipes/recipe-theme.css`:
- Around line 145-155: The cook-mode lift rule on rt-timer-dock is being
overridden by the inline bottom style persisted by TimerDock after a drag.
Update TimerDock so it does not apply the saved bottom inline offset while
body.rt-cook-mode-open is present, or otherwise make the cook-mode positioning
take precedence when cook mode is active. Use the TimerDock component and its
persisted drag-position logic as the place to gate the inline style, and keep
the existing .rt-timer-dock cook-mode stylesheet rule as the fallback
positioning.
In `@ui/components/recipes/cook-mode.tsx`:
- Around line 248-259: The non-modal Cook mode dialog in cook-mode.tsx leaves
the rest of the app reachable to assistive tech, so update the Cook mode
implementation to make background content inert while the dialog is mounted. In
the Cook mode component around the <dialog> render and its focus-trap logic,
either apply inert and aria-hidden to the underlying app/timer-dock content
during active cook mode, or refactor the floating timer dock approach so the
dialog can use showModal() safely. Make sure the change is tied to the Cook mode
mount/unmount lifecycle so background interactivity is restored correctly.
- Around line 471-477: The ingredient row key in the group items list can
collide when the same ingredient appears more than once, so update the key used
in the `group.items.map(...)` render inside `cook-mode.tsx` to include the item
index as well as the ingredient. Use the existing `item` mapping in the recipe
row rendering to build a stable unique key, so repeated ingredients do not
produce duplicate keys during reconciliation.
In `@ui/components/recipes/recipe-content.tsx`:
- Around line 213-226: The cook-step key generation in recipe-content.tsx can
collide when instruction text repeats because the tokenized path uses
tokens.map(...).join("|") and the non-tokenized path uses the raw step text.
Update the key logic in the recipe instruction mapping so each step gets a
stable unique identifier, using the existing recipe.slug plus the step index and
any token/index data in the relevant map callbacks for the cook-mode step list
and progress controls. Keep the key generation centralized in the step-mapping
code so repeated instructions no longer reuse the same key.
In `@ui/lib/domain/recipe/ingredientDisplay.ts`:
- Around line 39-43: The annotation lookup in ingredientDisplay.ts is
overwriting entries in the annotations map when duplicate ingredient rows share
the same slug but have different preparation/note values. Update the
annotation-building logic around the item.ingredient and normalizeSlug keys to
detect conflicting duplicates and mark them as ambiguous instead of replacing
the existing entry, and make resolveIngredientAnnotation ignore ambiguous keys
so annotations are not applied to the wrong row.
---
Nitpick comments:
In `@ui/components/recipes/timer-dock.tsx`:
- Around line 308-315: The Link href for the recipe navigation is using a
dynamic slug without encoding, so update the href construction in timer-dock.tsx
to pass timer.recipeSlug through encodeURIComponent before interpolating it into
the /recipes path. Keep the change localized to the Link element so the dynamic
route remains safe even if timer.recipeSlug contains unexpected characters.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 16a81cc1-132e-4f13-a96f-0ca0c62e1c98
📒 Files selected for processing (12)
ui/app/recipes/layout.tsxui/app/recipes/recipe-theme.cssui/components/recipes/cook-mode.tsxui/components/recipes/inline-timer.tsxui/components/recipes/recipe-content.tsxui/components/recipes/timer-dock.tsxui/hooks/use-cooking-timers.tsui/lib/cooking/alertTone.tsui/lib/cooking/timerStore.tsui/lib/cooking/wakeLock.tsui/lib/domain/recipe/ingredientDisplay.tsui/tests/lib/cooking/timer-store.test.ts
User-requested: distinguish and navigate multiple timers. - Timers now carry their originating step index + instruction text; the dock shows a "step N" tag (collapsed and expanded) plus the instruction snippet, and each dock timer deep-links to ?cook=1&step=N to jump straight to that instruction. Review fixes (CodeRabbit / Greptile / Qodo): - Accessibility: cook mode now marks all other top-level content `inert`/`aria-hidden` while open (the non-modal <dialog> alone didn't make the background inert for AT); the timer dock is portaled to <body> so it stays reachable. Exit button gets an aria-label for its icon-only mobile state. - Dock cook-mode lift no longer relies on a stylesheet rule that an inline drag position defeated: the footer clearance is computed in the component and composes with any dragged position (raised only while cooking, not persisted). Removed the dead CSS rule. - Dock re-clamps to the viewport right after restoring a persisted position, not just on resize/expand. - Wake lock reacquires on the sentinel `release` event when holders remain and the tab is visible (not only on visibilitychange). - Per-timer subscription: useCookingTimer selects a single timer so idle inline pills don't re-render on every tick. - Unique React keys for steps, step tokens, and ingredient rows. - Guard ambiguous ingredient annotations (same slug, different prep/note) instead of letting one row's note leak onto another. - encodeURIComponent the recipe slug in dock links. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EZ6MMqQC1fRiqdJDdRqa4K
|



Implements the Phase 2 "Kitchen Experience" cooking mode from the recipe project plan, adapted from (but not beholden to) the mid-fi designs (
recipe.jsx/mobile.jsx), for both desktop and mobile — including the stretch goal of interactive timers that persist between pages.Cook mode
A full-screen overlay on recipe detail pages, entered via a terracotta Start cooking button:
?cook=1&step=N) via the history API: reloading restores the exact step, deep links work, out-of-range steps clamp, and back exits cleanlyPersistent timers
Timer state moves from component-local state in
InlineTimerinto a global store (ui/lib/cooking/timerStore.ts, consumed viauseCookingTimers):storageevent/recipesand all recipe pages, and lifts itself above the cook-mode footer while cooking. Outside the recipes section timers keep running (timestamps) and the dock reappears on returnwakeLock.ts,alertTone.ts)recipe-content.tsxintolib/domain/recipe/ingredientDisplay.tsfor reuse by cook modeTesting
.recipe-surface'sisolation: isolate) — fixed by mounting the dock outside the isolated wrappertypecheck,lint,format, and production build (incl. agent-markdown generation) all pass; pre-commit hook was bypassed only becausemiseis unavailable in this environment, with the equivalent checks run manually🤖 Generated with Claude Code
https://claude.ai/code/session_01EZ6MMqQC1fRiqdJDdRqa4K
Generated by Claude Code
Summary by CodeRabbit
New Features
Bug Fixes