@cyber-dash-tech/revela 0.16.4 → 0.17.1

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 (57) hide show
  1. package/README.md +7 -5
  2. package/README.zh-CN.md +7 -5
  3. package/lib/commands/brief.ts +9 -0
  4. package/lib/commands/help.ts +5 -2
  5. package/lib/commands/init.ts +42 -27
  6. package/lib/commands/narrative.ts +39 -6
  7. package/lib/commands/research.ts +36 -20
  8. package/lib/commands/review.ts +35 -28
  9. package/lib/ctx.ts +1 -1
  10. package/lib/decks-state.ts +38 -4
  11. package/lib/edit/prompt.ts +1 -1
  12. package/lib/hook-notifications.ts +53 -0
  13. package/lib/media/download.ts +23 -3
  14. package/lib/media/save.ts +1 -0
  15. package/lib/media/types.ts +1 -0
  16. package/lib/narrative-state/display.ts +74 -4
  17. package/lib/narrative-state/map-html.ts +242 -107
  18. package/lib/narrative-state/render-plan.ts +238 -35
  19. package/lib/narrative-state/research-binding-eval.ts +260 -0
  20. package/lib/narrative-state/research-gaps.ts +2 -88
  21. package/lib/narrative-vault/authoring-contract.ts +127 -0
  22. package/lib/narrative-vault/authoring-guard.ts +122 -0
  23. package/lib/narrative-vault/auto-compile.ts +134 -0
  24. package/lib/narrative-vault/bootstrap.ts +63 -0
  25. package/lib/narrative-vault/cache.ts +14 -0
  26. package/lib/narrative-vault/compile-mirror.ts +45 -0
  27. package/lib/narrative-vault/compile.ts +350 -0
  28. package/lib/narrative-vault/constants.ts +6 -0
  29. package/lib/narrative-vault/diagnostic-report.ts +117 -0
  30. package/lib/narrative-vault/export.ts +71 -0
  31. package/lib/narrative-vault/frontmatter.ts +41 -0
  32. package/lib/narrative-vault/hook-targets.ts +40 -0
  33. package/lib/narrative-vault/index.ts +18 -0
  34. package/lib/narrative-vault/inventory.ts +392 -0
  35. package/lib/narrative-vault/markdown-qa.ts +237 -0
  36. package/lib/narrative-vault/markdown.ts +34 -0
  37. package/lib/narrative-vault/migration.ts +52 -0
  38. package/lib/narrative-vault/mutate.ts +361 -0
  39. package/lib/narrative-vault/paths.ts +19 -0
  40. package/lib/narrative-vault/read.ts +52 -0
  41. package/lib/narrative-vault/relations.ts +32 -0
  42. package/lib/narrative-vault/source-loader.ts +19 -0
  43. package/lib/narrative-vault/timestamp.ts +32 -0
  44. package/lib/narrative-vault/types.ts +44 -0
  45. package/lib/qa/checks.ts +206 -5
  46. package/lib/qa/measure.ts +63 -1
  47. package/lib/refine/server.ts +157 -20
  48. package/lib/source-materials.ts +98 -0
  49. package/lib/tool-result.ts +34 -0
  50. package/package.json +2 -2
  51. package/plugin.ts +60 -22
  52. package/skill/NARRATIVE_SKILL.md +25 -10
  53. package/skill/SKILL.md +6 -1
  54. package/tools/decks.ts +363 -67
  55. package/tools/narrative-view.ts +16 -0
  56. package/tools/research-save.ts +3 -0
  57. package/tools/workspace-scan.ts +1 -0
package/lib/qa/checks.ts CHANGED
@@ -23,9 +23,10 @@ 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" | "asset"
26
+ type: "canvas" | "scrollbar" | "navigation" | "overflow" | "text_overflow" | "overlap" | "density" | "balance" | "symmetry" | "rhythm" | "compliance" | "asset"
27
27
  /** Sub-category within the dimension */
28
- sub?: "size_mismatch" | "page_scroll" | "text_clipped" | "thin_content"
28
+ sub?: "size_mismatch" | "page_scroll" | "fixed_overlay_slides" | "hidden_paging" | "unreachable_slides" | "text_clipped" | "thin_content"
29
+ | "element_collision" | "major_overlap" | "possible_overlay"
29
30
  | "centroid_offset" | "bottom_gap" | "sparse"
30
31
  | "height_mismatch" | "density_mismatch"
31
32
  | "gap_variance"
