@cyber-dash-tech/revela 0.1.3 → 0.1.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
@@ -102,9 +102,8 @@ Three designs are bundled. Switch with `/revela designs <name>`.
102
102
 
103
103
  | Name | Description | Preview |
104
104
  |---|---|---|
105
- | `default` | Dark executive style — deep navy/slate, sharp typography, ECharts data visualization | ![default](assets/img/slide-example-default.jpg) |
106
- | `minimal` | Clean light theme — high contrast, generous whitespace, professional look | ![minimal](assets/img/slide-example-minimal.jpg) |
107
- | `editorial-ribbon` | Bold editorial layout — accent ribbons, strong headlines, high visual impact | ![editorial-ribbon](assets/img/slide-example-ribbon.jpg) |
105
+ | `aurora` | Dark executive style — deep navy/slate, sharp typography, ECharts data visualization | ![default](assets/img/slide-example-aurora.jpg) |
106
+ | `summit` | Editorial outdoor annual-report theme | ![summit](assets/img/slide-example-summit.jpg) |
108
107
 
109
108
  ---
110
109
 
package/README.zh-CN.md CHANGED
@@ -133,9 +133,8 @@ OPENCODE_ENABLE_EXA=1 opencode
133
133
 
134
134
  | 名称 | 说明 | 预览 |
135
135
  |---|---|---|
136
- | `default` | 深色商务风格 —— 深海军蓝/石板色,锐利字体,ECharts 数据可视化 | ![default](assets/img/slide-example-default.jpg) |
137
- | `minimal` | 简洁浅色主题 —— 高对比度,充足留白,专业外观 | ![minimal](assets/img/slide-example-minimal.jpg) |
138
- | `editorial-ribbon` | 大胆的编辑版式 —— 强调色横幅,醒目标题,高视觉冲击力 | ![editorial-ribbon](assets/img/slide-example-ribbon.jpg) |
136
+ | `aurora` | 颜色主题 极光, 高饱和度, ECharts 数据可视化 | ![default](assets/img/slide-example-aurora.jpg) |
137
+ | `summit` | 极简主义 - 户外,适合有丰富插图,Echart 数据可视化 | ![summit](assets/img/slide-example-summit.jpg) |
139
138
 
140
139
  ---
141
140
 
@@ -53,9 +53,13 @@ Formulate **3–6 targeted search queries** for your specific axis, covering:
53
53
 
54
54
  For Chinese topics: search in **both Chinese and English**.
55
55
 
