@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.
- package/LICENSE +21 -0
- package/README.md +39 -0
- package/package.json +228 -0
- package/src/components/Accordion.astro +110 -0
- package/src/components/Accordion.meta.ts +33 -0
- package/src/components/AnimatedBeam.astro +217 -0
- package/src/components/AnimatedBeam.meta.ts +39 -0
- package/src/components/AnimatedIcons.astro +193 -0
- package/src/components/AnimatedIcons.meta.ts +34 -0
- package/src/components/AnimatedList.astro +305 -0
- package/src/components/AnimatedList.meta.ts +36 -0
- package/src/components/AnimatedSelect.astro +227 -0
- package/src/components/AnimatedSelect.meta.ts +38 -0
- package/src/components/ArrowButton.astro +133 -0
- package/src/components/ArrowButton.meta.ts +32 -0
- package/src/components/Aurora.astro +188 -0
- package/src/components/Aurora.meta.ts +36 -0
- package/src/components/AvatarStack.astro +205 -0
- package/src/components/AvatarStack.meta.ts +37 -0
- package/src/components/BarGrow.astro +123 -0
- package/src/components/BarGrow.meta.ts +42 -0
- package/src/components/BorderBeam.astro +75 -0
- package/src/components/BorderBeam.meta.ts +33 -0
- package/src/components/BrowserFrame.astro +172 -0
- package/src/components/BrowserFrame.meta.ts +36 -0
- package/src/components/BubbleText.astro +90 -0
- package/src/components/BubbleText.meta.ts +38 -0
- package/src/components/CardStack.astro +79 -0
- package/src/components/CardStack.meta.ts +33 -0
- package/src/components/Carousel.astro +147 -0
- package/src/components/Carousel.meta.ts +35 -0
- package/src/components/CheckDraw.astro +159 -0
- package/src/components/CheckDraw.meta.ts +37 -0
- package/src/components/CircularText.astro +103 -0
- package/src/components/CircularText.meta.ts +38 -0
- package/src/components/CodeTabs.astro +213 -0
- package/src/components/CodeTabs.meta.ts +36 -0
- package/src/components/CommandPalette.astro +363 -0
- package/src/components/CommandPalette.meta.ts +40 -0
- package/src/components/Confetti.astro +143 -0
- package/src/components/Confetti.meta.ts +40 -0
- package/src/components/CopyButton.astro +189 -0
- package/src/components/CopyButton.meta.ts +41 -0
- package/src/components/CountUp.astro +134 -0
- package/src/components/CountUp.meta.ts +40 -0
- package/src/components/CountdownFlip.astro +244 -0
- package/src/components/CountdownFlip.meta.ts +39 -0
- package/src/components/CounterPreloader.astro +122 -0
- package/src/components/CounterPreloader.meta.ts +34 -0
- package/src/components/Cursor.astro +177 -0
- package/src/components/Cursor.meta.ts +37 -0
- package/src/components/CursorGrid.astro +113 -0
- package/src/components/CursorGrid.meta.ts +37 -0
- package/src/components/Curtain.astro +305 -0
- package/src/components/Curtain.meta.ts +38 -0
- package/src/components/Dialog.astro +156 -0
- package/src/components/Dialog.meta.ts +37 -0
- package/src/components/Dock.astro +193 -0
- package/src/components/Dock.meta.ts +35 -0
- package/src/components/DotPattern.astro +58 -0
- package/src/components/DotPattern.meta.ts +32 -0
- package/src/components/DottedMap.astro +153 -0
- package/src/components/DottedMap.meta.ts +38 -0
- package/src/components/Drawer.astro +196 -0
- package/src/components/Drawer.meta.ts +35 -0
- package/src/components/DynamicIsland.astro +122 -0
- package/src/components/DynamicIsland.meta.ts +35 -0
- package/src/components/FileTree.astro +207 -0
- package/src/components/FileTree.meta.ts +34 -0
- package/src/components/FilterGrid.astro +170 -0
- package/src/components/FilterGrid.meta.ts +38 -0
- package/src/components/FooterReveal.astro +54 -0
- package/src/components/FooterReveal.meta.ts +31 -0
- package/src/components/GlitchText.astro +145 -0
- package/src/components/GlitchText.meta.ts +39 -0
- package/src/components/Globe.astro +304 -0
- package/src/components/Globe.meta.ts +41 -0
- package/src/components/GlyphField.astro +363 -0
- package/src/components/GlyphField.meta.ts +53 -0
- package/src/components/GradientText.astro +63 -0
- package/src/components/GradientText.meta.ts +35 -0
- package/src/components/GridPattern.astro +55 -0
- package/src/components/GridPattern.meta.ts +32 -0
- package/src/components/HighlightMarker.astro +68 -0
- package/src/components/HighlightMarker.meta.ts +37 -0
- package/src/components/HorizontalScroll.astro +112 -0
- package/src/components/HorizontalScroll.meta.ts +34 -0
- package/src/components/HoverImageLinks.astro +219 -0
- package/src/components/HoverImageLinks.meta.ts +34 -0
- package/src/components/HoverVideo.astro +179 -0
- package/src/components/HoverVideo.meta.ts +37 -0
- package/src/components/ImageCompare.astro +212 -0
- package/src/components/ImageCompare.meta.ts +39 -0
- package/src/components/ImageTrail.astro +169 -0
- package/src/components/ImageTrail.meta.ts +36 -0
- package/src/components/Lightbox.astro +236 -0
- package/src/components/Lightbox.meta.ts +40 -0
- package/src/components/LinkPreview.astro +234 -0
- package/src/components/LinkPreview.meta.ts +39 -0
- package/src/components/LiquidGlass.astro +148 -0
- package/src/components/LiquidGlass.meta.ts +37 -0
- package/src/components/Magnetic.astro +92 -0
- package/src/components/Magnetic.meta.ts +31 -0
- package/src/components/Marquee.astro +165 -0
- package/src/components/Marquee.meta.ts +38 -0
- package/src/components/Meteors.astro +156 -0
- package/src/components/Meteors.meta.ts +36 -0
- package/src/components/Noise.astro +61 -0
- package/src/components/Noise.meta.ts +33 -0
- package/src/components/NumberFlow.astro +143 -0
- package/src/components/NumberFlow.meta.ts +42 -0
- package/src/components/Orb.astro +130 -0
- package/src/components/Orb.meta.ts +39 -0
- package/src/components/OrbitingCircles.astro +119 -0
- package/src/components/OrbitingCircles.meta.ts +36 -0
- package/src/components/OtpInput.astro +215 -0
- package/src/components/OtpInput.meta.ts +42 -0
- package/src/components/PageTransition.astro +121 -0
- package/src/components/PageTransition.meta.ts +33 -0
- package/src/components/Parallax.astro +69 -0
- package/src/components/Parallax.meta.ts +32 -0
- package/src/components/Particles.astro +210 -0
- package/src/components/Particles.meta.ts +37 -0
- package/src/components/PersistPlayer.astro +126 -0
- package/src/components/PersistPlayer.meta.ts +35 -0
- package/src/components/PhoneFrame.astro +161 -0
- package/src/components/PhoneFrame.meta.ts +35 -0
- package/src/components/PillNav.astro +170 -0
- package/src/components/PillNav.meta.ts +37 -0
- package/src/components/Popover.astro +95 -0
- package/src/components/Popover.meta.ts +34 -0
- package/src/components/PriceSwitch.astro +251 -0
- package/src/components/PriceSwitch.meta.ts +41 -0
- package/src/components/Probe.astro +84 -0
- package/src/components/Progress.astro +165 -0
- package/src/components/Progress.meta.ts +40 -0
- package/src/components/ProgressiveBlur.astro +95 -0
- package/src/components/ProgressiveBlur.meta.ts +36 -0
- package/src/components/PromptInput.astro +273 -0
- package/src/components/PromptInput.meta.ts +46 -0
- package/src/components/ReadingToc.astro +153 -0
- package/src/components/ReadingToc.meta.ts +34 -0
- package/src/components/RetroGrid.astro +84 -0
- package/src/components/RetroGrid.meta.ts +36 -0
- package/src/components/Reveal.astro +292 -0
- package/src/components/Reveal.meta.ts +48 -0
- package/src/components/Ripple.astro +94 -0
- package/src/components/Ripple.meta.ts +34 -0
- package/src/components/RotatingText.astro +170 -0
- package/src/components/RotatingText.meta.ts +35 -0
- package/src/components/ScrollExpand.astro +90 -0
- package/src/components/ScrollExpand.meta.ts +35 -0
- package/src/components/ScrollProgress.astro +73 -0
- package/src/components/ScrollProgress.meta.ts +32 -0
- package/src/components/ScrollScrub.astro +96 -0
- package/src/components/ScrollScrub.meta.ts +37 -0
- package/src/components/ShimmerButton.astro +109 -0
- package/src/components/ShimmerButton.meta.ts +37 -0
- package/src/components/ShinyText.astro +72 -0
- package/src/components/ShinyText.meta.ts +35 -0
- package/src/components/Skeleton.astro +76 -0
- package/src/components/Skeleton.meta.ts +32 -0
- package/src/components/Sparkline.astro +168 -0
- package/src/components/Sparkline.meta.ts +43 -0
- package/src/components/Spinner.astro +79 -0
- package/src/components/Spinner.meta.ts +33 -0
- package/src/components/SplitFlap.astro +157 -0
- package/src/components/SplitFlap.meta.ts +39 -0
- package/src/components/SplitText.astro +208 -0
- package/src/components/SplitText.meta.ts +50 -0
- package/src/components/Spotlight.astro +92 -0
- package/src/components/Spotlight.meta.ts +34 -0
- package/src/components/StaggerMenu.astro +301 -0
- package/src/components/StaggerMenu.meta.ts +39 -0
- package/src/components/StatefulButton.astro +148 -0
- package/src/components/StatefulButton.meta.ts +35 -0
- package/src/components/StickyBanner.astro +168 -0
- package/src/components/StickyBanner.meta.ts +45 -0
- package/src/components/StickyReveal.astro +256 -0
- package/src/components/StickyReveal.meta.ts +33 -0
- package/src/components/StrokeDraw.astro +110 -0
- package/src/components/StrokeDraw.meta.ts +43 -0
- package/src/components/Switch.astro +165 -0
- package/src/components/Switch.meta.ts +37 -0
- package/src/components/Tabs.astro +194 -0
- package/src/components/Tabs.meta.ts +47 -0
- package/src/components/TextRoll.astro +78 -0
- package/src/components/TextRoll.meta.ts +36 -0
- package/src/components/TextScramble.astro +107 -0
- package/src/components/TextScramble.meta.ts +35 -0
- package/src/components/ThemeScript.astro +17 -0
- package/src/components/ThemeToggle.astro +182 -0
- package/src/components/ThemeToggle.meta.ts +34 -0
- package/src/components/Tilt.astro +97 -0
- package/src/components/Tilt.meta.ts +34 -0
- package/src/components/Toast.astro +184 -0
- package/src/components/Toast.meta.ts +33 -0
- package/src/components/TracingBeam.astro +113 -0
- package/src/components/TracingBeam.meta.ts +34 -0
- package/src/components/Typewriter.astro +124 -0
- package/src/components/Typewriter.meta.ts +39 -0
- package/src/components/UnderlineHover.astro +61 -0
- package/src/components/UnderlineHover.meta.ts +34 -0
- package/src/components/VanishInput.astro +240 -0
- package/src/components/VanishInput.meta.ts +41 -0
- package/src/components/VideoDialog.astro +230 -0
- package/src/components/VideoDialog.meta.ts +40 -0
- package/src/components/WeightWave.astro +70 -0
- package/src/components/WeightWave.meta.ts +36 -0
- package/src/components/internal/DialogMorph.astro +97 -0
- package/src/components/internal/PromptInputSubmitOnEnter.astro +32 -0
- package/src/index.d.ts +43 -0
- package/src/index.js +60 -0
- package/src/lib/canvas.ts +124 -0
- package/src/lib/confetti.ts +127 -0
- package/src/lib/contrast.ts +116 -0
- package/src/lib/form.ts +231 -0
- package/src/lib/geo.ts +77 -0
- package/src/lib/gsap.ts +58 -0
- package/src/lib/land.ts +11 -0
- package/src/lib/lenis.ts +112 -0
- package/src/lib/meta.ts +79 -0
- package/src/lib/morph.ts +74 -0
- package/src/lib/reduced-motion-plugin.js +299 -0
- package/src/lib/runtime.ts +215 -0
- package/src/lib/support.ts +102 -0
- package/src/lib/theme.ts +18 -0
- package/src/lib/toast.ts +17 -0
- package/src/styles/base.css +218 -0
- package/src/styles/motion.css +43 -0
- package/src/styles/theme.css +397 -0
- package/src/styles/theme.tailwind.css +75 -0
package/src/lib/morph.ts
ADDED
|
@@ -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
|
+
}
|