kasaframe 0.1.4 → 0.1.6

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 (374) hide show
  1. package/NOTICE +311 -0
  2. package/README.md +18 -5
  3. package/docs/AGENTS.md +2 -1
  4. package/package.json +1 -1
  5. package/packages/adapters/src/project.ts +2 -0
  6. package/packages/check/package.json +1 -0
  7. package/packages/check/src/check.ts +8 -2
  8. package/packages/check/src/rules/frames.ts +4 -5
  9. package/packages/cli/src/commands/doctor.ts +15 -2
  10. package/packages/cli/src/commands/fonts.ts +118 -0
  11. package/packages/cli/src/commands/make.ts +15 -5
  12. package/packages/cli/src/commands/render.ts +34 -7
  13. package/packages/cli/src/commands/update.ts +66 -0
  14. package/packages/cli/src/fonts.ts +405 -0
  15. package/packages/cli/src/kf.ts +5 -0
  16. package/packages/cli/src/mix.ts +66 -4
  17. package/packages/cli/src/segments.ts +1 -1
  18. package/packages/cli/src/sfx.ts +429 -0
  19. package/packages/cli/src/update.ts +250 -0
  20. package/packages/core/src/frame.ts +7 -4
  21. package/packages/core/src/index.ts +3 -1
  22. package/packages/core/src/project.ts +19 -0
  23. package/packages/core/src/sfx.ts +70 -0
  24. package/packages/director/src/assemble.ts +7 -4
  25. package/packages/director/src/index.ts +1 -1
  26. package/packages/director/src/pattern.ts +2 -0
  27. package/packages/director/src/scene.ts +14 -5
  28. package/packages/director/src/transitions.ts +21 -8
  29. package/packages/motion/src/enter.ts +7 -1
  30. package/packages/motion/src/impact.ts +5 -2
  31. package/packages/motion/src/moves/dress.ts +15 -2
  32. package/packages/motion/src/moves/menu.ts +11 -5
  33. package/packages/motion/src/transition.ts +3 -1
  34. package/packages/motion/src/transition3d/index.ts +3 -1
  35. package/packages/motion/src/transition3d/live.ts +5 -3
  36. package/packages/patterns/src/bars/rank.ts +6 -7
  37. package/packages/patterns/src/before-after/shrink.ts +3 -4
  38. package/packages/patterns/src/before-after/strike.ts +4 -5
  39. package/packages/patterns/src/code/terminal.ts +25 -16
  40. package/packages/patterns/src/end/lockup.ts +13 -10
  41. package/packages/patterns/src/end/slam.ts +7 -5
  42. package/packages/patterns/src/hook-number/counter.ts +15 -9
  43. package/packages/patterns/src/hook-number/slam.ts +6 -8
  44. package/packages/patterns/src/kit.ts +281 -58
  45. package/packages/patterns/src/limit/gauge.ts +16 -11
  46. package/packages/patterns/src/quote/karaoke.ts +17 -12
  47. package/packages/patterns/src/sfx.ts +80 -0
  48. package/packages/patterns/src/term-reveal/chips.ts +24 -10
  49. package/packages/patterns/src/term-reveal/expand.ts +17 -13
  50. package/packages/patterns/src/title/mask.ts +9 -8
  51. package/packages/patterns/src/title/slam.ts +1 -1
  52. package/packages/patterns/src/types.ts +1 -0
  53. package/packages/plan/src/asks.ts +1 -1
  54. package/packages/plan/src/brief.ts +5 -2
  55. package/packages/plan/src/themes.ts +8 -2
  56. package/packages/themes/kit/THEME-MAKE.md +2 -2
  57. package/packages/themes/kit/fonts/display.json +3517 -0
  58. package/packages/themes/kit/fonts/sans.json +5155 -0
  59. package/packages/themes/kit/fonts/serif.json +5229 -0
  60. package/packages/themes/kit/fonts.json +243 -79
  61. package/packages/themes/package.json +3 -1
  62. package/packages/themes/src/fontfile.ts +183 -0
  63. package/packages/themes/src/fonts.ts +296 -0
  64. package/packages/themes/src/index.ts +32 -2
  65. package/packages/themes/src/look.ts +34 -8
  66. package/packages/themes/src/slots.ts +32 -2
  67. package/packages/themes/src/types.ts +33 -0
  68. package/packages/themes/src/vary.ts +35 -34
  69. package/presets/kawaii-pastel.json +2 -2
  70. package/sfx/README.md +82 -0
  71. package/sfx/click/appear.wav +0 -0
  72. package/sfx/click/burst.wav +0 -0
  73. package/sfx/click/drop.wav +0 -0
  74. package/sfx/click/end.wav +0 -0
  75. package/sfx/click/hop.wav +0 -0
  76. package/sfx/click/land.wav +0 -0
  77. package/sfx/click/select.wav +0 -0
  78. package/sfx/click/stick.wav +0 -0
  79. package/sfx/click/tick.wav +0 -0
  80. package/sfx/click/type.wav +0 -0
  81. package/sfx/click/whoosh.wav +0 -0
  82. package/sfx/pop/appear.wav +0 -0
  83. package/sfx/pop/burst.wav +0 -0
  84. package/sfx/pop/drop.wav +0 -0
  85. package/sfx/pop/end.wav +0 -0
  86. package/sfx/pop/hop.wav +0 -0
  87. package/sfx/pop/land.wav +0 -0
  88. package/sfx/pop/select.wav +0 -0
  89. package/sfx/pop/stick.wav +0 -0
  90. package/sfx/pop/tick.wav +0 -0
  91. package/sfx/pop/type.wav +0 -0
  92. package/sfx/pop/whoosh.wav +0 -0
  93. package/sfx/tags.json +545 -0
  94. package/sfx/ui/appear.wav +0 -0
  95. package/sfx/ui/burst.wav +0 -0
  96. package/sfx/ui/drop.wav +0 -0
  97. package/sfx/ui/end.wav +0 -0
  98. package/sfx/ui/hop.wav +0 -0
  99. package/sfx/ui/land.wav +0 -0
  100. package/sfx/ui/select.wav +0 -0
  101. package/sfx/ui/stick.wav +0 -0
  102. package/sfx/ui/tick.wav +0 -0
  103. package/sfx/ui/type.wav +0 -0
  104. package/sfx/ui/whoosh.wav +0 -0
  105. package/skills/kasaframe/SKILL.md +1 -0
  106. package/themes/blueprint/sample/style.json +5 -5
  107. package/themes/blueprint/style.json +5 -5
  108. package/themes/blueprint/theme.json +3 -1
  109. package/themes/blueprint/variants/deep-rose-suit.json +1 -1
  110. package/themes/blueprint/variants/navy-coral-2.json +1 -1
  111. package/themes/blueprint/variants/navy-coral-suit.json +1 -1
  112. package/themes/blueprint/variants/navy-orange-2.json +1 -1
  113. package/themes/blueprint/variants/navy-orange-suit.json +1 -1
  114. package/themes/cartography/sample/style.json +2 -2
  115. package/themes/cartography/style.json +2 -2
  116. package/themes/cartography/theme.json +3 -1
  117. package/themes/cartography/variants/deep-plum.json +1 -1
  118. package/themes/cartography/variants/frost-teal.json +1 -1
  119. package/themes/cartography/variants/navy-green.json +1 -1
  120. package/themes/circuit-board/sample/style.json +2 -2
  121. package/themes/circuit-board/style.json +2 -2
  122. package/themes/circuit-board/theme.json +3 -1
  123. package/themes/circuit-board/variants/dusk-gold-pretendard.json +1 -1
  124. package/themes/circuit-board/variants/navy-gold-grain.json +1 -1
  125. package/themes/circuit-board/variants/wine-gold-pretendard.json +1 -1
  126. package/themes/clay-toy/sample/style.json +2 -2
  127. package/themes/clay-toy/style.json +2 -2
  128. package/themes/clay-toy/theme.json +3 -1
  129. package/themes/clay-toy/variants/blush-coral-grain.json +1 -1
  130. package/themes/clay-toy/variants/chalk-teal.json +1 -1
  131. package/themes/clay-toy/variants/frost-sky.json +1 -1
  132. package/themes/clay-toy/variants/frost-teal.json +1 -1
  133. package/themes/clay-toy/variants/lilac-plum-round.json +1 -1
  134. package/themes/clay-toy/variants/lilac-plum.json +1 -1
  135. package/themes/clay-toy/variants/sage-indigo.json +1 -1
  136. package/themes/conveyor-factory/sample/style.json +3 -3
  137. package/themes/conveyor-factory/style.json +3 -3
  138. package/themes/conveyor-factory/theme.json +3 -1
  139. package/themes/conveyor-factory/variants/cocoa-gold.json +1 -1
  140. package/themes/conveyor-factory/variants/navy-gold.json +1 -1
  141. package/themes/conveyor-factory/variants/slate-amber-2.json +1 -1
  142. package/themes/conveyor-factory/variants/slate-amber-suit.json +1 -1
  143. package/themes/conveyor-factory/variants/slate-gold-bold.json +1 -1
  144. package/themes/data-journal/theme.json +3 -1
  145. package/themes/data-journal/variants/deep-coral.json +1 -1
  146. package/themes/data-journal/variants/navy-lime.json +1 -1
  147. package/themes/data-journal/variants/wine-indigo.json +1 -1
  148. package/themes/debug-hud/sample/style.json +1 -1
  149. package/themes/debug-hud/style.json +1 -1
  150. package/themes/debug-hud/theme.json +2 -1
  151. package/themes/debug-hud/variants/cocoa-teal.json +2 -2
  152. package/themes/debug-hud/variants/deep-teal-scan.json +2 -2
  153. package/themes/debug-hud/variants/dusk-plum.json +2 -2
  154. package/themes/debug-hud/variants/forest-lime.json +2 -2
  155. package/themes/debug-hud/variants/slate-green.json +2 -2
  156. package/themes/debug-hud/variants/wine-gold.json +2 -2
  157. package/themes/depth-flight/sample/style.json +1 -1
  158. package/themes/depth-flight/style.json +1 -1
  159. package/themes/depth-flight/theme.json +3 -1
  160. package/themes/depth-flight/variants/cocoa-cyan.json +1 -1
  161. package/themes/depth-flight/variants/cocoa-violet.json +1 -1
  162. package/themes/depth-flight/variants/deep-sky.json +1 -1
  163. package/themes/depth-flight/variants/forest-blue.json +1 -1
  164. package/themes/depth-flight/variants/navy-sky-grain.json +1 -1
  165. package/themes/depth-flight/variants/navy-sky.json +1 -1
  166. package/themes/depth-flight/variants/night-teal.json +1 -1
  167. package/themes/depth-flight/variants/slate-indigo.json +1 -1
  168. package/themes/depth-flight/variants/slate-violet.json +1 -1
  169. package/themes/film-reel/sample/style.json +2 -2
  170. package/themes/film-reel/style.json +2 -2
  171. package/themes/film-reel/theme.json +3 -1
  172. package/themes/film-reel/variants/chalk-pink.json +1 -1
  173. package/themes/film-reel/variants/lilac-gold.json +1 -1
  174. package/themes/film-reel/variants/lilac-violet.json +1 -1
  175. package/themes/glass-light/sample/style.json +1 -1
  176. package/themes/glass-light/style.json +1 -1
  177. package/themes/glass-light/theme.json +3 -1
  178. package/themes/glass-light/variants/blush-pink.json +1 -1
  179. package/themes/glass-light/variants/blush-plum.json +1 -1
  180. package/themes/glass-light/variants/cream-violet.json +1 -1
  181. package/themes/glass-light/variants/frost-sky-suit.json +1 -1
  182. package/themes/glass-light/variants/frost-sky.json +1 -1
  183. package/themes/glass-light/variants/sage-green.json +1 -1
  184. package/themes/ink-wash/sample/style.json +3 -3
  185. package/themes/ink-wash/style.json +3 -3
  186. package/themes/ink-wash/theme.json +3 -1
  187. package/themes/iso-diorama/sample/style.json +1 -1
  188. package/themes/iso-diorama/style.json +1 -1
  189. package/themes/iso-diorama/theme.json +3 -1
  190. package/themes/iso-diorama/variants/chalk-plum.json +1 -1
  191. package/themes/iso-diorama/variants/cocoa-indigo.json +1 -1
  192. package/themes/iso-diorama/variants/dusk-blue.json +1 -1
  193. package/themes/iso-diorama/variants/dusk-coral.json +1 -1
  194. package/themes/iso-diorama/variants/dusk-jade.json +1 -1
  195. package/themes/iso-diorama/variants/forest-cyan.json +1 -1
  196. package/themes/iso-diorama/variants/frost-green.json +1 -1
  197. package/themes/iso-diorama/variants/frost-teal.json +1 -1
  198. package/themes/iso-diorama/variants/navy-amber.json +1 -1
  199. package/themes/isotype/sample/style.json +2 -2
  200. package/themes/isotype/style.json +2 -2
  201. package/themes/isotype/theme.json +2 -1
  202. package/themes/isotype/variants/blush-rose.json +1 -1
  203. package/themes/isotype/variants/chalk-coral.json +1 -1
  204. package/themes/isotype/variants/chalk-orange.json +1 -1
  205. package/themes/isotype/variants/frost-amber.json +1 -1
  206. package/themes/isotype/variants/frost-coral.json +1 -1
  207. package/themes/isotype/variants/frost-orange.json +1 -1
  208. package/themes/isotype/variants/sand-amber.json +1 -1
  209. package/themes/kawaii-sticker/effects.ts +15 -3
  210. package/themes/kawaii-sticker/sample/style.json +2 -2
  211. package/themes/kawaii-sticker/style.json +2 -2
  212. package/themes/kawaii-sticker/theme.json +4 -1
  213. package/themes/kawaii-sticker/variants/cream-gold.json +1 -1
  214. package/themes/kawaii-sticker/variants/dusk-green.json +1 -1
  215. package/themes/kawaii-sticker/variants/dusk-indigo.json +1 -1
  216. package/themes/kawaii-sticker/variants/dusk-sky.json +1 -1
  217. package/themes/kawaii-sticker/variants/dusk-teal.json +1 -1
  218. package/themes/kawaii-sticker/variants/frost-blue.json +1 -1
  219. package/themes/kawaii-sticker/variants/frost-plum.json +1 -1
  220. package/themes/kawaii-sticker/variants/lilac-violet.json +1 -1
  221. package/themes/kawaii-sticker/variants/navy-gold.json +1 -1
  222. package/themes/kawaii-sticker/variants/navy-plum.json +1 -1
  223. package/themes/lab-notebook/sample/style.json +3 -3
  224. package/themes/lab-notebook/style.json +3 -3
  225. package/themes/lab-notebook/theme.json +2 -1
  226. package/themes/lab-notebook/variants/blush-orange.json +1 -1
  227. package/themes/lab-notebook/variants/chalk-coral.json +1 -1
  228. package/themes/lab-notebook/variants/chalk-orange.json +1 -1
  229. package/themes/lab-notebook/variants/chalk-rose.json +1 -1
  230. package/themes/lab-notebook/variants/lilac-rose.json +1 -1
  231. package/themes/lab-notebook/variants/sage-rose.json +1 -1
  232. package/themes/lab-notebook/variants/sand-orange.json +1 -1
  233. package/themes/lab-notebook/variants/snow-orange.json +1 -1
  234. package/themes/lab-notebook/variants/snow-rose.json +1 -1
  235. package/themes/manga-panel/sample/style.json +3 -3
  236. package/themes/manga-panel/style.json +3 -3
  237. package/themes/manga-panel/theme.json +3 -1
  238. package/themes/manga-panel/variants/chalk-cyan-dots.json +1 -1
  239. package/themes/manga-panel/variants/chalk-rose.json +1 -1
  240. package/themes/manga-panel/variants/snow-cyan.json +1 -1
  241. package/themes/manga-panel/variants/snow-magenta-dots.json +1 -1
  242. package/themes/marble-run/sample/style.json +2 -2
  243. package/themes/marble-run/style.json +2 -2
  244. package/themes/marble-run/theme.json +3 -1
  245. package/themes/marble-run/variants/slate-orange.json +1 -1
  246. package/themes/neon-sign/sample/style.json +2 -2
  247. package/themes/neon-sign/style.json +2 -2
  248. package/themes/neon-sign/theme.json +2 -1
  249. package/themes/neon-sign/variants/dusk-plum.json +1 -1
  250. package/themes/neon-sign/variants/forest-magenta.json +1 -1
  251. package/themes/neon-sign/variants/night-pink.json +1 -1
  252. package/themes/neon-sign/variants/night-violet.json +1 -1
  253. package/themes/neon-sign/variants/slate-violet.json +1 -1
  254. package/themes/newspaper/sample/style.json +2 -2
  255. package/themes/newspaper/style.json +2 -2
  256. package/themes/newspaper/theme.json +2 -1
  257. package/themes/newspaper/variants/blush-amber.json +1 -1
  258. package/themes/newspaper/variants/chalk-magenta.json +1 -1
  259. package/themes/newspaper/variants/frost-sky.json +1 -1
  260. package/themes/newspaper/variants/lilac-green.json +1 -1
  261. package/themes/newspaper/variants/sage-lime.json +1 -1
  262. package/themes/og-measure/THEME.md +41 -6
  263. package/themes/og-measure/effects.ts +236 -115
  264. package/themes/og-measure/sample/assets/brand/og-logo.svg +11 -11
  265. package/themes/og-measure/sample/lib/data.ts +6 -0
  266. package/themes/og-measure/sample/scenes/end.ts +14 -10
  267. package/themes/og-measure/sample/scenes/gather.ts +4 -1
  268. package/themes/og-measure/sample/scenes/models.ts +5 -2
  269. package/themes/og-measure/sample/scenes/one.ts +9 -6
  270. package/themes/og-measure/sample/scenes/price.ts +8 -3
  271. package/themes/og-measure/sample/style.json +30 -35
  272. package/themes/og-measure/slots.ts +54 -30
  273. package/themes/og-measure/still.png +0 -0
  274. package/themes/og-measure/style.json +30 -35
  275. package/themes/og-measure/theme.json +72 -49
  276. package/themes/og-measure/variants/snow-blue.json +90 -0
  277. package/themes/orbit-system/sample/style.json +3 -3
  278. package/themes/orbit-system/style.json +3 -3
  279. package/themes/orbit-system/theme.json +3 -1
  280. package/themes/orbit-system/variants/cocoa-amber.json +1 -1
  281. package/themes/orbit-system/variants/cocoa-cyan.json +1 -1
  282. package/themes/orbit-system/variants/dusk-magenta.json +1 -1
  283. package/themes/orbit-system/variants/dusk-violet.json +1 -1
  284. package/themes/orbit-system/variants/forest-coral.json +1 -1
  285. package/themes/orbit-system/variants/wine-amber.json +1 -1
  286. package/themes/orbit-system/variants/wine-magenta.json +1 -1
  287. package/themes/origami-fold/sample/style.json +1 -1
  288. package/themes/origami-fold/style.json +1 -1
  289. package/themes/origami-fold/theme.json +3 -1
  290. package/themes/origami-fold/variants/deep-amber-sunflower.json +1 -1
  291. package/themes/origami-fold/variants/deep-orange.json +1 -1
  292. package/themes/origami-fold/variants/forest-coral.json +1 -1
  293. package/themes/origami-fold/variants/forest-pink.json +1 -1
  294. package/themes/origami-fold/variants/frost-amber-paper.json +1 -1
  295. package/themes/origami-fold/variants/sage-coral-sunflower.json +1 -1
  296. package/themes/origami-fold/variants/sage-coral.json +1 -1
  297. package/themes/origami-fold/variants/sage-pink-round.json +1 -1
  298. package/themes/origami-fold/variants/sage-pink.json +1 -1
  299. package/themes/paper-cutout/sample/style.json +2 -2
  300. package/themes/paper-cutout/style.json +2 -2
  301. package/themes/paper-cutout/theme.json +3 -1
  302. package/themes/paper-cutout/variants/blush-violet.json +1 -1
  303. package/themes/paper-cutout/variants/frost-lime.json +1 -1
  304. package/themes/paper-cutout/variants/lilac-gold.json +1 -1
  305. package/themes/paper-cutout/variants/sage-cyan.json +1 -1
  306. package/themes/paper-cutout/variants/sage-green.json +1 -1
  307. package/themes/paper-cutout/variants/sage-pink.json +1 -1
  308. package/themes/paper-cutout/variants/sand-blue.json +1 -1
  309. package/themes/pixel-quest/theme.json +2 -1
  310. package/themes/receipt/sample/style.json +2 -2
  311. package/themes/receipt/style.json +2 -2
  312. package/themes/riso-zine/sample/style.json +2 -2
  313. package/themes/riso-zine/style.json +2 -2
  314. package/themes/riso-zine/theme.json +3 -1
  315. package/themes/riso-zine/variants/chalk-lime.json +1 -1
  316. package/themes/riso-zine/variants/frost-green.json +1 -1
  317. package/themes/riso-zine/variants/lilac-plum.json +1 -1
  318. package/themes/riso-zine/variants/lilac-violet.json +1 -1
  319. package/themes/server-hall/sample/style.json +2 -2
  320. package/themes/server-hall/style.json +2 -2
  321. package/themes/server-hall/theme.json +3 -1
  322. package/themes/server-hall/variants/night-indigo.json +1 -1
  323. package/themes/server-hall/variants/wine-plum.json +1 -1
  324. package/themes/subway-map/sample/style.json +2 -2
  325. package/themes/subway-map/style.json +2 -2
  326. package/themes/subway-map/theme.json +3 -1
  327. package/themes/subway-map/variants/deep-green.json +1 -1
  328. package/themes/subway-map/variants/deep-teal.json +1 -1
  329. package/themes/subway-map/variants/frost-jade.json +1 -1
  330. package/themes/subway-map/variants/frost-teal.json +1 -1
  331. package/themes/subway-map/variants/slate-green.json +1 -1
  332. package/themes/subway-map/variants/snow-jade.json +1 -1
  333. package/themes/swiss-poster/sample/style.json +2 -2
  334. package/themes/swiss-poster/style.json +2 -2
  335. package/themes/swiss-poster/theme.json +3 -1
  336. package/themes/swiss-poster/variants/chalk-plum.json +1 -1
  337. package/themes/swiss-poster/variants/cocoa-cyan.json +1 -1
  338. package/themes/swiss-poster/variants/dusk-magenta.json +1 -1
  339. package/themes/swiss-poster/variants/frost-indigo.json +1 -1
  340. package/themes/swiss-poster/variants/slate-orange.json +1 -1
  341. package/themes/water-works/sample/style.json +1 -1
  342. package/themes/water-works/style.json +1 -1
  343. package/themes/water-works/theme.json +3 -1
  344. package/themes/water-works/variants/chalk-cyan.json +1 -1
  345. package/themes/water-works/variants/frost-indigo.json +1 -1
  346. package/themes/water-works/variants/sand-amber.json +1 -1
  347. package/themes/water-works/variants/sand-plum.json +1 -1
  348. package/themes/whiteboard/sample/style.json +2 -2
  349. package/themes/whiteboard/style.json +2 -2
  350. package/themes/whiteboard/theme.json +3 -1
  351. package/themes/whiteboard/variants/chalk-coral.json +1 -1
  352. package/themes/whiteboard/variants/cream-lime.json +1 -1
  353. package/themes/whiteboard/variants/frost-teal.json +1 -1
  354. package/themes/whiteboard/variants/lilac-indigo.json +1 -1
  355. package/themes/whiteboard/variants/sage-plum.json +1 -1
  356. package/themes/whiteboard/variants/snow-cyan.json +1 -1
  357. package/themes/whiteboard/variants/snow-green.json +1 -1
  358. package/themes/whiteboard/variants/snow-magenta.json +1 -1
  359. package/themes/og-measure/sample/assets/brand/og-logo-full.svg +0 -12
  360. package/themes/og-measure/variants/chalk-orange.json +0 -102
  361. package/themes/og-measure/variants/cocoa-amber.json +0 -138
  362. package/themes/og-measure/variants/cocoa-cyan.json +0 -138
  363. package/themes/og-measure/variants/cocoa-gold.json +0 -129
  364. package/themes/og-measure/variants/cream-lime.json +0 -137
  365. package/themes/og-measure/variants/deep-jade.json +0 -100
  366. package/themes/og-measure/variants/dusk-violet.json +0 -133
  367. package/themes/og-measure/variants/forest-cyan.json +0 -123
  368. package/themes/og-measure/variants/frost-amber.json +0 -129
  369. package/themes/og-measure/variants/frost-cyan.json +0 -133
  370. package/themes/og-measure/variants/lilac-plum.json +0 -127
  371. package/themes/og-measure/variants/lilac-violet.json +0 -139
  372. package/themes/og-measure/variants/sage-green.json +0 -137
  373. package/themes/og-measure/variants/wine-jade.json +0 -127
  374. package/themes/og-measure/variants/wine-plum.json +0 -127
