@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 +6 -11
- package/README.zh-CN.md +6 -11
- package/lib/commands/pdf.ts +4 -1
- package/lib/commands/pptx.ts +4 -1
- package/lib/edit/prompt.ts +2 -1
- package/lib/edit/server.ts +53 -14
- package/lib/qa/checks.ts +8 -124
- package/lib/qa/compliance.ts +141 -0
- package/lib/qa/export-gate.ts +11 -0
- package/lib/qa/index.ts +11 -16
- package/lib/qa/measure.ts +2 -2
- package/package.json +1 -1
- package/plugin.ts +67 -39
- package/skill/SKILL.md +11 -8
- package/tools/decks.ts +1 -1
- package/tools/pdf.ts +3 -1
- package/tools/pptx.ts +3 -1
- package/tools/qa.ts +7 -18
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**English** | [中文](README.zh-CN.md)
|
|
4
4
|
|
|
5
|
-
[](https://www.npmjs.com/package/@cyber-dash-tech/revela) [](LICENSE) [](https://www.npmjs.com/package/@cyber-dash-tech/revela) [](LICENSE) [](tests/) [](https://opencode.ai) [](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
|
|
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
|
|
306
|
-
The
|
|
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
|
|
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
|
-
[](https://www.npmjs.com/package/@cyber-dash-tech/revela) [](LICENSE) [](https://www.npmjs.com/package/@cyber-dash-tech/revela) [](LICENSE) [](tests/) [](https://opencode.ai) [](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
|
|
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
|
|
272
|
-
|
|
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
|
|
package/lib/commands/pdf.ts
CHANGED
|
@@ -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(`
|
|
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(
|
package/lib/commands/pptx.ts
CHANGED
|
@@ -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(`
|
|
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/edit/prompt.ts
CHANGED
|
@@ -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
|
-
-
|
|
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
|
}
|
package/lib/edit/server.ts
CHANGED
|
@@ -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
|
|
97
|
+
const version = readDeckVersion(session)
|
|
98
98
|
session.lastActiveAt = Date.now()
|
|
99
99
|
scheduleIdleStop()
|
|
100
|
-
return jsonResponse({ ok: true,
|
|
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.
|
|
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.
|
|
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
|
|
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 = '
|
|
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 === '
|
|
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
|
|
5
|
-
*
|
|
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
|
|
616
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
5
|
-
*
|
|
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
|
|
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
|
|
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
|
|
22
|
+
* 3. Runs hard-error overflow checks only
|
|
22
23
|
* 4. Returns a structured QAReport
|
|
23
24
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
-
|
|
32
|
+
_vocabulary?: DesignClassVocabulary,
|
|
32
33
|
): Promise<QAReport> {
|
|
33
34
|
const result = await measureSlides(htmlFilePath)
|
|
34
|
-
|
|
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
|
|
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
|
|
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
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 {
|
|
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
|
|
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
|
-
// ──
|
|
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
|
-
|
|
604
|
-
|
|
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
|
-
|
|
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
|
-
|
|
647
|
-
|
|
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
|
|
236
|
-
|
|
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
|
|
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
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
closest component from the Component Index.
|
|
389
|
-
|
|
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
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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 —
|
|
4
|
+
* revela-qa — Hard-error quality assurance for generated slide HTML files.
|
|
5
5
|
*
|
|
6
|
-
* Exposed
|
|
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
|
|
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
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
|
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
|
})
|