rikiki-deck 0.6.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/.claude/skills/rikiki-debug/SKILL.md +17 -7
  2. package/.claude/skills/rikiki-deck/SKILL.md +362 -77
  3. package/.claude/skills/rikiki-theme/SKILL.md +1 -1
  4. package/README.md +69 -50
  5. package/bin/lib/assemble.mjs +154 -0
  6. package/bin/lib/box-geometry.mjs +66 -0
  7. package/bin/lib/browser.mjs +302 -0
  8. package/bin/lib/check-api.d.ts +28 -0
  9. package/bin/lib/check-api.mjs +6 -0
  10. package/bin/lib/check-plugins.mjs +228 -0
  11. package/bin/lib/check.mjs +1347 -0
  12. package/bin/lib/cli-error.mjs +26 -0
  13. package/bin/lib/component-deps.mjs +69 -0
  14. package/bin/lib/diff.mjs +275 -0
  15. package/bin/lib/export-pdf.mjs +65 -0
  16. package/bin/lib/graph-hit.mjs +86 -0
  17. package/bin/lib/inline.mjs +137 -39
  18. package/bin/lib/narrative.mjs +77 -0
  19. package/bin/lib/prune-icons.mjs +104 -0
  20. package/bin/lib/render.mjs +195 -0
  21. package/bin/lib/scan-external.mjs +126 -0
  22. package/bin/lib/starter.mjs +27 -14
  23. package/bin/lib/visual.mjs +120 -0
  24. package/bin/rikiki.mjs +420 -35
  25. package/dist/annotation-marks.d.ts +60 -0
  26. package/dist/annotation-marks.js +1 -0
  27. package/dist/bar-segments.d.ts +28 -0
  28. package/dist/bar-segments.js +1 -0
  29. package/dist/browser-location.d.ts +3 -0
  30. package/dist/browser-location.js +1 -0
  31. package/dist/cards-syntax.d.ts +31 -0
  32. package/dist/cards-syntax.js +6 -0
  33. package/dist/{plugins/click-stages.d.ts → click-stages.d.ts} +1 -1
  34. package/dist/deck-agenda.d.ts +25 -0
  35. package/dist/deck-agenda.js +6 -0
  36. package/dist/deck-annotate.d.ts +108 -0
  37. package/dist/deck-annotate.js +18 -0
  38. package/dist/deck-bar.d.ts +32 -0
  39. package/dist/deck-bar.js +19 -0
  40. package/dist/deck-bento.d.ts +38 -0
  41. package/dist/deck-bento.js +4 -0
  42. package/dist/{molecules/deck-callout.d.ts → deck-callout.d.ts} +2 -0
  43. package/dist/deck-callout.js +1 -1
  44. package/dist/deck-cell.d.ts +19 -0
  45. package/dist/deck-cell.js +1 -0
  46. package/dist/deck-checklist.d.ts +20 -0
  47. package/dist/deck-checklist.js +1 -0
  48. package/dist/{layouts/deck-cover.d.ts → deck-cover.d.ts} +8 -0
  49. package/dist/deck-cover.js +9 -6
  50. package/dist/deck-csv.d.ts +38 -0
  51. package/dist/deck-csv.js +15 -0
  52. package/dist/deck-feature-cards.js +2 -2
  53. package/dist/deck-feature.d.ts +18 -0
  54. package/dist/deck-feature.js +2 -2
  55. package/dist/deck-figure.d.ts +26 -0
  56. package/dist/deck-figure.js +8 -0
  57. package/dist/deck-fit.d.ts +14 -0
  58. package/dist/deck-fit.js +1 -0
  59. package/dist/deck-flow.d.ts +41 -0
  60. package/dist/deck-flow.js +7 -0
  61. package/dist/deck-graph.d.ts +92 -0
  62. package/dist/deck-graph.js +25 -0
  63. package/dist/deck-grid.js +1 -1
  64. package/dist/deck-icon.d.ts +20 -0
  65. package/dist/deck-icon.js +1 -0
  66. package/dist/{atoms/deck-kicker.d.ts → deck-kicker.d.ts} +5 -0
  67. package/dist/deck-kicker.js +1 -1
  68. package/dist/deck-kpi-grid.d.ts +26 -0
  69. package/dist/deck-kpi-grid.js +4 -0
  70. package/dist/deck-link.d.ts +21 -0
  71. package/dist/deck-link.js +1 -0
  72. package/dist/{molecules/deck-md.d.ts → deck-md.d.ts} +3 -0
  73. package/dist/deck-md.js +8 -3
  74. package/dist/deck-mermaid.js +15 -3
  75. package/dist/deck-outline.d.ts +50 -0
  76. package/dist/deck-outline.js +1 -0
  77. package/dist/deck-overview.js +53 -39
  78. package/dist/deck-persona.d.ts +31 -0
  79. package/dist/deck-persona.js +6 -0
  80. package/dist/deck-photo.js +1 -1
  81. package/dist/deck-point.d.ts +22 -0
  82. package/dist/deck-point.js +1 -0
  83. package/dist/deck-presenter.js +120 -48
  84. package/dist/deck-pull.d.ts +13 -0
  85. package/dist/deck-pull.js +1 -0
  86. package/dist/{atoms/deck-punch.d.ts → deck-punch.d.ts} +6 -0
  87. package/dist/deck-punch.js +1 -1
  88. package/dist/deck-quote.d.ts +28 -0
  89. package/dist/deck-quote.js +6 -0
  90. package/dist/{runtime/deck-root.d.ts → deck-root.d.ts} +88 -8
  91. package/dist/deck-root.js +17 -13
  92. package/dist/deck-section.js +2 -2
  93. package/dist/deck-source.d.ts +12 -0
  94. package/dist/deck-source.js +2 -0
  95. package/dist/deck-split.d.ts +30 -0
  96. package/dist/deck-split.js +5 -3
  97. package/dist/{molecules/deck-stat.d.ts → deck-stat.d.ts} +2 -0
  98. package/dist/deck-stat.js +2 -2
  99. package/dist/{molecules/deck-step-list.d.ts → deck-step-list.d.ts} +8 -0
  100. package/dist/deck-step-list.js +4 -2
  101. package/dist/deck-table.d.ts +26 -0
  102. package/dist/deck-table.js +1 -0
  103. package/dist/deck-takeaway.d.ts +18 -0
  104. package/dist/deck-takeaway.js +2 -2
  105. package/dist/deck-timeline.d.ts +37 -0
  106. package/dist/deck-timeline.js +5 -0
  107. package/dist/deck-transition.js +3 -3
  108. package/dist/deck-versus.d.ts +18 -0
  109. package/dist/deck-versus.js +9 -0
  110. package/dist/deep-link.d.ts +29 -0
  111. package/dist/deep-link.js +1 -0
  112. package/dist/escape-html.d.ts +3 -0
  113. package/dist/escape-html.js +1 -0
  114. package/dist/fit-controller.d.ts +27 -0
  115. package/dist/fit-controller.js +1 -0
  116. package/dist/graph-layout.d.ts +35 -0
  117. package/dist/graph-layout.js +1 -0
  118. package/dist/grid-tracks.d.ts +17 -0
  119. package/dist/grid-tracks.js +1 -0
  120. package/dist/icon-set.d.ts +6 -0
  121. package/dist/icon-set.js +1 -0
  122. package/dist/index.d.ts +37 -31
  123. package/dist/index.js +95 -49
  124. package/dist/keymap.d.ts +40 -0
  125. package/dist/keymap.js +1 -0
  126. package/dist/mouse-nav.d.ts +12 -0
  127. package/dist/mouse-nav.js +1 -0
  128. package/dist/navigation.d.ts +25 -0
  129. package/dist/navigation.js +1 -0
  130. package/dist/parse-csv.d.ts +9 -0
  131. package/dist/parse-csv.js +3 -0
  132. package/dist/shared-styles.js +1 -1
  133. package/dist/shiki.d.ts +8 -0
  134. package/dist/signature.d.ts +2 -0
  135. package/dist/signature.js +1 -0
  136. package/dist/slide-fill.d.ts +8 -0
  137. package/dist/slide-fill.js +1 -0
  138. package/dist/standalone.js +301 -169
  139. package/dist/vendor/THIRD-PARTY-NOTICES.txt +4347 -0
  140. package/dist/vendor/inventory.json +3029 -0
  141. package/dist/vendor/lit.js +62 -2
  142. package/dist/vendor/mermaid.min.js +95 -95
  143. package/dist/vendor/shiki.js +1 -57
  144. package/dist/viewport.d.ts +42 -0
  145. package/dist/viewport.js +1 -0
  146. package/docs/llms/rikiki-reference.md +955 -64
  147. package/docs/llms/rikiki-workflow.md +536 -0
  148. package/llms.txt +39 -12
  149. package/package.json +33 -12
  150. package/themes/rikiki.css +173 -47
  151. package/themes/siliceum.css +171 -51
  152. package/dist/layouts/deck-feature.d.ts +0 -11
  153. package/dist/layouts/deck-split.d.ts +0 -18
  154. package/dist/layouts/deck-takeaway.d.ts +0 -11
  155. package/dist/plugins/shiki.d.ts +0 -8
  156. /package/dist/{runtime/color.d.ts → color.d.ts} +0 -0
  157. /package/dist/{atoms/deck-badge.d.ts → deck-badge.d.ts} +0 -0
  158. /package/dist/{molecules/deck-card.d.ts → deck-card.d.ts} +0 -0
  159. /package/dist/{atoms/deck-code-highlighter.d.ts → deck-code-highlighter.d.ts} +0 -0
  160. /package/dist/{atoms/deck-code.d.ts → deck-code.d.ts} +0 -0
  161. /package/dist/{layouts/deck-feature-cards.d.ts → deck-feature-cards.d.ts} +0 -0
  162. /package/dist/{molecules/deck-grid.d.ts → deck-grid.d.ts} +0 -0
  163. /package/dist/{runtime/deck-help.d.ts → deck-help.d.ts} +0 -0
  164. /package/dist/{molecules/deck-mermaid.d.ts → deck-mermaid.d.ts} +0 -0
  165. /package/dist/{molecules/deck-metric.d.ts → deck-metric.d.ts} +0 -0
  166. /package/dist/{runtime/deck-notes.d.ts → deck-notes.d.ts} +0 -0
  167. /package/dist/{runtime/deck-overview.d.ts → deck-overview.d.ts} +0 -0
  168. /package/dist/{layouts/deck-photo.d.ts → deck-photo.d.ts} +0 -0
  169. /package/dist/{runtime/deck-presenter.d.ts → deck-presenter.d.ts} +0 -0
  170. /package/dist/{layouts/deck-section.d.ts → deck-section.d.ts} +0 -0
  171. /package/dist/{molecules/deck-shortcut.d.ts → deck-shortcut.d.ts} +0 -0
  172. /package/dist/{molecules/deck-stack.d.ts → deck-stack.d.ts} +0 -0
  173. /package/dist/{molecules/deck-tier-list.d.ts → deck-tier-list.d.ts} +0 -0
  174. /package/dist/{runtime/deck-transition.d.ts → deck-transition.d.ts} +0 -0
