@cyber-dash-tech/revela 0.7.0 → 0.7.4

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,7 +2,7 @@
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-207%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)
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-171%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
6
 
7
7
  <p align="center">
8
8
  <img src="assets/img/logo.png" alt="Revela" width="800" />
@@ -22,7 +22,8 @@ Enable it for the current session, assign a presentation task, and the agent can
22
22
  - supports workspace document discovery, transparent text extraction for `.pdf`, `.docx`, `.pptx`, and `.xlsx`, and cached embedded-material extraction for those formats
23
23
  - uses workspace `DECKS.json` as machine-readable deck memory, slide spec, and prewrite readiness state
24
24
  - blocks premature writes to `decks/*.html` until the active deck is marked structurally ready
25
- - runs automatic layout QA whenever the agent writes `decks/*.html`
25
+ - runs fast design compliance checks whenever the agent writes, patches, or edits `decks/*.html`
26
+ - opens a visual comment editor for existing decks so users can Ctrl/Cmd-click elements and send precise edit requests back to OpenCode
26
27
  - exports finished decks to PDF and editable PPTX
27
28
  - switches designs and domains locally with zero LLM cost
28
29
 
@@ -139,6 +140,7 @@ Disable presentation mode when done:
139
140
  /revela init initialize or refresh workspace DECKS.json
140
141
  /revela review [slug] review active deck readiness before writing HTML
141
142
  /revela remember <text> save an explicit user/workflow preference
