@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/form.ts
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @moonarc/core/form — a form that really sends, with JavaScript and
|
|
3
|
+
* without it.
|
|
4
|
+
*
|
|
5
|
+
* bindForms() bind every form[data-ma-form] now and after every
|
|
6
|
+
* ClientRouter navigation (the runtime's onMount). Call it
|
|
7
|
+
* from a section or layout script; a second call is a no-op.
|
|
8
|
+
* Returns the unregister function.
|
|
9
|
+
*
|
|
10
|
+
* Without JS the form posts natively (method="post" + action); nothing is
|
|
11
|
+
* hidden behind a script. With it, one submit listener on the form itself
|
|
12
|
+
* (target phase, so it runs before VanishInput's and the router's listeners
|
|
13
|
+
* on document, which read defaultPrevented) calls preventDefault() and reads
|
|
14
|
+
* the FormData before any await, then posts to data-endpoint (else the
|
|
15
|
+
* action) with Accept: application/json. Only method="post" is taken over,
|
|
16
|
+
* except a demo form.
|
|
17
|
+
*
|
|
18
|
+
* data-ma-form-status id of a role="status" node present at load (required)
|
|
19
|
+
* data-endpoint the fetch URL; the action otherwise
|
|
20
|
+
* data-encode multipart (default, the FormData) | urlencoded | json
|
|
21
|
+
* data-success navigate here after success
|
|
22
|
+
* data-carry response keys copied into the record, space-separated
|
|
23
|
+
* data-ref-param URL parameter copied into the field of that name, or appended
|
|
24
|
+
* data-ma-form-demo no request: success after the animations, this JSON as the response
|
|
25
|
+
* data-msg-pending · -done · -exists · -error · -429 · -offline the status texts
|
|
26
|
+
* (429 and offline fall back to error, exists to done)
|
|
27
|
+
*
|
|
28
|
+
* State on the form: data-pending while waiting, then data-state done |
|
|
29
|
+
* exists | error. While pending, and while done or exists without
|
|
30
|
+
* navigation, the submit buttons carry aria-disabled (not disabled: the
|
|
31
|
+
* focus stays on them) and the form novalidate, and a second submit is
|
|
32
|
+
* ignored — prevented and stopped before anything on document sees it —
|
|
33
|
+
* until a field changes; the author's novalidate comes back after. Events
|
|
34
|
+
* on the form, bubbling: ma:form-done (detail: the response object, or {})
|
|
35
|
+
* and ma:form-error (detail: { status }, 0 when offline); exists sends
|
|
36
|
+
* neither.
|
|
37
|
+
*
|
|
38
|
+
* Success is a 2xx whose JSON body, if any, is not ok: false; 409 or
|
|
39
|
+
* status: 'exists' is exists. On success with a same-origin data-success,
|
|
40
|
+
* sessionStorage['ma:form'] = { path, at, ...carried } is written just
|
|
41
|
+
* before navigating: path is the destination's pathname, and a carried value
|
|
42
|
+
* passes only as a number or [A-Za-z0-9_-]{1,32}, so an email never does.
|
|
43
|
+
* Navigation, and the restore after a failure, wait for the finite
|
|
44
|
+
* animations inside the form (a vanish; never the infinite shimmer), with a
|
|
45
|
+
* fallback from --ma-duration and --ma-stagger-tight because animations do
|
|
46
|
+
* not run in a hidden tab. A failure puts each text value back only into a
|
|
47
|
+
* field that is still empty and focuses the first rendered one that held
|
|
48
|
+
* text (a hidden honeypot before it is skipped). Texts from the service
|
|
49
|
+
* are never shown: they cannot be translated. Back from the bfcache the form
|
|
50
|
+
* starts over: no state, no lock, no status text; a form that was submitted
|
|
51
|
+
* is also reset() — every control back to its markup, so the field the
|
|
52
|
+
* vanish emptied is no longer :user-invalid (?ref is filled in again) — while
|
|
53
|
+
* one never submitted, or refilled after a failure, keeps what the visitor
|
|
54
|
+
* typed. Zero dependencies.
|
|
55
|
+
*/
|
|
56
|
+
import { onMount, type Cleanup } from './runtime';
|
|
57
|
+
|
|
58
|
+
type Field = HTMLInputElement | HTMLTextAreaElement;
|
|
59
|
+
|
|
60
|
+
/** A value that may leave the form: a number, or a short token — never an email, never free text. */
|
|
61
|
+
const safe = (v: unknown): v is string | number => typeof v == 'number' || (typeof v == 'string' && /^[\w-]{1,32}$/.test(v));
|
|
62
|
+
|
|
63
|
+
const sleep = (ms?: number) => new Promise((r) => setTimeout(r, ms));
|
|
64
|
+
|
|
65
|
+
/** The next task (the vanish starts in a listener on document, after ours), then the form's finite animations or the token fallback. */
|
|
66
|
+
async function settle(form: HTMLFormElement, chars: number): Promise<void> {
|
|
67
|
+
await sleep();
|
|
68
|
+
const style = getComputedStyle(form);
|
|
69
|
+
// a time token in ms; 0 when the base tokens are not on the page
|
|
70
|
+
const ms = (name: string) => {
|
|
71
|
+
const v = style.getPropertyValue(name);
|
|
72
|
+
return (parseFloat(v) || 0) * (v.includes('ms') ? 1 : 1000);
|
|
73
|
+
};
|
|
74
|
+
await Promise.race([
|
|
75
|
+
Promise.allSettled(
|
|
76
|
+
form
|
|
77
|
+
.getAnimations({ subtree: true })
|
|
78
|
+
.filter((a) => a.effect?.getComputedTiming().endTime != Infinity)
|
|
79
|
+
.map((a) => a.finished),
|
|
80
|
+
),
|
|
81
|
+
sleep(2 * ms('--ma-duration') + chars * ms('--ma-stagger-tight')),
|
|
82
|
+
]);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const FALLBACK: Record<string, string> = { 429: 'error', offline: 'error', exists: 'done' };
|
|
86
|
+
const JSON_TYPE = 'application/json';
|
|
87
|
+
|
|
88
|
+
let off: Cleanup | undefined;
|
|
89
|
+
|
|
90
|
+
export function bindForms(): Cleanup {
|
|
91
|
+
off ??= onMount<HTMLFormElement>('form[data-ma-form]', (form, { signal }) => {
|
|
92
|
+
const attr = (name: string) => form.getAttribute(name);
|
|
93
|
+
const data = form.dataset;
|
|
94
|
+
const status = document.getElementById(attr('data-ma-form-status')!);
|
|
95
|
+
const novalidate = form.noValidate;
|
|
96
|
+
// 0 ready · 1 pending · 2 done or exists (submits ignored until a field changes) · 3 leaving
|
|
97
|
+
let state = 0;
|
|
98
|
+
|
|
99
|
+
// the last text asked for: shown, or about to be
|
|
100
|
+
let text = '';
|
|
101
|
+
const say = (key: string) => {
|
|
102
|
+
const was = text;
|
|
103
|
+
text = attr('data-msg-' + key) ?? attr('data-msg-' + FALLBACK[key]) ?? '';
|
|
104
|
+
if (!status) return;
|
|
105
|
+
// the same text again is emptied for two frames first (as CopyButton does), so it is announced again; a newer text wins
|
|
106
|
+
if (text == was) {
|
|
107
|
+
status.textContent = '';
|
|
108
|
+
requestAnimationFrame(() => requestAnimationFrame(() => (status.textContent ||= text)));
|
|
109
|
+
} else status.textContent = text;
|
|
110
|
+
};
|
|
111
|
+
const lock = (on: boolean) => {
|
|
112
|
+
for (const b of form.elements) if ((b as HTMLButtonElement).type == 'submit') on ? b.setAttribute('aria-disabled', 'true') : b.removeAttribute('aria-disabled');
|
|
113
|
+
form.noValidate = on || novalidate;
|
|
114
|
+
};
|
|
115
|
+
const ready = () => {
|
|
116
|
+
state = 0;
|
|
117
|
+
lock(false);
|
|
118
|
+
delete data['state'];
|
|
119
|
+
delete data['pending'];
|
|
120
|
+
};
|
|
121
|
+
const fire = (type: string, detail: unknown) => form.dispatchEvent(new CustomEvent(type, { bubbles: true, detail }));
|
|
122
|
+
|
|
123
|
+
// ?ref=… into the field of that name (netlify renders one), else appended at submit
|
|
124
|
+
const refName = attr('data-ref-param');
|
|
125
|
+
const ref = refName && new URLSearchParams(location.search).get(refName);
|
|
126
|
+
const refField = refName ? (form.elements.namedItem(refName) as HTMLInputElement | null) : null;
|
|
127
|
+
const fillRef = () => {
|
|
128
|
+
if (refField && safe(ref)) refField.value = ref;
|
|
129
|
+
};
|
|
130
|
+
fillRef();
|
|
131
|
+
|
|
132
|
+
form.addEventListener(
|
|
133
|
+
'submit',
|
|
134
|
+
async (e) => {
|
|
135
|
+
const demo = attr('data-ma-form-demo');
|
|
136
|
+
if (demo == null && !/^post$/i.test(attr('method')!)) return;
|
|
137
|
+
e.preventDefault();
|
|
138
|
+
if (state) return e.stopPropagation();
|
|
139
|
+
const body = new FormData(form, e.submitter);
|
|
140
|
+
if (!refField && safe(ref)) body.append(refName!, ref);
|
|
141
|
+
const saved = [...form.elements].filter((f): f is Field => /^(text|email|search|url|tel|textarea)$/.test((f as Field).type)).map((f) => [f, f.value] as const);
|
|
142
|
+
state = 1;
|
|
143
|
+
delete data['state'];
|
|
144
|
+
data['pending'] = '';
|
|
145
|
+
lock(true);
|
|
146
|
+
say('pending');
|
|
147
|
+
|
|
148
|
+
let reply: Record<string, unknown> = {};
|
|
149
|
+
let code = 200;
|
|
150
|
+
let key: string;
|
|
151
|
+
try {
|
|
152
|
+
if (demo != null) reply = JSON.parse(demo || '{}');
|
|
153
|
+
else {
|
|
154
|
+
const encode = attr('data-encode');
|
|
155
|
+
const r = await fetch(attr('data-endpoint') || attr('action') || '', {
|
|
156
|
+
method: 'POST',
|
|
157
|
+
headers: encode == 'json' ? { Accept: JSON_TYPE, 'Content-Type': JSON_TYPE } : { Accept: JSON_TYPE },
|
|
158
|
+
body: encode == 'json' ? JSON.stringify(Object.fromEntries(body)) : encode == 'urlencoded' ? new URLSearchParams(body as unknown as string[][]) : body,
|
|
159
|
+
signal,
|
|
160
|
+
});
|
|
161
|
+
code = r.status;
|
|
162
|
+
const json = await r.json().catch(() => 0);
|
|
163
|
+
if (json && typeof json == 'object') reply = json;
|
|
164
|
+
}
|
|
165
|
+
key = code == 409 || reply['status'] == 'exists' ? 'exists' : code > 199 && code < 300 && reply['ok'] !== false ? 'done' : code == 429 ? '429' : 'error';
|
|
166
|
+
} catch {
|
|
167
|
+
key = 'offline';
|
|
168
|
+
code = 0;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
await settle(form, saved.reduce((n, [, v]) => n + v.length, 0));
|
|
172
|
+
if (signal.aborted) return;
|
|
173
|
+
delete data['pending'];
|
|
174
|
+
say(key);
|
|
175
|
+
|
|
176
|
+
if (key == 'done' || key == 'exists') {
|
|
177
|
+
state = 2;
|
|
178
|
+
data['state'] = key;
|
|
179
|
+
if (key == 'exists') return;
|
|
180
|
+
fire('ma:form-done', reply);
|
|
181
|
+
const success = attr('data-success');
|
|
182
|
+
if (!success) return;
|
|
183
|
+
state = 3;
|
|
184
|
+
await settle(form, 0);
|
|
185
|
+
if (signal.aborted) return;
|
|
186
|
+
const url = new URL(success, location.href);
|
|
187
|
+
if (url.origin == location.origin) {
|
|
188
|
+
const record: Record<string, unknown> = { path: url.pathname, at: Date.now() };
|
|
189
|
+
for (const k of attr('data-carry')?.split(' ') ?? []) if (!(k in record) && safe(reply[k])) record[k] = reply[k];
|
|
190
|
+
try {
|
|
191
|
+
sessionStorage.setItem('ma:form', JSON.stringify(record));
|
|
192
|
+
} catch {}
|
|
193
|
+
}
|
|
194
|
+
lock(false);
|
|
195
|
+
state = 2;
|
|
196
|
+
return location.assign(url);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
ready();
|
|
200
|
+
data['state'] = 'error';
|
|
201
|
+
for (const [f, v] of saved) f.value ||= v;
|
|
202
|
+
// the first field that held text and is rendered: never a honeypot (empty, display: none) before it
|
|
203
|
+
saved.find(([f, v]) => v && f.getClientRects().length)?.[0].focus();
|
|
204
|
+
fire('ma:form-error', { status: code });
|
|
205
|
+
},
|
|
206
|
+
{ signal },
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
form.addEventListener('input', () => state == 2 && ready(), { signal });
|
|
210
|
+
addEventListener(
|
|
211
|
+
'pageshow',
|
|
212
|
+
(e) => {
|
|
213
|
+
if (!e.persisted) return;
|
|
214
|
+
// a submitted form starts over: reset() also clears the user validity of the field the vanish emptied, which
|
|
215
|
+
// :user-invalid would otherwise paint as an error once data-state is gone. A form never submitted (or refilled
|
|
216
|
+
// after a failure) is state 0 and keeps what the visitor typed.
|
|
217
|
+
if (state) {
|
|
218
|
+
form.reset();
|
|
219
|
+
fillRef();
|
|
220
|
+
}
|
|
221
|
+
ready();
|
|
222
|
+
if (status) status.textContent = '';
|
|
223
|
+
},
|
|
224
|
+
{ signal },
|
|
225
|
+
);
|
|
226
|
+
});
|
|
227
|
+
return () => {
|
|
228
|
+
off?.();
|
|
229
|
+
off = undefined;
|
|
230
|
+
};
|
|
231
|
+
}
|
package/src/lib/geo.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Geography shared by DottedMap (server) and Globe (server + client):
|
|
3
|
+
* the land-mask lookup, the unit-sphere and equirectangular projections, and
|
|
4
|
+
* the row sampling that turns a lat/lng mask into evenly spread dots. No
|
|
5
|
+
* data here — `land.ts` holds the mask and is imported on the server only;
|
|
6
|
+
* Globe ships a downsampled copy of it as data in its HTML and decodes it
|
|
7
|
+
* with `maskAt` below, so the client bundle carries these few lines and
|
|
8
|
+
* never the mask.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface Marker {
|
|
12
|
+
lat: number;
|
|
13
|
+
lng: number;
|
|
14
|
+
label?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** One bit per cell in a base64 string, row-major from the north-west corner; `w × h` cells over the whole globe. */
|
|
18
|
+
export interface Mask {
|
|
19
|
+
w: number;
|
|
20
|
+
h: number;
|
|
21
|
+
bits: Uint8Array;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const decode = (b64: string, w: number, h: number): Mask => ({ w, h, bits: Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)) });
|
|
25
|
+
|
|
26
|
+
/** True when the cell containing lat/lng is land. */
|
|
27
|
+
export function maskAt(m: Mask, lat: number, lng: number): boolean {
|
|
28
|
+
const col = ((Math.floor(((lng + 180) / 360) * m.w) % m.w) + m.w) % m.w;
|
|
29
|
+
const row = Math.min(m.h - 1, Math.max(0, Math.floor(((90 - lat) / 180) * m.h)));
|
|
30
|
+
const k = row * m.w + col;
|
|
31
|
+
return (m.bits[k >> 3]! >> (k & 7)) & 1 ? true : false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** lat/lng in degrees → a point on the unit sphere, y up, z toward the viewer at lng 0. */
|
|
35
|
+
export function toSphere(lat: number, lng: number): [number, number, number] {
|
|
36
|
+
const la = (lat * Math.PI) / 180;
|
|
37
|
+
const lo = (lng * Math.PI) / 180;
|
|
38
|
+
const c = Math.cos(la);
|
|
39
|
+
return [c * Math.sin(lo), Math.sin(la), c * Math.cos(lo)];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** lat/lng → percent of an equirectangular map (0…100 across, 0…100 down). */
|
|
43
|
+
export const toMap = (lat: number, lng: number): [number, number] => [((lng + 180) / 360) * 100, ((90 - lat) / 180) * 100];
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Rows of dots `step` degrees apart in latitude, with the columns per row
|
|
47
|
+
* scaled by cos(lat) so the dots stay evenly spaced on the sphere instead of
|
|
48
|
+
* crowding at the poles. Yields the land cells only; a step of 0 or less
|
|
49
|
+
* yields none (it would never leave the loop, and the page would hang).
|
|
50
|
+
*/
|
|
51
|
+
export function landRows(m: Mask, step: number, south = -90, north = 90): Array<[number, number]> {
|
|
52
|
+
const out: Array<[number, number]> = [];
|
|
53
|
+
for (let lat = north - step / 2; step > 0 && lat > south; lat -= step) {
|
|
54
|
+
const cols = Math.max(1, Math.round((360 / step) * Math.cos((lat * Math.PI) / 180)));
|
|
55
|
+
for (let i = 0; i < cols; i++) {
|
|
56
|
+
const lng = -180 + ((i + 0.5) * 360) / cols;
|
|
57
|
+
if (maskAt(m, lat, lng)) out.push([lat, lng]);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The 1° mask reduced to `step`-degree cells (a cell is land when its centre is). */
|
|
64
|
+
export function downsample(m: Mask, step: number): { w: number; h: number; b64: string } {
|
|
65
|
+
const w = Math.round(360 / step);
|
|
66
|
+
const h = Math.round(180 / step);
|
|
67
|
+
const bits = new Uint8Array(Math.ceil((w * h) / 8));
|
|
68
|
+
for (let row = 0; row < h; row++) {
|
|
69
|
+
for (let col = 0; col < w; col++) {
|
|
70
|
+
if (maskAt(m, 90 - (row + 0.5) * step, -180 + (col + 0.5) * step)) {
|
|
71
|
+
const k = row * w + col;
|
|
72
|
+
bits[k >> 3]! |= 1 << (k & 7);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return { w, h, b64: btoa(String.fromCharCode(...bits)) };
|
|
77
|
+
}
|
package/src/lib/gsap.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @moonarc/core/gsap — GSAP that survives <ClientRouter />.
|
|
3
|
+
*
|
|
4
|
+
* The recurring failure: tweens and ScrollTriggers created on page one keep
|
|
5
|
+
* running against detached nodes after a swap, pin-spacers pile up, and
|
|
6
|
+
* ScrollTrigger positions are measured against the old layout. GSAP already
|
|
7
|
+
* has the right primitive — gsap.context() + revert() — this adapter runs it
|
|
8
|
+
* on the Astro lifecycle and refreshes ScrollTrigger when layout is real.
|
|
9
|
+
*
|
|
10
|
+
* `gsap` is passed in, never imported: core stays dependency-free and the
|
|
11
|
+
* consumer keeps one GSAP instance.
|
|
12
|
+
*/
|
|
13
|
+
import { onPage, type Cleanup } from './runtime';
|
|
14
|
+
|
|
15
|
+
/** The slice of GSAP this adapter touches — structural, so no type dependency on `gsap`. */
|
|
16
|
+
export interface GsapLike {
|
|
17
|
+
context(func: (self: unknown) => void, scope?: Element | string): { revert(): void };
|
|
18
|
+
}
|
|
19
|
+
export interface ScrollTriggerLike {
|
|
20
|
+
refresh(safe?: boolean): void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface GsapPageOptions {
|
|
24
|
+
/** Scope passed to gsap.context(): selector text inside `setup` resolves within it. */
|
|
25
|
+
scope?: Element | string;
|
|
26
|
+
/** Pass ScrollTrigger to recalculate positions after each navigation and once web fonts settle. */
|
|
27
|
+
scrollTrigger?: ScrollTriggerLike | null;
|
|
28
|
+
/** Skip the document.fonts.ready refresh. @default false */
|
|
29
|
+
noFontRefresh?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Run `setup` once per page view inside `gsap.context()`, and revert it —
|
|
34
|
+
* tweens, ScrollTriggers, pin-spacers, inline styles — before the next swap.
|
|
35
|
+
* Works on pages without the router too. Returns an unregister function.
|
|
36
|
+
*/
|
|
37
|
+
export function gsapPage<G extends GsapLike>(
|
|
38
|
+
gsap: G,
|
|
39
|
+
setup: (self: Parameters<Parameters<G['context']>[0]>[0]) => void,
|
|
40
|
+
options: GsapPageOptions = {},
|
|
41
|
+
): Cleanup {
|
|
42
|
+
return onPage(({ signal }) => {
|
|
43
|
+
const ctx = gsap.context(setup as (self: unknown) => void, options.scope);
|
|
44
|
+
const st = options.scrollTrigger;
|
|
45
|
+
if (st) {
|
|
46
|
+
// Positions depend on layout: measure after first paint, and again when web fonts settle.
|
|
47
|
+
requestAnimationFrame(() => {
|
|
48
|
+
if (!signal.aborted) st.refresh();
|
|
49
|
+
});
|
|
50
|
+
if (!options.noFontRefresh && 'fonts' in document) {
|
|
51
|
+
document.fonts.ready.then(() => {
|
|
52
|
+
if (!signal.aborted) st.refresh();
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return () => ctx.revert();
|
|
57
|
+
});
|
|
58
|
+
}
|
package/src/lib/land.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Land bitmask, 1° cells, 360 × 180 — generated by scripts/build-land.mjs from
|
|
3
|
+
* Natural Earth 1:110m land (public domain). Row 0 is latitude 90…89 N, column 0 is
|
|
4
|
+
* longitude −180…−179; bit k = row × 360 + col, little-endian within each byte.
|
|
5
|
+
* 21538 land cells (33.2 %). Imported on the server only —
|
|
6
|
+
* DottedMap draws a path from it, Globe sends a downsampled copy as data in its HTML.
|
|
7
|
+
* Do not edit; rerun the script.
|
|
8
|
+
*/
|
|
9
|
+
export const LAND_W = 360;
|
|
10
|
+
export const LAND_H = 180;
|
|
11
|
+
export const LAND = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADg/wEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADA/v8/AOD//z8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD+///D/////5//AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPAH/j/+//////8PAAAAgB0AAHgAAAAAAD8AAAAAAAAAAAAAAAAAAAAAAAAAAOD//wP8//////8BAACAfx4AAAAAAAAAAP4AAAAAAAAAAAAAAAAAAAAAAAA4+I73//n///////8AAAAAfwAAAAAAAAAAAAAfAAAAAAAAAAAAAAAAAAAAAOAQACDwP4D///////8BAAAAPAQAAAAAAAAAAAAYAAAAAAAAAAAAAAAAAAAAADzAgPP5H8D//////38AAAAAAAAAAAAA4AEAAAD+DwAAAAAAAAAAAAAAAAAAAIC/g7PfBwAA/v////8AAAAAAAAAAADgBwAAwP//PwAA4B8AAAAAAAAAAAAAAAAMAAD8AgAA+P////8AAAAAAAAAAABwAAAA/P//AwAAAAAAAAAAAAAAAAAAAP8Ahvc5OwAA8P///38AAAAAAAAAAAAcAADg////34cHAAcAAAAAAAAAAAAAgN9/xzf8RwAA4P///z8AAAAAAAAAAAAPAB7g//////8fgB8AAAAAAQAAAAAAAO//B3f8/wsA4P///z8AAAAAAAAAAAAPAG////////8f2f8HAAAAAAD4HwAAAAD8H/D4/38A4P///xMAAAAAAPAHAAAAgN//////////////DwAAAAD+///h/4//H+/Bg/8BwP7//w8AAAAAwP8fAAAAj9///////////////88/AeD///////8DB8TPB/4AAP///wAAAAAA8P//AwDjf77/////////////////DwD////////////vh/kPwP//BwAAAAAA/P//H/P//5//////////////////34H8////////////Afw/gP//AQAAAAAA/v+fH////+///////////////////vD///////////9fAPwYgP8PAOA/AAAA/+N/8P//////////////////////QID4///////////Pw/0DAP8HAMAfAACA//F//v////////////////////8/AAP4///////////BBPAHAP4HAAAAAADg//z///////////////////////9/AID///////////8AQ8ABAPwBAAAAAAD8P/7///////////////////////9fAMD//////////z8AwA8AAPgBAAAAAAD+H/7/////////////////////Of8BAID///z//////z8AwD8AAMABAAAAAAD+P/z///////////////////9/wH8AAAD8MwD//////x8AwH8MAAAAAAAAAAD+P2D///////////////////95cAAAAACAAwD8/////38AwP8eAAAAAAAAgAGcH/D//////////////////wEAOAAAAADADgDA/////38AgP8/AAAAAAAAwANAH/T//////////////////wAAfgAAAAAwAAAA//////8PgP8/AAAAAAAAwAFwD/7/////////////////PwAAfwAAAAAGAAAA//////8/wP//AAAAAAAAwAOwA/7/////////////////DwCAPwAAAIAAAAAA/v//////8///BwAAAAAAOAcgcP//////////////////HwAAHwAAAAAAAACA+P//////4///DwAAAAAAOA74/////////////////////wUADwAAAAAAAAAA8P//////4///DwAAAAAAGD///////////////////////wUAAwAAAAAAAAAA8P//////7///FwAAAAAAAB///////////////////////wUAAgAAAAAAAAAA0P//////////DAAAAAAAgOH//////////////////////w0AAAAAAAAAAAAAYP////////8xLAAAAAAAAPz//////////////////////wwAAAAAAAAAAAAAAP7//////38HfgAAAAAAgP///////////////////////wQAAAAAAAAAAAAAAP3///////8HUAAAAAAAAP7/////////////////////fwQAAAAAAAAAAAAAAP////////+XAAAAAAAAAPz///93/D/+////////////PwwAAAAAAAAAAAAAAP////////9/AAAAAAAAAPj//f9j/g/+////////////HwAAAAAAAAAAAAAAAP////////8MAAAAAAAAAPj/+P8B/Mf/////////////DwQAAAAAAAAAAAAAAP///////z8AAAAAAAAAcPzH8/8A8I//////////////Bx4AAAAAAAAAAAAAAP///////x8AAAAAAAAA+H+Aw/8AwA/+//////////9/AA8AAAAAAAAAAAAAAP///////x8AAAAAAAAA+D8Aj//w4R/8//////////8/AAAAAAAAAAAAAAAAAP///////wMAAAAAAAAA+B8wuE/+/z/+/////////98fAAMAAAAAAAAAAAAAAP///////wMAAAAAAAAA+A8wEMf//x/+/////////0cOAAMAAAAAAAAAAAAAAP7//////wEAAAAAAAAA+A8AAI7//x/8/////////wMOAAEAAAAAAAAAAAAAAP7//////wAAAAAAAAAA+AcABob//z/8/////////zccwAEAAAAAAAAAAAAAAPz//////wAAAAAAAAAAQOB/AAQ2/////////////x8c8AEAAAAAAAAAAAAAAPj//////wAAAAAAAAAAQPx/AAAA/////////////w8Y/gEAAAAAAAAAAAAAAPD/////fwAAAAAAAAAA4P8/AAAA/////////////w+EGwAAAAAAAAAAAAAAAMD/////HwAAAAAAAAAA8P9/AACA/////////////x/AAwAAAAAAAAAAAAAAAID/////DwAAAAAAAAAA+P//BwaA/////////////x/AAAAAAAAAAAAAAAAAAID9////BwAAAAAAAAAA/P//D3+E/////////////z9AAAAAAAAAAAAAAAAAAAD5////BwAAAAAAAAAA/P//f////////////////x8AAAAAAAAAAAAAAAAAAADy/x8GBgAAAAAAAAAA/P/////v/8///////////z8AAAAAAAAAAAAAAAAAAAD0/w8ABgAAAAAAAAAA/v////+//4///////////z8AAAAAAAAAAAAAAAAAAADu/wcADgAAAAAAAACA//////8f/x/+/////////x8AAAAAAAAAAAAAAAAAAACI/wcALAAAAAAAAADA//////8//z/g/////////w8AAAAAAAAAAAAAAAAAAACQ/wcACAAAAAAAAADg//////9//r+A/////////wcAAAAAAAAAAAAAAAAAAAAQ/wMAAAAAAAAAAADg//////9//n8cgP///////ycAAAAAAAAAAAAAAAAAAAAA/gMAAAAAAAAAAADw////////+P9/AP///////xEAAAAAAAAAAAAAAAAAAAAA/AMAHQAAAAAAAADw////////+P//APz/f///fxAAAAAAAAAAAAAAAAAAAAAA+AcAYAAAAAAAAAD4////////+f9/APz/B///BgAAAAAAAAAAAAAAAAAAAAAA/AccgAEAAAAAAADw////////8f9/AOD/B/5/AAAAAAAAAAAAAAAAAQAAAAAA+A8eADgAAAAAAADw////////4f8/AOD/Afw/BgAAAAAAAAAAAAAAAAAAAAAA8J8PAPwCAAAAAADw////////4/8fAOD/APw/AgAAAAAAAAAAAAAAAAAAAAAAgP8PAAAAAAAAAADw////////x/8HAOB/APx/ADAAAAAAAAAAAAAAAAAAAAAAAP4PAAAAAAAAAAD4////////h/8BAOA/APz/ADAAAAAAAAAAAAAAAAAAAAAAAID/AAAAAAAAAAD4////////j/8AAMAPAMD/ATAAAAAAAAAAAAAAAAAAAAAAAAD/AQAAAAAAAAD4////////nx8AAMAPAMD/ATAAAAAAAAAAAAAAAAAAAAAAAAD8AAAAAAAAAAD4////////vwcAAIAPAMD/AcAAAAAAAAAAAAAAAAAAAAAAAADgAQAAAAAAAAD4////////fwAAAIAPAMD8AQABAAAAAAAAAAAAAAAAAAAAAADAAAgAAAAAAADw////////f2AAAAAPAID4AUACAAAAAAAAAAAAAAAAAAAAAADAAe9TAAAAAADg/////////34AAAAPAEBwAAgAAAAAAAAAAAAAAAAAAAAAAAAAE+9/AAAAAADA/////////38AAAAXAEAgAAQCAAAAAAAAAAAAAAAAAAAAAAAAz///AAAAAACA/////////z8AAAASAMAAAIACAAAAAAAAAAAAAAAAAAAAAAAAyP//AQAAAACA/////////z8AAAAwAIAAAEAHAAAAAAAAAAAAAAAAAAAAAAAAgP//AwAAAAAA/v///////x8AAAAwAAADAAMBAAAAAAAAAAAAAAAAAAAAAAAAwP//fwAAAAAA/A/+/////x8AAAAAAAAHAAcAAAAAAAAAAAAAAAAAAAAAAAAAgP///wAAAAAAEAD8/////w8AAAAAADAGwAcAAAAAAAAAAAAAAAAAAAAAAAAAgP///wEAAAAAAADA/////wcAAAAAAGAG4AEAAAAAAAAAAAAAAAAAAAAAAAAA4P///wEAAAAAAADA/////wMAAAAAAMAM+AMAAAAAAAAAAAAAAAAAAAAAAAAA4P///wMAAAAAAADg/////wEAAAAAAIAL/gMQAAAAAAAAAAAAAAAAAAAAAAAA8P///wMAAAAAAADg////fwAAAAAAAIAH/vMQAAAAAAAAAAAAAAAAAAAAAAAA8P///wcAAAAAAADg////PwAAAAAAAAAP/gMAAQAAAAAAAAAAAAAAAAAAAAAA+P///38AAAAAAADg////PwAAAAAAAAAO/DmAAwAAAAAAAAAAAAAAAAAAAAAA+P///38BAAAAAADA////HwAAAAAAAAA+/DkA8gAAAAAAAAAAAAAAAAAAAAAA8P////8fAAAAAACA////DwAAAAAAAAA8wChE/gcAAAAAAAAAAAAAAAAAAAAA+P////8/AAAAAACA////BwAAAAAAAAA4AEAA8B8AAAAAAAAAAAAAAAAAAAAA+P//////AQAAAAAA////BwAAAAAAAAAwAEAAxD8NAAAAAAAAAAAAAAAAAAAA+P//////AQAAAAAA////BwAAAAAAAADAAQAAxP+AAAAAAAAAAAAAAAAAAAAA8P//////AQAAAAAA/v//BwAAAAAAAACAHwAAwH8ABAAAAAAAAAAAAAAAAAAA4P//////AQAAAAAA/v//BwAAAAAAAAAAQFYEAMcAAAAAAAAAAAAAAAAAAAAA4P//////AAAAAAAA/v//DwAAAAAAAAAAAAgBAIABMAAAAAAAAAAAAAAAAAAAwP//////AAAAAAAA/v//DwAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAgP////9/AAAAAAAA/P//DwAAAAAAAAAAAAAAAQQAAAAAAAAAAAAAAAAAAAAAgP////8/AAAAAAAA/v//HyAAAAAAAAAAAACAHwQAAAAAAAAAAAAAAAAAAAAAAP////8fAAAAAAAA/v//HyAAAAAAAAAAAADADwwAAAAAAAAAAAAAAAAAAAAAAP////8fAAAAAAAA////HzAAAAAAAAAAAADsDxwAAAAAAAAAAAAAAAAAAAAAAP7///8fAAAAAAAA////DzgAAAAAAAAAAAD/DxwAAAAAAAAAAAAAAAAAAAAAAPj///8fAAAAAAAA////Dz8AAAAAAAAAAAD/Pz4AAAiAAAAAAAAAAAAAAAAAAOD///8fAAAAAAAA////Ax8AAAAAAAAAAMD//z4AAABAAAAAAAAAAAAAAAAAAMD///8PAAAAAAAA////AB8AAAAAAAAAAMD//z8AAAAAAAAAAAAAAAAAAAAAAMD///8PAAAAAAAA/v9/AB8AAAAAAAAAAOD//38AAAAAAAAAAAAAAAAAAAAAAMD///8PAAAAAAAA/v9/AB8AAAAAAAAAAPz///8BAAEAAAAAAAAAAAAAAAAAAMD///8HAAAAAAAA/P9/gA8AAAAAAAAAgP////8BAAIAAAAAAAAAAAAAAAAAAMD///8DAAAAAAAA/P//gA8AAAAAAAAAwP////8DAAAAAAAAAAAAAAAAAAAAAMD//38AAAAAAAAA/P9/AA8AAAAAAAAAwP////8HAAAAAAAAAAAAAAAAAAAAAOD//x8AAAAAAAAA+P9/AAcAAAAAAAAA4P////8PAAAAAAAAAAAAAAAAAAAAAOD//w8AAAAAAAAA+P8fAAIAAAAAAAAAwP////8fAAAAAAAAAAAAAAAAAAAAAOD//wcAAAAAAAAA+P8fAAAAAAAAAAAA4P////8fAAAAAAAAAAAAAAAAAAAAAOD//wcAAAAAAAAA+P8fAAAAAAAAAAAAwP////8fAAAAAAAAAAAAAAAAAAAAAOD//wcAAAAAAAAA8P8PAAAAAAAAAAAAgP////8/AAAAAAAAAAAAAAAAAAAAAOD//wMAAAAAAAAA4P8HAAAAAAAAAAAAgP////8fAAAAAAAAAAAAAAAAAAAAAPD//wMAAAAAAAAA4P8HAAAAAAAAAAAAgP////8fAAAAAAAAAAAAAAAAAAAAAPD//wEAAAAAAAAAwP8DAAAAAAAAAAAAAP////8fAAAAAAAAAAAAAAAAAAAAAOD//wAAAAAAAAAAwP8BAAAAAAAAAAAAAP8D/P8PAAAAAAAAAAAAAAAAAAAAAPD/fwAAAAAAAAAAwH8AAAAAAAAAAAAAgP8A2P8HAAAAAAAAAAAAAAAAAAAAAPD/OwAAAAAAAAAAgAEAAAAAAAAAAAAAAAcA6P8HAAAAAAAAAAAAAAAAAAAAAPj/BwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwP8DAAACAAAAAAAAAAAAAAAAAPj/BwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8DAAAEAAAAAAAAAAAAAAAAAPz/BwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8DAAAIAAAAAAAAAAAAAAAAAPj/AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGgAAAA4AAAAAAAAAAAAAAAAAPg/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcAAAAAAAAAAAAAAAAAPw/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAYAAAAAAAAAAAAAAAAAPwHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAAAAALAAAAAAAAAAAAAAAAAPwfAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAAAIADAAAAAAAAAAAAAAAAAPgHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAMABAAAAAAAAAAAAAAAAAPwHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHAAAAAAAAAAAAAAAAAAAP4BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHgAAAAAAAAAAAAAAAAAAP4BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAAAAAAAAAAAAAAAAAAAP4DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH6AAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPADAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAOAAAAAAAAAAAAAAAAAIAfAAAAAAAeeADADwAAAAAAAAAAAAAAAAAAAAAAAAAHAAAAAAAAAAAAAAAAAPD/AADA/////////z8AAAAAAAAAAAAAAAAAAAAAAAAHAAAAAAAAAAAAAAAAgP///wH4//////////8DAAAAAAAAAAAAAAAAAAAAADAfAAAAAAAAAAAAAADw8////wP+////////////AwAAAAAAAAAAAAAAAAAAAHA/AAAAAAAAAAA8//P//////+D/////////////HwAAAAAAAAAAAAAAAAAAAP5+AAAAAAAA5v////////////D//////////////38AAAAAAAAAAAAAAAAAAAB+AAAAAACA/////////////////////////////38AAAAAAAAAYAYA4ON/OPh/AAAAAADw/////////////////////////////x8AAAAAAADwv/MXgP////8fAAAAAADw/////////////////////////////wMAAAAAAPz///////////8HAAAAAAD+/////////////////////////////wAAAAAAAPz//////////z8AAAAAAP///////////////////////////////wAAAADA/////////////wEAAAAA8P///////////////////////////////wAAAACH////////////PwAAAPgA/////////////////////////////////wcAAAAcgP//////////fwAAAP4BwP//////////////////////////////HwAAAAAAAP7//////////wf8wD8AwP//////////////////////////////DwAAAAAA/v////////////8HAAD4////////////////////////////////HwAAAAAA+P//////////////4f///////////////////////////////////wAAAAAA/P///////////////////////////////////////////////////x8A3x8AAP7///////////////////////////////////////////////////8/////f///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////';
|
package/src/lib/lenis.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @moonarc/core/lenis — Lenis that survives <ClientRouter />.
|
|
3
|
+
*
|
|
4
|
+
* Three parties touch scroll on a routed Astro site: Lenis, the router's
|
|
5
|
+
* scroll restoration, and (often) ScrollTrigger. Nobody coordinates them.
|
|
6
|
+
* Measured on Astro 7.3.2 with Lenis 1.3.26 (2026-09-25, docs/claims-evidence.md):
|
|
7
|
+
* a navigation while Lenis is still easing leaves the next page at the old
|
|
8
|
+
* page's target, and a Lenis created on every astro:page-load adds an
|
|
9
|
+
* instance per navigation. This adapter creates Lenis once, keeps it for the
|
|
10
|
+
* life of the document, drives it from gsap.ticker when asked, and after
|
|
11
|
+
* every swap stops a running animation and resyncs Lenis to the position the
|
|
12
|
+
* router set.
|
|
13
|
+
*
|
|
14
|
+
* Call it once, from a layout script.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface LenisLike {
|
|
18
|
+
raf(time: number): void;
|
|
19
|
+
resize(): void;
|
|
20
|
+
scrollTo(target: number, options?: { immediate?: boolean; force?: boolean }): void;
|
|
21
|
+
on(event: 'scroll', handler: (lenis: unknown) => void): void;
|
|
22
|
+
destroy(): void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface LenisPageOptions {
|
|
26
|
+
/** Drive Lenis from gsap.ticker (seconds → ms) and disable lag smoothing, per GSAP's guidance. */
|
|
27
|
+
gsap?: {
|
|
28
|
+
ticker: {
|
|
29
|
+
add(cb: (time: number) => void): void;
|
|
30
|
+
remove(cb: (time: number) => void): void;
|
|
31
|
+
lagSmoothing(threshold: number): void;
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
/** Keep ScrollTrigger in step with Lenis' scroll position. */
|
|
35
|
+
scrollTrigger?: { update(): void };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface LenisHandle<L> {
|
|
39
|
+
/** The live instance (created on first call if needed). */
|
|
40
|
+
get(): L;
|
|
41
|
+
/** Tear everything down — tests and HMR; not needed on navigation. */
|
|
42
|
+
destroy(): void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The two router events a resync runs on. */
|
|
46
|
+
const EVENTS = ['astro:after-swap', 'astro:page-load'];
|
|
47
|
+
|
|
48
|
+
export function lenisPage<L extends LenisLike>(create: () => L, options: LenisPageOptions = {}): LenisHandle<L> {
|
|
49
|
+
let lenis: L | null = null;
|
|
50
|
+
let tick: ((time: number) => void) | null = null;
|
|
51
|
+
let rafId = 0;
|
|
52
|
+
|
|
53
|
+
// After a swap the router has already scrolled the window (top, or the
|
|
54
|
+
// restored position), but a wheel animation from the previous page may still
|
|
55
|
+
// be running and would carry the new page to its old target. An immediate
|
|
56
|
+
// scrollTo stops it. It runs before resize(), because Lenis 1.3's resize()
|
|
57
|
+
// already moves the target to the window's position, and a scrollTo to the
|
|
58
|
+
// target Lenis already has returns early. A restored position past the
|
|
59
|
+
// previous page's limit gets clamped, so it is set again after resize().
|
|
60
|
+
// The swap also replaced <html>'s classes with the next page's, and Lenis
|
|
61
|
+
// writes its own (`lenis`, `lenis-smooth`, `lenis-stopped`) only when its
|
|
62
|
+
// state changes, at the next scroll, so they are written again here.
|
|
63
|
+
// updateClassName() is Lenis' own (1.3.26, private in its types); a Lenis
|
|
64
|
+
// without it keeps the classes it had before, missing until that scroll.
|
|
65
|
+
const resync = () => {
|
|
66
|
+
if (!lenis) return;
|
|
67
|
+
const y = scrollY;
|
|
68
|
+
const now = { immediate: true, force: true };
|
|
69
|
+
lenis.scrollTo(y, now);
|
|
70
|
+
lenis.resize();
|
|
71
|
+
if (scrollY !== y) lenis.scrollTo(y, now);
|
|
72
|
+
(lenis as unknown as { updateClassName?(): void }).updateClassName?.();
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const init = () => {
|
|
76
|
+
if (lenis) return;
|
|
77
|
+
lenis = create();
|
|
78
|
+
const st = options.scrollTrigger;
|
|
79
|
+
if (st) lenis.on('scroll', () => st.update());
|
|
80
|
+
const g = options.gsap;
|
|
81
|
+
if (g) {
|
|
82
|
+
tick = (time) => lenis?.raf(time * 1000);
|
|
83
|
+
g.ticker.add(tick);
|
|
84
|
+
g.ticker.lagSmoothing(0);
|
|
85
|
+
} else {
|
|
86
|
+
const loop = (time: number) => {
|
|
87
|
+
lenis?.raf(time);
|
|
88
|
+
rafId = requestAnimationFrame(loop);
|
|
89
|
+
};
|
|
90
|
+
rafId = requestAnimationFrame(loop);
|
|
91
|
+
}
|
|
92
|
+
for (const e of EVENTS) document.addEventListener(e, resync);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init, { once: true });
|
|
96
|
+
else init();
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
get() {
|
|
100
|
+
init();
|
|
101
|
+
return lenis as L;
|
|
102
|
+
},
|
|
103
|
+
destroy() {
|
|
104
|
+
if (!lenis) return;
|
|
105
|
+
for (const e of EVENTS) document.removeEventListener(e, resync);
|
|
106
|
+
if (tick) options.gsap?.ticker.remove(tick);
|
|
107
|
+
cancelAnimationFrame(rafId);
|
|
108
|
+
lenis.destroy();
|
|
109
|
+
lenis = null;
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
package/src/lib/meta.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Component metadata — one typed record per component, next to its .astro
|
|
3
|
+
* file. Drives the docs pages, the registry, llms.txt, the MCP catalogue and
|
|
4
|
+
* the site's filter facets, so the same facts are never written twice.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { FeatureKey } from './support';
|
|
8
|
+
|
|
9
|
+
export type Tier = 'A' | 'B' | 'C';
|
|
10
|
+
|
|
11
|
+
/** Ten categories: five were too coarse once the catalogue passed a dozen entries; `data` arrived with the number and chart primitives. */
|
|
12
|
+
export type Category = 'scroll' | 'text' | 'data' | 'loop' | 'pointer' | 'background' | 'ui' | 'loading' | 'transition' | 'section';
|
|
13
|
+
|
|
14
|
+
/** What sets the motion off. A component can have several. */
|
|
15
|
+
export type Trigger = 'scroll' | 'hover' | 'pointer' | 'load' | 'click' | 'always';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Browser support, stated instead of assumed. `widely` = every engine for
|
|
19
|
+
* more than a release cycle; `newly` = every engine, recently; `limited` =
|
|
20
|
+
* not every engine, and `fallback` says what those browsers get.
|
|
21
|
+
*/
|
|
22
|
+
export interface Support {
|
|
23
|
+
baseline: 'widely' | 'newly' | 'limited';
|
|
24
|
+
/** Platform features the motion depends on; versions come from lib/support.ts. */
|
|
25
|
+
needs: FeatureKey[];
|
|
26
|
+
/** What a browser without `needs` shows. Required when baseline is not `widely`. */
|
|
27
|
+
fallback?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface PropMeta {
|
|
31
|
+
name: string;
|
|
32
|
+
type: string;
|
|
33
|
+
default?: string;
|
|
34
|
+
description: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ComponentMeta {
|
|
38
|
+
/** File and export name, e.g. `Reveal`. */
|
|
39
|
+
name: string;
|
|
40
|
+
/** URL slug, e.g. `reveal`. */
|
|
41
|
+
slug: string;
|
|
42
|
+
/** Human title, e.g. `Scroll Reveal`. */
|
|
43
|
+
title: string;
|
|
44
|
+
/** One paragraph, plain, for cards and search. */
|
|
45
|
+
description: string;
|
|
46
|
+
/** One sentence of 120 characters at most: what it is, in the words someone searching for it would use. The site's
|
|
47
|
+
* meta description and share card print it, followed by the measured cost; no cost or support claim goes in it
|
|
48
|
+
* (docs/seo.md). */
|
|
49
|
+
summary: string;
|
|
50
|
+
category: Category;
|
|
51
|
+
tier: Tier;
|
|
52
|
+
trigger: Trigger[];
|
|
53
|
+
support: Support;
|
|
54
|
+
/** The one line that makes it, printed under the live tile: `<Reveal stagger={60}>`. */
|
|
55
|
+
readout: string;
|
|
56
|
+
/** Usage snippet for the docs page (the body under the import). */
|
|
57
|
+
usage: string;
|
|
58
|
+
props: PropMeta[];
|
|
59
|
+
/** What the component does under prefers-reduced-motion. */
|
|
60
|
+
reducedMotion: string;
|
|
61
|
+
/** How it behaves across <ClientRouter /> navigation. */
|
|
62
|
+
clientRouter: string;
|
|
63
|
+
/** Craft notes: the easing/duration reasoning an agent should repeat. */
|
|
64
|
+
craft: string[];
|
|
65
|
+
/** Slugs of related components. */
|
|
66
|
+
related: string[];
|
|
67
|
+
/** Search intents this component answers, for search_motion. */
|
|
68
|
+
intents: string[];
|
|
69
|
+
/** Which React-world components this replaces, for the migration table. */
|
|
70
|
+
replaces: string[];
|
|
71
|
+
/** How the budget harness renders it: props, default-slot text, and the string the server HTML must contain. */
|
|
72
|
+
fixture?: {
|
|
73
|
+
props?: Record<string, unknown>;
|
|
74
|
+
slot?: string;
|
|
75
|
+
expect?: string;
|
|
76
|
+
/** Optional-prop variants measured on their own fixture page (phase 7D): `morph` → `c/Dialog--morph`, budget key `Dialog.morph`. Props are merged over the default fixture's. */
|
|
77
|
+
variants?: Record<string, { props?: Record<string, unknown>; slot?: string; expect?: string }>;
|
|
78
|
+
};
|
|
79
|
+
}
|