@cyber-dash-tech/revela 0.15.0 → 0.15.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/qa/checks.ts CHANGED
@@ -1,17 +1,18 @@
1
1
  /**
2
2
  * lib/qa/checks.ts
3
3
  *
4
- * Geometry-based layout quality checks. The active default path only checks
5
- * overflow; softer visual heuristics are kept here for future opt-in use.
4
+ * Browser-measured slide quality checks. The active default path checks hard
5
+ * artifact failures plus a content-substance warning; older soft visual
6
+ * heuristics are kept here for future opt-in use.
6
7
  *
7
- * Dimension 1: Overflow elements exceed canvas bounds (correctness)
8
- * Dimension 2: Balance content centroid & distribution (fill, sparsity)
9
- * Dimension 3: Rhythm spacing regularity & internal whitespace
8
+ * Dimension 1: Canvas exact 1920x1080 slide/canvas size
9
+ * Dimension 2: Overflow scrollbars, element overflow, and text clipping
10
+ * Dimension 3: Density claim/evidence/source substance warnings
10
11
  * Dimension 4: Compliance — CSS classes match the active design's vocabulary
11
12
  *
12
13
  * All checks operate on SlideMetrics produced by measure.ts.
13
- * Dimensions 1–4 are geometry-only (no CSS class-name assumptions).
14
- * Dimension 5 requires an allowedClasses vocabulary from the design system.
14
+ * Design component compliance requires an allowedClasses vocabulary from the
15
+ * design system and is run by combined artifact QA.
15
16
  */
16
17
 
17
18
  import type { SlideMetrics, ElementInfo, Rect } from "./measure"
@@ -22,9 +23,10 @@ import { CANVAS_W, CANVAS_H } from "./measure"
22
23
  export type IssueSeverity = "error" | "warning" | "info"
23
24
 
