@cyber-dash-tech/revela 0.7.3 → 0.7.5

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,7 @@ 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
26
  - opens a visual comment editor for existing decks so users can Ctrl/Cmd-click elements and send precise edit requests back to OpenCode
27
27
  - exports finished decks to PDF and editable PPTX
28
28
  - switches designs and domains locally with zero LLM cost
@@ -302,23 +302,18 @@ This keeps final decks stable, offline-friendly, and independent from expiring r
302
302
 
303
303
  ## Layout QA And Compliance
304
304
 
305
- Every time the agent writes `decks/*.html`, Revela runs an automatic Puppeteer-based QA pass at `1920x1080`.
306
- 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`.
307
307
 
308
- Current QA dimensions:
308
+ Current QA checks:
309
309
 
310
310
  | Dimension | What it checks |
311
311
  |---|---|
312
312
  | `overflow` | Elements extending outside the slide canvas |
313
- | `balance` | Sparse slides, centroid drift, and bottom-gap issues |
314
- | `symmetry` | Side-by-side column imbalance in height or density |
315
- | `rhythm` | Irregular vertical spacing between stacked siblings |
316
313
  | `compliance` | Unknown design classes and novel CSS rules outside the active design vocabulary |
317
314
 
318
315
  Each slide must declare `slide-qa="true"` or `slide-qa="false"`.
319
-
320
- - use `slide-qa="true"` for content-heavy slides that should undergo full QA
321
- - 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.
322
317
 
323
318
  You can also run QA manually with the `revela-qa` tool.
324
319
 
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,7 @@ 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
26
  - 为已有 deck 打开可视化评论编辑器,用户可以 Ctrl/Cmd + 点击元素,并把精确修改意见发回 OpenCode
27
27
  - 支持导出成 PDF 和可编辑 PPTX
28
28
  - design 和 domain 的切换都在本地完成,不消耗 LLM token
@@ -268,23 +268,18 @@ Revela 使用工作区根目录的 `DECKS.json` 做跨会话记忆和 deck 生
268
268
 
269
269
  ## 布局 QA 与合规检查
270
270
 
271
- 每次 agent 写入 `decks/*.html` 时,Revela 都会自动在 `1920x1080` 下运行一轮基于 Puppeteer 的 QA。
272
- 报告会立刻返回,便于 agent 继续修正。
271
+ 每次 agent 写入、patch 或 edit `decks/*.html` 时,Revela 都会自动运行快速静态 design compliance 检查。
272
+ 手动 `revela-qa` 工具以及 PDF/PPTX 导出前置检查会额外在 `1920x1080` 下运行基于 Puppeteer 的 overflow 检查。
273
273
 
274
- 当前 QA 维度:
274
+ 当前 QA 检查:
275
275
 
276
276
  | 维度 | 检查内容 |
277
277
  |---|---|
278
278
  | `overflow` | 元素是否超出 slide canvas |
279
- | `balance` | 是否过稀、重心偏移、底部留白过大 |
280
- | `symmetry` | 并列列之间的高度或密度是否明显失衡 |
281
- | `rhythm` | 垂直堆叠元素之间的间距节奏是否不稳定 |
282
279
  | `compliance` | 是否使用了 active design 之外的 class 或新增 CSS 规则 |
283
280
 
284
281
  每张 slide 都必须声明 `slide-qa="true"` 或 `slide-qa="false"`。
285
-
286
- - `slide-qa="true"` 适用于内容型页面,执行完整 QA
287
- - `slide-qa="false"` 适用于封面、目录、引用、总结、结尾等结构型页面
282
+ 当前 QA 路径将其保留为 deck metadata,不再启用额外的主观平衡或间距检查。
288
283
 
289
284
  也可以手动调用 `revela-qa` 工具执行 QA。
290
285
 
@@ -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
 
@@ -76,6 +76,7 @@ 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
+ - Static design compliance is checked automatically after deck writes. If the tool result reports unknown classes, replace them with classes from the active design.
80
+ - Do not run QA after the edit unless the user explicitly asks for diagnostics. PDF/PPTX export commands run hard-error pre-export QA automatically.
80
81
  - If the comment is ambiguous, ask one concise clarification question instead of guessing.`
81
82
  }
@@ -94,16 +94,22 @@ async function handleRequest(req: Request): Promise<Response> {
94
94
 
95
95
  function handleDeckVersion(session: EditSession): Response {
96
96
  try {
97
- const stat = statSync(session.absoluteFile)
97
+ const version = readDeckVersion(session)
98
98
  session.lastActiveAt = Date.now()
99
99
  scheduleIdleStop()
100
- return jsonResponse({ ok: true, mtimeMs: stat.mtimeMs, size: stat.size })
100
+ return jsonResponse({ ok: true, ...version })
101
101
  } catch (error) {
102
102
  const message = error instanceof Error ? error.message : String(error)
103
103
  return jsonResponse({ ok: false, error: message }, 404)
104
104
  }
105
105
  }
106
106
 
107
+ function readDeckVersion(session: EditSession): { mtimeMs: number; size: number; version: string } {
108
+ const stat = statSync(session.absoluteFile)
109
+ const version = `${stat.mtimeMs}:${stat.size}`
110
+ return { mtimeMs: stat.mtimeMs, size: stat.size, version }
111
+ }
112
+
107
113
  async function handleComment(req: Request, session: EditSession): Promise<Response> {
108
114
  let body: Partial<EditCommentPayload>
109
115
  try {
@@ -132,6 +138,7 @@ async function handleComment(req: Request, session: EditSession): Promise<Respon
132
138
  elements,
133
139
  comments,
134
140
  })
141
+ const deckVersion = readDeckVersion(session).version
135
142
 
136
143
  await session.client.session.prompt({
137
144
  path: { id: session.sessionID },
@@ -142,7 +149,7 @@ async function handleComment(req: Request, session: EditSession): Promise<Respon
142
149
 
143
150
  session.lastActiveAt = Date.now()
144
151
  scheduleIdleStop()
145
- return jsonResponse({ ok: true })
152
+ return jsonResponse({ ok: true, deckVersion })
146
153
  }
147
154
 
148
155
  function validateSession(token: string | null): { ok: true; value: EditSession } | { ok: false; response: Response } {
@@ -233,11 +240,13 @@ function renderEditorShell(token: string): string {
233
240
  .comment-thread { display: flex; flex-direction: column; gap: 10px; max-height: 30vh; overflow: auto; }
234
241
  .comment-bubble { border: 1px solid #374151; border-radius: 14px; padding: 10px 12px; background: #0f172a; color: #e5e7eb; font-size: 13px; line-height: 1.45; }
235
242
  .comment-bubble.sending { border-color: rgba(56,189,248,.5); background: rgba(14,116,144,.14); }
236
- .comment-bubble.done { border-color: rgba(34,197,94,.55); background: rgba(22,101,52,.18); }
243
+ .comment-bubble.applied { border-color: rgba(34,197,94,.55); background: rgba(22,101,52,.18); }
244
+ .comment-bubble.stale { border-color: rgba(251,191,36,.6); background: rgba(120,53,15,.2); }
237
245
  .comment-bubble.failed { border-color: rgba(248,113,113,.65); background: rgba(127,29,29,.2); }
238
246
  .comment-bubble-text { white-space: pre-wrap; overflow-wrap: anywhere; }
239
247
  .comment-bubble-state { margin-top: 8px; color: #93c5fd; font-size: 12px; font-weight: 700; }
240
- .comment-bubble.done .comment-bubble-state { color: #86efac; }
248
+ .comment-bubble.applied .comment-bubble-state { color: #86efac; }
249
+ .comment-bubble.stale .comment-bubble-state { color: #fcd34d; }
241
250
  .comment-bubble.failed .comment-bubble-state { color: #fca5a5; }
242
251
  button { width: 100%; padding: 12px 14px; border: 0; border-radius: 12px; background: #38bdf8; color: #04111d; font-weight: 700; cursor: pointer; }
243
252
  button:disabled { cursor: not-allowed; opacity: .5; }
@@ -265,6 +274,7 @@ function renderEditorShell(token: string): string {
265
274
  <script>
266
275
  (() => {
267
276
  const token = ${encodedToken};
277
+ const COMMENT_STALE_MS = 60000;
268
278
  const state = {
269
279
  references: [],
270
280
  pendingComments: [],
@@ -374,7 +384,6 @@ function renderEditorShell(token: string): string {
374
384
  updateSendState();
375
385
  if (state.pendingRefreshMessage) {
376
386
  state.pendingRefreshMessage = false;
377
- markPendingCommentsDone();
378
387
  setStatus('Deck updated. Preview refreshed. Element references were cleared.');
379
388
  } else {
380
389
  setStatus(slides.length > 0 ? 'Editor ready. Found ' + slides.length + ' slides. Ctrl/Cmd + click to reference elements.' : 'Editor ready, but no .slide elements were found. Ctrl/Cmd + click to reference elements.');
@@ -394,13 +403,18 @@ function renderEditorShell(token: string): string {
394
403
  const res = await fetch('/api/deck-version?token=' + encodeURIComponent(token), { cache: 'no-store' });
395
404
  const body = await res.json().catch(() => ({}));
396
405
  if (!res.ok || !body.ok) throw new Error(body.error || 'Failed to check deck version');
397
- const nextVersion = String(body.mtimeMs) + ':' + String(body.size);
406
+ const nextVersion = body.version || (String(body.mtimeMs) + ':' + String(body.size));
398
407
  if (!state.deckVersion) {
399
408
  state.deckVersion = nextVersion;
409
+ markStaleComments();
410
+ return;
411
+ }
412
+ if (state.deckVersion === nextVersion) {
413
+ markStaleComments();
400
414
  return;
401
415
  }
402
- if (state.deckVersion === nextVersion) return;
403
416
  state.deckVersion = nextVersion;
417
+ markCommentsAppliedForVersion(nextVersion);
404
418
  refreshDeckPreview(body.mtimeMs);
405
419
  } catch (error) {
406
420
  reportError(error);
@@ -482,7 +496,7 @@ function renderEditorShell(token: string): string {
482
496
  });
483
497
  const body = await res.json().catch(() => ({}));
484
498
  if (!res.ok || !body.ok) throw new Error(body.error || 'Failed to send comment');
485
- updatePendingCommentStatus(commentId, 'sent');
499
+ updatePendingCommentStatus(commentId, 'sent', { baseDeckVersion: body.deckVersion || state.deckVersion });
486
500
  setStatus('Comment sent. Waiting for deck update...');
487
501
  updateSendState();
488
502
  } catch (error) {
@@ -555,29 +569,53 @@ function renderEditorShell(token: string): string {
555
569
  text,
556
570
  elements,
557
571
  status,
572
+ createdAt: Date.now(),
573
+ baseDeckVersion: state.deckVersion,
574
+ appliedVersion: null,
558
575
  });
559
576
  renderCommentThread();
560
577
  return id;
561
578
  }
562
579
 
563
- function updatePendingCommentStatus(id, status) {
580
+ function updatePendingCommentStatus(id, status, updates) {
564
581
  const comment = state.pendingComments.find((item) => item.id === id);
565
582
  if (!comment) return;
583
+ if (comment.status === 'applied' && status !== 'failed') return;
566
584
  comment.status = status;
585
+ if (updates) Object.assign(comment, updates);
567
586
  renderCommentThread();
568
587
  }
569
588
 
570
- function markPendingCommentsDone() {
589
+ function markCommentsAppliedForVersion(version) {
571
590
  let changed = false;
572
591
  state.pendingComments.forEach((comment) => {
573
- if (comment.status === 'sent' || comment.status === 'sending') {
574
- comment.status = 'done';
592
+ if ((comment.status === 'sent' || comment.status === 'sending' || comment.status === 'stale') && comment.baseDeckVersion !== version) {
593
+ comment.status = 'applied';
594
+ comment.appliedVersion = version;
575
595
  changed = true;
576
596
  }
577
597
  });
578
598
  if (changed) renderCommentThread();
579
599
  }
580
600
 
601
+ function markStaleComments() {
602
+ const now = Date.now();
603
+ let changed = false;
604
+ const hasWaiting = state.pendingComments.some((comment) => {
605
+ if (comment.status !== 'sent' && comment.status !== 'sending') return false;
606
+ if (now - comment.createdAt < COMMENT_STALE_MS) return true;
607
+ comment.status = 'stale';
608
+ changed = true;
609
+ return true;
610
+ });
611
+ if (changed) {
612
+ renderCommentThread();
613
+ setStatus('Still waiting for deck file update. If OpenCode already finished, refresh the editor.');
614
+ } else if (hasWaiting) {
615
+ setStatus('Comment sent. Waiting for deck update...');
616
+ }
617
+ }
618
+
581
619
  function renderCommentThread() {
582
620
  els.commentThread.textContent = '';
583
621
  state.pendingComments.forEach((comment) => {
@@ -599,7 +637,8 @@ function renderEditorShell(token: string): string {
599
637
  }
600
638
 
601
639
  function commentStatusLabel(status) {
602
- if (status === 'done') return '✅ Applied';
640
+ if (status === 'applied') return '✅ Applied';
641
+ if (status === 'stale') return 'Still waiting for deck file update';
603
642
  if (status === 'failed') return 'Failed to send';
604
643
  if (status === 'sending') return 'Sending to OpenCode...';
605
644
  return '⏳ Sent to OpenCode';
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,44 +1,38 @@
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. Static design compliance is handled by a
6
+ * separate post-write/post-patch/post-edit hook.
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
- * Pass `vocabulary` (from `extractDesignClasses()`) to enable compliance checks.
25
- * Omit it to run geometry-only checks (backward compatible).
25
+ * The optional `vocabulary` argument is retained for backward compatibility;
26
+ * compliance is intentionally not part of hard-error QA.
26
27
  *
27
28
  * Throws if the file cannot be opened or Chrome is not found.
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.3",
3
+ "version": "0.7.5",
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 static compliance after deck writes/patches/edits
15
15
  */
