@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,270 @@
1
+ /**
2
+ * Compile-only assertions about what `defineSite` infers.
3
+ *
4
+ * There is no test framework here and type errors do not surface at runtime, so these are
5
+ * asserted the only way types can be: this file must typecheck, and the lines marked
6
+ * `@ts-expect-error` must not. `pnpm typecheck` fails either way round — an unused
7
+ * `@ts-expect-error` is itself an error.
8
+ */
9
+
10
+ import {
11
+ array,
12
+ block,
13
+ blocksField,
14
+ collection,
15
+ defineSite,
16
+ form,
17
+ group,
18
+ link,
19
+ number,
20
+ relationship,
21
+ richText,
22
+ select,
23
+ textField as text,
24
+ upload,
25
+ } from './index'
26
+
27
+ text({ localized: false })
28
+ richText({ localized: false })
29
+ // @ts-expect-error numeric facts are structurally shared, not localizable copy
30
+ number({ localized: false })
31
+
32
+ const lexical = { root: { children: [], type: 'root' } }
33
+
34
+ const site = defineSite({
35
+ hero: block({
36
+ headline: text('Building Western Australia.'),
37
+ slides: array({ sector: text() }, [{ sector: 'Urban Development' }]),
38
+ /** Declared for the client to fill, committed by nobody — see the assertions below. */
39
+ gallery: array({ caption: text() }),
40
+ }),
41
+ /**
42
+ * A block a site publishes without seeding: every field is a slot, none is committed.
43
+ *
44
+ * One of each helper that builds its descriptor its own way rather than through `Scalar`'s
45
+ * shared body, since each is its own chance to answer the wrong thing.
46
+ */
47
+ pageBody: block(
48
+ {
49
+ heading: text(),
50
+ photo: upload(),
51
+ align: select({ options: ['left', 'right'] }),
52
+ /**
53
+ * A zone is created empty and commits nothing, which is the *normal* case for it — and
54
+ * the last helper with no line of its own here. `Aggregate` decides a group is absent
55
+ * by testing every member against `undefined`, so a helper answering `Unnamed<unknown>`
56
+ * instead makes any group holding one read as present while the runtime has nothing
57
+ * there. Every other helper is pinned below; without this line `blocksField` could
58
+ * regress to that and only a site would find out.
59
+ */
60
+ body: blocksField({ blocks: [] }),
61
+ },
62
+ { seed: false },
63
+ ),
64
+ banner: block({
65
+ cta: link({ default: { href: '/contact-us', label: 'Talk to us' } }),
66
+ /** No committed button: five children, none of which carries anything. */
67
+ secondary: link(),
68
+ }),
69
+ panel: block({
70
+ /**
71
+ * A page reference beside a caption the client fills in — an ordinary block shape, and
72
+ * the one that caught `relationship` answering `unknown`. Nothing here is committed, so
73
+ * the group is absent at runtime; the crash would land on `related`, not on `caption`.
74
+ */
75
+ related: group({ fields: { page: relationship({ relationTo: 'pages' }), caption: text() } }),
76
+ /** The same shape with one committed child: that child really is there, so it is typed. */
77
+ feature: group({
78
+ fields: { page: relationship({ relationTo: 'pages' }), caption: text('Our team at work.') },
79
+ }),
80
+ }),
81
+ contact: block({
82
+ /** One child committed, one not. */
83
+ address: group({ fields: { street: text('123 Main St'), suburb: text() } }),
84
+ /** No child committed: the group itself is what is missing, not just its children. */
85
+ hours: group({ fields: { open: text(), close: text() } }),
86
+ }),
87
+ story: block({
88
+ /** Bare copy: a string, like every other scalar. */
89
+ intro: richText('We have been building since 1974.'),
90
+ /** Stored one way, rendered another: the *rendered* form is what a component reads. */
91
+ body: richText({ default: lexical, value: 'Two paragraphs of it.' }),
92
+ /** Rendered as a document: the site reads the document back, not a string. */
93
+ note: richText({ value: lexical }),
94
+ }),
95
+ projects: collection(
96
+ { name: text(), image: upload() },
97
+ {
98
+ titleField: 'name',
99
+ rows: [{ name: 'Port Coogee', image: '/media/port-coogee.webp' }],
100
+ },
101
+ ),
102
+ logo: { ratio: 241 / 105, src: '/brand/logo-mark.png' },
103
+ /** A form commits no copy — see the assertion below. Same shape `syncForms` reads off it. */
104
+ contactForm: form({
105
+ fields: [{ label: 'Email', name: 'email', type: 'email', role: 'email' }],
106
+ label: 'Contact',
107
+ }),
108
+ })
109
+
110
+ export const brand = site.values
111
+ export type Brand = typeof brand
112
+
113
+ const headline: string = brand.hero.headline
114
+ const sector: string = brand.hero.slides[0]!.sector
115
+ const street: string = brand.contact.address.street
116
+ const ratio: number = brand.logo.ratio
117
+ const first: string = brand.projects[0]!.name
118
+ const intro: string = brand.story.intro
119
+ const body: string = brand.story.body
120
+ const note: unknown = brand.story.note.root
121
+
122
+ /*
123
+ * A bare entry is carried through with its own type, not widened to `unknown`.
124
+ *
125
+ * Both halves are the assertion: `unknown` would fail the annotation on `ratio` above, and
126
+ * this line is what says the type is `number` in particular rather than something a `string`
127
+ * would also satisfy.
128
+ */
129
+ // @ts-expect-error a bare entry keeps the type it was written with
130
+ const wrong: string = brand.logo.ratio
131
+
132
+ // @ts-expect-error a key that was never declared does not exist on the inferred type
133
+ const missing = brand.hero.subheadline
134
+
135
+ /**
136
+ * A form commits no copy to `site.values` — there is nothing for a component to render,
137
+ * only a schema the platform validates submissions against and a mailing list it seeds
138
+ * once. `defineSite`'s loop diverts a `form()` entry into `forms` and never writes
139
+ * `values[key]`, exactly as it does for a layout; this pins that a form key reads the same
140
+ * way a layout key does, `TS2339` rather than a compiling `undefined`.
141
+ */
142
+ // @ts-expect-error a form key does not exist on site.values — see `contactForm` above
143
+ const noForm = brand.contactForm
144
+
145
+ // @ts-expect-error a group's children are read through their own carriers, not as EditorField[]
146
+ const nested: number = brand.contact.address.street
147
+
148
+ /**
149
+ * A field nobody committed reads as `undefined`, not as the type it would have had.
150
+ *
151
+ * This is the half of the bargain a hand-written `Brand` got right by saying `heading?:
152
+ * string`. A generated type that answered `string` here would compile
153
+ * `brand.pageBody.heading.trim()` — which throws, on the fallback-render path this whole
154
+ * document exists to keep working. Each of these needs a guard to be read, which is the
155
+ * assertion: the type admits nothing until the caller has checked.
156
+ */
157
+ const uncommitted: undefined = brand.pageBody.heading
158
+
159
+ // @ts-expect-error an uncommitted scalar is not silently the type it would have had
160
+ const assumed: string = brand.pageBody.heading
161
+
162
+ // @ts-expect-error nor is it dereferenceable without a guard — this is the crash, caught
163
+ const trimmed = brand.pageBody.heading.trim()
164
+
165
+ /**
166
+ * An array with no committed items is `undefined`, not `[]`.
167
+ *
168
+ * The sharpest of them, because the reader is a `.map()` rather than a property access.
169
+ * `defineSite` writes no empty list here and must not: a declared-but-uncommitted array
170
+ * genuinely holds nothing, and a site that means an empty one writes `array(fields, [])`.
171
+ * Contrast the collection below, where `rows ?? []` makes `[]` true in both the type and the
172
+ * runtime.
173
+ */
174
+ const noItems: undefined = brand.hero.gallery
175
+
176
+ // @ts-expect-error mapping an array nobody committed is the TypeError this type now refuses
177
+ const mapped = brand.hero.gallery.map(String)
178
+
179
+ /** The helpers that build their own descriptor answer the same way. */
180
+ const noPhoto: undefined = brand.pageBody.photo
181
+ const noChoice: undefined = brand.pageBody.align
182
+ const noZone: undefined = brand.pageBody.body
183
+
184
+ // @ts-expect-error an empty zone is absent, not a list of sections waiting to be mapped
185
+ const zoned = brand.pageBody.body.map(String)
186
+
187
+ /** A committed button is read back as written; an uncommitted one is not there at all. */
188
+ const href: string = brand.banner.cta.href
189
+ const label: string = brand.banner.cta.label
190
+ const noButton: undefined = brand.banner.secondary
191
+
192
+ // @ts-expect-error a link nobody committed has no href to read
193
+ const missingHref = brand.banner.secondary.href
194
+
195
+ /** A group commits what its children commit — per child, and not at all when none do. */
196
+ const committedChild: string = brand.contact.address.street
197
+ const uncommittedChild: undefined = brand.contact.address.suburb
198
+ const noChildren: undefined = brand.contact.hours
199
+
200
+ // @ts-expect-error a group whose children all carry nothing is absent, not an empty object
201
+ const reached = brand.contact.hours.open
202
+
203
+ /**
204
+ * A reference commits nothing, so it counts as absent rather than as unreadable.
205
+ *
206
+ * The distinction is the whole of it. A member typed `unknown` refuses every dereference of
207
+ * *itself*, but it also makes the group around it look present — and then the crash lands on
208
+ * the group, one level above where anything could refuse it.
209
+ */
210
+ const noReference: undefined = brand.panel.related
211
+
212
+ // @ts-expect-error a group of an uncommitted reference and an uncommitted caption is not there
213
+ const captionOfNothing = brand.panel.related.caption
214
+
215
+ /** One committed child is enough to make the group present — and only that child is typed. */
216
+ const featureCaption: string = brand.panel.feature.caption
217
+ const featurePage: undefined = brand.panel.feature.page
218
+
219
+ /**
220
+ * A collection declared without rows still reads as a list, never as `undefined`.
221
+ *
222
+ * `defineSite` writes `rows ?? []`, so the type and the runtime agree: a site that has
223
+ * declared the record type but committed no rows maps over an empty array rather than
224
+ * crashing on a property of `undefined`.
225
+ */
226
+ const empty = defineSite({
227
+ team: collection({ name: text() }, { titleField: 'name' }),
228
+ }).values.team
229
+ const count: number = empty.length
230
+
231
+ // @ts-expect-error rich text rendered as a document is not silently a string
232
+ const flattened: string = brand.story.note
233
+
234
+ export const used = [
235
+ headline,
236
+ sector,
237
+ street,
238
+ ratio,
239
+ first,
240
+ intro,
241
+ body,
242
+ note,
243
+ wrong,
244
+ missing,
245
+ noForm,
246
+ nested,
247
+ flattened,
248
+ count,
249
+ uncommitted,
250
+ assumed,
251
+ trimmed,
252
+ noItems,
253
+ mapped,
254
+ noPhoto,
255
+ noChoice,
256
+ noZone,
257
+ zoned,
258
+ href,
259
+ label,
260
+ noButton,
261
+ missingHref,
262
+ noReference,
263
+ captionOfNothing,
264
+ featureCaption,
265
+ featurePage,
266
+ committedChild,
267
+ uncommittedChild,
268
+ noChildren,
269
+ reached,
270
+ ]
@@ -0,0 +1,128 @@
1
+ 'use client'
2
+
3
+ import React from 'react'
4
+
5
+ import { useHasMounted, useIsFramed } from './bridge'
6
+
7
+ /**
8
+ * The bar that says "you are looking at a draft, and nobody else is".
9
+ *
10
+ * Draft mode is a cookie, and cookies are invisible. Without this, the only signal that
11
+ * a browser is reading staged content instead of the live site is that the content
12
+ * looks wrong — which reads as "the site published something I never approved", because
13
+ * from the operator's side that is exactly what it looks like. This is the missing half
14
+ * of `createExitPreviewRoute`: one tells you, the other lets you leave.
15
+ *
16
+ * Render it from the site's root layout, behind the server's own draft check:
17
+ *
18
+ * const { isEnabled } = await draftMode()
19
+ * ...
20
+ * {isEnabled ? <DraftNotice /> : null}
21
+ *
22
+ * **Hidden inside the dashboard's Canvas frame.** There, a draft is the entire point and
23
+ * a bar would be noise sitting over the surface the client is editing. This component waits
24
+ * until hydration before drawing anything, so `useIsFramed` can perform its reliable mounted
25
+ * check without painting the public-preview bar for one commit inside Canvas.
26
+ *
27
+ * `position: fixed` so it takes no space in the document. Canvas measures elements in
28
+ * the frame to place its selection overlays, and a bar that pushed the page down by its
29
+ * own height would put every one of those overlays out by that much.
30
+ *
31
+ * Inline styles, deliberately: the kit cannot assume the consuming site's Tailwind
32
+ * content glob reaches into `node_modules`, and a bar styled with classes that were
33
+ * never generated is an invisible warning.
34
+ */
35
+ export const DraftNotice = ({
36
+ /** Where the exit route is mounted, if a site moves it. */
37
+ exitPath = '/api/exit-preview',
38
+ label = 'Viewing staged changes — visitors still see the published site.',
39
+ }: {
40
+ exitPath?: string
41
+ label?: string
42
+ } = {}) => {
43
+ const framed = useIsFramed()
44
+ const hydrated = useHasMounted()
45
+
46
+ if (!hydrated || framed) return null
47
+
48
+ /*
49
+ * The page you are on is read at click time rather than baked into the href.
50
+ *
51
+ * A layout has no pathname to render into the link, and the obvious fix — fill it in
52
+ * after mount — is wrong twice over: it sets state from an effect, and it goes stale
53
+ * the moment the visitor navigates client-side, so Exit would return them to whatever
54
+ * page they first landed on. Reading `location` in the handler is always current.
55
+ *
56
+ * The plain `href` stays as the no-JS path; it just returns you to the home page.
57
+ */
58
+ const exitHere = (event: React.MouseEvent<HTMLAnchorElement>) => {
59
+ // Let ⌘-click, middle-click and the rest keep their normal meaning.
60
+ if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.shiftKey) return
61
+ event.preventDefault()
62
+ const here = window.location.pathname + window.location.search
63
+ window.location.href = `${exitPath}?to=${encodeURIComponent(here)}`
64
+ }
65
+
66
+ return (
67
+ <div
68
+ role="status"
69
+ style={{
70
+ bottom: 'max(16px, env(safe-area-inset-bottom))',
71
+ display: 'flex',
72
+ justifyContent: 'center',
73
+ left: 0,
74
+ // The bar itself is the only thing clickable; the rest of this strip is not.
75
+ pointerEvents: 'none',
76
+ position: 'fixed',
77
+ right: 0,
78
+ zIndex: 2147483000,
79
+ }}
80
+ >
81
+ <div
82
+ style={{
83
+ alignItems: 'center',
84
+ background: '#111111',
85
+ border: '1px solid #333333',
86
+ boxShadow: '0 2px 12px rgba(0, 0, 0, 0.35)',
87
+ color: '#f5f5f5',
88
+ display: 'flex',
89
+ fontFamily:
90
+ 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
91
+ fontSize: '13px',
92
+ gap: '14px',
93
+ lineHeight: 1.3,
94
+ margin: '0 16px',
95
+ maxWidth: 'calc(100vw - 32px)',
96
+ padding: '9px 14px',
97
+ pointerEvents: 'auto',
98
+ }}
99
+ >
100
+ <span
101
+ aria-hidden
102
+ style={{
103
+ background: '#e5a000',
104
+ borderRadius: '50%',
105
+ flex: 'none',
106
+ height: '7px',
107
+ width: '7px',
108
+ }}
109
+ />
110
+ <span style={{ minWidth: 0 }}>{label}</span>
111
+ <a
112
+ href={exitPath}
113
+ onClick={exitHere}
114
+ style={{
115
+ color: '#ffffff',
116
+ flex: 'none',
117
+ fontWeight: 600,
118
+ textDecoration: 'underline',
119
+ textUnderlineOffset: '3px',
120
+ whiteSpace: 'nowrap',
121
+ }}
122
+ >
123
+ Exit preview
124
+ </a>
125
+ </div>
126
+ </div>
127
+ )
128
+ }