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
package/bin/rikiki.mjs CHANGED
@@ -2,11 +2,17 @@
2
2
  // ════════════════════════════════════════════════════════════════
3
3
  // rikiki CLI
4
4
  //
5
- // rikiki init --standalone [name.html] [--title "…"] [--theme rikiki|siliceum]
6
- // [--with-mermaid] [--with-shiki] [--no-fonts]
7
- // rikiki bundle <deck.html> [out.html|-] [--no-fonts]
5
+ // rikiki init [name.html] [--standalone] [--title "…"] [--theme rikiki|siliceum]
6
+ // [--with-mermaid] [--with-shiki] [--no-fonts] [--force]
7
+ // rikiki bundle <deck.html> [out.html|-] [--with-mermaid] [--with-shiki] [--no-fonts]
8
+ // rikiki assemble <deck.config.js> [out.html|-]
9
+ // rikiki render <deck.html> [--out dir] [--slides a,b] [--steps]
10
+ // [--baseline dir] [--threshold pct] [--json]
11
+ // rikiki check <deck.html> [--json] [--no-visual]
12
+ // rikiki export <deck.html> [--output deck.pdf]
8
13
  // rikiki skills [--dir <path>] [--force]
9
14
  //
15
+ // `init` writes an editable source deck plus the runtime it needs, next to it.
10
16
  // `init --standalone` generates a self-contained, share-anywhere deck with no
11
17
  // external links. `bundle` folds an existing deck into the same single file.
12
18
  // Both use the rolldown-powered inliner in lib/inline.mjs. `skills` installs the
@@ -19,20 +25,53 @@ import { resolve, dirname, basename, join } from 'node:path';
19
25
  import { fileURLToPath } from 'node:url';
20
26
  import { inlineDeck } from './lib/inline.mjs';
21
27
  import { starterHtml } from './lib/starter.mjs';
28
+ import { formatExternal, scanExternal } from './lib/scan-external.mjs';
29
+ import { pruneIcons } from './lib/prune-icons.mjs';
30
+ import { resolveCheckPlugins } from './lib/check-plugins.mjs';
31
+ import { exportPdf } from './lib/export-pdf.mjs';
32
+ import { ExpectedError, formatCliError } from './lib/cli-error.mjs';
33
+ import { assembleDeck } from './lib/assemble.mjs';
34
+ import { renderDeck } from './lib/render.mjs';
35
+ import { DEFAULT_THRESHOLD, diffFailed, formatDiff } from './lib/diff.mjs';
36
+ import { checkDeck, formatReport } from './lib/check.mjs';
22
37
 
23
38
  const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
24
39
 
25
40
  const HELP = `rikiki · self-contained slide decks
26
41
 
27
- rikiki init --standalone [name.html] [options] generate a new single-file deck
42
+ rikiki init [name.html] [options] write an editable deck + its runtime
43
+ rikiki init --standalone [name.html] [options] generate a single self-contained file
44
+ rikiki assemble <deck.config.js> [out.html|-] build one deck from ordered partials
28
45
  rikiki bundle <deck.html> [out.html|-] [options] fold an existing deck into one file
29
- rikiki skills [--dir <path>] [--force] install the Claude Code skills into a project
46
+ rikiki render <deck.html> [options] one PNG per slide, plus a gallery and a manifest
47
+ rikiki check <deck.html> [--json] [--no-visual] [--steps] measure the deck and report what is wrong
48
+ rikiki export <deck.html> [--output deck.pdf] render the deck to PDF, one slide per page
49
+ rikiki skills [--agent codex|claude] [--plugin package] [--dir path] [--force]
50
+ install core and module agent skills
30
51
 
31
52
  Options:
32
53
  --title "…" deck title (init)
54
+ --standalone init: emit one self-contained file instead of a source deck
55
+ --force init: overwrite an existing deck
33
56
  --theme rikiki|siliceum theme · siliceum inlines its local fonts (default: rikiki)
34
57
  --with-mermaid inline the mermaid runtime (+~3 MB)
35
- --with-shiki inline the Shiki highlighter (+~9 MB)
58
+ --with-shiki inline the curated Shiki highlighter (+~0.7 MB raw)
59
+ --output, -o <file> PDF path (export · default <deck>.pdf)
60
+ --out <dir> picture directory (render · default <deck>.shots/)
61
+ --slides a,b render: which slides · numbers (1-based) or ids
62
+ --steps render: one picture per revealed state, not just the first
63
+ --baseline <dir> render: compare this render to an earlier one, slide by slide
64
+ --threshold <pct> render: percent of pixels that makes a slide changed (default 0.5)
65
+ --width, --height render/check: canvas size in pixels (default 1920×1080)
66
+ --json check / render --baseline: write the report to stdout as JSON
67
+ --no-visual check: skip the pixel pass (one screenshot per slide)
68
+ --config <file> check/skills: explicit rikiki.config.json
69
+ --plugin <package> check/skills: activate a module (repeatable)
70
+ --no-plugins check: disable module checks explicitly
71
+ --narrative-out <file> check: prepare review material for the current agent
72
+ --narrative-review <file> check: import the current agent's structured review
73
+ --plugin-timeout <ms> check: execution deadline per plugin call (default 5000)
74
+ --steps check: measure every revealed state of each slide, not just the first
36
75
  --no-fonts drop fonts instead of inlining them (smaller, system fonts)
37
76
  --all bundle every component (skip the used-only curation)
38
77
  --include a,b force-include components used only from JS
@@ -46,6 +85,28 @@ The bundle is curated to the components the deck uses; HTML and CSS stay
46
85
  readable so you can keep editing the file. Images are inlined as base64 and
47
86
  SVGs as inline markup.`;
