@sonordev/site-kit 7.0.0 → 7.0.2
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 +3539 -0
- package/README.md +12 -13
- package/agent-manifest.json +1 -1
- package/dist/{AnalyticsProvider-GJ6JFQO5.js → AnalyticsProvider-XXWTFKJH.js} +4 -4
- package/dist/{ArticleViewTracker-FEA5SBKW.js → ArticleViewTracker-V4KZB6QN.js} +3 -3
- package/dist/{BlocksPopup-MNK4SDZM.js → BlocksPopup-JHGHB6XW.js} +4 -4
- package/dist/{ChatWidget-M2YNLRYW.js → ChatWidget-CG32POI3.js} +5 -5
- package/dist/{EngageWidget-RR63JQCA.js → EngageWidget-LQMR4LEX.js} +4 -4
- package/dist/{FileField-JFBXXD43.js → FileField-KUG3CKXG.js} +3 -3
- package/dist/{FormSpotlight-QE3J3VB5.js → FormSpotlight-FVNPOCU3.js} +1 -1
- package/dist/{FormStage-OTPKEWID.js → FormStage-C7VKRURJ.js} +1 -1
- package/dist/{ManagedForm-3VRALWUD.js → ManagedForm-VLNJKV65.js} +6 -6
- package/dist/{ManagedNewsletterForm-A2FE6L5C.js → ManagedNewsletterForm-KJEU23BV.js} +4 -4
- package/dist/{SignalCore-S6DO3VQV.js → SignalCore-K2O46QG7.js} +3 -3
- package/dist/{SiteDesignReporter-2WZVWJMA.js → SiteDesignReporter-D7MD66GI.js} +5 -5
- package/dist/SitemapSync-NMXGMPCQ.js +8 -0
- package/dist/_client/booking-widget.js +5 -5
- package/dist/affiliates/index.js +3 -3
- package/dist/analytics/index.js +4 -4
- package/dist/articles/index.js +1 -1
- package/dist/articles/server-ui.js +1 -1
- package/dist/chat/index.js +5 -5
- package/dist/{chunk-XZMVNORA.js → chunk-42OXY4JV.js} +1 -1
- package/dist/{chunk-5YSI5KFD.js → chunk-56JNI463.js} +1 -1
- package/dist/{chunk-M4ZY2FVB.js → chunk-5FBY2ZIH.js} +1 -1
- package/dist/{chunk-3XHK5DFX.js → chunk-7JIKGKWD.js} +7 -7
- package/dist/{chunk-7ZHJIRM3.js → chunk-B6RZ2NRH.js} +1 -1
- package/dist/{chunk-UINSEWQ3.js → chunk-BEL7YFMC.js} +1 -1
- package/dist/{chunk-R3TOKUDJ.js → chunk-BS7FWUOY.js} +1 -1
- package/dist/{chunk-MZFF5F7R.js → chunk-DKTSGYLM.js} +2 -2
- package/dist/{chunk-LT5ITURG.js → chunk-GGD4P7UW.js} +1 -1
- package/dist/{chunk-OO2GZ272.js → chunk-GYY6ETGB.js} +1 -1
- package/dist/{chunk-I6WUCBKI.js → chunk-K5WZX776.js} +2 -2
- package/dist/{chunk-B52EXDWI.js → chunk-LJZ3SUET.js} +2 -2
- package/dist/{chunk-OSQHWIM5.js → chunk-O52CH273.js} +1 -1
- package/dist/{chunk-HPUXKMNR.js → chunk-OIETJKIL.js} +1 -1
- package/dist/{chunk-5UZN5V52.js → chunk-P2GIIQH5.js} +1 -1
- package/dist/{chunk-5C4WVOVO.js → chunk-P72ZJRSX.js} +3 -3
- package/dist/{chunk-4WI3FU5L.js → chunk-RU2RMTGT.js} +2 -2
- package/dist/{chunk-GYBMMWGF.js → chunk-SAUTJMK6.js} +1 -1
- package/dist/{chunk-JMRESWQT.js → chunk-SWP36NCB.js} +1 -1
- package/dist/{chunk-WFHI6HXP.js → chunk-T4SY3FMN.js} +3 -3
- package/dist/{chunk-6ZB3XNAT.js → chunk-ZRE4ZYEG.js} +1 -1
- package/dist/{chunk-6CUFRMMF.js → chunk-ZSLRAMCK.js} +1 -1
- package/dist/client/index.js +3 -3
- package/dist/commerce/index.js +4 -4
- package/dist/engage/index.js +6 -6
- package/dist/fleet/index.js +4 -4
- package/dist/forms/index.js +7 -7
- package/dist/forms/server.js +2 -2
- package/dist/forms/types.d.ts +3 -1
- package/dist/images/index.js +4 -4
- package/dist/index.js +1 -1
- package/dist/layout/client.js +7 -7
- package/dist/layout/index.js +8 -8
- package/dist/maps/index.js +3 -3
- package/dist/mcp/sonor.js +9 -7
- package/dist/seo/client.js +4 -4
- package/dist/seo/index.js +4 -4
- package/dist/server/index.js +2 -2
- package/dist/shared/version.d.ts +1 -1
- package/dist/signal/index.js +2 -2
- package/dist/sync/index.js +5 -5
- package/dist/website/images.js +4 -4
- package/dist/website/index.js +5 -5
- package/dist/website/popups.js +4 -4
- package/docs/MIGRATING-TO-7.md +146 -0
- package/docs.json +67 -0
- package/package.json +9 -4
- package/src/admin-auth/README.md +88 -0
- package/src/analytics/README.md +264 -0
- package/src/articles/README.md +325 -0
- package/src/commerce/README.md +109 -0
- package/src/cta-bar/README.md +154 -0
- package/src/engage/README.md +241 -0
- package/src/forms/README.md +219 -0
- package/src/images/README.md +74 -0
- package/src/layout/README.md +66 -0
- package/src/llms/README.md +723 -0
- package/src/mcp/README.md +376 -0
- package/src/motion/README.md +372 -0
- package/src/og/README.md +304 -0
- package/src/proxy/README.md +152 -0
- package/src/redirects/README.md +74 -0
- package/src/reputation/README.md +64 -0
- package/src/seo/README.md +359 -0
- package/src/signal/README.md +115 -0
- package/src/sitemap/README.md +127 -0
- package/src/sync/README.md +115 -0
- package/dist/SitemapSync-IKNKPT2G.js +0 -8
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
# Motion — `@sonordev/site-kit/motion`
|
|
2
|
+
|
|
3
|
+
The Upforge motion standard: **highly interactive, lean, and designed from
|
|
4
|
+
day one for SEO/AEO.** Three tiers, three subpaths, so a site only installs
|
|
5
|
+
and ships what it imports.
|
|
6
|
+
|
|
7
|
+
| Tier | Import | Cost (gzipped) | When |
|
|
8
|
+
|------|--------|----------------|------|
|
|
9
|
+
| 0 | `@sonordev/site-kit/motion` | ~2KB, zero deps | Every site |
|
|
10
|
+
| 1 | `@sonordev/site-kit/motion/gsap` | +46KB (gsap + ScrollTrigger), at idle | Sequenced timelines, SplitText, Flip |
|
|
11
|
+
| 2 | `@sonordev/site-kit/motion/three` | +180KB (three), on approach | Flagship builds with a WebGL scene |
|
|
12
|
+
|
|
13
|
+
The reference build is [upforgelabs.com](https://upforgelabs.com): a full
|
|
14
|
+
three.js flythrough that scores 95 on mobile Lighthouse. Every primitive
|
|
15
|
+
here sits on the same scroll engine that site runs.
|
|
16
|
+
|
|
17
|
+
## The guarantees
|
|
18
|
+
|
|
19
|
+
These hold for every primitive in tier 0, and are asserted by
|
|
20
|
+
`motion.test.tsx`:
|
|
21
|
+
|
|
22
|
+
1. **The server HTML is fully visible.** No opacity, transform, or
|
|
23
|
+
visibility in the markup. Crawlers, no-JS visitors, and the LCP
|
|
24
|
+
measurement all read the finished page.
|
|
25
|
+
2. **Above-the-fold content is never animated in.** `<Reveal>` checks on
|
|
26
|
+
mount whether its element is already on screen and, if so, does nothing.
|
|
27
|
+
An `opacity: 0` hero cannot be the LCP element; Chrome records LCP at the
|
|
28
|
+
end of the entrance tween, or never.
|
|
29
|
+
3. **A headless renderer gets the content back.** Google's Web Rendering
|
|
30
|
+
Service executes JavaScript but never scrolls. If nothing scrolls within
|
|
31
|
+
2.5s of a reveal arming, the content is restored, so the indexed snapshot
|
|
32
|
+
is never transparent.
|
|
33
|
+
4. **Native scroll only.** The wheel is never hijacked and the body is never
|
|
34
|
+
transformed. The liquid feel comes from each scene lerping toward the real
|
|
35
|
+
scroll position.
|
|
36
|
+
5. **`prefers-reduced-motion` turns it all off.** Reveals and parallax stay
|
|
37
|
+
static; pinned scenes hold one representative frame.
|
|
38
|
+
6. **Nothing is mounted by `SiteKitLayout`.** Motion is opt-in per element,
|
|
39
|
+
never a wrapper around the page.
|
|
40
|
+
|
|
41
|
+
## Tier 0
|
|
42
|
+
|
|
43
|
+
### `<Reveal>`
|
|
44
|
+
|
|
45
|
+
Scroll-entrance reveal on a CSS transition. Replaces the per-site
|
|
46
|
+
`Reveal` / `ScrollReveal` / `RevealOnScroll` components.
|
|
47
|
+
|
|
48
|
+
```tsx
|
|
49
|
+
import { Reveal } from '@sonordev/site-kit/motion'
|
|
50
|
+
|
|
51
|
+
<Reveal>
|
|
52
|
+
<h2>Server-rendered, visible in the HTML, revealed as it scrolls in.</h2>
|
|
53
|
+
</Reveal>
|
|
54
|
+
|
|
55
|
+
<Reveal as="ul" stagger from="left" distance={32}>
|
|
56
|
+
<li>…</li><li>…</li><li>…</li>
|
|
57
|
+
</Reveal>
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
| Prop | Default | Meaning |
|
|
61
|
+
|------|---------|---------|
|
|
62
|
+
| `as` | `"div"` | Element to render |
|
|
63
|
+
| `from` | `"up"` | `"up" \| "down" \| "left" \| "right" \| "none"` — where it travels in from |
|
|
64
|
+
| `distance` | `24` | Travel in px |
|
|
65
|
+
| `delay` | `0` | Seconds before the transition starts |
|
|
66
|
+
| `duration` | `0.7` | Seconds |
|
|
67
|
+
| `stagger` | `false` | `true` (0.08s) or seconds between direct children |
|
|
68
|
+
| `once` | `true` | `false` re-hides when it scrolls back out |
|
|
69
|
+
| `threshold` | `0.15` | Reveal when the top crosses this far up from the bottom edge (0.15 = 85% down the screen) |
|
|
70
|
+
| `scale` | off | Starting scale, e.g. `0.96` |
|
|
71
|
+
| `opacity` | `0` | Starting opacity |
|
|
72
|
+
| `easing` | ease-out quint | Any CSS easing |
|
|
73
|
+
|
|
74
|
+
The element carries `data-sk-reveal` so you can style or debug it.
|
|
75
|
+
|
|
76
|
+
### `<Parallax>` / `useParallax(ref, options)`
|
|
77
|
+
|
|
78
|
+
Scroll-linked transform and opacity, compositor-only, driven by the
|
|
79
|
+
element's progress through the viewport (0 as its top enters at the bottom,
|
|
80
|
+
1 as its bottom leaves at the top). Replaces `ParallaxImage` / `ParallaxY`.
|
|
81
|
+
|
|
82
|
+
```tsx
|
|
83
|
+
import { Parallax } from '@sonordev/site-kit/motion'
|
|
84
|
+
|
|
85
|
+
// A parallax photo: the frame clips, the scale hides the travel.
|
|
86
|
+
<div className="overflow-hidden rounded-2xl">
|
|
87
|
+
<Parallax y={[-40, 40]} scale={[1.15, 1.15]}>
|
|
88
|
+
<Image src={photo} alt="…" fill sizes="…" />
|
|
89
|
+
</Parallax>
|
|
90
|
+
</div>
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Options: `y` (default `[40, -40]`), `x`, `scale`, `opacity`, `rotate` — each
|
|
94
|
+
a `[at p=0, at p=1]` pair — `ease(p)` to remap progress, and `anchor`.
|
|
95
|
+
|
|
96
|
+
**Above the fold, use `anchor="load"`.** Progress runs over the element's
|
|
97
|
+
whole trip through the viewport, and a hero layer is already partway along
|
|
98
|
+
that trip when the page loads (p ≈ 0.5), so an unanchored layer jumps to
|
|
99
|
+
that frame on hydration. A hero photo is usually the LCP element, so it
|
|
100
|
+
must not move. Anchored, progress counts from where the page was when the
|
|
101
|
+
layer armed: the first frame renders each range's first value, and from
|
|
102
|
+
there it moves at the usual rate (one range per full trip). Give it ranges
|
|
103
|
+
that start at rest:
|
|
104
|
+
|
|
105
|
+
```tsx
|
|
106
|
+
// Server component: the photo layer behind the hero copy.
|
|
107
|
+
<section className="relative overflow-hidden">
|
|
108
|
+
<Parallax anchor="load" y={[0, 120]} className="absolute inset-0 -bottom-16">
|
|
109
|
+
<img src="/hero.avif" alt="…" fetchPriority="high" className="h-full w-full object-cover" />
|
|
110
|
+
</Parallax>
|
|
111
|
+
<h1 className="relative">…</h1>
|
|
112
|
+
</section>
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
A layer that arms below the fold anchors at 0, so `anchor` changes nothing
|
|
116
|
+
for it.
|
|
117
|
+
|
|
118
|
+
### `<ScrollScene>` / `useScrollScene(ref, onProgress?, options)`
|
|
119
|
+
|
|
120
|
+
A pinned scene: a tall wrapper with a sticky, viewport-sized stage inside
|
|
121
|
+
it. As the visitor scrolls the wrapper's height the stage stays put and
|
|
122
|
+
progress runs 0→1. Progress is written to `--sk-p` on the wrapper every
|
|
123
|
+
frame, so choreography can be pure CSS.
|
|
124
|
+
|
|
125
|
+
```tsx
|
|
126
|
+
import { ScrollScene } from '@sonordev/site-kit/motion'
|
|
127
|
+
|
|
128
|
+
<ScrollScene length="300vh" className="scene">
|
|
129
|
+
<h2 className="scene-title">This copy is in the server HTML.</h2>
|
|
130
|
+
<img className="scene-art" src="…" alt="" />
|
|
131
|
+
</ScrollScene>
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
```css
|
|
135
|
+
.scene-title { opacity: calc(1 - var(--sk-p) * 2); }
|
|
136
|
+
.scene-art { transform: translateX(calc(var(--sk-p) * -40vw)) scale(calc(1 + var(--sk-p) * 0.4)); }
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
`onProgress(p, { target, visible })` runs the same frame for canvas
|
|
140
|
+
frame-scrub, three.js, or anything measured. `continuous` keeps rendering
|
|
141
|
+
while visible (shader time), `reducedP` sets the frame held under reduced
|
|
142
|
+
motion (default 0.55), `lerp` tunes the smoothing (default 0.16, 1 = locked
|
|
143
|
+
to the scrollbar).
|
|
144
|
+
|
|
145
|
+
The children are server-rendered. A `"use client"` component still renders
|
|
146
|
+
children that arrive as props on the server, so the copy inside a scene is
|
|
147
|
+
in the HTML. What you must **not** do is load a component containing a
|
|
148
|
+
scene through `dynamic(…, { ssr: false })`: that de-opts the whole subtree
|
|
149
|
+
to client rendering and the copy disappears from the HTML.
|
|
150
|
+
|
|
151
|
+
### The engine
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
import { registerScene, refreshScenes, prefersReducedMotion, sceneProgress, onNoScroll } from '@sonordev/site-kit/motion'
|
|
155
|
+
|
|
156
|
+
const stop = registerScene(pinElement, (p, { target, visible }) => draw(p), { mode: 'pin' })
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
One shared `requestAnimationFrame` drives every registered scene and sleeps
|
|
160
|
+
when everything has settled; scenes beyond a 35% viewport margin are not
|
|
161
|
+
rendered. Call `refreshScenes()` after a layout change the engine cannot see
|
|
162
|
+
(an accordion opening above a scene). `sceneProgress` is the pure progress
|
|
163
|
+
math, exported for tests and reuse.
|
|
164
|
+
|
|
165
|
+
`anchor: 'load'` works here too, for a custom scene above the fold: the
|
|
166
|
+
callback's first `p` is 0 and it moves one unit per full trip from there
|
|
167
|
+
(negative if the page scrolls back above where it armed). One scene can
|
|
168
|
+
drive many layers at their own rates:
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
registerScene(
|
|
172
|
+
collage,
|
|
173
|
+
(p) => tiles.forEach((t) => (t.style.transform = `translate3d(0, ${p * Number(t.dataset.depth)}px, 0)`)),
|
|
174
|
+
{ mode: 'view', lerp: 1, anchor: 'load' },
|
|
175
|
+
)
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## Tier 1 — GSAP
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
npm i gsap
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
```tsx
|
|
185
|
+
import { useGsap } from '@sonordev/site-kit/motion/gsap'
|
|
186
|
+
|
|
187
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
188
|
+
useGsap(ref, ({ gsap, el }) => {
|
|
189
|
+
gsap.from(el.querySelectorAll('[data-line]'), {
|
|
190
|
+
yPercent: 100, opacity: 0, stagger: 0.06, duration: 0.8, ease: 'power3.out',
|
|
191
|
+
scrollTrigger: { trigger: el, start: 'top 80%' },
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
`useGsap` loads gsap + ScrollTrigger once per page at idle (`whenIdle`: after
|
|
197
|
+
the `load` event, when the main thread goes quiet), so it's off the LCP path
|
|
198
|
+
and out of hydration's way but already in hand when a visitor scrolls to the
|
|
199
|
+
section. It runs the setup when the element comes within 200px of the
|
|
200
|
+
viewport (`near`), inside `gsap.context(el)` so selectors are scoped and
|
|
201
|
+
everything is reverted on unmount, and skips under reduced motion
|
|
202
|
+
(`reducedMotion: 'run'` to opt out). A reduced-motion visitor never downloads
|
|
203
|
+
gsap at all.
|
|
204
|
+
|
|
205
|
+
**Plugins beyond ScrollTrigger go in `options.plugins`**, never an
|
|
206
|
+
`import()` inside setup:
|
|
207
|
+
|
|
208
|
+
```tsx
|
|
209
|
+
useGsap(
|
|
210
|
+
ref,
|
|
211
|
+
({ gsap, el, plugins: { SplitText } }) => {
|
|
212
|
+
SplitText.create(el, {
|
|
213
|
+
type: 'lines',
|
|
214
|
+
mask: 'lines',
|
|
215
|
+
autoSplit: true,
|
|
216
|
+
onSplit: (self) =>
|
|
217
|
+
gsap.from(self.lines, { yPercent: 115, stagger: 0.09, scrollTrigger: { trigger: el, once: true } }),
|
|
218
|
+
})
|
|
219
|
+
},
|
|
220
|
+
[],
|
|
221
|
+
{ plugins: { SplitText: () => import('gsap/SplitText') } },
|
|
222
|
+
)
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
They load with gsap (once per page, however many components ask), get
|
|
226
|
+
registered, and reach setup by name, so setup stays synchronous. That's the
|
|
227
|
+
point: gsap.context only records what runs synchronously inside it, so a
|
|
228
|
+
plugin imported from within setup did its work after the context closed.
|
|
229
|
+
Its tweens and splits were unscoped and never reverted on unmount.
|
|
230
|
+
|
|
231
|
+
Anything setup still has to start later (after an await, in a timer) goes
|
|
232
|
+
through the context it's handed: `context.add(() => gsap.to(...))`.
|
|
233
|
+
|
|
234
|
+
Rules: below the fold only; hide things inside the setup with `gsap.set`,
|
|
235
|
+
never with a stylesheet; no ScrollSmoother (it transforms the page body,
|
|
236
|
+
fights native scroll, and costs INP).
|
|
237
|
+
|
|
238
|
+
### `scrollIn(gsap, el, build, { start })`
|
|
239
|
+
|
|
240
|
+
A below-the-fold entrance with the rules every kit reveal keeps, for use
|
|
241
|
+
inside a `useGsap` setup:
|
|
242
|
+
|
|
243
|
+
```tsx
|
|
244
|
+
useGsap(ref, ({ gsap, el }) =>
|
|
245
|
+
scrollIn(gsap, el, (tl) => tl.from(el.querySelectorAll('li'), { y: 24, opacity: 0, stagger: 0.06 })),
|
|
246
|
+
)
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
- An element already on screen when setup runs is left as the server
|
|
250
|
+
rendered it, and `build` never runs.
|
|
251
|
+
- Otherwise `build` fills a paused timeline. Its from-states apply at once,
|
|
252
|
+
while the element is still offscreen, and it plays when the element's top
|
|
253
|
+
crosses `start` (default `"top 90%"`).
|
|
254
|
+
- A visitor who never scrolls gets the finished state after 2.5s, callbacks
|
|
255
|
+
included, through the same failsafe `<Reveal>` uses.
|
|
256
|
+
|
|
257
|
+
It returns the failsafe's cancel; return it from setup as the cleanup.
|
|
258
|
+
|
|
259
|
+
### `<CountUp>`
|
|
260
|
+
|
|
261
|
+
A stat that rolls up to its value as it scrolls into view. Pass the
|
|
262
|
+
formatted value as its text; it rolls the first number and keeps everything
|
|
263
|
+
around it (currency, `%`, units, decimals, thousands separators only if the
|
|
264
|
+
text had them):
|
|
265
|
+
|
|
266
|
+
```tsx
|
|
267
|
+
import { CountUp } from '@sonordev/site-kit/motion/gsap'
|
|
268
|
+
|
|
269
|
+
<CountUp as="p" className="stat">$412,500</CountUp>
|
|
270
|
+
<CountUp>{`${ownerPct}%`}</CountUp>
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
The server HTML is the real text. A stat already on screen keeps its
|
|
274
|
+
number, the real text comes back at the end of the roll and from the
|
|
275
|
+
no-scroll failsafe, and reduced motion never touches it. Props: `as`
|
|
276
|
+
(default `"span"`), `className`, `id`, `duration` (1.3), `ease`
|
|
277
|
+
(`"power2.out"`), `start` (`"top 90%"`). `parseCountUp(text)` is the
|
|
278
|
+
formatting split, exported for tests.
|
|
279
|
+
|
|
280
|
+
Also exported: `loadGsap()` (the shared loader, immediate), `whenIdle()`
|
|
281
|
+
(the idle gate useGsap loads behind), and
|
|
282
|
+
`useExpandCollapse(isOpen)` (height/opacity expand without unmounting, so
|
|
283
|
+
the content stays indexable).
|
|
284
|
+
|
|
285
|
+
## Tier 2 — three.js
|
|
286
|
+
|
|
287
|
+
```bash
|
|
288
|
+
npm i three && npm i -D @types/three
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
The scene component is loaded lazily and mounted as a **childless sibling**
|
|
292
|
+
of the server-rendered stage, never wrapping it. Copy lives in the DOM,
|
|
293
|
+
never in the canvas.
|
|
294
|
+
|
|
295
|
+
```tsx
|
|
296
|
+
// page.tsx (server component)
|
|
297
|
+
import dynamic from 'next/dynamic'
|
|
298
|
+
import { ScrollScene } from '@sonordev/site-kit/motion'
|
|
299
|
+
const Scene = dynamic(() => import('./Scene'), { ssr: false })
|
|
300
|
+
|
|
301
|
+
<ScrollScene id="hero-scene" length="400vh">
|
|
302
|
+
<div className="relative z-10">
|
|
303
|
+
<h1>Real copy, in the HTML, above the canvas.</h1>
|
|
304
|
+
</div>
|
|
305
|
+
</ScrollScene>
|
|
306
|
+
<Scene pinId="hero-scene" />
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
```tsx
|
|
310
|
+
// Scene.tsx
|
|
311
|
+
'use client'
|
|
312
|
+
import { useRef, useEffect } from 'react'
|
|
313
|
+
import { useThreeStage } from '@sonordev/site-kit/motion/three'
|
|
314
|
+
import { Mesh, BoxGeometry, MeshStandardMaterial, DirectionalLight } from 'three'
|
|
315
|
+
|
|
316
|
+
export default function Scene({ pinId }: { pinId: string }) {
|
|
317
|
+
const pin = useRef<HTMLElement | null>(null)
|
|
318
|
+
useEffect(() => { pin.current = document.getElementById(pinId) }, [pinId])
|
|
319
|
+
useThreeStage(pin, {
|
|
320
|
+
onReady: ({ scene, camera }) => {
|
|
321
|
+
camera.position.z = 5
|
|
322
|
+
scene.add(new Mesh(new BoxGeometry(), new MeshStandardMaterial()), new DirectionalLight())
|
|
323
|
+
},
|
|
324
|
+
render: (p, { renderer, scene, camera }) => {
|
|
325
|
+
camera.position.z = 5 - p * 4
|
|
326
|
+
renderer.render(scene, camera)
|
|
327
|
+
},
|
|
328
|
+
})
|
|
329
|
+
return null
|
|
330
|
+
}
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
`useThreeStage` creates the canvas behind the stage's content, sizes it with
|
|
334
|
+
a `ResizeObserver`, caps DPR at 1.75 (`maxDpr`), pauses on context loss, and
|
|
335
|
+
disposes everything on unmount. `canRunWebGL()` is false under reduced
|
|
336
|
+
motion, Save-Data, or without WebGL, in which case nothing mounts and the
|
|
337
|
+
visitor keeps the server-rendered still. `loadTexture(url)` resolves `null`
|
|
338
|
+
instead of throwing, so one bad asset can't take a scene down.
|
|
339
|
+
`disposeObject(root)` frees a subtree.
|
|
340
|
+
|
|
341
|
+
Budgets: three itself is ~180KB gzipped, so this tier is for flagship builds;
|
|
342
|
+
keep models and textures to ≤5MB per scene and fetch them on approach.
|
|
343
|
+
|
|
344
|
+
## Migrating a site's own Reveal
|
|
345
|
+
|
|
346
|
+
Per-site components map onto `<Reveal>` almost one to one:
|
|
347
|
+
|
|
348
|
+
| Site prop | `<Reveal>` |
|
|
349
|
+
|-----------|-----------|
|
|
350
|
+
| `from="up"`, `direction="up"` | `from="up"` |
|
|
351
|
+
| `y={30}` (upforge.io) | `distance={30}` |
|
|
352
|
+
| `stagger` (boolean) | `stagger` |
|
|
353
|
+
| `delay`, `duration` | same, in seconds |
|
|
354
|
+
| `threshold`, `rootMargin` | `threshold` |
|
|
355
|
+
| `once` | `once` |
|
|
356
|
+
|
|
357
|
+
Remove `@gsap/react` and any `useGSAP` at module scope while you're there:
|
|
358
|
+
that pattern puts gsap in the initial bundle of every page.
|
|
359
|
+
|
|
360
|
+
A site's own count-up becomes `<CountUp>` with the formatted value as its
|
|
361
|
+
text (`<CountUp value={n} prefix="$" />` → `<CountUp>{usd(n)}</CountUp>`).
|
|
362
|
+
A hero or above-the-fold parallax that captured its first progress by hand
|
|
363
|
+
to stop the hydration jump becomes `<Parallax anchor="load">`, or
|
|
364
|
+
`registerScene(..., { anchor: 'load' })` for a custom scene.
|
|
365
|
+
|
|
366
|
+
## Before you ship
|
|
367
|
+
|
|
368
|
+
- `curl` the built page and confirm the copy inside every scene and reveal
|
|
369
|
+
is in the raw HTML.
|
|
370
|
+
- Mobile Lighthouse ≥ 90: `npx lighthouse <url> --form-factor=mobile --only-categories=performance`.
|
|
371
|
+
- Toggle reduced motion and confirm the page reads as a plain column.
|
|
372
|
+
- Disable JavaScript and confirm nothing is hidden.
|
package/src/og/README.md
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
# OG cards — `@sonordev/site-kit/og`
|
|
2
|
+
|
|
3
|
+
Social cards, rendered at build time by headless Chrome. Real CSS, real
|
|
4
|
+
webfonts, no Satori subset, nothing at runtime.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
npx sonor-setup og
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
That renders the site card to `public/og.png` **and one card per route**.
|
|
11
|
+
|
|
12
|
+
## The one rule that will bite you
|
|
13
|
+
|
|
14
|
+
**Config metadata beats the file convention.** A route whose `generateMetadata`
|
|
15
|
+
returns `openGraph.images` overrides its own `opengraph-image` file.
|
|
16
|
+
|
|
17
|
+
So a site using per-page cards must declare **no images anywhere**:
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
// ✅ root layout — no images array
|
|
21
|
+
openGraph: { type: 'website', locale: 'en_US', siteName: 'Acme' },
|
|
22
|
+
twitter: { card: 'summary_large_image' },
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
// ❌ this silently disables EVERY per-page card
|
|
27
|
+
openGraph: { images: ['/og.png'] },
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
That includes **`seo_pages.managed_og_image`** in Sonor. site-kit serves it into
|
|
31
|
+
`openGraph.images` for every managed page, so setting it suppresses that page's
|
|
32
|
+
card from the dashboard, with nothing in the repo to explain why. `sonor-setup
|
|
33
|
+
og` reads it for every route while it titles the cards and names the pages that
|
|
34
|
+
set one; `sonor-setup doctor --online` does the same. Offline, the doctor says
|
|
35
|
+
nothing about it (it used to warn on every per-page site, set or not).
|
|
36
|
+
|
|
37
|
+
The one image a page may declare is its own **per-URL card** or a **code card on
|
|
38
|
+
a `trailingSlash` site** (both below). Those replace the route's card on purpose.
|
|
39
|
+
|
|
40
|
+
This was verified against real builds, both ways: with images declared, all 32
|
|
41
|
+
routes on a real site served the same card and every generated page card was
|
|
42
|
+
inert; removing the declaration made each route serve its own.
|
|
43
|
+
|
|
44
|
+
A second surprise from the same investigation: **file metadata does not cascade
|
|
45
|
+
to child segments.** A card at `services/` is not inherited by
|
|
46
|
+
`services/[city]`. Dynamic segments get their own card (one file covers every
|
|
47
|
+
param), which the generator does automatically.
|
|
48
|
+
|
|
49
|
+
`sonor-setup og` and `sonor-setup doctor` both check this through one shared
|
|
50
|
+
rule (`og/wiring.ts`). Do not re-implement it — there were two copies once and
|
|
51
|
+
both told sites to do the wrong thing. The rule reads the site's code through
|
|
52
|
+
`shared/source-graph.ts`, which follows imports into `lib/` helpers and monorepo
|
|
53
|
+
workspace packages, so `twitter.card` set in a metadata helper counts. It used
|
|
54
|
+
to read the root layout alone.
|
|
55
|
+
|
|
56
|
+
## Writing the card
|
|
57
|
+
|
|
58
|
+
`og.config.ts` at the site root owns the theme. Sonor's brand data is only a
|
|
59
|
+
zero-config seed for sites that have no config yet.
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
import { defineOgCard } from '@sonordev/site-kit/og'
|
|
63
|
+
|
|
64
|
+
export default defineOgCard({
|
|
65
|
+
theme: { bg: '#0A0A0A', text: '#f5f5f7', accent: '#C41E3A', surface: '#141418' },
|
|
66
|
+
fonts: [{ family: 'Playfair Display', weights: [800] }],
|
|
67
|
+
logo: '/logo-white.svg',
|
|
68
|
+
layout: 'split',
|
|
69
|
+
photo: { src: '/team.jpg' },
|
|
70
|
+
content: {
|
|
71
|
+
kicker: 'Custom Closets · Cincinnati',
|
|
72
|
+
title: 'Built by\nbrothers',
|
|
73
|
+
subtitle: 'Designed, built, and installed by the same two people.',
|
|
74
|
+
bar: ['Free design', 'example.com'],
|
|
75
|
+
},
|
|
76
|
+
})
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Layouts: `plate-left` (logo plate + copy), `centered`, `banner` (copy only),
|
|
80
|
+
`split` (copy left, photo right). In `split` a configured `logo` renders as a
|
|
81
|
+
small mark above the kicker.
|
|
82
|
+
|
|
83
|
+
### Copy is fitted, not guessed
|
|
84
|
+
|
|
85
|
+
The title opens at 104px and the renderer steps it down until it fits **both**
|
|
86
|
+
axes, stopping at a 56px legibility floor — below that a headline stops reading
|
|
87
|
+
at the ~300px thumbnail width platforms actually show.
|
|
88
|
+
|
|
89
|
+
If copy still does not fit at the floor, the command **fails** and names the
|
|
90
|
+
element and the overflow in pixels. That is deliberate: the failure it exists to
|
|
91
|
+
catch was a card that shipped with the kicker off-canvas and the subtitle buried
|
|
92
|
+
under the bottom bar, while the CLI printed a tick.
|
|
93
|
+
|
|
94
|
+
Rules of thumb: about 10 uppercase characters per title line, and about 29 for
|
|
95
|
+
the kicker. `\n` in a title is a hard break, so choose the wrap yourself rather
|
|
96
|
+
than leaving it to the box.
|
|
97
|
+
|
|
98
|
+
## Per-page cards
|
|
99
|
+
|
|
100
|
+
Every static route gets a card, plus one per dynamic segment. Copy comes from
|
|
101
|
+
Sonor's managed title and description when `SONOR_API_KEY` is set, so a card and
|
|
102
|
+
its search result say the same thing; otherwise the route path is titled.
|
|
103
|
+
Everything else is inherited from the site card, so the set reads as one family.
|
|
104
|
+
|
|
105
|
+
Hand-write the few that deserve it:
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
cards: {
|
|
109
|
+
'/free-3d-design': {
|
|
110
|
+
content: { kicker: 'Free 3D design', title: 'See it\nfirst' },
|
|
111
|
+
photo: { src: '/lp/hero.jpg' },
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Keyed by route path or slug (`services-garage`), layered over the derived card,
|
|
117
|
+
so an entry only states what differs.
|
|
118
|
+
|
|
119
|
+
Managed titles carry the brand for the SERP (`About Us | Acme`); the card drops
|
|
120
|
+
it. It also drops the two broken suffixes managed titles turn up with: a dangling
|
|
121
|
+
separator with no brand after it (`About Us |`) and a domain after a comma
|
|
122
|
+
(`Privacy Policy, abbeyglenapts.com`). A hyphen inside a word is never a
|
|
123
|
+
separator (`Walk-In Closets` stays whole).
|
|
124
|
+
|
|
125
|
+
### Dynamic routes are keyed by pattern, one `*` per level
|
|
126
|
+
|
|
127
|
+
A dynamic segment has no single URL, so its card is keyed by the route pattern:
|
|
128
|
+
`services/[slug]` is `/services/*`, and `services/[slug]/[metro]` is
|
|
129
|
+
`/services/*/*`. Slug form is `services-any` and `services-any-any`.
|
|
130
|
+
|
|
131
|
+
Depth matters because a card file lives beside each `page.tsx`, so those two
|
|
132
|
+
directories are two different cards. They are also the one place the generator
|
|
133
|
+
runs out of facts: neither pattern has managed metadata of its own, so **both
|
|
134
|
+
derive their copy from the nearest static ancestor** (`/services`) and come out
|
|
135
|
+
identical. If the deeper route deserves its own words, write them:
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
cards: {
|
|
139
|
+
'/services/*': { content: { title: 'What we do' } },
|
|
140
|
+
'/services/*/*': { content: { kicker: 'Service areas', title: 'Near you' } },
|
|
141
|
+
},
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
This was a real bug: both directories keyed to `/services/*`, so 120
|
|
145
|
+
service-by-metro pages shipped the service pages' card and the `/services/*/*`
|
|
146
|
+
override matched nothing — silently. A `cards` key that matches no rendered
|
|
147
|
+
route is now reported by `sonor-setup og` (`og.cards`, a warning).
|
|
148
|
+
|
|
149
|
+
> **Writing a nested pattern in a comment.** `/services/*/*` contains `*/`,
|
|
150
|
+
> which **closes a `/* */` block comment early**. In TypeScript put it in a
|
|
151
|
+
> string, a `//` line comment, or spell the depth out in prose — this bit the
|
|
152
|
+
> fix for the bug above, inside the comment explaining the bug.
|
|
153
|
+
|
|
154
|
+
### Static routes under a dynamic segment get a card too
|
|
155
|
+
|
|
156
|
+
`properties/[slug]/about` is a static route under a dynamic one. It gets its
|
|
157
|
+
own card, keyed `/properties/*/about` and titled from its own name ("About",
|
|
158
|
+
kicker "properties"). The generator used to stop at the first dynamic segment,
|
|
159
|
+
so these pages shipped with no og:image at all, 30 of them on two
|
|
160
|
+
property sites.
|
|
161
|
+
|
|
162
|
+
### Per-URL cards: one per floor plan, one per community
|
|
163
|
+
|
|
164
|
+
A static file in a dynamic segment covers every param, so `/floor-plans/*` is
|
|
165
|
+
one card for every plan. When each page deserves its own, key `cards` by the
|
|
166
|
+
URL itself. `sonor-setup og` loads og.config.ts with plain Node, so it can
|
|
167
|
+
import the site's data only through a relative path with the `.ts` extension
|
|
168
|
+
and no `@/` aliases; a short list inline is often simpler:
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
// og.config.ts
|
|
172
|
+
import { defineOgCard } from '@sonordev/site-kit/og'
|
|
173
|
+
|
|
174
|
+
const floorPlans = [
|
|
175
|
+
{ slug: '1-bedroom', name: '1 Bedroom', image: '/living-room.webp' },
|
|
176
|
+
{ slug: '2-bedroom', name: '2 Bedroom', image: '/kitchen.webp' },
|
|
177
|
+
]
|
|
178
|
+
|
|
179
|
+
export default defineOgCard({
|
|
180
|
+
// ...theme, content
|
|
181
|
+
cards: {
|
|
182
|
+
'/floor-plans/*': { content: { title: 'Floor\nplans' } }, // the fallback
|
|
183
|
+
...Object.fromEntries(
|
|
184
|
+
floorPlans.map(plan => [
|
|
185
|
+
`/floor-plans/${plan.slug}`,
|
|
186
|
+
{ content: { kicker: 'Floor plan', title: plan.name }, photo: { src: plan.image } },
|
|
187
|
+
]),
|
|
188
|
+
),
|
|
189
|
+
},
|
|
190
|
+
})
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
`sonor-setup og` renders each to `public/_og/<url>.jpg` (the kit owns that
|
|
194
|
+
directory and clears it every run), and the page declares its own:
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
// app/floor-plans/[slug]/page.tsx
|
|
198
|
+
import { paramCardImage } from '@sonordev/site-kit/og'
|
|
199
|
+
import ogConfig from '../../../og.config'
|
|
200
|
+
|
|
201
|
+
export async function generateMetadata({ params }) {
|
|
202
|
+
const { slug } = await params
|
|
203
|
+
const card = paramCardImage(`/floor-plans/${slug}`, { config: ogConfig })
|
|
204
|
+
return {
|
|
205
|
+
openGraph: { ...(card && { images: [card] }) },
|
|
206
|
+
twitter: { card: 'summary_large_image', ...(card && { images: [card.url] }) },
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
With `config`, a page with no entry gets undefined and keeps the segment card.
|
|
212
|
+
The URL ends in `.jpg`, so a `trailingSlash: true` site never redirects it.
|
|
213
|
+
Per-URL cards need `sharp` (they have to be `.jpg`); without it the command
|
|
214
|
+
fails those cards and says so. Their copy comes from Sonor's managed title for
|
|
215
|
+
that URL, like any route card. A per-URL key matches a pattern one segment per
|
|
216
|
+
`*`, so `/properties/tall-pines/about` falls under `/properties/*/about`.
|
|
217
|
+
|
|
218
|
+
Cards are re-encoded to JPEG when `sharp` resolves — on a real 18-card site that
|
|
219
|
+
was 5.6 MB → 1.4 MB. Without sharp they stay PNG, which is correct, just heavier.
|
|
220
|
+
|
|
221
|
+
`--no-pages` renders only the site card.
|
|
222
|
+
|
|
223
|
+
## When cards go stale
|
|
224
|
+
|
|
225
|
+
Cards are build-time artifacts of the copy at generation time. If managed titles
|
|
226
|
+
change in Sonor, re-run `sonor-setup og` — nothing re-renders them automatically.
|
|
227
|
+
Worth adding to the same routine as a content pass.
|
|
228
|
+
|
|
229
|
+
## Per-entity cards (tier 2)
|
|
230
|
+
|
|
231
|
+
For cards that must vary per *record* and can't wait for a rebuild — one per
|
|
232
|
+
article published from Sonor, per event — `@sonordev/site-kit/og/route`
|
|
233
|
+
renders on demand with next/og. Use the metadata file convention:
|
|
234
|
+
|
|
235
|
+
```tsx
|
|
236
|
+
// app/article/[slug]/opengraph-image.tsx
|
|
237
|
+
import { createOgImage } from '@sonordev/site-kit/og/route'
|
|
238
|
+
import { getArticle } from '@sonordev/site-kit/articles/server'
|
|
239
|
+
import ogConfig from '../../../og.config'
|
|
240
|
+
|
|
241
|
+
export { size, contentType } from '@sonordev/site-kit/og/route'
|
|
242
|
+
export const alt = 'From the Acme article'
|
|
243
|
+
|
|
244
|
+
export default createOgImage<{ slug: string }>(async ({ slug }) => {
|
|
245
|
+
const post = await getArticle(slug)
|
|
246
|
+
if (!post) return null // 404
|
|
247
|
+
return {
|
|
248
|
+
theme: ogConfig.theme,
|
|
249
|
+
kicker: 'From the publication',
|
|
250
|
+
title: post.title,
|
|
251
|
+
photoUrl: post.featured_image, // must be absolute
|
|
252
|
+
bar: 'acme.com',
|
|
253
|
+
}
|
|
254
|
+
})
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Next calls it with `{ params }` and wires og:image for the post by itself, and
|
|
258
|
+
`sonor-setup og` sees the file and leaves the folder alone. The post page passes
|
|
259
|
+
`images: false` to `generateArticleMetadata`, or the featured image beats the
|
|
260
|
+
card (the doctor flags a page that doesn't).
|
|
261
|
+
|
|
262
|
+
`createOgImageRoute` is the same card as a route handler, `GET(req, { params })`,
|
|
263
|
+
for a card at a URL of your own. Next doesn't wire a route handler into
|
|
264
|
+
metadata. It used to be the only shape, so sites wrote an adapter to use it
|
|
265
|
+
from `opengraph-image.tsx`; delete those for `createOgImage`.
|
|
266
|
+
|
|
267
|
+
The runtime card fits its title the way the build-time card does, from an
|
|
268
|
+
estimate since Satori can't measure: 104px down to the 56px floor. When the
|
|
269
|
+
title can't fit beside the photo even at the floor, the photo goes and the
|
|
270
|
+
title gets the full width. No more hand-picked "drop the photo past 40
|
|
271
|
+
characters". The bottom bar's text defaults to whichever of `surface`, `bg` and
|
|
272
|
+
`text` reads on the accent (WCAG 3:1 for large text), in that order; set
|
|
273
|
+
`theme.barText` to choose. It used to be `text`, which put navy on crimson. The
|
|
274
|
+
build-time card uses the same rule, and keeps `surface` wherever it already
|
|
275
|
+
read.
|
|
276
|
+
|
|
277
|
+
Satori's constraints still apply: a flexbox-only CSS subset, no external
|
|
278
|
+
stylesheets, images as absolute URLs or data URIs (a relative `photoUrl` is
|
|
279
|
+
dropped), and fonts supplied as ArrayBuffers. Reach for it only when the cards
|
|
280
|
+
can't be rendered at build time; per-URL cards in `og.config.ts` cover
|
|
281
|
+
everything `generateStaticParams` can list.
|
|
282
|
+
|
|
283
|
+
### Code cards on a `trailingSlash: true` site
|
|
284
|
+
|
|
285
|
+
Next serves a code card at `<page>/opengraph-image`, with no extension, and
|
|
286
|
+
`trailingSlash: true` 308-redirects that URL, so every share preview starts
|
|
287
|
+
with a redirect. Declare the slashed URL from the page instead:
|
|
288
|
+
|
|
289
|
+
```ts
|
|
290
|
+
import { codeCardImage } from '@sonordev/site-kit/og'
|
|
291
|
+
|
|
292
|
+
openGraph: { images: [codeCardImage(`/article/${slug}`)] }, // /article/x/opengraph-image/
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
`trailingSlash` defaults to the site's next.config. `sonor-setup og` and the
|
|
296
|
+
doctor flag a code card on a `trailingSlash` site whose page doesn't. A code card
|
|
297
|
+
at `opengraph-image/route.tsx` (a route handler folder) is recognised too, so
|
|
298
|
+
the generator no longer writes an `opengraph-image.jpg` beside it, which Next
|
|
299
|
+
refused to build.
|
|
300
|
+
|
|
301
|
+
## Reminder
|
|
302
|
+
|
|
303
|
+
Facebook caches aggressively. After deploying a new card, re-scrape at
|
|
304
|
+
<https://developers.facebook.com/tools/debug/>.
|