@unitninety/site-kit 0.0.0-stage → 1.6.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 (206) hide show
  1. package/README.md +30 -2
  2. package/package.json +79 -4
  3. package/src/bridge/DraftBlocks.tsx +70 -0
  4. package/src/bridge/DraftEntries.tsx +41 -0
  5. package/src/bridge/PreviewCommitBoundary.tsx +19 -0
  6. package/src/bridge/U90Bridge.tsx +54 -0
  7. package/src/bridge/caret.ts +317 -0
  8. package/src/bridge/client.tsx +66 -0
  9. package/src/bridge/createBridge.ts +1015 -0
  10. package/src/bridge/hooks.tsx +56 -0
  11. package/src/bridge/index.ts +9 -0
  12. package/src/bridge/lexical.ts +177 -0
  13. package/src/bridge/lexicalDom.ts +212 -0
  14. package/src/bridge/preserveBlockMoves.ts +88 -0
  15. package/src/bridge/preview.ts +179 -0
  16. package/src/bridge/protocol.ts +273 -0
  17. package/src/bridge/registry.ts +30 -0
  18. package/src/bridge/styles.ts +137 -0
  19. package/src/components/DateText.tsx +9 -0
  20. package/src/components/Entries.tsx +16 -0
  21. package/src/components/File.tsx +9 -0
  22. package/src/components/Image.tsx +30 -0
  23. package/src/components/Link.tsx +24 -0
  24. package/src/components/NumberText.tsx +9 -0
  25. package/src/components/RichText.tsx +47 -0
  26. package/src/components/Text.tsx +15 -0
  27. package/src/components/Video.tsx +12 -0
  28. package/src/components/VideoPlayback.tsx +19 -0
  29. package/src/components/draft.ts +9 -0
  30. package/src/components/imageProps.ts +26 -0
  31. package/src/components/index.ts +13 -0
  32. package/src/components/videoProps.ts +6 -0
  33. package/src/elements/containers/group.ts +33 -0
  34. package/src/elements/containers/list.ts +31 -0
  35. package/src/elements/containers/zone.ts +41 -0
  36. package/src/elements/declaration.ts +118 -0
  37. package/src/elements/define.ts +195 -0
  38. package/src/elements/ids.ts +50 -0
  39. package/src/elements/index.ts +35 -0
  40. package/src/elements/jsonSchema.ts +36 -0
  41. package/src/elements/leaf.ts +98 -0
  42. package/src/elements/leaves/boolean.ts +30 -0
  43. package/src/elements/leaves/choice.ts +53 -0
  44. package/src/elements/leaves/date.ts +39 -0
  45. package/src/elements/leaves/file.ts +56 -0
  46. package/src/elements/leaves/image.ts +160 -0
  47. package/src/elements/leaves/link.ts +149 -0
  48. package/src/elements/leaves/number.ts +52 -0
  49. package/src/elements/leaves/query.ts +101 -0
  50. package/src/elements/leaves/relation.ts +69 -0
  51. package/src/elements/leaves/rich-text.ts +155 -0
  52. package/src/elements/leaves/selection.ts +40 -0
  53. package/src/elements/leaves/single.ts +29 -0
  54. package/src/elements/leaves/text.ts +103 -0
  55. package/src/elements/leaves/video.ts +108 -0
  56. package/src/elements/ops/expand.ts +94 -0
  57. package/src/elements/ops/overlay.ts +59 -0
  58. package/src/elements/ops/refs.ts +45 -0
  59. package/src/elements/ops/seed.ts +185 -0
  60. package/src/elements/ops/text.ts +20 -0
  61. package/src/elements/ops/translatable.ts +19 -0
  62. package/src/elements/ops/upgrade.ts +43 -0
  63. package/src/elements/ops/validate.ts +56 -0
  64. package/src/elements/order.ts +10 -0
  65. package/src/elements/path.ts +43 -0
  66. package/src/elements/prefix.ts +2 -0
  67. package/src/elements/registry.ts +48 -0
  68. package/src/elements/tokens.ts +14 -0
  69. package/src/elements/walk.ts +42 -0
  70. package/src/features/client/AskAiProvider.tsx +551 -0
  71. package/src/features/client/AskAiWidget.tsx +222 -0
  72. package/src/features/client/ConsentRuntime.tsx +95 -0
  73. package/src/features/client/FeedbackProvider.tsx +231 -0
  74. package/src/features/client/FeedbackWidget.tsx +101 -0
  75. package/src/features/client/PageReporter.tsx +26 -0
  76. package/src/features/client/README.md +131 -0
  77. package/src/features/client/SiteFeatures.tsx +250 -0
  78. package/src/features/client/askAiRecovery.ts +92 -0
  79. package/src/features/client/askAiStream.ts +59 -0
  80. package/src/features/client/context.tsx +24 -0
  81. package/src/features/client/index.ts +24 -0
  82. package/src/features/client/overlay.tsx +129 -0
  83. package/src/features/client/styles.css +337 -0
  84. package/src/features/client/transport.ts +310 -0
  85. package/src/features/client/useAskAI.ts +45 -0
  86. package/src/features/client/useConsent.ts +15 -0
  87. package/src/features/client/useFeedback.ts +33 -0
  88. package/src/features/client/useSiteSearch.ts +50 -0
  89. package/src/features/client/verification.ts +141 -0
  90. package/src/features/consent/README.md +70 -0
  91. package/src/features/consent/cookieScript.ts +53 -0
  92. package/src/features/consent/custom.ts +89 -0
  93. package/src/features/consent/defaults.ts +40 -0
  94. package/src/features/consent/drivers.ts +233 -0
  95. package/src/features/consent/loader.ts +248 -0
  96. package/src/features/consent/registry.ts +398 -0
  97. package/src/features/contracts/askAi.ts +166 -0
  98. package/src/features/contracts/askAiSettings.ts +42 -0
  99. package/src/features/contracts/base.ts +107 -0
  100. package/src/features/contracts/consent.ts +150 -0
  101. package/src/features/contracts/feedback.ts +22 -0
  102. package/src/features/contracts/feedbackSettings.ts +49 -0
  103. package/src/features/contracts/index.ts +61 -0
  104. package/src/features/contracts/knowledge.ts +99 -0
  105. package/src/features/contracts/knowledgePolicy.ts +249 -0
  106. package/src/features/contracts/search.ts +24 -0
  107. package/src/features/contracts/verification.ts +52 -0
  108. package/src/features/contracts/verificationConfig.ts +9 -0
  109. package/src/features/server/ConsentDefaults.tsx +15 -0
  110. package/src/features/server/README.md +185 -0
  111. package/src/features/server/gateway.ts +318 -0
  112. package/src/features/server/http.ts +179 -0
  113. package/src/features/server/index.ts +6 -0
  114. package/src/features/server/session.ts +23 -0
  115. package/src/features/server/verification.ts +39 -0
  116. package/src/features/server/verifyVisitor.ts +69 -0
  117. package/src/languages/config.d.mts +5 -0
  118. package/src/languages/config.mjs +53 -0
  119. package/src/languages/decide.ts +72 -0
  120. package/src/languages/index.ts +4 -0
  121. package/src/languages/proxy.ts +108 -0
  122. package/src/languages/routing.ts +93 -0
  123. package/src/languages/wire.ts +19 -0
  124. package/src/legacy/bridge.tsx +1987 -0
  125. package/src/legacy/caret.ts +246 -0
  126. package/src/legacy/cli.mjs +189 -0
  127. package/src/legacy/content.ts +993 -0
  128. package/src/legacy/contract.ts +664 -0
  129. package/src/legacy/defineBlocks.ts +1602 -0
  130. package/src/legacy/defineSite.ts +623 -0
  131. package/src/legacy/defineSite.types.ts +270 -0
  132. package/src/legacy/draft.tsx +128 -0
  133. package/src/legacy/image.ts +433 -0
  134. package/src/legacy/index.ts +209 -0
  135. package/src/legacy/language-switcher.tsx +58 -0
  136. package/src/legacy/languageHints.ts +153 -0
  137. package/src/legacy/languages/fields.ts +131 -0
  138. package/src/legacy/languages/hash.ts +98 -0
  139. package/src/legacy/languages/health.ts +97 -0
  140. package/src/legacy/languages/identity.ts +247 -0
  141. package/src/legacy/languages/links.ts +81 -0
  142. package/src/legacy/languages/resolution.ts +56 -0
  143. package/src/legacy/languages/routing.ts +169 -0
  144. package/src/legacy/languages/runtime.ts +216 -0
  145. package/src/legacy/languages/seo.ts +95 -0
  146. package/src/legacy/languages/settings.ts +211 -0
  147. package/src/legacy/languages/types.ts +85 -0
  148. package/src/legacy/languages.ts +8 -0
  149. package/src/legacy/lexical.ts +170 -0
  150. package/src/legacy/lexicalDom.ts +166 -0
  151. package/src/legacy/media.ts +272 -0
  152. package/src/legacy/nav.ts +176 -0
  153. package/src/legacy/preview.ts +79 -0
  154. package/src/legacy/redirects.ts +474 -0
  155. package/src/legacy/richText.tsx +104 -0
  156. package/src/legacy/routes.ts +414 -0
  157. package/src/legacy/seo.ts +327 -0
  158. package/src/legacy/shape.ts +412 -0
  159. package/src/legacy/uploads.ts +233 -0
  160. package/src/legacy/verification.ts +42 -0
  161. package/src/preview-lock/contracts.ts +42 -0
  162. package/src/preview-lock/index.ts +3 -0
  163. package/src/preview-lock/screen.tsx +244 -0
  164. package/src/preview-lock/server.ts +416 -0
  165. package/src/redirects/config.d.mts +5 -0
  166. package/src/redirects/config.mjs +22 -0
  167. package/src/redirects/index.ts +3 -0
  168. package/src/redirects/location.ts +31 -0
  169. package/src/redirects/match.ts +199 -0
  170. package/src/redirects/proxy.ts +91 -0
  171. package/src/redirects/wire.ts +30 -0
  172. package/src/seo/jsonLd.ts +70 -0
  173. package/src/seo/metadata.ts +127 -0
  174. package/src/seo/robots.ts +14 -0
  175. package/src/seo/sitemap.ts +22 -0
  176. package/src/seo/title.ts +22 -0
  177. package/src/server/adapters.ts +73 -0
  178. package/src/server/createU90.ts +96 -0
  179. package/src/server/handlers.ts +164 -0
  180. package/src/server/index.ts +6 -0
  181. package/src/server/read.ts +207 -0
  182. package/src/server/render.tsx +46 -0
  183. package/src/server/routes.tsx +171 -0
  184. package/src/site/assemble.ts +109 -0
  185. package/src/site/defineSite.ts +132 -0
  186. package/src/site/documents.ts +18 -0
  187. package/src/site/fields.ts +59 -0
  188. package/src/site/index.ts +14 -0
  189. package/src/site/replies.ts +118 -0
  190. package/src/site/store.ts +45 -0
  191. package/src/site/types.ts +75 -0
  192. package/src/site/version.ts +1 -0
  193. package/src/site/wire.ts +125 -0
  194. package/src/switcher/LanguageSwitcher.tsx +42 -0
  195. package/src/test-preview/README.md +90 -0
  196. package/src/test-preview/binding.ts +54 -0
  197. package/src/test-preview/bridge.ts +16 -0
  198. package/src/test-preview/guard.ts +186 -0
  199. package/src/test-preview/media.ts +23 -0
  200. package/src/test-preview/protocol.d.mts +67 -0
  201. package/src/test-preview/protocol.mjs +222 -0
  202. package/src/test-preview/runtime.ts +168 -0
  203. package/src/test-preview/server.ts +10 -0
  204. package/src/testing/fake.ts +148 -0
  205. package/src/testing/index.ts +2 -0
  206. package/src/testing/seed.ts +2 -0
