@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,222 @@
1
+ 'use client'
2
+ import { useEffect, useRef, useState, type CSSProperties } from 'react'
3
+ import { FeatureDialog } from './overlay'
4
+ import { useAskAI, type AskAiController } from './useAskAI'
5
+ /** Reusable accessible content; remixes may replace this entire presentation with the same hook. */
6
+ export function AskAiContent({
7
+ controller: c,
8
+ }: {
9
+ controller: Omit<AskAiController, 'launcherRef'>
10
+ }) {
11
+ const log = useRef<HTMLDivElement>(null)
12
+ useEffect(() => {
13
+ if (log.current) log.current.scrollTop = log.current.scrollHeight
14
+ }, [c.messages, c.status])
15
+ const busy = c.status === 'sending' || c.status === 'streaming' || c.recovering
16
+ return (
17
+ <>
18
+ <div
19
+ className="u90-ai-log"
20
+ role="log"
21
+ aria-label="Conversation"
22
+ aria-live="polite"
23
+ aria-relevant="additions text"
24
+ ref={log}
25
+ >
26
+ {!c.messages.length && <p>{c.settings.greeting}</p>}
27
+ {c.messages.map((m) => (
28
+ <article key={m.id} className={'u90-ai-message u90-ai-' + m.role}>
29
+ <p className="u90-ai-role">
30
+ {m.role === 'user' ? 'You' : 'Assistant'}
31
+ {m.newAttempt ? ' · New attempt' : ''}
32
+ </p>
33
+ <p className="u90-ai-text">
34
+ {m.text ||
35
+ (m.state === 'queued'
36
+ ? c.awaitingAnswer
37
+ ? 'Saved answer is still pending.'
38
+ : 'Sending… Keep this page open until the answer starts; refreshing now may prevent recovery.'
39
+ : m.state === 'streaming'
40
+ ? 'Preparing answer…'
41
+ : 'No answer received.')}
42
+ </p>
43
+ {m.role === 'assistant' && (
44
+ <>
45
+ {m.sources.length > 0 && (
46
+ <ul aria-label="Sources">
47
+ {m.sources.map((s) => (
48
+ <li key={JSON.stringify([s.kind, s.id])}>
49
+ {s.path === null ? <span>{s.title}</span> : <a href={s.path}>{s.title}</a>}
50
+ </li>
51
+ ))}
52
+ </ul>
53
+ )}
54
+ {['failed', 'aborted', 'unknown'].includes(m.state) && (
55
+ <p className="u90-ai-state">
56
+ {m.state === 'unknown'
57
+ ? 'Unconfirmed answer — may be incomplete.'
58
+ : 'Incomplete answer.'}
59
+ </p>
60
+ )}
61
+ {!['queued', 'streaming'].includes(m.state) && (
62
+ <div className="u90-ai-ratings" aria-label="Rate answer">
63
+ {(['helpful', 'unhelpful'] as const).map((r) => (
64
+ <button
65
+ type="button"
66
+ key={r}
67
+ aria-pressed={c.ratings[m.id] === r}
68
+ onClick={() => void c.rate(m.id, r)}
69
+ >
70
+ {r === 'helpful' ? 'Helpful' : 'Unhelpful'}
71
+ </button>
72
+ ))}
73
+ </div>
74
+ )}
75
+ </>
76
+ )}
77
+ </article>
78
+ ))}
79
+ </div>
80
+ {!c.messages.length && c.settings.suggestions.length > 0 && (
81
+ <div className="u90-ai-suggestions">
82
+ {c.settings.suggestions.map((s) => (
83
+ <button type="button" key={s} onClick={() => c.setDraft(s)}>
84
+ {s}
85
+ </button>
86
+ ))}
87
+ </div>
88
+ )}
89
+ {c.hasEarlier && (
90
+ <p>
91
+ Some history has not been loaded.{' '}
92
+ <button type="button" disabled={busy} onClick={() => void c.loadEarlier()}>
93
+ Load earlier messages
94
+ </button>
95
+ </p>
96
+ )}
97
+ {c.error && (
98
+ <div role="alert" className="u90-ai-error">
99
+ <p>{c.error}</p>
100
+ {c.canRetry && (
101
+ <button type="button" onClick={() => void c.retry()}>
102
+ Retry same request
103
+ </button>
104
+ )}
105
+ {c.canNewAttempt && (
106
+ <button type="button" onClick={() => void c.retryAnswer()}>
107
+ Try a new answer
108
+ </button>
109
+ )}
110
+ </div>
111
+ )}
112
+ {c.canRecover && (
113
+ <button type="button" disabled={busy} onClick={() => void c.recover()}>
114
+ Check saved answer
115
+ </button>
116
+ )}
117
+ {c.ratingError && (
118
+ <p role="alert" className="u90-ai-error">
119
+ {c.ratingError}
120
+ </p>
121
+ )}
122
+ <p role="status" className="u90-ai-progress">
123
+ {c.recovering
124
+ ? 'Loading saved conversation…'
125
+ : c.awaitingAnswer
126
+ ? 'Saved answer is still pending. Check the saved answer to refresh it.'
127
+ : c.status === 'sending'
128
+ ? 'Sending… Keep this page open until the answer starts; refreshing now may prevent recovery.'
129
+ : c.status === 'streaming'
130
+ ? 'Answering…'
131
+ : ''}
132
+ </p>
133
+ <form
134
+ className="u90-ai-composer"
135
+ onSubmit={(e) => {
136
+ e.preventDefault()
137
+ void c.send(c.draft)
138
+ }}
139
+ >
140
+ <label>
141
+ Message
142
+ <textarea
143
+ rows={2}
144
+ maxLength={2000}
145
+ value={c.draft}
146
+ onChange={(e) => c.setDraft(e.target.value)}
147
+ />
148
+ </label>
149
+ <div>
150
+ <button
151
+ className="u90-ai-send"
152
+ type="submit"
153
+ disabled={busy || c.awaitingAnswer || !c.draft.trim() || c.canRetry || c.canNewAttempt}
154
+ >
155
+ Send
156
+ </button>
157
+ {busy && (
158
+ <button type="button" onClick={c.stop}>
159
+ Stop
160
+ </button>
161
+ )}
162
+ <button type="button" onClick={c.startNewConversation}>
163
+ New conversation
164
+ </button>
165
+ </div>
166
+ </form>
167
+ </>
168
+ )
169
+ }
170
+ export function askAiForeground(hex: string) {
171
+ const channels = [1, 3, 5]
172
+ .map((i) => parseInt(hex.slice(i, i + 2), 16) / 255)
173
+ .map((v) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4))
174
+ const light = 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2]
175
+ return (light + 0.05) / 0.05 >= 1.05 / (light + 0.05) ? '#000000' : '#ffffff'
176
+ }
177
+ export default function AskAiWidget() {
178
+ const { launcherRef, ...c } = useAskAI(),
179
+ [viewport, setViewport] = useState({ height: 0, top: 0 })
180
+ useEffect(() => {
181
+ const vv = window.visualViewport
182
+ const measure = () =>
183
+ setViewport({ height: vv?.height ?? window.innerHeight, top: vv?.offsetTop ?? 0 })
184
+ measure()
185
+ vv?.addEventListener('resize', measure)
186
+ vv?.addEventListener('scroll', measure)
187
+ window.addEventListener('resize', measure)
188
+ return () => {
189
+ vv?.removeEventListener('resize', measure)
190
+ vv?.removeEventListener('scroll', measure)
191
+ window.removeEventListener('resize', measure)
192
+ }
193
+ }, [])
194
+ return (
195
+ <div
196
+ className="u90-ai"
197
+ style={
198
+ {
199
+ '--u90-ai-accent': c.settings.accentColor,
200
+ '--u90-ai-on-accent': askAiForeground(c.settings.accentColor),
201
+ '--u90-ai-radius': c.settings.cornerStyle === 'square' ? '0' : '1rem',
202
+ '--u90-ai-height': viewport.height ? viewport.height + 'px' : '100dvh',
203
+ '--u90-ai-top': viewport.top + 'px',
204
+ } as CSSProperties
205
+ }
206
+ >
207
+ <button
208
+ type="button"
209
+ className="u90-ai-launcher"
210
+ ref={launcherRef}
211
+ onClick={c.show}
212
+ aria-expanded={c.open}
213
+ >
214
+ {c.settings.launcherLabel}
215
+ </button>
216
+ <FeatureDialog name="askAi" label={c.settings.title} className="u90-ai-panel">
217
+ <h2>{c.settings.title}</h2>
218
+ <AskAiContent controller={c} />
219
+ </FeatureDialog>
220
+ </div>
221
+ )
222
+ }
@@ -0,0 +1,95 @@
1
+ 'use client'
2
+ import {
3
+ Suspense,
4
+ useCallback,
5
+ useLayoutEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ type ReactNode,
10
+ } from 'react'
11
+ import type { PublicFeatureBase } from '../contracts'
12
+ import { deniedConsentState } from '../contracts/consent'
13
+ import type { CustomConsentRegistration } from '../consent/custom'
14
+ import { consentRegistry } from '../consent/registry'
15
+ import { ConsentContext } from './useConsent'
16
+ import { PageReporter } from './PageReporter'
17
+ const ignoreTitle = () => {}
18
+ /** Commit-only ownership: rendering never installs listeners, scripts, or authority. */
19
+ export function ConsentRuntime({
20
+ endpoint,
21
+ config,
22
+ customConsent,
23
+ children,
24
+ }: {
25
+ endpoint: string
26
+ config: PublicFeatureBase | null
27
+ customConsent?: CustomConsentRegistration
28
+ children: ReactNode
29
+ }) {
30
+ const owner = useRef({})
31
+ const registry = useRef<ReturnType<typeof consentRegistry> | null>(null)
32
+ const [snapshot, setSnapshot] = useState(() => ({
33
+ state: deniedConsentState(),
34
+ ready: false,
35
+ error: null as string | null,
36
+ reloadRequired: false,
37
+ }))
38
+ useLayoutEffect(() => {
39
+ const current = consentRegistry(window)
40
+ registry.current = current
41
+ const off = current.subscribe(() => setSnapshot(current.read()))
42
+ const currentOwner = owner.current
43
+ current.attach(currentOwner)
44
+ return () => {
45
+ off()
46
+ current.detach(currentOwner)
47
+ registry.current = null
48
+ }
49
+ }, [])
50
+ useLayoutEffect(() => {
51
+ registry.current!.commit(
52
+ owner.current,
53
+ endpoint,
54
+ config?.enabled.consent ?? false,
55
+ config?.consent,
56
+ customConsent,
57
+ )
58
+ setSnapshot(registry.current!.read())
59
+ }, [endpoint, config, customConsent, customConsent?.adapter, customConsent?.openPreferences])
60
+ const openPreferences = useCallback(() => registry.current?.openPreferences(), [])
61
+ const page = useCallback((path: string) => registry.current?.page(path), [])
62
+ const value = useMemo(
63
+ () => ({
64
+ state: snapshot.state,
65
+ ready: snapshot.ready,
66
+ error: snapshot.error,
67
+ openPreferences,
68
+ }),
69
+ [snapshot, openPreferences],
70
+ )
71
+ return (
72
+ <ConsentContext.Provider value={value}>
73
+ {children}
74
+ <Suspense fallback={null}>
75
+ <PageReporter report={page} titleChanged={ignoreTitle} />
76
+ </Suspense>
77
+ {snapshot.error && (
78
+ <div className="u90-features u90-consent-status">
79
+ <p role="status" aria-live="polite">
80
+ {snapshot.error}
81
+ </p>
82
+ {snapshot.reloadRequired ? (
83
+ <button type="button" data-u90-consent-reload onClick={() => window.location.reload()}>
84
+ Reload to apply consent changes
85
+ </button>
86
+ ) : (
87
+ <button type="button" onClick={() => registry.current?.retry()}>
88
+ Retry consent setup
89
+ </button>
90
+ )}
91
+ </div>
92
+ )}
93
+ </ConsentContext.Provider>
94
+ )
95
+ }
@@ -0,0 +1,231 @@
1
+ 'use client'
2
+ import {
3
+ Suspense,
4
+ useCallback,
5
+ useLayoutEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ type ComponentType,
10
+ type ReactNode,
11
+ } from 'react'
12
+ import {
13
+ FeedbackInputSchema,
14
+ FeedbackWidgetSettingsSchema,
15
+ type FeedbackInput,
16
+ type PageContext,
17
+ } from '../contracts'
18
+ import { useSiteFeatures } from './context'
19
+ import { FeedbackContext, type FeedbackAnswer, type FeedbackController } from './useFeedback'
20
+ import { PageReporter } from './PageReporter'
21
+
22
+ type Status = FeedbackController['status']
23
+ const defaults = FeedbackWidgetSettingsSchema.parse({})
24
+ const uncertain =
25
+ 'We could not confirm your feedback. Retry the same feedback, or start new feedback.'
26
+ /** Independent of presentation remounts, but owned by the endpoint-keyed root. */
27
+ export function FeedbackProvider({
28
+ children,
29
+ presentation: Presentation,
30
+ }: {
31
+ children: ReactNode
32
+ presentation?: ComponentType
33
+ }) {
34
+ const runtime = useSiteFeatures()
35
+ const [currentPage, setPage] = useState<Readonly<PageContext> | null>(null)
36
+ const [capturedPage, setCapturedPage] = useState<Readonly<PageContext> | null>(null)
37
+ const [epoch, setEpoch] = useState(0)
38
+ const [status, setStatus] = useState<Status>('idle')
39
+ const [error, setError] = useState<string | null>(null)
40
+ const attempt = useRef<FeedbackInput | null>(null)
41
+ const state = useRef<Status>('idle')
42
+ const pending = useRef<Promise<void> | null>(null)
43
+ const operation = useRef<AbortController | null>(null)
44
+ const alive = useRef(false)
45
+ const titleReady = useRef(false)
46
+ const committed = useRef({
47
+ enabled: false,
48
+ page: null as Readonly<PageContext> | null,
49
+ settings: defaults,
50
+ send: runtime.submitFeedback,
51
+ })
52
+ const transition = useCallback((next: Status, message: string | null = null) => {
53
+ state.current = next
54
+ setStatus(next)
55
+ setError(message)
56
+ }, [])
57
+ useLayoutEffect(() => {
58
+ alive.current = true
59
+ return () => {
60
+ alive.current = false
61
+ operation.current?.abort()
62
+ }
63
+ }, [])
64
+ useLayoutEffect(() => {
65
+ const enabled = Boolean(runtime.config?.enabled.feedback)
66
+ const wasEnabled = committed.current.enabled
67
+ committed.current = {
68
+ ...committed.current,
69
+ enabled,
70
+ settings: runtime.config?.feedback ?? defaults,
71
+ send: runtime.submitFeedback,
72
+ }
73
+ if (wasEnabled && !enabled) {
74
+ operation.current?.abort()
75
+ operation.current = null
76
+ pending.current = null
77
+ setEpoch((value) => value + 1)
78
+ if (state.current === 'submitting') transition('error', uncertain)
79
+ else if (!attempt.current) transition('idle')
80
+ }
81
+ }, [runtime.config, runtime.submitFeedback, transition])
82
+ const report = useCallback(
83
+ (path: string) => {
84
+ if (committed.current.page?.path === path) return
85
+ titleReady.current = committed.current.page === null
86
+ const page = Object.freeze({ path })
87
+ committed.current = { ...committed.current, page }
88
+ setPage(page)
89
+ setEpoch((value) => value + 1)
90
+ if (!attempt.current || state.current === 'success') {
91
+ attempt.current = null
92
+ setCapturedPage(null)
93
+ transition('idle')
94
+ }
95
+ },
96
+ [transition],
97
+ )
98
+ const titleChanged = useCallback(() => {
99
+ if (committed.current.page?.path === window.location.pathname) titleReady.current = true
100
+ }, [])
101
+ const run = useCallback((): Promise<void> => {
102
+ if (pending.current) return pending.current
103
+ if (
104
+ !alive.current ||
105
+ !committed.current.enabled ||
106
+ !attempt.current ||
107
+ state.current === 'success'
108
+ )
109
+ return Promise.resolve()
110
+ const captured = attempt.current
111
+ const controller = new AbortController()
112
+ operation.current = controller
113
+ transition('submitting')
114
+ const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(120000)])
115
+ const current = () =>
116
+ alive.current &&
117
+ committed.current.enabled &&
118
+ operation.current === controller &&
119
+ attempt.current === captured &&
120
+ !signal.aborted
121
+ // The pending slot is claimed synchronously, before either consumer can submit again.
122
+ const promise = Promise.resolve()
123
+ .then(async () => {
124
+ signal.throwIfAborted()
125
+ await committed.current.send(captured, signal)
126
+ if (current()) transition('success')
127
+ })
128
+ .catch(() => {
129
+ if (
130
+ alive.current &&
131
+ committed.current.enabled &&
132
+ operation.current === controller &&
133
+ attempt.current === captured
134
+ )
135
+ transition('error', uncertain)
136
+ })
137
+ .finally(() => {
138
+ if (operation.current === controller) {
139
+ operation.current = null
140
+ pending.current = null
141
+ }
142
+ })
143
+ pending.current = promise
144
+ return promise
145
+ }, [transition])
146
+ const submit = useCallback(
147
+ (answer: FeedbackAnswer): Promise<void> => {
148
+ if (pending.current) return pending.current
149
+ if (attempt.current) return run() // Retried answers and source are immutable.
150
+ const { enabled, page, settings } = committed.current
151
+ if (!alive.current || !enabled) return Promise.resolve()
152
+ if (!page || page.path !== window.location.pathname) {
153
+ setError('The page is changing. Please try again once it is ready.')
154
+ return Promise.resolve()
155
+ }
156
+ const comment = settings.showComment ? answer.comment?.trim() : undefined
157
+ // Validate before allocating an intentional request identity.
158
+ const parsed = FeedbackInputSchema.safeParse({
159
+ requestId: '00000000-0000-4000-8000-000000000000',
160
+ page: {
161
+ path: page.path,
162
+ ...(titleReady.current && document.title ? { title: document.title.slice(0, 200) } : {}),
163
+ },
164
+ ...(settings.showRating && answer.rating !== undefined ? { rating: answer.rating } : {}),
165
+ ...(comment ? { comment } : {}),
166
+ })
167
+ if (!parsed.success) {
168
+ setError('Choose a rating or enter a comment of up to 2,000 characters.')
169
+ return Promise.resolve()
170
+ }
171
+ attempt.current = Object.freeze({
172
+ ...parsed.data,
173
+ page: Object.freeze(parsed.data.page),
174
+ requestId: crypto.randomUUID(),
175
+ })
176
+ setCapturedPage(attempt.current.page)
177
+ return run()
178
+ },
179
+ [run],
180
+ )
181
+ const startNew = useCallback(() => {
182
+ if (state.current === 'submitting') return
183
+ attempt.current = null
184
+ setCapturedPage(null)
185
+ transition('idle')
186
+ setEpoch((value) => value + 1)
187
+ }, [transition])
188
+ const { open, close } = runtime.overlay
189
+ const show = useCallback(() => open('feedback'), [open])
190
+ const value = useMemo(
191
+ () => ({
192
+ open: runtime.overlay.active === 'feedback',
193
+ status,
194
+ error,
195
+ currentPage,
196
+ page: capturedPage ?? currentPage,
197
+ show,
198
+ hide: close,
199
+ submit,
200
+ retry: run,
201
+ startNew,
202
+ }),
203
+ [
204
+ runtime.overlay.active,
205
+ status,
206
+ error,
207
+ currentPage,
208
+ capturedPage,
209
+ show,
210
+ close,
211
+ submit,
212
+ run,
213
+ startNew,
214
+ ],
215
+ )
216
+ return (
217
+ <FeedbackContext.Provider value={value}>
218
+ <Suspense fallback={null}>
219
+ <PageReporter report={report} titleChanged={titleChanged} />
220
+ </Suspense>
221
+ {children}
222
+ {runtime.config?.enabled.feedback && Presentation && (
223
+ <Suspense fallback={null}>
224
+ <div className="u90-features">
225
+ <Presentation key={epoch} />
226
+ </div>
227
+ </Suspense>
228
+ )}
229
+ </FeedbackContext.Provider>
230
+ )
231
+ }
@@ -0,0 +1,101 @@
1
+ 'use client'
2
+ import { useId, useState } from 'react'
3
+ import { FeedbackWidgetSettingsSchema } from '../contracts'
4
+ import { useSiteFeatures } from './context'
5
+ import { useFeedback, type FeedbackAnswer } from './useFeedback'
6
+ import { FeatureDialog } from './overlay'
7
+ const defaults = FeedbackWidgetSettingsSchema.parse({})
8
+ export default function FeedbackWidget() {
9
+ const { launcherRef, ...feedback } = useFeedback()
10
+ const settings = useSiteFeatures().config?.feedback ?? defaults
11
+ const [rating, setRating] = useState<FeedbackAnswer['rating']>()
12
+ const [comment, setComment] = useState('')
13
+ const id = useId()
14
+ return (
15
+ <>
16
+ <button
17
+ className="u90-feedback-launcher"
18
+ ref={launcherRef}
19
+ type="button"
20
+ aria-expanded={feedback.open}
21
+ onClick={feedback.show}
22
+ >
23
+ {settings.launcherLabel}
24
+ </button>
25
+ <FeatureDialog name="feedback" label={settings.title} className="u90-feedback-panel">
26
+ <h2>{settings.title}</h2>
27
+ <p className="u90-feedback-page">Feedback for {feedback.page?.path ?? 'this page'}</p>
28
+ {feedback.page?.path !== feedback.currentPage?.path && (
29
+ <p>
30
+ You are now on {feedback.currentPage?.path}. This feedback still belongs to{' '}
31
+ {feedback.page?.path}.
32
+ </p>
33
+ )}
34
+ {feedback.status === 'idle' && (
35
+ <form
36
+ onSubmit={(event) => {
37
+ event.preventDefault()
38
+ void feedback.submit({ rating, comment })
39
+ }}
40
+ >
41
+ {settings.showRating && (
42
+ <fieldset>
43
+ <legend>{settings.ratingLabel}</legend>
44
+ <div className="u90-feedback-ratings">
45
+ {([1, 2, 3, 4, 5] as const).map((value) => (
46
+ <label key={value}>
47
+ <input
48
+ type="radio"
49
+ name={id}
50
+ aria-label={`${value} out of 5`}
51
+ checked={rating === value}
52
+ onChange={() => setRating(value)}
53
+ />
54
+ <span>{value}</span>
55
+ </label>
56
+ ))}
57
+ </div>
58
+ </fieldset>
59
+ )}
60
+ {settings.showComment && (
61
+ <label className="u90-feedback-comment">
62
+ {settings.commentLabel}
63
+ <textarea
64
+ value={comment}
65
+ onChange={(event) => setComment(event.target.value)}
66
+ maxLength={2000}
67
+ rows={4}
68
+ placeholder={settings.commentPlaceholder}
69
+ />
70
+ </label>
71
+ )}
72
+ {feedback.error && <p role="alert">{feedback.error}</p>}
73
+ <button className="u90-feedback-submit" type="submit">
74
+ {settings.submitLabel}
75
+ </button>
76
+ </form>
77
+ )}
78
+ {feedback.status === 'submitting' && <p role="status">Sending feedback…</p>}
79
+ {feedback.status === 'error' && (
80
+ <>
81
+ <p role="alert">{feedback.error}</p>
82
+ <button type="button" onClick={() => void feedback.retry()}>
83
+ Retry feedback
84
+ </button>
85
+ <button type="button" onClick={feedback.startNew}>
86
+ Start new feedback
87
+ </button>
88
+ </>
89
+ )}
90
+ {feedback.status === 'success' && (
91
+ <>
92
+ <p role="status">{settings.successMessage}</p>
93
+ <button type="button" onClick={feedback.startNew}>
94
+ Start new feedback
95
+ </button>
96
+ </>
97
+ )}
98
+ </FeatureDialog>
99
+ </>
100
+ )
101
+ }
@@ -0,0 +1,26 @@
1
+ 'use client'
2
+ import { useLayoutEffect } from 'react'
3
+ import { usePathname } from 'next/navigation'
4
+ /** Isolated Suspense leaf: only committed navigation advances the presentation lifetime. */
5
+ export function PageReporter({
6
+ report,
7
+ titleChanged,
8
+ }: {
9
+ report: (path: string) => void
10
+ titleChanged: () => void
11
+ }) {
12
+ const pathname = usePathname()
13
+ useLayoutEffect(() => {
14
+ report(window.location.pathname)
15
+ let title = document.title
16
+ const observer = new MutationObserver(() => {
17
+ if (document.title !== title) {
18
+ title = document.title
19
+ titleChanged()
20
+ }
21
+ })
22
+ observer.observe(document.head, { subtree: true, childList: true, characterData: true })
23
+ return () => observer.disconnect()
24
+ }, [pathname, report, titleChanged])
25
+ return null
26
+ }