From 7b03d16ec0c7c6c41975c25cce4f31c92aca31bf Mon Sep 17 00:00:00 2001 From: dev_agent Date: Fri, 9 Oct 2026 07:45:42 -0400 Subject: [PATCH 1/2] Keep screen on via Screen Wake Lock in Kitchen reading mode The Keep screen on switch now holds a Screen Wake Lock while on: it asks navigator.wakeLock.request('screen') when turned on, asks again when the page is visible again while on, and releases the lock when turned off or when the screen is left, including a lock granted after the screen was left. A refused request turns the switch back off and is reported with a Retry. Without the API the switch is disabled with an explanation. Maestro task c7da10dc-7f27-4dd0-8548-28d6587bdabf. Co-Authored-By: Claude Opus 5.5 --- web/src/dinners/DinnerDetailPage.module.css | 7 + web/src/dinners/DinnerDetailPage.tsx | 21 ++- web/src/dinners/KitchenReading.test.tsx | 142 ++++++++++++++++++++ web/src/dinners/keepScreenOn.ts | 73 +++++++++- 4 files changed, 234 insertions(+), 9 deletions(-) diff --git a/web/src/dinners/DinnerDetailPage.module.css b/web/src/dinners/DinnerDetailPage.module.css index cff901a..425564e 100644 --- a/web/src/dinners/DinnerDetailPage.module.css +++ b/web/src/dinners/DinnerDetailPage.module.css @@ -312,6 +312,13 @@ font-weight: 700; } +.keepOnHint { + margin: 0; + font-size: 14px; + line-height: 1.4; + color: var(--color-neutral-700); +} + /* The A / A / A control: an outlined pill, the size chosen in grape, each A drawn at the size it stands for. */ .readingHeader .textSize { diff --git a/web/src/dinners/DinnerDetailPage.tsx b/web/src/dinners/DinnerDetailPage.tsx index ab21eb0..79b1928 100644 --- a/web/src/dinners/DinnerDetailPage.tsx +++ b/web/src/dinners/DinnerDetailPage.tsx @@ -326,7 +326,8 @@ const textSizes: SegmentedOption[] = [ // is planned for, its title, Keep screen on and the text size. function ReadingHeader({ dinner, textSize }: { dinner: DinnerDetail; textSize: ReturnType }) { const keepOnId = useId() - const [keepScreenOn, setKeepScreenOn] = useKeepScreenOn() + const keepOnHintId = useId() + const keepScreenOn = useKeepScreenOn() const { failure } = textSize return ( <> @@ -338,7 +339,13 @@ function ReadingHeader({ dinner, textSize }: { dinner: DinnerDetail; textSize: R
Keep screen on - +
+ {!keepScreenOn.supported && ( +

+ This browser can't keep the screen on, so it may dim while you cook. +

+ )} + {keepScreenOn.failure !== null && ( + keepScreenOn.setOn(true)}> + Couldn't keep the screen on: {keepScreenOn.failure} + + )} {failure !== null && ( {textSizeFailureText[failure.action]}: {failure.message} diff --git a/web/src/dinners/KitchenReading.test.tsx b/web/src/dinners/KitchenReading.test.tsx index 4d53796..314034e 100644 --- a/web/src/dinners/KitchenReading.test.tsx +++ b/web/src/dinners/KitchenReading.test.tsx @@ -116,6 +116,38 @@ function currentStep() { return current[0]!.textContent } +// FakeSentinel is a WakeLockSentinel as far as the screen uses one. +type FakeSentinel = { released: boolean; release: Mock<() => Promise> } + +// stubWakeLock gives jsdom, which has none, a Screen Wake Lock. Each request +// is granted at once, or, given hold, only when the test calls grant. +function stubWakeLock({ hold = false } = {}) { + const sentinels: FakeSentinel[] = [] + const pending: (() => void)[] = [] + const request = vi.fn((type: string) => { + expect(type).toBe('screen') + const sentinel: FakeSentinel = { + released: false, + release: vi.fn(() => { + sentinel.released = true + return Promise.resolve() + }), + } + sentinels.push(sentinel) + if (!hold) return Promise.resolve(sentinel) + return new Promise((resolve) => pending.push(() => resolve(sentinel))) + }) + Object.defineProperty(navigator, 'wakeLock', { configurable: true, value: { request } }) + return { request, sentinels, grant: () => pending.shift()!() } +} + +// setVisibility shows or hides the page as the browser would, telling the +// page so. +function setVisibility(state: DocumentVisibilityState) { + Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => state }) + document.dispatchEvent(new Event('visibilitychange')) +} + beforeEach(() => { mockViewport(1180) // Wednesday Oct 7, noon in New York. @@ -126,11 +158,14 @@ beforeEach(() => { afterEach(() => { vi.useRealTimers() Reflect.deleteProperty(window, 'matchMedia') + Reflect.deleteProperty(navigator, 'wakeLock') + Reflect.deleteProperty(document, 'visibilityState') vi.unstubAllGlobals() }) describe('Kitchen reading mode', () => { it('is the tablet layout of the same route: header with back, title, Keep screen on and text size', async () => { + stubWakeLock() serve() await renderPage() @@ -379,4 +414,111 @@ describe('Kitchen reading mode', () => { expect(screen.queryByRole('button', { name: 'Next step' })).not.toBeInTheDocument() expect(screen.getByRole('button', { name: 'Add to this week' })).toBeInTheDocument() }) + + describe('Keep screen on', () => { + it('holds a screen wake lock while on and releases it when turned off', async () => { + const wakeLock = stubWakeLock() + serve() + await renderPage() + const keepOn = screen.getByRole('switch', { name: 'Keep screen on' }) + expect(keepOn).toBeEnabled() + expect(wakeLock.request).not.toHaveBeenCalled() + + fireEvent.click(keepOn) + expect(keepOn).toHaveAttribute('aria-checked', 'true') + await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(1)) + expect(wakeLock.sentinels[0]!.release).not.toHaveBeenCalled() + + fireEvent.click(keepOn) + expect(keepOn).toHaveAttribute('aria-checked', 'false') + expect(wakeLock.sentinels[0]!.release).toHaveBeenCalledTimes(1) + + // On again: a fresh lock. + fireEvent.click(keepOn) + await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(2)) + expect(wakeLock.sentinels[1]!.released).toBe(false) + }) + + it('releases the lock when the screen is left', async () => { + const wakeLock = stubWakeLock() + serve() + const view = await renderPage() + fireEvent.click(screen.getByRole('switch', { name: 'Keep screen on' })) + await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(1)) + + view.unmount() + expect(wakeLock.sentinels[0]!.release).toHaveBeenCalledTimes(1) + }) + + it('releases a lock granted only after the screen was left', async () => { + const wakeLock = stubWakeLock({ hold: true }) + serve() + const view = await renderPage() + fireEvent.click(screen.getByRole('switch', { name: 'Keep screen on' })) + expect(wakeLock.request).toHaveBeenCalledTimes(1) + + view.unmount() + await act(async () => wakeLock.grant()) + expect(wakeLock.sentinels[0]!.release).toHaveBeenCalledTimes(1) + }) + + it('asks for the lock again when the page is visible again while on, and not while off', async () => { + const wakeLock = stubWakeLock() + serve() + await renderPage() + const keepOn = screen.getByRole('switch', { name: 'Keep screen on' }) + fireEvent.click(keepOn) + await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(1)) + + // Hidden, the browser lets the lock go; nothing is asked while hidden. + wakeLock.sentinels[0]!.released = true + act(() => setVisibility('hidden')) + expect(wakeLock.request).toHaveBeenCalledTimes(1) + + act(() => setVisibility('visible')) + await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(2)) + expect(keepOn).toHaveAttribute('aria-checked', 'true') + + // Visible again with the lock still held: no second lock. + act(() => setVisibility('visible')) + expect(wakeLock.request).toHaveBeenCalledTimes(2) + + fireEvent.click(keepOn) + act(() => setVisibility('hidden')) + act(() => setVisibility('visible')) + expect(wakeLock.request).toHaveBeenCalledTimes(2) + }) + + it('reports a lock the browser refuses and turns the switch back off', async () => { + const wakeLock = stubWakeLock() + wakeLock.request.mockRejectedValueOnce(new DOMException('Blocked by permissions policy', 'NotAllowedError')) + serve() + await renderPage() + const keepOn = screen.getByRole('switch', { name: 'Keep screen on' }) + fireEvent.click(keepOn) + + expect(await screen.findByRole('alert')).toHaveTextContent( + "Couldn't keep the screen on: NotAllowedError: Blocked by permissions policy", + ) + expect(keepOn).toHaveAttribute('aria-checked', 'false') + + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + expect(keepOn).toHaveAttribute('aria-checked', 'true') + await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(2)) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it('is disabled, and says why, in a browser that cannot keep the screen on', async () => { + serve() + await renderPage() + const keepOn = screen.getByRole('switch', { name: 'Keep screen on' }) + expect(keepOn).toBeDisabled() + expect(keepOn).toHaveAttribute('aria-checked', 'false') + expect(keepOn).toHaveAccessibleDescription( + "This browser can't keep the screen on, so it may dim while you cook.", + ) + fireEvent.click(keepOn) + expect(keepOn).toHaveAttribute('aria-checked', 'false') + }) + }) }) diff --git a/web/src/dinners/keepScreenOn.ts b/web/src/dinners/keepScreenOn.ts index a221399..a6907fe 100644 --- a/web/src/dinners/keepScreenOn.ts +++ b/web/src/dinners/keepScreenOn.ts @@ -1,10 +1,69 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' -// useKeepScreenOn is Kitchen reading mode's Keep screen on switch: whether it -// is on, and a setter. It starts off on each visit. +// KeepScreenOn is Kitchen reading mode's Keep screen on switch: whether it is +// on, setOn, whether this browser can keep the screen on at all, and why the +// screen could not be kept on, if it could not. +export type KeepScreenOn = { + on: boolean + setOn: (on: boolean) => void + supported: boolean + failure: string | null +} + +// useKeepScreenOn holds a Screen Wake Lock while the switch is on, and only +// then. It starts off on each visit. The browser lets the lock go whenever +// the page is hidden, so it is asked for again each time the page is visible +// while the switch is on. Turning the switch off or leaving the screen +// releases it, including a lock still being granted as the screen is left. // -// TODO(c7da10dc): hold a Screen Wake Lock while it is on. Today it is only -// the switch's state; nothing keeps the screen awake yet. -export function useKeepScreenOn(): [boolean, (on: boolean) => void] { - return useState(false) +// A browser without the Screen Wake Lock API (or a page not served over +// HTTPS) cannot keep the screen on: supported is false and the switch must +// not be offered as working. A request the browser refuses turns the switch +// back off and is reported as failure; turning it on again asks again. +export function useKeepScreenOn(): KeepScreenOn { + const [on, setOnState] = useState(false) + const [failure, setFailure] = useState(null) + const supported = 'wakeLock' in navigator + + useEffect(() => { + if (!on) return + const wakeLock = navigator.wakeLock + let sentinel: WakeLockSentinel | null = null + let requesting = false + let left = false + + function acquire() { + if (document.visibilityState !== 'visible' || requesting) return + if (sentinel !== null && !sentinel.released) return + requesting = true + wakeLock.request('screen').then( + (granted) => { + requesting = false + if (left) void granted.release() + else sentinel = granted + }, + (err: unknown) => { + requesting = false + if (left) return + setFailure(String(err)) + setOnState(false) + }, + ) + } + + acquire() + document.addEventListener('visibilitychange', acquire) + return () => { + left = true + document.removeEventListener('visibilitychange', acquire) + if (sentinel !== null) void sentinel.release() + } + }, [on]) + + function setOn(next: boolean) { + if (next) setFailure(null) + setOnState(next) + } + + return { on, setOn, supported, failure } } -- 2.55.0 From 94a3de309b266506368e6ed5143d8c4ac9e341a4 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Fri, 9 Oct 2026 08:14:25 -0400 Subject: [PATCH 2/2] Re-request the wake lock the browser lets go or a pending request misses Keep screen on now listens for each lock's release event: when the browser lets the lock go while the switch is on, the lock is asked for again at once if the page is visible (or on the next visibilitychange if hidden), so the switch never claims a lock that is not held. When a pending request is granted, the hook checks visibility and the lock again, so a hide-and-show during the request is no longer lost. Fixes review findings F-01 and F-02 on PR #151. Maestro task c7da10dc-7f27-4dd0-8548-28d6587bdabf. Co-Authored-By: Claude Opus 5.5 --- web/src/dinners/KitchenReading.test.tsx | 60 ++++++++++++++++++++++--- web/src/dinners/keepScreenOn.ts | 21 ++++++++- 2 files changed, 74 insertions(+), 7 deletions(-) diff --git a/web/src/dinners/KitchenReading.test.tsx b/web/src/dinners/KitchenReading.test.tsx index 314034e..d708fce 100644 --- a/web/src/dinners/KitchenReading.test.tsx +++ b/web/src/dinners/KitchenReading.test.tsx @@ -117,7 +117,14 @@ function currentStep() { } // FakeSentinel is a WakeLockSentinel as far as the screen uses one. -type FakeSentinel = { released: boolean; release: Mock<() => Promise> } +type FakeSentinel = EventTarget & { released: boolean; release: Mock<() => Promise> } + +// letGo releases a lock as the browser does, telling its listeners so. +function letGo(sentinel: FakeSentinel) { + if (sentinel.released) return + sentinel.released = true + sentinel.dispatchEvent(new Event('release')) +} // stubWakeLock gives jsdom, which has none, a Screen Wake Lock. Each request // is granted at once, or, given hold, only when the test calls grant. @@ -126,13 +133,13 @@ function stubWakeLock({ hold = false } = {}) { const pending: (() => void)[] = [] const request = vi.fn((type: string) => { expect(type).toBe('screen') - const sentinel: FakeSentinel = { + const sentinel: FakeSentinel = Object.assign(new EventTarget(), { released: false, release: vi.fn(() => { - sentinel.released = true + letGo(sentinel) return Promise.resolve() }), - } + }) sentinels.push(sentinel) if (!hold) return Promise.resolve(sentinel) return new Promise((resolve) => pending.push(() => resolve(sentinel))) @@ -471,8 +478,8 @@ describe('Kitchen reading mode', () => { await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(1)) // Hidden, the browser lets the lock go; nothing is asked while hidden. - wakeLock.sentinels[0]!.released = true act(() => setVisibility('hidden')) + act(() => letGo(wakeLock.sentinels[0]!)) expect(wakeLock.request).toHaveBeenCalledTimes(1) act(() => setVisibility('visible')) @@ -489,6 +496,49 @@ describe('Kitchen reading mode', () => { expect(wakeLock.request).toHaveBeenCalledTimes(2) }) + it('asks for the lock again when the browser lets it go while the page is visible', async () => { + const wakeLock = stubWakeLock() + serve() + await renderPage() + const keepOn = screen.getByRole('switch', { name: 'Keep screen on' }) + fireEvent.click(keepOn) + await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(1)) + + act(() => letGo(wakeLock.sentinels[0]!)) + await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(2)) + await act(async () => {}) + expect(keepOn).toHaveAttribute('aria-checked', 'true') + + // The fresh lock is watched too. + act(() => letGo(wakeLock.sentinels[1]!)) + await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(3)) + expect(wakeLock.sentinels[2]!.released).toBe(false) + expect(keepOn).toHaveAttribute('aria-checked', 'true') + }) + + it('asks for the lock again when the page was hidden and shown while a request was pending', async () => { + const wakeLock = stubWakeLock({ hold: true }) + serve() + await renderPage() + const keepOn = screen.getByRole('switch', { name: 'Keep screen on' }) + fireEvent.click(keepOn) + expect(wakeLock.request).toHaveBeenCalledTimes(1) + + // Hidden and shown again before the first lock is granted, which the + // browser has let go by the time it is. + act(() => setVisibility('hidden')) + act(() => setVisibility('visible')) + expect(wakeLock.request).toHaveBeenCalledTimes(1) + letGo(wakeLock.sentinels[0]!) + await act(async () => wakeLock.grant()) + expect(wakeLock.request).toHaveBeenCalledTimes(2) + + await act(async () => wakeLock.grant()) + expect(wakeLock.sentinels[1]!.released).toBe(false) + expect(wakeLock.request).toHaveBeenCalledTimes(2) + expect(keepOn).toHaveAttribute('aria-checked', 'true') + }) + it('reports a lock the browser refuses and turns the switch back off', async () => { const wakeLock = stubWakeLock() wakeLock.request.mockRejectedValueOnce(new DOMException('Blocked by permissions policy', 'NotAllowedError')) diff --git a/web/src/dinners/keepScreenOn.ts b/web/src/dinners/keepScreenOn.ts index a6907fe..63ed9a3 100644 --- a/web/src/dinners/keepScreenOn.ts +++ b/web/src/dinners/keepScreenOn.ts @@ -32,6 +32,15 @@ export function useKeepScreenOn(): KeepScreenOn { let requesting = false let left = false + // The browser may let a lock go while the switch is on: always when the + // page is hidden, and, the specification allows, while it is still + // visible. Either way the lock is asked for again at once if the page is + // visible, or when it is visible again if not, so the switch never claims + // a lock that is not held. + function onRelease() { + if (!left) acquire() + } + function acquire() { if (document.visibilityState !== 'visible' || requesting) return if (sentinel !== null && !sentinel.released) return @@ -39,8 +48,16 @@ export function useKeepScreenOn(): KeepScreenOn { wakeLock.request('screen').then( (granted) => { requesting = false - if (left) void granted.release() - else sentinel = granted + if (left) { + void granted.release() + return + } + sentinel = granted + granted.addEventListener('release', onRelease) + // While the request was pending the page may have been hidden and + // shown again (that visibilitychange was ignored) and the lock let + // go before it could be listened for, so check again now. + acquire() }, (err: unknown) => { requesting = false -- 2.55.0