@cyber-dash-tech/revela 0.1.1 → 0.1.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/lib/qa/checks.ts CHANGED
@@ -1,29 +1,30 @@
1
1
  /**
2
2
  * lib/qa/checks.ts
3
3
  *
4
- * Pure geometry-based layout checks — no class-name dependency.
4
+ * Geometry-based layout quality checks — four orthogonal visual dimensions.
5
+ *
6
+ * Dimension 1: Overflow — elements exceed canvas bounds (correctness)
7
+ * Dimension 2: Balance — content centroid & distribution (fill, sparsity)
8
+ * Dimension 3: Symmetry — side-by-side element consistency (height, density)
9
+ * Dimension 4: Rhythm — spacing regularity & internal whitespace
5
10
  *
6
11
  * All checks operate on SlideMetrics produced by measure.ts.
7
- * The checks are designed to be design-system-agnostic: they detect
8
- * structural layout problems regardless of CSS class names or component types.
12
+ * Design-system-agnostic: no CSS class-name assumptions.
9
13
  */
10
14
 
11
15
  import type { SlideMetrics, ElementInfo, Rect } from "./measure"
12
16
  import { CANVAS_W, CANVAS_H } from "./measure"
13
17
 
14
- // ── Types ────────────────────────────────────────────────────────────────────
18
+ // ── Types ─────────────────────────────────────────────────────────────────────
15
19
 
16
20
  export type IssueSeverity = "error" | "warning" | "info"
17
21
 