@@ -0,0 +1,131 @@
1
+ # Shared website features in Next
2
+
3
+ The `@unitninety/site-kit/features` entry point targets Next 16.3.8–16.x and React 19.
4
+ It uses Next's committed pathname updates; wrap `SiteFeatures` around the root layout's
5
+ children from the first render, mount the server `ConsentDefaults` once before it, and import `@unitninety/site-kit/features/styles.css`. The internal pathname
6
+ reporter has its own Suspense boundary. Pure `features/contracts` has no Next dependency.
7
+ No non-Next runtime support is claimed. The package's Next peer is optional for consumers
8
+ using only contracts or server APIs, but required for the feature client entry point.
9
+
10
+ With the same-origin gateway installed, enabling Feedback in U90 renders the complete
11
+ stock widget. No form declaration or site-specific payload mapping is required. Override
12
+ `components.Feedback` with a stable React component to supply complete custom markup.
13
+ Both presentations consume normalized `config.feedback` options (fallback:
14
+ `FeedbackWidgetSettingsSchema.parse({})`), including labels and question visibility.
15
+ The default comment label is **Tell us more**.
16
+
17
+ ```tsx
18
+ import { FeatureDialog, useFeedback } from '@unitninety/site-kit/features'
19
+ function MyFeedback() {
20
+ const { launcherRef, ...feedback } = useFeedback()
21
+ // Local answer fields belong inside this registered component.
22
+ return (
23
+ <>
24
+ <button ref={launcherRef} onClick={feedback.show}>
25
+ Feedback
26
+ </button>
27
+ <FeatureDialog name="feedback" label="Feedback">
28
+ <p>Feedback for {feedback.page?.path}</p>
29
+ {/* Render composition when idle; explicit retry/new actions on error. */}
30
+ </FeatureDialog>
31
+ </>
32
+ )
33
+ }
34
+ ```
35
+
36
+ `useFeedback(): FeedbackController` returns `open`, `status` (`idle | submitting |
37
+ success | error`), safe `error`, `page` and `currentPage` (`Readonly<PageContext> | null`),
38
+ `show()`, `hide()`, `submit({ rating?: 1|2|3|4|5, comment?: string }): Promise<void>`,
39
+ `retry(): Promise<void>`, `startNew(): void`, and `launcherRef: RefCallback<HTMLButtonElement>`.
40
+ Attach the ref to the presentation's single actual launcher. It restores focus to the
41
+ current launcher if navigation replaced the original one. Switching panels preserves
42
+ the original overlay's focus identity.
43
+
44
+ The root owns submission state; every hook consumer joins the same pending attempt.
45
+ Each valid intentional send receives one request ID and captures normalized answers,
46
+ pathname and an optional bounded title. Invalid/blank answers require no session or proof.
47
+ An uncertain result retains that exact attempt. `retry()` resends the same ID/page/answers;
48
+ `submit()` in that state is also a retry and **ignores changed arguments**. Never label a
49
+ new action “Retry”. `startNew()` explicitly abandons the old attempt and clears local draft;
50
+ it is inert while a request is pending. Successful sends remain inert until startNew or
51
+ pathname navigation. A browser abort cannot roll back a write that reached the server.
52
+
53
+ Committed pathname changes remount **only** the registered Feedback presentation, clearing
54
+ ordinary React draft state. Root children, Ask AI, coalesced session/proof and pending or
55
+ uncertain Feedback snapshots survive. Show the captured `page` while pending/retrying;
56
+ `currentPage` may differ after navigation. Query/hash/title-only changes do not clear drafts.
57
+ Do not keep draft state in module singletons, sessionStorage or above the registered
58
+ component: the boundary cannot reset external state. Hiding a panel does not cancel sending.
59
+ Disabling Feedback clears unsent draft and cancels only its operation; re-enabling never
60
+ automatically sends. Endpoint replacement/unmount discards that authority's state.
61
+
62
+ Feedback uses the configured runtime endpoint and canonical `FeedbackInput`. Each new logical
63
+ submission obtains a signed action grant, then sends the body with that grant. Public
64
+ verification configuration is refreshed before proof acquisition. Saved Disabled mode returns
65
+ `null` and skips provider scripts, widgets and injected browser adapters; the platform still
66
+ checks its saved mode and issues the same signed authority. Default and custom providers fail
67
+ closed on missing setup or failed checks. Proof, grants, tenant details and credentials never
68
+ belong in answer fields.
69
+
70
+ Interactive proof acquisition has a 120-second deadline; each HTTP exchange is bounded to ten
71
+ seconds. Cancellation cannot cancel another feature's shared session wait. Known response
72
+ fields are validated and response bodies are bounded before success. Hidden questions are
73
+ omitted at first capture; later settings changes cannot alter a retry.
74
+
75
+ The scoped theme variables are `--u90-feature-background`, `--u90-feature-foreground`,
76
+ `--u90-feature-accent`, `--u90-feature-radius`, and `--u90-feature-shadow`.
77
+
78
+ See [consent setup](../consent/README.md) for the server root bootstrap, optional custom registration, remote authority, lifecycle limits and preferences hook. Consent does not reset the Feedback presentation, session or focus lifetime.
79
+
80
+ ## Ask AI stock setup and remix
81
+
82
+ Use the same root `SiteFeatures`, same-origin gateway, verification adapter and stylesheet
83
+ setup above. Ask AI starts disabled. In U90, a manager connects a tenant-owned provider
84
+ key, explicitly chooses an available model and allowances, selects eligible published
85
+ knowledge (published FAQs can stand alone), and publishes the generation configuration.
86
+ Enable Ask AI in feature settings to show the complete stock widget. Provider keys stay
87
+ server-only; the browser never supplies a key, model or private prompt. See the
88
+ [management contract](../../../../../docs/superpowers/specs/2026-10-05-ask-ai-management.md)
89
+ for permissions, configuration and activation boundaries.
90
+
91
+ For custom markup, register a stable component through `components.AskAI` in your existing
92
+ client root wrapper. This example keeps the shared accessible conversation content:
93
+
94
+ ```tsx
95
+ 'use client'
96
+ import type { SiteFeaturesProps } from '@unitninety/site-kit/features'
97
+ import { AskAiContent, FeatureDialog, SiteFeatures, useAskAI } from '@unitninety/site-kit/features'
98
+
99
+ function MyAskAI() {
100
+ const ai = useAskAI()
101
+ return (
102
+ <>
103
+ <button ref={ai.launcherRef} onClick={ai.show}>
104
+ {ai.settings.launcherLabel}
105
+ </button>
106
+ <FeatureDialog name="askAi" label={ai.settings.title}>
107
+ <AskAiContent controller={ai} />
108
+ </FeatureDialog>
109
+ </>
110
+ )
111
+ }
112
+
113
+ export function Features(props: SiteFeaturesProps) {
114
+ return <SiteFeatures {...props} components={{ ...props.components, AskAI: MyAskAI }} />
115
+ }
116
+ ```
117
+
118
+ Keep this root mounted across page navigation: it owns conversation, draft, session and
119
+ pending-request state even when the presentation closes or remounts. Endpoint replacement
120
+ or root unmount discards that authority's state. `useAskAI()` supports a complete remix;
121
+ `AskAiContent` is optional. Honor remote copy/settings and deliberately document any brand
122
+ colours or corners that your remix overrides; the stock widget honors saved appearance.
123
+
124
+ For an uncertain send, `retry()` reuses the canonical request ID and captured body.
125
+ `retryAnswer()` after a confirmed terminal failure is an explicit **new attempt** with a
126
+ new ID and may incur a new charge; never label it as a same-request retry. Recovery uses
127
+ GET and never generates an answer. Session storage persists only a known conversation ID,
128
+ scoped by endpoint: no transcript, draft, request IDs, tokens or cursors. Refresh before the
129
+ first receipt can lose recovery correlation. See the
130
+ [widget contract](../../../../../docs/superpowers/specs/2026-10-05-ask-ai-widgets.md)
131
+ for full hook, recovery, stop, focus and lifecycle behavior.
@@ -0,0 +1,250 @@
1
+ 'use client'
2
+ import {
3
+ lazy,
4
+ Suspense,
5
+ useCallback,
6
+ useEffect,
7
+ useLayoutEffect,
8
+ useMemo,
9
+ useRef,
10
+ useState,
11
+ type ComponentType,
12
+ type ReactNode,
13
+ } from 'react'
14
+ import type { FeedbackInput, PublicFeatureBase } from '../contracts'
15
+ import { FeatureContext, type OverlayName } from './context'
16
+ import { AskAiProvider } from './AskAiProvider'
17
+ import { FeedbackProvider } from './FeedbackProvider'
18
+ import { ConsentRuntime } from './ConsentRuntime'
19
+ import type { CustomConsentRegistration } from '../consent/custom'
20
+ import { acquireVerificationProof } from './verification'
21
+ import type { VerificationAction } from '../contracts'
22
+ import { createFeatureTransport, waitForSignal, type VisitorVerification } from './transport'
23
+
24
+ const builtinLoaders: Partial<Record<OverlayName, () => Promise<{ default: ComponentType }>>> = {
25
+ feedback: () => import('./FeedbackWidget'),
26
+ askAi: () => import('./AskAiWidget'),
27
+ }
28
+ const builtins = Object.fromEntries(
29
+ Object.entries(builtinLoaders).map(([key, load]) => [key, lazy(load)]),
30
+ ) as Partial<Record<OverlayName, ComponentType>>
31
+ export type SiteFeaturesProps = {
32
+ endpoint?: string
33
+ components?: { AskAI?: ComponentType; Feedback?: ComponentType }
34
+ verification?: VisitorVerification
35
+ customConsent?: CustomConsentRegistration
36
+ children?: ReactNode
37
+ }
38
+ export function SiteFeatures(props: SiteFeaturesProps) {
39
+ // A changed endpoint is a different authority and must not retain its configuration/panel.
40
+ return <Runtime key={props.endpoint ?? '/api/u90/features/v1'} {...props} />
41
+ }
42
+ function Runtime({
43
+ endpoint,
44
+ components,
45
+ verification,
46
+ customConsent,
47
+ children,
48
+ }: SiteFeaturesProps) {
49
+ const adapter = useRef(verification)
50
+ useLayoutEffect(() => {
51
+ adapter.current = verification
52
+ }, [verification])
53
+ const transport = useRef<ReturnType<typeof createFeatureTransport> | null>(null)
54
+ const acquireProof = useCallback(
55
+ async (options: { signal: AbortSignal; action?: VerificationAction }) => {
56
+ if (adapter.current) return adapter.current.acquireProof(options)
57
+ // Refresh public connection configuration at the action boundary, so a saved
58
+ // provider replacement uses its new key even before the background refresh.
59
+ const config = await transport.current!.config(options.signal)
60
+ if (!config.verification) throw new Error('Verification unavailable')
61
+ return acquireVerificationProof(
62
+ config.verification,
63
+ options.action ?? 'ask_ai_session',
64
+ options,
65
+ )
66
+ },
67
+ [],
68
+ )
69
+ // Create the transport only from an effect or event. The root survives adapter
70
+ // updates, so an in-flight logical session attempt remains coalesced.
71
+ const getTransport = useCallback(() => {
72
+ if (!transport.current)
73
+ transport.current = createFeatureTransport({ endpoint, verification: { acquireProof } })
74
+ return transport.current
75
+ }, [endpoint, acquireProof])
76
+ const [config, setConfig] = useState<PublicFeatureBase | null>(null)
77
+ const [error, setError] = useState<string | null>(null)
78
+ const [active, setActive] = useState<OverlayName | null>(null)
79
+ const [announcement, announce] = useState('')
80
+ const activeRef = useRef(active)
81
+ useLayoutEffect(() => {
82
+ activeRef.current = active
83
+ }, [active])
84
+ const lifetime = useRef<AbortController | null>(null)
85
+ useLayoutEffect(() => {
86
+ const controller = new AbortController()
87
+ lifetime.current = controller
88
+ return () => controller.abort()
89
+ }, [])
90
+ const committedConfig = useRef(config)
91
+ useLayoutEffect(() => {
92
+ committedConfig.current = config
93
+ }, [config])
94
+ const launchers = useRef(new Map<OverlayName, { owner: symbol; node: HTMLButtonElement }>())
95
+ const registerLauncher = useCallback(
96
+ (name: OverlayName, owner: symbol, node: HTMLButtonElement | null) => {
97
+ if (node) launchers.current.set(name, { owner, node })
98
+ else if (launchers.current.get(name)?.owner === owner) launchers.current.delete(name)
99
+ },
100
+ [],
101
+ )
102
+ const returnName = useRef<OverlayName | null>(null)
103
+ const returnFocus = useRef<HTMLElement | null>(null)
104
+ const abort = useRef<AbortController | null>(null)
105
+ const pending = useRef<Promise<void> | null>(null)
106
+ const lastRefresh = useRef(0)
107
+ const close = useCallback(() => {
108
+ setActive(null)
109
+ const target = returnFocus.current
110
+ const origin = returnName.current
111
+ returnFocus.current = null
112
+ returnName.current = null
113
+ // Effects release the dialog's focus containment before restoring the launcher.
114
+ queueMicrotask(() => {
115
+ const usable = (node?: HTMLElement | null) =>
116
+ node?.isConnected &&
117
+ !node.matches(':disabled') &&
118
+ !node.closest('[hidden], [inert], [aria-hidden="true"]') &&
119
+ node.getClientRects().length > 0 &&
120
+ getComputedStyle(node).visibility === 'visible'
121
+ if (usable(target)) target!.focus()
122
+ else {
123
+ const replacement = origin && launchers.current.get(origin)?.node
124
+ if (replacement && usable(replacement)) replacement.focus()
125
+ }
126
+ })
127
+ }, [])
128
+ const refresh = useCallback((): Promise<void> => {
129
+ if (pending.current) return pending.current
130
+ const controller = new AbortController()
131
+ abort.current = controller
132
+ lastRefresh.current = Date.now()
133
+ pending.current = getTransport()
134
+ .config(controller.signal)
135
+ .then((next) => {
136
+ if (controller.signal.aborted) return
137
+ setConfig((current) => (current && current.revision > next.revision ? current : next))
138
+ setError(null)
139
+ if (activeRef.current && !next.enabled[activeRef.current]) close()
140
+ })
141
+ .catch(() => {
142
+ if (!controller.signal.aborted) setError('Site features unavailable. Please retry.')
143
+ })
144
+ .finally(() => {
145
+ if (abort.current === controller) pending.current = null
146
+ })
147
+ return pending.current
148
+ }, [getTransport, close])
149
+ useEffect(() => {
150
+ void refresh()
151
+ const visibleRefresh = () => {
152
+ if (document.visibilityState === 'visible' && Date.now() - lastRefresh.current >= 60000)
153
+ void refresh()
154
+ }
155
+ const timer = setInterval(visibleRefresh, 60000)
156
+ document.addEventListener('visibilitychange', visibleRefresh)
157
+ return () => {
158
+ clearInterval(timer)
159
+ document.removeEventListener('visibilitychange', visibleRefresh)
160
+ abort.current?.abort()
161
+ pending.current = null
162
+ }
163
+ }, [refresh])
164
+ const open = useCallback(
165
+ (name: OverlayName) => {
166
+ if (!config?.enabled[name]) return
167
+ if (activeRef.current === null) {
168
+ returnFocus.current =
169
+ document.activeElement instanceof HTMLElement ? document.activeElement : null
170
+ returnName.current = name
171
+ }
172
+ setActive(name)
173
+ void refresh()
174
+ },
175
+ [config, refresh],
176
+ )
177
+ const overlay = useMemo(() => ({ active, open, close }), [active, open, close])
178
+ const ensureSession = useCallback(
179
+ async (signal?: AbortSignal) => {
180
+ try {
181
+ const rootSignal = lifetime.current!.signal
182
+ const work = getTransport().ensureSession(rootSignal)
183
+ await (signal ? waitForSignal(work, signal) : work)
184
+ if (!rootSignal.aborted && !signal?.aborted) setError(null)
185
+ } catch (cause) {
186
+ if (!lifetime.current?.signal.aborted && !signal?.aborted)
187
+ setError('Verification or session unavailable. Please retry.')
188
+ throw cause
189
+ }
190
+ },
191
+ [getTransport],
192
+ )
193
+ const submitFeedback = useCallback(
194
+ async (input: FeedbackInput, signal: AbortSignal) => {
195
+ const rootSignal = lifetime.current!.signal
196
+ const operationSignal = AbortSignal.any([signal, rootSignal])
197
+ operationSignal.throwIfAborted()
198
+ operationSignal.throwIfAborted()
199
+ if (!committedConfig.current?.enabled.feedback) throw new Error('Feedback disabled')
200
+ return getTransport().feedback(input, operationSignal)
201
+ },
202
+ [getTransport],
203
+ )
204
+ const search = useCallback(
205
+ (query: string, signal: AbortSignal) => getTransport().search(query, signal),
206
+ [getTransport],
207
+ )
208
+ const value = useMemo(
209
+ () => ({
210
+ config,
211
+ error,
212
+ refresh,
213
+ ensureSession,
214
+ overlay,
215
+ announce,
216
+ submitFeedback,
217
+ registerLauncher,
218
+ search,
219
+ }),
220
+ [config, error, refresh, ensureSession, overlay, submitFeedback, registerLauncher, search],
221
+ )
222
+ const AskAI = components?.AskAI ?? builtins.askAi
223
+ const Feedback = components?.Feedback ?? builtins.feedback
224
+ return (
225
+ <FeatureContext.Provider value={value}>
226
+ <ConsentRuntime
227
+ endpoint={endpoint ?? '/api/u90/features/v1'}
228
+ config={config}
229
+ customConsent={customConsent}
230
+ >
231
+ <AskAiProvider endpoint={endpoint ?? '/api/u90/features/v1'} getTransport={getTransport}>
232
+ <FeedbackProvider presentation={Feedback}>
233
+ {children}
234
+ <div className="u90-features">
235
+ <Suspense fallback={null}>{config?.enabled.askAi && AskAI && <AskAI />}</Suspense>
236
+ <div
237
+ className="u90-feature-announcement"
238
+ role="status"
239
+ aria-live="polite"
240
+ aria-atomic="true"
241
+ >
242
+ {error ?? announcement}
243
+ </div>
244
+ </div>
245
+ </FeedbackProvider>
246
+ </AskAiProvider>
247
+ </ConsentRuntime>
248
+ </FeatureContext.Provider>
249
+ )
250
+ }
@@ -0,0 +1,92 @@
1
+ import {
2
+ AiConversationPageSchema,
3
+ type AiMessagePart,
4
+ type AiSource,
5
+ type AiTurnState,
6
+ } from '../contracts'
7
+ export type AskAiMessage = {
8
+ id: string
9
+ requestId: string
10
+ role: 'user' | 'assistant'
11
+ text: string
12
+ sources: AiSource[]
13
+ state: AiTurnState
14
+ sequence: number
15
+ newAttempt?: boolean
16
+ }
17
+ export type RecoveryState = {
18
+ conversationId: string
19
+ historyEpoch: number | null
20
+ messages: AskAiMessage[]
21
+ fragments: Map<string, AiMessagePart>
22
+ cursor: string | null
23
+ snapshot: number | null
24
+ }
25
+ export const emptyRecovery = (conversationId: string): RecoveryState => ({
26
+ conversationId,
27
+ historyEpoch: null,
28
+ messages: [],
29
+ fragments: new Map(),
30
+ cursor: null,
31
+ snapshot: null,
32
+ })
33
+ /** Fragments never modify displayed text until their whole immutable revision is available. */
34
+ export function mergeRecoveryPage(current: RecoveryState, input: unknown): RecoveryState {
35
+ const page = AiConversationPageSchema.parse(input)
36
+ if (page.conversationId !== current.conversationId) throw Error('Changed conversation')
37
+ const reset = current.historyEpoch !== null && current.historyEpoch !== page.historyEpoch
38
+ if (
39
+ !reset &&
40
+ current.cursor &&
41
+ current.snapshot !== null &&
42
+ current.snapshot !== page.snapshotUpperSequence
43
+ )
44
+ throw Error('Changed snapshot')
45
+ const state: RecoveryState = {
46
+ ...current,
47
+ historyEpoch: page.historyEpoch,
48
+ snapshot: page.snapshotUpperSequence,
49
+ cursor: page.nextCursor,
50
+ messages: reset ? [] : [...current.messages],
51
+ fragments: reset ? new Map() : new Map(current.fragments),
52
+ }
53
+ for (const part of page.parts) {
54
+ const prior = state.fragments.get(part.messageId)
55
+ let assembled = part
56
+ if (part.textStart !== 0) {
57
+ if (
58
+ !prior ||
59
+ prior.textEnd !== part.textStart ||
60
+ prior.contentRevision !== part.contentRevision ||
61
+ prior.requestId !== part.requestId ||
62
+ prior.sequence !== part.sequence ||
63
+ prior.role !== part.role ||
64
+ prior.state !== part.state ||
65
+ prior.totalLength !== part.totalLength ||
66
+ JSON.stringify(prior.sources) !== JSON.stringify(part.sources)
67
+ )
68
+ throw Error('Changed fragment')
69
+ assembled = { ...part, text: prior.text + part.text, textStart: 0 }
70
+ }
71
+ if (!assembled.lastPart) {
72
+ state.fragments.set(part.messageId, assembled)
73
+ continue
74
+ }
75
+ state.fragments.delete(part.messageId)
76
+ // A recovered local request replaces both placeholder identity and any previously streamed text.
77
+ state.messages = state.messages.filter(
78
+ (m) => m.id !== part.messageId && !(m.requestId === part.requestId && m.role === part.role),
79
+ )
80
+ state.messages.push({
81
+ id: part.messageId,
82
+ requestId: part.requestId,
83
+ sequence: part.sequence,
84
+ role: part.role,
85
+ text: assembled.text,
86
+ sources: part.sources,
87
+ state: part.state,
88
+ })
89
+ }
90
+ state.messages.sort((a, b) => a.sequence - b.sequence)
91
+ return state
92
+ }
@@ -0,0 +1,59 @@
1
+ import { AiStreamEventSchema, type AiStreamEvent } from '../contracts'
2
+ import { FeatureClientError, waitForSignal } from './transport'
3
+ /** One HTTP response is one sequence lifetime. Terminal replay starts at zero again. */
4
+ export async function consumeAiStream(
5
+ response: Response,
6
+ requestId: string,
7
+ receive: (event: AiStreamEvent) => void,
8
+ signal: AbortSignal,
9
+ ) {
10
+ if (!response.headers.get('content-type')?.startsWith('text/event-stream'))
11
+ throw new FeatureClientError()
12
+ const reader = response.body?.getReader()
13
+ if (!reader) throw new FeatureClientError()
14
+ const decoder = new TextDecoder('utf-8', { fatal: true })
15
+ let buffer = '',
16
+ sequence = -1,
17
+ terminal = false,
18
+ total = 0
19
+ const invalid = () => new FeatureClientError('unavailable')
20
+ try {
21
+ for (;;) {
22
+ const { done, value } = await waitForSignal(reader.read(), signal)
23
+ if (done) break
24
+ total += value.byteLength
25
+ if (total > 2 * 1024 * 1024) throw invalid()
26
+ buffer += decoder.decode(value, { stream: true })
27
+ for (;;) {
28
+ const match = /\r?\n\r?\n/.exec(buffer)
29
+ if (!match) break
30
+ const frame = buffer.slice(0, match.index)
31
+ buffer = buffer.slice(match.index + match[0].length)
32
+ if (!frame || frame.startsWith(':')) continue
33
+ if (new TextEncoder().encode(frame).length > 131072) throw invalid()
34
+ if (!frame.startsWith('data: ') || frame.includes('\n')) throw invalid()
35
+ const parsed = AiStreamEventSchema.safeParse(JSON.parse(frame.slice(6)))
36
+ if (!parsed.success || parsed.data.requestId.toLowerCase() !== requestId.toLowerCase())
37
+ throw invalid()
38
+ const event = parsed.data
39
+ if (event.sequence <= sequence) continue
40
+ if (
41
+ terminal ||
42
+ event.sequence !== sequence + 1 ||
43
+ (sequence === -1 && event.type !== 'start') ||
44
+ (sequence >= 0 && event.type === 'start')
45
+ )
46
+ throw invalid()
47
+ sequence = event.sequence
48
+ terminal = event.type === 'done' || event.type === 'error'
49
+ receive(event)
50
+ }
51
+ if (new TextEncoder().encode(buffer).length > 131072) throw invalid()
52
+ }
53
+ buffer += decoder.decode()
54
+ if (buffer.trim() || !terminal) throw invalid()
55
+ } finally {
56
+ await reader.cancel().catch(() => {})
57
+ reader.releaseLock()
58
+ }
59
+ }
@@ -0,0 +1,24 @@
1
+ 'use client'
2
+ import { createContext, useContext } from 'react'
3
+ import type { SearchHit, FeedbackInput, FeedbackReceipt, PublicFeatureBase } from '../contracts'
4
+ export type OverlayName = 'askAi' | 'feedback'
5
+ export type FeatureRuntime = {
6
+ config: PublicFeatureBase | null
7
+ error: string | null
8
+ refresh: () => Promise<void>
9
+ ensureSession: (signal?: AbortSignal) => Promise<void>
10
+ overlay: { active: OverlayName | null; open: (name: OverlayName) => void; close: () => void }
11
+ search: (query: string, signal: AbortSignal) => Promise<SearchHit[]>
12
+ submitFeedback: (input: FeedbackInput, signal: AbortSignal) => Promise<FeedbackReceipt>
13
+ registerLauncher: (name: OverlayName, owner: symbol, node: HTMLButtonElement | null) => void
14
+ announce: (message: string) => void
15
+ }
16
+ export const FeatureContext = createContext<FeatureRuntime | null>(null)
17
+ export function useSiteFeatures(): FeatureRuntime {
18
+ const context = useContext(FeatureContext)
19
+ if (!context) throw new Error('Feature hooks require a SiteFeatures root.')
20
+ return context
21
+ }
22
+ export function useFeatureOverlay() {
23
+ return useSiteFeatures().overlay
24
+ }
@@ -0,0 +1,24 @@
1
+ 'use client'
2
+ export { acquireVerificationProof, useFormVerification, VERIFICATION_CSP } from './verification'
3
+ export { SiteFeatures, type SiteFeaturesProps } from './SiteFeatures'
4
+ export {
5
+ useSiteFeatures,
6
+ useFeatureOverlay,
7
+ type FeatureRuntime,
8
+ type OverlayName,
9
+ } from './context'
10
+ export { FeatureDialog } from './overlay'
11
+ export { FeatureClientError, type VisitorVerification } from './transport'
12
+
13
+ export { useFeedback, type FeedbackController, type FeedbackAnswer } from './useFeedback'
14
+
15
+ export { cookieScriptAdapter, normalizeCookieScript } from '../consent/cookieScript'
16
+ export { customConsentAdapter, type CustomConsentRegistration } from '../consent/custom'
17
+
18
+ export { useConsent, type ConsentController } from './useConsent'
19
+ export { createConsentLoader } from '../consent/loader'
20
+
21
+ export { useSiteSearch } from './useSiteSearch'
22
+
23
+ export { useAskAI, type AskAiController } from './useAskAI'
24
+ export { AskAiContent } from './AskAiWidget'