@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.
@@ -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
 
@@ -1,4 +1,4 @@
1
- import { upsertDeck, upsertSlides, type DecksState, type EvidenceRef, type RequiredInputs, type SlideSpec } from "../decks-state"
1
+ import { deckPlanHash, upsertDeck, upsertSlides, type DecksState, type EvidenceRef, type RequiredInputs, type SlideSpec } from "../decks-state"
2
2
  import { ensureActiveHtmlDeckRenderTarget } from "../workspace-state/render-targets"
3
3
  import { getClaimSlideRefs } from "./queries"
4
4
  import { computeNarrativeHash } from "./hash"
@@ -65,6 +65,15 @@ export function compileDeckPlanFromNarrative(state: DecksState, options: Compile
65
65
  writeReadiness: deck?.writeReadiness ?? { status: "blocked" as const, blockers: [] },
66
66
  })
67
67
  next = upsertSlides(next, slug, slides)
68
+ const plannedDeck = next.decks[slug]
69
+ plannedDeck.planReview = {
70
+ status: "pending",
71
+ narrativeHash,
72
+ planHash: deckPlanHash(plannedDeck.slides),
73
+ }
74
+ plannedDeck.requiredInputs = { ...plannedDeck.requiredInputs, slidePlanConfirmed: false }
75
+ plannedDeck.writeReadiness = { status: "blocked", blockers: [] }
76
+ next.decks[slug] = plannedDeck
68
77
  next.narrative = { ...narrative, updatedAt: options.now ?? narrative.updatedAt }
69
78
  const htmlTarget = ensureActiveHtmlDeckRenderTarget(next)
