@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,74 @@
1
+ /**
2
+ * @moonarc/core/morph — same-document view transitions, once.
3
+ *
4
+ * vt(update, kind) document.startViewTransition around `update`,
5
+ * with <html data-ma-vt="kind"> for the duration so
6
+ * base.css can give the root snapshots a plain
7
+ * cross-fade on the tokens (and Curtain's iris never
8
+ * answers a morph). No view transitions, or reduced
9
+ * motion → `update()` runs at once. Resolves when
10
+ * the transition has finished, or right away.
11
+ * morphOpen(dialog, from) showModal() inside a view transition where `from`
12
+ * (the trigger, a thumbnail) carries the name
13
+ * `ma-morph` in the old state and the dialog carries
14
+ * it in the new one — the browser draws the box
15
+ * growing from one to the other
16
+ * morphClose(dialog) the reverse, back to the last target
17
+ * target(dialog, el) change where the dialog returns to (a lightbox
18
+ * that moved to another image)
19
+ *
20
+ * The name is set and removed around each transition, so nothing on the page
21
+ * carries a view-transition-name between them and a ClientRouter swap sees
22
+ * nothing of it. Zero dependencies.
23
+ */
24
+
25
+ type VTDoc = Document & { startViewTransition?: (cb: () => void) => { finished: Promise<void> } };
26
+
27
+ const NAME = 'ma-morph';
28
+ const back = new WeakMap<Element, Element>();
29
+
30
+ /** Reduced motion: the OS setting, or the page's own switch (html[data-ma-motion='reduce'], the runtime's contract and the value the CSS mirror matches). */
31
+ const still = () => matchMedia('(prefers-reduced-motion: reduce)').matches || document.documentElement.dataset['maMotion'] == 'reduce';
32
+
33
+ export function vt(update: () => void, kind = 'morph'): Promise<void> {
34
+ const doc = document as VTDoc;
35
+ const html = document.documentElement;
36
+ if (!doc.startViewTransition || still() || html.hasAttribute('data-ma-vt')) {
37
+ update();
38
+ return Promise.resolve();
39
+ }
40
+ html.setAttribute('data-ma-vt', kind);
41
+ return doc.startViewTransition(update).finished.catch(() => {}).finally(() => html.removeAttribute('data-ma-vt'));
42
+ }
43
+
44
+ const name = (el: Element | undefined | null, on: boolean) => el && ((el as HTMLElement).style.viewTransitionName = on ? NAME : '');
45
+
46
+ export function target(dialog: Element, el: Element): void {
47
+ back.set(dialog, el);
48
+ }
49
+
50
+ export function morphOpen(dialog: HTMLDialogElement, from: Element): Promise<void> {
51
+ if (dialog.open) return Promise.resolve();
52
+ back.set(dialog, from);
53
+ name(from, true);
54
+ return vt(() => {
55
+ name(from, false);
56
+ name(dialog, true);
57
+ dialog.showModal();
58
+ }).then(() => {
59
+ name(dialog, false);
60
+ });
61
+ }
62
+
63
+ export function morphClose(dialog: HTMLDialogElement): Promise<void> {
64
+ if (!dialog.open) return Promise.resolve();
65
+ const to = back.get(dialog);
66
+ name(dialog, true);
67
+ return vt(() => {
68
+ name(dialog, false);
69
+ name(to, true);
70
+ dialog.close();
71
+ }).then(() => {
72
+ name(to, false);
73
+ });
74
+ }
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Vite plugin — mirror every `@media (prefers-reduced-motion: reduce)` block
3
+ * into the same rules scoped under `[data-ma-motion='reduce']`, so a page can
4
+ * offer a reduced-motion switch that previews exactly what the media query
5
+ * does, without duplicating a line in any component. Runs on the compiled
6
+ * CSS of every stylesheet and <style> block (Tailwind's `motion-reduce:`
7
+ * utilities included); costs a few percent of CSS and no JavaScript.
8
+ *
9
+ * What counts as a reduced-motion block: a media query list with at least one
10
+ * query that requires `(prefers-reduced-motion: reduce)` or the boolean
11
+ * `(prefers-reduced-motion)`, alone or joined by `and` (`(hover: none),
12
+ * (prefers-reduced-motion: reduce)` or `screen and (prefers-reduced-motion)`).
13
+ * The mirror keeps what the rest of such a query asks for: `@media (hover:
14
+ * none) { … }` for the second example's sibling, nothing for a query that was
15
+ * the feature alone. A negated query is not mirrored, and neither is a
16
+ * `(prefers-reduced-motion: no-preference)` block: the switch cannot turn off
17
+ * what that block turns on without a selector of its own.
18
+ *
19
+ * The attribute goes inside `:where()`, so a mirrored rule has the same
20
+ * specificity as the rule it copies and the cascade picks between them as the
21
+ * media query would. A copied selector reaches what is inside the root
22
+ * (`:where([…]) .a`), or the root itself when it starts with `html`, `:root`
23
+ * or `::view-transition`; one that matches <html> another way (`*`, a class
24
+ * set on <html>) misses <html> under the switch. `@keyframes` inside a block
25
+ * are copied under a prefixed name, and the mirrored rules point at the copy:
26
+ * copied as they are, they would redefine the name for every visitor, switch
27
+ * or not. The one thing the switch cannot preview is a rule outside the block
28
+ * that uses a name the block redefines; it keeps the original keyframes under
29
+ * the switch.
30
+ *
31
+ * Enabled by `moonarc({ motionAttribute: true })`. The runtime's
32
+ * prefersReducedMotion() reads the same attribute, so scripts follow too.
33
+ *
34
+ * Plain JS on purpose: imported by the integration while Astro loads the
35
+ * config, before any transform exists.
36
+ */
37
+
38
+ const ATTR = "[data-ma-motion='reduce']";
39
+ const WHERE = `:where(${ATTR})`;
40
+ const KEYFRAMES = 'ma-rm-';
41
+ /** The feature itself, as one parenthesised term of a query. */
42
+ const FEATURE = /^\(\s*prefers-reduced-motion\s*(?::\s*reduce\s*)?\)$/i;
43
+ /** Group rules whose rules are mirrored one by one; `@scope` gets the attribute on its root, and any other at-rule in a block is left to the media query. */
44
+ const GROUP = /^@(media|supports|container|layer|starting-style)\b/i;
45
+ /** The file types Vite hands to plugins as compiled CSS, and Astro's and Vue's style blocks. */
46
+ const STYLE = /\.(?:css|less|sass|scss|styl|stylus|pcss|postcss|sss)(?:\?|$)|lang\.(?:css|less|sass|scss|styl|stylus|pcss|postcss|sss)\b|type=style/;
47
+
48
+ /** Index just past the comment, string or escape that starts at `i`, or `i` itself. */
49
+ function skip(css, i) {
50
+ const c = css[i];
51
+ // an escaped character is part of a name, never a quote or a brace: Tailwind writes `content-['']` as `.content-\[\'\'\]`
52
+ if (c === '\\') return i + 2;
53
+ if (c === '/' && css[i + 1] === '*') {
54
+ const end = css.indexOf('*/', i + 2);
55
+ return end < 0 ? css.length : end + 2;
56
+ }
57
+ if (c === '"' || c === "'") {
58
+ for (let j = i + 1; j < css.length; j++) {
59
+ if (css[j] === '\\') j++;
60
+ else if (css[j] === c || css[j] === '\n') return j + 1;
61
+ }
62
+ return css.length;
63
+ }
64
+ return i;
65
+ }
66
+
67
+ /** Index of the `}` that closes the block opened at `open`, or -1. Strings and comments are skipped. */
68
+ function blockEnd(css, open) {
69
+ let depth = 0;
70
+ for (let i = open; i < css.length; i++) {
71
+ const next = skip(css, i);
72
+ if (next !== i) {
73
+ i = next - 1;
74
+ continue;
75
+ }
76
+ const c = css[i];
77
+ if (c === '{') depth++;
78
+ else if (c === '}' && --depth === 0) return i;
79
+ }
80
+ return -1;
81
+ }
82
+
83
+ const uncomment = (s) => s.replace(/\/\*[\s\S]*?\*\//g, ' ').trim();
84
+
85
+ /** Split on `sep` outside parentheses, brackets, strings and comments. */
86
+ function splitTop(list, sep = ',') {
87
+ const out = [];
88
+ let depth = 0;
89
+ let from = 0;
90
+ for (let i = 0; i < list.length; i++) {
91
+ const next = skip(list, i);
92
+ if (next !== i) {
93
+ i = next - 1;
94
+ continue;
95
+ }
96
+ const c = list[i];
97
+ if (c === '(' || c === '[') depth++;
98
+ else if (c === ')' || c === ']') depth--;
99
+ else if (c === sep && depth === 0) {
100
+ out.push(list.slice(from, i));
101
+ from = i + 1;
102
+ }
103
+ }
104
+ out.push(list.slice(from));
105
+ return out;
106
+ }
107
+
108
+ /** A media query as its top-level terms: `screen and (a: b)` → ['screen', 'and', '(a: b)']. */
109
+ const terms = (query) => splitTop(query.replace(/\s+/g, ' ').trim(), ' ').filter(Boolean);
110
+
111
+ /**
112
+ * For a `@media` prelude, the conditions the mirror keeps: one per query that
113
+ * requires reduced motion, '' where that query asked for nothing else. Null
114
+ * when no query does.
115
+ */
116
+ function conditions(prelude) {
117
+ const out = [];
118
+ const and = (w) => /^and$/i.test(w ?? '');
119
+ for (const query of splitTop(prelude.replace(/^@media/i, ''))) {
120
+ const t = terms(query);
121
+ if (t.some((w) => /^not$/i.test(w))) continue;
122
+ // `a or b` (level 4) is two queries; in `a and b` every term but the feature stays
123
+ const sides = [[]];
124
+ for (const w of t) {
125
+ if (/^or$/i.test(w)) sides.push([]);
126
+ else sides[sides.length - 1].push(w);
127
+ }
128
+ for (const side of sides) {
129
+ const k = side.findIndex((w) => FEATURE.test(w));
130
+ if (k < 0) continue;
131
+ if (and(side[k - 1])) side.splice(k - 1, 2);
132
+ else side.splice(k, and(side[k + 1]) ? 2 : 1);
133
+ out.push(side.join(' ').replace(/^(only )?all$/i, ''));
134
+ }
135
+ }
136
+ return out.length ? out : null;
137
+ }
138
+
139
+ function prefix(selector) {
140
+ const s = selector.trim();
141
+ if (!s) return s;
142
+ if (/^(html|:root)(?![\w-])/i.test(s)) return s.replace(/^(html|:root)/i, `$1${WHERE}`);
143
+ // `:where(:root…)::view-transition-*(…)` — a component's zero-specificity root rule (Curtain); the attribute goes on the root inside the :where()
144
+ if (/^:where\((html|:root)(?![\w-])/i.test(s)) return s.replace(/^:where\((html|:root)/i, `:where($1${ATTR}`);
145
+ // a pseudo-element of the root itself (Astro's `::view-transition-group(*)`): on the root, not on an ancestor it does not have
146
+ if (s.startsWith('::view-transition')) return WHERE + s;
147
+ return `${WHERE} ${s}`;
148
+ }
149
+
150
+ /**
151
+ * `@scope (a, b) to (c)` with the attribute on each start selector: a scoped rule is relative to its root (`img` is
152
+ * `:scope img`), so the root is what has to sit under the switch. Null for a bare `@scope`, whose root is a <style>'s parent.
153
+ */
154
+ function scoped(prelude) {
155
+ const open = /^@scope\s*\(/i.exec(prelude);
156
+ if (!open) return null;
157
+ let depth = 0;
158
+ for (let i = open[0].length - 1; i < prelude.length; i++) {
159
+ const next = skip(prelude, i);
160
+ if (next !== i) {
161
+ i = next - 1;
162
+ continue;
163
+ }
164
+ if (prelude[i] === '(') depth++;
165
+ else if (prelude[i] === ')' && --depth === 0) return `@scope (${splitTop(prelude.slice(open[0].length, i)).map(prefix).join(',')})${prelude.slice(i + 1)}`;
166
+ }
167
+ return null;
168
+ }
169
+
170
+ /** Point `animation` and `animation-name` in a declaration block at the renamed keyframes. */
171
+ function renameIn(decls, names) {
172
+ if (!names.size) return decls;
173
+ return decls.replace(/(animation(?:-name)?\s*:)([^;{}]*)/gi, (_, prop, value) => {
174
+ for (const n of names) value = value.replace(new RegExp(`(^|[\\s,])${n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?=[\\s,!;]|$)`, 'g'), `$1${KEYFRAMES}${n}`);
175
+ return prop + value;
176
+ });
177
+ }
178
+
179
+ /** The @keyframes names a block defines, at any depth. */
180
+ function keyframesIn(css) {
181
+ const names = new Set();
182
+ for (const m of uncomment(css).matchAll(/@(?:-webkit-)?keyframes\s+([^\s{]+)\s*\{/gi)) names.add(m[1].replace(/^(['"])(.*)\1$/, '$2'));
183
+ return names;
184
+ }
185
+
186
+ /** The rules of a media block, selectors prefixed, nested group rules kept, keyframes renamed. */
187
+ function mirror(body, names) {
188
+ let out = '';
189
+ let i = 0;
190
+ while (i < body.length) {
191
+ // the next `{` or `;` outside strings and comments: a rule or a statement
192
+ let j = i;
193
+ while (j < body.length) {
194
+ const next = skip(body, j);
195
+ if (next !== j) j = next;
196
+ else if (body[j] === '{' || body[j] === ';') break;
197
+ else j++;
198
+ }
199
+ if (j >= body.length) break;
200
+ if (body[j] === ';') {
201
+ i = j + 1; // a statement (`@import`, `@layer a, b;`) is the media query's alone
202
+ continue;
203
+ }
204
+ const prelude = uncomment(body.slice(i, j));
205
+ const close = blockEnd(body, j);
206
+ if (close < 0) break;
207
+ const inner = body.slice(j + 1, close);
208
+ if (/^@(-webkit-)?keyframes\b/i.test(prelude)) {
209
+ out += prelude.replace(/(keyframes\s+)(['"]?)/i, `$1$2${KEYFRAMES}`) + `{${inner}}`;
210
+ } else if (/^@scope\b/i.test(prelude)) {
211
+ const start = scoped(prelude);
212
+ if (start) out += `${start}{${renameIn(inner, names)}}`;
213
+ } else if (GROUP.test(prelude)) {
214
+ out += `${prelude}{${mirror(inner, names)}}`;
215
+ } else if (prelude && !prelude.startsWith('@')) {
216
+ out += `${splitTop(prelude).map(prefix).join(',')}{${renameIn(inner, names)}}`;
217
+ }
218
+ i = close + 1;
219
+ }
220
+ return out;
221
+ }
222
+
223
+ /** Rewrite one stylesheet: every reduced-motion block is followed by its attribute-scoped mirror. */
224
+ export function mirrorReducedMotion(css) {
225
+ if (!/prefers-reduced-motion/i.test(css)) return css;
226
+ const inserts = [];
227
+ // one pass over the sheet: `from` is where the current prelude began, `rules` how many style rules enclose it
228
+ const stack = [];
229
+ let from = 0;
230
+ for (let i = 0; i < css.length; i++) {
231
+ const next = skip(css, i);
232
+ if (next !== i) {
233
+ i = next - 1;
234
+ continue;
235
+ }
236
+ const c = css[i];
237
+ if (c === ';') from = i + 1;
238
+ else if (c === '}') {
239
+ stack.pop();
240
+ from = i + 1;
241
+ } else if (c === '{') {
242
+ const prelude = uncomment(css.slice(from, i));
243
+ const kept = /^@media\b/i.test(prelude) ? conditions(prelude) : null;
244
+ if (kept) {
245
+ const close = blockEnd(css, i);
246
+ if (close < 0) break;
247
+ const body = css.slice(i + 1, close);
248
+ // nested in a style rule (CSS nesting), the block holds declarations: the mirror is `:where([…]) &` around them
249
+ const inRule = stack.some((p) => !p.startsWith('@'));
250
+ const rules = inRule ? `${WHERE} &{${body}}` : mirror(body, keyframesIn(body));
251
+ const wrapped = kept.includes('') ? rules : `@media ${kept.join(', ')}{${rules}}`;
252
+ if (rules) inserts.push([close + 1, wrapped]);
253
+ i = close;
254
+ from = close + 1;
255
+ continue;
256
+ }
257
+ stack.push(prelude);
258
+ from = i + 1;
259
+ } else if (c === '(') {
260
+ // a parenthesised run (`url(…;…)`, `:is(…)`, a media feature) holds no rule boundary
261
+ let depth = 0;
262
+ let j = i;
263
+ for (; j < css.length; j++) {
264
+ const n = skip(css, j);
265
+ if (n !== j) {
266
+ j = n - 1;
267
+ continue;
268
+ }
269
+ if (css[j] === '(') depth++;
270
+ else if (css[j] === ')' && --depth === 0) break;
271
+ }
272
+ i = j;
273
+ }
274
+ }
275
+ if (!inserts.length) return css;
276
+ let out = '';
277
+ let last = 0;
278
+ for (const [at, text] of inserts) {
279
+ out += css.slice(last, at) + text;
280
+ last = at;
281
+ }
282
+ return out + css.slice(last);
283
+ }
284
+
285
+ /** @returns {import('vite').Plugin} */
286
+ export function reducedMotionMirror() {
287
+ return {
288
+ name: 'moonarc:reduced-motion-mirror',
289
+ transform(code, id) {
290
+ // `?raw` and `?url` are not stylesheets: Vite has already turned the file into a JS module exporting its text or
291
+ // its URL, and a raw import promises the file as written (the site's registry ships base.css to buyers that way).
292
+ if (/[?&](?:raw|url)\b/.test(id)) return;
293
+ // .scss, .less, .styl and the rest arrive here compiled: Vite's CSS plugin runs before a plugin without `enforce`
294
+ if (!STYLE.test(id) || !/prefers-reduced-motion/i.test(code)) return;
295
+ const out = mirrorReducedMotion(code);
296
+ return out === code ? undefined : { code: out, map: null };
297
+ },
298
+ };
299
+ }
@@ -0,0 +1,215 @@
1
+ /**
2
+ * @moonarc/core/runtime — the lifecycle substrate.
3
+ *
4
+ * Astro bundles a component's <script> once per page and never re-runs it
5
+ * after a <ClientRouter /> navigation; on pages without the router the
6
+ * `astro:*` events do not exist at all; `astro:page-load` itself could be
7
+ * skipped or doubled on back/forward before Astro 5.5.0
8
+ * (withastro/astro#12858), and is lost entirely by an interrupted navigation.
9
+ * This module owns that problem once, so components and consumer scripts
10
+ * never re-derive it:
11
+ *
12
+ * onMount(selector, setup) bind every matching element now and after every
13
+ * navigation — each element exactly once; cleanup
14
+ * runs on astro:before-swap
15
+ * onPage(setup) one setup per page view, same lifecycle
16
+ * prefersReducedMotion() read once, kept current
17
+ * refresh() bind elements you injected yourself, and release
18
+ * the ones you took out of the page
19
+ *
20
+ * Register from a layout or a component, not from each page: every page
21
+ * script is its own module and would add its own registration.
22
+ *
23
+ * Zero dependencies.
24
+ */
25
+
26
+ export type Cleanup = () => void;
27
+
28
+ export interface MountContext {
29
+ /** Aborted when the binding is torn down — pass it to addEventListener. */
30
+ signal: AbortSignal;
31
+ /** prefers-reduced-motion at bind time; call prefersReducedMotion() for a live read. */
32
+ reducedMotion: boolean;
33
+ }
34
+
35
+ export type MountSetup<E extends Element> = (el: E, ctx: MountContext) => Cleanup | void;
36
+ export type PageSetup = (ctx: MountContext) => Cleanup | void;
37
+
38
+ interface Binding {
39
+ abort: AbortController;
40
+ cleanup?: Cleanup;
41
+ }
42
+ interface Mount {
43
+ selector: string;
44
+ root: ParentNode;
45
+ setup: MountSetup<Element>;
46
+ bound: Map<Element, Binding>;
47
+ }
48
+ interface Page {
49
+ setup: PageSetup;
50
+ active: Binding | null;
51
+ }
52
+
53
+ const mounts = new Set<Mount>();
54
+ const pages = new Set<Page>();
55
+
56
+ let reduced: boolean | null = null;
57
+
58
+ export function prefersReducedMotion(): boolean {
59
+ if (reduced === null) {
60
+ const mq = matchMedia('(prefers-reduced-motion: reduce)');
61
+ reduced = mq.matches;
62
+ mq.addEventListener('change', (e) => {
63
+ reduced = e.matches;
64
+ });
65
+ }
66
+ // html[data-ma-motion='reduce'] is the page's own switch (see the integration's motionAttribute option); the CSS
67
+ // mirror matches that value only, so any other value reads as no switch here too
68
+ return reduced || document.documentElement.dataset['maMotion'] == 'reduce';
69
+ }
70
+
71
+ /**
72
+ * One element must not take the page down with it. Every pass over the
73
+ * registry is a loop over independent components, so a setup or a cleanup
74
+ * that throws — a missing element, a null assertion, a third-party script —
75
+ * is reported and the rest of the page still binds. The guard sits around
76
+ * each element, not each registration: a Reveal whose setup threw on its first
77
+ * element used to leave every later one hidden, and on the first pass the
78
+ * throw also escaped onMount() and stopped the module that called it. That is
79
+ * the failure that reads as "it works after I refresh".
80
+ */
81
+ function guard(step?: () => void): void {
82
+ try {
83
+ step?.();
84
+ } catch (error) {
85
+ console.error('[moonarc]', error);
86
+ }
87
+ }
88
+
89
+ /** Run one setup under its own AbortController, recorded by `keep` first so a setup that calls refresh() is not bound twice. */
90
+ function begin(keep: (binding: Binding) => void, setup: PageSetup): void {
91
+ const binding: Binding = { abort: new AbortController() };
92
+ keep(binding);
93
+ guard(() => {
94
+ const cleanup = setup({ signal: binding.abort.signal, reducedMotion: prefersReducedMotion() });
95
+ if (typeof cleanup === 'function') binding.cleanup = cleanup;
96
+ });
97
+ }
98
+
99
+ function release(binding: Binding): void {
100
+ binding.abort.abort();
101
+ guard(binding.cleanup);
102
+ }
103
+
104
+ function bind(m: Mount): void {
105
+ // an element that has left its root since the last pass (replaced by a script, a Replay clone) is released now,
106
+ // not at the next swap: its listeners, observers and contexts go with it. Its root, not the page: a root that is not
107
+ // in the document yet keeps what it holds.
108
+ for (const [el, binding] of m.bound) if (!(m.root as Node).contains(el)) m.bound.delete(el), release(binding);
109
+ for (const el of m.root.querySelectorAll(m.selector)) {
110
+ if (!m.bound.has(el)) begin((binding) => m.bound.set(el, binding), (ctx) => m.setup(el, ctx));
111
+ }
112
+ }
113
+
114
+ function unbind(m: Mount): void {
115
+ for (const binding of m.bound.values()) release(binding);
116
+ m.bound.clear();
117
+ }
118
+
119
+ function start(p: Page): void {
120
+ // one run per page view, however many times page-load fires
121
+ if (!p.active) begin((binding) => (p.active = binding), p.setup);
122
+ }
123
+
124
+ function stop(p: Page): void {
125
+ const binding = p.active;
126
+ if (!binding) return;
127
+ p.active = null;
128
+ release(binding);
129
+ }
130
+
131
+ /** Bind every registered selector and page setup. Runs automatically; call it after injecting DOM yourself, or after taking bound elements out. */
132
+ export function refresh(): void {
133
+ // bind() throws only for a selector that is not one, and that registration is skipped, not the rest
134
+ for (const m of mounts) guard(() => bind(m));
135
+ for (const p of pages) start(p);
136
+ }
137
+
138
+ function teardown(): void {
139
+ for (const p of pages) stop(p);
140
+ for (const m of mounts) unbind(m);
141
+ }
142
+
143
+ /**
144
+ * `html[data-ma-js]` is the gate entrance effects hide behind, set before
145
+ * paint by the integration's head script. ClientRouter replaces <html>
146
+ * attributes on every swap; the head script sets it again on after-swap, and
147
+ * the runtime carries it across too, for a layout that still inlines the
148
+ * older one-line gate: recorded before the swap, restored after it and before
149
+ * paint.
150
+ */
151
+ let hadGate = false;
152
+ const GATE = 'data-ma-js';
153
+
154
+ let listening = false;
155
+ function listen(): void {
156
+ if (listening) return;
157
+ listening = true;
158
+ document.addEventListener('astro:page-load', refresh);
159
+ document.addEventListener('astro:before-swap', () => {
160
+ hadGate = document.documentElement.hasAttribute(GATE);
161
+ teardown();
162
+ });
163
+ document.addEventListener('astro:after-swap', () => {
164
+ if (hadGate) document.documentElement.setAttribute(GATE, '');
165
+ // and bind here, not only on astro:page-load. The router fires page-load
166
+ // from the promise that waits for the new page's scripts to load; a
167
+ // navigation interrupted after its swap leaves that promise unsettled and
168
+ // the view it swapped in never gets its page-load at all (the swap does
169
+ // fire, once, every time). A page bound only there sits inert until a
170
+ // reload. Binding is idempotent, so the pair costs one no-op pass, and the
171
+ // router has already awaited the new stylesheets before the swap, so
172
+ // anything that measures here measures a laid-out page.
173
+ refresh();
174
+ });
175
+ }
176
+
177
+ function ready(fn: () => void): void {
178
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn, { once: true });
179
+ else fn();
180
+ }
181
+
182
+ /**
183
+ * Bind `setup` to every element matching `selector` — now, and again after
184
+ * every ClientRouter navigation. Each element is bound once. Returns an
185
+ * unregister function.
186
+ */
187
+ export function onMount<E extends Element = HTMLElement>(
188
+ selector: string,
189
+ setup: MountSetup<E>,
190
+ options: { root?: ParentNode } = {},
191
+ ): Cleanup {
192
+ const m: Mount = { selector, root: options.root ?? document, setup: setup as MountSetup<Element>, bound: new Map() };
193
+ mounts.add(m);
194
+ listen();
195
+ ready(() => bind(m));
196
+ return () => {
197
+ unbind(m);
198
+ mounts.delete(m);
199
+ };
200
+ }
201
+
202
+ /**
203
+ * Run `setup` once per page view — now, and again after every navigation —
204
+ * and its cleanup before the next swap. Returns an unregister function.
205
+ */
206
+ export function onPage(setup: PageSetup): Cleanup {
207
+ const p: Page = { setup, active: null };
208
+ pages.add(p);
209
+ listen();
210
+ ready(() => start(p));
211
+ return () => {
212
+ stop(p);
213
+ pages.delete(p);
214
+ };
215
+ }