@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.
@@ -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
+ }
@@ -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';