@tractiontactics/tt-fidelity 0.2.1 → 0.2.2

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 CHANGED
@@ -11,6 +11,9 @@ You are measuring a prototype against a TT WP Theme draft. **You are not the gra
11
11
  5. **Never apply a rule site-wide** until `instances_per_page` (in clusters / QUEUE) shows it is a real default. Blanket CSS from a rare seam creates hundreds of false findings.
12
12
  6. **Cache:** the tool cache-busts by default. If draft and live disagree, purge host/CDN and re-run — do not “fix” ghosts.
13
13
  7. **Escalate** tokens → block Style/Advanced → minimal `PUT /custom-css` → theme change → HTML template. Cite `ttRowId` / `ttBlockIds` when present.
14
+ 8. **Default `--scope content`** (page body). Use `--scope chrome` for header/footer. Use `--scope full` only when you intentionally want both (TT draft may then count header/footer rows the prototype does not).
15
+ 9. **QUEUE lifetime:** `ttRowId` values persist across *renders* of the same layout JSON, but a **republish / rebuild that regenerates row ids** invalidates the QUEUE. Re-measure after any full layout rewrite.
16
+ 10. **`--only-failing`:** optional acceleration only. Do **not** grant exit 0 after an only-failing pass — run a full measure (all sections) first.
14
17
 
15
18
  ## Loop
16
19
 
@@ -21,21 +24,26 @@ npx @tractiontactics/tt-fidelity \
21
24
  --viewports 1280,768,390 \
22
25
  --out ./fidelity \
23
26
  --mode full \
27
+ --scope content \
24
28
  --round N \
25
29
  --max-rounds 6
26
30
  ```
27
31
 
28
32
  - Exit **0** → page (or chrome scope) done for this URL pair.
29
- - Exit **1** → do TASK 1 from the plan; re-measure.
33
+ - Exit **1** → do TASK 1 from the plan; re-measure. Section **count** mismatches and **unpaired** sections also keep exit 1.
30
34
  - Exit **2** → fix install / auth / `--roles`; do not guess missing sections.
31
35
  - Exit **3** → stop; report residuals; consider history restore.
32
36
 
37
+ ## Structural noise
38
+
39
+ If a STRUCTURAL task claims the draft has “extra sections” and those are clearly header/footer chrome, re-run with `--scope content` (default since 0.2.2). Do not `PUT /pages/.../layout` to “add” chrome rows.
40
+
33
41
  ## Large sites
34
42
 
35
- Compare one prototype page to its draft at a time, or pass `--pages`. Use `--scope chrome` then `--scope content`. Prefer `--only-failing` after the first full pass.
43
+ Compare one prototype page to its draft at a time, or pass `--pages`. Use `--scope chrome` then `--scope content`. Prefer `--only-failing` only mid-loop; finish with a full pass.
36
44
 
37
45
  ## Forbidden
38
46
 
39
47
  - Certifying “100% parity” without exit 0 from the pixel gate.
40
48
  - Substituting fetch-only CSS analysis for the main path.
41
- - Ignoring section count mismatches (structural first).
49
+ - Ignoring unpaired / section count mismatches (structural first — after confirming scope).
package/README.md CHANGED
@@ -12,9 +12,12 @@ npx @tractiontactics/tt-fidelity \
12
12
  --draft "https://site.example.com/?p=12&preview=true" \
13
13
  --viewports 1280,768,390 \
14
14
  --out ./fidelity \
15
- --mode full
15
+ --mode full \
16
+ --scope content
16
17
  ```
17
18
 
19
+ Default `--scope` is **`content`** (page body; excludes TT header/footer rows). Use `--scope chrome` for header/footer, `--scope full` only when you need both.
20
+
18
21
  Monorepo:
19
22
 