143
+ /revela edit <target> open visual comment editor for a deck slug or decks/*.html
142
144
 
143
145
  /revela designs list installed designs
144
146
  /revela designs <name> activate a design
@@ -157,7 +159,7 @@ Disable presentation mode when done:
157
159
  /revela pptx <file> export an HTML deck to editable PPTX in the same directory
158
160
  ```
159
161
 
160
- Most `/revela` commands run locally with zero LLM cost. `/revela init`, `/revela review`, `/revela remember`, `/revela designs-new`, and `/revela designs-edit` start AI-assisted workflows because they need to read or update project files.
162
+ Most `/revela` commands run locally with zero LLM cost. `/revela init`, `/revela review`, `/revela remember`, `/revela designs-new`, and `/revela designs-edit` start AI-assisted workflows because they need to read or update project files. `/revela edit` opens a local visual editor and then sends user comments back into the current OpenCode session when the user submits them.
161
163
 
162
164
  ---
163
165
 
@@ -300,23 +302,18 @@ This keeps final decks stable, offline-friendly, and independent from expiring r
300
302
 
301
303
  ## Layout QA And Compliance
302
304
 
303
- Every time the agent writes `decks/*.html`, Revela runs an automatic Puppeteer-based QA pass at `1920x1080`.
304
- The report is returned immediately so the agent can fix problems before moving on.
305
+ Every time the agent writes, patches, or edits `decks/*.html`, Revela runs a fast static design compliance check.
306
+ The manual `revela-qa` tool and PDF/PPTX export preflight also run a Puppeteer-based overflow check at `1920x1080`.
305
307
 
306
- Current QA dimensions:
308
+ Current QA checks:
307
309
 
308
310
  | Dimension | What it checks |
309
311
  |---|---|
310
312
  | `overflow` | Elements extending outside the slide canvas |
311
- | `balance` | Sparse slides, centroid drift, and bottom-gap issues |
312
- | `symmetry` | Side-by-side column imbalance in height or density |
313
- | `rhythm` | Irregular vertical spacing between stacked siblings |
314
313
  | `compliance` | Unknown design classes and novel CSS rules outside the active design vocabulary |
315
314
 
316
315
  Each slide must declare `slide-qa="true"` or `slide-qa="false"`.
317
-
318
- - use `slide-qa="true"` for content-heavy slides that should undergo full QA
319
- - use `slide-qa="false"` for structural slides such as cover, TOC, quote, summary, or closing pages
316
+ The current QA path keeps this as deck metadata; it does not enable additional subjective balance or spacing checks.
320
317
 
321
318
  You can also run QA manually with the `revela-qa` tool.
322
319
 
@@ -592,6 +589,23 @@ A custom domain is a folder containing `INDUSTRY.md`.
592
589
 
593
590
  ---
594
591
 
592
+ ## Visual Editing
593
+
594
+ Open the visual editor for an existing deck by slug or workspace-relative HTML path:
595
+
596
+ ```text
597
+ /revela edit my-deck
598
+ /revela edit decks/my-deck.html
599
+ ```
600
+
601
+ The editor opens in your browser. Use `Ctrl`/`Cmd` + click to reference deck elements, write a natural-language comment, then send it back to OpenCode. Revela sends a structured edit prompt that includes the deck file, slide context, selected element metadata, and your comment.
602
+
603
+ LLM tool equivalent: `revela-edit` with `{ "target": "decks/my-deck.html" }`. This lets the agent open the same editor when you say things like “I want to edit @decks/my-deck.html”.
604
+
605
+ `/revela edit` prepares minimal `DECKS.json` state for the existing HTML deck if needed, so the normal deck write gate can still protect `decks/*.html` while allowing targeted edits.
606
+
607
+ ---
608
+
595
609
  ## Export
596
610
 
597
611
  PDF export:
package/README.zh-CN.md CHANGED
@@ -2,7 +2,7 @@
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-207%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)
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-171%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
6
 
7
7
  <p align="center">
8
8
  <img src="assets/img/logo.png" alt="Revela" width="800" />
@@ -22,7 +22,8 @@ Revela 是一个 [OpenCode](https://opencode.ai) 插件,可以把你当前使
22
22
  - 支持工作区文档扫描,以及 `.pdf`、`.docx`、`.pptx`、`.xlsx` 的透明文本提取和嵌入素材缓存提取
23
23
  - 使用工作区 `DECKS.json` 保存机器可读的 deck 记忆、逐页规格和写入前 readiness 状态
24
24
  - 在 active deck 结构化 ready 前,阻止过早写入 `decks/*.html`
25
- - agent 每次写入 `decks/*.html` 时自动执行布局 QA
25
+ - agent 每次写入、patch 或 edit `decks/*.html` 时自动执行快速 design compliance 检查
26
+ - 为已有 deck 打开可视化评论编辑器,用户可以 Ctrl/Cmd + 点击元素,并把精确修改意见发回 OpenCode
26
27
  - 支持导出成 PDF 和可编辑 PPTX
27
28
  - design 和 domain 的切换都在本地完成,不消耗 LLM token
28
29
 
@@ -138,6 +139,7 @@ Create a 6-slide HTML deck on humanoid robotics supply chains. Cite the main mar
138
139
  /revela init 初始化或刷新工作区 DECKS.json
139
140
  /revela review [slug] 写 HTML 前检查 active deck readiness
140
141
  /revela remember <text> 保存明确的用户/工作流偏好
142
+ /revela edit <target> 为 deck slug 或 decks/*.html 打开可视化评论编辑器
141
143
 
142
144
  /revela designs 列出已安装 design
143
145
  /revela designs <name> 激活某个 design
@@ -156,7 +158,7 @@ Create a 6-slide HTML deck on humanoid robotics supply chains. Cite the main mar
156
158
  /revela pptx <file> 将 HTML deck 导出为同目录可编辑 PPTX
157
159
  ```
158
160
 
159
- 大多数 `/revela` 命令都在本地执行,不消耗 LLM token。`/revela init`、`/revela review`、`/revela remember`、`/revela designs-new` 和 `/revela designs-edit` 会启动 AI 辅助流程,因为它们需要读取或更新项目状态。
161
+ 大多数 `/revela` 命令都在本地执行,不消耗 LLM token。`/revela init`、`/revela review`、`/revela remember`、`/revela designs-new` 和 `/revela designs-edit` 会启动 AI 辅助流程,因为它们需要读取或更新项目状态。`/revela edit` 会打开本地可视化编辑器,并在用户提交评论后把修改请求发回当前 OpenCode 会话。
160
162
 
161
163
  ---
162
164
 
@@ -266,23 +268,18 @@ Revela 使用工作区根目录的 `DECKS.json` 做跨会话记忆和 deck 生
266
268
 
267
269
  ## 布局 QA 与合规检查
268
270
 
269
- 每次 agent 写入 `decks/*.html` 时,Revela 都会自动在 `1920x1080` 下运行一轮基于 Puppeteer 的 QA。
270
- 报告会立刻返回,便于 agent 继续修正。
271
+ 每次 agent 写入、patch 或 edit `decks/*.html` 时,Revela 都会自动运行快速静态 design compliance 检查。
272
+ 手动 `revela-qa` 工具以及 PDF/PPTX 导出前置检查会额外在 `1920x1080` 下运行基于 Puppeteer 的 overflow 检查。
271
273
 
272
- 当前 QA 维度:
274
+ 当前 QA 检查:
273
275
 
274
276
  | 维度 | 检查内容 |
275
277
  |---|---|
276
278
  | `overflow` | 元素是否超出 slide canvas |
277
- | `balance` | 是否过稀、重心偏移、底部留白过大 |
278
- | `symmetry` | 并列列之间的高度或密度是否明显失衡 |
279
- | `rhythm` | 垂直堆叠元素之间的间距节奏是否不稳定 |
280
279
  | `compliance` | 是否使用了 active design 之外的 class 或新增 CSS 规则 |
281
280
 
282
281
  每张 slide 都必须声明 `slide-qa="true"` 或 `slide-qa="false"`。
283
-
284
- - `slide-qa="true"` 适用于内容型页面,执行完整 QA
285
- - `slide-qa="false"` 适用于封面、目录、引用、总结、结尾等结构型页面
282
+ 当前 QA 路径将其保留为 deck metadata,不再启用额外的主观平衡或间距检查。
286
283
 
287
284
  也可以手动调用 `revela-qa` 工具执行 QA。
288
285
 
@@ -557,6 +554,23 @@ Prompt 注入规则:
557
554
 
558
555
  ---
559
556
 
557
+ ## 可视化编辑
558
+
559
+ 可以通过 deck slug 或工作区相对 HTML 路径打开可视化编辑器:
560
+
561
+ ```text
562
+ /revela edit my-deck
563
+ /revela edit decks/my-deck.html
564
+ ```
565
+
566
+ 编辑器会在浏览器中打开。使用 `Ctrl`/`Cmd` + 点击 deck 元素来引用它们,写一段自然语言评论,然后发送回 OpenCode。Revela 会把 deck 文件、slide 上下文、选中元素 metadata 和你的评论整理成结构化 edit prompt。
567
+
568
+ 对应的 LLM tool:`revela-edit`,参数为 `{ "target": "decks/my-deck.html" }`。因此当你说“我要编辑 @decks/my-deck.html”时,agent 也可以主动打开同一个编辑器。
569
+
570
+ 如果已有 HTML deck 缺少 `DECKS.json` 状态,`/revela edit` 会自动准备最小 deck state,让正常的 `decks/*.html` 写入门禁仍然生效,同时允许后续精准修改。
571
+
572
+ ---
573
+
560
574
  ## 导出
561
575
 
562
576
  PDF 导出:
@@ -1,27 +1,4 @@
1
- import { existsSync } from "fs"
2
- import { ctx } from "../ctx"
3
- import { ACTIVE_PROMPT_FILE } from "../config"
4
- import { buildPrompt } from "../prompt-builder"
5
- import { resolveEditableDeck } from "../edit/resolve-deck"
6
- import { ensureEditableDeckState } from "../edit/deck-state"
7
- import { startEditServer } from "../edit/server"
8
-
9
- function openUrl(url: string): void {
10
- if (process.platform === "darwin") {
11
- const proc = Bun.spawnSync(["open", url])
12
- if (proc.exitCode !== 0) throw new Error(proc.stderr.toString() || "Failed to open edit page")
13
- return
14
- }
15
-
16
- if (process.platform === "win32") {
17
- const proc = Bun.spawnSync(["cmd", "/c", "start", "", url])
18
- if (proc.exitCode !== 0) throw new Error(proc.stderr.toString() || "Failed to open edit page")
19
- return
20
- }
21
-
22
- const proc = Bun.spawnSync(["xdg-open", url])
23
- if (proc.exitCode !== 0) throw new Error(proc.stderr.toString() || "Failed to open edit page")
24
- }
1
+ import { openEditableDeck } from "../edit/open"
25
2
 
26
3
  export async function handleEdit(
27
4
  input: string,
@@ -35,32 +12,17 @@ export async function handleEdit(
35
12
  }
36
13
 
37
14
  try {
38
- const deck = resolveEditableDeck(options.workspaceRoot, target)
39
- const preflight = ensureEditableDeckState(options.workspaceRoot, deck)
40
- if (!preflight.readiness.ready) {
41
- await send(`**Edit blocked:** ${preflight.readiness.blocker || "Deck is not ready for HTML edits."}`)
42
- return
43
- }
44
-
45
- ctx.enabled = true
46
- if (!existsSync(ACTIVE_PROMPT_FILE)) buildPrompt()
47
-
48
- const editServer = startEditServer()
49
- const token = editServer.createSession({
15
+ const result = openEditableDeck(target, {
50
16
  client: options.client,
51
17
  sessionID: options.sessionID,
52
- deck,
18
+ workspaceRoot: options.workspaceRoot,
53
19
  })
54
- const url = `${editServer.baseUrl}/edit?token=${encodeURIComponent(token)}`
55
- openUrl(url)
56
20
 
57
- const source = deck.source === "decks-state" ? "DECKS.json" : deck.source === "file-path" ? "file path" : "fallback path"
58
- const stateNote = preflight.changed ? "Deck state was prepared in DECKS.json before opening the editor.\n" : "Deck state is ready in DECKS.json.\n"
59
21
  await send(
60
- `Opened visual editor for deck \`${deck.slug}\`.\n` +
61
- `File: \`${deck.file}\` (${source})\n` +
62
- stateNote +
63
- `URL: ${url}\n\n` +
22
+ `Opened visual editor for deck \`${result.deck.slug}\`.\n` +
23
+ `File: \`${result.deck.file}\` (${result.source})\n` +
24
+ `${result.stateNote}\n` +
25
+ `URL: ${result.url}\n\n` +
64
26
  `Use Ctrl/Cmd + click in the browser to reference elements, write a comment, then send comments. Revela mode has been enabled for the edit prompt.`
65
27
  )
66
28
  } catch (e: any) {
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { resolve } from "path"
11
11
  import { exportToPdf } from "../pdf/export"
12
+ import { assertExportQAPassed } from "../qa/export-gate"
12
13
 
13
14
  export async function handlePdf(
14
15
  filePath: string,
@@ -23,9 +24,11 @@ export async function handlePdf(
23
24
  }
24
25
 
25
26
  const abs = resolve(filePath)
26
- await send(`Exporting \`${abs}\` to PDF...`)
27
+ await send(`Running pre-export QA for \`${abs}\`...`)
27
28
 
28
29
  try {
30
+ await assertExportQAPassed(abs)
31
+ await send(`Exporting \`${abs}\` to PDF...`)
29
32
  const result = await exportToPdf(filePath)
30
33
  const secs = (result.durationMs / 1000).toFixed(1)
31
34
  await send(
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { resolve } from "path"
11
11
  import { exportToPptx } from "../pptx/export"
12
+ import { assertExportQAPassed } from "../qa/export-gate"
12
13
 
13
14
  function formatSecs(ms: number): string {
14
15
  return `${(ms / 1000).toFixed(1)}s`
@@ -27,9 +28,11 @@ export async function handlePptx(
27
28
  }
28
29
 
29
30
  const abs = resolve(filePath)
30
- await send(`Exporting \`${abs}\` to PPTX...`)
31
+ await send(`Running pre-export QA for \`${abs}\`...`)
31
32
 
32
33
  try {
34
+ await assertExportQAPassed(abs)
35
+ await send(`Exporting \`${abs}\` to PPTX...`)
33
36
  let lastSlideUpdate = 0
34
37
  let longDeckThreshold: number | null = null
35
38
 
package/lib/config.ts CHANGED
@@ -28,7 +28,7 @@ export const CONFIG_FILE = join(CONFIG_DIR, "config.json")
28
28
  export const ACTIVE_PROMPT_FILE = join(CONFIG_DIR, "_active-prompt.md")
29
29
 
30
30
  /** Default design name. */
