From 1028817193d6acf47516af3bdf18a87e8767878a Mon Sep 17 00:00:00 2001 From: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> Date: Fri, 9 Oct 2026 10:52:41 -0400 Subject: [PATCH] Keep screen on via Wake Lock (Feature 15) (#151) Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> --- web/src/dinners/DinnerDetailPage.module.css | 7 + web/src/dinners/DinnerDetailPage.tsx | 21 ++- web/src/dinners/KitchenReading.test.tsx | 192 ++++++++++++++++++++ web/src/dinners/keepScreenOn.ts | 90 ++++++++- 4 files changed, 301 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..d708fce 100644 --- a/web/src/dinners/KitchenReading.test.tsx +++ b/web/src/dinners/KitchenReading.test.tsx @@ -116,6 +116,45 @@ function currentStep() { return current[0]!.textContent } +// FakeSentinel is a WakeLockSentinel as far as the screen uses one. +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. +function stubWakeLock({ hold = false } = {}) { + const sentinels: FakeSentinel[] = [] + const pending: (() => void)[] = [] + const request = vi.fn((type: string) => { + expect(type).toBe('screen') + const sentinel: FakeSentinel = Object.assign(new EventTarget(), { + released: false, + release: vi.fn(() => { + letGo(sentinel) + 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 +165,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 +421,154 @@ 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. + act(() => setVisibility('hidden')) + act(() => letGo(wakeLock.sentinels[0]!)) + 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('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')) + 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..63ed9a3 100644 --- a/web/src/dinners/keepScreenOn.ts +++ b/web/src/dinners/keepScreenOn.ts @@ -1,10 +1,86 @@ -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 + + // 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 + requesting = true + wakeLock.request('screen').then( + (granted) => { + requesting = false + 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 + 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 } }