@cyber-dash-tech/revela 0.17.0 → 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.
@@ -1,4 +1,4 @@
1
- import { deckPlanHash, 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, type VisualBrief } from "../decks-state"
2
2
  import { ensureActiveHtmlDeckRenderTarget } from "../workspace-state/render-targets"
3
3
  import { getClaimSlideRefs } from "./queries"
4
4
  import { computeNarrativeHash } from "./hash"
@@ -35,6 +35,16 @@ export interface DeckPlanQualityCheck {
35
35
  message: string
36
36
  }
37
37
 
38
+ type VisualIntentKind = "hero" | "toc" | "metric-stat" | "evidence-table" | "comparison-grid" | "risk-matrix" | "steps" | "text-only"
39
+
40
+ interface VisualIntent {
41
+ kind: VisualIntentKind
42
+ component: string
43
+ rationale: string
44
+ dataSignals: string[]
45
+ evidenceBindingIds: string[]
46
+ }
47
+
38
48
  export function compileDeckPlanFromNarrative(state: DecksState, options: CompileDeckPlanOptions = {}): { state: DecksState; result: CompileDeckPlanResult } {
39
49
  const narrative = normalizeNarrativeState(state)
40
50
  const narrativeHash = computeNarrativeHash(narrative)
@@ -168,6 +178,7 @@ function buildDeckPlan(narrative: NarrativeStateV1): { slides: SlideSpec[]; chap
168
178
  }
169
179
 
170
180
  function coverSlide(index: number, narrative: NarrativeStateV1): SlideSpec {
181
+ const visualIntent = visualIntentForStructuralSlide("hero", "Use a hero frame to anchor the decision context and belief shift before evidence detail.")
171
182
  return {
172
183
  index,
173
184
  title: "Decision Context",
@@ -183,13 +194,16 @@ function coverSlide(index: number, narrative: NarrativeStateV1): SlideSpec {
183
194
  narrative.audience.beliefAfter ? `After: ${narrative.audience.beliefAfter}` : "After belief needs confirmation.",
184
195
  ],
185
196
  bullets: narrative.decision.action ? [`Decision: ${narrative.decision.action}`] : [],
197
+ data: { visualIntent },
186
198
  },
199
+ visuals: visualBriefs(index, visualIntent),
187
200
  evidence: [],
188
201
  status: "planned",
189
202
  }
190
203
  }
191
204
 
