@moonarc/core 0.1.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 (232) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +39 -0
  3. package/package.json +228 -0
  4. package/src/components/Accordion.astro +110 -0
  5. package/src/components/Accordion.meta.ts +33 -0
  6. package/src/components/AnimatedBeam.astro +217 -0
  7. package/src/components/AnimatedBeam.meta.ts +39 -0
  8. package/src/components/AnimatedIcons.astro +193 -0
  9. package/src/components/AnimatedIcons.meta.ts +34 -0
  10. package/src/components/AnimatedList.astro +305 -0
  11. package/src/components/AnimatedList.meta.ts +36 -0
  12. package/src/components/AnimatedSelect.astro +227 -0
  13. package/src/components/AnimatedSelect.meta.ts +38 -0
  14. package/src/components/ArrowButton.astro +133 -0
  15. package/src/components/ArrowButton.meta.ts +32 -0
  16. package/src/components/Aurora.astro +188 -0
  17. package/src/components/Aurora.meta.ts +36 -0
  18. package/src/components/AvatarStack.astro +205 -0
  19. package/src/components/AvatarStack.meta.ts +37 -0
  20. package/src/components/BarGrow.astro +123 -0
  21. package/src/components/BarGrow.meta.ts +42 -0
  22. package/src/components/BorderBeam.astro +75 -0
  23. package/src/components/BorderBeam.meta.ts +33 -0
  24. package/src/components/BrowserFrame.astro +172 -0
  25. package/src/components/BrowserFrame.meta.ts +36 -0
  26. package/src/components/BubbleText.astro +90 -0
  27. package/src/components/BubbleText.meta.ts +38 -0
  28. package/src/components/CardStack.astro +79 -0
  29. package/src/components/CardStack.meta.ts +33 -0
  30. package/src/components/Carousel.astro +147 -0
  31. package/src/components/Carousel.meta.ts +35 -0
  32. package/src/components/CheckDraw.astro +159 -0
  33. package/src/components/CheckDraw.meta.ts +37 -0
  34. package/src/components/CircularText.astro +103 -0
  35. package/src/components/CircularText.meta.ts +38 -0
  36. package/src/components/CodeTabs.astro +213 -0
  37. package/src/components/CodeTabs.meta.ts +36 -0
  38. package/src/components/CommandPalette.astro +363 -0
  39. package/src/components/CommandPalette.meta.ts +40 -0
  40. package/src/components/Confetti.astro +143 -0
  41. package/src/components/Confetti.meta.ts +40 -0
  42. package/src/components/CopyButton.astro +189 -0
  43. package/src/components/CopyButton.meta.ts +41 -0
  44. package/src/components/CountUp.astro +134 -0
  45. package/src/components/CountUp.meta.ts +40 -0
  46. package/src/components/CountdownFlip.astro +244 -0
  47. package/src/components/CountdownFlip.meta.ts +39 -0
  48. package/src/components/CounterPreloader.astro +122 -0
  49. package/src/components/CounterPreloader.meta.ts +34 -0
  50. package/src/components/Cursor.astro +177 -0
  51. package/src/components/Cursor.meta.ts +37 -0
  52. package/src/components/CursorGrid.astro +113 -0
  53. package/src/components/CursorGrid.meta.ts +37 -0
  54. package/src/components/Curtain.astro +305 -0
  55. package/src/components/Curtain.meta.ts +38 -0
  56. package/src/components/Dialog.astro +156 -0
  57. package/src/components/Dialog.meta.ts +37 -0
  58. package/src/components/Dock.astro +193 -0
  59. package/src/components/Dock.meta.ts +35 -0
  60. package/src/components/DotPattern.astro +58 -0
  61. package/src/components/DotPattern.meta.ts +32 -0
  62. package/src/components/DottedMap.astro +153 -0
  63. package/src/components/DottedMap.meta.ts +38 -0
  64. package/src/components/Drawer.astro +196 -0
  65. package/src/components/Drawer.meta.ts +35 -0
  66. package/src/components/DynamicIsland.astro +122 -0
  67. package/src/components/DynamicIsland.meta.ts +35 -0
  68. package/src/components/FileTree.astro +207 -0
  69. package/src/components/FileTree.meta.ts +34 -0
  70. package/src/components/FilterGrid.astro +170 -0
  71. package/src/components/FilterGrid.meta.ts +38 -0
  72. package/src/components/FooterReveal.astro +54 -0
  73. package/src/components/FooterReveal.meta.ts +31 -0
  74. package/src/components/GlitchText.astro +145 -0
  75. package/src/components/GlitchText.meta.ts +39 -0
  76. package/src/components/Globe.astro +304 -0
  77. package/src/components/Globe.meta.ts +41 -0
  78. package/src/components/GlyphField.astro +363 -0
  79. package/src/components/GlyphField.meta.ts +53 -0
  80. package/src/components/GradientText.astro +63 -0
  81. package/src/components/GradientText.meta.ts +35 -0
  82. package/src/components/GridPattern.astro +55 -0
  83. package/src/components/GridPattern.meta.ts +32 -0
  84. package/src/components/HighlightMarker.astro +68 -0
  85. package/src/components/HighlightMarker.meta.ts +37 -0
  86. package/src/components/HorizontalScroll.astro +112 -0
  87. package/src/components/HorizontalScroll.meta.ts +34 -0
  88. package/src/components/HoverImageLinks.astro +219 -0
  89. package/src/components/HoverImageLinks.meta.ts +34 -0
  90. package/src/components/HoverVideo.astro +179 -0
  91. package/src/components/HoverVideo.meta.ts +37 -0
  92. package/src/components/ImageCompare.astro +212 -0
  93. package/src/components/ImageCompare.meta.ts +39 -0
  94. package/src/components/ImageTrail.astro +169 -0
  95. package/src/components/ImageTrail.meta.ts +36 -0
  96. package/src/components/Lightbox.astro +236 -0
  97. package/src/components/Lightbox.meta.ts +40 -0
  98. package/src/components/LinkPreview.astro +234 -0
  99. package/src/components/LinkPreview.meta.ts +39 -0
  100. package/src/components/LiquidGlass.astro +148 -0
  101. package/src/components/LiquidGlass.meta.ts +37 -0
  102. package/src/components/Magnetic.astro +92 -0
  103. package/src/components/Magnetic.meta.ts +31 -0
  104. package/src/components/Marquee.astro +165 -0
  105. package/src/components/Marquee.meta.ts +38 -0
  106. package/src/components/Meteors.astro +156 -0
  107. package/src/components/Meteors.meta.ts +36 -0
  108. package/src/components/Noise.astro +61 -0
  109. package/src/components/Noise.meta.ts +33 -0
  110. package/src/components/NumberFlow.astro +143 -0
  111. package/src/components/NumberFlow.meta.ts +42 -0
  112. package/src/components/Orb.astro +130 -0
  113. package/src/components/Orb.meta.ts +39 -0
  114. package/src/components/OrbitingCircles.astro +119 -0
  115. package/src/components/OrbitingCircles.meta.ts +36 -0
  116. package/src/components/OtpInput.astro +215 -0
  117. package/src/components/OtpInput.meta.ts +42 -0
  118. package/src/components/PageTransition.astro +121 -0
  119. package/src/components/PageTransition.meta.ts +33 -0
  120. package/src/components/Parallax.astro +69 -0
  121. package/src/components/Parallax.meta.ts +32 -0
  122. package/src/components/Particles.astro +210 -0
  123. package/src/components/Particles.meta.ts +37 -0
  124. package/src/components/PersistPlayer.astro +126 -0
  125. package/src/components/PersistPlayer.meta.ts +35 -0
  126. package/src/components/PhoneFrame.astro +161 -0
  127. package/src/components/PhoneFrame.meta.ts +35 -0
  128. package/src/components/PillNav.astro +170 -0
  129. package/src/components/PillNav.meta.ts +37 -0
  130. package/src/components/Popover.astro +95 -0
  131. package/src/components/Popover.meta.ts +34 -0
  132. package/src/components/PriceSwitch.astro +251 -0
  133. package/src/components/PriceSwitch.meta.ts +41 -0
  134. package/src/components/Probe.astro +84 -0
  135. package/src/components/Progress.astro +165 -0
  136. package/src/components/Progress.meta.ts +40 -0
  137. package/src/components/ProgressiveBlur.astro +95 -0
  138. package/src/components/ProgressiveBlur.meta.ts +36 -0
  139. package/src/components/PromptInput.astro +273 -0
  140. package/src/components/PromptInput.meta.ts +46 -0
  141. package/src/components/ReadingToc.astro +153 -0
  142. package/src/components/ReadingToc.meta.ts +34 -0
  143. package/src/components/RetroGrid.astro +84 -0
  144. package/src/components/RetroGrid.meta.ts +36 -0
  145. package/src/components/Reveal.astro +292 -0
  146. package/src/components/Reveal.meta.ts +48 -0
  147. package/src/components/Ripple.astro +94 -0
  148. package/src/components/Ripple.meta.ts +34 -0
  149. package/src/components/RotatingText.astro +170 -0
  150. package/src/components/RotatingText.meta.ts +35 -0
  151. package/src/components/ScrollExpand.astro +90 -0
  152. package/src/components/ScrollExpand.meta.ts +35 -0
  153. package/src/components/ScrollProgress.astro +73 -0
  154. package/src/components/ScrollProgress.meta.ts +32 -0
  155. package/src/components/ScrollScrub.astro +96 -0
  156. package/src/components/ScrollScrub.meta.ts +37 -0
  157. package/src/components/ShimmerButton.astro +109 -0
  158. package/src/components/ShimmerButton.meta.ts +37 -0
  159. package/src/components/ShinyText.astro +72 -0
  160. package/src/components/ShinyText.meta.ts +35 -0
  161. package/src/components/Skeleton.astro +76 -0
  162. package/src/components/Skeleton.meta.ts +32 -0
  163. package/src/components/Sparkline.astro +168 -0
  164. package/src/components/Sparkline.meta.ts +43 -0
  165. package/src/components/Spinner.astro +79 -0
  166. package/src/components/Spinner.meta.ts +33 -0
  167. package/src/components/SplitFlap.astro +157 -0
  168. package/src/components/SplitFlap.meta.ts +39 -0
  169. package/src/components/SplitText.astro +208 -0
  170. package/src/components/SplitText.meta.ts +50 -0
  171. package/src/components/Spotlight.astro +92 -0
  172. package/src/components/Spotlight.meta.ts +34 -0
  173. package/src/components/StaggerMenu.astro +301 -0
  174. package/src/components/StaggerMenu.meta.ts +39 -0
  175. package/src/components/StatefulButton.astro +148 -0
  176. package/src/components/StatefulButton.meta.ts +35 -0
  177. package/src/components/StickyBanner.astro +168 -0
  178. package/src/components/StickyBanner.meta.ts +45 -0
  179. package/src/components/StickyReveal.astro +256 -0
  180. package/src/components/StickyReveal.meta.ts +33 -0
  181. package/src/components/StrokeDraw.astro +110 -0
  182. package/src/components/StrokeDraw.meta.ts +43 -0
  183. package/src/components/Switch.astro +165 -0
  184. package/src/components/Switch.meta.ts +37 -0
  185. package/src/components/Tabs.astro +194 -0
  186. package/src/components/Tabs.meta.ts +47 -0
  187. package/src/components/TextRoll.astro +78 -0
  188. package/src/components/TextRoll.meta.ts +36 -0
  189. package/src/components/TextScramble.astro +107 -0
  190. package/src/components/TextScramble.meta.ts +35 -0
  191. package/src/components/ThemeScript.astro +17 -0
  192. package/src/components/ThemeToggle.astro +182 -0
  193. package/src/components/ThemeToggle.meta.ts +34 -0
  194. package/src/components/Tilt.astro +97 -0
  195. package/src/components/Tilt.meta.ts +34 -0
  196. package/src/components/Toast.astro +184 -0
  197. package/src/components/Toast.meta.ts +33 -0
  198. package/src/components/TracingBeam.astro +113 -0
  199. package/src/components/TracingBeam.meta.ts +34 -0
  200. package/src/components/Typewriter.astro +124 -0
  201. package/src/components/Typewriter.meta.ts +39 -0
  202. package/src/components/UnderlineHover.astro +61 -0
  203. package/src/components/UnderlineHover.meta.ts +34 -0
  204. package/src/components/VanishInput.astro +240 -0
  205. package/src/components/VanishInput.meta.ts +41 -0
  206. package/src/components/VideoDialog.astro +230 -0
  207. package/src/components/VideoDialog.meta.ts +40 -0
  208. package/src/components/WeightWave.astro +70 -0
  209. package/src/components/WeightWave.meta.ts +36 -0
  210. package/src/components/internal/DialogMorph.astro +97 -0
  211. package/src/components/internal/PromptInputSubmitOnEnter.astro +32 -0
  212. package/src/index.d.ts +43 -0
  213. package/src/index.js +60 -0
  214. package/src/lib/canvas.ts +124 -0
  215. package/src/lib/confetti.ts +127 -0
  216. package/src/lib/contrast.ts +116 -0
  217. package/src/lib/form.ts +231 -0
  218. package/src/lib/geo.ts +77 -0
  219. package/src/lib/gsap.ts +58 -0
  220. package/src/lib/land.ts +11 -0
  221. package/src/lib/lenis.ts +112 -0
  222. package/src/lib/meta.ts +79 -0
  223. package/src/lib/morph.ts +74 -0
  224. package/src/lib/reduced-motion-plugin.js +299 -0
  225. package/src/lib/runtime.ts +215 -0
  226. package/src/lib/support.ts +102 -0
  227. package/src/lib/theme.ts +18 -0
  228. package/src/lib/toast.ts +17 -0
  229. package/src/styles/base.css +218 -0
  230. package/src/styles/motion.css +43 -0
  231. package/src/styles/theme.css +397 -0
  232. package/src/styles/theme.tailwind.css +75 -0
