@cyber-dash-tech/revela 0.15.0 → 0.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/qa/index.ts CHANGED
@@ -1,15 +1,17 @@
1
1
  /**
2
2
  * lib/qa/index.ts
3
3
  *
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.
4
+ * Public entry point for browser-rendered slide QA.
5
+ * Combined artifact QA, including contract and component compliance, lives in
6
+ * `lib/qa/artifact.ts`.
7
7
  */
8
8
 
9
9
  import { measureSlides } from "./measure"
10
10
  import { runChecks, formatReport } from "./checks"
11
- import type { QAReport } from "./checks"
11
+ import type { LayoutIssue, QAReport } from "./checks"
12
12
  import type { DesignClassVocabulary } from "../design/designs"
13
+ import { existsSync, readFileSync } from "fs"
14
+ import { dirname, resolve } from "path"
13
15
 
14
16
  export type { QAReport, SlideReport, LayoutIssue, IssueSeverity } from "./checks"
15
17
  export type { RunChecksOptions } from "./checks"
@@ -18,12 +20,14 @@ export type { RunChecksOptions } from "./checks"
18
20
  * Run hard-error QA on `htmlFilePath`.
19
21
  *
20
22
  * 1. Opens the file in headless Chrome (puppeteer-core)
21
- * 2. Measures each .slide element's geometry + CSS class definitions
22
- * 3. Runs hard-error overflow checks only
23
+ * 2. Measures each .slide element's geometry, scroll state, text clipping,
24
+ * content-density signals, and CSS class definitions
25
+ * 3. Runs browser QA checks for exact 1920x1080 slides, scrollbars, overflow,
26
+ * text clipping, and claim/evidence density warnings
23
27
  * 4. Returns a structured QAReport
24
28
  *
25
29
  * The optional `vocabulary` argument is retained for backward compatibility;
26
- * compliance is intentionally not part of hard-error QA.
30
+ * design compliance is handled by combined artifact QA.
27
31
  *
28
32
  * Throws if the file cannot be opened or Chrome is not found.
29
33
  */