48
87
 
88
+ /** A pixel dimension from the flags · a size that is not a size is an error,
89
+ * not a silent fallback to the default. */
90
+ function pixels(values, flag, fallback) {
91
+ if (values[flag] === undefined) return fallback;
92
+ const n = Number(values[flag]);
93
+ if (!Number.isInteger(n) || n < 1) {
94
+ throw new ExpectedError(`--${flag} must be a positive whole number of pixels`);
95
+ }
96
+ return n;
97
+ }
98
+
99
+ /** A percentage from the flags · anything that is not one is an error, not a
100
+ * silent fallback to the default. `0` is a legitimate value. */
101
+ function percent(values, flag, fallback) {
102
+ if (values[flag] === undefined) return fallback;
103
+ const n = Number(values[flag]);
104
+ if (!Number.isFinite(n) || n < 0 || n > 100) {
105
+ throw new ExpectedError(`--${flag} must be a percentage between 0 and 100`);
106
+ }
107
+ return n;
108
+ }
109
+
49
110
  /** Shared inlining options derived from the parsed flags. */
50
111
  const inlineOpts = (v) => ({
51
112
  all: v.all,
@@ -65,16 +126,81 @@ const SHARED_OPTIONS = {
65
126
  'no-fonts': { type: 'boolean', default: false },
66
127
  };
67
128
 
68
- /** Warn if anything external slipped through. */
69
- function warnExternal(html) {
70
- const hits = [...html.matchAll(/\b(?:https?:)?\/\/[^\s"')]+/g)]
71
- .map((m) => m[0])
72
- .filter((u) => !u.startsWith('//W') && /cdn|googleapis|gstatic|unpkg|jsdelivr|esm\.sh|fonts\./.test(u));
73
- if (hits.length) {
74
- console.error('rikiki · WARNING · external references remain:');
75
- [...new Set(hits)].slice(0, 5).forEach((u) => console.error(' · ' + u));
129
+ // References that appear in a bundle but are provably never fetched from it.
130
+ // Each one needs a reason, and the offline e2e test is what actually proves it.
131
+ const INERT_IN_BUNDLE = new Map([
132
+ [
133
+ './index.js',
134
+ 'deck-presenter computes it as a fallback bundle href · a bundled deck always ' +
135
+ 'takes the inline branch instead (bundleInline is non-empty)',
136
+ ],
137
+ ]);
138
+
139
+ /** Report anything a "self-contained" file could still fetch at runtime.
140
+ * Returns true when the file really reaches for nothing.
141
+ *
142
+ * `inlined` names the heavy runtimes folded into this file. Their loaders keep
143
+ * a `new URL('./vendor/…')` in the code, but that branch is dead once the
144
+ * global is already set · which is exactly what inlining does. */
145
+ function checkSelfContained(html, inlined = {}) {
146
+ const dead = new Map(INERT_IN_BUNDLE);
147
+ if (inlined.mermaid) {
148
+ dead.set(
149
+ './vendor/mermaid.min.js',
150
+ 'the mermaid UMD is inlined above and sets window.mermaid, so the loader ' +
151
+ 'returns before it builds this URL',
152
+ );
153
+ }
154
+ if (inlined.shiki) {
155
+ dead.set(
156
+ './vendor/shiki.js',
157
+ 'the Shiki highlighter is inlined above and registered on globalThis',
158
+ );
159
+ }
160
+ const hits = scanExternal(html);
161
+ const real = hits.filter((h) => !dead.has(h.ref));
162
+ const inert = hits.filter((h) => dead.has(h.ref));
163
+
164
+ for (const h of inert) {
165
+ console.error(`rikiki · note · ${h.ref} stays in the file but is never fetched`);
166
+ console.error(` (${dead.get(h.ref)})`);
76
167
  }
77
- return hits.length === 0;
168
+ if (real.length === 0) return true;
169
+
170
+ console.error('rikiki · ERROR · the bundle is NOT self-contained · it still fetches:');
171
+ console.error(formatExternal(real));
172
+ const wantsMermaid = real.some((h) => h.ref.includes('mermaid'));
173
+ const wantsShiki = real.some((h) => h.ref.includes('shiki'));
174
+ if (wantsMermaid) console.error(' → this deck uses <deck-mermaid> · re-run with --with-mermaid');
175
+ if (wantsShiki) console.error(' → this deck uses Shiki · re-run with --with-shiki');
176
+ return false;
177
+ }
178
+
179
+ /** Inject the heavy vendor runtimes the inliner then folds into the file.
180
+ * mermaid's UMD sets window.mermaid, so deck-mermaid skips its network load. */
181
+ function injectVendors(html, { withMermaid, withShiki }) {
182
+ // Shiki goes FIRST · it publishes globalThis.__rikikiShiki from a module, and
183
+ // modules run in document order, so a deck whose own <script> calls
184
+ // installShiki() must not be reached before the global exists.
185
+ const first = withShiki
186
+ ? `<script type="module">\n` +
187
+ `import { createHighlighter } from '${PKG_ROOT}/dist/vendor/shiki.js';\n` +
188
+ `globalThis.__rikikiShiki = createHighlighter;\n` +
189
+ `</script>\n`
190
+ : '';
191
+ // mermaid goes LAST · it is a classic script, so it runs during parsing, well
192
+ // before any module, wherever it sits. Keeping it after the deck's own markup
193
+ // also keeps the inliner's asset passes away from its 3 MB of minified JS.
194
+ const last = withMermaid
195
+ ? `<script src="${PKG_ROOT}/dist/vendor/mermaid.min.js"></script>\n`
196
+ : '';
197
+ if (!first && !last) return html;
198
+
199
+ let out = html;
200
+ const headOpen = out.match(/<head\b[^>]*>/i);
201
+ if (first) out = headOpen ? out.replace(headOpen[0], headOpen[0] + '\n' + first) : first + out;
202
+ if (last) out = out.includes('</head>') ? out.replace('</head>', last + '</head>') : out + last;
203
+ return out;
78
204
  }
79
205
 
80
206
  function writeOut(html, outputPath) {
@@ -84,12 +210,30 @@ function writeOut(html, outputPath) {
84
210
  console.error(`rikiki · wrote ${outputPath} · ${kb} KB`);
85
211
  }
86
212
 
213
+ // The runtime a source deck loads from next to itself. `dist/vendor` also
214
+ // holds lit and marked, which every deck needs · only the two heavy plugin
215
+ // payloads (~12 MB) wait until a deck asks for them.
216
+ const ASSET_DIR = 'rikiki';
217
+ const RUNTIME_ASSETS = ['dist', 'tokens.css', 'themes', 'fonts'];
218
+ const HEAVY_VENDORS = /[\\/]dist[\\/]vendor[\\/](mermaid\.min\.js|shiki\.js)$/;
219
+
220
+ /** Copy the runtime next to the deck. */
221
+ function copyRuntime(destRoot, { withVendor }) {
222
+ const skipVendor = (src) => withVendor || !HEAVY_VENDORS.test(src);
223
+ for (const asset of RUNTIME_ASSETS) {
224
+ const src = join(PKG_ROOT, asset);
225
+ if (!existsSync(src)) continue; // a trimmed install (e.g. no fonts) stays usable
226
+ cpSync(src, join(destRoot, asset), { recursive: true, filter: skipVendor });
227
+ }
228
+ }
229
+
87
230
  async function cmdInit(argv) {
88
231
  const { values, positionals } = parseArgs({
89
232
  args: argv,
90
233
  allowPositionals: true,
91
234
  options: {
92
235
  standalone: { type: 'boolean', default: false },
236
+ force: { type: 'boolean', default: false },
93
237
  title: { type: 'string' },
94
238
  theme: { type: 'string', default: 'rikiki' },
95
239
  'with-mermaid': { type: 'boolean', default: false },
@@ -98,31 +242,232 @@ async function cmdInit(argv) {
98
242
  },
99
243
  });
100
244
 
101
- // `init` only produces standalone single-file decks for now · accept the flag
102
- // explicitly but don't require it (the whole point is the self-contained file).
103
- if (!values.standalone) {
104
- console.error('rikiki · init currently generates standalone single-file decks · assuming --standalone');
105
- }
106
245
  const theme = values.theme === 'siliceum' ? 'siliceum' : 'rikiki';
107
246
  const name = positionals[0] || 'slides.html';
108
247
  const outputPath = name === '-' ? '-' : resolve(process.cwd(), name.endsWith('.html') ? name : name + '.html');
109
248
  const title = values.title || basename(name, '.html').replace(/[-_]/g, ' ') || 'My deck';
110
249
 
111
- const html = starterHtml({
250
+ // Someone's deck is not ours to replace · the second `init` in a directory is
251
+ // far more often a mistake than an intent.
252
+ if (outputPath !== '-' && existsSync(outputPath) && !values.force) {
253
+ throw new ExpectedError(`init · ${name} already exists · pass --force to overwrite it`);
254
+ }
255
+
256
+ const starter = {
112
257
  title, theme,
113
258
  withMermaid: values['with-mermaid'],
114
259
  withShiki: values['with-shiki'],
115
- });
260
+ };
261
+
262
+ // Default: a source deck. It needs nothing but Node, stays readable, and is
263
+ // what `bundle` later folds into a single file.
264
+ if (!values.standalone) {
265
+ const html = starterHtml({ ...starter, assetBase: ASSET_DIR + '/' });
266
+ if (outputPath === '-') {
267
+ console.error(`rikiki · note · run \`rikiki init <name>.html\` to also copy the runtime into ./${ASSET_DIR}/`);
268
+ writeOut(html, outputPath);
269
+ return;
270
+ }
271
+ copyRuntime(join(dirname(outputPath), ASSET_DIR), {
272
+ withVendor: values['with-mermaid'] || values['with-shiki'],
273
+ });
274
+ writeOut(html, outputPath);
275
+ console.error(`rikiki · runtime copied to ./${ASSET_DIR}/ · serve this folder over HTTP, ES modules do not load from file://`);
276
+ console.error(`rikiki · next · edit ${basename(outputPath)} · then \`rikiki bundle ${basename(outputPath)}\` for one shareable file`);
277
+ return;
278
+ }
279
+
280
+ const html = starterHtml(starter);
116
281
  const inlined = await inlineDeck({ html, baseDir: PKG_ROOT, pkgRoot: PKG_ROOT, ...inlineOpts(values) });
117
282
  writeOut(inlined, outputPath);
118
- if (outputPath !== '-') warnExternal(inlined);
283
+ // Same contract as `bundle` · a starter that would 404 offline is not a
284
+ // starter, so the exit code says so.
285
+ const ok = checkSelfContained(inlined, {
286
+ mermaid: values['with-mermaid'],
287
+ shiki: values['with-shiki'],
288
+ });
289
+ if (!ok) process.exit(1);
290
+ }
291
+
292
+ async function cmdAssemble(argv) {
293
+ const { positionals } = parseArgs({ args: argv, allowPositionals: true, options: {} });
294
+ const config = positionals[0];
295
+ if (!config) throw new ExpectedError('assemble · missing <deck.config.{js,json}>\n\n' + HELP);
296
+
297
+ const { html, outputPath, slides, unbundleable } = await assembleDeck(config, positionals[1]);
298
+ for (const href of unbundleable) {
299
+ console.error(`rikiki · note · ${href} is not a \`rikiki/…\` path · it serves, but \`rikiki bundle\` will not inline it`);
300
+ }
301
+ if (!outputPath) { process.stdout.write(html); return; }
302
+ const kb = (Buffer.byteLength(html) / 1024).toFixed(0);
303
+ console.error(`rikiki · wrote ${outputPath} · ${slides} partial(s) · ${kb} KB`);
304
+ }
305
+
306
+ async function cmdRender(argv) {
307
+ const { values, positionals } = parseArgs({
308
+ args: argv,
309
+ allowPositionals: true,
310
+ options: {
311
+ out: { type: 'string' },
312
+ slides: { type: 'string' },
313
+ steps: { type: 'boolean', default: false },
314
+ width: { type: 'string' },
315
+ height: { type: 'string' },
316
+ baseline: { type: 'string' },
317
+ threshold: { type: 'string' },
318
+ json: { type: 'boolean', default: false },
319
+ },
320
+ });
321
+ const inputPath = deckArgument('render', positionals[0]);
322
+ const outDir = resolve(process.cwd(), values.out ?? basename(inputPath, '.html') + '.shots');
323
+ // Checked before the render, not after: a baseline that is not there is
324
+ // worth knowing before spending a browser on thirty screenshots.
325
+ const baseline = baselineArgument(values.baseline);
326
+ const { manifest, galleryPath, diff, diffPath } = await renderDeck(inputPath, {
327
+ outDir,
328
+ slides: values.slides,
329
+ steps: values.steps,
330
+ width: pixels(values, 'width', 1920),
331
+ height: pixels(values, 'height', 1080),
332
+ baseline,
333
+ threshold: percent(values, 'threshold', DEFAULT_THRESHOLD),
334
+ });
335
+
336
+ for (const url of manifest.missing.slice(0, 5)) {
337
+ console.error(`rikiki · WARNING · the deck could not load: ${url}`);
338
+ }
339
+ console.error(
340
+ `rikiki · wrote ${manifest.captured} shot(s) to ${outDir} · ${manifest.canvas.width}×${manifest.canvas.height}`,
341
+ );
342
+ if (!manifest.stepsCaptured) {
343
+ console.error('rikiki · note · stepped slides are shown in their opening state · pass --steps for the rest');
344
+ }
345
+ console.error(`rikiki · gallery ${galleryPath}`);
346
+
347
+ if (!diff) return;
348
+ // In --json mode stdout carries the report and nothing else, so a caller can
349
+ // pipe it straight into a tool · same contract as `check --json`.
350
+ if (values.json) process.stdout.write(JSON.stringify(diff, null, 2) + '\n');
351
+ else console.error(formatDiff(diff));
352
+ console.error(`rikiki · diff ${diffPath}`);
353
+ if (diffFailed(diff.summary)) process.exit(1);
354
+ }
355
+
356
+ /** Resolve `--baseline` · a directory that is not there is the one thing this
357
+ * command cannot work around, so it stops rather than render into silence. */
358
+ function baselineArgument(input) {
359
+ if (input === undefined) return undefined;
360
+ const dir = resolve(process.cwd(), input);
361
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) {
362
+ throw new ExpectedError(`render · baseline directory not found: ${dir}`, { exitCode: 2 });
363
+ }
364
+ return dir;
365
+ }
366
+
367
+ /** Resolve a deck argument · shared by the commands that read one. */
368
+ function deckArgument(command, input, { exitCode = 1 } = {}) {
369
+ if (!input) throw new ExpectedError(`${command} · missing <deck.html>\n\n` + HELP, { exitCode });
370
+ const inputPath = resolve(process.cwd(), input);
371
+ if (!existsSync(inputPath) || !statSync(inputPath).isFile()) {
372
+ throw new ExpectedError(`${command} · input not found: ` + inputPath, { exitCode });
373
+ }
374
+ return inputPath;
375
+ }
376
+
377
+ async function cmdCheck(argv) {
378
+ const { values, positionals } = parseArgs({
379
+ args: argv,
380
+ allowPositionals: true,
381
+ options: {
382
+ json: { type: 'boolean', default: false },
383
+ 'no-visual': { type: 'boolean', default: false },
384
+ config: { type: 'string' },
385
+ plugin: { type: 'string', multiple: true },
386
+ 'no-plugins': { type: 'boolean', default: false },
387
+ 'narrative-out': { type: 'string' },
388
+ 'narrative-review': { type: 'string' },
389
+ 'plugin-timeout': { type: 'string' },
390
+ steps: { type: 'boolean', default: false },
391
+ width: { type: 'string' },
392
+ height: { type: 'string' },
393
+ },
394
+ });
395
+ const inputPath = deckArgument('check', positionals[0], { exitCode: 2 });
396
+ const report = await checkDeck(inputPath, {
397
+ width: pixels(values, 'width', 1920),
398
+ height: pixels(values, 'height', 1080),
399
+ visual: !values['no-visual'],
400
+ steps: values.steps,
401
+ config: values.config,
402
+ plugins: values.plugin,
403
+ noPlugins: values['no-plugins'],
404
+ narrativeOut: values['narrative-out'],
405
+ narrativeReview: values['narrative-review'],
406
+ pluginTimeoutMs: values['plugin-timeout'] === undefined ? 5000 : Number(values['plugin-timeout']),
407
+ });
408
+
409
+ // In --json mode stdout carries the report and nothing else, so a caller can
410
+ // pipe it without stripping anything · including when the deck is broken.
411
+ if (values.json) process.stdout.write(JSON.stringify(report, null, 2) + '\n');
412
+ else console.error(formatReport(report));
413
+
414
+ if (report.summary.error > 0) process.exit(1);
415
+ }
416
+
417
+ async function cmdExport(argv) {
418
+ const { values, positionals } = parseArgs({
419
+ args: argv,
420
+ allowPositionals: true,
421
+ options: { output: { type: 'string', short: 'o' } },
422
+ });
423
+ const input = positionals[0];
424
+ if (!input) {
425
+ console.error('rikiki export · missing <deck.html>\n\n' + HELP);
426
+ process.exit(1);
427
+ }
428
+ const inputPath = resolve(process.cwd(), input);
429
+ if (!existsSync(inputPath) || !statSync(inputPath).isFile()) {
430
+ console.error('rikiki export · input not found: ' + inputPath);
431
+ process.exit(1);
432
+ }
433
+ const outputPath = resolve(
434
+ process.cwd(),
435
+ values.output ?? positionals[1] ?? basename(inputPath, '.html') + '.pdf',
436
+ );
437
+
438
+ let result;
439
+ try {
440
+ result = await exportPdf(inputPath, outputPath);
441
+ } catch (e) {
442
+ console.error('rikiki export · ' + (e instanceof Error ? e.message : String(e)));
443
+ process.exit(1);
444
+ }
445
+ // A missing asset means a page printed without something the author put
446
+ // there · reporting it beats handing over a silently incomplete PDF.
447
+ if (result.missing.length) {
448
+ console.error('rikiki export · WARNING · the deck could not load:');
449
+ for (const url of [...new Set(result.missing)].slice(0, 10)) console.error(' · ' + url);
450
+ }
451
+ // One slide, one page · any gap either way means the paper does not match
452
+ // the deck, whether a slide was dropped or one spilled onto a second page.
453
+ if (result.pages !== result.slides) {
454
+ console.error(
455
+ `rikiki export · WARNING · ${result.slides} slides but ${result.pages} pages · ` +
456
+ 'compare the PDF with the deck outline',
457
+ );
458
+ }
459
+ console.error(`rikiki · wrote ${outputPath} · ${result.pages} pages`);
119
460
  }
120
461
 
121
462
  async function cmdBundle(argv) {
122
463
  const { values, positionals } = parseArgs({
123
464
  args: argv,
124
465
  allowPositionals: true,
125
- options: { ...SHARED_OPTIONS },
466
+ options: {
467
+ 'with-mermaid': { type: 'boolean', default: false },
468
+ 'with-shiki': { type: 'boolean', default: false },
469
+ ...SHARED_OPTIONS,
470
+ },
126
471
  });
127
472
  const input = positionals[0];
128
473
  if (!input) { console.error('rikiki bundle · missing <deck.html>\n\n' + HELP); process.exit(1); }
@@ -135,10 +480,36 @@ async function cmdBundle(argv) {
135
480
  : outArg ? resolve(process.cwd(), outArg)
136
481
  : join(dirname(inputPath), basename(inputPath, '.html') + '.bundle.html');
137
482
 
138
- const html = readFileSync(inputPath, 'utf8');
139
- const inlined = await inlineDeck({ html, baseDir: dirname(inputPath), pkgRoot: PKG_ROOT, ...inlineOpts(values) });
483
+ const html = injectVendors(readFileSync(inputPath, 'utf8'), {
484
+ withMermaid: values['with-mermaid'],
485
+ withShiki: values['with-shiki'],
486
+ });
487
+ let inlined = await inlineDeck({
488
+ html,
489
+ baseDir: dirname(inputPath),
490
+ pkgRoot: PKG_ROOT,
491
+ ...inlineOpts(values),
492
+ });
493
+ // Curate the icon set the way components are already curated · a bundled deck
494
+ // carries the glyphs it writes and nothing else.
495
+ const icons = pruneIcons(inlined, html);
496
+ if (icons.pruned) {
497
+ inlined = icons.js;
498
+ console.error(
499
+ `rikiki · icons · kept ${icons.kept.length}, dropped ${icons.dropped} (${icons.saved} bytes)`,
500
+ );
501
+ } else if (icons.reason?.startsWith('unknown icon name')) {
502
+ // Worth saying out loud · the glyph will not render.
503
+ console.error(`rikiki · WARNING · ${icons.reason}`);
504
+ }
140
505
  writeOut(inlined, outputPath);
141
- if (outputPath !== '-') warnExternal(inlined);
506
+ // A bundle that still fetches is a broken deliverable, not a warning · the
507
+ // exit code is the only thing a CI job or a script can act on.
508
+ const ok = checkSelfContained(inlined, {
509
+ mermaid: values['with-mermaid'],
510
+ shiki: values['with-shiki'],
511
+ });
512
+ if (!ok) process.exit(1);
142
513
  }
143
514
 
144
515
  // Consumer-facing skills shipped in the npm tarball. `rikiki-component` and
@@ -149,12 +520,22 @@ const DISTRIBUTED_SKILLS = ['rikiki-deck', 'rikiki-theme', 'rikiki-debug'];
149
520
  function cmdSkills(argv) {
150
521
  const { values } = parseArgs({
151
522
  args: argv,
152
- options: { dir: { type: 'string' }, force: { type: 'boolean', default: false } },
523
+ options: { dir: { type: 'string' }, force: { type: 'boolean', default: false },
524
+ agent: { type: 'string', default: 'claude' }, plugin: { type: 'string', multiple: true }, config: { type: 'string' } },
153
525
  });
154
- const targetRoot = resolve(process.cwd(), values.dir || '.claude/skills');
526
+ if (!['codex', 'claude'].includes(values.agent)) throw new ExpectedError('--agent must be codex or claude');
527
+ const targetRoot = resolve(process.cwd(), values.dir || (values.agent === 'codex' ? '.agents/skills' : '.claude/skills'));
528
+ const sources = DISTRIBUTED_SKILLS.map(name => ({ name, src: join(PKG_ROOT, '.claude', 'skills', name) }));
529
+ const resolution = resolveCheckPlugins(join(process.cwd(), 'deck.html'), { config: values.config, plugins: values.plugin });
530
+ if (resolution.diagnostics.length) throw new ExpectedError(resolution.diagnostics.map(d => d.message).join('\n'));
531
+ for (const plugin of resolution.plugins) for (const skill of plugin.skills ?? []) sources.push(skill);
532
+ const skillNames = new Set();
533
+ for (const { name } of sources) {
534
+ if (skillNames.has(name)) throw new ExpectedError(`duplicate skill name: ${name}`);
535
+ skillNames.add(name);
536
+ }
155
537
  let copied = 0;
156
- for (const name of DISTRIBUTED_SKILLS) {
157
- const src = join(PKG_ROOT, '.claude', 'skills', name);
538
+ for (const { name, src } of sources) {
158
539
  if (!existsSync(src)) continue; // not in this install (e.g. running from a trimmed tarball)
159
540
  const dest = join(targetRoot, name);
160
541
  if (existsSync(dest) && !values.force) {
@@ -163,21 +544,25 @@ function cmdSkills(argv) {
163
544
  }
164
545
  mkdirSync(dirname(dest), { recursive: true });
165
546
  cpSync(src, dest, { recursive: true });
166
- console.error(`rikiki skills · installed ${name} → ${join(values.dir || '.claude/skills', name)}`);
547
+ console.error(`rikiki skills · installed ${name} → ${dest}`);
167
548
  copied++;
168
549
  }
169
- if (copied) console.error(`rikiki skills · ${copied} skill(s) installed · restart Claude Code to pick them up`);
550
+ if (copied) console.error(`rikiki skills · ${copied} skill(s) installed for ${values.agent} · reload the agent if needed`);
170
551
  else console.error('rikiki skills · nothing installed');
171
552
  }
172
553
 
173
554
  const [cmd, ...rest] = process.argv.slice(2);
174
555
  try {
175
556
  if (cmd === 'init') await cmdInit(rest);
557
+ else if (cmd === 'assemble') await cmdAssemble(rest);
176
558
  else if (cmd === 'bundle') await cmdBundle(rest);
559
+ else if (cmd === 'render') await cmdRender(rest);
560
+ else if (cmd === 'check') await cmdCheck(rest);
561
+ else if (cmd === 'export') await cmdExport(rest);
177
562
  else if (cmd === 'skills') cmdSkills(rest);
178
563
  else if (!cmd || cmd === '-h' || cmd === '--help' || cmd === 'help') { console.log(HELP); }
179
564
  else { console.error('rikiki · unknown command: ' + cmd + '\n\n' + HELP); process.exit(1); }
180
565
  } catch (e) {
181
- console.error('rikiki · error · ' + (e && e.stack || e));
182
- process.exit(1);
566
+ console.error(formatCliError(e));
567
+ process.exit(e?.exitCode ?? 1);
183
568
  }
@@ -0,0 +1,60 @@
1
+ export interface Mark {
2
+ /** Horizontal position, 0..100, from the left edge of the image. */
3
+ readonly x: number;
4
+ /** Vertical position, 0..100, from the top edge. */
5
+ readonly y: number;
6
+ /** Caption shown in the legend · empty is allowed, the number still shows. */
7
+ readonly label: string;
8
+ }
9
+ export interface PlacedMark extends Mark {
10
+ /** 1-based, in document order · what the reader sees in the badge. */
11
+ readonly n: number;
12
+ }
13
+ /**
14
+ * Read the `marks` attribute · `x,y,label` triples separated by `|`.
15
+ *
16
+ * An attribute rather than JSON because a deck author writes HTML by hand, and
17
+ * `35,60,Latency spike` survives being retyped in five years better than an
18
+ * escaped JSON blob does.
19
+ *
20
+ * A mark with an unreadable coordinate is DROPPED rather than pinned to 0,0:
21
+ * a marker in the wrong corner of a screenshot is worse than a missing one,
22
+ * because the room believes it.
23
+ */
24
+ export declare function parseMarks(raw: string | null | undefined): Mark[];
25
+ /** Number the marks in document order. */
26
+ export declare function placeMarks(marks: readonly Mark[]): PlacedMark[];
27
+ /**
28
+ * How many marks are visible at `step`.
29
+ *
30
+ * Step 0 shows none, so the room looks at the screenshot before the speaker
31
+ * starts pointing at it. Each step reveals one more, and a step past the last
32
+ * mark keeps them all rather than wrapping.
33
+ */
34
+ export declare function visibleCount(total: number, step: number): number;
35
+ /** Steps a slide needs to reveal every mark · the count the engine asks for. */
36
+ export declare const stepsForMarks: (total: number) => number;
37
+ /** A badge side name : the four directions a keyword offset can name. */
38
+ export type AnchorSide = 'above' | 'below' | 'left' | 'right';
39
+ /** A parsed `offset` / `offsets` entry, either raw pixels or a named side. */
40
+ export type Offset = {
41
+ readonly kind: 'px';
42
+ readonly dx: number;
43
+ readonly dy: number;
44
+ } | {
45
+ readonly kind: 'anchor';
46
+ readonly side: AnchorSide;
47
+ };
48
+ /**
49
+ * Read one `offset` / `offsets` entry · either `x,y` CSS pixels or a keyword
50
+ * naming a side (`above`, `below`, `left`, `right`).
51
+ *
52
+ * A keyword lets the author state the intent instead of guessing pixels in a
53
+ * coordinate system they cannot see : the component turns it into a real
54
+ * displacement once it knows the rendered badge size.
55
+ *
56
+ * An unreadable entry falls back to `0,0`, same as an unreadable `x,y` pair
57
+ * always has : a badge pinned to its target is a smaller mistake than one
58
+ * thrown off-image by a typo.
59
+ */
60
+ export declare function parseOffset(text: string | null | undefined): Offset;
@@ -0,0 +1 @@
1
+ var u=e=>Number.isFinite(e)?Math.max(0,Math.min(100,e)):0;function m(e){return e?e.split("|").map(r=>r.trim()).filter(Boolean).map(r=>{let[n="",a="",...i]=r.split(","),t=o=>o.trim()===""?Number.NaN:Number(o.trim());return{x:t(n),y:t(a),label:i.join(",").trim()}}).filter(r=>Number.isFinite(r.x)&&Number.isFinite(r.y)).map(r=>({x:u(r.x),y:u(r.y),label:r.label})):[]}function d(e){return e.map((r,n)=>({...r,n:n+1}))}function b(e,r){return e<=0?0:Math.max(0,Math.min(e,Math.floor(r)))}var c=e=>Math.max(0,e),l=["above","below","left","right"],s={kind:"px",dx:0,dy:0};function f(e){let r=e?.trim();if(!r)return s;if(l.includes(r))return{kind:"anchor",side:r};let[n,a]=r.split(","),i=Number(n?.trim()),t=Number(a?.trim());return Number.isFinite(i)&&Number.isFinite(t)?{kind:"px",dx:i,dy:t}:s}export{m as parseMarks,f as parseOffset,d as placeMarks,c as stepsForMarks,b as visibleCount};
@@ -0,0 +1,28 @@
1
+ /** One slice of a bar · a share of the whole with a label and a tone. */
2
+ export interface Segment {
3
+ readonly label: string;
4
+ readonly value: number;
5
+ readonly tone?: string;
6
+ }
7
+ export interface SizedSegment extends Segment {
8
+ /** Share of the total, 0..100, rounded for display. */
9
+ readonly percent: number;
10
+ /** Exact share before rounding · used to lay the bar out without drift. */
11
+ readonly exact: number;
12
+ }
13
+ /** Parse the `segments` attribute · `label:value:tone` triples separated by
14
+ * `|`, because a deck author writes attributes, not JSON. The tone is
15
+ * optional; a malformed triple is skipped rather than rendered as garbage. */
16
+ export declare function parseSegments(raw: string | null | undefined): Segment[];
17
+ /**
18
+ * Size each segment against the total.
19
+ *
20
+ * `total` defaults to the sum, which is what a stacked bar wants. Passing a
21
+ * larger one is what makes "160 of 538" show the remaining 70% as empty track.
22
+ *
23
+ * Rounding is corrected on the largest segment so the displayed percentages
24
+ * always add to 100 · five segments of 16.67 must not print as 85.
25
+ */
26
+ export declare function sizeSegments(segments: readonly Segment[], total?: number): SizedSegment[];
27
+ /** The share of the track left empty · 0 for a stacked bar that fills it. */
28
+ export declare function remainder(sized: readonly SizedSegment[]): number;
@@ -0,0 +1 @@
1
+ function d(r){return r?r.split("|").map(e=>e.trim()).filter(Boolean).map(e=>{let[i="",u="",n=""]=e.split(":").map(c=>c.trim());return{label:i,value:u===""?Number.NaN:Number(u),tone:n||void 0}}).filter(e=>e.label.length>0&&Number.isFinite(e.value)&&e.value>=0):[]}function m(r,e){let i=r.reduce((t,a)=>t+Math.max(0,a.value),0),u=e!==void 0&&e>0?e:i;if(u<=0)return r.map(t=>({...t,percent:0,exact:0}));let n=r.map(t=>{let a=Math.max(0,t.value)/u*100;return{...t,exact:a,percent:Math.round(a)}});if(e!==void 0&&e>0)return n;let c=100-n.reduce((t,a)=>t+a.percent,0);if(c===0||n.length===0)return n;let l=0;for(let t=1;t<n.length;t++)n[t].exact>n[l].exact&&(l=t);return n[l]={...n[l],percent:n[l].percent+c},n}function o(r){let e=r.reduce((i,u)=>i+u.exact,0);return Math.max(0,100-e)}export{d as parseSegments,o as remainder,m as sizeSegments};
@@ -0,0 +1,3 @@
1
+ import type { LocationPort } from '../application/deep-link.js';
2
+ /** Reads and replaces the fragment of the current document. */
3
+ export declare const browserLocation: LocationPort;