24
25
  export interface LayoutIssue {
25
- type: "overflow" | "balance" | "symmetry" | "rhythm" | "compliance"
26
+ type: "canvas" | "scrollbar" | "overflow" | "text_overflow" | "density" | "balance" | "symmetry" | "rhythm" | "compliance"
26
27
  /** Sub-category within the dimension */
27
- sub?: "centroid_offset" | "bottom_gap" | "sparse"
28
+ sub?: "size_mismatch" | "page_scroll" | "text_clipped" | "thin_content"
29
+ | "centroid_offset" | "bottom_gap" | "sparse"
28
30
  | "height_mismatch" | "density_mismatch"
29
31
  | "gap_variance"
30
32
  | "unknown_class" | "novel_css_rule"
@@ -75,6 +77,9 @@ const T = {
75
77
  GAP_MIN_MEAN: 10,
76
78
  // Rhythm — min children count to check gap variance
77
79
  GAP_MIN_CHILDREN: 3,
80
+ CANVAS_TOLERANCE: 1,
81
+ DENSITY_MIN_TEXT_POINTS: 70,
82
+ DENSITY_MIN_UNITS: 2,
78
83
  }
79
84
 
80
85
  // ── Geometry helpers ──────────────────────────────────────────────────────────
@@ -172,6 +177,42 @@ function collectLeaves(el: ElementInfo): ElementInfo[] {
172
177
 
173
178
  // ── Dimension 1: Overflow ─────────────────────────────────────────────────────
174
179
 
180
+ function checkCanvas(metrics: SlideMetrics): LayoutIssue[] {
181
+ const issues: LayoutIssue[] = []
182
+ const tol = T.CANVAS_TOLERANCE
183
+ const canvasBad = Math.abs(metrics.canvasRect.width - CANVAS_W) > tol || Math.abs(metrics.canvasRect.height - CANVAS_H) > tol
184
+ const slideBad = Math.abs(metrics.slideRect.width - CANVAS_W) > tol || Math.abs(metrics.slideRect.height - CANVAS_H) > tol
185
+
186
+ if (canvasBad || slideBad) {
187
+ issues.push({
188
+ type: "canvas",
189
+ sub: "size_mismatch",
190
+ severity: "error",
191
+ detail: `Slide and canvas must render exactly ${CANVAS_W}x${CANVAS_H}px. Measured slide ${Math.round(metrics.slideRect.width)}x${Math.round(metrics.slideRect.height)}px, canvas ${Math.round(metrics.canvasRect.width)}x${Math.round(metrics.canvasRect.height)}px.`,
192
+ data: {
193
+ expectedWidth: CANVAS_W,
194
+ expectedHeight: CANVAS_H,
195
+ slideWidth: Math.round(metrics.slideRect.width),
196
+ slideHeight: Math.round(metrics.slideRect.height),
197
+ canvasWidth: Math.round(metrics.canvasRect.width),
198
+ canvasHeight: Math.round(metrics.canvasRect.height),
199
+ },
200
+ })
201
+ }
202
+
203
+ return issues
204
+ }
205
+
206
+ function checkScrollbars(metrics: SlideMetrics): LayoutIssue[] {
207
+ if (!metrics.hasScrollbars) return []
208
+ return [{
209
+ type: "scrollbar",
210
+ sub: "page_scroll",
211
+ severity: "error",
212
+ detail: "Rendered slide/page has scrollbars at 1920x1080. Deck slides must fit the fixed canvas without document, body, or slide scrolling.",
213
+ }]
214
+ }
215
+
175
216
  /**
176
217
  * Check 1: Overflow — elements extending beyond canvas boundaries.
177
218
  * Hard correctness check; applies to all slide types.
@@ -205,6 +246,45 @@ function checkOverflow(metrics: SlideMetrics): LayoutIssue[] {
205
246
  return issues
206
247
  }
207
248
 
249
+ function checkTextOverflow(metrics: SlideMetrics): LayoutIssue[] {
250
+ const issues: LayoutIssue[] = []
251
+
252
+ function walk(els: ElementInfo[]) {
253
+ for (const el of els) {
254
+ if (!el.visible) continue
255
+ if (el.textOverflow) {
256
+ issues.push({
257
+ type: "text_overflow",
258
+ sub: "text_clipped",
259
+ severity: "error",
260
+ detail: `Text appears clipped inside \`${el.selector}\`${el.text ? `: "${el.text}"` : ""}. Increase container size, reduce copy, or adjust font/line-height.`,
261
+ data: { selector: el.selector, text: el.text ?? "" },
262
+ })
263
+ }
264
+ if (el.children.length > 0) walk(el.children)
265
+ }
266
+ }
267
+
268
+ walk(metrics.elements)
269
+ return issues
270
+ }
271
+
272
+ function checkContentDensity(metrics: SlideMetrics): LayoutIssue[] {
273
+ if (!metrics.slideQa) return []
274
+ const { bodyTextPoints, contentUnits, supportReferences } = metrics.contentStats
275
+ const thinText = bodyTextPoints < T.DENSITY_MIN_TEXT_POINTS
276
+ const thinUnits = contentUnits < T.DENSITY_MIN_UNITS
277
+ if (!thinText && !thinUnits) return []
278
+
279
+ return [{
280
+ type: "density",
281
+ sub: "thin_content",
282
+ severity: "warning",
283
+ detail: `Content slide may not have enough claim/evidence substance: ${bodyTextPoints} non-title text point(s), ${contentUnits} recognizable content unit(s), ${supportReferences} evidence/source/claim reference(s). Add concrete claim points, evidence, metrics, chart/table support, or source/caveat text if this is not a deliberate focus slide.`,
284
+ data: { bodyTextPoints, contentUnits, supportReferences },
285
+ }]
286
+ }
287
+
208
288
  // ── Dimension 2: Balance ──────────────────────────────────────────────────────
209
289
 
210
290
  /**
@@ -542,7 +622,13 @@ export function runChecks(
542
622
  const slides: SlideReport[] = []
543
623
 
544
624
  for (const metrics of allMetrics) {
545
- const issues: LayoutIssue[] = [...checkOverflow(metrics)]
625
+ const issues: LayoutIssue[] = [
626
+ ...checkCanvas(metrics),
627
+ ...checkScrollbars(metrics),
628
+ ...checkOverflow(metrics),
629
+ ...checkTextOverflow(metrics),
630
+ ...checkContentDensity(metrics),
631
+ ]
546
632
 
547
633
  slides.push({ index: metrics.index, title: metrics.title, issues })
548
634
  }
@@ -605,7 +691,11 @@ export function formatReport(report: QAReport): string {
605
691
  `### Action Required`,
606
692
  ``,
607
693
  `Please fix the above hard-error issues in the HTML file. For each issue type:`,
694
+ `- **canvas**: ensure each slide and .slide-canvas render exactly 1920x1080px, not merely any 16:9 size.`,
695
+ `- **scrollbar**: remove document/body/slide scrolling; content must fit inside the fixed 1920x1080 canvas.`,
608
696
  `- **overflow**: reduce font size, padding, or content amount for the affected element.`,
697
+ `- **text_overflow**: increase the text container size, reduce copy, or adjust font/line-height so text is not clipped.`,
698
+ `- **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.`,
609
699
  `- **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.`,
610
700
  `- **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.`,
611
701
  )
package/lib/qa/index.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * lib/qa/index.ts
3
3
  *
4
- * Public entry point for hard-error slide QA.
5
- * Runs overflow measurement only. Static design compliance is handled by a
6
- * separate post-write/post-patch/post-edit hook.
4
+ * Public entry point for browser-rendered slide QA.
5
+ * Combined artifact QA, including contract and component compliance, lives in
6
+ * `lib/qa/artifact.ts`.
7
7
  */
8
8
 
9
9
  import { measureSlides } from "./measure"
@@ -18,12 +18,14 @@ export type { RunChecksOptions } from "./checks"
18
18
  * Run hard-error QA on `htmlFilePath`.
19
19
  *
20
20
  * 1. Opens the file in headless Chrome (puppeteer-core)
21
- * 2. Measures each .slide element's geometry + CSS class definitions
22
- * 3. Runs hard-error overflow checks only
21
+ * 2. Measures each .slide element's geometry, scroll state, text clipping,
22
+ * content-density signals, and CSS class definitions
23
+ * 3. Runs browser QA checks for exact 1920x1080 slides, scrollbars, overflow,
24
+ * text clipping, and claim/evidence density warnings
23
25
  * 4. Returns a structured QAReport
24
26
  *
25
27
  * The optional `vocabulary` argument is retained for backward compatibility;
26
- * compliance is intentionally not part of hard-error QA.
28
+ * design compliance is handled by combined artifact QA.
27
29
  *
28
30
  * Throws if the file cannot be opened or Chrome is not found.
29
31
  */
package/lib/qa/measure.ts CHANGED
@@ -49,6 +49,19 @@ export interface ElementInfo {
49
49
  children: ElementInfo[]
50
50
  /** all CSS class names on this element */
51
51
  classList: string[]
52
+ /** visible text excerpt for text overflow diagnostics */
53
+ text?: string
54
+ /** whether text content is clipped inside this element */
55
+ textOverflow?: boolean
56
+ }
57
+
58
+ export interface SlideContentStats {
59
+ /** Non-title effective text points: English words + CJK characters. */
60
+ bodyTextPoints: number
61
+ /** Recognizable semantic content units such as boxes, cards, evidence, charts, tables, media, metrics, bullets. */
62
+ contentUnits: number
63
+ /** Evidence/source/caveat-like references visible on the slide. */
64
+ supportReferences: number
52
65
  }
53
66
 
54
67
  export interface SlideMetrics {
@@ -65,10 +78,16 @@ export interface SlideMetrics {
65
78
  slideQa: boolean
66
79
  /** bounding box of the slide-canvas element itself (post-scale) */
67
80
  canvasRect: Rect
81
+ /** bounding box of the .slide element itself (post-scale) */
82
+ slideRect: Rect
83
+ /** whether document/body/slide has scrollbars at 1920x1080 */
84
+ hasScrollbars: boolean
68
85
  /** top-level visible children of .slide-canvas */
69
86
  elements: ElementInfo[]
70
87
  /** union bounding box of all visible leaf elements */
71
88
  contentRect: Rect
89
+ /** text/content-density signals for content slides */
90
+ contentStats: SlideContentStats
72
91
  }
73
92
 
74
93
  /**
@@ -207,6 +226,29 @@ export async function measureSlides(htmlFilePath: string): Promise<MeasurementRe
207
226
  visible: boolean
208
227
  children: EI[]
209
228
  classList: string[]
229
+ text?: string
230
+ textOverflow?: boolean
231
+ }
232
+
233
+ function textPoints(text: string): number {
234
+ const normalized = text.replace(/\s+/g, " ").trim()
235
+ if (!normalized) return 0
236
+ const cjk = (normalized.match(/[\u3400-\u9fff\uf900-\ufaff]/g) || []).length
237
+ const words = (normalized.replace(/[\u3400-\u9fff\uf900-\ufaff]/g, " ").match(/[A-Za-z0-9]+(?:[-'][A-Za-z0-9]+)*/g) || []).length
238
+ return cjk + words
239
+ }
240
+
241
+ function isSemanticContentUnit(el: Element): boolean {
242
+ const tag = el.tagName.toLowerCase()
243
+ if (["li", "table", "figure", "img", "svg", "canvas", "blockquote"].includes(tag)) return true
244
+ const cls = Array.from(el.classList).join(" ")
245
+ return /\b(box|card|claim|evidence|source|caveat|metric|stat|quote|media|chart|echart|table|step|roadmap|toc-item|bullet)\b/i.test(cls)
246
+ }
247
+
248
+ function isSupportReference(el: Element): boolean {
249
+ const text = (el.textContent || "").replace(/\s+/g, " ").trim()
250
+ const cls = Array.from(el.classList).join(" ")
251
+ return /\b(evidence|source|caveat|claim|support|citation|note)\b/i.test(cls) || /\b(source|evidence|caveat|claim|来源|证据|出处|风险|假设)\b/i.test(text)
210
252
  }