@@ -81,6 +82,12 @@ const T = {
81
82
  CANVAS_TOLERANCE: 1,
82
83
  DENSITY_MIN_TEXT_POINTS: 70,
83
84
  DENSITY_MIN_UNITS: 2,
85
+ OVERLAP_MIN_AREA: 1600,
86
+ OVERLAP_MIN_ELEMENT_AREA: 5000,
87
+ OVERLAP_WARN_RATIO: 0.08,
88
+ OVERLAP_ERROR_RATIO: 0.18,
89
+ OVERLAP_TEXT_WARN_RATIO: 0.05,
90
+ OVERLAP_TEXT_ERROR_RATIO: 0.12,
84
91
  }
85
92
 
86
93
  // ── Geometry helpers ──────────────────────────────────────────────────────────
@@ -205,15 +212,78 @@ function checkCanvas(metrics: SlideMetrics): LayoutIssue[] {
205
212
  }
206
213
 
207
214
  function checkScrollbars(metrics: SlideMetrics): LayoutIssue[] {
208
- if (!metrics.hasScrollbars) return []
215
+ const scrollbars = metrics.scrollbars
216
+ const totalSlides = metrics.navigation?.totalSlides ?? 1
217
+ const hasAllowedMultiSlideVerticalScroll = totalSlides > 1
218
+
219
+ if (scrollbars) {
220
+ const hasBlockingScrollbars =
221
+ scrollbars.documentHorizontal ||
222
+ scrollbars.bodyHorizontal ||
223
+ scrollbars.slideHorizontal ||
224
+ scrollbars.slideVertical ||
225
+ (!hasAllowedMultiSlideVerticalScroll && (scrollbars.documentVertical || scrollbars.bodyVertical))
226
+
227
+ if (!hasBlockingScrollbars) return []
228
+ } else if (!metrics.hasScrollbars) {
229
+ return []
230
+ }
231
+
209
232
  return [{
210
233
  type: "scrollbar",
211
234
  sub: "page_scroll",
212
235
  severity: "error",
213
- detail: "Rendered slide/page has scrollbars at 1920x1080. Deck slides must fit the fixed canvas without document, body, or slide scrolling.",
236
+ detail: "Rendered slide/page has blocking scrollbars at 1920x1080. Normal vertical document scroll is allowed for multi-slide navigation, but horizontal document/body scroll and slide-internal scroll must be removed.",
214
237
  }]
215
238
  }
216
239
 