31
- export const DEFAULT_DESIGN = "aurora"
31
+ export const DEFAULT_DESIGN = "summit"
32
32
 
33
33
  /** Default domain name. */
34
34
  export const DEFAULT_DOMAIN = "general"
@@ -112,7 +112,7 @@ function safeActiveDesign(): string {
112
112
  try {
113
113
  return activeDesign()
114
114
  } catch {
115
- return "aurora"
115
+ return "summit"
116
116
  }
117
117
  }
118
118
 
@@ -0,0 +1,76 @@
1
+ import { existsSync } from "fs"
2
+ import { ctx } from "../ctx"
3
+ import { ACTIVE_PROMPT_FILE } from "../config"
4
+ import { seedBuiltinDesigns } from "../design/designs"
5
+ import { seedBuiltinDomains } from "../domain/domains"
6
+ import { buildPrompt } from "../prompt-builder"
7
+ import { ensureEditableDeckState } from "./deck-state"
8
+ import { resolveEditableDeck, type EditableDeck } from "./resolve-deck"
9
+ import { startEditServer } from "./server"
10
+
11
+ export interface OpenEditableDeckResult {
12
+ deck: EditableDeck
13
+ url: string
14
+ source: string
15
+ stateNote: string
16
+ preflightChanged: boolean
17
+ }
18
+
19
+ export interface OpenEditableDeckOptions {
20
+ client: any
21
+ sessionID: string
22
+ workspaceRoot: string
23
+ openBrowser?: boolean
24
+ }
25
+
26
+ export function openUrl(url: string): void {
27
+ if (process.platform === "darwin") {
28
+ const proc = Bun.spawnSync(["open", url])
29
+ if (proc.exitCode !== 0) throw new Error(proc.stderr.toString() || "Failed to open edit page")
30
+ return
31
+ }
32
+
33
+ if (process.platform === "win32") {
34
+ const proc = Bun.spawnSync(["cmd", "/c", "start", "", url])
35
+ if (proc.exitCode !== 0) throw new Error(proc.stderr.toString() || "Failed to open edit page")
36
+ return
37
+ }
38
+
39
+ const proc = Bun.spawnSync(["xdg-open", url])
40
+ if (proc.exitCode !== 0) throw new Error(proc.stderr.toString() || "Failed to open edit page")
41
+ }
42
+
43
+ export function openEditableDeck(target: string, options: OpenEditableDeckOptions): OpenEditableDeckResult {
44
+ const deck = resolveEditableDeck(options.workspaceRoot, target)
45
+ const preflight = ensureEditableDeckState(options.workspaceRoot, deck)
46
+ if (!preflight.readiness.ready) {
47
+ throw new Error(preflight.readiness.blocker || "Deck is not ready for HTML edits.")
48
+ }
49
+
50
+ ctx.enabled = true
51
+ if (!existsSync(ACTIVE_PROMPT_FILE)) {
52
+ seedBuiltinDesigns()
53
+ seedBuiltinDomains()
54
+ buildPrompt()
55
+ }
56
+
57
+ const editServer = startEditServer()
58
+ const token = editServer.createSession({
59
+ client: options.client,
60
+ sessionID: options.sessionID,
61
+ deck,
62
+ })
63
+ const url = `${editServer.baseUrl}/edit?token=${encodeURIComponent(token)}`
64
+ if (options.openBrowser !== false) openUrl(url)
65
+
66
+ const source = deck.source === "decks-state" ? "DECKS.json" : deck.source === "file-path" ? "file path" : "fallback path"
67
+ const stateNote = preflight.changed ? "Deck state was prepared in DECKS.json before opening the editor." : "Deck state is ready in DECKS.json."
68
+
69
+ return {
70
+ deck,
71
+ url,
72
+ source,
73
+ stateNote,
74
+ preflightChanged: preflight.changed,
75
+ }
76
+ }
@@ -76,6 +76,6 @@ Instructions:
76
76
  - Locate each target primarily with slideIndex, slideTitle, selected text, nearbyText, and outerHTMLExcerpt. Use selector/domPath as hints; they may be approximate.
77
77
  - Before patching or writing ${"`decks/*.html`"}, ensure ${"`DECKS.json`"} contains this deck and call ${"`revela-decks`"} with action ${"`review`"}. If ${"`DECKS.json`"} or the deck entry is missing, initialize/upsert the deck state with ${"`revela-decks`"} first. If readiness remains blocked, explain the blockers instead of forcing the edit.
78
78
  - Apply the edit to ${payload.file} only after readiness allows deck HTML changes.
