Keep screen on via Wake Lock (Feature 15) (#151)
CI / ci (push) Successful in 5m27s

Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #151.
This commit is contained in:
2026-10-09 10:52:41 -04:00
committed by approver_agent
parent 930e55ab43
commit 1028817193
4 changed files with 301 additions and 9 deletions
@@ -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 {
+19 -2
View File
@@ -326,7 +326,8 @@ const textSizes: SegmentedOption<TextSize>[] = [
// is planned for, its title, Keep screen on and the text size.
function ReadingHeader({ dinner, textSize }: { dinner: DinnerDetail; textSize: ReturnType<typeof useTextSize> }) {
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
</div>
<div className={styles.keepOn}>
<span id={keepOnId}>Keep screen on</span>
<Toggle checked={keepScreenOn} onCheckedChange={setKeepScreenOn} aria-labelledby={keepOnId} />
<Toggle
checked={keepScreenOn.on}
onCheckedChange={keepScreenOn.setOn}
aria-labelledby={keepOnId}
disabled={!keepScreenOn.supported}
aria-describedby={keepScreenOn.supported ? undefined : keepOnHintId}
/>
</div>
<Segmented
label="Text size"
@@ -348,6 +355,16 @@ function ReadingHeader({ dinner, textSize }: { dinner: DinnerDetail; textSize: R
className={styles.textSize}
/>
</header>
{!keepScreenOn.supported && (
<p id={keepOnHintId} className={styles.keepOnHint}>
This browser can't keep the screen on, so it may dim while you cook.
</p>
)}
{keepScreenOn.failure !== null && (
<ErrorBanner onRetry={() => keepScreenOn.setOn(true)}>
Couldn't keep the screen on: {keepScreenOn.failure}
</ErrorBanner>
)}
{failure !== null && (
<ErrorBanner onRetry={textSize.retry}>
{textSizeFailureText[failure.action]}: {failure.message}
+192
View File
@@ -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<void>> }
// 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<FakeSentinel>((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')
})
})
})
+83 -7
View File
@@ -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<string | null>(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 }
}