240
+ function checkNavigationModel(allMetrics: SlideMetrics[]): LayoutIssue[] {
241
+ if (allMetrics.length <= 1) return []
242
+
243
+ const nav = allMetrics.map((metrics) => metrics.navigation).filter((item): item is NonNullable<SlideMetrics["navigation"]> => Boolean(item))
244
+ if (nav.length <= 1) return []
245
+
246
+ const positioned = nav.filter((item) => item.position === "fixed" || item.position === "absolute")
247
+ const hiddenByAria = nav.filter((item) => item.ariaHidden === "true" || item.visibility === "hidden" || item.display === "none")
248
+ const uniqueTops = new Set(nav.map((item) => Math.round(item.initialTop)))
249
+ const stacked = uniqueTops.size <= 1
250
+ const documentCanScroll = nav[0].documentScrollHeight > nav[0].viewportHeight + 2
251
+ const overflowHidden = nav[0].bodyOverflowY === "hidden" || nav[0].documentOverflowY === "hidden"
252
+
253
+ const issues: LayoutIssue[] = []
254
+ if (positioned.length === nav.length && stacked) {
255
+ issues.push({
256
+ type: "navigation",
257
+ sub: "fixed_overlay_slides",
258
+ severity: "error",
259
+ detail: "Slides are stacked with fixed/absolute positioning. Revela decks must keep each .slide in normal document flow so scrollIntoView and keyboard navigation can reach every slide.",
260
+ data: { slideCount: nav.length, positionedSlides: positioned.length },
261
+ })
262
+ }
263
+
264
+ if (positioned.length === nav.length && hiddenByAria.length > 0) {
265
+ issues.push({
266
+ type: "navigation",
267
+ sub: "hidden_paging",
268
+ severity: "error",
269
+ detail: "Slides use aria-hidden/visibility toggles with fixed overlay pagination. Do not make slide visibility depend on aria-hidden; keep slides visible in normal flow and navigate with scrollIntoView.",
270
+ data: { hiddenSlides: hiddenByAria.length, slideCount: nav.length },
271
+ })
272
+ }
273
+
274
+ if (!documentCanScroll && overflowHidden && allMetrics.length > 1) {
275
+ issues.push({
276
+ type: "navigation",
277
+ sub: "unreachable_slides",
278
+ severity: "error",
279
+ detail: "Multi-slide deck disables document vertical scrolling. Normal slide flow needs enough document height for all slides; fix slide overflow locally instead of hiding body/html overflow.",
280
+ data: { slideCount: nav.length, documentScrollHeight: nav[0].documentScrollHeight, viewportHeight: nav[0].viewportHeight },
281
+ })
282
+ }
283
+
284
+ return issues
285
+ }
286
+
217
287
  /**
218
288
  * Check 1: Overflow — elements extending beyond canvas boundaries.
219
289
  * Hard correctness check; applies to all slide types.
@@ -270,6 +340,132 @@ function checkTextOverflow(metrics: SlideMetrics): LayoutIssue[] {
270
340
  return issues
271
341
  }
272
342
 
343
+ const SEMANTIC_COMPONENT_CLASSES = [
344
+ "box",
345
+ "text-panel",
346
+ "media",
347
+ "echart-panel",
348
+ "data-table",
349
+ "stat-card",
350
+ "quote",
351
+ "hero",
352
+ "toc",
353
+ "steps",
354
+ "roadmap-horizontal",
355
+ "roadmap-vertical",
356
+ ]
357
+
358
+ const DECORATIVE_CLASSES = [
359
+ "page-number",
360
+ "brand-watermark",
361
+ "watermark",
362
+ "background",
363
+ "decorative",
364
+ "motif",
365
+ ]
366
+
367
+ function checkElementOverlap(metrics: SlideMetrics): LayoutIssue[] {
368
+ const candidates = overlapCandidates(metrics.elements)
369
+ const issues: LayoutIssue[] = []
370
+
371
+ for (let i = 0; i < candidates.length; i++) {
372
+ for (let j = i + 1; j < candidates.length; j++) {
373
+ const a = candidates[i]
374
+ const b = candidates[j]
375
+ if (isIntentionalOverlayPair(a, b)) continue
376
+
377
+ const overlap = intersection(a.rect, b.rect)
378
+ if (!overlap) continue
379
+
380
+ const intersectionArea = overlap.width * overlap.height
381
+ if (intersectionArea < T.OVERLAP_MIN_AREA) continue
382
+
383
+ const minArea = Math.min(area(a.rect), area(b.rect))
384
+ if (minArea <= 0) continue
385
+
386
+ const ratio = intersectionArea / minArea
387
+ const textSensitive = hasText(a) || hasText(b)
388
+ const errorRatio = textSensitive ? T.OVERLAP_TEXT_ERROR_RATIO : T.OVERLAP_ERROR_RATIO
389
+ const warnRatio = textSensitive ? T.OVERLAP_TEXT_WARN_RATIO : T.OVERLAP_WARN_RATIO
390
+ if (ratio < warnRatio) continue
391
+
392
+ const severity: IssueSeverity = ratio >= errorRatio ? "error" : "warning"
393
+ issues.push({
394
+ type: "overlap",
395
+ sub: severity === "error" ? "element_collision" : "possible_overlay",
396
+ severity,
397
+ detail: `Elements \`${a.selector}\` and \`${b.selector}\` overlap by ${Math.round(ratio * 100)}% of the smaller element (${Math.round(intersectionArea)}px²). Separate the components, reduce content, or choose a layout with more space.`,
398
+ data: {
399
+ elementA: a.selector,
400
+ elementB: b.selector,
401
+ overlapRatioPct: Math.round(ratio * 100),
402
+ intersectionArea: Math.round(intersectionArea),
403
+ rectA: rectData(a.rect),
404
+ rectB: rectData(b.rect),
405
+ },
406
+ })
407
+ }
408
+ }
409
+
410
+ return issues
411
+ }
412
+
413
+ function overlapCandidates(elements: ElementInfo[]): ElementInfo[] {
414
+ const semantic = elements.flatMap((element) => semanticDescendants(element))
415
+ const base = semantic.length > 1 ? semantic : elements.filter((element) => element.visible)
416
+ return base.filter((element) => {
417
+ if (!element.visible) return false
418
+ if (area(element.rect) < T.OVERLAP_MIN_ELEMENT_AREA) return false
419
+ if (isDecorative(element)) return false
420
+ return true
421
+ })
422
+ }
423
+
424
+ function semanticDescendants(element: ElementInfo): ElementInfo[] {
425
+ if (!element.visible || isDecorative(element)) return []
426
+ if (isSemanticComponent(element)) return [element]
427
+ return element.children.flatMap((child) => semanticDescendants(child))
428
+ }
429
+
430
+ function isSemanticComponent(element: ElementInfo): boolean {
431
+ return element.classList.some((className) =>
432
+ SEMANTIC_COMPONENT_CLASSES.includes(className) || className.startsWith("roadmap-")
433
+ )
434
+ }
435
+
436
+ function isDecorative(element: ElementInfo): boolean {
437
+ return element.classList.some((className) =>
438
+ DECORATIVE_CLASSES.includes(className) || className.startsWith("decorative-") || className.startsWith("background-")
439
+ )
440
+ }
441
+
442
+ function isIntentionalOverlayPair(a: ElementInfo, b: ElementInfo): boolean {
443
+ const classes = new Set([...a.classList, ...b.classList])
444
+ return classes.has("hero") && (classes.has("media") || classes.has("text-panel"))
445
+ }
446
+
447
+ function intersection(a: Rect, b: Rect): Rect | undefined {
448
+ const left = Math.max(a.left, b.left)
449
+ const top = Math.max(a.top, b.top)
450
+ const right = Math.min(a.right, b.right)
451
+ const bottom = Math.min(a.bottom, b.bottom)
452
+ if (right <= left || bottom <= top) return undefined
453
+ return { left, top, right, bottom, width: right - left, height: bottom - top }
454
+ }
455
+
456
+ function area(rect: Rect): number {
457
+ return Math.max(0, rect.width) * Math.max(0, rect.height)
458
+ }
459
+
460
+ function hasText(element: ElementInfo): boolean {
461
+ if (element.text?.trim()) return true
462
+ return element.children.some(hasText)
463
+ }
464
+
465
+ function rectData(rect: Rect): string {
466
+ return `${Math.round(rect.left)},${Math.round(rect.top)},${Math.round(rect.right)},${Math.round(rect.bottom)}`
467
+ }
468
+
273
469
  function checkContentDensity(metrics: SlideMetrics): LayoutIssue[] {
274
470
  if (!metrics.slideQa) return []
275
471
  const { bodyTextPoints, contentUnits, supportReferences } = metrics.contentStats
@@ -621,13 +817,16 @@ export function runChecks(
621
817
  _options?: RunChecksOptions,
622
818
  ): QAReport {
623
819
  const slides: SlideReport[] = []
820
+ const navigationIssues = checkNavigationModel(allMetrics)
624
821
 
625
822
  for (const metrics of allMetrics) {
626
823
  const issues: LayoutIssue[] = [
824
+ ...(metrics.index === 0 ? navigationIssues : []),
627
825
  ...checkCanvas(metrics),
628
826
  ...checkScrollbars(metrics),
629
827
  ...checkOverflow(metrics),
630
828
  ...checkTextOverflow(metrics),
829
+ ...checkElementOverlap(metrics),
631
830
  ...checkContentDensity(metrics),
632
831
  ]
633
832
 
@@ -693,9 +892,11 @@ export function formatReport(report: QAReport): string {
693
892
  ``,
694
893
  `Please fix the above hard-error issues in the HTML file. For each issue type:`,
695
894
  `- **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.`,
895
+ `- **scrollbar**: remove horizontal document/body scrolling and slide-internal scrolling. Multi-slide decks may use normal vertical document scroll for navigation.`,
896
+ `- **navigation**: keep every .slide in normal document flow; do not stack slides with fixed/absolute positioning or rely on aria-hidden/visibility toggles for pagination. Use scrollIntoView-based navigation.`,
697
897
  `- **overflow**: reduce font size, padding, or content amount for the affected element.`,
698
898
  `- **text_overflow**: increase the text container size, reduce copy, or adjust font/line-height so text is not clipped.`,
899
+ `- **overlap**: separate overlapping components, reduce copy, resize media/table/chart blocks, or use a layout with more space.`,
699
900
  `- **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.`,
700
901
  `- **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.`,
701
902
  `- **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.`,
package/lib/qa/measure.ts CHANGED
@@ -64,6 +64,29 @@ export interface SlideContentStats {
64
64
  supportReferences: number
65
65
  }
66
66
 
67
+ export interface ScrollbarMetrics {
68
+ documentHorizontal: boolean
69
+ documentVertical: boolean
70
+ bodyHorizontal: boolean
71
+ bodyVertical: boolean
72
+ slideHorizontal: boolean
73
+ slideVertical: boolean
74
+ }
75
+
76
+ export interface SlideNavigationMetrics {
77
+ totalSlides: number
78
+ initialTop: number
79
+ initialLeft: number
80
+ position: string
81
+ visibility: string
82
+ display: string
83
+ ariaHidden: string | null
84
+ bodyOverflowY: string
85
+ documentOverflowY: string
86
+ documentScrollHeight: number
87
+ viewportHeight: number
88
+ }
89
+
67
90
  export interface SlideMetrics {
68
91
  /** 0-based slide index */