16
16
 
17
17
  import type { Plugin } from "@opencode-ai/plugin"
@@ -85,7 +85,7 @@ import pdfTool from "./tools/pdf"
85
85
  import pptxTool from "./tools/pptx"
86
86
  import createEditTool from "./tools/edit"
87
87
  import { RESEARCH_PROMPT, RESEARCH_AGENT_SIGNATURE } from "./lib/agents/research-prompt"
88
- import { runQA, formatReport } from "./lib/qa"
88
+ import { formatReport, runComplianceQA } from "./lib/qa"
89
89
  import { extractDesignClasses } from "./lib/design/designs"
90
90
  import { log, childLog } from "./lib/log"
91
91
 
@@ -97,6 +97,20 @@ const INTERNAL_AGENT_SIGNATURES = [
97
97
  "Summarize what was done in this conversation",
98
98
  ]
99
99
 
100
+ function appendToolResult(output: any, text: string): void {
101
+ if (typeof output.output === "string") {
102
+ output.output = (output.output ? output.output + "\n\n" : "") + text
103
+ return
104
+ }
105
+
106
+ const existing = output.result ?? ""
107
+ output.result = (existing ? existing + "\n\n" : "") + text
108
+ }
109
+
110
+ function extractEditFilePath(args: any): string {
111
+ return args?.filePath ?? args?.file_path ?? args?.path ?? args?.file ?? ""
112
+ }
113
+
100
114
  // ── Helpers ────────────────────────────────────────────────────────────────