18
22
  export interface LayoutIssue {
19
- type:
20
- | "underfill" // canvas not filled enough
21
- | "bottom_whitespace" // large gap at bottom of slide
22
- | "asymmetry" // side-by-side elements with large height difference
23
- | "density_imbalance" // side-by-side columns with very different content density
24
- | "overflow" // element exceeds canvas bounds
25
- | "sparse" // very few visible elements
26
- | "card_height_variance" // cards in same row have very different heights
23
+ type: "overflow" | "balance" | "symmetry" | "rhythm"
24
+ /** Sub-category within the dimension */
25
+ sub?: "centroid_offset" | "bottom_gap" | "sparse"
26
+ | "height_mismatch" | "density_mismatch"
27
+ | "gap_variance"
27
28
  severity: IssueSeverity
28
29
  /** Human-readable description for the LLM to act on */
29
30
  detail: string
@@ -46,76 +47,36 @@ export interface QAReport {
46
47
  summary: string
47
48
  }
48
49
 
49
- // ── Slide type registry — single source of truth ─────────────────────────────
50
-
51
- /**
52
- * All valid values for the `data-slide-type` attribute on `<section class="slide">`.
53
- *
54
- * This is the single source of truth consumed by:
55
- * - QA checks (EXEMPT_TYPES below)
56
- * - prompt-builder.ts (injected into SKILL.md via <!-- @slide-types --> placeholder)
57
- */
58
- export const SLIDE_TYPES = [
59
- "cover",
60
- "toc",
61
- "content",
62
- "summary",
63
- "closing",
64
- "divider",
65
- "thank-you",
66
- ] as const
67
-
68
- export type SlideType = (typeof SLIDE_TYPES)[number]
69
-
70
- // ── Thresholds (tunable) ─────────────────────────────────────────────────────
71
-
72
- /**
73
- * Slide types that are intentionally sparse, centred, or structurally
74
- * different from "content" slides. Fill ratio and bottom-whitespace checks
75
- * are skipped for these types.
76
- *
77
- * The AI populates `data-slide-type` on each `<section class="slide">`.
78
- * When the attribute is absent (old HTML), we fall back to geometry.
79
- */
80
- export const EXEMPT_TYPES: ReadonlySet<string> = new Set<SlideType>([
81
- "cover",
82
- "toc",
83
- "closing",
84
- "divider",
85
- "summary",
86
- "thank-you",
87
- ])
50
+ // ── Thresholds ────────────────────────────────────────────────────────────────
88
51
 
89
52
  const T = {
90
- /** Canvas fill ratio below this underfill warning */
91
- FILL_WARN: 0.55,
92
- /** Canvas fill ratio below this → underfill error */
93
- FILL_ERROR: 0.40,
94
- /** Bottom whitespace (px inside 1080-height canvas) above this → warning */
95
- BOTTOM_WS_WARN: 200,
96
- /** Bottom whitespace above this error */
97
- BOTTOM_WS_ERROR: 350,
98
- /** Height asymmetry ratio (shorter/taller) below this → warning */
99
- ASYM_WARN: 0.70,
100
- /** Height asymmetry ratio below this → error */
101
- ASYM_ERROR: 0.50,
102
- /** Content density ratio (fewer/more leaf elements) for side-by-side columns → warning */
103
- DENSITY_WARN: 0.55,
104
- /** Content density ratio below this → error */
105
- DENSITY_ERROR: 0.35,
106
- /** Min horizontal overlap fraction to consider two elements "in the same row" */
107
- ROW_OVERLAP: 0.3,
108
- /** Min width of an element to be considered a "column" (not just an icon) */
109
- COL_MIN_WIDTH: 200,
110
- /** Visible top-level element count below this → sparse */
53
+ // Balance centroid offset (fraction of canvas half-dimension)
54
+ CENTROID_WARN: 0.25,
55
+ CENTROID_ERROR: 0.35,
56
+ // Balance — bottom gap (px)
57
+ BOTTOM_GAP_WARN: 200,
58
+ BOTTOM_GAP_ERROR: 350,
59
+ // Balance sparse: fewer than this many visible top-level elements
111
60
  SPARSE_THRESHOLD: 2,
112
- /** Card height ratio (min/max in same row) below this → variance warning */
113
- CARD_VAR_WARN: 0.65,
61
+ // Symmetry min/max ratio for height, content-height, leaf count
62
+ SYM_WARN: 0.70,
63
+ SYM_ERROR: 0.50,
64
+ // Symmetry — min element width to be considered a layout column
65
+ COL_MIN_WIDTH: 200,
66
+ // Symmetry — min vertical overlap fraction to consider elements "in the same row"
67
+ ROW_OVERLAP: 0.30,
68
+ // Rhythm — gap variance: coefficient of variation threshold
69
+ GAP_CV_WARN: 0.60,
70
+ GAP_CV_ERROR: 1.00,
71
+ // Rhythm — min mean gap (px) to bother checking variance
72
+ GAP_MIN_MEAN: 10,
73
+ // Rhythm — min children count to check gap variance
74
+ GAP_MIN_CHILDREN: 3,
114
75
  }
115
76
 
116
- // ── Geometry helpers ─────────────────────────────────────────────────────────
77
+ // ── Geometry helpers ──────────────────────────────────────────────────────────
117
78
 
118
- /** Vertical overlap between two rects [0..1] relative to the shorter one. */
79
+ /** Vertical overlap [0..1] relative to the shorter element. */
119
80
  function verticalOverlap(a: Rect, b: Rect): number {
120
81
  const overlapTop = Math.max(a.top, b.top)
121
82
  const overlapBot = Math.min(a.bottom, b.bottom)
@@ -124,7 +85,7 @@ function verticalOverlap(a: Rect, b: Rect): number {
124
85
  return shorter > 0 ? overlap / shorter : 0
125
86
  }
126
87
 
127
- /** Horizontal overlap [0..1] relative to the shorter width. */
88
+ /** Horizontal overlap [0..1] relative to the shorter element. */
128
89
  function horizontalOverlap(a: Rect, b: Rect): number {
129
90
  const ol = Math.max(a.left, b.left)
130
91
  const or = Math.min(a.right, b.right)
@@ -134,13 +95,11 @@ function horizontalOverlap(a: Rect, b: Rect): number {
134
95
  }
135
96
 
136
97
  /**
137
- * Group a list of elements into "rows": elements whose vertical centres are
138
- * close enough that they appear side-by-side.
139
- *
140
- * Returns an array of rows; each row is an array of ElementInfo sorted left→right.
98
+ * Group elements into rows: elements with significant vertical overlap are
99
+ * considered side-by-side. Each row is sorted left→right.
100
+ * Only elements wide enough to be layout columns are considered.
141
101
  */
142
102
  function groupIntoRows(elements: ElementInfo[]): ElementInfo[][] {
143
- // Only consider elements wide enough to be layout columns
144
103
  const candidates = elements.filter(
145
104
  (e) => e.visible && e.rect.width >= T.COL_MIN_WIDTH
146
105
  )
@@ -156,7 +115,6 @@ function groupIntoRows(elements: ElementInfo[]): ElementInfo[][] {
156
115
 
157
116
  for (let j = i + 1; j < candidates.length; j++) {
158
117
  if (assigned.has(j)) continue
159
- // Two elements are in the same row if they have significant vertical overlap
160
118
  if (verticalOverlap(candidates[i].rect, candidates[j].rect) >= T.ROW_OVERLAP) {
161
119
  row.push(candidates[j])
162
120
  assigned.add(j)
@@ -171,362 +129,411 @@ function groupIntoRows(elements: ElementInfo[]): ElementInfo[][] {
171
129
  return rows
172
130
  }
173
131
 
174
- // ── Individual checks ────────────────────────────────────────────────────────
132
+ /** Count all leaf (no-child) descendants. */
133
+ /**
134
+ * Sum of bounding-box areas of all visible leaf descendants.
135
+ * More accurate than leaf count for density comparisons — charts and large
136
+ * containers contribute proportionally to their visual footprint.
137
+ */
138
+ function leafArea(el: ElementInfo): number {
139
+ if (el.children.length === 0) {
140
+ return el.visible ? el.rect.width * el.rect.height : 0
141
+ }
142
+ return el.children.reduce((sum, ch) => sum + leafArea(ch), 0)
143
+ }
144
+
145
+ /**
146
+ * Actual content height of an element: from topmost child top to bottommost
147
+ * child bottom. Ignores CSS stretch padding on the container itself.
148
+ */
149
+ function contentHeight(el: ElementInfo): number {
150
+ if (el.children.length === 0) return el.rect.height
151
+ let top = Infinity, bottom = -Infinity
152
+ function walk(list: ElementInfo[]) {
153
+ for (const ch of list) {
154
+ if (!ch.visible) continue
155
+ top = Math.min(top, ch.rect.top)
156
+ bottom = Math.max(bottom, ch.rect.bottom)
157
+ if (ch.children.length > 0) walk(ch.children)
158
+ }
159
+ }
160
+ walk(el.children)
161
+ return top === Infinity ? el.rect.height : bottom - top
162
+ }
163
+
164
+ /** Collect all visible leaf elements recursively. */
165
+ function collectLeaves(el: ElementInfo): ElementInfo[] {
166
+ if (el.children.length === 0) return el.visible ? [el] : []
167
+ return el.children.flatMap(collectLeaves)
168
+ }
169
+
170
+ // ── Dimension 1: Overflow ─────────────────────────────────────────────────────
171
+
172
+ /**
173
+ * Check 1: Overflow — elements extending beyond canvas boundaries.
174
+ * Hard correctness check; applies to all slide types.
175
+ */
176
+ function checkOverflow(metrics: SlideMetrics): LayoutIssue[] {
177
+ const issues: LayoutIssue[] = []
178
+ const { canvasRect } = metrics
179
+ const tol = 2 // 2px sub-pixel tolerance
180
+
181
+ function walk(els: ElementInfo[]) {
182
+ for (const el of els) {
183
+ if (!el.visible) continue
184
+ const r = el.rect
185
+ if (
186
+ r.left < canvasRect.left - tol ||
187
+ r.top < canvasRect.top - tol ||
188
+ r.right > canvasRect.right + tol ||
189
+ r.bottom > canvasRect.bottom + tol
190
+ ) {
191
+ issues.push({
192
+ type: "overflow",
193
+ severity: "error",
194
+ detail: `Element \`${el.selector}\` overflows the canvas: rect(${Math.round(r.left)}, ${Math.round(r.top)}, ${Math.round(r.right)}, ${Math.round(r.bottom)}) vs canvas(${Math.round(canvasRect.left)}, ${Math.round(canvasRect.top)}, ${Math.round(canvasRect.right)}, ${Math.round(canvasRect.bottom)})`,
195
+ })
196
+ }
197
+ if (el.children.length > 0) walk(el.children)
198
+ }
199
+ }
200
+
201
+ walk(metrics.elements)
202
+ return issues
203
+ }
204
+
205
+ // ── Dimension 2: Balance ──────────────────────────────────────────────────────
175
206
 
176
- /** Check 1: Canvas fill ratio (content area / total canvas area) */
177
- function checkFill(metrics: SlideMetrics): LayoutIssue[] {
207
+ /**
208
+ * Check 2: Balance — content centroid, bottom gap, sparsity.
209
+ * Only runs when `metrics.slideQa` is true (content-heavy layouts).
210
+ * Structural/sparse slides (cover, closing, etc.) set slide-qa="false" and are skipped.
211
+ *
212
+ * Sub-checks:
213
+ * - centroid_offset: weighted centroid deviates too far from canvas centre
214
+ * - bottom_gap: large empty gap at bottom of slide
215
+ * - sparse: too few visible top-level elements
216
+ */
217
+ function checkBalance(metrics: SlideMetrics): LayoutIssue[] {
178
218
  const issues: LayoutIssue[] = []
179
- const { contentRect, canvasRect, elements } = metrics
219
+ const { elements, contentRect, canvasRect } = metrics
180
220
 
221
+ // Guard: no content at all
181
222
  if (contentRect.width === 0 || contentRect.height === 0) {
182
223
  issues.push({
183
- type: "sparse",
224
+ type: "balance",
225
+ sub: "sparse",
184
226
  severity: "error",
185
227
  detail: "Slide appears to have no visible content.",
186
228
  })
187
229
  return issues
188
230
  }
189
231
 
190
- // If the slide has an explicit type, use it — no geometry guessing needed
191
- if (metrics.slideType && EXEMPT_TYPES.has(metrics.slideType)) return []
192
-
193
- // Fallback for HTML without data-slide-type: detect cover/title slides by
194
- // geometry (single dominant column aligned to the centre of the canvas).
195
- const isCoverLike = (() => {
196
- const contentCenterX = (contentRect.left + contentRect.right) / 2
197
- const canvasCenterX = canvasRect.width / 2
198
- const centerOffset = Math.abs(contentCenterX - canvasCenterX) / canvasRect.width
199
- const maxElemWidth = Math.max(...elements.map((e) => e.rect.width))
200
- const isCentered = centerOffset < 0.15 && maxElemWidth < canvasRect.width * 0.65
201
- return isCentered
202
- })()
203
-
204
- if (isCoverLike) return [] // Skip fill check for cover/title slides (geometry fallback)
232
+ // Skip balance checks for structural/sparse slides (slide-qa="false")
233
+ if (!metrics.slideQa) return []
205
234
 
206
- // Compute content area relative to canvas dimensions
207
- const canvasArea = CANVAS_W * CANVAS_H
208
- const contentArea = contentRect.width * contentRect.height
209
- const fillRatio = contentArea / canvasArea
210
-
211
- if (fillRatio < T.FILL_ERROR) {
212
- issues.push({
213
- type: "underfill",
214
- severity: "error",
215
- detail: `Canvas fill ratio is very low (${Math.round(fillRatio * 100)}%). Content only occupies ${Math.round(contentRect.width)}×${Math.round(contentRect.height)}px of the 1920×1080 canvas.`,
216
- data: { fillRatio: Math.round(fillRatio * 100) },
217
- })
218
- } else if (fillRatio < T.FILL_WARN) {
235
+ // ── Sub-check: sparse ────────────────────────────────────────────────────
236
+ const visibleCount = elements.filter((e) => e.visible).length
237
+ if (visibleCount < T.SPARSE_THRESHOLD) {
219
238
  issues.push({
220
- type: "underfill",
239
+ type: "balance",
240
+ sub: "sparse",
221
241
  severity: "warning",
222
- detail: `Canvas fill ratio is low (${Math.round(fillRatio * 100)}%). Content area: ${Math.round(contentRect.width)}×${Math.round(contentRect.height)}px. Consider expanding content or reducing whitespace.`,
223
- data: { fillRatio: Math.round(fillRatio * 100) },
242
+ detail: `Slide has only ${visibleCount} visible top-level element(s). This may result in a lot of empty space.`,
243
+ data: { visibleCount },
224
244
  })
225
245
  }
226
246
 
227
- return issues
228
- }
229
-
230
- /** Check 2: Bottom whitespace — gap between last content element and canvas bottom */
231
- function checkBottomWhitespace(metrics: SlideMetrics): LayoutIssue[] {
232
- const issues: LayoutIssue[] = []
233
- const { contentRect, canvasRect, elements } = metrics
234
-
235
- if (contentRect.height === 0) return issues
247
+ // ── Sub-check: centroid offset ───────────────────────────────────────────
248
+ // Collect all leaf elements and compute area-weighted centroid
249
+ const leaves = elements.flatMap(collectLeaves)
250
+ if (leaves.length > 0) {
251
+ let totalArea = 0
252
+ let weightedX = 0
253
+ let weightedY = 0
254
+
255
+ for (const leaf of leaves) {
256
+ const area = leaf.rect.width * leaf.rect.height
257
+ const cx = (leaf.rect.left + leaf.rect.right) / 2
258
+ const cy = (leaf.rect.top + leaf.rect.bottom) / 2
259
+ totalArea += area
260
+ weightedX += cx * area
261
+ weightedY += cy * area
262
+ }
236
263
 
237
- // If the slide has an explicit type, use it — no geometry guessing needed
238
- if (metrics.slideType && EXEMPT_TYPES.has(metrics.slideType)) return []
264
+ if (totalArea > 0) {
265
+ const centroidX = weightedX / totalArea
266
+ const centroidY = weightedY / totalArea
239
267
 
240
- // Fallback geometry: skip cover/title slides (intentionally centred with bottom space)
241
- const isCoverLike = (() => {
242
- const contentCenterX = (contentRect.left + contentRect.right) / 2
243
- const canvasCenterX = canvasRect.width / 2
244
- const centerOffset = Math.abs(contentCenterX - canvasCenterX) / canvasRect.width
245
- const maxElemWidth = Math.max(...elements.map((e) => e.rect.width))
246
- return centerOffset < 0.15 && maxElemWidth < canvasRect.width * 0.65
247
- })()
268
+ // Normalise offset by half-canvas dimensions so both axes are comparable
269
+ const offsetX = Math.abs(centroidX - canvasRect.width / 2) / (canvasRect.width / 2)
270
+ const offsetY = Math.abs(centroidY - canvasRect.height / 2) / (canvasRect.height / 2)
271
+ const offset = Math.max(offsetX, offsetY)
248
272
 
249
- if (isCoverLike) return []
273
+ if (offset > T.CENTROID_ERROR) {
274
+ issues.push({
275
+ type: "balance",
276
+ sub: "centroid_offset",
277
+ severity: "error",
278
+ detail: `Content centroid is far off-centre (${Math.round(offset * 100)}% offset). Content is concentrated in one area of the slide — consider distributing it more evenly.`,
279
+ data: { offsetPct: Math.round(offset * 100), centroidX: Math.round(centroidX), centroidY: Math.round(centroidY) },
280
+ })
281
+ } else if (offset > T.CENTROID_WARN) {
282
+ issues.push({
283
+ type: "balance",
284
+ sub: "centroid_offset",
285
+ severity: "warning",
286
+ detail: `Content centroid is slightly off-centre (${Math.round(offset * 100)}% offset). Consider balancing the visual weight across the slide.`,
287
+ data: { offsetPct: Math.round(offset * 100), centroidX: Math.round(centroidX), centroidY: Math.round(centroidY) },
288
+ })
289
+ }
290
+ }
291
+ }
250
292
 
251
- // contentRect is in canvas-relative coords; canvasRect.bottom is the canvas height
252
- const gap = canvasRect.bottom - contentRect.bottom
293
+ // ── Sub-check: bottom gap ────────────────────────────────────────────────
294
+ const bottomGap = canvasRect.bottom - contentRect.bottom
253
295
 
254
- if (gap > T.BOTTOM_WS_ERROR) {
296
+ if (bottomGap > T.BOTTOM_GAP_ERROR) {
255
297
  issues.push({
256
- type: "bottom_whitespace",
298
+ type: "balance",
299
+ sub: "bottom_gap",
257
300
  severity: "error",
258
- detail: `${Math.round(gap)}px of empty space at the bottom of the slide (canvas bottom: ${Math.round(canvasRect.bottom)}px, last content: ${Math.round(contentRect.bottom)}px). The slide looks notably under-filled.`,
259
- data: { gapPx: Math.round(gap) },
301
+ detail: `${Math.round(bottomGap)}px of empty space at the bottom of the slide (last content at ${Math.round(contentRect.bottom)}px, canvas bottom at ${Math.round(canvasRect.bottom)}px). The slide looks notably under-filled.`,
302
+ data: { gapPx: Math.round(bottomGap) },
260
303
  })
261
- } else if (gap > T.BOTTOM_WS_WARN) {
304
+ } else if (bottomGap > T.BOTTOM_GAP_WARN) {
262
305
  issues.push({
263
- type: "bottom_whitespace",
306
+ type: "balance",
307
+ sub: "bottom_gap",
264
308
  severity: "warning",
265
- detail: `${Math.round(gap)}px empty gap at the bottom of the slide. Consider adding content, increasing padding, or using flex-grow to distribute space.`,
266
- data: { gapPx: Math.round(gap) },
309
+ detail: `${Math.round(bottomGap)}px empty gap at the bottom of the slide. Consider adding content, increasing padding, or using flex-grow to distribute vertical space.`,
310
+ data: { gapPx: Math.round(bottomGap) },
267
311
  })
268
312
  }
269
313
 
270
314
  return issues
271
315
  }
272
316
 
273
- /** Check 3: Overflow — elements extending beyond the canvas boundaries */
274
- function checkOverflow(metrics: SlideMetrics): LayoutIssue[] {
275
- const issues: LayoutIssue[] = []
276
- const { canvasRect } = metrics
317
+ // ── Dimension 3: Symmetry ─────────────────────────────────────────────────────
277
318
 
278
- function walkElements(els: ElementInfo[]) {
279
- for (const el of els) {
280
- if (!el.visible) continue
281
- const r = el.rect
282
- // Allow a small tolerance (2px) for sub-pixel rendering
283
- const tol = 2
284
- if (
285
- r.left < canvasRect.left - tol ||
286
- r.top < canvasRect.top - tol ||
287
- r.right > canvasRect.right + tol ||
288
- r.bottom > canvasRect.bottom + tol
289
- ) {
290
- issues.push({
291
- type: "overflow",
292
- severity: "error",
293
- detail: `Element \`${el.selector}\` overflows the canvas: rect(${Math.round(r.left)}, ${Math.round(r.top)}, ${Math.round(r.right)}, ${Math.round(r.bottom)}) vs canvas(${Math.round(canvasRect.left)}, ${Math.round(canvasRect.top)}, ${Math.round(canvasRect.right)}, ${Math.round(canvasRect.bottom)})`,
294
- })
295
- }
296
- if (el.children.length > 0) walkElements(el.children)
297
- }
298
- }
319
+ /**
320
+ * Check 3: Symmetry — side-by-side elements should be visually balanced.
321
+ *
322
+ * For each row of side-by-side columns, checks three sub-metrics and reports
323
+ * the most severe finding:
324
+ * - height_mismatch: rendered height ratio
325
+ * - density_mismatch: actual content height ratio (strips CSS stretch)
326
+ * - leaf count ratio: proxy for content density imbalance
327
+ *
328
+ * Applies at top-level and one level deep (nested rows inside columns).
329
+ */
330
+ function checkSymmetry(metrics: SlideMetrics): LayoutIssue[] {
331
+ const issues: LayoutIssue[] = []
299
332
 
300
- walkElements(metrics.elements)
301
- return issues
302
- }
333
+ function checkRow(row: ElementInfo[], parentSelector?: string) {
334
+ if (row.length < 2) return
303
335
 
304
- /** Check 4: Asymmetry — side-by-side elements with large height difference */
305
- function checkAsymmetry(metrics: SlideMetrics): LayoutIssue[] {
306
- const issues: LayoutIssue[] = []
336
+ const heights = row.map((e) => e.rect.height)
337
+ const contHeights = row.map(contentHeight)
338
+ const areas = row.map(leafArea)
307
339
 
308
- // Check at the top level of .slide-canvas children
309
- const rows = groupIntoRows(metrics.elements)
340
+ const minH = Math.min(...heights), maxH = Math.max(...heights)
341
+ const minCH = Math.min(...contHeights), maxCH = Math.max(...contHeights)
342
+ const minA = Math.min(...areas), maxA = Math.max(...areas)
310
343
 
311
- for (const row of rows) {
312
- if (row.length < 2) continue
344
+ const hRatio = maxH > 0 ? minH / maxH : 1
345
+ const chRatio = maxCH > 50 ? minCH / maxCH : 1 // skip tiny containers
346
+ const aRatio = maxA > 0 ? minA / maxA : 1
313
347
 
314
- const heights = row.map((e) => e.rect.height)
315
- const minH = Math.min(...heights)
316
- const maxH = Math.max(...heights)
348
+ // Height mismatch (rendered boxes)
349
+ if (hRatio < T.SYM_ERROR) {
350
+ issues.push({
351
+ type: "symmetry",
352
+ sub: "height_mismatch",
353
+ severity: "error",
354
+ detail: `${parentSelector ? `Columns inside \`${parentSelector}\`` : "Side-by-side columns"} have a severe height mismatch: [${heights.map((h) => Math.round(h) + "px").join(" vs ")}] (ratio ${Math.round(hRatio * 100)}%). The shorter column looks nearly empty.`,
355
+ data: { ratio: Math.round(hRatio * 100), minH: Math.round(minH), maxH: Math.round(maxH) },
356
+ })
357
+ } else if (hRatio < T.SYM_WARN) {
358
+ issues.push({
359
+ type: "symmetry",
360
+ sub: "height_mismatch",
361
+ severity: "warning",
362
+ detail: `${parentSelector ? `Columns inside \`${parentSelector}\`` : "Side-by-side columns"} have unequal heights: [${heights.map((h) => Math.round(h) + "px").join(" vs ")}] (ratio ${Math.round(hRatio * 100)}%). Consider equalising content density.`,
363
+ data: { ratio: Math.round(hRatio * 100), minH: Math.round(minH), maxH: Math.round(maxH) },
364
+ })
365
+ }
317
366
 
318
- if (maxH === 0) continue
319
- const ratio = minH / maxH
367
+ // Density mismatch (actual content height, ignores CSS stretch)
368
+ if (maxCH > 50 && chRatio < T.SYM_ERROR) {
369
+ issues.push({
370
+ type: "symmetry",
371
+ sub: "density_mismatch",
372
+ severity: "error",
373
+ detail: `${parentSelector ? `Columns inside \`${parentSelector}\`` : "Side-by-side columns"} have very different actual content heights: [${contHeights.map((h) => Math.round(h) + "px").join(" vs ")}] (ratio ${Math.round(chRatio * 100)}%). CSS stretch hides this — the sparser column will have large internal whitespace.`,
374
+ data: { ratio: Math.round(chRatio * 100), contentHeights: contHeights.map(Math.round).join(",") },
375
+ })
376
+ } else if (maxCH > 50 && chRatio < T.SYM_WARN) {
377
+ issues.push({
378
+ type: "symmetry",
379
+ sub: "density_mismatch",
380
+ severity: "warning",
381
+ detail: `${parentSelector ? `Columns inside \`${parentSelector}\`` : "Side-by-side columns"} have different actual content heights: [${contHeights.map((h) => Math.round(h) + "px").join(" vs ")}] (ratio ${Math.round(chRatio * 100)}%). Consider equalising content density.`,
382
+ data: { ratio: Math.round(chRatio * 100), contentHeights: contHeights.map(Math.round).join(",") },
383
+ })
384
+ }
320
385
 
321
- if (ratio < T.ASYM_ERROR) {
386
+ // Area imbalance (sum of leaf bounding-box areas — robust to chart containers)
387
+ if (maxA > 0 && aRatio < T.SYM_ERROR) {
322
388
  issues.push({
323
- type: "asymmetry",
389
+ type: "symmetry",
390
+ sub: "density_mismatch",
324
391
  severity: "error",
325
- detail: `Side-by-side columns have a severe height mismatch: [${heights.map((h) => Math.round(h) + "px").join(" vs ")}] (ratio ${Math.round(ratio * 100)}%). The shorter column appears nearly empty next to the taller one.`,
326
- data: { ratio: Math.round(ratio * 100), minH: Math.round(minH), maxH: Math.round(maxH) },
392
+ detail: `${parentSelector ? `Columns inside \`${parentSelector}\`` : "Side-by-side columns"} have very unequal content area: [${areas.map((a) => Math.round(a / 1000) + "k").join(" vs ")}]px² (ratio ${Math.round(aRatio * 100)}%). The sparse column may feel nearly empty.`,
393
+ data: { ratio: Math.round(aRatio * 100), areas: areas.map((a) => Math.round(a / 1000)).join(",") },
327
394
  })
328
- } else if (ratio < T.ASYM_WARN) {
395
+ } else if (maxA > 0 && aRatio < T.SYM_WARN) {
329
396
  issues.push({
330
- type: "asymmetry",
397
+ type: "symmetry",
398
+ sub: "density_mismatch",
331
399
  severity: "warning",
332
- detail: `Side-by-side columns have unequal heights: [${heights.map((h) => Math.round(h) + "px").join(" vs ")}] (ratio ${Math.round(ratio * 100)}%). Consider equalising content density or using align-items: stretch with matching visual weight.`,
333
- data: { ratio: Math.round(ratio * 100), minH: Math.round(minH), maxH: Math.round(maxH) },
400
+ detail: `${parentSelector ? `Columns inside \`${parentSelector}\`` : "Side-by-side columns"} have unequal content area: [${areas.map((a) => Math.round(a / 1000) + "k").join(" vs ")}]px² (ratio ${Math.round(aRatio * 100)}%). Consider balancing content between columns.`,
401
+ data: { ratio: Math.round(aRatio * 100), areas: areas.map((a) => Math.round(a / 1000)).join(",") },
334
402
  })
335
403
  }
404
+ }
336
405
 
337
- // Also recursively check inside each column for nested rows
406
+ // Top-level rows (elements that are side-by-side at the top level)
407
+ const topRows = groupIntoRows(metrics.elements)
408
+ for (const row of topRows) {
409
+ checkRow(row)
410
+ // One level deep: check nested rows inside each column
338
411
  for (const col of row) {
339
412
  if (col.children.length >= 2) {
340
413
  const nestedRows = groupIntoRows(col.children)
341
414
  for (const nestedRow of nestedRows) {
342
- if (nestedRow.length < 2) continue
343
- const nh = nestedRow.map((e) => e.rect.height)
344
- const nMin = Math.min(...nh)
345
- const nMax = Math.max(...nh)
346
- if (nMax === 0) continue
347
- const nRatio = nMin / nMax
348
- if (nRatio < T.CARD_VAR_WARN) {
349
- issues.push({
350
- type: "card_height_variance",
351
- severity: "warning",
352
- detail: `Nested row inside \`${col.selector}\` has card height variance: [${nh.map((h) => Math.round(h) + "px").join(", ")}] (min/max ratio ${Math.round(nRatio * 100)}%). Cards in the same row should be visually balanced.`,
353
- data: { ratio: Math.round(nRatio * 100) },
354
- })
355
- }
415
+ checkRow(nestedRow, col.selector)
356
416
  }
357
417
  }
358
418
  }
359
419
  }
360
420
 
361
- return issues
362
- }
363
-
364
- /** Check 5: Sparse slide very few top-level elements */
365
- function checkSparse(metrics: SlideMetrics): LayoutIssue[] {
366
- const issues: LayoutIssue[] = []
367
-
368
- // Exempt structural slides — they are intentionally minimal
369
- if (metrics.slideType && EXEMPT_TYPES.has(metrics.slideType)) return []
370
-
371
- const visibleCount = metrics.elements.filter((e) => e.visible).length
372
-
373
- if (visibleCount < T.SPARSE_THRESHOLD) {
374
- issues.push({
375
- type: "sparse",
376
- severity: "warning",
377
- detail: `Slide has only ${visibleCount} visible top-level element(s). This may result in a lot of empty space.`,
378
- data: { visibleCount },
379
- })
421
+ // Also check children of every top-level element that is NOT itself part of a row.
422
+ // This catches containers like .two-col whose children are side-by-side columns,
423
+ // even when the container itself is stacked vertically (no top-level sibling to pair with).
424
+ const inTopRow = new Set(topRows.flat().map((e) => e.selector))
425
+ for (const el of metrics.elements) {
426
+ if (!el.visible || inTopRow.has(el.selector)) continue
427
+ if (el.children.length >= 2) {
428
+ const childRows = groupIntoRows(el.children)
429
+ for (const row of childRows) {
430
+ checkRow(row, el.selector)
431
+ }
432
+ }
380
433
  }
381
434
 
382
435
  return issues
383
436
  }
384
437
 
385
- /**
386
- * Count all leaf (no-child) descendants of an ElementInfo tree.
387
- */
388
- function countLeaves(el: ElementInfo): number {
389
- if (el.children.length === 0) return 1
390
- return el.children.reduce((sum, ch) => sum + countLeaves(ch), 0)
391
- }
438
+ // ── Dimension 4: Rhythm ───────────────────────────────────────────────────────
392
439
 
393
440
  /**
394
- * Compute the actual content height of an element: from its topmost child's top
395
- * to its bottommost child's bottom (ignoring CSS stretch padding).
441
+ * Coefficient of variation: stddev / mean. Returns 0 if mean is 0.
396
442
  */
397
- function contentHeight(el: ElementInfo): number {
398
- if (el.children.length === 0) return el.rect.height
399
- let top = Infinity, bottom = -Infinity
400
- function walk(list: ElementInfo[]) {
401
- for (const ch of list) {
402
- if (!ch.visible) continue
403
- top = Math.min(top, ch.rect.top)
404
- bottom = Math.max(bottom, ch.rect.bottom)
405
- if (ch.children.length > 0) walk(ch.children)
406
- }
407
- }
408
- walk(el.children)
409
- return top === Infinity ? el.rect.height : bottom - top
443
+ function cv(values: number[]): number {
444
+ if (values.length < 2) return 0
445
+ const mean = values.reduce((s, v) => s + v, 0) / values.length
446
+ if (mean === 0) return 0
447
+ const variance = values.reduce((s, v) => s + (v - mean) ** 2, 0) / values.length
448
+ return Math.sqrt(variance) / mean
410
449
  }
411
450
 
412
451
  /**
413
- * Check 6: Content density imbalance in side-by-side columns.
452
+ * Check 4: Rhythm spacing regularity between stacked siblings.
414
453
  *
415
- * CSS `align-items: stretch` makes all columns the same height visually, so
416
- * a pure height asymmetry check won't catch imbalanced content density.
417
- * This check counts leaf elements and actual content height in each column.
454
+ * Sub-checks:
455
+ * - gap_variance: vertical gaps between stacked siblings are uneven
418
456
  */
419
- function checkDensityImbalance(metrics: SlideMetrics): LayoutIssue[] {
457
+ function checkRhythm(metrics: SlideMetrics): LayoutIssue[] {
420
458
  const issues: LayoutIssue[] = []
421
459
 
422
- // Find rows at the top level
423
- const rows = groupIntoRows(metrics.elements)
460
+ // Skip rhythm checks for structural/sparse slides (slide-qa="false")
461
+ if (!metrics.slideQa) return []
424
462
 
425
- for (const row of rows) {
426
- if (row.length < 2) continue
463
+ function checkContainer(els: ElementInfo[], containerSelector?: string) {
464
+ if (els.length < 2) return
427
465
 
428
- const leafCounts = row.map(countLeaves)
429
- const contentHeights = row.map(contentHeight)
466
+ // Identify vertically-stacked children (high horizontal overlap, low vertical overlap)
467
+ const visibleEls = els.filter((e) => e.visible).sort((a, b) => a.rect.top - b.rect.top)
468
+ if (visibleEls.length < T.GAP_MIN_CHILDREN) return
430
469
 
431
- // Check leaf count ratio
432
- const minLeaves = Math.min(...leafCounts)
433
- const maxLeaves = Math.max(...leafCounts)
434
- if (maxLeaves > 0) {
435
- const ratio = minLeaves / maxLeaves
436
- if (ratio < T.DENSITY_ERROR) {
437
- issues.push({
438
- type: "density_imbalance",
439
- severity: "error",
440
- detail: `Side-by-side columns have very unequal content density: [${leafCounts.join(" vs ")}] elements. The sparse column may feel nearly empty. Add more content to the lighter column or reduce content in the heavier one.`,
441
- data: { ratio: Math.round(ratio * 100), leafCounts: leafCounts.join(",") },
442
- })
443
- } else if (ratio < T.DENSITY_WARN) {
444
- issues.push({
445
- type: "density_imbalance",
446
- severity: "warning",
447
- detail: `Side-by-side columns have unequal content density: [${leafCounts.join(" vs ")}] elements. Consider balancing content between columns.`,
448
- data: { ratio: Math.round(ratio * 100), leafCounts: leafCounts.join(",") },
449
- })
450
- }
470
+ // Check if elements are mostly stacked (not side-by-side)
471
+ // Heuristic: average horizontal overlap > 0.5 among adjacent pairs
472
+ let hOverlapSum = 0
473
+ for (let i = 0; i < visibleEls.length - 1; i++) {
474
+ hOverlapSum += horizontalOverlap(visibleEls[i].rect, visibleEls[i + 1].rect)
475
+ }
476
+ const avgHOverlap = hOverlapSum / (visibleEls.length - 1)
477
+ if (avgHOverlap < 0.5) return // Side-by-side layout, not stacked
478
+
479
+ // Compute gaps between adjacent stacked elements
480
+ const gaps: number[] = []
481
+ for (let i = 0; i < visibleEls.length - 1; i++) {
482
+ const gap = visibleEls[i + 1].rect.top - visibleEls[i].rect.bottom
483
+ if (gap >= 0) gaps.push(gap) // negative gap = overlapping, skip
451
484
  }
485
+ if (gaps.length < 2) return
452
486
 
453
- // Check actual content height ratio (ignoring CSS stretch)
454
- const minCH = Math.min(...contentHeights)
455
- const maxCH = Math.max(...contentHeights)
456
- if (maxCH > 50) { // only check if columns have meaningful content
457
- const chRatio = minCH / maxCH
458
- if (chRatio < T.ASYM_ERROR) {
459
- issues.push({
460
- type: "density_imbalance",
461
- severity: "error",
462
- detail: `Side-by-side columns have very different actual content heights: [${contentHeights.map((h) => Math.round(h) + "px").join(" vs ")}] (CSS stretch hides this — ratio ${Math.round(chRatio * 100)}%). The column with less content will have large internal whitespace.`,
463
- data: { ratio: Math.round(chRatio * 100), contentHeights: contentHeights.map(Math.round).join(",") },
464
- })
465
- } else if (chRatio < T.ASYM_WARN) {
466
- issues.push({
467
- type: "density_imbalance",
468
- severity: "warning",
469
- detail: `Side-by-side columns have different actual content heights: [${contentHeights.map((h) => Math.round(h) + "px").join(" vs ")}] (ratio ${Math.round(chRatio * 100)}%). Consider equalising content density.`,
470
- data: { ratio: Math.round(chRatio * 100), contentHeights: contentHeights.map(Math.round).join(",") },
471
- })
472
- }
487
+ const meanGap = gaps.reduce((s, g) => s + g, 0) / gaps.length
488
+ if (meanGap < T.GAP_MIN_MEAN) return
489
+
490
+ const gapCV = cv(gaps)
491
+ const label = containerSelector ? `inside \`${containerSelector}\`` : "in slide"
492
+
493
+ if (gapCV > T.GAP_CV_ERROR) {
494
+ issues.push({
495
+ type: "rhythm",
496
+ sub: "gap_variance",
497
+ severity: "error",
498
+ detail: `Gaps between stacked elements ${label} are highly irregular (CV=${Math.round(gapCV * 100)}%, gaps=[${gaps.map(Math.round).join(", ")}]px). Use consistent gap or padding values.`,
499
+ data: { cv: Math.round(gapCV * 100), gaps: gaps.map(Math.round).join(",") },
500
+ })
501
+ } else if (gapCV > T.GAP_CV_WARN) {
502
+ issues.push({
503
+ type: "rhythm",
504
+ sub: "gap_variance",
505
+ severity: "warning",
506
+ detail: `Gaps between stacked elements ${label} are uneven (CV=${Math.round(gapCV * 100)}%, gaps=[${gaps.map(Math.round).join(", ")}]px). Consider using a consistent gap or flex spacing.`,
507
+ data: { cv: Math.round(gapCV * 100), gaps: gaps.map(Math.round).join(",") },
508
+ })
473
509
  }
474
510
  }
475
511
 
476
- // Also check one level deep (containers that hold two-column layouts)
512
+ // Check at top-level and one level deep
513
+ checkContainer(metrics.elements)
477
514
  for (const el of metrics.elements) {
478
- if (el.children.length >= 2) {
479
- const nestedRows = groupIntoRows(el.children)
480
- for (const nestedRow of nestedRows) {
481
- if (nestedRow.length < 2) continue
482
- const nLeaves = nestedRow.map(countLeaves)
483
- const nCH = nestedRow.map(contentHeight)
484
- const minNL = Math.min(...nLeaves)
485
- const maxNL = Math.max(...nLeaves)
486
- const minNCH = Math.min(...nCH)
487
- const maxNCH = Math.max(...nCH)
488
-
489
- if (maxNL > 0 && minNL / maxNL < T.DENSITY_WARN) {
490
- const ratio = minNL / maxNL
491
- issues.push({
492
- type: "density_imbalance",
493
- severity: ratio < T.DENSITY_ERROR ? "error" : "warning",
494
- detail: `Nested side-by-side columns inside \`${el.selector}\` have unequal content: [${nLeaves.join(" vs ")}] elements (ratio ${Math.round(ratio * 100)}%).`,
495
- data: { ratio: Math.round(ratio * 100) },
496
- })
497
- }
498
- if (maxNCH > 50 && minNCH / maxNCH < T.ASYM_WARN) {
499
- const chRatio = minNCH / maxNCH
500
- issues.push({
501
- type: "density_imbalance",
502
- severity: chRatio < T.ASYM_ERROR ? "error" : "warning",
503
- detail: `Nested columns inside \`${el.selector}\` have different actual content heights: [${nCH.map((h) => Math.round(h) + "px").join(" vs ")}] (ratio ${Math.round(chRatio * 100)}%).`,
504
- data: { ratio: Math.round(chRatio * 100) },
505
- })
506
- }
507
- }
515
+ if (el.children.length > 0) {
516
+ checkContainer(el.children, el.selector)
508
517
  }
509
518
  }
510
519
 
511
520
  return issues
512
521
  }
513
522
 
514
- // ── Main export ──────────────────────────────────────────────────────────────
523
+ // ── Main export ───────────────────────────────────────────────────────────────
515
524
 
516
525
  /**
517
- * Run all checks on a set of slide metrics and produce a QA report.
526
+ * Run all four dimension checks on a set of slide metrics and produce a QA report.
518
527
  */
519
528
  export function runChecks(filePath: string, allMetrics: SlideMetrics[]): QAReport {
520
529
  const slides: SlideReport[] = []
521
530
 
522
531
  for (const metrics of allMetrics) {
523
532
  const issues: LayoutIssue[] = [
524
- ...checkFill(metrics),
525
- ...checkBottomWhitespace(metrics),
526
533
  ...checkOverflow(metrics),
527
- ...checkAsymmetry(metrics),
528
- ...checkSparse(metrics),
529
- ...checkDensityImbalance(metrics),
534
+ ...checkBalance(metrics),
535
+ ...checkSymmetry(metrics),
536
+ ...checkRhythm(metrics),
530
537
  ]
531
538
 
532
539
  slides.push({ index: metrics.index, title: metrics.title, issues })
@@ -550,7 +557,7 @@ export function runChecks(filePath: string, allMetrics: SlideMetrics[]): QARepor
550
557
  return { file: filePath, slides, totalIssues, errorCount, warningCount, summary }
551
558
  }
552
559
 
553
- // ── Report formatter ─────────────────────────────────────────────────────────
560
+ // ── Report formatter ──────────────────────────────────────────────────────────
554
561
 
555
562
  /**
556
563
  * Format a QAReport into a markdown string suitable for the LLM to read.
@@ -573,7 +580,8 @@ export function formatReport(report: QAReport): string {
573
580
  lines.push(`### Slide ${slide.index + 1}: ${slide.title}`)
574
581
  for (const issue of slide.issues) {
575
582
  const icon = issue.severity === "error" ? "🔴" : "🟡"
576
- lines.push(`- ${icon} **${issue.type}**: ${issue.detail}`)
583
+ const label = issue.sub ? `${issue.type}/${issue.sub}` : issue.type
584
+ lines.push(`- ${icon} **${label}**: ${issue.detail}`)
577
585
  }
578
586
  lines.push("")
579
587
  }
@@ -581,13 +589,14 @@ export function formatReport(report: QAReport): string {
581
589
  lines.push(
582
590
  `### Action Required`,
583
591
  ``,
584
- `Please fix the above layout issues in the HTML file. For each issue:`,
585
- `- **underfill / bottom_whitespace**: expand content to fill the slide, use \`flex: 1\` on containers, add more content blocks, or reduce top padding.`,
586
- `- **asymmetry**: ensure side-by-side columns have matching visual weight — equalise item count, use \`align-items: stretch\`, or adjust heights explicitly.`,
587
- `- **density_imbalance**: add more items to the sparse column, reduce items in the dense column, or switch to a single-column layout. CSS stretch hides height differences but not visual emptiness.`,
592
+ `Please fix the above layout issues in the HTML file. For each issue type:`,
588
593
  `- **overflow**: reduce font size, padding, or content amount for the affected element.`,
589
- `- **card_height_variance**: ensure cards in the same row have similar content density, or use \`align-items: stretch\` on the grid.`,
590
- `- **sparse**: add more content components, increase font sizes, or use a layout with fewer columns.`,
594
+ `- **balance/centroid_offset**: redistribute content so the visual weight is centred avoid concentrating everything in one corner or side.`,
595
+ `- **balance/bottom_gap**: expand content to fill the slide, use \`flex: 1\` on containers, add more content blocks, or reduce top padding.`,
596
+ `- **balance/sparse**: add more content components, increase font sizes, or use a layout with fewer columns.`,
597
+ `- **symmetry/height_mismatch**: equalise side-by-side column heights — use \`align-items: stretch\` or match content density.`,
598
+ `- **symmetry/density_mismatch**: balance content between columns — add items to the sparse column or reduce items in the dense one.`,
599
+ `- **rhythm/gap_variance**: use consistent \`gap\` or \`margin\` values between stacked elements instead of mixing sizes.`,
591
600
  )
592
601
 
593
602
  return lines.join("\n")