Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 72 additions & 4 deletions scripts/preview-browser.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,53 @@ try {
await route.fulfill(asset ? { body: asset.body, contentType: asset.type } : { status: 404, body: '' });
});
await page.goto('http://kindpath-ui.invalid/');
const lightMode = page.getByRole('button', {name:'Light mode',exact:true});
const darkMode = page.getByRole('button', {name:'Dark mode',exact:true});
const supportsThemeTransition = await page.evaluate(() => {
if (!document.startViewTransition) return false;
const start = document.startViewTransition.bind(document);
window.themeTransitionCount = 0;
document.startViewTransition = update => {
const transition = start(update);
window.themeTransitionCount++;
window.themeTransitionFinished = transition.finished;
return transition;
};
return true;
});
const themeTransitionCount = () => page.evaluate(() => window.themeTransitionCount ?? 0);
const switchTheme = async (theme, key = 'Enter') => {
await (theme === 'light' ? lightMode : darkMode).press(key);
await page.waitForFunction(expected => document.documentElement.dataset.theme === expected, theme);
await page.evaluate(() => window.themeTransitionFinished);
};
const rootTheme = () => page.evaluate(() => ({
name: document.documentElement.dataset.theme,
background: getComputedStyle(document.documentElement).backgroundColor,
colourScheme: getComputedStyle(document.documentElement).colorScheme,
}));
await lightMode.waitFor();
assert.deepEqual(await rootTheme(), {name:'dark',background:'rgb(29, 39, 35)',colourScheme:'dark'});
const themeButton = await lightMode.boundingBox();
await switchTheme('light');
assert.deepEqual(await rootTheme(), {name:'light',background:'rgb(250, 248, 239)',colourScheme:'light'});
assert.deepEqual(await darkMode.boundingBox(), themeButton);
assert.equal(await page.locator('.url-field button').evaluate(node => getComputedStyle(node).backgroundColor), 'rgb(23, 62, 56)');
const comparisonLabels = page.locator('.landing-comparison-label');
const panelStyles = () => page.locator('.landing-comparison-panes').evaluate(node => ({
background: getComputedStyle(node).backgroundColor,
label: getComputedStyle(node.querySelector('.landing-comparison-label')).color,
}));
assert.deepEqual(await panelStyles(), {background:'rgba(0, 0, 0, 0)',label:'rgb(23, 62, 56)'});
const transitions = supportsThemeTransition ? 1 : 0;
assert.equal(await themeTransitionCount(), transitions);
await page.emulateMedia({reducedMotion:'reduce'});
await switchTheme('dark', 'Space');
assert.equal(await themeTransitionCount(), transitions);
assert.deepEqual(await panelStyles(), {background:'rgba(0, 0, 0, 0)',label:'rgb(226, 240, 227)'});
assert.equal(await comparisonLabels.count(), 2);
await page.emulateMedia({reducedMotion:'no-preference'});
assert.equal((await rootTheme()).name, 'dark');
const websiteEntry=page.getByLabel('Your website URL');
assert.equal(await websiteEntry.getAttribute('type'),'text');
assert.equal(await websiteEntry.getAttribute('inputmode'),'url');
Expand Down Expand Up @@ -315,6 +362,15 @@ try {
assert.equal(await frog.evaluate(node => getComputedStyle(node).animationName), 'none');
assert.equal(await sprout.getAttribute('aria-hidden'), 'true');
assert.equal(await sprout.evaluate(node => getComputedStyle(node).animationName), 'landing-sprite-frames');
const sproutFrames = await sprout.evaluate(node => {
const animation = node.getAnimations()[0];
animation.pause();
return [5400, 6000].map(time => {
animation.currentTime = time;
return getComputedStyle(node).backgroundPositionX;
});
});
assert.notEqual(sproutFrames[0], sproutFrames[1], 'Little Sprout must keep moving past two cycles');
await page.emulateMedia({reducedMotion:'reduce'});
assert.equal(await dots.evaluate(node => getComputedStyle(node).animationName), 'none');
for (const sprite of [frog, sprout]) {
Expand Down Expand Up @@ -357,15 +413,19 @@ try {
await page.emulateMedia({reducedMotion:'no-preference'});
assert.equal(await page.locator('.results .manual-card').getByText(/axe-core/).count(), 0);
const reportSupportFollows = async content => {
const bottom = await content.evaluate(node => node.getBoundingClientRect().bottom);
const top = await content.evaluate(node => node.getBoundingClientRect().top);
assert.ok(await page.locator('.results .report-exports').evaluate(node => node.getBoundingClientRect().bottom) <= top);
const bottom = await content.evaluate(node => node.getBoundingClientRect().bottom + scrollY);
const top = await content.evaluate(node => node.getBoundingClientRect().top + scrollY);
assert.ok(await page.locator('.results .report-exports').evaluate(node => node.getBoundingClientRect().bottom + scrollY) <= top);
for (const block of [
page.locator('.results').getByText('Automated results for the final scanned page only. Manual review is still required.', {exact:true}),
page.locator('.results .scan-coverage'),
]) assert.ok(await block.evaluate(node => node.getBoundingClientRect().top) >= bottom);
]) assert.ok(await block.evaluate(node => node.getBoundingClientRect().top + scrollY) >= bottom);
};
await reportSupportFollows(page.locator('.results .findings-layout'));
await switchTheme('light');
assert.equal(await page.locator('.finding').first().evaluate(node => getComputedStyle(node).backgroundColor), 'rgb(255, 254, 250)');
assert.equal(await page.locator('.report-exports').evaluate(node => getComputedStyle(node).backgroundColor), 'rgb(232, 237, 223)');
await switchTheme('dark');

assert.equal(await page.getByText('Automated page scan', { exact: true }).count(), 1);
const coverageNotice=page.locator('.results').getByRole('region',{name:'Scan coverage',exact:true});
Expand Down Expand Up @@ -542,6 +602,14 @@ try {
assert.equal(await page.getByRole('status').filter({hasText: /KindPath received a .* screen/}).count(), 0);
assert.equal(await page.locator('.vision-controls').first().evaluate(node => getComputedStyle(node).backgroundColor), 'rgb(41, 58, 48)');
const imageFrames=page.locator('iframe');
const lightThemeSources = await imageFrames.evaluateAll(frames => frames.map(frame => frame.getAttribute('srcdoc')));
await page.setViewportSize({width:320,height:844});
await switchTheme('light');
assert.equal(await page.locator('.vision-controls').first().evaluate(node => getComputedStyle(node).backgroundColor), 'rgb(232, 237, 223)');
assert.deepEqual(await imageFrames.evaluateAll(frames => frames.map(frame => frame.getAttribute('srcdoc'))), lightThemeSources);
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
await switchTheme('dark');
await page.setViewportSize({width:1280,height:900});
for(let i=0;i<2;i++){
const frame=page.frameLocator('iframe').nth(i);
await frame.locator('img').evaluate(img=>img.decode());
Expand Down
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { Report } from '../shared/types';
import { normalizeWebsiteEntry } from './components/websiteEntry';
import { scanFailureFeedback } from './components/scanFeedback';
import type { ScanFailureFeedback } from './components/scanFeedback';
import { ThemeToggle } from './components/ThemeToggle';
import { WebsiteForm } from './components/WebsiteForm';
import { ReportExports } from './components/ReportExports';
import { ScanCoverageNotice } from './components/ScanCoverageNotice';
Expand Down Expand Up @@ -137,6 +138,7 @@ export function App() {
<a className="skip-link" href="#main">Skip to content</a>
<header className="site-header">
<a className="brand" href="#">KindPath</a>
<ThemeToggle/>
</header>
<main id="main" tabIndex={-1}>
<section className="hero" aria-labelledby="landing-title">
Expand Down
44 changes: 44 additions & 0 deletions src/components/ThemeToggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { useLayoutEffect, useState } from 'react';
import { flushSync } from 'react-dom';

export function ThemeToggle() {
const [light, setLight] = useState(false);
useLayoutEffect(() => {
const root = document.documentElement;
const theme = light ? 'light' : 'dark';
if (root.dataset.theme === theme) return;

root.dataset.themeSwitching = '';
root.dataset.theme = theme;
// Let the new palette paint before restoring hover and press transitions.
let frame = requestAnimationFrame(() => {
frame = requestAnimationFrame(() => { delete root.dataset.themeSwitching; });
});
return () => {
cancelAnimationFrame(frame);
delete root.dataset.themeSwitching;
};
}, [light]);

function toggleTheme() {
const update = () => setLight(value => !value);
if (!document.startViewTransition || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
update();
return;
}

// Capture the old and new palettes as complete frames so controls stay readable.
const transition = document.startViewTransition(() => flushSync(update));
void transition.ready.catch(() => {}); // An overlapping switch can skip the previous transition.
}

return <button type="button" className="secondary theme-toggle" onClick={toggleTheme}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
{light ? <path d="M20.9 13.4A9 9 0 0 1 10.6 3.1a9 9 0 1 0 10.3 10.3Z"/> : <>
<circle cx="12" cy="12" r="4"/>
<path d="M12 2v2m0 16v2M2 12h2m16 0h2M5 5l1.5 1.5m11 11L19 19m0-14-1.5 1.5m-11 11L5 19"/>
</>}
</svg>
{light ? 'Dark mode' : 'Light mode'}
</button>;
}
Loading
Loading