20
23
  ```bash
@@ -97,10 +100,10 @@ Override fail cut with `--threshold` (default `0.10`).
97
100
  | `--out <dir>` | `fidelity.json`, `QUEUE.md`, `shots/sections/…` |
98
101
  | `--mode full\|pixel\|styles` | See above |
99
102
  | `--viewports` | Default `1280,768,390` |
100
- | `--scope full\|chrome\|content` | Section / landmark scope |
103
+ | `--scope` | Default **`content`**. `chrome` = header/footer. `full` = both (may skew TT section counts). |
101
104
  | `--cache-bust` / `--no-cache-bust` | Default **on** (`tt_nocache=`); logs cache headers |
102
105
  | `--concurrency <n>` | Parallel pages |
103
- | `--only-failing` | Skip sections clean in prior `fidelity.json` |
106
+ | `--only-failing` | Skip sections clean in prior `fidelity.json` — finish with a full pass before exit 0 |
104
107
  | `--roles` | Landmark map when defaults miss |
105
108
  | `--round` / `--max-rounds` | Agent loop |
106
109
  | `--json` | Extra copy of the document |
@@ -66,7 +66,14 @@
66
66
  }
67
67
  },
68
68
  "cacheHeaders": { "type": "object" },
69
- "coverage": { "type": "object" }
69
+ "coverage": { "type": "object" },
70
+ "unpaired": {
71
+ "type": "object",
72
+ "properties": {
73
+ "prototype": { "type": "array" },
74
+ "draft": { "type": "array" }
75
+ }
76
+ }
70
77
  }
71
78
  }
72
79
  }
@@ -8,6 +8,8 @@ import { fileURLToPath } from 'node:url';
8
8
  import { writeFileSync, mkdtempSync, rmSync } from 'node:fs';
9
9
  import { tmpdir } from 'node:os';
10
10
  import { runFidelity } from '../src/index.mjs';
11
+ import { alignSections } from '../src/sections.mjs';
12
+ import { PACKAGE_VERSION } from '../src/constants.mjs';
11
13
 
12
14
  const __dirname = dirname(fileURLToPath(import.meta.url));
13
15
  const FIX = __dirname;
@@ -24,6 +26,26 @@ function assert(cond, msg) {
24
26
  }
25
27
 
26
28
  async function main() {
29
+ assert(PACKAGE_VERSION === '0.2.2', 'package version constant is 0.2.2');
30
+
31
+ // Align: unpaired + structural when counts differ
32
+ {
33
+ const proto = [
34
+ { index: 0, label: 'A', selector: '#a', ttRowId: null },
35
+ { index: 1, label: 'B', selector: '#b', ttRowId: null },
36
+ { index: 2, label: 'C-unpaired', selector: '#c', ttRowId: null }
37
+ ];
38
+ const draft = [
39
+ { index: 0, label: 'A', selector: '#a', ttRowId: 'row_a' },
40
+ { index: 1, label: 'B', selector: '#b', ttRowId: 'row_b' }
41
+ ];
42
+ const { pairs, structural, unpaired } = alignSections(proto, draft);
43
+ assert(pairs.length === 2, 'align: pairs = min length');
44
+ assert(unpaired.prototype.length === 1 && unpaired.prototype[0].label === 'C-unpaired', 'align: unpaired proto listed');
45
+ assert(unpaired.draft.length === 0, 'align: no unpaired draft');
46
+ assert(structural.some((s) => s.kind === 'count'), 'align: count structural finding');
47
+ }
48
+
27
49
  // 1. Known style drift — exit 1 in styles mode
28
50
  {
29
51
  const r = await runFidelity({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tractiontactics/tt-fidelity",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Section-first visual fidelity: hybrid SSIM+pixel gate + computed-style explainer for TT Platform prototype→site builds.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -51,5 +51,8 @@
51
51
  "dependencies": {
52
52
  "pixelmatch": "^7.2.0",
53
53
  "pngjs": "^7.0.0"
54
+ },
55
+ "devDependencies": {
56
+ "playwright": "^1.63.0"
54
57
  }
55
58
  }
package/src/cli.mjs CHANGED
@@ -13,7 +13,7 @@ function parseArgs(argv) {
13
13
  only: null,
14
14
  menus: false,
15
15
  quiet: false,
16
- scope: 'full',
16
+ scope: 'content',
17
17
  round: 1,
18
18
  maxRounds: 6,
19
19
  mode: 'full',
@@ -77,11 +77,12 @@ function usage() {
77
77
  --auth u:p / --draft-auth basic auth
78
78
  --cookie "n=v" draft cookies
79
79
  --roles roles.json project role map
80
- --scope full|chrome|content
80
+ --scope content|chrome|full default content (page body; excludes TT header/footer rows)
81
+ use chrome for header/footer; full only when you need both
81
82
  --round / --max-rounds fidelity loop (exit 3 at max with diffs)
82
83
  --cache-bust / --no-cache-bust default ON (tt_nocache=)
83
84
  --concurrency <n> parallel pages (default 1)
84
- --only-failing skip sections clean in prior fidelity.json
85
+ --only-failing skip sections clean in prior fidelity.json (do not use until a final full pass)
85
86
  --prior <path> prior fidelity.json for --only-failing
86
87
  --json out.json also write document
87
88
  --plan-only suppress raw evidence tables
package/src/constants.mjs CHANGED
@@ -70,4 +70,4 @@ export const BANDS = {
70
70
  };
71
71
 
72
72
  export const SCHEMA_VERSION = 2;
73
- export const PACKAGE_VERSION = '0.2.1';
73
+ export const PACKAGE_VERSION = '0.2.2';
package/src/plan.mjs CHANGED
@@ -121,6 +121,7 @@ export function buildPlan(d, { pixelSections = [] } = {}) {
121
121
  }
122
122
  for (const [role, items] of structuralByRole) {
123
123
  const unmatched = items.some((i) => i.kind === 'unmatched' || i.kind === 'extra');
124
+ const hints = items.map((i) => i.hint).filter(Boolean);
124
125
  tasks.push({
125
126
  kind: 'structural',
126
127
  title: unmatched
@@ -129,13 +130,34 @@ export function buildPlan(d, { pixelSections = [] } = {}) {
129
130
  why: items.map((i) => i.text),
130
131
  fixAt: unmatched
131
132
  ? `FIRST verify it is really absent (inspect the page). If it exists under different markup, add its selector via --roles and re-run — do not "fix" it. If genuinely absent: ${fixHintFor(null, role, 'structural')}`
132
- : fixHintFor(null, role, 'structural'),
133
+ : (hints[0] || fixHintFor(null, role, 'structural')),
133
134
  rows: [],
134
135
  affects: items.length,
135
136
  mayResolve: 0
136
137
  });
137
138
  }
138
139
 
140
+ const unpaired = d.unpaired;
141
+ if (unpaired && ((unpaired.prototype && unpaired.prototype.length) || (unpaired.draft && unpaired.draft.length))) {
142
+ const why = [];
143
+ for (const u of unpaired.prototype || []) {
144
+ why.push(`unpaired prototype [${u.index}]: "${u.label || u.selector}"${u.ttRowId ? ` (${u.ttRowId})` : ''}`);
145
+ }
146
+ for (const u of unpaired.draft || []) {
147
+ why.push(`unpaired draft [${u.index}]: "${u.label || u.selector}"${u.ttRowId ? ` (${u.ttRowId})` : ''}`);
148
+ }
149
+ tasks.push({
150
+ kind: 'structural',
151
+ title: 'Unpaired sections — not pixel-compared (positional truncate)',
152
+ why,
153
+ fixAt:
154
+ 'These sections were dropped from the pixel gate because counts differ. Prefer --scope content for page body (default since 0.2.2). Fix real missing/extra content, then re-measure so every section is paired.',
155
+ rows: [],
156
+ affects: why.length,
157
+ mayResolve: 0
158
+ });
159
+ }
160
+
139
161
  // Pixel sections — severity-ranked, before global style (rebuild first).
140
162
  const failing = (pixelSections || [])
141
163
  .filter((s) => s.band && s.band !== 'clean')
@@ -381,6 +403,10 @@ export function renderQueueMd(clusters, pages) {
381
403
  lines.push('');
382
404
  lines.push('Ranked by leverage. Do one task, re-measure, continue.');
383
405
  lines.push('');
406
+ lines.push('> Default scope is **content** (page body). Use `--scope chrome` for header/footer.');
407
+ lines.push('> This QUEUE is valid only until the draft page layout is rewritten (row ids may change).');
408
+ lines.push('> Do not use `--only-failing` until a final full pass before claiming exit 0.');
409
+ lines.push('');
384
410
  if (clusters?.length) {
385
411
  lines.push('## Cross-page clusters (fix these first)');
386
412
  lines.push('');
@@ -397,13 +423,23 @@ export function renderQueueMd(clusters, pages) {
397
423
  for (const page of pages) {
398
424
  let fail = 0;
399
425
  let total = 0;
426
+ const unpairedNotes = [];
400
427
  for (const vp of page.viewports) {
401
428
  for (const s of vp.sections || []) {
402
429
  total += 1;
403
430
  if (s.band !== 'clean') fail += 1;
404
431
  }
432
+ const u = vp.unpaired || vp.styleDiff?.unpaired;
433
+ if (u && (u.prototype?.length || u.draft?.length)) {
434
+ unpairedNotes.push(
435
+ `${vp.width}px: ${u.prototype?.length || 0} proto / ${u.draft?.length || 0} draft unpaired`
436
+ );
437
+ }
438
+ }
439
+ lines.push(`- \`${page.id}\`: ${fail}/${total} sections failing (compared)`);
440
+ for (const note of unpairedNotes) {
441
+ lines.push(` - unpaired (not pixel-compared): ${note}`);
405
442
  }
