@cyber-dash-tech/revela 0.15.1 → 0.15.3

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.
Files changed (41) hide show
  1. package/README.md +58 -104
  2. package/README.zh-CN.md +59 -104
  3. package/designs/starter/DESIGN.md +33 -14
  4. package/designs/starter/preview.html +23 -16
  5. package/designs/summit/DESIGN.md +35 -42
  6. package/designs/summit/preview.html +49 -49
  7. package/lib/commands/brief.ts +1 -1
  8. package/lib/commands/designs-new.ts +6 -6
  9. package/lib/commands/designs.ts +9 -9
  10. package/lib/commands/domains.ts +9 -9
  11. package/lib/commands/edit.ts +1 -1
  12. package/lib/commands/help.ts +34 -37
  13. package/lib/commands/init.ts +1 -1
  14. package/lib/commands/inspect.ts +2 -20
  15. package/lib/commands/narrative.ts +3 -3
  16. package/lib/commands/pdf.ts +3 -3
  17. package/lib/commands/pptx.ts +2 -2
  18. package/lib/commands/review.ts +13 -6
  19. package/lib/decks-state.ts +6 -6
  20. package/lib/edit/deck-state.ts +1 -1
  21. package/lib/edit/prompt.ts +12 -0
  22. package/lib/edit/resolve-deck.ts +1 -1
  23. package/lib/inspect/prompt.ts +6 -1
  24. package/lib/inspect/server.ts +2 -2
  25. package/lib/media/download.ts +36 -11
  26. package/lib/media/save.ts +24 -0
  27. package/lib/media/search.ts +385 -0
  28. package/lib/media/types.ts +12 -0
  29. package/lib/prompt-builder.ts +20 -14
  30. package/lib/qa/checks.ts +2 -1
  31. package/lib/qa/index.ts +73 -2
  32. package/lib/refine/server.ts +758 -68
  33. package/package.json +1 -1
  34. package/plugin.ts +133 -283
  35. package/skill/NARRATIVE_SKILL.md +15 -17
  36. package/skill/SKILL.md +220 -477
  37. package/tools/edit.ts +1 -1
  38. package/tools/inspection-result.ts +1 -1
  39. package/tools/media-save.ts +6 -0
  40. package/lib/commands/disable.ts +0 -14
  41. package/lib/commands/enable.ts +0 -48
