@burdenoff/website-sdk 2026.1101.2 → 2026.1101.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -372,9 +372,16 @@ publishes audio, video or data, and it sends the avatar nothing to say.
372
372
  production nothing else is added, so no preflight has to complete while the
373
373
  page unloads. The server ends sessions on its own deadlines as well.
374
374
  - **Consent copy is versioned.** The panel shows a consent card only when it
375
- holds reviewed copy for exactly the version the server names (the current
376
- English copy is `web-consent-2026-10-31`, and `web-consent-2026-11` stays for
377
- servers that still name it); otherwise the view stays as it was.
375
+ holds reviewed copy for exactly the version the server names; otherwise the
376
+ view stays as it was. `web-consent-2026-11-02` is product-neutral: its card
377
+ reads "a suggested question" on every site, whatever `productName` is, so
378
+ every product's site shows the same words and the server can bind one notice
379
+ digest for all of them (the card's title, intro, processing, storage and
380
+ privacy-link lines, each ending with a line feed: 760 bytes, SHA-256
381
+ `2e02b051ceb1163c14bc58fa63a954a12c0d1f2695029a6d4805922d74c793f7`).
382
+ `web-consent-2026-10-31` (which names the product, "a suggested Healthy Bowl
383
+ question") and `web-consent-2026-11` stay, unchanged, for servers that still
384
+ name them.
378
385
  - **How a session ended.** When the worker leaves, the server closes the room,
379
386
  or the session's time runs out with the room still open, the view says only
380
387
  "The live avatar has ended." (key `finished`). The browser
@@ -439,10 +446,191 @@ Testing hooks (`data-boff-explore`): `avatar-live` (with `data-phase`),
439
446
  `avatar-live-captions`, `avatar-live-countdown`, `avatar-live-sound`,
440
447
  `avatar-live-continue`, `avatar-live-back`, `avatar-live-bot-check`.
441
448
 
449
+ #### Site assistant dock (`SiteAssistantDock`, every product site)
450
+
451
+ `SiteAssistantDock` is the site-wide assistant in one component: the floating
452
+ "Ask AI" button, which opens the assistant panel (`ExplorePanel`) over the
453
+ current page, and that panel, with the site's product scope and its avatar. It
454
+ replaces `<ExploreCta variant="floating" />` in a site's layout. The behaviour
455
+ is HealthyBowl's, as approved there (BOFF-9697, BOFF-9796):
456
+
457
+ - The button is still a real link to `/explore`: a modified click (new tab) and
458
+ a visitor without JavaScript reach the page; a plain click opens the compact
459
+ panel without leaving the page.
460
+ - Expand and Restore keep the same conversation, and so do closing, reopening
461
+ and going to `/explore` and back: the panel and `/explore` share the
462
+ provider's one conversation. Nothing is asked of a server until the first
463
+ opening; the dock starts the provider's conversation then.
464
+ - Escape and the phone's Back go expanded, then compact, then closed, through
465
+ history entries of the panel's own (the same URL, so the router stays put).
466
+ Closing stops the microphone and reading aloud, and focus goes back to the
467
+ button, which carries `aria-controls` and `aria-expanded`.
468
+ - On `/explore` (`explorePath`, matched as the router matches it: ignoring case
469
+ and a trailing slash) there is no button and the panel hides; an open panel
470
+ shows again on the way out.
471
+ - The panel keeps `bottomOffset` clear at the bottom of the window, so it never
472
+ covers the cookie banner's controls, and the floating button sits that much
473
+ higher (`calc(1.25rem + bottomOffset)`), so the banner does not cover the
474
+ button before the visitor answers it. Unset, the button is where it always was.
475
+ - One dock, one panel, one conversation: render it once, in the layout every
476
+ route shares. A second dock mounted while one is there renders nothing, in
477
+ every build (a development build also logs an error); if the first one
478
+ unmounts, the second takes over.
479
+
480
+ It **needs an `ExploreChatProvider` above it**. Without one a development build
481
+ throws `SiteAssistantDock must be rendered under an ExploreChatProvider`, and a
482
+ production build logs that error and renders the plain floating link to
483
+ `/explore` that sites had before the dock.
484
+
485
+ A site adds these lines.
486
+
487
+ 1. **The provider, at the app root**, inside the router and around the routes.
488
+ The dock starts it on the first opening, so `enabled` is for `/explore` only:
489
+
490
+ ```tsx
491
+ import { ExploreChatProvider, normalizeRoutePath } from '@burdenoff/website-sdk'
492
+
493
+ function SiteShell() {
494
+ const { pathname, search } = useLocation()
495
+ const onExplore = normalizeRoutePath(pathname) === '/explore'
496
+ // A shared conversation is /explore?c=<token> and nothing else.
497
+ const shareToken = onExplore ? new URLSearchParams(search).get('c') : null
498
+ const pageUrl =
499
+ typeof window === 'undefined' ? undefined : new URL(`${pathname}${search}`, window.location.origin).href
500
+ return (
501
+ <ExploreChatProvider
502
+ key={shareToken ?? ''}
503
+ enabled={onExplore}
504
+ productSlug={PRODUCT_SLUG}
505
+ locale={ASSISTANT.locale}
506
+ pageUrl={pageUrl}
507
+ shareToken={shareToken}
508
+ >
509
+ <AppRoutes />
510
+ </ExploreChatProvider>
511
+ )
512
+ }
513
+ ```
514
+
515
+ 2. **The config objects** (`src/config/assistant.ts`), built once at module
516
+ scope so the panel's memoised prompt list is not rebuilt on every render:
517
+
518
+ ```ts
519
+ import { websiteAvatarLive } from '@burdenoff/website-sdk'
520
+ import manifest from '@/data/avatar-manifest.<site>.en.json'
521
+
522
+ export const ASSISTANT = {
523
+ productSlug: PRODUCT_SLUG,
524
+ scope: 'product',
525
+ productName: 'Healthy Bowl', // the brand as a reader sees it
526
+ examplePrompts: PROBLEMS.map(({ problem }) => problem), // the questions the manifest lists
527
+ locale: import.meta.env.VITE_DEPLOY_ENV === 'alpha' ? 'en' : undefined,
528
+ } as const
529
+
530
+ export const AVATAR = {
531
+ src: '/avatar-faces/av_stock_001.webp',
532
+ live: websiteAvatarLive({
533
+ deployEnv: import.meta.env.VITE_DEPLOY_ENV,
534
+ siteKey: import.meta.env.VITE_AVATAR_TURNSTILE_SITE_KEY,
535
+ site: '<site>', // as the live avatar service knows it: `healthybowl`
536
+ manifestVersion: manifest.version,
537
+ load: () => import('@burdenoff/website-sdk/avatar-live'),
538
+ }),
539
+ }
540
+ ```
541
+
542
+ 3. **The dock in the layout**, in place of `<ExploreCta variant="floating" />`:
543
+
544
+ ```tsx
545
+ import { SiteAssistantDock } from '@burdenoff/website-sdk'
546
+
547
+ const { pathname } = useLocation()
548
+ const navigate = useNavigate()
549
+
550
+ <SiteAssistantDock
551
+ {...ASSISTANT}
552
+ avatar={AVATAR}
553
+ currentPath={pathname}
554
+ onNavigate={(path) => navigate(path)}
555
+ bottomOffset="var(--hb-cookie-banner-height, 0px)" // YOUR banner's variable, see below
556
+ />
557
+ ```
558
+
559
+ `bottomOffset` must name **the site's own cookie banner's height
560
+ variable**. The SDK sets no such variable and there is no shared name:
561
+ HealthyBowl's banner publishes `--hb-cookie-banner-height`, and another
562
+ site passes whatever its banner publishes. A banner that publishes none
563
+ leaves the panel and the button under it. We recommend every site's banner
564
+ publish its height as a CSS variable on `<html>` while it shows, and remove
565
+ it when it goes:
566
+
567
+ ```tsx
568
+ // In the site's cookie banner component.
569
+ const bannerRef = useRef<HTMLDivElement>(null)
570
+ useLayoutEffect(() => {
571
+ const banner = bannerRef.current
572
+ if (!banner) return
573
+ const root = document.documentElement
574
+ const publish = () =>
575
+ root.style.setProperty('--<site>-cookie-banner-height', `${banner.offsetHeight}px`)
576
+ publish()
577
+ const observer = new ResizeObserver(publish) // wrapping text, a resized window
578
+ observer.observe(banner)
579
+ return () => {
580
+ observer.disconnect()
581
+ root.style.removeProperty('--<site>-cookie-banner-height') // answered: nothing to keep clear
582
+ }
583
+ }, [])
584
+ ```
585
+
586
+ and keep the `, 0px` fallback in `bottomOffset`, so the dock sits at its
587
+ usual place whenever the banner is not on screen.
588
+
589
+ 4. **The build env var** `VITE_AVATAR_TURNSTILE_SITE_KEY`: the site's
590
+ Cloudflare Turnstile site key. `websiteAvatarLive` returns `undefined` (the
591
+ still picture and "Live avatar coming soon", no live avatar) when it is
592
+ unset or empty; on alpha (`VITE_DEPLOY_ENV=alpha`) it accepts Cloudflare's
593
+ always-pass test key `1x00000000000000000000AA` (the alpha gateway runs
594
+ Turnstile in dummy mode) or a real key; everywhere else, production
595
+ included, only a real-looking key (`0x…`, letters, digits, `_` and `-`, 18
596
+ to 64 characters) that is not one of Cloudflare's test keys (`1x…AA`,
597
+ `2x…AB`, `1x…BB`, `2x…BB`, `3x…FF`, or anything of their shape). Install
598
+ `livekit-client` on the site (see [Live avatar](#live-avatar-opt-in-receive-only)),
599
+ and declare the variable in `vite-env.d.ts`.
600
+ `websiteAvatarLive` trusts `VITE_DEPLOY_ENV`, so a production build must
601
+ never be labelled `alpha` (that would let a test key through the check);
602
+ the production gateway's real Turnstile secret would still reject a test
603
+ token, so the live avatar would fail to start rather than run unchecked.
604
+
605
+ 5. **The privacy anchor** `/privacy#live-avatar`: the live avatar's consent
606
+ card links there, so the site's privacy page needs an element with
607
+ `id="live-avatar"` describing the live avatar.
608
+
609
+ 6. **The still picture** `public/avatar-faces/av_stock_001.webp`: the approved
610
+ avatar picture the Avatar view shows (and shows instead of the live avatar
611
+ when it is off), copied from HealthyBowl's website.
612
+
613
+ **`SiteAssistantDock` props**
614
+
615
+ | Prop | Default | What it does |
616
+ |---|---|---|
617
+ | `productSlug`, `scope`, `productName`, `examplePrompts`, `locale` | – | Passed to the panel's chat unchanged. See [One product's own assistant](#one-products-own-assistant). |
618
+ | `avatar` | – | `{ src, name?, live? }`: the Avatar view's still picture, and `live` (from `websiteAvatarLive`) to opt in to the live avatar. Absent: no Avatar view. |
619
+ | `onNavigate` | – | The router's navigate, for links in the conversation. |
620
+ | `bottomOffset` | `0px` | A CSS length kept clear at the bottom: the site's own cookie banner's height variable (HealthyBowl: `var(--hb-cookie-banner-height, 0px)`). The panel keeps it clear and the floating button sits that much higher. |
621
+ | `className` | – | Extra classes for the floating button. |
622
+ | `panelClassName` | – | Extra classes for the panel. |
623
+ | `currentPath` | `window.location.pathname` | The router's path. Pass it: the fallback cannot see `pushState` navigations. |
624
+ | `explorePath` | `/explore` | The button's link, the route the dock hides on, and the panel's share links. |
625
+ | `label` | `Ask AI` | The button's label and accessible name. |
626
+ | `id` | `boff-explore-panel` | The panel's id, for the button's `aria-controls`. |
627
+ | `zIndex` | `50` | The panel's `z-index`. |
628
+
442
629
  **`ExploreCta` props**: `variant` (`'header' \| 'mobile' \| 'floating'`, required),
443
630
  `href` (default `/explore`), `label` (default `Ask AI`), `className`,
444
- `onNavigate`, `onClick`, `currentPath`. The `floating` variant hides itself on
445
- `/explore`. Pass `currentPath` from your router — the fallback
631
+ `onNavigate`, `onClick`, `currentPath`, and, for `floating` only,
632
+ `bottomOffset` (a CSS length to lift the bubble by, as on the dock). The
633
+ `floating` variant hides itself on `/explore`. Pass `currentPath` from your router — the fallback
446
634
  (`window.location.pathname` + `popstate`) cannot observe SPA `pushState`
447
635
  navigations.
448
636
 
@@ -474,6 +474,21 @@ var CONSENT = /* @__PURE__ */ new Map([
474
474
  privacyLink: "How we handle this"
475
475
  }
476
476
  }
477
+ ],
478
+ [
479
+ // The words of web-consent-2026-10-31 with no product name in them: one notice, byte for byte, on every product's
480
+ // site, because the server binds one consent version and one notice digest for all of them.
481
+ "web-consent-2026-11-02",
482
+ {
483
+ en: {
484
+ title: "Live AI avatar (beta)",
485
+ introProduct: null,
486
+ intro: "This is an AI avatar, not a real person. It reads the assistant's first eligible answer to a suggested question aloud for up to a minute. The answer is AI-generated and can be wrong.",
487
+ processing: "When you start, Cloudflare checks for bots. The answer text goes to Google to create the voice. Our server sends that voice through LiveKit to Protoface, which makes the avatar video streamed through LiveKit. Your browser connects to LiveKit, which sees your IP address.",
488
+ storage: "Your microphone and camera are not used. We do not record the live session or store the generated voice. We keep a record of this consent. The privacy notice explains our safeguards, storage periods and the providers' processing. For people aged eighteen and over.",
489
+ privacyLink: "How we handle this"
490
+ }
491
+ }
477
492
  ]
478
493
  ]);
479
494
  function avatarConsentCopy(version, locale) {
@@ -1229,7 +1244,7 @@ function AvatarLiveView({
1229
1244
  className: "w-full max-w-sm rounded-lg border border-border p-4 text-start",
1230
1245
  children: [
1231
1246
  /* @__PURE__ */ jsxRuntime.jsx("h2", { id: consentTitleId, className: "text-base font-semibold", children: copy.title }),
1232
- /* @__PURE__ */ jsxRuntime.jsx("p", { className: "mt-2 text-sm", children: productName === null ? copy.intro : chunkO4GS3XPZ_js.fill(copy.introProduct, { product: productName }) }),
1247
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "mt-2 text-sm", children: productName === null || copy.introProduct === null ? copy.intro : chunkO4GS3XPZ_js.fill(copy.introProduct, { product: productName }) }),
1233
1248
  /* @__PURE__ */ jsxRuntime.jsx("p", { className: "mt-2 text-sm text-muted-foreground", children: copy.processing }),
1234
1249
  /* @__PURE__ */ jsxRuntime.jsx("p", { className: "mt-2 text-sm text-muted-foreground", children: copy.storage }),
1235
1250
  /* @__PURE__ */ jsxRuntime.jsxs(