From f22cda134571a929e23f975d49f79beba51de991 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 27 Jun 2026 20:59:59 -0400 Subject: [PATCH] De-figma dom-box-provider: configurable node-identity attribute The visual-parity dom-box-provider hardcoded figma's data-figma-* attributes for node enumeration, selector generation, and id/name reads, coupling the otherwise product-neutral tool to a single source format. Make the node-identity attribute configurable via CLI flag (--node-id-attr, --node-name-attr) and env var (HOMEBOY_DOM_BOX_NODE_ID_ATTR, HOMEBOY_DOM_BOX_NODE_NAME_ATTR). The configured attribute drives element enumeration, selector generation, and id reads; node-name attributes are tried in order with aria-label as a generic final fallback. Defaults stay data-figma-node-id / data-figma-node-name,data-figma-name so existing figma callers (figma-fixture-matrix.php and its contract) work unchanged. Cover both the figma default path and the configured generic attribute path (env and flag) in the smoke test. Document the settings in the README. Co-Authored-By: Claude Opus 4.8 (1M context) --- php-transformer/tools/visual-parity/README.md | 17 ++++ .../visual-parity/bin/dom-box-provider.mjs | 90 +++++++++++++++++-- .../tools/visual-parity/tests/smoke.mjs | 36 ++++++++ 3 files changed, 135 insertions(+), 8 deletions(-) diff --git a/php-transformer/tools/visual-parity/README.md b/php-transformer/tools/visual-parity/README.md index 5b5e5ede..09673f46 100644 --- a/php-transformer/tools/visual-parity/README.md +++ b/php-transformer/tools/visual-parity/README.md @@ -46,6 +46,23 @@ Then run: node bin/visual-parity.mjs --config visual-parity.config.json ``` +## DOM box provider + +`bin/dom-box-provider.mjs` captures per-node DOM boxes for Homeboy's `artifact-origin dom-boxes` flow. Node identity is keyed off a configurable attribute, so the provider is product-neutral and not tied to any single source format. + +```sh +HOMEBOY_DOM_BOX_BASE_URL=https://example.test \ +HOMEBOY_DOM_BOX_PAGE_PATHS_JSON='["/index.html"]' \ +node bin/dom-box-provider.mjs --node-id-attr=data-node-id --node-name-attr=data-node-name +``` + +| Setting | Env var | Flag | Default | +| --- | --- | --- | --- | +| Node id attribute | `HOMEBOY_DOM_BOX_NODE_ID_ATTR` | `--node-id-attr` | `data-figma-node-id` | +| Node name attributes | `HOMEBOY_DOM_BOX_NODE_NAME_ATTR` | `--node-name-attr` | `data-figma-node-name,data-figma-name` | + +The node id attribute drives element enumeration, selector generation, and id reads. Node name attributes are tried in order; `aria-label` is always appended as a final generic fallback. The defaults stay backward-compatible with the figma-transformer's `data-figma-*` output, so existing figma callers work with no changes; non-figma consumers override the attributes to match their own emitter. + ## Output The JSON report includes normalized config, source and target probe snapshots, and a deterministic comparison summary. Default probes cover button, link, nav/menu, and card-like candidates. Each match records text, href, bounding box, and computed styles for display, color, background color, border, border radius, padding, font size, and font weight. diff --git a/php-transformer/tools/visual-parity/bin/dom-box-provider.mjs b/php-transformer/tools/visual-parity/bin/dom-box-provider.mjs index c73c1ef1..4591604f 100755 --- a/php-transformer/tools/visual-parity/bin/dom-box-provider.mjs +++ b/php-transformer/tools/visual-parity/bin/dom-box-provider.mjs @@ -1,6 +1,9 @@ #!/usr/bin/env node const DEFAULT_VIEWPORT = { width: 1440, height: 900, device_scale_factor: 1 }; +const DEFAULT_NODE_ID_ATTR = 'data-figma-node-id'; +const DEFAULT_NODE_NAME_ATTRS = ['data-figma-node-name', 'data-figma-name']; +const ATTRIBUTE_NAME_PATTERN = /^[A-Za-z_:][-A-Za-z0-9_:.]*$/; const PLAYWRIGHT_SETUP_HELP = 'Install DOM capture dependencies with: npm ci --prefix php-transformer/tools/visual-parity && npm --prefix php-transformer/tools/visual-parity run install:browsers'; const COMPUTED_STYLE_PROPERTIES = [ 'font-family', @@ -57,9 +60,12 @@ async function main() { return; } + const cli = parseCliArgs(process.argv.slice(2)); const baseUrl = requiredEnv('HOMEBOY_DOM_BOX_BASE_URL').replace(/\/+$/, ''); const pagePaths = parsePagePaths(requiredEnv('HOMEBOY_DOM_BOX_PAGE_PATHS_JSON')); const textSampleLimit = parseTextSampleLimit(process.env.HOMEBOY_DOM_BOX_TEXT_SAMPLE_LIMIT ?? '160'); + const nodeIdAttr = resolveNodeIdAttr(cli); + const nodeNameAttrs = resolveNodeNameAttrs(cli); const { chromium } = await loadPlaywright(); let browser; @@ -83,7 +89,7 @@ async function main() { page_path: pagePath, page_url: page.url(), viewport: DEFAULT_VIEWPORT, - elements: await extractElements(page, pagePath, textSampleLimit), + elements: await extractElements(page, pagePath, textSampleLimit, nodeIdAttr, nodeNameAttrs), }); await page.close(); } @@ -146,12 +152,68 @@ function parseTextSampleLimit(raw) { return value; } +function parseCliArgs(argv) { + const parsed = {}; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (!arg.startsWith('--')) { + continue; + } + + const [rawKey, inlineValue] = arg.slice(2).split('=', 2); + if (inlineValue !== undefined) { + parsed[rawKey] = inlineValue; + continue; + } + + const next = argv[index + 1]; + if (next !== undefined && !next.startsWith('--')) { + parsed[rawKey] = next; + index += 1; + } else { + parsed[rawKey] = 'true'; + } + } + return parsed; +} + +function validateAttributeName(name, source) { + const trimmed = String(name).trim(); + if (!ATTRIBUTE_NAME_PATTERN.test(trimmed)) { + throw new Error(`${source} must be a valid HTML attribute name, received: ${JSON.stringify(name)}`); + } + return trimmed; +} + +function resolveNodeIdAttr(cli) { + const raw = cli['node-id-attr'] ?? process.env.HOMEBOY_DOM_BOX_NODE_ID_ATTR ?? DEFAULT_NODE_ID_ATTR; + return validateAttributeName(raw, '--node-id-attr / HOMEBOY_DOM_BOX_NODE_ID_ATTR'); +} + +function resolveNodeNameAttrs(cli) { + const raw = cli['node-name-attr'] ?? process.env.HOMEBOY_DOM_BOX_NODE_NAME_ATTR; + const configured = raw === undefined + ? DEFAULT_NODE_NAME_ATTRS + : String(raw).split(',').map((value) => value.trim()).filter((value) => value !== ''); + + if (configured.length === 0) { + throw new Error('--node-name-attr / HOMEBOY_DOM_BOX_NODE_NAME_ATTR must list at least one attribute name.'); + } + + const names = configured.map((value) => validateAttributeName(value, '--node-name-attr / HOMEBOY_DOM_BOX_NODE_NAME_ATTR')); + // aria-label is a standard accessibility attribute, kept as a generic final fallback for node naming. + if (!names.includes('aria-label')) { + names.push('aria-label'); + } + return names; +} + function printHelp() { - process.stdout.write(`Capture DOM boxes for Homeboy artifact-origin dom-boxes.\n\nEnvironment:\n HOMEBOY_DOM_BOX_BASE_URL Static artifact origin base URL.\n HOMEBOY_DOM_BOX_PAGE_PATHS_JSON JSON array of page paths to capture.\n HOMEBOY_DOM_BOX_TEXT_SAMPLE_LIMIT Optional positive integer, default 160.\n\nOutput:\n JSON browser payload on stdout for Homeboy to shape as homeboy/static-artifact-dom-boxes/v1.\n`); + process.stdout.write(`Capture DOM boxes for Homeboy artifact-origin dom-boxes.\n\nNode identity is keyed off a configurable attribute so the tool is product-neutral.\nThe figma-transformer's data-figma-* attributes remain the backward-compatible default.\n\nEnvironment:\n HOMEBOY_DOM_BOX_BASE_URL Static artifact origin base URL.\n HOMEBOY_DOM_BOX_PAGE_PATHS_JSON JSON array of page paths to capture.\n HOMEBOY_DOM_BOX_TEXT_SAMPLE_LIMIT Optional positive integer, default 160.\n HOMEBOY_DOM_BOX_NODE_ID_ATTR Node identity attribute, default ${DEFAULT_NODE_ID_ATTR}.\n HOMEBOY_DOM_BOX_NODE_NAME_ATTR Comma-separated node name attributes, default ${DEFAULT_NODE_NAME_ATTRS.join(',')} (aria-label is always a final fallback).\n\nFlags (override the matching environment variable):\n --node-id-attr= Node identity attribute used for enumeration, selectors, and id reads.\n --node-name-attr=[,...] Node name attributes, tried in order before aria-label.\n\nOutput:\n JSON browser payload on stdout for Homeboy to shape as homeboy/static-artifact-dom-boxes/v1.\n`); } -async function extractElements(page, pagePath, textSampleLimit) { - return page.evaluate(({ pagePath: currentPagePath, limit, computedStyleProperties }) => { +async function extractElements(page, pagePath, textSampleLimit, nodeIdAttr, nodeNameAttrs) { + return page.evaluate(({ pagePath: currentPagePath, limit, computedStyleProperties, nodeIdAttr: idAttr, nodeNameAttrs: nameAttrs }) => { function normalizeText(value) { return String(value ?? '').replace(/\s+/g, ' ').trim().slice(0, limit); } @@ -162,7 +224,17 @@ async function extractElements(page, pagePath, textSampleLimit) { function selectorFor(element, nodeId) { const tag = element.tagName.toLowerCase(); - return `${tag}[data-figma-node-id="${String(nodeId).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"]`; + return `${tag}[${idAttr}="${String(nodeId).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"]`; + } + + function readNodeName(element) { + for (const attr of nameAttrs) { + const value = element.getAttribute(attr); + if (value) { + return value; + } + } + return null; } function serializeComputedStyle(element) { @@ -260,10 +332,10 @@ async function extractElements(page, pagePath, textSampleLimit) { }; } - return Array.from(document.querySelectorAll('[data-figma-node-id]')).map((element) => { + return Array.from(document.querySelectorAll(`[${idAttr}]`)).map((element) => { const rect = element.getBoundingClientRect(); - const nodeId = element.getAttribute('data-figma-node-id') || ''; - const nodeName = element.getAttribute('data-figma-node-name') || element.getAttribute('data-figma-name') || element.getAttribute('aria-label') || null; + const nodeId = element.getAttribute(idAttr) || ''; + const nodeName = readNodeName(element); const computedStyle = serializeComputedStyle(element); const textSample = normalizeText(element.textContent); const textLength = fullTextLength(element.textContent); @@ -285,5 +357,7 @@ async function extractElements(page, pagePath, textSampleLimit) { pagePath, limit: textSampleLimit, computedStyleProperties: COMPUTED_STYLE_PROPERTIES, + nodeIdAttr, + nodeNameAttrs, }); } diff --git a/php-transformer/tools/visual-parity/tests/smoke.mjs b/php-transformer/tools/visual-parity/tests/smoke.mjs index fc05df7d..73a13e2c 100644 --- a/php-transformer/tools/visual-parity/tests/smoke.mjs +++ b/php-transformer/tools/visual-parity/tests/smoke.mjs @@ -9,6 +9,7 @@ const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))); const outputDir = path.join(root, 'tmp'); const output = path.join(outputDir, 'visual-parity-smoke.json'); const domFixture = path.join(outputDir, 'dom-box.html'); +const domFixtureGeneric = path.join(outputDir, 'dom-box-generic.html'); const missingPlaywrightDir = path.join(tmpdir(), `blocks-engine-dom-provider-missing-playwright-${process.pid}`); const missingPlaywrightProvider = path.join(missingPlaywrightDir, 'dom-box-provider.mjs'); @@ -56,12 +57,20 @@ await writeFile(domFixture, `
Hello world
`); +await writeFile(domFixtureGeneric, `
Hello generic
`); const server = createServer(async (request, response) => { if (request.url === '/dom-box.html') { response.writeHead(200, { 'content-type': 'text/html' }); response.end(await readFile(domFixture)); return; } + if (request.url === '/dom-box-generic.html') { + response.writeHead(200, { 'content-type': 'text/html' }); + response.end(await readFile(domFixtureGeneric)); + return; + } response.writeHead(404, { 'content-type': 'application/json' }); response.end('{"error":"not_found"}'); }); @@ -80,6 +89,7 @@ try { assert(domReport.entrypoints.length === 1, 'DOM provider captures one entrypoint'); assert(domReport.entrypoints[0].elements[0].node_id === '12:34', 'DOM provider captures node id'); assert(domReport.entrypoints[0].elements[0].node_name === 'Hero', 'DOM provider captures node name'); + assert(domReport.entrypoints[0].elements[0].selector === 'main[data-figma-node-id="12:34"]', 'DOM provider keys selector off default figma attribute'); assert(domReport.entrypoints[0].elements[0].text_sample === 'Hello world', 'DOM provider captures text sample'); assert(domReport.entrypoints[0].elements[0].page_path === '/dom-box.html', 'DOM provider adds page path to elements'); assert(domReport.entrypoints[0].elements[0].computed_style['font-size'] === '20px', 'DOM provider captures computed font size'); @@ -95,6 +105,31 @@ try { assert(domReport.entrypoints[0].elements[0].asset_state.descendants[0].complete === true, 'DOM provider captures image complete state'); assert(domReport.entrypoints[0].elements[0].visibility.visible === true, 'DOM provider captures visible state'); assert(domReport.entrypoints[0].elements[0].visibility.clipped === true, 'DOM provider captures clipped-ish overflow state'); + + const genericEnvReport = await runJson(process.execPath, [path.join(root, 'bin/dom-box-provider.mjs')], root, { + ...process.env, + HOMEBOY_DOM_BOX_BASE_URL: `http://127.0.0.1:${address.port}`, + HOMEBOY_DOM_BOX_PAGE_PATHS_JSON: JSON.stringify(['/dom-box-generic.html']), + HOMEBOY_DOM_BOX_NODE_ID_ATTR: 'data-node-id', + HOMEBOY_DOM_BOX_NODE_NAME_ATTR: 'data-node-name', + }); + assert(genericEnvReport.entrypoints[0].elements.length === 1, 'DOM provider enumerates by configured generic attribute (env)'); + assert(genericEnvReport.entrypoints[0].elements[0].node_id === 'n-1', 'DOM provider reads node id from configured generic attribute (env)'); + assert(genericEnvReport.entrypoints[0].elements[0].node_name === 'Generic Hero', 'DOM provider reads node name from configured generic attribute (env)'); + assert(genericEnvReport.entrypoints[0].elements[0].selector === 'main[data-node-id="n-1"]', 'DOM provider keys selector off configured generic attribute (env)'); + + const genericFlagReport = await runJson(process.execPath, [ + path.join(root, 'bin/dom-box-provider.mjs'), + '--node-id-attr=data-node-id', + '--node-name-attr=data-node-name', + ], root, { + ...process.env, + HOMEBOY_DOM_BOX_BASE_URL: `http://127.0.0.1:${address.port}`, + HOMEBOY_DOM_BOX_PAGE_PATHS_JSON: JSON.stringify(['/dom-box-generic.html']), + }); + assert(genericFlagReport.entrypoints[0].elements[0].node_id === 'n-1', 'DOM provider reads node id from configured generic attribute (flag)'); + assert(genericFlagReport.entrypoints[0].elements[0].node_name === 'Generic Hero', 'DOM provider reads node name from configured generic attribute (flag)'); + assert(genericFlagReport.entrypoints[0].elements[0].selector === 'main[data-node-id="n-1"]', 'DOM provider keys selector off configured generic attribute (flag)'); } finally { await new Promise((resolve) => server.close(resolve)); } @@ -111,6 +146,7 @@ assert(missingPlaywright.stderr.includes('install:browsers'), 'DOM provider sugg await rm(output, { force: true }); await rm(domFixture, { force: true }); +await rm(domFixtureGeneric, { force: true }); await rm(missingPlaywrightDir, { recursive: true, force: true }); console.log('Visual parity smoke test passed.');