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,126 @@
1
+ // ════════════════════════════════════════════════════════════════
2
+ // scanExternal · everything a supposedly self-contained deck could still
3
+ // fetch at runtime.
4
+ //
5
+ // The previous check only matched absolute URLs from a hardcoded domain list,
6
+ // so a bundle that reached for `./vendor/mermaid.min.js` reported success and
7
+ // rendered an empty diagram offline. This one works the other way round: it
8
+ // finds every reference the page can load by itself, then subtracts the ones
9
+ // that are genuinely inert (data:, blob:, in-page fragments, user-facing links).
10
+ //
11
+ // Scripts are scanned separately and narrowly · minified JS is full of strings
12
+ // that look like markup (`href:n.href`, `"http://"`), and a whole-file regex
13
+ // reports those on every clean bundle.
14
+ // ════════════════════════════════════════════════════════════════
15
+
16
+ /** Attributes through which an element loads something on its own. */
17
+ const LOADING_ATTRS = ['src', 'srcset', 'poster', 'href'];
18
+
19
+ /** `href` only loads for these elements · on `<a>` and `<use>` it is navigation. */
20
+ const HREF_LOADS_ON = /^(?:link|image|script|iframe|embed|track|source)$/i;
21
+
22
+ const isInert = (ref) =>
23
+ !ref ||
24
+ ref.startsWith('data:') ||
25
+ ref.startsWith('blob:') ||
26
+ ref.startsWith('#') ||
27
+ ref.startsWith('mailto:') ||
28
+ ref.startsWith('tel:') ||
29
+ ref.startsWith('javascript:');
30
+
31
+ /** Keep every opening tag, drop the text inside <script> and <style>.
32
+ * Their bodies are code, not markup · a `<link …>` quoted in a CSS comment or
33
+ * a JS string is documentation, not a resource the page loads. */
34
+ function stripCodeBodies(html) {
35
+ return html.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, (m, tag) => {
36
+ const open = m.match(new RegExp(`<${tag}\\b[^>]*>`, 'i'));
37
+ return open ? `${open[0]}</${tag}>` : '';
38
+ });
39
+ }
40
+
41
+ function scriptBodies(html) {
42
+ return [...html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/gi)].map((m) => m[1]);
43
+ }
44
+
45
+ function styleBodies(html) {
46
+ return [...html.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)].map((m) => m[1]);
47
+ }
48
+
49
+ function scanTags(html, out) {
50
+ for (const tag of html.matchAll(/<([a-zA-Z][\w-]*)\b([^>]*)>/g)) {
51
+ const name = tag[1];
52
+ const attrs = tag[2] ?? '';
53
+ for (const attr of LOADING_ATTRS) {
54
+ // Quoted first, then unquoted · the inliner only ever handled quoted
55
+ // values, which is how an unquoted src survived it.
56
+ const re = new RegExp(`\\b${attr}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'>]+))`, 'gi');
57
+ for (const m of attrs.matchAll(re)) {
58
+ const ref = (m[1] ?? m[2] ?? m[3] ?? '').trim();
59
+ if (isInert(ref)) continue;
60
+ if (attr === 'href' && !HREF_LOADS_ON.test(name)) continue;
61
+ out.push({ kind: 'attribute', element: name.toLowerCase(), attr, ref });
62
+ }
63
+ }
64
+ }
65
+ }
66
+
67
+ function scanCss(rawCss, out) {
68
+ // A commented-out rule is not a dependency · tokens.css documents an
69
+ // alternative <link> inside its header comment.
70
+ const css = rawCss.replace(/\/\*[\s\S]*?\*\//g, '');
71
+ for (const m of css.matchAll(/@import\s+(?:url\(\s*)?["']?([^;"')]+)/g)) {
72
+ const ref = m[1].trim();
73
+ if (!isInert(ref)) out.push({ kind: 'css-import', ref });
74
+ }
75
+ // url() outside an @import · @import matches are removed first so a single
76
+ // reference is not reported twice.
77
+ const withoutImports = css.replace(/@import[^;]*;?/g, '');
78
+ for (const m of withoutImports.matchAll(/url\(\s*["']?([^)"']+)["']?\s*\)/g)) {
79
+ const ref = m[1].trim();
80
+ if (!isInert(ref)) out.push({ kind: 'css-url', ref });
81
+ }
82
+ }
83
+
84
+ function scanScript(js, out) {
85
+ // Only constructs that actually fetch · a bare string in minified code does not.
86
+ for (const m of js.matchAll(/new URL\(\s*["'`]([^"'`]+)["'`]\s*,\s*import\.meta\.url/g)) {
87
+ if (!isInert(m[1])) out.push({ kind: 'module-url', ref: m[1] });
88
+ }
89
+ for (const m of js.matchAll(/\bimport\(\s*["'`]([^"'`]+)["'`]\s*\)/g)) {
90
+ if (!isInert(m[1])) out.push({ kind: 'dynamic-import', ref: m[1] });
91
+ }
92
+ // A static import specifier is never a template literal, and the statement
93
+ // starts a line or follows a `;`/`}` · without both constraints, prose inside
94
+ // a library's own error message ("import `x` from `y`") reads as an import.
95
+ for (const m of js.matchAll(/(?:^|[;}])\s*import\s+[^;'"`]*?from\s*["']([^"']+)["']/gm)) {
96
+ if (!isInert(m[1])) out.push({ kind: 'static-import', ref: m[1] });
97
+ }
98
+ for (const m of js.matchAll(/importScripts\(\s*["'`]([^"'`]+)["'`]/g)) {
99
+ if (!isInert(m[1])) out.push({ kind: 'worker-import', ref: m[1] });
100
+ }
101
+ }
102
+
103
+ /** Every runtime reference that would leave the file, deduplicated by ref.
104
+ * An empty array is the only proof that a bundle is self-contained. */
105
+ export function scanExternal(html) {
106
+ const out = [];
107
+ const markup = stripCodeBodies(html);
108
+ scanTags(markup, out);
109
+ // Style bodies come from the ORIGINAL html · markup has had them emptied.
110
+ for (const css of styleBodies(html)) scanCss(css, out);
111
+ for (const js of scriptBodies(html)) scanScript(js, out);
112
+ // Inline style="" attributes can carry url() too.
113
+ for (const m of markup.matchAll(/\bstyle\s*=\s*"([^"]*)"/gi)) scanCss(m[1], out);
114
+
115
+ const seen = new Set();
116
+ return out.filter((hit) => {
117
+ if (seen.has(hit.ref)) return false;
118
+ seen.add(hit.ref);
119
+ return true;
120
+ });
121
+ }
122
+
123
+ /** One line per hit, ready to print. */
124
+ export function formatExternal(hits) {
125
+ return hits.map((h) => ` · [${h.kind}] ${h.ref}`).join('\n');
126
+ }
@@ -1,7 +1,12 @@
1
1
  // ════════════════════════════════════════════════════════════════
2
- // Starter deck template for `rikiki init`. Returns plain deck HTML whose
3
- // refs (tokens.css / themes / dist) resolve against the rikiki package
4
- // root · the inliner then folds everything into one self-contained file.
2
+ // Starter deck template for `rikiki init`. Returns plain deck HTML.
3
+ //
4
+ // `assetBase` decides which of the two shapes it takes:
5
+ // '' · refs resolve against the rikiki package root · the inliner
6
+ // then folds everything into one self-contained file.
7
+ // 'rikiki/' · refs point at the runtime copied next to the deck · the file
8
+ // stays a readable source you serve over HTTP and keep editing.
9
+ // Both are bundle-able: the inliner maps a `rikiki/…` ref back to the package.
5
10
  // ════════════════════════════════════════════════════════════════
6
11
 
7
12
  const esc = (s) => String(s).replace(/[&<>"]/g, (c) =>
@@ -13,21 +18,23 @@ const esc = (s) => String(s).replace(/[&<>"]/g, (c) =>
13
18
  * @param {'rikiki'|'siliceum'} o.theme
14
19
  * @param {boolean} o.withMermaid include + preload the mermaid runtime
15
20
  * @param {boolean} o.withShiki include + activate the Shiki highlighter
21
+ * @param {string} o.assetBase prefix every asset ref ('' or 'rikiki/')
16
22
  */
17
- export function starterHtml({ title = 'My deck', theme = 'rikiki', withMermaid = false, withShiki = false } = {}) {
18
- const themeHref = theme === 'siliceum' ? 'themes/siliceum.css' : 'tokens.css';
23
+ export function starterHtml({ title = 'My deck', theme = 'rikiki', withMermaid = false, withShiki = false, assetBase = '' } = {}) {
24
+ const asset = (path) => assetBase + path;
25
+ const themeHref = asset(theme === 'siliceum' ? 'themes/siliceum.css' : 'tokens.css');
19
26
 
20
27
  // Heavy plugins · injected as refs the inliner folds in. mermaid's UMD sets
21
28
  // window.mermaid (deck-mermaid then skips its network load); the shiki module
22
29
  // exposes the vendored highlighter as a global and activates it.
23
30
  const mermaidTag = withMermaid
24
- ? '<script src="dist/vendor/mermaid.min.js"></script>\n'
31
+ ? `<script src="${asset('dist/vendor/mermaid.min.js')}"></script>\n`
25
32
  : '';
26
33
  const shikiTag = withShiki
27
34
  ? `<script type="module">
28
- import { createHighlighter } from './dist/vendor/shiki.js';
35
+ import { createHighlighter } from './${asset('dist/vendor/shiki.js')}';
29
36
  globalThis.__rikikiShiki = createHighlighter;
30
- import { installShiki } from './dist/shiki.js';
37
+ import { installShiki } from './${asset('dist/shiki.js')}';
31
38
  await installShiki();
32
39
  </script>\n`
33
40
  : '';
@@ -49,7 +56,7 @@ await installShiki();
49
56
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
50
57
  <title>${esc(title)}</title>
51
58
  <link rel="stylesheet" href="${themeHref}">
52
- ${mermaidTag}${shikiTag}<script type="module" src="dist/index.js"></script>
59
+ ${mermaidTag}${shikiTag}<script type="module" src="${asset('dist/index.js')}"></script>
53
60
  </head>
54
61
  <body>
55
62
 
@@ -57,23 +64,29 @@ ${mermaidTag}${shikiTag}<script type="module" src="dist/index.js"></script>
57
64
 
58
65
  <deck-cover brand="rikiki" speaker="Your name" duration="~10 min" audience="Your audience">
59
66
  <h1>${esc(title)} <span class="accent">deck</span></h1>
60
- <p class="sub">A self-contained, shareable slide deck.</p>
67
+ <p class="sub">${assetBase ? 'An editable deck · one HTML file you own.' : 'A self-contained, shareable slide deck.'}</p>
61
68
  </deck-cover>
62
69
 
63
70
  <deck-feature eyebrow="Start here">
64
71
  <h1 slot="title">Edit <span class="accent">this file</span></h1>
65
72
  <deck-md>
66
- This whole deck is **one HTML file** with zero external links.
73
+ ${assetBase
74
+ ? `This deck is **plain HTML** you edit by hand.
75
+
76
+ - each \`&lt;deck-*&gt;\` element is a slide
77
+ - the runtime sits in \`${assetBase.replace(/\/$/, '')}/\` next to this file
78
+ - serve the folder over HTTP, then \`rikiki bundle\` it to share one file`
79
+ : `This whole deck is **one HTML file** with zero external links.
67
80
 
68
81
  - open it anywhere, offline
69
- - each \`<deck-*>\` element is a slide
70
- - press **?** for keyboard shortcuts
82
+ - each \`&lt;deck-*&gt;\` element is a slide
83
+ - press **?** for keyboard shortcuts`}
71
84
  </deck-md>
72
85
  </deck-feature>
73
86
  ${mermaidSlide}
74
87
  <deck-takeaway>
75
88
  <h1>Ship it</h1>
76
- <p>One file. No network. Share it.</p>
89
+ <p>${assetBase ? 'Write, check, bundle, share.' : 'One file. No network. Share it.'}</p>
77
90
  </deck-takeaway>
78
91
 
79
92
  </deck-root>
@@ -0,0 +1,120 @@
1
+ // ════════════════════════════════════════════════════════════════
2
+ // What a slide looks like, measured on the pixels rather than on the DOM.
3
+ //
4
+ // The DOM says a box is 400px tall. It does not say the slide reads as
5
+ // top-heavy, or that the ink sits in one corner. Those are pixel facts, and
6
+ // they are the ones a room reacts to first.
7
+ //
8
+ // The signals here follow the low-level design cues that correlate with human
9
+ // judgement of slide quality (Inui et al., 2025): ink density and spatial
10
+ // balance carry most of it. We measure only what is objective and act only on
11
+ // the imbalance · empty space on its own is a choice, never a defect.
12
+ // ════════════════════════════════════════════════════════════════
13
+
14
+ /** Runs in the page: decode a screenshot and measure where the ink sits. */
15
+ const MEASURE_INK = `async (dataUrl) => {
16
+ const img = new Image();
17
+ img.src = dataUrl;
18
+ await img.decode();
19
+
20
+ // Downscale · the layout facts survive, and a 240-wide grid is 100x cheaper
21
+ // to walk than the full canvas.
22
+ const w = 240;
23
+ const h = Math.max(1, Math.round((img.height / img.width) * w));
24
+ const canvas = document.createElement('canvas');
25
+ canvas.width = w;
26
+ canvas.height = h;
27
+ const ctx = canvas.getContext('2d', { willReadFrequently: true });
28
+ ctx.drawImage(img, 0, 0, w, h);
29
+ const { data } = ctx.getImageData(0, 0, w, h);
30
+
31
+ const at = (x, y) => {
32
+ const i = (y * w + x) * 4;
33
+ return [data[i], data[i + 1], data[i + 2]];
34
+ };
35
+ // The background is the most common colour · a deck paints its own ground.
36
+ const tally = new Map();
37
+ for (let y = 0; y < h; y++) {
38
+ for (let x = 0; x < w; x++) {
39
+ const key = at(x, y).map((c) => c >> 4).join(',');
40
+ tally.set(key, (tally.get(key) ?? 0) + 1);
41
+ }
42
+ }
43
+ const ground = [...tally.entries()].sort((a, b) => b[1] - a[1])[0][0]
44
+ .split(',').map((c) => (Number(c) << 4) + 8);
45
+
46
+ const far = (p) =>
47
+ Math.abs(p[0] - ground[0]) + Math.abs(p[1] - ground[1]) + Math.abs(p[2] - ground[2]) > 40;
48
+
49
+ let inked = 0;
50
+ let sumY = 0;
51
+ let sumX = 0;
52
+ let top = h;
53
+ let bottom = -1;
54
+ let left = w;
55
+ let right = -1;
56
+ const rowInk = new Array(h).fill(0);
57
+ for (let y = 0; y < h; y++) {
58
+ for (let x = 0; x < w; x++) {
59
+ if (!far(at(x, y))) continue;
60
+ inked++;
61
+ rowInk[y]++;
62
+ sumY += y;
63
+ sumX += x;
64
+ if (y < top) top = y;
65
+ if (y > bottom) bottom = y;
66
+ if (x < left) left = x;
67
+ if (x > right) right = x;
68
+ }
69
+ }
70
+ if (inked === 0) return { inkRatio: 0, empty: true };
71
+
72
+ return {
73
+ empty: false,
74
+ inkRatio: inked / (w * h),
75
+ // 0 = ink centred on the slide, negative = pulled up, positive = pulled down.
76
+ verticalBias: (sumY / inked) / h - 0.5,
77
+ horizontalBias: (sumX / inked) / w - 0.5,
78
+ // The band under the last content row, as a share of the slide height.
79
+ tailBand: 1 - (bottom + 1) / h,
80
+ headBand: top / h,
81
+ box: { top: top / h, bottom: bottom / h, left: left / w, right: right / w },
82
+ };
83
+ }`;
84
+
85
+ /** The engine's own furniture · counted as ink, it makes every slide look
86
+ * bottom-anchored. Hidden for the measurement, restored after. */
87
+ const HIDE_CHROME = `() => {
88
+ const root = document.querySelector('deck-root');
89
+ if (!root?.shadowRoot) return;
90
+ const style = document.createElement('style');
91
+ style.id = 'rik-measure-chrome';
92
+ style.textContent =
93
+ '#progress,#counter,#step-dots,#nav-arrows,#kb-hint,#live{display:none !important}';
94
+ root.shadowRoot.appendChild(style);
95
+ }`;
96
+
97
+ const SHOW_CHROME = `() => {
98
+ document.querySelector('deck-root')?.shadowRoot?.getElementById('rik-measure-chrome')?.remove();
99
+ }`;
100
+
101
+ /**
102
+ * Photograph each slide and measure where its ink sits.
103
+ * `goTo(index)` must leave the page on that slide, settled.
104
+ */
105
+ export async function measureSlides(page, count, goTo) {
106
+ await page.evaluate((fn) => new Function('return ' + fn)()(), HIDE_CHROME);
107
+ const out = [];
108
+ for (let index = 1; index <= count; index++) {
109
+ await goTo(index);
110
+ const shot = await page.screenshot({ type: 'png' });
111
+ const dataUrl = 'data:image/png;base64,' + shot.toString('base64');
112
+ const ink = await page.evaluate(
113
+ ({ fn, url }) => new Function('return ' + fn)()(url),
114
+ { fn: MEASURE_INK, url: dataUrl },
115
+ );
116
+ out.push({ index, ...ink });
117
+ }
118
+ await page.evaluate((fn) => new Function('return ' + fn)()(), SHOW_CHROME);
119
+ return out;
120
+ }