@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.
- package/lib/commands/narrative.ts +13 -4
- package/lib/commands/review.ts +14 -10
- 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 +125 -9
- package/lib/qa/checks.ts +206 -5
- package/lib/qa/measure.ts +63 -1
- package/lib/refine/server.ts +157 -20
- package/package.json +1 -1
- package/skill/SKILL.md +6 -1
- package/tools/narrative-view.ts +16 -0
|
@@ -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
|
-
|
|
382
|
-
if (claim.kind === "recommendation" || claim.kind === "ask") return ["box", "text-panel", "steps"]
|
|
383
|
-
return
|
|
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
|
-
|
|
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.
|