@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 CHANGED
@@ -2,14 +2,18 @@
2
2
 
3
3
  **English** | [中文](README.zh-CN.md)
4
4
 
5
+ [![npm version](https://img.shields.io/npm/v/@cyber-dash-tech/revela)](https://www.npmjs.com/package/@cyber-dash-tech/revela) [![license](https://img.shields.io/npm/l/@cyber-dash-tech/revela)](LICENSE) [![tests](https://img.shields.io/badge/tests-73%20passing-brightgreen)](tests/) [![OpenCode plugin](https://img.shields.io/badge/OpenCode-plugin-blue)](https://opencode.ai) [![Bun](https://img.shields.io/badge/Bun-%E2%89%A51.0-orange)](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
- Describe what you want to present Revela researches, analyses, and generates a complete slide deck for you.
12
+ Tell Revela what's on your mindit'll finish the research and analysis, and deliver a complete slide deck in a couple of minutes.
13
+
7
14
 
8
- [![npm version](https://img.shields.io/npm/v/@cyber-dash-tech/revela)](https://www.npmjs.com/package/@cyber-dash-tech/revela)
9
- [![license](https://img.shields.io/npm/l/@cyber-dash-tech/revela)](LICENSE)
10
- [![tests](https://img.shields.io/badge/tests-73%20passing-brightgreen)](tests/)
11
- [![OpenCode plugin](https://img.shields.io/badge/OpenCode-plugin-blue)](https://opencode.ai)
12
- [![Bun](https://img.shields.io/badge/Bun-%E2%89%A51.0-orange)](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
- Then describe your presentation goal to the AI. Revela will understand your real intent through conversation, then generate a complete slide deck. Open the output file in any browser — no build step, no framework.
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 | ![default](assets/img/slide-example-default.jpg) |
106
+ | `minimal` | Clean light theme — high contrast, generous whitespace, professional look | ![minimal](assets/img/slide-example-minimal.jpg) |
107
+ | `editorial-ribbon` | Bold editorial layout — accent ribbons, strong headlines, high visual impact | ![editorial-ribbon](assets/img/slide-example-ribbon.jpg) |
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
+ [![npm version](https://img.shields.io/npm/v/@cyber-dash-tech/revela)](https://www.npmjs.com/package/@cyber-dash-tech/revela) [![license](https://img.shields.io/npm/l/@cyber-dash-tech/revela)](LICENSE) [![tests](https://img.shields.io/badge/tests-73%20passing-brightgreen)](tests/) [![OpenCode plugin](https://img.shields.io/badge/OpenCode-plugin-blue)](https://opencode.ai) [![Bun](https://img.shields.io/badge/Bun-%E2%89%A51.0-orange)](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
- [![npm version](https://img.shields.io/npm/v/@cyber-dash-tech/revela)](https://www.npmjs.com/package/@cyber-dash-tech/revela)
9
- [![license](https://img.shields.io/npm/l/@cyber-dash-tech/revela)](LICENSE)
10
- [![tests](https://img.shields.io/badge/tests-73%20passing-brightgreen)](tests/)
11
- [![OpenCode plugin](https://img.shields.io/badge/OpenCode-plugin-blue)](https://opencode.ai)
12
- [![Bun](https://img.shields.io/badge/Bun-%E2%89%A51.0-orange)](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
- 然后向 AI 描述你的演讲目标。Revela 会通过聊天洞察你的真实诉求,随后生成一个完整的幻灯片文件。用浏览器直接打开,无需构建,无需框架。
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 数据可视化 | ![default](assets/img/slide-example-default.jpg) |
137
+ | `minimal` | 简洁浅色主题 —— 高对比度,充足留白,专业外观 | ![minimal](assets/img/slide-example-minimal.jpg) |
138
+ | `editorial-ribbon` | 大胆的编辑版式 —— 强调色横幅,醒目标题,高视觉冲击力 | ![editorial-ribbon](assets/img/slide-example-ribbon.jpg) |
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
- ## 布局 QA
167
+ ## 排版 QA
160
168
 
161
- 每次 AI 写入幻灯片文件时,Revela 会自动在 1920×1080 分辨率下运行基于 Puppeteer 的布局检测。发现问题后立即将报告反馈给 AI,AI 自行修正,无需人工干预。
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
- * Pure geometry-based layout checks — no class-name dependency.
4
+ * Geometry-based layout quality checks — four orthogonal visual dimensions.
5
+ *
6
+ * Dimension 1: Overflow — elements exceed canvas bounds (correctness)
7
+ * Dimension 2: Balance — content centroid & distribution (fill, sparsity)
8
+ * Dimension 3: Symmetry — side-by-side element consistency (height, density)
9
+ * Dimension 4: Rhythm — spacing regularity & internal whitespace
5
10
  *
6
11
  * All checks operate on SlideMetrics produced by measure.ts.
7
- * The checks are designed to be design-system-agnostic: they detect
8
- * structural layout problems regardless of CSS class names or component types.
12
+ * Design-system-agnostic: no CSS class-name assumptions.
9
13
  */
10
14
 
11
15
  import type { SlideMetrics, ElementInfo, Rect } from "./measure"
12
16
  import { CANVAS_W, CANVAS_H } from "./measure"
13
17
 
14
- // ── Types ────────────────────────────────────────────────────────────────────
18
+ // ── Types ─────────────────────────────────────────────────────────────────────
15
19
 
16
20
  export type IssueSeverity = "error" | "warning" | "info"
17
21
 
18
22
  export interface LayoutIssue {
19
- type:
20
- | "underfill" // canvas not filled enough
21
- | "bottom_whitespace" // large gap at bottom of slide
22
- | "asymmetry" // side-by-side elements with large height difference
23
- | "density_imbalance" // side-by-side columns with very different content density
24
- | "overflow" // element exceeds canvas bounds
25
- | "sparse" // very few visible elements
26
- | "card_height_variance" // cards in same row have very different heights
23
+ type: "overflow" | "balance" | "symmetry" | "rhythm"
24
+ /** Sub-category within the dimension */
25
+ sub?: "centroid_offset" | "bottom_gap" | "sparse"
26
+ | "height_mismatch" | "density_mismatch"
27
+ | "gap_variance"
27
28
  severity: IssueSeverity
28
29
  /** Human-readable description for the LLM to act on */
29
30
  detail: string
@@ -46,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
- * This is the single source of truth consumed by:
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. 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.
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
- /** 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 */
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
- /** Card height ratio (min/max in same row) below this → variance warning */
113
- CARD_VAR_WARN: 0.65,
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 between two rects [0..1] relative to the shorter one. */
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 width. */
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 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.
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
- // ── Individual checks ────────────────────────────────────────────────────────
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
- /** Check 1: Canvas fill ratio (content area / total canvas area) */
177
- function checkFill(metrics: SlideMetrics): LayoutIssue[] {
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, elements } = metrics
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: "sparse",
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
- // If the slide has an explicit type, use it — no geometry guessing needed
266
+ // Exempt structural slides
191
267
  if (metrics.slideType && EXEMPT_TYPES.has(metrics.slideType)) return []
192
268
 
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) {
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: "underfill",
282
+ type: "balance",
283
+ sub: "sparse",
221
284
  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) },
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
- 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
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
- // If the slide has an explicit type, use it — no geometry guessing needed
238
- if (metrics.slideType && EXEMPT_TYPES.has(metrics.slideType)) return []
307
+ if (totalArea > 0) {
308
+ const centroidX = weightedX / totalArea
309
+ const centroidY = weightedY / totalArea
239
310
 
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
- })()
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
- if (isCoverLike) return []
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
- // contentRect is in canvas-relative coords; canvasRect.bottom is the canvas height
252
- const gap = canvasRect.bottom - contentRect.bottom
336
+ // ── Sub-check: bottom gap ────────────────────────────────────────────────
337
+ const bottomGap = canvasRect.bottom - contentRect.bottom
253
338
 
254
- if (gap > T.BOTTOM_WS_ERROR) {
339
+ if (bottomGap > T.BOTTOM_GAP_ERROR) {
255
340
  issues.push({
256
- type: "bottom_whitespace",
341
+ type: "balance",
342
+ sub: "bottom_gap",
257
343
  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) },
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 (gap > T.BOTTOM_WS_WARN) {
347
+ } else if (bottomGap > T.BOTTOM_GAP_WARN) {
262
348
  issues.push({
263
- type: "bottom_whitespace",
349
+ type: "balance",
350
+ sub: "bottom_gap",
264
351
  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) },
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
- /** Check 3: Overflow — elements extending beyond the canvas boundaries */
274
- function checkOverflow(metrics: SlideMetrics): LayoutIssue[] {
275
- const issues: LayoutIssue[] = []
276
- const { canvasRect } = metrics
360
+ // ── Dimension 3: Symmetry ─────────────────────────────────────────────────────
277
361
 
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
- }
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
- walkElements(metrics.elements)
301
- return issues
302
- }
376
+ function checkRow(row: ElementInfo[], parentSelector?: string) {
377
+ if (row.length < 2) return
303
378
 
304
- /** Check 4: Asymmetry — side-by-side elements with large height difference */
305
- function checkAsymmetry(metrics: SlideMetrics): LayoutIssue[] {
306
- const issues: LayoutIssue[] = []
379
+ const heights = row.map((e) => e.rect.height)
380
+ const contHeights = row.map(contentHeight)
381
+ const areas = row.map(leafArea)
307
382
 
308
- // Check at the top level of .slide-canvas children
309
- const rows = groupIntoRows(metrics.elements)
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
- for (const row of rows) {
312
- if (row.length < 2) continue
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
- const heights = row.map((e) => e.rect.height)
315
- const minH = Math.min(...heights)
316
- const maxH = Math.max(...heights)
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
- if (maxH === 0) continue
319
- const ratio = minH / maxH
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
- if (ratio < T.ASYM_ERROR) {
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: "asymmetry",
432
+ type: "symmetry",
433
+ sub: "density_mismatch",
324
434
  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) },
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 (ratio < T.ASYM_WARN) {
438
+ } else if (maxA > 0 && aRatio < T.SYM_WARN) {
329
439
  issues.push({
330
- type: "asymmetry",
440
+ type: "symmetry",
441
+ sub: "density_mismatch",
331
442
  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) },
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
- // Also recursively check inside each column for nested rows
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
- 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
- }
458
+ checkRow(nestedRow, col.selector)
356
459
  }
357
460
  }
358
461
  }
359
462
  }
360
463
 
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
- })
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
- * 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).
484
+ * Coefficient of variation: stddev / mean. Returns 0 if mean is 0.
396
485
  */
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
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 6: Content density imbalance in side-by-side columns.
495
+ * Check 4: Rhythm spacing regularity between stacked siblings.
414
496
  *
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.
497
+ * Sub-checks:
498
+ * - gap_variance: vertical gaps between stacked siblings are uneven
418
499
  */
419
- function checkDensityImbalance(metrics: SlideMetrics): LayoutIssue[] {
500
+ function checkRhythm(metrics: SlideMetrics): LayoutIssue[] {
420
501
  const issues: LayoutIssue[] = []
421
502
 
422
- // Find rows at the top level
423
- const rows = groupIntoRows(metrics.elements)
503
+ // Exempt structural slides
504
+ if (metrics.slideType && EXEMPT_TYPES.has(metrics.slideType)) return []
424
505
 
425
- for (const row of rows) {
426
- if (row.length < 2) continue
506
+ function checkContainer(els: ElementInfo[], containerSelector?: string) {
507
+ if (els.length < 2) return
427
508
 
428
- const leafCounts = row.map(countLeaves)
429
- const contentHeights = row.map(contentHeight)
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 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
- }
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
- // 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
- }
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
- // Also check one level deep (containers that hold two-column layouts)
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 >= 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
- }
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
- ...checkAsymmetry(metrics),
528
- ...checkSparse(metrics),
529
- ...checkDensityImbalance(metrics),
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
- lines.push(`- ${icon} **${issue.type}**: ${issue.detail}`)
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
- `- **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.`,
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")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyber-dash-tech/revela",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "OpenCode plugin that turns AI into an HTML slide deck generator",
5
5
  "type": "module",
6
6
  "main": "./index.ts",