@@ -32,7 +36,76 @@ export async function runQA(
32
36
  _vocabulary?: DesignClassVocabulary,
33
37
  ): Promise<QAReport> {
34
38
  const result = await measureSlides(htmlFilePath)
35
- return runChecks(htmlFilePath, result.slides)
39
+ const report = runChecks(htmlFilePath, result.slides)
40
+ return withAssetChecks(report, htmlFilePath)
41
+ }
42
+
43
+ function withAssetChecks(report: QAReport, htmlFilePath: string): QAReport {
44
+ const issues = scanAssetRefs(htmlFilePath)
45
+ if (!issues.length) return report
46
+ const slides = [...report.slides]
47
+ const first = slides[0] ?? { index: 1, title: "Deck", issues: [] }
48
+ slides[0] = { ...first, issues: [...first.issues, ...issues] }
49
+ const errorCount = report.errorCount + issues.filter((issue) => issue.severity === "error").length
50
+ const warningCount = report.warningCount + issues.filter((issue) => issue.severity === "warning").length
51
+ return {
52
+ ...report,
53
+ slides,
54
+ totalIssues: report.totalIssues + issues.length,
55
+ errorCount,
56
+ warningCount,
57
+ summary: errorCount === 0
58
+ ? `QA passed with ${warningCount} warning${warningCount === 1 ? "" : "s"}.`
59
+ : `QA failed with ${errorCount} error${errorCount === 1 ? "" : "s"} and ${warningCount} warning${warningCount === 1 ? "" : "s"}.`,
60
+ }
61
+ }
62
+
63
+ function scanAssetRefs(htmlFilePath: string): LayoutIssue[] {
64
+ let html = ""
65
+ try {
66
+ html = readFileSync(htmlFilePath, "utf-8")
67
+ } catch {
68
+ return []
69
+ }
70
+ const refs = new Set<string>()
71
+ const attrPattern = /\b(?:src|href|poster)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi
72
+ let match: RegExpExecArray | null
73
+ while ((match = attrPattern.exec(html))) refs.add(match[1] ?? match[2] ?? match[3] ?? "")
74
+ const cssPattern = /url\(\s*(?:"([^"]*)"|'([^']*)'|([^\s)]+))\s*\)/gi
75
+ while ((match = cssPattern.exec(html))) refs.add(match[1] ?? match[2] ?? match[3] ?? "")
76
+
77
+ const issues: LayoutIssue[] = []
78
+ for (const raw of refs) {
79
+ const ref = raw.trim()
80
+ if (!ref || ref.startsWith("data:") || ref.startsWith("#") || ref.startsWith("mailto:") || ref.startsWith("tel:")) continue
81
+ if (/^https?:\/\//i.test(ref) || ref.startsWith("//")) {
82
+ issues.push({ type: "asset", sub: "remote_url", severity: "error", detail: `Deck HTML references remote asset URL \`${ref}\`. Save network images to workspace assets and reference the local file instead.` })
83
+ continue
84
+ }
85
+ if (ref.includes("/__revela_asset")) {
86
+ issues.push({ type: "asset", sub: "refine_proxy", severity: "error", detail: `Deck HTML references Refine proxy URL \`${ref}\`. Use the saved workspace asset path instead.` })
87
+ continue
88
+ }
89
+ if (!looksLikeImageRef(ref)) continue
90
+ const pathOnly = safeDecode(ref.split(/[?#]/)[0])
91
+ const resolved = resolve(dirname(htmlFilePath), pathOnly)
92
+ if (!existsSync(resolved)) {
93
+ issues.push({ type: "asset", sub: "missing_file", severity: "error", detail: `Deck HTML references missing image asset \`${ref}\`. Use a path relative to the deck HTML file or save the asset into workspace assets first.` })
94
+ }
95
+ }
96
+ return issues
97
+ }
98
+
99
+ function looksLikeImageRef(ref: string): boolean {
100
+ return /\.(?:png|jpe?g|webp|gif|svg)(?:[?#].*)?$/i.test(ref)
101
+ }
102
+
103
+ function safeDecode(value: string): string {
104
+ try {
105
+ return decodeURIComponent(value)
106
+ } catch {
107
+ return value
108
+ }
36
109
  }
37
110
 
38
111
  /**
package/lib/qa/measure.ts CHANGED
@@ -49,6 +49,19 @@ export interface ElementInfo {
49
49
  children: ElementInfo[]
50
50
  /** all CSS class names on this element */
51
51
  classList: string[]
52
+ /** visible text excerpt for text overflow diagnostics */
53
+ text?: string
54
+ /** whether text content is clipped inside this element */
55
+ textOverflow?: boolean
56
+ }
57
+
58
+ export interface SlideContentStats {
59
+ /** Non-title effective text points: English words + CJK characters. */
60
+ bodyTextPoints: number
61
+ /** Recognizable semantic content units such as boxes, cards, evidence, charts, tables, media, metrics, bullets. */
62
+ contentUnits: number
63
+ /** Evidence/source/caveat-like references visible on the slide. */
64
+ supportReferences: number
52
65
  }
53
66
 
54
67
  export interface SlideMetrics {
@@ -65,10 +78,16 @@ export interface SlideMetrics {
65
78
  slideQa: boolean
66
79
  /** bounding box of the slide-canvas element itself (post-scale) */
67
80
  canvasRect: Rect
81
+ /** bounding box of the .slide element itself (post-scale) */
82
+ slideRect: Rect
83
+ /** whether document/body/slide has scrollbars at 1920x1080 */
84
+ hasScrollbars: boolean
68
85
  /** top-level visible children of .slide-canvas */
69
86
  elements: ElementInfo[]
70
87
  /** union bounding box of all visible leaf elements */
71
88
  contentRect: Rect
89
+ /** text/content-density signals for content slides */
90
+ contentStats: SlideContentStats
72
91
  }
73
92
 
74
93
  /**
@@ -207,6 +226,29 @@ export async function measureSlides(htmlFilePath: string): Promise<MeasurementRe
207
226
  visible: boolean
208
227
  children: EI[]
209
228
  classList: string[]
229
+ text?: string
230
+ textOverflow?: boolean
231
+ }
232
+
233
+ function textPoints(text: string): number {
234
+ const normalized = text.replace(/\s+/g, " ").trim()
235
+ if (!normalized) return 0
236
+ const cjk = (normalized.match(/[\u3400-\u9fff\uf900-\ufaff]/g) || []).length
237
+ const words = (normalized.replace(/[\u3400-\u9fff\uf900-\ufaff]/g, " ").match(/[A-Za-z0-9]+(?:[-'][A-Za-z0-9]+)*/g) || []).length
238
+ return cjk + words
239
+ }
240
+
241
+ function isSemanticContentUnit(el: Element): boolean {
242
+ const tag = el.tagName.toLowerCase()
243
+ if (["li", "table", "figure", "img", "svg", "canvas", "blockquote"].includes(tag)) return true
244
+ const cls = Array.from(el.classList).join(" ")
245
+ return /\b(box|card|claim|evidence|source|caveat|metric|stat|quote|media|chart|echart|table|step|roadmap|toc-item|bullet)\b/i.test(cls)
246
+ }
247
+
248
+ function isSupportReference(el: Element): boolean {
249
+ const text = (el.textContent || "").replace(/\s+/g, " ").trim()
250
+ const cls = Array.from(el.classList).join(" ")
251
+ return /\b(evidence|source|caveat|claim|support|citation|note)\b/i.test(cls) || /\b(source|evidence|caveat|claim|来源|证据|出处|风险|假设)\b/i.test(text)
210
252
  }
211
253
 
212
254
  function collectChildren(
@@ -220,6 +262,11 @@ export async function measureSlides(htmlFilePath: string): Promise<MeasurementRe
220
262
  for (const child of Array.from(el.children)) {
221
263
  if (!isVisible(child)) continue
222
264
  const rawR = child.getBoundingClientRect()
265
+ const text = (child.textContent || "").replace(/\s+/g, " ").trim()
266
+ const textOverflow = textPoints(text) > 0 && (
267
+ (child as HTMLElement).scrollHeight > (child as HTMLElement).clientHeight + 2 ||
268
+ (child as HTMLElement).scrollWidth > (child as HTMLElement).clientWidth + 2
269
+ )
223
270
  const cls = child.className || ""
224
271
  if (
225
272
  typeof cls === "string" &&
@@ -235,6 +282,8 @@ export async function measureSlides(htmlFilePath: string): Promise<MeasurementRe
235
282
  rect: relR,
236
283
  visible: true,
237
284
  classList: Array.from(child.classList),
285
+ text: text.slice(0, 160),
286
+ textOverflow,
238
287
  children: collectChildren(child, offsetTop, offsetLeft, depth + 1),
239
288
  })
240
289
  }
@@ -273,6 +322,7 @@ export async function measureSlides(htmlFilePath: string): Promise<MeasurementRe
273
322
  if (!canvas) return null
274
323
 
275
324
  const canvasRaw = canvas.getBoundingClientRect()
325
+ const slideRaw = (slide as HTMLElement).getBoundingClientRect()
276
326
  // Use canvas top-left as the coordinate origin
277
327
  const offsetTop = canvasRaw.top
278
328
  const offsetLeft = canvasRaw.left
@@ -286,8 +336,40 @@ export async function measureSlides(htmlFilePath: string): Promise<MeasurementRe
286
336
  height: canvasRaw.height,
287
337
  }
288
338
 
339
+ const slideRect = {
340
+ left: 0,
341
+ top: 0,
342
+ right: slideRaw.width,
343
+ bottom: slideRaw.height,
344
+ width: slideRaw.width,
345
+ height: slideRaw.height,
346
+ }
347
+
289
348
  const elements = collectChildren(canvas, offsetTop, offsetLeft)
290
349
 
350
+ let bodyTextPoints = 0
351
+ let contentUnits = 0
352
+ let supportReferences = 0
353
+ for (const el of Array.from(canvas.querySelectorAll("*"))) {
354
+ if (!isVisible(el)) continue
355
+ if (/^H[1-2]$/.test(el.tagName)) continue
356
+ const text = (el.textContent || "").replace(/\s+/g, " ").trim()
357
+ if (text) bodyTextPoints += textPoints(text)
358
+ if (isSemanticContentUnit(el)) contentUnits++
359
+ if (isSupportReference(el)) supportReferences++
360
+ }
361
+
362
+ const doc = document.documentElement
363
+ const body = document.body
364
+ const slideEl = slide as HTMLElement
365
+ const hasScrollbars =
366
+ doc.scrollWidth > window.innerWidth + 2 ||
367
+ doc.scrollHeight > window.innerHeight + 2 ||
368
+ body.scrollWidth > window.innerWidth + 2 ||
369
+ body.scrollHeight > window.innerHeight + 2 ||
370
+ slideEl.scrollWidth > slideEl.clientWidth + 2 ||
371
+ slideEl.scrollHeight > slideEl.clientHeight + 2
372
+
291
373
  const titleEl = canvas.querySelector("h1, h2")
292
374
  const title = titleEl
293
375
  ? (titleEl.textContent || "").replace(/\s+/g, " ").trim().slice(0, 80)
@@ -298,8 +380,11 @@ export async function measureSlides(htmlFilePath: string): Promise<MeasurementRe
298
380
  title,
299
381
  slideQa,
300
382
  canvasRect,
383
+ slideRect,
384
+ hasScrollbars,
301
385
  elements,
302
386
  contentRect: unionRect(elements),
387
+ contentStats: { bodyTextPoints, contentUnits, supportReferences },
303
388
  }