@@ -2,6 +2,8 @@ import { clamp, ease, progress, spring, type SceneCtx, type Style } from "@kasaf
2
2
  import { mark } from "@kasaframe/motion/moves";
3
3
  import { typeDuration, typeText } from "@kasaframe/motion";
4
4
  import { partial } from "@kasaframe/themes/slotkit";
5
+ import type { MarkBox, MarkPaint } from "@kasaframe/themes/slots";
6
+ import { keyCue, rollCues, slamCue, stopCues, withSfx } from "./sfx.ts";
5
7
  import type { Kit, ThemeSlots } from "./types.ts";
6
8
 
7
9
  // 패턴들이 같이 쓰는 몫. 모듈 맨 위에서 DOM 을 만지지 않는다 — 감독이 Node 에서 meta·fits 를 읽는다.
@@ -58,12 +60,40 @@ export function inkLike(ctx: SceneCtx): boolean {
58
60
  return !Number.isFinite(a) || !Number.isFinite(b) || (hi + 0.05) / (lo + 0.05) < 3;
59
61
  }
60
62
 
63
+ // 글을 짚는 겹의 칠 — 강조색 글. 강조색 글이 본문 글과 같은 테마(HUD)면 반전 칸으로(같은 색으로 채우면 아무것도 안 바뀐다)
64
+ const readCss = (ctx: SceneCtx): string => (inkLike(ctx) ? "background:var(--kf-accent);color:var(--kf-on-accent)" : `color:${ACCENT_TEXT}`);
65
+
66
+ /**
67
+ * 다시 짚기 진행도 — 정지점 사이마다 0→1→0 으로 박마다 한 번 차올랐다 빠진다. 마지막 사이(장면 끝)는 차오르기만 해 끝 프레임에 다 짚혀 있다.
68
+ * 첫 정지점 전엔 0. 훑는 짚개를 쓰는 테마에서 짚을 대상이 하나뿐인 장면(짚개 없음)이 그 글을 박마다 강조로 채웠다 비워 쉬지 않는 몫
69
+ */
70
+ export function reread(t: number, stops: readonly number[]): number {
71
+ if (stops.length < 2 || t < stops[0]) return 0;
72
+ let j = 0;
73
+ while (j + 2 < stops.length && stops[j + 1] <= t) j++;
74
+ const p = clamp((t - stops[j]) / Math.max(1e-3, stops[j + 1] - stops[j]));
75
+ return j === stops.length - 2 ? p : 1 - Math.abs(2 * p - 1);
76
+ }
77
+
78
+ /**
79
+ * 글 상자 el(position:relative) 위에 같은 글의 강조 겹(readCss) — p 만큼 아래부터 글 전체에 차오른다(reread 로 다시 짚기).
80
+ * 왼쪽부터 짚으면 빈칸·가는 글자(1·i)를 지나는 동안 화면이 거의 안 바뀌어 멈춘 화면이 된다 — 차오름은 모든 글자를 함께 지난다
81
+ */
82
+ export function readOver(el: HTMLElement, text: string, ctx: SceneCtx): (p: number) => void {
83
+ const hi = node(el, `position:absolute;inset:0;${readCss(ctx)};white-space:inherit;text-align:inherit;visibility:hidden;pointer-events:none`, text);
84
+ return (p) => {
85
+ const k = clamp(p);
86
+ hi.style.visibility = k > 0 ? "visible" : "hidden";
87
+ hi.style.clipPath = `inset(${((1 - k) * 100).toFixed(2)}% 0 0 0)`;
88
+ };
89
+ }
90
+
61
91
  /**
62
- * 큰 이름 — 글자마다 가림막 아래에서 올라와 서고(riseAt 부터 each 간격), fill 진행도(0..1)만큼 강조가 왼쪽부터 글자를 짚는다.
63
- * 강조색 글이 본문 글과 같은 테마(HUD)면 반전 칸으로 — 같은 색으로 채우면 아무것도 안 바뀐다
92
+ * 큰 이름 — 글자마다 가림막 아래에서 올라와 서고(riseAt 부터 each 간격), fill 진행도(0..1)만큼 강조가 왼쪽부터 글자를 짚는다(readCss).
93
+ * up 을 주면 강조가 모든 글자에 아래부터 그만큼 차오른다(다시 짚기 — readOver 와 같은 결)
64
94
  */