192
205
  function tocSlide(index: number, chapters: DeckPlanChapter[]): SlideSpec {
206
+ const visualIntent = visualIntentForStructuralSlide("toc", "Render the chapter sequence as a visual table of contents instead of a bullet-only agenda.")
193
207
  return {
194
208
  index,
195
209
  title: "Storyline",
@@ -201,14 +215,16 @@ function tocSlide(index: number, chapters: DeckPlanChapter[]): SlideSpec {
201
215
  content: {
202
216
  headline: "How the decision story is organized",
203
217
  bullets: chapters.map((chapter) => chapter.title),
204
- data: { chapters: chapters.map((chapter) => ({ title: chapter.title, role: chapter.role })) },
218
+ data: { chapters: chapters.map((chapter) => ({ title: chapter.title, role: chapter.role })), visualIntent },
205
219
  },
220
+ visuals: visualBriefs(index, visualIntent),
206
221
  evidence: [],
207
222
  status: "planned",
208
223
  }
209
224
  }
210
225
 
211
226
  function claimSlide(index: number, claim: NarrativeClaim, bindings: NarrativeEvidenceBinding[]): SlideSpec {
227
+ const visualIntent = visualIntentForClaim(claim, bindings)
212
228
  return {
213
229
  index,
214
230
  title: titleFromClaim(claim),
@@ -216,21 +232,24 @@ function claimSlide(index: number, claim: NarrativeClaim, bindings: NarrativeEvi
216
232
  narrativeRole: claim.kind === "risk" || claim.kind === "assumption" ? "risk" : claim.kind === "ask" ? "ask" : claim.kind === "recommendation" ? "recommendation" : "evidence",
217
233
  layout: "two-col",
218
234
  qa: true,
219
- components: claimComponents(claim, bindings),
235
+ components: claimComponents(claim, bindings, visualIntent),
220
236
  claimIds: [claim.id],
221
237
  claimRefs: [{ claimId: claim.id, role: "primary", note: claimBoundaryNote(claim) }],
222
238
  evidenceBindingIds: bindings.map((binding) => binding.id),
223
239
  content: {
224
240
  headline: claim.text,
225
241
  bullets: claimBullets(claim, bindings),
242
+ data: { visualIntent },
226
243
  },
227
244
  evidence: bindings.map(evidenceRefFromBinding),
245
+ visuals: visualBriefs(index, visualIntent),
228
246
  status: "planned",
229
247
  }
230
248
  }
231
249
 
232
250
  function supportingLogicSlide(index: number, claims: NarrativeClaim[], evidenceByClaim: Map<string, NarrativeEvidenceBinding[]>): SlideSpec {
233
251
  const supportingBindings = claims.flatMap((claim) => evidenceByClaim.get(claim.id) ?? [])
252
+ const visualIntent = visualIntentForSupportingLogic(claims, supportingBindings)
234
253
  return {
235
254
  index,
236
255
  title: "Supporting Logic",
@@ -238,15 +257,17 @@ function supportingLogicSlide(index: number, claims: NarrativeClaim[], evidenceB
238
257
  narrativeRole: "evidence",
239
258
  layout: "card-grid",
240
259
  qa: true,
241
- components: ["box", "text-panel"],
260
+ components: componentsForVisualIntent(["box", "text-panel"], visualIntent),
242
261
  claimIds: claims.map((claim) => claim.id),
243
262
  claimRefs: claims.map((claim) => ({ claimId: claim.id, role: "supporting" as const, note: claimBoundaryNote(claim) })),
244
263
  evidenceBindingIds: supportingBindings.map((binding) => binding.id),
245
264
  content: {
246
265
  headline: "Supporting claims and boundaries",
247
266
  bullets: claims.slice(0, 5).flatMap((claim) => [claim.text, ...claimBoundaryBullets(claim), evidenceGapBullet(claim, evidenceByClaim.get(claim.id) ?? [])]).filter((item): item is string => Boolean(item)).slice(0, 8),
267
+ data: { visualIntent },
248
268
  },
249
269
  evidence: supportingBindings.map(evidenceRefFromBinding),
270
+ visuals: visualBriefs(index, visualIntent),
250
271
  status: "planned",
251
272
  }
252
273
  }
@@ -258,6 +279,7 @@ function riskObjectionSlide(index: number, narrative: NarrativeStateV1, evidence
258
279
  ]
259
280
  const challengedClaimIds = [...new Set(challengedClaimRefs.map((ref) => ref.claimId))]
260
281
  const challengedBindings = challengedClaimIds.flatMap((claimId) => evidenceByClaim.get(claimId) ?? [])
282
+ const visualIntent = visualIntentForRiskObjection(narrative, challengedBindings)
261
283
  return {
262
284
  index,
263
285
  title: "Risks And Objections",
@@ -265,7 +287,7 @@ function riskObjectionSlide(index: number, narrative: NarrativeStateV1, evidence
265
287
  narrativeRole: "risk",
266
288
  layout: "two-col",
267
289
  qa: true,
268
- components: ["box", "text-panel"],
290
+ components: componentsForVisualIntent(["box", "text-panel"], visualIntent),
269
291
  claimIds: challengedClaimIds,
270
292
  claimRefs: dedupeClaimRefs(challengedClaimRefs),
271
293
  evidenceBindingIds: challengedBindings.map((binding) => binding.id),
@@ -275,14 +297,17 @@ function riskObjectionSlide(index: number, narrative: NarrativeStateV1, evidence
275
297
  ...narrative.risks.slice(0, 3).map((risk) => risk.mitigation ? `${risk.text} Mitigation: ${risk.mitigation}` : risk.text),
276
298
  ...narrative.objections.slice(0, 3).map((objection) => objection.response ? `${objection.text} Response: ${objection.response}` : objection.text),
277
299
  ],
300
+ data: { visualIntent },
278
301
  },
279
302
  evidence: challengedBindings.map(evidenceRefFromBinding),
303
+ visuals: visualBriefs(index, visualIntent),
280
304
  status: "planned",
281
305
  }
282
306
  }
283
307
 
284
308
  function decisionAskSlide(index: number, narrative: NarrativeStateV1): SlideSpec {
285
309
  const askClaims = orderedClaims(narrative, (claim) => claim.kind === "ask" || claim.kind === "recommendation")
310
+ const visualIntent = visualIntentForStructuralSlide("steps", "Show the requested decision, owner, deadline, and consequence as an action sequence rather than a dense closing paragraph.")
286
311
  return {
287
312
  index,
288
313
  title: "Decision Ask",
@@ -300,8 +325,10 @@ function decisionAskSlide(index: number, narrative: NarrativeStateV1): SlideSpec
300
325
  narrative.decision.deadline ? `Deadline: ${narrative.decision.deadline}` : undefined,
301
326
  narrative.decision.consequenceOfNoDecision ? `If no decision: ${narrative.decision.consequenceOfNoDecision}` : undefined,
302
327
  ].filter((item): item is string => Boolean(item)),
328
+ data: { visualIntent },
303
329
  },
304
330
  evidence: [],
331
+ visuals: visualBriefs(index, visualIntent),
305
332
  status: "planned",
306
333
  }
307
334
  }
