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();