@olegkoval/agent-skills 1.59.3 → 1.61.0

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 (231) hide show
  1. package/.github/copilot-instructions.md +2 -0
  2. package/.github/prompts/carousel-maker.prompt.md +102 -0
  3. package/.github/prompts/self-critique.prompt.md +167 -43
  4. package/.github/prompts/shorts-animator.prompt.md +103 -0
  5. package/.kiro/steering/carousel-maker.md +101 -0
  6. package/.kiro/steering/shorts-animator.md +102 -0
  7. package/.windsurf/rules/carousel-maker.md +100 -0
  8. package/.windsurf/rules/shorts-animator.md +101 -0
  9. package/README.md +5 -3
  10. package/adapters/claude/olko-creative/skills/carousel-maker/SKILL.md +112 -0
  11. package/adapters/claude/olko-creative/skills/carousel-maker/scripts/cm.ts +220 -0
  12. package/adapters/claude/olko-creative/skills/carousel-maker/scripts/domain/check.ts +68 -0
  13. package/adapters/claude/olko-creative/skills/carousel-maker/scripts/domain/layout.ts +75 -0
  14. package/adapters/claude/olko-creative/skills/carousel-maker/scripts/domain/schema.ts +54 -0
  15. package/adapters/claude/olko-creative/skills/carousel-maker/scripts/domain/types.ts +33 -0
  16. package/adapters/claude/olko-creative/skills/carousel-maker/scripts/edge/io.ts +87 -0
  17. package/adapters/claude/olko-creative/skills/carousel-maker/scripts/package.json +4 -0
  18. package/adapters/claude/olko-creative/skills/carousel-maker/scripts/test/domain.test.ts +99 -0
  19. package/adapters/claude/olko-creative/skills/carousel-maker/scripts/tsconfig.json +16 -0
  20. package/adapters/claude/olko-creative/skills/shorts-animator/SKILL.md +113 -0
  21. package/adapters/claude/olko-creative/skills/shorts-animator/references/kit.md +59 -0
  22. package/adapters/claude/olko-creative/skills/shorts-animator/references/looks.md +32 -0
  23. package/adapters/claude/olko-creative/skills/shorts-animator/references/writing.md +9 -0
  24. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/audio/compose.ts +290 -0
  25. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/audio/dsp.ts +193 -0
  26. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/audio/instruments.ts +185 -0
  27. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/audio/master.ts +55 -0
  28. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/audio/soundtrack.ts +119 -0
  29. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/domain/captions.ts +35 -0
  30. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/domain/check.ts +96 -0
  31. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/domain/look.ts +26 -0
  32. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/domain/patch.ts +38 -0
  33. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/domain/schema.ts +81 -0
  34. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/domain/timeline.ts +75 -0
  35. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/domain/types.ts +91 -0
  36. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/edge/chrome.ts +169 -0
  37. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/edge/doctor.ts +52 -0
  38. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/edge/media.ts +105 -0
  39. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/edge/page.ts +53 -0
  40. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/edge/probe.ts +75 -0
  41. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/edge/sound.ts +46 -0
  42. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/edge/voice.ts +183 -0
  43. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/looks/gouache.json +7 -0
  44. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/looks/ink.json +6 -0
  45. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/looks/oil.json +7 -0
  46. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/looks/pastel.json +7 -0
  47. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/looks/watercolor.json +6 -0
  48. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/package.json +4 -0
  49. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/runtime/globals.d.ts +199 -0
  50. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/runtime/kit.ts +856 -0
  51. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/runtime/paint.ts +268 -0
  52. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/runtime/stage.ts +181 -0
  53. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/runtime/tsconfig.json +12 -0
  54. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/sa.ts +262 -0
  55. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/test/domain.test.ts +107 -0
  56. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/tsconfig.json +16 -0
  57. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/tts.py +20 -0
  58. package/adapters/claude/olko-reflection/skills/self-critique/SKILL.md +167 -43
  59. package/adapters/claude/olko-reflection/skills/self-critique/scripts/claim-ledger.mjs +159 -0
  60. package/adapters/claude/olko-reflection/skills/self-critique/scripts/rule-store.mjs +193 -0
  61. package/adapters/codex/olko-creative/README.md +2 -0
  62. package/adapters/cursor/olko-creative/skills/carousel-maker/SKILL.md +112 -0
  63. package/adapters/cursor/olko-creative/skills/carousel-maker/scripts/cm.ts +220 -0
  64. package/adapters/cursor/olko-creative/skills/carousel-maker/scripts/domain/check.ts +68 -0
  65. package/adapters/cursor/olko-creative/skills/carousel-maker/scripts/domain/layout.ts +75 -0
  66. package/adapters/cursor/olko-creative/skills/carousel-maker/scripts/domain/schema.ts +54 -0
  67. package/adapters/cursor/olko-creative/skills/carousel-maker/scripts/domain/types.ts +33 -0
  68. package/adapters/cursor/olko-creative/skills/carousel-maker/scripts/edge/io.ts +87 -0
  69. package/adapters/cursor/olko-creative/skills/carousel-maker/scripts/package.json +4 -0
  70. package/adapters/cursor/olko-creative/skills/carousel-maker/scripts/test/domain.test.ts +99 -0
  71. package/adapters/cursor/olko-creative/skills/carousel-maker/scripts/tsconfig.json +16 -0
  72. package/adapters/cursor/olko-creative/skills/shorts-animator/SKILL.md +113 -0
  73. package/adapters/cursor/olko-creative/skills/shorts-animator/references/kit.md +59 -0
  74. package/adapters/cursor/olko-creative/skills/shorts-animator/references/looks.md +32 -0
  75. package/adapters/cursor/olko-creative/skills/shorts-animator/references/writing.md +9 -0
  76. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/audio/compose.ts +290 -0
  77. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/audio/dsp.ts +193 -0
  78. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/audio/instruments.ts +185 -0
  79. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/audio/master.ts +55 -0
  80. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/audio/soundtrack.ts +119 -0
  81. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/domain/captions.ts +35 -0
  82. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/domain/check.ts +96 -0
  83. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/domain/look.ts +26 -0
  84. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/domain/patch.ts +38 -0
  85. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/domain/schema.ts +81 -0
  86. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/domain/timeline.ts +75 -0
  87. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/domain/types.ts +91 -0
  88. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/edge/chrome.ts +169 -0
  89. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/edge/doctor.ts +52 -0
  90. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/edge/media.ts +105 -0
  91. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/edge/page.ts +53 -0
  92. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/edge/probe.ts +75 -0
  93. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/edge/sound.ts +46 -0
  94. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/edge/voice.ts +183 -0
  95. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/looks/gouache.json +7 -0
  96. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/looks/ink.json +6 -0
  97. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/looks/oil.json +7 -0
  98. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/looks/pastel.json +7 -0
  99. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/looks/watercolor.json +6 -0
  100. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/package.json +4 -0
  101. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/runtime/globals.d.ts +199 -0
  102. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/runtime/kit.ts +856 -0
  103. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/runtime/paint.ts +268 -0
  104. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/runtime/stage.ts +181 -0
  105. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/runtime/tsconfig.json +12 -0
  106. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/sa.ts +262 -0
  107. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/test/domain.test.ts +107 -0
  108. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/tsconfig.json +16 -0
  109. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/tts.py +20 -0
  110. package/adapters/cursor/olko-reflection/skills/self-critique/SKILL.md +167 -43
  111. package/adapters/cursor/olko-reflection/skills/self-critique/scripts/claim-ledger.mjs +159 -0
  112. package/adapters/cursor/olko-reflection/skills/self-critique/scripts/rule-store.mjs +193 -0
  113. package/adapters/grok/olko-creative/skills/carousel-maker/SKILL.md +112 -0
  114. package/adapters/grok/olko-creative/skills/carousel-maker/scripts/cm.ts +220 -0
  115. package/adapters/grok/olko-creative/skills/carousel-maker/scripts/domain/check.ts +68 -0
  116. package/adapters/grok/olko-creative/skills/carousel-maker/scripts/domain/layout.ts +75 -0
  117. package/adapters/grok/olko-creative/skills/carousel-maker/scripts/domain/schema.ts +54 -0
  118. package/adapters/grok/olko-creative/skills/carousel-maker/scripts/domain/types.ts +33 -0
  119. package/adapters/grok/olko-creative/skills/carousel-maker/scripts/edge/io.ts +87 -0
  120. package/adapters/grok/olko-creative/skills/carousel-maker/scripts/package.json +4 -0
  121. package/adapters/grok/olko-creative/skills/carousel-maker/scripts/test/domain.test.ts +99 -0
  122. package/adapters/grok/olko-creative/skills/carousel-maker/scripts/tsconfig.json +16 -0
  123. package/adapters/grok/olko-creative/skills/shorts-animator/SKILL.md +113 -0
  124. package/adapters/grok/olko-creative/skills/shorts-animator/references/kit.md +59 -0
  125. package/adapters/grok/olko-creative/skills/shorts-animator/references/looks.md +32 -0
  126. package/adapters/grok/olko-creative/skills/shorts-animator/references/writing.md +9 -0
  127. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/audio/compose.ts +290 -0
  128. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/audio/dsp.ts +193 -0
  129. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/audio/instruments.ts +185 -0
  130. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/audio/master.ts +55 -0
  131. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/audio/soundtrack.ts +119 -0
  132. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/domain/captions.ts +35 -0
  133. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/domain/check.ts +96 -0
  134. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/domain/look.ts +26 -0
  135. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/domain/patch.ts +38 -0
  136. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/domain/schema.ts +81 -0
  137. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/domain/timeline.ts +75 -0
  138. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/domain/types.ts +91 -0
  139. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/edge/chrome.ts +169 -0
  140. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/edge/doctor.ts +52 -0
  141. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/edge/media.ts +105 -0
  142. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/edge/page.ts +53 -0
  143. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/edge/probe.ts +75 -0
  144. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/edge/sound.ts +46 -0
  145. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/edge/voice.ts +183 -0
  146. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/looks/gouache.json +7 -0
  147. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/looks/ink.json +6 -0
  148. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/looks/oil.json +7 -0
  149. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/looks/pastel.json +7 -0
  150. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/looks/watercolor.json +6 -0
  151. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/package.json +4 -0
  152. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/runtime/globals.d.ts +199 -0
  153. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/runtime/kit.ts +856 -0
  154. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/runtime/paint.ts +268 -0
  155. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/runtime/stage.ts +181 -0
  156. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/runtime/tsconfig.json +12 -0
  157. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/sa.ts +262 -0
  158. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/test/domain.test.ts +107 -0
  159. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/tsconfig.json +16 -0
  160. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/tts.py +20 -0
  161. package/adapters/grok/olko-reflection/skills/self-critique/SKILL.md +167 -43
  162. package/adapters/grok/olko-reflection/skills/self-critique/scripts/claim-ledger.mjs +159 -0
  163. package/adapters/grok/olko-reflection/skills/self-critique/scripts/rule-store.mjs +193 -0
  164. package/adapters/hermes/olko-creative/README.md +2 -0
  165. package/catalog/skills.json +47 -1
  166. package/docs/superpowers/plans/2026-10-27-self-critique-v2.md +306 -0
  167. package/docs/superpowers/specs/2026-10-27-self-critique-v2-spec.md +104 -0
  168. package/package.json +1 -1
  169. package/plugins/olko-apple-kit/.claude-plugin/plugin.json +1 -1
  170. package/plugins/olko-creative/.claude-plugin/plugin.json +1 -1
  171. package/plugins/olko-creative/skills/carousel-maker/SKILL.md +111 -0
  172. package/plugins/olko-creative/skills/carousel-maker/scripts/cm.ts +220 -0
  173. package/plugins/olko-creative/skills/carousel-maker/scripts/domain/check.ts +68 -0
  174. package/plugins/olko-creative/skills/carousel-maker/scripts/domain/layout.ts +75 -0
  175. package/plugins/olko-creative/skills/carousel-maker/scripts/domain/schema.ts +54 -0
  176. package/plugins/olko-creative/skills/carousel-maker/scripts/domain/types.ts +33 -0
  177. package/plugins/olko-creative/skills/carousel-maker/scripts/edge/io.ts +87 -0
  178. package/plugins/olko-creative/skills/carousel-maker/scripts/package.json +4 -0
  179. package/plugins/olko-creative/skills/carousel-maker/scripts/test/domain.test.ts +99 -0
  180. package/plugins/olko-creative/skills/carousel-maker/scripts/tsconfig.json +16 -0
  181. package/plugins/olko-creative/skills/shorts-animator/SKILL.md +112 -0
  182. package/plugins/olko-creative/skills/shorts-animator/references/kit.md +59 -0
  183. package/plugins/olko-creative/skills/shorts-animator/references/looks.md +32 -0
  184. package/plugins/olko-creative/skills/shorts-animator/references/writing.md +9 -0
  185. package/plugins/olko-creative/skills/shorts-animator/scripts/audio/compose.ts +290 -0
  186. package/plugins/olko-creative/skills/shorts-animator/scripts/audio/dsp.ts +193 -0
  187. package/plugins/olko-creative/skills/shorts-animator/scripts/audio/instruments.ts +185 -0
  188. package/plugins/olko-creative/skills/shorts-animator/scripts/audio/master.ts +55 -0
  189. package/plugins/olko-creative/skills/shorts-animator/scripts/audio/soundtrack.ts +119 -0
  190. package/plugins/olko-creative/skills/shorts-animator/scripts/domain/captions.ts +35 -0
  191. package/plugins/olko-creative/skills/shorts-animator/scripts/domain/check.ts +96 -0
  192. package/plugins/olko-creative/skills/shorts-animator/scripts/domain/look.ts +26 -0
  193. package/plugins/olko-creative/skills/shorts-animator/scripts/domain/patch.ts +38 -0
  194. package/plugins/olko-creative/skills/shorts-animator/scripts/domain/schema.ts +81 -0
  195. package/plugins/olko-creative/skills/shorts-animator/scripts/domain/timeline.ts +75 -0
  196. package/plugins/olko-creative/skills/shorts-animator/scripts/domain/types.ts +91 -0
  197. package/plugins/olko-creative/skills/shorts-animator/scripts/edge/chrome.ts +169 -0
  198. package/plugins/olko-creative/skills/shorts-animator/scripts/edge/doctor.ts +52 -0
  199. package/plugins/olko-creative/skills/shorts-animator/scripts/edge/media.ts +105 -0
  200. package/plugins/olko-creative/skills/shorts-animator/scripts/edge/page.ts +53 -0
  201. package/plugins/olko-creative/skills/shorts-animator/scripts/edge/probe.ts +75 -0
  202. package/plugins/olko-creative/skills/shorts-animator/scripts/edge/sound.ts +46 -0
  203. package/plugins/olko-creative/skills/shorts-animator/scripts/edge/voice.ts +183 -0
  204. package/plugins/olko-creative/skills/shorts-animator/scripts/looks/gouache.json +7 -0
  205. package/plugins/olko-creative/skills/shorts-animator/scripts/looks/ink.json +6 -0
  206. package/plugins/olko-creative/skills/shorts-animator/scripts/looks/oil.json +7 -0
  207. package/plugins/olko-creative/skills/shorts-animator/scripts/looks/pastel.json +7 -0
  208. package/plugins/olko-creative/skills/shorts-animator/scripts/looks/watercolor.json +6 -0
  209. package/plugins/olko-creative/skills/shorts-animator/scripts/package.json +4 -0
  210. package/plugins/olko-creative/skills/shorts-animator/scripts/runtime/globals.d.ts +199 -0
  211. package/plugins/olko-creative/skills/shorts-animator/scripts/runtime/kit.ts +856 -0
  212. package/plugins/olko-creative/skills/shorts-animator/scripts/runtime/paint.ts +268 -0
  213. package/plugins/olko-creative/skills/shorts-animator/scripts/runtime/stage.ts +181 -0
  214. package/plugins/olko-creative/skills/shorts-animator/scripts/runtime/tsconfig.json +12 -0
  215. package/plugins/olko-creative/skills/shorts-animator/scripts/sa.ts +262 -0
  216. package/plugins/olko-creative/skills/shorts-animator/scripts/test/domain.test.ts +107 -0
  217. package/plugins/olko-creative/skills/shorts-animator/scripts/tsconfig.json +16 -0
  218. package/plugins/olko-creative/skills/shorts-animator/scripts/tts.py +20 -0
  219. package/plugins/olko-garmin-kit/.claude-plugin/plugin.json +1 -1
  220. package/plugins/olko-git-tools/.claude-plugin/plugin.json +1 -1
  221. package/plugins/olko-github-pr/.claude-plugin/plugin.json +1 -1
  222. package/plugins/olko-obsidian/.claude-plugin/plugin.json +1 -1
  223. package/plugins/olko-product/.claude-plugin/plugin.json +1 -1
  224. package/plugins/olko-reflection/.claude-plugin/plugin.json +1 -1
  225. package/plugins/olko-reflection/skills/self-critique/SKILL.md +167 -43
  226. package/plugins/olko-reflection/skills/self-critique/scripts/claim-ledger.mjs +159 -0
  227. package/plugins/olko-reflection/skills/self-critique/scripts/rule-store.mjs +193 -0
  228. package/plugins/olko-release/.claude-plugin/plugin.json +1 -1
  229. package/plugins/olko-skill-meta/.claude-plugin/plugin.json +1 -1
  230. package/plugins/olko-web-ops/.claude-plugin/plugin.json +1 -1
  231. package/scripts/lib/catalog.mjs +2 -1