211
253
 
212
254
  function collectChildren(
@@ -220,6 +262,11 @@ export async function measureSlides(htmlFilePath: string): Promise<MeasurementRe
220
262
  for (const child of Array.from(el.children)) {
221
263
  if (!isVisible(child)) continue
222
264
  const rawR = child.getBoundingClientRect()
265
+ const text = (child.textContent || "").replace(/\s+/g, " ").trim()
266
+ const textOverflow = textPoints(text) > 0 && (
267
+ (child as HTMLElement).scrollHeight > (child as HTMLElement).clientHeight + 2 ||
268
+ (child as HTMLElement).scrollWidth > (child as HTMLElement).clientWidth + 2
269
+ )
223
270
  const cls = child.className || ""
224
271
  if (
225
272
  typeof cls === "string" &&
@@ -235,6 +282,8 @@ export async function measureSlides(htmlFilePath: string): Promise<MeasurementRe
235
282
  rect: relR,
236
283
  visible: true,
237
284
  classList: Array.from(child.classList),
285
+ text: text.slice(0, 160),
286
+ textOverflow,
238
287
  children: collectChildren(child, offsetTop, offsetLeft, depth + 1),
239
288
  })
240
289
  }
@@ -273,6 +322,7 @@ export async function measureSlides(htmlFilePath: string): Promise<MeasurementRe
273
322
  if (!canvas) return null