79
- - After editing, run ${"`revela-qa`"} on ${payload.file} and fix any relevant regressions caused by the edit.
79
+ - Design compliance is checked automatically after deck writes. Run ${"`revela-qa`"} on ${payload.file} only when the edit may affect layout geometry, such as size, spacing, font scale, content amount, or container structure; fix any overflow it reports.
80
80
  - If the comment is ambiguous, ask one concise clarification question instead of guessing.`
81
81
  }
package/lib/qa/checks.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * lib/qa/checks.ts
3
3
  *
4
- * Geometry-based layout quality checks four orthogonal visual dimensions,
5
- * plus a design-compliance dimension that verifies CSS class usage.
4
+ * Geometry-based layout quality checks. The active default path only checks
5
+ * overflow; softer visual heuristics are kept here for future opt-in use.
6
6
  *
7
7
  * Dimension 1: Overflow — elements exceed canvas bounds (correctness)
8
8
  * Dimension 2: Balance — content centroid & distribution (fill, sparsity)
@@ -523,106 +523,13 @@ function checkRhythm(metrics: SlideMetrics): LayoutIssue[] {
523
523
  return issues
524
524
  }
525
525
 
526
- // ── Compliance checks ─────────────────────────────────────────────────────────
527
-
528
- /**
529
- * Check whether a class name is exempt from compliance checking.
530
- * Returns true if the class matches any of the given prefix exemptions.
531
- */
532
- function isExemptClass(cls: string, prefixExemptions: string[]): boolean {
533
- return prefixExemptions.some((prefix) => cls.startsWith(prefix))
534
- }
535
-
536
- /**
537
- * Dimension 5a: unknown_class
538
- *
539
- * Walk the element tree and flag any CSS class not in `allowedClasses`
540
- * and not matching any `prefixExemptions`. Each unique unknown class name
541
- * is reported at most once per slide (de-duplicated).
542
- */
543
- function checkCompliance(
544
- slide: SlideMetrics,
545
- allowedClasses: Set<string>,
546
- prefixExemptions: string[],
547
- ): LayoutIssue[] {
548
- const issues: LayoutIssue[] = []
549
- const reported = new Set<string>()
550
-
551
- function walk(el: ElementInfo): void {
552
- for (const cls of el.classList) {
553
- if (!cls) continue
554
- if (reported.has(cls)) continue
555
- if (allowedClasses.has(cls)) continue
556
- if (isExemptClass(cls, prefixExemptions)) continue
557
-
558
- reported.add(cls)
559
- issues.push({
560
- type: "compliance",
561
- sub: "unknown_class",
562
- severity: "warning",
563
- detail: `Element \`${el.selector}\` uses CSS class \`${cls}\` which is not defined in the active design. Replace it with a class from the Component Index or Layout Index.`,
564
- data: { class: cls, selector: el.selector },
565
- })
566
- }
567
- for (const child of el.children) {
568
- walk(child)
569
- }
570
- }
571
-
572
- for (const el of slide.elements) {
573
- walk(el)
574
- }
575
-
576
- return issues
577
- }
578
-
579
- /**
580
- * Dimension 5b: novel_css_rule
581
- *
582
- * Check whether the <style> block defines CSS classes not in `allowedClasses`.
583
- * Returns issues as a flat list (caller attaches them to slide 0).
584
- */
585
- function checkNovelCssRules(
586
- cssDefinedClasses: string[],
587
- allowedClasses: Set<string>,
588
- prefixExemptions: string[],
589
- ): LayoutIssue[] {
590
- const issues: LayoutIssue[] = []
591
- const reported = new Set<string>()
592
-
593
- for (const cls of cssDefinedClasses) {
594
- if (!cls) continue
595
- if (reported.has(cls)) continue
596
- if (allowedClasses.has(cls)) continue
597
- if (isExemptClass(cls, prefixExemptions)) continue
598
-
599
- reported.add(cls)
600
- issues.push({
601
- type: "compliance",
602
- sub: "novel_css_rule",
603
- severity: "warning",
604
- detail: `<style> defines CSS class \`.${cls}\` which is not part of the active design. Remove this custom rule and use the design's existing component styles. For minor adjustments, use inline \`style=""\` instead.`,
605
- data: { class: cls },
606
- })
607
- }
608
-
609
- return issues
610
- }
611
-
612
526
  // ── Main export ───────────────────────────────────────────────────────────────
613
527
 
614
528
  /**
615
- * Options for runChecks(). All fields are optional omitting them disables
616
- * the corresponding checks (backward compatible).
529
+ * Options for future geometry checks. The current default path only checks
530
+ * overflow, regardless of options.
617
531
  */
618
- export interface RunChecksOptions {
619
- /** Allowed CSS class vocabulary from the active design (enables compliance checks). */
620
- allowedClasses?: Set<string>
621
- /** Class name prefixes exempt from compliance checks (e.g. "lucide-", "echarts-"). */
622
- prefixExemptions?: string[]
623
- /** CSS class names defined in <style> blocks (enables novel_css_rule check). */
624
- cssDefinedClasses?: string[]
625
- }
532
+ export interface RunChecksOptions {}
626
533
 
