@cyber-dash-tech/revela 0.1.1 → 0.1.2
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 +22 -15
- package/README.zh-CN.md +29 -21
- package/lib/qa/checks.ts +371 -319
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,14 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
**English** | [中文](README.zh-CN.md)
|
|
4
4
|
|
|
5
|
+
[](https://www.npmjs.com/package/@cyber-dash-tech/revela) [](LICENSE) [](tests/) [](https://opencode.ai) [](https://bun.sh)
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<img src="assets/img/logo.png" alt="Revela" width="800" />
|
|
9
|
+
</p>
|
|
10
|
+
|
|
5
11
|
An [OpenCode](https://opencode.ai) plugin that turns your AI into a presentation assistant.
|
|
6
|
-
|
|
12
|
+
Tell Revela what's on your mind — it'll finish the research and analysis, and deliver a complete slide deck in a couple of minutes.
|
|
13
|
+
|
|
7
14
|
|
|
8
|
-
|
|
9
|
-
[
|
|
10
|
-
[](tests/)
|
|
11
|
-
[](https://opencode.ai)
|
|
12
|
-
[](https://bun.sh)
|
|
15
|
+
|
|
16
|
+
**[Live Demo — The AI Power Shift](https://cyber-dash-tech.github.io/revela/assets/html/ai-power-shift.html)** · A 5-slide investment brief generated entirely by Revela.
|
|
13
17
|
|
|
14
18
|
---
|
|
15
19
|
|
|
@@ -55,18 +59,21 @@ export { default } from "/absolute/path/to/revela/index.ts";
|
|
|
55
59
|
|
|
56
60
|
## Quick Start
|
|
57
61
|
|
|
62
|
+
Enable OpenCode's web search (recommended):
|
|
63
|
+
```bash
|
|
64
|
+
OPENCODE_ENABLE_EXA=1 opencode
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Enable Revela in your OpenCode session — turns the primary agent into a slide design expert:
|
|
58
68
|
```
|
|
59
69
|
/revela enable
|
|
60
70
|
```
|
|
61
71
|
|
|
62
|
-
|
|
63
|
-
|
|
72
|
+
To turn it off and restore the primary agent to normal:
|
|
64
73
|
```
|
|
65
74
|
/revela disable
|
|
66
75
|
```
|
|
67
76
|
|
|
68
|
-
Turns off Revela's system prompt for the current session.
|
|
69
|
-
|
|
70
77
|
---
|
|
71
78
|
|
|
72
79
|
## Commands
|
|
@@ -93,11 +100,11 @@ All commands execute locally — zero LLM cost, instant feedback.
|
|
|
93
100
|
|
|
94
101
|
Three designs are bundled. Switch with `/revela designs <name>`.
|
|
95
102
|
|
|
96
|
-
| Name | Description |
|
|
97
|
-
|
|
98
|
-
| `default` | Dark executive style — deep navy/slate, sharp typography, ECharts data visualization |
|
|
99
|
-
| `minimal` | Clean light theme — high contrast, generous whitespace, professional look |
|
|
100
|
-
| `editorial-ribbon` | Bold editorial layout — accent ribbons, strong headlines, high visual impact |
|
|
103
|
+
| Name | Description | Preview |
|
|
104
|
+
|---|---|---|
|
|
105
|
+
| `default` | Dark executive style — deep navy/slate, sharp typography, ECharts data visualization |  |
|
|
106
|
+
| `minimal` | Clean light theme — high contrast, generous whitespace, professional look |  |
|
|
107
|
+
| `editorial-ribbon` | Bold editorial layout — accent ribbons, strong headlines, high visual impact |  |
|
|
101
108
|
|
|
102
109
|
---
|
|
103
110
|
|
package/README.zh-CN.md
CHANGED
|
@@ -2,14 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
[English](README.md) | **中文**
|
|
4
4
|
|
|
5
|
+
[](https://www.npmjs.com/package/@cyber-dash-tech/revela) [](LICENSE) [](tests/) [](https://opencode.ai) [](https://bun.sh)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
<p align="center">
|
|
9
|
+
<img src="assets/img/logo.png" alt="Revela" width="800" />
|
|
10
|
+
</p>
|
|
11
|
+
|
|
5
12
|
Revela 是一款 [OpenCode](https://opencode.ai) 插件,让 AI 成为你的PPT助手。
|
|
6
13
|
用对话方式描述你的需求,Revela 会自动调研、分析、洞察,最后呈现你心中的PPT。
|
|
7
14
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
[
|
|
11
|
-
[](https://opencode.ai)
|
|
12
|
-
[](https://bun.sh)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
**[在线演示 — AI 权力转移](https://cyber-dash-tech.github.io/revela/assets/html/ai-power-shift.html)** · 一份由 Revela 全程生成的 5 页投资简报。
|
|
13
18
|
|
|
14
19
|
---
|
|
15
20
|
|
|
@@ -85,18 +90,21 @@ export { default } from "/path/to/revela/index.ts";
|
|
|
85
90
|
|
|
86
91
|
## 快速开始
|
|
87
92
|
|
|
93
|
+
启用opencode搜索功能(推荐)
|
|
94
|
+
```Bash
|
|
95
|
+
OPENCODE_ENABLE_EXA=1 opencode
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
在 opencode 中启动 Revela(默认关闭),将 primary agent 变为演讲稿设计专家
|
|
88
99
|
```
|
|
89
100
|
/revela enable
|
|
90
101
|
```
|
|
91
102
|
|
|
92
|
-
|
|
93
|
-
|
|
103
|
+
关闭当前会话中 Revela,primary agent 恢复正常
|
|
94
104
|
```
|
|
95
105
|
/revela disable
|
|
96
106
|
```
|
|
97
107
|
|
|
98
|
-
关闭当前会话中 Revela 的系统提示注入。
|
|
99
|
-
|
|
100
108
|
---
|
|
101
109
|
|
|
102
110
|
## 命令
|
|
@@ -119,19 +127,19 @@ export { default } from "/path/to/revela/index.ts";
|
|
|
119
127
|
|
|
120
128
|
---
|
|
121
129
|
|
|
122
|
-
##
|
|
130
|
+
## 内置设计模版
|
|
123
131
|
|
|
124
132
|
插件内置三套设计,用 `/revela designs <name>` 切换。
|
|
125
133
|
|
|
126
|
-
| 名称 | 说明 |
|
|
127
|
-
|
|
128
|
-
| `default` | 深色商务风格 —— 深海军蓝/石板色,锐利字体,ECharts 数据可视化 |
|
|
129
|
-
| `minimal` | 简洁浅色主题 —— 高对比度,充足留白,专业外观 |
|
|
130
|
-
| `editorial-ribbon` | 大胆的编辑版式 —— 强调色横幅,醒目标题,高视觉冲击力 |
|
|
134
|
+
| 名称 | 说明 | 预览 |
|
|
135
|
+
|---|---|---|
|
|
136
|
+
| `default` | 深色商务风格 —— 深海军蓝/石板色,锐利字体,ECharts 数据可视化 |  |
|
|
137
|
+
| `minimal` | 简洁浅色主题 —— 高对比度,充足留白,专业外观 |  |
|
|
138
|
+
| `editorial-ribbon` | 大胆的编辑版式 —— 强调色横幅,醒目标题,高视觉冲击力 |  |
|
|
131
139
|
|
|
132
140
|
---
|
|
133
141
|
|
|
134
|
-
##
|
|
142
|
+
## 内置行业SOP
|
|
135
143
|
|
|
136
144
|
领域为 AI 的上下文提供特定行业的报告框架和术语。
|
|
137
145
|
|
|
@@ -156,9 +164,9 @@ export { default } from "/path/to/revela/index.ts";
|
|
|
156
164
|
|
|
157
165
|
---
|
|
158
166
|
|
|
159
|
-
##
|
|
167
|
+
## 排版 QA
|
|
160
168
|
|
|
161
|
-
每次 AI 写入幻灯片文件时,Revela 会自动在 1920×1080 分辨率下运行基于 Puppeteer
|
|
169
|
+
每次 AI 写入幻灯片文件时,Revela 会自动在 1920×1080 分辨率下运行基于 Puppeteer 的排版质检。发现问题后立即将报告反馈给 AI,AI 自行修正,无需人工干预。(**功能持续更新中 ...**)
|
|
162
170
|
|
|
163
171
|
每张幻灯片的检查项:
|
|
164
172
|
|
|
@@ -179,7 +187,7 @@ export { default } from "/path/to/revela/index.ts";
|
|
|
179
187
|
|
|
180
188
|
---
|
|
181
189
|
|
|
182
|
-
##
|
|
190
|
+
## 自定义模版
|
|
183
191
|
|
|
184
192
|
设计是包含 `DESIGN.md` 文件的文件夹,frontmatter 声明元数据:
|
|
185
193
|
|
|
@@ -232,7 +240,7 @@ ECharts / 数据可视化规范...
|
|
|
232
240
|
|
|
233
241
|
没有标记时,整个 `DESIGN.md` 内容每轮全量注入(向后兼容)。
|
|
234
242
|
|
|
235
|
-
###
|
|
243
|
+
### 自定义模版安装
|
|
236
244
|
|
|
237
245
|
```
|
|
238
246
|
/revela designs-add github:your-org/your-design
|
|
@@ -242,7 +250,7 @@ ECharts / 数据可视化规范...
|
|
|
242
250
|
|
|
243
251
|
---
|
|
244
252
|
|
|
245
|
-
##
|
|
253
|
+
## 自定义行业SOP
|
|
246
254
|
|
|
247
255
|
领域为 AI 增加特定行业的报告框架、术语和结构化指导。
|
|
248
256
|
|
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,12 +47,12 @@ export interface QAReport {
|
|
|
46
47
|
summary: string
|
|
47
48
|
}
|
|
48
49
|
|
|
49
|
-
// ── Slide type registry — single source of truth
|
|
50
|
+
// ── Slide type registry — single source of truth ──────────────────────────────
|
|
50
51
|
|
|
51
52
|
/**
|
|
52
53
|
* All valid values for the `data-slide-type` attribute on `<section class="slide">`.
|
|
53
54
|
*
|
|
54
|
-
*
|
|
55
|
+
* Single source of truth consumed by:
|
|
55
56
|
* - QA checks (EXEMPT_TYPES below)
|
|
56
57
|
* - prompt-builder.ts (injected into SKILL.md via <!-- @slide-types --> placeholder)
|
|
57
58
|
*/
|
|
@@ -67,15 +68,10 @@ export const SLIDE_TYPES = [
|
|
|
67
68
|
|
|
68
69
|
export type SlideType = (typeof SLIDE_TYPES)[number]
|
|
69
70
|
|
|
70
|
-
// ── Thresholds (tunable) ─────────────────────────────────────────────────────
|
|
71
|
-
|
|
72
71
|
/**
|
|
73
72
|
* Slide types that are intentionally sparse, centred, or structurally
|
|
74
|
-
* different from "content" slides.
|
|
75
|
-
*
|
|
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.
|
|
73
|
+
* different from "content" slides. Balance and rhythm checks are skipped
|
|
74
|
+
* for these types (overflow and symmetry still apply).
|
|
79
75
|
*/
|
|
80
76
|
export const EXEMPT_TYPES: ReadonlySet<string> = new Set<SlideType>([
|
|
81
77
|
"cover",
|
|
@@ -86,36 +82,36 @@ export const EXEMPT_TYPES: ReadonlySet<string> = new Set<SlideType>([
|
|
|
86
82
|
"thank-you",
|
|
87
83
|
])
|
|
88
84
|
|
|
85
|
+
// ── Thresholds ────────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
89
87
|
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 */
|
|
88
|
+
// Balance — centroid offset (fraction of canvas half-dimension)
|
|
89
|
+
CENTROID_WARN: 0.25,
|
|
90
|
+
CENTROID_ERROR: 0.35,
|
|
91
|
+
// Balance — bottom gap (px)
|
|
92
|
+
BOTTOM_GAP_WARN: 200,
|
|
93
|
+
BOTTOM_GAP_ERROR: 350,
|
|
94
|
+
// Balance — sparse: fewer than this many visible top-level elements
|
|
111
95
|
SPARSE_THRESHOLD: 2,
|
|
112
|
-
|
|
113
|
-
|
|
96
|
+
// Symmetry — min/max ratio for height, content-height, leaf count
|
|
97
|
+
SYM_WARN: 0.70,
|
|
98
|
+
SYM_ERROR: 0.50,
|
|
99
|
+
// Symmetry — min element width to be considered a layout column
|
|
100
|
+
COL_MIN_WIDTH: 200,
|
|
101
|
+
// Symmetry — min vertical overlap fraction to consider elements "in the same row"
|
|
102
|
+
ROW_OVERLAP: 0.30,
|
|
103
|
+
// Rhythm — gap variance: coefficient of variation threshold
|
|
104
|
+
GAP_CV_WARN: 0.60,
|
|
105
|
+
GAP_CV_ERROR: 1.00,
|
|
106
|
+
// Rhythm — min mean gap (px) to bother checking variance
|
|
107
|
+
GAP_MIN_MEAN: 10,
|
|
108
|
+
// Rhythm — min children count to check gap variance
|
|
109
|
+
GAP_MIN_CHILDREN: 3,
|
|
114
110
|
}
|
|
115
111
|
|
|
116
|
-
// ── Geometry helpers
|
|
112
|
+
// ── Geometry helpers ──────────────────────────────────────────────────────────
|
|
117
113
|
|
|
118
|
-
/** Vertical overlap
|
|
114
|
+
/** Vertical overlap [0..1] relative to the shorter element. */
|
|
119
115
|
function verticalOverlap(a: Rect, b: Rect): number {
|
|
120
116
|
const overlapTop = Math.max(a.top, b.top)
|
|
121
117
|
const overlapBot = Math.min(a.bottom, b.bottom)
|
|
@@ -124,7 +120,7 @@ function verticalOverlap(a: Rect, b: Rect): number {
|
|
|
124
120
|
return shorter > 0 ? overlap / shorter : 0
|
|
125
121
|
}
|
|
126
122
|
|
|
127
|
-
/** Horizontal overlap [0..1] relative to the shorter
|
|
123
|
+
/** Horizontal overlap [0..1] relative to the shorter element. */
|
|
128
124
|
function horizontalOverlap(a: Rect, b: Rect): number {
|
|
129
125
|
const ol = Math.max(a.left, b.left)
|
|
130
126
|
const or = Math.min(a.right, b.right)
|
|
@@ -134,13 +130,11 @@ function horizontalOverlap(a: Rect, b: Rect): number {
|
|
|
134
130
|
}
|
|
135
131
|
|
|
136
132
|
/**
|
|
137
|
-
* Group
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
* Returns an array of rows; each row is an array of ElementInfo sorted left→right.
|
|
133
|
+
* Group elements into rows: elements with significant vertical overlap are
|
|
134
|
+
* considered side-by-side. Each row is sorted left→right.
|
|
135
|
+
* Only elements wide enough to be layout columns are considered.
|
|
141
136
|
*/
|
|
142
137
|
function groupIntoRows(elements: ElementInfo[]): ElementInfo[][] {
|
|
143
|
-
// Only consider elements wide enough to be layout columns
|
|
144
138
|
const candidates = elements.filter(
|
|
145
139
|
(e) => e.visible && e.rect.width >= T.COL_MIN_WIDTH
|
|
146
140
|
)
|
|
@@ -156,7 +150,6 @@ function groupIntoRows(elements: ElementInfo[]): ElementInfo[][] {
|
|
|
156
150
|
|
|
157
151
|
for (let j = i + 1; j < candidates.length; j++) {
|
|
158
152
|
if (assigned.has(j)) continue
|
|
159
|
-
// Two elements are in the same row if they have significant vertical overlap
|
|
160
153
|
if (verticalOverlap(candidates[i].rect, candidates[j].rect) >= T.ROW_OVERLAP) {
|
|
161
154
|
row.push(candidates[j])
|
|
162
155
|
assigned.add(j)
|
|
@@ -171,362 +164,419 @@ function groupIntoRows(elements: ElementInfo[]): ElementInfo[][] {
|
|
|
171
164
|
return rows
|
|
172
165
|
}
|
|
173
166
|
|
|
174
|
-
|
|
167
|
+
/** Count all leaf (no-child) descendants. */
|
|
168
|
+
/**
|
|
169
|
+
* Sum of bounding-box areas of all visible leaf descendants.
|
|
170
|
+
* More accurate than leaf count for density comparisons — charts and large
|
|
171
|
+
* containers contribute proportionally to their visual footprint.
|
|
172
|
+
*/
|
|
173
|
+
function leafArea(el: ElementInfo): number {
|
|
174
|
+
if (el.children.length === 0) {
|
|
175
|
+
return el.visible ? el.rect.width * el.rect.height : 0
|
|
176
|
+
}
|
|
177
|
+
return el.children.reduce((sum, ch) => sum + leafArea(ch), 0)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Actual content height of an element: from topmost child top to bottommost
|
|
182
|
+
* child bottom. Ignores CSS stretch padding on the container itself.
|
|
183
|
+
*/
|
|
184
|
+
function contentHeight(el: ElementInfo): number {
|
|
185
|
+
if (el.children.length === 0) return el.rect.height
|
|
186
|
+
let top = Infinity, bottom = -Infinity
|
|
187
|
+
function walk(list: ElementInfo[]) {
|
|
188
|
+
for (const ch of list) {
|
|
189
|
+
if (!ch.visible) continue
|
|
190
|
+
top = Math.min(top, ch.rect.top)
|
|
191
|
+
bottom = Math.max(bottom, ch.rect.bottom)
|
|
192
|
+
if (ch.children.length > 0) walk(ch.children)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
walk(el.children)
|
|
196
|
+
return top === Infinity ? el.rect.height : bottom - top
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Collect all visible leaf elements recursively. */
|
|
200
|
+
function collectLeaves(el: ElementInfo): ElementInfo[] {
|
|
201
|
+
if (el.children.length === 0) return el.visible ? [el] : []
|
|
202
|
+
return el.children.flatMap(collectLeaves)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ── Dimension 1: Overflow ─────────────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Check 1: Overflow — elements extending beyond canvas boundaries.
|
|
209
|
+
* Hard correctness check; applies to all slide types.
|
|
210
|
+
*/
|
|
211
|
+
function checkOverflow(metrics: SlideMetrics): LayoutIssue[] {
|
|
212
|
+
const issues: LayoutIssue[] = []
|
|
213
|
+
const { canvasRect } = metrics
|
|
214
|
+
const tol = 2 // 2px sub-pixel tolerance
|
|
215
|
+
|
|
216
|
+
function walk(els: ElementInfo[]) {
|
|
217
|
+
for (const el of els) {
|
|
218
|
+
if (!el.visible) continue
|
|
219
|
+
const r = el.rect
|
|
220
|
+
if (
|
|
221
|
+
r.left < canvasRect.left - tol ||
|
|
222
|
+
r.top < canvasRect.top - tol ||
|
|
223
|
+
r.right > canvasRect.right + tol ||
|
|
224
|
+
r.bottom > canvasRect.bottom + tol
|
|
225
|
+
) {
|
|
226
|
+
issues.push({
|
|
227
|
+
type: "overflow",
|
|
228
|
+
severity: "error",
|
|
229
|
+
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)})`,
|
|
230
|
+
})
|
|
231
|
+
}
|
|
232
|
+
if (el.children.length > 0) walk(el.children)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
walk(metrics.elements)
|
|
237
|
+
return issues
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ── Dimension 2: Balance ──────────────────────────────────────────────────────
|
|
175
241
|
|
|
176
|
-
/**
|
|
177
|
-
|
|
242
|
+
/**
|
|
243
|
+
* Check 2: Balance — content centroid, bottom gap, sparsity.
|
|
244
|
+
* Skipped for EXEMPT_TYPES (cover, closing, etc.).
|
|
245
|
+
*
|
|
246
|
+
* Sub-checks:
|
|
247
|
+
* - centroid_offset: weighted centroid deviates too far from canvas centre
|
|
248
|
+
* - bottom_gap: large empty gap at bottom of slide
|
|
249
|
+
* - sparse: too few visible top-level elements
|
|
250
|
+
*/
|
|
251
|
+
function checkBalance(metrics: SlideMetrics): LayoutIssue[] {
|
|
178
252
|
const issues: LayoutIssue[] = []
|
|
179
|
-
const { contentRect, canvasRect
|
|
253
|
+
const { elements, contentRect, canvasRect } = metrics
|
|
180
254
|
|
|
255
|
+
// Guard: no content at all
|
|
181
256
|
if (contentRect.width === 0 || contentRect.height === 0) {
|
|
182
257
|
issues.push({
|
|
183
|
-
type: "
|
|
258
|
+
type: "balance",
|
|
259
|
+
sub: "sparse",
|
|
184
260
|
severity: "error",
|
|
185
261
|
detail: "Slide appears to have no visible content.",
|
|
186
262
|
})
|
|
187
263
|
return issues
|
|
188
264
|
}
|
|
189
265
|
|
|
190
|
-
//
|
|
266
|
+
// Exempt structural slides
|
|
191
267
|
if (metrics.slideType && EXEMPT_TYPES.has(metrics.slideType)) return []
|
|
192
268
|
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
if (
|
|
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) {
|
|
269
|
+
// Geometry fallback for old HTML without data-slide-type:
|
|
270
|
+
// detect cover-like slides (single centred column)
|
|
271
|
+
const contentCenterX = (contentRect.left + contentRect.right) / 2
|
|
272
|
+
const canvasCenterX = canvasRect.width / 2
|
|
273
|
+
const centerOffsetFrac = Math.abs(contentCenterX - canvasCenterX) / canvasRect.width
|
|
274
|
+
const maxElemWidth = Math.max(...elements.map((e) => e.rect.width))
|
|
275
|
+
const isCoverLike = centerOffsetFrac < 0.15 && maxElemWidth < canvasRect.width * 0.65
|
|
276
|
+
if (isCoverLike) return []
|
|
277
|
+
|
|
278
|
+
// ── Sub-check: sparse ────────────────────────────────────────────────────
|
|
279
|
+
const visibleCount = elements.filter((e) => e.visible).length
|
|
280
|
+
if (visibleCount < T.SPARSE_THRESHOLD) {
|
|
219
281
|
issues.push({
|
|
220
|
-
type: "
|
|
282
|
+
type: "balance",
|
|
283
|
+
sub: "sparse",
|
|
221
284
|
severity: "warning",
|
|
222
|
-
detail: `
|
|
223
|
-
data: {
|
|
285
|
+
detail: `Slide has only ${visibleCount} visible top-level element(s). This may result in a lot of empty space.`,
|
|
286
|
+
data: { visibleCount },
|
|
224
287
|
})
|
|
225
288
|
}
|
|
226
289
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
290
|
+
// ── Sub-check: centroid offset ───────────────────────────────────────────
|
|
291
|
+
// Collect all leaf elements and compute area-weighted centroid
|
|
292
|
+
const leaves = elements.flatMap(collectLeaves)
|
|
293
|
+
if (leaves.length > 0) {
|
|
294
|
+
let totalArea = 0
|
|
295
|
+
let weightedX = 0
|
|
296
|
+
let weightedY = 0
|
|
297
|
+
|
|
298
|
+
for (const leaf of leaves) {
|
|
299
|
+
const area = leaf.rect.width * leaf.rect.height
|
|
300
|
+
const cx = (leaf.rect.left + leaf.rect.right) / 2
|
|
301
|
+
const cy = (leaf.rect.top + leaf.rect.bottom) / 2
|
|
302
|
+
totalArea += area
|
|
303
|
+
weightedX += cx * area
|
|
304
|
+
weightedY += cy * area
|
|
305
|
+
}
|
|
236
306
|
|
|
237
|
-
|
|
238
|
-
|
|
307
|
+
if (totalArea > 0) {
|
|
308
|
+
const centroidX = weightedX / totalArea
|
|
309
|
+
const centroidY = weightedY / totalArea
|
|
239
310
|
|
|
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
|
-
})()
|
|
311
|
+
// Normalise offset by half-canvas dimensions so both axes are comparable
|
|
312
|
+
const offsetX = Math.abs(centroidX - canvasRect.width / 2) / (canvasRect.width / 2)
|
|
313
|
+
const offsetY = Math.abs(centroidY - canvasRect.height / 2) / (canvasRect.height / 2)
|
|
314
|
+
const offset = Math.max(offsetX, offsetY)
|
|
248
315
|
|
|
249
|
-
|
|
316
|
+
if (offset > T.CENTROID_ERROR) {
|
|
317
|
+
issues.push({
|
|
318
|
+
type: "balance",
|
|
319
|
+
sub: "centroid_offset",
|
|
320
|
+
severity: "error",
|
|
321
|
+
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.`,
|
|
322
|
+
data: { offsetPct: Math.round(offset * 100), centroidX: Math.round(centroidX), centroidY: Math.round(centroidY) },
|
|
323
|
+
})
|
|
324
|
+
} else if (offset > T.CENTROID_WARN) {
|
|
325
|
+
issues.push({
|
|
326
|
+
type: "balance",
|
|
327
|
+
sub: "centroid_offset",
|
|
328
|
+
severity: "warning",
|
|
329
|
+
detail: `Content centroid is slightly off-centre (${Math.round(offset * 100)}% offset). Consider balancing the visual weight across the slide.`,
|
|
330
|
+
data: { offsetPct: Math.round(offset * 100), centroidX: Math.round(centroidX), centroidY: Math.round(centroidY) },
|
|
331
|
+
})
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
250
335
|
|
|
251
|
-
//
|
|
252
|
-
const
|
|
336
|
+
// ── Sub-check: bottom gap ────────────────────────────────────────────────
|
|
337
|
+
const bottomGap = canvasRect.bottom - contentRect.bottom
|
|
253
338
|
|
|
254
|
-
if (
|
|
339
|
+
if (bottomGap > T.BOTTOM_GAP_ERROR) {
|
|
255
340
|
issues.push({
|
|
256
|
-
type: "
|
|
341
|
+
type: "balance",
|
|
342
|
+
sub: "bottom_gap",
|
|
257
343
|
severity: "error",
|
|
258
|
-
detail: `${Math.round(
|
|
259
|
-
data: { gapPx: Math.round(
|
|
344
|
+
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.`,
|
|
345
|
+
data: { gapPx: Math.round(bottomGap) },
|
|
260
346
|
})
|
|
261
|
-
} else if (
|
|
347
|
+
} else if (bottomGap > T.BOTTOM_GAP_WARN) {
|
|
262
348
|
issues.push({
|
|
263
|
-
type: "
|
|
349
|
+
type: "balance",
|
|
350
|
+
sub: "bottom_gap",
|
|
264
351
|
severity: "warning",
|
|
265
|
-
detail: `${Math.round(
|
|
266
|
-
data: { gapPx: Math.round(
|
|
352
|
+
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.`,
|
|
353
|
+
data: { gapPx: Math.round(bottomGap) },
|
|
267
354
|
})
|
|
268
355
|
}
|
|
269
356
|
|
|
270
357
|
return issues
|
|
271
358
|
}
|
|
272
359
|
|
|
273
|
-
|
|
274
|
-
function checkOverflow(metrics: SlideMetrics): LayoutIssue[] {
|
|
275
|
-
const issues: LayoutIssue[] = []
|
|
276
|
-
const { canvasRect } = metrics
|
|
360
|
+
// ── Dimension 3: Symmetry ─────────────────────────────────────────────────────
|
|
277
361
|
|
|
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
|
-
}
|
|
362
|
+
/**
|
|
363
|
+
* Check 3: Symmetry — side-by-side elements should be visually balanced.
|
|
364
|
+
*
|
|
365
|
+
* For each row of side-by-side columns, checks three sub-metrics and reports
|
|
366
|
+
* the most severe finding:
|
|
367
|
+
* - height_mismatch: rendered height ratio
|
|
368
|
+
* - density_mismatch: actual content height ratio (strips CSS stretch)
|
|
369
|
+
* - leaf count ratio: proxy for content density imbalance
|
|
370
|
+
*
|
|
371
|
+
* Applies at top-level and one level deep (nested rows inside columns).
|
|
372
|
+
*/
|
|
373
|
+
function checkSymmetry(metrics: SlideMetrics): LayoutIssue[] {
|
|
374
|
+
const issues: LayoutIssue[] = []
|
|
299
375
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
}
|
|
376
|
+
function checkRow(row: ElementInfo[], parentSelector?: string) {
|
|
377
|
+
if (row.length < 2) return
|
|
303
378
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
379
|
+
const heights = row.map((e) => e.rect.height)
|
|
380
|
+
const contHeights = row.map(contentHeight)
|
|
381
|
+
const areas = row.map(leafArea)
|
|
307
382
|
|
|
308
|
-
|
|
309
|
-
|
|
383
|
+
const minH = Math.min(...heights), maxH = Math.max(...heights)
|
|
384
|
+
const minCH = Math.min(...contHeights), maxCH = Math.max(...contHeights)
|
|
385
|
+
const minA = Math.min(...areas), maxA = Math.max(...areas)
|
|
310
386
|
|
|
311
|
-
|
|
312
|
-
|
|
387
|
+
const hRatio = maxH > 0 ? minH / maxH : 1
|
|
388
|
+
const chRatio = maxCH > 50 ? minCH / maxCH : 1 // skip tiny containers
|
|
389
|
+
const aRatio = maxA > 0 ? minA / maxA : 1
|
|
313
390
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
391
|
+
// Height mismatch (rendered boxes)
|
|
392
|
+
if (hRatio < T.SYM_ERROR) {
|
|
393
|
+
issues.push({
|
|
394
|
+
type: "symmetry",
|
|
395
|
+
sub: "height_mismatch",
|
|
396
|
+
severity: "error",
|
|
397
|
+
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.`,
|
|
398
|
+
data: { ratio: Math.round(hRatio * 100), minH: Math.round(minH), maxH: Math.round(maxH) },
|
|
399
|
+
})
|
|
400
|
+
} else if (hRatio < T.SYM_WARN) {
|
|
401
|
+
issues.push({
|
|
402
|
+
type: "symmetry",
|
|
403
|
+
sub: "height_mismatch",
|
|
404
|
+
severity: "warning",
|
|
405
|
+
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.`,
|
|
406
|
+
data: { ratio: Math.round(hRatio * 100), minH: Math.round(minH), maxH: Math.round(maxH) },
|
|
407
|
+
})
|
|
408
|
+
}
|
|
317
409
|
|
|
318
|
-
|
|
319
|
-
|
|
410
|
+
// Density mismatch (actual content height, ignores CSS stretch)
|
|
411
|
+
if (maxCH > 50 && chRatio < T.SYM_ERROR) {
|
|
412
|
+
issues.push({
|
|
413
|
+
type: "symmetry",
|
|
414
|
+
sub: "density_mismatch",
|
|
415
|
+
severity: "error",
|
|
416
|
+
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.`,
|
|
417
|
+
data: { ratio: Math.round(chRatio * 100), contentHeights: contHeights.map(Math.round).join(",") },
|
|
418
|
+
})
|
|
419
|
+
} else if (maxCH > 50 && chRatio < T.SYM_WARN) {
|
|
420
|
+
issues.push({
|
|
421
|
+
type: "symmetry",
|
|
422
|
+
sub: "density_mismatch",
|
|
423
|
+
severity: "warning",
|
|
424
|
+
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.`,
|
|
425
|
+
data: { ratio: Math.round(chRatio * 100), contentHeights: contHeights.map(Math.round).join(",") },
|
|
426
|
+
})
|
|
427
|
+
}
|
|
320
428
|
|
|
321
|
-
|
|
429
|
+
// Area imbalance (sum of leaf bounding-box areas — robust to chart containers)
|
|
430
|
+
if (maxA > 0 && aRatio < T.SYM_ERROR) {
|
|
322
431
|
issues.push({
|
|
323
|
-
type: "
|
|
432
|
+
type: "symmetry",
|
|
433
|
+
sub: "density_mismatch",
|
|
324
434
|
severity: "error",
|
|
325
|
-
detail: `Side-by-side columns have
|
|
326
|
-
data: { ratio: Math.round(
|
|
435
|
+
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.`,
|
|
436
|
+
data: { ratio: Math.round(aRatio * 100), areas: areas.map((a) => Math.round(a / 1000)).join(",") },
|
|
327
437
|
})
|
|
328
|
-
} else if (
|
|
438
|
+
} else if (maxA > 0 && aRatio < T.SYM_WARN) {
|
|
329
439
|
issues.push({
|
|
330
|
-
type: "
|
|
440
|
+
type: "symmetry",
|
|
441
|
+
sub: "density_mismatch",
|
|
331
442
|
severity: "warning",
|
|
332
|
-
detail: `Side-by-side columns have unequal
|
|
333
|
-
data: { ratio: Math.round(
|
|
443
|
+
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.`,
|
|
444
|
+
data: { ratio: Math.round(aRatio * 100), areas: areas.map((a) => Math.round(a / 1000)).join(",") },
|
|
334
445
|
})
|
|
335
446
|
}
|
|
447
|
+
}
|
|
336
448
|
|
|
337
|
-
|
|
449
|
+
// Top-level rows (elements that are side-by-side at the top level)
|
|
450
|
+
const topRows = groupIntoRows(metrics.elements)
|
|
451
|
+
for (const row of topRows) {
|
|
452
|
+
checkRow(row)
|
|
453
|
+
// One level deep: check nested rows inside each column
|
|
338
454
|
for (const col of row) {
|
|
339
455
|
if (col.children.length >= 2) {
|
|
340
456
|
const nestedRows = groupIntoRows(col.children)
|
|
341
457
|
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
|
-
}
|
|
458
|
+
checkRow(nestedRow, col.selector)
|
|
356
459
|
}
|
|
357
460
|
}
|
|
358
461
|
}
|
|
359
462
|
}
|
|
360
463
|
|
|
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
|
-
})
|
|
464
|
+
// Also check children of every top-level element that is NOT itself part of a row.
|
|
465
|
+
// This catches containers like .two-col whose children are side-by-side columns,
|
|
466
|
+
// even when the container itself is stacked vertically (no top-level sibling to pair with).
|
|
467
|
+
const inTopRow = new Set(topRows.flat().map((e) => e.selector))
|
|
468
|
+
for (const el of metrics.elements) {
|
|
469
|
+
if (!el.visible || inTopRow.has(el.selector)) continue
|
|
470
|
+
if (el.children.length >= 2) {
|
|
471
|
+
const childRows = groupIntoRows(el.children)
|
|
472
|
+
for (const row of childRows) {
|
|
473
|
+
checkRow(row, el.selector)
|
|
474
|
+
}
|
|
475
|
+
}
|
|
380
476
|
}
|
|
381
477
|
|
|
382
478
|
return issues
|
|
383
479
|
}
|
|
384
480
|
|
|
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
|
-
}
|
|
481
|
+
// ── Dimension 4: Rhythm ───────────────────────────────────────────────────────
|
|
392
482
|
|
|
393
483
|
/**
|
|
394
|
-
*
|
|
395
|
-
* to its bottommost child's bottom (ignoring CSS stretch padding).
|
|
484
|
+
* Coefficient of variation: stddev / mean. Returns 0 if mean is 0.
|
|
396
485
|
*/
|
|
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
|
|
486
|
+
function cv(values: number[]): number {
|
|
487
|
+
if (values.length < 2) return 0
|
|
488
|
+
const mean = values.reduce((s, v) => s + v, 0) / values.length
|
|
489
|
+
if (mean === 0) return 0
|
|
490
|
+
const variance = values.reduce((s, v) => s + (v - mean) ** 2, 0) / values.length
|
|
491
|
+
return Math.sqrt(variance) / mean
|
|
410
492
|
}
|
|
411
493
|
|
|
412
494
|
/**
|
|
413
|
-
* Check
|
|
495
|
+
* Check 4: Rhythm — spacing regularity between stacked siblings.
|
|
414
496
|
*
|
|
415
|
-
*
|
|
416
|
-
*
|
|
417
|
-
* This check counts leaf elements and actual content height in each column.
|
|
497
|
+
* Sub-checks:
|
|
498
|
+
* - gap_variance: vertical gaps between stacked siblings are uneven
|
|
418
499
|
*/
|
|
419
|
-
function
|
|
500
|
+
function checkRhythm(metrics: SlideMetrics): LayoutIssue[] {
|
|
420
501
|
const issues: LayoutIssue[] = []
|
|
421
502
|
|
|
422
|
-
//
|
|
423
|
-
|
|
503
|
+
// Exempt structural slides
|
|
504
|
+
if (metrics.slideType && EXEMPT_TYPES.has(metrics.slideType)) return []
|
|
424
505
|
|
|
425
|
-
|
|
426
|
-
if (
|
|
506
|
+
function checkContainer(els: ElementInfo[], containerSelector?: string) {
|
|
507
|
+
if (els.length < 2) return
|
|
427
508
|
|
|
428
|
-
|
|
429
|
-
const
|
|
509
|
+
// Identify vertically-stacked children (high horizontal overlap, low vertical overlap)
|
|
510
|
+
const visibleEls = els.filter((e) => e.visible).sort((a, b) => a.rect.top - b.rect.top)
|
|
511
|
+
if (visibleEls.length < T.GAP_MIN_CHILDREN) return
|
|
430
512
|
|
|
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
|
-
}
|
|
513
|
+
// Check if elements are mostly stacked (not side-by-side)
|
|
514
|
+
// Heuristic: average horizontal overlap > 0.5 among adjacent pairs
|
|
515
|
+
let hOverlapSum = 0
|
|
516
|
+
for (let i = 0; i < visibleEls.length - 1; i++) {
|
|
517
|
+
hOverlapSum += horizontalOverlap(visibleEls[i].rect, visibleEls[i + 1].rect)
|
|
518
|
+
}
|
|
519
|
+
const avgHOverlap = hOverlapSum / (visibleEls.length - 1)
|
|
520
|
+
if (avgHOverlap < 0.5) return // Side-by-side layout, not stacked
|
|
521
|
+
|
|
522
|
+
// Compute gaps between adjacent stacked elements
|
|
523
|
+
const gaps: number[] = []
|
|
524
|
+
for (let i = 0; i < visibleEls.length - 1; i++) {
|
|
525
|
+
const gap = visibleEls[i + 1].rect.top - visibleEls[i].rect.bottom
|
|
526
|
+
if (gap >= 0) gaps.push(gap) // negative gap = overlapping, skip
|
|
451
527
|
}
|
|
528
|
+
if (gaps.length < 2) return
|
|
452
529
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
})
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
530
|
+
const meanGap = gaps.reduce((s, g) => s + g, 0) / gaps.length
|
|
531
|
+
if (meanGap < T.GAP_MIN_MEAN) return
|
|
532
|
+
|
|
533
|
+
const gapCV = cv(gaps)
|
|
534
|
+
const label = containerSelector ? `inside \`${containerSelector}\`` : "in slide"
|
|
535
|
+
|
|
536
|
+
if (gapCV > T.GAP_CV_ERROR) {
|
|
537
|
+
issues.push({
|
|
538
|
+
type: "rhythm",
|
|
539
|
+
sub: "gap_variance",
|
|
540
|
+
severity: "error",
|
|
541
|
+
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.`,
|
|
542
|
+
data: { cv: Math.round(gapCV * 100), gaps: gaps.map(Math.round).join(",") },
|
|
543
|
+
})
|
|
544
|
+
} else if (gapCV > T.GAP_CV_WARN) {
|
|
545
|
+
issues.push({
|
|
546
|
+
type: "rhythm",
|
|
547
|
+
sub: "gap_variance",
|
|
548
|
+
severity: "warning",
|
|
549
|
+
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.`,
|
|
550
|
+
data: { cv: Math.round(gapCV * 100), gaps: gaps.map(Math.round).join(",") },
|
|
551
|
+
})
|
|
473
552
|
}
|
|
474
553
|
}
|
|
475
554
|
|
|
476
|
-
//
|
|
555
|
+
// Check at top-level and one level deep
|
|
556
|
+
checkContainer(metrics.elements)
|
|
477
557
|
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
|
-
}
|
|
558
|
+
if (el.children.length > 0) {
|
|
559
|
+
checkContainer(el.children, el.selector)
|
|
508
560
|
}
|
|
509
561
|
}
|
|
510
562
|
|
|
511
563
|
return issues
|
|
512
564
|
}
|
|
513
565
|
|
|
514
|
-
// ── Main export
|
|
566
|
+
// ── Main export ───────────────────────────────────────────────────────────────
|
|
515
567
|
|
|
516
568
|
/**
|
|
517
|
-
* Run all checks on a set of slide metrics and produce a QA report.
|
|
569
|
+
* Run all four dimension checks on a set of slide metrics and produce a QA report.
|
|
518
570
|
*/
|
|
519
571
|
export function runChecks(filePath: string, allMetrics: SlideMetrics[]): QAReport {
|
|
520
572
|
const slides: SlideReport[] = []
|
|
521
573
|
|
|
522
574
|
for (const metrics of allMetrics) {
|
|
523
575
|
const issues: LayoutIssue[] = [
|
|
524
|
-
...checkFill(metrics),
|
|
525
|
-
...checkBottomWhitespace(metrics),
|
|
526
576
|
...checkOverflow(metrics),
|
|
527
|
-
...
|
|
528
|
-
...
|
|
529
|
-
...
|
|
577
|
+
...checkBalance(metrics),
|
|
578
|
+
...checkSymmetry(metrics),
|
|
579
|
+
...checkRhythm(metrics),
|
|
530
580
|
]
|
|
531
581
|
|
|
532
582
|
slides.push({ index: metrics.index, title: metrics.title, issues })
|
|
@@ -550,7 +600,7 @@ export function runChecks(filePath: string, allMetrics: SlideMetrics[]): QARepor
|
|
|
550
600
|
return { file: filePath, slides, totalIssues, errorCount, warningCount, summary }
|
|
551
601
|
}
|
|
552
602
|
|
|
553
|
-
// ── Report formatter
|
|
603
|
+
// ── Report formatter ──────────────────────────────────────────────────────────
|
|
554
604
|
|
|
555
605
|
/**
|
|
556
606
|
* Format a QAReport into a markdown string suitable for the LLM to read.
|
|
@@ -573,7 +623,8 @@ export function formatReport(report: QAReport): string {
|
|
|
573
623
|
lines.push(`### Slide ${slide.index + 1}: ${slide.title}`)
|
|
574
624
|
for (const issue of slide.issues) {
|
|
575
625
|
const icon = issue.severity === "error" ? "🔴" : "🟡"
|
|
576
|
-
|
|
626
|
+
const label = issue.sub ? `${issue.type}/${issue.sub}` : issue.type
|
|
627
|
+
lines.push(`- ${icon} **${label}**: ${issue.detail}`)
|
|
577
628
|
}
|
|
578
629
|
lines.push("")
|
|
579
630
|
}
|
|
@@ -581,13 +632,14 @@ export function formatReport(report: QAReport): string {
|
|
|
581
632
|
lines.push(
|
|
582
633
|
`### Action Required`,
|
|
583
634
|
``,
|
|
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.`,
|
|
635
|
+
`Please fix the above layout issues in the HTML file. For each issue type:`,
|
|
588
636
|
`- **overflow**: reduce font size, padding, or content amount for the affected element.`,
|
|
589
|
-
`- **
|
|
590
|
-
`- **
|
|
637
|
+
`- **balance/centroid_offset**: redistribute content so the visual weight is centred — avoid concentrating everything in one corner or side.`,
|
|
638
|
+
`- **balance/bottom_gap**: expand content to fill the slide, use \`flex: 1\` on containers, add more content blocks, or reduce top padding.`,
|
|
639
|
+
`- **balance/sparse**: add more content components, increase font sizes, or use a layout with fewer columns.`,
|
|
640
|
+
`- **symmetry/height_mismatch**: equalise side-by-side column heights — use \`align-items: stretch\` or match content density.`,
|
|
641
|
+
`- **symmetry/density_mismatch**: balance content between columns — add items to the sparse column or reduce items in the dense one.`,
|
|
642
|
+
`- **rhythm/gap_variance**: use consistent \`gap\` or \`margin\` values between stacked elements instead of mixing sizes.`,
|
|
591
643
|
)
|
|
592
644
|
|
|
593
645
|
return lines.join("\n")
|