@tractiontactics/tt-fidelity 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +41 -0
- package/README.md +150 -0
- package/bin/tt-fidelity.js +2 -0
- package/fidelity-diff.mjs +7 -0
- package/fidelity.schema.json +77 -0
- package/fixtures/draft-opaque.html +57 -0
- package/fixtures/draft.html +61 -0
- package/fixtures/pages/cluster-a-draft.html +19 -0
- package/fixtures/pages/cluster-a-proto.html +19 -0
- package/fixtures/pages/cluster-b-draft.html +19 -0
- package/fixtures/pages/cluster-b-proto.html +19 -0
- package/fixtures/pages/cluster-pages.json +12 -0
- package/fixtures/pixel-diverge.html +28 -0
- package/fixtures/pixel-identical.html +21 -0
- package/fixtures/proto.html +51 -0
- package/fixtures/roles-opaque.json +13 -0
- package/fixtures/self-test.mjs +111 -0
- package/package.json +55 -0
- package/src/capture.mjs +114 -0
- package/src/cli.mjs +124 -0
- package/src/constants.mjs +73 -0
- package/src/fonts.mjs +61 -0
- package/src/index.mjs +6 -0
- package/src/pixel.mjs +197 -0
- package/src/plan.mjs +410 -0
- package/src/rank.mjs +52 -0
- package/src/run.mjs +433 -0
- package/src/schema.mjs +62 -0
- package/src/sections.mjs +119 -0
- package/src/styles.mjs +356 -0
- package/src/tt-map.mjs +17 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Self-test harness for @tractiontactics/tt-fidelity 0.2
|
|
4
|
+
*/
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
6
|
+
import { dirname, join } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { writeFileSync, mkdtempSync, rmSync } from 'node:fs';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { runFidelity } from '../src/index.mjs';
|
|
11
|
+
|
|
12
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const FIX = __dirname;
|
|
14
|
+
const fileUrl = (name) => pathToFileURL(join(FIX, name)).href;
|
|
15
|
+
|
|
16
|
+
let failed = 0;
|
|
17
|
+
function assert(cond, msg) {
|
|
18
|
+
if (!cond) {
|
|
19
|
+
console.error('FAIL:', msg);
|
|
20
|
+
failed += 1;
|
|
21
|
+
} else {
|
|
22
|
+
console.log('ok:', msg);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function main() {
|
|
27
|
+
// 1. Known style drift — exit 1 in styles mode
|
|
28
|
+
{
|
|
29
|
+
const r = await runFidelity({
|
|
30
|
+
proto: fileUrl('proto.html'),
|
|
31
|
+
draft: fileUrl('draft.html'),
|
|
32
|
+
viewports: [1280],
|
|
33
|
+
mode: 'styles',
|
|
34
|
+
quiet: true
|
|
35
|
+
});
|
|
36
|
+
assert(r.exitCode === 1, 'styles mode: draft vs proto exits 1');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 2. Identical — exit 0
|
|
40
|
+
{
|
|
41
|
+
const r = await runFidelity({
|
|
42
|
+
proto: fileUrl('pixel-identical.html'),
|
|
43
|
+
draft: fileUrl('pixel-identical.html'),
|
|
44
|
+
viewports: [1280],
|
|
45
|
+
mode: 'pixel',
|
|
46
|
+
quiet: true
|
|
47
|
+
});
|
|
48
|
+
assert(r.exitCode === 0, 'pixel identical pages exit 0');
|
|
49
|
+
assert(r.summary.failing === 0, 'pixel identical: 0 failing sections');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 3. Pixel diverge (gradient) — exit 1
|
|
53
|
+
{
|
|
54
|
+
const out = mkdtempSync(join(tmpdir(), 'tt-fid-'));
|
|
55
|
+
const r = await runFidelity({
|
|
56
|
+
proto: fileUrl('pixel-identical.html'),
|
|
57
|
+
draft: fileUrl('pixel-diverge.html'),
|
|
58
|
+
viewports: [1280],
|
|
59
|
+
mode: 'pixel',
|
|
60
|
+
outDir: out,
|
|
61
|
+
quiet: true
|
|
62
|
+
});
|
|
63
|
+
assert(r.exitCode === 1, 'pixel diverge exits 1');
|
|
64
|
+
assert(r.summary.failing >= 1, 'pixel diverge: at least one failing section');
|
|
65
|
+
rmSync(out, { recursive: true, force: true });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 4. Opaque markup — coverage guard in styles mode
|
|
69
|
+
{
|
|
70
|
+
const r = await runFidelity({
|
|
71
|
+
proto: fileUrl('proto.html'),
|
|
72
|
+
draft: fileUrl('draft-opaque.html'),
|
|
73
|
+
viewports: [1280],
|
|
74
|
+
mode: 'styles',
|
|
75
|
+
quiet: true
|
|
76
|
+
});
|
|
77
|
+
assert(r.exitCode === 2 || r.exitCode === 1, 'opaque draft: exit 2 (coverage) or 1 (diffs)');
|
|
78
|
+
if (r.exitCode === 2) assert(r.summary.error === 'low_coverage' || r.text.includes('CANNOT MEASURE'), 'opaque: low coverage message');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 5. Multi-page cluster
|
|
82
|
+
{
|
|
83
|
+
const pages = [
|
|
84
|
+
{ id: 'cluster-a', proto: fileUrl('pages/cluster-a-proto.html'), draft: fileUrl('pages/cluster-a-draft.html') },
|
|
85
|
+
{ id: 'cluster-b', proto: fileUrl('pages/cluster-b-proto.html'), draft: fileUrl('pages/cluster-b-draft.html') }
|
|
86
|
+
];
|
|
87
|
+
const out = mkdtempSync(join(tmpdir(), 'tt-fid-c-'));
|
|
88
|
+
const r = await runFidelity({
|
|
89
|
+
pages,
|
|
90
|
+
viewports: [1280],
|
|
91
|
+
mode: 'pixel',
|
|
92
|
+
outDir: out,
|
|
93
|
+
quiet: true
|
|
94
|
+
});
|
|
95
|
+
assert(r.exitCode === 1, 'cluster pages exit 1');
|
|
96
|
+
const talk = (r.clusters || []).find((c) => /talk it through/i.test(c.label || c.key));
|
|
97
|
+
assert(talk && talk.pages >= 2, 'cluster: Prefer to talk it through spans 2 pages');
|
|
98
|
+
rmSync(out, { recursive: true, force: true });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (failed) {
|
|
102
|
+
console.error(`\n${failed} assertion(s) failed`);
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
console.log('\nAll fidelity self-tests passed.');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
main().catch((err) => {
|
|
109
|
+
console.error(err);
|
|
110
|
+
process.exit(2);
|
|
111
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tractiontactics/tt-fidelity",
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Section-first visual fidelity: hybrid SSIM+pixel gate + computed-style explainer for TT Platform prototype→site builds.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"tt-fidelity": "bin/tt-fidelity.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "./src/index.mjs",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/index.mjs",
|
|
12
|
+
"./package.json": "./package.json",
|
|
13
|
+
"./fidelity.schema.json": "./fidelity.schema.json"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"bin",
|
|
17
|
+
"fidelity-diff.mjs",
|
|
18
|
+
"fidelity.schema.json",
|
|
19
|
+
"src",
|
|
20
|
+
"README.md",
|
|
21
|
+
"AGENTS.md",
|
|
22
|
+
"fixtures"
|
|
23
|
+
],
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=18"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"fidelity",
|
|
29
|
+
"visual-regression",
|
|
30
|
+
"pixelmatch",
|
|
31
|
+
"wordpress",
|
|
32
|
+
"tt-platform"
|
|
33
|
+
],
|
|
34
|
+
"license": "GPL-2.0-or-later",
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/tractiontactics/tt-wp-platform.git",
|
|
38
|
+
"directory": "tools/fidelity-diff"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"playwright": ">=1.40.0"
|
|
45
|
+
},
|
|
46
|
+
"peerDependenciesMeta": {
|
|
47
|
+
"playwright": {
|
|
48
|
+
"optional": true
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"pixelmatch": "^7.2.0",
|
|
53
|
+
"pngjs": "^7.0.0"
|
|
54
|
+
}
|
|
55
|
+
}
|
package/src/capture.mjs
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Playwright capture helpers: cache-bust, auth, fonts, section shots.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export function basic(pair) {
|
|
6
|
+
if (!pair) return undefined;
|
|
7
|
+
const i = pair.indexOf(':');
|
|
8
|
+
if (i < 0) return undefined;
|
|
9
|
+
return { username: pair.slice(0, i), password: pair.slice(i + 1) };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function cacheBustUrl(url, enabled = true) {
|
|
13
|
+
if (!enabled) return url;
|
|
14
|
+
try {
|
|
15
|
+
const u = new URL(url);
|
|
16
|
+
if (u.protocol === 'file:') return url;
|
|
17
|
+
u.searchParams.set('tt_nocache', String(Date.now()));
|
|
18
|
+
return u.toString();
|
|
19
|
+
} catch (e) {
|
|
20
|
+
return url;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function pickCacheHeaders(headers) {
|
|
25
|
+
const out = {};
|
|
26
|
+
for (const key of ['x-cache', 'x-cacheable', 'age', 'cf-cache-status', 'cache-control', 'x-drupal-cache']) {
|
|
27
|
+
const v = headers[key] || headers[key.toLowerCase()];
|
|
28
|
+
if (v) out[key] = v;
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Navigate and wait for fonts / network.
|
|
35
|
+
* @returns {{ page, context, cacheHeaders, finalUrl }}
|
|
36
|
+
*/
|
|
37
|
+
export async function openPage(browser, {
|
|
38
|
+
url,
|
|
39
|
+
width,
|
|
40
|
+
height = 900,
|
|
41
|
+
auth,
|
|
42
|
+
cookie,
|
|
43
|
+
cacheBust = true,
|
|
44
|
+
menus = false
|
|
45
|
+
}) {
|
|
46
|
+
const target = cacheBustUrl(url, cacheBust);
|
|
47
|
+
const httpCredentials = basic(auth);
|
|
48
|
+
const ctx = await browser.newContext({
|
|
49
|
+
viewport: { width, height },
|
|
50
|
+
httpCredentials,
|
|
51
|
+
ignoreHTTPSErrors: true
|
|
52
|
+
});
|
|
53
|
+
if (cookie) {
|
|
54
|
+
try {
|
|
55
|
+
const { hostname } = new URL(url);
|
|
56
|
+
await ctx.addCookies(
|
|
57
|
+
cookie.split(';').map((pair) => {
|
|
58
|
+
const [name, ...rest] = pair.trim().split('=');
|
|
59
|
+
return { name, value: rest.join('='), domain: hostname, path: '/' };
|
|
60
|
+
})
|
|
61
|
+
);
|
|
62
|
+
} catch (e) {
|
|
63
|
+
/* file:// has no hostname */
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const page = await ctx.newPage();
|
|
67
|
+
let cacheHeaders = {};
|
|
68
|
+
page.on('response', (res) => {
|
|
69
|
+
try {
|
|
70
|
+
if (res.url().split('?')[0] === target.split('?')[0] || res.url() === target) {
|
|
71
|
+
cacheHeaders = pickCacheHeaders(res.headers());
|
|
72
|
+
}
|
|
73
|
+
} catch (e) { /* ignore */ }
|
|
74
|
+
});
|
|
75
|
+
await page.goto(target, { waitUntil: 'networkidle', timeout: 45000 });
|
|
76
|
+
await page.evaluate(() => document.fonts && document.fonts.ready).catch(() => {});
|
|
77
|
+
await page.waitForTimeout(250);
|
|
78
|
+
|
|
79
|
+
if (menus) {
|
|
80
|
+
for (const s of ['nav li:has(ul)', '.menu-item-has-children', 'nav [aria-haspopup]']) {
|
|
81
|
+
try {
|
|
82
|
+
const el = page.locator(s).first();
|
|
83
|
+
if (await el.count()) {
|
|
84
|
+
await el.hover({ timeout: 2000 });
|
|
85
|
+
await page.waitForTimeout(350);
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
} catch (e) { /* try next */ }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { page, context: ctx, cacheHeaders, finalUrl: page.url() };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Screenshot a section by clipping from the page (element bounding box).
|
|
97
|
+
*/
|
|
98
|
+
export async function screenshotSection(page, section) {
|
|
99
|
+
const handle = await page.$(section.selector);
|
|
100
|
+
if (!handle) {
|
|
101
|
+
// fallback clip from stored box
|
|
102
|
+
const { x, viewportTop, width, height } = section.box;
|
|
103
|
+
return page.screenshot({
|
|
104
|
+
type: 'png',
|
|
105
|
+
clip: {
|
|
106
|
+
x: Math.max(0, x),
|
|
107
|
+
y: Math.max(0, viewportTop),
|
|
108
|
+
width: Math.max(1, width),
|
|
109
|
+
height: Math.max(1, height)
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
return handle.screenshot({ type: 'png' });
|
|
114
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* CLI for @tractiontactics/tt-fidelity
|
|
4
|
+
*/
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
6
|
+
import { runFidelity } from './run.mjs';
|
|
7
|
+
import { GROUPS } from './constants.mjs';
|
|
8
|
+
|
|
9
|
+
function parseArgs(argv) {
|
|
10
|
+
const out = {
|
|
11
|
+
viewports: [1280, 768, 390],
|
|
12
|
+
tolerance: 1,
|
|
13
|
+
only: null,
|
|
14
|
+
menus: false,
|
|
15
|
+
quiet: false,
|
|
16
|
+
scope: 'full',
|
|
17
|
+
round: 1,
|
|
18
|
+
maxRounds: 6,
|
|
19
|
+
mode: 'full',
|
|
20
|
+
cacheBust: true,
|
|
21
|
+
concurrency: 1,
|
|
22
|
+
planOnly: false,
|
|
23
|
+
onlyFailing: false
|
|
24
|
+
};
|
|
25
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
26
|
+
const a = argv[i];
|
|
27
|
+
const next = () => argv[++i];
|
|
28
|
+
if (a === '--proto') out.proto = next();
|
|
29
|
+
else if (a === '--draft') out.draft = next();
|
|
30
|
+
else if (a === '--pages') out.pagesFile = next();
|
|
31
|
+
else if (a === '--out') out.outDir = next();
|
|
32
|
+
else if (a === '--mode') out.mode = next();
|
|
33
|
+
else if (a === '--metric') out.metric = next();
|
|
34
|
+
else if (a === '--aa-threshold') out.aaThreshold = parseFloat(next());
|
|
35
|
+
else if (a === '--threshold') out.threshold = parseFloat(next());
|
|
36
|
+
else if (a === '--cache-bust') out.cacheBust = true;
|
|
37
|
+
else if (a === '--no-cache-bust') out.cacheBust = false;
|
|
38
|
+
else if (a === '--concurrency') out.concurrency = Math.max(1, parseInt(next(), 10) || 1);
|
|
39
|
+
else if (a === '--only-failing') out.onlyFailing = true;
|
|
40
|
+
else if (a === '--prior') out.priorJson = next();
|
|
41
|
+
else if (a === '--viewports') out.viewports = next().split(',').map((s) => parseInt(s.trim(), 10)).filter(Boolean);
|
|
42
|
+
else if (a === '--tolerance') out.tolerance = parseFloat(next());
|
|
43
|
+
else if (a === '--only') out.only = next().split(',').map((s) => s.trim()).filter(Boolean);
|
|
44
|
+
else if (a === '--menus') out.menus = true;
|
|
45
|
+
else if (a === '--auth') out.auth = next();
|
|
46
|
+
else if (a === '--draft-auth') out.draftAuth = next();
|
|
47
|
+
else if (a === '--cookie') out.cookie = next();
|
|
48
|
+
else if (a === '--roles') out.roles = next();
|
|
49
|
+
else if (a === '--scope') out.scope = next();
|
|
50
|
+
else if (a === '--round') out.round = Math.max(1, parseInt(next(), 10) || 1);
|
|
51
|
+
else if (a === '--max-rounds') out.maxRounds = Math.max(1, parseInt(next(), 10) || 6);
|
|
52
|
+
else if (a === '--json') out.json = next();
|
|
53
|
+
else if (a === '--plan-only') out.planOnly = true;
|
|
54
|
+
else if (a === '--quiet') out.quiet = true;
|
|
55
|
+
else if (a === '--help' || a === '-h') out.help = true;
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function usage() {
|
|
61
|
+
console.log(`tt-fidelity — section pixel gate + style explainer for prototype→site builds.
|
|
62
|
+
|
|
63
|
+
npx @tractiontactics/tt-fidelity --proto <url> --draft <url> [options]
|
|
64
|
+
node fidelity-diff.mjs --proto <url> --draft <url> [options]
|
|
65
|
+
|
|
66
|
+
--proto / --draft single page pair
|
|
67
|
+
--pages <file> JSON [{id,proto,draft}] or txt (proto|draft per line)
|
|
68
|
+
--out <dir> write fidelity.json, QUEUE.md, shots/
|
|
69
|
+
--mode full|pixel|styles default full (pixel/SSIM gates exit 0; styles explain)
|
|
70
|
+
--metric hybrid|ssim|pixel default hybrid (SSIM divergence + AA-aware pixel floor)
|
|
71
|
+
--aa-threshold <0-1> pixelmatch colour threshold (default 0.14)
|
|
72
|
+
--threshold <0-1> section fail when score >= (default 0.10 = clean band)
|
|
73
|
+
--viewports 1280,768,390
|
|
74
|
+
--tolerance 1 px tolerance for style lengths
|
|
75
|
+
--only fonts,colours style buckets only (${Object.keys(GROUPS).join(',')},menus)
|
|
76
|
+
--menus hover nav for dropdowns
|
|
77
|
+
--auth u:p / --draft-auth basic auth
|
|
78
|
+
--cookie "n=v" draft cookies
|
|
79
|
+
--roles roles.json project role map
|
|
80
|
+
--scope full|chrome|content
|
|
81
|
+
--round / --max-rounds fidelity loop (exit 3 at max with diffs)
|
|
82
|
+
--cache-bust / --no-cache-bust default ON (tt_nocache=)
|
|
83
|
+
--concurrency <n> parallel pages (default 1)
|
|
84
|
+
--only-failing skip sections clean in prior fidelity.json
|
|
85
|
+
--prior <path> prior fidelity.json for --only-failing
|
|
86
|
+
--json out.json also write document
|
|
87
|
+
--plan-only suppress raw evidence tables
|
|
88
|
+
--quiet
|
|
89
|
+
|
|
90
|
+
Exit 0 = pixel gate clean (or styles clean in --mode styles)
|
|
91
|
+
Exit 1 = differences remain
|
|
92
|
+
Exit 2 = could not run / low coverage
|
|
93
|
+
Exit 3 = max rounds reached with diffs`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
97
|
+
const args = parseArgs(argv);
|
|
98
|
+
if (args.help || ((!args.proto || !args.draft) && !args.pagesFile)) {
|
|
99
|
+
usage();
|
|
100
|
+
process.exit(args.help ? 0 : 2);
|
|
101
|
+
}
|
|
102
|
+
if (args.mode && !['full', 'pixel', 'styles'].includes(args.mode)) {
|
|
103
|
+
console.error(`unknown --mode "${args.mode}" (use full, pixel, or styles)`);
|
|
104
|
+
process.exit(2);
|
|
105
|
+
}
|
|
106
|
+
if (args.metric && !['hybrid', 'ssim', 'pixel'].includes(args.metric)) {
|
|
107
|
+
console.error(`unknown --metric "${args.metric}" (use hybrid, ssim, or pixel)`);
|
|
108
|
+
process.exit(2);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
const result = await runFidelity(args);
|
|
113
|
+
if (result.text) console.log(result.text);
|
|
114
|
+
process.exit(result.exitCode);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
console.error(`tt-fidelity failed: ${err && err.message ? err.message : err}`);
|
|
117
|
+
process.exit(err?.exitCode || 2);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Only auto-run when this file is the process entry (not when imported by fidelity-diff.mjs).
|
|
122
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
123
|
+
main();
|
|
124
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/** Shared measurement constants for @tractiontactics/tt-fidelity */
|
|
2
|
+
|
|
3
|
+
export const GROUPS = {
|
|
4
|
+
flow: ['display', 'position', 'flex-direction', 'justify-content', 'flex-wrap', 'grid-template-columns'],
|
|
5
|
+
alignment: ['text-align', 'align-items', 'vertical-align'],
|
|
6
|
+
padding_margin: [
|
|
7
|
+
'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
|
|
8
|
+
'margin-top', 'margin-right', 'margin-bottom', 'margin-left'
|
|
9
|
+
],
|
|
10
|
+
gaps: ['row-gap', 'column-gap'],
|
|
11
|
+
widths: ['max-width', 'min-height'],
|
|
12
|
+
fonts: [
|
|
13
|
+
'font-family', 'font-size', 'font-weight', 'line-height',
|
|
14
|
+
'letter-spacing', 'text-transform', 'font-style'
|
|
15
|
+
],
|
|
16
|
+
colours: [
|
|
17
|
+
'color', 'background-color', 'border-radius', 'box-shadow', 'opacity',
|
|
18
|
+
'border-top-width', 'border-top-color', 'border-bottom-width', 'border-bottom-color',
|
|
19
|
+
'border-left-width', 'border-left-color', 'border-right-width', 'border-right-color'
|
|
20
|
+
],
|
|
21
|
+
motion: ['transition-duration', 'transition-property', 'animation-name'],
|
|
22
|
+
imagery: ['object-fit', 'object-position']
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const ALL_PROPS = [...new Set(Object.values(GROUPS).flat())];
|
|
26
|
+
|
|
27
|
+
export const BUCKET_OF = Object.fromEntries(
|
|
28
|
+
Object.entries(GROUPS).flatMap(([bucket, props]) => props.map((p) => [p, bucket]))
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Landmark roles. First matching selector wins.
|
|
33
|
+
* Draft-side section probes prefer TT builder rows (see sections.mjs).
|
|
34
|
+
*/
|
|
35
|
+
export const DEFAULT_ROLES = [
|
|
36
|
+
['header', ['header', '[role=banner]', '.site-header', '.header', '#header',
|
|
37
|
+
'[class*=header i]', '[class*=hdr i]', '[class*=masthead i]', 'body > *:first-child'], 1],
|
|
38
|
+
['logo', ['.logo', '.site-logo', '[class*=logo i] img', '[class*=logo i]',
|
|
39
|
+
'[class*=brand i]', 'header img'], 1],
|
|
40
|
+
['nav', ['nav', '[role=navigation]', '.main-navigation', '.menu',
|
|
41
|
+
'[class*=nav i]', '[class*=menu i]'], 1],
|
|
42
|
+
['nav-item', ['nav a', '[role=navigation] a', '.menu a',
|
|
43
|
+
'[class*=nav i] a', '[class*=menu i] a'], 8],
|
|
44
|
+
['submenu', ['nav ul ul', '.sub-menu', '[class*=dropdown i]', '[class*=submenu i]',
|
|
45
|
+
'[class*=subnav i]'], 2],
|
|
46
|
+
['submenu-item', ['nav ul ul a', '.sub-menu a', '[class*=dropdown i] a',
|
|
47
|
+
'[class*=submenu i] a'], 6],
|
|
48
|
+
['main', ['main', '[role=main]', '.site-main', '#main', '#content',
|
|
49
|
+
'[class*=container i]', '[class*=wrapper i]', '[class*=wrap i]'], 1],
|
|
50
|
+
['h1', ['h1', '[class*=title i]', '[class*=headline i]'], 1],
|
|
51
|
+
['h2', ['h2', '[class*=subtitle i]'], 4],
|
|
52
|
+
['h3', ['h3'], 4],
|
|
53
|
+
['button', ['a.button', 'button', '.btn', '[class*=btn i]', '[class*=button i]',
|
|
54
|
+
'[class*=cta i]'], 6],
|
|
55
|
+
['section', ['main > section', 'main > .tt-pb-row', '.tt-pb-row', 'main > div', 'body > section',
|
|
56
|
+
'main > *'], 8],
|
|
57
|
+
['card', ['.tt-pb-card-grid__card', '.tt-pb-fcar', '[class*=card i]', '[class*=tile i]'], 6],
|
|
58
|
+
['img', ['main img', 'img'], 4],
|
|
59
|
+
['footer', ['footer', '[role=contentinfo]', '.site-footer', '.footer',
|
|
60
|
+
'[class*=footer i]', '[class*=ftr i]', 'body > *:last-child'], 1]
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
export const CHROME_ROLES = new Set(['header', 'logo', 'nav', 'nav-item', 'submenu', 'submenu-item', 'footer']);
|
|
64
|
+
export const CHROME_ROLE_SET = CHROME_ROLES;
|
|
65
|
+
|
|
66
|
+
/** Pixel severity bands (Halo §2.7). score = differing pixels / area. */
|
|
67
|
+
export const BANDS = {
|
|
68
|
+
clean: 0.1,
|
|
69
|
+
fix: 0.4
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export const SCHEMA_VERSION = 2;
|
|
73
|
+
export const PACKAGE_VERSION = '0.2.1';
|
package/src/fonts.mjs
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the font family that actually paints (Halo §2.2).
|
|
3
|
+
* Runs inside the page via page.evaluate.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/* eslint-disable no-undef */
|
|
7
|
+
export function resolveRenderedFonts(selectors) {
|
|
8
|
+
const probe = (family) => {
|
|
9
|
+
const canvas = document.createElement('canvas');
|
|
10
|
+
const ctx = canvas.getContext('2d');
|
|
11
|
+
const sample = 'mmmmmmmmmmlli';
|
|
12
|
+
ctx.font = '72px monospace';
|
|
13
|
+
const baseline = ctx.measureText(sample).width;
|
|
14
|
+
ctx.font = `72px ${family}, monospace`;
|
|
15
|
+
return Math.abs(ctx.measureText(sample).width - baseline) > 0.5;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const out = {};
|
|
19
|
+
for (const sel of selectors) {
|
|
20
|
+
let el;
|
|
21
|
+
try {
|
|
22
|
+
el = document.querySelector(sel);
|
|
23
|
+
} catch (e) {
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (!el) continue;
|
|
27
|
+
const stack = getComputedStyle(el).fontFamily || '';
|
|
28
|
+
const families = stack.split(',').map((f) => f.trim().replace(/^["']|["']$/g, ''));
|
|
29
|
+
let rendered = families[families.length - 1] || stack;
|
|
30
|
+
for (const f of families) {
|
|
31
|
+
if (!f || f.toLowerCase() === 'inherit') continue;
|
|
32
|
+
const quoted = /\s/.test(f) ? `"${f}"` : f;
|
|
33
|
+
if (probe(quoted)) {
|
|
34
|
+
rendered = f;
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
out[sel] = { declared: stack, rendered };
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
/* eslint-enable no-undef */
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Drop font-family style rows where declared stacks differ but rendered faces match.
|
|
46
|
+
*/
|
|
47
|
+
export function suppressFalseFontFamilyRows(elements, protoFonts, draftFonts) {
|
|
48
|
+
if (!protoFonts || !draftFonts) return elements;
|
|
49
|
+
return elements
|
|
50
|
+
.map((el) => {
|
|
51
|
+
const rows = el.rows.filter((row) => {
|
|
52
|
+
if (row.prop !== 'font-family') return true;
|
|
53
|
+
const pf = protoFonts[el.protoSelector];
|
|
54
|
+
const df = draftFonts[el.draftSelector];
|
|
55
|
+
if (!pf || !df) return true;
|
|
56
|
+
return pf.rendered.toLowerCase() !== df.rendered.toLowerCase();
|
|
57
|
+
});
|
|
58
|
+
return { ...el, rows };
|
|
59
|
+
})
|
|
60
|
+
.filter((el) => el.rows.length);
|
|
61
|
+
}
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { runFidelity } from './run.mjs';
|
|
2
|
+
export { GROUPS, BANDS, DEFAULT_ROLES, SCHEMA_VERSION, PACKAGE_VERSION } from './constants.mjs';
|
|
3
|
+
export { compareSectionPngs, scoreBand, computeSsim, DEFAULT_AA_THRESHOLD } from './pixel.mjs';
|
|
4
|
+
export { clusterSections } from './rank.mjs';
|
|
5
|
+
export { buildFidelityDocument } from './schema.mjs';
|
|
6
|
+
export { buildPlan } from './plan.mjs';
|