@cparkerwebm/webmonterey 1.0.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/CHANGELOG.md +56 -0
- package/LICENSE +21 -0
- package/README.md +104 -0
- package/agents/.gitkeep +0 -0
- package/dist/webm.mjs +2381 -0
- package/hooks/.gitkeep +0 -0
- package/package.json +101 -0
- package/schema/design.json +118 -0
- package/skills/launch/SKILL.md +183 -0
- package/skills/new-component/SKILL.md +85 -0
- package/skills/start/SKILL.md +117 -0
- package/skills/traps/SKILL.md +333 -0
- package/skills/upgrade/SKILL.md +42 -0
- package/src/actions/index.ts +348 -0
- package/src/cli/checks.test.ts +711 -0
- package/src/cli/checks.ts +822 -0
- package/src/cli/codemods.test.ts +42 -0
- package/src/cli/codemods.ts +51 -0
- package/src/cli/compare.test.ts +144 -0
- package/src/cli/compare.ts +222 -0
- package/src/cli/design-extract.test.ts +96 -0
- package/src/cli/design-extract.ts +229 -0
- package/src/cli/doctor.ts +187 -0
- package/src/cli/mcp.test.ts +57 -0
- package/src/cli/mcp.ts +110 -0
- package/src/cli/new.ts +148 -0
- package/src/cli/package-root.ts +58 -0
- package/src/cli/scaffold.test.ts +230 -0
- package/src/cli/scaffold.ts +424 -0
- package/src/cli/seed.ts +133 -0
- package/src/cli/slug.test.ts +70 -0
- package/src/cli/slug.ts +109 -0
- package/src/cli/sync.test.ts +137 -0
- package/src/cli/sync.ts +266 -0
- package/src/cli/upgrade.ts +93 -0
- package/src/design/__fixtures__/tokens-v1.4.1.css +227 -0
- package/src/design/brand.ts +49 -0
- package/src/design/compile.test.ts +98 -0
- package/src/design/compile.ts +155 -0
- package/src/design/defaults.ts +315 -0
- package/src/design/index.ts +18 -0
- package/src/design/resolve.test.ts +80 -0
- package/src/design/resolve.ts +108 -0
- package/src/design/types.ts +96 -0
- package/src/emails/autoresponse.test.ts +82 -0
- package/src/emails/autoresponse.ts +83 -0
- package/src/emails/footer.test.ts +102 -0
- package/src/emails/footer.ts +91 -0
- package/src/emails/index.ts +24 -0
- package/src/emails/subject.test.ts +66 -0
- package/src/emails/subject.ts +80 -0
- package/src/emails/submission-notification.ts +76 -0
- package/src/env.d.ts +12 -0
- package/src/includes/cloudflare/d1/client.ts +86 -0
- package/src/includes/cloudflare/r2/README.md +68 -0
- package/src/includes/cloudflare/r2/media.ts +41 -0
- package/src/includes/cloudflare/r2/url.test.ts +44 -0
- package/src/includes/cloudflare/r2/url.ts +34 -0
- package/src/includes/cloudflare/turnstile/Turnstile.astro +161 -0
- package/src/includes/cloudflare/turnstile/verify.ts +157 -0
- package/src/includes/cloudflare/workers/env.ts +56 -0
- package/src/includes/google/tag-manager/TagManager.astro +73 -0
- package/src/includes/sinch/mailgun/redirect.test.ts +96 -0
- package/src/includes/sinch/mailgun/redirect.ts +96 -0
- package/src/includes/sinch/mailgun/send.ts +186 -0
- package/src/includes/webmonterey/animations/animations.css +347 -0
- package/src/includes/webmonterey/animations/observe.test.ts +88 -0
- package/src/includes/webmonterey/animations/observe.ts +209 -0
- package/src/includes/webmonterey/compliance/ConsentInit.astro +74 -0
- package/src/includes/webmonterey/compliance/CookieConsent.astro +558 -0
- package/src/includes/webmonterey/compliance/consent-styles.test.ts +83 -0
- package/src/includes/webmonterey/compliance/consent.ts +142 -0
- package/src/includes/webmonterey/config.test.ts +94 -0
- package/src/includes/webmonterey/config.ts +346 -0
- package/src/includes/webmonterey/copy-defaults.ts +148 -0
- package/src/includes/webmonterey/copy.ts +13 -0
- package/src/includes/webmonterey/credits/Credit.astro +80 -0
- package/src/includes/webmonterey/credits/credit.test.ts +111 -0
- package/src/includes/webmonterey/credits/credit.ts +59 -0
- package/src/includes/webmonterey/forms/honeypot.test.ts +40 -0
- package/src/includes/webmonterey/forms/honeypot.ts +66 -0
- package/src/includes/webmonterey/prose/inline.test.ts +94 -0
- package/src/includes/webmonterey/prose/inline.ts +71 -0
- package/src/includes/webmonterey/scroll-top/ScrollTop.astro +209 -0
- package/src/includes/webmonterey/site.ts +136 -0
- package/src/includes/webmonterey/structured-data/nodes.ts +315 -0
- package/src/includes/webmonterey/zoned-hour.test.ts +49 -0
- package/src/integration/adapter.ts +53 -0
- package/src/integration/app-middleware.ts +43 -0
- package/src/integration/config.ts +96 -0
- package/src/integration/content.ts +66 -0
- package/src/integration/image-size.test.ts +83 -0
- package/src/integration/image-size.ts +100 -0
- package/src/integration/index.ts +386 -0
- package/src/integration/virtual.d.ts +101 -0
- package/src/layouts/base.astro +402 -0
- package/src/package.test.ts +121 -0
- package/src/pages/404.astro +33 -0
- package/src/pages/[...slug].astro +126 -0
- package/src/pages/robots.txt.ts +41 -0
- package/src/pages/webm.astro +23 -0
- package/src/scripts/.gitkeep +0 -0
- package/src/styles/base.css +185 -0
- package/src/styles/global.css +47 -0
- package/src/styles/layers.test.ts +32 -0
- package/src/styles/layers.ts +21 -0
- package/src/styles/layout.css +235 -0
- package/src/styles/reset.css +135 -0
- package/src/styles/utilities.css +90 -0
- package/src/worker.ts +76 -0
- package/template/assets/logo.svg +14 -0
- package/template/assets/open-graph.png +0 -0
- package/template/migrations/0001_create_submissions.sql +40 -0
- package/template/migrations/README.md +41 -0
- package/template/public/android-chrome-192x192.png +0 -0
- package/template/public/android-chrome-512x512.png +0 -0
- package/template/public/apple-touch-icon.png +0 -0
- package/template/public/favicon-16x16.png +0 -0
- package/template/public/favicon-32x32.png +0 -0
- package/template/public/favicon.ico +0 -0
- package/template/public/favicon.svg +4 -0
- package/template/public/open-graph.png +0 -0
- package/template/scripts/check-node.mjs +72 -0
- package/template/scripts/test-hooks.mjs +96 -0
- package/template/site/.editorconfig +22 -0
- package/template/site/.prettierignore +15 -0
- package/template/site/.prettierrc.json +17 -0
- package/template/site/CLAUDE.md +245 -0
- package/template/site/CONTENT.md +135 -0
- package/template/site/PRIVACY-POLICY-DRAFT.md +81 -0
- package/template/site/public/_headers +70 -0
- package/template/site/src/forms/contact.json +45 -0
- package/template/workflows/ci.yml +52 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Web animations — no dependencies, and no JavaScript beyond one small observer.
|
|
3
|
+
*
|
|
4
|
+
* Three things live here:
|
|
5
|
+
*
|
|
6
|
+
* 1. ONE-SHOT ANIMATIONS (.webm-animate) — play once on render, for things that APPEAR: a
|
|
7
|
+
* banner, a dialog, a toast. Pure CSS. Works with JavaScript off.
|
|
8
|
+
*
|
|
9
|
+
* 2. SCROLL REVEAL (.webm-reveal) — a drop-in class that reveals content as it enters the
|
|
10
|
+
* viewport. Needs observe.ts, imported once from base.astro. See that section for why it
|
|
11
|
+
* is not the CSS-only `animation-timeline: view()` it used to be.
|
|
12
|
+
*
|
|
13
|
+
* 3. NAMED KEYFRAMES (webm-fade-in, webm-slide-in-up, …) that any component can reference
|
|
14
|
+
* from its own @layer webm.components.core block. This is how the consent banner, a
|
|
15
|
+
* mobile menu, a modal, or a toast animates — reuse these rather than writing new ones.
|
|
16
|
+
*
|
|
17
|
+
* BOTH systems respect prefers-reduced-motion, by opposite routes. Section 1 only ever
|
|
18
|
+
* applies inside `no-preference`, so there is nothing to undo. Section 2 must start from a
|
|
19
|
+
* hidden state, so it instead forces every element visible under `reduce` — in CSS here and
|
|
20
|
+
* in observe.ts, which is why both halves are required.
|
|
21
|
+
*
|
|
22
|
+
* IMPORTED BY src/styles/global.css, alongside the other stylesheets — nothing to wire up.
|
|
23
|
+
* It used to be opt-in, documented as an `@import` the client had to add to custom/_index.css,
|
|
24
|
+
* and the failure mode for forgetting was silent: no error, no warning, build still exits 0,
|
|
25
|
+
* every rule below simply absent from the output. That is not a choice worth offering.
|
|
26
|
+
*
|
|
27
|
+
* Section 2 needs the companion script (observe.ts, imported once from base.astro). Section 1
|
|
28
|
+
* and the keyframes are pure CSS and work with JavaScript off.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
@layer webm.utilities {
|
|
32
|
+
/* ======================================================================
|
|
33
|
+
* 1. ONE-SHOT ANIMATIONS
|
|
34
|
+
*
|
|
35
|
+
* Play once on render. For elements that appear — a banner, a dialog, a
|
|
36
|
+
* dropdown — rather than elements scrolled past.
|
|
37
|
+
*
|
|
38
|
+
* <div class="webm-animate" data-animate="slide-in-up">…</div>
|
|
39
|
+
* <div class="webm-animate" data-animate="fade-in" data-speed="slow">…</div>
|
|
40
|
+
* ==================================================================== */
|
|
41
|
+
|
|
42
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
43
|
+
.webm-animate {
|
|
44
|
+
animation-duration: var(--webm-animate-duration, var(--webm-duration-normal));
|
|
45
|
+
animation-timing-function: var(--webm-animate-ease, var(--webm-ease-out));
|
|
46
|
+
animation-fill-mode: both;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.webm-animate:where([data-animate='fade-in']) {
|
|
50
|
+
animation-name: webm-fade-in;
|
|
51
|
+
}
|
|
52
|
+
.webm-animate:where([data-animate='fade-out']) {
|
|
53
|
+
animation-name: webm-fade-out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.webm-animate:where([data-animate='slide-in-up']) {
|
|
57
|
+
animation-name: webm-fade-in, webm-slide-in-up;
|
|
58
|
+
}
|
|
59
|
+
.webm-animate:where([data-animate='slide-in-down']) {
|
|
60
|
+
animation-name: webm-fade-in, webm-slide-in-down;
|
|
61
|
+
}
|
|
62
|
+
.webm-animate:where([data-animate='slide-in-left']) {
|
|
63
|
+
animation-name: webm-fade-in, webm-slide-in-left;
|
|
64
|
+
}
|
|
65
|
+
.webm-animate:where([data-animate='slide-in-right']) {
|
|
66
|
+
animation-name: webm-fade-in, webm-slide-in-right;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.webm-animate:where([data-animate='slide-out-up']) {
|
|
70
|
+
animation-name: webm-fade-out, webm-slide-out-up;
|
|
71
|
+
}
|
|
72
|
+
.webm-animate:where([data-animate='slide-out-down']) {
|
|
73
|
+
animation-name: webm-fade-out, webm-slide-out-down;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.webm-animate:where([data-animate='scale-in']) {
|
|
77
|
+
animation-name: webm-fade-in, webm-scale-in;
|
|
78
|
+
}
|
|
79
|
+
.webm-animate:where([data-animate='scale-out']) {
|
|
80
|
+
animation-name: webm-fade-out, webm-scale-out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/* Speed. Overrides the token default above. */
|
|
84
|
+
.webm-animate:where([data-speed='fast']) {
|
|
85
|
+
--webm-animate-duration: var(--webm-duration-fast);
|
|
86
|
+
}
|
|
87
|
+
.webm-animate:where([data-speed='slow']) {
|
|
88
|
+
--webm-animate-duration: var(--webm-duration-slow);
|
|
89
|
+
}
|
|
90
|
+
.webm-animate:where([data-speed='slower']) {
|
|
91
|
+
--webm-animate-duration: var(--webm-duration-slower);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* A little overshoot, for things that should feel physical. */
|
|
95
|
+
.webm-animate:where([data-ease='spring']) {
|
|
96
|
+
--webm-animate-ease: var(--webm-ease-spring);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/* ======================================================================
|
|
101
|
+
* 2. SCROLL REVEAL
|
|
102
|
+
*
|
|
103
|
+
* Driven by IntersectionObserver (observe.ts), which does nothing but add
|
|
104
|
+
* `data-reveal-visible` once the element enters the viewport. Everything
|
|
105
|
+
* below — distance, duration, easing, the reduced-motion and print
|
|
106
|
+
* overrides — stays here in CSS.
|
|
107
|
+
*
|
|
108
|
+
* This replaced an `animation-timeline: view()` implementation. That version
|
|
109
|
+
* was Chromium-only: Firefox keeps scroll-driven animations behind a flag,
|
|
110
|
+
* so `.webm-reveal` was a silent no-op for a large share of visitors.
|
|
111
|
+
*
|
|
112
|
+
* <div class="webm-reveal" data-reveal="up">…</div>
|
|
113
|
+
* <div class="webm-reveal" data-reveal="blur" data-reveal-delay="150">…</div>
|
|
114
|
+
* <ul data-reveal-stagger="80"> <li class="webm-reveal" data-reveal="up">…
|
|
115
|
+
*
|
|
116
|
+
* THE `html.js` GUARD IS LOAD-BEARING. Every hidden state is scoped to it,
|
|
117
|
+
* and the class is set by an inline script in base.astro's <head>. With
|
|
118
|
+
* JavaScript off — or if the bundle simply fails to load — nothing is ever
|
|
119
|
+
* hidden, so content and crawlers never face a permanent opacity: 0. That
|
|
120
|
+
* failure mode is the whole reason a JS reveal needs a guard and a pure-CSS
|
|
121
|
+
* one does not.
|
|
122
|
+
* ==================================================================== */
|
|
123
|
+
|
|
124
|
+
html.js .webm-reveal {
|
|
125
|
+
/*
|
|
126
|
+
* `translate` and `scale` as their own properties, not `transform` — the same choice the
|
|
127
|
+
* keyframes below make, and for the same reason: a direction and a scale can be driven
|
|
128
|
+
* independently without one clobbering the other.
|
|
129
|
+
*/
|
|
130
|
+
transition-property: opacity, translate, scale, filter;
|
|
131
|
+
transition-duration: var(--webm-reveal-duration);
|
|
132
|
+
transition-timing-function: var(--webm-reveal-ease);
|
|
133
|
+
transition-delay: var(--webm-reveal-delay);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/* Hidden until the observer marks it visible. */
|
|
137
|
+
html.js .webm-reveal:not([data-reveal-visible]) {
|
|
138
|
+
opacity: 0;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
html.js .webm-reveal:where([data-reveal='up']):not([data-reveal-visible]) {
|
|
142
|
+
translate: 0 var(--webm-reveal-distance);
|
|
143
|
+
}
|
|
144
|
+
html.js .webm-reveal:where([data-reveal='down']):not([data-reveal-visible]) {
|
|
145
|
+
translate: 0 calc(var(--webm-reveal-distance) * -1);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/* `left` enters travelling leftwards, so it starts out to the right. */
|
|
149
|
+
html.js .webm-reveal:where([data-reveal='left']):not([data-reveal-visible]) {
|
|
150
|
+
translate: calc(var(--webm-reveal-distance) * 2) 0;
|
|
151
|
+
}
|
|
152
|
+
html.js .webm-reveal:where([data-reveal='right']):not([data-reveal-visible]) {
|
|
153
|
+
translate: calc(var(--webm-reveal-distance) * -2) 0;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
html.js .webm-reveal:where([data-reveal='scale']):not([data-reveal-visible]) {
|
|
157
|
+
scale: 0.94;
|
|
158
|
+
}
|
|
159
|
+
html.js .webm-reveal:where([data-reveal='zoom-out']):not([data-reveal-visible]) {
|
|
160
|
+
scale: 1.06;
|
|
161
|
+
}
|
|
162
|
+
html.js .webm-reveal:where([data-reveal='blur']):not([data-reveal-visible]) {
|
|
163
|
+
filter: blur(var(--webm-reveal-blur));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/*
|
|
167
|
+
* The visible state is written out rather than left to inherited defaults, so an element
|
|
168
|
+
* revealing inside an already-transformed parent lands where you expect.
|
|
169
|
+
*/
|
|
170
|
+
html.js .webm-reveal[data-reveal-visible] {
|
|
171
|
+
opacity: 1;
|
|
172
|
+
translate: none;
|
|
173
|
+
scale: none;
|
|
174
|
+
filter: none;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/*
|
|
178
|
+
* Stagger. `--webm-reveal-index` is set per child by observe.ts; the step comes from
|
|
179
|
+
* `data-reveal-stagger` on the container, defaulting to --webm-reveal-stagger-step.
|
|
180
|
+
*
|
|
181
|
+
* This is the part the old scroll-driven version could not express. Its delays came from
|
|
182
|
+
* `animation-range-start`, which meant a fixed four-step ladder — `animation-delay` is
|
|
183
|
+
* time-based and has no effect on a scroll-driven animation at all. A transition has no
|
|
184
|
+
* such restriction, so the step is real milliseconds and the count is unbounded.
|
|
185
|
+
*/
|
|
186
|
+
/*
|
|
187
|
+
* DESCENDANT, NOT `>`. This was `> .webm-reveal`, which paired with observe.ts numbering only
|
|
188
|
+
* direct children: markup that nests the revealing element one level deeper - <ul> <li>
|
|
189
|
+
* <button>, or a card wrapper that holds its border still - got no index and no delay, so the
|
|
190
|
+
* group arrived at once and looked like a stagger that had never been applied. Both halves had
|
|
191
|
+
* to change together; an index nothing reads is as useless as a rule with no index.
|
|
192
|
+
*
|
|
193
|
+
* Nesting still resolves without extra specificity, because neither value is inherited from
|
|
194
|
+
* this rule: --webm-reveal-stagger-step is an inline property on each container, so an inner
|
|
195
|
+
* one shadows an outer one for its subtree, and --webm-reveal-index is written per element by
|
|
196
|
+
* observe.ts, which numbers each element under its NEAREST container.
|
|
197
|
+
*/
|
|
198
|
+
html.js [data-reveal-stagger] .webm-reveal {
|
|
199
|
+
--webm-reveal-delay: calc(var(--webm-reveal-stagger-step) * var(--webm-reveal-index, 0));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/* Convenience aliases, kept so the documented `data-delay` ladder still works. */
|
|
203
|
+
html.js .webm-reveal:where([data-delay='1']) {
|
|
204
|
+
--webm-reveal-delay: calc(var(--webm-reveal-stagger-step) * 1);
|
|
205
|
+
}
|
|
206
|
+
html.js .webm-reveal:where([data-delay='2']) {
|
|
207
|
+
--webm-reveal-delay: calc(var(--webm-reveal-stagger-step) * 2);
|
|
208
|
+
}
|
|
209
|
+
html.js .webm-reveal:where([data-delay='3']) {
|
|
210
|
+
--webm-reveal-delay: calc(var(--webm-reveal-stagger-step) * 3);
|
|
211
|
+
}
|
|
212
|
+
html.js .webm-reveal:where([data-delay='4']) {
|
|
213
|
+
--webm-reveal-delay: calc(var(--webm-reveal-stagger-step) * 4);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/*
|
|
217
|
+
* Reduced motion. Only half the fix — observe.ts also reveals every element immediately, so
|
|
218
|
+
* a motion-sensitive visitor never has to scroll to make content appear. Both halves are
|
|
219
|
+
* required; neutralising the CSS alone would leave content gated on scroll position.
|
|
220
|
+
*/
|
|
221
|
+
@media (prefers-reduced-motion: reduce) {
|
|
222
|
+
html.js .webm-reveal,
|
|
223
|
+
html.js .webm-reveal:not([data-reveal-visible]) {
|
|
224
|
+
opacity: 1 !important;
|
|
225
|
+
translate: none !important;
|
|
226
|
+
scale: none !important;
|
|
227
|
+
filter: none !important;
|
|
228
|
+
transition: none !important;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/*
|
|
233
|
+
* Print. Only elements scrolled past carry `data-reveal-visible`, so printing a long page
|
|
234
|
+
* would otherwise emit blank space wherever the reader had not been. Same for save-as-PDF
|
|
235
|
+
* and full-page screenshots.
|
|
236
|
+
*/
|
|
237
|
+
@media print {
|
|
238
|
+
html.js .webm-reveal,
|
|
239
|
+
html.js .webm-reveal:not([data-reveal-visible]) {
|
|
240
|
+
opacity: 1 !important;
|
|
241
|
+
translate: none !important;
|
|
242
|
+
scale: none !important;
|
|
243
|
+
filter: none !important;
|
|
244
|
+
transition: none !important;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/* ======================================================================
|
|
249
|
+
* 3. KEYFRAMES
|
|
250
|
+
*
|
|
251
|
+
* Deliberately outside the reduced-motion guard: they are inert until
|
|
252
|
+
* something references them, and components reference them directly.
|
|
253
|
+
* Movement uses `translate`/`scale` rather than `transform` so two
|
|
254
|
+
* animations can drive position and scale independently without one
|
|
255
|
+
* clobbering the other.
|
|
256
|
+
* ==================================================================== */
|
|
257
|
+
|
|
258
|
+
@keyframes webm-fade-in {
|
|
259
|
+
from {
|
|
260
|
+
opacity: 0;
|
|
261
|
+
}
|
|
262
|
+
to {
|
|
263
|
+
opacity: 1;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
@keyframes webm-fade-out {
|
|
268
|
+
from {
|
|
269
|
+
opacity: 1;
|
|
270
|
+
}
|
|
271
|
+
to {
|
|
272
|
+
opacity: 0;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
@keyframes webm-slide-in-up {
|
|
277
|
+
from {
|
|
278
|
+
translate: 0 var(--webm-slide-distance, var(--webm-space-lg));
|
|
279
|
+
}
|
|
280
|
+
to {
|
|
281
|
+
translate: 0 0;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
@keyframes webm-slide-in-down {
|
|
286
|
+
from {
|
|
287
|
+
translate: 0 calc(var(--webm-slide-distance, var(--webm-space-lg)) * -1);
|
|
288
|
+
}
|
|
289
|
+
to {
|
|
290
|
+
translate: 0 0;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
@keyframes webm-slide-in-left {
|
|
295
|
+
from {
|
|
296
|
+
translate: calc(var(--webm-slide-distance, var(--webm-space-lg)) * -1) 0;
|
|
297
|
+
}
|
|
298
|
+
to {
|
|
299
|
+
translate: 0 0;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
@keyframes webm-slide-in-right {
|
|
304
|
+
from {
|
|
305
|
+
translate: var(--webm-slide-distance, var(--webm-space-lg)) 0;
|
|
306
|
+
}
|
|
307
|
+
to {
|
|
308
|
+
translate: 0 0;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
@keyframes webm-slide-out-up {
|
|
313
|
+
from {
|
|
314
|
+
translate: 0 0;
|
|
315
|
+
}
|
|
316
|
+
to {
|
|
317
|
+
translate: 0 calc(var(--webm-slide-distance, var(--webm-space-lg)) * -1);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
@keyframes webm-slide-out-down {
|
|
322
|
+
from {
|
|
323
|
+
translate: 0 0;
|
|
324
|
+
}
|
|
325
|
+
to {
|
|
326
|
+
translate: 0 var(--webm-slide-distance, var(--webm-space-lg));
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
@keyframes webm-scale-in {
|
|
331
|
+
from {
|
|
332
|
+
scale: 0.94;
|
|
333
|
+
}
|
|
334
|
+
to {
|
|
335
|
+
scale: 1;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
@keyframes webm-scale-out {
|
|
340
|
+
from {
|
|
341
|
+
scale: 1;
|
|
342
|
+
}
|
|
343
|
+
to {
|
|
344
|
+
scale: 0.94;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { ownedReveals } from './observe.ts';
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* A tree of plain objects standing in for elements, and a lookup that models what the real code
|
|
7
|
+
* does: `element.parentElement?.closest('[data-reveal-stagger]')` — walk up from the PARENT and
|
|
8
|
+
* take the first stagger container found.
|
|
9
|
+
*
|
|
10
|
+
* These test the ownership rule, which is the logic that was wrong. They do not exercise
|
|
11
|
+
* querySelectorAll or closest themselves; those are the browser's, and there is no DOM here.
|
|
12
|
+
*/
|
|
13
|
+
interface Node {
|
|
14
|
+
id: string;
|
|
15
|
+
parent?: Node;
|
|
16
|
+
stagger?: true;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const ownerOf = (element: Node): Node | null => {
|
|
20
|
+
for (let p = element.parent; p; p = p.parent) if (p.stagger) return p;
|
|
21
|
+
return null;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Everything below `root`, in document order, as querySelectorAll would return it. */
|
|
25
|
+
const descendants = (root: Node, all: Node[]): Node[] =>
|
|
26
|
+
all.filter((n) => {
|
|
27
|
+
for (let p = n.parent; p; p = p.parent) if (p === root) return true;
|
|
28
|
+
return false;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const owned = (container: Node, all: Node[]) =>
|
|
32
|
+
ownedReveals(container, descendants(container, all), ownerOf).map((n) => n.id);
|
|
33
|
+
|
|
34
|
+
test('direct children are numbered in order - the case that already worked', () => {
|
|
35
|
+
const ul: Node = { id: 'ul', stagger: true };
|
|
36
|
+
const all = [ul, { id: 'a', parent: ul }, { id: 'b', parent: ul }, { id: 'c', parent: ul }];
|
|
37
|
+
assert.deepEqual(owned(ul, all), ['a', 'b', 'c']);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/*
|
|
41
|
+
* THE REGRESSION. <ul> -> <li> -> <button class="webm-reveal">: the reveal is a grandchild, the
|
|
42
|
+
* old `container.children` walk found nothing, every item took a delay of zero, and the group
|
|
43
|
+
* arrived at once looking exactly like a stagger nobody applied. Hit on three client sites.
|
|
44
|
+
*/
|
|
45
|
+
test('a reveal nested a level deeper is owned, not skipped', () => {
|
|
46
|
+
const ul: Node = { id: 'ul', stagger: true };
|
|
47
|
+
const li1: Node = { id: 'li1', parent: ul };
|
|
48
|
+
const li2: Node = { id: 'li2', parent: ul };
|
|
49
|
+
const all = [ul, li1, li2, { id: 'btn1', parent: li1 }, { id: 'btn2', parent: li2 }];
|
|
50
|
+
assert.deepEqual(
|
|
51
|
+
owned(ul, all),
|
|
52
|
+
['li1', 'li2', 'btn1', 'btn2'].filter((id) => id.length > 0),
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('an element belongs to its NEAREST container, so a nested grid is not numbered twice', () => {
|
|
57
|
+
const section: Node = { id: 'section', stagger: true };
|
|
58
|
+
const grid: Node = { id: 'grid', parent: section, stagger: true };
|
|
59
|
+
const all = [
|
|
60
|
+
section,
|
|
61
|
+
grid,
|
|
62
|
+
{ id: 'intro', parent: section },
|
|
63
|
+
{ id: 'card1', parent: grid },
|
|
64
|
+
{ id: 'card2', parent: grid },
|
|
65
|
+
];
|
|
66
|
+
// The outer owns the intro and the grid itself - not the grid's cards.
|
|
67
|
+
assert.deepEqual(owned(section, all), ['grid', 'intro']);
|
|
68
|
+
// And the inner numbers its own from zero.
|
|
69
|
+
assert.deepEqual(owned(grid, all), ['card1', 'card2']);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
/*
|
|
73
|
+
* Why the lookup starts at the PARENT rather than the element. With `closest()` from the element,
|
|
74
|
+
* a node that is both a reveal and a nested container resolves to ITSELF, matches no outer
|
|
75
|
+
* container, and is numbered by nothing at all.
|
|
76
|
+
*/
|
|
77
|
+
test('a container that is also a reveal is owned by the container above it', () => {
|
|
78
|
+
const section: Node = { id: 'section', stagger: true };
|
|
79
|
+
const grid: Node = { id: 'grid', parent: section, stagger: true };
|
|
80
|
+
const all = [section, grid, { id: 'card', parent: grid }];
|
|
81
|
+
assert.deepEqual(owned(section, all), ['grid']);
|
|
82
|
+
assert.deepEqual(owned(grid, all), ['card']);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('a container that owns nothing yields nothing rather than throwing', () => {
|
|
86
|
+
const empty: Node = { id: 'empty', stagger: true };
|
|
87
|
+
assert.deepEqual(owned(empty, [empty]), []);
|
|
88
|
+
});
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Scroll reveal — the JavaScript half of `.webm-reveal`.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS RATHER THAN `animation-timeline: view()`. The CSS-only version of this
|
|
5
|
+
* feature is Chromium-only: Firefox keeps scroll-driven animations behind a flag and the
|
|
6
|
+
* `animation-trigger` syntax that would actually fit a reveal-on-enter API is Chromium-only
|
|
7
|
+
* too. In every other browser the `@supports` guard made the whole feature a silent no-op.
|
|
8
|
+
* IntersectionObserver is the mechanism, not a fallback for one.
|
|
9
|
+
*
|
|
10
|
+
* The split of duties is deliberate and worth keeping: this file ONLY toggles the
|
|
11
|
+
* `data-reveal-visible` attribute and copies two numbers into custom properties. Every
|
|
12
|
+
* distance, duration, easing and reduced-motion rule lives in animations.css, so a client can
|
|
13
|
+
* retheme the motion without reading a line of TypeScript.
|
|
14
|
+
*
|
|
15
|
+
* The one-shot `.webm-animate` system is untouched by any of this — it is pure CSS, plays on
|
|
16
|
+
* render rather than on scroll, and needs no JavaScript at all.
|
|
17
|
+
*
|
|
18
|
+
* Imported once from base.astro. Astro dedupes bundled <script> across component instances,
|
|
19
|
+
* so importing it elsewhere is harmless but pointless.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const SELECTOR = '.webm-reveal';
|
|
23
|
+
const STAGGER_SELECTOR = '[data-reveal-stagger]';
|
|
24
|
+
|
|
25
|
+
/** Elements already wired, so re-running init never double-observes. */
|
|
26
|
+
const registered = new WeakSet<Element>();
|
|
27
|
+
|
|
28
|
+
let observer: IntersectionObserver | null = null;
|
|
29
|
+
|
|
30
|
+
function prefersReducedMotion(): boolean {
|
|
31
|
+
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function reveal(element: Element): void {
|
|
35
|
+
element.setAttribute('data-reveal-visible', '');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Reveal on a LATER frame, not this one.
|
|
40
|
+
*
|
|
41
|
+
* A transition only starts if the browser has already rendered the property's "before" value.
|
|
42
|
+
* A bundled module script runs before first paint, so revealing an element synchronously here
|
|
43
|
+
* means its `opacity: 0` is never painted — the element goes straight to its final state and
|
|
44
|
+
* nothing animates. The result is indistinguishable from an ordinary page load, which is
|
|
45
|
+
* exactly what an above-the-fold hero looked like before this existed.
|
|
46
|
+
*
|
|
47
|
+
* TWO frames, not one. A single requestAnimationFrame callback can still be folded into the
|
|
48
|
+
* same style recalculation, which reintroduces the bug intermittently — the kind that looks
|
|
49
|
+
* fixed on the machine you tested it on. The second frame guarantees a paint in between.
|
|
50
|
+
*
|
|
51
|
+
* Only the already-in-view path needs this. Anything revealed from the observer callback is
|
|
52
|
+
* long past first paint by definition.
|
|
53
|
+
*/
|
|
54
|
+
function revealAfterPaint(element: Element): void {
|
|
55
|
+
requestAnimationFrame(() => requestAnimationFrame(() => reveal(element)));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Copy `data-reveal-delay` / `-duration` onto the element as custom properties.
|
|
60
|
+
*
|
|
61
|
+
* Custom properties rather than an inline `transition` string: the CSS keeps ownership of
|
|
62
|
+
* easing and, more importantly, of the reduced-motion and print overrides. An inline
|
|
63
|
+
* shorthand would out-specify both.
|
|
64
|
+
*/
|
|
65
|
+
function applyTiming(element: HTMLElement): void {
|
|
66
|
+
const delay = element.dataset.revealDelay;
|
|
67
|
+
if (delay) element.style.setProperty('--webm-reveal-delay', `${parseInt(delay, 10) || 0}ms`);
|
|
68
|
+
|
|
69
|
+
const duration = element.dataset.revealDuration;
|
|
70
|
+
if (duration) {
|
|
71
|
+
element.style.setProperty('--webm-reveal-duration', `${parseInt(duration, 10) || 0}ms`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The reveals a stagger container OWNS, in document order.
|
|
77
|
+
*
|
|
78
|
+
* Generic and taking its own lookup so it is a pure function of its arguments: the ownership
|
|
79
|
+
* rule is the whole logic here, and it is the part that can be reasoned about and tested
|
|
80
|
+
* without a DOM. `applyStagger` supplies the real elements.
|
|
81
|
+
*
|
|
82
|
+
* OWNERSHIP, NOT DESCENT. Every `.webm-reveal` below a container is a candidate; it belongs to
|
|
83
|
+
* the NEAREST stagger container above it. That is what lets a staggered grid sit inside a
|
|
84
|
+
* staggered section without the grid's cards being numbered twice — the section owns the grid,
|
|
85
|
+
* the grid owns its cards, and each numbering starts at zero.
|
|
86
|
+
*/
|
|
87
|
+
export function ownedReveals<T>(
|
|
88
|
+
container: T,
|
|
89
|
+
candidates: readonly T[],
|
|
90
|
+
ownerOf: (element: T) => T | null,
|
|
91
|
+
): T[] {
|
|
92
|
+
return candidates.filter((element) => ownerOf(element) === container);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Index every reveal a stagger container owns, so the CSS can compute each delay.
|
|
97
|
+
*
|
|
98
|
+
* This is what the old `data-delay="1".."4"` ladder could not do: the step is a real
|
|
99
|
+
* millisecond value and the count is unbounded, so a grid of nine cards staggers as readily
|
|
100
|
+
* as a row of three.
|
|
101
|
+
*
|
|
102
|
+
* DESCENDANTS, NOT `children`. This walked `container.children` and so numbered only DIRECT
|
|
103
|
+
* children. Real markup nests the revealing element a level deeper more often than not — a
|
|
104
|
+
* `<ul>` whose `<li>` wraps the button that actually moves, or a card wrapper holding a border
|
|
105
|
+
* still while its inside animates. In those layouts the walk found nothing, set no index, and
|
|
106
|
+
* every item took a delay of zero: the group arrived in one piece, looking precisely like a
|
|
107
|
+
* stagger nobody had applied. Nothing threw, because nothing failed — there was simply nothing
|
|
108
|
+
* to do. Three client sites hit it and each worked around it by hand-writing delays off a map
|
|
109
|
+
* index. The CSS half of this had the same bug, as `> .webm-reveal`; changing either alone
|
|
110
|
+
* fixes nothing, because an index nothing reads is as useless as a rule with no index.
|
|
111
|
+
*
|
|
112
|
+
* `parentElement?.closest(...)` rather than `closest(...)` STARTS THE SEARCH ABOVE the element.
|
|
113
|
+
* An element that is both a reveal and a nested stagger container would otherwise resolve to
|
|
114
|
+
* itself, and so be numbered by nothing — the outer container would skip it as belonging to
|
|
115
|
+
* someone else. Starting a level up, it belongs to the outer container and still owns its own
|
|
116
|
+
* subtree, which is the behavior the markup implies.
|
|
117
|
+
*/
|
|
118
|
+
function applyStagger(root: ParentNode): void {
|
|
119
|
+
root.querySelectorAll<HTMLElement>(STAGGER_SELECTOR).forEach((container) => {
|
|
120
|
+
const step = container.dataset.revealStagger;
|
|
121
|
+
if (step) {
|
|
122
|
+
container.style.setProperty('--webm-reveal-stagger-step', `${parseInt(step, 10) || 0}ms`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const candidates = Array.from(container.querySelectorAll<HTMLElement>(SELECTOR));
|
|
126
|
+
ownedReveals(
|
|
127
|
+
container,
|
|
128
|
+
candidates,
|
|
129
|
+
(element) => element.parentElement?.closest<HTMLElement>(STAGGER_SELECTOR) ?? null,
|
|
130
|
+
).forEach((element, index) => {
|
|
131
|
+
element.style.setProperty('--webm-reveal-index', String(index));
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function getObserver(): IntersectionObserver {
|
|
137
|
+
if (observer) return observer;
|
|
138
|
+
|
|
139
|
+
observer = new IntersectionObserver(
|
|
140
|
+
(entries) => {
|
|
141
|
+
for (const entry of entries) {
|
|
142
|
+
if (!entry.isIntersecting) continue;
|
|
143
|
+
|
|
144
|
+
reveal(entry.target);
|
|
145
|
+
|
|
146
|
+
// `data-reveal-once="false"` opts into re-animating on every entry.
|
|
147
|
+
const once = (entry.target as HTMLElement).dataset.revealOnce !== 'false';
|
|
148
|
+
if (once) observer?.unobserve(entry.target);
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
/*
|
|
153
|
+
* Fire slightly before the element is fully on screen, and require only a sliver of it
|
|
154
|
+
* to be visible — otherwise a section taller than the viewport waits for its bottom
|
|
155
|
+
* edge, which never arrives.
|
|
156
|
+
*/
|
|
157
|
+
rootMargin: '0px 0px -10% 0px',
|
|
158
|
+
threshold: 0.05,
|
|
159
|
+
},
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
return observer;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Wire up every `.webm-reveal` in `root` that is not already wired.
|
|
167
|
+
*
|
|
168
|
+
* Safe to call repeatedly — on first load, after a view transition, and after injecting
|
|
169
|
+
* markup dynamically.
|
|
170
|
+
*/
|
|
171
|
+
export function initAnimations(root: ParentNode = document): void {
|
|
172
|
+
const elements = Array.from(root.querySelectorAll<HTMLElement>(SELECTOR));
|
|
173
|
+
if (elements.length === 0) return;
|
|
174
|
+
|
|
175
|
+
applyStagger(root);
|
|
176
|
+
|
|
177
|
+
/*
|
|
178
|
+
* Reduced motion: reveal everything at once and never observe. Neutralising the CSS is only
|
|
179
|
+
* half the fix — without this, a motion-sensitive visitor would still depend on scroll
|
|
180
|
+
* position for content to appear, because the CSS override only kills the movement.
|
|
181
|
+
*/
|
|
182
|
+
if (prefersReducedMotion()) {
|
|
183
|
+
elements.forEach(reveal);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const io = getObserver();
|
|
188
|
+
|
|
189
|
+
for (const element of elements) {
|
|
190
|
+
if (registered.has(element)) continue;
|
|
191
|
+
registered.add(element);
|
|
192
|
+
applyTiming(element);
|
|
193
|
+
|
|
194
|
+
// Anything already on screen at load must not wait for a scroll that may never come.
|
|
195
|
+
const rect = element.getBoundingClientRect();
|
|
196
|
+
if (rect.top < window.innerHeight && rect.bottom > 0) {
|
|
197
|
+
revealAfterPaint(element);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
io.observe(element);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Tear down between view transitions so the observer never holds stale nodes. */
|
|
206
|
+
export function destroyAnimations(): void {
|
|
207
|
+
observer?.disconnect();
|
|
208
|
+
observer = null;
|
|
209
|
+
}
|