@sonordev/site-kit 7.0.1 → 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.
Files changed (90) hide show
  1. package/CHANGELOG.md +3539 -0
  2. package/README.md +12 -13
  3. package/agent-manifest.json +1 -1
  4. package/dist/{AnalyticsProvider-EMM2TKRE.js → AnalyticsProvider-XXWTFKJH.js} +4 -4
  5. package/dist/{ArticleViewTracker-RA64BGL6.js → ArticleViewTracker-V4KZB6QN.js} +3 -3
  6. package/dist/{BlocksPopup-D25RFNOV.js → BlocksPopup-JHGHB6XW.js} +4 -4
  7. package/dist/{ChatWidget-RYI7BMJJ.js → ChatWidget-CG32POI3.js} +5 -5
  8. package/dist/{EngageWidget-UKFCN33M.js → EngageWidget-LQMR4LEX.js} +4 -4
  9. package/dist/{FileField-MUHA7LZR.js → FileField-KUG3CKXG.js} +3 -3
  10. package/dist/{FormSpotlight-TCLPWPLL.js → FormSpotlight-FVNPOCU3.js} +1 -1
  11. package/dist/{FormStage-CNYLP6I6.js → FormStage-C7VKRURJ.js} +1 -1
  12. package/dist/{ManagedForm-7ZL5SKTO.js → ManagedForm-VLNJKV65.js} +6 -6
  13. package/dist/{ManagedNewsletterForm-33B4JLX7.js → ManagedNewsletterForm-KJEU23BV.js} +4 -4
  14. package/dist/{SignalCore-L5FVDHFE.js → SignalCore-K2O46QG7.js} +3 -3
  15. package/dist/{SiteDesignReporter-4JOFL4FP.js → SiteDesignReporter-D7MD66GI.js} +5 -5
  16. package/dist/SitemapSync-NMXGMPCQ.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-QANVUXKH.js → chunk-42OXY4JV.js} +1 -1
  24. package/dist/{chunk-GYESATRY.js → chunk-56JNI463.js} +1 -1
  25. package/dist/{chunk-MV2MBTC3.js → chunk-5FBY2ZIH.js} +1 -1
  26. package/dist/{chunk-BMO3VGMR.js → chunk-7JIKGKWD.js} +7 -7
  27. package/dist/{chunk-QGHSMJKW.js → chunk-B6RZ2NRH.js} +1 -1
  28. package/dist/{chunk-OFOAHPUV.js → chunk-BEL7YFMC.js} +1 -1
  29. package/dist/{chunk-WATH55UY.js → chunk-BS7FWUOY.js} +1 -1
  30. package/dist/{chunk-FL4EPUWA.js → chunk-DKTSGYLM.js} +2 -2
  31. package/dist/{chunk-HGCK465A.js → chunk-GGD4P7UW.js} +1 -1
  32. package/dist/{chunk-FYBZ5SNP.js → chunk-GYY6ETGB.js} +1 -1
  33. package/dist/{chunk-CVTVNC2U.js → chunk-K5WZX776.js} +2 -2
  34. package/dist/{chunk-4IQ52CXL.js → chunk-LJZ3SUET.js} +2 -2
  35. package/dist/{chunk-P5J7VMQ3.js → chunk-O52CH273.js} +1 -1
  36. package/dist/{chunk-3KUUH2YP.js → chunk-OIETJKIL.js} +1 -1
  37. package/dist/{chunk-V6LSQRTH.js → chunk-P2GIIQH5.js} +1 -1
  38. package/dist/{chunk-4RMVXRBO.js → chunk-P72ZJRSX.js} +3 -3
  39. package/dist/{chunk-EGOD74PP.js → chunk-RU2RMTGT.js} +2 -2
  40. package/dist/{chunk-QZZIKMAT.js → chunk-SAUTJMK6.js} +1 -1
  41. package/dist/{chunk-T3MC4HOD.js → chunk-SWP36NCB.js} +1 -1
  42. package/dist/{chunk-5SEM2V4A.js → chunk-T4SY3FMN.js} +3 -3
  43. package/dist/{chunk-P4GRY6QP.js → chunk-ZRE4ZYEG.js} +1 -1
  44. package/dist/{chunk-UZN4ZYR2.js → chunk-ZSLRAMCK.js} +1 -1
  45. package/dist/client/index.js +3 -3
  46. package/dist/commerce/index.js +4 -4
  47. package/dist/engage/index.js +6 -6
  48. package/dist/fleet/index.js +4 -4
  49. package/dist/forms/index.js +7 -7
  50. package/dist/forms/server.js +2 -2
  51. package/dist/forms/types.d.ts +3 -1
  52. package/dist/images/index.js +4 -4
  53. package/dist/index.js +1 -1
  54. package/dist/layout/client.js +7 -7
  55. package/dist/layout/index.js +8 -8
  56. package/dist/maps/index.js +3 -3
  57. package/dist/mcp/sonor.js +6 -6
  58. package/dist/seo/client.js +4 -4
  59. package/dist/seo/index.js +4 -4
  60. package/dist/server/index.js +2 -2
  61. package/dist/shared/version.d.ts +1 -1
  62. package/dist/signal/index.js +2 -2
  63. package/dist/sync/index.js +5 -5
  64. package/dist/website/images.js +4 -4
  65. package/dist/website/index.js +5 -5
  66. package/dist/website/popups.js +4 -4
  67. package/docs/MIGRATING-TO-7.md +146 -0
  68. package/docs.json +67 -0
  69. package/package.json +9 -4
  70. package/src/admin-auth/README.md +88 -0
  71. package/src/analytics/README.md +264 -0
  72. package/src/articles/README.md +325 -0
  73. package/src/commerce/README.md +109 -0
  74. package/src/cta-bar/README.md +154 -0
  75. package/src/engage/README.md +241 -0
  76. package/src/forms/README.md +219 -0
  77. package/src/images/README.md +74 -0
  78. package/src/layout/README.md +66 -0
  79. package/src/llms/README.md +723 -0
  80. package/src/mcp/README.md +376 -0
  81. package/src/motion/README.md +372 -0
  82. package/src/og/README.md +304 -0
  83. package/src/proxy/README.md +152 -0
  84. package/src/redirects/README.md +74 -0
  85. package/src/reputation/README.md +64 -0
  86. package/src/seo/README.md +359 -0
  87. package/src/signal/README.md +115 -0
  88. package/src/sitemap/README.md +127 -0
  89. package/src/sync/README.md +115 -0
  90. package/dist/SitemapSync-7WKY4HXI.js +0 -8
