@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/README.md +6 -7
- package/README.zh-CN.md +6 -7
- package/designs/starter/DESIGN.md +168 -171
- package/designs/starter/preview.html +2 -2
- package/designs/summit/DESIGN.md +283 -129
- package/lib/commands/edit.ts +2 -21
- package/lib/commands/help.ts +1 -2
- package/lib/commands/narrative.ts +26 -0
- package/lib/commands/review.ts +49 -12
- package/lib/decks-state.ts +122 -3
- package/lib/design/designs.ts +1 -2
- package/lib/edit/prompt.ts +18 -5
- package/lib/edit/resolve-deck.ts +1 -1
- package/lib/inspect/prompt.ts +6 -1
- package/lib/media/download.ts +36 -11
- package/lib/media/save.ts +24 -0
- package/lib/media/search.ts +385 -0
- package/lib/media/types.ts +12 -0
- package/lib/narrative-state/render-plan.ts +10 -1
- package/lib/qa/artifact.ts +77 -0
- package/lib/qa/checks.ts +101 -10
- package/lib/qa/index.ts +81 -8
- package/lib/qa/measure.ts +85 -0
- package/lib/refine/open.ts +21 -1
- package/lib/refine/server.ts +884 -71
- package/lib/workspace-state/types.ts +1 -0
- package/package.json +1 -1
- package/plugin.ts +36 -130
- package/skill/NARRATIVE_SKILL.md +1 -1
- package/skill/SKILL.md +5 -10
- package/tools/decks.ts +29 -3
- package/tools/media-save.ts +6 -0
- package/tools/narrative-view.ts +1 -1
- package/tools/qa.ts +17 -11
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
22
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
package/lib/refine/open.ts
CHANGED
|
@@ -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
|
}
|