@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
|
@@ -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
|
+
}
|
package/lib/media/types.ts
CHANGED
|
@@ -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
|
-
*
|
|
5
|
-
*
|
|
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:
|
|
8
|
-
* Dimension 2:
|
|
9
|
-
* Dimension 3:
|
|
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
|
-
*
|
|
14
|
-
*
|
|
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?: "
|
|
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[] = [
|
|
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
|
)
|