diff --git a/components/core/SlideshowViewer.test.tsx b/components/core/SlideshowViewer.test.tsx index c43253d9..4a6fdf20 100644 --- a/components/core/SlideshowViewer.test.tsx +++ b/components/core/SlideshowViewer.test.tsx @@ -177,6 +177,47 @@ describe("SlideshowViewer clicker keys", () => { }); }); +describe("SlideshowViewer mounting", () => { + const numbered = (n: number) => + function NumberedSlide() { + return

; + }; + const deck = { ...config, slides: [0, 1, 2, 3, 4].map(numbered) }; + const mounted = () => + [...container.querySelectorAll("[data-slide]")].map((slide) => [ + slide.dataset.slide, + slide.parentElement?.style.display, + ]); + + it("keeps each neighbor mounted and hidden across moves, and unmounts a slide two or more away", async () => { + await renderAt("/slides/demo?slide=2", deck); + expect(mounted()).toEqual([["1", "none"], ["2", "block"], ["3", "none"]]); + // The same node means React kept the slide's instance, so its state survives the move. + const shown = container.querySelector("[data-slide='2']"); + await press("ArrowRight"); + expect(container.querySelector("[data-slide='2']")).toBe(shown); + await press("ArrowLeft"); + expect(container.querySelector("[data-slide='2']")).toBe(shown); + const neighbor = container.querySelector("[data-slide='3']"); + await press("End"); + expect(mounted()).toEqual([["3", "none"], ["4", "block"]]); + expect(container.querySelector("[data-slide='3']")).toBe(neighbor); + }); + + it("keeps each slide's instance while it or its neighbor steps through builds", async () => { + const built = { ...config, slides: [{ component: numbered(0), builds: 2 }, numbered(1)] }; + await renderAt("/slides/demo", built); + expect(mounted()).toEqual([["0", "block"], ["1", "none"]]); + const first = container.querySelector("[data-slide='0']"); + const second = container.querySelector("[data-slide='1']"); + for (const key of ["ArrowRight", "ArrowRight", "ArrowLeft", "ArrowLeft"]) { + await press(key); + expect(container.querySelector("[data-slide='0']")).toBe(first); + expect(container.querySelector("[data-slide='1']")).toBe(second); + } + }); +}); + describe("SlideshowViewer exportBuilds", () => { const seen: (number | undefined)[] = []; const Probe = ({ buildStep }: { buildStep?: number }) => { diff --git a/slideshows/appam-2026/EVIDENCE.md b/slideshows/appam-2026/EVIDENCE.md index c18bf669..da2faabf 100644 --- a/slideshows/appam-2026/EVIDENCE.md +++ b/slideshows/appam-2026/EVIDENCE.md @@ -195,7 +195,7 @@ We quote these on slide 7 and in the notes' Questions section, and we model none ## The web apps -We captured slide 12's coverage tracker from www.policyengine.org/us/model/rules/coverage on November 5, 2026. It shows 109 total programs, 87 complete, 18 partial and 4 in progress, and its top line reads "Coverage reflects policyengine-us 2.72.3 (main @ 4c65c82, fetched 2026-10-27). The production API served 2.2.1 when the snapshot was fetched." The deck's own runs use 2.90.2. Slide 12 loads that page in a live frame at the still's crop, and the still shows until the tracker renders and stands in for it in the PDF. +We captured slide 12's coverage tracker from www.policyengine.org/us/model/rules/coverage on November 5, 2026. It shows 109 total programs, 87 complete, 18 partial and 4 in progress, and its top line reads "Coverage reflects policyengine-us 2.72.3 (main @ 4c65c82, fetched 2026-10-27). The production API served 2.2.1 when the snapshot was fetched." The deck's own runs use 2.90.2. We load that page in a live frame on slide 12 at the still's crop, and print the still alone in an export. We show the still until the tracker prints that snapshot line and its four count cards finish fading in. We captured slide 11's CliffWatch screenshot on November 4, 2026, and Build's report on November 5, 2026, from the live sites. We scripted the recording, built it from screenshots and drew its cursor and address bar. CliffWatch drew that run's chart in under a second. Other runs took longer. diff --git a/slideshows/appam-2026/SPEAKER-NOTES.md b/slideshows/appam-2026/SPEAKER-NOTES.md index 4273e126..64866f2d 100644 --- a/slideshows/appam-2026/SPEAKER-NOTES.md +++ b/slideshows/appam-2026/SPEAKER-NOTES.md @@ -96,7 +96,7 @@ In Build, you can open the same Massachusetts credit, set it to $880, pick Massa We model federal income and payroll taxes and credits and every state income tax, in 44 jurisdictions. We model TANF and child care subsidies in all 50 states and DC, plus SNAP, WIC, SSI, school meals, ACA premium tax credits, Medicaid and CHIP eligibility and local taxes such as Philadelphia's wage tax. Our coverage tracker lists 109 programs and marks 87 complete, 18 partial and 4 in progress. -*The tracker runs live in the frame, so you can click and scroll in it, and the arrows keep moving the deck. If you click its search box, click the slide outside the frame, which also moves to the next step.* +*Once the tracker loads, you can click and scroll in the frame, and the left and right arrow keys still move the deck. After you click in its search box, they move the box's cursor until you click elsewhere in the frame. If you pick a year in its menu, the four counts change, because the tracker counts only the programs we mark verified for that year.* ## 13. We build Microcosm, our microdata, from surveys and official totals (9:50, 50 seconds) diff --git a/slideshows/appam-2026/slides/CoverageEmbed.test.tsx b/slideshows/appam-2026/slides/CoverageEmbed.test.tsx index 4ee9df29..14f709c0 100644 --- a/slideshows/appam-2026/slides/CoverageEmbed.test.tsx +++ b/slideshows/appam-2026/slides/CoverageEmbed.test.tsx @@ -1,7 +1,9 @@ import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import CoverageEmbed, { TRACKER_URL, forwardKey, trackerAgrees } from './CoverageEmbed'; +import CoverageEmbed, { forwardKey, trackerAgrees } from './CoverageEmbed'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; let container: HTMLDivElement; let root: Root; @@ -24,11 +26,13 @@ describe('the coverage tracker on the rules slide', () => { it('loads the live tracker over its still', async () => { window.history.replaceState(null, '', '/slides/appam-2026?slide=13'); await renderEmbed(); - expect(container.querySelector('iframe')?.getAttribute('src')).toBe(TRACKER_URL); + expect(container.querySelector('iframe')?.getAttribute('src')).toBe( + 'https://www.policyengine.org/us/model/rules/coverage', + ); expect(container.querySelector('img')?.getAttribute('alt')).toBe('The tracker'); }); - it('keeps the frame hidden until the tracker renders', async () => { + it('keeps the frame hidden on first render', async () => { window.history.replaceState(null, '', '/slides/appam-2026?slide=13'); await renderEmbed(); expect(container.querySelector('iframe')?.style.opacity).toBe('0'); @@ -43,31 +47,294 @@ describe('the coverage tracker on the rules slide', () => { }); }); +const SNAPSHOT = 'Coverage reflects policyengine-us 2.72.3 (main @ 4c65c82, fetched 2026-10-27). 109 Total programs'; +const FALLBACK = 'Coverage reflects the bundled fallback registry. 64 Total programs'; + +const card = (label: string, style = '') => `

${label}

`; +/** The legend after the tracker's cards, which repeats three of their labels and never fades. */ +const LEGEND = ['Complete', 'Partial', 'In progress', 'Not started'].map((label) => `${label}`).join(''); +const COUNT_LABELS = ['Total programs', 'Complete', 'Partial', 'In progress']; +/** The tracker's four cards and its legend, with the given attributes on one card. */ +const cardsWith = (styled: string, style: string) => + [...COUNT_LABELS.map((label) => card(label, label === styled ? style : '')), LEGEND].join(''); +/** The tracker's four cards and its legend, with the given attributes on the last card. */ +const cards = (lastStyle = '') => cardsWith('In progress', lastStyle); +const CARD_STATES = { + 'at rest': { html: cards(), rests: true }, + 'at rest under an identity matrix': { html: cards('data-transform="matrix(1, 0, 0, 1, 0, 0)"'), rests: true }, + 'still fading in': { html: cards('data-opacity="0"'), rests: false }, + 'nearly faded in': { html: cards('data-opacity="0.99"'), rests: false }, + 'still growing': { html: cards('data-transform="matrix(0.9, 0, 0, 0.9, 0, 0)"'), rests: false }, + 'missing a card': { html: [card('Total programs'), card('Complete'), card('Partial')].join(''), rests: false }, + 'not drawn yet': { html: '', rests: false }, +}; + +/** A page whose computed styles come from data attributes, since jsdom computes neither property. */ +const stubPage = (innerText: string, status = 'loaded', html = cards()) => { + const body = document.createElement('div'); + body.innerHTML = html; + Object.defineProperty(body, 'innerText', { value: innerText }); + return { + body, + fonts: { status }, + defaultView: { + getComputedStyle: (element: HTMLElement) => ({ + opacity: element.dataset.opacity ?? '1', + transform: element.dataset.transform ?? 'none', + }), + }, + } as unknown as Document; +}; + describe('the check that shows the frame over the still', () => { - const page = (innerText: string, status = 'loaded') => - ({ body: { innerText }, fonts: { status } }) as unknown as Document; - const snapshot = 'Coverage reflects policyengine-us 2.72.3 (main @ 4c65c82, fetched 2026-10-27). 109 Total programs'; + const TEXTS = { + 'the still’s snapshot': { text: SNAPSHOT, matches: true }, + 'the same commit, fetched later': { + text: 'Coverage reflects policyengine-us 2.72.3 (main @ 4c65c82, fetched 2026-11-01). 109 Total programs', + matches: true, + }, + 'the fallback counts': { text: FALLBACK, matches: false }, + 'another release': { text: 'Coverage reflects policyengine-us 2.95.0 (main @ abc1234). 120 Total programs', matches: false }, + 'another commit of the same release': { + text: 'Coverage reflects policyengine-us 2.72.3 (main @ abc1234, fetched 2026-11-01). 110 Total programs', + matches: false, + }, + 'nothing yet': { text: '', matches: false }, + }; + + it('passes for every page that prints the snapshot, with fonts loaded and cards at rest, and no other', () => { + for (const [textName, { text, matches }] of Object.entries(TEXTS)) { + for (const status of ['loaded', 'loading']) { + for (const [cardName, { html, rests }] of Object.entries(CARD_STATES)) { + const expected = matches && status === 'loaded' && rests; + expect(trackerAgrees(stubPage(text, status, html)), `${textName}, ${status}, ${cardName}`).toBe(expected); + } + } + } + }); - it('keeps the still while the tracker prints its fallback counts', () => { - expect(trackerAgrees(page('Coverage reflects the bundled fallback registry. 64 Total programs'))).toBe(false); + it('holds the still while any one of the four cards fades in or grows', () => { + for (const label of COUNT_LABELS) { + for (const style of ['data-opacity="0"', 'data-transform="matrix(0.9, 0, 0, 0.9, 0, 0)"']) { + expect(trackerAgrees(stubPage(SNAPSHOT, 'loaded', cardsWith(label, style))), `${label}, ${style}`).toBe(false); + } + } }); - it('keeps the still when the tracker moves to another snapshot', () => { - expect(trackerAgrees(page('Coverage reflects policyengine-us 2.95.0 (main @ abc1234). 120 Total programs'))).toBe(false); + it('holds the still for a fading ancestor of a card', () => { + const html = `
${cards()}
`; + expect(trackerAgrees(stubPage(SNAPSHOT, 'loaded', html))).toBe(false); }); - it('keeps the still on an empty or unreadable page', () => { - expect(trackerAgrees(page(''))).toBe(false); + it('keeps the still when it can’t read the page', () => { expect(trackerAgrees(null)).toBe(false); expect(trackerAgrees(undefined)).toBe(false); }); - it('keeps the still while the page loads its fonts', () => { - expect(trackerAgrees(page(snapshot, 'loading'))).toBe(false); + it('keeps the still when the page has no window to read styles from', () => { + const page = { ...stubPage(SNAPSHOT), defaultView: null } as unknown as Document; + expect(trackerAgrees(page)).toBe(false); + }); +}); + +describe('when the frame shows', () => { + let resize: (width: number) => void; + + beforeEach(() => { + vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }); + vi.stubGlobal( + 'ResizeObserver', + class { + constructor(callback: (entries: { contentRect: { width: number } }[]) => void) { + resize = (width) => callback([{ contentRect: { width } }]); + } + observe() {} + disconnect() {} + }, + ); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + const mount = async (page: Document | null) => { + window.history.replaceState(null, '', '/slides/appam-2026?slide=13'); + await renderEmbed(); + const frame = container.querySelector('iframe') as HTMLIFrameElement; + setPage(frame, page); + return frame; + }; + const setPage = (frame: HTMLIFrameElement, page: Document | null) => + Object.defineProperty(frame, 'contentDocument', { value: page, configurable: true }); + const show = (width: number) => act(async () => resize(width)); + const load = (frame: HTMLIFrameElement) => act(async () => void frame.dispatchEvent(new Event('load'))); + const poll = () => act(async () => void vi.advanceTimersByTime(1200)); + const shown = (frame: HTMLIFrameElement) => frame.style.opacity === '1' && frame.style.pointerEvents === 'auto'; + const hidden = (frame: HTMLIFrameElement) => frame.style.opacity === '0' && frame.style.pointerEvents === 'none'; + + it('needs the frame loaded, the slide showing and a page that agrees, in every combination', async () => { + const pages = { agreeing: true, fallback: false, unreadable: false }; + for (const loaded of [false, true]) { + for (const showing of [false, true]) { + for (const [name, agrees] of Object.entries(pages)) { + const page = name === 'agreeing' ? stubPage(SNAPSHOT) : name === 'fallback' ? stubPage(FALLBACK) : null; + const frame = await mount(page); + if (showing) await show(1171.5); + if (loaded) await load(frame); + await poll(); + const label = `loaded ${loaded}, showing ${showing}, ${name}`; + expect(shown(frame), label).toBe(loaded && showing && agrees); + expect(hidden(frame) || shown(frame), label).toBe(true); + await act(async () => root.unmount()); + root = createRoot(container); + } + } + } + }); + + it('waits for the slide to show even when the hidden page already agrees', async () => { + const frame = await mount(stubPage(SNAPSHOT)); + await load(frame); + await poll(); + expect(hidden(frame)).toBe(true); + await show(1171.5); + expect(hidden(frame)).toBe(true); + await poll(); + expect(shown(frame)).toBe(true); + }); + + it('holds the still until the cards come to rest', async () => { + const frame = await mount(stubPage(SNAPSHOT, 'loaded', cards('data-opacity="0"'))); + await show(1171.5); + await load(frame); + await poll(); + expect(hidden(frame)).toBe(true); + setPage(frame, stubPage(SNAPSHOT)); + await poll(); + expect(shown(frame)).toBe(true); + }); + + it('brings the still back when the frame’s page goes away, until the check passes again', async () => { + const frame = await mount(stubPage(SNAPSHOT)); + await show(1171.5); + await load(frame); + await poll(); + expect(shown(frame)).toBe(true); + + setPage(frame, stubPage(FALLBACK)); + await act(async () => void frame.contentWindow?.dispatchEvent(new Event('pagehide'))); + expect(hidden(frame)).toBe(true); + await load(frame); + await poll(); + expect(hidden(frame)).toBe(true); + + setPage(frame, stubPage(SNAPSHOT)); + await poll(); + expect(shown(frame)).toBe(true); + }); + + it('waits for the new page to load before it checks again', async () => { + const frame = await mount(stubPage(SNAPSHOT)); + await show(1171.5); + await load(frame); + await poll(); + await act(async () => void frame.contentWindow?.dispatchEvent(new Event('pagehide'))); + await poll(); + expect(hidden(frame)).toBe(true); + await load(frame); + await poll(); + expect(shown(frame)).toBe(true); + }); + + it('keeps the frame when the browser keeps the page for Back', async () => { + const frame = await mount(stubPage(SNAPSHOT)); + await show(1171.5); + await load(frame); + await poll(); + const leaving = Object.assign(new Event('pagehide'), { persisted: true }); + await act(async () => void frame.contentWindow?.dispatchEvent(leaving)); + await poll(); + expect(shown(frame)).toBe(true); + }); + + it('first checks the page 400 ms after the slide shows', async () => { + const frame = await mount(stubPage(SNAPSHOT)); + await load(frame); + await show(1171.5); + await act(async () => void vi.advanceTimersByTime(399)); + expect(hidden(frame)).toBe(true); + await act(async () => void vi.advanceTimersByTime(1)); + expect(shown(frame)).toBe(true); + }); + + it('hands a key pressed in the frame to the deck once, however many times the frame loads', async () => { + const frame = await mount(stubPage(SNAPSHOT)); + await load(frame); + await load(frame); + const deck = vi.fn(); + window.addEventListener('keydown', deck); + frame.contentWindow?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', cancelable: true })); + window.removeEventListener('keydown', deck); + expect(deck).toHaveBeenCalledOnce(); + expect(deck.mock.calls[0][0].key).toBe('ArrowRight'); + }); + + it('stays on the still across origins, where the browser denies the frame’s window and document', async () => { + const frame = await mount(null); + const denied = new Proxy( + {}, + { + get() { + throw new DOMException('Blocked a frame with another origin', 'SecurityError'); + }, + }, + ); + Object.defineProperty(frame, 'contentWindow', { value: denied, configurable: true }); + await show(1171.5); + await load(frame); + await poll(); + expect(hidden(frame)).toBe(true); + }); + + it('keeps showing the frame when the deck moves to a neighboring slide and back', async () => { + const frame = await mount(stubPage(SNAPSHOT)); + await show(1171.5); + await load(frame); + await poll(); + await show(0); + expect(frame.style.opacity).toBe('0'); + await show(1171.5); + expect(shown(frame)).toBe(true); + }); + + it('starts again from the still when the deck unmounts the slide and returns', async () => { + const frame = await mount(stubPage(SNAPSHOT)); + await show(1171.5); + await load(frame); + await poll(); + expect(shown(frame)).toBe(true); + await act(async () => root.unmount()); + root = createRoot(container); + const returned = await mount(stubPage(SNAPSHOT)); + await show(1171.5); + expect(hidden(returned)).toBe(true); + await poll(); + expect(hidden(returned)).toBe(true); }); - it('shows the frame once the tracker matches the still', () => { - expect(trackerAgrees(page(snapshot))).toBe(true); + it('stops reading the frame’s page once the frame shows', async () => { + const frame = await mount(stubPage(SNAPSHOT)); + await show(1171.5); + await load(frame); + await poll(); + expect(shown(frame)).toBe(true); + const read = vi.fn(() => stubPage(FALLBACK)); + Object.defineProperty(frame, 'contentDocument', { get: read, configurable: true }); + await poll(); + expect(read).not.toHaveBeenCalled(); + expect(shown(frame)).toBe(true); }); }); @@ -79,6 +346,41 @@ describe('a key pressed inside the frame', () => { return event; }; + it('reaches the deck from every element except a text field', () => { + const editable = document.createElement('div'); + Object.defineProperty(editable, 'isContentEditable', { value: true }); + const targets: [HTMLElement, boolean][] = [ + [document.createElement('div'), true], + [document.createElement('select'), true], + [document.createElement('button'), true], + [document.createElement('a'), true], + [document.createElement('input'), false], + [document.createElement('textarea'), false], + [editable, false], + ]; + for (const key of ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', ' ', 'PageDown', 'Home', 'f']) { + for (const [target, reaches] of targets) { + const deck = vi.fn(); + window.addEventListener('keydown', deck); + press(key, target); + window.removeEventListener('keydown', deck); + expect(deck.mock.calls.length, `${key} on ${target.tagName}`).toBe(reaches ? 1 : 0); + } + } + }); + + it('moves the deck from the year menu, and leaves the menu the keys the deck ignores', () => { + const deck = vi.fn((event: KeyboardEvent) => { + if (event.key === 'ArrowRight') event.preventDefault(); + }); + window.addEventListener('keydown', deck); + const right = press('ArrowRight', document.createElement('select')); + const down = press('ArrowDown', document.createElement('select')); + window.removeEventListener('keydown', deck); + expect(right.defaultPrevented).toBe(true); + expect(down.defaultPrevented).toBe(false); + }); + it('reaches the deck, and the frame leaves a key the deck uses alone', () => { const deck = vi.fn((event: KeyboardEvent) => event.preventDefault()); window.addEventListener('keydown', deck); diff --git a/slideshows/appam-2026/slides/CoverageEmbed.tsx b/slideshows/appam-2026/slides/CoverageEmbed.tsx index 5b69c2d8..06c7f74b 100644 --- a/slideshows/appam-2026/slides/CoverageEmbed.tsx +++ b/slideshows/appam-2026/slides/CoverageEmbed.tsx @@ -4,7 +4,7 @@ import { useEffect, useRef, useState } from 'react'; import Image from '@/components/core/BasePathImage'; import { isExportMode } from '@/lib/slide-url'; -export const TRACKER_URL = 'https://www.policyengine.org/us/model/rules/coverage'; +const TRACKER_URL = 'https://www.policyengine.org/us/model/rules/coverage'; const STILL = { width: 2343, height: 721 }; /** The frame lays the tracker out at the width of the still's capture. */ @@ -19,17 +19,45 @@ const CROP = { left: 404.5, top: 105.5, width: STILL.width / 2, height: STILL.he * other counts under another line, and a loading or error page prints neither, * so the still stays until the tracker agrees with it. */ -const TRACKER_TEXT = 'Coverage reflects policyengine-us 2.72.3'; +const TRACKER_TEXT = 'Coverage reflects policyengine-us 2.72.3 (main @ 4c65c82'; +/** The labels on the still's four count cards, which the tracker fades in. */ +const COUNT_LABELS = ['Total programs', 'Complete', 'Partial', 'In progress']; +const IDENTITY = 'matrix(1, 0, 0, 1, 0, 0)'; -/** Whether the page in the frame shows the still's snapshot in its own fonts. */ +/** Whether an element and everything around it have reached full opacity and size. */ +function atRest(element: Element, view: Window) { + for (let node: Element | null = element; node; node = node.parentElement) { + const { opacity, transform } = view.getComputedStyle(node); + if (Number(opacity) < 1 || (transform !== 'none' && transform !== IDENTITY)) return false; + } + return true; +} + +/** Whether the tracker has finished drawing the four count cards the still shows. */ +function countsAtRest(page: Document) { + const view = page.defaultView; + if (!view) return false; + const leaves = [...page.body.querySelectorAll('*')].filter((element) => element.children.length === 0); + return COUNT_LABELS.every((label) => { + const card = leaves.find((leaf) => leaf.textContent?.trim() === label); + return Boolean(card) && atRest(card as Element, view); + }); +} + +/** + * Whether the page in the frame prints the still's snapshot, with no font still + * loading and its count cards at rest. + */ export function trackerAgrees(page: Document | null | undefined) { if (!page?.body?.innerText?.includes(TRACKER_TEXT)) return false; - return (page.fonts?.status ?? 'loaded') === 'loaded'; + if ((page.fonts?.status ?? 'loaded') !== 'loaded') return false; + return countsAtRest(page); } +/** Only a text field keeps the arrows, so the year menu still lets them drive the deck. */ const isTyping = (target: EventTarget | null) => { const element = target as HTMLElement | null; - return Boolean(element?.isContentEditable) || ['INPUT', 'TEXTAREA', 'SELECT'].includes(element?.tagName ?? ''); + return Boolean(element?.isContentEditable) || ['INPUT', 'TEXTAREA'].includes(element?.tagName ?? ''); }; /** Hands a key pressed inside the frame to the deck, unless the key is typing. */ @@ -41,10 +69,11 @@ export function forwardKey(event: KeyboardEvent) { /** * The tracker, live on the slide, over a still of the same crop. The still - * shows until the tracker prints the still's snapshot, and stays if it never does. Clicks and - * scrolling go to the tracker. Arrow keys drive the deck after a click in the - * frame too, because the deck and the tracker share an origin in production; - * on a branch preview they stay with the frame until the slide is clicked. + * shows until the tracker prints the still's snapshot and its count cards come + * to rest, and it stays if they never do. On any host but www.policyengine.org, + * a cross-origin frame's document reads as null, so the still stays for good. + * Clicks and scrolling go to the tracker, and arrow keys still drive the deck + * after a click in the frame, unless the click lands in a text field. */ export default function CoverageEmbed({ alt }: { alt: string }) { const box = useRef(null); @@ -61,13 +90,12 @@ export default function CoverageEmbed({ alt }: { alt: string }) { return () => observer.disconnect(); }, [exporting]); - // A hidden frame starts no font loads, so the check waits for the slide to show. + // A hidden frame's cards read as at rest before they fade in, so the check waits for the slide to show. const visible = boxWidth > 0; useEffect(() => { if (!loaded || ready || !visible) return; - const sameOrigin = new URL(TRACKER_URL).origin === window.location.origin; const timer = setInterval(() => { - if (!sameOrigin || trackerAgrees(frame.current?.contentDocument)) setReady(true); + if (trackerAgrees(frame.current?.contentDocument)) setReady(true); }, 400); return () => clearInterval(timer); }, [loaded, ready, visible]); @@ -75,9 +103,17 @@ export default function CoverageEmbed({ alt }: { alt: string }) { const handleLoad = () => { setLoaded(true); try { - frame.current?.contentWindow?.addEventListener('keydown', forwardKey); + const page = frame.current?.contentWindow; + page?.addEventListener('keydown', forwardKey); + // A reload prints the fallback counts first, so the still returns until the new page loads and passes the check. + page?.addEventListener('pagehide', (event) => { + // A page the browser keeps for Back returns whole, and its frame fires no new load. + if (event.persisted) return; + setLoaded(false); + setReady(false); + }); } catch { - // A cross-origin frame, on a branch preview. + // A cross-origin frame, on any host but www.policyengine.org. } }; diff --git a/slideshows/appam-2026/slides/deck-layout.test.tsx b/slideshows/appam-2026/slides/deck-layout.test.tsx index d0132a4d..406c554d 100644 --- a/slideshows/appam-2026/slides/deck-layout.test.tsx +++ b/slideshows/appam-2026/slides/deck-layout.test.tsx @@ -343,5 +343,26 @@ describe('links', () => { await click(main.querySelector('.slide-active h1')!); expect(main.dataset.currentStep).toBe('2'); }); + + it("keeps slide 12's frame through every move that leaves the slide shown or a neighbor, and drops it two slides away", async () => { + const press = (key: string) => + act(async () => { + window.dispatchEvent(new KeyboardEvent('keydown', { key })); + }); + const frame = () => container.querySelector("iframe[title*='coverage tracker']"); + window.history.replaceState(null, '', '/slides/appam-2026?slide=13'); + await act(async () => { + root.render(); + }); + const shown = frame(); + expect(shown).not.toBeNull(); + // Back through both of slide 11's builds, then on to slide 13 and back. + for (const key of ['ArrowLeft', 'ArrowLeft', 'ArrowRight', 'ArrowRight', 'ArrowRight', 'ArrowLeft']) { + await press(key); + expect(frame()).toBe(shown); + } + for (const key of ['ArrowLeft', 'ArrowLeft', 'ArrowLeft']) await press(key); + expect(frame()).toBeNull(); + }); }); });