274
323
 
275
324
  const canvasRaw = canvas.getBoundingClientRect()
325
+ const slideRaw = (slide as HTMLElement).getBoundingClientRect()
276
326
  // Use canvas top-left as the coordinate origin
277
327
  const offsetTop = canvasRaw.top
278
328
  const offsetLeft = canvasRaw.left
@@ -286,8 +336,40 @@ export async function measureSlides(htmlFilePath: string): Promise<MeasurementRe
286
336
  height: canvasRaw.height,
287
337
  }
288
338
 
339
+ const slideRect = {
340
+ left: 0,
341
+ top: 0,
342
+ right: slideRaw.width,
343
+ bottom: slideRaw.height,
344
+ width: slideRaw.width,
345
+ height: slideRaw.height,
346
+ }
347
+
289
348
  const elements = collectChildren(canvas, offsetTop, offsetLeft)
290
349
 
350
+ let bodyTextPoints = 0
351
+ let contentUnits = 0
352
+ let supportReferences = 0
353
+ for (const el of Array.from(canvas.querySelectorAll("*"))) {
354
+ if (!isVisible(el)) continue
355
+ if (/^H[1-2]$/.test(el.tagName)) continue
356
+ const text = (el.textContent || "").replace(/\s+/g, " ").trim()
357
+ if (text) bodyTextPoints += textPoints(text)
358
+ if (isSemanticContentUnit(el)) contentUnits++
359
+ if (isSupportReference(el)) supportReferences++
360
+ }
361
+
362
+ const doc = document.documentElement
363
+ const body = document.body
364
+ const slideEl = slide as HTMLElement
365
+ const hasScrollbars =
366
+ doc.scrollWidth > window.innerWidth + 2 ||
367
+ doc.scrollHeight > window.innerHeight + 2 ||
368
+ body.scrollWidth > window.innerWidth + 2 ||
369
+ body.scrollHeight > window.innerHeight + 2 ||
370
+ slideEl.scrollWidth > slideEl.clientWidth + 2 ||
371
+ slideEl.scrollHeight > slideEl.clientHeight + 2
372
+
291
373
  const titleEl = canvas.querySelector("h1, h2")