70
79
  if (htmlTarget) {
@@ -0,0 +1,77 @@
1
+ import { formatDeckHtmlContractReport, validateDeckHtmlContract } from "../deck-html/contract"
2
+ import type { DesignClassVocabulary } from "../design/designs"
3
+ import { formatReport, runQA } from "./index"
4
+ import { runComplianceQA } from "./compliance"
5
+ import type { QAReport } from "./checks"
6
+
7
+ export interface ArtifactQAReport {
8
+ file: string
9
+ passed: boolean
10
+ hardErrorCount: number
11
+ warningCount: number
12
+ sections: string[]
13
+ }
14
+
15
+ function hardErrors(report: QAReport): number {
16
+ return report.slides.reduce((sum, slide) => sum + slide.issues.filter((issue) => issue.severity === "error").length, 0)
17
+ }
18
+
19
+ function warnings(report: QAReport): number {
20
+ return report.slides.reduce((sum, slide) => sum + slide.issues.filter((issue) => issue.severity === "warning").length, 0)
21
+ }
22
+
23
+ export async function runArtifactQA(input: {
24
+ workspaceRoot: string
25
+ filePath: string
26
+ vocabulary?: DesignClassVocabulary
27
+ }): Promise<ArtifactQAReport> {
28
+ const sections: string[] = []
29
+ let hardErrorCount = 0
30
+ let warningCount = 0
31
+
32
+ const contract = validateDeckHtmlContract(input.workspaceRoot, input.filePath)
33
+ if (contract.status === "invalid") {
34
+ hardErrorCount += contract.issues.filter((issue) => issue.severity === "error").length
35
+ warningCount += contract.warnings.length
36
+ sections.push("**[deck HTML contract]**\n\n" + formatDeckHtmlContractReport(contract))
37
+ } else if (contract.warnings.length > 0) {
38
+ warningCount += contract.warnings.length
39
+ sections.push("**[deck HTML contract]**\n\n" + formatDeckHtmlContractReport(contract))
40
+ }
41
+
42
+ const compliance = runComplianceQA(input.filePath, input.vocabulary)
43
+ const complianceErrors = hardErrors(compliance)
44
+ if (compliance.totalIssues > 0) {
45
+ hardErrorCount += complianceErrors
46
+ warningCount += warnings(compliance)
47
+ sections.push("**[component compliance]**\n\n" + formatReport(compliance))
48
+ }
49
+
50
+ try {
51
+ const browser = await runQA(input.filePath)
52
+ const browserErrors = hardErrors(browser)
53
+ if (browser.totalIssues > 0) {
54
+ hardErrorCount += browserErrors
55
+ warningCount += warnings(browser)
56
+ sections.push("**[browser artifact QA]**\n\n" + formatReport(browser))
57
+ }
58
+ } catch (e) {
59
+ hardErrorCount += 1
60
+ sections.push("**[browser artifact QA]**\n\nError running browser QA: " + (e instanceof Error ? e.message : String(e)))
61
+ }
62
+
63
+ return {
64
+ file: input.filePath,
65
+ passed: hardErrorCount === 0,
66
+ hardErrorCount,
67
+ warningCount,
68
+ sections,
69
+ }
70
+ }
71
+
72
+ export function formatArtifactQAReport(report: ArtifactQAReport): string {
73
+ const heading = report.passed ? "Artifact QA: PASSED" : "Artifact QA: FAILED"
74
+ const summary = `**File:** \`${report.file}\`\n\n**Hard errors:** ${report.hardErrorCount}\n**Warnings:** ${report.warningCount}`
75
+ if (report.sections.length === 0) return `## ${heading}\n\n${summary}\n\nAll artifact QA checks passed.`
76
+ return `## ${heading}\n\n${summary}\n\n${report.sections.join("\n\n---\n\n")}`
77
+ }
package/lib/qa/checks.ts CHANGED
@@ -1,17 +1,18 @@
1
1
  /**
2
2
  * lib/qa/checks.ts
3
3
  *
4
- * Geometry-based layout quality checks. The active default path only checks
5
- * overflow; softer visual heuristics are kept here for future opt-in use.
4
+ * Browser-measured slide quality checks. The active default path checks hard
5
+ * artifact failures plus a content-substance warning; older soft visual
6
+ * heuristics are kept here for future opt-in use.
6
7
  *
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
8
+ * Dimension 1: Canvas exact 1920x1080 slide/canvas size
9
+ * Dimension 2: Overflow scrollbars, element overflow, and text clipping
10
+ * Dimension 3: Density claim/evidence/source substance warnings
10
11
  * Dimension 4: Compliance — CSS classes match the active design's vocabulary
11
12
  *
12
13
  * All checks operate on SlideMetrics produced by measure.ts.
13
- * Dimensions 1–4 are geometry-only (no CSS class-name assumptions).
14
- * Dimension 5 requires an allowedClasses vocabulary from the design system.
14
+ * Design component compliance requires an allowedClasses vocabulary from the
15
+ * design system and is run by combined artifact QA.
15
16
  */
16
17
 
17
18
  import type { SlideMetrics, ElementInfo, Rect } from "./measure"
@@ -22,12 +23,14 @@ import { CANVAS_W, CANVAS_H } from "./measure"
22
23
  export type IssueSeverity = "error" | "warning" | "info"
23
24
 
24
25
  export interface LayoutIssue {
25
- type: "overflow" | "balance" | "symmetry" | "rhythm" | "compliance"
26
+ type: "canvas" | "scrollbar" | "overflow" | "text_overflow" | "density" | "balance" | "symmetry" | "rhythm" | "compliance" | "asset"
26
27
  /** Sub-category within the dimension */
27
- sub?: "centroid_offset" | "bottom_gap" | "sparse"
28
+ sub?: "size_mismatch" | "page_scroll" | "text_clipped" | "thin_content"
29
+ | "centroid_offset" | "bottom_gap" | "sparse"
28
30
  | "height_mismatch" | "density_mismatch"
29
31
  | "gap_variance"
30
32
  | "unknown_class" | "novel_css_rule"
33
+ | "remote_url" | "refine_proxy" | "missing_file"
31
34
  severity: IssueSeverity
32
35
  /** Human-readable description for the LLM to act on */
33
36
  detail: string
@@ -75,6 +78,9 @@ const T = {
75
78
  GAP_MIN_MEAN: 10,
76
79
  // Rhythm — min children count to check gap variance
77
80
  GAP_MIN_CHILDREN: 3,
81
+ CANVAS_TOLERANCE: 1,
82
+ DENSITY_MIN_TEXT_POINTS: 70,
83
+ DENSITY_MIN_UNITS: 2,
78
84
  }
79
85
 
80
86
  // ── Geometry helpers ──────────────────────────────────────────────────────────
@@ -172,6 +178,42 @@ function collectLeaves(el: ElementInfo): ElementInfo[] {
172
178
 
173
179
  // ── Dimension 1: Overflow ─────────────────────────────────────────────────────
174
180
 
181
+ function checkCanvas(metrics: SlideMetrics): LayoutIssue[] {
182
+ const issues: LayoutIssue[] = []
183
+ const tol = T.CANVAS_TOLERANCE
184
+ const canvasBad = Math.abs(metrics.canvasRect.width - CANVAS_W) > tol || Math.abs(metrics.canvasRect.height - CANVAS_H) > tol
185
+ const slideBad = Math.abs(metrics.slideRect.width - CANVAS_W) > tol || Math.abs(metrics.slideRect.height - CANVAS_H) > tol
186
+
187
+ if (canvasBad || slideBad) {
188
+ issues.push({
189
+ type: "canvas",
190
+ sub: "size_mismatch",
191
+ severity: "error",
192
+ detail: `Slide and canvas must render exactly ${CANVAS_W}x${CANVAS_H}px. Measured slide ${Math.round(metrics.slideRect.width)}x${Math.round(metrics.slideRect.height)}px, canvas ${Math.round(metrics.canvasRect.width)}x${Math.round(metrics.canvasRect.height)}px.`,
193
+ data: {
194
+ expectedWidth: CANVAS_W,
195
+ expectedHeight: CANVAS_H,
196
+ slideWidth: Math.round(metrics.slideRect.width),
197
+ slideHeight: Math.round(metrics.slideRect.height),
198
+ canvasWidth: Math.round(metrics.canvasRect.width),
199
+ canvasHeight: Math.round(metrics.canvasRect.height),
200
+ },
201
+ })
202
+ }
203
+
204
+ return issues
205
+ }
206
+
207
+ function checkScrollbars(metrics: SlideMetrics): LayoutIssue[] {
208
+ if (!metrics.hasScrollbars) return []
209
+ return [{
210
+ type: "scrollbar",
211
+ sub: "page_scroll",
212
+ severity: "error",
213
+ detail: "Rendered slide/page has scrollbars at 1920x1080. Deck slides must fit the fixed canvas without document, body, or slide scrolling.",
214
+ }]
215
+ }
216
+
175
217
  /**
176
218
  * Check 1: Overflow — elements extending beyond canvas boundaries.
177
219
  * Hard correctness check; applies to all slide types.
@@ -205,6 +247,45 @@ function checkOverflow(metrics: SlideMetrics): LayoutIssue[] {
205
247
  return issues
206
248
  }
207
249
 
250
+ function checkTextOverflow(metrics: SlideMetrics): LayoutIssue[] {
251
+ const issues: LayoutIssue[] = []
252
+
253
+ function walk(els: ElementInfo[]) {
254
+ for (const el of els) {
255
+ if (!el.visible) continue
256
+ if (el.textOverflow) {
257
+ issues.push({
258
+ type: "text_overflow",
259
+ sub: "text_clipped",
260
+ severity: "error",
261
+ detail: `Text appears clipped inside \`${el.selector}\`${el.text ? `: "${el.text}"` : ""}. Increase container size, reduce copy, or adjust font/line-height.`,
262
+ data: { selector: el.selector, text: el.text ?? "" },
263
+ })
264
+ }
265
+ if (el.children.length > 0) walk(el.children)
266
+ }
267
+ }
268
+
269
+ walk(metrics.elements)
270
+ return issues
271
+ }
272
+
273
+ function checkContentDensity(metrics: SlideMetrics): LayoutIssue[] {
274
+ if (!metrics.slideQa) return []
275
+ const { bodyTextPoints, contentUnits, supportReferences } = metrics.contentStats
276
+ const thinText = bodyTextPoints < T.DENSITY_MIN_TEXT_POINTS
277
+ const thinUnits = contentUnits < T.DENSITY_MIN_UNITS
278
+ if (!thinText && !thinUnits) return []
279
+
280
+ return [{
281
+ type: "density",
282
+ sub: "thin_content",
283
+ severity: "warning",
284
+ detail: `Content slide may not have enough claim/evidence substance: ${bodyTextPoints} non-title text point(s), ${contentUnits} recognizable content unit(s), ${supportReferences} evidence/source/claim reference(s). Add concrete claim points, evidence, metrics, chart/table support, or source/caveat text if this is not a deliberate focus slide.`,
285
+ data: { bodyTextPoints, contentUnits, supportReferences },
286
+ }]
287
+ }
288
+
208
289
  // ── Dimension 2: Balance ──────────────────────────────────────────────────────
209
290
 
210
291
  /**
@@ -542,7 +623,13 @@ export function runChecks(
542
623
  const slides: SlideReport[] = []
543
624
 
544
625
  for (const metrics of allMetrics) {
545
- const issues: LayoutIssue[] = [...checkOverflow(metrics)]
626
+ const issues: LayoutIssue[] = [
627
+ ...checkCanvas(metrics),
628
+ ...checkScrollbars(metrics),
629
+ ...checkOverflow(metrics),
630
+ ...checkTextOverflow(metrics),
631
+ ...checkContentDensity(metrics),
632
+ ]
546
633
 
547
634
  slides.push({ index: metrics.index, title: metrics.title, issues })
548
635
  }
@@ -605,7 +692,11 @@ export function formatReport(report: QAReport): string {
605
692
  `### Action Required`,
606
693
  ``,
607
694
  `Please fix the above hard-error issues in the HTML file. For each issue type:`,
695
+ `- **canvas**: ensure each slide and .slide-canvas render exactly 1920x1080px, not merely any 16:9 size.`,
696
+ `- **scrollbar**: remove document/body/slide scrolling; content must fit inside the fixed 1920x1080 canvas.`,
608
697
  `- **overflow**: reduce font size, padding, or content amount for the affected element.`,
698
+ `- **text_overflow**: increase the text container size, reduce copy, or adjust font/line-height so text is not clipped.`,
699
+ `- **density/thin_content**: add concrete claim/evidence points, metrics, chart/table support, or source/caveat text. This is a warning for content substance, not a blank-space failure.`,
609
700
  `- **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.`,
610
701
  `- **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.`,
611
702
  )