@storystick/react 0.0.0-stage → 0.2.0-rc.1

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 (106) hide show
  1. package/LICENSE +112 -0
  2. package/NOTICE +17 -0
  3. package/PUBLIC-API.md +38 -0
  4. package/README.md +226 -2
  5. package/dist/.source-hash +1 -0
  6. package/dist/authoring-skeleton.d.ts +5 -0
  7. package/dist/authoring-skeleton.js +29 -0
  8. package/dist/file.d.ts +18 -0
  9. package/dist/file.js +44 -0
  10. package/dist/hydrate-slice.d.ts +3 -0
  11. package/dist/hydrate-slice.js +1 -0
  12. package/dist/image-component.d.ts +26 -0
  13. package/dist/image-component.js +79 -0
  14. package/dist/image-element.d.ts +6 -0
  15. package/dist/image-element.js +68 -0
  16. package/dist/image.d.ts +3 -0
  17. package/dist/image.js +3 -0
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.js +2 -0
  20. package/dist/link-adapter.d.ts +2 -0
  21. package/dist/link-adapter.js +2 -0
  22. package/dist/link-component.d.ts +53 -0
  23. package/dist/link-component.js +97 -0
  24. package/dist/link.d.ts +3 -0
  25. package/dist/link.js +3 -0
  26. package/dist/preview-generation.d.ts +21 -0
  27. package/dist/preview-generation.js +134 -0
  28. package/dist/preview-host/preview-assistant-continuation.d.ts +9 -0
  29. package/dist/preview-host/preview-assistant-continuation.js +39 -0
  30. package/dist/preview-host/preview-authoring-client.d.ts +12 -0
  31. package/dist/preview-host/preview-authoring-client.js +108 -0
  32. package/dist/preview-host/preview-editor-continuation.d.ts +13 -0
  33. package/dist/preview-host/preview-editor-continuation.js +68 -0
  34. package/dist/preview-host/preview-standalone-controls.d.ts +18 -0
  35. package/dist/preview-host/preview-standalone-controls.js +528 -0
  36. package/dist/preview-host/preview-studio-destination.d.ts +20 -0
  37. package/dist/preview-host/preview-studio-destination.js +55 -0
  38. package/dist/preview-host/use-standalone-presentation.d.ts +15 -0
  39. package/dist/preview-host/use-standalone-presentation.js +223 -0
  40. package/dist/preview-icons.d.ts +10 -0
  41. package/dist/preview-icons.js +8 -0
  42. package/dist/preview-interaction-control.d.ts +6 -0
  43. package/dist/preview-interaction-control.js +39 -0
  44. package/dist/preview-page-state.d.ts +12 -0
  45. package/dist/preview-page-state.js +11 -0
  46. package/dist/preview-panel-state.d.ts +15 -0
  47. package/dist/preview-panel-state.js +28 -0
  48. package/dist/preview-provenance-publisher-model.d.ts +5 -0
  49. package/dist/preview-provenance-publisher-model.js +9 -0
  50. package/dist/preview-provenance-publisher.d.ts +15 -0
  51. package/dist/preview-provenance-publisher.js +68 -0
  52. package/dist/preview-provenance.d.ts +6 -0
  53. package/dist/preview-provenance.js +4 -0
  54. package/dist/preview-scope.d.ts +128 -0
  55. package/dist/preview-scope.js +161 -0
  56. package/dist/preview-slice-scope.d.ts +32 -0
  57. package/dist/preview-slice-scope.js +33 -0
  58. package/dist/preview-text-props.d.ts +18 -0
  59. package/dist/preview-text-props.js +13 -0
  60. package/dist/preview-toolbar-layout.d.ts +42 -0
  61. package/dist/preview-toolbar-layout.js +139 -0
  62. package/dist/preview-toolbar-panel.d.ts +14 -0
  63. package/dist/preview-toolbar-panel.js +6 -0
  64. package/dist/preview-toolbar-resize-handles.d.ts +9 -0
  65. package/dist/preview-toolbar-resize-handles.js +14 -0
  66. package/dist/preview-toolbar-shell.d.ts +19 -0
  67. package/dist/preview-toolbar-shell.js +144 -0
  68. package/dist/public-rich-text.d.ts +15 -0
  69. package/dist/public-rich-text.js +11 -0
  70. package/dist/public-slice-zone.d.ts +7 -0
  71. package/dist/public-slice-zone.js +6 -0
  72. package/dist/query-cache.d.ts +14 -0
  73. package/dist/query-cache.js +289 -0
  74. package/dist/query-preview-loader.d.ts +9 -0
  75. package/dist/query-preview-loader.js +28 -0
  76. package/dist/query-preview-runtime.d.ts +3 -0
  77. package/dist/query-preview-runtime.js +3 -0
  78. package/dist/rendering.d.ts +29 -0
  79. package/dist/rendering.js +17 -0
  80. package/dist/resize-edge.d.ts +8 -0
  81. package/dist/resize-edge.js +33 -0
  82. package/dist/rich-text.d.ts +150 -0
  83. package/dist/rich-text.js +618 -0
  84. package/dist/slice-render-failure.d.ts +8 -0
  85. package/dist/slice-render-failure.js +46 -0
  86. package/dist/slice-zone.d.ts +82 -0
  87. package/dist/slice-zone.js +273 -0
  88. package/dist/slice.d.ts +16 -0
  89. package/dist/slice.js +46 -0
  90. package/dist/storystick-mark.d.ts +3 -0
  91. package/dist/storystick-mark.js +6 -0
  92. package/dist/use-generated-query.d.ts +37 -0
  93. package/dist/use-generated-query.js +308 -0
  94. package/dist/use-momentary-preview-navigate-mode.d.ts +11 -0
  95. package/dist/use-momentary-preview-navigate-mode.js +158 -0
  96. package/dist/use-preview-perspective-intent.d.ts +53 -0
  97. package/dist/use-preview-perspective-intent.js +227 -0
  98. package/dist/use-preview-pointer-drag.d.ts +16 -0
  99. package/dist/use-preview-pointer-drag.js +98 -0
  100. package/dist/use-preview-toolbar-layout.d.ts +36 -0
  101. package/dist/use-preview-toolbar-layout.js +141 -0
  102. package/dist/use-render-mode-debounce.d.ts +31 -0
  103. package/dist/use-render-mode-debounce.js +143 -0
  104. package/dist/video.d.ts +21 -0
  105. package/dist/video.js +63 -0
  106. package/package.json +154 -3
