@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/README.md +23 -16
- package/README.zh-CN.md +30 -22
- package/lib/config.ts +1 -1
- package/lib/design/designs.ts +97 -7
- package/lib/prompt-builder.ts +29 -50
- package/lib/qa/checks.ts +359 -350
- package/lib/qa/measure.ts +8 -7
- package/package.json +1 -1
- package/skill/SKILL.md +19 -195
- package/tools/designs.ts +21 -5
- package/designs/default/DESIGN.md +0 -1100
- package/designs/editorial-ribbon/DESIGN.md +0 -1092
- package/designs/minimal/DESIGN.md +0 -1079
package/lib/qa/checks.ts
CHANGED
|
@@ -1,29 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* lib/qa/checks.ts
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
// ──
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
113
|
-
|
|
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
|
|
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
|
|
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
|
|
138
|
-
*
|
|
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
|
-
|
|
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
|
-
/**
|
|
177
|
-
|
|
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
|
|
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: "
|
|
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
|
-
//
|
|
191
|
-
if (metrics.
|
|
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
|
-
//
|
|
207
|
-
const
|
|
208
|
-
|
|
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: "
|
|
239
|
+
type: "balance",
|
|
240
|
+
sub: "sparse",
|
|
221
241
|
severity: "warning",
|
|
222
|
-
detail: `
|
|
223
|
-
data: {
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
238
|
-
|
|
264
|
+
if (totalArea > 0) {
|
|
265
|
+
const centroidX = weightedX / totalArea
|
|
266
|
+
const centroidY = weightedY / totalArea
|
|
239
267
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
252
|
-
const
|
|
293
|
+
// ── Sub-check: bottom gap ────────────────────────────────────────────────
|
|
294
|
+
const bottomGap = canvasRect.bottom - contentRect.bottom
|
|
253
295
|
|
|
254
|
-
if (
|
|
296
|
+
if (bottomGap > T.BOTTOM_GAP_ERROR) {
|
|
255
297
|
issues.push({
|
|
256
|
-
type: "
|
|
298
|
+
type: "balance",
|
|
299
|
+
sub: "bottom_gap",
|
|
257
300
|
severity: "error",
|
|
258
|
-
detail: `${Math.round(
|
|
259
|
-
data: { gapPx: Math.round(
|
|
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 (
|
|
304
|
+
} else if (bottomGap > T.BOTTOM_GAP_WARN) {
|
|
262
305
|
issues.push({
|
|
263
|
-
type: "
|
|
306
|
+
type: "balance",
|
|
307
|
+
sub: "bottom_gap",
|
|
264
308
|
severity: "warning",
|
|
265
|
-
detail: `${Math.round(
|
|
266
|
-
data: { gapPx: Math.round(
|
|
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
|
-
|
|
274
|
-
function checkOverflow(metrics: SlideMetrics): LayoutIssue[] {
|
|
275
|
-
const issues: LayoutIssue[] = []
|
|
276
|
-
const { canvasRect } = metrics
|
|
317
|
+
// ── Dimension 3: Symmetry ─────────────────────────────────────────────────────
|
|
277
318
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
}
|
|
333
|
+
function checkRow(row: ElementInfo[], parentSelector?: string) {
|
|
334
|
+
if (row.length < 2) return
|
|
303
335
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
336
|
+
const heights = row.map((e) => e.rect.height)
|
|
337
|
+
const contHeights = row.map(contentHeight)
|
|
338
|
+
const areas = row.map(leafArea)
|
|
307
339
|
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
312
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
319
|
-
|
|
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
|
-
|
|
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: "
|
|
389
|
+
type: "symmetry",
|
|
390
|
+
sub: "density_mismatch",
|
|
324
391
|
severity: "error",
|
|
325
|
-
detail: `Side-by-side columns have
|
|
326
|
-
data: { ratio: Math.round(
|
|
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 (
|
|
395
|
+
} else if (maxA > 0 && aRatio < T.SYM_WARN) {
|
|
329
396
|
issues.push({
|
|
330
|
-
type: "
|
|
397
|
+
type: "symmetry",
|
|
398
|
+
sub: "density_mismatch",
|
|
331
399
|
severity: "warning",
|
|
332
|
-
detail: `Side-by-side columns have unequal
|
|
333
|
-
data: { ratio: Math.round(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
*
|
|
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
|
|
398
|
-
if (
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
|
452
|
+
* Check 4: Rhythm — spacing regularity between stacked siblings.
|
|
414
453
|
*
|
|
415
|
-
*
|
|
416
|
-
*
|
|
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
|
|
457
|
+
function checkRhythm(metrics: SlideMetrics): LayoutIssue[] {
|
|
420
458
|
const issues: LayoutIssue[] = []
|
|
421
459
|
|
|
422
|
-
//
|
|
423
|
-
|
|
460
|
+
// Skip rhythm checks for structural/sparse slides (slide-qa="false")
|
|
461
|
+
if (!metrics.slideQa) return []
|
|
424
462
|
|
|
425
|
-
|
|
426
|
-
if (
|
|
463
|
+
function checkContainer(els: ElementInfo[], containerSelector?: string) {
|
|
464
|
+
if (els.length < 2) return
|
|
427
465
|
|
|
428
|
-
|
|
429
|
-
const
|
|
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
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
})
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
//
|
|
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
|
|
479
|
-
|
|
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
|
-
...
|
|
528
|
-
...
|
|
529
|
-
...
|
|
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
|
-
|
|
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
|
-
`- **
|
|
590
|
-
`- **
|
|
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")
|