@@ -0,0 +1,1347 @@
1
+ // ════════════════════════════════════════════════════════════════
2
+ // rikiki check · what is wrong with this deck, said in a way you can act on.
3
+ //
4
+ // A picture shows a problem; it does not name the element. This measures the
5
+ // deck in a real browser and reports each finding with a stable code, the slide
6
+ // it belongs to, a path that reaches inside the Shadow DOM, and the number that
7
+ // justifies it. The JSON shape is versioned because an agent parses it.
8
+ //
9
+ // What it deliberately does not do: call a slide bad for being sparse, or claim
10
+ // a deck is accessible. Empty space is a choice, and four measurements are not
11
+ // an accessibility audit.
12
+ // ════════════════════════════════════════════════════════════════
13
+
14
+ import { basename } from 'node:path';
15
+ import { readFileSync } from 'node:fs';
16
+ import { NAVIGATION_TIMEOUT_MS, PAGE_LOAD_TIMEOUT_MS, SLIDE_TITLE_READER, advanceStep, goToSlide, waitForStillFrame, withDeck } from './browser.mjs';
17
+ import { BOX_GEOMETRY_READER } from './box-geometry.mjs';
18
+ import { GRAPH_GEOMETRY_READER } from './graph-hit.mjs';
19
+ import { measureSlides } from './visual.mjs';
20
+ import { scanExternal } from './scan-external.mjs';
21
+ import { resolveCheckPlugins, startCheckPlugins, runCheckPlugins, pluginReport } from './check-plugins.mjs';
22
+ import { collectNarrative, narrativeRequest, applyNarrativeReview } from './narrative.mjs';
23
+
24
+ export const REPORT_SCHEMA = 1;
25
+
26
+ export const SEVERITY = { error: 'error', warning: 'warning' };
27
+
28
+ // Thresholds are named, not scattered · each says what a reader would notice.
29
+ const LIMITS = {
30
+ // Speech runs at 130 to 160 words a minute at a normal pace, and public
31
+ // speaking on technical material sits lower, around 100 to 120. A deck is
32
+ // measured against the slower end: what a room can follow, not what a
33
+ // speaker can articulate.
34
+ wordsPerMinute: 120,
35
+ // The gap that is worth a word. Below this the estimate is noise: how much
36
+ // someone says around a slide varies more than any measurement can capture.
37
+ talkLengthTolerance: 0.5,
38
+ // Under this, the back row of a room cannot read it. Measured against the
39
+ // deck's own canvas, so it holds whatever the projector does.
40
+ minTextPx: 18,
41
+ // A slide whose content spans more than this much of its height has nothing
42
+ // left to breathe · it reads as a wall.
43
+ denseFillRatio: 0.92,
44
+ // One pixel of clipping is a rounding artefact; a lost line is not.
45
+ clipPx: 4,
46
+ // A dead band under the content, as a share of the slide height. Empty space
47
+ // is a choice; a third of the slide empty *below* a full top is a slide that
48
+ // forgot to distribute itself. Measured on the pixels, chrome excluded.
49
+ tailBand: 0.3,
50
+ // How far the ink may sit above centre before the slide reads as top-heavy.
51
+ verticalBias: -0.1,
52
+ // Below this a box is a label or an icon, not a block worth comparing
53
+ // against its neighbours · an <em> inside an <h1> must never count.
54
+ paintedBoxMinWidth: 40,
55
+ paintedBoxMinHeight: 20,
56
+ // Two boxes sharing a few pixels at a corner is normal layout slop; sharing
57
+ // this many on both axes is one painted over the other.
58
+ siblingOverlapPx: 8,
59
+ // Under a pixel, two coordinates are the same coordinate · a percentage
60
+ // resolved into device pixels lands a hair apart and means nothing by it.
61
+ graphAlignFloorPx: 1,
62
+ // How far apart two graph centres may sit and still read as an attempt at
63
+ // the same row or the same column. A few tens of pixels on a 1920 canvas is
64
+ // the band where the eye says "almost" · beyond it the author moved the node
65
+ // somewhere else on purpose.
66
+ graphAlignSlackPx: 24,
67
+ // An edge whose smaller delta is under this share of its larger one was
68
+ // aiming at horizontal or vertical. Above it the line is a diagonal, and a
69
+ // diagonal is a choice nobody needs told about.
70
+ graphSkewRatio: 0.3,
71
+ // Two nodes of one row differing by less than this share read as a failed
72
+ // attempt at the same size; differing by more reads as a deliberate
73
+ // hierarchy. Paired with a floor, because text metrics move a box by a
74
+ // pixel or two on nothing but the glyphs in it.
75
+ graphSizeSlack: 0.3,
76
+ graphSizeFloorPx: 3,
77
+ // Below three nodes, one row is not an arrangement · two nodes side by side
78
+ // are just two nodes, and `layout` would say less than the coordinates do.
79
+ graphSemanticMinNodes: 3,
80
+ // A last line narrower than this share of the widest one is a stub hanging
81
+ // under the block · the classic orphan of a slide.
82
+ orphanLineRatio: 0.25,
83
+ // Under this many words there is no paragraph to break badly: a two-word
84
+ // label wrapping is the layout, not a typographic accident.
85
+ orphanMinWords: 8,
86
+ };
87
+
88
+ const diagnostic = (code, severity, message, extra = {}) => ({
89
+ code,
90
+ severity,
91
+ message,
92
+ ...extra,
93
+ });
94
+
95
+ /** Everything the page can tell us about itself, in one round trip.
96
+ *
97
+ * `only` is the 1-based slide to measure · `deck-root` lays out the slide on
98
+ * screen and hides the rest, so measuring the whole document at once reads
99
+ * empty rects everywhere but there. The caller walks the deck and names one
100
+ * slide per call; `null` measures them all, which is right only on a page
101
+ * that never settled.
102
+ *
103
+ * `scanDocument` covers what does not depend on which slide is showing ·
104
+ * unknown tags, stray attributes, unslotted content, the notes word count.
105
+ * Asking for them once per slide would walk the whole document N times over
106
+ * for the same answer. */
107
+ const inspectPage = ({ limits, titleReader, graphGeometry, boxGeometry, only = null, scanDocument = true }) => {
108
+ const titleOf = new Function('return ' + titleReader)();
109
+ /** `parseGraphPath` and `polylineHitsRect` from bin/lib/graph-hit.mjs · this
110
+ * function runs in the page, so they arrive as source and are rebuilt here.
111
+ * They are unit tested on the node side. */
112
+ const geometry = new Function('return ' + graphGeometry)();
113
+ /** `escapeOf`, `overlapOf` and `encloses` from bin/lib/box-geometry.mjs ·
114
+ * same reason, same node-side tests. */
115
+ const boxGeom = new Function('return ' + boxGeometry)();
116
+ const root = document.querySelector('deck-root');
117
+ const slides = root
118
+ ? Array.from(root.children).filter((el) => el.tagName.toLowerCase().startsWith('deck-'))
119
+ : [];
120
+
121
+ /** A selector a human can paste and an agent can search for. */
122
+ const pathOf = (el) => {
123
+ const parts = [];
124
+ let node = el;
125
+ while (node && node !== document.body) {
126
+ const tag = node.tagName?.toLowerCase();
127
+ if (!tag) break;
128
+ const host = node.getRootNode()?.host;
129
+ if (node.id) {
130
+ parts.unshift(`${tag}#${node.id}`);
131
+ } else if (host) {
132
+ // Inside a component's shadow tree · say so rather than pretend the
133
+ // element is reachable from the document.
134
+ parts.unshift(`${tag}`);
135
+ parts.unshift('::shadow');
136
+ node = host;
137
+ continue;
138
+ } else {
139
+ const siblings = Array.from(node.parentElement?.children ?? []).filter(
140
+ (s) => s.tagName === node.tagName,
141
+ );
142
+ parts.unshift(siblings.length > 1 ? `${tag}:nth-of-type(${siblings.indexOf(node) + 1})` : tag);
143
+ }
144
+ node = node.parentElement ?? node.getRootNode()?.host ?? null;
145
+ }
146
+ return parts.join(' > ').replace(/ > ::shadow > /g, ' ::shadow ');
147
+ };
148
+
149
+ /** Every box that clips, light DOM and shadow alike · asked of the computed
150
+ * style rather than of a list of class names that happened to clip once. */
151
+ const clippersIn = (slide) => {
152
+ const found = [];
153
+ const seen = new Set();
154
+ const collect = (node) => {
155
+ for (const el of node.querySelectorAll('*')) {
156
+ if (seen.has(el)) continue;
157
+ seen.add(el);
158
+ const overflow = getComputedStyle(el).overflow;
159
+ if (overflow === 'hidden' || overflow === 'clip') found.push(el);
160
+ if (el.shadowRoot) collect(el.shadowRoot);
161
+ }
162
+ };
163
+ found.push(slide);
164
+ if (slide.shadowRoot) collect(slide.shadowRoot);
165
+ collect(slide);
166
+ return found;
167
+ };
168
+
169
+ /** Does `el` sit inside one of `tags`, at any depth? Shadow boundaries
170
+ * crossed via the host, same walk as `pathOf`. Any depth and not just the
171
+ * nearest one: a graph node's own label is two levels below `deck-graph`,
172
+ * and stopping at the first `deck-*` ancestor let it through. */
173
+ const isInside = (el, tags) => {
174
+ let node = el.parentElement ?? el.getRootNode()?.host ?? null;
175
+ while (node) {
176
+ const tag = node.tagName?.toLowerCase();
177
+ if (tag && tags.has(tag)) return true;
178
+ node = node.parentElement ?? node.getRootNode()?.host ?? null;
179
+ }
180
+ return false;
181
+ };
182
+
183
+ /** Components that paint marks and nodes over each other by design, and
184
+ * report it under their own codes · nothing inside them is a painted box
185
+ * to compare against its neighbours. */
186
+ const PAINTS_OVER_ITSELF = new Set(['deck-annotate', 'deck-graph']);
187
+
188
+ /** Is `node` `ancestorEl` itself, or reached by walking up from it? Shadow
189
+ * boundaries crossed via the host, same as `pathOf`. */
190
+ const containsAcrossShadow = (ancestorEl, node) => {
191
+ let current = node;
192
+ while (current) {
193
+ if (current === ancestorEl) return true;
194
+ current = current.parentElement ?? current.getRootNode()?.host ?? null;
195
+ }
196
+ return false;
197
+ };
198
+
199
+ const textHead = (el) => (el.textContent ?? '').trim().replace(/\s+/g, ' ').slice(0, 36);
200
+
201
+ /** Every box worth comparing against its neighbours: rendered, in normal
202
+ * flow, big enough to be more than a label, and carrying words · light DOM
203
+ * and shadow alike, same walk as `clippersIn`. `deck-notes` is never shown,
204
+ * and `deck-annotate` / `deck-graph` paint marks and nodes on top of each
205
+ * other by design and have their own codes, so their internals are not
206
+ * painted boxes here. */
207
+ const paintedBoxesIn = (slide) => {
208
+ const boxes = [{ el: slide, rect: slide.getBoundingClientRect() }];
209
+ const seen = new Set();
210
+ const collect = (root) => {
211
+ for (const el of root.querySelectorAll('*')) {
212
+ if (seen.has(el)) continue;
213
+ seen.add(el);
214
+ if (el.shadowRoot) collect(el.shadowRoot);
215
+ if (el.tagName.toLowerCase() === 'deck-notes') continue;
216
+ if (el.closest?.('deck-notes')) continue;
217
+ if (isInside(el, PAINTS_OVER_ITSELF)) continue;
218
+ const style = getComputedStyle(el);
219
+ if (style.display === 'none' || style.visibility === 'hidden') continue;
220
+ if (style.position === 'absolute' || style.position === 'fixed') continue;
221
+ // An inline run (em, strong, a plain link inside a sentence) is not a
222
+ // box an author laid out · its rect follows the line box of the font
223
+ // actually used for that run, which an italic or bold face can offset
224
+ // from its parent's by a dozen px on nothing but metrics. Comparing it
225
+ // against its container reports the font, not a defect.
226
+ if (style.display === 'inline') continue;
227
+ const rect = el.getBoundingClientRect();
228
+ if (!(rect.width > limits.paintedBoxMinWidth && rect.height > limits.paintedBoxMinHeight)) continue;
229
+ if (!el.textContent?.trim()) continue;
230
+ boxes.push({ el, rect });
231
+ }
232
+ };
233
+ collect(slide);
234
+ return boxes;
235
+ };
236
+
237
+ /** The worst `CONTENT_ESCAPES_BOX` offender on a slide: a painted box whose
238
+ * rect leaves its nearest painted ancestor by more than `clipPx`, skipping
239
+ * any ancestor that already clips · that is `CONTENT_CLIPPED`'s job. */
240
+ const worstEscape = (boxes) => {
241
+ const boxSet = new Set(boxes.map((b) => b.el));
242
+ let worst = null;
243
+ for (const box of boxes) {
244
+ if (box.el === boxes[0].el) continue; // the slide itself has no ancestor here
245
+ let ancestorEl = box.el.parentElement ?? box.el.getRootNode()?.host ?? null;
246
+ while (ancestorEl && !boxSet.has(ancestorEl)) {
247
+ ancestorEl = ancestorEl.parentElement ?? ancestorEl.getRootNode()?.host ?? null;
248
+ }
249
+ if (!ancestorEl) continue;
250
+ const overflow = getComputedStyle(ancestorEl).overflow;
251
+ if (overflow === 'hidden' || overflow === 'clip') continue;
252
+ const ancestorBox = boxes.find((b) => b.el === ancestorEl);
253
+ const escape = boxGeom.escapeOf(box.rect, ancestorBox.rect);
254
+ if (escape.pixels > limits.clipPx && escape.pixels > (worst?.pixels ?? 0)) {
255
+ worst = {
256
+ pixels: Math.round(escape.pixels),
257
+ path: pathOf(box.el),
258
+ text: textHead(box.el),
259
+ containerPath: pathOf(ancestorEl),
260
+ containerText: textHead(ancestorEl),
261
+ };
262
+ }
263
+ }
264
+ return worst;
265
+ };
266
+
267
+ /** The worst `CONTENT_OVERLAPS_SIBLING` offender on a slide: two painted
268
+ * boxes, neither containing the other in the DOM and neither enclosing the
269
+ * other, whose rects intersect by more than `siblingOverlapPx` on both
270
+ * axes. Ranked by overlapping area. */
271
+ const worstOverlap = (boxes) => {
272
+ let worst = null;
273
+ for (let i = 1; i < boxes.length; i++) {
274
+ for (let j = i + 1; j < boxes.length; j++) {
275
+ const a = boxes[i];
276
+ const b = boxes[j];
277
+ if (containsAcrossShadow(a.el, b.el) || containsAcrossShadow(b.el, a.el)) continue;
278
+ const overlap = boxGeom.overlapOf(a.rect, b.rect);
279
+ if (overlap.x <= limits.siblingOverlapPx || overlap.y <= limits.siblingOverlapPx) continue;
280
+ if (boxGeom.encloses(a.rect, b.rect) || boxGeom.encloses(b.rect, a.rect)) continue;
281
+ const area = overlap.x * overlap.y;
282
+ if (area > (worst?.area ?? 0)) {
283
+ worst = {
284
+ area,
285
+ overlap: { x: Math.round(overlap.x), y: Math.round(overlap.y) },
286
+ pathA: pathOf(a.el),
287
+ textA: textHead(a.el),
288
+ pathB: pathOf(b.el),
289
+ textB: textHead(b.el),
290
+ };
291
+ }
292
+ }
293
+ }
294
+ return worst;
295
+ };
296
+
297
+ /** Where a component re-renders the author's own prose into its shadow tree ·
298
+ * the lines the browser broke are in there, not in the light DOM. */
299
+ const PROSE_IN_SHADOW = new Set(['deck-md']);
300
+
301
+ /** Text whose line breaks are not the browser's to judge: a code listing
302
+ * breaks where it was typed, a diagram and a table lay their own text out,
303
+ * and notes are never shown. */
304
+ const NOT_PROSE = new Set(['deck-notes', 'deck-code', 'deck-mermaid', 'deck-table', 'deck-graph', 'deck-annotate']);
305
+ const HEADINGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
306
+ const INLINE_DISPLAY = new Set(['inline', 'inline-block', 'inline-flex', 'contents']);
307
+
308
+ /** The words the browser put on the line starting at `lineTop` · measured
309
+ * one word at a time, because nothing short of a rect says where a line
310
+ * actually broke. Only ever asked of the block already found guilty. */
311
+ const wordsOnLine = (el, lineTop) => {
312
+ const out = [];
313
+ const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
314
+ const range = document.createRange();
315
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
316
+ for (const m of (node.textContent ?? '').matchAll(/\S+/g)) {
317
+ range.setStart(node, m.index);
318
+ range.setEnd(node, m.index + m[0].length);
319
+ const r = range.getBoundingClientRect();
320
+ if (r.height && Math.abs(r.top - lineTop) < r.height / 2) out.push(m[0]);
321
+ }
322
+ }
323
+ return out.join(' ');
324
+ };
325
+
326
+ /** The worst `TEXT_LAST_LINE_ORPHAN` offender on a slide: a block of prose
327
+ * whose last line is a stub of the ones above it.
328
+ *
329
+ * The lines are read with a Range over the block's own contents · the
330
+ * stylesheet says where text *may* break, only the painted rects say where
331
+ * it did. Headings and short blocks are left alone: a title wrapping onto a
332
+ * second line is the composition, not an accident. */
333
+ const worstOrphan = (slide) => {
334
+ let worst = null;
335
+ const consider = (el) => {
336
+ const tag = el.tagName.toLowerCase();
337
+ if (HEADINGS.has(tag) || NOT_PROSE.has(tag) || isInside(el, NOT_PROSE)) return;
338
+ if (el.ownerSVGElement) return;
339
+ const style = getComputedStyle(el);
340
+ if (style.display === 'none' || style.visibility === 'hidden') return;
341
+ if (INLINE_DISPLAY.has(style.display)) return;
342
+ if (style.whiteSpace.startsWith('pre')) return;
343
+ // A container is judged through its children, never as one block: a
344
+ // Range over it would read every line of every paragraph it holds and
345
+ // call the last one an orphan of the first.
346
+ for (const child of el.children) {
347
+ if (!INLINE_DISPLAY.has(getComputedStyle(child).display)) return;
348
+ }
349
+ const words = ((el.textContent ?? '').match(/[\p{L}\p{N}'’-]+/gu) ?? []).length;
350
+ if (words < limits.orphanMinWords) return;
351
+
352
+ const range = document.createRange();
353
+ range.selectNodeContents(el);
354
+ const rects = [...range.getClientRects()].filter((r) => r.width > 0.5 && r.height > 0.5);
355
+ if (rects.length < 2) return;
356
+ // Several rects share one line · an <em> mid-sentence is a rect of its
357
+ // own, not a line of its own.
358
+ const lines = [];
359
+ for (const r of rects) {
360
+ const line = lines.find((l) => Math.abs(l.top - r.top) < r.height / 2);
361
+ if (line) {
362
+ line.left = Math.min(line.left, r.left);
363
+ line.right = Math.max(line.right, r.right);
364
+ } else {
365
+ lines.push({ top: r.top, left: r.left, right: r.right });
366
+ }
367
+ }
368
+ if (lines.length < 2) return;
369
+ const last = lines[lines.length - 1];
370
+ const widest = Math.max(...lines.map((l) => l.right - l.left));
371
+ if (!widest) return;
372
+ const ratio = (last.right - last.left) / widest;
373
+ if (ratio >= limits.orphanLineRatio) return;
374
+ if (ratio < (worst?.ratio ?? Number.POSITIVE_INFINITY)) {
375
+ worst = { ratio, lines: lines.length, path: pathOf(el), tail: wordsOnLine(el, last.top).slice(0, 40) };
376
+ }
377
+ };
378
+
379
+ const seen = new Set();
380
+ const scan = (root) => {
381
+ for (const el of root.querySelectorAll('*')) {
382
+ if (seen.has(el)) continue;
383
+ seen.add(el);
384
+ if (el.shadowRoot && PROSE_IN_SHADOW.has(el.tagName.toLowerCase())) scan(el.shadowRoot);
385
+ consider(el);
386
+ }
387
+ };
388
+ scan(slide);
389
+ return worst;
390
+ };
391
+
392
+ const measured = slides.map((slide, index) => {
393
+ const outline = {
394
+ index: index + 1,
395
+ id: slide.id || null,
396
+ tag: slide.tagName.toLowerCase(),
397
+ title: titleOf(slide),
398
+ steps: Number.parseInt(slide.getAttribute('steps') ?? slide.dataset?.steps ?? '0', 10) || 0,
399
+ };
400
+ if (only !== null && outline.index !== only) return { ...outline, measured: false };
401
+
402
+ const box = slide.getBoundingClientRect();
403
+ let clipped = null;
404
+ for (const el of clippersIn(slide)) {
405
+ const overflowY = el.scrollHeight - el.clientHeight;
406
+ const overflowX = el.scrollWidth - el.clientWidth;
407
+ const worst = Math.max(overflowY, overflowX);
408
+ if (worst > (clipped?.pixels ?? 0)) {
409
+ clipped = { pixels: Math.round(worst), axis: overflowY >= overflowX ? 'y' : 'x', path: pathOf(el) };
410
+ }
411
+ }
412
+
413
+ const written = Array.from(slide.children)
414
+ .filter((el) => el.tagName.toLowerCase() !== 'deck-notes')
415
+ .map((el) => el.getBoundingClientRect())
416
+ .filter((b) => b.height > 0);
417
+ const spanned = written.length
418
+ ? Math.max(...written.map((b) => b.bottom)) - Math.min(...written.map((b) => b.top))
419
+ : 0;
420
+
421
+ // Text too small to read from the back.
422
+ //
423
+ // The size that matters is the one on the wall, not the one in the
424
+ // stylesheet: a deck is scaled to fit its canvas. Comparing the laid-out
425
+ // height with the painted height gives that factor whatever produced it.
426
+ // Elements inside an SVG are skipped · a diagram scales by its viewBox,
427
+ // which this ratio cannot see, and guessing there would cry wolf.
428
+ const IGNORED = new Set(['style', 'script', 'template', 'noscript', 'title']);
429
+ const tiny = [];
430
+ // Only the author's own text. A component's chrome (a cover's meta labels,
431
+ // a counter) is sized by the theme, and telling an author to fix a span
432
+ // they never wrote is noise. Slotted content stays in the light DOM, so it
433
+ // is measured here with the styles the component gives it · but two
434
+ // elements re-render the author's own words into their shadow tree, and
435
+ // skipping those hid a code block nobody could read.
436
+ const AUTHOR_TEXT_IN_SHADOW = new Set(['deck-md', 'deck-code']);
437
+ const walk = (node) => {
438
+ for (const el of node.querySelectorAll('*')) {
439
+ if (el.shadowRoot && AUTHOR_TEXT_IN_SHADOW.has(el.tagName.toLowerCase())) walk(el.shadowRoot);
440
+ if (IGNORED.has(el.tagName.toLowerCase())) continue;
441
+ if (el.ownerSVGElement || el.tagName.toLowerCase() === 'svg') continue;
442
+ const text = Array.from(el.childNodes)
443
+ .filter((n) => n.nodeType === 3)
444
+ .map((n) => n.textContent.trim())
445
+ .join('');
446
+ if (!text) continue;
447
+ const px = Number.parseFloat(getComputedStyle(el).fontSize);
448
+ if (!(px > 0) || !el.offsetHeight) continue;
449
+ const painted = el.getBoundingClientRect().height / el.offsetHeight;
450
+ const onScreen = px * (painted || 1);
451
+ if (onScreen < limits.minTextPx) {
452
+ tiny.push({ path: pathOf(el), px: Math.round(onScreen), text: text.slice(0, 40) });
453
+ }
454
+ }
455
+ };
456
+ walk(slide);
457
+
458
+ // Nothing clips, yet the paint still runs outside its box or over a
459
+ // sibling: most layouts do not set overflow hidden anywhere, so a box
460
+ // that is simply too small for its content just paints past its own
461
+ // edges, silently.
462
+ const boxes = paintedBoxesIn(slide);
463
+
464
+ return {
465
+ ...outline,
466
+ measured: true,
467
+ clipped,
468
+ fillRatio: box.height ? Math.round((spanned / box.height) * 100) / 100 : 0,
469
+ tiny: tiny.slice(0, 3),
470
+ escapesBox: worstEscape(boxes),
471
+ overlapsSibling: worstOverlap(boxes),
472
+ lastLineOrphan: worstOrphan(slide),
473
+ };
474
+ });
475
+
476
+ // An attribute a component does not observe is dropped in silence: the author
477
+ // wrote label="Budget consumed" on an element whose label comes from its
478
+ // content, and the words simply never appeared. Custom elements publish what
479
+ // they listen to, so this is asked rather than guessed.
480
+ const GLOBAL_ATTRS = /^(id|class|style|slot|hidden|title|lang|dir|part|exportparts|tabindex|role|steps|active|contenteditable|draggable|translate|spellcheck|itemscope|itemtype|itemprop|inert|popover|is)$/;
481
+
482
+ /** Attributes a component's own stylesheet selects on.
483
+ *
484
+ * `compact` on deck-mermaid changes nothing in JavaScript · it exists purely
485
+ * as `:host([compact])` in the shadow styles. An attribute that only styles
486
+ * is still an attribute the element reads. */
487
+ const styledAttrsOf = (el) => {
488
+ const names = new Set();
489
+ const sheets = [...(el.shadowRoot?.adoptedStyleSheets ?? []), ...(el.shadowRoot?.styleSheets ?? [])];
490
+ for (const sheet of sheets) {
491
+ let rules;
492
+ try {
493
+ rules = sheet.cssRules;
494
+ } catch {
495
+ continue; // a cross-origin sheet · nothing to read, nothing to guess
496
+ }
497
+ for (const rule of rules) {
498
+ for (const m of (rule.selectorText ?? '').matchAll(/\[\s*([a-zA-Z-]+)/g)) names.add(m[1]);
499
+ }
500
+ }
501
+ return names;
502
+ };
503
+
504
+ const strayAttributes = [];
505
+ const styledCache = new Map();
506
+ for (const el of scanDocument ? document.querySelectorAll('*') : []) {
507
+ const tag = el.tagName.toLowerCase();
508
+ if (!tag.includes('-')) continue;
509
+ const ctor = customElements.get(tag);
510
+ if (!ctor) continue;
511
+ if (!styledCache.has(tag)) styledCache.set(tag, styledAttrsOf(el));
512
+ const observed = new Set([...(ctor.observedAttributes ?? []), ...styledCache.get(tag)]);
513
+ for (const attr of el.getAttributeNames()) {
514
+ if (observed.has(attr)) continue;
515
+ if (GLOBAL_ATTRS.test(attr) || attr.startsWith('data-') || attr.startsWith('aria-')) continue;
516
+ strayAttributes.push({ tag, attr, path: pathOf(el), observed: [...observed] });
517
+ }
518
+ }
519
+
520
+ // Content a component never took. An element whose parent has a shadow root
521
+ // is rendered only if a <slot> accepts it: writing slot="a" where no such
522
+ // slot exists, or putting a block inside a component that only forwards
523
+ // named slots, drops it silently and leaves the slide blank.
524
+ const unslotted = [];
525
+ for (const el of scanDocument ? document.querySelectorAll('*') : []) {
526
+ const parent = el.parentElement;
527
+ if (!parent?.shadowRoot) continue;
528
+ if (!parent.tagName.toLowerCase().includes('-')) continue;
529
+ if (el.assignedSlot) continue;
530
+ if (el.tagName.toLowerCase() === 'deck-notes') continue; // read by the presenter, never shown
531
+ const offered = [...parent.shadowRoot.querySelectorAll('slot')].map((n) => n.name || '(default)');
532
+ // A component with no slot at all reads its own textContent · deck-code and
533
+ // deck-mermaid do, and every element the parser leaves in there is theirs
534
+ // to interpret, not ours to complain about.
535
+ if (offered.length === 0) continue;
536
+ const wanted = el.getAttribute('slot');
537
+ unslotted.push({ tag: el.tagName.toLowerCase(), parent: parent.tagName.toLowerCase(), wanted, offered, path: pathOf(el) });
538
+ }
539
+
540
+ // A tag that was never defined renders as an empty inline box: the author
541
+ // typed `deck-callot`, and the slide simply lost a block with no error.
542
+ const unknown = [];
543
+ const elements = scanDocument ? [...document.querySelectorAll('*')] : [];
544
+ const knownPrefixes = new Set(['deck']);
545
+ for (const el of elements) {
546
+ const tag = el.tagName.toLowerCase();
547
+ if (customElements.get(tag)) knownPrefixes.add(tag.split('-')[0]);
548
+ }
549
+ for (const el of elements) {
550
+ const tag = el.tagName.toLowerCase();
551
+ if (el.namespaceURI === 'http://www.w3.org/1999/xhtml' && tag.includes('-') && !customElements.get(tag)) {
552
+ unknown.push({ tag, path: pathOf(el), knownPrefix: knownPrefixes.has(tag.split('-')[0]) });
553
+ }
554
+ }
555
+
556
+ // Graph failures are geometric: valid markup can still place a node outside
557
+ // the drawing area, drop one on top of another, or route an edge through a
558
+ // node it does not connect. Read the painted boxes after layout, and the
559
+ // polyline the component says it painted, rather than inferring either from
560
+ // the authored `at`.
561
+ const graphIssues = [];
562
+ const centreOf = ({ box }) => ({ x: (box.left + box.right) / 2, y: (box.top + box.bottom) / 2 });
563
+ /** Every `deck-graph` of the slide under inspection · the slide itself
564
+ * counts, since a deck can put a graph straight into `deck-root`. */
565
+ const graphsOf = (scope) => {
566
+ if (!scope) return [];
567
+ const found = [...scope.querySelectorAll('deck-graph')];
568
+ if (scope.tagName?.toLowerCase() === 'deck-graph') found.unshift(scope);
569
+ return found;
570
+ };
571
+ for (const graph of only === null ? document.querySelectorAll('deck-graph') : graphsOf(slides[only - 1])) {
572
+ const graphBox = graph.getBoundingClientRect();
573
+ if (!graphBox.width || !graphBox.height) continue;
574
+ const slide = slides.find((candidate) => candidate.contains(graph));
575
+ const slideIndex = slide ? slides.indexOf(slide) + 1 : null;
576
+ const nodes = [...graph.querySelectorAll('deck-node')].map((node) => ({
577
+ node,
578
+ id: node.id,
579
+ box: node.getBoundingClientRect(),
580
+ }));
581
+ for (const { node, box } of nodes) {
582
+ const overflow = {
583
+ left: Math.max(0, graphBox.left - box.left),
584
+ right: Math.max(0, box.right - graphBox.right),
585
+ top: Math.max(0, graphBox.top - box.top),
586
+ bottom: Math.max(0, box.bottom - graphBox.bottom),
587
+ };
588
+ const pixels = Math.max(...Object.values(overflow));
589
+ if (pixels > limits.clipPx) {
590
+ graphIssues.push({ kind: 'node-out', slide: slideIndex, graph: pathOf(graph), node: pathOf(node), pixels: Math.round(pixels), overflow });
591
+ }
592
+ }
593
+ // Two nodes on top of each other hide each other's words. The boxes are
594
+ // already measured for the edge geometry; nobody was comparing them.
595
+ for (let i = 0; i < nodes.length; i++) {
596
+ for (let j = i + 1; j < nodes.length; j++) {
597
+ const a = nodes[i];
598
+ const b = nodes[j];
599
+ const x = Math.min(a.box.right, b.box.right) - Math.max(a.box.left, b.box.left);
600
+ const y = Math.min(a.box.bottom, b.box.bottom) - Math.max(a.box.top, b.box.top);
601
+ if (x > limits.clipPx && y > limits.clipPx) {
602
+ graphIssues.push({ kind: 'node-overlap', slide: slideIndex, graph: pathOf(graph), node: pathOf(a.node), other: pathOf(b.node), a: a.id || null, b: b.id || null, overlap: { x: Math.round(x), y: Math.round(y) } });
603
+ }
604
+ }
605
+ }
606
+
607
+ // A caption painted under a node is a caption nobody reads. The generic
608
+ // sibling rule skips everything inside a graph — the component stacks its
609
+ // own layers by design — so the region and edge captions have no net but
610
+ // this one. They live in shadow trees: the graph paints the edge labels,
611
+ // each deck-group / deck-lane paints its own.
612
+ const labels = [];
613
+ for (const tag of graph.shadowRoot?.querySelectorAll('.edge-label') ?? []) {
614
+ labels.push({ el: tag, kind: 'edge', text: tag.textContent?.trim() ?? '', box: tag.getBoundingClientRect() });
615
+ }
616
+ for (const region of graph.querySelectorAll('deck-group, deck-lane')) {
617
+ const tag = region.shadowRoot?.querySelector('.tag');
618
+ if (!tag) continue;
619
+ const kind = region.tagName.toLowerCase() === 'deck-lane' ? 'lane' : 'group';
620
+ labels.push({ el: region, kind, text: region.getAttribute('label') ?? '', box: tag.getBoundingClientRect() });
621
+ }
622
+ for (const label of labels) {
623
+ if (!label.box.width || !label.box.height) continue;
624
+ let covered = null;
625
+ for (const candidate of nodes) {
626
+ const x = Math.min(candidate.box.right, label.box.right) - Math.max(candidate.box.left, label.box.left);
627
+ const y = Math.min(candidate.box.bottom, label.box.bottom) - Math.max(candidate.box.top, label.box.top);
628
+ if (x <= limits.siblingOverlapPx || y <= limits.siblingOverlapPx) continue;
629
+ if (x * y > (covered?.area ?? 0)) {
630
+ covered = { area: x * y, entry: candidate, overlap: { x: Math.round(x), y: Math.round(y) } };
631
+ }
632
+ }
633
+ if (covered) {
634
+ graphIssues.push({ kind: 'label-covered', slide: slideIndex, graph: pathOf(graph), label: pathOf(label.el), labelKind: label.kind, text: label.text, node: pathOf(covered.entry.node), nodeId: covered.entry.id || null, overlap: covered.overlap });
635
+ }
636
+ }
637
+
638
+ // What the author meant to line up, and what the browser painted. These
639
+ // read the centres rather than the authored `at`: a percentage is resolved
640
+ // against the canvas, and two nodes written 2% apart are as misaligned as
641
+ // the canvas is wide.
642
+ const centres = nodes.map((entry) => ({
643
+ ...entry,
644
+ cx: (entry.box.left + entry.box.right) / 2,
645
+ cy: (entry.box.top + entry.box.bottom) / 2,
646
+ }));
647
+
648
+ // One sloppy row must not produce one diagnostic per pair · the worst
649
+ // offender names the row, and fixing it is what the author does anyway.
650
+ let offAxis = null;
651
+ for (let i = 0; i < centres.length; i++) {
652
+ for (let j = i + 1; j < centres.length; j++) {
653
+ for (const axis of ['x', 'y']) {
654
+ const delta = Math.abs(axis === 'x' ? centres[i].cx - centres[j].cx : centres[i].cy - centres[j].cy);
655
+ if (delta <= limits.graphAlignFloorPx || delta > limits.graphAlignSlackPx) continue;
656
+ if (delta > (offAxis?.delta ?? 0)) offAxis = { delta, axis, a: centres[i], b: centres[j] };
657
+ }
658
+ }
659
+ }
660
+ if (offAxis) {
661
+ graphIssues.push({ kind: 'nodes-off-axis', slide: slideIndex, graph: pathOf(graph), axis: offAxis.axis, pixels: Math.round(offAxis.delta), node: pathOf(offAxis.a.node), other: pathOf(offAxis.b.node), a: offAxis.a.id || null, b: offAxis.b.id || null });
662
+ }
663
+
664
+ /** The nodes grouped into rows (`cy`) or columns (`cx`) · a run of centres
665
+ * no further apart than the alignment slack is one row. */
666
+ const runsOn = (key) => {
667
+ const sorted = [...centres].sort((a, b) => a[key] - b[key]);
668
+ const runs = [];
669
+ let run = [];
670
+ for (const entry of sorted) {
671
+ if (run.length && entry[key] - run[run.length - 1][key] > limits.graphAlignSlackPx) {
672
+ runs.push(run);
673
+ run = [];
674
+ }
675
+ run.push(entry);
676
+ }
677
+ if (run.length) runs.push(run);
678
+ return runs.filter((r) => r.length > 1);
679
+ };
680
+ // A ragged edge down a column, or an uneven baseline across a row · the
681
+ // dimension compared is the one whose mismatch is visible as a ragged
682
+ // line, so a row is judged on height and a column on width.
683
+ let mixedSizes = null;
684
+ const compareSizes = (run, dimension, along) => {
685
+ for (let i = 0; i < run.length; i++) {
686
+ for (let j = i + 1; j < run.length; j++) {
687
+ const a = run[i].box[dimension];
688
+ const b = run[j].box[dimension];
689
+ const diff = Math.abs(a - b);
690
+ const largest = Math.max(a, b);
691
+ if (!largest || diff <= limits.graphSizeFloorPx) continue;
692
+ if (diff / largest >= limits.graphSizeSlack) continue;
693
+ if (diff > (mixedSizes?.diff ?? 0)) {
694
+ mixedSizes = { diff, ratio: diff / largest, dimension, along, a: run[i], b: run[j] };
695
+ }
696
+ }
697
+ }
698
+ };
699
+ for (const row of runsOn('cy')) compareSizes(row, 'height', 'row');
700
+ for (const column of runsOn('cx')) compareSizes(column, 'width', 'column');
701
+ if (mixedSizes) {
702
+ graphIssues.push({ kind: 'sizes-mixed', slide: slideIndex, graph: pathOf(graph), dimension: mixedSizes.dimension, along: mixedSizes.along, pixels: Math.round(mixedSizes.diff), share: Math.round(mixedSizes.ratio * 100), node: pathOf(mixedSizes.a.node), other: pathOf(mixedSizes.b.node), a: mixedSizes.a.id || null, b: mixedSizes.b.id || null });
703
+ }
704
+
705
+ // Hand-placed coordinates that spell out an arrangement the component
706
+ // already has a word for. `layout` reflects, and its default reflects as
707
+ // `free`, so both readings mean "the author placed every node".
708
+ const declaredLayout = (graph.getAttribute('layout') ?? 'free').toLowerCase();
709
+ if (declaredLayout === 'free' && centres.length >= limits.graphSemanticMinNodes) {
710
+ const spread = (key) => Math.max(...centres.map((c) => c[key])) - Math.min(...centres.map((c) => c[key]));
711
+ const arrangement =
712
+ spread('cy') <= limits.graphAlignSlackPx ? 'row' : spread('cx') <= limits.graphAlignSlackPx ? 'column' : null;
713
+ if (arrangement) {
714
+ graphIssues.push({ kind: 'layout-not-semantic', slide: slideIndex, graph: pathOf(graph), layout: arrangement, nodes: centres.length });
715
+ }
716
+ }
717
+
718
+ // An edge is a band of ink, not a mathematical line. Half the stroke width
719
+ // on each side of the centre line paints, so a line that misses a node by
720
+ // one pixel still crosses it on screen · that half width is the tolerance,
721
+ // and it replaces an inset of 2px that was narrower than the ink it was
722
+ // meant to excuse.
723
+ const painted = graph.shadowRoot?.querySelector('.edge');
724
+ const strokeWidth = painted ? Number.parseFloat(getComputedStyle(painted).strokeWidth) : Number.NaN;
725
+ const inkMargin = (Number.isFinite(strokeWidth) ? strokeWidth : 4) / 2;
726
+
727
+ const byId = new Map(nodes.filter(({ id }) => id).map((entry) => [entry.id, entry]));
728
+ for (const edge of graph.querySelectorAll('deck-edge')) {
729
+ const from = byId.get(edge.getAttribute('from') ?? '');
730
+ const to = byId.get(edge.getAttribute('to') ?? '');
731
+ if (!from || !to) continue;
732
+ // What deck-graph publishes is what deck-graph painted, orthogonal bends
733
+ // and boundary anchors included. A runtime older than this attribute
734
+ // publishes nothing: fall back to the straight centre-to-centre segment,
735
+ // which is right for `route="straight"` and only approximates an ortho
736
+ // route.
737
+ const published = geometry.parseGraphPath(edge.getAttribute('data-path'));
738
+ const points = published.length
739
+ ? published.map((p) => ({ x: p.x + graphBox.left, y: p.y + graphBox.top }))
740
+ : [centreOf(from), centreOf(to)];
741
+ for (const candidate of nodes) {
742
+ if (candidate === from || candidate === to) continue;
743
+ if (geometry.polylineHitsRect(points, candidate.box, inkMargin)) {
744
+ graphIssues.push({ kind: 'edge-crosses-node', slide: slideIndex, graph: pathOf(graph), edge: pathOf(edge), node: pathOf(candidate.node), from: from.id, to: to.id });
745
+ }
746
+ }
747
+ // An arrow that almost lands on horizontal or vertical reads as a slip;
748
+ // a frank diagonal reads as a decision. Only the first offending segment
749
+ // is reported · an ortho route bends at right angles and never fires.
750
+ for (let p = 1; p < points.length; p++) {
751
+ const dx = Math.abs(points[p].x - points[p - 1].x);
752
+ const dy = Math.abs(points[p].y - points[p - 1].y);
753
+ const minor = Math.min(dx, dy);
754
+ const major = Math.max(dx, dy);
755
+ if (!major || minor <= limits.graphAlignFloorPx || minor > limits.graphAlignSlackPx) continue;
756
+ if (minor / major >= limits.graphSkewRatio) continue;
757
+ graphIssues.push({ kind: 'edge-skewed', slide: slideIndex, graph: pathOf(graph), edge: pathOf(edge), from: from.id, to: to.id, axis: dx >= dy ? 'horizontal' : 'vertical', pixels: Math.round(minor), slope: Math.round((minor / major) * 100) });
758
+ break;
759
+ }
760
+ }
761
+ }
762
+
763
+ // What the deck says it lasts, and what it gives someone to say. Notes are
764
+ // the script; the projected words are read, not spoken, so they count for
765
+ // little. This is an order of magnitude, never a verdict.
766
+ const coverEl = slides.find((el) => el.tagName.toLowerCase() === 'deck-cover');
767
+ const announced = scanDocument ? coverEl?.getAttribute('duration') ?? null : null;
768
+ const countWords = (text) => (text.match(/[\p{L}\p{N}'’-]+/gu) ?? []).length;
769
+ let spokenWords = 0;
770
+ for (const el of scanDocument ? document.querySelectorAll('deck-notes') : []) {
771
+ spokenWords += countWords(el.textContent ?? '');
772
+ }
773
+
774
+ return {
775
+ announced,
776
+ spokenWords,
777
+ scannedDocument: scanDocument,
778
+ hasRoot: !!root,
779
+ runtimeLoaded: !!customElements.get('deck-root'),
780
+ slides: measured,
781
+ unknown,
782
+ strayAttributes,
783
+ unslotted,
784
+ graphIssues,
785
+ };
786
+ };
787
+
788
+ /** What the pixels say · imbalance only, never the amount of empty space. */
789
+ function diagnoseVisual(visual, outline, limits) {
790
+ const found = [];
791
+ for (const slide of visual) {
792
+ if (slide.empty) continue;
793
+ const named = outline[slide.index - 1] ?? {};
794
+ // Both conditions together: ink pulled up AND a dead band under it. Either
795
+ // one alone is a legitimate composition.
796
+ if (slide.tailBand > limits.tailBand && slide.verticalBias < limits.verticalBias) {
797
+ found.push(
798
+ diagnostic('SLIDE_TOP_HEAVY', SEVERITY.warning, `the content sits in the top of the slide · ${Math.round(slide.tailBand * 100)}% of the height below it is empty`, {
799
+ slide: slide.index,
800
+ slideId: named.id ?? null,
801
+ slideTag: named.tag ?? null,
802
+ measurement: {
803
+ emptyBandBelow: Math.round(slide.tailBand * 100) / 100,
804
+ verticalBias: Math.round(slide.verticalBias * 100) / 100,
805
+ inkRatio: Math.round(slide.inkRatio * 100) / 100,
806
+ },
807
+ suggestion: 'distribute with `spread` (center, between, around), or give the slide content that earns the space · a lone box under a headline is not restraint',
808
+ }),
809
+ );
810
+ }
811
+ }
812
+ return found;
813
+ }
814
+
815
+ /** Turn the measurements into findings · this is where policy lives. */
816
+ function diagnose(page, source, limits) {
817
+ const found = [];
818
+
819
+ if (!page.runtimeLoaded) {
820
+ found.push(
821
+ diagnostic(
822
+ 'RUNTIME_NOT_LOADED',
823
+ SEVERITY.error,
824
+ 'the rikiki runtime never registered its elements · every slide stays raw HTML',
825
+ { suggestion: 'check the <script type="module"> path, and serve over http:// · ES modules do not load from file://' },
826
+ ),
827
+ );
828
+ }
829
+ if (!page.hasRoot) {
830
+ found.push(
831
+ diagnostic('NO_DECK_ROOT', SEVERITY.error, 'the page has no <deck-root>', {
832
+ suggestion: 'wrap the slides in <deck-root>…</deck-root>',
833
+ }),
834
+ );
835
+ } else if (page.slides.length === 0) {
836
+ found.push(
837
+ diagnostic('NO_SLIDES', SEVERITY.error, '<deck-root> holds no deck-* element', {
838
+ suggestion: 'add at least one slide, e.g. <deck-cover><h1>…</h1></deck-cover>',
839
+ }),
840
+ );
841
+ }
842
+
843
+ // When the runtime never loaded, every deck-* element is undefined. Saying so
844
+ // once is a diagnosis; saying it per element is noise on top of the cause.
845
+ for (const u of page.runtimeLoaded ? page.unknown : []) {
846
+ // A name no custom element could ever have was not typed as markup: it is
847
+ // prose that reached the parser unescaped, and the parser made a node of it.
848
+ const impossible = !/^[a-z][a-z0-9]*(-[a-z0-9]+)+$/.test(u.tag);
849
+ found.push(
850
+ impossible
851
+ ? diagnostic('STRAY_MARKUP', SEVERITY.warning, `<${u.tag}> is not a possible element name · this looks like text the parser read as a tag`, {
852
+ element: u.path,
853
+ suggestion: 'escape the angle brackets (&lt; &gt;) where the deck talks about markup',
854
+ })
855
+ : diagnostic('UNKNOWN_ELEMENT', u.knownPrefix || u.tag.startsWith('deck-') ? SEVERITY.error : SEVERITY.warning, `<${u.tag}> is not defined · its component behavior is unavailable`, {
856
+ element: u.path,
857
+ suggestion: 'check the spelling and load the module that defines this custom element',
858
+ }),
859
+ );
860
+ }
861
+
862
+ for (const lost of page.runtimeLoaded ? page.unslotted : []) {
863
+ found.push(
864
+ diagnostic('CONTENT_NOT_RENDERED', SEVERITY.error, `<${lost.tag}> is inside <${lost.parent}> but no slot takes it · nothing of it appears`, {
865
+ element: lost.path,
866
+ measurement: { wantedSlot: lost.wanted, slotsOffered: lost.offered },
867
+ suggestion: lost.wanted
868
+ ? `<${lost.parent}> offers ${lost.offered.join(', ')} · check the slot name`
869
+ : `<${lost.parent}> only forwards named slots · give this element one of ${lost.offered.join(', ')}`,
870
+ }),
871
+ );
872
+ }
873
+
874
+ for (const stray of page.runtimeLoaded ? page.strayAttributes : []) {
875
+ found.push(
876
+ diagnostic('UNKNOWN_ATTRIBUTE', SEVERITY.warning, `<${stray.tag}> ignores ${stray.attr}="…" · the value is dropped, not rendered`, {
877
+ element: stray.path,
878
+ measurement: { accepts: stray.observed },
879
+ suggestion: `this element reads ${stray.observed.length ? stray.observed.join(', ') : 'no attribute'} · everything else goes in its content`,
880
+ }),
881
+ );
882
+ }
883
+
884
+ for (const issue of page.runtimeLoaded ? page.graphIssues ?? [] : []) {
885
+ const outline = issue.slide ? page.slides[issue.slide - 1] : null;
886
+ const where = {
887
+ slide: issue.slide ?? undefined,
888
+ slideId: outline?.id ?? null,
889
+ slideTag: outline?.tag ?? null,
890
+ element: issue.node,
891
+ };
892
+ if (issue.kind === 'node-out') {
893
+ found.push(
894
+ diagnostic('GRAPH_NODE_OUT_OF_BOUNDS', SEVERITY.error, `a graph node sits ${issue.pixels}px outside its canvas`, {
895
+ ...where,
896
+ measurement: { overflowPx: issue.pixels, sides: issue.overflow },
897
+ suggestion: 'move the node inward with `at`, shorten its note, or constrain it with `width` / `--deck-node-size`',
898
+ }),
899
+ );
900
+ } else if (issue.kind === 'node-overlap') {
901
+ const name = (id, path) => (id ? `"${id}"` : path);
902
+ found.push(
903
+ diagnostic('GRAPH_NODE_OVERLAPS_NODE', SEVERITY.error, `the ${name(issue.a, issue.node)} and ${name(issue.b, issue.other)} nodes overlap by ${issue.overlap.x}x${issue.overlap.y}px`, {
904
+ ...where,
905
+ measurement: { overlapPx: issue.overlap, nodes: [issue.node, issue.other] },
906
+ suggestion: 'move one of them with `at`, or narrow both with `width` / `--deck-node-size` · a node hidden behind another is a node nobody reads',
907
+ }),
908
+ );
909
+ } else if (issue.kind === 'edge-crosses-node') {
910
+ found.push(
911
+ diagnostic('GRAPH_EDGE_CROSSES_NODE', SEVERITY.warning, `the ${issue.from} → ${issue.to} edge passes under another node`, {
912
+ ...where,
913
+ element: issue.edge,
914
+ measurement: { obstructingNode: issue.node, from: issue.from, to: issue.to },
915
+ suggestion: 'move the obstructing node or split the route into a clear path; an orthogonal route is preferable when available',
916
+ }),
917
+ );
918
+ } else if (issue.kind === 'label-covered') {
919
+ found.push(
920
+ diagnostic('GRAPH_NODE_COVERS_LABEL', SEVERITY.error, `a node is painted over the "${issue.text}" ${issue.labelKind} label · ${issue.overlap.x}x${issue.overlap.y}px of it`, {
921
+ ...where,
922
+ element: issue.label,
923
+ measurement: { overlapPx: issue.overlap, node: issue.node },
924
+ suggestion: issue.labelKind === 'edge'
925
+ ? 'move the node with `at`, or the caption with `label-offset` · a label under a node is a label nobody reads'
926
+ : 'move the node with `at`, or the region with its own `at` · a label under a node is a label nobody reads',
927
+ }),
928
+ );
929
+ } else if (issue.kind === 'edge-skewed') {
930
+ found.push(
931
+ diagnostic('GRAPH_EDGE_SKEWED', SEVERITY.warning, `the ${issue.from} → ${issue.to} edge misses ${issue.axis} by ${issue.pixels}px`, {
932
+ ...where,
933
+ element: issue.edge,
934
+ measurement: { deviationPx: issue.pixels, slopePercent: issue.slope, axis: issue.axis },
935
+ suggestion: 'give both nodes the same `at` coordinate on that axis, or set `route="ortho"` · a frank diagonal is a choice, a three-degree slope is a slip',
936
+ }),
937
+ );
938
+ } else if (issue.kind === 'nodes-off-axis') {
939
+ const name = (id, path) => (id ? `"${id}"` : path);
940
+ const line = issue.axis === 'x' ? 'column' : 'row';
941
+ found.push(
942
+ diagnostic('GRAPH_NODES_OFF_AXIS', SEVERITY.warning, `the ${name(issue.a, issue.node)} and ${name(issue.b, issue.other)} nodes miss the same ${line} by ${issue.pixels}px`, {
943
+ ...where,
944
+ measurement: { offsetPx: issue.pixels, axis: issue.axis, nodes: [issue.node, issue.other] },
945
+ suggestion: `give them the same \`at\` coordinate on that axis · this close, they were meant to share a ${line}`,
946
+ }),
947
+ );
948
+ } else if (issue.kind === 'sizes-mixed') {
949
+ const name = (id, path) => (id ? `"${id}"` : path);
950
+ found.push(
951
+ diagnostic('GRAPH_NODE_SIZES_MIXED', SEVERITY.warning, `the ${name(issue.a, issue.node)} and ${name(issue.b, issue.other)} nodes share a ${issue.along} but differ by ${issue.pixels}px in ${issue.dimension} · ${issue.share}%`, {
952
+ ...where,
953
+ measurement: { differencePx: issue.pixels, sharePercent: issue.share, dimension: issue.dimension, nodes: [issue.node, issue.other] },
954
+ suggestion: 'set `width` or `--deck-node-size` on both, or even out their notes · near-equal boxes read as a failed attempt at the same size, clearly different ones read as a hierarchy',
955
+ }),
956
+ );
957
+ } else if (issue.kind === 'layout-not-semantic') {
958
+ found.push(
959
+ diagnostic('GRAPH_LAYOUT_NOT_SEMANTIC', SEVERITY.warning, `every node of this graph sits on one ${issue.layout} · the \`at\` coordinates spell out what layout="${issue.layout}" already says`, {
960
+ ...where,
961
+ element: issue.graph,
962
+ measurement: { nodes: issue.nodes, arrangement: issue.layout },
963
+ suggestion: `set layout="${issue.layout}" and drop the \`at\` · the arrangement then survives a node added or removed`,
964
+ }),
965
+ );
966
+ }
967
+ }
968
+
969
+ const ids = page.slides.map((s) => s.id).filter(Boolean);
970
+ const duplicated = [...new Set(ids.filter((id, i) => ids.indexOf(id) !== i))];
971
+ for (const id of duplicated) {
972
+ found.push(
973
+ diagnostic('DUPLICATE_SLIDE_ID', SEVERITY.warning, `two slides share the id "${id}"`, {
974
+ suggestion: 'ids address a slide for a targeted edit or a render selection · make them unique',
975
+ }),
976
+ );
977
+ }
978
+
979
+ // Layout measurements read a styled deck. On a page whose runtime never ran,
980
+ // every box is raw HTML: the numbers would be real and meaningless.
981
+ for (const slide of page.runtimeLoaded ? page.slides : []) {
982
+ if (slide.measured === false) continue; // measured by another pass of the walk
983
+ const where = { slide: slide.index, slideId: slide.id, slideTag: slide.tag };
984
+ const clippedHere = slide.clipped && slide.clipped.pixels > limits.clipPx;
985
+ if (clippedHere) {
986
+ found.push(
987
+ diagnostic('CONTENT_CLIPPED', SEVERITY.error, `content is cut off · ${slide.clipped.pixels}px do not fit`, {
988
+ ...where,
989
+ element: slide.clipped.path,
990
+ measurement: { clippedPx: slide.clipped.pixels, axis: slide.clipped.axis },
991
+ suggestion: 'the engine clips rather than scrolls · cut a sentence, move detail into <deck-notes>, or split the slide',
992
+ }),
993
+ );
994
+ }
995
+ // Density is what you say about a slide that still fits. Once it is
996
+ // clipped, saying "nothing is cut yet" underneath contradicts the line
997
+ // above it.
998
+ if (!clippedHere && slide.fillRatio > limits.denseFillRatio) {
999
+ found.push(
1000
+ diagnostic('SLIDE_DENSE', SEVERITY.warning, `the content spans ${Math.round(slide.fillRatio * 100)}% of the slide height`, {
1001
+ ...where,
1002
+ measurement: { fillRatio: slide.fillRatio },
1003
+ suggestion: 'nothing is cut yet, but there is no room left · consider splitting',
1004
+ }),
1005
+ );
1006
+ }
1007
+ if (slide.escapesBox) {
1008
+ const e = slide.escapesBox;
1009
+ found.push(
1010
+ diagnostic('CONTENT_ESCAPES_BOX', SEVERITY.error, `"${e.text}" spills ${e.pixels}px past its box · "${e.containerText}"`, {
1011
+ ...where,
1012
+ element: e.path,
1013
+ measurement: { escapePx: e.pixels, container: e.containerPath, containerText: e.containerText },
1014
+ suggestion: 'nothing clips here, so the box is simply too small for what is inside it · shorten the text, or give the box more room',
1015
+ }),
1016
+ );
1017
+ }
1018
+ if (slide.overlapsSibling) {
1019
+ const o = slide.overlapsSibling;
1020
+ found.push(
1021
+ diagnostic('CONTENT_OVERLAPS_SIBLING', SEVERITY.error, `"${o.textA}" overlaps "${o.textB}" by ${o.overlap.x}x${o.overlap.y}px`, {
1022
+ ...where,
1023
+ element: o.pathA,
1024
+ measurement: { overlapPx: o.overlap, other: o.pathB, otherText: o.textB },
1025
+ suggestion: 'two unrelated blocks paint over each other · give the earlier one a fixed height, or the layout more room to breathe',
1026
+ }),
1027
+ );
1028
+ }
1029
+ if (slide.lastLineOrphan) {
1030
+ const o = slide.lastLineOrphan;
1031
+ found.push(
1032
+ diagnostic('TEXT_LAST_LINE_ORPHAN', SEVERITY.warning, `"${o.tail}" hangs alone on the last line · ${Math.round(o.ratio * 100)}% of the width above it`, {
1033
+ ...where,
1034
+ element: o.path,
1035
+ measurement: { lastLineRatio: Math.round(o.ratio * 100) / 100, lines: o.lines, floorRatio: limits.orphanLineRatio },
1036
+ excerpt: o.tail,
1037
+ suggestion: 'set `text-wrap: pretty`, shorten the wording, or bind the last words with a non-breaking space · on a wall this reads as a typographic accident',
1038
+ }),
1039
+ );
1040
+ }
1041
+ for (const t of slide.tiny) {
1042
+ found.push(
1043
+ diagnostic('TEXT_TOO_SMALL', SEVERITY.warning, `text renders at ${t.px}px · the back row will not read it`, {
1044
+ ...where,
1045
+ element: t.path,
1046
+ measurement: { renderedPx: t.px, floorPx: limits.minTextPx },
1047
+ excerpt: t.text,
1048
+ suggestion: 'use the deck type scale rather than a hardcoded size',
1049
+ }),
1050
+ );
1051
+ }
1052
+ }
1053
+
1054
+ // A duration on the cover is a promise to whoever books the room.
1055
+ const minutes = Number.parseFloat(String(page.announced ?? '').replace(',', '.'));
1056
+ if (page.runtimeLoaded && Number.isFinite(minutes) && minutes > 0) {
1057
+ const spokenMinutes = page.spokenWords / limits.wordsPerMinute;
1058
+ const ratio = spokenMinutes / minutes;
1059
+ if (ratio < limits.talkLengthTolerance) {
1060
+ found.push(
1061
+ diagnostic('TALK_SHORTER_THAN_ANNOUNCED', SEVERITY.warning, `the cover announces ${minutes} min · the notes carry about ${spokenMinutes.toFixed(0)} min of speech`, {
1062
+ measurement: { announcedMinutes: minutes, spokenWords: page.spokenWords, wordsPerMinute: limits.wordsPerMinute },
1063
+ suggestion: 'either the deck has more to say than its notes admit, or the slot is shorter than announced · an estimate from the notes alone, never a verdict',
1064
+ }),
1065
+ );
1066
+ }
1067
+ }
1068
+
1069
+ // Said about the file, not about the run: a deck that fetches from a CDN is
1070
+ // fine on a network and empty on a plane. It is about the source, so it is
1071
+ // said on the pass that reads the whole document, not once per slide.
1072
+ for (const hit of page.scannedDocument === false ? [] : scanExternal(source).filter((h) => /^https?:|^\/\//.test(h.ref))) {
1073
+ found.push(
1074
+ diagnostic('EXTERNAL_DEPENDENCY', SEVERITY.warning, `the deck fetches ${hit.ref} at runtime`, {
1075
+ suggestion: 'fine on a network · run `rikiki bundle` for a file that opens offline',
1076
+ }),
1077
+ );
1078
+ }
1079
+
1080
+ return found;
1081
+ }
1082
+
1083
+ /** Codes whose message states a measurement. Between two states of one slide
1084
+ * that number drifts by a pixel and the finding is still the same one, so it
1085
+ * is blanked out of the identity. Nowhere else: two dependencies that differ
1086
+ * only by a version number are two dependencies, and blanking their digits
1087
+ * reported one. */
1088
+ const MEASURED_IN_MESSAGE = new Set([
1089
+ 'CONTENT_CLIPPED',
1090
+ 'CONTENT_ESCAPES_BOX',
1091
+ 'CONTENT_OVERLAPS_SIBLING',
1092
+ 'SLIDE_DENSE',
1093
+ 'SLIDE_TOP_HEAVY',
1094
+ 'TEXT_TOO_SMALL',
1095
+ 'TEXT_LAST_LINE_ORPHAN',
1096
+ 'GRAPH_NODE_OUT_OF_BOUNDS',
1097
+ 'GRAPH_NODE_OVERLAPS_NODE',
1098
+ 'GRAPH_NODE_COVERS_LABEL',
1099
+ 'GRAPH_EDGE_SKEWED',
1100
+ 'GRAPH_NODES_OFF_AXIS',
1101
+ 'GRAPH_NODE_SIZES_MIXED',
1102
+ 'TALK_SHORTER_THAN_ANNOUNCED',
1103
+ ]);
1104
+
1105
+ /** Two diagnostics identical on (code, slide, element, wording) are the same
1106
+ * finding seen at a different moment · keep the earliest state it held.
1107
+ *
1108
+ * The wording counts because one element carries several findings of the same
1109
+ * code · two attributes it ignores are two diagnostics on the same path, and
1110
+ * a diagnostic with no element at all is identified by its words alone. */
1111
+ function dedupeStateDiagnostics(diagnostics) {
1112
+ const kept = new Map();
1113
+ for (const d of diagnostics) {
1114
+ const message = String(d.message ?? '');
1115
+ const wording = MEASURED_IN_MESSAGE.has(d.code) ? message.replace(/[\d.]+/g, '#') : message;
1116
+ const key = JSON.stringify([d.plugin ?? 'rikiki', d.code, d.slide ?? null, d.element ?? null, d.key ?? wording]);
1117
+ const prior = kept.get(key);
1118
+ if (!prior || d.state < prior.state) kept.set(key, d);
1119
+ }
1120
+ return [...kept.values()];
1121
+ }
1122
+
1123
+ /** Walk the deck slide by slide, running `diagnose` on each.
1124
+ *
1125
+ * Both modes take this path: a slide is only laid out while it is on screen,
1126
+ * so even the opening state of slide 2 has to be navigated to before it can
1127
+ * be measured. With `steps`, each slide is also carried through every state
1128
+ * `advanceStep` reaches and the finding is tagged with the state it was
1129
+ * measured in · 0 for the opening state. Without it there is one state per
1130
+ * slide and nothing to tag.
1131
+ *
1132
+ * Whatever does not depend on which slide is showing is asked once, on the
1133
+ * first pass, rather than once per state. */
1134
+ async function diagnoseAllStates(page, slideCount, inspectOpts, source, limits, { steps, plugins, pluginTimeoutMs }) {
1135
+ const found = [];
1136
+ let statesInspected = 0;
1137
+ for (let index = 1; index <= slideCount; index++) {
1138
+ if (page.isClosed()) break;
1139
+ try {
1140
+ await goToSlide(page, index);
1141
+ } catch {
1142
+ // The deck did not arrive · a script swallowing the hash, a slide that
1143
+ // throws on connect. Report it and keep what the earlier slides gave:
1144
+ // a deck nobody can walk still deserves the report it already earned,
1145
+ // and an agent reading the JSON gets a diagnosis instead of a crash.
1146
+ found.push(
1147
+ diagnostic('NAVIGATION_STALLED', SEVERITY.error, `the deck never arrived at slide ${index} · the walk stopped there`, {
1148
+ slide: index,
1149
+ measurement: { slidesMeasured: index - 1, slideCount },
1150
+ suggestion: 'navigation is driven by the location hash · check for a script that intercepts it, or a slide that throws while connecting',
1151
+ }),
1152
+ );
1153
+ break;
1154
+ }
1155
+ let state = 0;
1156
+ for (;;) {
1157
+ const scanDocument = index === 1 && state === 0;
1158
+ const snapshot = await page.evaluate(inspectPage, { ...inspectOpts, only: index, scanDocument });
1159
+ statesInspected += 1;
1160
+ for (const d of diagnose(snapshot, source, limits)) found.push(steps ? { ...d, state } : d);
1161
+ found.push(...await runCheckPlugins(page, plugins, { slide: index, state, documentPass: scanDocument }, pluginTimeoutMs));
1162
+ if (page.isClosed()) break;
1163
+ if (!steps || !(await advanceStep(page))) break;
1164
+ state += 1;
1165
+ }
1166
+ }
1167
+ return { diagnostics: dedupeStateDiagnostics(found), statesInspected };
1168
+ }
1169
+
1170
+ /**
1171
+ * Inspect a deck and report what is wrong with it.
1172
+ * @returns {Promise<object>} the versioned report.
1173
+ */
1174
+ export async function checkDeck(
1175
+ deckPath,
1176
+ { timeoutMs = PAGE_LOAD_TIMEOUT_MS, width = 1920, height = 1080, visual = true, steps = false,
1177
+ config, plugins = [], noPlugins = false, pluginTimeoutMs = 5000, narrativeOut, narrativeReview } = {},
1178
+ ) {
1179
+ const source = readFileSync(deckPath, 'utf8');
1180
+ const limits = LIMITS;
1181
+ const resolution = resolveCheckPlugins(deckPath, { config, plugins, noPlugins });
1182
+ if (!Number.isFinite(pluginTimeoutMs) || pluginTimeoutMs < 1) throw new Error('pluginTimeoutMs must be positive');
1183
+
1184
+ return withDeck(
1185
+ deckPath,
1186
+ async ({ page, settled, missing, errors }) => {
1187
+ const inspectOpts = { limits, titleReader: SLIDE_TITLE_READER, graphGeometry: GRAPH_GEOMETRY_READER, boxGeometry: BOX_GEOMETRY_READER };
1188
+ // The outline, and nothing measured · `only: 0` names no slide. What the
1189
+ // walk below needs from this pass is how many slides there are and what
1190
+ // they are called; measuring them here would read the empty rects of
1191
+ // every slide that is not on screen, which is the bug this walk fixes.
1192
+ const outlineOpts = { ...inspectOpts, only: 0 };
1193
+ const observed = settled
1194
+ ? await page.evaluate(inspectPage, outlineOpts)
1195
+ : await page.evaluate(inspectPage, outlineOpts).catch(() => ({
1196
+ hasRoot: false,
1197
+ runtimeLoaded: false,
1198
+ slides: [],
1199
+ unknown: [],
1200
+ }));
1201
+
1202
+ let diagnostics;
1203
+ let statesInspected;
1204
+ if (settled) await startCheckPlugins(page, resolution, pluginTimeoutMs);
1205
+ else for (const plugin of resolution.plugins) {
1206
+ plugin.status = 'skipped';
1207
+ resolution.notChecked.push(`plugin ${plugin.id} · deck did not settle`);
1208
+ }
1209
+ if (settled && observed.slides.length && !page.isClosed()) {
1210
+ const walked = await diagnoseAllStates(page, observed.slides.length, inspectOpts, source, limits, { steps, plugins: resolution, pluginTimeoutMs });
1211
+ diagnostics = walked.diagnostics;
1212
+ statesInspected = walked.statesInspected;
1213
+ } else {
1214
+ // Nothing to walk: the deck never settled, or it holds no slide at
1215
+ // all. One whole-document pass is all there is to report · on a deck
1216
+ // that never settled the geometry is unreliable anyway, but saying
1217
+ // nothing about it would be worse.
1218
+ const whole = observed.slides.length && !page.isClosed()
1219
+ ? await page.evaluate(inspectPage, { ...inspectOpts, only: null }).catch(() => observed)
1220
+ : observed;
1221
+ diagnostics = diagnose(whole, source, limits);
1222
+ statesInspected = observed.slides.length;
1223
+ }
1224
+ diagnostics.push(...resolution.diagnostics);
1225
+
1226
+ let narrative = { status: 'not-run' };
1227
+ if ((narrativeOut || narrativeReview) && !page.isClosed() && settled) {
1228
+ const slides = await page.evaluate(collectNarrative);
1229
+ const request = narrativeRequest(deckPath, source, slides, { width, height }, resolution.config.narrative);
1230
+ if (narrativeOut) {
1231
+ const { writeFileSync } = await import('node:fs');
1232
+ writeFileSync(narrativeOut, JSON.stringify(request, null, 2) + '\n');
1233
+ narrative = { status: 'pending', digest: request.digest, request: narrativeOut };
1234
+ }
1235
+ if (narrativeReview) {
1236
+ const reviewed = applyNarrativeReview(request, narrativeReview);
1237
+ narrative = reviewed.narrative;
1238
+ diagnostics.push(...reviewed.diagnostics);
1239
+ }
1240
+ } else if (narrativeOut || narrativeReview) {
1241
+ narrative = { status: 'failed' };
1242
+ diagnostics.push(diagnostic('NARRATIVE_UNAVAILABLE', 'error', 'Narrative review requires a settled deck and an open browser'));
1243
+ }
1244
+
1245
+ // The pixel pass needs a settled deck and one screenshot per slide · it
1246
+ // is the slowest thing here, so it is skippable.
1247
+ let visualMeasured = false;
1248
+ if (visual && settled && observed.slides.length && !page.isClosed()) {
1249
+ const goTo = async (index) => {
1250
+ await page.evaluate((i) => {
1251
+ window.location.hash = `#${i}`;
1252
+ }, index);
1253
+ await page
1254
+ .waitForFunction((i) => document.querySelector('deck-root')?.current === i - 1, index, {
1255
+ timeout: NAVIGATION_TIMEOUT_MS,
1256
+ })
1257
+ .catch(() => {});
1258
+ await waitForStillFrame(page);
1259
+ };
1260
+ const measured = await measureSlides(page, observed.slides.length, goTo);
1261
+ diagnostics.push(...diagnoseVisual(measured, observed.slides, limits));
1262
+ visualMeasured = true;
1263
+ }
1264
+
1265
+ for (const message of [...new Set(errors)]) {
1266
+ diagnostics.unshift(
1267
+ diagnostic('PAGE_ERROR', SEVERITY.error, `the page threw: ${message}`, {
1268
+ suggestion: 'an exception during setup usually leaves the rest of the deck unbuilt',
1269
+ }),
1270
+ );
1271
+ }
1272
+ // The same miss arrives twice, once as a failed request and once as a 4xx
1273
+ // response · one file, one diagnostic.
1274
+ const byUrl = new Map();
1275
+ for (const entry of missing) {
1276
+ const url = entry.replace(/ \(HTTP \d+\)$/, '');
1277
+ const status = entry.match(/ \(HTTP (\d+)\)$/)?.[1];
1278
+ byUrl.set(url, status ?? byUrl.get(url) ?? null);
1279
+ }
1280
+ for (const [url, status] of byUrl) {
1281
+ diagnostics.unshift(
1282
+ diagnostic('RESOURCE_MISSING', SEVERITY.error, `the deck could not load ${url}${status ? ` (HTTP ${status})` : ''}`, {
1283
+ url,
1284
+ httpStatus: status ? Number(status) : null,
1285
+ suggestion: 'check the path · a missing runtime leaves every slide raw, a missing image leaves a gap',
1286
+ }),
1287
+ );
1288
+ }
1289
+
1290
+ const summary = { error: 0, warning: 0 };
1291
+ for (const d of diagnostics) summary[d.severity] = (summary[d.severity] ?? 0) + 1;
1292
+
1293
+ return {
1294
+ schema: REPORT_SCHEMA,
1295
+ deck: basename(deckPath),
1296
+ settled,
1297
+ slideCount: observed.slides.length,
1298
+ statesInspected,
1299
+ limits,
1300
+ visualMeasured,
1301
+ summary,
1302
+ diagnostics,
1303
+ plugins: pluginReport(resolution),
1304
+ narrative,
1305
+ // Named so a reader does not mistake silence for a clean bill.
1306
+ notChecked: [
1307
+ ...resolution.notChecked,
1308
+ ...(narrative.status === 'completed' ? [] : [`narrative composition · ${narrative.status} (review by the current agent)`]),
1309
+ ...(observed.runtimeLoaded
1310
+ ? []
1311
+ : ['layout · the runtime never ran, so nothing about size or fit was measured']),
1312
+ ...(steps ? [] : ['revealed steps · only the opening state of each slide is measured']),
1313
+ 'accessibility · no contrast, focus order or screen-reader check is run',
1314
+ 'wording, facts and figures · nothing here reads the content',
1315
+ 'what a speaker actually says · the length estimate reads the notes, not the room',
1316
+ 'other viewports · the deck is measured at its own canvas size',
1317
+ 'text inside a diagram · an SVG scales by its viewBox, which is not measured here',
1318
+ "a component's own chrome · only the text an author wrote is measured for size",
1319
+ ...(visualMeasured
1320
+ ? ['pixels of revealed states · the visual pass photographs only the opening state of each slide']
1321
+ : ['the pixels · the visual pass did not run']),
1322
+ ],
1323
+ };
1324
+ },
1325
+ { timeoutMs, viewport: { width, height } },
1326
+ );
1327
+ }
1328
+
1329
+ /** The human rendering of a report · the JSON is the machine one. */
1330
+ export function formatReport(report) {
1331
+ const lines = [];
1332
+ const icon = { error: '✗', warning: '!' };
1333
+ for (const d of report.diagnostics) {
1334
+ const at = d.slide ? ` · slide ${d.slide}${d.slideId ? ` (#${d.slideId})` : ''}` : '';
1335
+ const atState = d.state !== undefined ? ` · state ${d.state}` : '';
1336
+ lines.push(`${icon[d.severity] ?? '·'} ${d.code}${at}${atState}`);
1337
+ lines.push(` ${d.message}`);
1338
+ if (d.element) lines.push(` at ${d.element}`);
1339
+ if (d.suggestion) lines.push(` try ${d.suggestion}`);
1340
+ }
1341
+ const { error, warning } = report.summary;
1342
+ lines.push('');
1343
+ lines.push(
1344
+ `${report.deck} · ${report.slideCount} slide(s) · ${error} error(s), ${warning} warning(s)`,
1345
+ );
1346
+ return lines.join('\n');
1347
+ }