@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.
- package/LICENSE +21 -0
- package/README.md +239 -0
- package/README.zh-CN.md +270 -0
- package/designs/default/DESIGN.md +1100 -0
- package/designs/editorial-ribbon/DESIGN.md +1092 -0
- package/designs/minimal/DESIGN.md +1079 -0
- package/domains/consulting/INDUSTRY.md +230 -0
- package/domains/deeptech-investment/INDUSTRY.md +160 -0
- package/domains/general/INDUSTRY.md +6 -0
- package/index.ts +1 -0
- package/lib/agents/research-prompt.ts +129 -0
- package/lib/commands/designs.ts +59 -0
- package/lib/commands/disable.ts +14 -0
- package/lib/commands/domains.ts +59 -0
- package/lib/commands/enable.ts +48 -0
- package/lib/commands/help.ts +35 -0
- package/lib/config.ts +65 -0
- package/lib/ctx.ts +27 -0
- package/lib/design/designs.ts +389 -0
- package/lib/domain/domains.ts +258 -0
- package/lib/frontmatter.ts +63 -0
- package/lib/log.ts +35 -0
- package/lib/prompt-builder.ts +194 -0
- package/lib/qa/checks.ts +594 -0
- package/lib/qa/index.ts +38 -0
- package/lib/qa/measure.ts +287 -0
- package/lib/read-hooks/extractors/docx.ts +16 -0
- package/lib/read-hooks/extractors/pdf.ts +19 -0
- package/lib/read-hooks/extractors/pptx.ts +53 -0
- package/lib/read-hooks/extractors/xlsx.ts +81 -0
- package/lib/read-hooks/image/compress.ts +36 -0
- package/lib/read-hooks/index.ts +12 -0
- package/lib/read-hooks/post-read.ts +74 -0
- package/lib/read-hooks/pre-read.ts +51 -0
- package/package.json +65 -0
- package/plugin.ts +365 -0
- package/skill/SKILL.md +676 -0
- package/tools/designs.ts +126 -0
- package/tools/domains.ts +73 -0
- package/tools/qa.ts +61 -0
- package/tools/research-save.ts +96 -0
- package/tools/workspace-scan.ts +154 -0
package/lib/qa/checks.ts
ADDED
|
@@ -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
|
+
}
|
package/lib/qa/index.ts
ADDED
|
@@ -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"
|