@cyber-dash-tech/revela 0.1.0

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.
Files changed (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +239 -0
  3. package/README.zh-CN.md +270 -0
  4. package/designs/default/DESIGN.md +1100 -0
  5. package/designs/editorial-ribbon/DESIGN.md +1092 -0
  6. package/designs/minimal/DESIGN.md +1079 -0
  7. package/domains/consulting/INDUSTRY.md +230 -0
  8. package/domains/deeptech-investment/INDUSTRY.md +160 -0
  9. package/domains/general/INDUSTRY.md +6 -0
  10. package/index.ts +1 -0
  11. package/lib/agents/research-prompt.ts +129 -0
  12. package/lib/commands/designs.ts +59 -0
  13. package/lib/commands/disable.ts +14 -0
  14. package/lib/commands/domains.ts +59 -0
  15. package/lib/commands/enable.ts +48 -0
  16. package/lib/commands/help.ts +35 -0
  17. package/lib/config.ts +65 -0
  18. package/lib/ctx.ts +27 -0
  19. package/lib/design/designs.ts +389 -0
  20. package/lib/domain/domains.ts +258 -0
  21. package/lib/frontmatter.ts +63 -0
  22. package/lib/log.ts +35 -0
  23. package/lib/prompt-builder.ts +194 -0
  24. package/lib/qa/checks.ts +594 -0
  25. package/lib/qa/index.ts +38 -0
  26. package/lib/qa/measure.ts +287 -0
  27. package/lib/read-hooks/extractors/docx.ts +16 -0
  28. package/lib/read-hooks/extractors/pdf.ts +19 -0
  29. package/lib/read-hooks/extractors/pptx.ts +53 -0
  30. package/lib/read-hooks/extractors/xlsx.ts +81 -0
  31. package/lib/read-hooks/image/compress.ts +36 -0
  32. package/lib/read-hooks/index.ts +12 -0
  33. package/lib/read-hooks/post-read.ts +74 -0
  34. package/lib/read-hooks/pre-read.ts +51 -0
  35. package/package.json +65 -0
  36. package/plugin.ts +365 -0
  37. package/skill/SKILL.md +676 -0
  38. package/tools/designs.ts +126 -0
  39. package/tools/domains.ts +73 -0
  40. package/tools/qa.ts +61 -0
  41. package/tools/research-save.ts +96 -0
  42. package/tools/workspace-scan.ts +154 -0
@@ -0,0 +1,594 @@
1
+ /**
2
+ * lib/qa/checks.ts
3
+ *
4
+ * Pure geometry-based layout checks — no class-name dependency.
5
+ *
6
+ * 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.
9
+ */
10
+
11
+ import type { SlideMetrics, ElementInfo, Rect } from "./measure"
12
+ import { CANVAS_W, CANVAS_H } from "./measure"
13
+
14
+ // ── Types ────────────────────────────────────────────────────────────────────
15
+
16
+ export type IssueSeverity = "error" | "warning" | "info"
17
+
18
+ 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
27
+ severity: IssueSeverity
28
+ /** Human-readable description for the LLM to act on */
29
+ detail: string
30
+ /** Measured values for traceability */
31
+ data?: Record<string, number | string>
32
+ }
33
+
34
+ export interface SlideReport {
35
+ index: number
36
+ title: string
37
+ issues: LayoutIssue[]
38
+ }
39
+
40
+ export interface QAReport {
41
+ file: string
42
+ slides: SlideReport[]
43
+ totalIssues: number
44
+ errorCount: number
45
+ warningCount: number
46
+ summary: string
47
+ }
48
+
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
+ ])
88
+
89
+ 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 */
111
+ SPARSE_THRESHOLD: 2,
112
+ /** Card height ratio (min/max in same row) below this → variance warning */
113
+ CARD_VAR_WARN: 0.65,
114
+ }
115
+
116
+ // ── Geometry helpers ─────────────────────────────────────────────────────────
117
+
118
+ /** Vertical overlap between two rects [0..1] relative to the shorter one. */
119
+ function verticalOverlap(a: Rect, b: Rect): number {
120
+ const overlapTop = Math.max(a.top, b.top)
121
+ const overlapBot = Math.min(a.bottom, b.bottom)
122
+ const overlap = Math.max(0, overlapBot - overlapTop)
123
+ const shorter = Math.min(a.height, b.height)
124
+ return shorter > 0 ? overlap / shorter : 0
125
+ }
126
+
127
+ /** Horizontal overlap [0..1] relative to the shorter width. */
128
+ function horizontalOverlap(a: Rect, b: Rect): number {
129
+ const ol = Math.max(a.left, b.left)
130
+ const or = Math.min(a.right, b.right)
131
+ const overlap = Math.max(0, or - ol)
132
+ const shorter = Math.min(a.width, b.width)
133
+ return shorter > 0 ? overlap / shorter : 0
134
+ }
135
+
136
+ /**
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.
141
+ */
142
+ function groupIntoRows(elements: ElementInfo[]): ElementInfo[][] {
143
+ // Only consider elements wide enough to be layout columns
144
+ const candidates = elements.filter(
145
+ (e) => e.visible && e.rect.width >= T.COL_MIN_WIDTH
146
+ )
147
+ if (candidates.length === 0) return []
148
+
149
+ const rows: ElementInfo[][] = []
150
+ const assigned = new Set<number>()
151
+
152
+ for (let i = 0; i < candidates.length; i++) {
153
+ if (assigned.has(i)) continue
154
+ const row: ElementInfo[] = [candidates[i]]
155
+ assigned.add(i)
156
+
157
+ for (let j = i + 1; j < candidates.length; j++) {
158
+ if (assigned.has(j)) continue
159
+ // Two elements are in the same row if they have significant vertical overlap
160
+ if (verticalOverlap(candidates[i].rect, candidates[j].rect) >= T.ROW_OVERLAP) {
161
+ row.push(candidates[j])
162
+ assigned.add(j)
163
+ }
164
+ }
165
+
166
+ if (row.length > 1) {
167
+ rows.push(row.sort((a, b) => a.rect.left - b.rect.left))
168
+ }
169
+ }
170
+
171
+ return rows
172
+ }
173
+
174
+ // ── Individual checks ────────────────────────────────────────────────────────
175
+
176
+ /** Check 1: Canvas fill ratio (content area / total canvas area) */
177
+ function checkFill(metrics: SlideMetrics): LayoutIssue[] {
178
+ const issues: LayoutIssue[] = []
179
+ const { contentRect, canvasRect, elements } = metrics
180
+
181
+ if (contentRect.width === 0 || contentRect.height === 0) {
182
+ issues.push({
183
+ type: "sparse",
184
+ severity: "error",
185
+ detail: "Slide appears to have no visible content.",
186
+ })
187
+ return issues
188
+ }
189
+
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)
205
+
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) {
219
+ issues.push({
220
+ type: "underfill",
221
+ 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) },
224
+ })
225
+ }
226
+
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
236
+
237
+ // If the slide has an explicit type, use it — no geometry guessing needed
238
+ if (metrics.slideType && EXEMPT_TYPES.has(metrics.slideType)) return []
239
+
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
+ })()
248
+
249
+ if (isCoverLike) return []
250
+
251
+ // contentRect is in canvas-relative coords; canvasRect.bottom is the canvas height
252
+ const gap = canvasRect.bottom - contentRect.bottom
253
+
254
+ if (gap > T.BOTTOM_WS_ERROR) {
255
+ issues.push({
256
+ type: "bottom_whitespace",
257
+ 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) },
260
+ })
261
+ } else if (gap > T.BOTTOM_WS_WARN) {
262
+ issues.push({
263
+ type: "bottom_whitespace",
264
+ 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) },
267
+ })
268
+ }
269
+
270
+ return issues
271
+ }
272
+
273
+ /** Check 3: Overflow — elements extending beyond the canvas boundaries */
274
+ function checkOverflow(metrics: SlideMetrics): LayoutIssue[] {
275
+ const issues: LayoutIssue[] = []
276
+ const { canvasRect } = metrics
277
+
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
+ }
299
+
300
+ walkElements(metrics.elements)
301
+ return issues
302
+ }
303
+
304
+ /** Check 4: Asymmetry — side-by-side elements with large height difference */
305
+ function checkAsymmetry(metrics: SlideMetrics): LayoutIssue[] {
306
+ const issues: LayoutIssue[] = []
307
+
308
+ // Check at the top level of .slide-canvas children
309
+ const rows = groupIntoRows(metrics.elements)
310
+
311
+ for (const row of rows) {
312
+ if (row.length < 2) continue
313
+
314
+ const heights = row.map((e) => e.rect.height)
315
+ const minH = Math.min(...heights)
316
+ const maxH = Math.max(...heights)
317
+
318
+ if (maxH === 0) continue
319
+ const ratio = minH / maxH
320
+
321
+ if (ratio < T.ASYM_ERROR) {
322
+ issues.push({
323
+ type: "asymmetry",
324
+ 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) },
327
+ })
328
+ } else if (ratio < T.ASYM_WARN) {
329
+ issues.push({
330
+ type: "asymmetry",
331
+ 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) },
334
+ })
335
+ }
336
+
337
+ // Also recursively check inside each column for nested rows
338
+ for (const col of row) {
339
+ if (col.children.length >= 2) {
340
+ const nestedRows = groupIntoRows(col.children)
341
+ 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
+ }
356
+ }
357
+ }
358
+ }
359
+ }
360
+
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
+ })
380
+ }
381
+
382
+ return issues
383
+ }
384
+
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
+ }
392
+
393
+ /**
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).
396
+ */
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
410
+ }
411
+
412
+ /**
413
+ * Check 6: Content density imbalance in side-by-side columns.
414
+ *
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.
418
+ */
419
+ function checkDensityImbalance(metrics: SlideMetrics): LayoutIssue[] {
420
+ const issues: LayoutIssue[] = []
421
+
422
+ // Find rows at the top level
423
+ const rows = groupIntoRows(metrics.elements)
424
+
425
+ for (const row of rows) {
426
+ if (row.length < 2) continue
427
+
428
+ const leafCounts = row.map(countLeaves)
429
+ const contentHeights = row.map(contentHeight)
430
+
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
+ }
451
+ }
452
+
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
+ }
473
+ }
474
+ }
475
+
476
+ // Also check one level deep (containers that hold two-column layouts)
477
+ 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
+ }
508
+ }
509
+ }
510
+
511
+ return issues
512
+ }
513
+
514
+ // ── Main export ──────────────────────────────────────────────────────────────
515
+
516
+ /**
517
+ * Run all checks on a set of slide metrics and produce a QA report.
518
+ */
519
+ export function runChecks(filePath: string, allMetrics: SlideMetrics[]): QAReport {
520
+ const slides: SlideReport[] = []
521
+
522
+ for (const metrics of allMetrics) {
523
+ const issues: LayoutIssue[] = [
524
+ ...checkFill(metrics),
525
+ ...checkBottomWhitespace(metrics),
526
+ ...checkOverflow(metrics),
527
+ ...checkAsymmetry(metrics),
528
+ ...checkSparse(metrics),
529
+ ...checkDensityImbalance(metrics),
530
+ ]
531
+
532
+ slides.push({ index: metrics.index, title: metrics.title, issues })
533
+ }
534
+
535
+ const totalIssues = slides.reduce((s, r) => s + r.issues.length, 0)
536
+ const errorCount = slides.reduce(
537
+ (s, r) => s + r.issues.filter((i) => i.severity === "error").length,
538
+ 0
539
+ )
540
+ const warningCount = slides.reduce(
541
+ (s, r) => s + r.issues.filter((i) => i.severity === "warning").length,
542
+ 0
543
+ )
544
+
545
+ const summary =
546
+ totalIssues === 0
547
+ ? "All slides passed layout QA."
548
+ : `Found ${totalIssues} issue(s): ${errorCount} error(s), ${warningCount} warning(s) across ${slides.filter((s) => s.issues.length > 0).length} slide(s).`
549
+
550
+ return { file: filePath, slides, totalIssues, errorCount, warningCount, summary }
551
+ }
552
+
553
+ // ── Report formatter ─────────────────────────────────────────────────────────
554
+
555
+ /**
556
+ * Format a QAReport into a markdown string suitable for the LLM to read.
557
+ */
558
+ export function formatReport(report: QAReport): string {
559
+ if (report.totalIssues === 0) {
560
+ return `## Layout QA: PASSED\n\nAll ${report.slides.length} slide(s) passed layout checks. No issues found.`
561
+ }
562
+
563
+ const lines: string[] = [
564
+ `## Layout QA Report`,
565
+ ``,
566
+ `**File:** \`${report.file}\``,
567
+ `**Result:** ${report.errorCount > 0 ? "FAILED" : "WARNINGS"} — ${report.summary}`,
568
+ ``,
569
+ ]
570
+
571
+ for (const slide of report.slides) {
572
+ if (slide.issues.length === 0) continue
573
+ lines.push(`### Slide ${slide.index + 1}: ${slide.title}`)
574
+ for (const issue of slide.issues) {
575
+ const icon = issue.severity === "error" ? "🔴" : "🟡"
576
+ lines.push(`- ${icon} **${issue.type}**: ${issue.detail}`)
577
+ }
578
+ lines.push("")
579
+ }
580
+
581
+ lines.push(
582
+ `### Action Required`,
583
+ ``,
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.`,
588
+ `- **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.`,
591
+ )
592
+
593
+ return lines.join("\n")
594
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * lib/qa/index.ts
3
+ *
4
+ * Public entry point for the slide layout QA system.
5
+ * Combines measurement (Puppeteer) + checks (geometry rules) into one call.
6
+ */
7
+
8
+ import { measureSlides } from "./measure"
9
+ import { runChecks, formatReport } from "./checks"
10
+ import type { QAReport } from "./checks"
11
+
12
+ export type { QAReport, SlideReport, LayoutIssue, IssueSeverity } from "./checks"
13
+
14
+ /**
15
+ * Run a full layout QA pass on `htmlFilePath`.
16
+ *
17
+ * 1. Opens the file in headless Chrome (puppeteer-core)
18
+ * 2. Measures each .slide element's geometry
19
+ * 3. Runs all checks (fill, whitespace, overflow, asymmetry, sparse)
20
+ * 4. Returns a structured QAReport
21
+ *
22
+ * Throws if the file cannot be opened or Chrome is not found.
23
+ */
24
+ export async function runQA(htmlFilePath: string): Promise<QAReport> {
25
+ const metrics = await measureSlides(htmlFilePath)
26
+ return runChecks(htmlFilePath, metrics)
27
+ }
28
+
29
+ /**
30
+ * Run QA and return a formatted markdown report string.
31
+ * Suitable for injecting into tool output or sending as a message to the LLM.
32
+ */
33
+ export async function runQAFormatted(htmlFilePath: string): Promise<string> {
34
+ const report = await runQA(htmlFilePath)
35
+ return formatReport(report)
36
+ }
37
+
38
+ export { formatReport } from "./checks"