@@ -0,0 +1,385 @@
1
+ import type { MediaPurpose } from "./types"
2
+
3
+ export interface ImageCandidate {
4
+ candidateId: string
5
+ provider: string
6
+ title: string
7
+ thumbnailUrl: string
8
+ imageUrl: string
9
+ sourcePageUrl?: string
10
+ width?: number
11
+ height?: number
12
+ alt?: string
13
+ license?: string
14
+ attribution?: string
15
+ purpose?: MediaPurpose
16
+ }
17
+
18
+ export interface ImageSearchInput {
19
+ query: string
20
+ purpose?: MediaPurpose
21
+ limit?: number
22
+ page?: number
23
+ signal?: AbortSignal
24
+ providerTimeoutMs?: number
25
+ }
26
+
27
+ export interface ImageSearchProvider {
28
+ name: string
29
+ search(input: ImageSearchInput): Promise<ImageCandidate[]>
30
+ }
31
+
32
+ const PROVIDER_TIMEOUT_MS: Record<string, number> = {
33
+ "website-metadata": 3000,
34
+ unsplash: 6000,
35
+ "wikimedia-commons": 7000,
36
+ }
37
+
38
+ function slugify(value: string): string {
39
+ return value
40
+ .toLowerCase()
41
+ .replace(/[^a-z0-9]+/g, "-")
42
+ .replace(/^-+|-+$/g, "")
43
+ }
44
+
45
+ function normalizeLimit(limit: number | undefined): number {
46
+ if (!Number.isFinite(limit ?? Number.NaN)) return 12
47
+ return Math.max(1, Math.min(48, Math.floor(limit!)))
48
+ }
49
+
50
+ function normalizePage(page: number | undefined): number {
51
+ if (!Number.isFinite(page ?? Number.NaN)) return 1
52
+ return Math.max(1, Math.floor(page!))
53
+ }
54
+
55
+ function domainFromQuery(query: string): string | null {
56
+ const value = query.trim().toLowerCase()
57
+ if (!value) return null
58
+ const withoutProtocol = value.replace(/^https?:\/\//, "").replace(/^www\./, "")
59
+ const first = withoutProtocol.split(/[\s/]+/)[0]
60
+ if (/^[a-z0-9.-]+\.[a-z]{2,}$/.test(first)) return first
61
+ const compact = value.replace(/\b(logo|company|brand)\b/g, "").replace(/[^a-z0-9]+/g, "")
62
+ if (!compact) return null
63
+ return `${compact}.com`
64
+ }
65
+
66
+ function textFromMetadata(value: unknown): string | undefined {
67
+ if (!value || typeof value !== "object") return undefined
68
+ const raw = (value as { value?: unknown }).value
69
+ return typeof raw === "string" && raw.trim() ? raw.replace(/<[^>]+>/g, "").trim() : undefined
70
+ }
71
+
72
+ function compactBrandName(query: string): string | null {
73
+ const value = query
74
+ .toLowerCase()
75
+ .replace(/^https?:\/\//, "")
76
+ .replace(/^www\./, "")
77
+ .split(/[\s/]+/)[0]
78
+ .replace(/\.[a-z]{2,}$/, "")
79
+ .replace(/\b(logo|company|brand)\b/g, "")
80
+ .replace(/[^a-z0-9]+/g, "")
81
+ return value || null
82
+ }
83
+
84
+ function absoluteUrl(value: string, base: string): string | undefined {
85
+ try {
86
+ return new URL(value, base).toString()
87
+ } catch {
88
+ return undefined
89
+ }
90
+ }
91
+
92
+ function uniqueValues(values: Array<string | undefined>): string[] {
93
+ const seen = new Set<string>()
94
+ return values.flatMap((value) => {
95
+ if (!value || seen.has(value)) return []
96
+ seen.add(value)
97
+ return [value]
98
+ })
99
+ }
100
+
101
+ function htmlAttr(tag: string, attr: string): string | undefined {
102
+ const pattern = new RegExp(`${attr}=["']([^"']+)["']`, "i")
103
+ return tag.match(pattern)?.[1]
104
+ }
105
+
106
+ function htmlMetaContent(html: string, property: string): string | undefined {
107
+ const tags = html.match(/<meta\b[^>]*>/gi) ?? []
108
+ const needle = property.toLowerCase()
109
+ for (const tag of tags) {
110
+ const key = htmlAttr(tag, "property") || htmlAttr(tag, "name")
111
+ if (key?.toLowerCase() === needle) return htmlAttr(tag, "content")
112
+ }
113
+ return undefined
114
+ }
115
+
116
+ function htmlIconLinks(html: string): string[] {
117
+ const tags = html.match(/<link\b[^>]*>/gi) ?? []
118
+ return tags.flatMap((tag) => {
119
+ const rel = htmlAttr(tag, "rel")?.toLowerCase() ?? ""
120
+ if (!rel.includes("icon") && !rel.includes("apple-touch-icon")) return []
121
+ return htmlAttr(tag, "href") ?? []
122
+ })
123
+ }
124
+
125
+ function queryForPurpose(query: string, purpose: MediaPurpose | undefined): string {
126
+ const value = query.trim()
127
+ if (!value) return value
128
+ if (purpose === "hero") return `${value} hero background professional photography`
129
+ if (purpose === "portrait") return `${value} portrait headshot`
130
+ if (purpose === "screenshot") return `${value} screenshot interface product`
131
+ if (purpose === "illustration") return `${value} professional photo concept visual`
132
+ return value
133
+ }
134
+
135
+ function wikimediaQueryForPurpose(query: string, purpose: MediaPurpose | undefined): string {
136
+ const value = query.trim()
137
+ if (!value) return value
138
+ if (purpose === "portrait") return `${value} portrait`
139
+ if (purpose === "screenshot") return `${value} screenshot`
140
+ return value
141
+ }
142
+
143
+ export const clearbitLogoProvider: ImageSearchProvider = {
144
+ name: "clearbit-logo",
145
+ async search(input) {
146
+ if (normalizePage(input.page) > 1) return []
147
+ if (input.purpose && input.purpose !== "logo") return []
148
+ const domain = domainFromQuery(input.query)
149
+ if (!domain) return []
150
+ const title = `${domain} logo`
151
+ return [{
152
+ candidateId: `clearbit-${slugify(domain)}`,
153
+ provider: this.name,
154
+ title,
155
+ thumbnailUrl: `https://logo.clearbit.com/${encodeURIComponent(domain)}`,
156
+ imageUrl: `https://logo.clearbit.com/${encodeURIComponent(domain)}`,
157
+ sourcePageUrl: `https://${domain}`,
158
+ alt: title,
159
+ purpose: "logo",
160
+ }]
161
+ },
162
+ }
163
+
164
+ export const simpleIconsProvider: ImageSearchProvider = {
165
+ name: "simple-icons",
166
+ async search(input) {
167
+ if (normalizePage(input.page) > 1) return []
168
+ if (input.purpose && input.purpose !== "logo") return []
169
+ const brand = compactBrandName(input.query)
170
+ if (!brand) return []
171
+ const title = `${brand} logo`
172
+ const cdnUrl = `https://cdn.simpleicons.org/${encodeURIComponent(brand)}`
173
+ const imageUrl = `https://raw.githubusercontent.com/simple-icons/simple-icons/develop/icons/${encodeURIComponent(brand)}.svg`
174
+ return [{
175
+ candidateId: `simple-icons-${slugify(brand)}`,
176
+ provider: this.name,
177
+ title,
178
+ thumbnailUrl: cdnUrl,
179
+ imageUrl,
180
+ sourcePageUrl: `https://simpleicons.org/?q=${encodeURIComponent(brand)}`,
181
+ alt: title,
182
+ license: "See Simple Icons source license",
183
+ attribution: "Simple Icons",
184
+ purpose: "logo",
185
+ }]
186
+ },
187
+ }
188
+
189
+ export const websiteMetadataProvider: ImageSearchProvider = {
190
+ name: "website-metadata",
191
+ async search(input) {
192
+ if (normalizePage(input.page) > 1) return []
193
+ const domain = domainFromQuery(input.query)
194
+ if (!domain) return []
195
+ const homepage = `https://${domain}`
196
+ const response = await fetch(homepage, {
197
+ headers: { "user-agent": "Revela/0.15 asset search" },
198
+ signal: input.signal,
199
+ })
200
+ if (!response.ok) throw new Error(`Website metadata fetch failed: ${response.status}`)
201
+ const html = await response.text()
202
+ const title = htmlMetaContent(html, "og:site_name") || html.match(/<title[^>]*>([^<]+)<\/title>/i)?.[1]?.trim() || domain
203
+ const socialImages = [htmlMetaContent(html, "og:image"), htmlMetaContent(html, "twitter:image")]
204
+ const icons = [...htmlIconLinks(html), "/favicon.ico"]
205
+ const sourceUrls = input.purpose === "logo"
206
+ ? icons
207
+ : input.purpose === "screenshot"
208
+ ? socialImages
209
+ : socialImages
210
+ const urls = uniqueValues(sourceUrls.map((value) => value ? absoluteUrl(value, homepage) : undefined))
211
+ return urls.slice(0, normalizeLimit(input.limit)).map((imageUrl, index) => ({
212
+ candidateId: `website-${slugify(domain)}-${index + 1}`,
213
+ provider: this.name,
214
+ title: index === 0 ? `${title} image` : `${title} icon ${index}`,
215
+ thumbnailUrl: imageUrl,
216
+ imageUrl,
217
+ sourcePageUrl: homepage,
218
+ alt: `${title} image`,
219
+ purpose: input.purpose,
220
+ }))
221
+ },
222
+ }
223
+
224
+ export const unsplashProvider: ImageSearchProvider = {
225
+ name: "unsplash",
226
+ async search(input) {
227
+ const accessKey = process.env.UNSPLASH_ACCESS_KEY?.trim()
228
+ if (!accessKey) return []
229
+ if (input.purpose === "logo" || input.purpose === "screenshot") return []
230
+ const limit = normalizeLimit(input.limit)
231
+ const page = normalizePage(input.page)
232
+ const url = new URL("https://api.unsplash.com/search/photos")
233
+ url.searchParams.set("query", input.query)
234
+ url.searchParams.set("per_page", String(Math.min(30, limit)))
235
+ url.searchParams.set("page", String(page))
236
+ url.searchParams.set("orientation", input.purpose === "portrait" ? "portrait" : "landscape")
237
+ const response = await fetch(url, {
238
+ headers: {
239
+ authorization: `Client-ID ${accessKey}`,
240
+ "accept-version": "v1",
241
+ "user-agent": "Revela/0.15 asset search",
242
+ },
243
+ signal: input.signal,
244
+ })
245
+ if (!response.ok) throw new Error(`Unsplash search failed: ${response.status}`)
246
+ const body = await response.json() as any
247
+ const results = Array.isArray(body?.results) ? body.results : []
248
+ return results.flatMap((item: any) => {
249
+ const imageUrl = typeof item?.urls?.regular === "string" ? item.urls.regular : ""
250
+ if (!imageUrl) return []
251
+ const title = item.description || item.alt_description || "Unsplash photo"
252
+ const author = item.user?.name ? String(item.user.name) : undefined
253
+ return [{
254
+ candidateId: `unsplash-${slugify(String(item.id || title))}`,
255
+ provider: this.name,
256
+ title,
257
+ thumbnailUrl: typeof item.urls?.small === "string" ? item.urls.small : imageUrl,
258
+ imageUrl,
259
+ sourcePageUrl: typeof item.links?.html === "string" ? item.links.html : undefined,
260
+ width: typeof item.width === "number" ? item.width : undefined,
261
+ height: typeof item.height === "number" ? item.height : undefined,
262
+ alt: item.alt_description || title,
263
+ license: "Unsplash License",
264
+ attribution: author ? `Photo by ${author} on Unsplash` : "Unsplash",
265
+ purpose: input.purpose,
266
+ }]
267
+ }).slice(0, limit)
268
+ },
269
+ }
270
+
271
+ export const wikimediaProvider: ImageSearchProvider = {
272
+ name: "wikimedia-commons",
273
+ async search(input) {
274
+ const limit = normalizeLimit(input.limit)
275
+ const page = normalizePage(input.page)
276
+ const url = new URL("https://commons.wikimedia.org/w/api.php")
277
+ url.searchParams.set("action", "query")
278
+ url.searchParams.set("format", "json")
279
+ url.searchParams.set("origin", "*")
280
+ url.searchParams.set("generator", "search")
281
+ url.searchParams.set("gsrnamespace", "6")
282
+ url.searchParams.set("gsrlimit", String(limit))
283
+ url.searchParams.set("gsroffset", String((page - 1) * limit))
284
+ url.searchParams.set("gsrsearch", input.query)
285
+ url.searchParams.set("prop", "imageinfo|info")
286
+ url.searchParams.set("iiprop", "url|thumburl|mime|size|extmetadata")
287
+ url.searchParams.set("iiurlwidth", "320")
288
+ url.searchParams.set("inprop", "url")
289
+
290
+ const response = await fetch(url, {
291
+ headers: { "user-agent": "Revela/0.15 asset search" },
292
+ signal: input.signal,
293
+ })
294
+ if (!response.ok) throw new Error(`Wikimedia search failed: ${response.status}`)
295
+ const body = await response.json() as any
296
+ const pages = Object.values(body?.query?.pages ?? {}) as any[]
297
+ return pages.flatMap((page) => {
298
+ const info = page?.imageinfo?.[0]
299
+ const mime = typeof info?.mime === "string" ? info.mime : ""
300
+ if (!mime.startsWith("image/")) return []
301
+ const imageUrl = typeof info?.url === "string" ? info.url : ""
302
+ if (!imageUrl) return []
303
+ const thumbnailUrl = typeof info?.thumburl === "string" ? info.thumburl : imageUrl
304
+ const metadata = info?.extmetadata ?? {}
305
+ const title = String(page.title || "Wikimedia image").replace(/^File:/, "")
306
+ return [{
307
+ candidateId: `wikimedia-${slugify(title).slice(0, 80)}`,
308
+ provider: this.name,
309
+ title,
310
+ thumbnailUrl,
311
+ imageUrl,
312
+ sourcePageUrl: typeof page.fullurl === "string" ? page.fullurl : undefined,
313
+ width: typeof info.width === "number" ? info.width : undefined,
314
+ height: typeof info.height === "number" ? info.height : undefined,
315
+ alt: title,
316
+ license: textFromMetadata(metadata.LicenseShortName) || textFromMetadata(metadata.UsageTerms),
317
+ attribution: textFromMetadata(metadata.Artist) || textFromMetadata(metadata.Credit),
318
+ purpose: input.purpose,
319
+ }]
320
+ }).slice(0, limit)
321
+ },
322
+ }
323
+
324
+ export async function searchRemoteImages(input: ImageSearchInput): Promise<ImageCandidate[]> {
325
+ const query = input.query.trim()
326
+ if (!query) return []
327
+ const limit = normalizeLimit(input.limit)
328
+ const page = normalizePage(input.page)
329
+ const mediaQuery = queryForPurpose(query, input.purpose)
330
+ const wikimediaQuery = wikimediaQueryForPurpose(query, input.purpose)
331
+ const searches: Array<Promise<ImageCandidate[]>> = input.purpose === "logo"
332
+ ? [
333
+ runProvider(clearbitLogoProvider, { ...input, query, limit, page }),
334
+ runProvider(simpleIconsProvider, { ...input, query, limit, page }),
335
+ runProvider(websiteMetadataProvider, { ...input, query, limit, page }),
336
+ ]
337
+ : input.purpose === "screenshot"
338
+ ? [
339
+ runProvider(websiteMetadataProvider, { ...input, query, limit, page }),
340
+ runProvider(wikimediaProvider, { ...input, query: wikimediaQuery, limit, page }),
341
+ ]
342
+ : [
343
+ runProvider(websiteMetadataProvider, { ...input, query, limit, page }),
344
+ runProvider(unsplashProvider, { ...input, query: mediaQuery, limit, page }),
345
+ runProvider(wikimediaProvider, { ...input, query: wikimediaQuery, limit, page }),
346
+ ]
347
+ const batches = await Promise.allSettled(searches)
348
+ const candidates = batches.flatMap((result) => result.status === "fulfilled" ? result.value : [])
349
+ const seen = new Set<string>()
350
+ return candidates.filter((candidate) => {
351
+ const key = candidate.imageUrl || candidate.thumbnailUrl || candidate.candidateId
352
+ if (seen.has(key)) return false
353
+ seen.add(key)
354
+ return true
355
+ }).slice(0, limit)
356
+ }
357
+
358
+ async function runProvider(provider: ImageSearchProvider, input: ImageSearchInput): Promise<ImageCandidate[]> {
359
+ const timeoutMs = input.providerTimeoutMs ?? PROVIDER_TIMEOUT_MS[provider.name]
360
+ if (!timeoutMs) return provider.search(input).catch(() => [])
361
+
362
+ const controller = new AbortController()
363
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
364
+ const signal = input.signal ? anySignal([input.signal, controller.signal]) : controller.signal
365
+ try {
366
+ return await provider.search({ ...input, signal })
367
+ } catch {
368
+ return []
369
+ } finally {
370
+ clearTimeout(timer)
371
+ }
372
+ }
373
+
374
+ function anySignal(signals: AbortSignal[]): AbortSignal {
375
+ const controller = new AbortController()
376
+ const abort = () => controller.abort()
377
+ for (const signal of signals) {
378
+ if (signal.aborted) {
379
+ controller.abort()
380
+ break
381
+ }
382
+ signal.addEventListener("abort", abort, { once: true })
383
+ }
384
+ return controller.signal
385
+ }
@@ -15,6 +15,12 @@ export interface MediaSaveInput {
15
15
  alt?: string
16
16
  notes?: string
17
17
  failureReason?: string
18
+ provider?: string
19
+ sourcePageUrl?: string
20
+ license?: string
21
+ attribution?: string
22
+ width?: number
23
+ height?: number
18
24
  }
19
25
 
20
26
  export interface MediaAssetRecord {
@@ -30,6 +36,12 @@ export interface MediaAssetRecord {
30
36
  alt?: string
31
37
  notes?: string
32
38
  failureReason?: string
39
+ provider?: string
40
+ sourcePageUrl?: string
41
+ license?: string
42
+ attribution?: string
43
+ width?: number
44
+ height?: number
33
45
  savedAt: string
34
46
  }
35
47
 
@@ -7,8 +7,10 @@
7
7
  *
8
8
  * Deck-render mode:
9
9
  * Layer 1: SKILL.md — legacy deck render protocol (HTML rules, quality)
10
- * Layer 2: DOMAIN.md — domain structure/terminology
11
- * Layer 3: DESIGN.md — visual style (colors, fonts, animations, layout)
10
+ * Layer 2: DESIGN.md — visual style (colors, fonts, animations, layout)
11
+ *
12
+ * Domain guidance is intentionally narrative-only. Deck-render mode must render
13
+ * the approved canonical narrative instead of re-interpreting domain semantics.
12
14
  *
13
15
  * When the active DESIGN.md has @section markers, only the global section,
14
16
  * layouts section, and a generated component index are injected into the
@@ -87,19 +89,23 @@ export function buildPrompt(optionsOrDesignName?: BuildPromptOptions | string, l
87
89
  ? "<!-- - preview.html — canonical visual reference (read this before generating slides) -->"
88
90
  : "<!-- - (no preview.html for this design) -->"
89
91
 
90
- // Layer 2 — DOMAIN.md skill text (may be empty for "general")
92
+ // Layer 2 — DOMAIN.md skill text (narrative mode only). Deck-render mode
93
+ // renders the approved canonical narrative and must not re-interpret domain
94
+ // semantics from the full domain prompt.
91
95
  let domainSkill = ""
92
- try {
93
- domainSkill = getDomainSkillMd(domain)
94
- } catch (e) {
95
- // Domain not installed or empty — proceed without domain layer
96
- promptLog.warn("domain skill not foundbuilding without domain layer", {
97
- domain,
98
- error: e instanceof Error ? e.message : String(e),
99
- })
96
+ if (mode === "narrative") {
97
+ try {
98
+ domainSkill = getDomainSkillMd(domain)
99
+ } catch (e) {
100
+ // Domain not installed or empty proceed without domain layer
101
+ promptLog.warn("domain skill not found — building without domain layer", {
102
+ domain,
103
+ error: e instanceof Error ? e.message : String(e),
104
+ })
105
+ }
100
106
  }
101
107
 
102
- // Layer 3 — DESIGN.md: deck-render only. Narrative mode must not inject
108
+ // DESIGN.md: deck-render only. Narrative mode must not inject
103
109
  // visual CSS, layout catalogs, component indexes, or HTML skeleton rules.
104
110
  const designSkill = mode === "deck-render" ? buildDesignLayer(design) : ""
105
111
 
@@ -107,7 +113,7 @@ export function buildPrompt(optionsOrDesignName?: BuildPromptOptions | string, l
107
113
  const header = mode === "deck-render"
108
114
  ? `<!-- Revela prompt mode: deck-render -->\n` +
109
115
  `<!-- Active design: ${design} -->\n` +
110
- `<!-- Active domain: ${domain} -->\n` +
116
+ `<!-- Active domain: ${domain} (not injected in deck-render mode) -->\n` +
111
117
  `<!-- Design files: ${designDir}/ -->\n` +
112
118
  `<!-- - DESIGN.md — metadata + style instructions (injected below) -->\n` +
113
119
  `${previewLine}\n\n`
@@ -115,7 +121,7 @@ export function buildPrompt(optionsOrDesignName?: BuildPromptOptions | string, l
115
121
  `<!-- Active domain: ${domain} -->\n` +
116
122
  `<!-- Design layer intentionally omitted in narrative mode. Use deck-render mode before writing deck artifacts. -->\n\n`
117
123
 
118
- // Concatenation: Header → Skill → Domain → Design (deck-render only)
124
+ // Concatenation: Header → Skill → Domain (narrative only) → Design (deck-render only)
119
125
  const parts = [header, coreSkill]
120
126
  if (domainSkill) {
121
127
  parts.push(`\n\n---\n\n${domainSkill}`)
package/lib/qa/checks.ts CHANGED
@@ -23,13 +23,14 @@ import { CANVAS_W, CANVAS_H } from "./measure"
23
23
  export type IssueSeverity = "error" | "warning" | "info"
24
24
 
25
25
  export interface LayoutIssue {
26
- type: "canvas" | "scrollbar" | "overflow" | "text_overflow" | "density" | "balance" | "symmetry" | "rhythm" | "compliance"
26
+ type: "canvas" | "scrollbar" | "overflow" | "text_overflow" | "density" | "balance" | "symmetry" | "rhythm" | "compliance" | "asset"
27
27
  /** Sub-category within the dimension */
28
28
  sub?: "size_mismatch" | "page_scroll" | "text_clipped" | "thin_content"
29
29
  | "centroid_offset" | "bottom_gap" | "sparse"
30
30
  | "height_mismatch" | "density_mismatch"
31
31
  | "gap_variance"
32
32
  | "unknown_class" | "novel_css_rule"
33
+ | "remote_url" | "refine_proxy" | "missing_file"
33
34
  severity: IssueSeverity
34
35
  /** Human-readable description for the LLM to act on */
35
36
  detail: string
package/lib/qa/index.ts CHANGED
@@ -8,8 +8,10 @@
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"
@@ -34,7 +36,76 @@ export async function runQA(
34
36
  _vocabulary?: DesignClassVocabulary,
35
37
  ): Promise<QAReport> {
36
38
  const result = await measureSlides(htmlFilePath)
37
- 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
+ }
38
109
  }
39
110
 
40
111
  /**