69
92
  index: number
@@ -82,6 +105,10 @@ export interface SlideMetrics {
82
105
  slideRect: Rect
83
106
  /** whether document/body/slide has scrollbars at 1920x1080 */
84
107
  hasScrollbars: boolean
108
+ /** detailed scrollbar source signals for document/body/slide */
109
+ scrollbars?: ScrollbarMetrics
110
+ /** deck navigation model signals captured before per-slide scrolling */
111
+ navigation?: SlideNavigationMetrics
85
112
  /** top-level visible children of .slide-canvas */
86
113
  elements: ElementInfo[]
87
114
  /** union bounding box of all visible leaf elements */
@@ -163,6 +190,31 @@ export async function measureSlides(htmlFilePath: string): Promise<MeasurementRe
163
190
  () => document.querySelectorAll(".slide").length
164
191
  )
165
192
 
193
+ const navigationData = await page.evaluate(() => {
194
+ const doc = document.documentElement
195
+ const body = document.body
196
+ const docStyle = window.getComputedStyle(doc)
197
+ const bodyStyle = window.getComputedStyle(body)
198
+ const slides = Array.from(document.querySelectorAll(".slide")) as HTMLElement[]
199
+ return slides.map((slide) => {
200
+ const rect = slide.getBoundingClientRect()
201
+ const style = window.getComputedStyle(slide)
202
+ return {
203
+ totalSlides: slides.length,
204
+ initialTop: rect.top,
205
+ initialLeft: rect.left,
206
+ position: style.position,
207
+ visibility: style.visibility,
208
+ display: style.display,
209
+ ariaHidden: slide.getAttribute("aria-hidden"),
210
+ bodyOverflowY: bodyStyle.overflowY,
211
+ documentOverflowY: docStyle.overflowY,
212
+ documentScrollHeight: doc.scrollHeight,
213
+ viewportHeight: window.innerHeight,
214
+ }
215
+ })
216
+ })
217
+
166
218
  const metrics: SlideMetrics[] = []