406
- lines.push(`- \`${page.id}\`: ${fail}/${total} sections failing`);
407
443
  }
408
444
  lines.push('');
409
445
  return lines.join('\n');
package/src/run.mjs CHANGED
@@ -150,9 +150,11 @@ async function measurePagePair(browser, pageSpec, opts, roles) {
150
150
  if (doPixel) {
151
151
  const protoSecs = await protoOpen.page.evaluate(inPageSections, { scope: opts.scope });
152
152
  const draftSecs = await draftOpen.page.evaluate(inPageSections, { scope: opts.scope });
153
- const { pairs, structural } = alignSections(protoSecs, draftSecs);
153
+ const { pairs, structural, unpaired } = alignSections(protoSecs, draftSecs);
154
154
  styleDiff.structural = [...(styleDiff.structural || []), ...structural];
155
-
155
+ if (unpaired?.prototype?.length || unpaired?.draft?.length) {
156
+ styleDiff.unpaired = unpaired;
157
+ }
156
158
  const shotsDir = opts.outDir
157
159
  ? join(opts.outDir, 'shots', 'sections', pageSpec.id, String(width))
158
160
  : null;
@@ -225,6 +227,7 @@ async function measurePagePair(browser, pageSpec, opts, roles) {
225
227
  coverage,
226
228
  styleDiff,
227
229
  sections,
230
+ unpaired: styleDiff.unpaired || { prototype: [], draft: [] },
228
231
  cacheHeaders: draftOpen.cacheHeaders,
229
232
  plan
230
233
  });
@@ -264,7 +267,7 @@ export async function runFidelity(opts = {}) {
264
267
  viewports: [1280, 768, 390],
265
268
  tolerance: 1,
266
269
  mode: 'full',
267
- scope: 'full',
270
+ scope: 'content',
268
271
  round: 1,
269
272
  maxRounds: 6,
270
273
  cacheBust: true,
package/src/sections.mjs CHANGED
@@ -95,7 +95,7 @@ export function inPageSections({ scope }) {
95
95
  /* eslint-enable no-undef */
96
96
 
97
97
  /**
98
- * Align sections by document order. Length mismatch → structural findings.
98
+ * Align sections by document order. Length mismatch → structural findings + unpaired lists.
99
99
  */
100
100
  export function alignSections(protoSections, draftSections) {
101
101
  const n = Math.min(protoSections.length, draftSections.length);
@@ -103,17 +103,33 @@ export function alignSections(protoSections, draftSections) {
103
103
  for (let i = 0; i < n; i += 1) {
104
104
  pairs.push({ index: i, proto: protoSections[i], draft: draftSections[i] });
105
105
  }
106
+ const unpaired = {
107
+ prototype: protoSections.slice(n).map(summarizeUnpaired),
108
+ draft: draftSections.slice(n).map(summarizeUnpaired)
109
+ };
106
110
  const structural = [];
107
111
  if (protoSections.length !== draftSections.length) {
112
+ const missing = protoSections.length - draftSections.length;
108
113
  structural.push({
109
114
  kind: 'count',
110
115
  role: 'section',
111
116
  text: `section: prototype has ${protoSections.length}, draft has ${draftSections.length}${
112
- draftSections.length < protoSections.length
113
- ? ` (draft is missing ${protoSections.length - draftSections.length})`
114
- : ` (draft has ${draftSections.length - protoSections.length} extra)`
115
- }`
117
+ missing > 0
118
+ ? ` (draft is missing ${missing})`
119
+ : ` (draft has ${-missing} extra)`
120
+ }`,
121
+ hint:
122
+ 'If comparing page body only, re-run with --scope content (default since 0.2.2) so TT header/footer rows are not counted as extra sections. Chromeless count gaps may still be real layout issues.'
116
123
  });
117
124
  }
118
- return { pairs, structural };
125
+ return { pairs, structural, unpaired };
126
+ }
127
+
128
+ function summarizeUnpaired(sec) {
129
+ return {
130
+ index: sec.index,
131
+ label: sec.label || '',
132
+ selector: sec.selector || '',
133
+ ttRowId: sec.ttRowId || null
134
+ };
119
135
  }