@sonordev/site-kit 7.0.1 → 7.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/CHANGELOG.md +3606 -0
  2. package/README.md +12 -13
  3. package/agent-manifest.json +11 -5
  4. package/dist/{AnalyticsProvider-EMM2TKRE.js → AnalyticsProvider-ZMQUV33M.js} +4 -4
  5. package/dist/{ArticleViewTracker-RA64BGL6.js → ArticleViewTracker-V7NUXYBY.js} +3 -3
  6. package/dist/{BlocksPopup-D25RFNOV.js → BlocksPopup-52EU7OUY.js} +4 -4
  7. package/dist/{ChatWidget-RYI7BMJJ.js → ChatWidget-5BHMNR57.js} +5 -5
  8. package/dist/{EngageWidget-UKFCN33M.js → EngageWidget-PCGLX7SO.js} +4 -4
  9. package/dist/{FileField-MUHA7LZR.js → FileField-TSFGNAMY.js} +3 -3
  10. package/dist/{FormSpotlight-TCLPWPLL.js → FormSpotlight-XLBWEOTE.js} +1 -1
  11. package/dist/{FormStage-CNYLP6I6.js → FormStage-IJQ5Q2X6.js} +1 -1
  12. package/dist/{ManagedForm-7ZL5SKTO.js → ManagedForm-Z3PKOPIZ.js} +6 -6
  13. package/dist/{ManagedNewsletterForm-33B4JLX7.js → ManagedNewsletterForm-QAO3POLC.js} +4 -4
  14. package/dist/{SignalCore-L5FVDHFE.js → SignalCore-RBA3VDBL.js} +3 -3
  15. package/dist/{SiteDesignReporter-4JOFL4FP.js → SiteDesignReporter-C4LR5X2V.js} +5 -5
  16. package/dist/SitemapSync-XVMGKCF3.js +8 -0
  17. package/dist/_client/booking-widget.js +5 -5
  18. package/dist/affiliates/index.js +3 -3
  19. package/dist/analytics/index.js +4 -4
  20. package/dist/articles/index.js +1 -1
  21. package/dist/articles/server-ui.js +1 -1
  22. package/dist/chat/index.js +5 -5
  23. package/dist/{chunk-FYBZ5SNP.js → chunk-3G2SE2J4.js} +1 -1
  24. package/dist/{chunk-HGCK465A.js → chunk-3J2ERO3I.js} +1 -1
  25. package/dist/{chunk-KXPBMCFL.js → chunk-3QI26673.js} +3 -1
  26. package/dist/{chunk-BMO3VGMR.js → chunk-3XPJKZ6D.js} +30 -7
  27. package/dist/{chunk-QGHSMJKW.js → chunk-4JQQDCMO.js} +1 -1
  28. package/dist/{chunk-6HDT4G4A.js → chunk-4YTYGG2C.js} +2 -2
  29. package/dist/{chunk-N2UVOR3X.js → chunk-662ILEZ6.js} +2 -0
  30. package/dist/chunk-6G43IRWR.js +4 -0
  31. package/dist/{chunk-LVESVYCE.js → chunk-7MHHWZKC.js} +11 -117
  32. package/dist/{chunk-MV2MBTC3.js → chunk-7QTMMHUO.js} +1 -1
  33. package/dist/{chunk-KPAZG65P.js → chunk-CGWUXUYZ.js} +138 -46
  34. package/dist/{chunk-4IQ52CXL.js → chunk-DUAO4Q75.js} +2 -2
  35. package/dist/{chunk-4RMVXRBO.js → chunk-EIULXXUJ.js} +3 -3
  36. package/dist/{chunk-3KUUH2YP.js → chunk-EVFZ7KEW.js} +1 -1
  37. package/dist/{chunk-OFOAHPUV.js → chunk-F42R35NV.js} +1 -1
  38. package/dist/{chunk-QANVUXKH.js → chunk-FLR3EMK6.js} +1 -1
  39. package/dist/{chunk-P4GRY6QP.js → chunk-GIAOPEN6.js} +1 -1
  40. package/dist/{chunk-P5J7VMQ3.js → chunk-GWUKQ26F.js} +1 -1
  41. package/dist/{chunk-TT63HHIT.js → chunk-HAG4YIZY.js} +1 -1
  42. package/dist/{chunk-QZZIKMAT.js → chunk-HVH37YPX.js} +1 -1
  43. package/dist/{chunk-SSUQKA7L.js → chunk-J4D6ZXRW.js} +1 -1
  44. package/dist/{chunk-GYESATRY.js → chunk-L2DJD5Y4.js} +1 -1
  45. package/dist/{chunk-WATH55UY.js → chunk-LFXVE32I.js} +1 -1
  46. package/dist/chunk-LPH5FANE.js +169 -0
  47. package/dist/{chunk-V6LSQRTH.js → chunk-PLUP2KN5.js} +1 -1
  48. package/dist/chunk-RYVDGXC2.js +19 -0
  49. package/dist/{chunk-EGOD74PP.js → chunk-U35H2JIQ.js} +2 -2
  50. package/dist/chunk-VCJYLYJV.js +49 -0
  51. package/dist/{chunk-FL4EPUWA.js → chunk-W2CL2DB3.js} +2 -2
  52. package/dist/{chunk-UZN4ZYR2.js → chunk-XD3ZQET6.js} +1 -1
  53. package/dist/{chunk-CVTVNC2U.js → chunk-XNVSCQ2O.js} +2 -2
  54. package/dist/{chunk-T3MC4HOD.js → chunk-YLSEB32F.js} +1 -1
  55. package/dist/chunk-ZETJTCMV.js +118 -0
  56. package/dist/{chunk-5SEM2V4A.js → chunk-ZIMFQWGJ.js} +3 -3
  57. package/dist/client/index.js +3 -3
  58. package/dist/cms/CmsPage.d.ts +1 -0
  59. package/dist/cms/CmsPreview.d.ts +1 -0
  60. package/dist/cms/CmsSection.d.ts +1 -0
  61. package/dist/cms/index.d.ts +6 -0
  62. package/dist/cms/server-api.d.ts +3 -0
  63. package/dist/commerce/index.js +4 -4
  64. package/dist/config/index.js +1 -1
  65. package/dist/contracts/entries.d.ts +1 -1
  66. package/dist/contracts/site-cache.d.ts +55 -0
  67. package/dist/contracts/site-edit-param.d.ts +7 -0
  68. package/dist/contracts/site-edit.d.ts +77 -0
  69. package/dist/contracts/slot-content.d.ts +111 -0
  70. package/dist/contracts/slots.d.ts +39 -25
  71. package/dist/engage/index.js +6 -6
  72. package/dist/fleet/index.js +4 -4
  73. package/dist/forms/index.js +8 -8
  74. package/dist/forms/server.js +2 -2
  75. package/dist/forms/types.d.ts +3 -1
  76. package/dist/images/index.js +4 -4
  77. package/dist/index.js +1 -1
  78. package/dist/layout/client.js +8 -7
  79. package/dist/layout/index.js +9 -8
  80. package/dist/llms/index.js +4 -2
  81. package/dist/llms/seo-revalidate.d.ts +8 -1
  82. package/dist/maps/index.js +3 -3
  83. package/dist/mcp/sonor.js +6 -6
  84. package/dist/overlay-RXV6U6QC.js +353 -0
  85. package/dist/proxy/index.js +2 -2
  86. package/dist/proxy/securityHeaders.d.ts +4 -0
  87. package/dist/revalidate/index.d.ts +44 -0
  88. package/dist/revalidate/index.js +27 -0
  89. package/dist/seo/ManagedContent.d.ts +2 -0
  90. package/dist/seo/client.js +4 -4
  91. package/dist/seo/index.js +9 -8
  92. package/dist/seo/llms.js +4 -2
  93. package/dist/seo/register-sitemap-cli.js +1 -1
  94. package/dist/seo/server.js +3 -2
  95. package/dist/seo/sitemap.js +2 -2
  96. package/dist/server/index.js +2 -2
  97. package/dist/{server-api-GJJQZVG7.js → server-api-BVCBLJKL.js} +2 -1
  98. package/dist/shared/build-entries.d.ts +1 -0
  99. package/dist/shared/edit-bridge.d.ts +8 -0
  100. package/dist/shared/version.d.ts +1 -1
  101. package/dist/signal/index.js +2 -2
  102. package/dist/sitemap/index.js +2 -2
  103. package/dist/slots/ManagedLink.d.ts +31 -0
  104. package/dist/slots/ManagedList.d.ts +30 -0
  105. package/dist/slots/ManagedRichText.d.ts +31 -0
  106. package/dist/slots/contract.js +2 -1
  107. package/dist/slots/edit/locate.d.ts +30 -0
  108. package/dist/slots/edit/overlay.d.ts +18 -0
  109. package/dist/slots/index.d.ts +12 -4
  110. package/dist/slots/index.js +4 -2
  111. package/dist/slots/revalidate.d.ts +8 -3
  112. package/dist/slots/rich.d.ts +7 -0
  113. package/dist/slots/server-api.d.ts +6 -2
  114. package/dist/sync/index.js +5 -5
  115. package/dist/website/images.js +4 -4
  116. package/dist/website/index.js +5 -5
  117. package/dist/website/popups.js +4 -4
  118. package/dist/website/slots/contract.js +2 -1
  119. package/dist/website/slots.js +4 -2
  120. package/dist/{writeLLMsTxt-UMHKGNRR.js → writeLLMsTxt-QR23OQUE.js} +1 -1
  121. package/docs/MIGRATING-TO-7.md +146 -0
  122. package/docs.json +69 -0
  123. package/package.json +14 -4
  124. package/src/admin-auth/README.md +88 -0
  125. package/src/analytics/README.md +264 -0
  126. package/src/articles/README.md +325 -0
  127. package/src/commerce/README.md +109 -0
  128. package/src/cta-bar/README.md +154 -0
  129. package/src/engage/README.md +241 -0
  130. package/src/forms/README.md +219 -0
  131. package/src/images/README.md +74 -0
  132. package/src/layout/README.md +66 -0
  133. package/src/llms/README.md +723 -0
  134. package/src/mcp/README.md +376 -0
  135. package/src/motion/README.md +372 -0
  136. package/src/og/README.md +304 -0
  137. package/src/proxy/README.md +152 -0
  138. package/src/redirects/README.md +74 -0
  139. package/src/reputation/README.md +64 -0
  140. package/src/revalidate/README.md +82 -0
  141. package/src/seo/README.md +346 -0
  142. package/src/signal/README.md +115 -0
  143. package/src/sitemap/README.md +127 -0
  144. package/src/slots/README.md +168 -0
  145. package/src/sync/README.md +115 -0
  146. package/dist/SitemapSync-7WKY4HXI.js +0 -8
  147. package/dist/chunk-SS636UDN.js +0 -35
@@ -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.
@@ -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/>.