@yewdiel21/motion-kit 0.0.0-stage → 0.9.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 (391) hide show
  1. package/NOTICE +38 -0
  2. package/README.md +80 -2
  3. package/bin/motion-kit.mjs +242 -0
  4. package/css.d.ts +2 -0
  5. package/dist/auto/index.d.ts +63 -0
  6. package/dist/auto/index.js +207 -0
  7. package/dist/auto/index.js.map +1 -0
  8. package/dist/chunk-2BQGFOA7.js +177 -0
  9. package/dist/chunk-2BQGFOA7.js.map +1 -0
  10. package/dist/chunk-34XYOXHG.js +2374 -0
  11. package/dist/chunk-34XYOXHG.js.map +1 -0
  12. package/dist/chunk-6OJAP6ZL.js +832 -0
  13. package/dist/chunk-6OJAP6ZL.js.map +1 -0
  14. package/dist/chunk-ABNRDDO7.js +28 -0
  15. package/dist/chunk-ABNRDDO7.js.map +1 -0
  16. package/dist/chunk-FS2HJVVY.js +3320 -0
  17. package/dist/chunk-FS2HJVVY.js.map +1 -0
  18. package/dist/chunk-J27KH2A2.js +157 -0
  19. package/dist/chunk-J27KH2A2.js.map +1 -0
  20. package/dist/chunk-KLY5BRRI.js +18 -0
  21. package/dist/chunk-KLY5BRRI.js.map +1 -0
  22. package/dist/chunk-Q4LVFNLN.js +1617 -0
  23. package/dist/chunk-Q4LVFNLN.js.map +1 -0
  24. package/dist/chunk-RFDIZYFX.js +304 -0
  25. package/dist/chunk-RFDIZYFX.js.map +1 -0
  26. package/dist/chunk-TGV27ZWM.js +17 -0
  27. package/dist/chunk-TGV27ZWM.js.map +1 -0
  28. package/dist/confetti-BSX1CLLV.d.ts +41 -0
  29. package/dist/css/components/chat-background.css +87 -0
  30. package/dist/css/components/chat.css +237 -0
  31. package/dist/css/components/cursor.css +178 -0
  32. package/dist/css/components/dialog.css +259 -0
  33. package/dist/css/components/island.css +301 -0
  34. package/dist/css/components/menu.css +105 -0
  35. package/dist/css/components/nav.css +1054 -0
  36. package/dist/css/components/steps.css +50 -0
  37. package/dist/css/components/theme-picker.css +115 -0
  38. package/dist/css/components/toast.css +115 -0
  39. package/dist/css/controls.css +950 -0
  40. package/dist/css/effects.css +587 -0
  41. package/dist/css/fonts.css +3 -0
  42. package/dist/css/themes/black-green.css +126 -0
  43. package/dist/css/themes/black.css +126 -0
  44. package/dist/css/themes/blue.css +126 -0
  45. package/dist/css/themes/brown.css +126 -0
  46. package/dist/css/themes/cold-blue.css +126 -0
  47. package/dist/css/themes/funny.css +126 -0
  48. package/dist/css/themes/gray.css +126 -0
  49. package/dist/css/themes/green.css +126 -0
  50. package/dist/css/themes/modern-1.css +126 -0
  51. package/dist/css/themes/neogreen.css +126 -0
  52. package/dist/css/themes/new.css +126 -0
  53. package/dist/css/themes/new2.css +126 -0
  54. package/dist/css/themes/oled.css +126 -0
  55. package/dist/css/themes/orange.css +126 -0
  56. package/dist/css/themes/pastel-pink.css +126 -0
  57. package/dist/css/themes/pink.css +126 -0
  58. package/dist/css/themes/purple.css +126 -0
  59. package/dist/css/themes/red.css +126 -0
  60. package/dist/css/themes/super-blue.css +126 -0
  61. package/dist/css/themes.css +2431 -0
  62. package/dist/css/tokens.css +452 -0
  63. package/dist/elements/index.d.ts +183 -0
  64. package/dist/elements/index.js +472 -0
  65. package/dist/elements/index.js.map +1 -0
  66. package/dist/engine-CUr9QijM.d.ts +46 -0
  67. package/dist/gl/index.d.ts +61 -0
  68. package/dist/gl/index.js +18 -0
  69. package/dist/gl/index.js.map +1 -0
  70. package/dist/index.css +5296 -0
  71. package/dist/index.d.ts +440 -0
  72. package/dist/index.js +390 -0
  73. package/dist/index.js.map +1 -0
  74. package/dist/react/index.d.ts +476 -0
  75. package/dist/react/index.js +969 -0
  76. package/dist/react/index.js.map +1 -0
  77. package/dist/reduced-BM4Ls0g5.d.ts +566 -0
  78. package/dist/reveal-WFUWDrei.d.ts +416 -0
  79. package/dist/store-Bqy_obnD.d.ts +195 -0
  80. package/dist/theme/generate.d.ts +18 -0
  81. package/dist/theme/generate.js +64 -0
  82. package/dist/theme/generate.js.map +1 -0
  83. package/dist/theme/index.d.ts +17 -0
  84. package/dist/theme/index.js +32 -0
  85. package/dist/theme/index.js.map +1 -0
  86. package/dist/theme-BFMoCzi6.d.ts +158 -0
  87. package/dist/types-BkS_raL8.d.ts +7 -0
  88. package/package.json +114 -3
  89. package/registry.json +469 -0
  90. package/skills/motion-kit/SKILL.md +189 -0
  91. package/skills/motion-kit/reference/components.md +86 -0
  92. package/skills/motion-kit/reference/design.md +92 -0
  93. package/skills/motion-kit/reference/recipes.md +172 -0
  94. package/src/auto/auto-init.ts +151 -0
  95. package/src/auto/index.ts +3 -0
  96. package/src/auto/parse-options.ts +68 -0
  97. package/src/components/chat/README.md +90 -0
  98. package/src/components/chat/chat.css +236 -0
  99. package/src/components/chat/element.ts +98 -0
  100. package/src/components/chat/engine.ts +295 -0
  101. package/src/components/chat/react.tsx +196 -0
  102. package/src/components/chat-background/README.md +50 -0
  103. package/src/components/chat-background/audio.ts +56 -0
  104. package/src/components/chat-background/chat-background.css +86 -0
  105. package/src/components/chat-background/element.ts +31 -0
  106. package/src/components/chat-background/engine.ts +108 -0
  107. package/src/components/chat-background/react.tsx +36 -0
  108. package/src/components/cursor/README.md +53 -0
  109. package/src/components/cursor/cursor.css +177 -0
  110. package/src/components/cursor/element.ts +23 -0
  111. package/src/components/cursor/engine.ts +254 -0
  112. package/src/components/cursor/react.tsx +26 -0
  113. package/src/components/dialog/README.md +106 -0
  114. package/src/components/dialog/dialog.css +258 -0
  115. package/src/components/dialog/element.ts +118 -0
  116. package/src/components/dialog/engine.ts +432 -0
  117. package/src/components/dialog/react.tsx +200 -0
  118. package/src/components/icon.tsx +10 -0
  119. package/src/components/icons.ts +24 -0
  120. package/src/components/island/README.md +91 -0
  121. package/src/components/island/element.ts +45 -0
  122. package/src/components/island/engine.ts +200 -0
  123. package/src/components/island/island.css +300 -0
  124. package/src/components/island/react.tsx +115 -0
  125. package/src/components/menu/README.md +67 -0
  126. package/src/components/menu/element.ts +55 -0
  127. package/src/components/menu/engine.ts +142 -0
  128. package/src/components/menu/menu.css +104 -0
  129. package/src/components/menu/react.tsx +82 -0
  130. package/src/components/nav/README.md +85 -0
  131. package/src/components/nav/element.ts +39 -0
  132. package/src/components/nav/engine.ts +164 -0
  133. package/src/components/nav/nav.css +1053 -0
  134. package/src/components/nav/react.tsx +90 -0
  135. package/src/components/steps/README.md +70 -0
  136. package/src/components/steps/element.ts +43 -0
  137. package/src/components/steps/engine.ts +185 -0
  138. package/src/components/steps/react.tsx +91 -0
  139. package/src/components/steps/steps.css +49 -0
  140. package/src/components/theme-picker/README.md +57 -0
  141. package/src/components/theme-picker/element.ts +20 -0
  142. package/src/components/theme-picker/engine.ts +91 -0
  143. package/src/components/theme-picker/react.tsx +31 -0
  144. package/src/components/theme-picker/theme-picker.css +114 -0
  145. package/src/components/toast/README.md +60 -0
  146. package/src/components/toast/element.ts +25 -0
  147. package/src/components/toast/engine.ts +483 -0
  148. package/src/components/toast/react.tsx +22 -0
  149. package/src/components/toast/store.ts +122 -0
  150. package/src/components/toast/toast.css +114 -0
  151. package/src/core/channels.ts +165 -0
  152. package/src/core/curves.ts +35 -0
  153. package/src/core/frame.ts +12 -0
  154. package/src/core/gsap.ts +65 -0
  155. package/src/core/loop.ts +44 -0
  156. package/src/core/math.ts +9 -0
  157. package/src/core/physics-spring.ts +59 -0
  158. package/src/core/pointer.ts +63 -0
  159. package/src/core/reduced.ts +21 -0
  160. package/src/core/scope.ts +70 -0
  161. package/src/core/scroll-track.ts +106 -0
  162. package/src/core/split.ts +68 -0
  163. package/src/core/spring.ts +22 -0
  164. package/src/core/styles.ts +31 -0
  165. package/src/core/timing.ts +204 -0
  166. package/src/core/types.ts +20 -0
  167. package/src/core/visible.ts +19 -0
  168. package/src/css/base.css +142 -0
  169. package/src/css/controls/README.md +122 -0
  170. package/src/css/controls/burger.css +67 -0
  171. package/src/css/controls/button.css +226 -0
  172. package/src/css/controls/checkbox.css +53 -0
  173. package/src/css/controls/chip.css +85 -0
  174. package/src/css/controls/field.css +124 -0
  175. package/src/css/controls/glass.css +220 -0
  176. package/src/css/controls/icon.css +26 -0
  177. package/src/css/controls/segmented.css +60 -0
  178. package/src/css/controls/switch.css +72 -0
  179. package/src/css/default-theme.css +123 -0
  180. package/src/css/effects/README.md +134 -0
  181. package/src/css/effects/anim.css +224 -0
  182. package/src/css/effects/fade.css +15 -0
  183. package/src/css/effects/float.css +64 -0
  184. package/src/css/effects/frost.css +62 -0
  185. package/src/css/effects/grain.css +58 -0
  186. package/src/css/effects/loading.css +60 -0
  187. package/src/css/effects/success.css +22 -0
  188. package/src/css/effects/text.css +67 -0
  189. package/src/css/fonts.css +3 -0
  190. package/src/css/states.css +51 -0
  191. package/src/css/themes/black-green.css +126 -0
  192. package/src/css/themes/black.css +126 -0
  193. package/src/css/themes/blue.css +126 -0
  194. package/src/css/themes/brown.css +126 -0
  195. package/src/css/themes/cold-blue.css +126 -0
  196. package/src/css/themes/funny.css +126 -0
  197. package/src/css/themes/gray.css +126 -0
  198. package/src/css/themes/green.css +126 -0
  199. package/src/css/themes/modern-1.css +126 -0
  200. package/src/css/themes/neogreen.css +126 -0
  201. package/src/css/themes/new.css +126 -0
  202. package/src/css/themes/new2.css +126 -0
  203. package/src/css/themes/oled.css +126 -0
  204. package/src/css/themes/orange.css +126 -0
  205. package/src/css/themes/pastel-pink.css +126 -0
  206. package/src/css/themes/pink.css +126 -0
  207. package/src/css/themes/purple.css +126 -0
  208. package/src/css/themes/red.css +126 -0
  209. package/src/css/themes/super-blue.css +126 -0
  210. package/src/css/tokens.css +182 -0
  211. package/src/elements/base.ts +76 -0
  212. package/src/elements/index.ts +48 -0
  213. package/src/gl/index.ts +15 -0
  214. package/src/gl/liquid-blend/README.md +23 -0
  215. package/src/gl/liquid-blend/create-liquid-blend.ts +7 -0
  216. package/src/gl/liquid-blend/shader.ts +9 -0
  217. package/src/gl/matcap/README.md +23 -0
  218. package/src/gl/matcap/create-matcap.ts +7 -0
  219. package/src/gl/matcap/shader.ts +16 -0
  220. package/src/gl/particle-hero/README.md +41 -0
  221. package/src/gl/particle-hero/create-particle-hero.ts +544 -0
  222. package/src/gl/particle-hero/particle-hero.css +19 -0
  223. package/src/gl/particle-hero/shaders.ts +84 -0
  224. package/src/gl/shader-blur/README.md +23 -0
  225. package/src/gl/shader-blur/create-shader-blur.ts +7 -0
  226. package/src/gl/shader-blur/shader.ts +11 -0
  227. package/src/gl/shader-grain/README.md +23 -0
  228. package/src/gl/shader-grain/create-shader-grain.ts +7 -0
  229. package/src/gl/shader-grain/shader.ts +6 -0
  230. package/src/gl/shared/create-shader-effect.ts +186 -0
  231. package/src/gl/shared/shader.css +3 -0
  232. package/src/gl/shared/shaders.ts +30 -0
  233. package/src/index.ts +177 -0
  234. package/src/motion/anchor.ts +45 -0
  235. package/src/motion/chat.ts +97 -0
  236. package/src/motion/confetti.ts +130 -0
  237. package/src/motion/count.ts +95 -0
  238. package/src/motion/details.ts +70 -0
  239. package/src/motion/drag-dismiss.ts +231 -0
  240. package/src/motion/effects.ts +81 -0
  241. package/src/motion/entrances.ts +27 -0
  242. package/src/motion/floating.ts +389 -0
  243. package/src/motion/flow.ts +115 -0
  244. package/src/motion/indicator.ts +44 -0
  245. package/src/motion/layout.ts +39 -0
  246. package/src/motion/liquid.ts +227 -0
  247. package/src/motion/morph.ts +540 -0
  248. package/src/motion/reveal.ts +112 -0
  249. package/src/motion/sheet.ts +65 -0
  250. package/src/motion/surface.ts +108 -0
  251. package/src/react/animated-number.tsx +30 -0
  252. package/src/react/flip-group.tsx +44 -0
  253. package/src/react/frost.tsx +33 -0
  254. package/src/react/index.ts +80 -0
  255. package/src/react/slot-text.tsx +44 -0
  256. package/src/react/theme-provider.tsx +42 -0
  257. package/src/react/use-card-dock.ts +9 -0
  258. package/src/react/use-cascade.ts +9 -0
  259. package/src/react/use-contrast.ts +9 -0
  260. package/src/react/use-counter.ts +8 -0
  261. package/src/react/use-curved-carousel.ts +10 -0
  262. package/src/react/use-depth-grid.ts +9 -0
  263. package/src/react/use-details.ts +7 -0
  264. package/src/react/use-dock-pill.ts +10 -0
  265. package/src/react/use-drag-ruler.ts +10 -0
  266. package/src/react/use-fill-text.ts +9 -0
  267. package/src/react/use-fit-text.ts +8 -0
  268. package/src/react/use-flow.ts +8 -0
  269. package/src/react/use-frame-zoom.ts +9 -0
  270. package/src/react/use-infinite-reel.ts +8 -0
  271. package/src/react/use-letters.ts +9 -0
  272. package/src/react/use-liquid-blend.ts +8 -0
  273. package/src/react/use-marquee.ts +9 -0
  274. package/src/react/use-mask-text.ts +9 -0
  275. package/src/react/use-matcap.ts +8 -0
  276. package/src/react/use-parallax.ts +9 -0
  277. package/src/react/use-particle-hero.ts +9 -0
  278. package/src/react/use-pile.ts +8 -0
  279. package/src/react/use-pop.ts +8 -0
  280. package/src/react/use-radial-menu.ts +10 -0
  281. package/src/react/use-recipe.ts +38 -0
  282. package/src/react/use-reflow.ts +9 -0
  283. package/src/react/use-repel-bento.ts +8 -0
  284. package/src/react/use-reveal.ts +8 -0
  285. package/src/react/use-rotator.ts +10 -0
  286. package/src/react/use-shader-blur.ts +8 -0
  287. package/src/react/use-shader-grain.ts +8 -0
  288. package/src/react/use-slot-text.ts +17 -0
  289. package/src/react/use-snap-strip.ts +9 -0
  290. package/src/react/use-sortable.ts +14 -0
  291. package/src/react/use-spring-cards.ts +8 -0
  292. package/src/react/use-stretch-footer.ts +20 -0
  293. package/src/react/use-tabs.ts +10 -0
  294. package/src/react/use-text-roll.ts +9 -0
  295. package/src/react/use-ticket-menu.ts +15 -0
  296. package/src/react/use-words.ts +8 -0
  297. package/src/recipes/card-dock/README.md +36 -0
  298. package/src/recipes/card-dock/card-dock.css +178 -0
  299. package/src/recipes/card-dock/create-card-dock.ts +97 -0
  300. package/src/recipes/cascade/README.md +33 -0
  301. package/src/recipes/cascade/create-cascade.ts +111 -0
  302. package/src/recipes/contrast/README.md +29 -0
  303. package/src/recipes/contrast/create-contrast.ts +68 -0
  304. package/src/recipes/counter/README.md +42 -0
  305. package/src/recipes/counter/create-counter.ts +84 -0
  306. package/src/recipes/curved-carousel/README.md +50 -0
  307. package/src/recipes/curved-carousel/carousel-math.ts +20 -0
  308. package/src/recipes/curved-carousel/create-curved-carousel.ts +257 -0
  309. package/src/recipes/curved-carousel/curved-carousel.css +50 -0
  310. package/src/recipes/depth-grid/README.md +30 -0
  311. package/src/recipes/depth-grid/create-depth-grid.ts +120 -0
  312. package/src/recipes/details/README.md +31 -0
  313. package/src/recipes/details/create-details.ts +27 -0
  314. package/src/recipes/dock-pill/README.md +23 -0
  315. package/src/recipes/dock-pill/create-dock-pill.ts +68 -0
  316. package/src/recipes/dock-pill/dock-pill.css +5 -0
  317. package/src/recipes/drag-ruler/README.md +21 -0
  318. package/src/recipes/drag-ruler/create-drag-ruler.ts +95 -0
  319. package/src/recipes/drag-ruler/drag-ruler.css +16 -0
  320. package/src/recipes/fill-text/README.md +27 -0
  321. package/src/recipes/fill-text/create-fill-text.ts +93 -0
  322. package/src/recipes/fit-text/README.md +37 -0
  323. package/src/recipes/fit-text/create-fit-text.ts +55 -0
  324. package/src/recipes/fit-text/fit-text.css +6 -0
  325. package/src/recipes/flow/README.md +46 -0
  326. package/src/recipes/flow/create-flow.ts +55 -0
  327. package/src/recipes/frame-zoom/README.md +29 -0
  328. package/src/recipes/frame-zoom/create-frame-zoom.ts +91 -0
  329. package/src/recipes/infinite-reel/README.md +23 -0
  330. package/src/recipes/infinite-reel/create-infinite-reel.ts +140 -0
  331. package/src/recipes/infinite-reel/infinite-reel.css +8 -0
  332. package/src/recipes/letters/README.md +30 -0
  333. package/src/recipes/letters/create-letters.ts +113 -0
  334. package/src/recipes/marquee/README.md +32 -0
  335. package/src/recipes/marquee/create-marquee.ts +74 -0
  336. package/src/recipes/marquee/marquee.css +41 -0
  337. package/src/recipes/mask-text/README.md +39 -0
  338. package/src/recipes/mask-text/create-mask-text.ts +134 -0
  339. package/src/recipes/parallax/README.md +31 -0
  340. package/src/recipes/parallax/create-parallax.ts +74 -0
  341. package/src/recipes/pile/README.md +48 -0
  342. package/src/recipes/pile/create-pile.ts +50 -0
  343. package/src/recipes/pile/pile-math.ts +27 -0
  344. package/src/recipes/pile/pile.css +63 -0
  345. package/src/recipes/pill/README.md +30 -0
  346. package/src/recipes/pill/pill.css +48 -0
  347. package/src/recipes/pop/README.md +36 -0
  348. package/src/recipes/pop/create-pop.ts +52 -0
  349. package/src/recipes/radial-menu/README.md +44 -0
  350. package/src/recipes/radial-menu/create-radial-menu.ts +96 -0
  351. package/src/recipes/radial-menu/radial-menu.css +45 -0
  352. package/src/recipes/reflow/README.md +47 -0
  353. package/src/recipes/reflow/create-reflow.ts +49 -0
  354. package/src/recipes/repel-bento/README.md +21 -0
  355. package/src/recipes/repel-bento/create-repel-bento.ts +65 -0
  356. package/src/recipes/repel-bento/repel-bento.css +3 -0
  357. package/src/recipes/reveal/README.md +36 -0
  358. package/src/recipes/reveal/create-reveal.ts +48 -0
  359. package/src/recipes/rotator/README.md +21 -0
  360. package/src/recipes/rotator/create-rotator.ts +66 -0
  361. package/src/recipes/rotator/rotator.css +3 -0
  362. package/src/recipes/slot-text/README.md +44 -0
  363. package/src/recipes/slot-text/create-slot-text.ts +244 -0
  364. package/src/recipes/slot-text/slot-text.css +42 -0
  365. package/src/recipes/snap-strip/README.md +31 -0
  366. package/src/recipes/snap-strip/create-snap-strip.ts +133 -0
  367. package/src/recipes/snap-strip/snap-strip.css +23 -0
  368. package/src/recipes/sortable/README.md +88 -0
  369. package/src/recipes/sortable/create-sortable.ts +863 -0
  370. package/src/recipes/sortable/sortable.css +40 -0
  371. package/src/recipes/spring-cards/README.md +48 -0
  372. package/src/recipes/spring-cards/create-spring-cards.ts +140 -0
  373. package/src/recipes/spring-cards/spring-cards.css +23 -0
  374. package/src/recipes/stretch-footer/README.md +44 -0
  375. package/src/recipes/stretch-footer/create-stretch-footer.ts +69 -0
  376. package/src/recipes/stretch-footer/stretch-footer.css +28 -0
  377. package/src/recipes/tabs/README.md +39 -0
  378. package/src/recipes/tabs/create-tabs.ts +91 -0
  379. package/src/recipes/tabs/tabs.css +19 -0
  380. package/src/recipes/text-roll/README.md +35 -0
  381. package/src/recipes/text-roll/create-text-roll.ts +137 -0
  382. package/src/recipes/text-roll/text-roll.css +21 -0
  383. package/src/recipes/ticket-menu/README.md +24 -0
  384. package/src/recipes/ticket-menu/create-ticket-menu.ts +6 -0
  385. package/src/recipes/words/README.md +43 -0
  386. package/src/recipes/words/create-words.ts +114 -0
  387. package/src/theme/css-format.ts +32 -0
  388. package/src/theme/generate.ts +72 -0
  389. package/src/theme/index.ts +15 -0
  390. package/src/theme/presets.ts +79 -0
  391. package/src/theme/theme.ts +119 -0