65
- export function riseName(el: HTMLElement, text: string, ctx: SceneCtx): (t: number, o: { riseAt: number; each: number; fill: number }) => void {
66
- const hiCss = inkLike(ctx) ? "background:var(--kf-accent);color:var(--kf-on-accent)" : `color:${ACCENT_TEXT}`;
95
+ export function riseName(el: HTMLElement, text: string, ctx: SceneCtx): (t: number, o: { riseAt: number; each: number; fill: number; up?: number }) => void {
96
+ const hiCss = readCss(ctx);
67
97
  odoStyle(el);
68
98
  const glyphs = Array.from(text).map((ch) => {
69
99
  const clip = node(el, "display:inline-block;overflow:hidden;padding:0.06em 0 0.12em;margin:-0.06em 0 -0.12em");
@@ -87,9 +117,9 @@ export function riseName(el: HTMLElement, text: string, ctx: SceneCtx): (t: numb
87
117
  g.firstChild.remove();
88
118
  }
89
119
  if (!up) g.dataset.kfOdo = g.dataset.ch ?? "";
90
- const k = clamp(fp - i);
120
+ const k = clamp(o.up ?? fp - i);
91
121
  hi.style.visibility = k > 0 ? "visible" : "hidden";
92
- hi.style.clipPath = `inset(0 ${((1 - k) * 100).toFixed(2)}% 0 0)`;
122
+ hi.style.clipPath = o.up === undefined ? `inset(0 ${((1 - k) * 100).toFixed(2)}% 0 0)` : `inset(${((1 - k) * 100).toFixed(2)}% 0 0 0)`;
93
123
  });
94
124
  };
95
125
  }