@@ -377,10 +404,99 @@ function hasClaimKind(claims: NarrativeClaim[], kinds: NarrativeClaim["kind"][])
377
404
  return claims.some((claim) => kinds.includes(claim.kind))
378
405
  }
379
406
 
380
- function claimComponents(claim: NarrativeClaim, bindings: NarrativeEvidenceBinding[]): string[] {
381
- if (bindings.some((binding) => binding.quote?.trim())) return ["box", "text-panel", "quote"]
382
- if (claim.kind === "recommendation" || claim.kind === "ask") return ["box", "text-panel", "steps"]
383
- return ["box", "text-panel"]
407
+ function claimComponents(claim: NarrativeClaim, bindings: NarrativeEvidenceBinding[], visualIntent: VisualIntent): string[] {
408
+ const base = bindings.some((binding) => binding.quote?.trim()) ? ["box", "text-panel", "quote"] : ["box", "text-panel"]
409
+ if ((claim.kind === "recommendation" || claim.kind === "ask") && visualIntent.kind === "text-only") return ["box", "text-panel", "steps"]
410
+ return componentsForVisualIntent(base, visualIntent)
411
+ }
412
+
413
+ function visualIntentForClaim(claim: NarrativeClaim, bindings: NarrativeEvidenceBinding[]): VisualIntent {
414
+ const evidenceBindingIds = bindings.map((binding) => binding.id)
415
+ const dataSignals = dataSignalsFromBindings(bindings)
416
+ if (bindings.length >= 2) {
417
+ return {
418
+ kind: "evidence-table",
419
+ component: "data-table",
420
+ rationale: "Compare multiple evidence bindings with source, support scope, and caveat columns so the slide is not a bullet stack.",
421
+ dataSignals,
422
+ evidenceBindingIds,
423
+ }
424
+ }
425
+ if (dataSignals.length > 0) {
426
+ return {
427
+ kind: "metric-stat",
428
+ component: "stat-card",
429
+ rationale: "Promote the strongest quantitative evidence signal into a metric card, with the source quote retained for traceability.",
430
+ dataSignals,
431
+ evidenceBindingIds,
432
+ }
433
+ }
434
+ if (claim.kind === "recommendation" || claim.kind === "ask") {
435
+ return {
436
+ kind: "steps",
437
+ component: "steps",
438
+ rationale: "Show the recommendation as phased actions or decision gates rather than a paragraph.",
439
+ dataSignals,
440
+ evidenceBindingIds,
441
+ }
442
+ }
443
+ return {
444
+ kind: "text-only",
445
+ component: "box",
446
+ rationale: "No quantified or multi-source visual signal is available; use semantic evidence boxes and keep boundaries explicit.",
447
+ dataSignals,
448
+ evidenceBindingIds,
449
+ }
450
+ }
451
+
452
+ function visualIntentForSupportingLogic(claims: NarrativeClaim[], bindings: NarrativeEvidenceBinding[]): VisualIntent {
453
+ const dataSignals = dataSignalsFromBindings(bindings)
454
+ return {
455
+ kind: claims.length >= 3 || bindings.length >= 2 ? "comparison-grid" : "evidence-table",
456
+ component: "data-table",
457
+ rationale: "Organize supporting claims as a comparison grid with evidence status and boundaries, avoiding a long undifferentiated bullet list.",
458
+ dataSignals,
459
+ evidenceBindingIds: bindings.map((binding) => binding.id),
460
+ }
461
+ }
462
+
463
+ function visualIntentForRiskObjection(narrative: NarrativeStateV1, bindings: NarrativeEvidenceBinding[]): VisualIntent {
464
+ return {
465
+ kind: "risk-matrix",
466
+ component: "data-table",
467
+ rationale: "Pair each risk or objection with mitigation or response in a compact matrix so caveats stay visible without becoming prose-heavy.",
468
+ dataSignals: [...narrative.risks.map((risk) => risk.severity), ...narrative.objections.map((objection) => objection.priority)].filter(Boolean),
469
+ evidenceBindingIds: bindings.map((binding) => binding.id),
470
+ }
471
+ }
472
+
473
+ function visualIntentForStructuralSlide(kind: Extract<VisualIntentKind, "hero" | "toc" | "steps">, rationale: string): VisualIntent {
474
+ return { kind, component: kind === "toc" ? "toc" : kind === "steps" ? "steps" : "hero", rationale, dataSignals: [], evidenceBindingIds: [] }
475
+ }
476
+
477
+ function componentsForVisualIntent(base: string[], visualIntent: VisualIntent): string[] {
478
+ const next = [...base]
479
+ if (visualIntent.component && !next.includes(visualIntent.component)) next.push(visualIntent.component)
480
+ return next
481
+ }
482
+
483
+ function visualBriefs(slideIndex: number, visualIntent: VisualIntent): VisualBrief[] {
484
+ return [{
485
+ id: `visual:${slideIndex}:${visualIntent.kind}`,
486
+ purpose: visualIntent.kind,
487
+ brief: `${visualIntent.rationale} Use ${visualIntent.component} and preserve cited evidence boundaries${visualIntent.dataSignals.length > 0 ? `; visible signals: ${visualIntent.dataSignals.slice(0, 4).join(", ")}.` : "."}`,
488
+ }]
489
+ }
490
+
491
+ function dataSignalsFromBindings(bindings: NarrativeEvidenceBinding[]): string[] {
492
+ const signals = bindings.flatMap((binding) => numericSignals([binding.quote, binding.supportScope, binding.source, binding.location].filter(Boolean).join(" ")))
493
+ return [...new Set(signals)].slice(0, 6)
494
+ }
495
+
496
+ function numericSignals(text: string): string[] {
497
+ return [...text.matchAll(/(?:[$€£¥]\s*)?\d+(?:\.\d+)?\s*(?:%|bps|x|k|m|bn|billion|million|year|years|yr|yrs)?/gi)]
498
+ .map((match) => match[0].trim())
499
+ .filter(Boolean)
384
500
  }
385
501
 
386
502
  function claimBullets(claim: NarrativeClaim, bindings: NarrativeEvidenceBinding[]): string[] {
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.