@tractiontactics/tt-fidelity 0.2.1 → 0.2.3

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
@@ -10,7 +10,11 @@ You are measuring a prototype against a TT WP Theme draft. **You are not the gra
10
10
  4. **One WORK PLAN task**, then re-run with `--round` incremented. Do not batch speculative fixes.
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
+ 6b. **Capture prep (0.2.3+):** the tool scrolls the full page, forces lazy images, and unlocks common scroll-reveal (`opacity:0`) before section shots. If prototype PNGs look blank cream with only sticky widgets, you are on an old package — upgrade. Separately, **404 images** on the prototype (or `onerror` hiding media) are real source defects, not capture bugs.
13
14
  7. **Escalate** tokens → block Style/Advanced → minimal `PUT /custom-css` → theme change → HTML template. Cite `ttRowId` / `ttBlockIds` when present.
15
+ 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).
16
+ 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.
17
+ 10. **`--only-failing`:** optional acceleration only. Do **not** grant exit 0 after an only-failing pass — run a full measure (all sections) first.
14
18
 
15
19
  ## Loop
16
20
 
@@ -21,21 +25,26 @@ npx @tractiontactics/tt-fidelity \
21
25
  --viewports 1280,768,390 \
22
26
  --out ./fidelity \
23
27
  --mode full \
28
+ --scope content \
24
29
  --round N \
25
30
  --max-rounds 6
