Mercurial
diff mrjunejune/test/theme_and_webp_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 | 30c2196d03d4 |
| children | 667156fcd3e3 |
line wrap: on
line diff
--- a/mrjunejune/test/theme_and_webp_test.js Tue Aug 04 16:49:11 2026 -0700 +++ b/mrjunejune/test/theme_and_webp_test.js Wed Aug 05 05:25:40 2026 -0700 @@ -56,6 +56,72 @@ return files; } +function primitiveOwnershipViolations(source) { + const violations = []; + const stack = []; + const voidElements = new Set([ + 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', + 'meta', 'param', 'source', 'track', 'wbr', + ]); + const ownerMap = { + button: new Set(['zen-button']), + select: new Set(['zen-field', 'zen-native-select', 'zen-select']), + textarea: new Set(['zen-field', 'zen-textarea']), + }; + + for (const token of source.matchAll( + /<!--[\s\S]*?-->|<\/?([a-zA-Z][\w-]*)\b([^>]*)>/g, + )) { + if (!token[1]) continue; + const name = token[1].toLowerCase(); + if (token[0].startsWith('</')) { + while (stack.length) { + if (stack.pop() === name) break; + } + continue; + } + + let owners = ownerMap[name]; + if (name === 'input') { + const type = /\btype\s*=\s*["']?([^"'\s>]+)/i.exec(token[2])?.[1] + ?.toLowerCase() || 'text'; + if (type === 'checkbox') { + owners = new Set(['zen-checkbox', 'zen-field', 'zen-switch']); + } else if (type === 'radio') { + owners = new Set(['zen-field', 'zen-radio-group']); + } else if (type === 'range') { + owners = new Set(['zen-field', 'zen-slider']); + } else if (type === 'date') { + owners = new Set(['zen-calendar', 'zen-date-picker', 'zen-field']); + } else { + owners = new Set([ + 'zen-combobox', + 'zen-command', + 'zen-field', + 'zen-input', + 'zen-input-group', + 'zen-input-otp', + ]); + } + } + if (owners) { + const matchingOwners = stack.filter(ancestor => owners.has(ancestor)); + if (matchingOwners.length !== 1) { + violations.push({ + control: name, + owners: matchingOwners, + source: token[0], + }); + } + } + + if (!voidElements.has(name) && !token[0].endsWith('/>')) { + stack.push(name); + } + } + return violations; +} + async function sampleTheme(browser, theme) { const context = await browser.newContext({ colorScheme: 'dark' }); await context.addInitScript(value => { @@ -125,19 +191,46 @@ converted[2] * 0.0722; }; const bodyStyle = getComputedStyle(document.body); - const foreground = colorLuminance(bodyStyle.color); - const background = colorLuminance( - getComputedStyle(document.querySelector('main')).backgroundColor, + const themeButtonStyle = getComputedStyle( + document.querySelector('#themeToggle'), ); + const foreground = colorLuminance(bodyStyle.color); + const contrastProbe = document.createElement('span'); + contrastProbe.style.background = 'var(--zen-color-surface)'; + document.body.append(contrastProbe); + const background = colorLuminance( + getComputedStyle(contrastProbe).backgroundColor, + ); + contrastProbe.remove(); return { - cardCount: document.querySelectorAll('.site-link-grid zen-card').length, - componentReady: Boolean(customElements.get('zen-card')), + backgroundRepeat: bodyStyle.backgroundRepeat, + backgroundSize: bodyStyle.backgroundSize, + bodyCoversViewport: document.body.getBoundingClientRect().height >= + innerHeight, + componentReady: Boolean(customElements.get('zen-button')), count, fontFamily: getComputedStyle(document.body).fontFamily, + headerPaws: document.querySelectorAll( + 'header [data-zen-link-decoration]', + ).length, luminance: count ? luminance / count : 0, textContrast: (Math.max(foreground, background) + 0.05) / (Math.min(foreground, background) + 0.05), rootTheme: document.documentElement.dataset.zenTheme || 'auto', + enhancedLinks: document.querySelectorAll( + 'zen-link[effect="paw"] > a > zen-icon[name="paw"]', + ).length, + links: document.querySelectorAll('a').length, + pawLinks: [...document.querySelectorAll('a')].filter(link => + !link.closest('zen-button') && + link.dataset.zenLinkEffect !== 'none' + ).length, + mainBackground: getComputedStyle( + document.querySelector('main'), + ).backgroundColor, + themeButtonBackground: themeButtonStyle.backgroundColor, + themeButtonBorder: themeButtonStyle.borderTopWidth, + themeButtonShadow: themeButtonStyle.boxShadow, themeLabel: document.querySelector('#themeName')?.textContent, }; }); @@ -168,6 +261,118 @@ await context.close(); } +async function testPlainField(browser) { + const page = await browser.newPage(); + await page.goto(`${baseUrl}/tools/markdown_to_html`, { + waitUntil: 'networkidle', + }); + await page.waitForFunction(() => customElements.get('zen-field')); + const state = await page.locator('zen-field[appearance="plain"]').evaluate( + field => { + const label = field.querySelector('label'); + const textarea = field.querySelector('textarea'); + const probe = document.createElement('span'); + probe.style.border = '1px solid var(--accent)'; + document.body.append(probe); + const accent = getComputedStyle(probe).borderColor; + probe.remove(); + textarea.focus(); + return { + borderColor: getComputedStyle(textarea).borderColor, + display: getComputedStyle(field).display, + labelFor: label.htmlFor, + accent, + textareaId: textarea.id, + }; + }, + ); + assert.deepEqual(state, { + accent: state.borderColor, + borderColor: state.borderColor, + display: 'contents', + labelFor: 'input', + textareaId: 'input', + }); + await page.close(); +} + +async function testButtonScale(browser) { + const page = await browser.newPage(); + for (const route of [ + '/talk', + '/notes/login', + '/tools/hls_player', + '/tools/latex_editor', + '/tools/markdown_to_html', + '/tools/file_converter', + '/offline.html', + ]) { + await page.goto(`${baseUrl}${route}`, { waitUntil: 'networkidle' }); + await page.waitForFunction(() => customElements.get('zen-button')); + const controls = await page.locator( + 'zen-button[size] > :is(button, a):not(#themeToggle)', + ).evaluateAll(elements => elements.map(control => { + const host = control.parentElement; + const style = getComputedStyle(control); + const hostStyle = getComputedStyle(host); + return { + height: control.getBoundingClientRect().height, + minimum: Number.parseFloat( + hostStyle.getPropertyValue('--zenbu-control-height'), + ) * Number.parseFloat(getComputedStyle(document.documentElement).fontSize), + paddingBlock: style.paddingBlockStart, + paddingVariable: Number.parseFloat( + hostStyle.getPropertyValue('--zenbu-control-padding-block'), + ) * Number.parseFloat(getComputedStyle(document.documentElement).fontSize), + size: host.getAttribute('size'), + }; + })); + assert.ok(controls.length > 0, route); + for (const control of controls) { + assert.match(control.size, /^(?:xs|sm|md|lg|xl)$/); + assert.ok(control.height >= control.minimum - 1, JSON.stringify(control)); + assert.ok( + Math.abs(Number.parseFloat(control.paddingBlock) - + control.paddingVariable) < 1, + JSON.stringify(control), + ); + } + } + await page.close(); +} + +async function testDynamicButtonOwnership(browser) { + const page = await browser.newPage(); + await page.goto(baseUrl, { waitUntil: 'networkidle' }); + await page.evaluate(() => { + window.dispatchEvent(new Event('beforeinstallprompt', { + cancelable: true, + })); + }); + const state = await page.locator('#pwa-install-btn').evaluate(button => ({ + ownerCount: button.closest('zen-button') ? 1 : 0, + appearance: button.closest('zen-button')?.getAttribute('appearance'), + size: button.closest('zen-button')?.getAttribute('size'), + })); + assert.deepEqual(state, { + appearance: 'plain', + ownerCount: 1, + size: 'md', + }); + await page.close(); +} + +async function testResumePrint(browser) { + const page = await browser.newPage(); + await page.emulateMedia({ media: 'print' }); + await page.goto(`${baseUrl}/resume`, { waitUntil: 'networkidle' }); + assert.equal( + await page.locator('.info > a[href="/public/resume.pdf"]').isVisible(), + false, + ); + await page.close(); +} + async function testHlsPlayer(browser, siteRoot) { const page = await browser.newPage(); const errors = []; @@ -423,6 +628,34 @@ baseUrl = `http://127.0.0.1:${port}`; const pngFiles = listFiles(siteRoot).filter(file => file.endsWith('.png')); assert.deepEqual(pngFiles, [], `PNG files leaked into runfiles: ${pngFiles}`); + for (const file of listFiles(siteRoot).filter(file => + file.endsWith('.html') + )) { + const source = fs.readFileSync(file, 'utf8'); + for (const primitive of source.matchAll(/<zen-button\b([^>]*)>/g)) { + assert.match( + primitive[1], + /\bsize=["'](?:xs|sm|md|lg|xl)["']/, + `${path.relative(siteRoot, file)} has an unsized zen-button`, + ); + } + assert.deepEqual( + primitiveOwnershipViolations(source), + [], + `${path.relative(siteRoot, file)} violates primitive ownership`, + ); + } + for (const file of listFiles(siteRoot).filter(file => + /\.(?:css|html|js)$/.test(file) && + !file.includes(`${path.sep}public${path.sep}design-system${path.sep}`) + )) { + const source = fs.readFileSync(file, 'utf8'); + assert.doesNotMatch( + source, + /var\(\s*--zen-/, + `${path.relative(siteRoot, file)} consumes a deprecated token`, + ); + } server = spawn(serverBinary, [], { cwd: runfilesWorkspace, @@ -446,7 +679,7 @@ for (const source of [home, dogGame, manifest]) { assert.doesNotMatch(source, /\.png(?:["')]|$)/i); } - assert.match(serviceWorker, /v5-zenbu-themes/); + assert.match(serviceWorker, /v6-zenbu-headless/); assert.match( await ( await fetch(`${baseUrl}/public/pwa-register.js`) @@ -486,15 +719,28 @@ assert.equal(automatic.themeLabel, 'Auto'); for (const sample of [paper, ink, playful, automatic]) { assert.ok(sample.count > 0); + assert.equal(sample.backgroundRepeat, 'no-repeat'); + assert.equal(sample.backgroundSize, 'cover'); + assert.equal(sample.bodyCoversViewport, true); assert.equal(sample.componentReady, true); - assert.equal(sample.cardCount, 4); + assert.ok(sample.links > 0); + assert.equal(sample.enhancedLinks, sample.pawLinks); assert.match(sample.fontFamily, /More/); + assert.equal(sample.headerPaws, 0); assert.ok(sample.textContrast >= 4.5, JSON.stringify(sample)); + assert.equal(sample.mainBackground, 'rgba(0, 0, 0, 0)'); + assert.equal(sample.themeButtonBackground, 'rgba(0, 0, 0, 0)'); + assert.equal(sample.themeButtonBorder, '0px'); + assert.equal(sample.themeButtonShadow, 'none'); } assert.ok(paper.luminance < 175, JSON.stringify(paper)); assert.ok(playful.luminance < 175, JSON.stringify(playful)); assert.ok(ink.luminance > 200, JSON.stringify(ink)); await testThemeCycle(browser); + await testPlainField(browser); + await testButtonScale(browser); + await testDynamicButtonOwnership(browser); + await testResumePrint(browser); await testHlsPlayer(browser, siteRoot); } finally { if (browser) await browser.close();