304
389
  },
305
390
  idx
@@ -22,6 +22,10 @@ export interface OpenRefineDeckResult {
22
22
  mode: RefineMode
23
23
  }
24
24
 
25
+ export interface EnsureRefineDeckOpenResult extends OpenRefineDeckResult {
26
+ skippedReason?: "live-session"
27
+ }
28
+
25
29
  export interface OpenRefineDeckOptions {
26
30
  client: any
27
31
  sessionID: string
@@ -32,6 +36,21 @@ export interface OpenRefineDeckOptions {
32
36
  }
33
37
 
34
38
  export function openRefineDeck(target: string, options: OpenRefineDeckOptions): OpenRefineDeckResult {
39
+ return openRefineDeckInternal(target, options, { skipLiveSession: false })
40
+ }
41
+
42
+ export function ensureRefineDeckOpenForChange(
43
+ target: string,
44
+ options: OpenRefineDeckOptions,
45
+ ): EnsureRefineDeckOpenResult {
46
+ return openRefineDeckInternal(target, options, { skipLiveSession: true })
47
+ }
48
+
49
+ function openRefineDeckInternal(
50
+ target: string,
51
+ options: OpenRefineDeckOptions,
52
+ behavior: { skipLiveSession: boolean },
53
+ ): EnsureRefineDeckOpenResult {
35
54
  const deck = resolveEditableDeck(options.workspaceRoot, target)
36
55
  const preflight = ensureEditableDeckState(options.workspaceRoot, deck)
37
56
  assertDeckHtmlContractValid(options.workspaceRoot, deck.absoluteFile)
@@ -53,7 +72,7 @@ export function openRefineDeck(target: string, options: OpenRefineDeckOptions):
53
72
  mode,
54
73
  })
55
74
  const url = `${refineServer.baseUrl}/refine?token=${encodeURIComponent(session.token)}`
56
- const shouldOpen = options.openBrowser !== false
75
+ const shouldOpen = options.openBrowser !== false && !(behavior.skipLiveSession && session.live)
57
76
  if (shouldOpen) (options.openUrl ?? openUrl)(url)
58
77
 
59
78
  return {
@@ -66,5 +85,6 @@ export function openRefineDeck(target: string, options: OpenRefineDeckOptions):
66
85
  liveSession: session.live,
67
86
  openedBrowser: shouldOpen,
68
87
  mode,
88
+ skippedReason: behavior.skipLiveSession && session.live ? "live-session" : undefined,
69
89
  }
70
90
  }