167
219
 
168
220
  for (let idx = 0; idx < slideCount; idx++) {
@@ -370,6 +422,15 @@ export async function measureSlides(htmlFilePath: string): Promise<MeasurementRe
370
422
  slideEl.scrollWidth > slideEl.clientWidth + 2 ||
371
423
  slideEl.scrollHeight > slideEl.clientHeight + 2
372
424
 
425
+ const scrollbars = {
426
+ documentHorizontal: doc.scrollWidth > window.innerWidth + 2,
427
+ documentVertical: doc.scrollHeight > window.innerHeight + 2,
428
+ bodyHorizontal: body.scrollWidth > window.innerWidth + 2,
429
+ bodyVertical: body.scrollHeight > window.innerHeight + 2,
430
+ slideHorizontal: slideEl.scrollWidth > slideEl.clientWidth + 2,
431
+ slideVertical: slideEl.scrollHeight > slideEl.clientHeight + 2,
432
+ }
433
+
373
434
  const titleEl = canvas.querySelector("h1, h2")
374
435
  const title = titleEl
375
436
  ? (titleEl.textContent || "").replace(/\s+/g, " ").trim().slice(0, 80)
@@ -382,6 +443,7 @@ export async function measureSlides(htmlFilePath: string): Promise<MeasurementRe
382
443
  canvasRect,
383
444
  slideRect,
384
445
  hasScrollbars,
446
+ scrollbars,
385
447
  elements,
386
448
  contentRect: unionRect(elements),
387
449
  contentStats: { bodyTextPoints, contentUnits, supportReferences },
@@ -390,7 +452,7 @@ export async function measureSlides(htmlFilePath: string): Promise<MeasurementRe
390
452
  idx
391
453
  )
392
454
 
393
- if (slideData) metrics.push(slideData as SlideMetrics)
455
+ if (slideData) metrics.push({ ...(slideData as SlideMetrics), navigation: navigationData[idx] })
394
456
  }
395
457
 
396
458
  // Extract all CSS class names defined in <style> blocks.