@@ -151,16 +181,68 @@ export function odometer(el: HTMLElement, o: { template: string; decimals: numbe
151
181
  };
152
182
  }
153
183
 
184
+ /** 테마 짚음 슬롯을 이 장면에 묶은 것 — 칸 요소를 받아 칠하고 자리 함수를 준다 */
185
+ export type Marker = (el: HTMLElement) => MarkPaint;
186
+
187
+ /** 테마가 짚는 칸 모양(mark 슬롯)을 채웠으면 그것, 아니면 null — 짚개·반전 짚음이 패턴 기본(강조색 꽉 찬 칸)으로 그린다 */
188
+ export const markOf = (kit: Kit, ctx: SceneCtx): Marker | null => {
189
+ const slot = kit.slots.mark;
190
+ return slot ? (el) => slot(el, { ctx, seed: `${kit.seed}:mark` }) : null;
191
+ };
192
+
193
+ // 짚는 칸 요소 — 대상 글 뒤에 깔린다(놓는 쪽 상자에 isolation). 칠은 테마 슬롯이나 plainMark 가 한다
194
+ const MARK_CSS = "position:absolute;left:0;top:0;width:0;height:0;z-index:-1;visibility:hidden;pointer-events:none";
195
+
196
+ // 테마가 비운 짚음 — 강조색 꽉 찬 칸이 크기 그대로 미끄러진다(직선 7 + smoothstep 3 — 훑는 동안 정지점에 닿아 서면 그 사이가 멈춘 화면)
197
+ function plainMark(el: HTMLElement): MarkPaint {
198
+ el.style.background = "var(--kf-accent)";
199
+ el.style.borderRadius = "var(--kf-radius-sm)";
200
+ return {
201
+ ink: "var(--kf-on-accent)",
202
+ at(from, to, u) {
203
+ const a = from ?? to;
204
+ const e = 0.7 * u + 0.3 * u * u * (3 - 2 * u);
205
+ const mix = (p: number, q: number) => p + (q - p) * e;
206
+ return { x: mix(a.x, to.x), y: mix(a.y, to.y), w: mix(a.w, to.w), h: mix(a.h, to.h) };
207
+ },
208
+ };
209
+ }
210
+
211
+ function place(el: HTMLElement, b: MarkBox): void {
212
+ el.style.transform = `translate(${b.x.toFixed(1)}px, ${b.y.toFixed(1)}px)`;
213
+ el.style.width = `${b.w.toFixed(1)}px`;
214
+ el.style.height = `${b.h.toFixed(1)}px`;
215
+ }
216
+
154
217
  /**
155
218
  * 반전 짚음 — el(글 상자, position:relative — 쓰기 슬롯이 글을 갈아 끼우는 요소면 그 부모에 건다) 위에 강조색 면 + onAccent 글 겹을 깔고 p 만큼 왼쪽부터 드러낸다.
156
- * 옅은 형광펜(accentSoft)은 밝은 바탕에서 화면이 바뀐 것으로 안 읽혀 이쪽이 쉬지 않는 몫을 진다
219
+ * 옅은 형광펜(accentSoft)은 밝은 바탕에서 화면이 바뀐 것으로 안 읽혀 이쪽이 쉬지 않는 몫을 진다.
220
+ * 테마 짚음(mark)이 있으면 그 칸이 글 뒤에 p 만큼 나타나고(모양은 테마 몫), 칸 위 글은 테마 ink 로
157
221
  */