627
534
  /**
628
535
  * Run all dimension checks on a set of slide metrics and produce a QA report.
@@ -630,31 +537,12 @@ export interface RunChecksOptions {
630
537
  export function runChecks(
631
538
  filePath: string,
632
539
  allMetrics: SlideMetrics[],
633
- options?: RunChecksOptions,
540
+ _options?: RunChecksOptions,
634
541
  ): QAReport {
635
542
  const slides: SlideReport[] = []
636
- const { allowedClasses, prefixExemptions = [], cssDefinedClasses } = options ?? {}
637
-
638
- // novel_css_rule issues are global (not per-slide); attach to slide 0.
639
- const novelCssIssues: LayoutIssue[] =
640
- allowedClasses && cssDefinedClasses
641
- ? checkNovelCssRules(cssDefinedClasses, allowedClasses, prefixExemptions)
642
- : []
643
543
 
644
544
  for (const metrics of allMetrics) {
645
- const complianceIssues: LayoutIssue[] =
646
- allowedClasses
647
- ? checkCompliance(metrics, allowedClasses, prefixExemptions)
648
- : []
649
-
650
- const issues: LayoutIssue[] = [
651
- ...checkOverflow(metrics),
652
- ...checkBalance(metrics),
653
- ...checkRhythm(metrics),
654
- ...complianceIssues,
655
- // Attach novel_css_rule issues to slide 0 only
656
- ...(metrics.index === 0 ? novelCssIssues : []),
657
- ]
545
+ const issues: LayoutIssue[] = [...checkOverflow(metrics)]
658
546
 
659
547
  slides.push({ index: metrics.index, title: metrics.title, issues })
660
548
  }
@@ -709,12 +597,8 @@ export function formatReport(report: QAReport): string {
709
597
  lines.push(
710
598
  `### Action Required`,
711
599
  ``,
712
- `Please fix the above layout issues in the HTML file. For each issue type:`,
600
+ `Please fix the above hard-error issues in the HTML file. For each issue type:`,
713
601
  `- **overflow**: reduce font size, padding, or content amount for the affected element.`,
714
- `- **balance/centroid_offset**: redistribute content so the visual weight is centred — avoid concentrating everything in one corner or side.`,
715
- `- **balance/bottom_gap**: expand content to fill the slide, use \`flex: 1\` on containers, add more content blocks, or reduce top padding.`,
716
- `- **balance/sparse**: add more content components, increase font sizes, or use a layout with fewer columns.`,
717
- `- **rhythm/gap_variance**: use consistent \`gap\` or \`margin\` values between stacked elements instead of mixing sizes.`,
718
602
  `- **compliance/unknown_class**: an HTML element uses a CSS class not defined in the active design. Replace it with a class from the Component Index or Layout Index. Fetch the component/layout details with the \`revela-designs\` tool if needed.`,
719
603
  `- **compliance/novel_css_rule**: \`<style>\` defines a CSS class that is not part of the active design. Remove the custom rule and use the design's existing component styles. For minor spacing/sizing adjustments, use inline \`style=""\` instead.`,
720
604
  )
@@ -0,0 +1,141 @@
1
+ import { readFileSync } from "fs"
2
+ import type { DesignClassVocabulary } from "../design/designs"
3
+ import type { LayoutIssue, QAReport, SlideReport } from "./checks"
4
+
5
+ interface ClassUse {
6
+ className: string
7
+ selector: string
8
+ }
9
+
10
+ interface SlideClassUses {
11
+ title: string
12
+ uses: ClassUse[]
13
+ }
14
+
15
+ function isExemptClass(cls: string, prefixExemptions: string[]): boolean {
16
+ return prefixExemptions.some((prefix) => cls.startsWith(prefix))
17
+ }
18
+
19
+ function stripTags(value: string): string {
20
+ return value.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim()
21
+ }
22
+
23
+ function extractTitle(html: string, index: number): string {
24
+ const match = /<(?:h1|h2|h3|title)\b[^>]*>([\s\S]*?)<\/(?:h1|h2|h3|title)>/i.exec(html)
25
+ const title = match ? stripTags(match[1]).slice(0, 80) : ""
26
+ return title || `Slide ${index + 1}`
27
+ }
28
+
29
+ function extractClassUses(html: string): ClassUse[] {
30
+ const uses: ClassUse[] = []
31
+ const classAttrRe = /class\s*=\s*(["'])([\s\S]*?)\1/gi
32
+ let match: RegExpExecArray | null
33
+
34
+ while ((match = classAttrRe.exec(html)) !== null) {
35
+ const raw = match[2] || ""
36
+ for (const cls of raw.split(/\s+/).map((v) => v.trim()).filter(Boolean)) {
37
+ uses.push({ className: cls, selector: `.${cls}` })
38
+ }
39
+ }
40
+
41
+ return uses
42
+ }
43
+
44
+ function extractSlideClassUses(html: string): SlideClassUses[] {
45
+ const slides: SlideClassUses[] = []
46
+ const sectionRe = /<section\b[\s\S]*?<\/section>/gi
47
+ let match: RegExpExecArray | null
48
+ let index = 0
49
+
50
+ while ((match = sectionRe.exec(html)) !== null) {
51
+ const chunk = match[0]
52
+ slides.push({ title: extractTitle(chunk, index), uses: extractClassUses(chunk) })
53
+ index++
54
+ }
55
+
56
+ if (slides.length === 0) {
57
+ slides.push({ title: extractTitle(html, 0), uses: extractClassUses(html) })
58
+ }
59
+
60
+ return slides
61
+ }
62
+
63
+ function extractCssDefinedClasses(html: string): string[] {
64
+ const classes = new Set<string>()
65
+ const styleRe = /<style\b[^>]*>([\s\S]*?)<\/style>/gi
66
+ const classRe = /\.([a-zA-Z_][\w-]*)/g
67
+ let styleMatch: RegExpExecArray | null
68
+
69
+ while ((styleMatch = styleRe.exec(html)) !== null) {
70
+ classRe.lastIndex = 0
71
+ let classMatch: RegExpExecArray | null
72
+ while ((classMatch = classRe.exec(styleMatch[1])) !== null) {
73
+ classes.add(classMatch[1])
74
+ }
75
+ }
76
+
77
+ return [...classes]
78
+ }
79
+
80
+ function summarize(filePath: string, slides: SlideReport[]): QAReport {
81
+ const totalIssues = slides.reduce((sum, slide) => sum + slide.issues.length, 0)
82
+ const errorCount = slides.reduce((sum, slide) => sum + slide.issues.filter((issue) => issue.severity === "error").length, 0)
83
+ const warningCount = slides.reduce((sum, slide) => sum + slide.issues.filter((issue) => issue.severity === "warning").length, 0)
84
+ const summary = totalIssues === 0
85
+ ? "All slides passed layout QA."
86
+ : `Found ${totalIssues} issue(s): ${errorCount} error(s), ${warningCount} warning(s) across ${slides.filter((s) => s.issues.length > 0).length} slide(s).`
87
+
88
+ return { file: filePath, slides, totalIssues, errorCount, warningCount, summary }
89
+ }
90
+
91
+ export function runComplianceQA(htmlFilePath: string, vocabulary?: DesignClassVocabulary): QAReport {
92
+ const html = readFileSync(htmlFilePath, "utf-8")
93
+ const slideUses = extractSlideClassUses(html)
94
+ const allowedClasses = vocabulary?.classes
95
+ const prefixExemptions = vocabulary?.prefixExemptions ?? []
96
+
97
+ const slides: SlideReport[] = slideUses.map((slide, index) => {
98
+ const issues: LayoutIssue[] = []
99
+ const reported = new Set<string>()
100
+
101
+ if (allowedClasses) {
102
+ for (const use of slide.uses) {
103
+ if (reported.has(use.className)) continue
104
+ if (allowedClasses.has(use.className)) continue
105
+ if (isExemptClass(use.className, prefixExemptions)) continue
106
+
107
+ reported.add(use.className)
108
+ issues.push({
109
+ type: "compliance",
110
+ sub: "unknown_class",
111
+ severity: "warning",
112
+ detail: `HTML uses CSS class \`${use.className}\` which is not defined in the active design. Replace it with a class from the Component Index or Layout Index.`,
113
+ data: { class: use.className, selector: use.selector },
114
+ })
115
+ }
116
+ }
117
+
118
+ return { index, title: slide.title, issues }
119
+ })
120
+
121
+ if (allowedClasses && slides.length > 0) {
122
+ const first = slides[0]
123
+ const reported = new Set<string>()
124
+ for (const cls of extractCssDefinedClasses(html)) {
125
+ if (reported.has(cls)) continue
126
+ if (allowedClasses.has(cls)) continue
127
+ if (isExemptClass(cls, prefixExemptions)) continue
128
+
129
+ reported.add(cls)
130
+ first.issues.push({
131
+ type: "compliance",
132
+ sub: "novel_css_rule",
133
+ severity: "warning",
134
+ detail: `<style> defines CSS class \`.${cls}\` which is not part of the active design. Remove this custom rule and use the design's existing component styles. For minor adjustments, use inline \`style=""\` instead.`,
135
+ data: { class: cls },
136
+ })
137
+ }
138
+ }
139
+
140
+ return summarize(htmlFilePath, slides)
141
+ }
@@ -0,0 +1,11 @@
1
+ import { formatReport, runQA } from "./index"
2
+
3
+ export async function assertExportQAPassed(filePath: string): Promise<void> {
4
+ const report = await runQA(filePath)
5
+ if (report.totalIssues === 0) return
6
+
7
+ throw new Error(
8
+ "Export blocked because pre-export QA found issues. Fix them and export again.\n\n" +
9
+ formatReport(report)
10
+ )
11
+ }
package/lib/qa/index.ts CHANGED
@@ -1,24 +1,25 @@
1
1
  /**
2
2
  * lib/qa/index.ts
3
3
  *
4
- * Public entry point for the slide layout QA system.
5
- * Combines measurement (Puppeteer) + checks (geometry rules) into one call.
4
+ * Public entry point for hard-error slide QA.
5
+ * Runs overflow measurement only; design compliance is an automatic post-write
6
+ * hook concern, not part of manual/export QA.
6
7
  */