26
31
  ```
27
32
 
28
33
  - Exit **0** → page (or chrome scope) done for this URL pair.
29
- - Exit **1** → do TASK 1 from the plan; re-measure.
34
+ - Exit **1** → do TASK 1 from the plan; re-measure. Section **count** mismatches and **unpaired** sections also keep exit 1.
30
35
  - Exit **2** → fix install / auth / `--roles`; do not guess missing sections.
31
36
  - Exit **3** → stop; report residuals; consider history restore.
32
37
 
38
+ ## Structural noise
39
+
40
+ 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.
41
+
33
42
  ## Large sites
34
43
 
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.
44
+ 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
45
 
37
46
  ## Forbidden
38
47
 
39
48
  - Certifying “100% parity” without exit 0 from the pixel gate.
40
49
  - Substituting fetch-only CSS analysis for the main path.
41
- - Ignoring section count mismatches (structural first).
50
+ - 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 |
@@ -126,12 +129,15 @@ Schema: [`fidelity.schema.json`](./fidelity.schema.json) (`schema_version: 2`).
126
129
  ## How measurement works
127
130
 
128
131
  1. Load both URLs at the same viewport (cache-busted by default).
129
- 2. Discover **sections**: draft prefers `.tt-pb-row` / `data-tt-row`; prototype prefers `main > section`.
130
- 3. Align by document order; count mismatch structural task.
131
- 4. Screenshot each pair, normalize canvas, `pixelmatch`score + band.
132
- 5. Optionally capture landmark computed styles (explainer).
133
- 6. Cluster failing headings across `--pages` → `QUEUE.md` leverage list.
134
- 7. Emit TT ids (`data-tt-row` / `data-tt-block`) when present for `PUT /pages/{id}/layout`.
132
+ 2. **Prepare for capture (0.2.3+):** full-page scroll (scroll-reveal / IntersectionObserver), eager lazy images, unlock common `opacity:0` reveal classes, hide sticky tip/chat overlays.
133
+ 3. Discover **sections**: draft prefers `.tt-pb-row` / `data-tt-row`; prototype prefers `main > section` (or `body > section`).
134
+ 4. Align by document order; count mismatchstructural task.
135
+ 5. Screenshot each pair (scroll section into view again), normalize canvas, `pixelmatch` → score + band.
136
+ 6. Optionally capture landmark computed styles (explainer).
137
+ 7. Cluster failing headings across `--pages` `QUEUE.md` leverage list.
138
+ 8. Emit TT ids (`data-tt-row` / `data-tt-block`) when present for `PUT /pages/{id}/layout`.
139
+
140
+ If prototype shots look like blank cream with only a sticky widget, you are on a pre-0.2.3 build — upgrade. Broken/404 media on the prototype itself will still appear empty.
135
141
 
136
142
  ## Agent contract
137
143
 
@@ -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.3', 'package version constant is 0.2.3');
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.3",
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/capture.mjs CHANGED
@@ -72,9 +72,9 @@ export async function openPage(browser, {
72
72
  }
73
73
  } catch (e) { /* ignore */ }
74
74
  });
75
- await page.goto(target, { waitUntil: 'networkidle', timeout: 45000 });
75
+ await page.goto(target, { waitUntil: 'networkidle', timeout: 60000 });
76
76
  await page.evaluate(() => document.fonts && document.fonts.ready).catch(() => {});
77
- await page.waitForTimeout(250);
77
+ await preparePageForCapture(page);
78
78
 
79
79
  if (menus) {
80
80
  for (const s of ['nav li:has(ul)', '.menu-item-has-children', 'nav [aria-haspopup]']) {
@@ -92,23 +92,133 @@ export async function openPage(browser, {
92
92
  return { page, context: ctx, cacheHeaders, finalUrl: page.url() };
93
93
  }
94
94
 
95
+ /**
96
+ * Prototypes often use scroll-reveal (opacity:0 until .in) and loading=lazy images.
97
+ * Without a full scroll + image wait, section screenshots are blank cream and the
98
+ * pixel gate is meaningless.
99
+ */
100
+ export async function preparePageForCapture(page) {
101
+ await page.evaluate(async () => {
102
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
103
+
104
+ // Force-eager lazy images / common data-src patterns.
105
+ for (const img of document.querySelectorAll('img')) {
106
+ try {
107
+ img.loading = 'eager';
108
+ const ds = img.getAttribute('data-src') || img.getAttribute('data-lazy-src');
109
+ if (ds && !img.getAttribute('src')) img.setAttribute('src', ds);
110
+ const dss = img.getAttribute('data-srcset') || img.getAttribute('data-lazy-srcset');
111
+ if (dss && !img.getAttribute('srcset')) img.setAttribute('srcset', dss);
112
+ } catch (e) { /* ignore */ }
113
+ }
114
+
115
+ // Scroll the full document so IntersectionObservers / reveal classes fire.
116
+ const step = Math.max(240, Math.floor(window.innerHeight * 0.7));
117
+ const maxY = Math.max(document.body.scrollHeight, document.documentElement.scrollHeight);
118
+ for (let y = 0; y < maxY; y += step) {
119
+ window.scrollTo(0, y);
120
+ await sleep(80);
121
+ }
122
+ window.scrollTo(0, maxY);
123
+ await sleep(150);
124
+ window.scrollTo(0, 0);
125
+ await sleep(200);
126
+
127
+ // If reveal never fired (no IO), force common "in view" classes / opacity.
128
+ for (const el of document.querySelectorAll('section, .sec, .sec-alt, .trust, .lead, .phero, [class*=reveal i], [class*=fade i]')) {
129
+ try {
130
+ const cs = getComputedStyle(el);
131
+ if (parseFloat(cs.opacity) < 0.5) {
132
+ el.classList.add('in', 'is-inview', 'is-visible', 'visible', 'aos-animate');
133
+ el.style.opacity = '1';
134
+ el.style.transform = 'none';
135
+ el.style.visibility = 'visible';
136
+ }
137
+ } catch (e) { /* ignore */ }
138
+ }
139
+
140
+ // Hide sticky tips/chat that paint over section shots (not page content).
141
+ for (const el of document.querySelectorAll(
142
+ '[class*=did-you i], [class*=cookie i], [id*=cookie i], .bc-widget, [class*=chat-widget i], [class*=intercom i]'
143
+ )) {
144
+ try {
145
+ const cs = getComputedStyle(el);
146
+ if (cs.position === 'fixed' || cs.position === 'sticky') {
147
+ el.style.setProperty('display', 'none', 'important');
148
+ }
149
+ } catch (e) { /* ignore */ }
150
+ }
151
+ });
152
+
153
+ // Wait for images that already have a layout box.
154
+ await page.evaluate(async () => {
155
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
156
+ const deadline = Date.now() + 8000;
157
+ while (Date.now() < deadline) {
158
+ const pending = [...document.images].filter((img) => {
159
+ if (!img.src || img.src.startsWith('data:')) return false;
160
+ const r = img.getBoundingClientRect();
161
+ if (r.width < 2 || r.height < 2) return false;
162
+ return !(img.complete && img.naturalWidth > 0);
163
+ });
164
+ if (!pending.length) break;
165
+ await sleep(150);
166
+ }
167
+ if (document.fonts?.ready) await document.fonts.ready;
168
+ }).catch(() => {});
169
+
170
+ await page.waitForTimeout(200);
171
+ }
172
+
95
173
  /**
96
174
  * Screenshot a section by clipping from the page (element bounding box).
97
175
  */
98
176
  export async function screenshotSection(page, section) {
99
177
  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
- });
178
+ if (handle) {
179
+ try {
180
+ await handle.evaluate((el) => {
181
+ el.scrollIntoView({ block: 'center', inline: 'nearest' });
182
+ const cs = getComputedStyle(el);
183
+ if (parseFloat(cs.opacity) < 0.5) {
184
+ el.classList.add('in', 'is-inview', 'is-visible', 'visible', 'aos-animate');
185
+ el.style.opacity = '1';
186
+ el.style.transform = 'none';
187
+ el.style.visibility = 'visible';
188
+ }
189
+ for (const img of el.querySelectorAll('img')) {
190
+ img.loading = 'eager';
191
+ const ds = img.getAttribute('data-src') || img.getAttribute('data-lazy-src');
192
+ if (ds && !img.getAttribute('src')) img.setAttribute('src', ds);
193
+ }
194
+ });
195
+ await page.waitForTimeout(120);
196
+ // Wait briefly for in-section images.
197
+ await handle.evaluate(async (el) => {
198
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
199
+ const deadline = Date.now() + 2500;
200
+ while (Date.now() < deadline) {
201
+ const pending = [...el.querySelectorAll('img')].filter(
202
+ (img) => img.src && !(img.complete && img.naturalWidth > 0)
203
+ );
204
+ if (!pending.length) break;
205
+ await sleep(100);
206
+ }
207
+ }).catch(() => {});
208
+ return await handle.screenshot({ type: 'png', timeout: 20000 });
209
+ } catch (e) {
210
+ /* fall through to clip */
211
+ }
112
212
  }
113
- return handle.screenshot({ type: 'png' });
213
+ const { x, viewportTop, width, height } = section.box || {};
214
+ return page.screenshot({
215
+ type: 'png',
216
+ clip: {
217
+ x: Math.max(0, x || 0),
218
+ y: Math.max(0, viewportTop || 0),
219
+ width: Math.max(1, width || 1),
220
+ height: Math.max(1, Math.min(height || 1, 8000))
221
+ },
222
+ timeout: 15000
223
+ });
114
224
  }
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.3';
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
  }