comparison 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
comparison
equal deleted inserted replaced
257:609d3c6aff4e 258:60a876c4587a
52 const absolute = path.join(root, entry.name); 52 const absolute = path.join(root, entry.name);
53 if (entry.isDirectory()) files.push(...listFiles(absolute)); 53 if (entry.isDirectory()) files.push(...listFiles(absolute));
54 else files.push(absolute); 54 else files.push(absolute);
55 } 55 }
56 return files; 56 return files;
57 }
58
59 function primitiveOwnershipViolations(source) {
60 const violations = [];
61 const stack = [];
62 const voidElements = new Set([
63 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link',
64 'meta', 'param', 'source', 'track', 'wbr',
65 ]);
66 const ownerMap = {
67 button: new Set(['zen-button']),
68 select: new Set(['zen-field', 'zen-native-select', 'zen-select']),
69 textarea: new Set(['zen-field', 'zen-textarea']),
70 };
71
72 for (const token of source.matchAll(
73 /<!--[\s\S]*?-->|<\/?([a-zA-Z][\w-]*)\b([^>]*)>/g,
74 )) {
75 if (!token[1]) continue;
76 const name = token[1].toLowerCase();
77 if (token[0].startsWith('</')) {
78 while (stack.length) {
79 if (stack.pop() === name) break;
80 }
81 continue;
82 }
83
84 let owners = ownerMap[name];
85 if (name === 'input') {
86 const type = /\btype\s*=\s*["']?([^"'\s>]+)/i.exec(token[2])?.[1]
87 ?.toLowerCase() || 'text';
88 if (type === 'checkbox') {
89 owners = new Set(['zen-checkbox', 'zen-field', 'zen-switch']);
90 } else if (type === 'radio') {
91 owners = new Set(['zen-field', 'zen-radio-group']);
92 } else if (type === 'range') {
93 owners = new Set(['zen-field', 'zen-slider']);
94 } else if (type === 'date') {
95 owners = new Set(['zen-calendar', 'zen-date-picker', 'zen-field']);
96 } else {
97 owners = new Set([
98 'zen-combobox',
99 'zen-command',
100 'zen-field',
101 'zen-input',
102 'zen-input-group',
103 'zen-input-otp',
104 ]);
105 }
106 }
107 if (owners) {
108 const matchingOwners = stack.filter(ancestor => owners.has(ancestor));
109 if (matchingOwners.length !== 1) {
110 violations.push({
111 control: name,
112 owners: matchingOwners,
113 source: token[0],
114 });
115 }
116 }
117
118 if (!voidElements.has(name) && !token[0].endsWith('/>')) {
119 stack.push(name);
120 }
121 }
122 return violations;
57 } 123 }
58 124
59 async function sampleTheme(browser, theme) { 125 async function sampleTheme(browser, theme) {
60 const context = await browser.newContext({ colorScheme: 'dark' }); 126 const context = await browser.newContext({ colorScheme: 'dark' });
61 await context.addInitScript(value => { 127 await context.addInitScript(value => {
123 return converted[0] * 0.2126 + 189 return converted[0] * 0.2126 +
124 converted[1] * 0.7152 + 190 converted[1] * 0.7152 +
125 converted[2] * 0.0722; 191 converted[2] * 0.0722;
126 }; 192 };
127 const bodyStyle = getComputedStyle(document.body); 193 const bodyStyle = getComputedStyle(document.body);
194 const themeButtonStyle = getComputedStyle(
195 document.querySelector('#themeToggle'),
196 );
128 const foreground = colorLuminance(bodyStyle.color); 197 const foreground = colorLuminance(bodyStyle.color);
198 const contrastProbe = document.createElement('span');
199 contrastProbe.style.background = 'var(--zen-color-surface)';
200 document.body.append(contrastProbe);
129 const background = colorLuminance( 201 const background = colorLuminance(
130 getComputedStyle(document.querySelector('main')).backgroundColor, 202 getComputedStyle(contrastProbe).backgroundColor,
131 ); 203 );
204 contrastProbe.remove();
132 return { 205 return {
133 cardCount: document.querySelectorAll('.site-link-grid zen-card').length, 206 backgroundRepeat: bodyStyle.backgroundRepeat,
134 componentReady: Boolean(customElements.get('zen-card')), 207 backgroundSize: bodyStyle.backgroundSize,
208 bodyCoversViewport: document.body.getBoundingClientRect().height >=
209 innerHeight,
210 componentReady: Boolean(customElements.get('zen-button')),
135 count, 211 count,
136 fontFamily: getComputedStyle(document.body).fontFamily, 212 fontFamily: getComputedStyle(document.body).fontFamily,
213 headerPaws: document.querySelectorAll(
214 'header [data-zen-link-decoration]',
215 ).length,
137 luminance: count ? luminance / count : 0, 216 luminance: count ? luminance / count : 0,
138 textContrast: (Math.max(foreground, background) + 0.05) / 217 textContrast: (Math.max(foreground, background) + 0.05) /
139 (Math.min(foreground, background) + 0.05), 218 (Math.min(foreground, background) + 0.05),
140 rootTheme: document.documentElement.dataset.zenTheme || 'auto', 219 rootTheme: document.documentElement.dataset.zenTheme || 'auto',
220 enhancedLinks: document.querySelectorAll(
221 'zen-link[effect="paw"] > a > zen-icon[name="paw"]',
222 ).length,
223 links: document.querySelectorAll('a').length,
224 pawLinks: [...document.querySelectorAll('a')].filter(link =>
225 !link.closest('zen-button') &&
226 link.dataset.zenLinkEffect !== 'none'
227 ).length,
228 mainBackground: getComputedStyle(
229 document.querySelector('main'),
230 ).backgroundColor,
231 themeButtonBackground: themeButtonStyle.backgroundColor,
232 themeButtonBorder: themeButtonStyle.borderTopWidth,
233 themeButtonShadow: themeButtonStyle.boxShadow,
141 themeLabel: document.querySelector('#themeName')?.textContent, 234 themeLabel: document.querySelector('#themeName')?.textContent,
142 }; 235 };
143 }); 236 });
144 237
145 if (errors.length) throw new Error(`${theme}\n${errors.join('\n')}`); 238 if (errors.length) throw new Error(`${theme}\n${errors.join('\n')}`);
166 ); 259 );
167 } 260 }
168 await context.close(); 261 await context.close();
169 } 262 }
170 263
264 async function testPlainField(browser) {
265 const page = await browser.newPage();
266 await page.goto(`${baseUrl}/tools/markdown_to_html`, {
267 waitUntil: 'networkidle',
268 });
269 await page.waitForFunction(() => customElements.get('zen-field'));
270 const state = await page.locator('zen-field[appearance="plain"]').evaluate(
271 field => {
272 const label = field.querySelector('label');
273 const textarea = field.querySelector('textarea');
274 const probe = document.createElement('span');
275 probe.style.border = '1px solid var(--accent)';
276 document.body.append(probe);
277 const accent = getComputedStyle(probe).borderColor;
278 probe.remove();
279 textarea.focus();
280 return {
281 borderColor: getComputedStyle(textarea).borderColor,
282 display: getComputedStyle(field).display,
283 labelFor: label.htmlFor,
284 accent,
285 textareaId: textarea.id,
286 };
287 },
288 );
289 assert.deepEqual(state, {
290 accent: state.borderColor,
291 borderColor: state.borderColor,
292 display: 'contents',
293 labelFor: 'input',
294 textareaId: 'input',
295 });
296 await page.close();
297 }
298
299 async function testButtonScale(browser) {
300 const page = await browser.newPage();
301 for (const route of [
302 '/talk',
303 '/notes/login',
304 '/tools/hls_player',
305 '/tools/latex_editor',
306 '/tools/markdown_to_html',
307 '/tools/file_converter',
308 '/offline.html',
309 ]) {
310 await page.goto(`${baseUrl}${route}`, { waitUntil: 'networkidle' });
311 await page.waitForFunction(() => customElements.get('zen-button'));
312 const controls = await page.locator(
313 'zen-button[size] > :is(button, a):not(#themeToggle)',
314 ).evaluateAll(elements => elements.map(control => {
315 const host = control.parentElement;
316 const style = getComputedStyle(control);
317 const hostStyle = getComputedStyle(host);
318 return {
319 height: control.getBoundingClientRect().height,
320 minimum: Number.parseFloat(
321 hostStyle.getPropertyValue('--zenbu-control-height'),
322 ) * Number.parseFloat(getComputedStyle(document.documentElement).fontSize),
323 paddingBlock: style.paddingBlockStart,
324 paddingVariable: Number.parseFloat(
325 hostStyle.getPropertyValue('--zenbu-control-padding-block'),
326 ) * Number.parseFloat(getComputedStyle(document.documentElement).fontSize),
327 size: host.getAttribute('size'),
328 };
329 }));
330 assert.ok(controls.length > 0, route);
331 for (const control of controls) {
332 assert.match(control.size, /^(?:xs|sm|md|lg|xl)$/);
333 assert.ok(control.height >= control.minimum - 1, JSON.stringify(control));
334 assert.ok(
335 Math.abs(Number.parseFloat(control.paddingBlock) -
336 control.paddingVariable) < 1,
337 JSON.stringify(control),
338 );
339 }
340 }
341 await page.close();
342 }
343
344 async function testDynamicButtonOwnership(browser) {
345 const page = await browser.newPage();
346 await page.goto(baseUrl, { waitUntil: 'networkidle' });
347 await page.evaluate(() => {
348 window.dispatchEvent(new Event('beforeinstallprompt', {
349 cancelable: true,
350 }));
351 });
352 const state = await page.locator('#pwa-install-btn').evaluate(button => ({
353 ownerCount: button.closest('zen-button') ? 1 : 0,
354 appearance: button.closest('zen-button')?.getAttribute('appearance'),
355 size: button.closest('zen-button')?.getAttribute('size'),
356 }));
357 assert.deepEqual(state, {
358 appearance: 'plain',
359 ownerCount: 1,
360 size: 'md',
361 });
362 await page.close();
363 }
364
365 async function testResumePrint(browser) {
366 const page = await browser.newPage();
367 await page.emulateMedia({ media: 'print' });
368 await page.goto(`${baseUrl}/resume`, { waitUntil: 'networkidle' });
369 assert.equal(
370 await page.locator('.info > a[href="/public/resume.pdf"]').isVisible(),
371 false,
372 );
373 await page.close();
374 }
375
171 async function testHlsPlayer(browser, siteRoot) { 376 async function testHlsPlayer(browser, siteRoot) {
172 const page = await browser.newPage(); 377 const page = await browser.newPage();
173 const errors = []; 378 const errors = [];
174 const mediaRequests = []; 379 const mediaRequests = [];
175 let testingExpectedFailure = false; 380 let testingExpectedFailure = false;
421 try { 626 try {
422 port = await findFreePort(); 627 port = await findFreePort();
423 baseUrl = `http://127.0.0.1:${port}`; 628 baseUrl = `http://127.0.0.1:${port}`;
424 const pngFiles = listFiles(siteRoot).filter(file => file.endsWith('.png')); 629 const pngFiles = listFiles(siteRoot).filter(file => file.endsWith('.png'));
425 assert.deepEqual(pngFiles, [], `PNG files leaked into runfiles: ${pngFiles}`); 630 assert.deepEqual(pngFiles, [], `PNG files leaked into runfiles: ${pngFiles}`);
631 for (const file of listFiles(siteRoot).filter(file =>
632 file.endsWith('.html')
633 )) {
634 const source = fs.readFileSync(file, 'utf8');
635 for (const primitive of source.matchAll(/<zen-button\b([^>]*)>/g)) {
636 assert.match(
637 primitive[1],
638 /\bsize=["'](?:xs|sm|md|lg|xl)["']/,
639 `${path.relative(siteRoot, file)} has an unsized zen-button`,
640 );
641 }
642 assert.deepEqual(
643 primitiveOwnershipViolations(source),
644 [],
645 `${path.relative(siteRoot, file)} violates primitive ownership`,
646 );
647 }
648 for (const file of listFiles(siteRoot).filter(file =>
649 /\.(?:css|html|js)$/.test(file) &&
650 !file.includes(`${path.sep}public${path.sep}design-system${path.sep}`)
651 )) {
652 const source = fs.readFileSync(file, 'utf8');
653 assert.doesNotMatch(
654 source,
655 /var\(\s*--zen-/,
656 `${path.relative(siteRoot, file)} consumes a deprecated token`,
657 );
658 }
426 659
427 server = spawn(serverBinary, [], { 660 server = spawn(serverBinary, [], {
428 cwd: runfilesWorkspace, 661 cwd: runfilesWorkspace,
429 env: { ...process.env, MRJUNEJUNE_PORT: port }, 662 env: { ...process.env, MRJUNEJUNE_PORT: port },
430 stdio: ['ignore', 'pipe', 'pipe'], 663 stdio: ['ignore', 'pipe', 'pipe'],
444 await fetch(`${baseUrl}/sw.js`) 677 await fetch(`${baseUrl}/sw.js`)
445 ).text(); 678 ).text();
446 for (const source of [home, dogGame, manifest]) { 679 for (const source of [home, dogGame, manifest]) {
447 assert.doesNotMatch(source, /\.png(?:["')]|$)/i); 680 assert.doesNotMatch(source, /\.png(?:["')]|$)/i);
448 } 681 }
449 assert.match(serviceWorker, /v5-zenbu-themes/); 682 assert.match(serviceWorker, /v6-zenbu-headless/);
450 assert.match( 683 assert.match(
451 await ( 684 await (
452 await fetch(`${baseUrl}/public/pwa-register.js`) 685 await fetch(`${baseUrl}/public/pwa-register.js`)
453 ).text(), 686 ).text(),
454 /register\('\/sw\.js', \{ scope: '\/' \}\)/, 687 /register\('\/sw\.js', \{ scope: '\/' \}\)/,
484 assert.equal(ink.themeLabel, 'Ink'); 717 assert.equal(ink.themeLabel, 'Ink');
485 assert.equal(playful.themeLabel, 'Playful'); 718 assert.equal(playful.themeLabel, 'Playful');
486 assert.equal(automatic.themeLabel, 'Auto'); 719 assert.equal(automatic.themeLabel, 'Auto');
487 for (const sample of [paper, ink, playful, automatic]) { 720 for (const sample of [paper, ink, playful, automatic]) {
488 assert.ok(sample.count > 0); 721 assert.ok(sample.count > 0);
722 assert.equal(sample.backgroundRepeat, 'no-repeat');
723 assert.equal(sample.backgroundSize, 'cover');
724 assert.equal(sample.bodyCoversViewport, true);
489 assert.equal(sample.componentReady, true); 725 assert.equal(sample.componentReady, true);
490 assert.equal(sample.cardCount, 4); 726 assert.ok(sample.links > 0);
727 assert.equal(sample.enhancedLinks, sample.pawLinks);
491 assert.match(sample.fontFamily, /More/); 728 assert.match(sample.fontFamily, /More/);
729 assert.equal(sample.headerPaws, 0);
492 assert.ok(sample.textContrast >= 4.5, JSON.stringify(sample)); 730 assert.ok(sample.textContrast >= 4.5, JSON.stringify(sample));
731 assert.equal(sample.mainBackground, 'rgba(0, 0, 0, 0)');
732 assert.equal(sample.themeButtonBackground, 'rgba(0, 0, 0, 0)');
733 assert.equal(sample.themeButtonBorder, '0px');
734 assert.equal(sample.themeButtonShadow, 'none');
493 } 735 }
494 assert.ok(paper.luminance < 175, JSON.stringify(paper)); 736 assert.ok(paper.luminance < 175, JSON.stringify(paper));
495 assert.ok(playful.luminance < 175, JSON.stringify(playful)); 737 assert.ok(playful.luminance < 175, JSON.stringify(playful));
496 assert.ok(ink.luminance > 200, JSON.stringify(ink)); 738 assert.ok(ink.luminance > 200, JSON.stringify(ink));
497 await testThemeCycle(browser); 739 await testThemeCycle(browser);
740 await testPlainField(browser);
741 await testButtonScale(browser);
742 await testDynamicButtonOwnership(browser);
743 await testResumePrint(browser);
498 await testHlsPlayer(browser, siteRoot); 744 await testHlsPlayer(browser, siteRoot);
499 } finally { 745 } finally {
500 if (browser) await browser.close(); 746 if (browser) await browser.close();
501 await stopProcess(server); 747 await stopProcess(server);
502 } 748 }