7
8
 
8
9
  import { measureSlides } from "./measure"
9
10
  import { runChecks, formatReport } from "./checks"
10
- import type { QAReport, RunChecksOptions } from "./checks"
11
+ import type { QAReport } from "./checks"
11
12
  import type { DesignClassVocabulary } from "../design/designs"
12
13
 
13
14
  export type { QAReport, SlideReport, LayoutIssue, IssueSeverity } from "./checks"
14
15
  export type { RunChecksOptions } from "./checks"
15
16
 
16
17
  /**
17
- * Run a full layout QA pass on `htmlFilePath`.
18
+ * Run hard-error QA on `htmlFilePath`.
18
19
  *
19
20
  * 1. Opens the file in headless Chrome (puppeteer-core)
20
21
  * 2. Measures each .slide element's geometry + CSS class definitions
21
- * 3. Runs all checks (overflow, balance, symmetry, rhythm, compliance)
22
+ * 3. Runs hard-error overflow checks only
22
23
  * 4. Returns a structured QAReport
23
24
  *
24
25
  * Pass `vocabulary` (from `extractDesignClasses()`) to enable compliance checks.
@@ -28,17 +29,10 @@ export type { RunChecksOptions } from "./checks"
28
29
  */
29
30
  export async function runQA(
30
31
  htmlFilePath: string,
31
- vocabulary?: DesignClassVocabulary,
32
+ _vocabulary?: DesignClassVocabulary,
32
33
  ): Promise<QAReport> {
33
34
  const result = await measureSlides(htmlFilePath)
34
- const options: RunChecksOptions | undefined = vocabulary
35
- ? {
36
- allowedClasses: vocabulary.classes,
37
- prefixExemptions: vocabulary.prefixExemptions,
38
- cssDefinedClasses: result.cssDefinedClasses,
39
- }
40
- : undefined
41
- return runChecks(htmlFilePath, result.slides, options)
35
+ return runChecks(htmlFilePath, result.slides)
42
36
  }
43
37
 
44
38
  /**
@@ -54,3 +48,4 @@ export async function runQAFormatted(
54
48
  }
55
49
 
56
50
  export { formatReport } from "./checks"
51
+ export { runComplianceQA } from "./compliance"
package/lib/qa/measure.ts CHANGED
@@ -57,7 +57,7 @@ export interface SlideMetrics {
57
57
  /** slide title extracted from the first h1/h2 inside the slide */
58
58
  title: string
59
59
  /**
60
- * Whether this slide should be included in layout QA checks.
60
+ * Whether this slide is marked as QA-relevant deck metadata.
61
61
  * Read from the `slide-qa` attribute on `<section class="slide">`.
62
62
  * Defaults to `false` when the attribute is absent.
63
63
  * Content-heavy layouts set `slide-qa="true"`; structural/sparse slides omit or use `"false"`.
@@ -266,7 +266,7 @@ export async function measureSlides(htmlFilePath: string): Promise<MeasurementRe
266
266
  const slide = document.querySelectorAll(".slide")[slideIdx]
267
267
  if (!slide) return null
268
268
 
269
- // Read the QA flag true means this slide gets balance/rhythm checks
269
+ // Read the QA flag for deck metadata; default checks do not branch on it.
270
270
  const slideQa = (slide as HTMLElement).getAttribute("slide-qa") === "true"
271
271
 
272
272
  const canvas = slide.querySelector(".slide-canvas") as HTMLElement | null
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyber-dash-tech/revela",
3
- "version": "0.7.0",
3
+ "version": "0.7.4",
4
4
  "description": "OpenCode plugin that turns AI into an HTML slide deck generator",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
package/plugin.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  * 5. experimental.chat.system.transform: inject three-layer prompt when enabled
12
12
  * 6. chat.message: intercept @-referenced / pasted binary files → extract text → replace FilePart with TextPart
13
13
  * 7. tool.execute.before: intercept read on DOCX/PPTX/XLSX → preRead()
14
- * 8. tool.execute.after: intercept read on PDF/images → postRead()
14
+ * 8. tool.execute.after: intercept read on PDF/images → postRead(); run fast design compliance after deck writes/patches/edits
15
15
  */
16
16
 
17
17
  import type { Plugin } from "@opencode-ai/plugin"
@@ -83,8 +83,9 @@ import extractDocumentMaterialsTool from "./tools/extract-document-materials"
83
83
  import qaTool from "./tools/qa"
84
84
  import pdfTool from "./tools/pdf"
85
85
  import pptxTool from "./tools/pptx"
86
+ import createEditTool from "./tools/edit"
86
87
  import { RESEARCH_PROMPT, RESEARCH_AGENT_SIGNATURE } from "./lib/agents/research-prompt"
87
- import { runQA, formatReport } from "./lib/qa"
88
+ import { formatReport, runComplianceQA } from "./lib/qa"
88
89
  import { extractDesignClasses } from "./lib/design/designs"
89
90
  import { log, childLog } from "./lib/log"
90
91
 
@@ -96,6 +97,15 @@ const INTERNAL_AGENT_SIGNATURES = [
96
97
  "Summarize what was done in this conversation",
97
98
  ]
98
99
 
100
+ function appendToolResult(output: any, text: string): void {
101
+ const existing = output.result ?? ""
102
+ output.result = (existing ? existing + "\n\n" : "") + text
103
+ }
104
+
105
+ function extractEditFilePath(args: any): string {
106
+ return args?.filePath ?? args?.file_path ?? args?.path ?? args?.file ?? ""
107
+ }
108
+
99
109
  // ── Helpers ────────────────────────────────────────────────────────────────
100
110
 