56
- Use \`webfetch\` to retrieve specific pages for depth. Do NOT use \`websearch\` —
57
- it is not available. Use \`webfetch\` with targeted URLs from your knowledge or
58
- from initial search results.
56
+ Use **\`websearch\`** for broad keyword queries to find relevant pages, reports,
57
+ and data. Then use **\`webfetch\`** to retrieve specific pages for depth.
58
+
59
+ Search strategy:
60
+ - Start with \`websearch\` to discover relevant URLs (market reports, company pages, news)
61
+ - Follow up with \`webfetch\` on the most promising URLs for full content
62
+ - For Chinese topics: run \`websearch\` queries in both Chinese and English
59
63
 
60
64
  ---
61
65
 
@@ -436,6 +436,164 @@ function installFromPath(srcPath: string, name?: string): string {
436
436
  return designName
437
437
  }
438
438
 
439
+ // ---------------------------------------------------------------------------
440
+ // Design class vocabulary extraction
441
+ // ---------------------------------------------------------------------------
442
+
443
+ /**
444
+ * The set of CSS class names that are always allowed, regardless of design.
445
+ * These are structural/behavioural classes used by every presentation.
446
+ */
447
+ const UNIVERSAL_CLASSES = new Set([
448
+ "slide",
449
+ "slide-canvas",
450
+ "visible",
451
+ "reveal",
452
+ "editable",
453
+ "page",
454
+ "bg",
455
+ "fg",
456
+ "overlay",
457
+ "alt",
458
+ "strong",
459
+ ])
460
+
461
+ /**
462
+ * CSS class prefixes that are always exempt from compliance checks.
463
+ * Third-party libraries (icons, charts) generate classes with these prefixes.
464
+ */
465
+ export const DEFAULT_PREFIX_EXEMPTIONS: string[] = ["lucide-", "echarts-", "editable-"]
466
+
467
+ export interface DesignClassVocabulary {
468
+ /** Complete set of allowed CSS class names. */
469
+ classes: Set<string>
470
+ /** Class name prefixes that bypass compliance checks. */
471
+ prefixExemptions: string[]
472
+ }
473
+
474
+ /**
475
+ * Extract all CSS class names defined in a DESIGN.md and return a closed
476
+ * vocabulary of allowed class names for compliance checking.
477
+ *
478
+ * Extraction sources:
479
+ * - @design:foundation — parses CSS `.class-name` selectors in code blocks
480
+ * - @layout:xxx — parses HTML class="..." attributes and CSS selectors
481
+ * - @component:xxx — same as layouts
482
+ *
483
+ * UNIVERSAL_CLASSES and DEFAULT_PREFIX_EXEMPTIONS are always included.
484
+ *
485
+ * Falls back to UNIVERSAL_CLASSES-only when the design has no markers.
486
+ */
487
+ export function extractDesignClasses(designName?: string): DesignClassVocabulary {
488
+ const name = designName || activeDesign()
489
+ const mdPath = join(DESIGNS_DIR, name, "DESIGN.md")
490
+
491
+ if (!existsSync(mdPath)) {
492
+ return { classes: new Set(UNIVERSAL_CLASSES), prefixExemptions: DEFAULT_PREFIX_EXEMPTIONS }
493
+ }
494
+
495
+ const raw = readFileSync(mdPath, "utf-8")
496
+ const { body } = parseFrontmatter(raw)
497
+ const { sections, layouts, components, hasMarkers } = parseDesignSections(body)
498
+
499
+ if (!hasMarkers) {
500
+ // No markers — can't extract a reliable vocabulary; return universal only
501
+ return { classes: new Set(UNIVERSAL_CLASSES), prefixExemptions: DEFAULT_PREFIX_EXEMPTIONS }
502
+ }
503
+
504
+ const classes = new Set(UNIVERSAL_CLASSES)
505
+
506
+ // Regex patterns for extraction (stateless — reset lastIndex before each use)
507
+ const htmlClassRe = /class="([^"]*)"/g
508
+ const cssClassRe = /\.([a-zA-Z_][\w-]*)/g
509
+
510
+ /** Extract CSS class names from a CSS string (selector context only).
511
+ * Strips url(...) and string literals before scanning to avoid false positives
512
+ * from inline SVG data URIs and other non-selector content.
513
+ */
514
+ function extractFromCss(css: string): void {
515
+ // Remove url(...) values (may contain encoded paths like w3.org, data URIs, etc.)
516
+ const stripped = css
517
+ .replace(/url\([^)]*\)/gi, "url()")
518
+ // Remove quoted strings (single or double)
519
+ .replace(/"[^"]*"/g, '""')
520
+ .replace(/'[^']*'/g, "''")
521
+ cssClassRe.lastIndex = 0
522
+ let m: RegExpExecArray | null
523
+ while ((m = cssClassRe.exec(stripped)) !== null) {
524
+ if (m[1]) classes.add(m[1])
525
+ }
526
+ }
527
+
528
+ /** Extract CSS class names from an HTML string (class="..." attributes only). */
529
+ function extractFromHtml(html: string): void {
530
+ htmlClassRe.lastIndex = 0
531
+ let m: RegExpExecArray | null
532
+ while ((m = htmlClassRe.exec(html)) !== null) {
533
+ for (const cls of m[1].split(/\s+/)) {
534
+ if (cls.trim()) classes.add(cls.trim())
535
+ }
536
+ }
537
+ // Also scan inline <style>...</style> blocks inside HTML snippets
538
+ const styleBlockRe = /<style[^>]*>([\s\S]*?)<\/style>/gi
539
+ styleBlockRe.lastIndex = 0
540
+ while ((m = styleBlockRe.exec(html)) !== null) {
541
+ extractFromCss(m[1])
542
+ }
543
+ }
544
+
545
+ /**
546
+ * Scan a DESIGN.md section body and extract CSS class names only from:
547
+ * - ```css ... ``` code blocks → CSS selector extraction
548
+ * - ```html ... ``` code blocks → HTML class="..." attribute extraction
549
+ * - <style>...</style> blocks → CSS selector extraction
550
+ *
551
+ * Skips ```javascript / ```js / ```ts code blocks entirely to avoid
552
+ * extracting JS method names (e.g. .classList, .forEach) as class names.
553
+ */
554
+ function extractFromSection(text: string): void {
555
+ // Match fenced code blocks: ```<lang>\n...\n```
556
+ const fenceRe = /```(\w*)\n([\s\S]*?)```/g
557
+ let m: RegExpExecArray | null
558
+ fenceRe.lastIndex = 0
559
+ while ((m = fenceRe.exec(text)) !== null) {
560
+ const lang = m[1].toLowerCase()
561
+ const body = m[2]
562
+ if (lang === "css" || lang === "scss" || lang === "less") {
563
+ extractFromCss(body)
564
+ } else if (lang === "html" || lang === "xml" || lang === "") {
565
+ // Unknown-lang fences in DESIGN.md are usually HTML snippets
566
+ extractFromHtml(body)
567
+ }
568
+ // javascript / js / ts / typescript → skip entirely
569
+ }
570
+
571
+ // Also scan top-level <style>...</style> outside code blocks
572
+ const styleBlockRe = /<style[^>]*>([\s\S]*?)<\/style>/gi
573
+ styleBlockRe.lastIndex = 0
574
+ while ((m = styleBlockRe.exec(text)) !== null) {
575
+ extractFromCss(m[1])
576
+ }
577
+ }
578
+
579
+ // Extract from all sections (foundation, rules, etc.)
580
+ for (const content of Object.values(sections)) {
581
+ extractFromSection(content)
582
+ }
583
+
584
+ // Extract from all layouts
585
+ for (const { content } of Object.values(layouts)) {
586
+ extractFromSection(content)
587
+ }
588
+
589
+ // Extract from all components
590
+ for (const content of Object.values(components)) {
591
+ extractFromSection(content)
592
+ }
593
+
594
+ return { classes, prefixExemptions: DEFAULT_PREFIX_EXEMPTIONS }
595
+ }
596
+
439
597
  async function installFromUrl(url: string, name?: string): Promise<string> {
440
598
  // Download zip to temp dir
441
599
  const tmp = join(tmpdir(), `revela-design-${Date.now()}`)
package/lib/log.ts CHANGED
@@ -17,8 +17,9 @@ export const log = new Logger({
17
17
  type: "json",
18
18
  hideLogPositionForProduction: true,
19
19
  overwrite: {
20
- transportJSON: (logObj: unknown) => {
21
- process.stderr.write(JSON.stringify(logObj) + "\n")
20
+ transportJSON: (_logObj: unknown) => {
21
+ // Silenced: revela runs as an OpenCode plugin; writing to stderr
22
+ // pollutes the host terminal. Logs are intentionally suppressed.
22
23
  },
23
24
  },
24
25
  })
package/lib/qa/checks.ts CHANGED
@@ -1,15 +1,17 @@
1
1
  /**
2
2
  * lib/qa/checks.ts
3
3
  *
4
- * Geometry-based layout quality checks — four orthogonal visual dimensions.
4
+ * Geometry-based layout quality checks — four orthogonal visual dimensions,
5
+ * plus a design-compliance dimension that verifies CSS class usage.
5
6
  *
6
- * Dimension 1: Overflow — elements exceed canvas bounds (correctness)
7
- * Dimension 2: Balance — content centroid & distribution (fill, sparsity)
8
- * Dimension 3: Symmetry side-by-side element consistency (height, density)
9
- * Dimension 4: Rhythm spacing regularity & internal whitespace
7
+ * Dimension 1: Overflow — elements exceed canvas bounds (correctness)
8
+ * Dimension 2: Balance — content centroid & distribution (fill, sparsity)
9
+ * Dimension 3: Rhythm spacing regularity & internal whitespace
10
+ * Dimension 4: Compliance CSS classes match the active design's vocabulary
10
11
  *
11
12
  * All checks operate on SlideMetrics produced by measure.ts.
12
- * Design-system-agnostic: no CSS class-name assumptions.
13
+ * Dimensions 1–4 are geometry-only (no CSS class-name assumptions).
14
+ * Dimension 5 requires an allowedClasses vocabulary from the design system.
13
15
  */
14
16
 
15
17
  import type { SlideMetrics, ElementInfo, Rect } from "./measure"
@@ -20,11 +22,12 @@ import { CANVAS_W, CANVAS_H } from "./measure"
20
22
  export type IssueSeverity = "error" | "warning" | "info"
21
23
 
22
24
  export interface LayoutIssue {
23
- type: "overflow" | "balance" | "symmetry" | "rhythm"
25
+ type: "overflow" | "balance" | "symmetry" | "rhythm" | "compliance"
24
26
  /** Sub-category within the dimension */
25
27
  sub?: "centroid_offset" | "bottom_gap" | "sparse"
26
28
  | "height_mismatch" | "density_mismatch"
27
29
  | "gap_variance"
30
+ | "unknown_class" | "novel_css_rule"
28
31
  severity: IssueSeverity
29
32
  /** Human-readable description for the LLM to act on */
30
33
  detail: string
@@ -520,20 +523,137 @@ function checkRhythm(metrics: SlideMetrics): LayoutIssue[] {
520
523
  return issues
521
524
  }
522
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
+
523
612
  // ── Main export ───────────────────────────────────────────────────────────────
524
613
 
525
614
  /**
526
- * Run all four dimension checks on a set of slide metrics and produce a QA report.
615
+ * Options for runChecks(). All fields are optional omitting them disables
616
+ * the corresponding checks (backward compatible).
617
+ */
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
+ }
626
+
627
+ /**
628
+ * Run all dimension checks on a set of slide metrics and produce a QA report.
527
629
  */
528
- export function runChecks(filePath: string, allMetrics: SlideMetrics[]): QAReport {
630
+ export function runChecks(
631
+ filePath: string,
632
+ allMetrics: SlideMetrics[],
633
+ options?: RunChecksOptions,
634
+ ): QAReport {
529
635
  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
+ : []
530
643
 
531
644
  for (const metrics of allMetrics) {
645
+ const complianceIssues: LayoutIssue[] =
646
+ allowedClasses
647
+ ? checkCompliance(metrics, allowedClasses, prefixExemptions)
648
+ : []
649
+
532
650
  const issues: LayoutIssue[] = [
533
651
  ...checkOverflow(metrics),
534
652
  ...checkBalance(metrics),
535
- ...checkSymmetry(metrics),
536
653
  ...checkRhythm(metrics),
654
+ ...complianceIssues,
655
+ // Attach novel_css_rule issues to slide 0 only
656
+ ...(metrics.index === 0 ? novelCssIssues : []),
537
657
  ]
538
658
 
539
659
  slides.push({ index: metrics.index, title: metrics.title, issues })
@@ -594,9 +714,9 @@ export function formatReport(report: QAReport): string {
594
714
  `- **balance/centroid_offset**: redistribute content so the visual weight is centred — avoid concentrating everything in one corner or side.`,
595
715
  `- **balance/bottom_gap**: expand content to fill the slide, use \`flex: 1\` on containers, add more content blocks, or reduce top padding.`,
596
716
  `- **balance/sparse**: add more content components, increase font sizes, or use a layout with fewer columns.`,
597
- `- **symmetry/height_mismatch**: equalise side-by-side column heights — use \`align-items: stretch\` or match content density.`,
598
- `- **symmetry/density_mismatch**: balance content between columns — add items to the sparse column or reduce items in the dense one.`,
599
717
  `- **rhythm/gap_variance**: use consistent \`gap\` or \`margin\` values between stacked elements instead of mixing sizes.`,
718
+ `- **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
+ `- **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.`,
600
720
  )
601
721
 
602
722
  return lines.join("\n")
package/lib/qa/index.ts CHANGED
@@ -7,31 +7,49 @@
7
7
 
8
8
  import { measureSlides } from "./measure"
9
9
  import { runChecks, formatReport } from "./checks"
10
- import type { QAReport } from "./checks"
10
+ import type { QAReport, RunChecksOptions } from "./checks"
11
+ import type { DesignClassVocabulary } from "../design/designs"
11
12
 
12
13
  export type { QAReport, SlideReport, LayoutIssue, IssueSeverity } from "./checks"
14
+ export type { RunChecksOptions } from "./checks"
13
15
 
14
16
  /**
15
17
  * Run a full layout QA pass on `htmlFilePath`.
16
18
  *
17
19
  * 1. Opens the file in headless Chrome (puppeteer-core)
18
- * 2. Measures each .slide element's geometry
19
- * 3. Runs all checks (fill, whitespace, overflow, asymmetry, sparse)
20
+ * 2. Measures each .slide element's geometry + CSS class definitions
21
+ * 3. Runs all checks (overflow, balance, symmetry, rhythm, compliance)
20
22
  * 4. Returns a structured QAReport
21
23
  *
24
+ * Pass `vocabulary` (from `extractDesignClasses()`) to enable compliance checks.
25
+ * Omit it to run geometry-only checks (backward compatible).
26
+ *
22
27
  * Throws if the file cannot be opened or Chrome is not found.
23
28
  */
24
- export async function runQA(htmlFilePath: string): Promise<QAReport> {
25
- const metrics = await measureSlides(htmlFilePath)
26
- return runChecks(htmlFilePath, metrics)
29
+ export async function runQA(
30
+ htmlFilePath: string,
31
+ vocabulary?: DesignClassVocabulary,
32
+ ): Promise<QAReport> {
33
+ 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)
27
42
  }
28
43
 
29
44
  /**
30
45
  * Run QA and return a formatted markdown report string.
31
46
  * Suitable for injecting into tool output or sending as a message to the LLM.
32
47
  */
33
- export async function runQAFormatted(htmlFilePath: string): Promise<string> {
34
- const report = await runQA(htmlFilePath)
48
+ export async function runQAFormatted(
49
+ htmlFilePath: string,
50
+ vocabulary?: DesignClassVocabulary,
51
+ ): Promise<string> {
52
+ const report = await runQA(htmlFilePath, vocabulary)
35
53
  return formatReport(report)
36
54
  }
37
55
 
package/lib/qa/measure.ts CHANGED
@@ -47,6 +47,8 @@ export interface ElementInfo {
47
47
  visible: boolean
48
48
  /** direct children that are also visible */
49
49
  children: ElementInfo[]
50
+ /** all CSS class names on this element */
51
+ classList: string[]
50
52
  }
51
53
 
52
54
  export interface SlideMetrics {
@@ -69,6 +71,16 @@ export interface SlideMetrics {
69
71
  contentRect: Rect
70
72
  }
71
73
 
74
+ /**
75
+ * Result returned by measureSlides().
76
+ * Contains per-slide geometry data and CSS class names defined in <style> blocks.
77
+ */
78
+ export interface MeasurementResult {
79
+ slides: SlideMetrics[]
80
+ /** All CSS class names defined in <style> blocks of the HTML (deduplicated). */
81
+ cssDefinedClasses: string[]
82
+ }
83
+
72
84
  // ── Helpers ──────────────────────────────────────────────────────────────────
73
85
 
74
86
  function findChromePath(): string {
@@ -86,9 +98,9 @@ function findChromePath(): string {
86
98
 
87
99
  /**
88
100
  * Open `htmlFilePath` in a headless Chrome at 1920×1080, measure each slide,
89
- * and return an array of SlideMetrics (one per .slide element).
101
+ * and return slide geometry + CSS class names defined in <style> blocks.
90
102
  */
91
- export async function measureSlides(htmlFilePath: string): Promise<SlideMetrics[]> {
103
+ export async function measureSlides(htmlFilePath: string): Promise<MeasurementResult> {
92
104
  const executablePath = findChromePath()
93
105
  const fileUrl = pathToFileURL(htmlFilePath).href
94
106
 
@@ -106,9 +118,22 @@ export async function measureSlides(htmlFilePath: string): Promise<SlideMetrics[
106
118
  try {
107
119
  const page = await browser.newPage()
108
120
 
121
+ // Block all external (http/https) requests — fonts, CDN scripts, images.
122
+ // QA checks are purely geometry-based and do not require network resources.
123
+ // This makes measurement fast and reliable regardless of network conditions.
124
+ await page.setRequestInterception(true)
125
+ page.on("request", (req) => {
126
+ const url = req.url()
127
+ if (url.startsWith("https://") || url.startsWith("http://")) {
128
+ req.abort()
129
+ } else {
130
+ req.continue()
131
+ }
132
+ })
133
+
109
134
  // Set viewport to exact canvas size so scale === 1 (no CSS transform needed).
110
135
  await page.setViewport({ width: CANVAS_W, height: CANVAS_H })
111
- await page.goto(fileUrl, { waitUntil: "networkidle0", timeout: 30000 })
136
+ await page.goto(fileUrl, { waitUntil: "domcontentloaded", timeout: 15000 })
112
137
 
113
138
  // Wait for any entrance animations / intersection observers to fire.
114
139
  await new Promise((r) => setTimeout(r, 600))
@@ -181,6 +206,7 @@ export async function measureSlides(htmlFilePath: string): Promise<SlideMetrics[
181
206
  rect: ReturnType<typeof toRectRelative>
182
207
  visible: boolean
183
208
  children: EI[]
209
+ classList: string[]
184
210
  }
185
211
 
186
212
  function collectChildren(
@@ -208,6 +234,7 @@ export async function measureSlides(htmlFilePath: string): Promise<SlideMetrics[
208
234
  selector: selectorOf(child),
209
235
  rect: relR,
210
236
  visible: true,
237
+ classList: Array.from(child.classList),
211
238
  children: collectChildren(child, offsetTop, offsetLeft, depth + 1),
212
239
  })
213
240
  }
@@ -281,7 +308,30 @@ export async function measureSlides(htmlFilePath: string): Promise<SlideMetrics[
281
308
  if (slideData) metrics.push(slideData as SlideMetrics)
282
309
  }
283
310
 
284
- return metrics
311
+ // Extract all CSS class names defined in <style> blocks.
312
+ // Uses the browser's CSSStyleRule API for reliable selector parsing.
313
+ const cssDefinedClasses = await page.evaluate((): string[] => {
314
+ const classes: string[] = []
315
+ const classRe = /\.([a-zA-Z_][\w-]*)/g
316
+ for (const sheet of Array.from(document.styleSheets)) {
317
+ try {
318
+ for (const rule of Array.from(sheet.cssRules)) {
319
+ if (rule instanceof CSSStyleRule) {
320
+ let m: RegExpExecArray | null
321
+ classRe.lastIndex = 0
322
+ while ((m = classRe.exec(rule.selectorText)) !== null) {
323
+ classes.push(m[1])
324
+ }
325
+ }
326
+ }
327
+ } catch {
328
+ // Cross-origin or inaccessible sheets (e.g. external CDN) will throw
329
+ }
330
+ }
331
+ return [...new Set(classes)]
332
+ })
333
+
334
+ return { slides: metrics, cssDefinedClasses }
285
335
  } finally {
286
336
  await browser.close()
287
337
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyber-dash-tech/revela",
3
- "version": "0.1.3",
3
+ "version": "0.1.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
@@ -48,6 +48,7 @@ import workspaceScanTool from "./tools/workspace-scan"
48
48
  import qaTool from "./tools/qa"
49
49
  import { RESEARCH_PROMPT, RESEARCH_AGENT_SIGNATURE } from "./lib/agents/research-prompt"
50
50
  import { runQA, formatReport } from "./lib/qa"
51
+ import { extractDesignClasses } from "./lib/design/designs"
51
52
  import { log, childLog } from "./lib/log"
52
53
 
53
54
  // OpenCode internal agent signatures — used to skip system prompt injection
@@ -111,6 +112,7 @@ const server: Plugin = (async (pluginCtx) => {
111
112
  // Permissions: read-only on edit/bash; write allowed to create researches/ files.
112
113
  // No model override — inherits from the calling primary agent.
113
114
  opencodeConfig.agent ??= {}
115
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
114
116
  opencodeConfig.agent["revela-research"] = {
115
117
  description: "Revela research agent — searches and collects raw materials for presentations",
116
118
  mode: "subagent",
@@ -123,7 +125,19 @@ const server: Plugin = (async (pluginCtx) => {
123
125
  "ls": "allow",
124
126
  },
125
127
  webfetch: "allow",
126
- },
128
+ } as any,
129
+ }
130
+ // Give revela-research explicit websearch allow (overrides global deny below)
131
+ ;(opencodeConfig.agent["revela-research"].permission as any).websearch = "allow"
132
+
133
+ // Block websearch for the primary agent globally.
134
+ // permission.ask hook is not triggered by OpenCode (no R.trigger call in binary).
135
+ // tool.execute.before throw is swallowed (trigger().catch(()=>{})).
136
+ // The only working mechanism is the config-level permission ruleset.
137
+ // revela-research agent overrides this with websearch: "allow" above.
138
+ opencodeConfig.permission ??= {}
139
+ if (!(opencodeConfig.permission as Record<string, unknown>)["websearch"]) {
140
+ ;(opencodeConfig.permission as Record<string, unknown>)["websearch"] = "deny"
127
141
  }
128
142
  },
129
143
 
@@ -283,22 +297,10 @@ const server: Plugin = (async (pluginCtx) => {
283
297
  // ── Pre-read: intercept binary files before read executes ──────────────
284
298
  // Handles DOCX/PPTX/XLSX — read tool would Effect.fail on these.
285
299
  // Extracts text → writes temp .txt → redirects args.filePath.
286
- //
287
- // Also blocks websearch for the primary agent — websearch must be delegated
288
- // to the revela-research subagent. Use webfetch for specific URLs instead.
289
300
  "tool.execute.before": async (input, output) => {
301
+ log.info("[hook] tool.execute.before fired", { tool: input.tool, enabled: ctx.enabled, isResearch: ctx.isResearchAgent })
290
302
  if (!ctx.enabled) return
291
303
 
292
- // ── Block websearch for primary agent ──────────────────────────────
293
- if (input.tool === "websearch" && !ctx.isResearchAgent) {
294
- throw new Error(
295
- "[revela] websearch is not available for the primary agent. " +
296
- "Delegate web research to the revela-research subagent via the Task tool — " +
297
- "it searches systematically and saves structured findings for reuse across sessions. " +
298
- "Use the webfetch tool if you need to read a specific URL directly.",
299
- )
300
- }
301
-
302
304
  if (input.tool !== "read") return
303
305
  try {
304
306
  await preRead(output.args)
@@ -338,7 +340,14 @@ const server: Plugin = (async (pluginCtx) => {
338
340
  if (!filePath.match(/slides\/[^/]+\.html$/)) return
339
341
 
340
342
  try {
341
- const report = await runQA(filePath)
343
+ // Extract design's allowed class vocabulary for compliance checking
344
+ let vocabulary
345
+ try {
346
+ vocabulary = extractDesignClasses()
347
+ } catch {
348
+ // Design may not be installed or may have no markers — skip compliance
349
+ }
350
+ const report = await runQA(filePath, vocabulary)
342
351
  // Only append QA report to tool output if there are issues
343
352
  if (report.totalIssues > 0) {
344
353
  const formatted = formatReport(report)
package/skill/SKILL.md CHANGED
@@ -34,9 +34,9 @@ Before writing any HTML, ask the user these questions **in a single message**
34
34
 
35
35
  If the user's first message already answers most of these, skip what's clear and
36
36
  only ask about what's missing. If the message is detailed enough, proceed directly
37
- to Phase 1.5.
37
+ to Phase 2.
38
38
 
39
- ### Phase 1.5 — Select Design
39
+ ### Phase 2 — Select Design
40
40
 
41
41
  Once you have the user's answers (especially topic, audience, and visual style),
42
42
  pick the best-fit design before generating slides.
@@ -58,16 +58,16 @@ pick the best-fit design before generating slides.
58
58
 
59
59
  4. Wait for the user's reply, then act:
60
60
  - **Confirmed** (e.g. "yes", "sure", "go ahead") → activate the recommended
61
- design and proceed to Phase 2:
61
+ design and proceed to Phase 3:
62
62
  Call the `designs` tool with action `"activate"` and name `"<name>"`.
63
- - **User names a different design** → activate that one instead, then Phase 2.
64
- - **User says keep the current one** → skip the switch, proceed to Phase 2.
63
+ - **User names a different design** → activate that one instead, then Phase 3.
64
+ - **User says keep the current one** → skip the switch, proceed to Phase 3.
65
65
 
66
- Do not proceed to Phase 2 until the user has replied to the design question.
66
+ Do not proceed to Phase 3 until the user has replied to the design question.
67
67
 
68
68
  ---
69
69
 
70
- ### Phase 1.8 — Research-First Protocol (自主调研)
70
+ ### Phase 3 — Research-First Protocol (自主调研)
71
71
 
72
72
  **Always execute this phase — regardless of whether the user mentions reference
73
73
  files.** Your job is to proactively gather all available information before
@@ -83,26 +83,26 @@ Research layers are **NOT** a sequential fallback chain where you stop once
83
83
  │ LAUNCH TOGETHER (as your first action): │
84
84
  │ │
85
85
  │ ┌──────────────┐ ┌─────────────────────┐ │
86
- │ │ Layer 1 │ │ Layer 2.5 │ │
86
+ │ │ Layer 1 │ │ Layer 2 │ │
87
87
  │ │ Workspace │ │ Research agents │ │
88
88
  │ │ scan │ │ (parallel per axis) │ │
89
89
  │ └──────────────┘ └─────────────────────┘ │
90
90
  │ │
91
91
  │ After both complete: │
92
92
  │ ┌──────────────┐ │
93
- │ │ Layer 2 │ AI knowledge fills gaps │
93
+ │ │ Layer 3 │ AI knowledge fills gaps │
94
94
  │ └──────────────┘ │
95
95
  │ │
96
96
  │ Only if still missing: │
97
97
  │ ┌──────────────┐ │
98
- │ │ Layer 3 │ Ask the user │
98
+ │ │ Layer 4 │ Ask the user │
99
99
  │ └──────────────┘ │
100
100
  └─────────────────────────────────────────────┘
101
101
  ```
102
102
 
103
- **Layer 1 and Layer 2.5 launch in parallel as the FIRST action after Phase 1.5.**
104
- Do not wait for Layer 1 results before launching Layer 2.5. Do not use Layer 2
105
- (AI knowledge) as an excuse to skip Layer 2.5.
103
+ **Layer 1 and Layer 2 launch in parallel as the FIRST action after Phase 2.**
104
+ Do not wait for Layer 1 results before launching Layer 2. Do not use Layer 3
105
+ (AI knowledge) as an excuse to skip Layer 2.
106
106
 
107
107
  ---
108
108
 
@@ -119,15 +119,16 @@ extracts text from binary formats (PDF, Excel, Word, PowerPoint) — just call
119
119
 
120
120
  ---
121
121
 
122
- #### Layer 2.5 — Deep Research via Research Agents (MANDATORY)
122
+ #### Layer 2 — Deep Research via Research Agents (MANDATORY)
123
123
 
124
124
  **This layer is mandatory whenever the `@revela-research` subagent (Task tool
125
125
  with `subagent_type: "revela-research"`) is available.** It is the primary
126
126
  research workhorse — not an optional enhancement.
127
127
 
128
- The research agent searches the web aggressively using `webfetch` on targeted
129
- URLs, reads workspace documents, and writes structured findings to a single
130
- file `researches/{topic-slug}/{axis-name}.md` in the workspace.
128
+ The research agent searches the web using `websearch` for broad discovery and
129
+ `webfetch` for depth on specific pages, reads workspace documents, and writes
130
+ structured findings to a single file `researches/{topic-slug}/{axis-name}.md`
131
+ in the workspace.
131
132
 
132
133
  ##### Parallelization Rule
133
134
 
@@ -160,7 +161,8 @@ List and read the findings files: `ls researches/{topic-slug}/`, then `read`
160
161
  each `.md` file. Each file contains structured `## Data`, `## Cases`,
161
162
  `## Images`, and `## Gaps` sections — use these directly as slide material.
162
163
  Cross-reference agent findings with workspace documents (Layer 1). Flag any
163
- contradictions.
164
+ contradictions. Once all findings are read, proceed to Phase 4 to present the
165
+ slide plan.
164
166
 
165
167
  **Anti-pattern — NEVER do this:**
166
168
  - Do NOT use `websearch` directly — it is blocked by the Revela plugin;
@@ -171,23 +173,23 @@ contradictions.
171
173
 
172
174
  ---
173
175
 
174
- #### Layer 2 — AI Knowledge (Supplementary)
176
+ #### Layer 3 — AI Knowledge (Supplementary)
175
177
 
176
- After Layer 1 and Layer 2.5 results are in, use your training data to fill
178
+ After Layer 1 and Layer 2 results are in, use your training data to fill
177
179
  remaining gaps: industry context, historical background, technical explanations.
178
180
 
179
181
  **Critical:** Always mark AI-sourced information with
180
182
  `[Source: AI 公开知识,建议核实]`. Never present AI knowledge as verified fact.
181
183
 
182
184
  This layer is supplementary — it adds context around the hard data from
183
- Layers 1 and 2.5. It must never be the primary source for quantitative claims
185
+ Layers 1 and 2. It must never be the primary source for quantitative claims
184
186
  (market size, revenue, growth rates, etc.).
185
187
 
186
188
  ---
187
189
 
188
- #### Layer 3 — Ask the User (Last Resort Only)
190
+ #### Layer 4 — Ask the User (Last Resort Only)
189
191
 
190
- Only ask the user for information that Layers 1, 2, and 2.5 cannot cover.
192
+ Only ask the user for information that Layers 1, 2, and 3 cannot cover.
191
193
  When asking, first report what you already know:
192
194
 
193
195
  > 我已从 workspace 文档和在线调研中获取了以下信息:
@@ -210,7 +212,6 @@ When asking, first report what you already know:
210
212
  - **ALWAYS** decompose the topic into independent axes before launching agents
211
213
  - **ALWAYS** read each `researches/{slug}/{axis}.md` after agents complete
212
214
  - Use the `read` tool for all file types — binary formats are handled transparently
213
-
214
215
  ---
215
216
 
216
217
  ### Required Slide Structure
@@ -260,7 +261,41 @@ core rules and the visual design below.
260
261
 
261
262
  ---
262
263
 
263
- ### Phase 2Generate
264
+ ### Phase 4Presentation Plan
265
+
266
+ After all research is complete and findings have been read, present a detailed
267
+ slide plan to the user **before writing any HTML**.
268
+
269
+ Format the plan as a markdown table:
270
+
271
+ | # | Title | Content Summary | Layout | Components |
272
+ |---|-------|-----------------|--------|------------|
273
+ | 1 | Cover | Topic title, subtitle, presenter, date | `cover` | `gradient-text`, `deco-blob`, `accent-line` |
274
+ | 2 | Table of Contents | 5 chapter headings | `toc` | `toc-list` |
275
+ | 3 | Market Background | Key problem, 3 pain points, $4.2B TAM | `two-col` | `evidence-list`, `card` |
276
+ | 4 | Key Metrics | Growth 85%, TAM $12B, NPS 72 | `stats` | `stat-card ×3`, `gradient-text` |
277
+
278
+ Rules for filling the table:
279
+ - **Layout**: use the exact layout name from the Layout Index (e.g. `cover`, `two-col`, `card-grid`, `stats`)
280
+ - **Components**: list component names from the Component Index — no CSS details
281
+ (e.g. `card ×3`, `stat-card`, `evidence-list`, `step-flow`, `quote-block`)
282
+ - **Content Summary**: 1 sentence of actual content — specific numbers, key points, or
283
+ real data from research findings (not vague descriptions like "overview of topic")
284
+
285
+ After the table, add one sentence explaining any notable layout choices if non-obvious.
286
+
287
+ Then ask:
288
+ > "Does this plan look good? I'll generate the HTML once you confirm — or let me know
289
+ > if you'd like to adjust any slide."
290
+
291
+ **Do not write any HTML until the user replies with confirmation.**
292
+
293
+ - On confirmation → proceed to Phase 5
294
+ - On change request → update the table and ask again
295
+
296
+ ---
297
+
298
+ ### Phase 5 — Generate
264
299
 
265
300
  Once you have enough information, generate the complete HTML file in one shot.
266
301
 
@@ -270,7 +305,7 @@ Once you have enough information, generate the complete HTML file in one shot.
270
305
  (e.g. "AI Future" → `slides/ai-future.html`)
271
306
  - The file must be completely self-contained (all CSS and JS inline)
272
307
 
273
- ### Phase 3 — Iterate
308
+ ### Phase 6 — Iterate
274
309
 
275
310
  After generating, briefly tell the user:
276
311
  - The filename you wrote (e.g. `slides/ai-future.html`)
@@ -301,6 +336,42 @@ Follow these rules on every generation. They are non-negotiable.
301
336
  Never use any other icon library (no Font Awesome, no Heroicons, no Material Icons).
302
337
  - All JS methods must be **fully implemented** — no empty stubs, no `// TODO` comments.
303
338
 
339
+ ### Design Compliance — Strict Mode
340
+
341
+ The active design defines a **closed vocabulary** of layouts and components.
342
+ You MUST use ONLY the layouts and components listed in the Layout Index and
343
+ Component Index injected into this prompt.
344
+
345
+ **Layouts:** Every `<section class="slide">` must use exactly one layout class
346
+ from the Layout Index. Do NOT invent custom grid or flex structures.
347
+
348
+ **Components:** Every content block must use a component class from the
349
+ Component Index. Do NOT create novel CSS classes for content elements.
350
+
351
+ **`<style>` block — no new class rules.** The design already provides all
352
+ necessary CSS (foundation, layouts, components). Your `<style>` block should
353
+ contain only CSS rules copied verbatim from the design's sections. Never define
354
+ a CSS class rule (`.my-custom-thing { ... }`) that is not in the design.
355
+
356
+ **Inline `style=""` — minor adjustments only.** Inline styles are permitted
357
+ for fine-tuning spacing and sizing (`margin`, `padding`, `gap`, `font-size`,
358
+ `max-width`, `min-height`, `width`, `height`). They must NOT be used to
359
+ define new visual effects — no custom `background-image`, `box-shadow`,
360
+ `border-radius`, `color`, or layout structures via inline style.
361
+
362
+ **CSS variables:** Use only `var(--xxx)` properties defined in
363
+ `@design:foundation`. Do NOT define new custom properties.
364
+
365
+ **Fetch before use:** Before generating any slide, call the `revela-designs`
366
+ tool to fetch the full HTML/CSS for each layout and component you plan to
367
+ use. Generate HTML that matches the fetched examples exactly.
368
+
369
+ **No suitable component?** Adapt the *content* to fit the closest available
370
+ component — never adapt the component structure to fit content.
371
+
372
+ The QA system will automatically flag any unrecognised CSS class as a
373
+ compliance warning after you write the file.
374
+
304
375
  ### Inline Editing
305
376
 
306
377
  **Always include inline editing** in every generated presentation. The complete
@@ -314,8 +385,9 @@ element selector list, and `window.getEditedHTML()` definition.
314
385
  - Always use the **original** file path in HTML `<img src>` for full-quality rendering
315
386
  - Never repeat the same image on multiple slides (logos: title + closing only)
316
387
  - Image compression is handled automatically by the server
317
- - **Use the active design's image components** (`.image-card`, `.card-img`, `.avatar`)
318
- for displaying images — they provide proper rounded corners and cropping
388
+ - **Use the active design's image components** for displaying images — they
389
+ provide proper rounded corners and cropping. Use inline `style=""` only for
390
+ minor sizing adjustments; do not create custom image container classes.
319
391
 
320
392
  ### Accessibility
321
393
 
@@ -333,13 +405,15 @@ element selector list, and `window.getEditedHTML()` definition.
333
405
 
334
406
  ### Visual Quality Rules
335
407
 
336
- **Layout Diversity** — choose components and layout based on content type, never
337
- default to a bullet list. The active design's **Composition Guide** suggests
338
- which components work well for each content pattern — consult it first.
408
+ **Layout Diversity** — choose from the design's defined layouts and components
409
+ based on content type, never default to a bullet list. The active design's
410
+ **Composition Guide** suggests which components work well for each content
411
+ pattern — consult it first.
339
412
 
340
413
  The active design's **Component Library** defines the HTML/CSS for each
341
414
  component, and **Layout Primitives** defines the grid/flex patterns for
342
- arranging them. Combine components and layouts freely to serve the content.
415
+ arranging them. Combine the design's defined layouts and components to serve
416
+ the content — never invent new ones.
343
417
 
344
418
  **Visual Hierarchy** — every slide must have exactly 1 dominant visual focal point.
345
419
  Forbidden: plain background + unstyled bullet list with zero decorative elements.
package/tools/qa.ts CHANGED
@@ -12,6 +12,7 @@ import { tool } from "@opencode-ai/plugin"
12
12
  import { resolve } from "path"
13
13
  import { existsSync } from "fs"
14
14
  import { runQA, formatReport } from "../lib/qa"
15
+ import { extractDesignClasses } from "../lib/design/designs"
15
16
 
16
17
  export default tool({
17
18
  description:
@@ -42,7 +43,14 @@ export default tool({
42
43
  }
43
44
 
44
45
  try {
45
- const report = await runQA(filePath)
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)
46
54
  const formatted = formatReport(report)
47
55
 
48
56
  // Prepend a compact JSON summary for programmatic use if needed
@@ -1,6 +1,6 @@
1
1
  import { tool } from "@opencode-ai/plugin"
2
2
  import { readdirSync, statSync, existsSync } from "fs"
3
- import { join, relative, extname } from "path"
3
+ import { join, relative, extname, resolve, sep, isAbsolute } from "path"
4
4
 
5
5
  const DOC_EXTENSIONS = new Set([
6
6
  ".pdf", ".docx", ".doc", ".xlsx", ".xls",
@@ -113,7 +113,22 @@ export default tool({
113
113
  async execute(args, context) {
114
114
  try {
115
115
  const workspaceDir = context.directory ?? process.cwd()
116
- const scanRoot = args.path ? join(workspaceDir, args.path) : workspaceDir
116
+
117
+ // Validate and resolve scanRoot — must stay within workspaceDir
118
+ let scanRoot = workspaceDir
119
+ if (args.path) {
120
+ if (isAbsolute(args.path)) {
121
+ return JSON.stringify({ error: "path must be relative to workspace root" })
122
+ }
123
+ const candidate = join(workspaceDir, args.path)
124
+ const resolvedCandidate = resolve(candidate)
125
+ const resolvedWorkspace = resolve(workspaceDir)
126
+ if (resolvedCandidate !== resolvedWorkspace && !resolvedCandidate.startsWith(resolvedWorkspace + sep)) {
127
+ return JSON.stringify({ error: "path must be within workspace" })
128
+ }
129
+ scanRoot = candidate
130
+ }
131
+
117
132
  const maxDepth = args.max_depth ?? 6
118
133
 
119
134
  if (!existsSync(scanRoot)) {