158
- export function inverseOver(el: HTMLElement, text: string): (p: number) => void {
159
- const hi = node(el, "position:absolute;inset:0;background:var(--kf-accent);color:var(--kf-on-accent);white-space:inherit;text-align:inherit;visibility:hidden;pointer-events:none", text);
222
+ export function inverseOver(el: HTMLElement, text: string, mark: Marker | null = null): (p: number) => void {
223
+ if (!mark) {
224
+ const hi = node(el, "position:absolute;inset:0;background:var(--kf-accent);color:var(--kf-on-accent);white-space:inherit;text-align:inherit;visibility:hidden;pointer-events:none", text);
225
+ return (p) => {
226
+ const k = clamp(p);
227
+ hi.style.visibility = k > 0 ? "visible" : "hidden";
228
+ hi.style.clipPath = `inset(0 ${((1 - k) * 100).toFixed(2)}% 0 0)`;
229
+ };
230
+ }
231
+ el.style.isolation = "isolate";
232
+ const block = node(el, MARK_CSS);
233
+ const shape = mark(block);
234
+ const hi = shape.ink ? node(el, `position:absolute;inset:0;color:${shape.ink};white-space:inherit;text-align:inherit;visibility:hidden;pointer-events:none`, text) : null;
160
235
  return (p) => {
161
236
  const k = clamp(p);
237
+ const w = el.offsetWidth;
238
+ const h = el.offsetHeight;
239
+ const pad = (parseFloat(getComputedStyle(el).fontSize) || 20) * 0.12;
240
+ const b = shape.at(null, { x: -pad, y: -pad * 0.5, w: w + pad * 2, h: h + pad }, k);
241
+ block.style.visibility = k > 0 ? "visible" : "hidden";
242
+ place(block, b);
243
+ if (!hi) return;
162
244
  hi.style.visibility = k > 0 ? "visible" : "hidden";
163
- hi.style.clipPath = `inset(0 ${((1 - k) * 100).toFixed(2)}% 0 0)`;
245
+ hi.style.clipPath = `inset(${b.y.toFixed(1)}px ${(w - b.x - b.w).toFixed(1)}px ${(h - b.y - b.h).toFixed(1)}px ${b.x.toFixed(1)}px)`;
164
246
  };
165
247
  }
166
248
 
@@ -175,6 +257,7 @@ export function sceneDrop(ctx: SceneCtx): number | null {
175
257
  * 등장 전엔 opacity 0 이라 그 사이를 다른 움직임이 채워야 한다
176
258
  */
177
259
  export function slam(t: number, at: number, from = 1.9): Style {
260
+ slamCue(t, at);
178
261
  const lead = 0.16;
179
262
  if (t < at - lead) return { opacity: 0 };
180
263
  if (t < at) {
@@ -210,9 +293,9 @@ export function fitOnce(el: HTMLElement, maxW: number, maxPx: number, text?: str
210
293
  }
211
294
 
212
295
  /**
213
- * 짚개의 정지점 — from 뒤의 박을 간격 gap 넘게 골라 쓰고(빈 곳은 고르게 메운다), 마지막 정지점은 장면 끝(to)이다.
214
- * 끝 프레임에서 칸이 대상에 거의 앉아 있어야 표지·견본 스틸에 반쯤 걸친 띠가 안 찍힌다. 칸은 그 순간까지 움직이니(직선 7) 서지는 않는다.
215
- * 장면이 길어도 짚개 빠르기는 박이 정한다 — 장면 길이에 비례해 느려지는 차오름은 긴 장면에서 멈춘 화면이 된다
296
+ * 박 정지점 — from 뒤의 박을 간격 gap 넘게 골라 쓰고(빈 곳은 고르게 메운다), 마지막 정지점은 장면 끝(to)이다.
297
+ * 끝 프레임에서 옮겨 다니는 틀이 대상에 거의 앉아 있어야 표지·견본 스틸에 반쯤 걸친 틀이 안 찍힌다.
298
+ * 장면이 길어도 빠르기는 박이 정한다 — 장면 길이에 비례해 느려지는 차오름은 긴 장면에서 멈춘 화면이 된다
216
299
  */
217
300
  export function beatStops(hits: readonly number[], from: number, to: number, gap = 0.4): number[] {
218
301
  const out = [from];
@@ -225,18 +308,13 @@ export function beatStops(hits: readonly number[], from: number, to: number, gap
225
308
  return out;
226
309
  }
227
310
 
228
- /**
229
- * 짚개 — 강조색 칸이 대상 사이를 정지점마다 옮겨 다니며 「지금 볼 항」을 짚는다. 정지점 사이를 내내 움직인다(직선 7 + smoothstep 3 —
230
- * 닿아서 서면 그 사이가 멈춘 화면). 칸은 대상 글 뒤에 깔리고(host 에 isolation), 칸이 앉은 대상 글은 onAccent 로 바뀐다.
231
- * 대상 자리는 그 프레임에 잰다 — 대상이 굴러가거나 내려앉아도 따라간다
232
- */
233
311
  // 글자를 두 색으로 — over 사각형(글 상자 안 좌표) 안은 띠 위 글색, 밖은 그 글의 원래 색. 글자 모양으로 오려 칠하니 반쯤 겹쳐도 겹친 글자 부분만 바뀐다.
234
312
  // 글색(color)은 건드리지 않아 원래 색을 늘 다시 읽을 수 있다. 칠하기 전 인라인 값을 그대로 되돌린다 — 테마 슬롯이 글에 칠한 배경을 지우면
235
313
  // 한 번 짚고 지나간 뒤로 그림이 바뀌어 같은 시각이 다르게 그려졌다(frame.nondeterministic)
236
314
  const TONE_PROPS = ["-webkit-text-fill-color", "background-image", "background-color", "background-repeat", "background-size", "background-position", "-webkit-background-clip", "background-clip"];
237
315
  const toneSaved = new WeakMap<HTMLElement, [string, string, string][]>();
238
316
 
239
- function paintTone(el: HTMLElement, over: { x: number; y: number; w: number; h: number } | null): void {
317
+ function paintTone(el: HTMLElement, over: MarkBox | null, ink = "var(--kf-on-accent)"): void {
240
318
  const s = el.style;
241
319
  if (!over) {
242
320
  const saved = toneSaved.get(el);
@@ -250,7 +328,7 @@ function paintTone(el: HTMLElement, over: { x: number; y: number; w: number; h:
250
328
  el.dataset.kfTone = "";
251
329
  s.setProperty("-webkit-text-fill-color", "transparent");
252
330
  s.backgroundColor = "currentColor";
253
- s.backgroundImage = "linear-gradient(var(--kf-on-accent), var(--kf-on-accent))";
331
+ s.backgroundImage = `linear-gradient(${ink}, ${ink})`;
254
332
  s.backgroundRepeat = "no-repeat";
255
333
  s.backgroundSize = `${over.w.toFixed(1)}px ${over.h.toFixed(1)}px`;
256
334
  s.backgroundPosition = `${over.x.toFixed(1)}px ${over.y.toFixed(1)}px`;
@@ -258,17 +336,22 @@ function paintTone(el: HTMLElement, over: { x: number; y: number; w: number; h:
258
336
  s.setProperty("background-clip", "text");
259
337
  }
260
338
 
261
- // 대상 안에 바탕이 칠해진 요소가 있나 — 투명·알파 0 이면 없는 것
339
+ // 대상 안에 글 뒤를 가리는 판이 있나 — 바탕이 칠해졌고(투명·알파 0 이면 없는 것) 대상 상자의 절반 넘게 덮는 것만.
340
+ // 판 위 구분선·모서리 눈금처럼 가는 장식(og-measure 의 위 구분선 3px)을 판으로 읽으면 띠는 숫자 뒤에 훤히 보이는데 글은 원래 색으로 남았다.
341
+ // 넓이는 변환 전 크기로 잰다 — 판이 내려앉는 중(scale)이어도 같은 답이 나오게
262
342
  function hasPlate(root: HTMLElement): boolean {
343
+ const area = (el: HTMLElement) => el.offsetWidth * el.offsetHeight;
344
+ const whole = area(root);
263
345
  const els = [root, ...Array.from(root.querySelectorAll<HTMLElement>("*"))];
264
346
  return els.some((el) => {
265
347
  // 짚개가 글에 칠한 배경(표식 data-kf-tone)은 판이 아니다 — 앞 프레임에 칠했는지와 상관없이 같은 답이 나오게
266
348
  if (el.dataset.kfTone !== undefined) return false;
267
349
  const cs = getComputedStyle(el);
268
- if (cs.backgroundImage !== "none") return true;
269
350
  const m = /rgba?\(([^)]+)\)/.exec(cs.backgroundColor);
270
- const a = m ? Number(m[1].split(",")[3] ?? 1) : 0;
271
- return a > 0.05;
351
+ const painted = cs.backgroundImage !== "none" || (m ? Number(m[1].split(",")[3] ?? 1) : 0) > 0.05;
352
+ if (!painted) return false;
353
+ // SVG 처럼 레이아웃 크기가 없는 것과 아직 자리를 안 잡은 대상은 칠했으면 판으로 둔다
354
+ return !(el instanceof HTMLElement) || !whole || area(el) >= whole * 0.5;
272
355
  });
273
356
  }
274
357
 
@@ -287,9 +370,145 @@ function leavesOf(root: HTMLElement): HTMLElement[] {
287
370
  return out;
288
371
  }
289
372
 
290
- export function glider(host: HTMLElement, targets: HTMLElement[], o: { pad?: number; hub?: boolean } = {}): (t: number, stops: readonly number[], until?: number) => void {
373
+ /** 요점 — 그 시각부터 짚개가 내려앉아 머무는 대상 */
374
+ export interface Focus {
375
+ at: number;
376
+ el: HTMLElement;
377
+ }
378
+
379
+ // 낱말·글을 견줄 꼴 — 문장부호를 떼고 소문자로
380
+ const bare = (s: string) => s.toLowerCase().replace(/[^\p{L}\p{N}.]+/gu, " ").trim();
381
+
382
+ // 목소리 낱말 w 가 글 text 를 말했나 — 낱말이 글의 한 마디이거나 거기에 조사·어미가 붙은 꼴(「캐시가」 = 캐시). 두 글자 아래 마디는 숫자만
383
+ function said(w: string, text: string): boolean {
384
+ const word = bare(w);
385
+ if (!word) return false;
386
+ return bare(text)
387
+ .split(" ")
388
+ .some((tk) => (tk.length >= 2 || /^\d/.test(tk)) && (word === tk || (word.startsWith(tk) && word.length - tk.length <= 3) || (tk.startsWith(word) && word.length >= 2)));
389
+ }
390
+
391
+ /**
392
+ * 짚을 요점과 그 시각 — 첫 대상(장면이 주인공·강조로 세운 것)에 from 에 내려앉고, 그 뒤엔 목소리가 다른 대상의 글을 말하는 낱말 시각에만
393
+ * 그리로 옮긴다(주인공 글을 다시 말하면 돌아온다). 나타나는 차례로 훑지 않는다 — 요점이 바뀌지 않으면 머문다.
394
+ * 대상 글은 다 찍힌 뒤의 글(패턴이 아는 내용)로 견준다 — 찍히는 중인 화면 글로 견주면 시각마다 답이 달라진다.
395
+ * 짚을 것이 하나뿐이면 빈 목록 — 하나뿐인 것을 짚는 칸은 「지금 볼 것」을 가리키지 못하고 의미 없는 네모로 남는다
396
+ */
397
+ export function keyPoints(say: readonly { text: string; from: number }[], from: number, points: readonly { el: HTMLElement; text?: string }[]): Focus[] {
398
+ if (points.length < 2) return [];
399
+ const out: Focus[] = [{ at: from, el: points[0].el }];
400
+ for (const w of say) {
401
+ if (w.from < from) continue;
402
+ const hit = points.find((p) => p.text && said(w.text, p.text));
403
+ if (hit && hit.el !== out[out.length - 1].el) out.push({ at: w.from, el: hit.el });
404
+ }
405
+ return out;
406
+ }
407
+
408
+ /** 글 text 가 [from, to) 안에서 말해지는 시각 — 목소리 낱말이 있으면 그 글을 말하는 첫 낱말, 없으면 자막 줄 안에서 그 글이 놓인 자리만큼. 못 찾으면 null */
409
+ export function heardAt(kit: Kit, text: string, from: number, to: number): number | null {
410
+ if (kit.say.length) return kit.say.find((w) => w.from >= from - 0.05 && w.from < to && said(w.text, text))?.from ?? null;
411
+ const key = bare(text).split(" ").find((tk) => tk.length >= 2 || /^\d/.test(tk));
412
+ for (const l of key ? kit.lines : []) {
413
+ const i = bare(l.text).indexOf(key!);
414
+ const at = l.from + ((l.to - l.from) * i) / Math.max(1, bare(l.text).length);
415
+ if (i >= 0 && at >= from - 0.05 && at < to) return at;
416
+ }
417
+ return null;
418
+ }
419
+
420
+ /**
421
+ * 항목이 말해지는 시각 — 목소리 낱말(kit.say)이 그 글을 말하는 첫 시각, 낱말 시각 없이 자막 줄(kit.lines)만 있으면 줄 안에서 그 글이 놓인 자리만큼.
422
+ * 못 찾은 항목은 앞뒤로 찾은 항목 사이(처음은 from, 끝은 to)를 고르게 메우고, 하나도 없으면 [from, to] 를 고르게 — 셋 다 박에 붙인다(0.15초 안).
423
+ * 항목 차례대로 늦어지기만 한다. 감독 장면은 목소리가 길이를 정해 4–9초인데 내용은 2–3초 안에 다 서 — 항목을 말하는 순간에 나눠 세워 그 사이를 메운다
424
+ */
425
+ export function sayTimes(kit: Kit, texts: readonly (string | undefined)[], from: number, to: number): number[] {
426
+ const found = texts.map((tx) => (tx ? heardAt(kit, tx, from, to) : null));
427
+ const out = found.map((x) => (x === null ? NaN : clamp(x, from, to)));
428
+ // 못 찾은 줄 — 앞 닻(없으면 from)과 뒤 닻(없으면 to) 사이를 고르게
429
+ for (let i = 0; i < out.length; ) {
430
+ if (!Number.isNaN(out[i])) {
431
+ i++;
432
+ continue;
433
+ }
434
+ let j = i;
435
+ while (j < out.length && Number.isNaN(out[j])) j++;
436
+ const a = i > 0 ? out[i - 1] : from;
437
+ const b = j < out.length ? out[j] : to;
438
+ const lead = i === 0 ? 0 : 1;
439
+ const span = j - i + lead + (j < out.length ? 1 : 0) - 1;
440
+ for (let k = i; k < j; k++) out[k] = a + ((b - a) * (k - i + lead)) / Math.max(1, span);
441
+ i = j;
442
+ }
443
+ let last = -Infinity;
444
+ return out.map((x, i) => {
445
+ const near = kit.hits.reduce((best, h) => (Math.abs(h - x) < Math.abs(best - x) ? h : best), Infinity);
446
+ const snapped = found[i] === null && Math.abs(near - x) < 0.15 ? near : x;
447
+ last = Math.max(snapped, last);
448
+ return last;
449
+ });
450
+ }
451
+
452
+ /**
453
+ * 지금 말하는 항목 쪽으로 다가가는 카메라 — 장면 첫 프레임부터 첫 항목 쪽으로, 항목이 말해질 때마다 그쪽으로 천천히 다가가 다음 항목까지,
454
+ * 마지막 항목 뒤엔 장면 끝까지 한 번의 움직임으로 잇는다. 내용이 다 선 뒤 쉬는 구간을 뜻 있는 움직임으로 메운다(다가감의 이유가 그 순간 말하는 것).
455
+ * 다가갈수록 조금씩 가까이(배율 1.02 씩, 1.08 까지). 머무는 짚개(dwell) 테마만 — 훑는 테마는 짚개가 쉬지 않는 몫을 진다. 카메라를 끈 영상엔 안 건다
456
+ */
457
+ export function followSpoken(kit: Kit, ctx: SceneCtx, shots: readonly Focus[]): void {
458
+ if (!cameraOk(ctx) || !kit.stage || !dwells(kit, ctx)) return;
459
+ const sorted = [...shots].filter((s) => s.at < ctx.dur - 0.2).sort((a, b) => a.at - b.at);
460
+ if (!sorted.length) return;
461
+ // 첫 프레임부터 주인공 쪽으로 — 앞 장면의 다가감과 끊김 없이 이어져 한 번으로 센다(틀이 카메라를 프레이즈당 한 번 허락한다). 0 에서 시작하면
462
+ // 첫 프레임엔 아직 안 움직여 사이가 뜬다. 아직 안 찍힌(빈) 항목 쪽으로 먼저 가면 화면이 비켜 내용이 안전 영역 밖으로 밀린다
463
+ const hero = kit.stage.cam.querySelector<HTMLElement>("[data-kf-hero]") ?? sorted[0].el;
464
+ const list = [{ at: -0.05, el: hero }, ...sorted.filter((s) => s.at > 0.05)];
465
+ // 다음 다가감과 조금 겹쳐 잇는다 — 사이에 한 프레임이라도 서면 검사가 카메라를 두 번 움직인 것으로 센다
466
+ list.forEach((s, i) => {
467
+ const end = list[i + 1] ? list[i + 1].at + 0.1 : ctx.dur + 0.1;
468
+ kit.stage.camera.pushIn(s.at, { to: s.el, scale: Math.min(1.08, 1 + 0.02 * (i + 1)), dur: Math.max(0.4, end - s.at) });
469
+ });
470
+ }
471
+
472
+ // 요점 사이 옮김 — 스타일가이드 자리 옮김 300ms. 다음 요점이 그보다 가까우면 닿을 틈을 남기고 더 빨리
473
+ const MOVE = 0.3;
474
+ const REST = 0.03;
475
+
476
+ /** 짚을 대상 — text 는 다 찍힌 뒤의 글(목소리 낱말과 견준다) */
477
+ export interface Point {
478
+ el: HTMLElement;
479
+ text?: string;
480
+ }
481
+
482
+ /** 테마 짚음이 요점에 머무는 방식(mark 의 dwell)인가 — 칸 모양만 묻는다(붙이지 않는 빈 칸에 칠하게 하고 읽는다) */
483
+ export const dwells = (kit: Kit, ctx: SceneCtx): boolean => !!markOf(kit, ctx)?.(document.createElement("div")).dwell;
484
+
485
+ /**
486
+ * 짚개 — 칸이 「지금 볼 것」을 짚는다. 짚기 방식은 테마 짚음(mark)이 고른다:
487
+ * - 머묾(dwell): 첫 대상(장면이 주인공·강조로 세운 것)에 from 에 내려앉아 머물고, 목소리가 다른 대상의 글을 말할 때만 0.3초에 옮긴다(keyPoints).
488
+ * hub 가 아닌 목록(차례로 늘어선 낱말·줄)은 주인공이 없어 짚지 않는다
489
+ * - 훑기(비운 테마): from 부터 박 정지점(beatStops — to 까지)마다 대상 사이를 내내 옮긴다. hub 면 주인공과 나머지를 번갈아 —
490
+ * 작은 글 사이를 옮겨 다니면 칸이 조금밖에 안 움직여 멈춘 화면으로 읽힌다. 마지막 정지점(장면 끝)이 주인공이 되게 차례를 맞춘다
491
+ * 짚을 것이 하나뿐이면 짚지 않는다(의미 없는 네모). until 부터는 걷는다. 칸 모양·옮겨 가는 길·칸 위 글색도 테마 짚음(비우면 강조색 꽉 찬 칸),
492
+ * 칸은 대상 글 뒤에 깔리고(host 에 isolation) 칸이 덮은 글은 그 짚음의 ink 로 바뀐다. 대상 자리는 그 프레임에 잰다 — 대상이 굴러가거나 내려앉아도 따라간다
493
+ */
494
+ export function glider(host: HTMLElement, points: readonly Point[], o: { kit: Kit; ctx: SceneCtx; from: number; to?: number; until?: number; hub?: boolean; pad?: number }): (t: number) => void {
495
+ const hub = o.hub ?? true;
496
+ if (points.length < 2) return () => {};
291
497
  host.style.isolation = "isolate";
292
- const block = node(host, "position:absolute;left:0;top:0;width:0;height:0;background:var(--kf-accent);border-radius:var(--kf-radius-sm);z-index:-1;visibility:hidden;pointer-events:none");
498
+ const block = node(host, MARK_CSS);
499
+ const shape = markOf(o.kit, o.ctx)?.(block) ?? plainMark(block);
500
+ const focus = shape.dwell ? (hub ? keyPoints(o.kit.say, o.from, points) : []) : null;
501
+ if (focus && !focus.length) {
502
+ block.remove();
503
+ return () => {};
504
+ }
505
+ const targets = focus ? [...new Set(focus.map((f) => f.el))] : points.map((p) => p.el);
506
+ const stops = focus ? [] : beatStops(o.kit.hits, o.from, o.to ?? o.ctx.dur);
507
+ const until = o.until ?? Infinity;
508
+ const n = targets.length;
509
+ // 훑기 차례 — 마지막 정지점(장면 끝)이 주인공이 되게 맞춘다. 끝 프레임(표지·견본)에 곁가지 글이 아니라 주인공이 짚혀 있다
510
+ const base = hub ? (stops.length - 1) % 2 : 0;
511
+ const order = (k: number) => (hub ? ((k + base) % 2 === 0 ? 0 : 1 + (((k + base - 1) / 2) % (n - 1))) : k % n);
293
512
  let toned = new Set<HTMLElement>();
294
513
  const pad = o.pad ?? 0.12;
295
514
  // 프레임마다 잰다 — 처음 그린 프레임에서 한 번만 재면 탭마다 첫 프레임이 달라 같은 시각이 다르게 그려졌다(frame.nondeterministic)
@@ -306,50 +525,52 @@ export function glider(host: HTMLElement, targets: HTMLElement[], o: { pad?: num
306
525
  const fs = parseFloat(getComputedStyle(el).fontSize) || 20;
307
526
  return { x: b.x - fs * pad, y: b.y - fs * pad * 0.5, w: b.w + fs * pad * 2, h: b.h + fs * pad };
308
527
  };
309
- return (t, stops, until = Infinity) => {
310
- const n = targets.length;
311
- if (!n || t < stops[0] || t >= until) {
312
- block.style.visibility = "hidden";
313
- for (const el of toned) paintTone(el, null);
314
- toned = new Set();
315
- return;
528
+ // 이 프레임 칸 자리 — 아직 안 나타났거나 걷었으면 null
529
+ const where = (t: number): MarkBox | null => {
530
+ if (t >= until) return null;
531
+ if (focus) {
532
+ let k = -1;
533
+ while (k + 1 < focus.length && focus[k + 1].at <= t) k++;
534
+ if (k < 0) return null;
535
+ keyCue(t, focus[k].at);
536
+ const next = focus[k + 1];
537
+ const move = Math.max(0.1, Math.min(MOVE, (next ? next.at - focus[k].at : Infinity) - REST));
538
+ return shape.at(k ? rect(focus[k - 1].el) : null, rect(focus[k].el), clamp((t - focus[k].at) / move));
316
539
  }
540
+ if (t < stops[0]) return null;
541
+ stopCues(t, stops, (k) => order(k) === 0);
317
542
  let j = 0;
318
543
  while (j + 1 < stops.length && stops[j + 1] <= t) j++;
319
544
  const a = stops[j];
320
545
  const b = stops[j + 1] ?? a + 1;
321
- const p = clamp((t - a) / Math.max(1e-3, b - a));
322
- const e = 0.7 * p + 0.3 * p * p * (3 - 2 * p);
323
- // hub 면 주인공(첫 대상)과 나머지를 번갈아 — 작은 글 사이를 옮겨 다니면 칸이 조금밖에 안 움직여 멈춘 화면으로 읽힌다.
324
- // 마지막 정지점(장면 끝)이 주인공이 되게 차례를 맞춘다 — 끝 프레임(표지·견본)에 곁가지 글이 아니라 주인공이 짚혀 있다
325
- const base = o.hub ? (stops.length - 1) % 2 : 0;
326
- const order = (k: number) => (o.hub && n > 1 ? ((k + base) % 2 === 0 ? 0 : 1 + (((k + base - 1) / 2) % (n - 1))) : k % n);
327
- const from = rect(targets[order(j)]);
328
- const to = rect(targets[order(j + 1)]);
329
- const mix = (u: number, v: number) => u + (v - u) * e;
546
+ return shape.at(rect(targets[order(j)]), rect(targets[order(j + 1)]), clamp((t - a) / Math.max(1e-3, b - a)));
547
+ };
548
+ return (t) => {
549
+ const box = where(t);
550
+ if (!box) {
551
+ block.style.visibility = "hidden";
552
+ for (const el of toned) paintTone(el, null);
553
+ toned = new Set();
554
+ return;
555
+ }
330
556
  block.style.visibility = "visible";
331
- block.style.transform = `translate(${mix(from.x, to.x).toFixed(1)}px, ${mix(from.y, to.y).toFixed(1)}px)`;
332
- block.style.width = `${mix(from.w, to.w).toFixed(1)}px`;
333
- block.style.height = `${mix(from.h, to.h).toFixed(1)}px`;
334
- // 띠와 겹친 글자 부분만 흰 글로 — 글자에 「겹친 사각형만 on-accent, 나머지는 원래 글색」인 배경을 깔고 글자 모양으로 오린다.
335
- // 통째로 덮였을 때만 글색을 바꾸면, 쉬지 않고 옮겨 가는 띠는 거의 늘 반쯤 걸쳐 있어 띠 위 글이 검은 채로 남았다
336
- const bx = mix(from.x, to.x);
337
- const by = mix(from.y, to.y);
338
- const bw = mix(from.w, to.w);
339
- const bh = mix(from.h, to.h);
557
+ place(block, box);
340
558
  const now = new Set<HTMLElement>();
559
+ // 띠와 겹친 글자 부분만 띠 위 글색으로 — 글자에 「겹친 사각형만 ink, 나머지는 원래 글색」인 배경을 깔고 글자 모양으로 오린다.
560
+ // 통째로 덮였을 때만 글색을 바꾸면, 옮겨 가는 동안 반쯤 걸친 띠 위 글이 원래 색으로 남았다.
341
561
  // 불투명한 판을 품은 대상(테마 panel 이 깔린 숫자판·계기 판)은 띠가 그 판 뒤로 가려 안 보인다 — 거기 글을 흰 글로 칠하면 흰 판 위 흰 글이 된다
342
- for (const el of targets.filter((x) => !opaqueOf(x)).flatMap(leavesOf)) {
562
+ const ink = shape.ink;
563
+ for (const el of ink ? targets.filter((x) => !opaqueOf(x)).flatMap(leavesOf) : []) {
343
564
  const r = boxOf(el);
344
- const x0 = Math.max(bx, r.x);
345
- const y0 = Math.max(by, r.y);
346
- const x1 = Math.min(bx + bw, r.x + r.w);
347
- const y1 = Math.min(by + bh, r.y + r.h);
565
+ const x0 = Math.max(box.x, r.x);
566
+ const y0 = Math.max(box.y, r.y);
567
+ const x1 = Math.min(box.x + box.w, r.x + r.w);
568
+ const y1 = Math.min(box.y + box.h, r.y + r.h);
348
569
  if (x1 - x0 < 1 || y1 - y0 < 1) continue;
349
570
  // 글 상자에 변환(배율)이 걸려 있으면 배경 좌표는 변환 전 크기로
350
571
  const sx = (el.offsetWidth || r.w) / (r.w || 1);
351
572
  const sy = (el.offsetHeight || r.h) / (r.h || 1);
352
- paintTone(el, { x: (x0 - r.x) * sx, y: (y0 - r.y) * sy, w: (x1 - x0) * sx, h: (y1 - y0) * sy });
573
+ paintTone(el, { x: (x0 - r.x) * sx, y: (y0 - r.y) * sy, w: (x1 - x0) * sx, h: (y1 - y0) * sy }, ink);
353
574
  now.add(el);
354
575
  }
355
576
  for (const el of toned) if (!now.has(el)) paintTone(el, null);
@@ -400,6 +621,7 @@ export function factRoll(t: number, o: { from: number; to: number; at: number; d
400
621
  const dec = o.decimals ?? decimalsOf(o.to);
401
622
  const x = progress(t, o.at, o.dur);
402
623
  const p = 0.75 * ease.outCubic(x) + 0.25 * x;
624
+ rollCues(t, { at: o.at, dur: o.dur, p: (u) => 0.75 * ease.outCubic(u) + 0.25 * u });
403
625
  const lo = Math.min(o.from, o.to);
404
626
  const hi = Math.max(o.from, o.to);
405
627
  const value = p >= 1 ? o.to : clamp(o.from + (o.to - o.from) * p, lo, hi);
@@ -446,5 +668,6 @@ const DEFAULT_SLOTS: Required<Pick<ThemeSlots, "panel" | "land" | "write" | "tra
446
668
 
447
669
  /** 테마 슬롯 + 비운 자리는 기본 */
448
670
  export function slotsOf(kit: Kit): ThemeSlots & typeof DEFAULT_SLOTS {
449
- return { ...DEFAULT_SLOTS, ...kit.slots } as ThemeSlots & typeof DEFAULT_SLOTS;
671
+ // 소리 자리는 감싸개가 단다 — 테마 슬롯이든 기본이든 같은 자리에서
672
+ return withSfx({ ...DEFAULT_SLOTS, ...kit.slots }) as ThemeSlots & typeof DEFAULT_SLOTS;
450
673
  }
@@ -1,6 +1,6 @@
1
1
  import { applyStyle, clamp, spring } from "@kasaframe/core";
2
2
  import { fitSize, textEm } from "@kasaframe/motion/moves";
3
- import { area, beatStops, decimalsOf, factRoll, fmt, glider, moving, node, slotsOf } from "../kit.ts";
3
+ import { area, decimalsOf, dwells, factRoll, fmt, followSpoken, glider, moving, node, sayTimes, slotsOf } from "../kit.ts";
4
4
  import type { Pattern } from "../types.ts";
5
5
 
6
6
  export interface LimitContent {
@@ -12,12 +12,12 @@ export interface LimitContent {
12
12
 
13
13
  /**
14
14
  * 한계 — 굵은 계기가 차올라 끝의 위험 칸(한계)에 부딪힌다. 값이 있으면 계기와 같은 진행도로 굴러 그 값에 닿는다.
15
- * 부딪힌 뒤 치르는 것 한 낱말이 딱지로 붙고, 짚개가 한계 문장·계기·치르는 것을 번갈아 박마다 짚는다.
15
+ * 부딪힌 뒤 치르는 것 한 낱말이 딱지로 붙고, 짚개가 한계 문장부터 짚는다(머무는 테마는 목소리가 계기 값·치르는 것을 말할 때만 그리로).
16
16
  */
17
17
  const pattern: Pattern<LimitContent> = {
18
18
  id: "limit.gauge",
19
19
  type: "limit",
20
- does: "계기가 차올라 끝의 위험 칸에 부딪히고, 치르는 것이 붙은 뒤 짚개가 한계·계기·대가를 번갈아 짚는다",
20
+ does: "계기가 차올라 끝의 위험 칸에 부딪히고, 치르는 것이 붙은 뒤 짚개가 한계부터 짚는다",
21
21
  energy: ["low", "mid"],
22
22
  family: "grow",
23
23
  camera: ["none"],
@@ -78,24 +78,29 @@ const pattern: Pattern<LimitContent> = {
78
78
  const hits = kit.hits.length ? kit.hits : [0];
79
79
  const labelDur = clamp([...c.label].length * 0.03, 0.3, 0.7);
80
80
  const fillAt = labelDur * 0.5;
81
- // 계기는 1–2.2초 안에 벽에 닿는다 — 장면이 길다고 느리게 차오르면 그 사이가 멈춘 화면이다
81
+ // 머무는 짚개 테마는 목소리가 한계를 말하는 순간 벽에 닿는다 — 그때까지 고른 빠르기로 차오르고, 치르는 것은 그것을 말할 때 붙고,
82
+ // 닿은 뒤엔 카메라가 한계·치르는 것 쪽으로 다가간다. 훑는 테마·목소리 없는 영상은 1–2.2초 안 박에 닿는다
83
+ const tail = Math.max(fillAt + 1, ctx.dur - 0.6);
84
+ const heard = dwells(kit, ctx) && (kit.say.length > 0 || kit.lines.length > 0);
85
+ const [heardLabel, heardCost] = heard ? sayTimes(kit, [c.label, c.cost], fillAt + 0.9, tail) : [0, 0];
82
86
  const hitWant = clamp(ctx.dur * 0.45, fillAt + 0.9, fillAt + 2.2);
83
- const hitAt = hits.reduce((best, b) => (Math.abs(b - hitWant) < Math.abs(best - hitWant) && b > fillAt + 0.6 ? b : best), hitWant);
84
- const costAt = hitAt + 0.1;
87
+ const hitAt = heard ? heardLabel : hits.reduce((best, b) => (Math.abs(b - hitWant) < Math.abs(best - hitWant) && b > fillAt + 0.6 ? b : best), hitWant);
88
+ const costAt = Math.max(hitAt + 0.1, heardCost);
85
89
  const markFrom = hitAt + 0.2;
90
+ followSpoken(kit, ctx, [{ at: hitAt, el: labelBox }, ...(cost ? [{ at: costAt, el: cost }] : [])]);
86
91
 
87
- const writeLabel = slots.write(label, { ctx, text: c.label, at: 0, dur: labelDur, seed: `${kit.seed}:l` });
92
+ // 한계 문장은 목소리가 말하는 순간 찍힌다(그 밖엔 처음부터)
93
+ const writeLabel = slots.write(label, { ctx, text: c.label, at: heard ? Math.max(0, hitAt - labelDur) : 0, dur: labelDur, seed: `${kit.seed}:l` });
88
94
  const writeCost = cost ? slots.write(cost, { ctx, text: c.cost!, at: costAt, dur: 0.25, seed: `${kit.seed}:c` }) : null;
89
95
  const landed = slots.land(wallEl, { ctx, at: hitAt, seed: `${kit.seed}:wall` });
90
- // 벽에 닿은 뒤엔 짚개가 한계 문장 ↔ 계기 ↔ 치르는 것을 번갈아 박마다 짚는다
96
+ // 벽에 닿으면 짚개가 한계 문장부터 짚는다 — 머무는 테마는 목소리가 치르는 것을 말할 때만 그리로
91
97
  // 세로면 계기 판은 짚지 않는다 — 띠가 판 뒤로 가려 그 사이가 멈춘 화면이 된다. 한계 문장 ↔ 대가를 오간다
92
- const point = glider(host, vertical && cost ? [labelBox, cost] : [labelBox, gauge, ...(cost ? [cost] : [])], { hub: true });
93
- const stops = beatStops(kit.hits, markFrom, ctx.dur);
98
+ const point = glider(host, vertical && cost ? [{ el: labelBox, text: c.label }, { el: cost, text: c.cost }] : [{ el: labelBox, text: c.label }, { el: gauge }, ...(cost ? [{ el: cost, text: c.cost }] : [])], { kit, ctx, from: markFrom });
94
99
 
95
100
  return (t: number) => {
96
101
  moving("limit.gauge", ctx);
97
102
  writeLabel(t);
98
- point(t, stops);
103
+ point(t);
99
104
  // 고른 빠르기로 차올라 벽에 부딪힌다 — 감속하면 부딪힘이 없고 끝자락이 멈춘 화면이 된다
100
105
  const p = clamp((t - fillAt) / (hitAt - fillAt));
101
106
  fill.style.transform = vertical ? `scaleY(${(p * wall).toFixed(4)})` : `scaleX(${(p * wall).toFixed(4)})`;
@@ -1,6 +1,6 @@
1
1
  import { applyStyle, clamp, spring } from "@kasaframe/core";
2
2
  import { fitSize, textEm } from "@kasaframe/motion/moves";
3
- import { ACCENT_TEXT, area, beatStops, glider, moving, node, slotsOf } from "../kit.ts";
3
+ import { ACCENT_TEXT, area, glider, inverseOver, markOf, moving, node, slotsOf } from "../kit.ts";
4
4
  import type { Pattern, SpokenWord } from "../types.ts";
5
5
 
6
6
  export interface QuoteContent {
@@ -80,14 +80,23 @@ const pattern: Pattern<QuoteContent> = {
80
80
  const mark = node(col, `font-family:var(--kf-font-display);font-size:${Math.round(px * 1.4)}px;line-height:.6;height:${Math.round(px * 0.6)}px;font-weight:900;color:${ACCENT_TEXT}`, "“");
81
81
  const body = node(col, `font-family:var(--kf-font-display);font-size:${px}px;font-weight:${ctx.style.type?.weightDisplay ?? 800};line-height:1.3;color:var(--kf-ink);word-break:keep-all`);
82
82
  kit.stage?.hero(body);
83
+ const shape = markOf(kit, ctx);
83
84
  const spans = words.map((wd, i) => {
84
85
  const sp = body.ownerDocument.createElement("span");
85
86
  sp.style.cssText = "position:relative;white-space:nowrap";
86
87
  sp.textContent = wd;
87
- const hi = node(sp, "position:absolute;left:-0.08em;right:-0.08em;top:0.02em;bottom:0.02em;padding:0 0.08em;background:var(--kf-accent);color:var(--kf-on-accent);white-space:nowrap;visibility:hidden", wd);
88
88
  body.append(sp);
89
89
  if (i < words.length - 1) body.append(body.ownerDocument.createTextNode(" "));
90
- return { sp, hi };
90
+ // 읽는 낱말 반전 칸 — 테마가 짚는 칸 모양을 정했으면 그 칸(반전 짚음)
91
+ if (shape) return { sp, paint: inverseOver(sp, wd, shape) };
92
+ const hi = node(sp, "position:absolute;left:-0.08em;right:-0.08em;top:0.02em;bottom:0.02em;padding:0 0.08em;background:var(--kf-accent);color:var(--kf-on-accent);white-space:nowrap;visibility:hidden", wd);
93
+ return {
94
+ sp,
95
+ paint: (k: number) => {
96
+ hi.style.visibility = k > 0 ? "visible" : "hidden";
97
+ hi.style.clipPath = `inset(0 ${((1 - k) * 100).toFixed(2)}% 0 0)`;
98
+ },
99
+ };
91
100
  });
92
101
  const whoText = [c.who, c.role].filter(Boolean).join(" · ");
93
102
  const who = whoText ? node(col, `align-self:flex-end;font-family:var(--kf-font-sans);font-size:${whoPx}px;font-weight:600;color:var(--kf-ink-soft, var(--kf-ink));white-space:nowrap;line-height:1.3;height:1.3em`) : null;
@@ -101,24 +110,20 @@ const pattern: Pattern<QuoteContent> = {
101
110
  const times = kit.say.length ? timing(words, kit.say, readFrom, ctx.dur - 0.05) : even(words, readFrom, readTo);
102
111
  const readEnd = times[times.length - 1][1];
103
112
  const whoAt = Math.min(Math.max(readFrom, ctx.dur - whoDur - 0.6), readEnd);
104
- // 다 읽은 뒤엔 짚개가 문장 ↔ 누가 말했는지를 박마다 번갈아 짚는다(누가 없으면 낱말을 차례로)
105
- const point = glider(host, who ? [body, who] : spans.map((x) => x.sp), { hub: !!who, pad: 0.06 });
106
- const stops = beatStops(kit.hits, readEnd + 0.05, ctx.dur);
113
+ // 다 읽으면 짚개가 문장부터 짚는다(머무는 테마는 목소리가 누가를 말할 때만 그리로). 누가가 없으면 훑는 테마만 낱말을 차례로 — 머무는 테마엔 주인공이 없다
114
+ const point = glider(host, who ? [{ el: body, text: c.text }, { el: who, text: whoText }] : spans.map((x) => ({ el: x.sp })), { kit, ctx, from: readEnd + 0.05, hub: !!who, pad: 0.06 });
107
115
  const writeWho = who ? slots.write(who, { ctx, text: whoText, at: whoAt, dur: whoDur, seed: `${kit.seed}:who` }) : null;
108
116
 
109
117
  return (t: number) => {
110
118
  moving("quote.karaoke", ctx);
111
119
  applyStyle(mark, { scale: spring(t, { from: 0.4, to: 1, stiffness: 260, damping: 14 }) });
120
+ // 짚는 동안은 낱말 반전 칸을 걷는다 — 두 강조가 겹치면 무엇을 짚는지 흐려진다
112
121
  spans.forEach((s, i) => {
113
122
  const [a, b] = times[i];
114
- const k = clamp((t - a) / Math.max(0.05, b - a));
115
- s.hi.style.visibility = k > 0 ? "visible" : "hidden";
116
- s.hi.style.clipPath = `inset(0 ${((1 - k) * 100).toFixed(2)}% 0 0)`;
123
+ s.paint(t >= readEnd + 0.05 ? 0 : clamp((t - a) / Math.max(0.05, b - a)));
117
124
  });
118
125
  writeWho?.(t);
119
- // 짚는 동안은 낱말 반전 칸을 걷는다 — 두 강조가 겹치면 무엇을 짚는지 흐려진다
120
- if (t >= readEnd + 0.05) spans.forEach((x) => (x.hi.style.visibility = "hidden"));
121
- point(t, stops);
126
+ point(t);
122
127
  };
123
128
  },
124
129
  };