@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.
- package/README.md +7 -5
- package/README.zh-CN.md +7 -5
- package/lib/commands/brief.ts +9 -0
- package/lib/commands/help.ts +5 -2
- package/lib/commands/init.ts +42 -27
- package/lib/commands/narrative.ts +39 -6
- package/lib/commands/research.ts +36 -20
- package/lib/commands/review.ts +35 -28
- package/lib/ctx.ts +1 -1
- package/lib/decks-state.ts +38 -4
- package/lib/edit/prompt.ts +1 -1
- package/lib/hook-notifications.ts +53 -0
- package/lib/media/download.ts +23 -3
- package/lib/media/save.ts +1 -0
- package/lib/media/types.ts +1 -0
- package/lib/narrative-state/display.ts +74 -4
- package/lib/narrative-state/map-html.ts +242 -107
- package/lib/narrative-state/render-plan.ts +238 -35
- package/lib/narrative-state/research-binding-eval.ts +260 -0
- package/lib/narrative-state/research-gaps.ts +2 -88
- package/lib/narrative-vault/authoring-contract.ts +127 -0
- package/lib/narrative-vault/authoring-guard.ts +122 -0
- package/lib/narrative-vault/auto-compile.ts +134 -0
- package/lib/narrative-vault/bootstrap.ts +63 -0
- package/lib/narrative-vault/cache.ts +14 -0
- package/lib/narrative-vault/compile-mirror.ts +45 -0
- package/lib/narrative-vault/compile.ts +350 -0
- package/lib/narrative-vault/constants.ts +6 -0
- package/lib/narrative-vault/diagnostic-report.ts +117 -0
- package/lib/narrative-vault/export.ts +71 -0
- package/lib/narrative-vault/frontmatter.ts +41 -0
- package/lib/narrative-vault/hook-targets.ts +40 -0
- package/lib/narrative-vault/index.ts +18 -0
- package/lib/narrative-vault/inventory.ts +392 -0
- package/lib/narrative-vault/markdown-qa.ts +237 -0
- package/lib/narrative-vault/markdown.ts +34 -0
- package/lib/narrative-vault/migration.ts +52 -0
- package/lib/narrative-vault/mutate.ts +361 -0
- package/lib/narrative-vault/paths.ts +19 -0
- package/lib/narrative-vault/read.ts +52 -0
- package/lib/narrative-vault/relations.ts +32 -0
- package/lib/narrative-vault/source-loader.ts +19 -0
- package/lib/narrative-vault/timestamp.ts +32 -0
- package/lib/narrative-vault/types.ts +44 -0
- package/lib/qa/checks.ts +206 -5
- package/lib/qa/measure.ts +63 -1
- package/lib/refine/server.ts +157 -20
- package/lib/source-materials.ts +98 -0
- package/lib/tool-result.ts +34 -0
- package/package.json +2 -2
- package/plugin.ts +60 -22
- package/skill/NARRATIVE_SKILL.md +25 -10
- package/skill/SKILL.md +6 -1
- package/tools/decks.ts +363 -67
- package/tools/narrative-view.ts +16 -0
- package/tools/research-save.ts +3 -0
- 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
|
-
|
|
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.
|
|
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
|
|
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.
|
package/lib/refine/server.ts
CHANGED
|
@@ -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
|
|
257
|
-
|
|
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
|
|
262
|
-
status: "success",
|
|
263
|
-
sourceUrl: candidate.imageUrl,
|
|
262
|
+
brief,
|
|
264
263
|
alt: body?.alt || candidate.alt || candidate.title,
|
|
265
264
|
notes: body?.notes,
|
|
266
|
-
|
|
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:
|
|
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
|
-
|
|
2048
|
-
|
|
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);
|