101
111
  /**
@@ -128,6 +138,33 @@ const server: Plugin = (async (pluginCtx) => {
128
138
  const blockedDeckWrites = new Map<string, string>()
129
139
  const blockedDeckPatches = new Map<string, string>()
130
140
 
141
+ async function appendComplianceReport(filePath: string, output: any): Promise<void> {
142
+ if (!isDeckHtmlPath(filePath)) return
143
+
144
+ try {
145
+ let vocabulary
146
+ try {
147
+ vocabulary = extractDesignClasses()
148
+ } catch {
149
+ // Design may not be installed or may have no markers — skip compliance.
150
+ }
151
+
152
+ const report = runComplianceQA(filePath, vocabulary)
153
+ if (report.totalIssues === 0) return
154
+
155
+ appendToolResult(
156
+ output,
157
+ "---\n\n**[revela design compliance]** Auto-check completed:\n\n" +
158
+ formatReport(report)
159
+ )
160
+ } catch (e) {
161
+ childLog("qa").warn("auto compliance failed", {
162
+ filePath,
163
+ error: e instanceof Error ? e.message : String(e),
164
+ })
165
+ }
166
+ }
167
+
131
168
  // ── Startup: seed + build initial prompt ────────────────────────────────
132
169
  try {
133
170
  seedBuiltinDesigns()
@@ -338,6 +375,7 @@ const server: Plugin = (async (pluginCtx) => {
338
375
  "revela-qa": qaTool,
339
376
  "revela-pdf": pdfTool,
340
377
  "revela-pptx": pptxTool,
378
+ "revela-edit": createEditTool({ client, workspaceRoot }),
341
379
  },
342
380
 
343
381
  // ── chat.message: intercept @-referenced / pasted binary files ────────
@@ -575,7 +613,7 @@ Next step: use \`revela-decks\` or \`/revela review\` to update ${DECKS_STATE_FI
575
613
  // Handles PDF and images — read tool succeeds with base64 attachment.
576
614
  // PDF: extract text, remove base64. Images: jimp compress.
577
615
  //
578
- // Also handles: auto layout QA after writing decks/*.html
616
+ // Also handles: fast design compliance after writing/patching/editing decks/*.html
579
617
  "tool.execute.after": async (input, output) => {
580
618
  if (!ctx.enabled) return
581
619
 
@@ -592,61 +630,47 @@ Next step: use \`revela-decks\` or \`/revela review\` to update ${DECKS_STATE_FI
592
630
  return
593
631
  }
594
632
 
595
- // ── Auto layout QA after writing decks/*.html ─────────────────────
633
+ // ── Fast design compliance after deck HTML writes/patches/edits ───
596
634
  if (input.tool === "write") {
597
635
  const filePath: string = input.args?.filePath ?? ""
598
636
  const blockedReason = blockedDeckWrites.get(filePath)
599
637
  if (blockedReason) {
600
638
  blockedDeckWrites.delete(filePath)
601
- const existing = (output as any).result ?? ""
602
- ;(output as any).result =
603
- (existing ? existing + "\n\n" : "") +
639
+ appendToolResult(
640
+ output,
604
641
  "---\n\n**[revela state gate]** Write was blocked.\n\n" +
605
642
  `${blockedReason}\n\n` +
606
643
  "Use the `revela-decks` tool or complete the DECKS.json review workflow instead."
644
+ )
607
645
  return
608
646
  }
609
- // Only trigger for HTML files inside a decks/ directory
610
- if (!isDeckHtmlPath(filePath)) return
611
-
612
- try {
613
- // Extract design's allowed class vocabulary for compliance checking
614
- let vocabulary
615
- try {
616
- vocabulary = extractDesignClasses()
617
- } catch {
618
- // Design may not be installed or may have no markers — skip compliance
619
- }
620
- const report = await runQA(filePath, vocabulary)
621
- // Only append QA report to tool output if there are issues
622
- if (report.totalIssues > 0) {
623
- const formatted = formatReport(report)
624
- // Append to the write tool's output so the LLM sees it immediately
625
- const existing = (output as any).result ?? ""
626
- ;(output as any).result =
627
- (existing ? existing + "\n\n" : "") +
628
- "---\n\n**[revela layout QA]** Auto-check completed:\n\n" +
629
- formatted
630
- }
631
- } catch (e) {
632
- childLog("qa").warn("auto QA failed", {
633
- filePath,
634
- error: e instanceof Error ? e.message : String(e),
635
- })
636
- // Don't surface errors to the LLM — fail silently
637
- }
647
+ await appendComplianceReport(filePath, output)
638
648
  return
639
649
  }
640
650
 
641
651
  if (input.tool === "apply_patch" && blockedDeckPatches.size > 0) {
642
652
  const [blockedPath, blockedReason] = blockedDeckPatches.entries().next().value ?? []
643
653
  if (blockedPath) blockedDeckPatches.delete(blockedPath)
644
- const existing = (output as any).result ?? ""
645
- ;(output as any).result =
646
- (existing ? existing + "\n\n" : "") +
654
+ appendToolResult(
655
+ output,
647
656
  "---\n\n**[revela prewrite gate]** Deck HTML patch was blocked.\n\n" +
648
657
  `${blockedReason}\n\n` +
649
658
  "Run `/revela review` or complete the same DECKS.json review workflow before patching the deck."
659
+ )
660
+ return
661
+ }
662
+
663
+ if (input.tool === "apply_patch") {
664
+ const patchText = extractPatchTextArg(input.args as Record<string, unknown>)
665
+ const targets = patchText ? extractDeckHtmlTargetsFromPatch(patchText) : []
666
+ for (const target of targets) {
667
+ await appendComplianceReport(target, output)
668
+ }
669
+ return
670
+ }
671
+
672
+ if (input.tool === "edit") {
673
+ await appendComplianceReport(extractEditFilePath(input.args), output)
650
674
  return
651
675
  }
652
676
  },
package/skill/SKILL.md CHANGED
@@ -232,8 +232,8 @@ layouts (cover, TOC, closing, quote, summary, etc.). When unsure, use `"false"`.
232
232
 
233
233
  Example: `<section class="slide" slide-qa="true" data-index="0">`
234
234
 
235
- The layout QA system uses this to skip fill-ratio and spacing checks on slides
236
- that are intentionally sparse.
235
+ The current QA path treats this as deck metadata. Automated checks focus on
236
+ design compliance and hard overflow errors, not subjective fill or spacing.
237
237
 
238
238
  ### Domain Context
239
239
 
@@ -382,11 +382,11 @@ exclusively for fine-tuning spacing and sizing (`margin`, `padding`, `gap`,
382
382
  component — **NEVER adapt the component structure to fit content. NEVER create
383
383
  a new component because the existing ones "don't quite fit".**
384
384
 
385
- The QA system will flag any unrecognised CSS class as a **compliance error**.
386
- If the QA report contains compliance issues after you write the file, you MUST
385
+ The automatic compliance check will flag any unrecognised CSS class. If the
386
+ QA report contains compliance issues after you write the file, you MUST
387
387
  fix them immediately — remove the offending classes and replace them with the
388
388
  closest component from the Component Index. Do not move on until all compliance
389
- errors are resolved.
389
+ issues are resolved.
390
390
 
391
391
  ### Inline Editing
392
392
 
package/tools/decks.ts CHANGED
@@ -57,7 +57,7 @@ export default tool({
57
57
  title: tool.schema.string().describe("Slide title."),
58
58
  purpose: tool.schema.string().optional().describe("Narrative purpose of this slide."),
59
59
  layout: tool.schema.string().describe("Design layout name."),
60
- qa: tool.schema.boolean().optional().describe("Whether the layout requires full QA."),
60
+ qa: tool.schema.boolean().optional().describe("Whether the slide is marked QA-relevant deck metadata."),
61
61
  components: tool.schema.array(tool.schema.string()).describe("Design components used by this slide."),
62
62
  content: tool.schema.object({
63
63
  headline: tool.schema.string().optional(),
package/tools/edit.ts ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * tools/edit.ts
3
+ *
4
+ * revela-edit — Open Revela's visual comment editor for an existing deck.
5
+ */
6
+
7
+ import { tool } from "@opencode-ai/plugin"
8
+ import { openEditableDeck } from "../lib/edit/open"
9
+
10
+ export function createEditTool(options: { client: any; workspaceRoot: string; openBrowser?: boolean }) {
11
+ return tool({
12
+ description:
13
+ "Open Revela's visual comment editor for an existing slide deck. " +
14
+ "Use this when the user asks to edit, revise, annotate, or visually comment on a deck, " +
15
+ "including when they reference a decks/*.html file with @. " +
16
+ "Accepts either a deck slug from DECKS.json or a workspace-relative decks/*.html path. " +
17
+ "This opens a local browser editor where the user can Ctrl/Cmd-click deck elements, write comments, " +
18
+ "and send precise edit requests back to the current OpenCode session.",
19
+ args: {
20
+ target: tool.schema
21
+ .string()
22
+ .describe("Deck slug or workspace-relative deck HTML path, e.g. investor-update or decks/investor-update.html."),
23
+ },
24
+ async execute({ target }, context: any) {
25
+ const sessionID = context?.sessionID ?? context?.session?.id ?? ""
26
+ if (!sessionID) {
27
+ return JSON.stringify({
28
+ ok: false,
29
+ error: "Cannot open visual editor because the current OpenCode session id is unavailable.",
30
+ })
31
+ }
32
+
33
+ try {
34
+ const result = openEditableDeck(target, {
35
+ client: options.client,
36
+ sessionID,
37
+ workspaceRoot: options.workspaceRoot,
38
+ openBrowser: options.openBrowser,
39
+ })
40
+
41
+ return JSON.stringify({
42
+ ok: true,
43
+ deck: result.deck.slug,
44
+ file: result.deck.file,
45
+ source: result.source,
46
+ url: result.url,
47
+ message:
48
+ `${result.stateNote} Opened visual editor. ` +
49
+ "Ask the user to use Ctrl/Cmd + click in the browser to reference elements, write a comment, then send comments.",
50
+ }, null, 2)
51
+ } catch (error) {
52
+ return JSON.stringify({
53
+ ok: false,
54
+ error: error instanceof Error ? error.message : String(error),
55
+ })
56
+ }
57
+ },
58
+ })
59
+ }
60
+
61
+ export default createEditTool
package/tools/pdf.ts CHANGED
@@ -8,11 +8,12 @@ import { tool } from "@opencode-ai/plugin"
8
8
  import { existsSync } from "fs"