@@ -0,0 +1,137 @@
1
+ import { gsap, SplitText, registerMotion } from '../../core/gsap';
2
+ import { entrances } from '../../core/reduced';
3
+ import { TIMING } from '../../core/timing';
4
+ import { inert, warnOnce, type Instance } from '../../core/types';
5
+ import { shows, thresholds } from '../../core/visible';
6
+
7
+ /*
8
+ * Siena's slot title: every letter is a little reel — its face and a copy
9
+ * under it scroll up one height per turn, 8–14 turns picked at random, in a
10
+ * paused timeline. play() pushes all those timelines from 0 to 1 in 2 s
11
+ * (expo.out, 40 ms apart in random order), so the letters spin fast and settle
12
+ * one by one on the same letter. Optionally it plays again every 6 s while
13
+ * visible and the page is not scrolling (it waits 1 s of calm).
14
+ */
15
+
16
+ export interface TextRollOptions {
17
+ duration?: number;
18
+ stagger?: number;
19
+ /** [min, max] repeats of each letter's turn (Siena: 8–14). */
20
+ turns?: readonly [number, number];
21
+ /** 'load' (default) after `delay`; 'scroll' when it shows; 'hover'; 'manual'. */
22
+ trigger?: 'load' | 'scroll' | 'hover' | 'manual';
23
+ delay?: number;
24
+ /** Replay every n ms while visible and not scrolling (Siena: 6000). 0: off. */
25
+ interval?: number;
26
+ }
27
+
28
+ export interface TextRollInstance extends Instance {
29
+ play(): void;
30
+ readonly timelines: gsap.core.Timeline[];
31
+ }
32
+
33
+ export function createTextRoll(el: HTMLElement | null, options: TextRollOptions = {}): TextRollInstance {
34
+ if (!el) {
35
+ warnOnce('text-roll: missing element');
36
+ return inert({ play() {}, timelines: [] });
37
+ }
38
+ const t = TIMING.textRoll;
39
+ const { duration = t.duration, stagger = t.stagger, turns = t.turns, trigger = 'load', delay = 0, interval = 0 } = options;
40
+ if (!entrances()) {
41
+ el.dataset.mkReady = '';
42
+ return {
43
+ play() {},
44
+ timelines: [],
45
+ destroy() {
46
+ delete el.dataset.mkReady;
47
+ },
48
+ };
49
+ }
50
+ registerMotion();
51
+ const split = SplitText.create(el, { type: 'words,chars', wordsClass: 'mk-roll-word', charsClass: 'mk-roll-char', aria: 'auto' });
52
+ const timelines = (split.chars as HTMLElement[]).map((char) => {
53
+ const letter = char.textContent ?? '';
54
+ const face = document.createElement('span');
55
+ face.className = 'mk-roll-face';
56
+ face.textContent = letter;
57
+ const copy = face.cloneNode(true);
58
+ char.replaceChildren(face, copy);
59
+ const timeline = gsap.timeline({ paused: true });
60
+ timeline.to([face, copy], { yPercent: '-=100', repeat: gsap.utils.random(turns[0], turns[1], 1), ease: 'none', duration: 1 });
61
+ return timeline;
62
+ });
63
+
64
+ let envelope: gsap.core.Tween | null = null;
65
+ const play = () => {
66
+ envelope?.kill();
67
+ envelope = gsap.to(timelines, {
68
+ progress: 1,
69
+ duration,
70
+ ease: 'expo.out',
71
+ stagger: { each: stagger, ease: 'none', from: 'random' },
72
+ onComplete: () => {
73
+ gsap.set(timelines, { progress: 0 });
74
+ },
75
+ });
76
+ };
77
+
78
+ const cleanups: Array<() => void> = [];
79
+ let visible = true;
80
+ let scrolling = false;
81
+ if (interval > 0 || trigger === 'scroll') {
82
+ if (typeof IntersectionObserver === 'function') {
83
+ visible = false;
84
+ let played = false;
85
+ const observer = new IntersectionObserver(
86
+ ([entry]) => {
87
+ visible = Boolean(entry?.isIntersecting);
88
+ if (entry && shows(entry, 0.5) && trigger === 'scroll' && !played) {
89
+ played = true;
90
+ play();
91
+ }
92
+ },
93
+ { threshold: thresholds(0.5) },
94
+ );
95
+ observer.observe(el);
96
+ cleanups.push(() => observer.disconnect());
97
+ }
98
+ }
99
+ if (interval > 0) {
100
+ let quiet: ReturnType<typeof setTimeout> | undefined;
101
+ const onScroll = () => {
102
+ scrolling = true;
103
+ clearTimeout(quiet);
104
+ quiet = setTimeout(() => (scrolling = false), t.quietMs);
105
+ };
106
+ addEventListener('scroll', onScroll, { passive: true });
107
+ const timer = setInterval(() => {
108
+ if (visible && !scrolling) play();
109
+ }, interval);
110
+ cleanups.push(() => {
111
+ removeEventListener('scroll', onScroll);
112
+ clearInterval(timer);
113
+ clearTimeout(quiet);
114
+ });
115
+ }
116
+ if (trigger === 'hover') {
117
+ el.addEventListener('pointerenter', play);
118
+ cleanups.push(() => el.removeEventListener('pointerenter', play));
119
+ }
120
+ if (trigger === 'load') {
121
+ const call = gsap.delayedCall(delay, play);
122
+ cleanups.push(() => call.kill());
123
+ }
124
+ el.dataset.mkReady = '';
125
+
126
+ return {
127
+ play,
128
+ timelines,
129
+ destroy() {
130
+ for (const cleanup of cleanups.splice(0)) cleanup();
131
+ envelope?.kill();
132
+ timelines.forEach((timeline) => timeline.kill());
133
+ split.revert();
134
+ delete el.dataset.mkReady;
135
+ },
136
+ };
137
+ }
@@ -0,0 +1,21 @@
1
+ /* text-roll — Siena's slot title: each letter is a reel (its face and a copy below), clipped to one line. */
2
+ .mk-roll-word {
3
+ white-space: nowrap;
4
+ }
5
+
6
+ .mk-roll-char {
7
+ position: relative;
8
+ display: inline-block;
9
+ overflow: clip;
10
+ vertical-align: top;
11
+ }
12
+
13
+ .mk-roll-face {
14
+ display: block;
15
+ }
16
+
17
+ .mk-roll-face + .mk-roll-face {
18
+ position: absolute;
19
+ top: 100%;
20
+ left: 0;
21
+ }
@@ -0,0 +1,24 @@
1
+ # ticket-menu
2
+
3
+ Menú boleto de Siena sobre menu: recorte, filas escalonadas, teclado y foco.
4
+
5
+ ```html
6
+ <button data-mk-open="sections">Secciones</button>
7
+ <dialog id="sections" class="mk-dialog mk-menu" data-mk="ticket-menu" aria-label="Secciones">
8
+ <button data-mk-item>Archivo</button><button data-mk-item>Contacto</button>
9
+ </dialog>
10
+ ```
11
+
12
+ ```ts
13
+ import { createTicketMenu } from '@yewdiel21/motion-kit';
14
+ import { useTicketMenu } from '@yewdiel21/motion-kit/react';
15
+ import '@yewdiel21/motion-kit/css';
16
+ const instance = createTicketMenu(root);
17
+ instance.destroy();
18
+ ```
19
+
20
+ `createTicketMenu(dialog, MenuOptions)` reutiliza el motor de menú. También `<mk-menu ticket>` y `<Menu.Root ticket>`. Apertura por clip-path .9 s; filas desde yPercent 150, 1.1 s, stagger .02. `open(origin?)`, `close()`, `toggle()`, `items`, `isOpen`. Origen: Siena `siena.menu.expand` y `siena.menu.details`; adaptación de sus grid rows a un diálogo local.
21
+
22
+ Vanilla declarativo: `autoInit(root)`. React: `useTicketMenu(ref, options, deps)` devuelve una ref a la instancia; se destruye al desmontar. Con movimiento reducido no se ejecuta movimiento decorativo. `destroy()` restaura atributos y estilos y quita listeners, observers y nodos generados.
23
+
24
+ Desde 0.8.0. Parámetros investigados en `REA - Dousan OFFFICE Siena/`; implementación propia, sin código ni assets de esos sitios.
@@ -0,0 +1,6 @@
1
+ import { createMenu, type MenuInstance, type MenuOptions } from '../../components/menu/engine';
2
+
3
+ export type TicketMenuOptions = Omit<MenuOptions, 'ticket'>;
4
+ export type TicketMenuInstance = MenuInstance;
5
+ export const createTicketMenu = (root: HTMLElement | null, options: TicketMenuOptions = {}): TicketMenuInstance =>
6
+ createMenu(root, { ...options, ticket: true });
@@ -0,0 +1,43 @@
1
+ # words
2
+
3
+ Cada palabra (o letra) crece desde la base con rebote: `scaleY 0 → 1`, 100 ms entre palabras (60 ms entre letras).
4
+
5
+ Origen: spencergabor.com — titulares del Hero.
6
+
7
+ ## Vanilla / Astro
8
+
9
+ ```html
10
+ <h2 data-mk="words">Selected <span class="muted">work.</span></h2>
11
+ <span data-mk="fit-text words" data-mk-by="chars" data-mk-trigger="load">Portfolio</span>
12
+ ```
13
+
14
+ Con `autoInit()` (de `@yewdiel21/motion-kit/auto`) las opciones van como `data-mk-<opción>` en kebab-case. Sin `autoInit`: `createWords(el, opciones)`.
15
+
16
+ ## React
17
+
18
+ ```tsx
19
+ const ref = useRef<HTMLHeadingElement>(null);
20
+ const words = useWords(ref, { trigger: 'scroll' });
21
+ // cambio de idioma: words.current?.replace(() => (ref.current!.textContent = t('title')))
22
+ ```
23
+
24
+ ## Opciones
25
+
26
+ | Opción | Por defecto | Qué hace |
27
+ |---|---|---|
28
+ | `by` | `'words'` | 'words' o 'chars' |
29
+ | `trigger` | `'scroll'` | 'scroll' (al entrar en pantalla), 'load' o 'manual' |
30
+ | `start` | `'top 88%'` | Inicio del ScrollTrigger |
31
+ | `duration` | `.8` | Segundos |
32
+ | `stagger` | `.1 / .06` | Entre palabras / letras |
33
+ | `delay` | `0` | Segundos (0.2 por defecto con `load`) |
34
+
35
+ ## Controles
36
+
37
+ `reveal(delay?)`, `replace(mutate)`, `destroy()`
38
+
39
+ ## CSS
40
+
41
+ Los titulares (`h1`–`h6`) reciben `aria-label` y las piezas `aria-hidden`; otros elementos se leen tal cual. Si el elemento no está renderizado al crear (dentro de un `<dialog>` cerrado), se divide al llamar `reveal()`.
42
+
43
+ Con `prefers-reduced-motion: reduce` la receta deja el contenido en su estado final, sin animar.
@@ -0,0 +1,114 @@
1
+ import { gsap, ELASTIC, ScrollTrigger, SplitText, registerMotion } from '../../core/gsap';
2
+ import { entrances } from '../../core/reduced';
3
+ import { inert, warnOnce, type Instance } from '../../core/types';
4
+
5
+ /*
6
+ * spencergabor.com headlines: every word (or letter) grows from its baseline,
7
+ * scaleY 0 → 1 and 25 % up → 0, elastic, 100 ms apart (60 ms for letters).
8
+ */
9
+
10
+ export interface WordsOptions {
11
+ by?: 'words' | 'chars';
12
+ /** 'scroll' plays when the element enters the viewport; 'load' plays now; 'manual' waits for reveal(). */
13
+ trigger?: 'scroll' | 'load' | 'manual';
14
+ /** ScrollTrigger start for trigger 'scroll'. */
15
+ start?: string;
16
+ duration?: number;
17
+ stagger?: number;
18
+ delay?: number;
19
+ }
20
+
21
+ export interface WordsInstance extends Instance {
22
+ /** Play the entrance (splits first if needed). */
23
+ reveal(delay?: number): void;
24
+ /** Change the text (e.g. language switch) and replay the entrance. */
25
+ replace(mutate: () => void): void;
26
+ }
27
+
28
+ const HIDDEN = { scaleY: 0, yPercent: 25 };
29
+
30
+ export function createWords(el: HTMLElement | null, options: WordsOptions = {}): WordsInstance {
31
+ if (!el) {
32
+ warnOnce('words: missing element');
33
+ return inert({ reveal() {}, replace(mutate: () => void) { mutate(); } });
34
+ }
35
+ const letters = options.by === 'chars';
36
+ const o = {
37
+ trigger: 'scroll' as const,
38
+ start: 'top 88%',
39
+ duration: 0.8,
40
+ stagger: letters ? 0.06 : 0.1,
41
+ delay: 0,
42
+ ...options,
43
+ };
44
+
45
+ registerMotion();
46
+ let split: SplitText | null = null;
47
+ let scroll: ScrollTrigger | null = null;
48
+ let destroyed = false;
49
+
50
+ const units = () => (split ? ((letters ? split.chars : split.words) as HTMLElement[]) : []);
51
+ // SplitText needs rendered text: a closed <dialog> or display:none parent loses word spaces.
52
+ const rendered = () => el.getClientRects().length > 0;
53
+
54
+ const prepare = () => {
55
+ split = SplitText.create(el, {
56
+ type: letters ? 'chars' : 'words',
57
+ charsClass: 'mk-w',
58
+ wordsClass: 'mk-w',
59
+ // Headings get an aria-label; inline elements stay readable as they are.
60
+ aria: /^H[1-6]$/.test(el.tagName) ? 'auto' : 'none',
61
+ smartWrap: letters,
62
+ });
63
+ const list = units();
64
+ if (list.length) gsap.set(list, { transformOrigin: '50% 100%', ...HIDDEN });
65
+ };
66
+
67
+ const release = () => {
68
+ if (!split) return;
69
+ gsap.killTweensOf(units());
70
+ split.revert();
71
+ split = null;
72
+ };
73
+
74
+ function reveal(delay = o.delay) {
75
+ if (destroyed) return;
76
+ scroll?.kill();
77
+ scroll = null;
78
+ if (!entrances()) return;
79
+ if (!split) prepare();
80
+ const list = units();
81
+ if (!list.length) return;
82
+ gsap.killTweensOf(list);
83
+ gsap.fromTo(list, HIDDEN, { scaleY: 1, yPercent: 0, duration: o.duration, ease: ELASTIC, stagger: o.stagger, delay });
84
+ }
85
+
86
+ function replace(mutate: () => void) {
87
+ release();
88
+ mutate();
89
+ if (entrances() && rendered()) {
90
+ prepare();
91
+ reveal(0);
92
+ }
93
+ }
94
+
95
+ if (entrances() && rendered()) prepare();
96
+ el.dataset.mkReady = '';
97
+
98
+ if (o.trigger === 'load') reveal(o.delay || 0.2);
99
+ else if (o.trigger === 'scroll' && entrances()) {
100
+ scroll = ScrollTrigger.create({ trigger: el, start: o.start, end: 'max', once: true, onEnter: () => reveal() });
101
+ }
102
+
103
+ return {
104
+ reveal,
105
+ replace,
106
+ destroy() {
107
+ if (destroyed) return;
108
+ destroyed = true;
109
+ scroll?.kill();
110
+ release();
111
+ delete el.dataset.mkReady;
112
+ },
113
+ };
114
+ }
@@ -0,0 +1,32 @@
1
+ import { ROLES, type Role } from './presets';
2
+
3
+ export type Palette = Record<Role, string>;
4
+ export interface ThemePalettes {
5
+ light: Palette;
6
+ dark: Palette;
7
+ }
8
+
9
+ function block(selectors: string[], scheme: 'light' | 'dark', palette: Palette, indent = '') {
10
+ const lines = [`${indent}${selectors.join(`,\n${indent}`)} {`, `${indent} color-scheme: ${scheme};`];
11
+ for (const role of ROLES) lines.push(`${indent} --md-sys-color-${role}: ${palette[role]};`);
12
+ lines.push(`${indent}}`);
13
+ return lines.join('\n');
14
+ }
15
+
16
+ /**
17
+ * A theme's stylesheet: light on `[data-mk-theme="<id>"]`, dark when the element
18
+ * or an ancestor has `data-mk-mode="dark"`, and dark under the system's dark
19
+ * scheme unless the page forces light. Same format as css/themes/*.css
20
+ * (scripts/import-themes.mjs keeps a copy of this function; a test checks they agree).
21
+ */
22
+ export function themeCss(id: string, { light, dark }: ThemePalettes): string {
23
+ const self = `[data-mk-theme="${id}"]`;
24
+ return [
25
+ block([self], 'light', light),
26
+ block([`${self}[data-mk-mode="dark"]`, `[data-mk-mode="dark"] ${self}`], 'dark', dark),
27
+ '@media (prefers-color-scheme: dark) {',
28
+ block([`:root:not([data-mk-mode="light"])${self}`, `:root:not([data-mk-mode="light"]) ${self}`], 'dark', dark, ' '),
29
+ '}',
30
+ '',
31
+ ].join('\n');
32
+ }
@@ -0,0 +1,72 @@
1
+ import {
2
+ Hct,
3
+ MaterialDynamicColors,
4
+ SchemeContent,
5
+ SchemeExpressive,
6
+ SchemeFidelity,
7
+ SchemeMonochrome,
8
+ SchemeNeutral,
9
+ SchemeTonalSpot,
10
+ SchemeVibrant,
11
+ argbFromHex,
12
+ hexFromArgb,
13
+ } from '@material/material-color-utilities';
14
+ import { ROLES, type Role } from './presets';
15
+ import { themeCss, type Palette, type ThemePalettes } from './css-format';
16
+ import { registerTheme, setTheme } from './theme';
17
+
18
+ /*
19
+ * Material 3 palettes from any colour, in the same 37 roles as Stepbro's themes.
20
+ * Needs the optional peer @material/material-color-utilities ^0.3.0
21
+ * (0.4.0 publishes ESM imports without extensions that Node cannot load).
22
+ */
23
+
24
+ export type ThemeVariant = 'tonal-spot' | 'vibrant' | 'expressive' | 'neutral' | 'content' | 'fidelity' | 'monochrome';
25
+
26
+ export interface GenerateOptions {
27
+ variant?: ThemeVariant;
28
+ /** -1 (reduced) … 0 (standard) … 1 (high). */
29
+ contrast?: number;
30
+ }
31
+
32
+ const SCHEMES = {
33
+ 'tonal-spot': SchemeTonalSpot,
34
+ vibrant: SchemeVibrant,
35
+ expressive: SchemeExpressive,
36
+ neutral: SchemeNeutral,
37
+ content: SchemeContent,
38
+ fidelity: SchemeFidelity,
39
+ monochrome: SchemeMonochrome,
40
+ } as const;
41
+
42
+ const camel = (role: string) => role.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
43
+ type Dynamic = { getArgb(scheme: unknown): number };
44
+
45
+ export function generateTheme(seed: string, { variant = 'tonal-spot', contrast = 0 }: GenerateOptions = {}): ThemePalettes {
46
+ if (!/^#?[0-9a-f]{6}$/i.test(seed)) throw new Error(`generateTheme: seed must be a #rrggbb colour (got "${seed}")`);
47
+ const Scheme = SCHEMES[variant];
48
+ if (!Scheme) throw new Error(`generateTheme: unknown variant "${variant}"`);
49
+ const source = Hct.fromInt(argbFromHex(seed.startsWith('#') ? seed : `#${seed}`));
50
+ const palette = (dark: boolean): Palette => {
51
+ const scheme = new Scheme(source, dark, contrast);
52
+ const colors = MaterialDynamicColors as unknown as Record<string, Dynamic>;
53
+ return Object.fromEntries(ROLES.map((role) => [role, hexFromArgb(colors[camel(role)].getArgb(scheme))])) as Record<Role, string>;
54
+ };
55
+ return { light: palette(false), dark: palette(true) };
56
+ }
57
+
58
+ export { themeCss };
59
+
60
+ /** Generate a theme from `seed`, inject it as a <style>, register and select it. */
61
+ export function applyTheme(seed: string, { id = 'custom', ...options }: GenerateOptions & { id?: string } = {}) {
62
+ const css = themeCss(id, generateTheme(seed, options));
63
+ let style = document.querySelector<HTMLStyleElement>(`style[data-mk-theme-style="${id}"]`);
64
+ if (!style) {
65
+ style = document.createElement('style');
66
+ style.dataset.mkThemeStyle = id;
67
+ document.head.append(style);
68
+ }
69
+ style.textContent = css;
70
+ registerTheme(id);
71
+ return setTheme(id);
72
+ }
@@ -0,0 +1,15 @@
1
+ export {
2
+ initTheme,
3
+ setTheme,
4
+ setMode,
5
+ getTheme,
6
+ registerTheme,
7
+ themeHeadScript,
8
+ MK_THEME_HEAD_SCRIPT,
9
+ THEME_EVENT,
10
+ type ThemeMode,
11
+ type ThemeState,
12
+ type InitThemeOptions,
13
+ } from './theme';
14
+ export { THEMES, ROLES, DEFAULT_THEME, type ThemeId, type ThemePreset, type Role } from './presets';
15
+ export { themeCss, type Palette, type ThemePalettes } from './css-format';
@@ -0,0 +1,79 @@
1
+ // Generated by scripts/import-themes.mjs from the Stepbro replication kit; do not edit.
2
+
3
+ /** The 37 Material 3 colour roles every theme defines (`--md-sys-color-<role>`). */
4
+ export const ROLES = [
5
+ 'background',
6
+ 'on-background',
7
+ 'surface',
8
+ 'surface-dim',
9
+ 'surface-bright',
10
+ 'surface-container-lowest',
11
+ 'surface-container-low',
12
+ 'surface-container',
13
+ 'surface-container-high',
14
+ 'surface-container-highest',
15
+ 'on-surface',
16
+ 'surface-variant',
17
+ 'on-surface-variant',
18
+ 'inverse-surface',
19
+ 'inverse-on-surface',
20
+ 'outline',
21
+ 'outline-variant',
22
+ 'shadow',
23
+ 'scrim',
24
+ 'surface-tint',
25
+ 'primary',
26
+ 'on-primary',
27
+ 'primary-container',
28
+ 'on-primary-container',
29
+ 'inverse-primary',
30
+ 'secondary',
31
+ 'on-secondary',
32
+ 'secondary-container',
33
+ 'on-secondary-container',
34
+ 'tertiary',
35
+ 'on-tertiary',
36
+ 'tertiary-container',
37
+ 'on-tertiary-container',
38
+ 'error',
39
+ 'on-error',
40
+ 'error-container',
41
+ 'on-error-container',
42
+ ] as const;
43
+ export type Role = (typeof ROLES)[number];
44
+
45
+ export interface ThemePreset {
46
+ readonly id: string;
47
+ readonly name: string;
48
+ /** Hidden from Stepbro's own picker (kept in its code). */
49
+ readonly hidden: boolean;
50
+ /** Modes with their own palette. OLED is dark-only and uses Black in light mode. */
51
+ readonly modes: readonly ('light' | 'dark')[];
52
+ /** Representative colour (dark surface-tint): swatch and generator seed. */
53
+ readonly seed: string;
54
+ }
55
+
56
+ export const THEMES = [
57
+ { id: 'black', name: 'Black', hidden: true, modes: ['light', 'dark'], seed: '#b4c7ed' },
58
+ { id: 'blue', name: 'Blue', hidden: false, modes: ['light', 'dark'], seed: '#b3c5ff' },
59
+ { id: 'purple', name: 'Purple', hidden: false, modes: ['light', 'dark'], seed: '#d2bcff' },
60
+ { id: 'neogreen', name: 'Neogreen', hidden: false, modes: ['light', 'dark'], seed: '#00e563' },
61
+ { id: 'green', name: 'Green', hidden: true, modes: ['light', 'dark'], seed: '#53e081' },
62
+ { id: 'red', name: 'Red', hidden: false, modes: ['light', 'dark'], seed: '#ffb4ac' },
63
+ { id: 'orange', name: 'Orange', hidden: true, modes: ['light', 'dark'], seed: '#ffc988' },
64
+ { id: 'pink', name: 'Pink', hidden: false, modes: ['light', 'dark'], seed: '#f7acff' },
65
+ { id: 'pastel-pink', name: 'Pastel Pink', hidden: false, modes: ['light', 'dark'], seed: '#ffcee8' },
66
+ { id: 'brown', name: 'Brown', hidden: false, modes: ['light', 'dark'], seed: '#ffb783' },
67
+ { id: 'super-blue', name: 'Super Blue', hidden: false, modes: ['light', 'dark'], seed: '#bcc2ff' },
68
+ { id: 'cold-blue', name: 'Cold Blue', hidden: true, modes: ['light', 'dark'], seed: '#87ceff' },
69
+ { id: 'modern-1', name: 'Modern', hidden: true, modes: ['light', 'dark'], seed: '#b3c5ff' },
70
+ { id: 'gray', name: 'Gray', hidden: false, modes: ['light', 'dark'], seed: '#c6c6c6' },
71
+ { id: 'oled', name: 'Oled', hidden: false, modes: ['dark'], seed: '#9fb8e4' },
72
+ { id: 'funny', name: 'Funny', hidden: true, modes: ['light', 'dark'], seed: '#22d3ee' },
73
+ { id: 'new', name: 'New', hidden: true, modes: ['light', 'dark'], seed: '#c4a68d' },
74
+ { id: 'new2', name: 'New 2', hidden: true, modes: ['light', 'dark'], seed: '#a3ffb7' },
75
+ { id: 'black-green', name: 'Black Green', hidden: true, modes: ['light', 'dark'], seed: '#a0d499' },
76
+ ] as const satisfies readonly ThemePreset[];
77
+
78
+ export type ThemeId = (typeof THEMES)[number]['id'];
79
+ export const DEFAULT_THEME: ThemeId = 'neogreen';
@@ -0,0 +1,119 @@
1
+ import { DEFAULT_THEME, THEMES, type ThemeId } from './presets';
2
+ import { warnOnce } from '../core/types';
3
+
4
+ /*
5
+ * Theme and mode on <html data-mk-theme="neogreen" data-mk-mode="system|light|dark">,
6
+ * remembered in localStorage (Stepbro stores the palette per app and follows the
7
+ * system for light/dark unless told otherwise). Nothing runs at import time.
8
+ */
9
+
10
+ export type ThemeMode = 'light' | 'dark' | 'system';
11
+ export interface ThemeState {
12
+ id: string;
13
+ mode: ThemeMode;
14
+ /** The mode actually shown (system resolved through prefers-color-scheme). */
15
+ resolved: 'light' | 'dark';
16
+ }
17
+
18
+ export interface InitThemeOptions {
19
+ /** Theme when nothing is stored or set on the root. Default: neogreen. */
20
+ default?: ThemeId | (string & {});
21
+ mode?: ThemeMode;
22
+ storageKey?: string;
23
+ root?: HTMLElement;
24
+ }
25
+
26
+ export const THEME_EVENT = 'mk:theme';
27
+ const MODES: ThemeMode[] = ['light', 'dark', 'system'];
28
+ const known = new Set<string>(THEMES.map((theme) => theme.id));
29
+ const settings = { storageKey: 'mk-theme', root: null as HTMLElement | null };
30
+ let watching = false;
31
+
32
+ /** Accept an extra theme id in setTheme (generated or project themes). */
33
+ export function registerTheme(id: string) {
34
+ known.add(id);
35
+ }
36
+
37
+ const rootOf = () => settings.root ?? document.documentElement;
38
+ const systemDark = () => typeof matchMedia === 'function' && matchMedia('(prefers-color-scheme: dark)').matches;
39
+
40
+ function read(): Partial<Pick<ThemeState, 'id' | 'mode'>> | null {
41
+ try {
42
+ const raw = localStorage.getItem(settings.storageKey);
43
+ return raw ? (JSON.parse(raw) as Partial<ThemeState>) : null;
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ function write(state: Pick<ThemeState, 'id' | 'mode'>) {
50
+ try {
51
+ localStorage.setItem(settings.storageKey, JSON.stringify({ id: state.id, mode: state.mode }));
52
+ } catch {
53
+ // Private mode or blocked storage: the choice simply isn't remembered.
54
+ }
55
+ }
56
+
57
+ export function getTheme(): ThemeState {
58
+ const root = rootOf();
59
+ const id = root.getAttribute('data-mk-theme') ?? DEFAULT_THEME;
60
+ const attr = root.getAttribute('data-mk-mode') as ThemeMode | null;
61
+ const mode = attr && MODES.includes(attr) ? attr : 'system';
62
+ return { id, mode, resolved: mode === 'system' ? (systemDark() ? 'dark' : 'light') : mode };
63
+ }
64
+
65
+ function apply(id: string, mode: ThemeMode, persist: boolean) {
66
+ const root = rootOf();
67
+ root.setAttribute('data-mk-theme', id);
68
+ root.setAttribute('data-mk-mode', mode);
69
+ if (persist) write({ id, mode });
70
+ const state = getTheme();
71
+ document.dispatchEvent(new CustomEvent<ThemeState>(THEME_EVENT, { detail: state }));
72
+ return state;
73
+ }
74
+
75
+ /** Apply the stored (or default) theme and mode. Call once on startup. */
76
+ export function initTheme(options: InitThemeOptions = {}): ThemeState {
77
+ if (options.storageKey) settings.storageKey = options.storageKey;
78
+ if (options.root) settings.root = options.root;
79
+ const root = rootOf();
80
+ const stored = read();
81
+ const id = stored?.id ?? root.getAttribute('data-mk-theme') ?? options.default ?? DEFAULT_THEME;
82
+ const storedMode = stored?.mode && MODES.includes(stored.mode) ? stored.mode : null;
83
+ const mode = storedMode ?? (root.getAttribute('data-mk-mode') as ThemeMode | null) ?? options.mode ?? 'system';
84
+ if (!watching && typeof matchMedia === 'function') {
85
+ watching = true;
86
+ // In system mode the resolved scheme follows the OS; tell listeners when it flips.
87
+ matchMedia('(prefers-color-scheme: dark)').addEventListener?.('change', () => {
88
+ if (getTheme().mode === 'system') document.dispatchEvent(new CustomEvent<ThemeState>(THEME_EVENT, { detail: getTheme() }));
89
+ });
90
+ }
91
+ return apply(id, MODES.includes(mode) ? mode : 'system', false);
92
+ }
93
+
94
+ export function setTheme(id: ThemeId | (string & {})): ThemeState {
95
+ if (!known.has(id)) {
96
+ warnOnce(`setTheme: unknown theme "${id}" (register it with registerTheme or applyTheme)`);
97
+ return getTheme();
98
+ }
99
+ return apply(id, getTheme().mode, true);
100
+ }
101
+
102
+ export function setMode(mode: ThemeMode): ThemeState {
103
+ if (!MODES.includes(mode)) {
104
+ warnOnce(`setMode: unknown mode "${mode}"`);
105
+ return getTheme();
106
+ }
107
+ return apply(getTheme().id, mode, true);
108
+ }
109
+
110
+ /** Inline script for <head>: applies the stored choice before the first paint (no flash). */
111
+ export function themeHeadScript({ storageKey = 'mk-theme', default: fallback = DEFAULT_THEME, mode = 'system' }: Omit<InitThemeOptions, 'root'> = {}) {
112
+ return (
113
+ `(function(){var r=document.documentElement,s=null;try{s=JSON.parse(localStorage.getItem(${JSON.stringify(storageKey)})||'null')}catch(e){}` +
114
+ `r.setAttribute('data-mk-theme',(s&&s.id)||r.getAttribute('data-mk-theme')||${JSON.stringify(fallback)});` +
115
+ `r.setAttribute('data-mk-mode',(s&&s.mode)||r.getAttribute('data-mk-mode')||${JSON.stringify(mode)})})();`
116
+ );
117
+ }
118
+
119
+ export const MK_THEME_HEAD_SCRIPT = themeHeadScript();