@@ -0,0 +1,220 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * Carousel Maker: a brand and a few lines of copy in, Instagram carousel slides out.
4
+ *
5
+ * CM new <dir> [--name "Brand"] [--handle @x] [--look ink] brand.json, decks/example.json, art/
6
+ * CM check <dir> [deck] what a render would get wrong, no browser
7
+ * CM art <dir> [scene...] draws the library: art/scenes.js -> library/<scene>.jpg
8
+ * CM render <dir> [deck] slides -> out/<deck>/slide-N.png, sheet.jpg, caption.txt
9
+ *
10
+ * CM = node <skill-dir>/scripts/cm.ts
11
+ */
12
+ import { spawnSync } from "node:child_process";
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+ import { check, fitFindings, report, type Finding, type Fit } from "./domain/check.ts";
16
+ import { contexts, H, MEASURE, page, section } from "./domain/layout.ts";
17
+ import { isMap, parseBrand, parseDeck } from "./domain/schema.ts";
18
+ import type { Art, Brand, Deck, Json, Result } from "./domain/types.ts";
19
+ import { dumpDom, findChrome, mkdir, readJson, screenshot, sheet, url, writeJson, writeNew } from "./edge/io.ts";
20
+
21
+ type Args = { readonly dir: string | undefined; readonly rest: readonly string[] };
22
+ type Command = (a: Args) => Promise<number>;
23
+
24
+ const SA = process.env.SHORTS_ANIMATOR ?? path.join(import.meta.dirname, "../../shorts-animator/scripts/sa.ts");
25
+ const ART_LOOKS = ["ink", "gouache", "watercolor", "pastel", "oil"];
26
+
27
+ const unwrap = <T>(r: Result<T>): T => {
28
+ if (!r.ok) throw new Error(r.errors.join("\n "));
29
+ return r.value;
30
+ };
31
+ const need = (dir: string | undefined, usage: string): string => {
32
+ if (!dir) throw new Error(`usage: ${usage}`);
33
+ return dir;
34
+ };
35
+ const opt = (rest: readonly string[], k: string, dflt: string): string => {
36
+ const i = rest.indexOf(`--${k}`);
37
+ return (i >= 0 ? rest[i + 1] : undefined) ?? dflt;
38
+ };
39
+ const positional = (rest: readonly string[]): readonly string[] =>
40
+ rest.filter((a, i) => !a.startsWith("--") && !(rest[i - 1] ?? "").startsWith("--"));
41
+
42
+ const brandOf = (d: string): Brand => unwrap(parseBrand(readJson(path.join(d, "brand.json"))));
43
+ const deckFiles = (d: string, only?: string): readonly string[] => {
44
+ const ddir = path.join(d, "decks");
45
+ const all = fs.existsSync(ddir) ? fs.readdirSync(ddir).filter((f) => f.endsWith(".json")).toSorted() : [];
46
+ const picked = only ? all.filter((f) => f === `${only.replace(/\.json$/, "")}.json`) : all;
47
+ if (picked.length === 0) throw new Error(only ? `no deck decks/${only}.json` : `no decks in ${ddir}`);
48
+ return picked.map((f) => path.join(ddir, f));
49
+ };
50
+ const deckOf = (f: string): Deck => {
51
+ const r = parseDeck(readJson(f));
52
+ if (!r.ok) throw new Error(`${path.basename(f)}:\n ${r.errors.join("\n ")}`);
53
+ return r.value;
54
+ };
55
+ const library = (d: string): readonly string[] => {
56
+ const ldir = path.join(d, "library");
57
+ return fs.existsSync(ldir) ? fs.readdirSync(ldir).filter((f) => f.endsWith(".jpg")).map((f) => f.slice(0, -4)) : [];
58
+ };
59
+ const artPath = (d: string, a: Art): string | undefined =>
60
+ a.kind === "library" ? path.join(d, "library", `${a.name}.jpg`) : a.kind === "file" ? path.resolve(d, a.file) : undefined;
61
+
62
+ const deckFindings = (d: string, deck: Deck): readonly Finding[] =>
63
+ check({
64
+ deck, library: library(d),
65
+ files: deck.slides.flatMap((s) => (s.art.kind === "file" && fs.existsSync(path.resolve(d, s.art.file)) ? [s.art.file] : [])),
66
+ });
67
+
68
+ const sa = (args: readonly string[]): number => {
69
+ if (!fs.existsSync(SA)) throw new Error(`shorts-animator not found at ${SA}; install it next to this skill or set SHORTS_ANIMATOR`);
70
+ return spawnSync(process.execPath, [SA, ...args], { stdio: "inherit" }).status ?? 1;
71
+ };
72
+
73
+ const EXAMPLE_DECK = {
74
+ title: "Example",
75
+ caption: "The post text under the carousel. Two short lines and one question for the comments.",
76
+ slides: [
77
+ { kind: "cover", title: "Five-word promise goes here", body: "One line on who it is for.", art: "cover" },
78
+ { kind: "point", title: "First step, short", body: "One or two plain sentences that make it concrete." },
79
+ { kind: "point", title: "Second step, short", body: "One or two plain sentences that make it concrete." },
80
+ { kind: "cta", title: "Save this for later", body: "Try one step today.", follow: "Follow for more." },
81
+ ],
82
+ };
83
+ const EXAMPLE_SCENES = `// One function per library drawing, named the way decks refer to it ("art": "cover").
84
+ // k is the shorts-animator kit (../shorts-animator/references/kit.md). One big subject, 1:1, brand colours.
85
+ window.SCENES = {
86
+ cover: (k) => {
87
+ k.sky({ stops: ["#f4efe3", "#e8dcc4"] });
88
+ k.sun({ x: 0.5, y: 0.45, r: 0.18, color: "#e0783a" });
89
+ },
90
+ };
91
+ `;
92
+
93
+ const create: Command = async ({ dir, rest }) => {
94
+ const d = need(dir, "CM new <dir>");
95
+ mkdir(d, "decks");
96
+ mkdir(d, "library");
97
+ const name = opt(rest, "name", "Untitled brand");
98
+ const look = opt(rest, "look", "ink");
99
+ if (!ART_LOOKS.includes(look)) throw new Error(`--look must be one of ${ART_LOOKS.join(", ")}`);
100
+ writeNew(path.join(d, "brand.json"), JSON.stringify({ name, handle: opt(rest, "handle", ""), look,
101
+ colors: { paper: "#f4efe3", ink: "#1b1712", accent: "#e0783a", muted: "#6b6257" },
102
+ fonts: { head: "Georgia, serif", body: '"Helvetica Neue", Helvetica, Arial, sans-serif' } }, null, 2) + "\n");
103
+ writeNew(path.join(d, "decks", "example.json"), JSON.stringify(EXAMPLE_DECK, null, 2) + "\n");
104
+ const art = path.join(d, "art");
105
+ if (!fs.existsSync(path.join(art, "short.json")) && fs.existsSync(SA)) {
106
+ sa(["new", art, "--look", look, "--format", "1:1"]);
107
+ fs.writeFileSync(path.join(art, "scenes.js"), EXAMPLE_SCENES);
108
+ }
109
+ console.log(`✓ ${d}\n brand.json name, handle, colours, fonts, art look\n decks/ one JSON per carousel\n art/ the drawings (scenes.js), drawn once per brand\n library/ the drawn pictures decks pick from`);
110
+ return 0;
111
+ };
112
+
113
+ const checkCmd: Command = async ({ dir, rest }) => {
114
+ const d = need(dir, "CM check <dir> [deck]");
115
+ brandOf(d);
116
+ const results = deckFiles(d, positional(rest)[0]).map((f) => {
117
+ const found = deckFindings(d, deckOf(f));
118
+ console.log(`${path.basename(f, ".json")}: ${report(found)}`);
119
+ return found;
120
+ });
121
+ return results.flat().some((f) => f.level === "error") ? 1 : 0;
122
+ };
123
+
124
+ /** Every library name any deck asks for, in first-use order. */
125
+ const wanted = (d: string): readonly string[] => [
126
+ ...new Set(deckFiles(d).flatMap((f) => deckOf(f).slides.flatMap((s) => (s.art.kind === "library" ? [s.art.name] : [])))),
127
+ ];
128
+
129
+ const art: Command = async ({ dir, rest }) => {
130
+ const d = need(dir, "CM art <dir> [scene...]");
131
+ const names = positional(rest).length > 0 ? positional(rest) : wanted(d);
132
+ if (names.length === 0) throw new Error("no deck asks for a library drawing; add \"art\": \"<scene>\" to a slide or name scenes");
133
+ const adir = path.join(d, "art");
134
+ const short = fs.existsSync(path.join(adir, "short.json")) ? readJson(path.join(adir, "short.json")) : {};
135
+ const base: { readonly [k: string]: Json } = isMap(short) ? short : {};
136
+ // One still line per drawing, so frame N is drawing N; no voice, music or captions to pay for.
137
+ writeJson(path.join(mkdir(adir), "short.json"), {
138
+ ...base, title: `${brandOf(d).name} art`, format: "1:1", voice: "none", music: "none", captions: { style: "none" },
139
+ lines: names.map((n) => ({ say: `the ${n} drawing for a carousel slide`, scene: n, move: "still" })),
140
+ });
141
+ if (sa(["check", adir]) !== 0) return 1;
142
+ if (sa(["frames", adir]) !== 0) return 1;
143
+ const ldir = mkdir(d, "library");
144
+ names.forEach((n, i) => fs.copyFileSync(path.join(adir, "frames", `s${String(i).padStart(3, "0")}.jpg`), path.join(ldir, `${n}.jpg`)));
145
+ console.log(`✓ ${names.length} drawings -> ${ldir}\n look once at ${path.join(adir, "frames", "sheet.jpg")}`);
146
+ return 0;
147
+ };
148
+
149
+ const fitOf = (dom: string): readonly Fit[] => {
150
+ const m = /<pre id="fit">([^<]*)<\/pre>/.exec(dom);
151
+ const raw: unknown = m?.[1] ? JSON.parse(m[1].replace(/&quot;/g, '"').replace(/&amp;/g, "&")) : [];
152
+ return Array.isArray(raw)
153
+ ? raw.flatMap((x: unknown) => (isMap(x) && typeof x["slide"] === "number"
154
+ ? [{ slide: x["slide"], overflow: x["overflow"] === true, missingArt: x["missingArt"] === true }] : []))
155
+ : [];
156
+ };
157
+
158
+ const renderDeck = (d: string, brand: Brand, f: string): readonly Finding[] => {
159
+ const deck = deckOf(f);
160
+ const before = deckFindings(d, deck);
161
+ if (before.some((x) => x.level === "error")) return before;
162
+ const chrome = findChrome();
163
+ if (!chrome) throw new Error("no Chrome found; install Chrome or set CHROME=/path/to/chrome");
164
+ const out = mkdir(d, "out", path.basename(f, ".json"));
165
+ const srcs = deck.slides.map((s) => {
166
+ const p = artPath(d, s.art);
167
+ return p ? url(p) : undefined;
168
+ });
169
+ const ctxs = contexts(brand, deck.slides, srcs);
170
+ const sections = deck.slides.flatMap((s, i) => {
171
+ const c = ctxs[i];
172
+ return c ? [section(s, c)] : [];
173
+ });
174
+ const deckHtml = path.join(out, "deck.html");
175
+ fs.writeFileSync(deckHtml, page(brand, sections.join(""), MEASURE));
176
+ const fit = fitFindings(fitOf(dumpDom(chrome, deckHtml, H)));
177
+ sections.forEach((s, i) => {
178
+ const html = path.join(out, `slide-${i + 1}.html`);
179
+ fs.writeFileSync(html, page(brand, s));
180
+ screenshot(chrome, html, path.join(out, `slide-${i + 1}.png`));
181
+ });
182
+ fs.writeFileSync(path.join(out, "caption.txt"), (deck.caption ?? "") + "\n");
183
+ const sheetFile = path.join(out, "sheet.jpg");
184
+ const made = sheet(path.join(out, "slide-%d.png"), deck.slides.length, sheetFile);
185
+ console.log(`✓ ${deck.slides.length} slides -> ${out}${made ? `\n look once at ${sheetFile}` : "\n (no ffmpeg: no contact sheet)"}`);
186
+ return [...before, ...fit];
187
+ };
188
+
189
+ const render: Command = async ({ dir, rest }) => {
190
+ const d = need(dir, "CM render <dir> [deck]");
191
+ const brand = brandOf(d);
192
+ const found = deckFiles(d, positional(rest)[0]).flatMap((f) => {
193
+ const r = renderDeck(d, brand, f);
194
+ if (r.length > 0) console.log(`${path.basename(f, ".json")}: ${report(r)}`);
195
+ return r;
196
+ });
197
+ return found.some((f) => f.level === "error") ? 1 : 0;
198
+ };
199
+
200
+ const COMMANDS: Readonly<Record<string, Command>> = { new: create, check: checkCmd, art, render };
201
+
202
+ const usage = (): string => fs.readFileSync(import.meta.filename, "utf8").split("\n").slice(2, 11).join("\n").replace(/^ \* ?/gm, "");
203
+
204
+ process.removeAllListeners("warning");
205
+ process.on("warning", (w) => {
206
+ if (!(w.name === "ExperimentalWarning" && /stripTypeScriptTypes/.test(w.message))) console.error(`${w.name}: ${w.message}`);
207
+ });
208
+
209
+ const [cmd = "", dirArg, ...rest] = process.argv.slice(2);
210
+ const command = COMMANDS[cmd];
211
+ if (!command) {
212
+ console.log(usage());
213
+ process.exit(cmd ? 1 : 0);
214
+ }
215
+ command({ dir: dirArg ? path.resolve(dirArg) : undefined, rest })
216
+ .then((code) => process.exit(code))
217
+ .catch((e: unknown) => {
218
+ console.error("✗ " + (e instanceof Error ? e.message : String(e)));
219
+ process.exit(1);
220
+ });
@@ -0,0 +1,68 @@
1
+ // `CM check`: catch what a render would get wrong, before paying for one. Each rule is a pure
2
+ // function over plain facts; the edge gathers the facts (the deck, the library, which files exist).
3
+ import { KINDS } from "./layout.ts";
4
+ import type { Deck, Slide } from "./types.ts";
5
+
6
+ export type Facts = { readonly deck: Deck; readonly library: readonly string[]; readonly files: readonly string[] };
7
+ export type Fit = { readonly slide: number; readonly overflow: boolean; readonly missingArt: boolean };
8
+ export type Finding = { readonly level: "error" | "warn"; readonly where: string; readonly msg: string };
9
+
10
+ const SLIDES = { min: 3, max: 10 };
11
+
12
+ const err = (where: string, msg: string): Finding => ({ level: "error", where, msg });
13
+ const warn = (where: string, msg: string): Finding => ({ level: "warn", where, msg });
14
+ const at = (i: number): string => `slide ${i + 1}`;
15
+ const words = (s: string | undefined): number => (s ? s.trim().split(/\s+/).length : 0);
16
+ const each = (deck: Deck, rule: (s: Slide, i: number) => readonly Finding[]): readonly Finding[] => deck.slides.flatMap(rule);
17
+
18
+ const unknownKinds = ({ deck }: Facts): readonly Finding[] =>
19
+ each(deck, (s, i) => (KINDS[s.kind] ? [] : [err(at(i), `kind "${s.kind}" is not one of ${Object.keys(KINDS).join(", ")}`)]));
20
+
21
+ const deckSize = ({ deck: { slides } }: Facts): readonly Finding[] =>
22
+ slides.length < SLIDES.min || slides.length > SLIDES.max ? [warn("deck", `${slides.length} slides; ${SLIDES.min} to ${SLIDES.max} read best`)] : [];
23
+
24
+ const shape = ({ deck: { slides } }: Facts): readonly Finding[] => [
25
+ ...(slides[0]?.kind === "cover" ? [] : [warn(at(0), "the first slide should be a cover: it is the only one most people see")]),
26
+ ...(slides.length < 2 || slides.at(-1)?.kind === "cta" ? [] : [warn(at(slides.length - 1), "the last slide should be a cta (save, follow, comment)")]),
27
+ ];
28
+
29
+ const lengths = ({ deck }: Facts): readonly Finding[] =>
30
+ each(deck, (s, i) => {
31
+ const k = KINDS[s.kind];
32
+ if (!k) return [];
33
+ return [
34
+ ...(words(s.title) > k.titleWords ? [warn(at(i), `title is ${words(s.title)} words; a ${s.kind} title fits ${k.titleWords}`)] : []),
35
+ ...(words(s.body) > k.bodyWords ? [warn(at(i), `body is ${words(s.body)} words; a ${s.kind} body fits ${k.bodyWords}`)] : []),
36
+ ];
37
+ });
38
+
39
+ const missingArt = ({ deck, library, files }: Facts): readonly Finding[] => {
40
+ const lib = new Set(library);
41
+ const have = new Set(files);
42
+ return each(deck, (s, i) =>
43
+ s.art.kind === "library" && !lib.has(s.art.name)
44
+ ? [err(at(i), `no drawing "${s.art.name}" in library/; add it to art/scenes.js and run CM art`)]
45
+ : s.art.kind === "file" && !have.has(s.art.file)
46
+ ? [err(at(i), `no file ${s.art.file}`)]
47
+ : [],
48
+ );
49
+ };
50
+
51
+ const repeatedArt = ({ deck }: Facts): readonly Finding[] => {
52
+ const names = deck.slides.map((s) => (s.art.kind === "library" ? s.art.name : s.art.kind === "file" ? s.art.file : ""));
53
+ return names.flatMap((n, i) => (n && names.indexOf(n) < i ? [warn(at(i), `repeats the picture of slide ${names.indexOf(n) + 1}`)] : []));
54
+ };
55
+
56
+ const RULES = [unknownKinds, missingArt, deckSize, shape, lengths, repeatedArt];
57
+
58
+ export const check = (facts: Facts): readonly Finding[] => RULES.flatMap((rule) => rule(facts));
59
+
60
+ /** What the browser measured after layout, as findings. */
61
+ export const fitFindings = (fit: readonly Fit[]): readonly Finding[] =>
62
+ fit.flatMap((f) => [
63
+ ...(f.overflow ? [err(`slide ${f.slide}`, "text spills out of the frame; shorten the title or body")] : []),
64
+ ...(f.missingArt ? [err(`slide ${f.slide}`, "its picture did not load")] : []),
65
+ ]);
66
+
67
+ export const report = (fs: readonly Finding[]): string =>
68
+ fs.length === 0 ? "check: clean" : fs.map((f) => `${f.level === "error" ? "ERROR" : "warn "} ${f.where}: ${f.msg}`).join("\n");
@@ -0,0 +1,75 @@
1
+ // The brand template: one fixed 1080x1350 layout per slide kind, filled from the deck. Pure strings;
2
+ // the edge decides where the HTML goes and resolves each picture to a URL first.
3
+ import type { Brand, Slide } from "./types.ts";
4
+
5
+ export const W = 1080;
6
+ export const H = 1350;
7
+
8
+ export type Ctx = { readonly brand: Brand; readonly n: number; readonly total: number; readonly point: number; readonly src?: string };
9
+ type Kind = { readonly titleWords: number; readonly bodyWords: number; readonly render: (s: Slide, c: Ctx) => string };
10
+
11
+ export const esc = (s: string): string => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
12
+ const img = (c: Ctx): string => (c.src ? `<img class="art" src="${esc(c.src)}">` : "");
13
+ const para = (text: string | undefined, cls = "body"): string => (text ? `<p class="${cls}">${esc(text)}</p>` : "");
14
+ const BOOKMARK = (b: Brand): string =>
15
+ `<svg class="mark" width="150" height="190" viewBox="0 0 150 190"><path d="M14 10h122v170l-61-44-61 44z" fill="${b.colors.accent}" stroke="${b.colors.ink}" stroke-width="8" stroke-linejoin="round"/></svg>`;
16
+
17
+ export const KINDS: Readonly<Record<string, Kind>> = {
18
+ cover: { titleWords: 8, bodyWords: 14, render: (s, c) => `${img(c)}<h1>${esc(s.title)}</h1>${para(s.body)}` },
19
+ point: {
20
+ titleWords: 7, bodyWords: 22,
21
+ render: (s, c) => `<div class="n">${String(c.point).padStart(2, "0")}</div><h1>${esc(s.title)}</h1>${para(s.body)}${img(c)}`,
22
+ },
23
+ cta: { titleWords: 6, bodyWords: 14, render: (s, c) => `${c.src ? img(c) : BOOKMARK(c.brand)}<h1>${esc(s.title)}</h1>${para(s.body)}${para(s.follow, "body follow")}` },
24
+ };
25
+
26
+ export const css = ({ colors: k, fonts: f }: Brand): string => `
27
+ *{margin:0;box-sizing:border-box}
28
+ body{background:${k.paper}}
29
+ .slide{width:${W}px;height:${H}px;background:${k.paper};color:${k.ink};font-family:${f.body};
30
+ padding:80px 84px 64px;display:flex;flex-direction:column;overflow:hidden;position:relative}
31
+ h1{font-family:${f.head};font-weight:700;letter-spacing:-1.5px;line-height:1.02;text-wrap:balance;overflow-wrap:normal}
32
+ .art{display:block;width:100%;aspect-ratio:1;object-fit:cover;border-radius:28px;border:4px solid ${k.ink}}
33
+ .n{font-family:${f.head};font-weight:700;font-size:56px;color:${k.accent};margin-bottom:12px}
34
+ .body{font-size:34px;line-height:1.35;color:${k.muted};margin-top:18px;text-wrap:pretty}
35
+ .foot{margin-top:auto;display:flex;justify-content:space-between;font-size:26px;font-weight:600;color:${k.muted};padding-top:28px}
36
+ .swipe{color:${k.accent}}
37
+ .cover h1{font-size:92px;margin-top:44px}
38
+ .cover .art{width:760px;margin:0 auto}
39
+ .point h1{font-size:76px}
40
+ .point .art{width:800px;margin:44px auto 0}
41
+ .cta{justify-content:center;text-align:center}
42
+ .cta .mark{margin:auto auto 0}
43
+ .cta .art{width:520px;margin:auto auto 0}
44
+ .cta h1{font-size:104px;margin-top:56px}
45
+ .cta .follow{color:${k.ink};font-weight:600;margin-bottom:auto}
46
+ .noart.cover h1{font-size:128px;margin-top:auto}
47
+ .noart.point h1{font-size:104px;margin-top:40px}
48
+ .noart.point .body{font-size:44px;margin-top:36px}
49
+ `;
50
+
51
+ const foot = (c: Ctx): string =>
52
+ `<div class="foot"><span>${esc(c.brand.handle)}</span><span>${c.n === 1 ? '<span class="swipe">swipe &rarr;</span>' : `${c.n}/${c.total}`}</span></div>`;
53
+
54
+ /** One slide's section; an unknown kind renders as a point so a typo never blanks a slide. */
55
+ export const section = (s: Slide, c: Ctx): string => {
56
+ const kind = KINDS[s.kind] ? s.kind : "point";
57
+ const body = (KINDS[kind] ?? KINDS["point"])?.render(s, c) ?? "";
58
+ return `<section class="slide ${kind}${c.src ? "" : " noart"}">${body}${foot(c)}</section>`;
59
+ };
60
+
61
+ /** The context of every slide: its place in the deck, its point number, its picture. */
62
+ export const contexts = (brand: Brand, slides: readonly Slide[], srcs: readonly (string | undefined)[]): readonly Ctx[] =>
63
+ slides.map((s, i) => ({
64
+ brand, n: i + 1, total: slides.length, src: srcs[i],
65
+ point: slides.slice(0, i + 1).filter((x) => x.kind === "point" || !KINDS[x.kind]).length,
66
+ }));
67
+
68
+ export const page = (brand: Brand, body: string, script = ""): string =>
69
+ `<!doctype html><meta charset="utf-8"><style>${css(brand)}</style><body>${body}${script}</body>`;
70
+
71
+ /** Runs in the browser after layout: which slides spill out of their frame or lost their picture. */
72
+ export const MEASURE = `<script>addEventListener("load",()=>{const r=[...document.querySelectorAll(".slide")].map((s,i)=>({slide:i+1,
73
+ overflow:s.scrollHeight>s.clientHeight+1||s.scrollWidth>s.clientWidth+1||[...s.querySelectorAll("h1,p")].some(e=>e.scrollWidth>e.clientWidth+1),
74
+ missingArt:[...s.querySelectorAll("img")].some(i=>!i.complete||i.naturalWidth===0)}));
75
+ const p=document.createElement("pre");p.id="fit";p.textContent=JSON.stringify(r);document.body.append(p)})</script>`;
@@ -0,0 +1,54 @@
1
+ // brand.json and decks/*.json arrive as unknown JSON; this is the one place they are narrowed.
2
+ import type { Art, Brand, Deck, Json, JsonMap, Result, Slide } from "./types.ts";
3
+ import { fail, ok } from "./types.ts";
4
+
5
+ export const isMap = (x: unknown): x is JsonMap => typeof x === "object" && x !== null && !Array.isArray(x);
6
+
7
+ const str = (o: JsonMap, k: string): string | undefined => {
8
+ const v = o[k];
9
+ return typeof v === "string" && v.trim() !== "" ? v : undefined;
10
+ };
11
+ const map = (o: JsonMap, k: string): JsonMap => {
12
+ const v = o[k];
13
+ return isMap(v) ? v : {};
14
+ };
15
+
16
+ const HEX = /^#[0-9a-f]{6}$/i;
17
+ export const DEFAULT_COLORS = { paper: "#f4efe3", ink: "#1b1712", accent: "#e0783a", muted: "#6b6257" };
18
+ export const DEFAULT_FONTS = { head: "Georgia, serif", body: '"Helvetica Neue", Helvetica, Arial, sans-serif' };
19
+
20
+ export function parseBrand(raw: unknown): Result<Brand> {
21
+ if (!isMap(raw)) return fail("brand.json is not an object");
22
+ const c = map(raw, "colors");
23
+ const f = map(raw, "fonts");
24
+ const colors = {
25
+ paper: str(c, "paper") ?? DEFAULT_COLORS.paper, ink: str(c, "ink") ?? DEFAULT_COLORS.ink,
26
+ accent: str(c, "accent") ?? DEFAULT_COLORS.accent, muted: str(c, "muted") ?? DEFAULT_COLORS.muted,
27
+ };
28
+ const bad = Object.entries(colors).filter(([, v]) => !HEX.test(v)).map(([k, v]) => `colors.${k} "${v}" is not a #rrggbb colour`);
29
+ const name = str(raw, "name");
30
+ const errors = [...(name ? [] : ["brand.json needs a name"]), ...bad];
31
+ if (errors.length > 0 || !name) return fail(...errors);
32
+ return ok({ name, handle: str(raw, "handle") ?? "", colors, fonts: { head: str(f, "head") ?? DEFAULT_FONTS.head, body: str(f, "body") ?? DEFAULT_FONTS.body } });
33
+ }
34
+
35
+ /** A bare word is a library drawing; anything with a slash or an extension is a file. */
36
+ export const artOf = (x: Json | undefined): Art =>
37
+ typeof x !== "string" || x.trim() === "" ? { kind: "none" } : /[/.]/.test(x) ? { kind: "file", file: x } : { kind: "library", name: x };
38
+
39
+ const slideOf = (x: Json, i: number): Result<Slide> => {
40
+ if (!isMap(x)) return fail(`slides[${i}] is not an object`);
41
+ const title = str(x, "title");
42
+ if (!title) return fail(`slides[${i}] needs a title`);
43
+ return ok({ kind: str(x, "kind") ?? "point", title, body: str(x, "body"), follow: str(x, "follow"), art: artOf(x["art"]) });
44
+ };
45
+
46
+ export function parseDeck(raw: unknown): Result<Deck> {
47
+ if (!isMap(raw)) return fail("the deck is not an object");
48
+ const list = raw["slides"];
49
+ if (!Array.isArray(list) || list.length === 0) return fail("the deck needs a non-empty slides list");
50
+ const parsed = list.map(slideOf);
51
+ const errors = parsed.flatMap((r) => (r.ok ? [] : r.errors));
52
+ const slides = parsed.flatMap((r) => (r.ok ? [r.value] : []));
53
+ return errors.length > 0 ? fail(...errors) : ok({ title: str(raw, "title") ?? "carousel", caption: str(raw, "caption"), slides });
54
+ }
@@ -0,0 +1,33 @@
1
+ // The data a carousel is made of. Maps stay open: unknown keys are ignored, never rejected.
2
+
3
+ export type Json = null | boolean | number | string | readonly Json[] | { readonly [k: string]: Json };
4
+ export type JsonMap = { readonly [k: string]: Json };
5
+
6
+ export type Brand = {
7
+ readonly name: string;
8
+ readonly handle: string;
9
+ readonly colors: { readonly paper: string; readonly ink: string; readonly accent: string; readonly muted: string };
10
+ readonly fonts: { readonly head: string; readonly body: string };
11
+ };
12
+
13
+ /** Where a slide's picture comes from: a drawing in the brand library, a file, or nothing. */
14
+ export type Art = { readonly kind: "none" } | { readonly kind: "library"; readonly name: string } | { readonly kind: "file"; readonly file: string };
15
+
16
+ export type Slide = {
17
+ readonly kind: string;
18
+ readonly title: string;
19
+ readonly body?: string;
20
+ readonly follow?: string;
21
+ readonly art: Art;
22
+ };
23
+
24
+ export type Deck = {
25
+ readonly title: string;
26
+ readonly caption?: string;
27
+ readonly slides: readonly Slide[];
28
+ };
29
+
30
+ export type Result<T> = { readonly ok: true; readonly value: T } | { readonly ok: false; readonly errors: readonly string[] };
31
+
32
+ export const ok = <T>(value: T): Result<T> => ({ ok: true, value });
33
+ export const fail = <T>(...errors: readonly string[]): Result<T> => ({ ok: false, errors });
@@ -0,0 +1,87 @@
1
+ // Every process, browser and disk write the tool makes, kept out of the domain.
2
+ import { spawnSync, type SpawnSyncOptions } from "node:child_process";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { pathToFileURL } from "node:url";
7
+ import { H, W } from "../domain/layout.ts";
8
+ import type { Json } from "../domain/types.ts";
9
+
10
+ export function run(cmd: string, args: readonly string[], opts: SpawnSyncOptions = {}): string {
11
+ const r = spawnSync(cmd, args, { encoding: "utf8", maxBuffer: 1 << 26, timeout: 120_000, ...opts });
12
+ if (r.status !== 0) {
13
+ const out = String(r.stderr || r.stdout || r.error?.message || "");
14
+ throw new Error(`${path.basename(cmd)} ${args.slice(0, 2).join(" ")}... failed:\n${out.slice(-1500)}`);
15
+ }
16
+ return String(r.stdout ?? "");
17
+ }
18
+
19
+ export const has = (cmd: string): boolean => spawnSync(cmd, ["-version"], { stdio: "ignore" }).status === 0;
20
+ export const readJson = (f: string): unknown => JSON.parse(fs.readFileSync(f, "utf8"));
21
+ export const writeJson = (f: string, v: Json | object): void => fs.writeFileSync(f, JSON.stringify(v, null, 2) + "\n");
22
+ export const mkdir = (...parts: string[]): string => {
23
+ const d = path.join(...parts);
24
+ fs.mkdirSync(d, { recursive: true });
25
+ return d;
26
+ };
27
+ export const writeNew = (f: string, text: string): boolean => {
28
+ if (fs.existsSync(f)) return false;
29
+ fs.writeFileSync(f, text);
30
+ return true;
31
+ };
32
+
33
+ export type Chrome = { readonly bin: string; readonly shell: boolean };
34
+
35
+ const SHELL = process.platform === "win32" ? "chrome-headless-shell.exe" : "chrome-headless-shell";
36
+ const SYSTEM: Readonly<Record<string, readonly string[]>> = {
37
+ darwin: ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"],
38
+ linux: ["/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser"],
39
+ win32: [
40
+ `${process.env["PROGRAMFILES"]}\\Google\\Chrome\\Application\\chrome.exe`,
41
+ `${process.env["PROGRAMFILES(X86)"]}\\Google\\Chrome\\Application\\chrome.exe`,
42
+ `${process.env["PROGRAMFILES(X86)"]}\\Microsoft\\Edge\\Application\\msedge.exe`,
43
+ ],
44
+ };
45
+
46
+ const dirs = (d: string): readonly string[] =>
47
+ fs.existsSync(d) ? fs.readdirSync(d, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => path.join(d, e.name)) : [];
48
+
49
+ /** The headless shell shorts-animator's doctor (via HyperFrames) downloads, newest first. */
50
+ const cachedShell = (): string | undefined =>
51
+ dirs(path.join(os.homedir(), ".cache/hyperframes/chrome/chrome-headless-shell"))
52
+ .toSorted()
53
+ .reverse()
54
+ .flatMap((v) => dirs(v).map((sub) => path.join(sub, SHELL)))
55
+ .find((f) => fs.existsSync(f));
56
+
57
+ export function findChrome(): Chrome | undefined {
58
+ const env = process.env.CHROME;
59
+ if (env && fs.existsSync(env)) return { bin: env, shell: /headless-shell/.test(env) };
60
+ const sys = (SYSTEM[process.platform] ?? []).find((c) => fs.existsSync(c));
61
+ if (sys) return { bin: sys, shell: false };
62
+ const shell = cachedShell();
63
+ return shell ? { bin: shell, shell: true } : undefined;
64
+ }
65
+
66
+ // One short-lived browser per call: no DevTools socket to manage, and a crash costs one slide.
67
+ const flags = (c: Chrome, height: number): readonly string[] => [
68
+ ...(c.shell ? [] : ["--headless"]), "--disable-gpu", "--hide-scrollbars", "--allow-file-access-from-files",
69
+ "--force-device-scale-factor=1", "--no-first-run", "--no-default-browser-check", `--window-size=${W},${height}`,
70
+ ];
71
+
72
+ export const url = (file: string): string => pathToFileURL(path.resolve(file)).href;
73
+
74
+ export const screenshot = (c: Chrome, html: string, png: string): void =>
75
+ void run(c.bin, [...flags(c, H), `--screenshot=${png}`, url(html)], { stdio: ["ignore", "ignore", "pipe"] });
76
+
77
+ /** The DOM after load, images and fonts included (the virtual time budget waits for them). */
78
+ export const dumpDom = (c: Chrome, html: string, height: number): string =>
79
+ run(c.bin, [...flags(c, height), "--virtual-time-budget=5000", "--dump-dom", url(html)]);
80
+
81
+ export const sheet = (pattern: string, count: number, out: string): boolean => {
82
+ if (!has("ffmpeg")) return false;
83
+ const cols = Math.min(count, 5);
84
+ run("ffmpeg", ["-v", "error", "-y", "-start_number", "1", "-framerate", "1", "-i", pattern,
85
+ "-vf", `scale=360:-2,tile=${cols}x${Math.ceil(count / cols)}:padding=8:color=white`, "-frames:v", "1", out]);
86
+ return true;
87
+ };
@@ -0,0 +1,4 @@
1
+ {
2
+ "private": true,
3
+ "type": "module"
4
+ }
@@ -0,0 +1,99 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { check, fitFindings, report, type Facts } from "../domain/check.ts";
4
+ import { contexts, esc, section } from "../domain/layout.ts";
5
+ import { artOf, parseBrand, parseDeck } from "../domain/schema.ts";
6
+ import type { Brand, Deck, Result } from "../domain/types.ts";
7
+
8
+ const value = <T>(r: Result<T>): T => {
9
+ if (!r.ok) throw new Error(r.errors.join("; "));
10
+ return r.value;
11
+ };
12
+
13
+ const BRAND: Brand = value(parseBrand({ name: "Slow Hours", handle: "@slowhours" }));
14
+ const RAW = {
15
+ title: "mornings",
16
+ slides: [
17
+ { kind: "cover", title: "Slow mornings, in 4 steps", body: "A calmer first hour.", art: "window" },
18
+ { kind: "point", title: "Wake with the light", body: "Open the curtains.", art: "light" },
19
+ { kind: "point", title: "Coffee, outside", art: "photos/mug.jpg" },
20
+ { kind: "cta", title: "Save this for tomorrow", follow: "Follow @slowhours" },
21
+ ],
22
+ };
23
+ const deck = (patch: object = {}): Deck => value(parseDeck({ ...RAW, ...patch }));
24
+ const facts = (d: Deck, library = ["window", "light"], files = ["photos/mug.jpg"]): Facts => ({ deck: d, library, files });
25
+ const msgs = (f: Facts, level: "error" | "warn"): readonly string[] => check(f).filter((x) => x.level === level).map((x) => `${x.where}: ${x.msg}`);
26
+
27
+ test("parseBrand fills default colours and fonts, and rejects a bad colour", () => {
28
+ assert.equal(BRAND.colors.accent, "#e0783a");
29
+ assert.match(BRAND.fonts.head, /Georgia/);
30
+ assert.equal(parseBrand({ name: "x", colors: { accent: "orange" } }).ok, false);
31
+ assert.equal(parseBrand({}).ok, false);
32
+ });
33
+
34
+ test("artOf tells a library drawing from a file from nothing", () => {
35
+ assert.deepEqual(artOf("window"), { kind: "library", name: "window" });
36
+ assert.deepEqual(artOf("photos/mug.jpg"), { kind: "file", file: "photos/mug.jpg" });
37
+ assert.deepEqual(artOf(undefined), { kind: "none" });
38
+ assert.deepEqual(artOf(""), { kind: "none" });
39
+ });
40
+
41
+ test("parseDeck defaults the kind to point and names every slide without a title", () => {
42
+ assert.equal(deck({ slides: [{ title: "a" }] }).slides[0]?.kind, "point");
43
+ const bad = parseDeck({ slides: [{ title: "a" }, {}, { kind: "cta" }] });
44
+ assert.equal(bad.ok, false);
45
+ assert.deepEqual(bad.ok ? [] : bad.errors, ["slides[1] needs a title", "slides[2] needs a title"]);
46
+ assert.equal(parseDeck({ slides: [] }).ok, false);
47
+ });
48
+
49
+ test("a well-shaped deck with its art present checks clean", () => {
50
+ assert.equal(report(check(facts(deck()))), "check: clean");
51
+ });
52
+
53
+ test("missing library drawings and files are errors", () => {
54
+ const errors = msgs(facts(deck(), ["window"], []), "error");
55
+ assert.ok(errors.some((m) => m.startsWith("slide 2") && m.includes('no drawing "light"')));
56
+ assert.ok(errors.some((m) => m.startsWith("slide 3") && m.includes("no file photos/mug.jpg")));
57
+ });
58
+
59
+ test("an unknown kind is an error; a deck without a cover or cta is warned", () => {
60
+ const d = deck({ slides: [{ kind: "quote", title: "a b" }, { kind: "point", title: "c d" }, { kind: "point", title: "e f" }] });
61
+ assert.ok(msgs(facts(d), "error").some((m) => m.includes('kind "quote"')));
62
+ const warns = msgs(facts(d), "warn");
63
+ assert.ok(warns.some((m) => m.startsWith("slide 1") && m.includes("cover")));
64
+ assert.ok(warns.some((m) => m.startsWith("slide 3") && m.includes("cta")));
65
+ });
66
+
67
+ test("long titles and repeated pictures are warned", () => {
68
+ const d = deck({ slides: [
69
+ { kind: "cover", title: "one two three four five six seven eight nine", art: "window" },
70
+ { kind: "point", title: "short", art: "window" },
71
+ { kind: "cta", title: "save" },
72
+ ] });
73
+ const warns = msgs(facts(d), "warn");
74
+ assert.ok(warns.some((m) => m.includes("title is 9 words")));
75
+ assert.ok(warns.some((m) => m.startsWith("slide 2") && m.includes("repeats the picture of slide 1")));
76
+ });
77
+
78
+ test("fitFindings turns the browser's measurements into errors", () => {
79
+ const f = fitFindings([{ slide: 1, overflow: false, missingArt: false }, { slide: 2, overflow: true, missingArt: true }]);
80
+ assert.deepEqual(f.map((x) => x.where), ["slide 2", "slide 2"]);
81
+ assert.ok(f.every((x) => x.level === "error"));
82
+ });
83
+
84
+ test("points are numbered among points only, and the footer counts every slide", () => {
85
+ const d = deck();
86
+ const ctx = contexts(BRAND, d.slides, ["a.jpg", "b.jpg", "c.jpg", undefined]);
87
+ assert.deepEqual(ctx.map((c) => c.point), [0, 1, 2, 2]);
88
+ const html = ctx.map((c, i) => (d.slides[i] ? section(d.slides[i], c) : ""));
89
+ assert.match(html[2] ?? "", /<div class="n">02<\/div>/);
90
+ assert.match(html[0] ?? "", /swipe/);
91
+ assert.match(html[3] ?? "", /4\/4/);
92
+ assert.match(html[3] ?? "", /class="slide cta noart"/);
93
+ });
94
+
95
+ test("copy is escaped into the HTML", () => {
96
+ assert.equal(esc(`<b>"x" & y</b>`), "&lt;b&gt;&quot;x&quot; &amp; y&lt;/b&gt;");
97
+ const html = section({ kind: "point", title: "<script>", art: { kind: "none" } }, { brand: BRAND, n: 2, total: 3, point: 1 });
98
+ assert.ok(!html.includes("<script>"));
99
+ });