292
374
  const title = titleEl
293
375
  ? (titleEl.textContent || "").replace(/\s+/g, " ").trim().slice(0, 80)
@@ -298,8 +380,11 @@ export async function measureSlides(htmlFilePath: string): Promise<MeasurementRe
298
380
  title,
299
381
  slideQa,
300
382
  canvasRect,
383
+ slideRect,
384
+ hasScrollbars,
301
385
  elements,
302
386
  contentRect: unionRect(elements),
387
+ contentStats: { bodyTextPoints, contentUnits, supportReferences },
303
388
  }
304
389
  },
305
390
  idx
@@ -22,6 +22,10 @@ export interface OpenRefineDeckResult {
22
22
  mode: RefineMode
23
23
  }
24
24
 
25
+ export interface EnsureRefineDeckOpenResult extends OpenRefineDeckResult {
26
+ skippedReason?: "live-session"
27
+ }
28
+
25
29
  export interface OpenRefineDeckOptions {
26
30
  client: any
27
31
  sessionID: string
@@ -32,6 +36,21 @@ export interface OpenRefineDeckOptions {
32
36
  }
33
37
 
34
38
  export function openRefineDeck(target: string, options: OpenRefineDeckOptions): OpenRefineDeckResult {
39
+ return openRefineDeckInternal(target, options, { skipLiveSession: false })
40
+ }
41
+
42
+ export function ensureRefineDeckOpenForChange(
43
+ target: string,
44
+ options: OpenRefineDeckOptions,
45
+ ): EnsureRefineDeckOpenResult {
46
+ return openRefineDeckInternal(target, options, { skipLiveSession: true })
47
+ }
48
+
49
+ function openRefineDeckInternal(
50
+ target: string,
51
+ options: OpenRefineDeckOptions,
52
+ behavior: { skipLiveSession: boolean },
53
+ ): EnsureRefineDeckOpenResult {
35
54
  const deck = resolveEditableDeck(options.workspaceRoot, target)
36
55
  const preflight = ensureEditableDeckState(options.workspaceRoot, deck)
37
56
  assertDeckHtmlContractValid(options.workspaceRoot, deck.absoluteFile)
@@ -53,7 +72,7 @@ export function openRefineDeck(target: string, options: OpenRefineDeckOptions):
53
72
  mode,
54
73
  })
55
74
  const url = `${refineServer.baseUrl}/refine?token=${encodeURIComponent(session.token)}`
56
- const shouldOpen = options.openBrowser !== false
75
+ const shouldOpen = options.openBrowser !== false && !(behavior.skipLiveSession && session.live)
57
76
  if (shouldOpen) (options.openUrl ?? openUrl)(url)
58
77
 
59
78
  return {
@@ -66,5 +85,6 @@ export function openRefineDeck(target: string, options: OpenRefineDeckOptions):
66
85
  liveSession: session.live,
67
86
  openedBrowser: shouldOpen,
68
87
  mode,
88
+ skippedReason: behavior.skipLiveSession && session.live ? "live-session" : undefined,
69
89
  }
70
90
  }