@@ -0,0 +1,18 @@
1
+ import { type PreviewOverlayClickTarget } from '@storystick/preview/provenance';
2
+ import type { PreviewReadContext } from '@storystick/types/preview-query-targeting';
3
+ import type { PreviewAuthoringTarget } from '@storystick/preview/authoring/runtime-contract';
4
+ import { type ReactElement } from 'react';
5
+ import type { PreviewRuntimeAdapterSnapshot } from '@storystick/preview/framework/runtime-adapter-store';
6
+ export interface PreviewStandaloneControlsProps {
7
+ projectId: string | null;
8
+ state: PreviewRuntimeAdapterSnapshot;
9
+ basePath?: string;
10
+ pathname?: string | null;
11
+ recovery?: {
12
+ error: string | null;
13
+ retry: () => void;
14
+ };
15
+ }
16
+ /** Minimal host: one sleeping bubble, one compact menu and one shared editor. */
17
+ export declare function PreviewStandaloneControls({ state, projectId, recovery, basePath, pathname, }: PreviewStandaloneControlsProps): ReactElement | null;
18
+ export declare function targetForOverlay(target: PreviewOverlayClickTarget, perspective: PreviewReadContext['perspective'] | null, presentationLocale?: string): PreviewAuthoringTarget | null;
@@ -0,0 +1,528 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { useMomentaryPreviewInteractionOverride } from '@storystick/react/internal/use-momentary-preview-navigate-mode';
4
+ import { PreviewPanelAction, PreviewPanelState } from '@storystick/react/internal/preview-panel-state';
5
+ import { AuthoringPanelSkeleton } from '@storystick/react/internal/authoring-skeleton';
6
+ import { useStandalonePresentation } from './use-standalone-presentation.js';
7
+ import { PreviewInteractionControl } from '@storystick/react/internal/preview-interaction-control';
8
+ import { PreviewToolbarShell, PreviewEditIcon as EditIcon, PreviewAssistantIcon as ChatIcon, PreviewStudioIcon as StudioIcon, } from '@storystick/react/internal/preview-toolbar-shell';
9
+ import { readStandaloneStudioUrl, readStandaloneInteractionMode, rememberStandaloneInteractionMode, } from '@storystick/preview/identity/browser-context';
10
+ import { readStandaloneOverlaySnapshot, revealStandaloneSource, revealStandaloneSlice, setStandaloneOverlayMode, setStandaloneOverlaySuspended, subscribeStandaloneOverlayIntent, subscribeStandaloneOverlaySnapshot, } from '@storystick/preview/runtime/standalone-overlays';
11
+ import { overlayEditorTarget, overlayEditorLocale, } from '@storystick/preview/provenance';
12
+ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, } from 'react';
13
+ import { clearPreviewEditorNavigation, readPreviewEditorNavigation, rememberPreviewEditorNavigation, } from './preview-editor-continuation.js';
14
+ import { previewPerspectivesEqual } from '@storystick/preview/runtime/preview-perspective-alignment';
15
+ import { emptyPrimaryPagesSnapshot, previewCurrentPrimaryPage, previewStudioDestination, readPrimaryPagesSnapshot, subscribePrimaryPages, } from './preview-studio-destination.js';
16
+ import { previewRuntime } from '@storystick/preview/runtime/store';
17
+ import { STORYSTICK_PREVIEW_AUTHORING_ROUTE_PATH, storystickRouteForBasePath, } from '@storystick/preview/routes';
18
+ import { authoringRuntimeUrl, loadPreviewAuthoringRuntime, requestPreviewAuthoringAdmission, } from './preview-authoring-client.js';
19
+ import { readPreviewAssistantNavigation, rememberPreviewAssistantNavigation } from './preview-assistant-continuation.js';
20
+ const EMPTY_OVERLAYS = Object.freeze({
21
+ mode: 'browse',
22
+ available: false,
23
+ generation: null,
24
+ rejections: [],
25
+ });
26
+ /** Minimal host: one sleeping bubble, one compact menu and one shared editor. */
27
+ export function PreviewStandaloneControls({ state, projectId, recovery, basePath = '', pathname, }) {
28
+ const presentation = useStandalonePresentation(basePath);
29
+ const host = useRef(null);
30
+ const editorContainer = useRef(null);
31
+ const [headerLeading, setHeaderLeading] = useState(null);
32
+ const [headerTitle, setHeaderTitle] = useState(null);
33
+ const [headerTrailing, setHeaderTrailing] = useState(null);
34
+ const [contextContainer, setContextContainer] = useState(null);
35
+ const [interactionMode, setInteractionMode] = useState(() => readStandaloneInteractionMode(projectId, state.sessionId));
36
+ useEffect(() => {
37
+ setInteractionMode(readStandaloneInteractionMode(projectId, state.sessionId));
38
+ }, [projectId, state.sessionId]);
39
+ const chooseInteractionMode = (mode) => {
40
+ setInteractionMode(mode);
41
+ if (projectId && state.sessionId) {
42
+ rememberStandaloneInteractionMode({ projectId, sessionId: state.sessionId }, mode);
43
+ }
44
+ };
45
+ const isLocalPreviewEvent = useCallback((event) => !event.composedPath().includes(host.current), []);
46
+ const momentaryMode = useMomentaryPreviewInteractionOverride({
47
+ enabled: state.state === 'authenticated',
48
+ persistentMode: interactionMode,
49
+ isLocalPreviewEvent,
50
+ });
51
+ const effectiveMode = momentaryMode ?? interactionMode;
52
+ useEffect(() => {
53
+ setStandaloneOverlayMode(effectiveMode);
54
+ }, [effectiveMode]);
55
+ const runtimeHandle = useRef(null);
56
+ const runtimeOwner = useRef(null);
57
+ const admission = useRef(null);
58
+ const targetRef = useRef(null);
59
+ const [continuation, setContinuation] = useState(() => readPreviewEditorNavigation(projectId, state.sessionId));
60
+ const navigationHandoff = useRef(continuation);
61
+ const mounted = useRef(true);
62
+ const [root, setRoot] = useState(null);
63
+ const [assistantNavigation] = useState(() => readPreviewAssistantNavigation(projectId, state.sessionId));
64
+ const assistantThreadId = useRef(assistantNavigation?.threadId ?? null);
65
+ const activeSurface = useRef(null);
66
+ const [surface, setSurface] = useState(continuation ? 'editor' : assistantNavigation?.open ? 'assistant' : null);
67
+ const [menuOpen, setMenuOpen] = useState(false);
68
+ activeSurface.current = surface;
69
+ const [committedTarget, setCommittedTarget] = useState(null);
70
+ const rememberAssistant = useCallback((open) => {
71
+ if (projectId && state.sessionId)
72
+ rememberPreviewAssistantNavigation({ projectId, sessionId: state.sessionId, threadId: assistantThreadId.current, open });
73
+ }, [projectId, state.sessionId]);
74
+ useEffect(() => {
75
+ runtimeHandle.current?.setSurface(surface === 'editor' || surface === 'assistant' ? surface : null);
76
+ rememberAssistant(surface === 'assistant');
77
+ }, [surface, rememberAssistant]);
78
+ const [editorStatus, setEditorStatus] = useState('idle');
79
+ const [targetRequest, setTargetRequest] = useState(null);
80
+ const target = targetRequest?.target ?? null;
81
+ const [connectAttempt, setConnectAttempt] = useState(0);
82
+ const [error, setError] = useState(null);
83
+ useEffect(() => {
84
+ const next = readPreviewEditorNavigation(projectId, state.sessionId);
85
+ if (!next || runtimeHandle.current)
86
+ return;
87
+ navigationHandoff.current = next;
88
+ setContinuation(next);
89
+ setSurface('editor');
90
+ }, [projectId, state.sessionId]);
91
+ const evidence = useSyncExternalStore(subscribePrimaryPages, readPrimaryPagesSnapshot, emptyPrimaryPagesSnapshot);
92
+ const overlays = useSyncExternalStore(subscribeStandaloneOverlaySnapshot, readStandaloneOverlaySnapshot, () => EMPTY_OVERLAYS);
93
+ const browserPathname = typeof window === 'undefined' ? (pathname ?? '/') : window.location.pathname;
94
+ const destination = previewStudioDestination({
95
+ studioUrl: state.standaloneStudioUrl ??
96
+ readStandaloneStudioUrl(projectId, state.sessionId),
97
+ projectId,
98
+ sessionId: state.sessionId,
99
+ context: state.readContext,
100
+ authenticated: state.state === 'authenticated' && state.readContextStatus === 'active',
101
+ browserPathname,
102
+ basePath,
103
+ evidence,
104
+ });
105
+ const page = state.readContext === null
106
+ ? null
107
+ : previewCurrentPrimaryPage(evidence, state.sessionId, state.readContext, browserPathname, basePath);
108
+ const currentPageTarget = useMemo(() => page && state.readContext
109
+ ? targetForPage(page, state.readContext.perspective)
110
+ : null, [page, state.readContext]);
111
+ const studioUrl = state.standaloneStudioUrl ?? readStandaloneStudioUrl(projectId, state.sessionId);
112
+ const runtimeSource = studioUrl
113
+ ? authoringRuntimeUrl(studioUrl)
114
+ : null;
115
+ const authoringUrl = storystickRouteForBasePath(basePath, STORYSTICK_PREVIEW_AUTHORING_ROUTE_PATH);
116
+ const perspective = state.readContext?.perspective;
117
+ const rendered = state.readContext !== null &&
118
+ state.renderedReadContext?.contextId === state.readContext.contextId &&
119
+ state.renderedReadContext?.revision === state.readContext.revision;
120
+ const connected = state.state === 'authenticated' && state.readContextStatus === 'active';
121
+ const connectionNotice = state.state === 'error' || recovery?.error
122
+ ? 'Preview unavailable'
123
+ : !connected
124
+ ? 'Connecting to Preview…'
125
+ : null;
126
+ const notice = state.readContextNotice === 'release-published'
127
+ ? 'This release was published. Preview now follows Main, including its drafts.'
128
+ : state.readContextNotice === 'release-archived'
129
+ ? 'This release was archived. Preview now follows Main, including its drafts.'
130
+ : null;
131
+ const interactionSuspended = !connected || !rendered || presentation.transition.kind !== 'idle';
132
+ // Render proof protects page overlays. An accepted editor gesture can load
133
+ // and edit its target while the site catches up, under the shared admission.
134
+ const acceptedEditorContext = state.readContext != null &&
135
+ presentation.acceptedContext?.contextId === state.readContext.contextId &&
136
+ presentation.acceptedContext?.revision === state.readContext.revision;
137
+ const editorSuspended = !connected || (!rendered && !acceptedEditorContext);
138
+ const interactionSuspendedRef = useRef(interactionSuspended);
139
+ interactionSuspendedRef.current = interactionSuspended;
140
+ useEffect(() => {
141
+ setStandaloneOverlaySuspended(interactionSuspended);
142
+ }, [interactionSuspended]);
143
+ const canEdit = !interactionSuspended &&
144
+ connected &&
145
+ rendered &&
146
+ runtimeSource !== null &&
147
+ (currentPageTarget !== null || overlays.available);
148
+ const overlayNotice = overlays.rejections.length > 0
149
+ ? overlays.rejections.some(rejection => rejection.reason === 'edit-locator-variant-mismatch')
150
+ ? 'Element selection is unavailable because some elements do not match their editing context.'
151
+ : 'Element selection is unavailable because this page has incomplete or conflicting editing information.'
152
+ : null;
153
+ const shouldPreloadRuntime = canEdit && (interactionMode === 'edit' || menuOpen);
154
+ useEffect(() => {
155
+ if (!shouldPreloadRuntime || !runtimeSource)
156
+ return;
157
+ // Warm the common runtime; panel dependencies follow a specific intent.
158
+ // Admission and mounting stay separate from this shared code preparation.
159
+ void loadPreviewAuthoringRuntime(runtimeSource).catch(() => undefined);
160
+ }, [shouldPreloadRuntime, runtimeSource]);
161
+ const preloadPanel = useCallback((surface) => {
162
+ if (runtimeSource)
163
+ void loadPreviewAuthoringRuntime(runtimeSource, surface).catch(() => undefined);
164
+ }, [runtimeSource]);
165
+ const currentPage = useRef(page);
166
+ currentPage.current = page;
167
+ const presentationRef = useRef(presentation);
168
+ presentationRef.current = presentation;
169
+ const contextRef = useRef(state.readContext);
170
+ contextRef.current = state.readContext;
171
+ const sessionRef = useRef(state.sessionId);
172
+ sessionRef.current = state.sessionId;
173
+ const contextListeners = useRef(new Set());
174
+ useEffect(() => {
175
+ // The authoring controls observe both context and presentation. A locale
176
+ // or native page navigation can change without revising the read context.
177
+ contextListeners.current.forEach(listener => listener());
178
+ }, [state.readContext, page, presentation.acceptedContext, presentation.acceptedPresentation]);
179
+ const requestEditorTarget = useCallback((next) => {
180
+ setTargetRequest(next ? {
181
+ target: next,
182
+ context: contextRef.current,
183
+ sessionId: sessionRef.current,
184
+ location: window.location.href,
185
+ } : null);
186
+ }, []);
187
+ const observedPage = useRef(null);
188
+ const followedGeneration = useRef(0);
189
+ useEffect(() => {
190
+ if (presentation.transition.kind !== 'idle' || !navigationHandoff.current)
191
+ return;
192
+ // Only confirmed rendering consumes a successful hard-navigation hint.
193
+ // Acceptance alone is earlier than a potential document replacement.
194
+ if (presentation.completedGeneration > 0) {
195
+ clearPreviewEditorNavigation(navigationHandoff.current.id);
196
+ navigationHandoff.current = null;
197
+ }
198
+ }, [presentation.transition.kind, presentation.completedGeneration]);
199
+ useEffect(() => {
200
+ if (!page || !state.readContext || !rendered || presentation.isPending)
201
+ return;
202
+ const previous = observedPage.current;
203
+ observedPage.current = {
204
+ documentId: page.documentId,
205
+ locale: page.locale,
206
+ contextId: state.readContext.contextId,
207
+ revision: state.readContext.revision,
208
+ };
209
+ const ownTransition = followedGeneration.current !== presentation.completedGeneration;
210
+ followedGeneration.current = presentation.completedGeneration;
211
+ if (!previous ||
212
+ ownTransition ||
213
+ !runtimeHandle.current ||
214
+ !targetRef.current)
215
+ return;
216
+ const navigated = previous.documentId !== page.documentId || previous.locale !== page.locale;
217
+ const changedScope = previous.contextId !== state.readContext.contextId ||
218
+ previous.revision !== state.readContext.revision;
219
+ if (!navigated && !changedScope)
220
+ return;
221
+ const next = targetForPage(navigated ? page : { ...page,
222
+ documentId: targetRef.current.documentId,
223
+ locale: targetRef.current.locale,
224
+ }, state.readContext.perspective);
225
+ requestEditorTarget(next);
226
+ }, [
227
+ page,
228
+ state.readContext,
229
+ rendered,
230
+ presentation.isPending,
231
+ presentation.completedGeneration,
232
+ requestEditorTarget,
233
+ ]);
234
+ const closePanel = useCallback(() => {
235
+ if (navigationHandoff.current) {
236
+ clearPreviewEditorNavigation(navigationHandoff.current.id);
237
+ navigationHandoff.current = null;
238
+ }
239
+ setContinuation(null);
240
+ runtimeHandle.current?.setSurface(null);
241
+ setSurface(null);
242
+ }, []);
243
+ const openEditor = useCallback((next) => {
244
+ if (navigationHandoff.current) {
245
+ clearPreviewEditorNavigation(navigationHandoff.current.id);
246
+ navigationHandoff.current = null;
247
+ }
248
+ setContinuation(null);
249
+ setError(null);
250
+ setMenuOpen(false);
251
+ setSurface('editor');
252
+ runtimeHandle.current?.setSurface('editor');
253
+ requestEditorTarget(next);
254
+ }, [requestEditorTarget]);
255
+ useEffect(() => {
256
+ if (!continuation || !canEdit || !page || !perspective)
257
+ return;
258
+ if (page.documentId !== continuation.destination.documentId ||
259
+ page.locale !== continuation.destination.locale ||
260
+ !previewPerspectivesEqual(perspective, continuation.destination.perspective))
261
+ return;
262
+ // Restore only after the same render proof used by the live transition.
263
+ // The editor still obtains a fresh admission through its normal mount.
264
+ openEditor(continuation.target);
265
+ }, [continuation, canEdit, page, perspective, openEditor]);
266
+ useEffect(() => subscribeStandaloneOverlayIntent((intent) => {
267
+ if (interactionSuspendedRef.current)
268
+ return;
269
+ const next = targetForOverlay(intent, state.readContext?.perspective ?? null, page?.locale);
270
+ if (next)
271
+ openEditor(next);
272
+ }), [openEditor, state.readContext?.perspective, page?.locale]);
273
+ const wantsRuntime = surface !== null || menuOpen;
274
+ useEffect(() => {
275
+ if (!wantsRuntime ||
276
+ !connected ||
277
+ (surface === 'editor' && !targetRequest) ||
278
+ root === null ||
279
+ editorContainer.current === null ||
280
+ contextContainer === null || headerLeading === null || headerTitle === null || headerTrailing === null ||
281
+ runtimeSource === null ||
282
+ runtimeHandle.current !== null)
283
+ return;
284
+ let cancelled = false;
285
+ const container = editorContainer.current;
286
+ const requestedContext = contextRef.current;
287
+ const requestedSession = sessionRef.current;
288
+ const requestedLocation = window.location.href;
289
+ const connect = async () => {
290
+ setEditorStatus('connecting');
291
+ setError(null);
292
+ try {
293
+ const [runtime, initialAdmission] = await Promise.all([
294
+ loadPreviewAuthoringRuntime(runtimeSource, surface),
295
+ requestPreviewAuthoringAdmission({ url: authoringUrl }),
296
+ ]);
297
+ if (cancelled)
298
+ return;
299
+ if ((surface === 'editor' && interactionSuspendedRef.current) ||
300
+ requestedSession !== sessionRef.current ||
301
+ requestedLocation !== window.location.href ||
302
+ requestedContext?.contextId !== contextRef.current?.contextId ||
303
+ requestedContext?.revision !== contextRef.current?.revision) {
304
+ throw new Error('PREVIEW_PRESENTATION_SUPERSEDED');
305
+ }
306
+ admission.current = initialAdmission;
307
+ const owner = Symbol();
308
+ runtimeOwner.current = owner;
309
+ // This is the initial editor destination, before Convex authentication
310
+ // can report onTarget. Later requests only commit through onTarget.
311
+ targetRef.current = surface === 'editor' ? targetRequest.target : null;
312
+ const handle = runtime.mount({
313
+ root,
314
+ container,
315
+ header: { leading: headerLeading, title: headerTitle, trailing: headerTrailing },
316
+ target: targetRef.current ?? undefined,
317
+ surface,
318
+ assistantThreadId: assistantThreadId.current,
319
+ onSurface: next => { if (runtimeOwner.current === owner) {
320
+ setMenuOpen(false);
321
+ setSurface(next);
322
+ } },
323
+ onAssistantThread: id => {
324
+ if (runtimeOwner.current !== owner)
325
+ return;
326
+ assistantThreadId.current = id;
327
+ rememberAssistant(activeSurface.current === 'assistant');
328
+ },
329
+ context: {
330
+ container: contextContainer,
331
+ getSnapshot: () => previewRuntime.getState().session?.readContext ?? null,
332
+ subscribe: listener => {
333
+ contextListeners.current.add(listener);
334
+ return () => { contextListeners.current.delete(listener); };
335
+ },
336
+ change: next => presentationRef.current.changePerspective(next),
337
+ },
338
+ studioUrl: destination?.href,
339
+ revealSource: revealStandaloneSource,
340
+ revealSlice: revealStandaloneSlice,
341
+ onTarget: (next) => {
342
+ if (runtimeOwner.current !== owner)
343
+ return;
344
+ targetRef.current = next;
345
+ setCommittedTarget(next);
346
+ },
347
+ getPresentation: () => {
348
+ const hostPage = currentPage.current;
349
+ const context = contextRef.current;
350
+ const accepted = presentationRef.current;
351
+ if (context && accepted.acceptedContext?.contextId === context.contextId &&
352
+ accepted.acceptedContext.revision === context.revision &&
353
+ accepted.acceptedPresentation)
354
+ return accepted.acceptedPresentation;
355
+ return hostPage && context
356
+ ? {
357
+ documentId: hostPage.documentId,
358
+ locale: hostPage.locale,
359
+ pathname: window.location.pathname.slice(basePath.length) || '/',
360
+ perspective: context.perspective,
361
+ }
362
+ : null;
363
+ },
364
+ present: async (request, nextTarget) => {
365
+ const handoff = projectId && state.sessionId && nextTarget
366
+ ? rememberPreviewEditorNavigation(projectId, state.sessionId, basePath, request, nextTarget)
367
+ : null;
368
+ navigationHandoff.current = handoff;
369
+ try {
370
+ return await presentationRef.current.present(request);
371
+ }
372
+ catch (cause) {
373
+ // A new browser document owns the handoff after pagehide.
374
+ if (mounted.current && handoff) {
375
+ clearPreviewEditorNavigation(handoff.id);
376
+ if (navigationHandoff.current === handoff)
377
+ navigationHandoff.current = null;
378
+ }
379
+ throw cause;
380
+ }
381
+ },
382
+ admission: initialAdmission,
383
+ refreshAdmission: async () => {
384
+ const next = await requestPreviewAuthoringAdmission({
385
+ url: authoringUrl,
386
+ previous: admission.current,
387
+ });
388
+ admission.current = next;
389
+ return next;
390
+ },
391
+ closeAdmission: () => {
392
+ admission.current = null;
393
+ },
394
+ onStatus: (status) => {
395
+ if (runtimeOwner.current !== owner)
396
+ return;
397
+ if (status === 'disposed') {
398
+ runtimeOwner.current = null;
399
+ runtimeHandle.current = null;
400
+ admission.current = null;
401
+ }
402
+ if (status === 'ready')
403
+ setEditorStatus('ready');
404
+ if (status === 'connecting')
405
+ setEditorStatus('connecting');
406
+ if (status === 'unauthorized') {
407
+ setEditorStatus('error');
408
+ setError('Editing access ended. Reopen Preview from Studio.');
409
+ }
410
+ },
411
+ });
412
+ if (cancelled) {
413
+ if (runtimeOwner.current === owner)
414
+ runtimeOwner.current = null;
415
+ await handle.dispose();
416
+ return;
417
+ }
418
+ runtimeHandle.current = handle;
419
+ handle.setSurface(surface === 'editor' || surface === 'assistant' ? surface : null);
420
+ }
421
+ catch (cause) {
422
+ if (!cancelled) {
423
+ setEditorStatus('error');
424
+ setError(authoringErrorMessage(cause));
425
+ }
426
+ }
427
+ };
428
+ void connect();
429
+ return () => {
430
+ cancelled = true;
431
+ };
432
+ }, [authoringUrl, connectAttempt, targetRequest, root, contextContainer, headerLeading, headerTitle, headerTrailing, runtimeSource, surface, wantsRuntime, connected]);
433
+ useEffect(() => {
434
+ if (!targetRequest || !runtimeHandle.current)
435
+ return;
436
+ const handle = runtimeHandle.current;
437
+ let current = true;
438
+ const isCurrent = () => current && runtimeHandle.current === handle &&
439
+ !interactionSuspendedRef.current &&
440
+ sessionRef.current === targetRequest.sessionId &&
441
+ targetRequest.context !== null &&
442
+ contextRef.current?.contextId === targetRequest.context.contextId &&
443
+ contextRef.current.revision === targetRequest.context.revision &&
444
+ window.location.href === targetRequest.location;
445
+ setEditorStatus('connecting');
446
+ void handle.open(targetRequest.target, isCurrent).then(() => {
447
+ // A cancelled opening leaves the retained editor usable. Its onTarget
448
+ // callback, not this transport status, owns the committed destination.
449
+ if (current)
450
+ setEditorStatus('ready');
451
+ }, (cause) => {
452
+ if (!isCurrent())
453
+ return;
454
+ setEditorStatus('error');
455
+ setError(authoringErrorMessage(cause));
456
+ });
457
+ return () => {
458
+ current = false;
459
+ };
460
+ }, [targetRequest]);
461
+ useEffect(() => {
462
+ mounted.current = true;
463
+ const dispose = () => {
464
+ mounted.current = false;
465
+ runtimeOwner.current = null;
466
+ void runtimeHandle.current?.dispose();
467
+ runtimeHandle.current = null;
468
+ };
469
+ window.addEventListener('pagehide', dispose);
470
+ return () => {
471
+ window.removeEventListener('pagehide', dispose);
472
+ dispose();
473
+ };
474
+ }, []);
475
+ return _jsx(PreviewToolbarShell, { hostRef: host, onRoot: setRoot, menuOpen: menuOpen, onMenuOpenChange: setMenuOpen, panelOpen: surface !== null, onPanelClose: closePanel, panelTitle: _jsx("span", { ref: setHeaderTitle, className: "panel-title", "data-placeholder": surface === "assistant" ? "Assistant" : "Edit content" }), panelLeading: _jsx("div", { ref: setHeaderLeading, "data-ss-surface": "" }), panelTrailing: _jsx("div", { ref: setHeaderTrailing, "data-ss-surface": "" }), panelLabel: surface === "assistant" ? "Assistant" : "Edit content", menu: _jsxs(_Fragment, { children: [_jsxs("div", { className: "menu-group", role: "group", "aria-label": "Site view", children: [_jsx("span", { className: "menu-group-label", children: "Site view" }), _jsxs("div", { className: "menu-group-content", children: [_jsx("div", { ref: setContextContainer, "data-ss-surface": "" }), editorStatus === 'connecting' ? _jsxs("div", { className: "menu-context-loading", role: "status", "aria-label": "Loading site view", children: [_jsx("span", {}), _jsx("span", {})] }) : null] })] }), _jsx("div", { className: "menu-separator", "aria-hidden": "true" }), _jsxs("div", { className: "menu-group", role: "group", "aria-label": "Interaction", children: [_jsx("span", { className: "menu-group-label", children: "Interaction" }), _jsx("div", { className: "menu-group-content", children: _jsx(PreviewInteractionControl, { interactionMode: effectiveMode, onChange: chooseInteractionMode }) })] }), _jsx("div", { className: "menu-separator", "aria-hidden": "true" }), _jsxs("button", { type: "button", className: "menu-action", disabled: !canEdit, onMouseEnter: () => { if (canEdit)
476
+ preloadPanel('editor'); }, onFocus: () => { if (canEdit)
477
+ preloadPanel('editor'); }, onClick: () => openEditor(currentPageTarget), children: [_jsx(EditIcon, {}), _jsx("span", { children: "Edit content" })] }), _jsxs("button", { type: "button", className: "menu-action", disabled: !connected || !runtimeSource, onMouseEnter: () => { if (connected)
478
+ preloadPanel('assistant'); }, onFocus: () => { if (connected)
479
+ preloadPanel('assistant'); }, onClick: () => { setError(null); setMenuOpen(false); setSurface('assistant'); }, children: [_jsx(ChatIcon, {}), _jsx("span", { children: "Assistant" })] }), destination && (_jsxs("a", { className: "menu-action", href: destination.href, children: [_jsx(StudioIcon, {}), _jsx("span", { children: "Open in Studio" })] })), (connectionNotice || notice ||
480
+ overlayNotice ||
481
+ (!rendered && connected) ||
482
+ recovery?.error ||
483
+ error) && (_jsxs("div", { className: "notices", children: [connectionNotice && _jsx("p", { role: "status", children: connectionNotice }), notice && _jsx("p", { children: notice }), overlayNotice && _jsx("p", { role: "status", children: overlayNotice }), connected && !rendered && (_jsxs("p", { children: ["This page has not confirmed its rendered perspective.", ' ', _jsx("button", { type: "button", onClick: () => window.location.reload(), children: "Reload" })] })), recovery?.error && (_jsxs("p", { role: "alert", children: [recovery.error, ' ', _jsx("button", { type: "button", onClick: recovery.retry, disabled: false, children: "Retry" })] })), error && _jsxs("p", { role: "alert", children: [error, ' ', editorStatus === 'error' && !runtimeHandle.current ? (_jsx("button", { type: "button", onClick: () => setConnectAttempt(value => value + 1), children: "Retry" })) : null] })] }))] }), panel: _jsxs(_Fragment, { children: [surface === 'editor' && target === null && committedTarget === null && !continuation && (_jsxs("div", { className: "editor-empty", children: [_jsx(EditIcon, {}), _jsx("strong", { children: "Select an element on the page" }), _jsx("p", { children: "The matching field will open here." })] })), (editorStatus === 'idle' || editorStatus === 'connecting') &&
484
+ (surface === 'assistant' || target || continuation) && (_jsx("div", { className: "editor-loading", children: _jsx(AuthoringPanelSkeleton, { surface: surface === 'assistant' ? 'assistant' : 'editor' }) })), editorStatus === 'error' && error && (_jsx("div", { className: "panel-state-overlay", children: _jsx(PreviewPanelState, { title: surface === 'assistant' ? 'Assistant unavailable' : 'Editor unavailable', description: error, actions: _jsx(PreviewPanelAction, { onClick: () => {
485
+ setError(null);
486
+ if (runtimeHandle.current && (committedTarget ?? target))
487
+ requestEditorTarget({ ...(committedTarget ?? target) });
488
+ else
489
+ setConnectAttempt((value) => value + 1);
490
+ }, children: "Retry" }) }) })), surface === 'editor' && presentation.transition.kind === 'unconfirmed' && (_jsx("div", { className: "panel-state-overlay", children: _jsx(PreviewPanelState, { title: "Page not confirmed", description: "The site has not confirmed this page. Reload the preview if it does not catch up.", actions: _jsx(PreviewPanelAction, { onClick: () => window.location.reload(), children: "Reload preview" }) }) })), _jsx("div", { ref: editorContainer, className: "editor-mount", inert: (surface === 'editor' ? editorSuspended : !connected) || editorStatus !== 'ready', "aria-busy": (surface === 'editor' && editorSuspended) || editorStatus === 'connecting' })] }) });
491
+ }
492
+ function targetForPage(page, perspective) {
493
+ return {
494
+ documentId: page.documentId,
495
+ locale: page.locale,
496
+ ...(perspective.kind === 'published' ? { published: true } : perspective.kind === 'variant'
497
+ ? { variantId: perspective.variantId }
498
+ : perspective.kind === 'release'
499
+ ? { releaseId: perspective.releaseId }
500
+ : {}),
501
+ };
502
+ }
503
+ export function targetForOverlay(target, perspective, presentationLocale) {
504
+ const locale = overlayEditorLocale(target, presentationLocale);
505
+ if (!locale)
506
+ return null;
507
+ return {
508
+ documentId: target.documentId,
509
+ locale,
510
+ ...(perspective?.kind === 'published' ? { published: true } : perspective?.kind === 'variant'
511
+ ? { variantId: perspective.variantId }
512
+ : perspective?.kind === 'release'
513
+ ? { releaseId: perspective.releaseId }
514
+ : {}),
515
+ position: overlayEditorTarget(target),
516
+ };
517
+ }
518
+ function authoringErrorMessage(error) {
519
+ const code = error instanceof Error ? error.message : '';
520
+ console.warn('[storystick] Preview authoring connection failed:', /^[A-Z][A-Z0-9_]{0,80}$/.test(code) ? code : 'AUTHORING_RUNTIME_ERROR');
521
+ if (code.includes('AUTHORING_WRITE_FORBIDDEN')) {
522
+ return 'You do not have permission to edit this project.';
523
+ }
524
+ if (code.includes('CONTENT_SHAPE_RELOAD_REQUIRED')) {
525
+ return 'Reopen Preview from Studio after the site deployment changed.';
526
+ }
527
+ return 'Editing is unavailable. Reopen Preview from Studio and try again.';
528
+ }
@@ -0,0 +1,20 @@
1
+ import type { PreviewReadContext } from '@storystick/types/preview-query-targeting';
2
+ import { type PreviewPrimaryPage } from '@storystick/preview/provenance';
3
+ /** Render evidence is navigation metadata. Studio still authorizes every document. */
4
+ export declare function previewStudioDestination(input: {
5
+ studioUrl: string | null;
6
+ projectId: string | null;
7
+ sessionId: string | null;
8
+ context: PreviewReadContext | null;
9
+ authenticated: boolean;
10
+ browserPathname: string;
11
+ basePath: string;
12
+ evidence: unknown;
13
+ }): {
14
+ href: string;
15
+ kind: 'document' | 'project';
16
+ } | null;
17
+ export declare function previewCurrentPrimaryPage(evidence: unknown, sessionId: string | null, context: PreviewReadContext, browserPathname: string, basePath: string): PreviewPrimaryPage | null;
18
+ export declare function readPrimaryPagesSnapshot(): unknown;
19
+ export declare function subscribePrimaryPages(listener: () => void): () => void;
20
+ export declare const emptyPrimaryPagesSnapshot: () => null;
@@ -0,0 +1,55 @@
1
+ import { isPreviewReadContext } from '@storystick/types/preview-query-targeting';
2
+ import { selectPreviewPrimaryPage } from '@storystick/preview/provenance';
3
+ /** Render evidence is navigation metadata. Studio still authorizes every document. */
4
+ export function previewStudioDestination(input) {
5
+ if (!input.studioUrl || !input.projectId)
6
+ return null;
7
+ let url;
8
+ try {
9
+ url = new URL(input.studioUrl);
10
+ }
11
+ catch {
12
+ return null;
13
+ }
14
+ const [, prefix, project] = url.pathname.split('/');
15
+ if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password || prefix !== 'p' ||
16
+ !(project === input.projectId || project?.endsWith(`-${input.projectId}`)))
17
+ return null;
18
+ url.pathname = `/p/${project}`;
19
+ url.search = '';
20
+ url.hash = '';
21
+ const context = input.authenticated ? input.context : null;
22
+ if (context && input.sessionId)
23
+ url.searchParams.set('previewSource', input.sessionId);
24
+ if (context?.perspective.kind === 'published')
25
+ url.searchParams.set('perspective', 'published');
26
+ if (context?.perspective.kind === 'release')
27
+ url.searchParams.set('release', context.perspective.releaseId);
28
+ const page = context ? previewCurrentPrimaryPage(input.evidence, input.sessionId, context, input.browserPathname, input.basePath) : null;
29
+ if (page) {
30
+ url.pathname += `/d/${encodeURIComponent(page.documentId)}`;
31
+ url.searchParams.set('locale', page.locale);
32
+ url.searchParams.set('panel', 'preview');
33
+ if (context?.perspective.kind === 'variant')
34
+ url.searchParams.set('previewVariant', context.perspective.variantId);
35
+ }
36
+ return { href: url.href, kind: page ? 'document' : 'project' };
37
+ }
38
+ export function previewCurrentPrimaryPage(evidence, sessionId, context, browserPathname, basePath) {
39
+ if (!evidence || typeof evidence !== 'object')
40
+ return null;
41
+ const value = evidence;
42
+ if (!sessionId || value.sessionId !== sessionId || !isPreviewReadContext(value.readContext) ||
43
+ value.readContext.contextId !== context.contextId || value.readContext.revision !== context.revision ||
44
+ value.pathname !== browserPathname || !Array.isArray(value.pages))
45
+ return null;
46
+ return selectPreviewPrimaryPage(value.pages, browserPathname, basePath);
47
+ }
48
+ export function readPrimaryPagesSnapshot() {
49
+ return typeof window === 'undefined' ? null : window.__ssPreviewPrimaryPages ?? null;
50
+ }
51
+ export function subscribePrimaryPages(listener) {
52
+ window.addEventListener('storystick.preview.primary-pages', listener);
53
+ return () => window.removeEventListener('storystick.preview.primary-pages', listener);
54
+ }
55
+ export const emptyPrimaryPagesSnapshot = () => null;
@@ -0,0 +1,15 @@
1
+ import { type PreviewTransitionState } from '@storystick/preview/runtime/preview-transition-state';
2
+ import type { PreviewAuthoringPresentation } from '@storystick/preview/authoring/runtime-contract';
3
+ import type { PreviewPerspective, PreviewReadContext } from '@storystick/types/preview-query-targeting';
4
+ /** Acceptance releases the editor; render proof releases the page overlays.
5
+ * No authoring credentials cross this port; the framework uses its Preview cookie. */
6
+ export declare function useStandalonePresentation(basePath: string): {
7
+ present: (request: PreviewAuthoringPresentation) => Promise<PreviewReadContext>;
8
+ /** The project menu also exists on routes without an editable document. */
9
+ changePerspective(next: PreviewPerspective): Promise<PreviewReadContext>;
10
+ transition: PreviewTransitionState;
11
+ completedGeneration: number;
12
+ acceptedContext: PreviewReadContext | null;
13
+ acceptedPresentation: PreviewAuthoringPresentation | null;
14
+ isPending: boolean;
15
+ };