Mercurial
diff design_system/test/catalog_test.js @ 258:60a876c4587a
[ui] Add semantic primitive ownership
Build a layered Zenbu token and sizing system, make authored controls use native-underneath primitives, migrate mrjunejune without imposing visual surfaces, and document/enforce HTML ownership in the catalog and wiki.
Co-authored-by: Copilot <[email protected]>
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Wed, 05 Aug 2026 05:25:40 -0700 |
| parents | |
| children | 667156fcd3e3 |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/design_system/test/catalog_test.js Wed Aug 05 05:25:40 2026 -0700 @@ -0,0 +1,2100 @@ +const assert = require('node:assert/strict'); +const http = require('node:http'); +const net = require('node:net'); +const path = require('node:path'); +const { spawn } = require('node:child_process'); + +const RUNFILES = process.env.JS_BINARY__RUNFILES; +const WORKSPACE = process.env.JS_BINARY__WORKSPACE; +const runfilesWorkspace = path.join(RUNFILES, WORKSPACE); +const playwrightPath = path.join( + runfilesWorkspace, + 'hg-web/e2e/node_modules/playwright-core', +); +const { chromium } = require(playwrightPath); + +function findFreePort() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + server.close(error => { + if (error) reject(error); + else resolve(String(address.port)); + }); + }); + }); +} + +async function stopProcess(child) { + if (!child || child.exitCode !== null) return; + child.kill('SIGTERM'); + await new Promise(resolve => { + const timer = setTimeout(() => { + if (child.exitCode === null) child.kill('SIGKILL'); + }, 3000); + child.once('exit', () => { + clearTimeout(timer); + resolve(); + }); + }); +} + +async function waitForServer(server, baseUrl, logs) { + const deadline = Date.now() + 15000; + while (Date.now() < deadline) { + if (server.exitCode !== null) { + throw new Error(`Server exited with ${server.exitCode}\n${logs.join('')}`); + } + try { + const response = await fetch(baseUrl); + if (response.ok) return; + } catch { + // Keep waiting. + } + await new Promise(resolve => setTimeout(resolve, 100)); + } + throw new Error(`Server startup timed out\n${logs.join('')}`); +} + +function requestRawPath(port, requestPath) { + return new Promise((resolve, reject) => { + const request = http.request({ + host: '127.0.0.1', + port, + method: 'GET', + path: requestPath, + agent: false, + }, response => { + response.resume(); + response.on('end', () => resolve(response.statusCode)); + }); + request.on('error', reject); + request.end(); + }); +} + +(async () => { + assert.ok(RUNFILES); + assert.ok(WORKSPACE); + const serverBinary = path.join( + runfilesWorkspace, + 'design_system/design_system_server', + ); + const chromiumPath = path.resolve(process.env.CHROMIUM_PATH); + const port = await findFreePort(); + const baseUrl = `http://127.0.0.1:${port}`; + const logs = []; + let server; + let browser; + + try { + const invalidServer = spawn(serverBinary, [], { + cwd: runfilesWorkspace, + env: { + ...process.env, + DESIGN_SYSTEM_PORT: 'not-a-port', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + const invalidExit = await new Promise(resolve => { + invalidServer.once('exit', (code, signal) => resolve({ code, signal })); + }); + assert.equal(invalidExit.code, 1); + assert.equal(invalidExit.signal, null); + + server = spawn(serverBinary, [], { + cwd: runfilesWorkspace, + env: { + ...process.env, + DESIGN_SYSTEM_PORT: port, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + server.stdout.on('data', chunk => logs.push(chunk.toString())); + server.stderr.on('data', chunk => logs.push(chunk.toString())); + await waitForServer(server, baseUrl, logs); + assert.equal(await requestRawPath(port, '/../../MODULE.bazel'), 400); + + for (const route of [ + '/', + '/tokens', + '/icons', + '/components', + '/components/button', + '/components/card', + '/components/alert', + '/components/field', + '/components/notifications', + '/components/stack', + ]) { + const response = await fetch(`${baseUrl}${route}`); + assert.equal(response.status, 200, route); + assert.match( + response.headers.get('content-type') || '', + /^text\/html/, + ); + } + for (const asset of [ + '/styles/tokens.css', + '/styles/reference.css', + '/styles/semantic.css', + '/styles/density.css', + '/styles/themes.css', + '/styles/components.css', + '/styles/elements.css', + '/components/index.js', + '/components/icon.js', + '/catalog.js', + '/catalog.js', + ]) { + const response = await fetch(`${baseUrl}${asset}`); + assert.equal(response.status, 200, asset); + assert.ok((await response.text()).length > 100, asset); + } + + browser = await chromium.launch({ + executablePath: chromiumPath, + headless: true, + args: ['--no-sandbox'], + }); + const context = await browser.newContext({ colorScheme: 'light' }); + const page = await context.newPage(); + const errors = []; + const devtools = await context.newCDPSession(page); + await devtools.send('Runtime.enable'); + devtools.on('Runtime.exceptionThrown', event => { + const exception = event.exceptionDetails; + errors.push( + `${exception.text}: ${exception.exception?.description || ''} ` + + `${exception.url || ''}:${exception.lineNumber + 1}`, + ); + }); + page.on('pageerror', error => errors.push(error.stack || error.message)); + page.on('console', message => { + if (message.type() === 'error') { + const location = message.location(); + errors.push( + `${message.text()} ${location.url || ''}:${location.lineNumber + 1}`, + ); + } + }); + page.on('response', response => { + if (response.status() >= 400) { + errors.push(`${response.status()} ${response.url()}`); + } + }); + + await page.goto(`${baseUrl}/components/button`, { + waitUntil: 'networkidle', + }); + try { + await page.waitForFunction( + () => customElements.get('zen-button') && + customElements.get('zen-story'), + undefined, + { timeout: 5000 }, + ); + } catch (error) { + throw new Error(`${error.message}\n${errors.join('\n')}`); + } + const catalogState = await page.evaluate(async () => { + const links = [ + ...document.querySelectorAll( + '.catalog-nav[aria-label="Components"] a', + ), + ]; + const elements = [...new Set( + links.map(link => link.dataset.zenElement).filter(Boolean), + )]; + const statuses = await Promise.all( + links.map(async link => (await fetch(link.href)).status), + ); + return { + cards: document.querySelectorAll('.component-grid > a').length, + elements, + links: links.length, + missing: elements.filter(name => !customElements.get(name)), + routes: links.map(link => new URL(link.href).pathname), + statuses, + }; + }); + assert.equal(catalogState.cards, 70); + assert.equal(catalogState.links, 70); + assert.equal(catalogState.elements.length, 69); + assert.deepEqual(catalogState.missing, []); + assert.deepEqual( + [...new Set(catalogState.statuses)], + [200], + ); + const primitiveOwnership = await page.evaluate(() => { + const buttonOwners = [ + 'zen-alert', + 'zen-attachment', + 'zen-button', + 'zen-button-group', + 'zen-calendar', + 'zen-carousel', + 'zen-combobox', + 'zen-command', + 'zen-context-menu', + 'zen-data-table', + 'zen-date-picker', + 'zen-dropdown-menu', + 'zen-input-group', + 'zen-menubar', + 'zen-message-scroller', + 'zen-navigation-menu', + 'zen-notifications', + 'zen-sidebar', + 'zen-tabs', + 'zen-toggle', + 'zen-toggle-group', + ].join(','); + const controlOwners = [ + 'zen-calendar', + 'zen-checkbox', + 'zen-combobox', + 'zen-command', + 'zen-date-picker', + 'zen-field', + 'zen-form', + 'zen-input', + 'zen-input-group', + 'zen-input-otp', + 'zen-native-select', + 'zen-radio-group', + 'zen-select', + 'zen-slider', + 'zen-switch', + 'zen-textarea', + ].join(','); + return { + buttons: [...document.querySelectorAll('button')].filter(button => + !button.closest(buttonOwners) && + !button.matches('zen-story > header > button') + ).map(button => button.outerHTML), + controls: [ + ...document.querySelectorAll('input, select, textarea'), + ].filter(control => !control.closest(controlOwners)) + .map(control => control.outerHTML), + }; + }); + assert.deepEqual(primitiveOwnership, { + buttons: [], + controls: [], + }); + const brokenCatalogPages = await page.evaluate(routes => { + const failures = []; + const buttonOwners = [ + 'zen-alert', + 'zen-attachment', + 'zen-button', + 'zen-button-group', + 'zen-calendar', + 'zen-carousel', + 'zen-combobox', + 'zen-command', + 'zen-context-menu', + 'zen-data-table', + 'zen-date-picker', + 'zen-dropdown-menu', + 'zen-input-group', + 'zen-menubar', + 'zen-message-scroller', + 'zen-navigation-menu', + 'zen-notifications', + 'zen-sidebar', + 'zen-tabs', + 'zen-toggle', + 'zen-toggle-group', + ].join(','); + for (const route of ['/', '/tokens', '/icons', '/components', ...routes]) { + history.replaceState({}, '', route); + window.dispatchEvent(new PopStateEvent('popstate')); + const visible = [ + ...document.querySelectorAll('[data-catalog-page]:not([hidden])'), + ]; + const expected = route === '/' || route === '/components' + ? 'overview' + : route.slice(route.lastIndexOf('/') + 1); + if (visible.length !== 1 || + visible[0].dataset.catalogPage !== expected || + !visible[0].querySelector('h1')?.textContent.trim()) { + failures.push(route); + continue; + } + if (route.startsWith('/components/')) { + const story = visible[0].querySelector('zen-story'); + const canvas = story?.querySelector('.story-canvas'); + const source = story?.querySelector('pre code'); + if (!story || !canvas?.children.length || + !source?.textContent.trim()) { + failures.push(`${route}:empty-story`); + } + const unownedButtons = [...canvas?.querySelectorAll('button') || []] + .filter(button => !button.closest(buttonOwners)); + if (unownedButtons.length) { + failures.push(`${route}:unowned-button`); + } + } + } + history.replaceState({}, '', '/components/button'); + window.dispatchEvent(new PopStateEvent('popstate')); + return failures; + }, catalogState.routes); + assert.deepEqual(brokenCatalogPages, []); + await page.keyboard.press('/'); + assert.equal( + await page.locator('#componentSearch').evaluate( + input => document.activeElement === input, + ), + true, + ); + await page.locator('#componentSearch').fill('date'); + assert.equal( + await page.locator( + '.catalog-nav[aria-label="Components"] a:not([hidden])', + ).count(), + 2, + ); + assert.equal( + await page.locator('.component-grid > a:not([hidden])').count(), + 2, + ); + assert.match( + await page.locator('#componentSearchStatus').textContent(), + /^2 components found$/, + ); + await page.locator('#componentSearch').press('Escape'); + assert.equal(await page.locator('#componentSearch').inputValue(), ''); + assert.equal( + await page.locator( + '.catalog-nav[aria-label="Components"] a:not([hidden])', + ).count(), + 70, + ); + await page.locator('#componentSearch').fill('stack'); + assert.equal( + await page.locator( + '.catalog-nav[aria-label="Components"] a[href="/components/stack"]', + ).getAttribute('hidden'), + null, + ); + assert.equal( + await page.locator( + '.component-grid > a[href="/components/stack"]', + ).getAttribute('hidden'), + null, + ); + await page.locator('#componentSearch').press('Escape'); + assert.equal( + await page.locator('[data-catalog-page="button"]').isVisible(), + true, + ); + assert.equal( + await page.locator('.catalog-sidebar a[href="/components/button"]').getAttribute('aria-current'), + 'page', + ); + assert.match( + await page.locator('zen-story pre').first().textContent(), + /<zen-button>/, + ); + const plainPresentation = await page.evaluate(() => { + const style = document.createElement('style'); + style.textContent = ` + .application-button { + padding: 7px; + border: 3px solid currentColor; + box-shadow: none; + background: transparent; + } + .application-input { + appearance: auto; + padding: 9px; + border: 4px solid currentColor; + background: transparent; + } + `; + document.head.append(style); + const buttonHost = document.createElement('zen-button'); + buttonHost.setAttribute('appearance', 'plain'); + buttonHost.setAttribute('loading', ''); + const button = document.createElement('button'); + button.className = 'application-button'; + button.textContent = 'Plain'; + buttonHost.append(button); + + const card = document.createElement('zen-card'); + card.setAttribute('appearance', 'plain'); + const article = document.createElement('article'); + card.append(article); + + const field = document.createElement('zen-field'); + field.setAttribute('appearance', 'plain'); + const label = document.createElement('label'); + label.textContent = 'Plain field'; + const input = document.createElement('input'); + field.append(label, input); + const inputHost = document.createElement('zen-input'); + inputHost.setAttribute('appearance', 'plain'); + inputHost.setAttribute('size', 'lg'); + const applicationInput = document.createElement('input'); + applicationInput.className = 'application-input'; + inputHost.append(applicationInput); + document.body.append(buttonHost, card, field, inputHost); + + const buttonStyle = getComputedStyle(button); + const cardStyle = getComputedStyle(article); + return { + buttonBackground: buttonStyle.backgroundColor, + buttonBorder: buttonStyle.borderTopWidth, + buttonPadding: buttonStyle.paddingTop, + buttonShadow: buttonStyle.boxShadow, + buttonOpacity: buttonStyle.opacity, + buttonSpinner: getComputedStyle(button, '::before').content, + cardBackground: cardStyle.backgroundColor, + cardBorder: cardStyle.borderTopWidth, + cardShadow: cardStyle.boxShadow, + fieldDisplay: getComputedStyle(field).display, + inputAppearance: getComputedStyle(input).appearance, + plainInputAppearance: getComputedStyle(applicationInput).appearance, + plainInputBorder: getComputedStyle(applicationInput).borderTopWidth, + plainInputPadding: getComputedStyle(applicationInput).paddingTop, + labelFor: label.htmlFor, + inputId: input.id, + }; + }); + assert.deepEqual(plainPresentation, { + buttonBackground: 'rgba(0, 0, 0, 0)', + buttonBorder: '3px', + buttonPadding: '7px', + buttonShadow: 'none', + buttonOpacity: '1', + buttonSpinner: 'none', + cardBackground: 'rgba(0, 0, 0, 0)', + cardBorder: '0px', + cardShadow: 'none', + fieldDisplay: 'contents', + inputAppearance: 'auto', + plainInputAppearance: 'auto', + plainInputBorder: '4px', + plainInputPadding: '9px', + labelFor: plainPresentation.inputId, + inputId: plainPresentation.inputId, + }); + const buttonScale = await page.locator( + '[data-catalog-page="button"] zen-button[size] > button', + ).evaluateAll(buttons => buttons.map(button => { + const host = button.parentElement; + const style = getComputedStyle(button); + const hostStyle = getComputedStyle(host); + return { + fontSize: Number.parseFloat(style.fontSize), + height: button.getBoundingClientRect().height, + paddingInline: Number.parseFloat(style.paddingInlineStart), + size: host.getAttribute('size'), + variableHeight: Number.parseFloat( + hostStyle.getPropertyValue('--zenbu-control-height'), + ) * Number.parseFloat(getComputedStyle(document.documentElement).fontSize), + }; + })); + assert.deepEqual( + buttonScale.map(item => item.size), + ['xs', 'sm', 'md', 'lg', 'xl'], + ); + for (let index = 1; index < buttonScale.length; index++) { + assert.ok(buttonScale[index].height > buttonScale[index - 1].height); + assert.ok( + buttonScale[index].paddingInline > + buttonScale[index - 1].paddingInline, + ); + assert.ok( + buttonScale[index].fontSize > buttonScale[index - 1].fontSize, + ); + } + for (const item of buttonScale) { + assert.ok( + Math.abs(item.height - item.variableHeight) < 1, + JSON.stringify(buttonScale), + ); + } + const compactScale = await page.evaluate(() => { + document.documentElement.dataset.zenDensity = 'compact'; + return [...document.querySelectorAll( + '[data-catalog-page="button"] zen-button[size] > button', + )] + .map(button => button.getBoundingClientRect().height); + }); + assert.equal(compactScale.length, buttonScale.length); + for (let index = 0; index < compactScale.length; index++) { + assert.ok(compactScale[index] < buttonScale[index].height); + } + await page.evaluate(() => { + delete document.documentElement.dataset.zenDensity; + }); + + await page.emulateMedia({ reducedMotion: 'reduce' }); + assert.equal( + await page.evaluate(() => + getComputedStyle(document.documentElement) + .getPropertyValue('--zenbu-sys-motion-duration-state') + .trim() + ), + '1ms', + ); + await page.emulateMedia({ reducedMotion: 'no-preference' }); + + assert.equal( + await page.locator('zen-button[loading]:not([appearance="plain"])').getAttribute('inert'), + null, + ); + assert.equal( + await page.locator('zen-button[loading]:not([appearance="plain"]) button').getAttribute('aria-disabled'), + 'true', + ); + assert.equal( + await page.locator('zen-button[loading]:not([appearance="plain"]) button').getAttribute('aria-busy'), + 'true', + ); + await page.evaluate(() => { + window.__loadingClicks = 0; + document.querySelector('zen-button[loading]:not([appearance="plain"]) button') + .addEventListener('click', () => window.__loadingClicks++); + }); + await page.locator('zen-button[loading]:not([appearance="plain"]) button').evaluate( + button => button.click(), + ); + assert.equal(await page.evaluate(() => window.__loadingClicks), 0); + await page.locator('zen-button[loading]:not([appearance="plain"]) button').focus(); + assert.equal( + await page.locator('zen-button[loading]:not([appearance="plain"]) button').evaluate( + button => document.activeElement === button, + ), + true, + ); + await page.keyboard.press('Tab'); + assert.equal( + await page.locator('zen-button[loading]:not([appearance="plain"]) button').evaluate( + button => document.activeElement === button, + ), + false, + ); + const nativeDisabledPreserved = await page.evaluate(async () => { + const wrapper = document.createElement('zen-button'); + const button = document.createElement('button'); + button.disabled = true; + button.textContent = 'Native disabled'; + wrapper.append(button); + document.body.append(wrapper); + await customElements.whenDefined('zen-button'); + wrapper.setAttribute('disabled', ''); + wrapper.removeAttribute('disabled'); + return button.disabled; + }); + assert.equal(nativeDisabledPreserved, true); + const replacementButtonState = await page.evaluate(async () => { + const wrapper = document.createElement('zen-button'); + wrapper.setAttribute('disabled', ''); + const first = document.createElement('button'); + first.textContent = 'First'; + wrapper.append(first); + document.body.append(wrapper); + await new Promise(resolve => setTimeout(resolve)); + const second = document.createElement('button'); + second.textContent = 'Second'; + wrapper.replaceChildren(second); + await new Promise(resolve => setTimeout(resolve)); + return { + firstDisabled: first.disabled, + secondAriaDisabled: second.getAttribute('aria-disabled'), + wrapperInert: wrapper.hasAttribute('inert'), + }; + }); + assert.equal(replacementButtonState.firstDisabled, false); + assert.equal(replacementButtonState.secondAriaDisabled, 'true'); + assert.equal(replacementButtonState.wrapperInert, false); + const modifiedClickAllowed = await page.evaluate(() => { + const link = document.querySelector( + '.catalog-sidebar a[href="/components/card"]', + ); + return link.dispatchEvent(new MouseEvent('click', { + bubbles: true, + cancelable: true, + button: 0, + ctrlKey: true, + })); + }); + assert.equal(modifiedClickAllowed, true); + + await page.goto(`${baseUrl}/icons`, { waitUntil: 'networkidle' }); + const iconCount = await page.locator('#iconGrid figure').count(); + assert.ok(iconCount >= 30); + assert.equal( + await page.locator('#iconGrid figure zen-icon > svg').count(), + iconCount, + ); + const iconFallback = await page.evaluate(() => { + const icon = document.createElement('zen-icon'); + icon.setAttribute('name', 'not-a-real-icon'); + icon.setAttribute('label', 'Missing icon fallback'); + document.body.append(icon); + return { + fallback: icon.dataset.zenIcon, + hidden: icon.getAttribute('aria-hidden'), + label: icon.getAttribute('aria-label'), + role: icon.getAttribute('role'), + }; + }); + assert.deepEqual(iconFallback, { + fallback: 'alert', + hidden: null, + label: 'Missing icon fallback', + role: 'img', + }); + + await page.goto(`${baseUrl}/components/link`, { + waitUntil: 'networkidle', + }); + const linkPrimitive = page.locator('zen-link[effect="paw"]').first(); + assert.equal( + await linkPrimitive.locator( + ':scope > a > zen-icon[name="paw"][data-zen-link-decoration]', + ).count(), + 1, + ); + const pawMotion = await linkPrimitive.locator('zen-icon').evaluate(icon => { + const style = getComputedStyle(icon); + const keyframes = icon.getAnimations().some(animation => + animation.animationName === 'zen-paw-step' + ); + return { + animationDuration: style.animationDuration, + color: style.color, + keyframes, + }; + }); + assert.notEqual(pawMotion.animationDuration, '0s'); + assert.notEqual(pawMotion.color, 'rgba(0, 0, 0, 0)'); + assert.equal(pawMotion.keyframes, true); + await page.emulateMedia({ reducedMotion: 'reduce' }); + assert.equal( + await linkPrimitive.locator('zen-icon').evaluate( + icon => getComputedStyle(icon).animationName, + ), + 'none', + ); + await page.emulateMedia({ reducedMotion: 'no-preference' }); + + await page.goto(`${baseUrl}/components/text`, { + waitUntil: 'networkidle', + }); + const textScale = await page.locator('zen-text[size] > p').evaluateAll( + texts => texts.map(text => Number.parseFloat( + getComputedStyle(text).fontSize, + )), + ); + assert.equal(textScale.length, 5); + for (let index = 1; index < textScale.length; index++) { + assert.ok(textScale[index] > textScale[index - 1]); + } + + await page.goto(`${baseUrl}/components/heading`, { + waitUntil: 'networkidle', + }); + const headingScale = await page.locator( + 'zen-heading[size] > h3', + ).evaluateAll(headings => headings.map(heading => ({ + fontSize: Number.parseFloat(getComputedStyle(heading).fontSize), + weight: Number(getComputedStyle(heading).fontWeight), + }))); + assert.equal(headingScale.length, 5); + assert.ok(headingScale.every(heading => heading.weight >= 700)); + for (let index = 1; index < headingScale.length; index++) { + assert.ok( + headingScale[index].fontSize > headingScale[index - 1].fontSize, + ); + } + + await page.goto(`${baseUrl}/components/box`, { + waitUntil: 'networkidle', + }); + const boxScale = await page.locator('zen-box[padding]').evaluateAll( + boxes => boxes.map(box => Number.parseFloat( + getComputedStyle(box).paddingTop, + )), + ); + assert.equal(boxScale.length, 5); + for (let index = 1; index < boxScale.length; index++) { + assert.ok(boxScale[index] > boxScale[index - 1]); + } + + await page.goto(`${baseUrl}/components/skeleton`, { + waitUntil: 'networkidle', + }); + assert.equal( + await page.locator('zen-skeleton[width="60%"]').evaluate( + skeleton => skeleton.style.getPropertyValue( + '--zenbu-skeleton-width', + ), + ), + '60%', + ); + + await page.goto(`${baseUrl}/tokens`, { waitUntil: 'networkidle' }); + const colorTokenState = await page.evaluate(() => { + const root = document.documentElement; + const value = name => getComputedStyle(root) + .getPropertyValue(`--zen-color-${name}`) + .trim(); + const systemValue = name => getComputedStyle(root) + .getPropertyValue(`--zenbu-sys-${name}`) + .trim(); + const families = [ + 'neutral', + 'red', + 'orange', + 'amber', + 'green', + 'teal', + 'blue', + 'violet', + 'rose', + 'brown', + ]; + const steps = [ + '50', + '100', + '200', + '300', + '400', + '500', + '600', + '700', + '800', + '900', + '950', + ]; + const rampsComplete = families.every(family => + steps.every(step => value(`${family}-${step}`)) + ); + const data = Array.from( + { length: 10 }, + (_, index) => value(`data-${index + 1}`), + ); + const materials = [ + 'paper', + 'washi', + 'linen', + 'sumi', + 'brick', + 'persimmon', + 'ochre', + 'moss', + 'patina', + 'indigo', + 'plum', + 'clay', + 'wood', + ].map(value); + const lightMuted = systemValue('color-surface-subtle'); + root.dataset.zenTheme = 'dark'; + const darkMuted = systemValue('color-surface-subtle'); + const darkData = Array.from( + { length: 10 }, + (_, index) => value(`data-${index + 1}`), + ); + const themes = ['paper', 'ink', 'playful'].map(theme => { + root.dataset.zenTheme = theme; + const channels = value => value.match(/\d+(?:\.\d+)?/g) + .slice(0, 3) + .map(Number); + const luminance = value => { + const converted = channels(value).map(channel => { + const normalized = channel / 255; + return normalized <= 0.04045 + ? normalized / 12.92 + : ((normalized + 0.055) / 1.055) ** 2.4; + }); + return converted[0] * 0.2126 + + converted[1] * 0.7152 + + converted[2] * 0.0722; + }; + const contrasts = [ + 'info', + 'success', + 'warning', + 'danger', + ].map(tone => { + const probe = document.createElement('span'); + probe.style.background = + `var(--zenbu-sys-color-${tone}-background)`; + probe.style.color = + `var(--zenbu-sys-color-${tone}-foreground)`; + document.body.append(probe); + const probeStyle = getComputedStyle(probe); + const foreground = luminance(probeStyle.color); + const background = luminance(probeStyle.backgroundColor); + const contrast = (Math.max(foreground, background) + 0.05) / + (Math.min(foreground, background) + 0.05); + probe.remove(); + return contrast; + }); + return { + canvas: systemValue('color-surface-page'), + contrast: Math.min(...contrasts), + font: getComputedStyle(root) + .getPropertyValue('--zenbu-sys-font-family-ui') + .trim(), + name: theme, + radius: getComputedStyle(root) + .getPropertyValue('--zenbu-sys-radius-container') + .trim(), + }; + }); + delete root.dataset.zenTheme; + return { + darkData, + darkMuted, + data, + lightMuted, + materials, + rampsComplete, + themes, + }; + }); + assert.equal(colorTokenState.rampsComplete, true); + assert.equal(new Set(colorTokenState.materials).size, 13); + assert.equal(new Set(colorTokenState.data).size, 10); + assert.equal(new Set(colorTokenState.darkData).size, 10); + assert.notDeepEqual(colorTokenState.data, colorTokenState.darkData); + assert.equal( + new Set(colorTokenState.themes.map(theme => theme.canvas)).size, + 3, + ); + assert.notEqual( + colorTokenState.themes.find(theme => theme.name === 'paper').radius, + colorTokenState.themes.find(theme => theme.name === 'playful').radius, + ); + assert.ok( + colorTokenState.themes.every(theme => theme.contrast >= 4.5), + JSON.stringify(colorTokenState.themes), + ); + assert.notEqual( + colorTokenState.lightMuted, + colorTokenState.darkMuted, + ); + assert.equal(await page.locator('.palette-grid > div').count(), 13); + assert.equal(await page.locator('.data-palette > span').count(), 10); + await page.evaluate(() => { + document.documentElement.dataset.zenTheme = 'playful'; + }); + await page.emulateMedia({ forcedColors: 'active' }); + assert.equal( + await page.evaluate(() => + getComputedStyle(document.documentElement) + .getPropertyValue('--zenbu-sys-color-surface-page') + .trim() + .toLowerCase() + ), + 'canvas', + ); + await page.emulateMedia({ forcedColors: 'none' }); + await page.evaluate(() => { + delete document.documentElement.dataset.zenTheme; + }); + + await page.goto(`${baseUrl}/components/field`, { + waitUntil: 'networkidle', + }); + const fieldWiring = await page.locator('zen-field').first().evaluate(field => { + const label = field.querySelector('label'); + const input = field.querySelector('input'); + const help = field.querySelector('small'); + input.checkValidity(); + return { + describedBy: input.getAttribute('aria-describedby'), + helpId: help.id, + inputId: input.id, + invalid: field.hasAttribute('data-invalid'), + labelFor: label.htmlFor, + }; + }); + assert.ok(fieldWiring.inputId); + assert.equal(fieldWiring.labelFor, fieldWiring.inputId); + assert.equal(fieldWiring.describedBy, fieldWiring.helpId); + assert.equal(fieldWiring.invalid, true); + await page.locator('zen-field input').first().focus(); + assert.equal( + await page.locator('zen-field input').first().evaluate( + input => getComputedStyle(input).outlineStyle, + ), + 'none', + ); + const replacementField = await page.locator('zen-field').first().evaluate( + async field => { + const label = field.querySelector('label'); + const oldInput = field.querySelector('input'); + const blocker = document.createElement('div'); + blocker.id = 'zen-field-3'; + document.body.append(blocker); + const nextInput = document.createElement('input'); + nextInput.required = true; + oldInput.replaceWith(nextInput); + await new Promise(resolve => setTimeout(resolve)); + oldInput.dispatchEvent(new Event('invalid')); + const help = field.querySelector('small'); + nextInput.id = 'replacement-email'; + help.id = 'replacement-help'; + await new Promise(resolve => setTimeout(resolve)); + return { + describedBy: nextInput.getAttribute('aria-describedby'), + helpId: help.id, + labelFor: label.htmlFor, + nextId: nextInput.id, + blockerId: blocker.id, + }; + }, + ); + assert.ok(replacementField.nextId); + assert.notEqual(replacementField.nextId, replacementField.blockerId); + assert.equal(replacementField.labelFor, replacementField.nextId); + assert.equal(replacementField.describedBy, replacementField.helpId); + + await page.goto(`${baseUrl}/components/alert`, { + waitUntil: 'networkidle', + }); + await page.evaluate(() => { + window.__dismissed = 0; + document.addEventListener('zen-dismiss', () => { + window.__dismissed++; + }); + }); + const dismissible = page.locator('zen-alert[dismissible]'); + assert.equal(await dismissible.getAttribute('role'), 'alert'); + await dismissible.locator('[data-zen-dismiss]').click(); + assert.equal(await dismissible.count(), 0); + assert.equal(await page.evaluate(() => window.__dismissed), 1); + const authoredDismissState = await page.evaluate(async () => { + const alert = document.createElement('zen-alert'); + alert.setAttribute('dismissible', ''); + const message = document.createElement('p'); + message.textContent = 'Authored dismiss control'; + const dismiss = document.createElement('button'); + dismiss.dataset.zenDismiss = ''; + alert.append(message, dismiss); + document.body.append(alert); + await new Promise(resolve => setTimeout(resolve)); + let events = 0; + alert.addEventListener('zen-dismiss', () => events++); + alert.removeAttribute('dismissible'); + dismiss.click(); + return { + connected: alert.isConnected, + events, + }; + }); + assert.equal(authoredDismissState.connected, true); + assert.equal(authoredDismissState.events, 0); + + await page.goto(`${baseUrl}/components/accordion`, { + waitUntil: 'networkidle', + }); + const accordion = page.locator('zen-accordion').first(); + const disclosureIcon = accordion.locator( + 'summary > zen-icon[data-zen-disclosure-icon]', + ).nth(1); + const collapsedDisclosure = await disclosureIcon.evaluate(icon => { + const style = getComputedStyle(icon); + return { + duration: style.transitionDuration, + transform: style.transform, + }; + }); + assert.notEqual(collapsedDisclosure.duration, '0s'); + await accordion.locator('summary').nth(1).click(); + await page.waitForTimeout(250); + assert.notEqual( + await disclosureIcon.evaluate(icon => getComputedStyle(icon).transform), + collapsedDisclosure.transform, + ); + assert.equal( + await accordion.locator('details').first().getAttribute('open'), + null, + ); + assert.equal( + await accordion.locator('details').nth(1).getAttribute('open'), + '', + ); + await accordion.locator('summary').nth(1).press('ArrowUp'); + assert.equal( + await accordion.locator('summary').first().evaluate( + summary => document.activeElement === summary, + ), + true, + ); + + await page.goto(`${baseUrl}/components/aspect-ratio`, { + waitUntil: 'networkidle', + }); + const aspectColors = await page.locator('.demo-media').evaluate(media => { + const style = getComputedStyle(media); + const probe = document.createElement('span'); + probe.style.background = 'var(--zenbu-sys-color-surface-subtle)'; + document.body.append(probe); + const token = getComputedStyle(probe).backgroundColor; + probe.remove(); + return { + background: style.backgroundColor, + image: style.backgroundImage, + token, + }; + }); + assert.equal(aspectColors.image, 'none'); + assert.equal(aspectColors.background, aspectColors.token); + + await page.goto(`${baseUrl}/components/tabs`, { + waitUntil: 'networkidle', + }); + await page.evaluate(() => { + window.__tabValue = null; + document.querySelector('zen-tabs').addEventListener( + 'zen-change', + event => { + window.__tabValue = event.detail.value; + }, + ); + }); + await page.locator('zen-tabs [role="tab"]').nth(1).click(); + assert.equal( + await page.locator('zen-tabs [role="tab"]').nth(1) + .getAttribute('aria-selected'), + 'true', + ); + assert.equal( + await page.locator('zen-tabs [role="tabpanel"]').nth(1) + .getAttribute('hidden'), + null, + ); + assert.equal(await page.evaluate(() => window.__tabValue), 'history'); + + await page.goto(`${baseUrl}/components/dialog`, { + waitUntil: 'networkidle', + }); + await page.locator('zen-dialog [data-zen-trigger]').click(); + assert.equal(await page.locator('zen-dialog dialog').getAttribute('open'), ''); + await page.locator('zen-dialog [data-zen-close]').click(); + assert.equal(await page.locator('zen-dialog dialog').getAttribute('open'), null); + const dynamicOverlays = await page.evaluate(async () => { + const ownAction = button => { + const owner = document.createElement('zen-button'); + owner.setAttribute('size', 'md'); + owner.append(button); + return owner; + }; + const dialogHost = document.createElement('zen-dialog'); + const popoverHost = document.createElement('zen-popover'); + const tooltipHost = document.createElement('zen-tooltip'); + const menuHost = document.createElement('zen-dropdown-menu'); + document.body.append(dialogHost, popoverHost, tooltipHost, menuHost); + + const dialogTrigger = document.createElement('button'); + dialogTrigger.type = 'button'; + dialogTrigger.dataset.zenTrigger = ''; + const dialog = document.createElement('dialog'); + dialogHost.append(ownAction(dialogTrigger), dialog); + + const popoverTrigger = document.createElement('button'); + popoverTrigger.type = 'button'; + popoverTrigger.dataset.zenTrigger = ''; + const popover = document.createElement('div'); + popover.dataset.zenContent = ''; + popoverHost.append(ownAction(popoverTrigger), popover); + + const tooltipTrigger = document.createElement('button'); + tooltipTrigger.type = 'button'; + tooltipTrigger.dataset.zenTrigger = ''; + const tooltip = document.createElement('span'); + tooltip.dataset.zenContent = ''; + tooltipHost.append(ownAction(tooltipTrigger), tooltip); + + const menuTrigger = document.createElement('button'); + menuTrigger.type = 'button'; + menuTrigger.dataset.zenTrigger = ''; + const menu = document.createElement('div'); + menu.setAttribute('role', 'menu'); + const item = document.createElement('button'); + item.type = 'button'; + item.setAttribute('role', 'menuitem'); + menu.append(item); + menuHost.append(menuTrigger, menu); + + await new Promise(resolve => setTimeout(resolve)); + dialogTrigger.click(); + const dialogOpen = dialog.open; + dialog.close(); + popoverTrigger.click(); + menuTrigger.click(); + tooltipTrigger.focus(); + await new Promise(resolve => setTimeout(resolve, 375)); + return { + dialogControlled: Boolean(dialogTrigger.getAttribute('aria-controls')), + dialogOpen, + menuOpen: !menu.hidden, + popoverExpanded: popoverTrigger.getAttribute('aria-expanded'), + tooltipOpen: !tooltip.hidden, + }; + }); + assert.deepEqual(dynamicOverlays, { + dialogControlled: true, + dialogOpen: true, + menuOpen: true, + popoverExpanded: 'true', + tooltipOpen: true, + }); + + await page.goto(`${baseUrl}/components/dropdown-menu`, { + waitUntil: 'networkidle', + }); + const dropdownTrigger = page.locator( + 'zen-dropdown-menu [data-zen-trigger]', + ); + await dropdownTrigger.click(); + assert.equal( + await page.locator('zen-dropdown-menu [role="menu"]') + .getAttribute('hidden'), + null, + ); + await page.keyboard.press('Escape'); + assert.equal( + await page.locator('zen-dropdown-menu [role="menu"]') + .getAttribute('hidden'), + '', + ); + assert.equal( + await dropdownTrigger.evaluate( + trigger => document.activeElement === trigger, + ), + true, + ); + await dropdownTrigger.click(); + await page.locator('zen-dropdown-menu [role="menuitem"]').first().click(); + assert.equal( + await dropdownTrigger.evaluate( + trigger => document.activeElement === trigger, + ), + true, + ); + + await page.goto(`${baseUrl}/components/combobox`, { + waitUntil: 'networkidle', + }); + const comboboxInput = page.locator('zen-combobox input'); + await comboboxInput.fill('seo'); + assert.equal( + await page.locator('zen-combobox [role="option"]:not([hidden])').count(), + 1, + ); + await comboboxInput.press('ArrowDown'); + await comboboxInput.press('Enter'); + assert.equal(await comboboxInput.inputValue(), 'seobeo'); + assert.equal( + await page.locator('zen-combobox [role="listbox"]') + .getAttribute('hidden'), + '', + ); + + await page.goto(`${baseUrl}/components/date-picker`, { + waitUntil: 'networkidle', + }); + const dateInput = page.locator('zen-date-picker input[type="date"]'); + assert.equal(await dateInput.getAttribute('aria-hidden'), 'true'); + assert.equal( + await dateInput.evaluate(input => getComputedStyle(input).position), + 'absolute', + ); + await page.evaluate(() => { + window.__dateValue = null; + document.querySelector('zen-date-picker').addEventListener( + 'zen-change', + event => { + window.__dateValue = event.detail.value; + }, + ); + }); + const dateTrigger = page.locator( + 'zen-date-picker [data-zen-date-trigger]', + ); + await dateTrigger.click(); + assert.equal( + await page.locator('zen-date-picker [data-zen-calendar-panel]') + .getAttribute('hidden'), + null, + ); + assert.equal( + await page.locator( + 'zen-date-picker [data-zen-calendar-day]', + ).count(), + 42, + ); + const chosenDate = await page.locator( + 'zen-date-picker [data-zen-calendar-day]:not([data-outside]):not(:disabled)', + ).nth(14).getAttribute('data-zen-calendar-day'); + await page.locator( + `zen-date-picker [data-zen-calendar-day="${chosenDate}"]`, + ).click(); + assert.equal(await dateInput.inputValue(), chosenDate); + assert.equal(await page.evaluate(() => window.__dateValue), chosenDate); + assert.equal(await dateTrigger.getAttribute('aria-expanded'), 'false'); + const triggerName = await dateTrigger.evaluate(trigger => + (trigger.getAttribute('aria-labelledby') || '') + .split(/\s+/) + .map(id => document.getElementById(id)?.textContent || '') + .join(' ') + ); + assert.match(triggerName, /Deploy on/); + assert.equal( + await page.locator('zen-date-picker > label').getAttribute('for'), + await dateTrigger.getAttribute('id'), + ); + const minimumDate = `${chosenDate.slice(0, 8)}10`; + await dateInput.evaluate((input, minimum) => { + input.min = minimum; + }, minimumDate); + await page.waitForTimeout(0); + await dateTrigger.click(); + const focusedBoundary = page.locator( + `zen-date-picker [data-zen-calendar-day="${chosenDate}"]`, + ); + await focusedBoundary.press('PageUp'); + assert.equal( + await page.evaluate(() => + document.activeElement?.dataset.zenCalendarDay + ), + minimumDate, + ); + assert.equal( + await page.locator( + 'zen-date-picker [data-zen-calendar-day][tabindex="0"]:not(:disabled)', + ).count(), + 1, + ); + await page.keyboard.press('Escape'); + await dateInput.evaluate(input => { + input.disabled = true; + }); + await page.waitForTimeout(0); + assert.equal(await dateTrigger.isDisabled(), true); + await dateInput.evaluate(input => { + input.disabled = false; + input.readOnly = true; + }); + await page.waitForTimeout(0); + assert.equal(await dateTrigger.isDisabled(), true); + await dateInput.evaluate(input => { + input.readOnly = false; + input.required = true; + input.value = ''; + input.dispatchEvent(new Event('input', { bubbles: true })); + input.reportValidity(); + }); + await page.waitForTimeout(0); + assert.equal(await dateTrigger.getAttribute('aria-invalid'), 'true'); + assert.equal( + await dateTrigger.evaluate( + trigger => document.activeElement === trigger, + ), + true, + ); + assert.equal( + await page.locator('zen-date-picker').getAttribute('data-invalid'), + '', + ); + await dateInput.evaluate((input, value) => { + input.value = value; + input.dispatchEvent(new Event('input', { bubbles: true })); + }, chosenDate); + assert.equal(await dateTrigger.getAttribute('aria-invalid'), 'false'); + const earlyYear = await page.evaluate(async () => { + const calendar = document.createElement('zen-calendar'); + const input = document.createElement('input'); + input.type = 'date'; + input.value = '0001-01-01'; + input.setAttribute('aria-label', 'Early date'); + calendar.append(input); + document.body.append(calendar); + await new Promise(resolve => setTimeout(resolve)); + return { + selected: calendar.querySelector( + '[data-zen-calendar-day][aria-selected="true"]', + )?.dataset.zenCalendarDay, + value: input.value, + }; + }); + assert.deepEqual(earlyYear, { + selected: '0001-01-01', + value: '0001-01-01', + }); + const resetDateState = await page.evaluate(async () => { + const label = document.createElement('span'); + label.id = 'reset-date-label'; + label.textContent = 'Reset date'; + const form = document.createElement('form'); + const picker = document.createElement('zen-date-picker'); + const input = document.createElement('input'); + input.type = 'date'; + input.defaultValue = '2026-08-04'; + input.setAttribute('aria-labelledby', label.id); + picker.append(input); + form.append(picker); + document.body.append(label, form); + await new Promise(resolve => setTimeout(resolve)); + input.value = '2026-08-12'; + input.dispatchEvent(new Event('input', { bubbles: true })); + form.reset(); + await new Promise(resolve => setTimeout(resolve)); + const trigger = picker.querySelector('[data-zen-date-trigger]'); + return { + labelledBy: trigger.getAttribute('aria-labelledby'), + selected: picker.querySelector( + '[data-zen-calendar-day][aria-selected="true"]', + )?.dataset.zenCalendarDay, + value: input.value, + }; + }); + assert.match(resetDateState.labelledBy, /^reset-date-label /); + assert.deepEqual( + { + selected: resetDateState.selected, + value: resetDateState.value, + }, + { + selected: '2026-08-04', + value: '2026-08-04', + }, + ); + + await page.goto(`${baseUrl}/components/input-otp`, { + waitUntil: 'networkidle', + }); + await page.evaluate(() => { + window.__otpValue = null; + document.querySelector('zen-input-otp').addEventListener( + 'zen-complete', + event => { + window.__otpValue = event.detail.value; + }, + ); + }); + await page.locator('zen-input-otp input').evaluate(input => { + input.value = '12a3456'; + input.dispatchEvent(new Event('input', { bubbles: true })); + }); + assert.equal(await page.locator('zen-input-otp input').inputValue(), '123456'); + assert.equal( + await page.locator('zen-input-otp [data-zen-otp-slots] > span').count(), + 6, + ); + const otp = page.locator('zen-input-otp'); + await otp.evaluate(host => host.setAttribute('size', 'xs')); + const compactOtpSize = await otp.locator( + '[data-zen-otp-slots] > span', + ).first().evaluate(slot => slot.getBoundingClientRect().width); + await otp.evaluate(host => host.setAttribute('size', 'xl')); + const largeOtpSize = await otp.locator( + '[data-zen-otp-slots] > span', + ).first().evaluate(slot => slot.getBoundingClientRect().width); + assert.ok(largeOtpSize > compactOtpSize); + assert.equal(await page.evaluate(() => window.__otpValue), '123456'); + + await page.goto(`${baseUrl}/components/data-table`, { + waitUntil: 'networkidle', + }); + await page.locator('zen-data-table [data-zen-sort="commits"]').click(); + assert.equal( + await page.locator('zen-data-table th').nth(1).getAttribute('aria-sort'), + 'ascending', + ); + assert.equal( + await page.locator('zen-data-table tbody tr').first().locator( + '[data-key="commits"]', + ).textContent(), + '12', + ); + + await page.goto(`${baseUrl}/components/carousel`, { + waitUntil: 'networkidle', + }); + await page.evaluate(() => { + window.__carouselIndex = null; + document.querySelector('zen-carousel').addEventListener( + 'zen-change', + event => { + window.__carouselIndex = event.detail.index; + }, + ); + }); + await page.locator('zen-carousel [data-zen-next]').click(); + assert.equal(await page.evaluate(() => window.__carouselIndex), 1); + assert.equal( + await page.locator('zen-carousel [data-zen-prev]').isDisabled(), + false, + ); + + await page.goto(`${baseUrl}/components/resizable`, { + waitUntil: 'networkidle', + }); + const resizeHandle = page.locator('zen-resizable [data-zen-handle]'); + const initialPercent = Number( + await resizeHandle.getAttribute('aria-valuenow'), + ); + await resizeHandle.focus(); + await resizeHandle.press('ArrowRight'); + assert.ok( + Number(await resizeHandle.getAttribute('aria-valuenow')) > initialPercent, + ); + + await page.goto(`${baseUrl}/components/switch`, { + waitUntil: 'networkidle', + }); + const switchInput = page.locator('zen-switch input'); + assert.equal(await switchInput.getAttribute('role'), 'switch'); + assert.equal(await switchInput.getAttribute('aria-checked'), 'true'); + await switchInput.uncheck(); + assert.equal(await switchInput.getAttribute('aria-checked'), 'false'); + + await page.goto(`${baseUrl}/components/checkbox`, { + waitUntil: 'networkidle', + }); + assert.equal( + await page.locator('zen-checkbox input').evaluate( + input => getComputedStyle(input).appearance, + ), + 'none', + ); + await page.goto(`${baseUrl}/components/native-select`, { + waitUntil: 'networkidle', + }); + assert.equal( + await page.locator('zen-native-select select').evaluate( + select => getComputedStyle(select).appearance, + ), + 'none', + ); + assert.equal( + await page.locator( + 'zen-native-select > zen-icon[data-zen-select-icon]', + ).count(), + 1, + ); + await page.goto(`${baseUrl}/components/slider`, { + waitUntil: 'networkidle', + }); + assert.equal( + await page.locator('zen-slider input').evaluate( + input => getComputedStyle(input).appearance, + ), + 'none', + ); + + await page.goto(`${baseUrl}/components/toggle-group`, { + waitUntil: 'networkidle', + }); + await page.locator('zen-toggle-group button[value="right"]').click(); + assert.equal( + await page.locator('zen-toggle-group button[value="right"]') + .getAttribute('aria-pressed'), + 'true', + ); + assert.equal( + await page.locator('zen-toggle-group').getAttribute('value'), + 'right', + ); + + await page.goto(`${baseUrl}/components/sidebar`, { + waitUntil: 'networkidle', + }); + const sidebarTrigger = page.locator( + 'zen-sidebar [data-zen-sidebar-trigger]', + ); + const sidebarPanel = page.locator( + 'zen-sidebar [data-zen-sidebar-panel]', + ); + assert.equal(await sidebarTrigger.getAttribute('aria-expanded'), 'true'); + assert.equal(await sidebarPanel.getAttribute('hidden'), null); + await sidebarTrigger.click(); + assert.equal(await sidebarTrigger.getAttribute('aria-expanded'), 'false'); + assert.equal(await sidebarPanel.getAttribute('hidden'), ''); + + await page.goto(`${baseUrl}/components/notifications`, { + waitUntil: 'networkidle', + }); + const notificationScope = page.locator('zen-notifications').first(); + const notificationPosition = await notificationScope.locator( + '[data-zen-notification-stack]', + ).evaluate(stack => { + const style = getComputedStyle(stack); + return { + bottom: style.bottom, + position: style.position, + right: style.right, + }; + }); + assert.equal(notificationPosition.position, 'fixed'); + assert.notEqual(notificationPosition.bottom, 'auto'); + assert.notEqual(notificationPosition.right, 'auto'); + + const dedupe = await notificationScope.evaluate(scope => { + const source = scope.querySelector('[data-notification-demo]'); + const send = detail => source.dispatchEvent( + new CustomEvent('zen-notify', { + bubbles: true, + composed: false, + detail, + }), + ); + send({ + version: 1, + id: 'dedupe', + tone: 'info', + message: 'First announcement', + announcement: 'polite', + persistent: true, + }); + send({ + version: 1, + id: 'dedupe', + tone: 'success', + message: 'Updated without duplication', + announcement: 'polite', + persistent: true, + }); + send({ version: 99, id: 'invalid' }); + return { + size: scope.size, + visible: scope.visibleCount, + }; + }); + assert.deepEqual(dedupe, { size: 1, visible: 1 }); + assert.equal( + await notificationScope.locator('article').count(), + 1, + ); + assert.match( + await notificationScope.locator('article').textContent(), + /Updated without duplication/, + ); + await page.waitForFunction(() => + document.querySelector( + 'zen-notifications [data-zen-live="polite"]', + )?.textContent === 'First announcement' + ); + + const bounded = await notificationScope.evaluate(scope => { + let accepted = 0; + for (let index = 0; index < 25; index++) { + if (scope.notify({ + version: 1, + id: `bounded-${index}`, + tone: 'info', + message: `Bounded ${index}`, + announcement: 'none', + durationMs: 120000, + })) accepted++; + } + return { + accepted, + size: scope.size, + visible: scope.visibleCount, + }; + }); + assert.deepEqual(bounded, { accepted: 25, size: 23, visible: 3 }); + assert.equal( + await notificationScope.locator('article').count(), + 3, + ); + await page.waitForFunction(() => + [...document.querySelectorAll( + 'zen-notifications article', + )].some(article => article.dataset.depth === '2') + ); + const collapsedStack = await notificationScope.locator( + '[data-zen-notification-stack]', + ).evaluate(stack => { + const articles = [...stack.querySelectorAll('article')]; + return { + depths: articles.map(article => article.dataset.depth), + height: stack.getBoundingClientRect().height, + positions: articles.map(article => + getComputedStyle(article).position + ), + transforms: articles.map(article => + getComputedStyle(article).transform + ), + }; + }); + assert.deepEqual(collapsedStack.depths, ['2', '1', '0']); + assert.deepEqual( + collapsedStack.positions, + ['absolute', 'absolute', 'absolute'], + ); + await notificationScope.locator('article').last().hover(); + await page.waitForTimeout(300); + const expandedStack = await notificationScope.locator( + '[data-zen-notification-stack]', + ).evaluate(stack => ({ + height: stack.getBoundingClientRect().height, + rects: [...stack.querySelectorAll('article')].map(article => { + const bounds = article.getBoundingClientRect(); + return { + bottom: bounds.bottom, + left: bounds.left, + right: bounds.right, + top: bounds.top, + }; + }), + tops: [...stack.querySelectorAll('article')].map( + article => Math.round(article.getBoundingClientRect().top), + ), + transforms: [...stack.querySelectorAll('article')].map( + article => getComputedStyle(article).transform, + ), + })); + assert.ok(expandedStack.height > collapsedStack.height); + assert.equal(new Set(expandedStack.tops).size, 3); + const expandedGaps = [ + expandedStack.rects[1].top - expandedStack.rects[0].bottom, + expandedStack.rects[2].top - expandedStack.rects[1].bottom, + ]; + for (const gap of expandedGaps) { + assert.ok(gap >= 10 && gap <= 14, JSON.stringify(expandedGaps)); + } + assert.notDeepEqual( + expandedStack.transforms, + collapsedStack.transforms, + ); + const gapX = + (expandedStack.rects[0].left + expandedStack.rects[0].right) / 2; + const gapY = + (expandedStack.rects[0].bottom + expandedStack.rects[1].top) / 2; + await page.mouse.move(gapX, gapY); + await page.waitForTimeout(100); + const gapState = await notificationScope.locator( + '[data-zen-notification-stack]', + ).evaluate(stack => ({ + height: stack.getBoundingClientRect().height, + hovered: stack.matches(':hover'), + })); + assert.equal(gapState.hovered, true); + assert.ok(gapState.height >= expandedStack.height - 1); + await page.mouse.move(0, 0); + + const actionScope = await page.evaluate(() => { + const scope = document.createElement('zen-notifications'); + scope.id = 'action-scope'; + const source = document.createElement('button'); + scope.append(source); + document.body.append(scope); + window.__notificationAction = null; + scope.addEventListener('zen-notification-action', event => { + window.__notificationAction = event.detail; + }); + source.dispatchEvent(new CustomEvent('zen-notify', { + bubbles: true, + composed: false, + detail: { + version: 1, + id: 'action', + tone: 'error', + message: 'Action required', + announcement: 'assertive', + persistent: true, + action: { + token: 'opaque-secret-token', + label: 'Retry', + }, + }, + })); + return scope.id; + }); + assert.equal( + await page.locator(`#${actionScope}`).evaluate( + scope => scope.records, + ), + undefined, + ); + const actionArticle = page.locator( + `#${actionScope} [data-zen-notification-id="action"]`, + ); + assert.doesNotMatch( + await actionArticle.evaluate(article => article.outerHTML), + /opaque-secret-token/, + ); + await actionArticle.locator('.zen-notification-action').focus(); + await page.locator(`#${actionScope}`).evaluate(scope => { + const source = scope.querySelector('button'); + source.dispatchEvent(new CustomEvent('zen-notify', { + bubbles: true, + composed: false, + detail: { + version: 1, + id: 'action-neighbor', + tone: 'info', + message: 'Neighbor', + announcement: 'none', + persistent: true, + }, + })); + }); + assert.equal( + await actionArticle.locator('.zen-notification-action').evaluate( + action => document.activeElement === action, + ), + true, + ); + await actionArticle.locator('.zen-notification-action').click(); + assert.deepEqual( + await page.evaluate(() => window.__notificationAction), + { + version: 1, + id: 'action', + token: 'opaque-secret-token', + }, + ); + await page.locator(`#${actionScope}`).evaluate(scope => { + const source = scope.querySelector('button'); + source.dispatchEvent(new CustomEvent( + 'zen-dismiss-notification', + { + bubbles: true, + composed: false, + detail: { version: 1, id: 'action' }, + }, + )); + }); + await actionArticle.waitFor({ state: 'detached' }); + + await page.evaluate(() => { + const scope = document.createElement('zen-notifications'); + scope.id = 'announcement-scope'; + const source = document.createElement('button'); + scope.append(source); + document.body.append(scope); + window.__announcements = []; + const region = scope.querySelector('[data-zen-live="polite"]'); + new MutationObserver(() => { + if (region.textContent) { + window.__announcements.push(region.textContent); + } + }).observe(region, { childList: true }); + for (let index = 0; index < 3; index++) { + source.dispatchEvent(new CustomEvent('zen-notify', { + bubbles: true, + composed: false, + detail: { + version: 1, + id: `announcement-${index}`, + tone: 'info', + message: `Announcement ${index}`, + announcement: 'polite', + persistent: true, + }, + })); + } + }); + await page.waitForFunction(() => + window.__announcements?.length === 3 + ); + assert.deepEqual( + await page.evaluate(() => window.__announcements), + ['Announcement 0', 'Announcement 1', 'Announcement 2'], + ); + + const composedRejected = await page.evaluate(() => { + const scope = document.createElement('zen-notifications'); + const source = document.createElement('button'); + scope.append(source); + document.body.append(scope); + source.dispatchEvent(new CustomEvent('zen-notify', { + bubbles: true, + composed: true, + detail: { + version: 1, + id: 'composed', + tone: 'info', + message: 'Must be rejected', + announcement: 'none', + persistent: true, + }, + })); + return scope.size; + }); + assert.equal(composedRejected, 0); + + await page.evaluate(() => { + const scope = document.createElement('zen-notifications'); + scope.id = 'focus-timer-scope'; + const source = document.createElement('button'); + scope.append(source); + document.body.append(scope); + source.dispatchEvent(new CustomEvent('zen-notify', { + bubbles: true, + composed: false, + detail: { + version: 1, + id: 'focus-timer', + tone: 'warning', + message: 'Focus timer', + announcement: 'none', + durationMs: 300, + action: { token: 'focus-token', label: 'Keep focused' }, + }, + })); + }); + const focusTimer = page.locator( + '#focus-timer-scope [data-zen-notification-id="focus-timer"]', + ); + await focusTimer.locator('.zen-notification-action').focus(); + await page.locator('#focus-timer-scope').evaluate(scope => { + const source = scope.querySelector(':scope > button'); + source.dispatchEvent(new CustomEvent('zen-notify', { + bubbles: true, + composed: false, + detail: { + version: 1, + id: 'focus-neighbor', + tone: 'info', + message: 'Focus neighbor', + announcement: 'none', + persistent: true, + }, + })); + }); + await page.waitForTimeout(500); + assert.equal(await focusTimer.count(), 1); + assert.equal( + await focusTimer.locator('.zen-notification-action').evaluate( + action => document.activeElement === action, + ), + true, + ); + await page.mouse.move(0, 0); + await page.locator('#catalogMain').focus(); + await page.waitForFunction(() => + !document.querySelector( + '#focus-timer-scope [data-zen-notification-id="focus-timer"]', + ) + ); + + await page.evaluate(() => { + const scope = document.createElement('zen-notifications'); + scope.id = 'timer-scope'; + const source = document.createElement('button'); + scope.append(source); + document.body.append(scope); + source.dispatchEvent(new CustomEvent('zen-notify', { + bubbles: true, + composed: false, + detail: { + version: 1, + id: 'timer', + tone: 'info', + message: 'Paused timer', + announcement: 'none', + durationMs: 300, + }, + })); + }); + const timerArticle = page.locator('#timer-scope article'); + await timerArticle.hover(); + await page.waitForTimeout(500); + assert.equal(await timerArticle.count(), 1); + await page.mouse.move(0, 0); + await page.waitForFunction(() => + !document.querySelector('#timer-scope article') + ); + + await page.evaluate(() => { + const scope = document.createElement('zen-notifications'); + scope.id = 'hidden-timer-scope'; + const source = document.createElement('button'); + scope.append(source); + document.body.append(scope); + source.dispatchEvent(new CustomEvent('zen-notify', { + bubbles: true, + composed: false, + detail: { + version: 1, + id: 'hidden-timer', + tone: 'info', + message: 'Hidden timer', + announcement: 'none', + durationMs: 300, + }, + })); + Object.defineProperty(document, 'hidden', { + configurable: true, + value: true, + }); + document.dispatchEvent(new Event('visibilitychange')); + }); + await page.waitForTimeout(500); + assert.equal( + await page.locator('#hidden-timer-scope article').count(), + 1, + ); + await page.evaluate(() => { + Object.defineProperty(document, 'hidden', { + configurable: true, + value: false, + }); + document.dispatchEvent(new Event('visibilitychange')); + }); + await page.waitForFunction(() => + !document.querySelector('#hidden-timer-scope article') + ); + + const isolated = await page.evaluate(() => { + const makeScope = id => { + const scope = document.createElement('zen-notifications'); + scope.id = id; + const source = document.createElement('button'); + scope.append(source); + document.body.append(scope); + return { scope, source }; + }; + const first = makeScope('scope-one'); + const second = makeScope('scope-two'); + first.source.dispatchEvent(new CustomEvent('zen-notify', { + bubbles: true, + composed: false, + detail: { + version: 1, + id: 'isolated', + tone: 'success', + message: 'Only first scope', + announcement: 'none', + persistent: true, + }, + })); + return [first.scope.size, second.scope.size]; + }); + assert.deepEqual(isolated, [1, 0]); + + const lightCanvas = await page.evaluate(() => + getComputedStyle(document.documentElement) + .getPropertyValue('--zenbu-sys-color-surface-page') + .trim() + ); + await page.locator('#themeToggle').click(); + const darkCanvas = await page.evaluate(() => + getComputedStyle(document.documentElement) + .getPropertyValue('--zenbu-sys-color-surface-page') + .trim() + ); + assert.notEqual(lightCanvas, darkCanvas); + assert.equal( + await page.evaluate(() => localStorage.getItem('zen-theme')), + 'dark', + ); + + assert.deepEqual(errors, []); + await context.close(); + + const darkContext = await browser.newContext({ colorScheme: 'dark' }); + const darkPage = await darkContext.newPage(); + await darkPage.goto(baseUrl, { waitUntil: 'networkidle' }); + assert.equal( + await darkPage.locator('#themeToggle').getAttribute('aria-pressed'), + 'true', + ); + const systemDarkCanvas = await darkPage.evaluate(() => + getComputedStyle(document.documentElement) + .getPropertyValue('--zenbu-sys-color-surface-page') + .trim() + ); + await darkPage.locator('#themeToggle').click(); + const explicitLightCanvas = await darkPage.evaluate(() => + getComputedStyle(document.documentElement) + .getPropertyValue('--zenbu-sys-color-surface-page') + .trim() + ); + assert.notEqual(systemDarkCanvas, explicitLightCanvas); + await darkContext.close(); + + const mobileContext = await browser.newContext({ + colorScheme: 'light', + viewport: { width: 390, height: 844 }, + }); + const mobilePage = await mobileContext.newPage(); + const mobileErrors = []; + mobilePage.on('pageerror', error => { + mobileErrors.push(error.stack || error.message); + }); + mobilePage.on('console', message => { + if (message.type() === 'error') mobileErrors.push(message.text()); + }); + await mobilePage.goto(baseUrl, { waitUntil: 'networkidle' }); + await mobilePage.waitForFunction(() => + customElements.get('zen-date-picker') && + document.querySelectorAll( + '.catalog-nav[aria-label="Components"] a', + ).length === 70 + ); + const mobileFailures = await mobilePage.evaluate(() => { + const routes = [ + '/', + '/tokens', + '/icons', + ...[...document.querySelectorAll( + '.catalog-nav[aria-label="Components"] a', + )].map(link => new URL(link.href).pathname), + ]; + const failures = []; + for (const route of routes) { + history.replaceState({}, '', route); + window.dispatchEvent(new PopStateEvent('popstate')); + const page = document.querySelector( + '[data-catalog-page]:not([hidden])', + ); + if (!page || + document.documentElement.scrollWidth > innerWidth + 1 || + page.getBoundingClientRect().right > innerWidth + 1) { + failures.push(route); + } + } + return failures; + }); + assert.deepEqual(mobileFailures, []); + await mobilePage.goto(`${baseUrl}/components/date-picker`, { + waitUntil: 'networkidle', + }); + await mobilePage.locator( + 'zen-date-picker [data-zen-date-trigger]', + ).click(); + const mobileCalendarBounds = await mobilePage.locator( + 'zen-date-picker [data-zen-calendar-panel]', + ).evaluate(panel => { + const bounds = panel.getBoundingClientRect(); + return { + bottom: bounds.bottom, + left: bounds.left, + right: bounds.right, + top: bounds.top, + }; + }); + assert.ok(mobileCalendarBounds.left >= 0); + assert.ok(mobileCalendarBounds.right <= 390); + assert.ok(mobileCalendarBounds.top >= 0); + assert.ok(mobileCalendarBounds.bottom <= 844); + assert.deepEqual(mobileErrors, []); + await mobileContext.close(); + } finally { + if (browser) await browser.close(); + await stopProcess(server); + } +})().catch(error => { + console.error(error.stack || error); + process.exitCode = 1; +});