package/CHANGELOG.md ADDED
@@ -0,0 +1,3539 @@
1
+ # @sonordev/site-kit Changelog
2
+
3
+ ## 7.0.2
4
+
5
+ - The docs ship in the package: every module README, the migration guide and
6
+ this changelog, listed in a new `docs.json`. [sonor.dev](https://sonor.dev)
7
+ reads them straight from the published release, so the docs there always
8
+ match `latest`.
9
+ - README fixes: a broken link to the articles docs, "Portal" where it meant
10
+ Sonor, and a reCAPTCHA variable site-kit no longer reads.
11
+
12
+ ## 7.0.1
13
+
14
+ - `reportToolCallsToSonor` sends the `x-sitekit-version` header every other
15
+ site-kit request carries, so Sonor records which site-kit a tool call came
16
+ from (its `kit_version` was empty).
17
+
18
+ ## 7.0.0
19
+
20
+ A major: one entry per Sonor module, a root that runs nothing, ESM only,
21
+ Next 16 only, a 0.6 MB package, Cache Components support, and MCP tools
22
+ every site can give agents. Most sites move in one command when next
23
+ touched: `npx sonor-setup codemod --write`, then build. The full guide is
24
+ [docs/MIGRATING-TO-7.md](docs/MIGRATING-TO-7.md). Copies of three fleet
25
+ sites on 4.2, 5.8 and 6.5 were migrated that way and built.
26
+
27
+ ### Agents: MCP tools for every site (new)
28
+
29
+ - **`@sonordev/site-kit/mcp/sonor`**: the tools ten sites hand-rolled, once,
30
+ over the fetchers the site's own pages use. `sonorMcpServer` /
31
+ `sonorMcpTools`: `get_business_profile`, `list_services`, `search_faq`,
32
+ `find_pages`, `list_articles`, `get_article`, `get_reviews`, plus opt-in
33
+ `list_offerings`, `check_availability` and `get_inquiry_form` +
34
+ `send_inquiry`. `send_inquiry` goes through Sonor's agent-inquiry door:
35
+ the person must have said yes, and the call carries the agent's badge and
36
+ `human_approved`.
37
+ - **Who called.** `createMcpHandler` takes `onToolCall`; `reportToolCallsToSonor`
38
+ sends each call (tool, outcome, the agent's name, never the arguments) to
39
+ Sonor after the response. The dashboard's AI Visibility tab and Echo show
40
+ which agents used the site, and Echo offers the fix for a failing tool.
41
+ - **`npx sonor-setup mcp`** writes the whole wiring: server file, endpoint
42
+ with reporting, the server card at both well-known paths, and on Netlify
43
+ the rate-limited relay plus `MCP_TRANSPORT_SECRET`.
44
+ - **A custom MCP server is left alone.** A site that runs its own
45
+ (upforge.io, re-site-kit sites) gets nothing automatic: `sonor-setup mcp`
46
+ writes nothing there, and no llms.txt section is added. Every piece is
47
+ opt-in for it (reporting, some built-in tools, the section). See
48
+ src/mcp/README.md.
49
+ - **Discovery.** llms.txt gains an "Agent access" section (added to the
50
+ build-time file automatically for the built-in server only; `mcp: false`,
51
+ `createSitemap({ llmsAgentAccess })` or `--no-agent-access` turn it off), and
52
+ `createProxy({ llmsDiscovery: { mcpServerCard: true } })` links the card as
53
+ `rel="service-desc"`.
54
+
55
+ ### Breaking
56
+
57
+ - **ESM only.** `"type": "module"`; each export is `{ types, default }`. Next
58
+ and the kits are unaffected; Node 20.19+/22.12+ `require()` it, which
59
+ covers a `next.config.ts`. `engines.node` is `>=20.19`.
60
+ - **Next 16 only** (`next` peer `^16`; every fleet site is on 16).
61
+ - **The root entry is types-only** (plus `SITE_KIT_VERSION`). `BookingWidget`
62
+ → `./sync`, `AffiliatesWidget`/`useAffiliates` → the new `./affiliates`,
63
+ commerce → `./commerce`, `ManagedImage` → `./website/images`, signal →
64
+ `./signal`, reputation → `./reputation`, redirects → `./seo/redirects`,
65
+ `LandingPage` → `./website/landing`; `formatBookingTime`/`formatBookingDate`
66
+ are `formatTime`/`formatDate` on `./sync`. The codemod rewrites these.
67
+ - **The setup CLI is its own package, `sonor-setup`.** `npx sonor-setup`
68
+ works unchanged; a site whose scripts run it adds it as a dev dependency
69
+ (the codemod does). The `site-kit` bin alias is gone.
70
+ `sonor-register-sitemap` stays here.
71
+ - **No source maps in the package**, and no `.d.ts.map`s.
72
+ - **Removed, no site used them:** `./search`, `./search/contract`,
73
+ `./redirects/not-found` (with `x-sk-path` and `SK_PATH_HEADER`), `./setup`,
74
+ `LocationPageContent`/`getLocationSection`, `identifyTopicClusters`,
75
+ `formatContentSignals`, the `SiteKitConfig` type, and the postinstall GEO
76
+ bootstrap (site-kit runs nothing at install; use `npx sonor-setup geo`).
77
+ - **The register-sitemap bin's .env precedence is Next.js's**: the real
78
+ environment wins, then `.env.production.local`, `.env.local`,
79
+ `.env.production`, `.env`. It used to let `.env.local` override a
80
+ variable the host had set. `dotenv` is gone (Node's `util.parseEnv`).
81
+
82
+ ### Smaller and lighter
83
+
84
+ - The tarball is **0.60 MB** (2.24 MB unpacked), from 4.03 MB (17 MB) in 6.x.
85
+ - **One markdown library, `marked`.** The chat widget renders its lexer
86
+ tokens as React elements (raw HTML shows as text; only http(s), mailto,
87
+ tel and relative links keep an address). react-markdown and its 85
88
+ packages (~8 MB) are gone from every site's install.
89
+
90
+ ### New
91
+
92
+ - **Cache Components.** A site can turn on Next 16's `cacheComponents`:
93
+ nothing in site-kit's render path reads the wall clock or `Math.random`
94
+ any more (deadlines, TTLs and jitter use `performance.now()`; the forms'
95
+ render stamp is set on mount). The integration harness builds the fixture
96
+ both ways.
97
+ - **`@sonordev/contracts`**, a new dependency-free package: the rules
98
+ site-kit shares with sonor-api, signal-api and the dashboard (popup
99
+ blocks, site hosts, seo_pages resolution, title quality, llms sanitizers,
100
+ honeypot, fleet, slots, portfolio). site-kit's `*/contract` entries are
101
+ the same code; the APIs' byte-identical copies and drift tests are gone.
102
+ - **`sonor-setup codemod`** moves `middleware.ts` to `proxy.ts` (Next 16's
103
+ rename) with `createMiddleware` → `createProxy`, deterministically and
104
+ offline. It flags a file that sets `runtime` instead of moving it.
105
+
106
+ ### Fixed
107
+
108
+ - The seo/website homes one directory deeper than their source shipped
109
+ declarations that pointed at themselves (e.g. `seo/og/route` exported
110
+ nothing to TypeScript); `./website/slots` dropped `./slots`' default
111
+ export; `BookingWidget` and `TestimonialSection` could land in a
112
+ non-client entry and fail a server page's prerender. verify-dts and the
113
+ build now check all three.
114
+
115
+ ### Kept through 7.x
116
+
117
+ - Every 6.6 path (`./sitemap`, `./og`, `./llms`, `./images`, `./engage`,
118
+ `./middleware`, …) still works as an alias of its new home, marked
119
+ deprecated in the agent manifest; so do `createMiddleware` and
120
+ `SiteKitMiddlewareConfig`. They go in 8.0.
121
+ - `SiteKitLayout`'s `engage` prop, alongside `chat` and `popups`.
122
+ - Deprecated options sites still pass (`contentSignals`, `nativeReturnTo`,
123
+ `ManagedScripts`) are no-ops until 8.0.
124
+
125
+ ## 6.6.0
126
+
127
+ ### Popups in the site's own design (Website → Popups & Banners)
128
+
129
+ - **Blocks.** A popup is now an ordered list of blocks (heading, formatted
130
+ text, image, button, divider), drawn by one renderer, `PopupBlocks`, in
131
+ the site's own colors, fonts and corners. It's an accessible dialog
132
+ (labelled, focus kept inside and handed back, Escape closes), and it
133
+ re-sanitizes text when it renders. `EngageWidget` prefers blocks when
134
+ sonor-api sends them and lazy-loads the whole path, so the engage bundle
135
+ grows 0.7%. Kits before 6.6 keep drawing `design_json`, which sonor-api
136
+ still sends.
137
+ - **The site's design, measured.** `SiteDesignReporter` (mounted by
138
+ `SiteKitLayout`) reads the rendered home page at idle: page background,
139
+ the text most copy is set in, the accent its buttons share, fonts, radius
140
+ and card surfaces. It reports them to Sonor when they change (or weekly),
141
+ never from a cross-origin frame or localhost, so the popup builder
142
+ previews in the same design. `--sk-*` variables count as declared, and a
143
+ new `design` prop declares anything outright (`{ primary: 'var(--brand)' }`),
144
+ keeps it on the site (`{ report: false }`) or turns it off (`false`).
145
+ - **`@sonordev/site-kit/website/contract`**: the pure contract (design
146
+ tokens, popup blocks, the text sanitizer, URL rules) that sonor-api and
147
+ the dashboard share.
148
+
149
+ ### One entry per Sonor module
150
+
151
+ Someone who knows the dashboard can now guess the import path:
152
+
153
+ - `./website/{popups,images,slots,cms,landing,cta-bar}` (Website);
154
+ - `./seo/{sitemap,robots,indexnow,redirects,og,llms}` and
155
+ `./seo/{meta,pages}/contract` (SEO);
156
+ - `./chat` (Website chat, out of `./engage`).
157
+
158
+ Each is a re-export of the module's existing home. **Old paths keep
159
+ working** and resolve to the same module; the agent manifest marks them
160
+ deprecated with their new home. `SiteKitLayout` gains `chat` and `popups`
161
+ props; `engage` stays as an alias (`engage={false}` still turns both off).
162
+
163
+ ### Fixes
164
+
165
+ - **Reputation:** `fetchReviews` kept one cache for every call, so a second
166
+ caller with different options (another service, a limit, featured-only)
167
+ got the first caller's reviews. The cache is now keyed by key and URL, and
168
+ a keyless call never reads it.
169
+
170
+ ## 6.5.0
171
+
172
+ ### Portfolio: client-reported numbers
173
+
174
+ `@sonordev/site-kit/portfolio/contract` now owns **metric provenance**: the
175
+ three sources a case-study number can have, and what each may do.
176
+
177
+ - **`measured`**: we measured it. **`reported`**: the client told us, and a
178
+ named person at the client stands behind it. **`estimated`**: a projection.
179
+ (`METRIC_SOURCES`, `MetricSource`, `isMetricSource`.)
180
+ - **A reported number must say whose it is.** It carries
181
+ `reportedBy: { name, role?, organization, date }`, and
182
+ `metricSourceProblem()` refuses one without it (or with an unknown source).
183
+ The KPI JSON schema enforces the same with `if`/`then`.
184
+ - **What may headline** (a hero tile, a gallery card, a carousel, a social
185
+ post): `isHeadlineMetric()`: measured, or reported with a name behind it.
186
+ Never an estimate, never an unlabelled legacy number.
187
+ - **Credits:** `formatAttribution()` gives "Reported by Scott Mann, Director
188
+ of Business Development, True Power Systems, September 25, 2026";
189
+ `shortAttribution()` gives "per True Power Systems"; `metricSourceLabel()`
190
+ gives the badge text.
191
+ - **`carryReportedMetrics()`** keeps a person's reported numbers through a
192
+ regeneration. The generator can't author one (it can't see a client's
193
+ books), so a rewritten hero would otherwise erase them.
194
+
195
+ Additive: nothing changes for a site until it reads `source: 'reported'`.
196
+ sonor-api keeps a byte-identical mirror, guarded by the same test vectors.
197
+
198
+ ### Showcase frames: tell the embedder, stay out of its page
199
+
200
+ Agency case studies show a client's live site in device frames over a
201
+ screenshot of it. The embedder can't tell from outside whether a
202
+ cross-origin frame painted (its document reads as null either way), so the
203
+ screenshot never gave way.
204
+
205
+ - **`announceFrameReady()`**: a page framed by another origin posts
206
+ `{ type: 'sonor:frame-ready', v: 1 }` to its parent once it has loaded and
207
+ painted. `SiteKitClientProviders` calls it; nothing to configure. At the
208
+ top level or in a same-origin frame it does nothing.
209
+ - **`FRAME_READY_MESSAGE` / `isFrameReadyMessage`** are exported from
210
+ `@sonordev/site-kit/portfolio/contract` for the embedder's side.
211
+ - **Engage stays out of someone else's page**: no popups or chat launcher in
212
+ a cross-origin frame (the agency's visitor isn't this site's, and the
213
+ impressions would count here). Opt back in with `engage.allowInFrame`, the
214
+ same default and switch analytics has had since the send gate.
215
+
216
+ ## 6.4.0
217
+
218
+ Two pieces of fleet logic that sites were copying between each other now live
219
+ in the kit; nothing changes for a site until it imports them. Author JSON-LD
220
+ does change on upgrade: author pages become ProfilePages and a person carries
221
+ one `@id` across sites (last section). Engage popups also get safer and
222
+ better-behaved (next section).
223
+
224
+ ### Engage: popups, banners and toasts
225
+
226
+ Popups made in Sonor (Website → Popups & Banners, or by an agent through the
227
+ Sonor MCP) work on every released kit from 3.2 on: Sonor sends the shape the
228
+ widget already renders. This release is the kit's own half:
229
+
230
+ - **Links and images are checked here too.** `DesignRenderer` follows a
231
+ button or link only to a site path, an anchor, http(s), mailto or tel, and
232
+ loads only http(s) or same-site images (`safeActionUrl`, `safeImageSrc` in
233
+ `engage/element-rules.ts`). Sonor refuses anything else when a popup is
234
+ saved; before, the renderer would have run a `javascript:` URL.
235
+ - **Clicking the button counts as seen.** The frequency cap was recorded
236
+ only on close, so a "once" popup came back on the page its own button led
237
+ to.
238
+ - **Popups leave pages they don't target.** On a client-side navigation the
239
+ widget re-checks every element and hides one that no longer matches (it
240
+ used to stay up until closed).
241
+ - **One centred popup at a time.** A second waits until the first is closed;
242
+ banners and toasts still show alongside.
243
+ - **Buttons take an accessible name** (`props.ariaLabel`), which Sonor sets on
244
+ the close button.
245
+ - The elements request sends `deviceType`.
246
+
247
+ ### MCP: `@sonordev/site-kit/mcp/transport`, the rate-limited public relay
248
+
249
+ Netlify rate-limits a native function, never a Next.js route handler, so
250
+ upforge.io (2026-09-21) and gmwlaw.org put their public MCP endpoint behind a
251
+ native function that signs each call and relays it to a Next route. Both sites
252
+ carried the same four files. The new subpath is that relay:
253
+
254
+ - `createNetlifyMcpRelay(options?)`: the default export for
255
+ `netlify/functions/mcp.mjs`. HMAC-SHA256 signature in a transport header
256
+ (overwriting any client-sent value), relay to `/api/mcp-internal` on the
257
+ deploy permalink, 64 KB body cap, `redirect: 'error'`, 55 s timeout, 502 on
258
+ upstream failure, `Cache-Control: no-store` and the
259
+ `netlify-rate-limited-v1` marker on responses. The function file keeps its
260
+ own literal `config` (`path` + `rateLimit`), because Netlify reads it
261
+ statically.
262
+ - `protectMcpHandlers(handlers, options?)` for `/api/mcp`: 403 for unsigned
263
+ calls when `NODE_ENV === 'production'`.
264
+ - `createMcpInternalRoute(handlers, options?)` for `/api/mcp-internal`: 403
265
+ unless signed, in every environment, then dispatch by method.
266
+ - `hasMcpTransportAuthorization(request, options?)` and `signMcpTransport`,
267
+ constant-time via `timingSafeEqual`.
268
+
269
+ Header and label are configurable (`{ header, label }`) and default to
270
+ `x-site-mcp-transport` / `site-mcp-transport-v1`, so a site with live names
271
+ (upforge.io's `x-upforge-mcp-transport`, which its release check asserts) keeps
272
+ them. The entry is Node only, imports no `server-only` (it throws in a plain
273
+ Node function) and is not re-exported from `@sonordev/site-kit/mcp`. Setup and
274
+ the three files: `src/mcp/README.md`, step 5.
275
+
276
+ ### llms: `createSeoRevalidationHandler`, Sonor's SEO webhook
277
+
278
+ heinrich-law-nextjs and wirsch-law-nextjs each carried
279
+ `lib/seo-revalidation.ts`, the `/api/seo-revalidate` handler Sonor calls when
280
+ a title, description or schema changes. It is now
281
+ `createSeoRevalidationHandler({ secret, revalidatePath, revalidateTag, publicationBasePath? })`
282
+ in `@sonordev/site-kit/llms`, wrapping `createLlmsRevalidateHandler`:
283
+
284
+ - `Authorization: Bearer` only, constant-time compare. No `?secret=` form.
285
+ - 16 KB body cap, read without buffering past it (413). At most 100 paths and
286
+ tags; every path must be a same-site local path, even after
287
+ percent-decoding (400, nothing revalidated).
288
+ - Regenerates the named pages, `/sitemap.xml` and both llms files. A full or
289
+ tag-only `seo` refresh also regenerates the root layout. Tags expire now
290
+ (`{ expire: 0 }`).
291
+ - `publicationBasePath` (optional) also refreshes the publication index and
292
+ feeds, and maps legacy `/blog/...` paths onto the publication root.
293
+
294
+ `parseSeoRevalidationPayload` is exported for sites that want the validation
295
+ alone.
296
+
297
+ Three options let a package build on the handler instead of copying it.
298
+ agency-site-kit 0.9.0's portfolio `createRevalidateHandler` is now a
299
+ configuration of it:
300
+
301
+ - `secret` may be a getter, read on every call, so a route can create the
302
+ handler once at module scope.
303
+ - `extraPaths`: local paths regenerated on every call (a portfolio hub).
304
+ Validated when the handler is created.
305
+ - `extendPayload(payload, body)`: add paths or tags from body fields the
306
+ handler doesn't read (portfolio `slug`/`slugs`, a default tag). Its result
307
+ is validated like the body, and a throw is a 400, so an extension can't
308
+ widen what a caller may revalidate.
309
+
310
+ ### llms: `createLlmsRevalidateHandler` compares its secret in constant time
311
+
312
+ It used `!==`. It now shares the constant-time compare with the SEO handler.
313
+ Behavior is otherwise unchanged, including the documented `?secret=` form.
314
+
315
+ ### Articles: author pages are ProfilePages, one Person `@id` everywhere
316
+
317
+ Google reads an author page as a profile when it is a `ProfilePage` whose
318
+ `mainEntity` is the `Person`, and it joins mentions of one person when every
319
+ page gives them the same `@id`. The kit emitted a bare `Person` with no id, so
320
+ Ramsey Deal's upforge.io author page, his Forge articles and ramseydeal.com
321
+ described three unlinked people (2026-09-24, while working toward his
322
+ Knowledge Panel).
323
+
324
+ - **`generateAuthorSchema` now returns a `ProfilePage`** (`@id`
325
+ `<page>#profilepage`, `url`, `name`, `dateCreated`/`dateModified` from the
326
+ row) with the `Person` as `mainEntity`. Every current caller (upforge.io,
327
+ qcr-nextjs, `AuthorPage`) renders it as its own script, so none double-wraps.
328
+ To embed the person in a bigger graph, use the new
329
+ **`generateAuthorPersonSchema`**: the same `Person`, without `@context`.
330
+ - **One `@id` rule, `authorEntityId`.** The author row's new `entity_id`
331
+ (`blog_authors.entity_id`, an absolute https URI such as
332
+ `https://ramseydeal.com/#person`) wins; otherwise the Person is
333
+ `<author profile URL>#person`. A relative URL (no `siteUrl`) gives no `@id`.
334
+ sonor-api serves author rows with `select('*')`, so the column reaches sites
335
+ with no API change; the author DTOs accept it for writes.
336
+ - **Article bylines share it.** `generateArticleSchema` and
337
+ `generateClusterArticleSchema` build `author` through the new
338
+ `generateArticleAuthorNode`, which adds `@id` and the author page `url`. A
339
+ byline links to an author page only when the site declares one (the new
340
+ **`authorPages`** routing option) or the row has `author_page_url`: bd-aec,
341
+ ccc and heinrich have no author pages, and a byline URL that 404s is worse
342
+ than none. A post with no author now omits `author` instead of emitting a
343
+ nameless `Person`.
344
+ - **Stored `schema_json` too.** Signal freezes a byline into the post at
345
+ publish time. `generateAllArticleSchemas` now passes stored nodes through
346
+ `withArticleAuthorIdentity`, which adds the author's `@id` and `url` to an
347
+ article node whose `Person` byline has the same name and lacks them. Stored
348
+ values always win and the row is never rewritten.
349
+ - **`authorPages` routing option**: `true` for author pages at
350
+ `<publication>/author/<slug>`, or a function for a custom path (upforge.io
351
+ serves `/author/<slug>` beside a `/theforge` publication).
352
+ - **`AuthorPage` takes `siteUrl`, `siteName` and `jsonLd`.** Without
353
+ `siteUrl` its JSON-LD had relative URLs and no id. `jsonLd={false}` turns it
354
+ off for a page that renders `generateAuthorSchema` itself.
355
+
356
+ Upgrading a site:
357
+
358
+ - **upforge.io** renders `generateAuthorSchema` *and* `<AuthorPage>`, so each
359
+ author page carries two author schemas today (one with a relative `url`).
360
+ Keep the page's own `generateAuthorSchema` (server-side, with `siteUrl` and
361
+ its root `/author` route) and pass `jsonLd={false}` to `AuthorPage`. Don't
362
+ hand `AuthorPage` a function `routing.authorPages` from a server page: it's
363
+ exported from the client-stamped `articles` barrel, and Next can't pass a
364
+ function to a client component. (Corrected after publish; the first version
365
+ of this note said to do exactly that.)
366
+ - **qcr-nextjs** (author pages under `/paddlewheel-post/author`): add
367
+ `authorPages: true` to `paddlewheelRouting` so article bylines link.
368
+ - Set `entity_id` on an author row only for a person with a canonical id of
369
+ their own (Ramsey: `https://ramseydeal.com/#person`).
370
+
371
+ ## 6.3.5
372
+
373
+ ### Forms: the first submit on a reCAPTCHA form works again
374
+
375
+ On a cold page, `enterprise.js` fires `onload` while `grecaptcha.enterprise`
376
+ is still Google's loader stub, which has `ready()` and no `execute`.
377
+ `getRecaptchaToken` checked for `execute` before awaiting `ready()`, saw the
378
+ stub, returned no token, and `submitForm` threw "Verification could not be
379
+ completed". The visitor saw "Something went wrong sending your submission" and
380
+ **no request reached Sonor**, so nothing was stored or refused either. It hit
381
+ the first submit on every form of a project with `requireRecaptcha` (upforge.io
382
+ and upforgeapps.com). A second click usually worked, which is why it hid.
383
+ Rania Lombera (Pollard Properties) filled in upforge.io/free-audit on
384
+ 2026-09-21, pressed submit once, got the error and booked a call instead.
385
+
386
+ - `getRecaptchaToken` now loads, awaits `ready()` (bounded), and only then
387
+ requires `execute`.
388
+ - Every managed form starts loading reCAPTCHA on its first field focus
389
+ (`preloadRecaptcha`, wired through `FormClient` and `useForm`), so the script
390
+ is ready long before the submit. Tokens are still minted at submit; they
391
+ expire after about two minutes.
392
+
393
+ ### Forms: `onSuccess` gets the submitted values
394
+
395
+ `onSuccess` was typed as `FormSubmission` (a server row with `data`,
396
+ `routing_type`, `is_spam`...) but received Sonor's receipt,
397
+ `{ success, message, submissionId, redirectUrl }`, so `submission.data` was
398
+ always undefined. It now receives `FormSubmitResult`: the receipt plus `id` and
399
+ `data`, the visible values that were sent. `FormSubmission` is now a deprecated
400
+ alias of `FormSubmitResult`, so annotated callbacks keep compiling. Code that
401
+ read `routing_type`, `is_spam` or similar was reading undefined and now fails
402
+ to compile, which is the point: Sonor answers spam and accepted submissions
403
+ identically on purpose.
404
+
405
+ ## 6.3.4
406
+
407
+ ### robots.txt: no more Content-Signal line
408
+
409
+ `createRobotsTxtHandler` no longer writes a `Content-Signal:` line. Google's
410
+ robots.txt parser reports it as an "Unknown directive" error in Search Console
411
+ (cincinnaticondoconnection.com, 2026-09-22), and robots.txt is the one file
412
+ every crawler has to parse cleanly. 37 fleet sites were sending it.
413
+
414
+ - `contentSignals` is a deprecated no-op, kept so existing call sites
415
+ type-check. Remove it when you next touch a site's `app/robots.txt/route.ts`.
416
+ - `formatContentSignals` is deprecated. Express AI-training preferences with
417
+ `buildAiCrawlerRules({ training: 'allow' | 'block' })`, which every crawler
418
+ understands.
419
+ - The handler now emits only User-Agent, Allow, Disallow, Sitemap and Host.
420
+
421
+ ## 6.3.3
422
+
423
+ ### Maps: no Google key in page HTML
424
+
425
+ `SiteKitLayout` no longer copies `NEXT_PUBLIC_GOOGLE_MAPS_API_KEY` (or
426
+ `GOOGLE_MAPS_BROWSER_KEY`) into every page. It shipped the key in the HTML of
427
+ every route, even on sites with no Google map, and Netlify's secrets scanner
428
+ now fails the build on it (cincinnaticondoconnection.com, 2026-09-22).
429
+
430
+ Client maps already got their key from Sonor first: `fetchMapsConfig()` calls
431
+ `/api/public/maps/config`, which returns Sonor's managed, HTTP-referrer and
432
+ API-restricted browser key, and `fetchNearbyPlaces()` proxies Places through
433
+ Sonor's server key. That's now the only client path, so **a site needs no
434
+ Google key of its own**: delete `NEXT_PUBLIC_GOOGLE_MAPS_API_KEY` from its env.
435
+ A server render can still fall back to that variable if it's set.
436
+
437
+ - `publishSiteCredential` no longer takes `mapsBrowserKey` or sets
438
+ `window.__GOOGLE_MAPS_API_KEY__`.
439
+
440
+ ## 6.3.2
441
+
442
+ ### Forms: the no-JavaScript submission path is gone
443
+
444
+ Managed forms no longer render a native `action` pointing at
445
+ `/api/public/forms/submit-native` or a hidden `_sk_token`, and sonor-api no
446
+ longer has that endpoint. The token sat in the page's HTML, so a bot only had
447
+ to download the page to borrow it: from 2026-08-14 that path took 10,500 bot
448
+ submissions across the fleet and not one real lead.
449
+
450
+ A form now takes exactly two routes in: a browser running site-kit, or a named
451
+ agent through `/forms/agent-inquiry`.
452
+
453
+ - `StaticForm` and the classic form render `method="post"` with no `action`,
454
+ so a click before hydration never puts what someone typed in a URL.
455
+ - `nativeReturnTo` (ManagedForm, FormEnhancer, StaticForm) and `returnTo`
456
+ (ServerForm) are deprecated no-ops, kept so existing call sites still
457
+ type-check. Remove them when you next touch the file.
458
+ - `native_token` is gone from `ManagedFormConfig`.
459
+
460
+ ## 6.3.1
461
+
462
+ ### admin-auth: gated API routes and `getRequestSession`
463
+
464
+ - **`apiPaths`** on `createSonorSso`: API prefixes the middleware should
465
+ guard (e.g. `['/api/studio']`). Without a session they answer 401 JSON
466
+ instead of redirecting to the login page. Handlers still check the session
467
+ themselves; this is the second lock, not the only one.
468
+ - **`getRequestSession(request)`**: the identity from a request's cookie, for
469
+ middleware and route handlers that have the request in hand.
470
+
471
+ ## 6.3.0
472
+
473
+ ### Sign in with Sonor: `@sonordev/site-kit/admin-auth`
474
+
475
+ A site's own admin area (a bid tool, a registrations dashboard, a design
476
+ studio, a property editor) can now sit behind a Sonor login with one factory
477
+ instead of a hand-copied auth folder. 4m-lawn-care, legacy-clay-classic and
478
+ the CCG builder each carried their own copy of this handshake, and the copies
479
+ had drifted: different crypto, different session lengths, roles in only one.
480
+
481
+ ```ts
482
+ // lib/sonor-sso.ts
483
+ export const sso = createSonorSso({ paths: '/admin' })
484
+
485
+ // middleware.ts
486
+ export default createProxy({ before: sso.gate, securityHeaders: true })
487
+
488
+ // app/api/auth/callback/route.ts
489
+ export const GET = sso.handleCallback
490
+
491
+ // app/api/auth/logout/route.ts
492
+ export const GET = sso.handleLogout
493
+ export const POST = sso.handleLogout
494
+ ```
495
+
496
+ - **`gate`** plugs into `createProxy({ before })`. It redirects to the login
497
+ page with a `return_to`, marks the area `noindex`, and returns nothing for
498
+ public pages so redirects and AI discovery headers still run there.
499
+ - **`getSession()`** is the check for server components, server actions and
500
+ API routes. The middleware matcher skips `/api/*`, so every API route that
501
+ exposes admin data has to call it.
502
+ - **`getLoginUrl({ returnTo })`** builds the `app.sonor.io/sso/grant` link.
503
+ The project id comes from `SONOR_API_KEY`; no extra env var.
504
+ - **`handleCallback`** verifies the token with Sonor, which rejects tokens
505
+ issued for any other project, and sets a signed httpOnly cookie. The
506
+ return path is limited to the gated area, so the callback can't be used as
507
+ an open redirect. The token's URL gets `Referrer-Policy: no-referrer`.
508
+ - Sessions last **30 days** by default (`sessionLifetimeSeconds`). Rotating
509
+ `SONOR_SESSION_SECRET` (`secretEnv` to rename it) signs everyone out.
510
+ - **`adminEmails`** marks admins within the area. `isAdmin` is computed from
511
+ the verified cookie on every read, never stored in it.
512
+ - Web Crypto throughout, so the gate runs in Edge middleware. The cookie
513
+ format matches the pre-kit copies: pass a site's old `cookieName` and
514
+ `secretEnv` and nobody is signed out by the switch.
515
+ - Outside production the gate lets everyone through as a dev identity
516
+ (`devBypass: false` to turn that off).
517
+
518
+ ## 6.2.0
519
+
520
+ ### Motion: `<CountUp>`, `scrollIn`, and parallax anchored at load
521
+
522
+ Two helpers every animated site ended up writing for itself now live in the
523
+ kit. hometown-mortgage and art-realty each carried their own copies.
524
+
525
+ - **`<CountUp>`** (`@sonordev/site-kit/motion/gsap`): a stat that rolls up
526
+ to its value as it scrolls into view. Pass the formatted value as its
527
+ text (`<CountUp>$412,500</CountUp>`); it rolls the first number and keeps
528
+ the formatting around it. The server HTML is the real text, a stat
529
+ already on screen keeps its number, and the real text is restored at the
530
+ end of the roll and by the no-scroll failsafe. `parseCountUp` is exported.
531
+ - **`scrollIn(gsap, el, build, { start })`** (same subpath): the fold check,
532
+ offscreen arm and 2.5s no-scroll failsafe as one helper for any
533
+ below-the-fold entrance built in a `useGsap` setup. CountUp is built on
534
+ it.
535
+ - **`anchor: 'load'`** on `registerScene`, `useScrollScene` and
536
+ `<Parallax>` / `useParallax`: progress counts from where the page was
537
+ when the scene first rendered, so an above-the-fold layer (a hero photo,
538
+ usually the LCP element) renders its rest state and moves only when the
539
+ visitor scrolls. Before, it jumped on hydration to the frame for its
540
+ partway-through-the-trip position. The rate is unchanged, and a scene
541
+ that arms below the fold behaves exactly as before. `SceneAnchor` is
542
+ exported.
543
+
544
+ ### Motion: `useGsap` loads gsap at idle, not on approach
545
+
546
+ `useGsap` used to start downloading gsap + ScrollTrigger only once its
547
+ element came within 200px of the viewport. A visitor scrolling at a normal
548
+ pace reached the section before the ~46KB chunk had arrived, so setup ran
549
+ with the section already on screen and its animation was skipped or started
550
+ late.
551
+
552
+ - gsap now loads once per page at idle: after the `load` event, on the next
553
+ `requestIdleCallback` (2s timeout; Safari, which lacks it, waits 200ms
554
+ after load). Still off the LCP critical path and clear of hydration.
555
+ - Setup timing is unchanged: it still runs when the element comes within
556
+ `near` (default `200px 0px`), now with gsap already in hand.
557
+ - Reduced-motion visitors still never download gsap.
558
+ - New export `whenIdle()` from `@sonordev/site-kit/motion/gsap`: the idle
559
+ gate itself, for anything else that should wait for the same moment.
560
+
561
+ Echo now follows the project's **Enable Chat Widget** switch in Sonor. Before,
562
+ the launcher showed on every site with engage on, whatever the switch said:
563
+ `ChatWidget` fetched the setting and never read it.
564
+
565
+ - A project that never saved chat settings counts as on (its config has no
566
+ `is_enabled`, and only an explicit `false` hides Echo), so no site that
567
+ shows Echo today loses it. As of this release no project has switched it
568
+ off.
569
+ - `ChatWidget` renders nothing until the widget config arrives, so a
570
+ switched-off site never flashes a launcher. If the config can't be fetched,
571
+ the launcher stays hidden.
572
+ - Availability polling starts only once Echo is on, instead of on every page
573
+ of every engage site.
574
+ - `isChatEnabled(config)` is the one visibility check. The Echo UI moved into
575
+ `EchoChat`, which `ChatWidget` mounts; it isn't part of the public API.
576
+
577
+ ### Echo: a chat started from a quick-action chip showed the visitor's message twice
578
+
579
+ Clicking a welcome quick-action chip on an AI-mode widget drew the visitor's
580
+ message as two consecutive bubbles, on every Echo site. `startChat` drew the
581
+ bubble, then the session-init effect drew it again before sending it to Echo.
582
+ Only the doubled bubble was wrong: Echo was called once and answered once.
583
+ Live-chat (socket) mode never doubled; the server doesn't echo a visitor's own
584
+ message back.
585
+
586
+ - One rule now, in `src/engage/chat-messages.ts`: a visitor's bubble is drawn
587
+ once, when they act. Delivery afterwards only sends. `openingMessages`
588
+ draws the chip's message when the chat begins; `deliverOpeningMessage`
589
+ sends it once the session is up and has no way to draw it.
590
+ - The composer, the `suggest_action` chips and the opening message share one
591
+ AI turn, `askEcho`. Three copies of the reply/error/loading handling
592
+ collapsed into one, and the composer's "talk to a person" offer now reads the
593
+ message it just sent instead of the visitor's previous one.
594
+ - Regression tests replay the opening sequence against a plain transcript
595
+ (the package's vitest has no DOM), in AI and live mode, plus a source guard
596
+ that fails if `ChatWidget` builds a visitor bubble or an Echo error reply
597
+ inline again. Checked in Chrome against the old code: two bubbles before,
598
+ one after.
599
+
600
+ ## 6.1.3
601
+
602
+ ### `CtaBarAction` is polymorphic on `as`
603
+
604
+ `as` forwarded every prop to the component at runtime, but the props type
605
+ only admitted anchor and button attributes. `<CtaBarAction
606
+ as={ScheduleTourButton} values={tourValues}>` failed with TS2322, so the MDG
607
+ unit pages wrapped the tour button in a local adapter just to bind `values`.
608
+
609
+ - `CtaBarActionProps<C>` is generic on `as`. With `as`, the action takes that
610
+ component's own props, required ones included, minus the five it owns
611
+ (`icon`, `variant`, `collapse`, `children`, `className`). A Next `Link`
612
+ takes its object `href` and `prefetch`; forgetting a prop the component
613
+ requires is now a type error instead of a silent runtime gap.
614
+ - Without `as`, nothing changes: the same anchor and button attributes as
615
+ 6.1.0, and unknown props still error.
616
+ - Type-level tests in `src/cta-bar/cta-bar.test-d.tsx`. `pnpm test` now runs
617
+ vitest's typecheck mode for `*.test-d.ts[x]` files, so type contracts are
618
+ guarded by the same command as the rest (checked with a negative control:
619
+ the 6.1.2 typing fails seven assertions). `tsconfig.build.json` keeps them
620
+ out of `dist`.
621
+
622
+ ## 6.1.2
623
+
624
+ Echo drew the raw brand colour as text on its light panel. On a light brand
625
+ that failed WCAG AA: Gunning Homes' `#d4af37` gold sat at 2.1:1, and the
626
+ fleet's teal `#39bfb0` at 2.3:1. It predates 6.1 (the solid white window had
627
+ the same problem).
628
+
629
+ - One helper, `src/engage/brand-color.ts`, now derives Echo's brand text
630
+ colour. It's the raw brand when that already clears 4.5:1 against the panel
631
+ and its brand-tinted chips; otherwise the brand is pulled toward black on a
632
+ light panel, or white on a dark one, only as far as AA needs. Gold becomes
633
+ `#887023` (4.8:1), keeping its hue. Blues, reds and other dark brands are
634
+ unchanged, and so are dark panels whose brand already reads (the TPS power
635
+ studies microsites' navy Echo).
636
+ - It covers the welcome quick-action chips, the "Or call us at" phone link,
637
+ markdown link buttons, suggestion chips (Echo's and the inline
638
+ `suggest_action` ones), the "Talk to a person" button and the sent-message
639
+ check. Fills (launcher, avatar tile, the visitor's bubbles, buttons) keep
640
+ the raw brand.
641
+ - `--sk-primary` in `rgb()`, `hsl()` or 3-digit hex is now read properly.
642
+ Before, a non-hex brand put white text on a light brand's buttons and
643
+ bubbles and tinted every chip with the default blue.
644
+ - The inline Echo form's submit button drew white text on the brand
645
+ regardless of the brand; it now follows the rest of the widget (dark text
646
+ on a light brand).
647
+ - Colour parsing moved to `src/shared/color.ts`, shared with the brand-profile
648
+ extractor, instead of two parsers that disagreed.
649
+
650
+ ## 6.1.1
651
+
652
+ Found piloting 6.1.0 on gunning-homes.
653
+
654
+ - The CTA bar, its spacer, the Echo launcher and the Echo window stay off
655
+ paper (`@media print`). Fixed chrome repeats on every printed sheet;
656
+ sites were hiding it by hand.
657
+ - Echo's reduced-motion rule moved into a hoisted `sk-echo` stylesheet, so it
658
+ is present before the window first opens.
659
+ - The fixture app now also renders a page-level bar inside `<main>` (how
660
+ gunning-homes and the MDG unit pages use it), so the axe gate covers both
661
+ placements. It passes: axe 4.13 retired `landmark-complementary-is-top-level`.
662
+
663
+ ## 6.1.0: Liquid Glass
664
+
665
+ One glass material for the kit's floating chrome (`src/shared/glass.tsx`),
666
+ and the two places visitors touch it most: a new mobile CTA bar, and Echo.
667
+ No breaking API changes. Echo looks different on every site that bumps.
668
+
669
+ ### New: `@sonordev/site-kit/cta-bar`
670
+
671
+ `<CtaBar>` + `<CtaBarAction>`, the floating glass capsule that keeps a site's
672
+ one or two highest-intent actions a thumb away on phones. It replaces the
673
+ fleet's eleven hand-rolled sticky mobile bars, each of which had solved a
674
+ different part of the same problem:
675
+
676
+ - `hideOver`: steps aside while the form it points at (or the footer) is on screen.
677
+ - `showAfter`: stays off the hero until the hero CTA scrolls away; server-rendered hidden, so nothing slides over the hero during hydration.
678
+ - `hideWhileTyping` (default on): never sits on the on-screen keyboard.
679
+ - `compactOnScroll` (default on): tightens on the way down, an icon-bearing secondary action folds to its icon, and scrolling up restores it.
680
+ - Lifts the Echo launcher above itself while it's on screen, below its breakpoint, by setting `--sk-echo-offset-bottom`. No site CSS.
681
+ - Reserves its own height at the end of the page (a spacer), so sites drop their `padding-bottom` hacks. Or pass `spacer={false}` and pad the footer with `--sk-cta-bar-space`, published on `<html>` while a bar is present, so the footer's background runs under the bar.
682
+ - Emits `cta_click` through the standalone analytics dispatch.
683
+ - Server component with a childless behaviour island: a server layout passes `as={Link}` directly. About 4.4 KB gzipped, glass and analytics included.
684
+ - Solid when there's no `backdrop-filter`, reduced transparency or more contrast; no transitions under reduced motion; visible with JS off.
685
+
686
+ ### Echo: the Liquid Glass update
687
+
688
+ - The launcher is brand-tinted glass, and its icon turns dark on a light brand (it was always white).
689
+ - The chat window is a glass panel that grows out of the launcher's corner. The header is part of the sheet with a brand wash instead of a solid gradient block; the brand lives on the avatar tile, the visitor's bubbles, the send button and the launcher.
690
+ - Bubbles and the composer stay near-opaque on the glass, for contrast.
691
+ - The launcher glides when a CTA bar lifts it.
692
+ - Chat input, offline form and inline Echo form fields are 16px, so iOS Safari no longer zooms the page when a visitor taps into them.
693
+ - `--sk-glass-panel-opacity: 100%` restores a solid window; `--sk-glass-brand-opacity: 100%` a solid launcher.
694
+
695
+ ## 6.0.1
696
+
697
+ ### Build-time llms.txt links clusters to the site's own article path
698
+
699
+ 6.0 moved the default publication path to `/articles`. `generateLLMsTxt`
700
+ took a `publication` option, but the two writers that run at build time
701
+ didn't pass one, so a site whose articles live at `/blog` or `/insights`
702
+ would get topic-cluster links to `/articles` whenever llms.txt falls back to
703
+ local generation.
704
+
705
+ - `createSitemap({ publication: { basePath: '/insights' } })` passes it to
706
+ the build-time llms.txt write.
707
+ - `writeLLMsTxtToPublic({ publication })` passes it through.
708
+ - `sonor-register-sitemap --write-llms --publication /insights` for
709
+ postbuild writers.
710
+
711
+ ## 6.0.0
712
+
713
+ ### Breaking: blog is now articles
714
+
715
+ Sonor publishes articles from Broadcast, and site-kit's API says so.
716
+ `@sonordev/site-kit/blog*` is `@sonordev/site-kit/articles*`, every
717
+ Blog-named export has an Article or Publication name, the default publication
718
+ path is `/articles`, and default CSS classes are `.sk-article-*`. No aliases.
719
+ The full old-to-new table and the one thing that moves URLs (`basePath`) are
720
+ in MIGRATION.md.
721
+
722
+ - The `articles` client barrel no longer exports async server components;
723
+ they're in `articles/server-ui`. That removes the last exception in the
724
+ client-entry boundary test.
725
+ - Reads go to `/public/articles/*` on the Sonor API.
726
+ - llms.txt topic-cluster links use the site's publication routes (new
727
+ `publication` option) instead of a hardcoded `/blog/<slug>`.
728
+ - Author Person schema URLs follow the publication's author route.
729
+
730
+ ### IndexNow key route: Bing hears about an article the moment it's published
731
+
732
+ Sonor now submits every published, edited or removed article to IndexNow
733
+ (Bing, and through it ChatGPT search and Copilot, plus Yandex, Seznam and
734
+ Naver). IndexNow only accepts that from a host that serves the project's key,
735
+ and Sonor checks the host first, so a site gets it by adding one file:
736
+
737
+ ```ts
738
+ // app/indexnow.txt/route.ts
739
+ export { GET } from '@sonordev/site-kit/robots/indexnow'
740
+ ```
741
+
742
+ The key comes from Sonor with the site's `SONOR_API_KEY` and isn't a secret.
743
+ Without the route the site is skipped, never refused.
744
+
745
+ ### Image sitemap and Google News sitemap for articles
746
+
747
+ - `generateArticleSitemap` adds each post's featured image as an `images` entry,
748
+ which Next renders as `<image:image>`.
749
+ - `createSitemap`'s `additionalPaths` items take `images` (relative URLs
750
+ resolve against the site) and `lastModified`, so a site listing its own
751
+ articles gets an image sitemap and real modified dates instead of the build
752
+ time.
753
+ - New `generateNewsSitemap({ siteUrl, publicationName, ...routing })` returns a
754
+ Google News sitemap of posts published in the last two days. Serve it from
755
+ `app/news-sitemap.xml/route.ts` and list it in robots.txt. The pure builder
756
+ (`buildNewsSitemapXml`) is exported for sites with their own post source.
757
+
758
+ ### Feeds carry the 20 newest posts, not 100
759
+
760
+ `generateRssFeed` and `generateAtomFeed` put every post's full HTML in the
761
+ feed, and fetched up to 100 posts, so an active blog's feed ran past half a
762
+ megabyte, where some readers and aggregators stop fetching. They now carry the
763
+ 20 newest (`maxItems` to change it); older posts stay in the sitemap. A `]]>`
764
+ inside a post body no longer ends the CDATA section early and breaks the feed.
765
+
766
+ ## 5.8.3
767
+
768
+ ### AEO components and SpeakableSchema get a client-safe entry point
769
+
770
+ `@sonordev/site-kit/llms` is one barrel that also exports `writeLLMsTxtToPublic`
771
+ and the `createLLMsTxtHandler`/`createLLMsFullTxtHandler` route handlers, both
772
+ of which import Node's `fs` at module scope. Importing `AEOBlock` (or any AEO
773
+ component, or `SpeakableSchema`) from a Client Component pulls that whole
774
+ module graph into the browser bundle, and `fs` doesn't resolve there — the
775
+ build fails outright, even though the component itself never touches the
776
+ filesystem. Hit on spade-nextjs, cincy-mahjong-club-nextjs, and the MDG
777
+ apartment sites' apply page during the 2026-09-16 fleet AEO rollout.
778
+
779
+ - New `@sonordev/site-kit/llms/client` exports the ten `AEO*` components,
780
+ `SpeakableSchema`, `createSpeakableSchema`, and their types, nothing else.
781
+ Import AEO markup from here in a Client Component; keep using
782
+ `@sonordev/site-kit/llms` from server components and route handlers.
783
+
784
+ ### commerce/server's helpers were 404ing: missing `/api` segment
785
+
786
+ Every server-side commerce helper (`getOfferingBySlug`, `getOfferings`,
787
+ `getOfferingPaths`, `getUpcomingEvents`, and their `*Result` variants)
788
+ fetched `${apiUrl}/public/commerce/...`, missing the `/api` segment the Sonor
789
+ API actually serves. Every other module in the package, including
790
+ `commerce/api.ts` (the client-side fetcher for the same data), already used
791
+ `/api/public/commerce/...`; only the server helpers had drifted. Two sites
792
+ (gwa-nextjs, moore-canine-co-nextjs) had worked around it with a raw fetch
793
+ instead of these helpers.
794
+
795
+ - Fixed the endpoint on all seven calls. Added a test asserting the real URL
796
+ for each exported helper — the existing regression tests mocked `fetch`
797
+ without ever inspecting what was passed to it, so the wrong path shipped
798
+ with a green suite.
799
+
800
+ ### ManagedSchema stopped double-counting a breadcrumb inside a managed `@graph`
801
+
802
+ Its auto-generated BreadcrumbList only fires when none of the combined
803
+ schemas already has one, but the check looked for `@type === 'BreadcrumbList'`
804
+ on the top level only. Sonor's `managed_schema` and entity-enhanced schemas
805
+ are frequently shipped as one `{ "@graph": [...] }` wrapper rather than a flat
806
+ node, so a breadcrumb nested inside the graph was invisible to it —
807
+ art-realty-nextjs was shipping three BreadcrumbList blocks on some pages (its
808
+ own component's, Sonor's, and the kit's synthesized one) before this was
809
+ caught.
810
+
811
+ - The check now looks inside `@graph` too, and treats a string-array `@type`
812
+ as a match.
813
+
814
+ ## 5.8.2
815
+
816
+ ### A form that changes slug mid-fill no longer wipes what was typed
817
+
818
+ `useForm` re-seeded its values from scratch every time a config arrived, so a
819
+ form whose slug changed while someone was filling it in lost everything they
820
+ had entered. That is not an exotic case: notification recipients are per-form,
821
+ so a site that routes to different inboxes has to put a picker on the form and
822
+ swap which managed form receives the lead. Choosing from that picker emptied
823
+ the name, email and phone the visitor had already given.
824
+
825
+ - Values now carry across a config change, for any slug the new config still
826
+ has. Precedence, weakest first: config defaults, the caller's
827
+ `initialValues`, then anything already entered.
828
+ - A field the new config does NOT have is dropped rather than carried, so a
829
+ value can't be submitted invisibly on a form that never asked for it.
830
+ - An entered empty string counts as entered, so a default cannot silently
831
+ refill a field the visitor deliberately cleared.
832
+ - Errors are pruned the same way. A message on a field that is gone could
833
+ never be cleared: nothing on screen corrects it and validation never
834
+ revisits it.
835
+ - The precedence lives in `forms/form-values.ts` as a pure function, following
836
+ `field-rules.ts`, so it is tested on its own. `FormClient` does not use it:
837
+ its config comes from a prop and never changes.
838
+
839
+ ## 5.8.1
840
+
841
+ ### The published package is 40% smaller
842
+
843
+ `sonor-setup` bundles Babel for its migrate codemods, and the source maps for
844
+ that bundle — maps describing Babel's own source, which no consuming site ever
845
+ steps through — were 2.4 MB of the tarball's 3.9 MB of gzipped maps. Over half
846
+ the package existed to debug the CLI.
847
+
848
+ - A build step (`scripts/prune-cli-maps.cjs`, run from tsup's `onSuccess`, so
849
+ `pnpm build` and `prepublishOnly` both get it) drops source maps for output
850
+ nothing but the CLI can reach. It reads reachability from the emitted files
851
+ rather than a hand-kept list, and prunes a file only when it is unreachable
852
+ from every non-CLI entry, so anything shared with the library keeps its map.
853
+ - **Library maps are untouched, embedded sources and all.** A site stepping
854
+ through analytics, forms or blog in devtools still lands on real site-kit
855
+ source.
856
+ - Declarations no longer include tests. 109 `.test.d.ts` files were shipping to
857
+ every site. `tsconfig.build.json` drives the declaration build now;
858
+ `tsconfig.json` still includes the tests, so `pnpm typecheck` keeps checking
859
+ them — that's where the compile-time guards live.
860
+
861
+ Net: **5.92 MB → 3.53 MB packed**, 26.9 → 15.1 MB unpacked, 1540 → 1304 files.
862
+ That's install and CI download time only. Nothing a site ships to browsers
863
+ changes, and the per-entry bundle budgets are unchanged.
864
+
865
+ ## 5.8.0
866
+
867
+ ### Build-time reads stopped replaying an old build's response
868
+
869
+ `public/llms.txt` could be frozen for months. The build-time fetch behind it
870
+ carried no cache option, so a statically prerendered route stored it in Next's
871
+ Data Cache with a one-year revalidate, and hosts persist `.next/cache` between
872
+ builds — every later build rewrote the same stale file. Found on
873
+ vsfconsultingservices.com on 2026-09-15: nine new pages were missing from a file
874
+ last refreshed on Aug 26, and llms.txt is the file handed straight to AI
875
+ crawlers.
876
+
877
+ - `shared/fresh-fetch.ts` is the single source of truth for requests that must
878
+ skip the Data Cache: it calls the original fetch Next keeps on its patched
879
+ one, so nothing is cached and the route's staticness is untouched.
880
+ `cache: 'no-store'` (bails static generation) and `next: { revalidate }`
881
+ (lowers the route's window) are both wrong here, and the file says why. The
882
+ mint's `resolveFetch` moved here; `server/mint-site-token` re-exports it.
883
+ - `getOptimizedLLMsTxt` always reads fresh. `writeLLMsTxtToPublic` also asks its
884
+ local fallback for fresh reads (`fresh` on `generateLLMsTxt`), and
885
+ `createSitemap`'s Sonor reads are fresh during `next build` and unchanged at
886
+ request time. The llms route handlers keep their one-hour window.
887
+
888
+ **Rebuild once on this version to refresh a stale `public/llms.txt`.** Nothing
889
+ else to change.
890
+
891
+ ### The llms.txt write can no longer fail a build, and has a home that fits
892
+
893
+ With reads fresh, the in-route write runs for real on every build, and Sonor
894
+ generates llms.txt with an LLM call that took ~56s for a 147-page site — more
895
+ than the 60s Next allows a prerendered route. The sitemap route then exhausted
896
+ its retries and the build exited.
897
+
898
+ - The write now gets what's left of a 50s share of the route's budget
899
+ (`llmsWriteTimeoutMs` still overrides). If Sonor answers in time the file
900
+ refreshes; if not, the existing file keeps serving, the build passes, and the
901
+ warning names the fix below. The old 120000ms default outlasted the route.
902
+ - `sonor-register-sitemap --write-llms` (and `--write-llms-full`) writes the
903
+ file from your postbuild, where no 60s ceiling applies, after the sync the
904
+ generation reads. It runs on the skip path too, which is the common one for a
905
+ site whose sitemap route owns the sync. For a guaranteed refresh every build:
906
+ `optimizedLLMsTxt: false` in `createSitemap`, and
907
+ `"postbuild": "sonor-register-sitemap --write-llms"`.
908
+
909
+ ### One set of field rules for every form
910
+
911
+ `useForm` and `FormClient` each carried their own copy of conditional
912
+ visibility (`show_when`) and validation, and the copies had drifted. They now
913
+ share `src/forms/field-rules.ts`, as do the stage and spotlight experiences.
914
+ Where the copies disagreed or were wrong, the merged rules decide:
915
+
916
+ - **`contains` / `not_contains` read an unanswered field as empty text.**
917
+ Managed forms read it as the text "undefined", so `contains "n"` showed a
918
+ field before the visitor had typed anything.
919
+ - **A multi-value answer is searched option by option,** so a needle can't
920
+ match across two options ("r,W" in Solar, Wind).
921
+ - **An emptied checkbox group or multi-select is unanswered.** Ticking then
922
+ unticking every option left `[]`, which passed `required`, so the form
923
+ submitted with nothing selected. It also showed the valid tick, and
924
+ `is_empty` said it wasn't empty.
925
+ - **A number 0 is an answer**: it satisfies `required`, and `min`/`max` apply.
926
+ An unticked single checkbox is still unanswered for `required` and
927
+ `is_empty`.
928
+
929
+ ### Forms report field-level drop-off, and abandonment on every kind of leave
930
+
931
+ The Sonor app's Field Performance card reads `form_analytics.field_interactions`
932
+ and `abandonment_field`, and nothing wrote either: 0 of ~80.6k form sessions on
933
+ 2026-09-15. Abandonment only came from `beforeunload`, which iOS Safari never
934
+ fires and which client-side navigation doesn't trigger, so only ~3.8k of those
935
+ sessions were ever marked abandoned.
936
+
937
+ - **Per-field tracking.** Every managed form (classic, stage, spotlight) now
938
+ records, per field slug, how often the visitor focused it, how long they
939
+ spent in it, and whether it held a complete, valid value. That goes out as
940
+ `fieldInteractions` with the step, complete and abandon calls. The abandon
941
+ call also carries `abandonmentField`, the field the visitor was on.
942
+ Requires the matching sonor-api release; older APIs ignore the new keys.
943
+ - **Abandonment is reported on page hide, pagehide, and when the form
944
+ unmounts** (client-side navigation, a closed modal), no longer on
945
+ `beforeunload`. The page-hide pair is now one shared helper
946
+ (`shared/page-leave.ts`) that Signal's flush and scroll depth use too.
947
+ - **Only a visit that touched the form can be abandoned.** The session opens
948
+ when the form mounts, so a page load where nobody focused a field, typed,
949
+ or changed step is a view, not an abandonment. Expect `abandoned` rows to
950
+ mean "started and left" from this release on.
951
+ - A visitor who comes back to a hidden tab and keeps going is reported again,
952
+ with where they got to, when they leave. A later submit clears it.
953
+ - The completion call now uses `keepalive`, so a form with a `redirect_url`
954
+ no longer loses it to the navigation.
955
+ - `useForm` and custom renderers (`FormRenderProps`) get `trackFieldFocus` /
956
+ `trackFieldBlur` to wire to their own inputs. Value changes through
957
+ `setFieldValue` are tracked without them.
958
+
959
+ ### Brand profile: theme from luminance, and the push is opt-in
960
+
961
+ The brand-profile extractor called a site "dark" whenever its CSS contained
962
+ the substring `.dark` or `dark:`. That caught shadcn's
963
+ `@custom-variant dark (&:is(.dark *))`, a `--surface-dark:` color token, and
964
+ even `.btn-outline-dark:hover`. Postbuilds on 2026-09-15 relabelled seven
965
+ light sites (background `#ffffff`) as dark in production: Watson, Reinhart
966
+ and the five MDG property sites.
967
+
968
+ - **Theme comes from the page background's luminance**, falling back to the
969
+ text color: what `html`/`body` paint, else the usual tokens, with `var()`
970
+ chains resolved (including Tailwind v4 `@theme`). Reads hex, `rgb()`,
971
+ `hsl()`, shadcn's bare HSL triples and `oklch()`. Dark-mode overrides
972
+ (`.dark`, `:root.dark`, `[data-theme=dark]`, `@media
973
+ (prefers-color-scheme: dark)`) are skipped, so the create-next-app default
974
+ no longer reads as dark. When nothing resolves, `theme` is omitted rather
975
+ than guessed.
976
+ - **New `supports_dark_mode`** records what the old check actually detected:
977
+ the site ships a dark variant.
978
+ - **New `extractor_version: 2`** on every push. The API doesn't trust the
979
+ `theme` of a push without it.
980
+ - **CSS comments are stripped before scanning.** A `{}` inside a `:root`
981
+ comment used to end the block early (upforge.io's background never got read).
982
+ - **`sonor-register-sitemap` no longer pushes the brand profile by default.**
983
+ Pass `--brand-profile` (or `brandProfile: true` to `registerLocalSitemap`)
984
+ to opt in. Brand data has nothing to do with the sitemap, and a heuristic
985
+ that runs on every build of every site shouldn't write to production by
986
+ default. A plain `sonor-register-sitemap --auto-discover` postbuild is back
987
+ to syncing pages only.
988
+
989
+ ### createSitemap follows `trailingSlash` from next.config
990
+
991
+ On a site with `trailingSlash: true`, Next 308-redirects `/about` to
992
+ `/about/`. createSitemap didn't know about the setting and emitted `/about`,
993
+ so every entry except `/` redirected, and the sitemap disagreed with the
994
+ site's own canonicals. All five MDG property sites shipped like this
995
+ (found 2026-09-15 by crawling the built sites).
996
+
997
+ createSitemap now emits the URL the site serves. With `trailingSlash: true`,
998
+ every path ends in `/` except `/` itself, file-like paths (a `.` in the last
999
+ segment, like `/llms.txt` or `/feed.xml`) and `/.well-known/*`, which is the
1000
+ same rule Next's own redirects use.
1001
+
1002
+ - **No config needed.** The option defaults to the site's next.config value.
1003
+ Next inlines `trailingSlash` into every module it bundles
1004
+ (`process.env.__NEXT_TRAILING_SLASH`), so the kit reads it with no file
1005
+ I/O. Verified on Next 16.3.2 with Turbopack, and with webpack plus
1006
+ `transpilePackages`.
1007
+ - **`trailingSlash?: boolean` on `SitemapConfig`** for the rare site that
1008
+ loads the kit outside Next's bundler (`serverExternalPackages`). An explicit
1009
+ value always wins.
1010
+ - **The Sonor sync doesn't change.** `register-sitemap` still gets unslashed
1011
+ paths, which is how seo_pages stores them, and dedupe, `exclude`,
1012
+ `priorities` and `intelligentPriority` still match on the unslashed path.
1013
+ - **llms.txt links follow the same rule.** Portal builds llms.txt links from
1014
+ `business.website` plus the unslashed page path, so on a `trailingSlash`
1015
+ site each one redirected. `writeLLMsTxtToPublic` now adds the slash to this
1016
+ site's links, both absolute and root-relative, before it writes
1017
+ (createSitemap passes its setting through). `generateLLMsTxt` and the
1018
+ llms.txt route handlers do the same. Links to other hosts, files and the
1019
+ bare origin are left alone. Both take a `trailingSlash` option, which
1020
+ defaults to next.config. Pass it explicitly when you call
1021
+ `writeLLMsTxtToPublic` from a plain-Node postbuild script, where there's
1022
+ no Next bundle to read the setting from.
1023
+ - **`ClusterNavigation`'s `trailingSlash` prop** defaults to next.config too,
1024
+ and runs through the same rule.
1025
+
1026
+ `## Optional` entries in a generated llms.txt that aren't in the page list
1027
+ used to get a slash forced onto them, which redirected on a default site.
1028
+ They now follow the site's setting like every other link.
1029
+
1030
+ ### `sonor-setup doctor` flags sitemap URLs that redirect
1031
+
1032
+ The new `sitemap.trailing-slash` check warns when next.config sets
1033
+ `trailingSlash: true` and the sitemap lists unslashed URLs. It reads the
1034
+ built sitemap (`.next/server/app/sitemap.xml.body`, or the
1035
+ `generateSitemaps` shards) when there is one, because that's what crawlers
1036
+ get, however the route was written. Without a build, it checks the source for
1037
+ a `trailingSlash: false` that overrides next.config.
1038
+
1039
+ **What changes on upgrade:** sites with `trailingSlash: true` get slashed
1040
+ sitemap and llms.txt URLs on their next build. For sites that wrapped
1041
+ createSitemap to add the slash themselves (the MDG property template), the
1042
+ wrapper can go. Sites on Next's default see no change.
1043
+
1044
+ ### A missing blog post is a 404, not a soft 404
1045
+
1046
+ `BlogPost` rendered its "Post Not Found" message with HTTP 200, and
1047
+ `generateBlogPostMetadata` gave it an indexable "Post Not Found" title, so
1048
+ every mistyped or deleted post URL was a soft 404 (vsf-consulting-nextjs
1049
+ /insights, 2026-09-15). Both now call Next's `notFound()`, and there's a
1050
+ helper for the page:
1051
+
1052
+ - **`requireBlogPost(slug, { site? })`** from `@sonordev/site-kit/blog/server`
1053
+ returns the post or calls `notFound()`. Call it from `generateMetadata`:
1054
+ metadata resolves before the page streams, so the status is a real 404.
1055
+ - **`generateBlogPostMetadata`** calls `notFound()` for a missing post.
1056
+ `notFound: false` returns the old placeholder, now marked `noindex`.
1057
+ - **`BlogPost`** calls `notFound()` for a missing post. `notFound={false}`
1058
+ renders the message instead.
1059
+
1060
+ A failed fetch still counts as a missing post, as it always has.
1061
+
1062
+ **What changes on upgrade:** unknown post slugs answer 404 with `noindex`.
1063
+ Sites that wrote their own `requirePost()` (vsf-consulting-nextjs) can switch
1064
+ to `requireBlogPost`.
1065
+
1066
+ ### `generateBlogPostMetadata({ images: false })` for posts with their own card
1067
+
1068
+ The featured image was always declared as `openGraph.images` and
1069
+ `twitter.images`, and Next lets declared images beat a route's
1070
+ `opengraph-image` file, so a per-post card never shipped. `images: false`
1071
+ leaves both keys out entirely. (Not `images: undefined`: Next checks
1072
+ `hasOwnProperty('images')`, so even an undefined value hides the card.) The
1073
+ doctor flags a post route with an `opengraph-image.tsx` whose page doesn't
1074
+ pass it.
1075
+
1076
+ ### `createOgImage`: the runtime card for `opengraph-image.tsx`
1077
+
1078
+ `createOgImageRoute` returns `GET(request, { params })`, but a metadata image
1079
+ file is called as `default({ params })`, so watsonhac.com, reinhart and
1080
+ vsf-consulting-nextjs each wrote an adapter that built a dummy Request.
1081
+ `createOgImage(async (params, { id }) => card | null)` is that file's default
1082
+ export: params (and a `generateImageMetadata` id) arrive awaited, null is a
1083
+ 404. `size` and `contentType` are exported for re-export from the file. Both
1084
+ factories render through one function.
1085
+
1086
+ ### The runtime card fits its title, and its bar is readable
1087
+
1088
+ The runtime (Satori) card set the title at a fixed 84px, uppercase, with no
1089
+ fitting, so a long post title ran off the card beside the photo. Sites dropped
1090
+ the photo past a hand-picked 40 or 50 characters. The title is now fitted over
1091
+ the build-time card's range (104px down to the 56px floor) from a width
1092
+ estimate, since Satori can't measure, and clipped on a whole line as a
1093
+ backstop. When it can't fit beside the photo even at the floor, the photo is
1094
+ dropped. A relative `photoUrl`, which Satori can't load, is ignored instead of
1095
+ failing the render.
1096
+
1097
+ The bar set `theme.text` on `theme.accent`: navy on crimson on watsonhac.com,
1098
+ ink on green on reinhart, so both left the bar off. Both cards now pick the
1099
+ bar text from one rule (`og/contrast.ts`): `theme.barText` if set, else the
1100
+ first of `surface`, `bg`, `text` that reaches 3:1 (WCAG AA for large text) on
1101
+ the accent. The build-time card always used `surface`, and keeps it wherever
1102
+ it already read. `barText` is new on `OgTheme`, and the runtime theme accepts
1103
+ og.config.ts's `theme` as is.
1104
+
1105
+ **What changes on upgrade:** per-post cards with long titles shrink instead
1106
+ of overflowing. A bar whose `surface` failed on the accent changes color.
1107
+
1108
+ ### `sonor-setup og`: nested routes, per-URL cards, and cleaner titles
1109
+
1110
+ - **Static routes under a dynamic segment get a card.** `properties/[slug]/about`
1111
+ is keyed `/properties/*/about` and titled from its own name. The generator
1112
+ stopped at the first dynamic segment, so 30 Green Bay and SS Oshkosh pages
1113
+ shipped with no og:image.
1114
+ - **Per-URL cards.** A `cards` key naming one URL under a dynamic route
1115
+ (`/floor-plans/1-bedroom`, `/properties/tall-pines/about`) renders to
1116
+ `public/_og/<url>.jpg`, with that URL's managed copy under the override.
1117
+ The page declares it with `paramCardImage(path, { config })` from
1118
+ `@sonordev/site-kit/og`, which answers undefined for a page with no entry.
1119
+ The kit owns `public/_og/` and clears it every run. The files end in `.jpg`,
1120
+ so `trailingSlash: true` never redirects them. They need `sharp`. The MDG
1121
+ sites rendered these with their own script (`og-param-cards.mjs`) served by
1122
+ a code route per segment.
1123
+ - **Code cards on `trailingSlash` sites.** Next serves `opengraph-image.tsx`
1124
+ at an extensionless URL, which `trailingSlash: true` 308-redirects.
1125
+ `codeCardImage(path)` gives the slashed URL for the page to declare, and the
1126
+ wiring check (og and doctor) flags a code card on such a site whose page
1127
+ doesn't.
1128
+ - **A route handler at `opengraph-image/route.tsx` counts as a code card.**
1129
+ The generator wrote `opengraph-image.jpg` beside that folder, which Next
1130
+ refuses to build. It's the shape og/route's own docs suggested.
1131
+ - **Titles lose broken suffixes.** A dangling separator (`About Us |`) and a
1132
+ domain after a comma (`Privacy Policy, abbeyglenapts.com`) are dropped like
1133
+ a brand suffix. A hyphen inside a word is no longer a separator: `Custom
1134
+ Walk-In Closets` used to become `Custom Walk`.
1135
+ - A `cards` key that matches a dynamic pattern isn't reported as unmatched.
1136
+
1137
+ **What changes on upgrade:** sites with static routes under a dynamic segment
1138
+ get new `opengraph-image.jpg` files there on the next `sonor-setup og`.
1139
+
1140
+ ### doctor `og.card` stops guessing
1141
+
1142
+ - **managed_og_image is reported only when it's set.** The check warned on
1143
+ every site with per-page cards that `managed_og_image` might override them,
1144
+ whether or not any page had one. `sonor-setup og` now reads it while it
1145
+ titles cards and names the pages that set one. `doctor --online` does the
1146
+ same. Offline, it says nothing.
1147
+ - **`summary_large_image` is found where sites set it.** The check grepped the
1148
+ root layout only. It now reads the layout, every page, and what they import:
1149
+ `lib/` helpers through tsconfig paths, and monorepo workspace packages. A
1150
+ `twitter-image` file counts too.
1151
+
1152
+ ### Answer engines: Amazonbot, MistralAI-User and YouBot
1153
+
1154
+ `buildAiCrawlerRules` names three more agents. MistralAI-User (Le Chat
1155
+ fetching for a user) and YouBot (You.com search) are retrieval crawlers.
1156
+ Amazonbot is a training crawler, because Amazon says what it collects may
1157
+ train its models, so `training: 'block'` covers it. vsf-consulting-nextjs kept
1158
+ these three in a local `NOT_YET_IN_KIT` list.
1159
+
1160
+ `@sonordev/site-kit/robots` now re-exports `buildAiCrawlerRules`,
1161
+ `createRobotsTxtHandler`, `formatContentSignals` and the two lists, which is
1162
+ where people looked for them. Same functions as `@sonordev/site-kit/llms`.
1163
+ The llms README's robots example used a hand-rolled list and now uses
1164
+ `buildAiCrawlerRules`.
1165
+
1166
+ **What changes on upgrade:** robots.txt built with `buildAiCrawlerRules`
1167
+ gains three named groups. No crawler's access changes on an allow-all site.
1168
+
1169
+ ### The llms discovery header can't come from a layout
1170
+
1171
+ The llms README's "Option B" told sites to `export async function headers()`
1172
+ from the root layout. That isn't a Next API (`headers()` is a next.config
1173
+ option), so those sites sent no Link header, and `sonor-setup geo` passed
1174
+ them. The README now recommends `createProxy({ llmsDiscovery })`, with
1175
+ `withSiteKitConfig({ llmsTxtDiscoveryLink: true })` for sites without a
1176
+ proxy, and geo fails a layout `headers()` export and says why.
1177
+
1178
+ ### `llmsDiscovery` sends Link to requests with no Accept header
1179
+
1180
+ The proxy only sent `Link: rel="describedby"` when the Accept header named
1181
+ text/html or `*/*`, so it skipped every request that sends none: curl, link
1182
+ checkers, and many crawlers and AI fetchers, which the header is for. A
1183
+ missing Accept header means anything, so it counts now. Still skipped:
1184
+ non-GET/HEAD requests, Accept headers that name only non-HTML types,
1185
+ file-like paths (`/llms.txt`, `/sitemap.xml`), `/api/` routes, and Next's RSC
1186
+ navigation requests. The rule is `wantsLlmsDiscoveryLink`, exported from
1187
+ `@sonordev/site-kit/llms`.
1188
+
1189
+ ### `useGsap` loads plugins inside its context
1190
+
1191
+ The docs said to `import('gsap/SplitText')` inside the setup. gsap.context
1192
+ only records what runs synchronously inside it, so everything the plugin made
1193
+ landed outside the context: unscoped, and never reverted on unmount.
1194
+ vsf-consulting-nextjs's SplitHeading built a second context to clean up. Pass
1195
+ plugins as `options.plugins: { SplitText: () => import('gsap/SplitText') }`.
1196
+ They load with gsap (once per page), get registered, and reach setup as
1197
+ `plugins.SplitText`, so setup stays synchronous. Setup also gets `context`,
1198
+ for anything it has to start later (`context.add(() => ...)`).
1199
+ `loadGsapPlugins` and `runGsapSetup` are exported.
1200
+
1201
+ ### `ManagedSchema` `excludeTypes` drops nested nodes
1202
+
1203
+ `excludeTypes` dropped a schema row only when its `schema_type` matched, so a
1204
+ FAQPage embedded in a page-level row got through (MDG Green Bay's Georgetown
1205
+ and Westbrooke pages, whose FAQPage describes Q&A the pages don't show). An
1206
+ excluded type now goes wherever it sits in Sonor's schema: a whole row, an
1207
+ `@graph` member, or a nested value like `mainEntity`, in `managed_schema` and
1208
+ the entity graph too. A row left empty is dropped. Your `additionalSchemas`
1209
+ are never filtered, and `includeTypes` is unchanged.
1210
+
1211
+ **What changes on upgrade:** a site that passes `excludeTypes` loses nodes of
1212
+ those types that were nested in other rows.
1213
+
1214
+ ### doctor and geo read helpers and workspace packages
1215
+
1216
+ Both read only the file in the site, so wiring in a helper was invisible: geo
1217
+ failed three MDG sites whose `app/sitemap.ts` calls the workspace package's
1218
+ createSitemap (which turns the build-time llms.txt off) until each restated
1219
+ `optimizedLLMsTxt: false`. Route, sitemap, proxy and metadata checks now read
1220
+ the file plus what it imports, through one reader (`shared/source-graph.ts`):
1221
+ relative imports, tsconfig `paths`, and workspace packages resolved through
1222
+ their `exports`. Published packages in node_modules, this kit included, are
1223
+ never followed, and comments are ignored, so a helper's doc comment can't pass
1224
+ or fail a check.
1225
+
1226
+ ### `sonor-register-sitemap` sends the site host
1227
+
1228
+ The CLI registered pages with no `site`, so on a multi-site project every
1229
+ page synced as unattributed rather than as this host's. It now sends
1230
+ `NEXT_PUBLIC_SITE_URL`'s host (it loads `.env` and `.env.local`), or the host
1231
+ from `--site`. `registerSitemap` and `registerLocalSitemap` on `seo/server`
1232
+ had the same gap and take `site` too. All of them and createSitemap's own sync
1233
+ build the request in one place (`seo/register-sitemap-request.ts`).
1234
+
1235
+ **What changes on upgrade:** a microsite's postbuild sync tags its pages with
1236
+ its host. Single-site projects and API servers that predate the site
1237
+ dimension ignore it.
1238
+
1239
+ ## 5.7.2
1240
+
1241
+ ### Managed FAQs, internal links and content blocks send the site host
1242
+
1243
+ Multi-site projects can now have managed SEO content per site. A managed FAQ,
1244
+ internal link or content block with no site applies to every host. One tagged
1245
+ `bd-charlotte.com` applies only there. The API answers each read with the
1246
+ asking host's rows plus the project-wide ones, and treats a read with no site
1247
+ as the project's primary domain, so these reads now say which site is asking:
1248
+
1249
+ - `getFAQData`, `getInternalLinks` and `getContentBlock` (and so
1250
+ `ManagedFAQ`, `ManagedInternalLinks` and `ManagedContent`) carry `site` in
1251
+ the POST body to `/api/public/seo/faq`, `/internal-links` and `/content`.
1252
+ - `getFAQItems` from `@sonordev/site-kit/llms` carries `?site=`, like the
1253
+ other llms reads.
1254
+ - The host comes from `resolveSiteHost`, the same resolver the blog, sitemap
1255
+ and llms.txt reads use: an explicit `site`, then the `NEXT_PUBLIC_SITE_URL`
1256
+ host, then `window.location.host` in the browser. When nothing resolves,
1257
+ `site` is left off entirely.
1258
+
1259
+ **What changes on upgrade:** nothing for most sites. Every microsite already
1260
+ sets `NEXT_PUBLIC_SITE_URL`, so its managed content is scoped on the next
1261
+ deploy. Single-site projects get the same rows as before, and API servers that
1262
+ predate the site column ignore the field.
1263
+
1264
+ To pin a host, pass `site`. The three components take it as a prop. The
1265
+ fetchers take it as a new optional last argument:
1266
+ `getFAQData(path, site)`, `getInternalLinks(path, { position, limit, site })`,
1267
+ `getContentBlock(path, section, site)`,
1268
+ `getManagedContentData(path, section, site)` and
1269
+ `getFAQItems(projectId, limit, site)`. Existing calls keep working unchanged.
1270
+
1271
+ ## 5.7.1
1272
+
1273
+ ### Blog reads send the site host, so each host gets its own posts
1274
+
1275
+ Multi-site projects (one Sonor project serving many domains, like bd-aec.com
1276
+ and its city microsites) can now have a blog per site. A post with no site is
1277
+ project-wide and shows on every host. A post tagged `bd-charlotte.com` shows
1278
+ only there. For the API to tell them apart, it has to know which site is
1279
+ asking, so every blog read now says:
1280
+
1281
+ - Every GET under `/public/blog/*` carries `?site=<host>`: posts, slugs,
1282
+ categories, tags, recent, clusters and authors.
1283
+ - The related-posts and view-count POSTs carry `site` in the body.
1284
+ - The host comes from `resolveSiteHost`, the same resolver the sitemap sync
1285
+ and llms.txt reads use: an explicit `site`, then the `NEXT_PUBLIC_SITE_URL`
1286
+ host, then `window.location.host` in the browser. When nothing resolves,
1287
+ `site` is left off entirely.
1288
+
1289
+ **What changes on upgrade:** nothing for most sites. Every microsite already
1290
+ sets `NEXT_PUBLIC_SITE_URL`, so its blog reads are scoped on the next deploy.
1291
+ Single-site projects see the same posts as before, and API servers that
1292
+ predate the site dimension ignore the param.
1293
+
1294
+ To pin a host, pass `site`. Components take it as a prop (`BlogList`,
1295
+ `BlogPost`, `BlogSidebar`, `BlogLayout`, `RelatedPosts`,
1296
+ `ClusterLandingPage`). Fetchers that take arguments accept
1297
+ `{ site }`, for example `getBlogPost(slug, { site })`,
1298
+ `getAllBlogPosts({ site })` and `getRelatedInsights(slug, { site })`.
1299
+ `getAllBlogSlugs()` and `getAllAuthorSlugs()` stay zero-argument so they can
1300
+ still be exported as `generateStaticParams`, and always use
1301
+ `NEXT_PUBLIC_SITE_URL`. `BlogPost`'s view counter uses its `site` prop, or
1302
+ else the host SiteKitLayout published, the same one analytics tags page views
1303
+ with.
1304
+
1305
+ The code that appends `site` is shared by the llms and blog reads
1306
+ (`sites/site-param`). The post, category and related-posts fetches that were
1307
+ copied between `blog/server` and the components now share one module too.
1308
+
1309
+ ### `normalizeSiteHost` from `@sonordev/site-kit/blog` is now `linkClassificationHost`
1310
+
1311
+ The blog module had its own `normalizeSiteHost`, which strips `www.` to decide
1312
+ whether a link in a post is internal or external. It shared a name with the
1313
+ multi-site `normalizeSiteHost` from `sites/contract`, which keeps `www.`
1314
+ because `www.example.com` and `example.com` can be different sites. It's now
1315
+ called `linkClassificationHost`, with the same behavior. The old name is still
1316
+ exported from `@sonordev/site-kit/blog` as a deprecated alias, so existing
1317
+ imports keep working.
1318
+
1319
+ ## 5.7.0
1320
+
1321
+ 5.6.1 was versioned in the repo but never published to npm, so its changes ship
1322
+ in this release. Everything in this section is new since 5.6.0.
1323
+
1324
+ ### llms.txt handlers send `X-Robots-Tag: noindex` by default
1325
+
1326
+ `createLLMsTxtHandler` and `createLLMsFullTxtHandler` now send
1327
+ `X-Robots-Tag: noindex`. llms.txt is a plain-text restatement of pages the site
1328
+ already serves as HTML, so a search index that picks it up holds a thin
1329
+ duplicate of the site. AI crawlers still fetch it: they request `/llms.txt` by
1330
+ convention, and noindex doesn't stop them.
1331
+
1332
+ - Pass `noindex: false` to either handler if you want the file in search
1333
+ results.
1334
+ - A static `public/llms.txt` (the build-time write) is served straight from the
1335
+ CDN and never runs the handler. Set the header in `netlify.toml`,
1336
+ `public/_headers` or `vercel.json` instead. The llms README has the
1337
+ `netlify.toml` block.
1338
+ - Keep llms.txt out of the XML sitemap, which lists indexable HTML pages.
1339
+ `includeLlmsTxtInSitemap` and `includeLlmsFullTxtInSitemap` already defaulted
1340
+ to off. `sonor-setup scaffold` no longer turns them on, and `sonor-setup geo`
1341
+ now warns when a sitemap lists them (it used to fail sites that didn't).
1342
+
1343
+ **What changes on upgrade:** every site that serves llms.txt through these
1344
+ handlers starts sending the header on its next deploy. No code change is needed.
1345
+
1346
+ ### `export const generateStaticParams = generateBlogStaticParams` type-checks again
1347
+
1348
+ Since 5.4.0, `generateBlogStaticParams` took `BlogRoutingOptions` as its first
1349
+ parameter, so the documented direct export failed Next 16's route type check:
1350
+
1351
+ ```
1352
+ .next/types/validator.ts: Type '(options?: BlogRoutingOptions) => Promise<...>'
1353
+ is not assignable to type '(props: { params: { slug: string } }) => any[] | Promise<any[]>'.
1354
+ ```
1355
+
1356
+ Webpack builds failed the older `.next/types/app/**/page.ts` guard as well.
1357
+ At runtime Next's `{ params }` argument was also being read as routing options.
1358
+ It was harmless because no keys overlap, but it only worked by luck. Found when
1359
+ spade-nextjs went from 4.2.2 to 5.6.0.
1360
+
1361
+ - `generateBlogStaticParams` now has a second overload that accepts Next's
1362
+ props (`NextStaticParamsProps`, exported from `blog/server`), and any
1363
+ argument with a `params` key is ignored. Direct exports type-check under both
1364
+ of Next's checks and use the default routes.
1365
+ - Routing options still work the same way from a wrapper:
1366
+ `export function generateStaticParams() { return generateBlogStaticParams(routing) }`.
1367
+ - `generateCategoryStaticParams` and `generateAuthorStaticParams` take no
1368
+ arguments, so they were never affected. A compile-time test now covers all
1369
+ three against both of Next's checks.
1370
+
1371
+ **If your site patched around this:** a wrapper like
1372
+ `export function generateStaticParams() { return generateBlogStaticParams() }`
1373
+ keeps working. You can switch back to the one-line export, but you don't have to.
1374
+
1375
+ ### robots.txt never blocks `/_next/`
1376
+
1377
+ `createRobots`, `buildAiCrawlerRules` and `createRobotsTxtHandler` now drop
1378
+ any `/_next` disallow path (`/_next`, `/_next/`, `/_next/*`,
1379
+ `/_next/static`, `/_next/image`) and log a warning for each one. Googlebot
1380
+ renders pages with the CSS, JS and optimized images served from `/_next/`,
1381
+ and `/_next/image` is how a site's photos reach Google Images. A 2026-09
1382
+ fleet sweep found ten sites disallowing it, two of them through these
1383
+ helpers. None of the helpers ever added it by default. `isNextInternalsPath`
1384
+ is exported from `@sonordev/site-kit/robots` for sites that build robots.txt
1385
+ by hand.
1386
+
1387
+ ### `resolveManagedRedirect()` is deprecated: it made every page dynamic
1388
+
1389
+ Calling `resolveManagedRedirect()` from `app/not-found.tsx` opts every route
1390
+ into dynamic rendering. Next renders the root not-found boundary inside every
1391
+ page, and the resolver reads `headers()`. On nkylawfirm.com every static route
1392
+ turned dynamic, and reverting only `not-found.jsx` put them back. The 404
1393
+ boundary can't be made static-safe: skipping the lookup at build time leaves
1394
+ the 404 page prerendered static, so the redirects would never run.
1395
+
1396
+ - The recommended setup is `createProxy({ redirects: true })` again (it was
1397
+ always the default). The rule list is cached in memory for five minutes, so
1398
+ page loads rarely wait on it. The proxy docs no longer tell sites to prefer
1399
+ `redirects: false`.
1400
+ - `sonor-setup scaffold` now writes `createProxy({ redirects: true })` and a
1401
+ plain `not-found.tsx` that reads no request APIs.
1402
+ - `resolveManagedRedirect()` still works, so existing sites keep building, but
1403
+ it's marked `@deprecated` and logs a one-time warning.
1404
+ - The agent manifest has a new failure mode, `static.dynamic-not-found`, and a
1405
+ caution on `redirects/not-found`.
1406
+
1407
+ **If your site calls it:** delete the call from `not-found.tsx`, set
1408
+ `redirects: true` in `proxy.ts`, and rebuild. The route table should show ○/●
1409
+ again instead of ƒ.
1410
+
1411
+ ### Blog metadata no longer hides a route's OG card
1412
+
1413
+ `generateBlogPostMetadata`, `generateBlogIndexMetadata`,
1414
+ `generateBlogCategoryMetadata` and `generateAuthorPageMetadata` returned
1415
+ `images: undefined` on `openGraph` and `twitter` when there was no image. Next
1416
+ reads the bare key as "this route has no images", which hid the route's own
1417
+ `opengraph-image` card. The key is now left out, the same way
1418
+ `getManagedMetadata` already did it. Found on nkylawfirm.com, where `/insights`
1419
+ served no `og:image` despite having a card file.
1420
+
1421
+ ### `sonor-setup og` leaves code-based cards alone
1422
+
1423
+ The generator wrote a static `opengraph-image.jpg` into every page folder,
1424
+ including ones that already render their own card with
1425
+ `opengraph-image.(tsx|jsx|ts|js)`, such as a per-city or per-article card. That
1426
+ left two `opengraph-image` files in one folder. Those folders are now skipped
1427
+ and reported (`og.code-card`), and a static card an earlier run left there is
1428
+ removed.
1429
+
1430
+ ### `sonor-setup` reads `.jsx` sites and proxy-based discovery correctly
1431
+
1432
+ The CLI gave false failures on heinrich-law-nextjs, whose app files are `.jsx`,
1433
+ and on nkylawfirm.com, which sends its llms discovery header from `proxy.ts`.
1434
+
1435
+ - `doctor` reported "no app/layout.tsx found" when the root layout was
1436
+ `app/layout.jsx`. Every lookup of the root layout, pages, route handlers and
1437
+ proxy/middleware now accepts `.tsx`, `.ts`, `.jsx` and `.js`, and checks
1438
+ `app/` before `src/app/` the way Next does. That covers `doctor`'s layout,
1439
+ sitemap and proxy checks, `init`'s layout injection, `og`, and route
1440
+ discovery.
1441
+ - The `og.card` check now counts code-generated cards (`opengraph-image.tsx`
1442
+ and friends) as page cards.
1443
+ - `geo` failed the discovery-header check for sites using
1444
+ `createProxy({ llmsDiscovery })`. It now reads proxy/middleware (root and
1445
+ `src/`), next.config, the root layout and host config, and treats
1446
+ `llmsDiscovery: false` as off.
1447
+ - `geo --ensure-routes` no longer writes `route.ts` beside an existing
1448
+ `route.js`, which Next refuses to build.
1449
+ - `migrateSitemap` wrote `sitemap.ts` into `src/app/` when a project had both
1450
+ `app/` and `src/app/`. Next reads `app/`, so the file was ignored. It now
1451
+ writes to `app/`.
1452
+ - Route auto-discovery skipped `page.ts` routes, in the CLI and in
1453
+ `registerLocalSitemap({ autoDiscover: true })`. It finds them now.
1454
+
1455
+ ## 5.6.0
1456
+
1457
+ ### Local builds stay out of production analytics
1458
+
1459
+ Browser pages on `localhost`, `*.localhost`, `127.0.0.1`, `[::1]`, and
1460
+ `0.0.0.0` no longer send analytics, fleet heartbeats, or client-side sitemap
1461
+ registrations. Engage stays unmounted there, including its chat transport and
1462
+ impression/click tracking. This covers `next start`, Lighthouse, and headless
1463
+ verification, regardless of `NODE_ENV` or a production `analytics.site` /
1464
+ `NEXT_PUBLIC_SITE_URL` setting. The gate reads the actual browser hostname.
1465
+
1466
+ Explicitly enable local reporting with
1467
+ `<SiteKitLayout analytics={{ allowLocalhost: true }}>`. Standalone
1468
+ AnalyticsProvider, WebVitals, FleetHeartbeat, SitemapSync, EngageWidget and
1469
+ sendFleetHeartbeat accept the same option. Local cross-origin frames need both
1470
+ `allowLocalhost` and `allowInFrame`. These options also flow from the layout to
1471
+ fleet, Engage and SitemapSync, which now share the analytics send gate.
1472
+
1473
+ Build-time sitemap sync and Node fleet sends are unchanged. This client release
1474
+ doesn't protect sites still on older kit versions; see
1475
+ [the read-only audit and server follow-up](docs/localhost-analytics-audit.md).
1476
+ No analytics rows were deleted.
1477
+
1478
+ ### CLI project config and Sonor state paths
1479
+
1480
+ `sonor-setup migrate`, `setup`, `faqs`, and `locations` now share one config
1481
+ resolver and work with only the `SONOR_API_KEY` written by `init`. Commands
1482
+ that need a full project UUID resolve it through the API; saved IDs and the
1483
+ key's eight-character prefix are never used as the full UUID. API URL
1484
+ overrides apply to both project resolution and subsequent requests.
1485
+
1486
+ CLI state writes now use `.sonor/templates`, `.sonor-images.json`, and
1487
+ `~/.sonor/credentials.json`; `.sonor/config.json` is the preferred config
1488
+ path. Legacy state remains a read-only fallback, including image refresh.
1489
+ The auth client is renamed to `src/cli/api/sonor.ts`, and the environment
1490
+ variable regression test now covers the CLI too.
1491
+
1492
+ ### One calendar entry per booked meeting
1493
+
1494
+ When the host has Google Calendar connected, Google invites the guest to the
1495
+ host's event. BookingWidget's success screen still offered Google, Outlook and
1496
+ iCal "Add to your calendar" buttons, and the Google and Outlook ones build a
1497
+ separate personal event with no tie to that invitation. A guest who clicked
1498
+ one had two entries for the same meeting.
1499
+
1500
+ `BookingResult` has a new `invitation` field, `'google' | 'sonor'`, that says
1501
+ who sends the guest's invite. When it's `'google'`, the success screen drops
1502
+ the buttons and says who the invite is coming from: "Your calendar invite is
1503
+ on its way from Jordan Lee." When it's `'sonor'`, or when an older API doesn't
1504
+ send it, the buttons stay. The API keeps sending `calendarLinks` for every
1505
+ booking, so widgets on older versions work as before. A custom success screen
1506
+ built on `createBooking` should check `invitation` before it renders
1507
+ `calendarLinks`.
1508
+
1509
+ ### Booking errors explain the next step
1510
+
1511
+ Sync's format, availability and reservation requests now share one error
1512
+ reader that understands both Sonor's nested error response and older flat
1513
+ responses. An address outside the travel radius shows the server's guidance
1514
+ to meet virtually or at the office. Invalid or missing responses use a
1515
+ visitor-facing fallback.
1516
+
1517
+ ## 5.5.0 — 2026-09-10
1518
+
1519
+ ### The chat launcher can be moved without `!important`
1520
+
1521
+ The Echo launcher's placement is an inline style: fixed, 20px from the side,
1522
+ `calc(20px + env(safe-area-inset-bottom))` from the bottom, z-index 9999.
1523
+ `EngageConfig` offered a corner and nothing else, and an inline style beats any
1524
+ stylesheet, so a site that needed the launcher somewhere else had one tool: an
1525
+ `!important` rule aimed at the kit's markup. Three sites wrote one.
1526
+
1527
+ gunninghomes.com is the one that cost something. Its mobile pages carry a
1528
+ full-width conversion bar, and at 390x844 the launcher occupied x 310-370,
1529
+ y 764-824 while the bar's call to action spanned x 76-374. The bubble covered
1530
+ the right 60px of the primary button on every phone, and at z-index 9999
1531
+ against the bar's 40 it always won.
1532
+
1533
+ `offsetBottom` sets the launcher's distance from the bottom edge:
1534
+
1535
+ ```tsx
1536
+ <SiteKitLayout engage={{ offsetBottom: '88px' }}>…</SiteKitLayout>
1537
+ ```
1538
+
1539
+ Any CSS length works, and a number is pixels. The safe-area inset is still
1540
+ added on top, so pass the clearance you want, not the inset.
1541
+
1542
+ When the offset depends on the page or the breakpoint, set
1543
+ `--sk-echo-offset-bottom` from a stylesheet instead; it wins over the option.
1544
+ The launcher is portalled to `<body>`, so a declaration on `body` reaches it:
1545
+
1546
+ ```css
1547
+ @media (max-width: 1023.98px) {
1548
+ body:has(.sticky-cta) {
1549
+ --sk-echo-offset-bottom: 5.5rem;
1550
+ }
1551
+ }
1552
+ ```
1553
+
1554
+ That is now gunninghomes.com's entire override. It declares a value the kit
1555
+ reads instead of beating the kit's inline style, and it no longer names the
1556
+ kit's markup. The kit never declares the property itself, which is
1557
+ load-bearing: an unset property falls through to `offsetBottom`, then to 20px.
1558
+
1559
+ ### The chat popup opens above the launcher, wherever the launcher is
1560
+
1561
+ The popup had its own hardcoded `bottom: calc(90px + inset)`. An override that
1562
+ lifted only the launcher `<button>` left the popup behind, and the launcher
1563
+ then sat on top of the popup's input row and send button. Both elements are
1564
+ now placed from one clearance (`engage/launcher-placement.ts`). The popup
1565
+ opens 70px above the launcher's bottom edge (its 60px height plus a 10px gap),
1566
+ and its max height gives up the same clearance plus 100px, so it keeps 30px
1567
+ clear of the top edge.
1568
+
1569
+ With nothing set, the geometry is what it was: launcher at 20px, popup at
1570
+ 90px, max height `100dvh - 120px`. One deliberate difference: the safe-area
1571
+ inset now also comes off the popup's max height. Before, on a phone with a
1572
+ home indicator (34px) and a short viewport, a full-height popup ran 4px past
1573
+ the top of the screen.
1574
+
1575
+ ### `VisualViewportGap` moves into the kit
1576
+
1577
+ queencityriverboats.com and destinyyachtcharters.com each shipped a
1578
+ byte-identical `VisualViewportGap.tsx`. On a phone the layout viewport can be
1579
+ taller than what the visitor sees, `position: fixed` is measured against the
1580
+ layout viewport, and so their launcher sat below the fold. The component
1581
+ measures the difference and publishes it on `<html>` as `--sk-vv-layout-gap`.
1582
+
1583
+ It is now exported from `@sonordev/site-kit/client` as `VisualViewportGap`
1584
+ (and `useVisualViewportGap`, for an existing client component), and the
1585
+ launcher and popup include `var(--sk-vv-layout-gap, 0px)` in their placement.
1586
+ Mounting it is all a site needs. It also writes the property only when the
1587
+ value changes: a custom property on `<html>` restyles the whole document, and
1588
+ `visualViewport` fires continuously during a pinch or a toolbar animation.
1589
+
1590
+ It stays opt-in. While a field has focus the gap grows to the keyboard's
1591
+ height, which lifts everything that reads it above the keyboard. That suits
1592
+ those two sites; it is not a default to impose on the fleet. Without the
1593
+ tracker the property is unset, resolves to 0px, and nothing moves.
1594
+
1595
+ The `./client` export stays in place, including for the QCR and Destiny
1596
+ migrations already prepared for 5.5.0. Its integration bundle baseline is
1597
+ deliberately refreshed for this shared viewport tracker. The gate measures
1598
+ the barrel's entire static import graph before consumer tree-shaking, so
1599
+ it counts the tracker even when a site only imports `useDeferredActivation`.
1600
+ The package declares JS side-effect-free, allowing consumer bundlers to
1601
+ remove the unused tracker. The 10% growth limit remains unchanged.
1602
+
1603
+ ### One placement type
1604
+
1605
+ `position` was declared separately on the layout's `EngageConfig`, engage's
1606
+ `EngageConfig`, `EngageWidget`'s props and `ChatConfig`. All four now extend
1607
+ `ChatLauncherPlacement` (exported from `@sonordev/site-kit/engage`), which is
1608
+ where `offsetBottom` lives, so the next placement option is added once.
1609
+
1610
+ ### `zIndex` reaches the chat
1611
+
1612
+ `engage={{ zIndex }}` stacked popups, nudges and bars, but the chat never got
1613
+ it. ChatWidget hardcoded 9999 on the launcher and 9998 on the popup, and
1614
+ EngageWidget didn't pass the value on, so
1615
+ `<SiteKitLayout engage={{ zIndex: 50 }}>` left the chat above everything a
1616
+ site drew. EngageWidget now passes it through: the launcher sits on `zIndex`
1617
+ and the popup one layer beneath it, the same relationship as before. Unset,
1618
+ both render exactly as they did.
1619
+
1620
+ `zIndex` joined `position` and `offsetBottom` in `ChatLauncherPlacement`, so
1621
+ its three separate declarations (both `EngageConfig`s and `EngageWidget`'s
1622
+ props) are now one, and `ChatConfig` accepts it for a `ChatWidget` mounted on
1623
+ its own. At `zIndex` 0 or below the popup shares the launcher's layer rather
1624
+ than dropping to -1, which would paint it behind the page.
1625
+
1626
+ ### `SiteKitConfig` is deprecated
1627
+
1628
+ The root export `SiteKitConfig` was the props shape of `SiteKitProvider`. 4.0
1629
+ removed the provider, and nothing in the kit reads the type now. Its
1630
+ `engage` field is a fifth copy of launcher placement (`position` and `zIndex`,
1631
+ no `offsetBottom`), and it stopped matching the day the other four became
1632
+ `ChatLauncherPlacement`.
1633
+
1634
+ No fleet site imports it, so it's marked `@deprecated` rather than reworked,
1635
+ and it goes in 6.0 (removing an exported type is breaking). Code that still
1636
+ uses it should switch to `SiteKitLayout`'s config types, `EngageConfig` and
1637
+ `AnalyticsConfig` from `@sonordev/site-kit/layout`.
1638
+
1639
+ ### `ManagedImage`'s picker toggle is `?sonor_dev=true`
1640
+
1641
+ The image picker could be forced open on any host with `?uptrade_dev=true`,
1642
+ the last Uptrade name on a runtime path. It's `?sonor_dev=true` now, read as a
1643
+ real query parameter instead of a substring match. Nothing in the workspace
1644
+ generated the old parameter, so there's no alias: a bookmarked `uptrade_dev`
1645
+ link just stops opening the picker. `localhost`, `NODE_ENV=development` and
1646
+ `forceDevMode` behave as before.
1647
+
1648
+ ### `sonor-setup migrate` stops writing `SONOR_PROJECT_ID` into sites
1649
+
1650
+ The migrator's templates put `projectId={process.env.SONOR_PROJECT_ID!}` on
1651
+ every `ManagedSchema`, `ManagedFAQ` and `getManagedMetadata` call they added.
1652
+ The prop has been ignored since the project started coming from the key, and
1653
+ sites configure exactly one env var. Generated code now passes only `path`.
1654
+ Sites migrated earlier keep working; delete the stray prop whenever the file is
1655
+ next touched.
1656
+
1657
+ ### `ssr.render` stops blaming the layout
1658
+
1659
+ Since 3.0.2 `SiteKitLayout` renders `{children}` first and mounts analytics,
1660
+ engage, sitemap sync and the heartbeat after them as deferred, childless
1661
+ siblings, and 4.0 made that structural. The CLI never caught up. When a route
1662
+ bailed to client rendering, `verify` and `doctor` told the site to run
1663
+ `SiteKitLayout analytics={false}` and hand-roll a deferred analytics sibling,
1664
+ which was the workaround from before 3.0.2. On a current install that changes
1665
+ nothing: the plain layout isn't the cause, so the real one survives the fix.
1666
+
1667
+ Measured on the integration fixture under Next 16.3: a plain `<SiteKitLayout>`
1668
+ prerenders every route static with its content in the HTML, and none of the 9
1669
+ initial scripts carries analytics code. A site's own
1670
+ `next/dynamic({ ssr: false })` provider around `{children}` bails the route to
1671
+ 0 content tags, and the old fix pointed at the layout anyway.
1672
+
1673
+ - The `ssr.render` fix now says to keep the layout, find the component around
1674
+ `{children}` that skips server rendering, and import it statically or mount
1675
+ it childless. `analytics={false}` survives only as a stopgap below 3.0.2. The
1676
+ text lives in `src/cli/agent/ssr-bailout.ts`, and a test pins the agent
1677
+ manifest's `ssr.bailout` failure mode to it.
1678
+ - The `analytics-sibling` codemod no longer flags a plain
1679
+ `<SiteKitLayout>{children}</SiteKitLayout>` (the manifest's own blessed
1680
+ layout) as a manual follow-up. It still flags `<AnalyticsProvider>` wrapped
1681
+ around `{children}`.
1682
+ - AGENTS.md, the site-kit skill, the manifest's `analytics.deferred-sibling`
1683
+ pattern and the analytics README describe the plain layout. The README's
1684
+ standalone example no longer wraps `{children}` or mounts a second
1685
+ `WebVitals`, and custom events use `trackEvent` / `trackConversion`, because
1686
+ `useAnalytics()` throws outside a provider.
1687
+ - The integration fixture runs a plain `SiteKitLayout`, so the prepublish SSR
1688
+ gate covers the layout sites are told to use.
1689
+
1690
+ Nothing breaks for a site that still carries `analytics={false}` plus its own
1691
+ deferred sibling (two fleet sites do). It can delete both the next time its
1692
+ layout is touched.
1693
+
1694
+ ### Upgrading
1695
+
1696
+ Nothing moves until a site opts in. A site that overrides the launcher by hand
1697
+ should drop the override in the same deploy that picks up this release, and
1698
+ not before: 5.4.x ignores `--sk-echo-offset-bottom` and exports no
1699
+ `VisualViewportGap`, so an early migration either puts the launcher back where
1700
+ it was or fails the build.
1701
+
1702
+ - A `button[data-sk-echo-launcher] { bottom: … !important }` rule becomes
1703
+ `offsetBottom`, or a `--sk-echo-offset-bottom` declaration if the rule was
1704
+ scoped to a page or a breakpoint.
1705
+ - A local `VisualViewportGap` becomes the kit's. Keep any
1706
+ `var(--sk-vv-layout-gap)` in the site's own CSS; the property name is
1707
+ unchanged.
1708
+
1709
+ ### `optionalPagePaths` moves pages to `## Optional` instead of listing them twice
1710
+
1711
+ This was written up as 5.4.1 and never released on its own; it ships here.
1712
+
1713
+ llmstxt.org's `## Optional` is the section a short-context parser may skip, so
1714
+ it can spend its budget on the pages that matter. `optionalPagePaths` is how a
1715
+ site puts pages there, but the generator only ever **appended** the section.
1716
+ `## Site Pages` was still built from the full page list, so every demoted page
1717
+ was listed twice. The page a parser was told it could skip was still in the part
1718
+ it reads, and it still used up one of the index's `maxPages` slots.
1719
+
1720
+ It was measured on live sites, not just read in the code. homesinkentucky.com
1721
+ (art-realty-nextjs) lists three of its four demoted pages (`/privacy`,
1722
+ `/accessibility`, `/fair-housing`) in both sections. nkylawfirm.com
1723
+ (heinrich-law-nextjs) lists `/accessibility` in both, and its `## Site Pages` is
1724
+ full at exactly 50 entries, so the duplicate pushes a real page out of the
1725
+ index. gunning-homes-nextjs tried `optionalPagePaths:
1726
+ ['/privacy']` on 5.4.0, got the privacy policy twice, and turned the option back
1727
+ off. A comment in its llms.txt route explains why.
1728
+
1729
+ Matching pages now **move**. They're filtered out of `## Site Pages` before
1730
+ `maxPages` is applied, so demoting a page frees its slot for the next one rather
1731
+ than using one up. Matching ignores leading and trailing slashes (`/privacy`,
1732
+ `privacy` and `/privacy/` are one page, and get one line), and one normaliser
1733
+ serves both sections, so the index and Optional can't disagree about which page
1734
+ a path means.
1735
+
1736
+ A moved page also keeps the line it had in the index: the same URL, the same
1737
+ note. Optional used to rebuild the URL from the path with a forced trailing
1738
+ slash. On a site with Next's default `trailingSlash: false`, that's a 308:
1739
+ `https://nkylawfirm.com/accessibility/` redirects to the URL `## Site Pages` had
1740
+ already given. Both sections now render a page through one `formatPageLine`, so
1741
+ moving a page doesn't change where it links. A path with no matching page is
1742
+ still listed under Optional, unchanged.
1743
+
1744
+ `## Optional` still needs a resolvable base URL. Without one it's skipped, as
1745
+ before, and the demoted pages now stay in `## Site Pages` rather than dropping
1746
+ out of the file.
1747
+
1748
+ ### Upgrading for `optionalPagePaths`
1749
+
1750
+ Sites on `^5.3.0` or later pick this up on a plain reinstall. Sites that already
1751
+ set the option (art-realty-nextjs, heinrich-law-nextjs) lose their duplicate
1752
+ listings, and their Optional links will match the index URLs. gunning-homes-nextjs
1753
+ can turn `optionalPagePaths` on now.
1754
+
1755
+ ## 5.4.0 — 2026-09-09
1756
+
1757
+ ### Meeting formats and recoverable booking
1758
+
1759
+ BookingWidget supports virtual meetings, office visits, and visits to the guest's
1760
+ location when the project's meeting settings enable them. It validates the
1761
+ location before loading availability and carries the prepared meeting through
1762
+ the time reservation and booking request. Pending requests show their actual
1763
+ status and don't offer confirmation-only calendar links.
1764
+
1765
+ Guests can change formats or return to the service selector without losing their
1766
+ address, access notes, or contact details. Navigation is locked during reservation
1767
+ and booking requests. Stale responses can't reopen old steps, and abandoned holds
1768
+ are released. Expired reservations offer recovery with the guest's details intact.
1769
+ A valid hold remains usable after its preparation token expires, until the hold's
1770
+ own deadline.
1771
+
1772
+ ### Article artwork and complete cards
1773
+
1774
+ `BlogPost` exposes optional `editorial_image` and `editorial_image_alt` fields.
1775
+ The stock article component and generated article schemas use editorial artwork
1776
+ when available. Explicit empty alt text remains empty for decorative artwork.
1777
+ Older API responses fall back to the featured image.
1778
+
1779
+ Homepage/list, related, sidebar, and author cards keep the complete featured
1780
+ image. Sonor's composed cards render without cropping or image hover zoom;
1781
+ editor-selected photographs retain their existing layout. OG, Twitter, and feed
1782
+ share images remain on the featured card. Supplied editorial schemas aren't
1783
+ rewritten. Custom layouts can use `resolveBlogArtwork(post, 'article' | 'card')`.
1784
+
1785
+ ### Accessible article tables
1786
+
1787
+ The stock article wraps tables in named, keyboard-focusable scroll regions with
1788
+ native touch scrolling and themed scrollbars. It preserves captions, table
1789
+ semantics, and the author's HTML, leaves code examples alone, and doesn't force
1790
+ small tables to scroll. Custom layouts can use `wrapBlogTables` and `blogTableCss`.
1791
+
1792
+ ### One publication routing contract
1793
+
1794
+ `createBlogRoutes` supplies the publication root and article, category, cluster,
1795
+ author, and feed paths. Use `basePath`, `includeCategoryInPath`, or custom
1796
+ `postPath`/`categoryPath` callbacks. Existing `blogBasePath` metadata configuration
1797
+ continues to work. Stock components accept a shared `routing` prop; metadata,
1798
+ schema, RSS, Atom, sitemap, and static-parameter helpers accept the same options.
1799
+
1800
+ Generated SEO URLs and feeds honor supplied canonical URLs. Sitemap generation
1801
+ reads full, paginated post records so canonical URLs and category segments agree
1802
+ with the article, without the feed's 100-post cap. Sitemap category and cluster
1803
+ entries can be disabled when those routes aren't implemented. `/blog` remains
1804
+ the default, and existing cluster navigation behavior is retained when no new
1805
+ routing configuration is supplied.
1806
+
1807
+ ### Upgrading
1808
+
1809
+ Deploy the compatible Sonor booking API and migrations before enabling meeting
1810
+ formats. Upgrade and deploy consuming sites before enabling the format setting;
1811
+ older widgets can't send a prepared meeting token. Projects without meeting
1812
+ formats retain their existing booking flow.
1813
+
1814
+ The publishing fixes need no new schema or generated artwork. The atmospheric
1815
+ Forge header remains an Upforge design choice; package styles use the shared
1816
+ `--sk-*` theme tokens.
1817
+
1818
+ ## 5.3.2 — 2026-09-09
1819
+
1820
+ ### Embedded previews no longer report analytics against the site they embed
1821
+
1822
+ A site loaded in a **cross-origin iframe** now sends nothing: no page views,
1823
+ journey/session rows, scroll depth, heatmap clicks, web vitals, events or
1824
+ conversions. The visitor is on whoever framed the page, not on this site, so
1825
+ every metric the frame produced was phantom traffic in the analytics its owner
1826
+ reads — and, for an agency, reports to the client.
1827
+
1828
+ This was measured, not theorised. Upforge case studies embed each client's
1829
+ whole production site in three eager iframes (`DeviceTrifolio`), which
1830
+ `DEFAULT_FRAME_ANCESTORS` has permitted since it was introduced. The
1831
+ screenshot overlay in those frames hides the **pixels**, not the
1832
+ **JavaScript**: the embedded site still loads, hydrates and runs its
1833
+ analytics. In `analytics_page_views` on 2026-09-08 the signature was
1834
+ unmistakable — three views of `/` sharing one session_id and one visitor_id,
1835
+ 21-80ms apart, referrer `https://upforge.io/`:
1836
+
1837
+ ```
1838
+ abbeyglenapts.com 20:02:13.852 / .900 / .921
1839
+ goldenmilenky.com 04:12:23.840 / .868 / 24.624
1840
+ queencityriverboats 22:12:33.609 / .690 / .784
1841
+ ```
1842
+
1843
+ 23 client sites carried this traffic, the oldest row from 2026-06-11. Three
1844
+ loads of one page inside 70 milliseconds is not a person; it is the desktop,
1845
+ tablet and mobile frames of one case study.
1846
+
1847
+ **Same-origin frames still report.** The block is on cross-origin embedding,
1848
+ not on being framed at all. A site embedding itself (a preview pane, a print
1849
+ view, an on-domain booking frame) has a real visitor really on that site and
1850
+ no other tenant to pollute.
1851
+
1852
+ **Opt back in when the frame IS the product** — a widget, a partner-hosted
1853
+ booking or menu page, anything deliberately distributed as an embed:
1854
+
1855
+ ```tsx
1856
+ <SiteKitLayout analytics={{ allowInFrame: true }}>…</SiteKitLayout>
1857
+ ```
1858
+
1859
+ `SitemapSync` is gated by the same frame fact, for a different reason. Its
1860
+ rows would not be mis-attributed (inside the frame the document still resolves
1861
+ its own host and its own sitemap), but a **write** triggered by a third
1862
+ party's page view runs a fetch + DOMParser on a visitor's main thread in a
1863
+ page that gets nothing from it, and it bumps `seo_pages.updated_at` — the
1864
+ tiebreaker in `pickSeoPageRow`'s "most recent" fallback. On a multi-site
1865
+ project that is how a shared path like `/` starts resolving to a different
1866
+ host's row. A third party's traffic should not be able to move which row wins.
1867
+
1868
+ ### One send gate instead of eight hand-rolled copies
1869
+
1870
+ `analytics/send-gate.ts` is now the single source of truth for "may this
1871
+ document report analytics, and where to". Every phone-home in the module
1872
+ resolves credentials through `resolveAnalyticsTarget` and sends through
1873
+ `analyticsSend` / `analyticsBeacon`; the eight separate copies of that
1874
+ resolution it replaced are exactly how a rule gets fixed in one place and left
1875
+ broken in seven. `send-gate.test.ts` fails the build if a new direct
1876
+ `sonorFetch` / `sonorBeacon` call appears in the analytics module.
1877
+
1878
+ The frame **fact** lives apart from the analytics **policy**:
1879
+ `shared/frame.ts` owns how a cross-origin frame is detected (and why
1880
+ `window.top` rather than `window.parent`), `send-gate.ts` owns what analytics
1881
+ does about it.
1882
+
1883
+ New exports from `@sonordev/site-kit/analytics` — `isCrossOriginFrame`,
1884
+ `isFramed`, `isTopFrameSameOrigin` — so a site can branch on the same answer
1885
+ analytics uses (skipping its own third-party pixels in an embed, say) rather
1886
+ than hand-rolling a second `window.top` check that drifts from this one.
1887
+
1888
+ ### `BlogPost` exposes the publication index artwork
1889
+
1890
+ `index_image` and `index_image_alt`, the dedicated index frame from Sonor's
1891
+ atmosphere + content-stage renderer. Both optional; a post without one falls
1892
+ back to `featured_image` as before.
1893
+
1894
+ ### Upgrading
1895
+
1896
+ Sites on `^5.3.0` pick this up on a plain reinstall. **Check any site you
1897
+ deliberately distribute as an embed** before deploying: it needs
1898
+ `analytics={{ allowInFrame: true }}` or it will go quiet. Sites that are only
1899
+ ever framed by Upforge case studies want exactly the new default.
1900
+
1901
+ ## 5.3.1 — 2026-09-08
1902
+
1903
+ ### `frame-ancestors` reaches upforgelabs.com, which is its own apex
1904
+
1905
+ Every managed site's default framing policy was `'self' https://upforge.io
1906
+ https://*.upforge.io`. upforgelabs.com is a **separate apex domain**, not a
1907
+ subdomain of upforge.io, so `https://*.upforge.io` never matched it and never
1908
+ could. Labs case studies embed the live client site in device frames, so every
1909
+ one of those frames was refused with `ERR_BLOCKED_BY_RESPONSE`.
1910
+
1911
+ The failure is quiet in the way these always are: the page still renders, with
1912
+ an empty rectangle where the client's site should be. It surfaced on
1913
+ upforgelabs.com/work/abbey-glen as a Lighthouse **Best Practices 92 instead of
1914
+ 100** — three blocked frames failing both `errors-in-console` and
1915
+ `inspector-issues` — rather than as the missing centrepiece of the page.
1916
+
1917
+ `DEFAULT_FRAME_ANCESTORS` now carries `https://upforgelabs.com` and
1918
+ `https://*.upforgelabs.com`. `X-Frame-Options` is still not emitted alongside
1919
+ it: XFO has no allowlist form, and a stray `DENY` re-blocks what
1920
+ `frame-ancestors` just allowed.
1921
+
1922
+ upforgeapps.com is deliberately **not** added. It is a third Upforge apex, but
1923
+ it only links to case studies on upforge.io/work and frames no client site —
1924
+ an origin earns a place on this list by embedding, not by belonging to Upforge.
1925
+
1926
+ **This ships to a site only when that site upgrades and redeploys.** Sites on
1927
+ `^5.3.0` pick it up on a plain reinstall; sites pinned to 4.x or 5.0.0 need an
1928
+ explicit bump. Until then they keep the old policy and keep blocking Labs.
1929
+
1930
+ ### A dark form field stops handing you near-black text
1931
+
1932
+ `--sk-input-text` fell back straight to `#111827`, so a site that themed
1933
+ `--sk-input-bg` dark and `--sk-text-primary` light — never having heard of a
1934
+ field-specific token — got near-black text on a dark field anyway. Measured at
1935
+ **1.03:1** on upforgelabs.com's contact form: not a contrast score to nudge, a
1936
+ field you cannot read your own answer in.
1937
+
1938
+ The fallback chain is now `--sk-input-text` → `--sk-text-primary` → `#111827`,
1939
+ so naming the field colour still wins and theming the page's text is enough on
1940
+ its own. Placeholders follow `--sk-text-tertiary` at `opacity: 1`, since the
1941
+ UA's own placeholder alpha compounds the same problem. Neither token is
1942
+ declared in `:root`, which is load-bearing rather than an omission — a token
1943
+ with a value can never reach the second argument of its own `var()` fallback.
1944
+
1945
+
1946
+ ## 5.3.0 — 2026-09-02
1947
+
1948
+ ### Motion: the Upforge motion standard ships in the kit
1949
+
1950
+ Seventeen of thirty-six sites in the fleet carry GSAP, fourteen of them with
1951
+ their own `ScrollReveal` implementation, and every one of those copies is a
1952
+ place the same bug has to be fixed. This release replaces them with one
1953
+ module, tiered by what each library actually costs, so a site only installs
1954
+ and ships what it imports.
1955
+
1956
+ **`@sonordev/site-kit/motion` (tier 0, zero dependencies, ~2KB).** What every
1957
+ site gets:
1958
+
1959
+ - `<Reveal>` — scroll-entrance reveal on a CSS transition. The server HTML is
1960
+ fully visible, above-the-fold content is never touched, and a headless
1961
+ renderer that never scrolls (Google's included) gets the content back
1962
+ after 2.5s, so the indexed snapshot is never transparent.
1963
+ - `<Parallax>` / `useParallax` — scroll-linked transform and opacity,
1964
+ compositor-only.
1965
+ - `<ScrollScene>` / `useScrollScene` — a pinned scene: sticky stage, progress
1966
+ 0→1 written to a CSS custom property (`--sk-p`) so choreography can be pure
1967
+ CSS, plus an `onProgress` callback for anything that needs code.
1968
+ - `registerScene` — the engine itself: one shared requestAnimationFrame for
1969
+ every scene on the page, native scroll only, offscreen scenes not rendered,
1970
+ `prefers-reduced-motion` held at a still frame. Extracted from
1971
+ upforgelabs.com.
1972
+
1973
+ **`@sonordev/site-kit/motion/gsap` (tier 1, optional peer `gsap`).**
1974
+ `useGsap` runs a setup inside `gsap.context` when its element nears the
1975
+ viewport, so the ~46KB of core + ScrollTrigger never sits on the LCP path;
1976
+ `loadGsap` and `useExpandCollapse` come along. ScrollSmoother is deliberately
1977
+ not included: it drives scrolling by transforming the page body, which fights
1978
+ native scroll and costs INP on mobile.
1979
+
1980
+ **`@sonordev/site-kit/motion/three` (tier 2, optional peer `three`).**
1981
+ `useThreeStage` mounts a renderer on a pinned scene and drives it from
1982
+ scroll, owning the canvas, sizing, DPR, context loss, and disposal.
1983
+ `canRunWebGL()` keeps the server-rendered still for reduced-motion,
1984
+ Save-Data, and no-WebGL visitors. `loadTexture` never throws.
1985
+
1986
+ The tiers are enforced, not suggested: a test fails the build if anything
1987
+ outside `src/motion/gsap.ts` imports gsap or anything outside
1988
+ `src/motion/three.ts` imports three, because bundlers resolve even a dynamic
1989
+ `import()` at build time and a stray import would force every consumer of
1990
+ `./motion` to install a library it never asked for.
1991
+
1992
+ Nothing here is mounted by `SiteKitLayout`. Motion is opt-in per element and
1993
+ never wraps the page. Full API in `src/motion/README.md`.
1994
+
1995
+ ## 5.2.0 — 2026-08-31
1996
+
1997
+ ### Lead conversions can now be reported by Sonor instead of the browser
1998
+
1999
+ If your site fires a Google Ads or GA4 conversion when a form is submitted, it
2000
+ has been counting spam as leads. That is not a bug in your site — it is
2001
+ unavoidable from the browser. Sonor answers every submission with a byte
2002
+ identical success payload whether it accepted the lead or quarantined it as
2003
+ spam, deliberately, so a bot never learns it was caught. Your page therefore
2004
+ cannot tell the two apart, and Smart Bidding learns to buy more of whatever
2005
+ traffic produced the spam.
2006
+
2007
+ The accept or reject verdict only exists on the server, so that is where the
2008
+ conversion has to be reported from. This release supplies the one piece the
2009
+ server was missing.
2010
+
2011
+ **Every form submission now carries the visitor's existing GA4 identity** —
2012
+ `gaClientId` from the `_ga` cookie, and `gaSessions` (a map of container id to
2013
+ session id) from the `_ga_<CONTAINER>` cookies. Nothing new is written or
2014
+ tracked; these are cookies Google's own tag already set, and they are read, not
2015
+ created. The session map is keyed by property so a page carrying two GA4
2016
+ properties cannot attach a lead to the wrong one.
2017
+
2018
+ **`ManagedFormConfig` gained `server_side_lead_conversion`.** When true, Sonor
2019
+ is reporting this project's lead conversions and your site must NOT fire its
2020
+ own on submit, or every real lead is counted twice:
2021
+
2022
+ ```tsx
2023
+ const { form } = useForm(slug, {
2024
+ onSuccess: () => {
2025
+ if (form?.server_side_lead_conversion) return // Sonor reports it
2026
+ gtag('event', 'conversion', { send_to: '...' })
2027
+ },
2028
+ })
2029
+ ```
2030
+
2031
+ **Nothing changes until a project turns it on.** The flag is false for every
2032
+ project that has not configured server-side reporting in Sonor, so existing
2033
+ sites behave exactly as they do today. Turn it on under Forms, Settings, Lead
2034
+ Conversion Reporting.
2035
+
2036
+ ## 5.1.0 — 2026-08-27
2037
+
2038
+ ### Server commerce helpers can finally tell "empty" from "broken"
2039
+
2040
+ Every server-side commerce helper returned `[]` (or `null`) when the fetch
2041
+ failed, identically to a genuinely empty result. That let calling pages state a
2042
+ business fact they did not know.
2043
+
2044
+ It bit a real customer: QCR's /public-cruises rendered that `[]` as **"No public
2045
+ cruises currently on the schedule"** while three cruises were on sale, and the
2046
+ client emailed asking why the site said they had no events. Because the route was
2047
+ statically revalidated, Next then cached the claim.
2048
+
2049
+ **New `*Result` helpers** return `{ ok, data }`, where `ok: false` means the
2050
+ request failed and `ok: true` with empty data means genuinely empty:
2051
+
2052
+ - `getUpcomingEventsResult` — use this wherever the UI renders "no upcoming events"
2053
+ - `getOfferingsResult` — use this wherever it renders "nothing available"
2054
+ - `getOfferingBySlugResult` — separates "no such offering" from "unreachable", so
2055
+ a transient failure no longer `notFound()`s a page that exists and de-indexes it
2056
+ - `getNextEventResult`
2057
+ - `ServerResult<T>` is exported for typing your own wrappers
2058
+
2059
+ **Nothing breaks.** `getUpcomingEvents`, `getOfferings`, `getOfferingBySlug` and
2060
+ `getNextEvent` keep their exact signatures and behaviour, delegating to the new
2061
+ helpers. Existing sites need no change; adopt the `*Result` variants where the
2062
+ distinction matters.
2063
+
2064
+ ### Framework signals are no longer swallowed as fetch failures
2065
+
2066
+ `apiFetch`/`apiPost` caught everything, including Next's own control-flow throws.
2067
+ A `DYNAMIC_SERVER_USAGE` digest means "this route can't be prerendered", and
2068
+ `redirect()`/`notFound()` throw too — swallowing those logged a fake fetch error
2069
+ on every build and made a real outage look identical to the framework working
2070
+ correctly. These now rethrow.
2071
+
2072
+ ### Failed static-path generation is no longer silent
2073
+
2074
+ `getProductPaths` / `getEventPaths` / `getOfferingPaths` returning `[]` on a
2075
+ failed fetch prerenders ZERO pages while the build still reports success — a site
2076
+ ships with its whole catalogue missing and nothing says so. They now log an
2077
+ explicit error. (Return shape unchanged.)
2078
+
2079
+ ### Known gap
2080
+
2081
+ These helpers still issue a plain `fetch` with no cache control, so a site that
2082
+ needs `next: { revalidate }` (to keep a route static rather than flipping to
2083
+ per-request SSR) must still wrap them. That is why QCR keeps its own fetcher.
2084
+
2085
+ ## 5.0.0 — 2026-08-26
2086
+
2087
+ The blog module got a full contract audit against the deployed Sonor API and
2088
+ the live database schema. Seventeen confirmed defects, most of them silent —
2089
+ clean 200s hiding wrong or empty results. Everything below works against
2090
+ today's api.sonor.io and improves further when the paired API deploy lands.
2091
+
2092
+ ### The headline: several blog features have never worked
2093
+
2094
+ **Related posts always returned nothing.** Two independent bugs: the fetch
2095
+ called a path that does not exist, and once that was fixed, the body sent
2096
+ `currentPostId` where the API reads `current_post_id`. Both fixed. If your
2097
+ site mounts `RelatedPosts` or calls `getRelatedInsights`, a populated
2098
+ related-posts section appears for the first time with no change on your
2099
+ side — budget for the layout. (The packaged `BlogPost` has its own internal
2100
+ related fetch and is unaffected.) `getRelatedInsights`' `category` option is
2101
+ still accepted and still ignored; the server derives relatedness from the
2102
+ current post.
2103
+
2104
+ **RSS and Atom feeds were capped at 12 posts.** The feed fetch sent `limit`,
2105
+ a parameter the API never read, so every feed silently got the default page.
2106
+ Feeds now paginate properly and include up to 100 posts — so a feed that has
2107
+ been serving 12 items grows on the next build, and readers may see older
2108
+ posts arrive as "new". A mid-pagination failure now returns the posts
2109
+ gathered so far instead of an empty feed. `getPostsByCategory` goes from 12
2110
+ to 50 for the same reason.
2111
+
2112
+ **"X min read" never rendered.** The components gated on
2113
+ `reading_time_minutes`; the API returns `reading_time`. One shared helper
2114
+ (`readingTimeMinutes`) now feeds every render site.
2115
+
2116
+ **Tag pages were empty for any multi-word tag.** The sidebar linked by
2117
+ slugified slug ("case-study") while the API filters by exact stored name
2118
+ ("Case Study"). Links now carry the encoded raw name, and the paired API
2119
+ deploy also accepts slugs — so old shipped links heal too. Note the URL
2120
+ shape changed: sidebar tag links are now `?tag=Case%20Study`, not
2121
+ `?tag=case-study`. A site that reads the `tag` search param itself, or that
2122
+ has canonicalised the old shape, should expect the new value.
2123
+
2124
+ **Signal-generated E-E-A-T JSON-LD was discarded.** The schema resolver now
2125
+ reads `schema ?? schema_json`, so stored structured data reaches pages
2126
+ instead of falling back to the generic generated Article.
2127
+
2128
+ **Author social links now render** from the real `blog_authors` columns
2129
+ (linkedin_url, twitter_url, website_url) via one shared helper, in the post
2130
+ byline, `AuthorCard`, and `AuthorPage`. The byline normalizer stopped
2131
+ dropping those columns on the floor.
2132
+
2133
+ ### New
2134
+
2135
+ **`BlogViewTracker`** — `BlogPost` now mounts a childless client island that
2136
+ counts actual readers: one POST to `/public/blog/view` per post per browser
2137
+ session (deduped through a `__sonor_blog_viewed__:<slug>` sessionStorage
2138
+ key), deferred to idle, no retries (the increment is not idempotent). Under
2139
+ ISR the old server-side count incremented when the *cache revalidated*, so
2140
+ `view_count` was measuring cache churn, not people.
2141
+
2142
+ This is new outbound traffic from every blog post page, including the
2143
+ custom `children` render-prop path, which previously shipped no client
2144
+ islands at all. It requires `SiteKitLayout` (it reads the layout's globals
2145
+ and sends the minted token) and silently no-ops without it.
2146
+
2147
+ The endpoint it calls is already live, so counting starts the moment you
2148
+ upgrade — but the *old* server-side increment keeps running until the
2149
+ paired api.sonor.io deploy removes it, so a site on 5.0.0 against the
2150
+ un-deployed API counts both readers and revalidations for that window.
2151
+ Upgrade near the deploy, or expect an inflated stretch. Historical counts
2152
+ are left as-is either way: treat pre-cutover numbers as a different metric,
2153
+ not a comparable series.
2154
+
2155
+ **`NewsletterWidget` actually subscribes people.** The old widget rendered a
2156
+ form whose submit handler discarded the email. It now takes either an
2157
+ `onSubmit` callback or a `formSlug` pointing at a managed Sonor form —
2158
+ formSlug mode drives the managed-forms rail, inheriting newsletter routing,
2159
+ honeypot, reCAPTCHA, and attribution. The forms engine is lazy-loaded so
2160
+ blog pages that never mount it pay nothing. With neither prop the widget
2161
+ renders nothing and warns: a dead form that swallows emails must not come
2162
+ back.
2163
+
2164
+ **Safe imports for async server components.** `BlogLayout`, `BlogPage`,
2165
+ `BlogPostPage`, `CategoryPage`, `BlogSidebar`, and `RelatedPosts` are
2166
+ exported from `@sonordev/site-kit/blog/server-ui`. Importing them from
2167
+ `@sonordev/site-kit/blog` (a client-stamped entry) produces an HTTP 500 in
2168
+ production — that path remains only for backwards compatibility, and a
2169
+ ratcheted guard test now pins the offender list so it can only shrink.
2170
+
2171
+ ### Also fixed
2172
+
2173
+ - Category metadata builds its og:url from a slug — "Case Studies" produced
2174
+ `/blog/category/case%20studies` against a real route of
2175
+ `/blog/category/case-studies`. Takes an explicit `categorySlug`, respects
2176
+ `blogBasePath`.
2177
+ - `BlogList` accepts `cluster` to filter by topic-cluster slug; the `author`
2178
+ filter is documented as slug-preferred (the paired API deploy resolves
2179
+ slugs).
2180
+ - `getAuthorPosts` gained an `offset` parameter and hydrates the full author
2181
+ row when an older API returns the stripped `{name, slug}` shape.
2182
+ - Topic cluster mapping carries `created_at`, and the detail path prefers
2183
+ the authoritative `pillar_post_id` over the embedded pillar's id.
2184
+ - OG metadata and JSON-LD stopped reading fields that do not exist
2185
+ (`og_image`, image width/height columns): image resolution goes straight
2186
+ to `featured_image`, and schema images are plain URL strings instead of
2187
+ ImageObjects with fabricated dimensions.
2188
+ - `sonor-setup sync --blog` stopped POSTing to an endpoint that has never
2189
+ existed (every run 404'd). It now inventories local markdown and says
2190
+ plainly that the Sonor blog is dashboard-managed.
2191
+
2192
+ ### Breaking
2193
+
2194
+ The type surface stopped promising fields the wire never carries. **No
2195
+ runtime change** — every removed field was already `undefined` at runtime —
2196
+ but reads of them are now compile errors, which is the point.
2197
+
2198
+ - `BlogTag` is `{ name, slug, post_count? }`. Tags are synthesized from
2199
+ post tag strings; no server version can ever supply `id`/`project_id`.
2200
+ - `BlogCategory.id` and `.project_id` are optional (real columns, stripped
2201
+ from the public response).
2202
+ - `BlogPost` loses `og_image`, `featured_image_width`,
2203
+ `featured_image_height`, `scheduled_at`, `is_featured`. The real columns
2204
+ — `featured` and `scheduled_for` — are typed.
2205
+ - `BlogAuthor` loses `email` (the API now strips it server-side too).
2206
+ - The `BlogAnalytics` interface is gone; `BlogViewTracker` supersedes it.
2207
+ - **`<NewsletterWidget />` with no props now renders nothing** (and warns)
2208
+ where 4.x rendered a visible subscribe form. That form discarded every
2209
+ email it collected, so this is deliberate — but it is a visible sidebar
2210
+ block disappearing, with no compile error to warn you. Pass `onSubmit` or
2211
+ `formSlug` to keep it. Sites that already passed `onSubmit` get the
2212
+ opposite surprise in their favour: 4.x never invoked it (the prop was
2213
+ declared but never destructured), and 5.0.0 does.
2214
+
2215
+ **Behaviour change worth reading twice.** On blogs that render the packaged
2216
+ `BlogPost` with cluster posts on a *flat* URL structure (no category
2217
+ segment), the cluster navigation's pillar link changes from
2218
+ `/blog/<slug>` (accidentally correct) to the category-segmented form,
2219
+ matching how sibling links already behaved. An explicit URL-shape control
2220
+ on `ClusterNavigation` is planned; until then flat-URL blogs with clusters
2221
+ should hold on 4.x or pass their own cluster nav.
2222
+
2223
+ ### Paired API deploy
2224
+
2225
+ The api.sonor.io deploy that pairs with this release fixes the other half
2226
+ of several contracts: the author embed no longer shadows the byline column
2227
+ (bylines return fleet-wide with zero site rebuilds), `schema_json` is
2228
+ mapped to `schema`, internal scoring fields and author emails stop shipping
2229
+ publicly, `limit` works as a `per_page` alias, tag and author filters
2230
+ accept slugs, category counts stop including drafts, and
2231
+ `GET posts/:slug` no longer increments `view_count`. Deploy order is free:
2232
+ every site-kit change degrades gracefully against the old API and vice
2233
+ versa.
2234
+
2235
+ ## 4.3.2 — 2026-08-25
2236
+
2237
+ ### A site now describes itself, not its neighbours
2238
+
2239
+ One Sonor project can host many domains. `seo_pages` is unique on
2240
+ `(project_id, site, url)`, so a shared path like `/` or `/terms` legitimately
2241
+ keeps one row per host. The build path never said which host it was, which left
2242
+ both halves of that guessing.
2243
+
2244
+ **The build-time sitemap sync is tagged with the host.** `createSitemap` now
2245
+ sends `site` to `register-sitemap`, the same way the runtime `SitemapSync` and
2246
+ the reconciler cron already did. Before this, pages a build discovered landed
2247
+ unattributed, so nothing could tell one sibling's rows from another's.
2248
+
2249
+ **Every llms.txt read is scoped to the building host.** `?site=` goes out on
2250
+ `/api/public/llms/{data,services,pages,txt}`. On a 49-domain network this is the
2251
+ difference between a homepage entry that describes the site and one that
2252
+ describes whichever sibling deployed most recently. It also stops a host
2253
+ advertising paths it does not serve: the hub was publishing `/terms` and
2254
+ `/privacy` borrowed from its microsites, both of which answered 404.
2255
+
2256
+ **Behaviour change worth reading twice.** A `full-replace` sitemap sync that
2257
+ carries a `site` prunes that host's stale rows. A sync without one is the legacy
2258
+ project-wide mode, and on a multi-site project the API refuses deletions
2259
+ outright. So on projects that span several hosts, build-time pruning becomes
2260
+ active where it was previously skipped. That is the correct behaviour and it is
2261
+ what makes per-host page sets converge, but it is new, and it is why a build
2262
+ whose `additionalPaths()` silently returns short now costs rows rather than
2263
+ being absorbed. Single-site projects are unaffected: their scope was already
2264
+ everything.
2265
+
2266
+ **Host resolution has one source and a defined order.** `resolveSiteHost` moved
2267
+ into `sites/resolve`, shared by the browser (which publishes
2268
+ `__SITE_KIT_SITE__`) and the build. Precedence is `site` option, then `baseUrl`,
2269
+ then `NEXT_PUBLIC_SITE_URL`, then the resolved base URL. A per-call `baseUrl`
2270
+ outranks the environment variable deliberately: it is set by this repo for this
2271
+ build, while `NEXT_PUBLIC_SITE_URL` is process-wide and can be stale or point at
2272
+ a sibling. Because `site` is part of page identity, getting that order wrong
2273
+ does not fail loudly, it mints a duplicate page set.
2274
+
2275
+ Also on Next 16.3.2. The peer range is unchanged at `^15.0.0 || ^16.0.0`; this
2276
+ is the version site-kit itself builds and tests against.
2277
+
2278
+ Requires the matching Sonor API release. Older API builds ignore `site` and keep
2279
+ working.
2280
+
2281
+ ## 4.2.4 — 2026-08-15
2282
+
2283
+ ### The generated cards now look like someone made them
2284
+
2285
+ 4.2.3 shipped per-page cards that all *fit*. Then someone asked whether they
2286
+ were actually any good, and looking at all nineteen instead of the two I had
2287
+ checked found four problems the fit report could never catch — it measures
2288
+ geometry, not whether copy reads well.
2289
+
2290
+ **The home page kept its hand-written card.** Deriving every route from its
2291
+ managed title meant `/` lost the crafted headline ("Built by brothers") for the
2292
+ SEO string ("Custom Closets Cincinnati Tri-State") — four lines that restated
2293
+ the kicker directly above them. `og.config.ts` `content` IS the home page's
2294
+ card: it is the one route whose subject is the whole site, and it is written by
2295
+ hand. It is no longer overwritten.
2296
+
2297
+ **Redundant kickers are dropped.** Managed titles carry the section and the
2298
+ city, so a derived kicker frequently repeated the title back at itself. When
2299
+ either contains the other, or every kicker word already appears in the title,
2300
+ the title wins and the kicker goes — which also returns a line of the frame to
2301
+ the headline.
2302
+
2303
+ **Subtitles refuse rather than truncate.** A managed description is 150+
2304
+ characters of prose written for a SERP row, and at card size no truncation of it
2305
+ looks deliberate. A word cut gave "across Cincinnati and…"; cutting at the last
2306
+ comma gave "home offices designed, built", which is worse because without an
2307
+ ellipsis it looks complete and merely ungrammatical. `clampSubtitle` now takes a
2308
+ whole sentence when one fits and otherwise returns nothing, so the card falls
2309
+ back to the site's own short subtitle. A clean generic line beats a mangled
2310
+ specific one, and the title is already page-specific.
2311
+
2312
+ **The bar is checked on both axes.** A long segment wrapped INSIDE its span, so
2313
+ `scrollWidth` never exceeded `clientWidth` while the text grew past the bar's
2314
+ fixed height and spilled over the copy above it — and the renderer passed it.
2315
+ Segments are `white-space: nowrap` now, which turns that into horizontal
2316
+ overflow the existing check sees, plus a height check as belt-and-braces. Found
2317
+ by rendering a real campaign card with a deadline in the bar.
2318
+
2319
+ None of these were visible from the fit report, which is the lesson: the
2320
+ renderer can prove a card fits, and only a person can say whether it reads.
2321
+
2322
+ ## 4.2.3 — 2026-08-15
2323
+
2324
+ ### OG cards: one per page, and cards that verify themselves
2325
+
2326
+ Written after being the factory's first real consumer. Two of the three problems
2327
+ below only surfaced because a human opened the PNG.
2328
+
2329
+ **Every page gets its own card.** `sonor-setup og` still writes the site card to
2330
+ `public/og.png`, and now also renders one card per route through the same
2331
+ headless-Chrome path. Copy comes from Sonor's managed title and description, so a
2332
+ card and its search result say the same thing; without a key the route path is
2333
+ titled. Theme, fonts, logo and photo are inherited from `og.config.ts` so the set
2334
+ reads as one family, and `cards: { '/path': {…} }` hand-writes the few that
2335
+ deserve it. Cards are written as Next's `opengraph-image` file convention beside
2336
+ each `page.tsx`, so a site needs no per-page metadata. `--no-pages` opts out.
2337
+
2338
+ **Two things about Next metadata that are the opposite of the intuition.** Both
2339
+ verified against real builds, both wrong in the first implementation:
2340
+
2341
+ 1. *Config metadata beats the file convention.* A route returning
2342
+ `openGraph.images` overrides its own card file. With images declared, all 32
2343
+ routes on a real site served the site card and every generated page card was
2344
+ inert; removing the declaration made each serve its own. This matters most
2345
+ for `seo_pages.managed_og_image`, which site-kit serves into
2346
+ `openGraph.images` for every managed page — set it and it silently suppresses
2347
+ the entire set from the dashboard.
2348
+ 2. *File metadata does not cascade.* A card at `services/` is not inherited by
2349
+ `services/[city]`; those pages shipped with no `og:image` at all until dynamic
2350
+ segments got their own. One static file in a dynamic segment covers every
2351
+ param.
2352
+
2353
+ **The wiring rule now has one implementation.** It had two — the CLI's
2354
+ post-render check and the doctor's `og.card` check — and both said the same wrong
2355
+ thing: "add `openGraph.images: ['/og.png']` to the root layout". Since per-page
2356
+ cards landed, that advice breaks the site. Both now call `og/wiring.ts`, which
2357
+ knows which mode a site is in and, in per-page mode, treats a declared images
2358
+ array as the defect. The old CLI check read only the root layout and reported
2359
+ "wiring looks right" while 16 routes had no image at all.
2360
+
2361
+ **Cards fail loudly instead of silently.** The first card generated in anger had
2362
+ the kicker off-canvas, the subtitle buried under the bottom bar and the bar
2363
+ wrapped into the crop; the CLI printed a tick. The renderer has a live DOM, so it
2364
+ measures before screenshotting: an in-page fitter waits for
2365
+ `document.fonts.ready`, steps the title down from 104px until it fits **both**
2366
+ axes — height alone is not enough, since an unbreakable word like "WORKBENCHES"
2367
+ overflows sideways at a size that fits vertically — and reports anything still
2368
+ clipped. `--screenshot` and `--dump-dom` run in one Chrome invocation, so the
2369
+ report always describes the image that was actually written. Copy that cannot fit
2370
+ fails the command with the element and the overflow in pixels.
2371
+
2372
+ 104px is an opening size now rather than a fixed one, with a 56px legibility
2373
+ floor: below that a headline stops reading at the ~300px thumbnail width
2374
+ platforms actually show, so the answer is shorter copy, not smaller type.
2375
+
2376
+ **Smaller fixes**
2377
+
2378
+ - `logo` was silently dropped in `split` layout (that branch renders copy+photo
2379
+ and never touches the plate). It now renders as a mark above the kicker; the
2380
+ "no `.plate` in split" invariant still holds.
2381
+ - Page cards are re-encoded to JPEG when `sharp` resolves: 5.6 MB → 1.4 MB across
2382
+ 18 cards, at no visible cost on a photo-plus-flat-colour card.
2383
+ - Subtitles clamp to a real sentence where one fits, and no longer produce `….`
2384
+ by appending an ellipsis after existing punctuation.
2385
+ - The legibility preview moved out of `public/` (where it deployed with the site)
2386
+ to `.sonor/`, and the Facebook debugger link is pre-filled with the site's
2387
+ domain.
2388
+ - New `src/og/README.md` documents the precedence rule, the copy budget, and the
2389
+ tier-2 escape hatch.
2390
+
2391
+ **Upgrading:** running `sonor-setup og` now writes `opengraph-image` files into
2392
+ your app directory and, if it finds them, asks you to REMOVE `openGraph.images`
2393
+ from the root layout and clear `managed_og_image` in Sonor. That is the correct
2394
+ direction — it is what makes per-page cards take effect — but it is a change of
2395
+ advice from every previous version, so read the wiring output rather than
2396
+ skimming it. `--no-pages` keeps the old single-card behaviour.
2397
+
2398
+ ## 4.2.2 — 2026-08-14
2399
+
2400
+ > 4.2.1 was tagged but never published, so upgrading from 4.2.0 also picks up
2401
+ > its `sitemapSync` default flip — see the note at the end of this entry.
2402
+
2403
+ ### Every managed form logged two "form started" rows per page load
2404
+
2405
+ One page load wrote **two** `form_analytics` rows, milliseconds apart, each with
2406
+ its own `session_id`. `form_analytics` is the start signal behind funnel
2407
+ reporting, so every reported start → submission conversion rate was **half its
2408
+ true value** — a page converting at 10% reported 5%.
2409
+
2410
+ The cause was two trackers on one form. `ManagedForm` called `useForm`, which
2411
+ tracks, *and* rendered `FormClient`, which tracks again. Two independent
2412
+ `useFormTracking` instances, two session uuids, two `POST /analytics/start`.
2413
+ Both `ServerForm`/`FormEnhancer` and a plain client `<ManagedForm>` end up in
2414
+ the same place, which is why every rendering path doubled.
2415
+
2416
+ It was not an effect firing twice. The proof is in the shape of the data: paired
2417
+ rows where exactly one ever completed (only `FormClient`'s instance owns submit,
2418
+ so `useForm`'s row could never be completed) but **both** abandoned. One hook has
2419
+ one analytics row id and one `beforeunload` listener — it cannot abandon two
2420
+ rows. Only two independent instances can. So every successful submission also
2421
+ wrote a phantom abandonment.
2422
+
2423
+ The fix is one owner per form. `ManagedForm` now passes `trackAnalytics: false`
2424
+ to `useForm` — it delegates rendering, step navigation and submission to
2425
+ `FormClient`, so `FormClient` owns the funnel. `useForm` and `FormClient` remain
2426
+ fully tracked when used on their own; only the composition changed.
2427
+
2428
+ Underneath that, `forms/tracking-session.ts` is now the single source of truth:
2429
+ one start per `formId` per page load, with every mounted tracker joining one
2430
+ session and **sharing** its analytics row id. Sharing rather than silencing the
2431
+ second tracker is deliberate — whichever component owns submit can still
2432
+ complete the row no matter which one opened it, so the guard does not depend on
2433
+ which instance mounts first. A short grace period on unmount also absorbs React
2434
+ StrictMode's dev-mode remount and the `FormEnhancer` static → interactive swap,
2435
+ both of which are one page load and must stay one row.
2436
+
2437
+ Two smaller correctness fixes came with it:
2438
+
2439
+ - Abandonment is now recorded once per row rather than once per tracker, and a
2440
+ completed form is never also reported as abandoned.
2441
+ - `trackStepChange` / `trackComplete` now wait on an in-flight start instead of
2442
+ silently dropping. A fast submit used to race the start POST and lose the
2443
+ completion, understating conversions the same way double-starting overstated
2444
+ them.
2445
+
2446
+ `sonor-api` gained a matching server-side guard (a partial unique index on
2447
+ `form_analytics`, keyed on the request rather than the client's `sessionId`),
2448
+ since sites pin their own site-kit version and the fleet updates slowly.
2449
+
2450
+ **Historical data:** rows written before this fix are affected but **not
2451
+ uniformly** — 96% of page loads doubled in 2026-01, 40% in 2026-07, across up to
2452
+ 29 projects, because only the `ManagedForm` path double-tracked. Do not apply a
2453
+ blanket 50% correction. To recount a period honestly, collapse rows sharing
2454
+ `(form_id, date_trunc('second', started_at))` rather than scaling totals.
2455
+
2456
+ ### Also included: `sitemapSync` now defaults to false (from the unpublished 4.2.1)
2457
+
2458
+ Sitemap registration is a build-time and server-side job — build-time
2459
+ `createSitemap` is canonical, and sonor-api's nightly reconciler fetches each
2460
+ host's `/sitemap.xml` itself. But `sitemapSync` defaulted to `true` and both its
2461
+ guards live in browser storage (throttle in `sessionStorage`, content hash in
2462
+ `localStorage`), so every first-time visitor, incognito tab and crawler missed
2463
+ both and re-POSTed the entire sitemap. Sitemap traffic scaled with visitor count.
2464
+ `SiteKitLayout` now defaults it off; sites that genuinely want runtime sync can
2465
+ still pass `sitemapSync`.
2466
+
2467
+ ## 4.2.0 — 2026-08-14
2468
+
2469
+ ### `landing/server`: the landing module's own documented pattern builds again
2470
+
2471
+ `@sonordev/site-kit/landing` documents this:
2472
+
2473
+ ```tsx
2474
+ export const metadata = landingPageMetadata({ ... })
2475
+ ```
2476
+
2477
+ It did not build. Next forbids exporting `metadata` from a client module, and
2478
+ `landingPageMetadata` was shipping from a chunk stamped `'use client'` — so
2479
+ every campaign route following the README failed, and the workaround was to
2480
+ hand-write the noindex robots block and skip the helper.
2481
+
2482
+ Neither helper needed the client. `landing/metadata.ts` and
2483
+ `landing/contract.ts` have no hooks, no directive and no browser globals. The
2484
+ stamp did NOT come from `CLIENT_ENTRIES` (`landing/index` is not in it): the
2485
+ build also stamps any shared chunk that *contains* React hooks, `<LandingPage>`
2486
+ has them, and the two pure functions were bundled alongside it. The same
2487
+ mechanism put `DatePicker`'s hooks on the `FormField` chunk in 4.0.2.
2488
+
2489
+ ```tsx
2490
+ import { LandingPage } from '@sonordev/site-kit/landing'
2491
+ import { landingPageMetadata } from '@sonordev/site-kit/landing/server'
2492
+ ```
2493
+
2494
+ Splitting the entry also gives the helpers their own hook-free chunk, so the
2495
+ original barrel import works again too. That is a consequence of chunk
2496
+ splitting and could silently re-merge, so the dedicated entry is the guarantee
2497
+ and `landing/server-entry.test.ts` pins it at source level.
2498
+
2499
+ This is the third instance of one root cause — a server-usable export made
2500
+ unusable by sharing a chunk with hook-bearing code. `forms/static` (4.0.2) and
2501
+ the `FIELD_CONTROLS` injection (4.0.2) were the first two. A build-time guard
2502
+ that fails when a hook-free module lands in a stamped chunk would catch the
2503
+ fourth before a consumer's build does.
2504
+
2505
+ ## 4.1.0 — 2026-08-14
2506
+
2507
+ ### The CMS module builds on Next 16 again
2508
+
2509
+ Every site importing `@sonordev/site-kit/cms` has been failing `next build` at
2510
+ page-data collection with:
2511
+
2512
+ ```
2513
+ Error: dynamic usage of require is not supported
2514
+ ```
2515
+
2516
+ `cms/server-api.ts` reached for React's `cache` with
2517
+ `const { cache } = require('react')`. tsup compiles a bare `require` in ESM
2518
+ output to its `__require` interop shim, and Turbopack refuses to evaluate that.
2519
+ Five other modules — `seo/api.ts`, `seo/server-api.ts`, `llms/api.ts`,
2520
+ `slots/server-api.ts`, `seo/LocationPageContent.tsx` — already did the same
2521
+ thing correctly with a static `import { cache } from 'react'`. This one file
2522
+ had drifted, and it took the whole CMS module down with it.
2523
+
2524
+ This was **not** a 4.0.3 regression — published 4.0.2 fails identically. It has
2525
+ been broken for as long as sites have been on Next 16. A test now scans all
2526
+ shipped source (everything outside `src/cli`, which is CJS by design) for bare
2527
+ `require()` calls, so the next drift fails here instead of at a customer build.
2528
+
2529
+ ### React 19 is now the floor
2530
+
2531
+ `peerDependencies` asked for `react: ^18.0.0 || ^19.0.0`. That was never true.
2532
+ Six modules import React's `cache` statically — `cms/server-api.ts`,
2533
+ `seo/api.ts`, `seo/server-api.ts`, `llms/api.ts`, `slots/server-api.ts` and
2534
+ `seo/LocationPageContent.tsx` — and **React 18 does not export `cache` at all**
2535
+ (confirmed `undefined` in both 18.2.0 and 18.3.1). A site on React 18 could not
2536
+ have used site-kit's SEO, CMS, LLMs or slots modules regardless of what the
2537
+ range claimed.
2538
+
2539
+ - `react` / `react-dom` peers are now `^19.0.0`.
2540
+ - `next` peer drops `^14.0.0` (now `^15.0.0 || ^16.0.0`): Next 14 pins React
2541
+ 18.2, so `next@14` + `react@19` was an unsatisfiable pair once React 18 was
2542
+ gone. Every repo in the fleet is on Next 15 or 16 — none on 14.
2543
+ - The `next` devDependency moves 16.3.0 → 16.3.1 to match current.
2544
+
2545
+ This narrows a published range, so treat it as the breaking part of this
2546
+ release. In practice the blast radius is zero: every fleet repo is already on
2547
+ React 19, and the single React 18 project doesn't depend on site-kit.
2548
+
2549
+ ### Sanity packages are optional peers now
2550
+
2551
+ `@portabletext/react` and `@sanity/image-url` moved from `dependencies` to
2552
+ optional `peerDependencies`. They serve only the CMS module — 8 packages and
2553
+ ~1.7 MB that every site on the fleet was installing to render Sanity content
2554
+ most of them never touch.
2555
+
2556
+ **If you use `@sonordev/site-kit/cms`, add them:**
2557
+
2558
+ ```bash
2559
+ npm i @portabletext/react @sanity/image-url
2560
+ ```
2561
+
2562
+ **If you don't, there's nothing to do** — you simply stop installing them.
2563
+ `@sonordev/site-kit/cms/server` is unaffected either way; it has no Sanity
2564
+ imports and needs no peers.
2565
+
2566
+ This ships as a minor rather than a major because the blast radius is narrow
2567
+ and the failure is loud: only a site that explicitly imports `./cms` is
2568
+ affected, and it gets a build-time `Module not found: Can't resolve
2569
+ '@portabletext/react'` naming exactly what to install — not a silent runtime
2570
+ break. Verified on real Next 16 builds in all three states: no-CMS site builds
2571
+ and skips the packages, CMS site without peers fails with that message, CMS
2572
+ site with peers builds and renders.
2573
+
2574
+ The same treatment is **not** possible for `react-markdown` (85 packages,
2575
+ ~8 MB), even though it is used by a single component. It is reachable from
2576
+ `./engage`, which chat sites do import, and bundlers resolve even a *dynamic*
2577
+ import at build time — so there is no runtime fallback to degrade into. What
2578
+ makes the Sanity packages safe is subpath isolation: nothing outside
2579
+ `src/cms/` imports them and no shared chunk carries them, exactly like
2580
+ `@vis.gl/react-google-maps` for `./maps`. Both rules are asserted by tests.
2581
+
2582
+ ## 4.0.3 — 2026-08-14
2583
+
2584
+ ### Security: 4.0.3 is the version that clears the socket.io advisories
2585
+
2586
+ **Bump the fleet to 4.0.3 to clear all four.** Sites on 4.0.2 and earlier
2587
+ inherit them through the Engage chat's socket.io dependency:
2588
+
2589
+ | Advisory | Package | Severity | Patched at |
2590
+ |---|---|---|---|
2591
+ | [GHSA-96hv-2xvq-fx4p](https://github.com/advisories/GHSA-96hv-2xvq-fx4p) — memory exhaustion DoS from tiny fragments | ws | High | 8.21.0 |
2592
+ | [GHSA-58qx-3vcg-4xpx](https://github.com/advisories/GHSA-58qx-3vcg-4xpx) — uninitialized memory disclosure | ws | Moderate | 8.20.1 |
2593
+ | [GHSA-2m8v-j782-fhvr](https://github.com/advisories/GHSA-2m8v-j782-fhvr) — zero-attachment memory exhaustion | socket.io-parser | High | 4.2.7 |
2594
+ | [GHSA-677m-j7p3-52f9](https://github.com/advisories/GHSA-677m-j7p3-52f9) — unbounded binary attachments | socket.io-parser | High | 4.2.6 |
2595
+
2596
+ The obvious fix doesn't exist: `socket.io-client@4.8.3` is already the latest
2597
+ release, and its own ranges (`engine.io-client: ~6.6.1`,
2598
+ `socket.io-parser: ~4.2.4`) are wide enough to resolve *either* the vulnerable
2599
+ or the patched versions. A fresh install today happens to land on the patched
2600
+ ones. That's the trap — it means the ranges look fine while the fleet isn't.
2601
+
2602
+ What actually pins a site to the vulnerable tree is its **lockfile**. npm won't
2603
+ touch a transitive dependency that still satisfies the existing range, so a
2604
+ site can bump site-kit, see the version change, and keep shipping ws 8.18.3.
2605
+ Verified on a stale lockfile: bumping with the ranges alone left
2606
+ engine.io-client 6.6.4 / ws 8.18.3 / socket.io-parser 4.2.5 in place and three
2607
+ advisories still open.
2608
+
2609
+ So 4.0.3 raises the declared floor instead. `engine.io-client: ^6.6.6` and
2610
+ `socket.io-parser: ^4.2.7` are now direct dependencies — not because anything
2611
+ imports them, but because a *library* can't fix this any other way: npm and
2612
+ pnpm only honour `overrides`/`resolutions` from the root project, so site-kit's
2613
+ own overrides would never reach a consuming site. A declared floor does.
2614
+ 6.6.6 is the exact floor that matters — 6.6.5 still pulls ws `~8.20.1`, which
2615
+ is short of the High-severity DoS fix; only 6.6.6 depends on ws `~8.21.0`.
2616
+
2617
+ - **Fleet sequencing:** bump to `@sonordev/site-kit@4.0.3` and run
2618
+ `npm install` (or `pnpm install`) so the lockfile regenerates. `npm ci`
2619
+ against an un-regenerated lockfile will fail rather than quietly reinstall
2620
+ the vulnerable tree, which is the intended behaviour — the floors make the
2621
+ stale lock impossible to satisfy instead of merely unlucky.
2622
+ - No API change, no bundle change. socket.io is still lazy-loaded on chat open
2623
+ by `engage/socket-loader`, so the resolved versions move but nothing new
2624
+ enters the initial chunk.
2625
+ - The Engage `ChatWidget` websocket path was verified end-to-end against the
2626
+ patched stack: namespace handshake, `visitor:message`/`message` round trip,
2627
+ binary attachments over the patched parser, and auto-reconnect after a
2628
+ transport drop.
2629
+ ### Repo hygiene that came out of the same investigation
2630
+
2631
+ - **One lockfile.** The repo tracked both `package-lock.json` and
2632
+ `pnpm-lock.yaml`. They had drifted six days apart, and the npm one was the
2633
+ artifact still holding the vulnerable pins. `package-lock.json` is deleted
2634
+ and gitignored, and `packageManager` now pins pnpm so the fork can't recur.
2635
+ - **`pnpm audit` was never going to catch this.** No lockfile ships in the
2636
+ published tarball, so a repo-local audit describes a tree no consumer ever
2637
+ installs — and it buries the signal under devDependency noise. New
2638
+ `pnpm audit:consumer` packs the real tarball, installs it into a throwaway
2639
+ project, and audits the production tree only. It runs in `prepublishOnly`,
2640
+ so a release that would hand the fleet an advisory now fails to publish.
2641
+ - **`react-markdown` stays a dependency, deliberately.** It's 85 packages /
2642
+ ~8 MB for one component, so making it an optional peer looks like free
2643
+ savings. It isn't possible: bundlers resolve even a *dynamic* import at
2644
+ build time, so a site that didn't install it fails with "Module not found"
2645
+ before any runtime fallback can run — confirmed against Next 16 / Turbopack.
2646
+ A test now records this so the experiment doesn't get repeated.
2647
+
2648
+ ## 4.0.2 — 2026-08-13
2649
+
2650
+ Managed forms are Server Components. The client runtime is now the optional
2651
+ half.
2652
+
2653
+ ### The form renders on the server, with zero JavaScript
2654
+
2655
+ `ManagedForm` was a client component for reasons that stopped being true:
2656
+ it fetched its own config (4.0's `getFormConfig` ended that) and it could
2657
+ only submit over JSON (4.0.1's native endpoint ended that). Nobody moved the
2658
+ boundary afterward, so every site that put a form on a page shipped the whole
2659
+ forms runtime — validation, spotlight, celebration, momentum — as an INITIAL
2660
+ script on the LCP-critical path.
2661
+
2662
+ Measured on upforge.io's homepage: 48 KB of form code in the initial chunk set
2663
+ cost **6 Lighthouse points** (88 → 82, LCP 3.8s → 4.7s). Deferring the client
2664
+ component recovered the score but deleted the form from the HTML, taking the
2665
+ no-JS floor and crawlable fields with it. Neither half was acceptable.
2666
+
2667
+ ```tsx
2668
+ import { ServerForm } from '@sonordev/site-kit/forms/server'
2669
+
2670
+ export default function ContactPage() {
2671
+ return <ServerForm formId="contact" returnTo="https://example.com/contact/" />
2672
+ }
2673
+ ```
2674
+
2675
+ One line, in a Server Component. Fetches the config server-side, renders a
2676
+ complete working `<form>` into the HTML, and upgrades it to the interactive
2677
+ experience at idle. Client JS on the critical path drops from ~48 KB to
2678
+ **4.8 KB** (the enhancer plus the shared idle gate) — a 90% cut — and the
2679
+ Lighthouse regression is fully recovered at 88 with LCP back to 3.8s.
2680
+
2681
+ The progression is now: no JS at all → native POST, works. JS but pre-idle →
2682
+ native POST, works. Post-idle → JSON submit with the full experience. Chunk
2683
+ fails to load → the shell stays and still submits. A form is never blank.
2684
+
2685
+ - **`ServerForm`** (`forms/server`) — the recommended way to render a form.
2686
+ - **`StaticForm`** (`forms/server`) — the zero-JS form alone, if you want to
2687
+ compose the enhancement yourself. `enhance={false}` on ServerForm ships no
2688
+ form JavaScript at all.
2689
+ - **`FormEnhancer`** (`forms`) — the ~3 KB client boundary that swaps the
2690
+ shell for the interactive form at idle.
2691
+ - The client `ManagedForm` is unchanged and still exported. Use it when the
2692
+ form must live inside an existing client component (a modal, a chat panel)
2693
+ where a Server Component cannot go.
2694
+
2695
+ ### Entrance animation for the idle upgrade
2696
+
2697
+ An above-the-fold form upgrades while the visitor is looking at it, so the
2698
+ swap must not pop. The mount wrapper carries `data-sk-form-mount`
2699
+ (`shell` → `enhanced`) and the arriving form gets `data-sk-enter` for one
2700
+ frame, with defaults driven by `--sk-form-enter-duration` / `-easing` /
2701
+ `-distance`. `enter="none"` opts out; `enter="custom"` emits the hooks and no
2702
+ styles so the site owns the animation entirely. All defaults collapse under
2703
+ `prefers-reduced-motion`.
2704
+
2705
+ ### One field renderer, two modes
2706
+
2707
+ `FormField` lost its `'use client'` directive and now renders controlled
2708
+ (client) or uncontrolled/`defaultValue` (server) from the same code, so the
2709
+ shell and the enhancement cannot drift — a divergence would show up to a
2710
+ visitor as a jump on swap. `field-parity` tests pin the structure.
2711
+
2712
+ - The file-upload branch moved to `FileField.tsx`; it was the only thing in
2713
+ the file that needed state.
2714
+ - `DatePicker` and `FileField` are now **injected and fetched on demand**
2715
+ (`useFieldControls`) rather than imported. Importing them put 17 hooks in
2716
+ `FormField`'s chunk and the build stamped the whole thing `'use client'` —
2717
+ a "server" field renderer that shipped 38 KB of date picker to render one
2718
+ text input. Injection alone still left both in the forms entry's eager
2719
+ graph, so every form paid for a date picker most forms have no field for;
2720
+ the hook now loads each control only when the field list contains one.
2721
+ Until the chunk lands — and in static mode, and if the fetch fails — the
2722
+ browser's own `date` / `file` controls render, and they submit natively
2723
+ anyway, so the wait is invisible and never costs a submission.
2724
+ - `normalizeFormConfig` moved to `normalize-config.ts` so the server shell and
2725
+ the client fetch share one normalizer.
2726
+
2727
+ ### Fixed: a `url` field rendered no input at all
2728
+
2729
+ `FieldType` had no `'url'` branch, so a field configured as a URL emitted its
2730
+ label and **no control**. On upforge.io's free-audit form that silently
2731
+ dropped the one required value the entire feature needs — the site to audit —
2732
+ and it only surfaced by grepping the built HTML. Every field type is now
2733
+ covered by a test asserting it emits a named, submittable control.
2734
+
2735
+ ## 4.0.1 — 2026-08-13
2736
+
2737
+ ### No-JS submissions: the floor under every managed form
2738
+
2739
+ A single-step managed form now works with JavaScript disabled. When the
2740
+ forms/config response carries a `native_token` (sonor-api mints it once the
2741
+ no-JS train is deployed), the classic form — which is already the SSR/no-JS
2742
+ surface under the spotlight and stage experiences — renders a real
2743
+ `action`/`method` pointing at `POST /api/public/forms/submit-native`, plus a
2744
+ hidden `_sk_token` control field. With JS running, nothing changes: the
2745
+ existing `onSubmit` intercepts and the JSON path wins. Without JS, the
2746
+ browser performs an ordinary form-encoded POST, sonor-api authenticates via
2747
+ the signed token, runs the honeypot + render-timestamp + quarantine spam
2748
+ stack, and 303s back to the page with `?submitted=1`.
2749
+
2750
+ - New `nativeReturnTo` prop on `ManagedForm` — the absolute page URL the
2751
+ native redirect should land on. Optional: without it the server falls back
2752
+ to the Referer origin (right site, homepage instead of this page).
2753
+ - Token-less configs (older API deployments) and multi-step forms render
2754
+ exactly as before — no action attribute, so a browser is never pointed at
2755
+ an endpoint that would reject it. Step navigation is JS; the native floor
2756
+ is deliberately single-step, which is the overwhelming lead-capture case.
2757
+ - `getApiConfig`'s server branch now honors `SONOR_API_URL`, matching what
2758
+ `SiteKitLayout` feeds the client — so SSR-rendered absolute URLs point at
2759
+ the same API the browser uses (staging sites included).
2760
+
2761
+ ## 4.0.0 — 2026-08-13
2762
+
2763
+ Major release. Absorbs the unpublished 3.9.0 (Next 16 alignment + the
2764
+ redirect rail off the hot path) and adds the provider-island removal, the
2765
+ OG card factory, site search, events polish, and hardening below.
2766
+
2767
+ ### The provider island is gone (children are never wrapped)
2768
+
2769
+ The prerender-bailout / force-static / remount-flash bug class traced to one
2770
+ structural fact: client providers wrapped page children. Now children render
2771
+ first and every module mounts as a keyed childless sibling. SiteKitProvider
2772
+ (deprecated) and SiteKitIdentityProvider are removed; useSiteKitIdentity reads
2773
+ the storage singletons; useSignal reads a buffering module store and — BREAKING
2774
+ — no longer throws outside a bridge (bounded no-op stub instead).
2775
+
2776
+ ### OG card factory
2777
+
2778
+ `sonor-setup og` renders og.config.ts (defineOgCard — the SITE owns the theme;
2779
+ Sonor brand is only the zero-config seed) through headless Chrome to a static
2780
+ public/og.png + a 300px legibility preview, and verifies metadata wiring.
2781
+ Doctor gains `og.card`. 24 of 66 fleet sites had no card.
2782
+
2783
+ ### Per-entity runtime OG cards (tier 2)
2784
+
2785
+ createOgImageRoute() in @sonordev/site-kit/og/route: unique cards per blog
2786
+ post/event/product via next/og in a route handler — resolver returns the
2787
+ card (title, page photo as absolute URL, theme) or null for a clean 404.
2788
+ Satori constraints documented; fonts passed as ArrayBuffers.
2789
+
2790
+ ### Fleet migration command
2791
+
2792
+ sonor-setup next16 wraps the official middleware-to-proxy codemod and
2793
+ verifies the result with the doctor check — fleet sweeps as a command
2794
+ instead of bespoke bash.
2795
+
2796
+ ### Parts contract on legacy commerce components
2797
+
2798
+ CalendarView (nav, month title), EventTile, OfferingCard, and EventsWidget
2799
+ cards now carry data-sk-part alongside data-category.
2800
+
2801
+ ### Site search (new)
2802
+
2803
+ @sonordev/site-kit/search: useSiteSearch + unstyled SiteSearch against the new
2804
+ POST /api/public/search (pages + posts + offerings, ranked; multi-site aware).
2805
+ Degrades to inert against older APIs.
2806
+
2807
+ ### Events polish
2808
+
2809
+ ICS + Google Calendar links (RFC 5545, all-day correct), EventJsonLd
2810
+ (schema.org/Event with seat-count-honest availability), CapacityBadge
2811
+ (spots_remaining is the only honest sold-out signal), and EventsAgenda — the
2812
+ month-grouped list layout with range + all-day support and data-sk-part
2813
+ theming hooks. Commerce surfaces expose data-category/data-offering-type.
2814
+
2815
+ ### Security posture (verified during the 4.0 audit)
2816
+
2817
+ Of the tracked key-leak bypass trio: the blog barrel is pinned shut by the
2818
+ client-entry boundary test; Echo chat HTTP flows through sonorFetch's token
2819
+ path; and the chat WebSocket handshake carries NO credential at all (project
2820
+ + visitor + session ids only). The remaining bypass lives in
2821
+ @sonordev/agency-site-kit and ships with that package's own release. No-JS
2822
+ form submission is specced (docs/SITE-KIT-4.0-NOJS-FORMS.md) but gated on
2823
+ sonor-api spam-defense work — a native POST cannot carry a reCAPTCHA token.
2824
+
2825
+ ### Hardening
2826
+
2827
+ - Honeypot is CSS-proof: inert + belt-and-suspenders inline hiding +
2828
+ data-sk-honeypot (a site's own form CSS once un-hid it).
2829
+ - SpeculationRules component / `speculation` prop on SiteKitLayout:
2830
+ declarative prerender-on-intent for static sites, zero JS.
2831
+ - Scaffold emits og.config.ts; postbuild gating recipe: `sonor-setup verify`
2832
+ and `doctor` already exit non-zero on error-level checks — wire them into CI.
2833
+
2834
+ ### Absorbed from the unpublished 3.9.0
2835
+
2836
+ Next 16 alignment + the redirect rail moves off the hot path. Driven by fleet
2837
+ research: both redirect tables have 0 rows platform-wide, yet 33 sites paid a
2838
+ blocking rules fetch on every request (measured: ~130ms warm TTFB, 2.4s cold).
2839
+
2840
+ ### Redirects: bounded, and resolvable at the 404 boundary
2841
+
2842
+ - `fetchRedirectRules` now aborts at 300ms (`fetchTimeoutMs`, guarded
2843
+ AbortSignal.timeout) and caches failures for 30s. Previously it failed open
2844
+ on error but NOT on slow — a degraded Portal became every site's TTFB.
2845
+ Build-time `generateNextRedirects` uses a 10s budget.
2846
+ - NEW `resolveManagedRedirect()` in `@sonordev/site-kit/redirects/not-found`:
2847
+ resolve managed redirects in `app/not-found.tsx` — the only place they can
2848
+ matter — instead of on 100% of page loads. Dashboard edits still apply
2849
+ instantly. The proxy always stamps `x-sk-path` (pathname + search) on the
2850
+ request so the 404 boundary knows the URL; pure header write, no fetch.
2851
+ - Proxy `redirects: true` still works (now bounded) but is documented as
2852
+ legacy; the scaffold emits `redirects: false` + the not-found resolver.
2853
+
2854
+ ### Next 16 correctness
2855
+
2856
+ - Scaffold emits `proxy.ts` (not deprecated `middleware.ts`) with the matcher
2857
+ INLINED — `export const config = siteKitMatcher` is a Turbopack build error
2858
+ because matchers must be statically analyzable. All docs updated;
2859
+ `siteKitMatcher` is now documented as a copy-source reference value.
2860
+ - Doctor: recognizes `proxy.ts`/`src/proxy.ts` (previously reported "No
2861
+ middleware file" on correctly-configured Next 16 sites), errors on any
2862
+ `runtime` option in proxy files (Next 16 throws on it), warns on the
2863
+ deprecated middleware convention with the official codemod command, and no
2864
+ longer gates on netlify.toml (UI-deployed Netlify sites have none).
2865
+
2866
+ ### Commerce
2867
+
2868
+ - `getUpcomingEvents`/`getNextEvent` (server) now use the events endpoint and
2869
+ return offerings WITH `schedules[]` and `category` embedded — the offerings
2870
+ list endpoint never embedded schedules, so the server helper was useless for
2871
+ calendars and sites hand-rolled fetchers. Falls back to the legacy query on
2872
+ older APIs.
2873
+ - Commerce surfaces expose `data-category` / `data-offering-type` attributes
2874
+ (CalendarView/EventCalendar chips, EventTile, EventsWidget, OfferingCard) so
2875
+ sites can theme categories with CSS attribute selectors.
2876
+
2877
+ ### Forms
2878
+
2879
+ - NEW `getFormConfig()` in `@sonordev/site-kit/forms/server` +
2880
+ `ManagedForm initialConfig` prop: fetch the form config in a Server
2881
+ Component and the fields render on the first client render — no more
2882
+ "Loading form..." flash. Falls back to the client fetch on any failure, and
2883
+ both paths share one normalizer so they cannot drift.
2884
+ - **NEW: the delight layer.** Forms are the conversion surface — filling one
2885
+ now feels like progress, not paperwork. All zero-config on every
2886
+ `ManagedForm`, all `--sk-primary`-themed, all collapsed by
2887
+ `prefers-reduced-motion`:
2888
+ - `FormCelebration` success state: brand disc springs in, the check draws
2889
+ itself, a particle burst fires, plus a soft haptic tick on mobile.
2890
+ - Field lock-in: a valid field earns a drawn check + ripple ring and a
2891
+ one-shot border flash. Focused fields lift with a brand aura.
2892
+ - `FormMomentum`: a fields-completed meter (endowed progress) with live
2893
+ sheen, advance flash, leading-edge spark, and an "N of M" label that
2894
+ flips to READY and charges the bar when every requirement clears.
2895
+ - The submit button wakes up (pop + breathing glow) the moment the form is
2896
+ actually submittable, and pulses while submitting
2897
+ (`data-sk-ready` / `data-sk-state` hooks for site-level styling).
2898
+ - Every element carries `data-sk-part` hooks, and all chrome mixes
2899
+ `--sk-primary` toward `currentColor`, so it stays visible even inside
2900
+ sections painted with the brand color itself.
2901
+ - **NEW `FormReveal` — entrance theater for form/CTA tiles.** The tile
2902
+ animates open when scrolled into view and its contents cascade in; pass a
2903
+ brand mark (`logoPath`) and the logo pops in and **morphs into the tile**
2904
+ (MorphSVG, free since GSAP 3.13) before handing off to the cascade.
2905
+ `animate` prop: `auto` (default — static when the tile starts inside the
2906
+ viewport, so above-the-fold forms never pay an entrance), `off`, `reveal`,
2907
+ `morph`. SSR markup is always fully visible (LCP-safe by construction);
2908
+ reduced motion, missing IntersectionObserver, or a failed chunk all degrade
2909
+ to static, with a safety un-hide timer behind the observer.
2910
+ - **gsap is now a site-kit dependency** (`^3.13.0`), loaded ONLY via the
2911
+ shared idle loader (`loadGsap` / `warmGsapAtIdle`): dynamically imported in
2912
+ its own chunk, prefetched at browser idle, never in the critical bundle —
2913
+ static tiles still warm it so interaction animations are ready on first
2914
+ touch. Fleet sites already shipping their own gsap dedupe to one copy.
2915
+ - Inputs now inline `color: var(--sk-input-text, #111827)` paired with the
2916
+ existing `--sk-input-bg` default, and the border width is themeable via
2917
+ `--sk-input-border-w`. fg/bg must come from the same source: the kit used
2918
+ to inline the background but let `color` cascade from the section, which
2919
+ produced cream-on-white inputs on brand-colored panels. Theme inputs
2920
+ through the `--sk-input-*` variables (raw `input {}` rules can't beat the
2921
+ inline layer).
2922
+
2923
+ ### Scaffold & guardrails
2924
+
2925
+ - Scaffold writes `.env.local` (skipped when present) instead of
2926
+ `.env.example` — one real env file per project.
2927
+ - Doctor: new `images.dims-drift` check compares `<Image width/height>`
2928
+ against the referenced SVG's actual viewBox and warns on >2% drift (a
2929
+ re-exported logo changed aspect 3.16:1 → 5.52:1 and the stale props kept
2930
+ the old shape).
2931
+ - New regression test bans raw `crypto.randomUUID()` outside the guarded
2932
+ shared helpers — the 3.6.0 hydration-crash class, made unrepresentable.
2933
+
2934
+ ## 3.3.1 — 2026-07-21
2935
+
2936
+ Sitemap-sync safety release. Fixes the "sitemap oscillation" bug where the
2937
+ `sonor-register-sitemap --auto-discover` postbuild fought a site's
2938
+ `app/sitemap.{ts,js}` on every build — deleting and recreating each other's
2939
+ `seo_pages` rows, and destroying managed metadata, Signal optimizations, LLM
2940
+ schemas, and Search Console history on real (especially dynamic-route) pages.
2941
+ No API surface changes. **Behavior change:** `--auto-discover` no longer does a
2942
+ `full-replace` by default — see below. Recommended for every site.
2943
+
2944
+ ### Fixed: `--auto-discover` can no longer delete pages it can't see
2945
+
2946
+ The root cause was architectural: a filesystem scan of `app/` is structurally
2947
+ **incomplete** — it can't enumerate the params of a dynamic route
2948
+ (`/work/[slug]`, `/blog/[cat]/[id]`) and it ignores the app's sitemap `exclude`
2949
+ config. Driving a `full-replace` from that incomplete set tells the API to
2950
+ delete every page not in it. Two independent fixes, both shipped:
2951
+
2952
+ - **Self-skip when a sitemap route exists.** `--auto-discover` now detects an
2953
+ `app/sitemap.{ts,tsx,js,jsx,mjs}` (or `src/app/…`) and **skips entirely** with
2954
+ a clear message. That route's `/sitemap.xml` is the authoritative page set,
2955
+ and site-kit's runtime `SitemapSync` already mirrors it (additively). The
2956
+ postbuild line is now **safe to leave in any site** — it self-skips. The
2957
+ brand-profile push still runs on the skip path, so keeping the line doesn't
2958
+ silently stop refreshing brand awareness.
2959
+ - **Additive by default otherwise.** When there's no sitemap route (a
2960
+ pure-static site where the CLI is the only page source), the sync is now
2961
+ `additive` — it adds/updates pages but **never deletes**. Pass the new
2962
+ **`--full-replace`** flag to opt back into pruning removed pages.
2963
+
2964
+ The same `autoDiscover → full-replace` default was also fixed in the
2965
+ programmatic `registerLocalSitemap` export (`@sonordev/site-kit/seo/server`):
2966
+ it now defaults to `additive`; pass `mode: 'full-replace'` for the old behavior.
2967
+
2968
+ ### Changed: consolidated triplicated sitemap logic
2969
+
2970
+ `inferPageType` and the sitemap URL/path-normalization helpers were copy-pasted
2971
+ (and had already drifted) across `createSitemap`, the runtime `SitemapSync`
2972
+ component, and the CLI. They now live in one pure, browser-safe module
2973
+ (`sitemap/shared.ts`), and the filesystem route-discovery — previously duplicated
2974
+ between the CLI impl and `seo/routing.ts` — is unified in `sitemap/discover.ts`.
2975
+ No public API change.
2976
+
2977
+ ## 3.3.0 — 2026-07-21
2978
+
2979
+ BookingWidget friction + theming release. No breaking changes — a drop-in bump
2980
+ for every 3.x site. New behavior is on by default but can be disabled.
2981
+
2982
+ ### Added: auto-select first available day (`autoSelectFirstDay`, default true)
2983
+
2984
+ When the calendar loads, the widget now probes availability starting at the
2985
+ earliest bookable day (min-notice aware, bounded sequential probe, stops at the
2986
+ first day with open slots), selects that day, and shows its times — guests land
2987
+ on pickable times instead of an empty "select a date" state. The probe's fetch
2988
+ is reused for the selected day (no duplicate request), aborts cleanly if the
2989
+ guest clicks a day mid-probe, and fails silent (manual picking still works).
2990
+ Pass `autoSelectFirstDay={false}` to restore the old behavior.
2991
+
2992
+ ### Added: skeleton loading for time slots
2993
+
2994
+ Slot loading (and the auto-select probe) now renders shimmer placeholders
2995
+ sized like real slot buttons instead of a spinner, so the times column doesn't
2996
+ collapse and jump. Respects `prefers-reduced-motion`.
2997
+
2998
+ ### Fixed: dark-theme derived colors
2999
+
3000
+ `--sk-primary-light` now mixes the primary color with `styles.backgroundColor`
3001
+ instead of always white, so selected-slot/hold-notice tints no longer glow on
3002
+ dark panels. The error banner's hardcoded light-red palette is now derived from
3003
+ `--sk-error` + `--sk-bg` the same way.
3004
+
3005
+ ### Improved: tap targets and keyboard focus
3006
+
3007
+ Time-slot buttons and the slot Confirm button are now ≥44px tall and the
3008
+ submit button ≥48px (mobile tap-target guidance); all widget buttons and links
3009
+ get a visible `:focus-visible` outline in the primary color.
3010
+
3011
+ ## 3.2.1 — 2026-07-13
3012
+
3013
+ Accessibility patch. No API changes, no breaking changes — a drop-in bump for
3014
+ every 3.x site. Recommended for any site using the commerce (events/products),
3015
+ forms, engage, or booking widgets.
3016
+
3017
+ ### Fixed: default status colors now pass WCAG AA contrast (both directions)
3018
+
3019
+ The default `--sk-error`, `--sk-success`, and `--sk-warning` tokens shipped as
3020
+ the Tailwind -500/-600 shades, all of which fail WCAG AA (4.5:1) — both as text
3021
+ on white and as a solid background under white text. Lighthouse flagged this on
3022
+ mahjcincy.com, where the events widget's "N left" urgency badge renders white on
3023
+ `--sk-error`. Because contrast is symmetric, one darker value fixes both uses:
3024
+
3025
+ | Token | Was | Now | Contrast on white |
3026
+ |-------|-----|-----|-------------------|
3027
+ | `--sk-error` | `#ef4444` (red-500) | `#dc2626` (red-600) | 3.76 → **4.83** ✓ |
3028
+ | `--sk-success` | `#059669` (emerald-600) | `#047857` (emerald-700) | 3.77 → **5.48** ✓ |
3029
+ | `--sk-warning` | `#d97706` (amber-600) | `#b45309` (amber-700) | 3.19 → **5.02** ✓ |
3030
+
3031
+ - Updated the canonical defaults in `brand.css`, the `forms/styles.css` mirror,
3032
+ every inline `var(--sk-error, …)` fallback across forms/engage/commerce, the
3033
+ `BookingWidget` inline token root (whose success/warning were the even-lighter
3034
+ -500 shades), and the one hardcoded `#ef4444` in `ManagedForm` (now uses the
3035
+ token). The light-tint error-bg pattern (`--sk-error-bg #fef2f2` + `#dc2626`
3036
+ text) was already correct and is unchanged.
3037
+ - This only moves the **defaults**. Any site that sets its own `--sk-error`
3038
+ (etc.) is unaffected. Sites can't pick up the fix without re-installing, so as
3039
+ an interim they may override `--sk-error: #dc2626` in their own `:root`.
3040
+ - New `src/brand/status-contrast.test.ts` (vitest) reads the real CSS defaults
3041
+ and every inline status fallback in `src/` and asserts each clears 4.5:1 on
3042
+ white — with a teeth self-check proving it catches the old failing values. The
3043
+ integration axe harness now renders an error/success/warning swatch block on
3044
+ the `/form` fixture route (real-browser contrast) plus a teeth check that flips
3045
+ the tokens back to the -500s and confirms axe reports `color-contrast`.
3046
+
3047
+ ## 3.2.0 — 2026-07-13
3048
+
3049
+ Transport, trust, and tooling release. No breaking changes; recommended target for every 2.x/3.0.x site. (Supersedes unpublished 3.0.6/3.1.0 work.)
3050
+
3051
+ ### New: agent-native CLI — the site-kit toolchain is now driveable by coding agents
3052
+
3053
+ `sonor-setup` is built to be driven by a coding agent (Claude Code et al.), not
3054
+ just a human reading `README.md`. North star: an agent takes a bare Next.js repo
3055
+ → a fully wired, **verified-green** Sonor site with zero human intervention. See
3056
+ `docs/SITE-KIT-AGENT-NATIVE.md` and the new shipped `AGENTS.md`.
3057
+
3058
+ - **Machine-readable everything.** New shared output layer (`src/cli/agent/`):
3059
+ every agent-native command supports `--json`, emitting exactly one stable
3060
+ envelope (`schemaVersion: 1`) to stdout — all human logs go to stderr, so
3061
+ stdout is always `JSON.parse`-clean. Frozen exit-code set (`0` OK · `1` FAILED
3062
+ · `2` USAGE · `3` CONFIG · `4` NETWORK · `5` INTERNAL). Wired on `verify`,
3063
+ `doctor`, `status`, `codemod`, `manifest`, `init`, `install`, `upgrade`.
3064
+ - **Never hangs an agent.** When `--json`, `--yes`, or a non-TTY is detected, no
3065
+ command creates an interactive prompt — a command that needs input exits `3`
3066
+ with an actionable `fix` naming the exact flag. `init` runs fully
3067
+ non-interactive with `--api-key … --yes`.
3068
+ - **`verify` — the definition of done.** New command composes static health + a
3069
+ key-validity ping + a **post-build SSR check** (asserts the built/live page
3070
+ server-renders real content instead of shipping an empty shell + RSC flight
3071
+ data — the #1 fleet perf trap). Exit 0 only when the integration is genuinely
3072
+ green. `--url <url>` checks a live/preview deploy; `--offline` skips the ping.
3073
+ - **`doctor` — fast, offline health** with stable check ids (`env.api-key`,
3074
+ `layout.sitekit`, `ssr.render`, `middleware.netlify`, `key.valid`, …), each
3075
+ carrying a `fix`/`fixCommand`. `status` is the same engine for humans. All
3076
+ check logic is consolidated in one library (`src/cli/agent/checks.ts`) — no
3077
+ more forked copies (the old `status.ts` hand-rolled them; a broken WIP
3078
+ `doctor.ts` imported non-exported helpers — both replaced).
3079
+ - **`codemod` — deterministic 2.x→3.x transforms.** Offline, idempotent,
3080
+ minimal-diff. Dry-run by default; `--write` applies (with `.bak`); `--check`
3081
+ is the CI/agent gate (exit 1 if pending). Transforms: `provider-to-layout`
3082
+ (SiteKitProvider→SiteKitLayout), `uptrade-to-sonor` (package/env/token
3083
+ remnants; flags `uptrade_` key *values* it can't mint a replacement for), and
3084
+ `analytics-sibling` (detects the SSR-bailout wrapper and flags the exact fix
3085
+ rather than risk a blind rewrite).
3086
+ - **The package ships its own agent instructions.** `AGENTS.md`
3087
+ (consumer/agent-facing), `agent-manifest.json` (machine manifest — modules
3088
+ derived from `package.json` exports so it can't drift, plus env contract,
3089
+ blessed patterns, and failure modes), and a Claude Code skill under `skills/`
3090
+ are now in the npm tarball. Read them with `cat node_modules/@sonordev/site-kit/AGENTS.md`
3091
+ or `npx sonor-setup manifest --json` — no web access needed. Contributor
3092
+ branding rules moved to `CONTRIBUTING.md`.
3093
+ - **MCP server: evaluated, deferred.** For agents with a shell (the target),
3094
+ `npx sonor-setup <cmd> --json` already beats an MCP tool on install/config
3095
+ friction and CI parity — recommendation is CLI-first, revisit a thin MCP
3096
+ wrapper only for shell-less agents (rationale in the design doc §9).
3097
+
3098
+ ### New: canonical client transport (`sonorFetch`)
3099
+
3100
+ Every client-side module (analytics, forms, commerce, engage config/telemetry,
3101
+ maps, images, SitemapSync) now routes through one shared transport instead of
3102
+ bare `fetch()`:
3103
+
3104
+ - **Per-attempt timeout + retry with backoff.** A hung request left to the
3105
+ browser's own network timeout logs `net::ERR_TIMED_OUT` and fails the
3106
+ Lighthouse best-practices `errors-in-console` audit (observed in production
3107
+ via SitemapSync). Non-idempotent paths (checkout, payment intents, uploads,
3108
+ page-views) run `retries: 0` — timeout only, never an automatic retry.
3109
+ - **`x-sitekit-version` header on every request.** The platform can now see
3110
+ which kit version each site runs from live traffic — the foundation for
3111
+ fleet visibility.
3112
+ - **Auth circuit breaker.** After a 401/403 the key is paused for 5 minutes
3113
+ with ONE friendly `console.info` — a site on a stale or canceled key no
3114
+ longer spams the API or the visitor's console.
3115
+ - **Beacons keep the key out of URLs.** Unload-time telemetry (session end,
3116
+ scroll depth) previously used `sendBeacon` with `?key=` in the URL — which
3117
+ lands in server/CDN access logs. It now uses keepalive fetch with header
3118
+ auth (`sonorBeacon`).
3119
+ - Deliberately excluded: `engage/ChatWidget` message paths (Echo replies can
3120
+ exceed the transport timeout; will be brought under with tuned limits).
3121
+
3122
+ ### New: canonical cross-repo contracts
3123
+
3124
+ `sites/contract` (host normalization), `seo-pages/contract` (seo_pages row
3125
+ resolution), `portfolio/contract` (Lighthouse KPI display policy), and
3126
+ `forms/contract` (honeypot field) — the logic that previously lived as
3127
+ "keep these in sync" comment-twins across sonor-api, signal-api, and
3128
+ agency-site-kit. Both APIs now import these; they require this release.
3129
+
3130
+ ### New: agent-native CLI engine + integration harness
3131
+
3132
+ All CLI health logic consolidated in `cli/agent/checks.ts` with a stable JSON
3133
+ envelope (`--json` everywhere, exit codes as verdicts, fix commands attached):
3134
+ `doctor` (fast, offline-by-default), `status` (same engine, network on), and
3135
+ `verify` (health + key + SSR = definition of done). The package ships an
3136
+ agent manifest (`agent-manifest.json`) generated at build. A Playwright
3137
+ integration harness (fixture Next app: SSR-integrity, axe, zero-console, and
3138
+ per-entry gzipped bundle budgets) now gates `npm publish`.
3139
+
3140
+ ### New: `@sonordev/site-kit/fleet` — fleet heartbeat (contract v1)
3141
+
3142
+ Fire-and-forget, idle-deferred heartbeat reporting the site's own build
3143
+ fingerprint (kit version, active client modules, Next.js version) so the
3144
+ platform can see what the fleet actually runs. No visitor data; project
3145
+ identity resolves server-side from x-api-key like every public endpoint.
3146
+ `fleet/contract` is the wire contract for the sonor-api ingest side (same
3147
+ pattern as `llms/contract` / `slots/contract`).
3148
+
3149
+ ### New: `sonor-setup doctor`
3150
+
3151
+ Executable health checks with `--json` (stable schema; exit code is the
3152
+ verdict) so agents and CI can consume it: env + API connectivity + **real key
3153
+ validity** (authed endpoint, not `/health`), **SSR integrity** (detects pages
3154
+ that bailed to client rendering — only RSC flight data, no content tags),
3155
+ sitemap, llms.txt, and the Netlify `runtime: 'nodejs'` middleware trap.
3156
+
3157
+ ### New: client auth hardening — minted tokens, domain-binding, per-key limits
3158
+
3159
+ The project API key (`sonor_{uuid8}_{secret}`) is a long-lived secret; shipping
3160
+ it to every visitor's browser (`window.__SITE_KIT_API_KEY__`) let anyone scrape
3161
+ it and reuse it anywhere, forever — the form-spam incident. Three layered,
3162
+ backward-compatible changes (2.x sites keep sending raw keys forever):
3163
+
3164
+ - **Minted tokens.** `SiteKitLayout` (a server component) now mints a
3165
+ short-lived (~1h), project-scoped HMAC token server-to-server and injects
3166
+ THAT instead of the raw key. `sonorFetch` refreshes a stale/expired token
3167
+ transparently (a static page's seed can be days old on first view) and
3168
+ retries once on a 401. If minting is unavailable (older API, transient
3169
+ outage) the layout falls back to the raw key — the build never breaks. New
3170
+ endpoints on api.sonor.io: `POST /api/public/site-token` (mint, server-side),
3171
+ `POST /api/public/site-token/refresh` (browser, domain-bound). Guards on both
3172
+ APIs accept a token OR a raw key.
3173
+ - **Domain-binding.** A browser-originated credential is checked against the
3174
+ project's registered domains (`projects.domain` + settings + the multi-site
3175
+ `site` hosts). Server-to-server calls (no Origin) are unaffected, so SSR keeps
3176
+ working. Rolls out log-only; enforced per project via
3177
+ `settings.site_auth.enforce_domain_binding` once its domains are verified. Not
3178
+ a hard wall (Origin is forgeable by non-browser clients) — it stops casual
3179
+ cross-origin reuse and pairs with the token + rate-limit layers.
3180
+ - **Per-key rate limits.** Token mint/refresh and the AI/widget endpoints are
3181
+ throttled per project credential (not per IP), so a distributed bot on one
3182
+ site's key hits one bucket. Signal API's global throttler is now key-scoped.
3183
+ - **Incident response.** Bumping `settings.site_auth.token_version` instantly
3184
+ invalidates every outstanding token for a project; deactivating a key kills
3185
+ its tokens.
3186
+ - **doctor:** adds `Token minting` + `Key is domain-bound` online checks.
3187
+
3188
+ Set `SITE_TOKEN_SECRET` (same value on api + signal) to enable minting; unset,
3189
+ the kit transparently keeps injecting the raw key. See `docs/CLIENT-AUTH.md` for
3190
+ the endpoint contracts and rollout runbook.
3191
+
3192
+ ### Fixed
3193
+
3194
+ - **Forms: honeypot input hidden from assistive technology.** The spam
3195
+ honeypot (`_hp_field`) had no `aria-hidden`, so screen readers announced an
3196
+ unlabeled input and Lighthouse failed the accessibility `label` audit on any
3197
+ page with a managed form. Now `aria-hidden="true"`.
3198
+ - **Default brand color now passes WCAG AA color-contrast.** `--sk-primary`
3199
+ (and the mirrored `--sk-btn-bg` in `forms/styles.css`) was blue-500
3200
+ `#3b82f6` — only ~3.68:1 against the white button text, a "serious" axe
3201
+ color-contrast violation. Any fleet site that shipped the default without
3202
+ overriding its brand color failed Lighthouse's a11y contrast audit on the
3203
+ submit button. The default is now blue-600 `#2563eb` (~5.2:1, AA), hover
3204
+ blue-700 `#1d4ed8`. Sites that set their own `--sk-primary` are unaffected.
3205
+ The same blue-500 default was swept out of every `var(--sk-primary, …)`
3206
+ fallback and JS brand default across forms/blog/engage so the package-wide
3207
+ default is consistent (commerce already defaulted to `#2563eb`).
3208
+ - **Load-path `console.error` downgraded to `console.warn`** in commerce,
3209
+ maps, and images. Error-level console output from transient API failures
3210
+ fails the best-practices audit; the kit's production paths now stay at
3211
+ warn/info.
3212
+ - `sonor-setup status`: the Sitemap Sync check only recognized legacy
3213
+ `uptrade` postbuild scripts; it now recognizes `sonor` ones.
3214
+
3215
+ ### Internal
3216
+
3217
+ - `fetchWithRetry` moved from `src/forms/` to `src/shared/` — single source of
3218
+ truth for resilient fetch; `sonorFetch` wraps it. Removed maps' third
3219
+ hand-rolled retry implementation.
3220
+ - `src/shared/version.ts` (`SITE_KIT_VERSION`) — asserted against
3221
+ package.json in tests and at publish (verify-dts).
3222
+
3223
+ ## 3.0.0 — 2026-06-18
3224
+
3225
+ **Site-Kit 3.0 — "the site acts."** The 3.0 line begins the shift from
3226
+ site-as-sensor to site-as-actuator (see `docs/SITE-KIT-3.0-VISION.md`). This
3227
+ first release ships the foundation: the Managed Slots primitive, visitor→contact
3228
+ identity binding, and an opt-in edge identity/segment pass. It is **additive
3229
+ over 2.x** for every existing module (SEO, Analytics, Engage, Forms, Blog, CMS,
3230
+ Commerce, …) — upgrading does not change their behavior.
3231
+
3232
+ ### New module: `@sonordev/site-kit/slots` — Managed Slots (Pillar 1 of the 3.0 vision)
3233
+
3234
+ First slice of the 3.0 "the site acts" spine (see `docs/SITE-KIT-3.0-VISION.md` and
3235
+ `docs/SITE-KIT-3.0-SPEC-SLOTS.md`). A slot is a named, Sonor-managed text region
3236
+ inside an element the developer still owns:
3237
+
3238
+ ```tsx
3239
+ <h1 className="hero-title">
3240
+ <ManagedSlot id="home-hero-headline">
3241
+ New Homes in Cincinnati & Northern Kentucky
3242
+ </ManagedSlot>
3243
+ </h1>
3244
+ ```
3245
+
3246
+ No managed content → the static children render, byte-for-byte what the site ships
3247
+ today. Content exists in Sonor (owner edit or approved Signal proposal) → it renders
3248
+ instead, with no deploy.
3249
+
3250
+ Design guarantees, all tested:
3251
+
3252
+ - **Static-safe**: RSC resolution via ISR-cached fetch (`tags: ['sonor-slots']`),
3253
+ no request access, fragment render with zero hydration cost — pages stay `○`.
3254
+ - **Fallback-first**: every failure (missing key, network, HTTP error, 404 while the
3255
+ API endpoint isn't deployed, malformed payload) renders the fallback; slots can
3256
+ never break a build or a page.
3257
+ - **Signed from contract v1**: payloads carry an HMAC-SHA256 signature keyed with the
3258
+ project API key (`slots/contract`, shared with sonor-api like `llms/contract`);
3259
+ site-kit drops anything unsigned or tampered. Plain-text content type only in v1 —
3260
+ rendered escaped, never as HTML.
3261
+ - `createSlotsRevalidateHandler` gives Sonor an on-demand cache-bust webhook so
3262
+ approved changes go live in seconds.
3263
+
3264
+ Resolve requests also report each slot's current static text (`fallback`,
3265
+ sent automatically when ManagedSlot children are a plain string) so the
3266
+ dashboard editor shows the live copy next to the slot id instead of a bare
3267
+ identifier.
3268
+
3269
+ New module is Sonor-only auth (`SONOR_API_KEY`) — 3.0 code does not implement the
3270
+ deprecated `UPTRADE_*` fallbacks. The server side is BUILT: sonor-api `SlotsModule`
3271
+ (`POST /api/public/slots/resolve` + portal CRUD with `checkProjectAccess` tenancy
3272
+ asserts), the `managed_slots` table (applied 2026-06-11), and the dashboard editor
3273
+ (Website module → Text Slots). The June 2026 security remediation landed first;
3274
+ this endpoint follows the hardened pattern.
3275
+
3276
+ Also: vitest now includes `.test.tsx` files (`vitest.config.ts` include pattern).
3277
+
3278
+ ### Forms: visitor identity on submissions (Pillar 5 — identity graph)
3279
+
3280
+ `submitForm` now sends the anonymous visitor id (`_sk_vid`, the same id Analytics
3281
+ and Signal stamp on page views) with each submission, so Sonor can bind the
3282
+ resulting CRM lead to its browsing session — closing the page → lead attribution
3283
+ loop. Additive and silent: no change to the `submitForm` signature or to callers
3284
+ (`<ManagedForm>` / `useForm`). Pairs with the sonor-api side that stores
3285
+ `form_submissions.visitor_id` and stamps `contacts.visitor_id` on routing.
3286
+
3287
+ ### Middleware: opt-in edge identity + visitor segment (the "one edge pass")
3288
+
3289
+ `createMiddleware({ identity: true })` adds an edge identity pass to the existing
3290
+ redirects + security + discovery chain. When enabled it ensures a first-party
3291
+ `_sk_vid` visitor cookie (server-authoritative id) and resolves a coarse visitor
3292
+ segment — new-vs-returning + marketing source (paid / organic / social /
3293
+ referral / direct) + campaign — into a `_sk_seg` cookie, for segment-aware slots
3294
+ and personalization. **Purely additive: it only writes cookies, never changes
3295
+ the rendered HTML, so it stays LCP/static-safe. Default OFF** — existing sites
3296
+ are unaffected until they opt in. New exports from `@sonordev/site-kit/middleware`:
3297
+ `resolveVisitorSegment`, `encodeSegment`, `decodeSegment`, and the `VisitorSegment`
3298
+ / `SegmentSource` types.
3299
+
3300
+ ### Upgrading from 2.x
3301
+
3302
+ This release is additive for every shipped module — a drop-in upgrade. Notes:
3303
+
3304
+ - The long-deprecated `SiteKitProvider` is **not** part of the public exports;
3305
+ use `SiteKitLayout` (RSC-safe), which has been the recommended pattern since 2.x.
3306
+ - 3.0 modules (`slots`, the edge `identity` pass) are **Sonor-only** auth
3307
+ (`SONOR_API_KEY`). The deprecated `UPTRADE_*` fallbacks still work for the
3308
+ older modules so pre-rebrand sites keep building.
3309
+
3310
+ ## 2.9.0
3311
+
3312
+ ### /llms.txt now prerenders statically — no more "Dynamic server usage" build errors
3313
+
3314
+ Every consumer site's `next build` logged a scary (but non-fatal) error:
3315
+
3316
+ ```
3317
+ @sonordev/llms: Error generating llms.txt: Error: Dynamic server usage:
3318
+ Route /llms.txt couldn't be rendered statically because it used `request.headers`.
3319
+ ```
3320
+
3321
+ and the route was demoted to dynamic (`ƒ`), served as a serverless function
3322
+ with no static/CDN caching — for what is effectively a static text file.
3323
+
3324
+ **Cause.** `createLLMsTxtHandler` / `createLLMsFullTxtHandler` read
3325
+ `request.headers.get('if-none-match')` to serve 304s themselves. During
3326
+ prerendering, ANY access to the incoming Request's headers throws
3327
+ `DynamicServerError` and flags the route dynamic — and because the access sat
3328
+ inside the handlers' own `try/catch`, the error was also caught and logged on
3329
+ every build.
3330
+
3331
+ **Fix.** The returned GET handlers no longer touch the incoming `Request` at
3332
+ all (signature is now `(request?: Request) => Promise<Response>`). In-handler
3333
+ `If-None-Match`/304 matching is removed; the weak `ETag` is still emitted and
3334
+ conditional requests are answered by the Next static layer / CDN, which
3335
+ already does this for prerendered responses. With the route opted into
3336
+ prerendering (Next 15+: `export const revalidate = 3600` or
3337
+ `dynamic = 'force-static'` in the route file), `/llms.txt` now builds as
3338
+ static (`○`) and the build-log error is gone. Verified on a Next 16.1.2
3339
+ consumer site: `ƒ /llms.txt` + error before, `○ /llms.txt` + clean log after.
3340
+
3341
+ ### New `baseUrl` option for llms.txt link resolution
3342
+
3343
+ `GenerateLLMSTxtOptions` (and therefore both handler factories and
3344
+ `generateLLMsTxt` / `generateLLMsFullTxt`) accepts an explicit `baseUrl`,
3345
+ matching the sitemap helper's convention. The link base resolves as:
3346
+
3347
+ 1. explicit `baseUrl` option
3348
+ 2. Portal/local `business.website` (existing behavior, unchanged default)
3349
+ 3. `NEXT_PUBLIC_SITE_URL`, then `SITE_URL` env vars
3350
+
3351
+ It is never derived from request headers, so the route stays statically
3352
+ prerenderable. The resolved base is normalized (trailing slashes stripped —
3353
+ this also fixes double-slash links like `https://x.com//about` when
3354
+ `business.website` had a trailing slash), and sections that previously
3355
+ required `business.website` (`## Optional`, the `linkToFullLlms` block, the
3356
+ `**Website:**` header line) now also render when only an env base is
3357
+ available.
3358
+
3359
+ ### Breaking changes
3360
+
3361
+ None for the documented usage (`export const GET = createLLMsTxtHandler()`).
3362
+ Behavioral note: the handlers no longer answer conditional requests with 304
3363
+ themselves — on dynamic deployments that's now handled by the CDN layer (or
3364
+ clients simply get a full 200, which is valid HTTP).
3365
+
3366
+ ### `@sonordev/site-kit/sync` now ships its TypeScript declarations
3367
+
3368
+ The `./sync` subpath (`BookingWidget`) shipped runtime JS but **no `.d.ts`**
3369
+ in 2.7.2 and 2.8.1. `package.json` pointed `exports["./sync"].types` at
3370
+ `./dist/sync/index.d.ts`, but that file was never built — so consumer sites
3371
+ on `moduleResolution: "bundler"` + `strict` hit:
3372
+
3373
+ ```
3374
+ error TS2307: Cannot find module '@sonordev/site-kit/sync'
3375
+ ```
3376
+
3377
+ and had to add an ambient module shim to compile.
3378
+
3379
+ **Cause.** `tsup.config.ts` kept the DTS entry list as a hand-maintained
3380
+ second copy of the main `entry` map, and the two drifted: `sync/index` was
3381
+ in `entry` (so JS built) but missing from `dts.entry` (so no declarations).
3382
+
3383
+ **Fix.** The DTS entry set is now *derived* from the single `entry` map
3384
+ (every entry minus the CLI binaries), so a subpath can never again ship JS
3385
+ without types. The `prepublishOnly` guard was also strengthened — it now
3386
+ verifies **every** `exports[*].types` target exists on disk (via
3387
+ `scripts/verify-dts.cjs`), not just the root `index.d.ts`, so a missing
3388
+ subpath declaration fails the publish instead of slipping through.
3389
+
3390
+ Consumer sites that added a `*/sync.d.ts` ambient shim can delete it once on
3391
+ this version.
3392
+
3393
+ ### Breaking changes
3394
+
3395
+ None. Packaging-only fix — no runtime, API, or export-surface change.
3396
+
3397
+ ## 2.8.1
3398
+
3399
+ ### `@sonordev/site-kit/reputation/server` — RSC-safe entry
3400
+
3401
+ The reputation module's API functions (`fetchReviews`, `fetchReviewStats`)
3402
+ were previously bundled with `TestimonialSection`, which is a Client
3403
+ Component. Because the shared chunk carried a `'use client'` directive,
3404
+ calling the API functions from a React Server Component failed at build
3405
+ time with *"Attempted to call fetchReviews() from the server but
3406
+ fetchReviews is on the client"*.
3407
+
3408
+ 2.9.0 adds a new `./reputation/server` entry point that exports only the
3409
+ data fetchers and types — no client taint — so they can be called from
3410
+ RSC, route handlers, `generateMetadata`, etc.
3411
+
3412
+ ```ts
3413
+ // React Server Component
3414
+ import { fetchReviews, fetchReviewStats } from '@sonordev/site-kit/reputation/server'
3415
+
3416
+ export default async function Page() {
3417
+ const reviews = await fetchReviews({ limit: 6 })
3418
+ // ...
3419
+ }
3420
+
3421
+ // Client Component — unchanged
3422
+ import { TestimonialSection } from '@sonordev/site-kit/reputation'
3423
+ ```
3424
+
3425
+ This matches the existing split on `./seo/server`, `./blog/server`,
3426
+ `./images/server`, and `./commerce/server`.
3427
+
3428
+ ### Breaking changes
3429
+
3430
+ None. The existing `./reputation` entry continues to export
3431
+ `TestimonialSection`, `fetchReviews`, `fetchReviewStats`, and types for
3432
+ backwards compatibility — only the recommended import path for RSC
3433
+ contexts has changed.
3434
+
3435
+ ---
3436
+
3437
+ ## 2.8.0
3438
+
3439
+ ### Multi-site projects — forms + sitemap
3440
+
3441
+ `@sonordev/site-kit` 2.7.0 introduced the `analytics.site` dimension so one
3442
+ Sonor project could host many sub-sites (e.g. the True Power Systems project
3443
+ hosts truepowersystems.com + 16 state-themed microsites) with each event
3444
+ tagged by its host. 2.8.0 extends the same dimension across two more
3445
+ surfaces so the dashboard can scope by sub-site everywhere:
3446
+
3447
+ - **`SitemapSync`** now sends the host (`__SITE_KIT_SITE__`) alongside each
3448
+ registration, tagging every `seo_pages` row with its sub-site. The Sonor
3449
+ dashboard's page-tree sidebar filters by this when the site picker is
3450
+ set.
3451
+ - **`submitForm`** includes `site` in submission metadata so leads are
3452
+ attributed to the originating microsite even after the form definition
3453
+ is moved or merged. Persisted on `form_submissions.site`.
3454
+ - **`formsApi.sync` / `CreateFormInput`** gained an optional `site` field.
3455
+ CLI scripts (`migrate-contact-form.ts`) should derive it from
3456
+ `NEXT_PUBLIC_SITE_URL` host so one Sonor project can host one form per
3457
+ microsite (`ohio-quote → ohiopowerstudies.com`,
3458
+ `georgia-quote → georgiapowerstudies.com`, etc.).
3459
+
3460
+ The Sonor API (`api.sonor.io`) accepts `?site=ohiopowerstudies.com` on
3461
+ every analytics, SEO, and forms read endpoint to scope results. The
3462
+ dashboard's site picker (introduced alongside this release) sets this
3463
+ filter globally per project.
3464
+
3465
+ ### Breaking changes
3466
+
3467
+ None. All new fields are optional — single-site projects continue to work
3468
+ unchanged, and older versions of site-kit can still submit forms / sync
3469
+ sitemaps (the server stores `site = NULL` for those events).
3470
+
3471
+ ---
3472
+
3473
+ ## 2.7.0
3474
+
3475
+ ### Multi-site analytics
3476
+
3477
+ - **`AnalyticsConfig.site`** — new sub-site identifier. One Sonor project
3478
+ can now host many sites (e.g. TPS hosting truepowersystems.com + 16
3479
+ microsites) with every page-view, event, session, scroll, web-vital, and
3480
+ heatmap event tagged by host. Resolved with precedence: explicit
3481
+ `analytics.site` > `NEXT_PUBLIC_SITE_URL` host > `window.location.host`.
3482
+ - **`window.__SITE_KIT_SITE__`** — global set by SiteKitClientProviders,
3483
+ read by every analytics/sitemap surface.
3484
+
3485
+ ### Breaking changes
3486
+
3487
+ None. Sites that don't opt in continue to behave exactly as before.
3488
+
3489
+ ---
3490
+
3491
+ ## 2.4.0
3492
+
3493
+ ### GEO / AEO
3494
+
3495
+ - **LLM GEO contract** (`src/llms/contract.ts`, `LLM_GEO_CONTRACT.md`) — versioned payload rules, `sanitizeLlmsPublicSummary`, `pickManagedLlmSchemaForJsonLd`.
3496
+ - **llms.txt** — optional `optionalPagePaths`, `linkToFullLlms`; page list prefers `llms_public_summary`; `meta.last_updated` in header blockquote when API returns it.
3497
+ - **Handlers** — `llmsResponseHeaders`, weak `ETag`, `stale-while-revalidate`, `If-None-Match` / 304; `GET` handlers receive `Request` (use `export const GET = createLLMsTxtHandler()`).
3498
+ - **`buildAiDiscoveryHeaders`** — opt-in `Link: rel=describedby` for `/llms.txt`.
3499
+ - **Sitemap** — `includeLlmsTxtInSitemap`, `includeLlmsFullTxtInSitemap` (default false).
3500
+ - **`LLMSchema`** — filtered JSON-LD + `isPartOf` → `WebSite` when project `site_url` exists.
3501
+ - **`createWebSiteOrganizationStub`** — optional grounding when Portal does not emit org/site nodes.
3502
+ - **CLI `status`** — optional `llms.txt` smoke check when `NEXT_PUBLIC_SITE_URL` / `SITE_BASE_URL` set.
3503
+ - **SiteKitLayout** — `showLlmsTxtFooterLink` (default false).
3504
+
3505
+ ## 1.3.0
3506
+
3507
+ ### Performance
3508
+
3509
+ - **Suspense-wrapped all async server components** — `ManagedSchema`, `LLMSchema`, `ManagedFAQ`, `ManagedContent`, `ManagedInternalLinks`, `ManagedScripts`, `ManagedNoScripts`, and `LocationPageContent` now stream independently. API fetches no longer block page content from flushing, dramatically improving LCP on pages that use these components. Zero config — works automatically for all sites.
3510
+ - **Parallelized ManagedSchema API calls** — `getSchemaMarkups`, `getSEOPageData`, and `getEntityEnhancedSchema` now run via `Promise.all` instead of sequentially, cutting schema fetch time to the slowest single call.
3511
+ - **Deferred AnalyticsProvider by default** — `AnalyticsProvider` now lazy-loads internally (dynamic import, `ssr: false`) so analytics JS is excluded from the critical hydration path without sites needing `next/dynamic` wrappers.
3512
+
3513
+ ### Breaking Changes
3514
+
3515
+ - None. All changes are backwards-compatible. Sites that already wrap these components in `<Suspense>` will have a harmless double-wrap (no functional impact).
3516
+
3517
+ ---
3518
+
3519
+ ## 1.2.10
3520
+
3521
+ ### Fixes
3522
+
3523
+ - Deferred analytics loading pattern added to `AnalyticsProvider` export
3524
+
3525
+ ---
3526
+
3527
+ ## 1.2.9
3528
+
3529
+ ### Changes
3530
+
3531
+ - Package rename from `@uptrademedia/site-kit` to `@sonordev/site-kit`
3532
+ - Updated all API endpoints from `api.uptrademedia.com` to `api.sonor.io`
3533
+ - Updated CLI commands from `uptrade-*` to `sonor-*`
3534
+
3535
+ ---
3536
+
3537
+ ## 1.2.2 and earlier
3538
+
3539
+ - Legacy versions published under `@uptrademedia/site-kit`