@@ -0,0 +1,97 @@
1
+ ---
2
+ /**
3
+ * The script half of `<Dialog morph>`, rendered only when the prop is set,
4
+ * so a plain Dialog stays at 0 B (measured on its own fixture: `Dialog.morph`).
5
+ * It intercepts the invoker click on every trigger that points at the
6
+ * dialog, the close form, Escape and the backdrop click, and runs each
7
+ * through lib/morph so the box grows from the trigger and returns to it.
8
+ * The styles below shape the shared-name group; the root cross-fade is in
9
+ * base.css under html[data-ma-vt].
10
+ */
11
+ interface Props {
12
+ /** The dialog's id. */
13
+ for: string;
14
+ }
15
+ const { for: id } = Astro.props;
16
+ ---
17
+
18
+ <span class="ma-dialog-morph" data-ma-dialog-morph={id} hidden></span>
19
+
20
+ <style is:global>
21
+ @layer components {
22
+ /* the morphing box: both snapshots fill the group without distortion, so a thumbnail becomes an image, not a smear */
23
+ :where(:root)[data-ma-vt='morph']::view-transition-old(ma-morph),
24
+ :where(:root)[data-ma-vt='morph']::view-transition-new(ma-morph) {
25
+ block-size: 100%;
26
+ inline-size: 100%;
27
+ object-fit: cover;
28
+ overflow: clip;
29
+ animation-duration: var(--ma-duration);
30
+ animation-timing-function: var(--ma-ease-out);
31
+ }
32
+ :where(:root)[data-ma-vt='morph']::view-transition-group(ma-morph) {
33
+ animation-duration: var(--ma-duration);
34
+ animation-timing-function: var(--ma-ease);
35
+ z-index: 2;
36
+ }
37
+ /* while the library's transition runs, the dialog's own entrance and exit step aside: the transition is the motion.
38
+ The attribute on <html> is what outranks the Dialog's zero-specificity rules (the same :where() here lost to them on
39
+ order), and it scopes the step-aside to the transition, so without one (no support, reduced motion) the dialog
40
+ opens and closes as usual. */
41
+ :where(:root)[data-ma-vt] :where(.ma-dialog[data-morph]) {
42
+ transition: none;
43
+ scale: 1;
44
+ translate: 0 0;
45
+ }
46
+ :where(:root)[data-ma-vt] :where(.ma-dialog[data-morph])::backdrop {
47
+ transition: none;
48
+ }
49
+ @media (prefers-reduced-motion: reduce) {
50
+ :where(:root)[data-ma-vt='morph']::view-transition-group(ma-morph) {
51
+ animation-duration: var(--ma-duration-fast);
52
+ animation-timing-function: linear;
53
+ }
54
+ }
55
+ }
56
+ </style>
57
+
58
+ <script>
59
+ import { onMount } from '../../lib/runtime';
60
+ import { morphClose, morphOpen } from '../../lib/morph';
61
+
62
+ onMount<HTMLElement>('[data-ma-dialog-morph]', (mark, { signal }) => {
63
+ const dialog = document.getElementById(mark.dataset.maDialogMorph!) as HTMLDialogElement | null;
64
+ if (!dialog) return;
65
+ // every trigger on the page that points at this dialog: the click is ours, the invoker's default is cancelled (and the polyfill honours defaultPrevented)
66
+ for (const t of document.querySelectorAll<HTMLElement>(`[commandfor="${dialog.id}"][command="show-modal"]`)) {
67
+ t.addEventListener(
68
+ 'click',
69
+ (e) => {
70
+ e.preventDefault();
71
+ morphOpen(dialog, t);
72
+ },
73
+ { signal },
74
+ );
75
+ }
76
+ // Escape and the backdrop (closedby) arrive as a cancelable `cancel`; the close button is a form with method="dialog"
77
+ dialog.addEventListener(
78
+ 'cancel',
79
+ (e) => {
80
+ e.preventDefault();
81
+ morphClose(dialog);
82
+ },
83
+ { signal },
84
+ );
85
+ dialog.addEventListener(
86
+ 'submit',
87
+ (e) => {
88
+ const f = e.target as HTMLFormElement;
89
+ if (f.method === 'dialog') {
90
+ e.preventDefault();
91
+ morphClose(dialog);
92
+ }
93
+ },
94
+ { signal },
95
+ );
96
+ });
97
+ </script>
@@ -0,0 +1,32 @@
1
+ ---
2
+ /**
3
+ * The script half of `<PromptInput submitOnEnter>`, rendered only when the
4
+ * prop is set, so a plain PromptInput stays at 0 B (measured on its own
5
+ * fixture: `PromptInput.submitOnEnter`). Enter submits through
6
+ * form.requestSubmit(), so required and the host's submit handler still
7
+ * run; Shift+Enter is a newline; nothing happens while an IME is composing
8
+ * (isComposing, or the legacy keyCode 229), so a Japanese or Chinese
9
+ * reader's Enter confirms the candidate instead of sending the message.
10
+ */
11
+ ---
12
+
13
+ <span class="ma-prompt-enter" data-ma-prompt-enter hidden></span>
14
+
15
+ <script>
16
+ import { onMount } from '../../lib/runtime';
17
+
18
+ onMount<HTMLElement>('[data-ma-prompt-enter]', (mark, { signal }) => {
19
+ const form = mark.closest('form');
20
+ const area = form?.querySelector('textarea');
21
+ if (!form || !area) return;
22
+ area.addEventListener(
23
+ 'keydown',
24
+ (e) => {
25
+ if (e.key !== 'Enter' || e.shiftKey || e.isComposing || e.keyCode === 229) return;
26
+ e.preventDefault();
27
+ if (!form.hasAttribute('data-pending')) form.requestSubmit();
28
+ },
29
+ { signal },
30
+ );
31
+ });
32
+ </script>
package/src/index.d.ts ADDED
@@ -0,0 +1,43 @@
1
+ import type { AstroIntegration } from 'astro';
2
+
3
+ export interface MoonarcOptions {
4
+ /**
5
+ * Inject `@moonarc/core/styles/base.css` (theme-aware tokens) into every
6
+ * page. Set to `false` and import the stylesheet yourself to control order.
7
+ * @default true
8
+ */
9
+ injectStyles?: boolean;
10
+ /**
11
+ * Inject a tiny inline script that sets `data-ma-js` on `<html>` before paint,
12
+ * and again after every `<ClientRouter />` swap. Entrance effects hide content
13
+ * only behind that attribute, so pages without JS stay readable. Disable it
14
+ * and inline `gateScript` yourself if you manage head scripts.
15
+ * @default true
16
+ */
17
+ jsGate?: boolean;
18
+ /**
19
+ * Add `@moonarc/core` to `vite.ssr.noExternal`. Not needed on Astro 5–7;
20
+ * kept as an escape hatch for setups that externalise `.astro` files.
21
+ * @default false
22
+ */
23
+ noExternal?: boolean;
24
+ /**
25
+ * Mirror every `@media (prefers-reduced-motion: reduce)` rule — the
26
+ * components', Tailwind's `motion-reduce:` utilities, your own — to
27
+ * `html[data-ma-motion='reduce']` at build time, so a page can offer a
28
+ * reduced-motion switch that previews exactly what the media query does.
29
+ * The runtime's `prefersReducedMotion()` reads the attribute too. Costs a
30
+ * few percent of CSS, no JavaScript.
31
+ * @default false
32
+ */
33
+ motionAttribute?: boolean;
34
+ }
35
+
36
+ /**
37
+ * The text of the gate `jsGate` injects: sets `data-ma-js` on `<html>` now and
38
+ * on every `astro:after-swap` (the router replaces `<html>`'s attributes and
39
+ * does not re-run an inline script it has already run).
40
+ */
41
+ export declare const gateScript: string;
42
+
43
+ export default function moonarc(options?: MoonarcOptions): AstroIntegration;
package/src/index.js ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * @moonarc/core — Astro integration.
3
+ *
4
+ * Plain JS on purpose: this file is evaluated while Astro loads the consumer's
5
+ * config, before any Vite transform exists. Types live in ./index.d.ts.
6
+ */
7
+
8
+ import { reducedMotionMirror } from './lib/reduced-motion-plugin.js';
9
+
10
+ const PKG = '@moonarc/core';
11
+
12
+ /**
13
+ * The head gate `jsGate` injects: `data-ma-js` on <html> now and after every
14
+ * ClientRouter swap (`dataset.maJs` is that attribute). f() sets it and returns
15
+ * 1, which is also the capture flag: the router dispatches astro:after-swap on
16
+ * document without bubbling, so a window listener hears it only while
17
+ * capturing, and then ahead of every listener on document. Exported so a
18
+ * layout that sets `jsGate: false` can inline the same text.
19
+ */
20
+ export const gateScript = `(f=>addEventListener('astro:after-swap',f,f()))(()=>document.documentElement.dataset.maJs=1)`;
21
+
22
+ /**
23
+ * @param {import('./index.js').MoonarcOptions} [options]
24
+ * @returns {import('astro').AstroIntegration}
25
+ */
26
+ export default function moonarc(options = {}) {
27
+ const { injectStyles = true, jsGate = true, noExternal = false, motionAttribute = false } = options;
28
+
29
+ return {
30
+ name: 'moonarc',
31
+ hooks: {
32
+ 'astro:config:setup': ({ injectScript, updateConfig }) => {
33
+ // Theme + motion tokens on every page, resolved and bundled by Vite.
34
+ if (injectStyles) {
35
+ injectScript('page-ssr', `import '${PKG}/styles/base.css';`);
36
+ }
37
+ // Entrance effects (Reveal, …) hide content only under html[data-ma-js],
38
+ // so server HTML stays readable when JS never runs. Set before paint,
39
+ // and again after every ClientRouter swap: the router replaces <html>'s
40
+ // attributes with the next page's and never re-runs this script (same
41
+ // text), so a session that landed on a page without a Moonarc script,
42
+ // and so without the runtime's carry-over, lost the gate for good.
43
+ if (jsGate) {
44
+ injectScript('head-inline', gateScript);
45
+ }
46
+ // Phase 0 consumer checks established that Astro 5–7 compile .astro
47
+ // files from node_modules without this. It stays as an escape hatch
48
+ // for exotic setups (see docs/integration).
49
+ if (noExternal) {
50
+ updateConfig({ vite: { ssr: { noExternal: [PKG] } } });
51
+ }
52
+ // Mirror every reduced-motion rule to html[data-ma-motion='reduce'] at
53
+ // build, so a page can offer a switch that previews the media query.
54
+ if (motionAttribute) {
55
+ updateConfig({ vite: { plugins: [reducedMotionMirror()] } });
56
+ }
57
+ },
58
+ },
59
+ };
60
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * @moonarc/core/canvas — the shared half of every canvas component
3
+ * (Particles, Globe, Confetti, and yours). A canvas is the one place the
4
+ * library cannot let CSS do the moving, so the rules that keep a CSS
5
+ * component cheap are written here once:
6
+ *
7
+ * fit(canvas, host, signal, onSize) size from a ResizeObserver, DPR capped at 2,
8
+ * the context pre-scaled — never the resize event
9
+ * loop(frame, signal) a requestAnimationFrame loop with wake()/sleep():
10
+ * it never runs in a hidden tab
11
+ * visible(host, signal, cb) IntersectionObserver → cb(true|false); pair it with
12
+ * loop so an off-screen canvas costs nothing
13
+ * onTheme(signal, cb) data-theme / class / data-ma-motion on <html>, the OS
14
+ * scheme or the OS reduced-motion setting changed — re-read
15
+ * colours, re-decide the loop
16
+ * (ThemeScript's contract; the page's reduced-motion switch)
17
+ * colors(el) five theme-resolved colours: the element's `color`
18
+ * and four border colours, which a component's CSS
19
+ * maps its tokens onto (rgb strings a 2D context takes)
20
+ * num(el, name, fallback) a unitless custom property as a number — physics
21
+ * constants live in CSS, not in the script
22
+ * ms(el, name, fallback) a duration token in milliseconds (`450ms` or `1.39s`)
23
+ *
24
+ * Everything is released through the AbortSignal the runtime hands a mount.
25
+ * Zero dependencies; ~1 kB.
26
+ */
27
+
28
+ export type Frame = (t: number, dt: number) => void;
29
+
30
+ export interface Loop {
31
+ wake(): void;
32
+ sleep(): void;
33
+ /** Draw one frame now without starting the loop (reduced motion, theme change). */
34
+ once(): void;
35
+ }
36
+
37
+ /** Cap the device pixel ratio: 3× canvases cost nine times the paint of 1× for no visible gain in a dot field. */
38
+ export const dpr = (): number => Math.min(2, window.devicePixelRatio || 1);
39
+
40
+ export function fit(canvas: HTMLCanvasElement, host: Element, signal: AbortSignal, onSize: (w: number, h: number, ctx: CanvasRenderingContext2D) => void): void {
41
+ const ctx = canvas.getContext('2d')!;
42
+ const apply = () => {
43
+ const r = host.getBoundingClientRect();
44
+ const w = Math.max(1, Math.round(r.width));
45
+ const h = Math.max(1, Math.round(r.height));
46
+ const k = dpr();
47
+ canvas.width = w * k;
48
+ canvas.height = h * k;
49
+ ctx.setTransform(k, 0, 0, k, 0, 0);
50
+ onSize(w, h, ctx);
51
+ };
52
+ const ro = new ResizeObserver(apply);
53
+ ro.observe(host);
54
+ signal.addEventListener('abort', () => ro.disconnect());
55
+ apply();
56
+ }
57
+
58
+ export function loop(frame: Frame, signal: AbortSignal): Loop {
59
+ let raf = 0;
60
+ let last = 0;
61
+ let want = false;
62
+ const tick = (t: number) => {
63
+ raf = 0;
64
+ if (!want || document.hidden) return;
65
+ frame(t, last ? Math.min(t - last, 100) : 0);
66
+ last = t;
67
+ raf = requestAnimationFrame(tick);
68
+ };
69
+ const l: Loop = {
70
+ wake() {
71
+ want = true;
72
+ if (!raf && !document.hidden) {
73
+ last = 0;
74
+ raf = requestAnimationFrame(tick);
75
+ }
76
+ },
77
+ sleep() {
78
+ want = false;
79
+ cancelAnimationFrame(raf);
80
+ raf = 0;
81
+ },
82
+ once() {
83
+ frame(performance.now(), 0);
84
+ },
85
+ };
86
+ // Hiding cancels nothing: a hidden tab runs no frames, and the one still queued either fires on return or, where
87
+ // an engine runs it while hidden, finds document.hidden and clears raf. Cancelling it here left raf holding a dead
88
+ // id, and wake() on return then saw a frame pending and never asked for one: the canvas froze after a tab switch.
89
+ document.addEventListener('visibilitychange', () => want && l.wake(), { signal });
90
+ signal.addEventListener('abort', l.sleep);
91
+ return l;
92
+ }
93
+
94
+ export function visible(host: Element, signal: AbortSignal, cb: (on: boolean) => void): void {
95
+ const io = new IntersectionObserver(([e]) => cb(!!e?.isIntersecting));
96
+ io.observe(host);
97
+ signal.addEventListener('abort', () => io.disconnect());
98
+ }
99
+
100
+ export function onTheme(signal: AbortSignal, cb: () => void): void {
101
+ const mo = new MutationObserver(cb);
102
+ mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'class', 'data-ma-motion'] });
103
+ matchMedia('(prefers-color-scheme: dark)').addEventListener('change', cb, { signal });
104
+ // reduced motion turned on or off in the OS while the page is open: a running loop stops (or starts) without a reload
105
+ matchMedia('(prefers-reduced-motion: reduce)').addEventListener('change', cb, { signal });
106
+ signal.addEventListener('abort', () => mo.disconnect());
107
+ }
108
+
109
+ /** `color`, then border top / right / bottom / left — resolved by the engine to rgb, so tokens, color-mix() and oklch() all arrive as something the context can paint. */
110
+ export function colors(el: Element): [string, string, string, string, string] {
111
+ const s = getComputedStyle(el);
112
+ return [s.color, s.borderTopColor, s.borderRightColor, s.borderBottomColor, s.borderLeftColor];
113
+ }
114
+
115
+ export function num(el: Element, name: string, fallback: number): number {
116
+ const v = parseFloat(getComputedStyle(el).getPropertyValue(name));
117
+ return Number.isFinite(v) ? v : fallback;
118
+ }
119
+
120
+ export function ms(el: Element, name: string, fallback: number): number {
121
+ const v = getComputedStyle(el).getPropertyValue(name).trim();
122
+ const n = parseFloat(v);
123
+ return Number.isFinite(n) ? (v.endsWith('ms') ? n : n * 1000) : fallback;
124
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * confetti(options?) — a burst of paper from a point, on one fixed canvas that
3
+ * exists only while pieces are in the air. The `<Confetti />` component wires
4
+ * this to a load, a click or an `ma:confetti` event on document; call it
5
+ * directly from your own handler.
6
+ *
7
+ * Physics constants are custom properties on :root (the component's stylesheet
8
+ * declares them per preset; without it the defaults below apply):
9
+ * --ma-confetti-gravity px/frame² at 60 fps, pulls the pieces down
10
+ * --ma-confetti-drag velocity kept per frame, 0–1
11
+ * --ma-confetti-velocity launch speed in px/frame
12
+ * --ma-confetti-spread launch cone in degrees
13
+ * --ma-confetti-flutter how fast a piece rocks as it falls
14
+ * Bursts share the canvas; the last piece off screen removes it. Nothing fires
15
+ * under prefers-reduced-motion (or the page's data-ma-motion switch).
16
+ */
17
+ import { loop, num, type Loop } from './canvas';
18
+ import { prefersReducedMotion } from './runtime';
19
+
20
+ export interface ConfettiOptions {
21
+ /** Pieces per burst. */
22
+ count?: number;
23
+ /**
24
+ * Literal colours (hex, rgb(), oklch()); the burst cycles through them. var() is not resolved by the canvas — for
25
+ * tokens use CSS on .ma-confetti (the component reads its computed colours), or pass getComputedStyle(el).color.
26
+ */
27
+ colors?: string[];
28
+ /** Launch point in viewport px; default the centre. */
29
+ origin?: { x: number; y: number };
30
+ /** Launch direction in degrees, 0 = up, 90 = right. */
31
+ angle?: number;
32
+ /** Launch cone in degrees; overrides the token. */
33
+ spread?: number;
34
+ /** Launch speed in px/frame; overrides the token. */
35
+ velocity?: number;
36
+ }
37
+
38
+ interface Piece {
39
+ x: number;
40
+ y: number;
41
+ vx: number;
42
+ vy: number;
43
+ w: number;
44
+ h: number;
45
+ a: number;
46
+ va: number;
47
+ f: number;
48
+ c: string;
49
+ }
50
+
51
+ let canvas: HTMLCanvasElement | null = null;
52
+ let ctx: CanvasRenderingContext2D;
53
+ let pieces: Piece[] = [];
54
+ let run: Loop | null = null;
55
+ let abort: AbortController | null = null;
56
+
57
+ function teardown(): void {
58
+ abort?.abort();
59
+ canvas?.remove();
60
+ canvas = null;
61
+ run = null;
62
+ pieces = [];
63
+ }
64
+
65
+ function frame(): void {
66
+ if (!canvas) return;
67
+ const w = innerWidth;
68
+ const h = innerHeight;
69
+ const k = Math.min(2, devicePixelRatio || 1);
70
+ if (canvas.width !== w * k || canvas.height !== h * k) {
71
+ canvas.width = w * k;
72
+ canvas.height = h * k;
73
+ }
74
+ ctx.setTransform(k, 0, 0, k, 0, 0);
75
+ ctx.clearRect(0, 0, w, h);
76
+ const root = document.documentElement;
77
+ const g = num(root, '--ma-confetti-gravity', 0.22);
78
+ const drag = num(root, '--ma-confetti-drag', 0.985);
79
+ const fl = num(root, '--ma-confetti-flutter', 0.12);
80
+ pieces = pieces.filter((p) => {
81
+ p.vy += g;
82
+ p.vx *= drag;
83
+ p.vy *= drag;
84
+ p.x += p.vx;
85
+ p.y += p.vy;
86
+ p.a += p.va;
87
+ p.f += fl;
88
+ ctx.save();
89
+ ctx.translate(p.x, p.y);
90
+ ctx.rotate(p.a);
91
+ ctx.scale(1, Math.cos(p.f));
92
+ ctx.fillStyle = p.c;
93
+ ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
94
+ ctx.restore();
95
+ return p.y < h + 40 && p.x > -40 && p.x < w + 40;
96
+ });
97
+ if (pieces.length === 0) teardown();
98
+ }
99
+
100
+ export function confetti(options: ConfettiOptions = {}): void {
101
+ if (prefersReducedMotion()) return;
102
+ const root = document.documentElement;
103
+ const count = options.count ?? 120;
104
+ const colors = options.colors?.length ? options.colors : ['#f5a524', '#e4572e', '#3a9a5b', '#4c7dff', '#c94ad3'];
105
+ const ox = options.origin?.x ?? innerWidth / 2;
106
+ const oy = options.origin?.y ?? innerHeight / 2;
107
+ const spread = ((options.spread ?? num(root, '--ma-confetti-spread', 70)) * Math.PI) / 180;
108
+ const v = options.velocity ?? num(root, '--ma-confetti-velocity', 14);
109
+ const dir = ((options.angle ?? 0) * Math.PI) / 180;
110
+ for (let i = 0; i < count; i++) {
111
+ const a = dir + (Math.random() - 0.5) * spread;
112
+ const s = v * (0.5 + Math.random());
113
+ pieces.push({ x: ox, y: oy, vx: Math.sin(a) * s, vy: -Math.cos(a) * s, w: 6 + Math.random() * 6, h: 4 + Math.random() * 8, a: Math.random() * Math.PI, va: (Math.random() - 0.5) * 0.3, f: Math.random() * Math.PI, c: colors[i % colors.length]! });
114
+ }
115
+ if (!canvas) {
116
+ canvas = document.createElement('canvas');
117
+ canvas.setAttribute('aria-hidden', 'true');
118
+ canvas.style.cssText = 'position:fixed;inset:0;width:100%;height:100%;pointer-events:none;z-index:2147483647';
119
+ ctx = canvas.getContext('2d')!;
120
+ document.body.append(canvas);
121
+ abort = new AbortController();
122
+ run = loop(frame, abort.signal);
123
+ // a navigation mid-burst takes the canvas with it; a new page starts clean
124
+ document.addEventListener('astro:before-swap', teardown, { signal: abort.signal });
125
+ }
126
+ run!.wake();
127
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Build-time contrast for highlighted code. `readableCode()` is a Shiki transformer (pass it to Astro's <Code
3
+ * transformers>, or to markdown.shikiConfig.transformers) that moves every token colour's OKLCH lightness — hue kept,
4
+ * chroma kept unless sRGB cannot hold it — just far enough to read at `ratio` against the ground it is shown on. A colour
5
+ * that already passes is untouched, so a theme keeps its look and only its faint greys, reds and oranges get firmer.
6
+ * Any theme becomes AA; nothing runs in the browser.
7
+ *
8
+ * Grounds: `light` and `dark` (the `--shiki-light` / `--shiki-dark` variables of a dual theme) default to a light and a
9
+ * dark panel a little heavier than the library's own, so a block stays AA on a tinted surface too. A single theme's
10
+ * `color` is read against the background the theme paints on its <pre>.
11
+ */
12
+
13
+ type Rgb = [number, number, number];
14
+ interface HastNode {
15
+ type: string;
16
+ tagName?: string;
17
+ properties?: Record<string, unknown>;
18
+ children?: HastNode[];
19
+ }
20
+
21
+ export interface ReadableCodeOptions {
22
+ /** Minimum WCAG 2 ratio. */
23
+ ratio?: number;
24
+ /** Ground per theme key (`light`, `dark`, `color`, or a custom key of `themes`), as #rrggbb. */
25
+ grounds?: Record<string, string>;
26
+ }
27
+
28
+ const GROUNDS: Record<string, string> = { light: '#eceff1', dark: '#2a2f33' };
29
+
30
+ const toLin = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
31
+ const fromLin = (c: number) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);
32
+ const lum = ([r, g, b]: Rgb) => 0.2126 * toLin(r) + 0.7152 * toLin(g) + 0.0722 * toLin(b);
33
+ const ratioOf = (a: Rgb, b: Rgb) => { const x = lum(a), y = lum(b); return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05); };
34
+
35
+ function parseHex(hex: string): { rgb: Rgb; alpha: string } | null {
36
+ let h = hex.slice(1);
37
+ if (h.length === 3 || h.length === 4) h = [...h].map((c) => c + c).join('');
38
+ if (h.length !== 6 && h.length !== 8) return null;
39
+ const n = (i: number) => parseInt(h.slice(i, i + 2), 16) / 255;
40
+ return { rgb: [n(0), n(2), n(4)], alpha: h.slice(6) };
41
+ }
42
+ const toHex = (rgb: Rgb) => '#' + rgb.map((c) => Math.round(Math.min(1, Math.max(0, c)) * 255).toString(16).padStart(2, '0')).join('');
43
+
44
+ function toOklch([r, g, b]: Rgb): Rgb {
45
+ const [lr, lg, lb] = [toLin(r), toLin(g), toLin(b)];
46
+ const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
47
+ const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
48
+ const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
49
+ const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
50
+ const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
51
+ const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
52
+ return [L, Math.hypot(A, B), Math.atan2(B, A)];
53
+ }
54
+ function fromOklch(L: number, C: number, h: number): Rgb | null {
55
+ const A = C * Math.cos(h), B = C * Math.sin(h);
56
+ const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3;
57
+ const m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3;
58
+ const s = (L - 0.0894841775 * A - 1.291485548 * B) ** 3;
59
+ const lin = [4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s];
60
+ if (lin.some((c) => c < -1e-4 || c > 1 + 1e-4)) return null;
61
+ return lin.map((c) => fromLin(Math.min(1, Math.max(0, c)))) as Rgb;
62
+ }
63
+ /** The colour at lightness L with this hue, and as much of chroma C as sRGB can hold. */
64
+ function inGamut(L: number, C: number, h: number): Rgb {
65
+ const direct = fromOklch(L, C, h);
66
+ if (direct) return direct;
67
+ let lo = 0, hi = C;
68
+ for (let i = 0; i < 16; i++) { const mid = (lo + hi) / 2; if (fromOklch(L, mid, h)) lo = mid; else hi = mid; }
69
+ return fromOklch(L, lo, h) ?? [L, L, L];
70
+ }
71
+
72
+ /** `fg` moved in OKLCH lightness until it reads at `ratio` on `ground`; unchanged when it already does. */
73
+ export function readable(fg: string, ground: string, ratio = 4.5): string {
74
+ const f = parseHex(fg), g = parseHex(ground);
75
+ if (!f || !g || ratioOf(f.rgb, g.rgb) >= ratio) return fg;
76
+ const [L, C, h] = toOklch(f.rgb);
77
+ const darken = lum(g.rgb) > 0.18;
78
+ // lightness is monotonic in the ratio on either side of the ground: bisect between the colour and black or white
79
+ let near = L, far = darken ? 0 : 1;
80
+ for (let i = 0; i < 24; i++) {
81
+ const mid = (near + far) / 2;
82
+ if (ratioOf(inGamut(mid, C, h), g.rgb) >= ratio) far = mid; else near = mid;
83
+ }
84
+ let out = toHex(inGamut(far, C, h));
85
+ // 8-bit rounding can land a hair under; step until it clears
86
+ for (let i = 0; i < 8 && ratioOf(parseHex(out)!.rgb, g.rgb) < ratio; i++) {
87
+ far += darken ? -0.005 : 0.005;
88
+ out = toHex(inGamut(far, C, h));
89
+ }
90
+ return out + f.alpha;
91
+ }
92
+
93
+ const COLOR = /(^|;)(\s*)(color|--shiki-[a-zA-Z0-9]+)(\s*:\s*)(#[0-9a-fA-F]{3,8})\b/g;
94
+
95
+ export function readableCode({ ratio = 4.5, grounds = {} }: ReadableCodeOptions = {}) {
96
+ const ground = (key: string, pre: string | undefined) => grounds[key] ?? (key === 'color' ? pre : GROUNDS[key]);
97
+ const fix = (style: string, pre: string | undefined) =>
98
+ style.replace(COLOR, (m, sep: string, sp: string, prop: string, colon: string, hex: string) => {
99
+ const g = ground(prop === 'color' ? 'color' : prop.slice('--shiki-'.length), pre);
100
+ return g ? `${sep}${sp}${prop}${colon}${readable(hex, g, ratio)}` : m;
101
+ });
102
+ const walk = (node: HastNode, pre: string | undefined) => {
103
+ const style = node.properties?.['style'];
104
+ if (typeof style === 'string') node.properties!['style'] = fix(style, pre);
105
+ node.children?.forEach((c) => walk(c, pre));
106
+ };
107
+ return {
108
+ name: 'moonarc:readable-code',
109
+ root(root: HastNode) {
110
+ const find = (n: HastNode): HastNode | undefined => (n.tagName === 'pre' ? n : n.children?.map(find).find(Boolean));
111
+ const style = find(root)?.properties?.['style'];
112
+ const pre = typeof style === 'string' ? /background-color\s*:\s*(#[0-9a-fA-F]{3,8})/.exec(style)?.[1] : undefined;
113
+ walk(root, pre);
114
+ },
115
+ };
116
+ }