@@ -253,35 +253,105 @@ async function handleAssetSave(req: Request, session: EditSession): Promise<Resp
253
253
  const candidate = normalizeImageCandidate(body?.candidate ?? body)
254
254
  if (!candidate) return jsonResponse({ ok: false, error: "Valid image candidate is required" }, 400)
255
255
  const purpose = normalizeMediaPurpose(body?.purpose) || candidate.purpose || "illustration"
256
- const result = await saveMediaAsset({
257
- topic: session.deck,
256
+ const brief = body?.brief || `Saved from ${candidate.provider} for Review asset placement.`
257
+ const saved = await saveAssetCandidateUrls({
258
+ session,
259
+ candidate,
258
260
  id: body?.id || candidate.candidateId,
259
- type: "image",
260
261
  purpose,
261
- brief: body?.brief || `Saved from ${candidate.provider} for Review asset placement.`,
262
- status: "success",
263
- sourceUrl: candidate.imageUrl,
262
+ brief,
264
263
  alt: body?.alt || candidate.alt || candidate.title,
265
264
  notes: body?.notes,
266
- provider: candidate.provider,
267
- sourcePageUrl: candidate.sourcePageUrl,
268
- license: candidate.license,
269
- attribution: candidate.attribution,
270
- width: candidate.width,
271
- height: candidate.height,
272
- }, session.workspaceRoot)
265
+ })
273
266
 
274
267
  session.lastActiveAt = Date.now()
275
268
  scheduleIdleStop()
269
+ const result = saved.result
276
270
  if (!result.ok) return jsonResponse({ ok: false, error: result.error }, 400)
277
271
  if (result.status !== "success" || !result.path) {
278
- return jsonResponse({ ok: false, error: `Failed to save asset: ${result.status}` }, 400)
272
+ return jsonResponse({ ok: false, error: failedAssetSaveMessage(result.status, saved.failures) }, 400)
279
273
  }
280
274
  const asset = savedAssetForResult(session, result.assetId)
275
+ ?? savedAssetFallback(session, {
276
+ id: result.assetId,
277
+ path: result.path,
278
+ sourceUrl: saved.sourceUrl,
279
+ purpose,
280
+ brief,
281
+ candidate,
282
+ })
281
283
  if (!asset) return jsonResponse({ ok: false, error: "Saved asset was not found in workspace assets." }, 500)
282
284
  return jsonResponse({ ok: true, asset, result })
283
285
  }
284
286
 