101
115
 
102
116
  /**
@@ -129,6 +143,33 @@ const server: Plugin = (async (pluginCtx) => {
129
143
  const blockedDeckWrites = new Map<string, string>()
130
144
  const blockedDeckPatches = new Map<string, string>()
131
145
 
146
+ async function appendComplianceReport(filePath: string, output: any): Promise<void> {
147
+ if (!isDeckHtmlPath(filePath)) return
148
+
149
+ try {
150
+ let vocabulary
151
+ try {
152
+ vocabulary = extractDesignClasses()
153
+ } catch {
154
+ // Design may not be installed or may have no markers — skip compliance.
155
+ }
156
+
157
+ const report = runComplianceQA(filePath, vocabulary)
158
+ if (report.totalIssues === 0) return
159
+
160
+ appendToolResult(
161
+ output,
162
+ "---\n\n**[revela design compliance]** Static check completed:\n\n" +
163
+ formatReport(report)
164
+ )
165
+ } catch (e) {
166
+ childLog("compliance").warn("static compliance failed", {
167
+ filePath,
168
+ error: e instanceof Error ? e.message : String(e),
169
+ })
170
+ }
171
+ }
172
+
132
173
  // ── Startup: seed + build initial prompt ────────────────────────────────
133
174
  try {
134
175
  seedBuiltinDesigns()
@@ -577,7 +618,8 @@ Next step: use \`revela-decks\` or \`/revela review\` to update ${DECKS_STATE_FI
577
618
  // Handles PDF and images — read tool succeeds with base64 attachment.
578
619
  // PDF: extract text, remove base64. Images: jimp compress.
579
620
  //
580
- // Also handles: auto layout QA after writing decks/*.html
621
+ // Also reports writes/patches blocked by the DECKS.json prewrite gate and
622
+ // runs lightweight static design compliance after successful deck changes.
581
623
  "tool.execute.after": async (input, output) => {
582
624
  if (!ctx.enabled) return
583
625
 
@@ -594,61 +636,47 @@ Next step: use \`revela-decks\` or \`/revela review\` to update ${DECKS_STATE_FI
594
636
  return
595
637
  }
596
638
 
597
- // ── Auto layout QA after writing decks/*.html ─────────────────────
639
+ // ── Report blocked deck writes and run static compliance ──────────
598
640
  if (input.tool === "write") {
599
641
  const filePath: string = input.args?.filePath ?? ""
600
642
  const blockedReason = blockedDeckWrites.get(filePath)
601
643
  if (blockedReason) {
602
644
  blockedDeckWrites.delete(filePath)
603
- const existing = (output as any).result ?? ""
604
- ;(output as any).result =
605
- (existing ? existing + "\n\n" : "") +
645
+ appendToolResult(
646
+ output,
606
647
  "---\n\n**[revela state gate]** Write was blocked.\n\n" +
607
648
  `${blockedReason}\n\n` +
608
649
  "Use the `revela-decks` tool or complete the DECKS.json review workflow instead."
650
+ )
609
651
  return
610
652
  }
611
- // Only trigger for HTML files inside a decks/ directory
612
- if (!isDeckHtmlPath(filePath)) return
613
-
614
- try {
615
- // Extract design's allowed class vocabulary for compliance checking
616
- let vocabulary
617
- try {
618
- vocabulary = extractDesignClasses()
619
- } catch {
620
- // Design may not be installed or may have no markers — skip compliance
621
- }
622
- const report = await runQA(filePath, vocabulary)
623
- // Only append QA report to tool output if there are issues
624
- if (report.totalIssues > 0) {
625
- const formatted = formatReport(report)
626
- // Append to the write tool's output so the LLM sees it immediately
627
- const existing = (output as any).result ?? ""
628
- ;(output as any).result =
629
- (existing ? existing + "\n\n" : "") +
630
- "---\n\n**[revela layout QA]** Auto-check completed:\n\n" +
631
- formatted
632
- }
633
- } catch (e) {
634
- childLog("qa").warn("auto QA failed", {
635
- filePath,
636
- error: e instanceof Error ? e.message : String(e),
637
- })
638
- // Don't surface errors to the LLM — fail silently
639
- }
653
+ await appendComplianceReport(filePath, output)
640
654
  return
641
655
  }
642
656
 
643
657
  if (input.tool === "apply_patch" && blockedDeckPatches.size > 0) {
644
658
  const [blockedPath, blockedReason] = blockedDeckPatches.entries().next().value ?? []
645
659
  if (blockedPath) blockedDeckPatches.delete(blockedPath)
646
- const existing = (output as any).result ?? ""
647
- ;(output as any).result =
648
- (existing ? existing + "\n\n" : "") +
660
+ appendToolResult(
661
+ output,
649
662
  "---\n\n**[revela prewrite gate]** Deck HTML patch was blocked.\n\n" +
650
663
  `${blockedReason}\n\n` +
651
664
  "Run `/revela review` or complete the same DECKS.json review workflow before patching the deck."
665
+ )
666
+ return
667
+ }
668
+
669
+ if (input.tool === "apply_patch") {
670
+ const patchText = extractPatchTextArg(input.args as Record<string, unknown>)
671
+ const targets = patchText ? extractDeckHtmlTargetsFromPatch(patchText) : []
672
+ for (const target of targets) {
673
+ await appendComplianceReport(target, output)
674
+ }
675
+ return
676
+ }
677
+
678
+ if (input.tool === "edit") {
679
+ await appendComplianceReport(extractEditFilePath(input.args), output)
652
680
  return
653
681
  }
654
682
  },
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 export QA path treats this as deck metadata. It is consumed when PDF/PPTX
236
+ export runs preflight checks.
237
237
 
238
238
  ### Domain Context
239
239
 
@@ -323,7 +323,7 @@ After generating, briefly tell the user:
323
323
  - How to navigate (arrow keys / swipe)
324
324
  - One line invitation to request changes
325
325
 
326
- Then use `revela-decks` to record written/QA status when available. Preserve
326
+ Then use `revela-decks` to record written status when available. Preserve
327
327
  stable decisions in deck memory when useful.
328
328
 
329
329
  For change requests: re-generate the **entire** file (don't patch). Apply the
@@ -382,11 +382,14 @@ 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
387
- fix them immediately remove the offending classes and replace them with the
388
- closest component from the Component Index. Do not move on until all compliance
389
- errors are resolved.
385
+ The automatic static compliance check will flag any unrecognised CSS class after
386
+ deck HTML writes or patches. If the tool result reports compliance issues, fix
387
+ them immediately by removing the offending classes and replacing them with the
388
+ closest component from the Component Index.
389
+
390
+ Do not run `revela-qa` after writing or editing HTML unless the user explicitly
391
+ asks for diagnostics. PDF/PPTX export commands run hard-error pre-export QA
392
+ automatically and will report overflow issues that must be fixed before exporting.
390
393
 
391
394
  ### Inline Editing
392
395
 
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/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 as a manual diagnostic tool. Export commands run pre-export QA automatically.
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
+ "Normally PDF/PPTX export commands run this automatically; call it directly only for explicit diagnostics.",
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
  })