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

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 (102) hide show
  1. package/LICENSE +112 -0
  2. package/NOTICE +17 -0
  3. package/PUBLIC-API.md +36 -0
  4. package/README.md +226 -2
  5. package/dist/.source-hash +1 -0
  6. package/dist/file.d.ts +18 -0
  7. package/dist/file.js +44 -0
  8. package/dist/hydrate-slice.d.ts +3 -0
  9. package/dist/hydrate-slice.js +1 -0
  10. package/dist/image-component.d.ts +26 -0
  11. package/dist/image-component.js +79 -0
  12. package/dist/image-element.d.ts +6 -0
  13. package/dist/image-element.js +68 -0
  14. package/dist/image.d.ts +3 -0
  15. package/dist/image.js +3 -0
  16. package/dist/index.d.ts +1 -0
  17. package/dist/index.js +2 -0
  18. package/dist/link-adapter.d.ts +2 -0
  19. package/dist/link-adapter.js +2 -0
  20. package/dist/link-component.d.ts +53 -0
  21. package/dist/link-component.js +97 -0
  22. package/dist/link.d.ts +3 -0
  23. package/dist/link.js +3 -0
  24. package/dist/preview-generation.d.ts +21 -0
  25. package/dist/preview-generation.js +134 -0
  26. package/dist/preview-host/preview-assistant-continuation.d.ts +9 -0
  27. package/dist/preview-host/preview-assistant-continuation.js +39 -0
  28. package/dist/preview-host/preview-authoring-client.d.ts +12 -0
  29. package/dist/preview-host/preview-authoring-client.js +100 -0
  30. package/dist/preview-host/preview-editor-continuation.d.ts +13 -0
  31. package/dist/preview-host/preview-editor-continuation.js +68 -0
  32. package/dist/preview-host/preview-standalone-controls.d.ts +18 -0
  33. package/dist/preview-host/preview-standalone-controls.js +518 -0
  34. package/dist/preview-host/preview-studio-destination.d.ts +20 -0
  35. package/dist/preview-host/preview-studio-destination.js +55 -0
  36. package/dist/preview-host/use-standalone-presentation.d.ts +15 -0
  37. package/dist/preview-host/use-standalone-presentation.js +223 -0
  38. package/dist/preview-icons.d.ts +10 -0
  39. package/dist/preview-icons.js +8 -0
  40. package/dist/preview-interaction-control.d.ts +6 -0
  41. package/dist/preview-interaction-control.js +39 -0
  42. package/dist/preview-panel-state.d.ts +15 -0
  43. package/dist/preview-panel-state.js +28 -0
  44. package/dist/preview-provenance-publisher-model.d.ts +5 -0
  45. package/dist/preview-provenance-publisher-model.js +9 -0
  46. package/dist/preview-provenance-publisher.d.ts +15 -0
  47. package/dist/preview-provenance-publisher.js +68 -0
  48. package/dist/preview-provenance.d.ts +6 -0
  49. package/dist/preview-provenance.js +4 -0
  50. package/dist/preview-scope.d.ts +128 -0
  51. package/dist/preview-scope.js +161 -0
  52. package/dist/preview-slice-scope.d.ts +32 -0
  53. package/dist/preview-slice-scope.js +33 -0
  54. package/dist/preview-text-props.d.ts +18 -0
  55. package/dist/preview-text-props.js +13 -0
  56. package/dist/preview-toolbar-layout.d.ts +42 -0
  57. package/dist/preview-toolbar-layout.js +139 -0
  58. package/dist/preview-toolbar-panel.d.ts +13 -0
  59. package/dist/preview-toolbar-panel.js +6 -0
  60. package/dist/preview-toolbar-resize-handles.d.ts +9 -0
  61. package/dist/preview-toolbar-resize-handles.js +14 -0
  62. package/dist/preview-toolbar-shell.d.ts +18 -0
  63. package/dist/preview-toolbar-shell.js +138 -0
  64. package/dist/public-rich-text.d.ts +15 -0
  65. package/dist/public-rich-text.js +11 -0
  66. package/dist/public-slice-zone.d.ts +7 -0
  67. package/dist/public-slice-zone.js +6 -0
  68. package/dist/query-cache.d.ts +14 -0
  69. package/dist/query-cache.js +289 -0
  70. package/dist/query-preview-loader.d.ts +9 -0
  71. package/dist/query-preview-loader.js +28 -0
  72. package/dist/query-preview-runtime.d.ts +3 -0
  73. package/dist/query-preview-runtime.js +3 -0
  74. package/dist/rendering.d.ts +29 -0
  75. package/dist/rendering.js +17 -0
  76. package/dist/resize-edge.d.ts +8 -0
  77. package/dist/resize-edge.js +33 -0
  78. package/dist/rich-text.d.ts +150 -0
  79. package/dist/rich-text.js +618 -0
  80. package/dist/slice-render-failure.d.ts +8 -0
  81. package/dist/slice-render-failure.js +46 -0
  82. package/dist/slice-zone.d.ts +82 -0
  83. package/dist/slice-zone.js +273 -0
  84. package/dist/slice.d.ts +16 -0
  85. package/dist/slice.js +46 -0
  86. package/dist/storystick-mark.d.ts +3 -0
  87. package/dist/storystick-mark.js +5 -0
  88. package/dist/use-generated-query.d.ts +37 -0
  89. package/dist/use-generated-query.js +308 -0
  90. package/dist/use-momentary-preview-navigate-mode.d.ts +11 -0
  91. package/dist/use-momentary-preview-navigate-mode.js +158 -0
  92. package/dist/use-preview-perspective-intent.d.ts +53 -0
  93. package/dist/use-preview-perspective-intent.js +227 -0
  94. package/dist/use-preview-pointer-drag.d.ts +16 -0
  95. package/dist/use-preview-pointer-drag.js +98 -0
  96. package/dist/use-preview-toolbar-layout.d.ts +36 -0
  97. package/dist/use-preview-toolbar-layout.js +141 -0
  98. package/dist/use-render-mode-debounce.d.ts +31 -0
  99. package/dist/use-render-mode-debounce.js +143 -0
  100. package/dist/video.d.ts +21 -0
  101. package/dist/video.js +63 -0
  102. package/package.json +146 -3