9
9
  import { resolve } from "path"
10
10
  import { exportToPdf } from "../lib/pdf/export"
11
+ import { assertExportQAPassed } from "../lib/qa/export-gate"
11
12
 
12
13
  export default tool({
13
14
  description:
14
15
  "Export a Revela-generated HTML slide deck to PDF. " +
15
- "Use this after the deck HTML has been written and layout QA has passed. " +
16
+ "Runs pre-export QA before writing the PDF. " +
16
17
  "Output is written beside the input file with the same basename and a .pdf extension.",
17
18
  args: {
18
19
  file: tool.schema
@@ -34,6 +35,7 @@ export default tool({
34
35
  }
35
36
 
36
37
  try {
38
+ await assertExportQAPassed(filePath)
37
39
  const result = await exportToPdf(filePath)
38
40
  return JSON.stringify({ ok: true, ...result }, null, 2)
39
41
  } catch (e: any) {
package/tools/pptx.ts CHANGED
@@ -8,11 +8,12 @@ import { tool } from "@opencode-ai/plugin"
8
8
  import { existsSync } from "fs"
9
9
  import { resolve } from "path"
10
10
  import { exportToPptx } from "../lib/pptx/export"
11
+ import { assertExportQAPassed } from "../lib/qa/export-gate"
11
12
 
12
13
  export default tool({
13
14
  description:
14
15
  "Export a Revela-generated HTML slide deck to editable PPTX. " +
15
- "Use this after the deck HTML has been written and layout QA has passed. " +
16
+ "Runs pre-export QA before writing the PPTX. " +
16
17
  "Output is written beside the input file with the same basename and a .pptx extension.",
17
18
  args: {
18
19
  file: tool.schema
@@ -36,6 +37,7 @@ export default tool({
36
37
  const progress: string[] = []
37
38
 
38
39
  try {
40
+ await assertExportQAPassed(filePath)
39
41
  const result = await exportToPptx(filePath, {
40
42
  onProgress: (event) => {
41
43
  progress.push(event.message)
package/tools/qa.ts CHANGED
@@ -1,27 +1,23 @@
1
1
  /**
2
2
  * tools/qa.ts
3
3
  *
4
- * revela-qa — Layout quality assurance tool for generated slide HTML files.
4
+ * revela-qa — Hard-error quality assurance for generated slide HTML files.
5
5
  *
6
- * Exposed to the LLM so it can run layout checks after writing a slides file.
7
- * Also called automatically by the tool.execute.after hook in plugin.ts
8
- * when the LLM writes a file matching decks/*.html.
6
+ * Exposed to the LLM so it can check overflow and design compliance.
9
7
  */
10
8
 
11
9
  import { tool } from "@opencode-ai/plugin"
12
10
  import { resolve } from "path"
13
11
  import { existsSync } from "fs"
14
12
  import { runQA, formatReport } from "../lib/qa"
15
- import { extractDesignClasses } from "../lib/design/designs"
16
13
 
17
14
  export default tool({
18
15
  description:
19
- "Run layout quality checks on a generated slide HTML file. " +
16
+ "Run hard-error checks on a generated slide HTML file. " +
20
17
  "Opens the file in a headless browser and measures actual rendered geometry. " +
21
- "Checks for: canvas underfill (too much empty space), bottom whitespace, " +
22
- "left-right column asymmetry, element overflow, and card height variance. " +
18
+ "Checks for element overflow. " +
23
19
  "Returns a structured report with specific issues and fix instructions. " +
24
- "Call this after writing or editing any decks/*.html file to verify layout quality.",
20
+ "Call this when an edit may affect layout, or before delivery if export commands are not used.",
25
21
  args: {
26
22
  file: tool.schema
27
23
  .string()
@@ -43,14 +39,7 @@ export default tool({
43
39
  }
44
40
 
45
41
  try {
46
- // Extract design's allowed class vocabulary for compliance checking
47
- let vocabulary
48
- try {
49
- vocabulary = extractDesignClasses()
50
- } catch {
51
- // Design may not be installed or may have no markers — skip compliance
52
- }
53
- const report = await runQA(filePath, vocabulary)
42
+ const report = await runQA(filePath)
54
43
  const formatted = formatReport(report)
55
44
 
56
45
  // Prepend a compact JSON summary for programmatic use if needed
@@ -63,7 +52,7 @@ export default tool({
63
52
 
64
53
  return `<!-- QA Summary: ${jsonSummary} -->\n\n${formatted}`
65
54
  } catch (err: any) {
66
- return `Error running layout QA: ${err?.message ?? String(err)}\n\nMake sure Chrome is installed at /Applications/Google Chrome.app`
55
+ return `Error running hard-error QA: ${err?.message ?? String(err)}\n\nMake sure Chrome is installed at /Applications/Google Chrome.app`
67
56
  }
68
57
  },
69
58
  })