287
+ async function saveAssetCandidateUrls(input: {
288
+ session: EditSession
289
+ candidate: ImageCandidate
290
+ id: string
291
+ purpose: MediaPurpose
292
+ brief: string
293
+ alt?: string
294
+ notes?: string
295
+ }): Promise<{
296
+ result: Awaited<ReturnType<typeof saveMediaAsset>>
297
+ sourceUrl?: string
298
+ failures: Array<{ url: string; status: string }>
299
+ }> {
300
+ const urls = uniqueAssetUrls([input.candidate.imageUrl, input.candidate.thumbnailUrl])
301
+ const failures: Array<{ url: string; status: string }> = []
302
+ let lastResult: Awaited<ReturnType<typeof saveMediaAsset>> | undefined
303
+
304
+ for (const sourceUrl of urls) {
305
+ const result = await saveMediaAsset({
306
+ topic: input.session.deck,
307
+ id: input.id,
308
+ type: "image",
309
+ purpose: input.purpose,
310
+ brief: input.brief,
311
+ status: "success",
312
+ sourceUrl,
313
+ alt: input.alt,
314
+ notes: input.notes,
315
+ provider: input.candidate.provider,
316
+ sourcePageUrl: input.candidate.sourcePageUrl,
317
+ license: input.candidate.license,
318
+ attribution: input.candidate.attribution,
319
+ width: input.candidate.width,
320
+ height: input.candidate.height,
321
+ }, input.session.workspaceRoot)
322
+ lastResult = result
323
+ if (result.ok && result.status === "success" && result.path) return { result, sourceUrl, failures }
324
+ failures.push({ url: sourceUrl, status: result.ok ? result.failureReason ?? result.status : result.error })
325
+ }
326
+
327
+ return {
328
+ result: lastResult ?? { ok: false, error: "No downloadable image URL was provided" },
329
+ failures,
330
+ }
331
+ }
332
+
333
+ function uniqueAssetUrls(values: Array<string | undefined>): string[] {
334
+ const seen = new Set<string>()
335
+ return values.flatMap((value) => {
336
+ const url = value?.trim()
337
+ if (!url || seen.has(url)) return []
338
+ seen.add(url)
339
+ return [url]
340
+ })
341
+ }
342
+
343
+ function failedAssetSaveMessage(status: string, failures: Array<{ url: string; status: string }>): string {
344
+ if (!failures.length) return `Failed to save asset: ${status}`
345
+ const details = failures
346
+ .map((failure) => `${shortUrl(failure.url)}: ${failure.status}`)
347
+ .join("; ")
348
+ return `Failed to save asset: ${status} (${details})`
349
+ }
350
+
351
+ function shortUrl(value: string): string {
352
+ return value.length <= 96 ? value : `${value.slice(0, 93)}...`
353
+ }
354
+
285
355
  function handleAssetList(session: EditSession): Response {
286
356
  session.lastActiveAt = Date.now()
287
357
  scheduleIdleStop()
@@ -292,6 +362,39 @@ function savedAssetForResult(session: EditSession, assetId: string): (MediaAsset
292
362
  return listSavedAssets(session).find((asset) => asset.id === assetId) ?? null
293
363
  }
294
364
 
365
+ function savedAssetFallback(
366
+ session: EditSession,
367
+ input: {
368
+ id: string
369
+ path: string | null
370
+ sourceUrl?: string
371
+ purpose: MediaPurpose
372
+ brief: string
373
+ candidate: ImageCandidate
374
+ },
375
+ ): (MediaAssetRecord & { previewUrl?: string; deckPath?: string }) | null {
376
+ if (!input.path) return null
377
+ return {
378
+ id: input.id,
379
+ type: "image",
380
+ purpose: input.purpose,
381
+ brief: input.brief,
382
+ status: "success",
383
+ path: input.path,
384
+ sourceUrl: input.sourceUrl ?? input.candidate.imageUrl,
385
+ alt: input.candidate.alt || input.candidate.title,
386
+ provider: input.candidate.provider,
387
+ sourcePageUrl: input.candidate.sourcePageUrl,
388
+ license: input.candidate.license,
389
+ attribution: input.candidate.attribution,
390
+ width: input.candidate.width,
391
+ height: input.candidate.height,
392
+ savedAt: new Date().toISOString(),
393
+ previewUrl: assetUrlForRef(input.path, session, session.workspaceRoot) ?? undefined,
394
+ deckPath: relative(dirname(session.absoluteFile), resolve(session.workspaceRoot, input.path)).replace(/\\/g, "/"),
395
+ }
396
+ }
397
+
295
398
  function listSavedAssets(session: EditSession): Array<MediaAssetRecord & { previewUrl?: string; deckPath?: string }> {
296
399
  const manifestPath = resolve(session.workspaceRoot, "assets", slugify(session.deck), "media-manifest.json")
297
400
  if (!existsSync(manifestPath)) return []
@@ -891,6 +994,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
891
994
  .skeleton-line.long { width: 92%; }
892
995
  .asset-card.is-saving::after { content: ""; position: absolute; inset: 0; background: rgba(15,23,42,.32); }
893
996
  .asset-card.is-saving .asset-save { z-index: 1; }
997
+ .asset-card.is-saved-candidate .asset-thumb { opacity: .72; }
894
998
  @keyframes spin { to { transform: rotate(360deg); } }
895
999
  @keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
896
1000
  .asset-search { display: grid; grid-template-columns: minmax(0, 1fr) 118px; gap: 8px; }
@@ -913,6 +1017,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
913
1017
  .asset-search-title span { color: #756f66; font-size: 12px; line-height: 1.35; }
914
1018
  .asset-back { width: auto; padding: 9px 11px; border-radius: 999px; background: #ebe4d8; color: #111827; box-shadow: none; }
915
1019
  .asset-save { position: absolute; left: 7px; right: 7px; bottom: 7px; width: auto; padding: 7px 8px; border-radius: 10px; font-size: 11px; background: rgba(17,24,39,.9); color: #fbfaf7; box-shadow: 0 8px 16px rgba(31,41,51,.2); opacity: .96; }
1020
+ .asset-save.saved { background: rgba(77,97,56,.94); color: #fbfaf7; cursor: default; }
916
1021
  .asset-empty { grid-column: 1 / -1; margin: 0; color: #756f66; font-size: 12px; line-height: 1.45; }
917
1022
  .edit-assets { padding: 10px; border: 1px solid #d8d2c6; border-radius: 16px; background: #f7f3ea; }
918
1023
  .edit-assets .panel { gap: 8px; }
@@ -2000,9 +2105,13 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
2000
2105
  }
2001
2106
  state.assetCandidates.forEach((candidate, index) => {
2002
2107
  const card = assetCard(candidate, false, index);
2108
+ const savedAsset = savedAssetForCandidate(candidate);
2003
2109
  if (state.assetSavingIndex === index) {
2004
2110
  card.classList.add('is-saving');
2005
2111
  appendAssetSaveButton(card, 'Saving...', 'Saving to workspace', () => {}, true);
2112
+ } else if (savedAsset) {
2113
+ card.classList.add('is-saved-candidate');
2114
+ appendAssetSaveButton(card, '✅ Saved', 'Asset already saved to Local Assets', () => {}, false, 'saved');
2006
2115
  } else {
2007
2116
  appendAssetSaveButton(card, 'Save', 'Save to workspace', () => saveCandidate(index));
2008
2117
  }
@@ -2025,7 +2134,11 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
2025
2134
  });
2026
2135
  const body = await res.json().catch(() => ({}));
2027
2136
  if (!res.ok || !body.ok) throw new Error(body.error || 'Failed to save asset');
2028
- await loadSavedAssets();
2137
+ const listed = await loadSavedAssets();
2138
+ if (body.asset && (!listed || !findSavedAsset(body.asset.id))) {
2139
+ mergeSavedAsset(body.asset);
2140
+ renderSavedAssets();
2141
+ }
2029
2142
  const path = body.asset && (body.asset.path || body.asset.deckPath);
2030
2143
  setStatus(path ? 'Saved to ' + path + '. Use it from Local Assets.' : 'Asset saved. Use it from Local Assets.');
2031
2144
  } catch (error) {
@@ -2043,12 +2156,36 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
2043
2156
  if (!res.ok || !body.ok) throw new Error(body.error || 'Failed to list assets');
2044
2157
  state.savedAssets = Array.isArray(body.assets) ? body.assets : [];
2045
2158
  renderSavedAssets();
2159
+ return true;
2046
2160
  } catch (error) {
2047
- const message = '<p class="asset-empty">' + escapeHtml(error && error.message ? error.message : String(error)) + '</p>';
2048
- els.editSavedAssets.innerHTML = message;
2161
+ if (!state.savedAssets.length) {
2162
+ const message = '<p class="asset-empty">' + escapeHtml(error && error.message ? error.message : String(error)) + '</p>';
2163
+ els.editSavedAssets.innerHTML = message;
2164
+ }
2165
+ return false;
2049
2166
  }
2050
2167
  }
2051
2168
 
2169
+ function mergeSavedAsset(asset) {
2170
+ if (!asset || !asset.id) return;
2171
+ const next = state.savedAssets.filter((existing) => existing.id !== asset.id);
2172
+ next.unshift(asset);
2173
+ state.savedAssets = next;
2174
+ }
2175
+
2176
+ function savedAssetForCandidate(candidate) {
2177
+ const id = slugifyAssetId(candidate && candidate.candidateId);
2178
+ if (!id) return null;
2179
+ return state.savedAssets.find((asset) => asset.id === id) || null;
2180
+ }
2181
+
2182
+ function slugifyAssetId(value) {
2183
+ return String(value || '')
2184
+ .toLowerCase()
2185
+ .replace(/[^a-z0-9]+/g, '-')
2186
+ .replace(/^-+|-+$/g, '');
2187
+ }
2188
+
2052
2189
  function renderSavedAssets() {
2053
2190
  renderSavedAssetGrid(els.editSavedAssets, 'No local assets yet. Click + to search assets.');
2054
2191
  }
@@ -2108,12 +2245,12 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
2108
2245
  }
2109
2246
  }
2110
2247
 
2111
- function appendAssetSaveButton(card, text, label, onClick, loading) {
2248
+ function appendAssetSaveButton(card, text, label, onClick, loading, variant) {
2112
2249
  const button = document.createElement('button');
2113
2250
  button.type = 'button';
2114
- button.className = 'asset-save';
2251
+ button.className = variant ? 'asset-save ' + variant : 'asset-save';
2115
2252
  button.innerHTML = loading ? '<span class="spinner" aria-hidden="true"></span><span>' + escapeHtml(text) + '</span>' : escapeHtml(text);
2116
- button.disabled = !!loading;
2253
+ button.disabled = !!loading || variant === 'saved';
2117
2254
  button.setAttribute('aria-label', label);
2118
2255
  button.title = label;
2119
2256
  button.addEventListener('click', onClick);