@@ -634,6 +634,11 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
634
634
  .resize-handle:hover::before, body.resizing .resize-handle::before { height: 52px; background: #94a3b8; box-shadow: 0 0 0 4px rgba(148,163,184,.16); }
635
635
  iframe { display: block; width: 100%; height: 100%; border: 0; background: #fff; }
636
636
  .hitbox { position: absolute; inset: 0; z-index: 2; cursor: crosshair; background: transparent; }
637
+ .deck-nav { position: absolute; left: 50%; bottom: 18px; z-index: 4; display: inline-flex; align-items: center; gap: 8px; transform: translateX(-50%); padding: 7px; border: 1px solid rgba(148,163,184,.42); border-radius: 999px; background: rgba(15,23,42,.76); box-shadow: 0 16px 44px rgba(15,23,42,.24); backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); pointer-events: auto; }
638
+ .deck-nav button { width: auto; min-width: 84px; padding: 8px 12px; border-radius: 999px; background: rgba(255,255,255,.12); color: #fff; box-shadow: none; font-size: 12px; font-weight: 900; }
639
+ .deck-nav button:hover:not(:disabled) { background: rgba(255,255,255,.22); }
640
+ .deck-nav button:disabled { opacity: .38; }
641
+ .deck-nav-status { min-width: 76px; color: #e2e8f0; font-size: 12px; font-weight: 900; text-align: center; font-variant-numeric: tabular-nums; }
637
642
  aside { display: flex; flex-direction: column; gap: 16px; padding: 20px; background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); overflow: auto; }
638
643
  h1 { margin: 0; font-size: 18px; line-height: 1.2; letter-spacing: -.01em; color: #0f172a; }
639
644
  .wordmark { font-family: Garamond, "Iowan Old Style", Georgia, serif; font-size: 21px; letter-spacing: .08em; font-weight: 600; }
@@ -681,12 +686,12 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
681
686
  button { width: 100%; padding: 12px 14px; border: 0; border-radius: 12px; background: #2563eb; color: #ffffff; font-weight: 800; cursor: pointer; box-shadow: 0 10px 24px rgba(37,99,235,.22); }
682
687
  button:disabled { cursor: not-allowed; opacity: .5; }
683
688
  .status { min-height: 20px; color: #475569; font-size: 13px; line-height: 1.45; }
684
- @media (max-width: 900px) { .app { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1fr) auto; } .resize-handle { display: none; } aside { max-height: 48vh; } }
689
+ @media (max-width: 900px) { .app { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1fr) auto; } .resize-handle { display: none; } aside { max-height: 48vh; } .deck-nav { bottom: 10px; } }
685
690
  </style>
686
691
  </head>
687
692
  <body>
688
693
  <main class="app">
689
- <section class="preview"><iframe id="deck" src="/deck?token=${encodeURIComponent(token)}"></iframe><div id="hitbox" class="hitbox" aria-label="Deck element selection layer"></div></section>
694
+ <section class="preview"><iframe id="deck" src="/deck?token=${encodeURIComponent(token)}"></iframe><div id="hitbox" class="hitbox" aria-label="Deck element selection layer"></div><nav class="deck-nav" aria-label="Deck navigation"><button id="deckPrev" type="button" title="Previous slide (ArrowLeft / ArrowUp / PageUp)">Previous</button><div id="deckCounter" class="deck-nav-status" aria-live="polite">-- / --</div><button id="deckNext" type="button" title="Next slide (ArrowRight / ArrowDown / Space / PageDown)">Next</button></nav></section>
690
695
  <div id="resizeHandle" class="resize-handle" role="separator" aria-label="Resize editor panel" aria-orientation="vertical" title="Drag to resize editor. Double-click to reset."></div>
691
696
  <aside>
692
697
  <div>
@@ -752,6 +757,8 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
752
757
  bound: false,
753
758
  commentRange: null,
754
759
  resizeDrag: null,
760
+ deckSlideIndex: 0,
761
+ deckSlideCount: 0,
755
762
  mode: defaultMode === 'inspect' ? 'inspect' : 'edit',
756
763
  inspecting: false,
757
764
  activeInspectRequestId: '',
@@ -762,6 +769,9 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
762
769
  frame: null,
763
770
  hitbox: null,
764
771
  resizeHandle: null,
772
+ deckPrev: null,
773
+ deckNext: null,
774
+ deckCounter: null,
765
775
  selectionSummary: null,
766
776
  selectionChips: null,
767
777
  editTab: null,
@@ -792,6 +802,9 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
792
802
  els.frame = document.getElementById('deck');
793
803
  els.hitbox = document.getElementById('hitbox');
794
804
  els.resizeHandle = document.getElementById('resizeHandle');
805
+ els.deckPrev = document.getElementById('deckPrev');
806
+ els.deckNext = document.getElementById('deckNext');
807
+ els.deckCounter = document.getElementById('deckCounter');
795
808
  els.selectionSummary = document.getElementById('selectionSummary');
796
809
  els.selectionChips = document.getElementById('selectionChips');
797
810
  els.editTab = document.getElementById('editTab');
@@ -808,7 +821,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
808
821
 
809
822
  els.inspectLanguage = document.getElementById('inspectLanguage');
810
823
 
811
- if (!els.frame || !els.hitbox || !els.resizeHandle || !els.selectionSummary || !els.selectionChips || !els.editTab || !els.inspectTab || !els.editPanel || !els.inspectPanel || !els.comment || !els.commentThread || !els.send || !els.inspectButton || !els.inspectLanguage || !els.inspectCards || !els.inspectStale || !els.status) {
824
+ if (!els.frame || !els.hitbox || !els.resizeHandle || !els.deckPrev || !els.deckNext || !els.deckCounter || !els.selectionSummary || !els.selectionChips || !els.editTab || !els.inspectTab || !els.editPanel || !els.inspectPanel || !els.comment || !els.commentThread || !els.send || !els.inspectButton || !els.inspectLanguage || !els.inspectCards || !els.inspectStale || !els.status) {
812
825
  throw new Error('Editor boot failed: required DOM nodes are missing.');
813
826
  }
814
827
 
@@ -828,7 +841,18 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
828
841
  state.bound = true;
829
842
  els.frame.addEventListener('load', initFrame);
830
843
  document.addEventListener('keydown', (event) => {
831
- if (event.key === 'Escape') clearHover();
844
+ if (event.key === 'Escape') {
845
+ clearHover();
846
+ return;
847
+ }
848
+ if (isTextInputTarget(event.target) || event.metaKey || event.ctrlKey || event.altKey) return;
849
+ if (['ArrowDown', 'ArrowRight', ' ', 'PageDown'].includes(event.key)) {
850
+ event.preventDefault();
851
+ nextDeckSlide();
852
+ } else if (['ArrowUp', 'ArrowLeft', 'PageUp'].includes(event.key)) {
853
+ event.preventDefault();
854
+ prevDeckSlide();
855
+ }
832
856
  });
833
857
  els.comment.addEventListener('input', () => {
834
858
  saveCommentRange();
@@ -854,6 +878,8 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
854
878
  }, { passive: false });
855
879
  els.resizeHandle.addEventListener('pointerdown', startEditorResize);
856
880
  els.resizeHandle.addEventListener('dblclick', resetEditorWidth);
881
+ els.deckPrev.addEventListener('click', prevDeckSlide);
882
+ els.deckNext.addEventListener('click', nextDeckSlide);
857
883
  els.send.addEventListener('click', sendComment);
858
884
  els.inspectButton.addEventListener('click', inspectCurrentSelection);
859
885
  els.inspectLanguage.addEventListener('change', () => {
@@ -940,6 +966,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
940
966
  renderReferenceOutlines();
941
967
  }, true);
942
968
  const slides = getSlides(doc);
969
+ syncDeckNavigation();
943
970
  updateSendState();
944
971
  if (state.pendingRefreshMessage) {
945
972
  state.pendingRefreshMessage = false;
@@ -952,6 +979,102 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
952
979
  }
953
980
  }
954
981
 
982
+ function isTextInputTarget(target) {
983
+ if (!target || !(target instanceof Element)) return false;
984
+ const tag = target.tagName.toLowerCase();
985
+ return tag === 'input' || tag === 'textarea' || tag === 'select' || target.isContentEditable || Boolean(target.closest('[contenteditable="true"]'));
986
+ }
987
+
988
+ function syncDeckNavigation() {
989
+ try {
990
+ const doc = els.frame.contentDocument;
991
+ const slides = doc ? getSlides(doc) : [];
992
+ state.deckSlideCount = slides.length;
993
+ state.deckSlideIndex = Math.max(0, Math.min(state.deckSlideIndex, Math.max(0, slides.length - 1)));
994
+ updateDeckNavControls();
995
+ } catch {
996
+ state.deckSlideCount = 0;
997
+ state.deckSlideIndex = 0;
998
+ updateDeckNavControls();
999
+ }
1000
+ }
1001
+
1002
+ function updateDeckNavControls() {
1003
+ const total = state.deckSlideCount;
1004
+ const current = total > 0 ? state.deckSlideIndex + 1 : 0;
1005
+ els.deckCounter.textContent = total > 0 ? current + ' / ' + total : '-- / --';
1006
+ els.deckPrev.disabled = total <= 1 || state.deckSlideIndex <= 0;
1007
+ els.deckNext.disabled = total <= 1 || state.deckSlideIndex >= total - 1;
1008
+ }
1009
+
1010
+ function prevDeckSlide() {
1011
+ goToDeckSlide(state.deckSlideIndex - 1);
1012
+ }
1013
+
1014
+ function nextDeckSlide() {
1015
+ goToDeckSlide(state.deckSlideIndex + 1);
1016
+ }
1017
+
1018
+ function goToDeckSlide(index) {
1019
+ try {
1020
+ const doc = els.frame.contentDocument;
1021
+ const win = els.frame.contentWindow;
1022
+ if (!doc || !win) return;
1023
+ const slides = getSlides(doc);
1024
+ if (!slides.length) {
1025
+ syncDeckNavigation();
1026
+ return;
1027
+ }
1028
+ const clamped = Math.max(0, Math.min(slides.length - 1, index));
1029
+ const nav = win.RevelaDeckNav;
1030
+ let handled = false;
1031
+ if (nav && typeof nav.goTo === 'function') {
1032
+ try {
1033
+ nav.goTo(clamped);
1034
+ handled = true;
1035
+ } catch {}
1036
+ } else if (nav && clamped > state.deckSlideIndex && typeof nav.next === 'function') {
1037
+ try {
1038
+ nav.next();
1039
+ handled = true;
1040
+ } catch {}
1041
+ } else if (nav && clamped < state.deckSlideIndex && typeof nav.prev === 'function') {
1042
+ try {
1043
+ nav.prev();
1044
+ handled = true;
1045
+ } catch {}
1046
+ }
1047
+ if (!handled) applyFallbackDeckNavigation(win, doc, slides, clamped);
1048
+ state.deckSlideIndex = clamped;
1049
+ updateDeckNavControls();
1050
+ renderHoverOutline(state.hoverEl);
1051
+ renderReferenceOutlines();
1052
+ } catch (error) {
1053
+ reportError(error);
1054
+ }
1055
+ }
1056
+
1057
+ function applyFallbackDeckNavigation(win, doc, slides, index) {
1058
+ const target = slides[index];
1059
+ const usesOverlaySlides = slides.some((slide) => {
1060
+ const style = win.getComputedStyle(slide);
1061
+ return style.position === 'absolute' || style.position === 'fixed' || style.opacity === '0' || slide.style.opacity !== '';
1062
+ });
1063
+ if (usesOverlaySlides) {
1064
+ slides.forEach((slide, i) => {
1065
+ slide.style.opacity = i === index ? '1' : '0';
1066
+ slide.style.pointerEvents = i === index ? 'auto' : 'none';
1067
+ });
1068
+ win.scrollTo?.(0, 0);
1069
+ return;
1070
+ }
1071
+ if (target && typeof target.scrollIntoView === 'function') {
1072
+ target.scrollIntoView({ block: 'start', inline: 'nearest', behavior: 'auto' });
1073
+ return;
1074
+ }
1075
+ doc.defaultView?.scrollTo?.(0, index * win.innerHeight);
1076
+ }
1077
+
955
1078
  function startDeckVersionPolling() {
956
1079
  pollDeckVersion();
957
1080
  window.setInterval(pollDeckVersion, 2000);
@@ -98,6 +98,7 @@ export type WorkspaceActionType =
98
98
  | "research.gap_closed"
99
99
  | "narrative.upserted"
100
100
  | "deck.plan_compiled"
101
+ | "deck.plan_confirmed"
101
102
  | "artifact.coverage_backfilled"
102
103
  | "evidence.candidate_generated"
103
104
  | "evidence.binding_applied"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyber-dash-tech/revela",
3
- "version": "0.15.0",
3
+ "version": "0.15.1",
4
4
  "description": "OpenCode plugin that turns AI into an HTML slide deck generator",
5
5
  "type": "module",
6
6
  "main": "./index.ts",