@@ -0,0 +1,161 @@
1
+ /**
2
+ * preview-scope — the SliceZone ↔ `Slice.*` prod-strip contract (ADR-129
3
+ * D3 / D4-amended).
4
+ *
5
+ * `Slice.section` emits the `data-ss-slice` binding attribute ONLY in
6
+ * preview. The runtime preview-mode signal cannot be a React context —
7
+ * a React Server Component cannot read context, so a context-gated
8
+ * `Slice.*` would be forced `'use client'` and stop being RSC-safe
9
+ * (empirically confirmed, ADR-129 vertical-slice de-risk, check C).
10
+ *
11
+ * Instead the signal travels on the one object `Slice.section` already
12
+ * receives: the `slice` prop. `<SliceZone>` is the single component
13
+ * that knows preview-vs-production (the `publishedAt`/`snapshotId`
14
+ * discriminator — see `slice-zone.tsx`). In preview it STAMPS each
15
+ * slice object it passes to a slice component with that slice's
16
+ * serialized per-slice model (ADR-129 D4/D9). `Slice.section` reads the
17
+ * stamp and emits it back as the `data-ss-model` attribute — on the
18
+ * slice's own root element, alongside `data-ss-slice`. Outside preview
19
+ * the slice is passed through unstamped and neither attribute is
20
+ * emitted — a true prod-strip, RSC-safe, zero hooks.
21
+ *
22
+ * D9 amendment (cross-model /review R5) — the per-slice model is a
23
+ * `data-ss-model` ATTRIBUTE, never a `<script>` node. A `<script>`
24
+ * sibling is `display:none` but still an element, so it shifts
25
+ * `:nth-child` / `first:` / `odd:` / `even:` for the customer's slices
26
+ * (preview ≠ production). An attribute on the slice's own element
27
+ * shifts nothing — the same rule that drove D4 from a marker-node to
28
+ * an attribute. With the island node gone, ADR-129 injects zero nodes.
29
+ *
30
+ * ── RSC AMENDMENT (2026-05-27) ──────────────────────────────────────
31
+ * The stamp was originally a module-local `Symbol` key. Next.js 15's
32
+ * RSC serializer rejects Symbol-keyed properties on objects passed
33
+ * from a Server Component to a Client Component with the error
34
+ * "Only plain objects can be passed to Client Components from
35
+ * Server Components. Objects with symbol properties like
36
+ * `storystick.previewScope` are not supported."
37
+ * Customer slice components are routinely `'use client'` (interactive
38
+ * forms, hooks, …); even when the slice component is RSC, its
39
+ * descendants are usually client components, so the stamped slice
40
+ * always ends up crossing the RSC→Client boundary in real apps. The
41
+ * Symbol was a non-starter on Next 15.
42
+ *
43
+ * The stamp is now a string-keyed property under a fresh, namespaced
44
+ * Storystick reserved key: `_ssPreview`. The earlier `_model` slot
45
+ * was rejected by codex R1 — `_model` is the ADR-053 model-linking
46
+ * sidecar key that `hydrateSlice` reads as a string model id, so
47
+ * reusing it would (a) overwrite the model link in preview and
48
+ * (b) false-positive `isPreviewScoped()` on every production-linked
49
+ * slice. The `_ss*` prefix mirrors the existing `data-ss-*` DOM
50
+ * attribute convention used by `Slice.*` (`data-ss-slice`,
51
+ * `data-ss-bind`, `data-ss-model`) — one namespace, no ambiguity.
52
+ *
53
+ * Codex R2 then raised a P2: a string key alone is forgeable — a
54
+ * customer could (in theory) define a slice field literally named
55
+ * `_ssPreview` of type `string`, and SliceZone would mistake it for
56
+ * a preview stamp. The mitigation is structural: the stamp value is
57
+ * not a bare string but a tagged object `{ v: 'ss-preview-1', model:
58
+ * <JSON> }`. `isPreviewScoped()` checks the tag, not just the key —
59
+ * a customer field with the same string-typed name is rejected by
60
+ * shape.
61
+ *
62
+ * Codex R3 P2 (accepted as documented tradeoff): a customer COULD
63
+ * still define a field literally named `_ssPreview` with an object
64
+ * value that exactly matches the tagged shape `{ v: 'ss-preview-1',
65
+ * model: <string> }`. Two reasons we accept this:
66
+ *
67
+ * 1. The `_<x>` prefix is the existing Storystick reserved
68
+ * namespace — `_type`, `_key`, `_variation`, `_model` all live
69
+ * there. Adding `_ssPreview` extends the established convention.
70
+ * The storyspec format documents leading-underscore field names
71
+ * as SDK-reserved; the schema generator doesn't emit them.
72
+ * 2. The Symbol-keyed alternative was the ONLY structurally
73
+ * unforgeable choice, and it is incompatible with Next 15's RSC
74
+ * serializer (the bug this fix addresses). There is no third
75
+ * design that satisfies BOTH "RSC-serializable" AND
76
+ * "structurally unforgeable from any customer field".
77
+ *
78
+ * If a customer in v∞ produces a field shaped exactly like the stamp,
79
+ * the failure mode is overwrite-in-preview only (production renders
80
+ * never call `stampPreviewScope`). Detection would be loud (the
81
+ * customer's component receives a model JSON instead of its data),
82
+ * and the schema reader is the right place to forbid `_<x>` field
83
+ * names — a follow-up scoped to the schema parser, not this SDK seam.
84
+ *
85
+ * `slice-zone.tsx::SLICE_META_KEYS` gains `_ssPreview` so
86
+ * `extractSliceFields` filters it out of the serialized model
87
+ * payload (no recursive self-embedding of the stamp into its own
88
+ * model). Customer iteration (`Object.keys`, `JSON.stringify` of the
89
+ * slice) sees one extra meta key under the same `_*` convention
90
+ * customers already filter when walking slice fields; in production
91
+ * the property is never stamped, so production renders are
92
+ * byte-identical to a slice with no preview machinery — the
93
+ * reference-stable fast path is preserved.
94
+ *
95
+ * Why this is solid:
96
+ * - Plain object property ⇒ RSC-safe by construction.
97
+ * - Fresh namespaced key + tagged value shape ⇒ unforgeable from a
98
+ * customer field of any type the schema generator can emit.
99
+ * - Filtered by `extractSliceFields` via `SLICE_META_KEYS` ⇒ doesn't
100
+ * recurse into the serialized model payload.
101
+ * - Production stays unstamped ⇒ no leak in prod, no reference churn.
102
+ * - The wire contract with `@storystick/preview` is the DOM
103
+ * `data-ss-model` attribute — that has not changed.
104
+ */
105
+ /**
106
+ * The reserved string key the SliceZone stamps onto a slice. The
107
+ * VALUE under this key is a tagged record (see `STAMP_VERSION`), not
108
+ * a bare string — so a customer slice field literally named
109
+ * `_ssPreview` of any plain JSON type will NOT false-positive
110
+ * `isPreviewScoped()` (mitigation for codex R2 P2). MUST be listed
111
+ * in `slice-zone.tsx::SLICE_META_KEYS` so `extractSliceFields`
112
+ * filters it out of the serialized per-slice model.
113
+ */
114
+ const PREVIEW_MODEL_KEY = '_ssPreview';
115
+ /**
116
+ * Tag value embedded in every preview stamp record. Bumping this
117
+ * literal would invalidate every existing stamp at runtime — useful
118
+ * if the stamp shape ever evolves (none planned in v1).
119
+ */
120
+ const STAMP_VERSION = 'ss-preview-1';
121
+ /**
122
+ * Return a shallow copy of `slice` carrying the preview-scope stamp —
123
+ * the serialized per-slice model string `<SliceZone>` built for it.
124
+ * A copy (never a mutation) so `hydrateSlice`'s no-mutation invariant
125
+ * holds and the customer's query-cache entry is untouched. The copy
126
+ * costs one object identity per slice per render — accepted: it is
127
+ * stamped ONLY in preview, where slices already re-render on every
128
+ * live-content revision, so `React.memo` stability is not in play.
129
+ * Production renders never call this — there `hydrateSlice`'s
130
+ * reference-stable fast path is preserved intact.
131
+ */
132
+ export function stampPreviewScope(slice, modelJson) {
133
+ const stamp = { v: STAMP_VERSION, model: modelJson };
134
+ return { ...slice, [PREVIEW_MODEL_KEY]: stamp };
135
+ }
136
+ /**
137
+ * The serialized per-slice model `<SliceZone>` stamped on this slice,
138
+ * or `undefined` when the slice is not preview-scoped (production, a
139
+ * bare slice, a test stub, or a customer field that happens to share
140
+ * the `_ssPreview` name but doesn't match the stamp shape).
141
+ */
142
+ export function getPreviewModel(slice) {
143
+ if (slice === null || typeof slice !== 'object')
144
+ return undefined;
145
+ const v = slice[PREVIEW_MODEL_KEY];
146
+ // Shape-check, not just presence — the tagged value is what makes
147
+ // the stamp unforgeable from a string-named customer field.
148
+ if (v === null || typeof v !== 'object')
149
+ return undefined;
150
+ const stamp = v;
151
+ if (stamp.v !== STAMP_VERSION)
152
+ return undefined;
153
+ return typeof stamp.model === 'string' ? stamp.model : undefined;
154
+ }
155
+ /**
156
+ * Was this slice object stamped by `<SliceZone>` as preview-scoped?
157
+ * Equivalent to "has a per-slice model" — the two are co-stamped.
158
+ */
159
+ export function isPreviewScoped(slice) {
160
+ return getPreviewModel(slice) !== undefined;
161
+ }
@@ -0,0 +1,32 @@
1
+ import type { ComponentType, ReactNode } from 'react';
2
+ import type { PreviewProvenanceRouteFrameContext } from './preview-provenance.js';
3
+ import type { SliceComponentProps, SliceData } from './slice-zone.js';
4
+ export interface PreviewSliceScopeDescriptor {
5
+ /** Optional overlay/provenance context; projection freshness does not depend on it. */
6
+ readonly routeFrame: PreviewProvenanceRouteFrameContext | null;
7
+ readonly documentId: string;
8
+ readonly sliceField: string;
9
+ readonly slicePath: string;
10
+ readonly sliceKey: string;
11
+ readonly sliceType: string;
12
+ readonly sliceIndex: number;
13
+ }
14
+ export interface PreviewSliceScopeInvocation {
15
+ readonly component: ComponentType<SliceComponentProps>;
16
+ readonly props: SliceComponentProps<SliceData>;
17
+ readonly scope: PreviewSliceScopeDescriptor;
18
+ }
19
+ export interface PreviewSliceScopeAdapter {
20
+ (input: PreviewSliceScopeInvocation): ReactNode | Promise<ReactNode>;
21
+ }
22
+ /**
23
+ * Installs the framework adapter once. Re-registering the same function is
24
+ * idempotent; replacing it would make scope ownership import-order dependent.
25
+ *
26
+ * @internal
27
+ */
28
+ export declare function registerPreviewSliceScopeAdapter(adapter: PreviewSliceScopeAdapter): void;
29
+ /** @internal */
30
+ export declare function readPreviewSliceScopeAdapter(): PreviewSliceScopeAdapter | undefined;
31
+ /** Async server functions are the only direct-invocation candidate. */
32
+ export declare function isPreviewSliceDirectInvocationCandidate(component: ComponentType<SliceComponentProps>): boolean;
@@ -0,0 +1,33 @@
1
+ let installedAdapter;
2
+ /**
3
+ * Installs the framework adapter once. Re-registering the same function is
4
+ * idempotent; replacing it would make scope ownership import-order dependent.
5
+ *
6
+ * @internal
7
+ */
8
+ export function registerPreviewSliceScopeAdapter(adapter) {
9
+ if (installedAdapter === undefined) {
10
+ installedAdapter = adapter;
11
+ return;
12
+ }
13
+ if (installedAdapter !== adapter) {
14
+ throw new Error('StorystickError: a different preview slice-scope adapter is already registered.');
15
+ }
16
+ }
17
+ /** @internal */
18
+ export function readPreviewSliceScopeAdapter() {
19
+ return installedAdapter;
20
+ }
21
+ /** Async server functions are the only direct-invocation candidate. */
22
+ export function isPreviewSliceDirectInvocationCandidate(component) {
23
+ const candidate = component;
24
+ if (candidate.$$typeof !== undefined)
25
+ return false;
26
+ if (typeof component === 'function' &&
27
+ component
28
+ .prototype?.isReactComponent) {
29
+ return false;
30
+ }
31
+ return (typeof component === 'function' &&
32
+ component.constructor?.name === 'AsyncFunction');
33
+ }
@@ -0,0 +1,18 @@
1
+ export type PreviewTextValue = string | number | boolean | null | undefined;
2
+ export interface PreviewTextProps {
3
+ readonly children: PreviewTextValue;
4
+ readonly 'data-ss-p'?: string;
5
+ }
6
+ /**
7
+ * Render a text field on an existing element without putting Preview metadata
8
+ * into its text nodes. The carrier remains plain data; the Preview runtime
9
+ * still validates its frame, read context and receipts before allowing edits.
10
+ *
11
+ * Only one complete field can own the element. Composed or partially encoded
12
+ * strings are cleaned without assigning an owner; render their fields on
13
+ * separate elements when each needs its own edit target.
14
+ */
15
+ export declare function previewTextProps(value: string): PreviewTextProps & {
16
+ readonly children: string;
17
+ };
18
+ export declare function previewTextProps(value: PreviewTextValue): PreviewTextProps;
@@ -0,0 +1,13 @@
1
+ import { decodePreviewProvenanceText } from '@storystick/types/internal/preview-provenance';
2
+ export function previewTextProps(value) {
3
+ if (typeof value !== 'string')
4
+ return { children: value };
5
+ const { text, segments } = decodePreviewProvenanceText(value);
6
+ const segment = segments.length === 1 ? segments[0] : undefined;
7
+ return {
8
+ children: text,
9
+ ...(segment?.markerStart === 0 && segment.markerEnd === value.length
10
+ ? { 'data-ss-p': segment.markerId }
11
+ : {}),
12
+ };
13
+ }
@@ -0,0 +1,42 @@
1
+ /** Geometry of our owned surfaces only. No customer elements participate. */
2
+ export interface Point {
3
+ x: number;
4
+ y: number;
5
+ }
6
+ export interface Size {
7
+ width: number;
8
+ height: number;
9
+ }
10
+ export interface Rect extends Point, Size {
11
+ }
12
+ export type BubbleDock = {
13
+ edge: 'left' | 'right' | 'bottom';
14
+ offset: number;
15
+ };
16
+ export type PanelPlacement = Size & ({
17
+ kind: 'docked';
18
+ edge: 'left' | 'right';
19
+ } | {
20
+ kind: 'stretched';
21
+ x: number;
22
+ } | {
23
+ kind: 'floating';
24
+ x: number;
25
+ y: number;
26
+ });
27
+ export interface ToolbarLayout {
28
+ bubble: BubbleDock;
29
+ panel: PanelPlacement;
30
+ }
31
+ export type ResizeEdge = 'n' | 'e' | 's' | 'w' | 'ne' | 'se' | 'sw' | 'nw';
32
+ export declare const TOOLBAR_GAP = 8;
33
+ export declare const PANEL_MIN_SIZE: Size;
34
+ export declare const DEFAULT_TOOLBAR_LAYOUT: ToolbarLayout;
35
+ export declare function bubbleRect(dock: BubbleDock, viewport: Rect): Rect;
36
+ export declare function snapBubble(point: Point, viewport: Rect): BubbleDock;
37
+ export declare function panelRect(placement: PanelPlacement, viewport: Rect, dock: BubbleDock): Rect;
38
+ /** Moving changes attachment; resizing never does. Preferred size survives docking. */
39
+ export declare function placePanel(point: Point, placement: PanelPlacement, viewport: Rect, dock: BubbleDock, snap: boolean): PanelPlacement;
40
+ export declare function panelResizeEdges(placement: PanelPlacement): readonly ResizeEdge[];
41
+ export declare function resizePanel(placement: PanelPlacement, start: Rect, edge: ResizeEdge, delta: Point, viewport: Rect, dock: BubbleDock): PanelPlacement;
42
+ export declare function parseToolbarLayout(value: string | null): ToolbarLayout;
@@ -0,0 +1,139 @@
1
+ export const TOOLBAR_GAP = 8;
2
+ export const PANEL_MIN_SIZE = { width: 360, height: 480 };
3
+ export const DEFAULT_TOOLBAR_LAYOUT = {
4
+ bubble: { edge: 'bottom', offset: 1 },
5
+ panel: { kind: 'docked', edge: 'right', width: 460, height: 720 },
6
+ };
7
+ const BUBBLE_SIZE = 40;
8
+ const DOCK_DISTANCE = 24;
9
+ const clamp = (n, min, max) => Math.max(min, Math.min(Math.max(min, max), n));
10
+ const fraction = (n, length) => length > 0 ? clamp(n / length, 0, 1) : 0;
11
+ export function bubbleRect(dock, viewport) {
12
+ const width = Math.min(BUBBLE_SIZE, viewport.width);
13
+ const height = Math.min(BUBBLE_SIZE, viewport.height);
14
+ const x = viewport.x + Math.min(TOOLBAR_GAP, (viewport.width - width) / 2);
15
+ const y = viewport.y + Math.min(TOOLBAR_GAP, (viewport.height - height) / 2);
16
+ const travelX = Math.max(0, viewport.width - width - TOOLBAR_GAP * 2);
17
+ const travelY = Math.max(0, viewport.height - height - TOOLBAR_GAP * 2);
18
+ return { x: x + (dock.edge === 'bottom' ? travelX * dock.offset : dock.edge === 'right' ? travelX : 0),
19
+ y: y + (dock.edge === 'bottom' ? travelY : travelY * dock.offset), width, height };
20
+ }
21
+ export function snapBubble(point, viewport) {
22
+ const left = bubbleRect({ edge: 'left', offset: 0 }, viewport);
23
+ const right = bubbleRect({ edge: 'right', offset: 1 }, viewport);
24
+ const x = clamp(point.x, left.x, right.x);
25
+ const y = clamp(point.y, left.y, right.y);
26
+ // Bottom wins a corner tie, preserving the initial panel-above-bubble layout.
27
+ const distance = Math.min(x - left.x, right.x - x, right.y - y);
28
+ if (distance === right.y - y)
29
+ return { edge: 'bottom', offset: fraction(x - left.x, right.x - left.x) };
30
+ return { edge: distance === x - left.x ? 'left' : 'right', offset: fraction(y - left.y, right.y - left.y) };
31
+ }
32
+ function panelArea(viewport, dock) {
33
+ const bubble = bubbleRect(dock, viewport);
34
+ const left = dock.edge === 'left' ? bubble.x + bubble.width + TOOLBAR_GAP : viewport.x + TOOLBAR_GAP;
35
+ const right = dock.edge === 'right' ? bubble.x - TOOLBAR_GAP : viewport.x + viewport.width - TOOLBAR_GAP;
36
+ return { x: left, y: viewport.y + TOOLBAR_GAP, width: Math.max(0, right - left), height: Math.max(0, viewport.height - TOOLBAR_GAP * 2) };
37
+ }
38
+ function panelSpace(x, width, viewport, dock) {
39
+ const area = panelArea(viewport, dock);
40
+ const bubble = bubbleRect(dock, viewport);
41
+ const overlapsBottom = dock.edge === 'bottom' && x < bubble.x + bubble.width + TOOLBAR_GAP && x + width > bubble.x - TOOLBAR_GAP;
42
+ const bottom = overlapsBottom ? bubble.y - TOOLBAR_GAP : area.y + area.height;
43
+ return { ...area, height: Math.max(0, bottom - area.y) };
44
+ }
45
+ export function panelRect(placement, viewport, dock) {
46
+ const area = panelArea(viewport, dock);
47
+ const width = Math.min(placement.width, area.width);
48
+ const x = area.x + (area.width - width) * (placement.kind === 'docked' ? placement.edge === 'right' ? 1 : 0 : placement.x);
49
+ const space = panelSpace(x, width, viewport, dock);
50
+ const height = placement.kind === 'floating' ? Math.min(placement.height, space.height) : space.height;
51
+ const y = space.y + (placement.kind === 'floating' ? (space.height - height) * placement.y : 0);
52
+ return { x, y, width, height };
53
+ }
54
+ /** Moving changes attachment; resizing never does. Preferred size survives docking. */
55
+ export function placePanel(point, placement, viewport, dock, snap) {
56
+ const area = panelArea(viewport, dock);
57
+ const width = Math.min(placement.width, area.width);
58
+ const travelX = area.width - width;
59
+ const x = clamp(point.x, area.x, area.x + travelX);
60
+ const space = panelSpace(x, width, viewport, dock);
61
+ const height = placement.kind === 'floating' ? placement.height
62
+ : Math.min(placement.height, Math.max(PANEL_MIN_SIZE.height, space.height * .8));
63
+ const size = { width: placement.width, height };
64
+ if (snap && x - area.x <= DOCK_DISTANCE)
65
+ return { ...size, kind: 'docked', edge: 'left' };
66
+ if (snap && area.x + travelX - x <= DOCK_DISTANCE)
67
+ return { ...size, kind: 'docked', edge: 'right' };
68
+ const travelY = Math.max(0, space.height - height);
69
+ const y = clamp(point.y, space.y, space.y + travelY);
70
+ const normalizedX = fraction(x - area.x, travelX);
71
+ if (snap && (y - space.y <= DOCK_DISTANCE || space.y + travelY - y <= DOCK_DISTANCE)) {
72
+ return { ...size, kind: 'stretched', x: normalizedX };
73
+ }
74
+ return { ...size, kind: 'floating', x: normalizedX, y: fraction(y - space.y, travelY) };
75
+ }
76
+ const FLOATING_EDGES = ['n', 'e', 's', 'w', 'ne', 'se', 'sw', 'nw'];
77
+ export function panelResizeEdges(placement) {
78
+ return placement.kind === 'docked' ? [placement.edge === 'left' ? 'e' : 'w']
79
+ : placement.kind === 'stretched' ? ['e', 'w'] : FLOATING_EDGES;
80
+ }
81
+ export function resizePanel(placement, start, edge, delta, viewport, dock) {
82
+ if (!panelResizeEdges(placement).includes(edge))
83
+ return placement;
84
+ const area = panelArea(viewport, dock);
85
+ const bubble = bubbleRect(dock, viewport);
86
+ let minX = area.x;
87
+ let maxX = area.x + area.width;
88
+ // A low floating window cannot grow sideways through the bottom bubble.
89
+ // Keep the opposite edge fixed; full-height panels instead adapt their height.
90
+ if (placement.kind === 'floating' && dock.edge === 'bottom' && start.y + start.height > bubble.y - TOOLBAR_GAP) {
91
+ if (start.x + start.width <= bubble.x - TOOLBAR_GAP)
92
+ maxX = bubble.x - TOOLBAR_GAP;
93
+ else if (start.x >= bubble.x + bubble.width + TOOLBAR_GAP)
94
+ minX = bubble.x + bubble.width + TOOLBAR_GAP;
95
+ }
96
+ let left = start.x;
97
+ let right = start.x + start.width;
98
+ const minWidth = Math.min(PANEL_MIN_SIZE.width, start.width);
99
+ if (edge.includes('w'))
100
+ left = clamp(left + delta.x, minX, right - minWidth);
101
+ if (edge.includes('e'))
102
+ right = clamp(right + delta.x, left + minWidth, maxX);
103
+ const width = right - left;
104
+ const x = fraction(left - area.x, area.width - width);
105
+ const preferredWidth = Math.max(PANEL_MIN_SIZE.width, width);
106
+ if (placement.kind === 'docked')
107
+ return { ...placement, width: preferredWidth };
108
+ if (placement.kind === 'stretched')
109
+ return { ...placement, x, width: preferredWidth };
110
+ const space = panelSpace(left, width, viewport, dock);
111
+ let top = start.y;
112
+ let bottom = start.y + start.height;
113
+ const minHeight = Math.min(PANEL_MIN_SIZE.height, start.height);
114
+ if (edge.includes('n'))
115
+ top = clamp(top + delta.y, space.y, bottom - minHeight);
116
+ if (edge.includes('s'))
117
+ bottom = clamp(bottom + delta.y, top + minHeight, space.y + space.height);
118
+ const height = bottom - top;
119
+ return { kind: 'floating', x, y: fraction(top - space.y, space.height - height),
120
+ width: preferredWidth, height: Math.max(PANEL_MIN_SIZE.height, height) };
121
+ }
122
+ export function parseToolbarLayout(value) {
123
+ try {
124
+ const data = JSON.parse(value ?? 'null');
125
+ const unit = (n) => typeof n === 'number' && Number.isFinite(n) && n >= 0 && n <= 1;
126
+ const dimension = (n, min) => typeof n === 'number' && Number.isFinite(n) && n >= min;
127
+ if (!data || !['left', 'right', 'bottom'].includes(data.bubble?.edge) || !unit(data.bubble.offset))
128
+ return DEFAULT_TOOLBAR_LAYOUT;
129
+ const panel = data.panel;
130
+ if (!dimension(panel?.width, PANEL_MIN_SIZE.width) || !dimension(panel?.height, PANEL_MIN_SIZE.height))
131
+ return DEFAULT_TOOLBAR_LAYOUT;
132
+ if (panel.kind === 'docked' && ['left', 'right'].includes(panel.edge) ||
133
+ panel.kind === 'stretched' && unit(panel.x) ||
134
+ panel.kind === 'floating' && unit(panel.x) && unit(panel.y))
135
+ return { bubble: data.bubble, panel };
136
+ }
137
+ catch { /* An unavailable or stale preference does not prevent opening the UI. */ }
138
+ return DEFAULT_TOOLBAR_LAYOUT;
139
+ }
@@ -0,0 +1,13 @@
1
+ import type { ComponentProps, ReactNode, RefObject } from 'react';
2
+ /** The same movable frame hosts the retained editor and assistant. */
3
+ export declare function PreviewToolbarPanel({ open, label, onClose, panelRef, handleProps, resizeHandles, leading, trailing, children, }: {
4
+ open: boolean;
5
+ label: string;
6
+ onClose(): void;
7
+ panelRef: RefObject<HTMLElement | null>;
8
+ handleProps: ComponentProps<'button'>;
9
+ resizeHandles: ReactNode;
10
+ leading?: ReactNode;
11
+ trailing?: ReactNode;
12
+ children: ReactNode;
13
+ }): import("react").JSX.Element;
@@ -0,0 +1,6 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { X } from './preview-icons.js';
3
+ /** The same movable frame hosts the retained editor and assistant. */
4
+ export function PreviewToolbarPanel({ open, label, onClose, panelRef, handleProps, resizeHandles, leading, trailing, children, }) {
5
+ return (_jsxs("section", { ref: panelRef, className: "editor-panel", "aria-label": label, hidden: !open, inert: !open, children: [_jsxs("div", { className: "panel-contents", children: [_jsxs("header", { className: "panel-header", children: [leading, _jsx("button", { type: "button", className: "panel-drag-handle", "aria-label": `Move ${label} panel`, title: "Drag to move. Arrow keys move; Alt + Up/Down fills the height; Home and End dock to a side.", ...handleProps, children: label }), trailing, _jsx("button", { type: "button", className: "panel-close", "aria-label": `Close ${label}`, onClick: onClose, children: _jsx(X, { size: 16, strokeWidth: 1.25, absoluteStrokeWidth: true, "aria-hidden": "true" }) })] }), _jsx("div", { className: "panel-body", children: children })] }), resizeHandles] }));
6
+ }
@@ -0,0 +1,9 @@
1
+ import type { ComponentProps } from 'react';
2
+ import type { ResizeEdge, Size } from './preview-toolbar-layout.js';
3
+ /** Resize affordances belong to the window frame, never to its hosted content. */
4
+ export declare function PreviewToolbarResizeHandles({ label, edges, size, handleProps }: {
5
+ label: string;
6
+ edges: readonly ResizeEdge[];
7
+ size: Size;
8
+ handleProps(edge: ResizeEdge): ComponentProps<'button'>;
9
+ }): import("react").JSX.Element[];
@@ -0,0 +1,14 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { ResizeEdgeIndicator } from './resize-edge.js';
3
+ const EDGE_LABELS = {
4
+ n: 'top', e: 'right', s: 'bottom', w: 'left',
5
+ ne: 'top right', se: 'bottom right', sw: 'bottom left', nw: 'top left',
6
+ };
7
+ /** Resize affordances belong to the window frame, never to its hosted content. */
8
+ export function PreviewToolbarResizeHandles({ label, edges, size, handleProps }) {
9
+ return edges.map(edge => _jsx("button", { type: "button", className: "panel-resize-handle", "data-resize-edge": edge, "data-ss-resize-handle": "", "aria-label": `Resize ${label} from ${EDGE_LABELS[edge]}`, "aria-description": `${Math.round(size.width)} by ${Math.round(size.height)} pixels. Use arrow keys to resize; hold Shift for larger steps.`, ...handleProps(edge), children: edge === 'w' || edge === 'e'
10
+ ? _jsx(ResizeEdgeIndicator, { style: { left: edge === 'w' ? 3 : 4 } })
11
+ : edge === 'n' || edge === 's'
12
+ ? _jsx(ResizeEdgeIndicator, { orientation: "horizontal", style: { top: edge === 'n' ? 3 : 4 } })
13
+ : null }, edge));
14
+ }
@@ -0,0 +1,18 @@
1
+ import { type ReactNode, type RefObject } from "react";
2
+ /** The host owns admission and active content; this shell owns presentation. */
3
+ export declare function PreviewToolbarShell(props: {
4
+ menuOpen: boolean;
5
+ panelOpen: boolean;
6
+ menu: ReactNode;
7
+ panel: ReactNode;
8
+ panelLabel: string;
9
+ panelLeading?: ReactNode;
10
+ panelTrailing?: ReactNode;
11
+ onMenuOpenChange(open: boolean): void;
12
+ onPanelClose(): void;
13
+ onRoot?: (root: ShadowRoot) => void;
14
+ hostRef?: RefObject<HTMLDivElement | null>;
15
+ }): import("react").JSX.Element;
16
+ export declare function PreviewEditIcon(): import("react").JSX.Element;
17
+ export declare function PreviewAssistantIcon(): import("react").JSX.Element;
18
+ export declare function PreviewStudioIcon(): import("react").JSX.Element;