Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions php-transformer/tools/visual-parity/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
90 changes: 82 additions & 8 deletions php-transformer/tools/visual-parity/bin/dom-box-provider.mjs
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -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;
Expand All @@ -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();
}
Expand Down Expand Up @@ -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=<attr> Node identity attribute used for enumeration, selectors, and id reads.\n --node-name-attr=<attr>[,<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);
}
Expand All @@ -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) {
Expand Down Expand Up @@ -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);
Expand All @@ -285,5 +357,7 @@ async function extractElements(page, pagePath, textSampleLimit) {
pagePath,
limit: textSampleLimit,
computedStyleProperties: COMPUTED_STYLE_PROPERTIES,
nodeIdAttr,
nodeNameAttrs,
});
}
36 changes: 36 additions & 0 deletions php-transformer/tools/visual-parity/tests/smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -56,12 +57,20 @@ await writeFile(domFixture, `<!doctype html><html><head><style>
height: 24px;
}
</style></head><body><main class="hero" data-figma-node-id="12:34" data-figma-node-name="Hero">Hello world <img alt="" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='2' height='3'%3E%3C/svg%3E"></main></body></html>`);
await writeFile(domFixtureGeneric, `<!doctype html><html><head><style>
.hero { color: rgb(12, 34, 56); font-size: 20px; width: 120px; height: 24px; }
</style></head><body><main class="hero" data-node-id="n-1" data-node-name="Generic Hero">Hello generic</main></body></html>`);
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"}');
});
Expand All @@ -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');
Expand All @@ -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));
}
Expand All @@ -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.');

Expand Down
Loading