@cyber-dash-tech/revela 0.17.0 → 0.17.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.
@@ -328,8 +328,11 @@ function questionForClaimTarget(kind: ResearchTargetKind, claim: NarrativeClaim)
328
328
  function requiredEvidenceForClaim(claim: NarrativeClaim | undefined): string[] {
329
329
  const base = ["source", "quote/snippet", "support scope", "unsupported scope", "caveat", "strength"]
330
330
  if (!claim) return base
331
- if (claim.unsupportedScope) return [...base, `address unsupported scope: ${claim.unsupportedScope}`]
332
- return base
331
+ const chapterReady = claim.importance === "central"
332
+ ? ["framing/background support", "1-2 evidence bindings, cases, or quantitative signals", "implication, risk, or boundary material"]
333
+ : []
334
+ if (claim.unsupportedScope) return [...base, ...chapterReady, `address unsupported scope: ${claim.unsupportedScope}`]
335
+ return [...base, ...chapterReady]
333
336
  }
334
337
 
335
338
  function bindingFailuresForClaim(claim: NarrativeClaim): EvidenceBindingFailureReason[] {
@@ -1,11 +1,13 @@
1
1
  import { normalizeCanonicalNarrativeState } from "../narrative-state/normalize"
2
2
  import { computeNarrativeHash } from "../narrative-state/hash"
3
+ import { readDeckPlanProjection } from "../narrative-state/deck-plan-artifact"
3
4
  import type {
4
5
  AudienceIntent,
5
6
  DecisionIntent,
6
7
  NarrativeApproval,
7
8
  NarrativeClaim,
8
9
  NarrativeClaimKind,
10
+ NarrativeClaimRelationType,
9
11
  NarrativeEvidenceBinding,
10
12
  NarrativeObjection,
11
13
  NarrativeResearchGap,
@@ -89,7 +91,7 @@ export function compileNarrativeVault(workspaceRoot: string, options: CompileNar
89
91
  risks: docs.filter((doc) => typeField(doc) === "risk").map((doc) => compileRisk(doc, relationTargets)),
90
92
  researchGaps: docs.filter((doc) => typeField(doc) === "research-gap").map((doc) => compileResearchGap(doc, options.now, relationTargets)),
91
93
  claimRelations: relations
92
- .filter((relation) => byId.get(relation.fromId) && typeField(byId.get(relation.fromId)) === "claim" && byId.get(relation.toId) && typeField(byId.get(relation.toId)) === "claim")
94
+ .filter((relation): relation is VaultRelation & { relation: NarrativeClaimRelationType } => isNarrativeClaimRelationType(relation.relation) && Boolean(byId.get(relation.fromId)) && typeField(byId.get(relation.fromId)) === "claim" && Boolean(byId.get(relation.toId)) && typeField(byId.get(relation.toId)) === "claim")
93
95
  .map((relation) => ({ id: relation.id ?? `${relation.fromId}:${relation.relation}:${relation.toId}`, fromClaimId: relation.fromId, toClaimId: relation.toId, relation: relation.relation, rationale: relation.rationale })),
94
96
  approvals: options.fallbackApprovals ?? [],
95
97
  updatedAt: options.now ?? new Date().toISOString(),
@@ -108,6 +110,15 @@ export function compileNarrativeVault(workspaceRoot: string, options: CompileNar
108
110
  nodes: nodeDocs.map((doc) => ({ id: stringField(doc, "id"), type: typeField(doc), file: doc.relativePath })),
109
111
  relations,
110
112
  }
113
+ if (normalized) {
114
+ const knownNodeIds = new Set(graph.nodes.map((node) => node.id))
115
+ const deckPlan = readDeckPlanProjection(workspaceRoot, { narrativeHash: computeNarrativeHash(normalized), knownNodeIds })
116
+ if (deckPlan) {
117
+ graph.nodes.push(...deckPlan.graphNodes)
118
+ graph.relations.push(...deckPlan.graphRelations)
119
+ for (const diagnostic of deckPlan.diagnostics) diagnostics.push({ severity: diagnostic.severity, code: diagnostic.code, message: diagnostic.message, file: diagnostic.file, nodeId: diagnostic.nodeId })
120
+ }
121
+ }
111
122
  return { ok: !diagnostics.some((diagnostic) => diagnostic.severity === "error") && Boolean(normalized), narrative: normalized, diagnostics, graph }
112
123
  }
113
124
 
@@ -227,6 +238,10 @@ function isVaultNodeType(value: string): value is VaultNodeType {
227
238
  return value === "research-gap" || ["index", "audience", "decision", "thesis", "claim", "evidence", "objection", "risk"].includes(value)
228
239
  }
229
240
 
241
+ function isNarrativeClaimRelationType(value: string): value is NarrativeClaimRelationType {
242
+ return ["leads_to", "supports", "depends_on", "contrasts_with", "constrains", "answers"].includes(value)
243
+ }
244
+
230
245
  function illegalRelationReason(fromType: VaultNodeType, toType: VaultNodeType, relation: string): string | undefined {
231
246
  if (fromType !== "claim") {
232
247
  const allowedNonClaim: Record<string, VaultNodeType[]> = {
@@ -1,6 +1,8 @@
1
1
  import type { NarrativeClaimRelationType, NarrativeStateV1 } from "../narrative-state/types"
2
2
 
3
3
  export type VaultNodeType = "index" | "audience" | "decision" | "thesis" | "claim" | "evidence" | "objection" | "risk" | "research-gap"
4
+ export type WorkspaceGraphNodeType = VaultNodeType | "deck-plan" | "deck-plan-slide"
5
+ export type WorkspaceGraphRelationType = NarrativeClaimRelationType | "uses_claim" | "uses_evidence" | "addresses_risk" | "answers_objection" | "mentions_gap"
4
6
 
5
7
  export type VaultDiagnosticSeverity = "error" | "warning"
6
8
 
@@ -15,7 +17,7 @@ export interface VaultDiagnostic {
15
17
  export interface VaultRelation {
16
18
  id?: string
17
19
  fromId: string
18
- relation: NarrativeClaimRelationType
20
+ relation: WorkspaceGraphRelationType
19
21
  toId: string
20
22
  rationale?: string
21
23
  file: string
@@ -39,6 +41,6 @@ export interface NarrativeVaultCompileResult {
39
41
  }
40
42
 
41
43
  export interface NarrativeVaultGraph {
42
- nodes: Array<{ id: string; type: VaultNodeType; file: string }>
44
+ nodes: Array<{ id: string; type: WorkspaceGraphNodeType; file: string }>
43
45
  relations: VaultRelation[]
44
46
  }
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.
@@ -78,8 +78,8 @@ function openRefineDeckInternal(
78
78
  return {
79
79
  deck,
80
80
  url,
81
- source: deck.source === "render-target" ? "render target" : deck.source === "decks-state" ? "DECKS.json" : deck.source === "file-path" ? "file path" : "fallback path",
82
- stateNote: preflight.changed ? "Deck state was prepared in DECKS.json for refinement." : "Deck state already points to this refinement target.",
81
+ source: deck.source === "file-path" ? "file path" : "discovered deck file",
82
+ stateNote: preflight.changed ? "Deck file preflight updated runtime state." : "Deck review uses the selected HTML artifact directly.",
83
83
  preflightChanged: preflight.changed,
84
84
  reusedSession: session.reused,
85
85
  liveSession: session.live,